diff --git a/Cargo.lock b/Cargo.lock index b7519e3e7..e2f1d1370 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,23 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cb94155d965e3d37ffbbe7cc5b82c3dd79dd33bd48e536f73d2cfb8d85506f" +[[package]] +name = "async-compression" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00461f243d703f6999c8e7494f077799f1362720a55ae49a90ffe6214032fc0b" +dependencies = [ + "bzip2", + "flate2", + "futures-core", + "futures-io", + "memchr", + "pin-project-lite", + "xz2", + "zstd", + "zstd-safe", +] + [[package]] name = "async-tungstenite" version = "0.20.0" @@ -100,6 +117,23 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "async_zip" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79eaa2b44cfdce59cfff6cb013c96900635085fe7c28fbcbe926c9e5ad0ddfbc" +dependencies = [ + "async-compression", + "chrono", + "crc32fast", + "futures-util", + "log", + "pin-project", + "thiserror", + "tokio", + "tokio-util", +] + [[package]] name = "atk" version = "0.15.1" @@ -668,9 +702,9 @@ dependencies = [ [[package]] name = "daedalus" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f15581178acf1d6160505d7ca36cde6a29d121fb16af80c7343e6b9052b0548e" +checksum = "83c9c34a2d4904bcaa4cfa5f62b38c915c106fdc92a6a66276ae2bd5ba1b2527" dependencies = [ "bincode", "bytes", @@ -1818,6 +1852,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -3510,6 +3555,7 @@ version = "0.1.0" dependencies = [ "argh", "async-tungstenite", + "async_zip", "bincode", "bytes", "chrono", @@ -3754,6 +3800,7 @@ checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -4565,6 +4612,15 @@ dependencies = [ "libc", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yansi" version = "0.5.1" diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index e92b97b56..4503a17e7 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -18,9 +18,10 @@ sled = { version = "0.34.7", features = ["compression"] } url = "2.2" uuid = { version = "1.1", features = ["serde", "v4"] } zip = "0.5" +async_zip = { version = "0.0.13", features = ["full"] } chrono = { version = "0.4.19", features = ["serde"] } -daedalus = { version = "0.1.16", features = ["bincode"] } +daedalus = { version = "0.1.18", features = ["bincode"] } dirs = "4.0" # TODO: possibly replace with tracing to have structured logging log = "0.4.14" diff --git a/theseus/src/api/mod.rs b/theseus/src/api/mod.rs index eeb7a24d9..0e1757bd6 100644 --- a/theseus/src/api/mod.rs +++ b/theseus/src/api/mod.rs @@ -1,5 +1,6 @@ //! API for interacting with Theseus pub mod auth; +pub mod pack; pub mod process; pub mod profile; pub mod profile_create; @@ -17,7 +18,7 @@ pub mod prelude { pub use crate::{ auth::{self, Credentials}, data::*, - process, + pack, process, profile::{self, Profile}, profile_create, settings, State, }; diff --git a/theseus/src/api/pack.rs b/theseus/src/api/pack.rs new file mode 100644 index 000000000..9f59977a0 --- /dev/null +++ b/theseus/src/api/pack.rs @@ -0,0 +1,338 @@ +use crate::config::{MODRINTH_API_URL, REQWEST_CLIENT}; +use crate::data::ModLoader; +use crate::state::{ModrinthProject, ModrinthVersion, SideType}; +use crate::util::fetch::{fetch, fetch_mirrors, write, write_cached_icon}; +use crate::State; +use async_zip::tokio::read::seek::ZipFileReader; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::{Component, PathBuf}; +use tokio::fs; + +#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +struct PackFormat { + pub game: String, + pub format_version: i32, + pub version_id: String, + pub name: String, + pub summary: Option, + pub files: Vec, + pub dependencies: HashMap, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +struct PackFile { + pub path: String, + pub hashes: HashMap, + pub env: Option>, + pub downloads: Vec, + pub file_size: u32, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "camelCase", from = "String")] +enum PackFileHash { + Sha1, + Sha512, + Unknown(String), +} + +impl From for PackFileHash { + fn from(s: String) -> Self { + return match s.as_str() { + "sha1" => PackFileHash::Sha1, + "sha512" => PackFileHash::Sha512, + _ => PackFileHash::Unknown(s), + }; + } +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "camelCase")] +enum EnvType { + Client, + Server, +} + +#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum PackDependency { + Forge, + FabricLoader, + QuiltLoader, + Minecraft, +} + +pub async fn install_pack_from_version_id( + version_id: String, +) -> crate::Result { + let version: ModrinthVersion = REQWEST_CLIENT + .get(format!("{}version/{}", MODRINTH_API_URL, version_id)) + .send() + .await? + .json() + .await?; + + let (url, hash) = + if let Some(file) = version.files.iter().find(|x| x.primary) { + Some((file.url.clone(), file.hashes.get("sha1"))) + } else { + version + .files + .first() + .map(|file| (file.url.clone(), file.hashes.get("sha1"))) + } + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Specified version has no files".to_string(), + ) + })?; + + let file = async { + let state = &State::get().await?; + let semaphore = state.io_semaphore.acquire().await?; + fetch(&url, hash.map(|x| &**x), &semaphore).await + } + .await?; + + let project: ModrinthProject = REQWEST_CLIENT + .get(format!( + "{}project/{}", + MODRINTH_API_URL, version.project_id + )) + .send() + .await? + .json() + .await?; + + let icon = if let Some(icon_url) = project.icon_url { + let state = State::get().await?; + let semaphore = state.io_semaphore.acquire().await?; + + let icon_bytes = fetch(&icon_url, None, &semaphore).await?; + + let filename = icon_url.rsplit('/').next(); + + if let Some(filename) = filename { + Some( + write_cached_icon( + filename, + &state.directories.caches_dir(), + icon_bytes, + &semaphore, + ) + .await?, + ) + } else { + None + } + } else { + None + }; + + install_pack(file, icon, Some(version.project_id)).await +} + +pub async fn install_pack_from_file(path: PathBuf) -> crate::Result { + let file = fs::read(path).await?; + + install_pack(bytes::Bytes::from(file), None, None).await +} + +async fn install_pack( + file: bytes::Bytes, + icon: Option, + project_id: Option, +) -> crate::Result { + let state = &State::get().await?; + + let reader = Cursor::new(&file); + + // Create zip reader around file + let mut zip_reader = ZipFileReader::new(reader).await.map_err(|_| { + crate::Error::from(crate::ErrorKind::InputError( + "Failed to read input modpack zip".to_string(), + )) + })?; + + // Extract index of modrinth.index.json + let zip_index_option = zip_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == "modrinth.index.json"); + if let Some(zip_index) = zip_index_option { + let mut manifest = String::new(); + let entry = zip_reader + .file() + .entries() + .get(zip_index) + .unwrap() + .entry() + .clone(); + let mut reader = zip_reader.entry(zip_index).await?; + reader.read_to_string_checked(&mut manifest, &entry).await?; + + let pack: PackFormat = serde_json::from_str(&manifest)?; + + if &*pack.game != "minecraft" { + return Err(crate::ErrorKind::InputError( + "Pack does not support Minecraft".to_string(), + ) + .into()); + } + + let mut game_version = None; + let mut mod_loader = None; + let mut loader_version = None; + for (key, value) in pack.dependencies { + match key { + PackDependency::Forge => { + mod_loader = Some(ModLoader::Forge); + loader_version = Some(value); + } + PackDependency::FabricLoader => { + mod_loader = Some(ModLoader::Fabric); + loader_version = Some(value); + } + PackDependency::QuiltLoader => { + mod_loader = Some(ModLoader::Quilt); + loader_version = Some(value); + } + PackDependency::Minecraft => game_version = Some(value), + } + } + + let game_version = if let Some(game_version) = game_version { + game_version + } else { + return Err(crate::ErrorKind::InputError( + "Pack did not specify Minecraft version".to_string(), + ) + .into()); + }; + + let profile = crate::api::profile_create::profile_create( + pack.name, + game_version.clone(), + mod_loader.unwrap_or(ModLoader::Vanilla), + loader_version, + icon, + project_id, + ) + .await?; + + use futures::StreamExt; + futures::stream::iter(pack.files.into_iter()) + .map(Ok::) + .try_for_each_concurrent(None, |project| { + let profile = profile.clone(); + + async move { + // TODO: Future update: prompt user for optional files in a modpack + if let Some(env) = project.env { + if env + .get(&EnvType::Client) + .map(|x| x == &SideType::Unsupported) + .unwrap_or(false) + { + return Ok(()); + } + } + + let permit = state.io_semaphore.acquire().await?; + + let file = fetch_mirrors( + &project + .downloads + .iter() + .map(|x| &**x) + .collect::>(), + project.hashes.get(&PackFileHash::Sha1).map(|x| &**x), + &permit, + ) + .await?; + + let path = + std::path::Path::new(&project.path).components().next(); + if let Some(path) = path { + match path { + Component::CurDir | Component::Normal(_) => { + let path = profile.join(project.path); + write(&path, &file, &permit).await?; + } + _ => {} + }; + } + + Ok(()) + } + }) + .await?; + + let extract_overrides = |overrides: String| async { + let reader = Cursor::new(&file); + + let mut overrides_zip = + ZipFileReader::new(reader).await.map_err(|_| { + crate::Error::from(crate::ErrorKind::InputError( + "Failed extract overrides Zip".to_string(), + )) + })?; + + let profile = profile.clone(); + async move { + for index in 0..overrides_zip.file().entries().len() { + let file = overrides_zip + .file() + .entries() + .get(index) + .unwrap() + .entry() + .clone(); + + let file_path = PathBuf::from(file.filename()); + if file.filename().starts_with(&overrides) + && !file.filename().ends_with('/') + { + // Reads the file into the 'content' variable + let mut content = Vec::new(); + let mut reader = overrides_zip.entry(index).await?; + reader.read_to_end_checked(&mut content, &file).await?; + + let mut new_path = PathBuf::new(); + let components = file_path.components().skip(1); + + for component in components { + new_path.push(component); + } + + if new_path.file_name().is_some() { + let permit = state.io_semaphore.acquire().await?; + write(&profile.join(new_path), &content, &permit) + .await?; + } + } + } + + Ok::<(), crate::Error>(()) + } + .await + }; + + extract_overrides("overrides".to_string()).await?; + extract_overrides("client_overrides".to_string()).await?; + + super::profile::sync(&profile).await?; + + Ok(profile) + } else { + Err(crate::Error::from(crate::ErrorKind::InputError( + "No pack manifest found in mrpack".to_string(), + ))) + } +} diff --git a/theseus/src/api/profile.rs b/theseus/src/api/profile.rs index a16cbd7ac..7e8c1070e 100644 --- a/theseus/src/api/profile.rs +++ b/theseus/src/api/profile.rs @@ -12,32 +12,12 @@ use std::{ }; use tokio::{process::Command, sync::RwLock}; -/// Add a profile to the in-memory state -#[tracing::instrument] -pub async fn add(profile: Profile) -> crate::Result<()> { - let state = State::get().await?; - let mut profiles = state.profiles.write().await; - profiles.insert(profile)?; - - Ok(()) -} - -/// Add a path as a profile in-memory -#[tracing::instrument] -pub async fn add_path(path: &Path) -> crate::Result<()> { - let state = State::get().await?; - let mut profiles = state.profiles.write().await; - profiles.insert_from(path).await?; - - Ok(()) -} - /// Remove a profile #[tracing::instrument] pub async fn remove(path: &Path) -> crate::Result<()> { let state = State::get().await?; let mut profiles = state.profiles.write().await; - profiles.remove(path)?; + profiles.remove(path).await?; Ok(()) } @@ -48,29 +28,7 @@ pub async fn get(path: &Path) -> crate::Result> { let state = State::get().await?; let profiles = state.profiles.read().await; - profiles.0.get(path).map_or(Ok(None), |prof| match prof { - Some(prof) => Ok(Some(prof.clone())), - None => Err(crate::ErrorKind::UnloadedProfileError( - path.display().to_string(), - ) - .as_error()), - }) -} - -/// Check if a profile is already managed by Theseus -#[tracing::instrument] -pub async fn is_managed(profile: &Path) -> crate::Result { - let state = State::get().await?; - let profiles = state.profiles.read().await; - Ok(profiles.0.contains_key(profile)) -} - -/// Check if a profile is loaded -#[tracing::instrument] -pub async fn is_loaded(profile: &Path) -> crate::Result { - let state = State::get().await?; - let profiles = state.profiles.read().await; - Ok(profiles.0.get(profile).and_then(Option::as_ref).is_some()) + Ok(profiles.0.get(path).cloned()) } /// Edit a profile using a given asynchronous closure @@ -85,11 +43,7 @@ where let mut profiles = state.profiles.write().await; match profiles.0.get_mut(path) { - Some(&mut Some(ref mut profile)) => action(profile).await, - Some(&mut None) => Err(crate::ErrorKind::UnloadedProfileError( - path.display().to_string(), - ) - .as_error()), + Some(ref mut profile) => action(profile).await, None => Err(crate::ErrorKind::UnmanagedProfileError( path.display().to_string(), ) @@ -99,13 +53,45 @@ where /// Get a copy of the profile set #[tracing::instrument] -pub async fn list( -) -> crate::Result>> { +pub async fn list() -> crate::Result> +{ let state = State::get().await?; let profiles = state.profiles.read().await; Ok(profiles.0.clone()) } +/// Query + sync profile's projects with the UI from the FS +#[tracing::instrument] +pub async fn sync(path: &Path) -> crate::Result<()> { + let state = State::get().await?; + + if let Some(profile) = get(path).await? { + let paths = profile.get_profile_project_paths()?; + let projects = crate::state::infer_data_from_files( + paths, + state.directories.caches_dir(), + &state.io_semaphore, + ) + .await?; + + { + let mut profiles = state.profiles.write().await; + if let Some(profile) = profiles.0.get_mut(path) { + profile.projects = projects; + } + } + + State::sync().await?; + + Ok(()) + } else { + Err( + crate::ErrorKind::UnmanagedProfileError(path.display().to_string()) + .as_error(), + ) + } +} + /// Run Minecraft using a profile /// Returns Arc pointer to RwLock to Child #[tracing::instrument(skip_all)] @@ -113,7 +99,7 @@ pub async fn run( path: &Path, credentials: &crate::auth::Credentials, ) -> crate::Result>> { - let state = State::get().await.unwrap(); + let state = State::get().await?; let settings = state.settings.read().await; let profile = get(path).await?.ok_or_else(|| { crate::ErrorKind::OtherError(format!( @@ -141,19 +127,21 @@ pub async fn run( for hook in pre_launch_hooks.iter() { // TODO: hook parameters let mut cmd = hook.split(' '); - let result = Command::new(cmd.next().unwrap()) - .args(&cmd.collect::>()) - .current_dir(path) - .spawn()? - .wait() - .await?; + if let Some(command) = cmd.next() { + let result = Command::new(command) + .args(&cmd.collect::>()) + .current_dir(path) + .spawn()? + .wait() + .await?; - if !result.success() { - return Err(crate::ErrorKind::LauncherError(format!( - "Non-zero exit code for pre-launch hook: {}", - result.code().unwrap_or(-1) - )) - .as_error()); + if !result.success() { + return Err(crate::ErrorKind::LauncherError(format!( + "Non-zero exit code for pre-launch hook: {}", + result.code().unwrap_or(-1) + )) + .as_error()); + } } } diff --git a/theseus/src/api/profile_create.rs b/theseus/src/api/profile_create.rs index 2046f2b89..f8cd9ef1b 100644 --- a/theseus/src/api/profile_create.rs +++ b/theseus/src/api/profile_create.rs @@ -1,5 +1,5 @@ //! Theseus profile management interface -use crate::{prelude::ModLoader, profile}; +use crate::prelude::ModLoader; pub use crate::{ state::{JavaSettings, Profile}, State, @@ -22,8 +22,9 @@ pub async fn profile_create_empty() -> crate::Result { String::from(DEFAULT_NAME), // the name/path of the profile String::from("1.19.2"), // the game version of the profile ModLoader::Vanilla, // the modloader to use - String::from("stable"), // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader - None, // the icon for the profile + None, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader + None, // the icon for the profile + None, ) .await } @@ -32,17 +33,17 @@ pub async fn profile_create_empty() -> crate::Result { // Returns filepath at which it can be accessed in the State #[tracing::instrument] pub async fn profile_create( - name: String, // the name of the profile, and relative path - game_version: String, // the game version of the profile - modloader: ModLoader, // the modloader to use - loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader - icon: Option, // the icon for the profile + name: String, // the name of the profile, and relative path + game_version: String, // the game version of the profile + modloader: ModLoader, // the modloader to use + loader_version: Option, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader. defaults to latest + icon: Option, // the icon for the profile + linked_project_id: Option, // the linked project ID (mainly for modpacks)- used for updating ) -> crate::Result { let state = State::get().await?; let uuid = Uuid::new_v4(); let path = state.directories.profiles_dir().join(uuid.to_string()); - if path.exists() { if !path.is_dir() { return Err(ProfileCreationError::NotFolder.into()); @@ -64,6 +65,7 @@ pub async fn profile_create( } else { fs::create_dir_all(&path).await?; } + println!( "Creating profile at path {}", &canonicalize(&path)?.display() @@ -71,12 +73,12 @@ pub async fn profile_create( let loader = modloader; let loader = if loader != ModLoader::Vanilla { - let version = loader_version; + let version = loader_version.unwrap_or_else(|| "latest".to_string()); let filter = |it: &LoaderVersion| match version.as_str() { "latest" => true, "stable" => it.stable, - id => it.id == *id, + id => it.id == *id || format!("{}-{}", game_version, id) == it.id, }; let loader_data = match loader { @@ -93,7 +95,12 @@ pub async fn profile_create( let loaders = &loader_data .game_versions .iter() - .find(|it| it.id == game_version) + .find(|it| { + it.id.replace( + daedalus::modded::DUMMY_REPLACE_STRING, + &game_version, + ) == game_version + }) .ok_or_else(|| { ProfileCreationError::ModloaderUnsupported( loader.to_string(), @@ -130,13 +137,19 @@ pub async fn profile_create( let path = canonicalize(&path)?; let mut profile = Profile::new(name, game_version, path.clone()).await?; if let Some(ref icon) = icon { - profile.with_icon(icon).await?; + profile.set_icon(icon).await?; } if let Some((loader_version, loader)) = loader { - profile.with_loader(loader, Some(loader_version)); + profile.metadata.loader = loader; + profile.metadata.loader_version = Some(loader_version); + } + + profile.metadata.linked_project_id = linked_project_id; + { + let mut profiles = state.profiles.write().await; + profiles.insert(profile)?; } - profile::add(profile).await?; State::sync().await?; Ok(path) diff --git a/theseus/src/config.rs b/theseus/src/config.rs index 4268e3dd6..ca4785210 100644 --- a/theseus/src/config.rs +++ b/theseus/src/config.rs @@ -11,10 +11,19 @@ pub static BINCODE_CONFIG: Lazy = }); pub static REQWEST_CLIENT: Lazy = Lazy::new(|| { + let mut headers = reqwest::header::HeaderMap::new(); + let header = reqwest::header::HeaderValue::from_str(&format!( + "modrinth/daedalus/{} (support@modrinth.com)", + env!("CARGO_PKG_VERSION") + )) + .unwrap(); + headers.insert(reqwest::header::USER_AGENT, header); reqwest::Client::builder() + .timeout(time::Duration::from_secs(15)) .tcp_keepalive(Some(time::Duration::from_secs(10))) + .default_headers(headers) .build() - .unwrap() + .expect("Reqwest Client Building Failed") }); pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/"; diff --git a/theseus/src/error.rs b/theseus/src/error.rs index 12c8f5e21..84526cf80 100644 --- a/theseus/src/error.rs +++ b/theseus/src/error.rs @@ -67,10 +67,8 @@ pub enum ErrorKind { #[error("Recv error: {0}")] RecvError(#[from] tokio::sync::oneshot::error::RecvError), - #[error( - "Tried to access unloaded profile {0}, loading it probably failed" - )] - UnloadedProfileError(String), + #[error("Error acquiring semaphore: {0}")] + AcquireError(#[from] tokio::sync::AcquireError), #[error("Profile {0} is not managed by Theseus!")] UnmanagedProfileError(String), @@ -78,6 +76,9 @@ pub enum ErrorKind { #[error("Could not create profile: {0}")] ProfileCreationError(#[from] profile_create::ProfileCreationError), + #[error("Zip error: {0}")] + ZipError(#[from] async_zip::error::ZipError), + #[error("Error: {0}")] OtherError(String), } diff --git a/theseus/src/launcher/args.rs b/theseus/src/launcher/args.rs index fdfab7105..70ddcb6da 100644 --- a/theseus/src/launcher/args.rs +++ b/theseus/src/launcher/args.rs @@ -36,7 +36,7 @@ pub fn get_class_paths( return None; } - Some(get_lib_path(libraries_path, &library.name)) + Some(get_lib_path(libraries_path, &library.name, false)) }) .collect::, _>>()?; @@ -62,17 +62,25 @@ pub fn get_class_paths_jar>( ) -> crate::Result { let cps = libraries .iter() - .map(|library| get_lib_path(libraries_path, library.as_ref())) + .map(|library| get_lib_path(libraries_path, library.as_ref(), false)) .collect::, _>>()?; Ok(cps.join(classpath_separator())) } -pub fn get_lib_path(libraries_path: &Path, lib: &str) -> crate::Result { +pub fn get_lib_path( + libraries_path: &Path, + lib: &str, + allow_not_exist: bool, +) -> crate::Result { let mut path = libraries_path.to_path_buf(); path.push(get_path_from_artifact(lib)?); + if !path.exists() && allow_not_exist { + return Ok(path.to_string_lossy().to_string()); + } + let path = &canonicalize(&path).map_err(|_| { crate::ErrorKind::LauncherError(format!( "Library file at path {} does not exist", @@ -343,13 +351,14 @@ pub fn get_processor_arguments>( get_lib_path( libraries_path, &entry.client[1..entry.client.len() - 1], + true, )? } else { entry.client.clone() }) } } else if argument.as_ref().starts_with('[') { - new_arguments.push(get_lib_path(libraries_path, trimmed_arg)?) + new_arguments.push(get_lib_path(libraries_path, trimmed_arg, true)?) } else { new_arguments.push(argument.as_ref().to_string()) } @@ -361,7 +370,7 @@ pub fn get_processor_arguments>( pub async fn get_processor_main_class( path: String, ) -> crate::Result> { - tokio::task::spawn_blocking(move || { + let main_class = tokio::task::spawn_blocking(move || { let zipfile = std::fs::File::open(&path)?; let mut archive = zip::ZipArchive::new(zipfile).map_err(|_| { crate::ErrorKind::LauncherError(format!( @@ -394,6 +403,7 @@ pub async fn get_processor_main_class( Ok::, crate::Error>(None) }) - .await - .unwrap() + .await??; + + Ok(main_class) } diff --git a/theseus/src/launcher/auth.rs b/theseus/src/launcher/auth.rs index aba2a4539..a1d5ac3e8 100644 --- a/theseus/src/launcher/auth.rs +++ b/theseus/src/launcher/auth.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use url::Url; lazy_static! { - static ref HYDRA_URL: Url = - Url::parse("https://hydra.modrinth.com").unwrap(); + static ref HYDRA_URL: Url = Url::parse("https://hydra.modrinth.com") + .expect("Hydra URL parse failed"); } // Socket messages diff --git a/theseus/src/launcher/download.rs b/theseus/src/launcher/download.rs index f1092dc55..f88dd3337 100644 --- a/theseus/src/launcher/download.rs +++ b/theseus/src/launcher/download.rs @@ -39,11 +39,12 @@ pub async fn download_version_info( version: &GameVersion, loader: Option<&LoaderVersion>, ) -> crate::Result { - let version_id = loader.map_or(&version.id, |it| &it.id); + let version_id = loader + .map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); log::debug!("Loading version info for Minecraft {version_id}"); let path = st .directories - .version_dir(version_id) + .version_dir(&version_id) .join(format!("{version_id}.json")); let res = if path.exists() { @@ -58,10 +59,10 @@ pub async fn download_version_info( if let Some(loader) = loader { let partial = d::modded::fetch_partial_version(&loader.url).await?; info = d::modded::merge_partial_version(partial, info); - info.id = loader.id.clone(); } + info.id = version_id.clone(); - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; write(&path, &serde_json::to_vec(&info)?, &permit).await?; Ok(info) }?; @@ -92,7 +93,7 @@ pub async fn download_client( .join(format!("{version}.jar")); if !path.exists() { - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; let bytes = fetch(&client_download.url, Some(&client_download.sha1), &permit) .await?; @@ -122,7 +123,7 @@ pub async fn download_assets_index( .and_then(|ref it| Ok(serde_json::from_slice(it)?)) } else { let index = d::minecraft::fetch_assets_index(version).await?; - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; write(&path, &serde_json::to_vec(&index)?, &permit).await?; log::info!("Fetched assets index"); Ok(index) @@ -141,7 +142,7 @@ pub async fn download_assets( log::debug!("Loading assets"); stream::iter(index.objects.iter()) .map(Ok::<(&String, &Asset), crate::Error>) - .try_for_each_concurrent(Some(st.settings.read().await.max_concurrent_downloads), |(name, asset)| async move { + .try_for_each_concurrent(None, |(name, asset)| async move { let hash = &asset.hash; let resource_path = st.directories.object_dir(hash); let url = format!( @@ -153,7 +154,7 @@ pub async fn download_assets( tokio::try_join! { async { if !resource_path.exists() { - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; let resource = fetch_cell .get_or_try_init(|| fetch(&url, Some(hash), &permit)) .await?; @@ -164,7 +165,7 @@ pub async fn download_assets( }, async { if with_legacy { - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; let resource = fetch_cell .get_or_try_init(|| fetch(&url, Some(hash), &permit)) .await?; @@ -201,7 +202,7 @@ pub async fn download_libraries( stream::iter(libraries.iter()) .map(Ok::<&Library, crate::Error>) - .try_for_each_concurrent(Some(st.settings.read().await.max_concurrent_downloads), |library| async move { + .try_for_each_concurrent(None, |library| async move { if let Some(rules) = &library.rules { if !rules.iter().all(super::parse_rule) { return Ok(()); @@ -218,7 +219,7 @@ pub async fn download_libraries( artifact: Some(ref artifact), .. }) => { - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; let bytes = fetch(&artifact.url, Some(&artifact.sha1), &permit) .await?; write(&path, &bytes, &permit).await?; @@ -234,7 +235,7 @@ pub async fn download_libraries( &artifact_path ].concat(); - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; let bytes = fetch(&url, None, &permit).await?; write(&path, &bytes, &permit).await?; log::info!("Fetched library {}", &library.name); @@ -262,12 +263,17 @@ pub async fn download_libraries( ); if let Some(native) = classifiers.get(&parsed_key) { - let permit = st.io_semaphore.acquire().await.unwrap(); + let permit = st.io_semaphore.acquire().await?; let data = fetch(&native.url, Some(&native.sha1), &permit).await?; let reader = std::io::Cursor::new(&data); - let mut archive = zip::ZipArchive::new(reader).unwrap(); - archive.extract(&st.directories.version_natives_dir(version)).unwrap(); - log::info!("Fetched native {}", &library.name); + if let Ok(mut archive) = zip::ZipArchive::new(reader) { + match archive.extract(&st.directories.version_natives_dir(version)) { + Ok(_) => log::info!("Fetched native {}", &library.name), + Err(err) => log::error!("Failed extracting native {}. err: {}", &library.name, err) + } + } else { + log::error!("Failed extracting native {}", &library.name) + } } } diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index 9fb71c92a..76d26b297 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -72,9 +72,10 @@ pub async fn launch_minecraft( "Invalid game version: {game_version}" )))?; - let version_jar = loader_version - .as_ref() - .map_or(version.id.clone(), |it| it.id.clone()); + let version_jar = + loader_version.as_ref().map_or(version.id.clone(), |it| { + format!("{}-{}", version.id.clone(), it.id.clone()) + }); let mut version_info = download::download_version_info( &state, @@ -85,7 +86,7 @@ pub async fn launch_minecraft( let client_path = state .directories - .version_dir(&version.id) + .version_dir(&version_jar) .join(format!("{version_jar}.jar")); download::download_minecraft(&state, &version_info).await?; @@ -133,6 +134,7 @@ pub async fn launch_minecraft( args::get_processor_main_class(args::get_lib_path( &state.directories.libraries_dir(), &processor.jar, + false, )?) .await? .ok_or_else(|| { @@ -193,7 +195,7 @@ pub async fn launch_minecraft( args::get_jvm_arguments( args.get(&d::minecraft::ArgumentType::Jvm) .map(|x| x.as_slice()), - &state.directories.version_natives_dir(&version.id), + &state.directories.version_natives_dir(&version_jar), &state.directories.libraries_dir(), &args::get_class_paths( &state.directories.libraries_dir(), @@ -205,7 +207,6 @@ pub async fn launch_minecraft( Vec::from(java_args), )? .into_iter() - .map(|r| r.replace(' ', r"\ ")) .collect::>(), ) .arg(version_info.main_class.clone()) @@ -223,7 +224,6 @@ pub async fn launch_minecraft( *resolution, )? .into_iter() - .map(|r| r.replace(' ', r"\ ")) .collect::>(), ) .current_dir(instance_path.clone()) diff --git a/theseus/src/state/metadata.rs b/theseus/src/state/metadata.rs index 89251efe0..a3c2e694b 100644 --- a/theseus/src/state/metadata.rs +++ b/theseus/src/state/metadata.rs @@ -10,7 +10,7 @@ use daedalus::{ use futures::prelude::*; use std::collections::LinkedList; -const METADATA_URL: &str = "https://meta.modrinth.com/gamedata"; +const METADATA_URL: &str = "https://meta.modrinth.com"; const METADATA_DB_FIELD: &[u8] = b"metadata"; const RETRY_ATTEMPTS: i32 = 3; diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index b14fa3537..4e95e4fe8 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -77,17 +77,17 @@ impl State { let settings = Settings::init(&directories.settings_file()).await?; - // Launcher data - let (metadata, profiles) = tokio::try_join! { - Metadata::init(&database), - Profiles::init(&database, &directories), - }?; - let users = Users::init(&database)?; - // Loose initializations let io_semaphore = Semaphore::new(settings.max_concurrent_downloads); + // Launcher data + let (metadata, profiles) = tokio::try_join! { + Metadata::init(&database), + Profiles::init(&database, &directories, &io_semaphore), + }?; + let users = Users::init(&database)?; + let children = Children::new(); let auth_flow = AuthTask::new(); @@ -133,8 +133,7 @@ impl State { reader.sync(&state.directories.settings_file()).await?; Ok::<_, crate::Error>(()) }) - .await - .unwrap() + .await? }; let sync_profiles = async { @@ -148,15 +147,16 @@ impl State { profiles.sync(&mut batch).await?; Ok::<_, crate::Error>(()) }) - .await - .unwrap() + .await? }; tokio::try_join!(sync_settings, sync_profiles)?; - state - .database - .apply_batch(Arc::try_unwrap(batch).unwrap().into_inner())?; + state.database.apply_batch( + Arc::try_unwrap(batch) + .expect("Error saving state by acquiring Arc") + .into_inner(), + )?; state.database.flush_async().await?; Ok(()) diff --git a/theseus/src/state/profiles.rs b/theseus/src/state/profiles.rs index f9e1570f0..fa8c7412a 100644 --- a/theseus/src/state/profiles.rs +++ b/theseus/src/state/profiles.rs @@ -11,11 +11,12 @@ use std::{ path::{Path, PathBuf}, }; use tokio::fs; +use tokio::sync::Semaphore; const PROFILE_JSON_PATH: &str = "profile.json"; const PROFILE_SUBTREE: &[u8] = b"profiles"; -pub(crate) struct Profiles(pub HashMap>); +pub(crate) struct Profiles(pub HashMap); // TODO: possibly add defaults to some of these values pub const CURRENT_FORMAT_VERSION: u32 = 1; @@ -30,7 +31,6 @@ pub struct Profile { #[serde(skip)] pub path: PathBuf, pub metadata: ProfileMetadata, - pub projects: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub java: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -39,6 +39,7 @@ pub struct Profile { pub resolution: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hooks: Option, + pub projects: HashMap, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -52,6 +53,7 @@ pub struct ProfileMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub loader_version: Option, pub format_version: u32, + pub linked_project_id: Option, } // TODO: Quilt? @@ -64,6 +66,7 @@ pub enum ModLoader { Vanilla, Forge, Fabric, + Quilt, } impl std::fmt::Display for ModLoader { @@ -72,6 +75,7 @@ impl std::fmt::Display for ModLoader { Self::Vanilla => "Vanilla", Self::Forge => "Forge", Self::Fabric => "Fabric", + Self::Quilt => "Quilt", }) } } @@ -107,6 +111,7 @@ impl Profile { loader: ModLoader::Vanilla, loader_version: None, format_version: CURRENT_FORMAT_VERSION, + linked_project_id: None, }, projects: HashMap::new(), java: None, @@ -116,16 +121,8 @@ impl Profile { }) } - // TODO: deduplicate these builder methods - // They are flat like this in order to allow builder-style usage #[tracing::instrument] - pub fn with_name(&mut self, name: String) -> &mut Self { - self.metadata.name = name; - self - } - - #[tracing::instrument] - pub async fn with_icon<'a>( + pub async fn set_icon<'a>( &'a mut self, icon: &'a Path, ) -> crate::Result<&'a mut Self> { @@ -149,54 +146,24 @@ impl Profile { } } - #[tracing::instrument] - pub fn with_game_version(&mut self, version: String) -> &mut Self { - self.metadata.game_version = version; - self - } + pub fn get_profile_project_paths(&self) -> crate::Result> { + let mut files = Vec::new(); + let mut read_paths = |path: &str| { + let new_path = self.path.join(path); + if new_path.exists() { + for path in std::fs::read_dir(self.path.join(path))? { + files.push(path?.path()); + } + } + Ok::<(), crate::Error>(()) + }; - #[tracing::instrument] - pub fn with_loader( - &mut self, - loader: ModLoader, - version: Option, - ) -> &mut Self { - self.metadata.loader = loader; - self.metadata.loader_version = version; - self - } + read_paths("mods")?; + read_paths("shaders")?; + read_paths("resourcepacks")?; + read_paths("datapacks")?; - #[tracing::instrument] - pub fn with_java_settings( - &mut self, - settings: Option, - ) -> &mut Self { - self.java = settings; - self - } - - #[tracing::instrument] - pub fn with_memory( - &mut self, - settings: Option, - ) -> &mut Self { - self.memory = settings; - self - } - - #[tracing::instrument] - pub fn with_resolution( - &mut self, - resolution: Option, - ) -> &mut Self { - self.resolution = resolution; - self - } - - #[tracing::instrument] - pub fn with_hooks(&mut self, hooks: Option) -> &mut Self { - self.hooks = hooks; - self + Ok(files) } } @@ -205,6 +172,7 @@ impl Profiles { pub async fn init( db: &sled::Db, dirs: &DirectoryInfo, + io_sempahore: &Semaphore, ) -> crate::Result { let profile_db = db.get(PROFILE_SUBTREE)?.map_or( Ok(Default::default()), @@ -229,38 +197,34 @@ impl Profiles { }; (path, prof) }) - .collect::>>() + .filter_map(|(key, opt_value)| async move { + opt_value.map(|value| (key, value)) + }) + .collect::>() .await; // project path, parent profile path let mut files: HashMap = HashMap::new(); { - for (profile_path, _profile_opt) in profiles.iter() { - let mut read_paths = |path: &str| { - let new_path = profile_path.join(path); - if new_path.exists() { - for path in std::fs::read_dir(profile_path.join(path))? - { - files.insert(path?.path(), profile_path.clone()); - } - } - Ok::<(), crate::Error>(()) - }; - read_paths("mods")?; - read_paths("shaders")?; - read_paths("resourcepacks")?; - read_paths("datapacks")?; + for (profile_path, profile) in profiles.iter() { + let paths = profile.get_profile_project_paths()?; + + for path in paths { + files.insert(path, profile_path.clone()); + } } } + let inferred = super::projects::infer_data_from_files( files.keys().cloned().collect(), dirs.caches_dir(), + io_sempahore, ) .await?; for (key, value) in inferred { if let Some(profile_path) = files.get(&key) { - if let Some(Some(profile)) = profiles.get_mut(profile_path) { + if let Some(profile) = profiles.get_mut(profile_path) { profile.projects.insert(key, value); } } @@ -278,7 +242,7 @@ impl Profiles { crate::ErrorKind::UTFError(profile.path.clone()).as_error(), )? .into(), - Some(profile), + profile, ); Ok(self) } @@ -292,9 +256,15 @@ impl Profiles { } #[tracing::instrument(skip(self))] - pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> { - let path = PathBuf::from(&canonicalize(path)?.to_str().unwrap()); + pub async fn remove(&mut self, path: &Path) -> crate::Result<&Self> { + let path = + PathBuf::from(&canonicalize(path)?.to_string_lossy().to_string()); self.0.remove(&path); + + if path.exists() { + fs::remove_dir_all(path).await?; + } + Ok(self) } @@ -308,8 +278,8 @@ impl Profiles { .try_for_each_concurrent(None, |(path, profile)| async move { let json = serde_json::to_vec_pretty(&profile)?; - let json_path = - Path::new(path.to_str().unwrap()).join(PROFILE_JSON_PATH); + let json_path = Path::new(&path.to_string_lossy().to_string()) + .join(PROFILE_JSON_PATH); fs::write(json_path, json).await?; Ok::<_, crate::Error>(()) diff --git a/theseus/src/state/projects.rs b/theseus/src/state/projects.rs index c504ebc49..32578016a 100644 --- a/theseus/src/state/projects.rs +++ b/theseus/src/state/projects.rs @@ -1,17 +1,17 @@ //! Project management + inference use crate::config::{MODRINTH_API_URL, REQWEST_CLIENT}; +use crate::util::fetch::write_cached_icon; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::Digest; use std::collections::HashMap; -use std::ffi::OsStr; -use std::fs::File; -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use tokio::io::AsyncReadExt; -use zip::ZipArchive; +use tokio::sync::Semaphore; +// use zip::ZipArchive; +use async_zip::tokio::read::fs::ZipFileReader; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Project { @@ -33,8 +33,8 @@ pub struct ModrinthProject { pub published: DateTime, pub updated: DateTime, - pub client_side: String, - pub server_side: String, + pub client_side: SideType, + pub server_side: SideType, pub downloads: u32, pub followers: u32, @@ -46,7 +46,75 @@ pub struct ModrinthProject { pub versions: Vec, - pub icon_url: String, + pub icon_url: Option, +} + +/// A specific version of a project +#[derive(Serialize, Deserialize)] +pub struct ModrinthVersion { + pub id: String, + pub project_id: String, + pub author_id: String, + + pub featured: bool, + + pub name: String, + pub version_number: String, + pub changelog: String, + pub changelog_url: Option, + + pub date_published: DateTime, + pub downloads: u32, + pub version_type: String, + + pub files: Vec, + pub dependencies: Vec, + pub game_versions: Vec, + pub loaders: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ModrinthVersionFile { + pub hashes: HashMap, + pub url: String, + pub filename: String, + pub primary: bool, + pub size: u32, + pub file_type: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Dependency { + pub version_id: Option, + pub project_id: Option, + pub file_name: Option, + pub dependency_type: DependencyType, +} + +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "lowercase")] +pub enum DependencyType { + Required, + Optional, + Incompatible, + Embedded, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum SideType { + Required, + Optional, + Unsupported, + Unknown, +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum FileType { + RequiredResourcePack, + OptionalResourcePack, + Unknown, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -63,9 +131,57 @@ pub enum ProjectMetadata { Unknown, } +async fn read_icon_from_file( + icon_path: Option, + cache_dir: &Path, + path: &PathBuf, + io_semaphore: &Semaphore, +) -> crate::Result> { + if let Some(icon_path) = icon_path { + // we have to repoen the zip twice here :( + let zip_file_reader = ZipFileReader::new(path).await; + if let Ok(zip_file_reader) = zip_file_reader { + // Get index of icon file and open it + let zip_index_option = zip_file_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == icon_path); + if let Some(index) = zip_index_option { + let entry = zip_file_reader + .file() + .entries() + .get(index) + .unwrap() + .entry(); + let mut bytes = Vec::new(); + if zip_file_reader + .entry(zip_index_option.unwrap()) + .await? + .read_to_end_checked(&mut bytes, entry) + .await + .is_ok() + { + let bytes = bytes::Bytes::from(bytes); + let permit = io_semaphore.acquire().await?; + let path = write_cached_icon( + &icon_path, cache_dir, bytes, &permit, + ) + .await?; + + return Ok(Some(path)); + } + }; + } + } + + Ok(None) +} + pub async fn infer_data_from_files( paths: Vec, cache_dir: PathBuf, + io_semaphore: &Semaphore, ) -> crate::Result> { let mut file_path_hashes = HashMap::new(); @@ -139,51 +255,28 @@ pub async fn infer_data_from_files( } for (hash, path) in further_analyze_projects { - let file = File::open(path.clone())?; - - // TODO: get rid of below unwrap - let mut zip = ZipArchive::new(file).unwrap(); - - let read_icon_from_file = - |icon_path: Option| -> crate::Result> { - if let Some(icon_path) = icon_path { - // we have to repoen the zip twice here :( - let zip_file = File::open(path.clone())?; - if let Ok(mut zip) = ZipArchive::new(zip_file) { - if let Ok(mut file) = zip.by_name(&icon_path) { - let mut bytes = Vec::new(); - if file.read_to_end(&mut bytes).is_ok() { - let extension = Path::new(&icon_path) - .extension() - .and_then(OsStr::to_str); - let hash = sha1::Sha1::from(&bytes).hexdigest(); - let path = cache_dir.join("icons").join( - if let Some(ext) = extension { - format!("{hash}.{ext}") - } else { - hash - }, - ); - - if !path.exists() { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let mut file = File::create(path.clone())?; - file.write_all(&bytes)?; - } - - return Ok(Some(path)); - } - }; - } - } - - Ok(None) - }; - - if let Ok(mut file) = zip.by_name("META-INF/mods.toml") { + let zip_file_reader = if let Ok(zip_file_reader) = + ZipFileReader::new(path.clone()).await + { + zip_file_reader + } else { + return_projects.insert( + path.clone(), + Project { + sha512: hash, + disabled: path.ends_with(".disabled"), + metadata: ProjectMetadata::Unknown, + }, + ); + continue; + }; + let zip_index_option = zip_file_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == "META-INF/mods.toml"); + if let Some(index) = zip_index_option { + let file = zip_file_reader.file().entries().get(index).unwrap(); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ForgeModInfo { @@ -201,18 +294,30 @@ pub async fn infer_data_from_files( } let mut file_str = String::new(); - if file.read_to_string(&mut file_str).is_ok() { + if zip_file_reader + .entry(index) + .await? + .read_to_string_checked(&mut file_str, file.entry()) + .await + .is_ok() + { if let Ok(pack) = serde_json::from_str::(&file_str) { if let Some(pack) = pack.mods.first() { - let icon = read_icon_from_file(pack.logo_file.clone())?; + let icon = read_icon_from_file( + pack.logo_file.clone(), + &cache_dir, + &path, + io_semaphore, + ) + .await?; return_projects.insert( path.clone(), Project { sha512: hash, - disabled: false, + disabled: path.ends_with(".disabled"), metadata: ProjectMetadata::Inferred { title: Some( pack.display_name @@ -236,7 +341,13 @@ pub async fn infer_data_from_files( } } - if let Ok(mut file) = zip.by_name("mcmod.info") { + let zip_index_option = zip_file_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == "mcmod.info"); + if let Some(index) = zip_index_option { + let file = zip_file_reader.file().entries().get(index).unwrap(); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ForgeMod { @@ -249,15 +360,27 @@ pub async fn infer_data_from_files( } let mut file_str = String::new(); - if file.read_to_string(&mut file_str).is_ok() { + if zip_file_reader + .entry(index) + .await? + .read_to_string_checked(&mut file_str, file.entry()) + .await + .is_ok() + { if let Ok(pack) = serde_json::from_str::(&file_str) { - let icon = read_icon_from_file(pack.logo_file)?; + let icon = read_icon_from_file( + pack.logo_file, + &cache_dir, + &path, + io_semaphore, + ) + .await?; return_projects.insert( path.clone(), Project { sha512: hash, - disabled: false, + disabled: path.ends_with(".disabled"), metadata: ProjectMetadata::Inferred { title: Some(if pack.name.is_empty() { pack.modid @@ -276,7 +399,13 @@ pub async fn infer_data_from_files( } } - if let Ok(mut file) = zip.by_name("fabric.mod.json") { + let zip_index_option = zip_file_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == "fabric.mod.json"); + if let Some(index) = zip_index_option { + let file = zip_file_reader.file().entries().get(index).unwrap(); #[derive(Deserialize)] #[serde(untagged)] enum FabricAuthor { @@ -295,15 +424,27 @@ pub async fn infer_data_from_files( } let mut file_str = String::new(); - if file.read_to_string(&mut file_str).is_ok() { + if zip_file_reader + .entry(index) + .await? + .read_to_string_checked(&mut file_str, file.entry()) + .await + .is_ok() + { if let Ok(pack) = serde_json::from_str::(&file_str) { - let icon = read_icon_from_file(pack.icon)?; + let icon = read_icon_from_file( + pack.icon, + &cache_dir, + &path, + io_semaphore, + ) + .await?; return_projects.insert( path.clone(), Project { sha512: hash, - disabled: false, + disabled: path.ends_with(".disabled"), metadata: ProjectMetadata::Inferred { title: Some(pack.name.unwrap_or(pack.id)), description: pack.description, @@ -325,7 +466,13 @@ pub async fn infer_data_from_files( } } - if let Ok(mut file) = zip.by_name("quilt.mod.json") { + let zip_index_option = zip_file_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == "quilt.mod.json"); + if let Some(index) = zip_index_option { + let file = zip_file_reader.file().entries().get(index).unwrap(); #[derive(Deserialize)] struct QuiltMetadata { pub name: Option, @@ -341,17 +488,27 @@ pub async fn infer_data_from_files( } let mut file_str = String::new(); - if file.read_to_string(&mut file_str).is_ok() { + if zip_file_reader + .entry(index) + .await? + .read_to_string_checked(&mut file_str, file.entry()) + .await + .is_ok() + { if let Ok(pack) = serde_json::from_str::(&file_str) { let icon = read_icon_from_file( pack.metadata.as_ref().and_then(|x| x.icon.clone()), - )?; + &cache_dir, + &path, + io_semaphore, + ) + .await?; return_projects.insert( path.clone(), Project { sha512: hash, - disabled: false, + disabled: path.ends_with(".disabled"), metadata: ProjectMetadata::Inferred { title: Some( pack.metadata @@ -383,23 +540,39 @@ pub async fn infer_data_from_files( } } - if let Ok(mut file) = zip.by_name("pack.mcmeta") { + let zip_index_option = zip_file_reader + .file() + .entries() + .iter() + .position(|f| f.entry().filename() == "pack.mcdata"); + if let Some(index) = zip_index_option { + let file = zip_file_reader.file().entries().get(index).unwrap(); #[derive(Deserialize)] struct Pack { description: Option, } let mut file_str = String::new(); - if file.read_to_string(&mut file_str).is_ok() { + if zip_file_reader + .entry(index) + .await? + .read_to_string_checked(&mut file_str, file.entry()) + .await + .is_ok() + { if let Ok(pack) = serde_json::from_str::(&file_str) { - let icon = - read_icon_from_file(Some("pack.png".to_string()))?; - + let icon = read_icon_from_file( + Some("pack.png".to_string()), + &cache_dir, + &path, + io_semaphore, + ) + .await?; return_projects.insert( path.clone(), Project { sha512: hash, - disabled: false, + disabled: path.ends_with(".disabled"), metadata: ProjectMetadata::Inferred { title: None, description: pack.description, @@ -415,10 +588,10 @@ pub async fn infer_data_from_files( } return_projects.insert( - path, + path.clone(), Project { sha512: hash, - disabled: false, + disabled: path.ends_with(".disabled"), metadata: ProjectMetadata::Unknown, }, ); diff --git a/theseus/src/util/fetch.rs b/theseus/src/util/fetch.rs index 2787cb562..c0862b0ac 100644 --- a/theseus/src/util/fetch.rs +++ b/theseus/src/util/fetch.rs @@ -1,76 +1,85 @@ //! Functions for fetching infromation from the Internet use crate::config::REQWEST_CLIENT; -use futures::prelude::*; -use std::{collections::LinkedList, convert::TryInto, path::Path, sync::Arc}; +use bytes::Bytes; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use tokio::{ fs::{self, File}, io::AsyncWriteExt, - sync::{Semaphore, SemaphorePermit}, + sync::SemaphorePermit, }; const FETCH_ATTEMPTS: usize = 3; +/// Downloads a file with retry and checksum functionality #[tracing::instrument(skip(_permit))] pub async fn fetch<'a>( url: &str, sha1: Option<&str>, _permit: &SemaphorePermit<'a>, ) -> crate::Result { - let mut attempts = LinkedList::new(); - for _ in 0..FETCH_ATTEMPTS { - attempts.push_back( - async { - let content = REQWEST_CLIENT.get(url).send().await?; - let bytes = content.bytes().await?; + for attempt in 1..=(FETCH_ATTEMPTS + 1) { + let result = REQWEST_CLIENT.get(url).send().await; - if let Some(hash) = sha1 { - let actual_hash = sha1_async(bytes.clone()).await; - if actual_hash != hash { - return Err(crate::ErrorKind::HashError( - actual_hash, - String::from(hash), - ) - .into()); + match result { + Ok(x) => { + let bytes = x.bytes().await; + + if let Ok(bytes) = bytes { + if let Some(sha1) = sha1 { + let hash = sha1_async(bytes.clone()).await?; + if &*hash != sha1 { + if attempt <= 3 { + continue; + } else { + return Err(crate::ErrorKind::HashError( + sha1.to_string(), + hash, + ) + .into()); + } + } } - } - Ok(bytes) + log::debug!("Done downloading URL {url}"); + return Ok(bytes); + } else if attempt <= 3 { + continue; + } else if let Err(err) = bytes { + return Err(err.into()); + } } - .boxed(), - ) + Err(_) if attempt <= 3 => continue, + Err(err) => return Err(err.into()), + } } - log::debug!("Done downloading URL {url}"); - future::select_ok(attempts).map_ok(|it| it.0).await + unreachable!() } -// This is implemented, as it will be useful in porting modpacks -// For now, allow it to be dead code -#[allow(dead_code)] -#[tracing::instrument(skip(sem))] -pub async fn fetch_mirrors( - urls: &[&str], +/// Downloads a file from specified mirrors +#[tracing::instrument(skip(permit))] +pub async fn fetch_mirrors<'a>( + mirrors: &[&str], sha1: Option<&str>, - permits: u32, - sem: &Semaphore, + permit: &SemaphorePermit<'a>, ) -> crate::Result { - let _permits = sem.acquire_many(permits).await.unwrap(); - let sem = Arc::new(Semaphore::new(permits.try_into().unwrap())); + if mirrors.is_empty() { + return Err(crate::ErrorKind::InputError( + "No mirrors provided!".to_string(), + ) + .into()); + } - future::select_ok(urls.iter().map(|url| { - let sha1 = sha1.map(String::from); - let url = String::from(*url); - let sem = Arc::clone(&sem); + for (index, mirror) in mirrors.iter().enumerate() { + let result = fetch(mirror, sha1, permit).await; - tokio::spawn(async move { - let permit = sem.acquire().await.unwrap(); - fetch(&url, sha1.as_deref(), &permit).await - }) - .map(Result::unwrap) - .boxed() - })) - .await - .map(|it| it.0) + if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) { + return result; + } + } + + unreachable!() } #[tracing::instrument(skip(bytes, _permit))] @@ -89,8 +98,31 @@ pub async fn write<'a>( Ok(()) } -async fn sha1_async(bytes: bytes::Bytes) -> String { - tokio::task::spawn_blocking(move || sha1::Sha1::from(bytes).hexdigest()) - .await - .unwrap() +#[tracing::instrument(skip(bytes, permit))] +pub async fn write_cached_icon<'a>( + icon_path: &str, + cache_dir: &Path, + bytes: Bytes, + permit: &SemaphorePermit<'a>, +) -> crate::Result { + let extension = Path::new(&icon_path).extension().and_then(OsStr::to_str); + let hash = sha1_async(bytes.clone()).await?; + let path = cache_dir.join("icons").join(if let Some(ext) = extension { + format!("{hash}.{ext}") + } else { + hash + }); + + write(&path, &bytes, permit).await?; + + Ok(path) +} + +async fn sha1_async(bytes: Bytes) -> crate::Result { + let hash = tokio::task::spawn_blocking(move || { + sha1::Sha1::from(bytes).hexdigest() + }) + .await?; + + Ok(hash) } diff --git a/theseus/src/util/jre.rs b/theseus/src/util/jre.rs index d74a46392..2e8c62b9b 100644 --- a/theseus/src/util/jre.rs +++ b/theseus/src/util/jre.rs @@ -209,7 +209,8 @@ pub fn check_java_at_filepath(path: PathBuf) -> Option { // Extracting version numbers lazy_static! { static ref JAVA_VERSION_CAPTURE: Regex = - Regex::new(r#"version "([\d\._]+)""#).unwrap(); + Regex::new(r#"version "([\d\._]+)""#) + .expect("Error creating java version capture regex"); } // Extract version info from it diff --git a/theseus_cli/src/subcommands/profile.rs b/theseus_cli/src/subcommands/profile.rs index 7acdc2097..f6f6515ef 100644 --- a/theseus_cli/src/subcommands/profile.rs +++ b/theseus_cli/src/subcommands/profile.rs @@ -10,6 +10,7 @@ use paris::*; use std::path::{Path, PathBuf}; use tabled::Tabled; use theseus::prelude::*; +use theseus::profile_create::profile_create; use tokio::fs; use tokio_stream::wrappers::ReadDirStream; @@ -24,53 +25,12 @@ pub struct ProfileCommand { #[derive(argh::FromArgs, Debug)] #[argh(subcommand)] pub enum ProfileSubcommand { - Add(ProfileAdd), Init(ProfileInit), List(ProfileList), Remove(ProfileRemove), Run(ProfileRun), } -#[derive(argh::FromArgs, Debug)] -#[argh(subcommand, name = "add")] -/// add a new profile to Theseus -pub struct ProfileAdd { - #[argh(positional, default = "std::env::current_dir().unwrap()")] - /// the profile to add - profile: PathBuf, -} - -impl ProfileAdd { - pub async fn run( - &self, - _args: &crate::Args, - _largs: &ProfileCommand, - ) -> Result<()> { - info!( - "Adding profile at path '{}' to Theseus", - self.profile.display() - ); - - let profile = canonicalize(&self.profile)?; - let json_path = profile.join("profile.json"); - - ensure!( - json_path.exists(), - "Profile json does not exist. Perhaps you wanted `profile init` or `profile fetch`?" - ); - ensure!( - !profile::is_managed(&profile).await?, - "Profile already managed by Theseus. If the contents of the profile are invalid or missing, the profile can be regenerated using `profile init` or `profile fetch`" - ); - - profile::add_path(&profile).await?; - State::sync().await?; - success!("Profile added!"); - - Ok(()) - } -} - #[derive(argh::FromArgs, Debug)] #[argh(subcommand, name = "init")] /// create a new profile and manage it with Theseus @@ -87,10 +47,6 @@ pub struct ProfileInit { /// the game version of the profile game_version: Option, - #[argh(option)] - /// the icon for the profile - icon: Option, - #[argh(option, from_str_fn(modloader_from_str))] /// the modloader to use modloader: Option, @@ -230,28 +186,15 @@ impl ProfileInit { None }; - let icon = match &self.icon { - Some(icon) => Some(icon.clone()), - None => Some( - prompt_async("Icon".to_owned(), Some(String::new())).await?, - ) - .filter(|it| !it.trim().is_empty()) - .map(PathBuf::from), - }; - - let mut profile = - Profile::new(name, game_version, self.path.clone()).await?; - - if let Some(ref icon) = icon { - profile.with_icon(icon).await?; - } - - if let Some((loader_version, loader)) = loader { - profile.with_loader(loader, Some(loader_version)); - } - - profile::add(profile).await?; - State::sync().await?; + profile_create( + name, + game_version, + loader.clone().map(|x| x.1).unwrap_or(ModLoader::Vanilla), + loader.map(|x| x.0.id), + None, + None, + ) + .await?; success!( "Successfully created instance, it is now available to use with Theseus!" @@ -312,12 +255,7 @@ impl ProfileList { _largs: &ProfileCommand, ) -> Result<()> { let profiles = profile::list().await?; - let rows = profiles.iter().map(|(path, prof)| { - prof.as_ref().map_or_else( - || ProfileRow::from(path.as_path()), - ProfileRow::from, - ) - }); + let rows = profiles.values().map(ProfileRow::from); let table = table(rows).with( tabled::Modify::new(tabled::Column(1..=1)) @@ -348,13 +286,9 @@ impl ProfileRemove { info!("Removing profile {} from Theseus", self.profile.display()); if confirm_async(String::from("Do you wish to continue"), true).await? { - if !profile::is_managed(&profile).await? { - warn!("Profile was not managed by Theseus!"); - } else { - profile::remove(&profile).await?; - State::sync().await?; - success!("Profile removed!"); - } + profile::remove(&profile).await?; + State::sync().await?; + success!("Profile removed!"); } else { error!("Aborted!"); } @@ -385,11 +319,6 @@ impl ProfileRun { info!("Starting profile at path {}...", self.profile.display()); let path = canonicalize(&self.profile)?; - ensure!( - profile::is_managed(&path).await?, - "Profile not managed by Theseus (if it exists, try using `profile add` first!)", - ); - let id = future::ready(self.user.ok_or(())) .or_else(|_| async move { let state = State::get().await?; @@ -415,7 +344,6 @@ impl ProfileRun { impl ProfileCommand { pub async fn run(&self, args: &crate::Args) -> Result<()> { dispatch!(&self.action, (args, self) => { - ProfileSubcommand::Add, ProfileSubcommand::Init, ProfileSubcommand::List, ProfileSubcommand::Remove, diff --git a/theseus_gui/src-tauri/src/api/mod.rs b/theseus_gui/src-tauri/src/api/mod.rs index b19a64138..1c16de8d7 100644 --- a/theseus_gui/src-tauri/src/api/mod.rs +++ b/theseus_gui/src-tauri/src/api/mod.rs @@ -4,6 +4,7 @@ use thiserror::Error; pub mod auth; +pub mod pack; pub mod process; pub mod profile; pub mod profile_create; diff --git a/theseus_gui/src-tauri/src/api/pack.rs b/theseus_gui/src-tauri/src/api/pack.rs new file mode 100644 index 000000000..6ce6585b6 --- /dev/null +++ b/theseus_gui/src-tauri/src/api/pack.rs @@ -0,0 +1,17 @@ +use crate::api::Result; +use std::path::{Path, PathBuf}; +use theseus::prelude::*; + +// Creates a pack from a version ID (returns a path to the created profile) +// invoke('pack_install_version_id', version_id) +#[tauri::command] +pub async fn pack_install_version_id(version_id: String) -> Result { + let res = pack::install_pack_from_version_id(version_id).await?; + Ok(res) +} + +#[tauri::command] +pub async fn pack_install_file(path: &Path) -> Result { + let res = pack::install_pack_from_file(path.to_path_buf()).await?; + Ok(res) +} diff --git a/theseus_gui/src-tauri/src/api/profile.rs b/theseus_gui/src-tauri/src/api/profile.rs index bf0687afb..dd31c93a8 100644 --- a/theseus_gui/src-tauri/src/api/profile.rs +++ b/theseus_gui/src-tauri/src/api/profile.rs @@ -2,30 +2,11 @@ use crate::api::Result; use std::path::{Path, PathBuf}; use theseus::prelude::*; -// Add a profile to the in-memory state -// invoke('profile_add',profile) -#[tauri::command] -pub async fn profile_add(profile: Profile) -> Result<()> { - profile::add(profile).await?; - State::sync().await?; - Ok(()) -} - -// Add a path as a profile in-memory -// invoke('profile_add_path',path) -#[tauri::command] -pub async fn profile_add_path(path: &Path) -> Result<()> { - profile::add_path(path).await?; - State::sync().await?; - Ok(()) -} - // Remove a profile // invoke('profile_add_path',path) #[tauri::command] pub async fn profile_remove(path: &Path) -> Result<()> { profile::remove(path).await?; - State::sync().await?; Ok(()) } @@ -34,25 +15,6 @@ pub async fn profile_remove(path: &Path) -> Result<()> { #[tauri::command] pub async fn profile_get(path: &Path) -> Result> { let res = profile::get(path).await?; - State::sync().await?; - Ok(res) -} - -// Check if a profile is already managed by Theseus -// invoke('profile_is_managed',profile) -#[tauri::command] -pub async fn profile_is_managed(profile: &Path) -> Result { - let res = profile::is_managed(profile).await?; - State::sync().await?; - Ok(res) -} - -// Check if a profile is loaded -// invoke('profile_is_loaded',profile) -#[tauri::command] -pub async fn profile_is_loaded(profile: &Path) -> Result { - let res = profile::is_loaded(profile).await?; - State::sync().await?; Ok(res) } @@ -60,9 +22,8 @@ pub async fn profile_is_loaded(profile: &Path) -> Result { // invoke('profile_list') #[tauri::command] pub async fn profile_list( -) -> Result>> { +) -> Result> { let res = profile::list().await?; - State::sync().await?; Ok(res) } @@ -71,10 +32,7 @@ pub async fn profile_list( // for the actual Child in the state. // invoke('profile_run') #[tauri::command] -pub async fn profile_run( - path: &Path, - credentials: theseus::auth::Credentials, -) -> Result { +pub async fn profile_run(path: &Path, credentials: Credentials) -> Result { let proc_lock = profile::run(path, &credentials).await?; let pid = proc_lock.read().await.child.id().ok_or_else(|| { theseus::Error::from(theseus::ErrorKind::LauncherError( @@ -89,7 +47,7 @@ pub async fn profile_run( #[tauri::command] pub async fn profile_run_wait( path: &Path, - credentials: theseus::auth::Credentials, + credentials: Credentials, ) -> Result<()> { let proc_lock = profile::run(path, &credentials).await?; let mut proc = proc_lock.write().await; diff --git a/theseus_gui/src-tauri/src/api/profile_create.rs b/theseus_gui/src-tauri/src/api/profile_create.rs index 52b735c66..e38586136 100644 --- a/theseus_gui/src-tauri/src/api/profile_create.rs +++ b/theseus_gui/src-tauri/src/api/profile_create.rs @@ -25,10 +25,10 @@ pub async fn profile_create( name, game_version, modloader, - loader_version, + Some(loader_version), icon, + None, ) .await?; - State::sync().await?; Ok(res) } diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index 86bb1f22a..d051e31ea 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -20,15 +20,13 @@ fn main() { initialize_state, api::profile_create::profile_create_empty, api::profile_create::profile_create, - api::profile::profile_add, - api::profile::profile_add_path, api::profile::profile_remove, api::profile::profile_get, - api::profile::profile_is_managed, - api::profile::profile_is_loaded, api::profile::profile_list, api::profile::profile_run, api::profile::profile_run_wait, + api::pack::pack_install_version_id, + api::pack::pack_install_file, api::auth::auth_authenticate_begin_flow, api::auth::auth_authenticate_await_completion, api::auth::auth_refresh, diff --git a/theseus_gui/src/helpers/pack.js b/theseus_gui/src/helpers/pack.js new file mode 100644 index 000000000..afb8ebc11 --- /dev/null +++ b/theseus_gui/src/helpers/pack.js @@ -0,0 +1,16 @@ +/** + * All theseus API calls return serialized values (both return values and errors); + * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, + * and deserialized into a usable JS object. + */ +import { invoke } from '@tauri-apps/api/tauri' + +// Installs pack from a version ID +export async function install(version_id) { + return await invoke('pack_install_version_id', version_id) +} + +// Installs pack from a path +export async function install_from_file(path) { + return await invoke('pack_install_file', path) +} diff --git a/theseus_gui/src/helpers/profile.js b/theseus_gui/src/helpers/profile.js index c9fbb58f6..7c3ebc8ba 100644 --- a/theseus_gui/src/helpers/profile.js +++ b/theseus_gui/src/helpers/profile.js @@ -16,16 +16,6 @@ export async function create() { return await invoke('profile_create') } -// Add a profile to the in-memory state -export async function add(profile) { - return await invoke('profile_add', { profile }) -} - -// Add a path as a profile in-memory -export async function add_path(path) { - return await invoke('profile_add_path', { path }) -} - // Remove a profile export async function remove(path) { return await invoke('profile_remove', { path }) @@ -37,18 +27,6 @@ export async function get(path) { return await invoke('profile_get', { path }) } -// Check if a pathed profile is already managed by Theseus -// Returns bool -export async function is_managed(path) { - return await invoke('profile_is_managed', { path }) -} - -// Check if a pathed profile is loaded -// Returns bool -export async function is_loaded(path) { - return await invoke('profile_is_loaded', { path }) -} - // Get a copy of the profile set // Returns hashmap of path -> Profile export async function list() { diff --git a/theseus_playground/src/main.rs b/theseus_playground/src/main.rs index 4f86ae3f2..88d611818 100644 --- a/theseus_playground/src/main.rs +++ b/theseus_playground/src/main.rs @@ -5,7 +5,7 @@ use dunce::canonicalize; use std::path::Path; -use theseus::{prelude::*, profile_create::profile_create}; +use theseus::prelude::*; use tokio::time::{sleep, Duration}; // A simple Rust implementation of the authentication run @@ -32,42 +32,23 @@ async fn main() -> theseus::Result<()> { // Initialize state let st = State::get().await?; - - // Set max concurrent downloads to 10 - st.settings.write().await.max_concurrent_downloads = 10; - - // Example variables for simple project case - let name = "Example".to_string(); - let game_version = "1.19.2".to_string(); - let modloader = ModLoader::Vanilla; - let loader_version = "stable".to_string(); - - // let icon = Some( - // Path::new("../icon_test.png") - // .canonicalize() - // .expect("Icon could be not be found. If not using, set to None"), - // ); - let icon = None; + st.settings.write().await.max_concurrent_downloads = 100; // Clear profiles println!("Clearing profiles."); - let h = profile::list().await?; - for (path, _) in h.into_iter() { - profile::remove(&path).await?; + { + let h = profile::list().await?; + for (path, _) in h.into_iter() { + profile::remove(&path).await?; + } } println!("Creating/adding profile."); - // Attempt to create a profile. If that fails, try adding one from the same path. - // TODO: actually do a nested error check for the correct profile error. - let profile_path = profile_create( - name.clone(), - game_version, - modloader, - loader_version, - icon, - ) - .await?; - State::sync().await?; + + let profile_path = + pack::install_pack_from_version_id("KxUUUFh5".to_string()) + .await + .unwrap(); // async closure for testing any desired edits // (ie: changing the java runtime of an added profile) diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index fb57ccd13..000000000 --- a/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - -