mod constants; mod version; mod profile; mod strsub; mod download; mod rules; use std::borrow::Cow; use std::collections::HashMap; use std::env::consts::{ARCH, OS}; use std::error::Error; use std::fmt::{Display, Formatter}; use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use const_format::formatcp; use futures::{stream, StreamExt, TryStreamExt}; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use sha1_smol::Sha1; use sysinfo::System; use tokio::{fs, io}; use tokio::fs::File; use tokio::io::AsyncWriteExt; use download::{MultiDownloader, VerifiedDownload}; use rules::{CompatCheck, IncompatibleError}; use version::{VersionList, VersionResolveError, VersionResult}; use crate::version::{Logging, Library, OSRestriction, OperatingSystem}; pub use profile::{Instance, Profile}; use crate::util; use crate::util::{FileVerifyError, IntegrityError}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { profiles: HashMap, instances: HashMap } #[derive(Debug)] enum SettingsLoadError { IO(io::Error), Format(serde_json::Error) } impl Display for SettingsLoadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { SettingsLoadError::IO(err) => write!(f, "I/O error loading settings: {}", err), SettingsLoadError::Format(err) => write!(f, "settings format error: {}", err), } } } impl Error for SettingsLoadError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { SettingsLoadError::IO(err) => Some(err), SettingsLoadError::Format(err) => Some(err), } } } impl Settings { async fn load(path: impl AsRef) -> Result { let data = match fs::read_to_string(&path).await { Ok(data) => data, Err(e) => return match e.kind() { ErrorKind::NotFound => Ok(Settings::default()), _ => Err(SettingsLoadError::IO(e)) } }; serde_json::from_str(data.as_str()).map_err(SettingsLoadError::Format) } } #[derive(Debug)] pub enum LogConfigError { UnknownType(String), InvalidId(Option), MissingURL, IO{ what: &'static str, error: io::Error }, Offline, Download{ url: String, error: reqwest::Error }, Integrity(IntegrityError) } impl Display for LogConfigError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { LogConfigError::UnknownType(log_type) => write!(f, "unknown log type {}", log_type), LogConfigError::InvalidId(oid) => match oid { Some(id) => write!(f, "invalid log config id: {}", id), None => f.write_str("missing log config id") }, LogConfigError::MissingURL => f.write_str("missing log config download URL"), LogConfigError::IO { what, error} => write!(f, "i/o error ({}): {}", what, error), LogConfigError::Offline => f.write_str("launcher in offline mode"), LogConfigError::Download { url, error } => write!(f, "failed to download log config ({}): {}", url, error), LogConfigError::Integrity(e) => write!(f, "log config verify error: {}", e) } } } impl Error for LogConfigError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { LogConfigError::IO { error, .. } => Some(error), LogConfigError::Download {error, ..} => Some(error), LogConfigError::Integrity(error) => Some(error), _ => None } } } struct SystemInfo { os: OperatingSystem, os_version: String, arch: String } struct LibraryRepository { home: PathBuf } pub struct Launcher { online: bool, home: PathBuf, versions: VersionList, settings_path: PathBuf, // maybe redundant but idc >:3 settings: Settings, system_info: SystemInfo, libraries: LibraryRepository } #[derive(Debug)] pub enum LaunchError { // version resolution errors UnknownVersion(String), LoadVersion(Box), ResolveVersion(VersionResolveError), IncompatibleVersion(IncompatibleError), // library errors LibraryDirError(PathBuf, io::Error), LibraryVerifyError(FileVerifyError), LibraryDownloadError, // log errors LogConfig(LogConfigError) } impl Display for LaunchError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self { LaunchError::UnknownVersion(id) => write!(f, "unknown version id: {id}"), LaunchError::LoadVersion(e) => write!(f, "error loading remote version: {e}"), LaunchError::ResolveVersion(e) => write!(f, "error resolving remote version: {e}"), LaunchError::IncompatibleVersion(e) => e.fmt(f), LaunchError::LibraryDirError(path, e) => write!(f, "failed to create library directory {}: {}", path.display(), e), LaunchError::LibraryVerifyError(e) => write!(f, "failed to verify library: {}", e), LaunchError::LibraryDownloadError => f.write_str("library download failed (see above logs for details)"), // TODO: booo this sucks LaunchError::LogConfig(e) => write!(f, "failed to configure logger: {}", e) } } } impl Error for LaunchError { fn cause(&self) -> Option<&dyn Error> { match &self { LaunchError::LoadVersion(e) => Some(e.as_ref()), LaunchError::ResolveVersion(e) => Some(e), LaunchError::IncompatibleVersion(e) => Some(e), LaunchError::LibraryDirError(_, e) => Some(e), LaunchError::LibraryVerifyError(e) => Some(e), LaunchError::LogConfig(e) => Some(e), _ => None } } } impl Launcher { // FIXME: more descriptive error type por favor pub async fn new(home: &Path, online: bool) -> Result> { let home = home.to_owned(); let versions_home = home.join("versions"); let versions; match tokio::fs::create_dir_all(&home).await { Ok(_) => (), Err(e) => match e.kind() { ErrorKind::AlreadyExists => (), _ => { warn!("Failed to create launcher home directory: {}", e); return Err(e.into()); } } } debug!("Version list online?: {online}"); if online { versions = VersionList::online(versions_home.as_ref()).await?; } else { versions = VersionList::offline(versions_home.as_ref()).await?; } let settings_path = home.join("ozone.json"); let settings = Settings::load(&settings_path).await?; Ok(Launcher { online, home: home.to_owned(), versions, settings_path, settings, system_info: SystemInfo::new(), libraries: LibraryRepository { home: home.join("libraries"), } }) } async fn log_config_ensure(&self, config: &Logging) -> Result, LogConfigError> { if config.client.log_type != "log4j2-xml" { return Err(LogConfigError::UnknownType(config.client.log_type.clone())); } let dlinfo = &config.client.file; let Some(id) = dlinfo.id.as_ref() else { return Err(LogConfigError::InvalidId(None)); }; let mut path = self.home.join("logging"); fs::create_dir_all(path.as_path()).await .map_err(|e| LogConfigError::IO{ what: "creating log directory", error: e })?; let Some(Component::Normal(filename)) = Path::new(id).components().last() else { return Err(LogConfigError::InvalidId(Some(id.clone()))); }; path.push(filename); debug!("Logger config {} is at {}", id, path.display()); // creates the JVM argument for this logger config fn get_arg(arg: &str, path: &Path) -> String { strsub::replace_str(arg, |key| match key { "path" => Some(path.to_string_lossy().clone()), _ => None }) } // verify the file match util::verify_file(path.as_path(), dlinfo.size, dlinfo.sha1).await { // integrity passed. return Ok(_) => { info!("Log configuration {} exists and integrity matches. Skipping.", id); return Ok(Some(get_arg(config.client.argument.as_str(), path.as_path()))); }, // ruh roh Err(e) => match e { FileVerifyError::Open(_, ioe) => match ioe.kind() { ErrorKind::NotFound => (), _ => return Err(LogConfigError::IO{ what: "verify file (open)", error: ioe }) }, FileVerifyError::Read(_, ioe) => return Err(LogConfigError::IO{ what: "verify file (read)", error: ioe }), FileVerifyError::Integrity(_, ie) => info!("log config failed integrity check: {}", ie) } } if !self.online { warn!("Cannot download log config! We are offline. Rerun the launcher in online mode to launch this version."); return Err(LogConfigError::Offline); } // download it let Some(url) = dlinfo.url.as_ref() else { return Err(LogConfigError::MissingURL); }; let mut file = File::create(path.as_path()).await.map_err(|e| LogConfigError::IO { what: "save log config (open)", error: e })?; debug!("Logger configuration {} must be downloaded ({}).", id, url); let mut response = reqwest::get(url).await.map_err(|e| LogConfigError::Download{ url: url.to_owned(), error: e })?; let mut tally = 0usize; let mut sha1 = Sha1::new(); while let Some(chunk) = response.chunk().await.map_err(|e| LogConfigError::Download{ url: url.to_owned(), error: e })? { let slice = chunk.as_ref(); file.write_all(slice).await.map_err(|e| LogConfigError::IO { what: "save log config (write)", error: e })?; tally += slice.len(); sha1.update(slice); } drop(file); // manually close file let del_file_silent = || async { debug!("Deleting downloaded log config {} since its integrity doesn't match :( {}", id, path.display()); let _ = fs::remove_file(path.as_path()).await.map_err(|e| warn!("failed to delete invalid log config: {}", e)); () }; if dlinfo.size.is_some_and(|s| s != tally) { del_file_silent().await; return Err(LogConfigError::Integrity(IntegrityError::SizeMismatch { expect: dlinfo.size.unwrap(), actual: tally })); } let digest = sha1.digest(); if dlinfo.sha1.is_some_and(|exp_dig| exp_dig != digest) { del_file_silent().await; return Err(LogConfigError::Integrity(IntegrityError::Sha1Mismatch { expect: dlinfo.sha1.unwrap(), actual: digest })); } info!("Log configuration {} downloaded successfully.", id); Ok(Some(get_arg(config.client.argument.as_str(), path.as_path()))) } pub async fn prepare_launch(&self, profile: &Profile) -> Result<(), LaunchError> { /* tasks 2 l;aunch the gayme!!!! :3 * - java runtime * - normal process (good research, past figboot :3) * - libraries * - (done) check which libraries we actually need (some have classifiers that don't apply to us) * - (done) of the libraries we need, check which have correct size and sha1 * - (done) redownload necessary libraries * - (if offline mode and there are libraries to download, then explode violently) * - extract natives * - logging * - download the config if present and necessary * - (explode if offline mode and we need to download stuff) * - assets * - get asset index (check if our local copy is good and redownload if not) * - check what ones are good and what needs to be downloaded * - download them * - (if offline mode, explode) * - if virtual or resource-mapped, copy (or perhaps hardlink? that would be cool) * - the actual client jar * - check integriddy and download if needed * - (explode if offline mode) * - launch the game * - build argument list and whatnot also */ let ver_res = self.versions.get_version_lazy(&profile.version_id); let ver = match ver_res { VersionResult::Remote(mv) => Cow::Owned(self.versions.load_remote_version(mv).await.map_err(|e| LaunchError::LoadVersion(e))?), VersionResult::Complete(cv) => Cow::Borrowed(cv), VersionResult::None => { return Err(LaunchError::UnknownVersion(profile.version_id.clone()).into()) } }; let ver = self.versions.resolve_version(ver.as_ref()).await.map_err(|e| LaunchError::ResolveVersion(e))?; ver.rules_apply(&self.system_info, |_| false).map_err(|e| LaunchError::IncompatibleVersion(e))?; let mut libs = Vec::new(); let mut downloads = Vec::new(); for lib in ver.libraries.values() { if lib.rules_apply(&self.system_info, |_| false).is_err() { continue; } libs.push(lib); if let Some(dl) = self.libraries.create_download(lib, self.system_info.os) { dl.make_dirs().await.map_err(|e| LaunchError::LibraryDirError(dl.get_path().to_path_buf(), e))?; downloads.push(dl); } } if self.online { info!("Downloading {} libraries...", downloads.len()); let mut multi = MultiDownloader::new(downloads); multi.perform().await .inspect_err(|e| warn!("library download failed: {e}")) .try_fold((), |_, _| async {Ok(())}) .await .map_err(|_| LaunchError::LibraryDownloadError)?; } else { info!("Verifying {} libraries...", downloads.len()); stream::iter(downloads) .map(|dl| Ok(async move { debug!("Verifying library {}", dl.get_path().display()); util::verify_file(dl.get_path(), dl.get_expect_size(), dl.get_expect_sha1()).await })) .try_buffer_unordered(5) .try_fold((), |_, _| async {Ok(())}) .await .map_err(|e| { warn!("A library could not be verified: {}", e); warn!("Since the launcher is in offline mode, libraries cannot be downloaded. Please try again in online mode."); LaunchError::LibraryVerifyError(e) })?; } let log_arg; if let Some(logging) = ver.logging.as_ref() { info!("Ensuring log configuration exists and is valid."); log_arg = self.log_config_ensure(logging).await .map_err(|e| LaunchError::LogConfig(e))?; } else { log_arg = None; } dbg!(log_arg); //todo!() Ok(()) } } #[derive(Debug, Clone)] enum LibraryError { InvalidName(String), IOError(ErrorKind) } impl Display for LibraryError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { LibraryError::InvalidName(name) => write!(f, "invalid name: {name}"), LibraryError::IOError(e) => write!(f, "io error reading library: {e}"), } } } impl Error for LibraryError {} const ARCH_BITS: &'static str = formatcp!("{}", usize::BITS); impl LibraryRepository { fn lib_replace(key: &str) -> Option> { match key { "arch" => Some(Cow::Borrowed(ARCH_BITS)), _ => None } } fn get_artifact_base_dir(name: &str) -> Option { let end_of_gid = name.find(':')?; Some(name[..end_of_gid].split('.').chain(name.split(':').skip(1).take(2)).collect()) } fn get_artifact_filename(name: &str, classifier: Option<&str>) -> Option { let n: Vec<&str> = name.splitn(4, ':').skip(1).collect(); if let Some(classifier) = classifier { match n.len() { 3 => Some(PathBuf::from(strsub::replace_thru(format!("{}-{}-{}.jar", n[1], n[2], classifier), Self::lib_replace))), 4 => Some(PathBuf::from(strsub::replace_thru(format!("{}-{}-{}-{}.jar", n[1], n[2], classifier, n[3]), Self::lib_replace))), _ => None } } else { match n.len() { 3 => Some(PathBuf::from(strsub::replace_thru(format!("{}-{}.jar", n[1], n[2]), Self::lib_replace))), 4 => Some(PathBuf::from(strsub::replace_thru(format!("{}-{}-{}.jar", n[1], n[2], n[3]), Self::lib_replace))), _ => None } } } fn get_artifact_path(name: &str, classifier: Option<&str>) -> Option { let Some(mut p) = Self::get_artifact_base_dir(name) else { return None; }; p.push(Self::get_artifact_filename(name, classifier)?); Some(p) } fn create_download(&self, lib: &Library, os: OperatingSystem) -> Option { let classifier = lib.natives.as_ref().map_or(None, |n| n.get(&os)).map(|s| s.as_str()); if lib.url.is_some() || lib.downloads.is_none() { // TODO: derive download URL in this situation? warn!("BUG: Deprecated case for library {}: url present or downloads missing. The launcher does not support out-of-line checksums at this time. Not downloading this library.", lib.name); return None; } let dlinfo = lib.downloads.as_ref()?.get_download_info(classifier)?; // drinking game: take a shot once per heap allocation let path = self.home.join(dlinfo.path.as_ref().map(PathBuf::from).or_else(|| Self::get_artifact_path(lib.name.as_str(), classifier))?); Some(VerifiedDownload::new(dlinfo.url.as_ref()?, path.as_path(), dlinfo.size, dlinfo.sha1)) } } impl SystemInfo { fn new() -> SystemInfo { let os = match OS { "windows" => OperatingSystem::Windows, "macos" => OperatingSystem::MacOS, "linux" => OperatingSystem::Linux, _ => OperatingSystem::Unknown // could probably consider "hurd" and "*bsd" to be linux... }; let mut os_version = System::os_version().unwrap_or_default(); if os == OperatingSystem::Windows && (os_version.starts_with("10") || os_version.starts_with("11")) { os_version.replace_range(..2, "10.0"); // minecraft expects this funny business... } let mut arch = ARCH.to_owned(); if arch == "x86_64" { // this nomenclature is preferred, since some versions expect the arch containing "x86" to mean 32-bit. arch.replace_range(.., "amd64"); } SystemInfo { os, os_version, arch } } fn is_our_os(&self, os: OperatingSystem) -> bool { if self.os == OperatingSystem::Unknown { return false; } self.os == os } fn applies(&self, restriction: &OSRestriction) -> bool { restriction.os.is_none_or(|os| self.is_our_os(os)) && restriction.version.as_deref().is_none_or(|pat| pat.is_match(&self.os_version)) && restriction.arch.as_deref().is_none_or(|pat| pat.is_match(&self.arch)) } }