mod constants; mod version; mod strsub; mod download; mod rules; mod assets; mod extract; mod settings; mod runner; use std::borrow::Cow; use std::cmp::min; use std::env::consts::{ARCH, OS}; use std::error::Error; use std::ffi::{OsStr, OsString}; use std::fmt::{Display, Formatter}; use std::io::ErrorKind; use std::io::ErrorKind::AlreadyExists; use std::path::{Component, Path, PathBuf}; use std::{env, process}; use std::env::JoinPathsError; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use const_format::formatcp; use futures::{StreamExt, TryStreamExt}; use log::{debug, info, warn}; use reqwest::Client; use sha1_smol::Sha1; use sysinfo::System; use tokio::{fs, io}; use tokio::fs::File; use tokio::io::AsyncWriteExt; use tokio_stream::wrappers::ReadDirStream; use download::{MultiDownloader, VerifiedDownload}; use rules::{CompatCheck, IncompatibleError}; use version::{VersionList, VersionResolveError, VersionResult}; use crate::version::{Logging, Library, OSRestriction, OperatingSystem, DownloadType, DownloadInfo, LibraryExtractRule}; use assets::{AssetError, AssetRepository}; use crate::util::{self, FileVerifyError, IntegrityError}; pub use settings::*; use crate::assets::AssetIndex; use crate::version::manifest::VersionType; #[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, natives: PathBuf } pub struct Launcher { online: bool, home: PathBuf, versions: VersionList, system_info: SystemInfo, libraries: LibraryRepository, assets: AssetRepository } #[derive(Debug)] pub enum LaunchError { UnknownInstance(String), // version resolution errors UnknownVersion(String), LoadVersion(Box), ResolveVersion(VersionResolveError), IncompatibleVersion(IncompatibleError), // library errors LibraryDirError(PathBuf, io::Error), LibraryVerifyError(FileVerifyError), LibraryDownloadError, LibraryExtractError(extract::ZipExtractError), LibraryClasspathError(JoinPathsError), // ensure file errors MissingURL, IO { what: &'static str, error: io::Error }, Offline, Download { url: String, error: reqwest::Error }, Integrity(IntegrityError), // log errors UnknownLogType(String), InvalidLogId(Option), // asset errors Assets(AssetError) } impl Display for LaunchError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self { LaunchError::UnknownInstance(inst) => write!(f, "unknown instance: {inst}"), 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::LibraryExtractError(e) => write!(f, "library extract zip error: {e}"), LaunchError::LibraryClasspathError(e) => write!(f, "error building classpath: {e}"), LaunchError::MissingURL => f.write_str("cannot download required file, URL is missing"), LaunchError::IO { what, error } => write!(f, "i/o error ({}): {}", what, error), LaunchError::Offline => f.write_str("cannot download file in offline mode"), LaunchError::Download { url, error } => write!(f, "failed to download file ({}): {}", url, error), LaunchError::Integrity(e) => write!(f, "file verify error: {}", e), LaunchError::UnknownLogType(t) => write!(f, "unknown log type: {}", t), LaunchError::InvalidLogId(Some(id)) => write!(f, "invalid log id: {}", id), LaunchError::InvalidLogId(None) => write!(f, "missing log id"), LaunchError::Assets(e) => write!(f, "failed to fetch assets: {}", 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::LibraryExtractError(e) => Some(e), LaunchError::LibraryClasspathError(e) => Some(e), LaunchError::IO { error: e, .. } => Some(e), LaunchError::Download { error: e, .. } => Some(e), LaunchError::Integrity(e) => Some(e), LaunchError::Assets(e) => Some(e), _ => None } } } pub struct Launch<'l> { launcher: &'l Launcher, asset_index_name: Option, classpath: String, virtual_assets_path: Option, instance_home: PathBuf, natives_path: PathBuf, client_jar: Option, version_id: String, version_type: Option, asset_index: Option, jvm_arguments: Vec, game_arguments: Vec, } impl Launcher { // FIXME: more descriptive error type por favor pub async fn new(home: impl AsRef, online: bool) -> Result> { match tokio::fs::create_dir_all(home.as_ref()).await { Err(e) if e.kind() != AlreadyExists => { warn!("Failed to create launcher home directory: {}", e); return Err(e.into()); }, _ => () } let home = fs::canonicalize(home.as_ref()).await?; let versions_home = home.join("versions"); let versions; 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 assets_path = home.join("assets"); Ok(Launcher { online, versions, system_info: SystemInfo::new(), libraries: LibraryRepository { home: home.join("libraries"), natives: home.join("natives") }, assets: AssetRepository::new(online, &assets_path).await?, home }) } fn choose_lib_classifier<'lib>(&self, lib: &'lib Library) -> Option<&'lib str> { lib.natives.as_ref().map_or(None, |n| n.get(&self.system_info.os)).map(|s| s.as_str()) } async fn ensure_file(&self, path: &Path, dlinfo: &DownloadInfo) -> Result<(), LaunchError> { // verify the file match util::verify_file(path, dlinfo.size, dlinfo.sha1).await { // integrity passed. return Ok(_) => { info!("File {} exists and integrity matches. Skipping.", path.display()); return Ok(()); }, // ruh roh Err(e) => match e { FileVerifyError::Open(_, ioe) if ioe.kind() != ErrorKind::NotFound => return Err(LaunchError::IO{ what: "verify file (open)", error: ioe }), FileVerifyError::Read(_, ioe) => return Err(LaunchError::IO{ what: "verify file (read)", error: ioe }), FileVerifyError::Integrity(_, ie) => info!("file {} failed integrity check: {}", path.display(), ie), _ => () } } if !self.online { warn!("Cannot download file {}! We are offline. Rerun the launcher in online mode to launch this version.", path.display()); return Err(LaunchError::Offline); } // download it let Some(url) = dlinfo.url.as_ref() else { return Err(LaunchError::MissingURL); }; let mut file = File::create(path).await.map_err(|e| LaunchError::IO { what: "save downloaded file (open)", error: e })?; debug!("File {} must be downloaded ({}).", path.display(), url); let mut response = reqwest::get(url).await.map_err(|e| LaunchError::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| LaunchError::Download{ url: url.to_owned(), error: e })? { let slice = chunk.as_ref(); file.write_all(slice).await.map_err(|e| LaunchError::IO { what: "save downloaded file (write)", error: e })?; tally += slice.len(); sha1.update(slice); } drop(file); // manually close file let del_file_silent = || async { debug!("Deleting downloaded file {} since its integrity doesn't match :(", path.display()); let _ = fs::remove_file(path).await.map_err(|e| warn!("failed to delete invalid downloaded file: {}", e)); () }; if dlinfo.size.is_some_and(|s| s != tally) { del_file_silent().await; return Err(LaunchError::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(LaunchError::Integrity(IntegrityError::Sha1Mismatch { expect: dlinfo.sha1.unwrap(), actual: digest })); } info!("File {} downloaded successfully.", path.display()); Ok(()) } async fn log_config_ensure(&self, config: &Logging) -> Result { info!("Ensuring log configuration exists and is valid."); if config.client.log_type != "log4j2-xml" { return Err(LaunchError::UnknownLogType(config.client.log_type.clone())); } let dlinfo = &config.client.file; let Some(id) = dlinfo.id.as_ref() else { return Err(LaunchError::InvalidLogId(None)); }; let mut path = self.home.join("logging"); fs::create_dir_all(path.as_path()).await .map_err(|e| LaunchError::IO{ what: "creating log directory", error: e })?; let Some(Component::Normal(filename)) = Path::new(id).components().last() else { return Err(LaunchError::InvalidLogId(Some(id.clone()))); }; path.push(filename); debug!("Logger config {} is at {}", id, path.display()); self.ensure_file(&path, dlinfo).await?; Ok(strsub::replace_string(config.client.argument.as_str(), |key| match key { "path" => Some(path.to_string_lossy()), _ => None }).to_string()) } pub async fn prepare_launch(&self, version_id: &ProfileVersion, instance: &Instance) -> Result, LaunchError> { /* tasks 2 l;aunch the gayme!!!! :3 * - java runtime * - normal process (good research, past figboot :3) * - (done) 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 * - (done) (if offline mode and there are libraries to download, then explode violently) * - (done) extract natives * - (done) logging * - (done) download the config if present and necessary * - (done) (explode if offline mode and we need to download stuff) * - (done) assets * - (done) get asset index (check if our local copy is good and redownload if not) * - (done) check what ones are good and what needs to be downloaded * - (done) download them * - (done) (if offline mode, explode) * - (done) if virtual or resource-mapped, copy (or perhaps hardlink? that would be cool) * - (done) the actual client jar * - (done) check integriddy and download if needed * - (done) (explode if offline mode) * - launch the game * - build argument list and whatnot also */ let Some(version_id) = self.versions.get_profile_version_id(version_id) else { // idk how common this use case actually is warn!("Can't use latest release/snapshot profiles while offline!"); return Err(LaunchError::UnknownVersion("".into())); }; info!("Preparing launch for \"{}\"...", version_id); let inst_home = instance.get_path(&self.home).await.map_err(|e| LaunchError::IO { what: "resolving instance directory", error: e })?; fs::create_dir_all(inst_home.as_path()).await.map_err(|e| LaunchError::IO { what: "creating instance directory", error: e })?; info!("Launching the game in {}", inst_home.display()); let ver_res = self.versions.get_version_lazy(version_id.as_ref()); 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(version_id.into_owned()).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))?; info!("Resolved launch version {}!", ver.id); let mut libs = Vec::new(); let mut extract_jobs = 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.choose_lib_classifier(lib)) { dl.make_dirs().await.map_err(|e| LaunchError::LibraryDirError(dl.get_path().to_path_buf(), e))?; if lib.natives.is_some() { extract_jobs.push(LibraryExtractJob { source: dl.get_path().to_owned(), rule: lib.extract.clone() }); } downloads.push(dl); } } if self.online { info!("Downloading {} libraries...", downloads.len()); let client = Client::new(); MultiDownloader::new(downloads.iter_mut()).perform(&client).await .inspect_err(|e| warn!("library download failed: {e}")) .try_fold((), |_, _| async {Ok(())}) .await .map_err(|_| LaunchError::LibraryDownloadError)?; } else { info!("Verifying {} libraries...", downloads.len()); download::verify_files(downloads.iter_mut()).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() { log_arg = Some(self.log_config_ensure(logging).await?); } else { log_arg = None; } dbg!(log_arg); // download assets let (asset_idx_name, asset_idx) = if let Some(idx_download) = ver.asset_index.as_ref() { let asset_idx_name = idx_download.id.as_ref().or(ver.assets.as_ref()).map(String::as_str); let asset_idx = self.assets.load_index(idx_download, asset_idx_name).await .map_err(|e| LaunchError::Assets(e))?; self.assets.ensure_assets(&asset_idx).await.map_err(|e| LaunchError::Assets(e))?; (asset_idx_name, Some(asset_idx)) } else { (None, None) }; // download client jar let client_jar_path; if let Some(client) = ver.downloads.get(&DownloadType::Client) { let mut client_path: PathBuf = [self.home.as_ref(), OsStr::new("versions"), OsStr::new(&ver.id)].iter().collect(); fs::create_dir_all(&client_path).await.map_err(|e| LaunchError::IO{ what: "creating client download directory", error: e })?; client_path.push(format!("{}.jar", ver.id)); info!("Downloading client jar {}", client_path.display()); self.ensure_file(client_path.as_path(), client).await?; client_jar_path = Some(client_path); } else { client_jar_path = None; } // clean up old natives let nnatives = self.libraries.clean_old_natives().await?; info!("Cleaned up {} old natives directories.", nnatives); // extract natives (execute this function unconditionally because we still need the natives dir to exist) info!("Extracting natives from libraries"); let natives_dir = self.libraries.extract_natives(extract_jobs).await?; let game_assets = if let Some(asset_idx) = asset_idx.as_ref() { info!("Reconstructing assets"); self.assets.reconstruct_assets(asset_idx, inst_home.as_path(), asset_idx_name).await .map_err(|e| LaunchError::Assets(e))? } else { None }; info!("Building classpath"); let classpath = env::join_paths(downloads.iter() .map(|job| job.get_path()) .chain(client_jar_path.iter().map(|p| p.as_path()))) .map_err(|e| LaunchError::LibraryClasspathError(e))? .into_string() .unwrap_or_else(|os| { warn!("Classpath contains invalid UTF-8. The game may not launch correctly."); os.to_string_lossy().to_string() }); info!("Classpath: {classpath}"); Ok(Launch { launcher: self, asset_index_name: asset_idx_name.map(|s| s.to_owned()), classpath, virtual_assets_path: game_assets, instance_home: inst_home, natives_path: natives_dir, client_jar: client_jar_path, version_id: ver.id.to_string(), version_type: ver.version_type.clone(), asset_index: asset_idx, jvm_arguments: Vec::new(), // TODO game_arguments: Vec::new(), // TODO }) } } #[derive(Debug)] enum LibraryError { InvalidName(String), IO { what: &'static str, error: io::Error } } impl Display for LibraryError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { LibraryError::InvalidName(name) => write!(f, "invalid name: {name}"), LibraryError::IO { what, error } => write!(f, "library i/o error ({what}): {error}"), } } } impl Error for LibraryError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { LibraryError::IO { error, .. } => Some(error), _ => None } } } #[derive(Debug)] struct LibraryExtractJob { source: PathBuf, rule: Option } 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() { 2 => Some(PathBuf::from(strsub::replace_string(format!("{}-{}-{}.jar", n[0], n[1], classifier).as_str(), Self::lib_replace).as_ref())), 3 => Some(PathBuf::from(strsub::replace_string(format!("{}-{}-{}-{}.jar", n[0], n[1], classifier, n[2]).as_str(), Self::lib_replace).as_ref())), _ => None } } else { match n.len() { 2 => Some(PathBuf::from(strsub::replace_string(format!("{}-{}.jar", n[0], n[1]).as_str(), Self::lib_replace).as_ref())), 3 => Some(PathBuf::from(strsub::replace_string(format!("{}-{}-{}.jar", n[0], n[1], n[2]).as_str(), Self::lib_replace).as_ref())), _ => 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, classifier: Option<&str>) -> Option { 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)) } async fn clean_old_natives(&self) -> Result { info!("Cleaning up old natives folders..."); let boot_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() - min(System::uptime(), 7u64*24*60*60); let readdir = match fs::read_dir(&self.natives).await { Ok(readdir) => readdir, Err(e) if e.kind() == ErrorKind::NotFound => return Ok(0), Err(e) => return Err(LaunchError::IO { what: "reading natives directory", error: e }) }; ReadDirStream::new(readdir) .map(|entry| Ok(async move { let entry = entry.map_err(|e| LaunchError::IO { what: "reading natives entry", error: e })?; let ftype = entry.file_type().await.map_err(|e| LaunchError::IO { what: "'stat'ing natives entry", error: e })?; if !ftype.is_dir() { return Ok(false); } let Some(ftime) = entry.file_name().to_str() .map_or(None, |s| constants::NATIVES_DIR_PATTERN.captures(s)) .map_or(None, |c| c.get(1)) .map_or(None, |cap| cap.as_str().parse::().ok()) else { return Ok(false); }; if ftime < boot_time { let path = entry.path(); info!("Deleting old natives directory {}", path.display()); fs::remove_dir_all(&path).await.map_err(|e| LaunchError::IO { what: "reading natives entry", error: e })?; return Ok(true); } Ok(false) })) .try_buffer_unordered(8) .try_fold(0usize, |accum, res| async move { match res { true => Ok(accum + 1), _ => Ok(accum) } }).await } async fn extract_natives<'lib>(&self, libs: Vec) -> Result { let libs = libs; fs::create_dir_all(&self.natives).await.map_err(|e| LaunchError::IO { what: "creating natives directory", error: e })?; let time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let natives_dir = self.natives.join(format!("{}{}-{}", constants::NATIVES_PREFIX, time, process::id())); // create_dir_all suppresses "AlreadyExists", but this is a fatal error here. fs::create_dir(&natives_dir).await.map_err(|e| LaunchError::IO { what: "creating natives directory", error: e })?; let (path_again, extracted) = tokio::task::spawn_blocking(move || { let mut tally = 0usize; for job in libs { debug!("Extracting natives for {}", job.source.display()); tally += extract::extract_zip(&job.source, &natives_dir, |name| job.rule.as_ref().is_some_and(|rules| rules.exclude.iter().filter(|ex| name.starts_with(ex.as_str())).next().is_none()))?; } Ok((natives_dir, tally)) }).await.unwrap().map_err(LaunchError::LibraryExtractError)?; info!("Done extracting natives! Copied {} files.", extracted); Ok(path_again) } } 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)) } }