diff --git a/maxima-bootstrap/src/main.rs b/maxima-bootstrap/src/main.rs index 9cd16c2f..0429789d 100644 --- a/maxima-bootstrap/src/main.rs +++ b/maxima-bootstrap/src/main.rs @@ -132,6 +132,7 @@ async fn platform_launch(args: BootstrapLaunchArgs) -> Result<(), NativeError> { None, false, CommandType::WaitForExitAndRun, + Some(&args.slug), ) .await?; diff --git a/maxima-cli/src/main.rs b/maxima-cli/src/main.rs index de2ce960..325ce18a 100644 --- a/maxima-cli/src/main.rs +++ b/maxima-cli/src/main.rs @@ -32,8 +32,6 @@ use maxima::{ clients::JUNO_PC_CLIENT_ID, cloudsync::CloudSyncLockMode, launch::{self, LaunchMode, LaunchOptions}, - library::OwnedTitle, - manifest::{self, MANIFEST_RELATIVE_PATH}, service_layer::{ ServiceGetBasicPlayerRequestBuilder, ServiceGetLegacyCatalogDefsRequestBuilder, ServiceLegacyOffer, ServicePlayer, SERVICE_REQUEST_GETBASICPLAYER, @@ -43,7 +41,11 @@ use maxima::{ }, ooa, rtm::client::BasicPresence, - util::{log::init_logger, native::take_foreground_focus, registry::check_registry_validity}, + util::{ + log::init_logger, + native::{maxima_dir, take_foreground_focus}, + registry::check_registry_validity, + }, }; lazy_static! { @@ -70,6 +72,7 @@ enum Mode { ListGames, LocateGame { path: String, + slug: String, }, CloudSync { game_slug: String, @@ -295,13 +298,21 @@ async fn startup() -> Result<()> { } } } else { - slug + slug.clone() }; - start_game(&offer_id, game_path, game_args, login, maxima_arc.clone()).await + start_game( + &offer_id, + &slug, + game_path, + game_args, + login, + maxima_arc.clone(), + ) + .await } Mode::ListGames => list_games(maxima_arc.clone()).await, - Mode::LocateGame { path } => locate_game(maxima_arc.clone(), &path).await, + Mode::LocateGame { path, slug } => locate_game(maxima_arc.clone(), &path, &slug).await, Mode::CloudSync { game_slug, write } => { do_cloud_sync(maxima_arc.clone(), &game_slug, write).await } @@ -355,10 +366,9 @@ async fn run_interactive(maxima_arc: LockedMaxima) -> Result<()> { } async fn interactive_start_game(maxima_arc: LockedMaxima) -> Result<()> { - let offer_id = { - let mut maxima = maxima_arc.lock().await; - - let mut owned_games = Vec::new(); + let mut maxima = maxima_arc.lock().await; + let mut owned_games = Vec::new(); + let owned_games_strs = { for game in maxima.mut_library().games().await? { if !game.base_offer().is_installed().await { continue; @@ -367,17 +377,19 @@ async fn interactive_start_game(maxima_arc: LockedMaxima) -> Result<()> { owned_games.push(game); } - let owned_games_strs = owned_games + owned_games .iter() .map(|g| g.name()) - .collect::>(); - - let name = Select::new("What game would you like to play?", owned_games_strs).prompt()?; - let game: &OwnedTitle = owned_games.iter().find(|g| g.name() == name).unwrap(); - game.base_offer().offer_id().to_owned() + .collect::>() }; - start_game(&offer_id, None, Vec::new(), None, maxima_arc.clone()).await?; + let name = Select::new("What game would you like to play?", owned_games_strs).prompt()?; + let game = owned_games.iter().find(|g| g.name() == name).unwrap(); + + let offer_id = game.base_offer().offer_id().to_owned().clone(); + let slug = game.base_offer().slug().to_owned().clone(); + drop(maxima); // To unlock before starting the game, since start_game will also need to lock + start_game(&offer_id, &slug, None, Vec::new(), None, maxima_arc.clone()).await?; Ok(()) } @@ -385,7 +397,7 @@ async fn interactive_start_game(maxima_arc: LockedMaxima) -> Result<()> { async fn interactive_install_game(maxima_arc: LockedMaxima) -> Result<()> { let mut maxima = maxima_arc.lock().await; - let offer_id = { + let game = { let mut owned_games = Vec::new(); for game in maxima.mut_library().games().await? { if game.base_offer().is_installed().await { @@ -402,10 +414,16 @@ async fn interactive_install_game(maxima_arc: LockedMaxima) -> Result<()> { let name = Select::new("What game would you like to install?", owned_games_strs).prompt()?; - let game = owned_games.iter().find(|g| g.name() == name).unwrap(); - game.base_offer().offer_id().to_owned() + owned_games + .iter() + .find(|g| g.name() == name) + .unwrap() + .clone() }; + let offer_id = game.base_offer().offer_id().to_owned(); + let slug = game.base_offer().slug().to_owned(); + let builds = maxima .content_manager() .service() @@ -423,6 +441,18 @@ async fn interactive_install_game(maxima_arc: LockedMaxima) -> Result<()> { Text::new("Where would you like to install the game? (must be an absolute path)") .prompt()?, ); + + #[cfg(unix)] + let wine_prefix = { + let input = + Text::new("Where do you want to store the Wine prefix? (must be an absolute path)") + .prompt()?; + PathBuf::from(input) + }; + + #[cfg(not(unix))] + let wine_prefix = PathBuf::new(); + if !path.is_absolute() { error!("Path {:?} is not absolute.", path); return Ok(()); @@ -432,6 +462,8 @@ async fn interactive_install_game(maxima_arc: LockedMaxima) -> Result<()> { .offer_id(offer_id) .build_id(build.build_id().to_owned()) .path(path.clone()) + .slug(slug) // Needs the slug here for the manifest touchup after installation, which needs to know the wine prefix path + .wine_prefix(Some(wine_prefix)) .build()?; let start_time = Instant::now(); @@ -622,7 +654,7 @@ async fn juno_token_refresh(maxima_arc: LockedMaxima) -> Result<()> { } async fn read_license_file(content_id: &str) -> Result<()> { - let path = ooa::get_license_dir()?.join(format!("{}.dlf", content_id)); + let path = ooa::get_license_dir(None)?.join(format!("{}.dlf", content_id)); let mut data = tokio::fs::read(path).await?; data.drain(0..65); // Signature @@ -666,8 +698,8 @@ async fn get_user_by_id(maxima_arc: LockedMaxima, user_id: &str) -> Result<()> { Ok(()) } -async fn get_game_by_slug(maxima_arc: LockedMaxima, slug: &str) -> Result<()> { - let maxima = maxima_arc.lock().await; +async fn get_game_by_slug(maxima_arc: LockedMaxima, _slug: &str) -> Result<()> { + let _maxima = maxima_arc.lock().await; // match maxima.owned_game_by_slug(slug).await { // Ok(game) => info!("Game: {}", game.id()), @@ -768,10 +800,8 @@ async fn list_games(maxima_arc: LockedMaxima) -> Result<()> { Ok(()) } -async fn locate_game(maxima_arc: LockedMaxima, path: &str) -> Result<()> { +async fn locate_game(maxima_arc: LockedMaxima, path: &str, slug: &str) -> Result<()> { let path = PathBuf::from(path); - let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await?; - manifest.run_touchup(&path).await?; info!("Installed!"); Ok(()) } @@ -809,6 +839,7 @@ async fn do_cloud_sync(maxima_arc: LockedMaxima, game_slug: &str, write: bool) - async fn start_game( offer_id: &str, + slug: &str, game_path_override: Option, game_args: Vec, login: Option, diff --git a/maxima-lib/Cargo.toml b/maxima-lib/Cargo.toml index 9f3a6e77..07a30d7a 100644 --- a/maxima-lib/Cargo.toml +++ b/maxima-lib/Cargo.toml @@ -76,6 +76,7 @@ gethostname = "0.5.0" thiserror = "2.0.12" url = "2.5.2" http = "0.2.12" +globset = "0.4" [target.'cfg(windows)'.dependencies] winapi = { version = "0.3.9", features = [ diff --git a/maxima-lib/src/content/downloader.rs b/maxima-lib/src/content/downloader.rs index e982a190..6f93ad06 100644 --- a/maxima-lib/src/content/downloader.rs +++ b/maxima-lib/src/content/downloader.rs @@ -25,7 +25,7 @@ use bytes::{Buf, BufMut, Bytes, BytesMut}; use derive_getters::Getters; use flate2::bufread::DeflateDecoder as BufreadDeflateDecoder; use futures::{Stream, StreamExt, TryStreamExt}; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use reqwest::Client; use strum_macros::Display; use thiserror::Error; diff --git a/maxima-lib/src/content/exclusion.rs b/maxima-lib/src/content/exclusion.rs new file mode 100644 index 00000000..a47f6d5a --- /dev/null +++ b/maxima-lib/src/content/exclusion.rs @@ -0,0 +1,38 @@ +use crate::util::native::maxima_dir; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use log::{error, info, warn}; +use std::fs::File; +use std::io::{BufRead, BufReader}; + +pub fn get_exclusion_list(slug: &str) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + + if let Ok(dir) = maxima_dir() + // Checks to make sure maxima directory exists + { + let filepath = dir.join("exclude").join(&slug); // Path to exclusion file + info!("Loading exclusion file from {}", filepath.display()); + + if let Ok(file) = File::open(&filepath) { + let reader = BufReader::new(file); + for line in reader.lines().flatten() { + let entry = line.trim(); + if !entry.is_empty() && !entry.starts_with('#') { + if let Ok(g) = Glob::new(entry) { + builder.add(g); + } else { + warn!("Invalid glob pattern '{}' in {}", entry, filepath.display()); + } + } + } + } else { + warn!("Exclusion file not found: {}", filepath.display()); + } + } else { + error!("Failed to resolve maxima data directory"); + } + + builder + .build() + .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap()) // Returns an empty GlobSet on failure +} diff --git a/maxima-lib/src/content/manager.rs b/maxima-lib/src/content/manager.rs index f16116db..7cbc64a3 100644 --- a/maxima-lib/src/content/manager.rs +++ b/maxima-lib/src/content/manager.rs @@ -19,6 +19,7 @@ use tokio_util::sync::CancellationToken; use crate::{ content::{ downloader::{DownloadError, ZipDownloader}, + exclusion::get_exclusion_list, zip::{self, CompressionType, ZipError, ZipFileEntry}, ContentService, }, @@ -28,6 +29,7 @@ use crate::{ service_layer::ServiceLayerError, MaximaEvent, }, + gameinfo::GameInstallInfo, util::native::{maxima_dir, NativeError}, }; @@ -38,6 +40,8 @@ pub struct QueuedGame { offer_id: String, build_id: String, path: PathBuf, + slug: String, + wine_prefix: Option, } #[derive(Default, Getters, Serialize, Deserialize)] @@ -124,6 +128,9 @@ impl DownloadQueue { pub struct GameDownloader { offer_id: String, + slug: String, + path: PathBuf, + wine_prefix: Option, downloader: Arc, entries: Vec, @@ -149,8 +156,15 @@ impl GameDownloader { let downloader = ZipDownloader::new(&game.offer_id, &url.url(), &game.path).await?; let mut entries = Vec::new(); + + let exclusion_list = get_exclusion_list(game.slug.as_str()); + for ele in downloader.manifest().entries() { // TODO: Filtering + if exclusion_list.is_match(&ele.name()) { + // info!("Excluding file from download: {}", ele.name()); Spams if a lot of files are excluded + continue; + } entries.push(ele.clone()); } @@ -163,7 +177,9 @@ impl GameDownloader { Ok(GameDownloader { offer_id: game.offer_id.to_owned(), - + slug: game.slug.to_owned(), + path: game.path.to_owned(), + wine_prefix: game.wine_prefix.clone(), downloader: Arc::new(downloader), entries, cancel_token: CancellationToken::new(), @@ -178,6 +194,8 @@ impl GameDownloader { let (downloader_arc, entries, cancel_token, completed_bytes, notify) = self.prepare_download_vars(); let total_count = self.total_count; + let slug = self.slug.clone(); + let game_install_info = GameInstallInfo::new(self.path.clone(), self.wine_prefix.clone()); tokio::spawn(async move { let dl = GameDownloader::start_downloads( total_count, @@ -186,6 +204,8 @@ impl GameDownloader { cancel_token, completed_bytes, notify, + slug, + game_install_info, ) .await; if let Err(err) = dl { @@ -219,6 +239,8 @@ impl GameDownloader { cancel_token: CancellationToken, completed_bytes: Arc, notify: Arc, + slug: String, + game_install_info: GameInstallInfo, ) -> Result<(), DownloaderError> { let mut handles = Vec::with_capacity(total_count); @@ -256,10 +278,17 @@ impl GameDownloader { let path = downloader_arc.path(); - info!("Files downloaded, running touchup..."); - let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await?; + game_install_info.save_to_json(&slug); + info!("Files downloaded"); + + #[cfg(windows)] + // Touchup will be run on linux/mac when first running the game, so we don't need to run it here + { + info!("Running touchup..."); + let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await?; + manifest.run_touchup(path, &slug).await?; + } - manifest.run_touchup(path).await?; info!("Installation finished!"); completed_bytes.fetch_add(1, Ordering::SeqCst); diff --git a/maxima-lib/src/content/mod.rs b/maxima-lib/src/content/mod.rs index 4a98e432..252950d8 100644 --- a/maxima-lib/src/content/mod.rs +++ b/maxima-lib/src/content/mod.rs @@ -12,6 +12,7 @@ use crate::core::{ }; pub mod downloader; +pub mod exclusion; pub mod manager; pub mod zip; pub mod zlib; diff --git a/maxima-lib/src/core/auth/hardware.rs b/maxima-lib/src/core/auth/hardware.rs index c11df25d..0ca93fee 100644 --- a/maxima-lib/src/core/auth/hardware.rs +++ b/maxima-lib/src/core/auth/hardware.rs @@ -53,7 +53,7 @@ pub enum HardwareHashError { impl HardwareInfo { #[cfg(windows)] - pub fn new(version: u32) -> Self { + pub fn new(version: u32, _slug: Option<&str>) -> Self { use std::collections::HashMap; use log::warn; @@ -149,7 +149,7 @@ impl HardwareInfo { } #[cfg(target_os = "linux")] - pub fn new(version: u32) -> Self { + pub fn new(version: u32, slug: Option<&str>) -> Self { use std::{fs, path::Path, process::Command}; let board_manufacturer = match fs::read_to_string("/sys/class/dmi/id/board_vendor") { @@ -167,7 +167,7 @@ impl HardwareInfo { }; let bios_sn = String::from("Serial number"); - let os_install_date = get_root_creation_str(); + let os_install_date = get_root_creation_str(slug); let os_sn = String::from("00330-50000-00000-AAOEM"); let mut gpu_pnp_id: Option = None; @@ -246,7 +246,7 @@ impl HardwareInfo { } #[cfg(target_os = "macos")] - pub fn new(version: u32) -> Self { + pub fn new(version: u32, slug: Option<&str>) -> Self { use std::process::Command; use smbioslib::{ @@ -273,7 +273,7 @@ impl HardwareInfo { bios_sn = bios.serial_number().to_string(); } - let os_install_date = get_root_creation_str(); + let os_install_date = get_root_creation_str(slug); let mut os_sn = String::from("None"); if let Some(uuid) = bios_data.and_then(|bios| bios.uuid()) { os_sn = uuid.to_string(); @@ -282,7 +282,8 @@ impl HardwareInfo { let mut gpu_pnp_id: Option = None; let output = Command::new("system_profiler") .args(["SPDisplaysDataType", "-json"]) - .output().unwrap(); + .output() + .unwrap(); if output.status.success() { let json = String::from_utf8_lossy(&output.stdout); let result: SPDisplaysDataType = serde_json::from_str(&json).unwrap(); @@ -298,7 +299,10 @@ impl HardwareInfo { } let mut disk_sn = String::from("None"); - let output = Command::new("diskutil").args(["info", "/"]).output().unwrap(); + let output = Command::new("diskutil") + .args(["info", "/"]) + .output() + .unwrap(); // Check if the command was successful if output.status.success() { // Convert the output bytes to a UTF-8 string @@ -472,23 +476,20 @@ impl HardwareInfo { } #[cfg(unix)] -fn get_root_creation_str() -> String { +fn get_root_creation_str(slug: Option<&str>) -> String { use crate::unix::wine::wine_prefix_dir; use chrono::{TimeZone, Utc}; use std::{fs, os::unix::fs::MetadataExt}; let date_str = String::from("1970010100:00:00.000000000+0000"); - let wine_prefix = wine_prefix_dir(); - if wine_prefix.is_err() { - return date_str; - } - let wine_prefix = wine_prefix.unwrap(); + let wine_prefix = match slug { + Some(slug) => wine_prefix_dir(Some(slug)).unwrap(), + None => return date_str, + }; let date_str = match fs::metadata(wine_prefix.join("drive_c")) { Ok(metadata) => { let nsec = (metadata.mtime_nsec() / 1_000_000) * 1_000_000; - // Convert Unix timestamp to a DateTime let datetime = Utc.timestamp_nanos((metadata.mtime() * 1_000_000_000) + nsec); - // Format the DateTime return datetime.format("%Y%m%d%H%M%S%.6f+000").to_string(); } Err(_) => date_str, diff --git a/maxima-lib/src/core/auth/pc_sign.rs b/maxima-lib/src/core/auth/pc_sign.rs index 6c3bc134..cdc86147 100644 --- a/maxima-lib/src/core/auth/pc_sign.rs +++ b/maxima-lib/src/core/auth/pc_sign.rs @@ -34,7 +34,7 @@ pub struct PCSign<'a> { impl PCSign<'_> { pub fn new() -> Result { - let hw_info = HardwareInfo::new(1); + let hw_info = HardwareInfo::new(1, None); let timestamp = Utc::now(); let formatted_timestamp = timestamp.format("%Y-%m-%d %H:%M:%S:%3f"); diff --git a/maxima-lib/src/core/background_service_nix.rs b/maxima-lib/src/core/background_service_nix.rs index d383350c..b169346c 100644 --- a/maxima-lib/src/core/background_service_nix.rs +++ b/maxima-lib/src/core/background_service_nix.rs @@ -25,7 +25,11 @@ pub struct WineInjectArgs { pub path: String, } -pub async fn wine_get_pid(launch_id: &str, name: &str) -> Result { +pub async fn wine_get_pid( + launch_id: &str, + name: &str, + slug: Option<&str>, +) -> Result { debug!("Searching for wine PID for {}", name); let launch_args = WineGetPidArgs { @@ -43,6 +47,7 @@ pub async fn wine_get_pid(launch_id: &str, name: &str) -> Result Result Result<(), NativeError> { +pub async fn request_library_injection( + pid: u32, + path: &str, + slug: Option<&str>, +) -> Result<(), NativeError> { debug!("Injecting {}", path); let launch_args = WineInjectArgs { @@ -81,6 +90,7 @@ pub async fn request_library_injection(pid: u32, path: &str) -> Result<(), Nativ None, false, CommandType::RunInPrefix, + slug, ) .await?; diff --git a/maxima-lib/src/core/cloudsync.rs b/maxima-lib/src/core/cloudsync.rs index 41e4cd75..54f0db7f 100644 --- a/maxima-lib/src/core/cloudsync.rs +++ b/maxima-lib/src/core/cloudsync.rs @@ -13,7 +13,6 @@ /// - Call `/lock/authorize` with a `Vec`, creating details that match the file and keeping track of them for later /// - Push the files to the endpoints, along with a manifest outlining the files you uploaded and/or that are already there. /// - Call `/lock/delete` - use super::{ auth::storage::LockedAuthStorage, endpoints::API_CLOUDSYNC, launch::LaunchMode, library::OwnedOffer, @@ -99,27 +98,29 @@ async fn acquire_auth(auth: &LockedAuthStorage) -> Result<(String, String), Clou } #[cfg(windows)] -fn home_dir() -> Result { +fn home_dir(_slug: Option<&str>) -> Result { Ok(PathBuf::from( std::env::var_os("USERPROFILE").unwrap_or_else(|| "C:\\Users\\Public".into()), )) } #[cfg(unix)] -fn home_dir() -> Result { +fn home_dir(slug: Option<&str>) -> Result { use crate::unix::wine::wine_prefix_dir; - Ok(wine_prefix_dir()?.join("drive_c/users/steamuser")) + Ok(wine_prefix_dir(slug) + .unwrap() + .join("drive_c/users/steamuser")) } -fn substitute_paths>(path: P) -> Result { +fn substitute_paths>(path: P, slug: Option<&str>) -> Result { let mut result = PathBuf::new(); let path_str = path.as_ref(); if path_str.contains("%Documents%") { - let path = home_dir()?.join("Documents"); + let path = home_dir(slug)?.join("Documents"); result.push(path_str.replace("%Documents%", path.to_str().unwrap_or_default())); } else if path_str.contains("%SavedGames%") { - let path = home_dir()?.join("Saved Games"); + let path = home_dir(slug)?.join("Saved Games"); result.push(path_str.replace("%SavedGames%", path.to_str().unwrap_or_default())); } else { result.push(path_str); @@ -128,9 +129,9 @@ fn substitute_paths>(path: P) -> Result { Ok(result) } -fn unsubstitute_paths>(path: P) -> Result { +fn unsubstitute_paths>(path: P, slug: Option<&str>) -> Result { let path = path.as_ref(); - let home = home_dir()?; + let home = home_dir(slug)?; let documents_path = home.join("Documents"); let saved_games_path = home.join("Saved Games"); @@ -197,6 +198,7 @@ pub struct CloudSyncLock<'a> { manifest: CloudSyncManifest, mode: CloudSyncLockMode, allowed_files: Vec, + slug: String, } impl<'a> CloudSyncLock<'a> { @@ -207,6 +209,7 @@ impl<'a> CloudSyncLock<'a> { lock: String, mode: CloudSyncLockMode, allowed_files: Vec, + slug: &str, ) -> Result { let res = client.get(manifest_url).send().await?; @@ -238,6 +241,7 @@ impl<'a> CloudSyncLock<'a> { manifest, mode, allowed_files, + slug: slug.to_owned(), }) } @@ -274,7 +278,7 @@ impl<'a> CloudSyncLock<'a> { let mut paths = HashMap::new(); for i in 0..self.manifest.file.len() { let local_path = &self.manifest.file[i].local_name; - let path = substitute_paths(local_path)?; + let path = substitute_paths(local_path, Some(&self.slug))?; let file = OpenOptions::new().read(true).open(path.clone()).await; @@ -410,7 +414,7 @@ impl<'a> CloudSyncLock<'a> { continue; } - let name = unsubstitute_paths(&path)?; + let name = unsubstitute_paths(&path, Some(&self.slug))?; let write_data = WriteData::File { name, file, @@ -506,7 +510,7 @@ impl<'a> CloudSyncLock<'a> { len as u64 } - WriteData::Text {text, .. } => { + WriteData::Text { text, .. } => { req = req.body(text.to_owned()); text.len() as u64 } @@ -559,11 +563,12 @@ impl CloudSyncClient { offer.offer().multiplayer_id().as_ref().unwrap() ); + let slug = offer.slug().to_string(); let mut allowed_files = Vec::new(); if let Some(config) = offer.offer().cloud_save_configuration_override() { let criteria: CloudSyncSaveFileCriteria = quick_xml::de::from_str(config)?; for include in criteria.include { - let path = substitute_paths(include.value)?; + let path = substitute_paths(include.value, Some(&slug))?; let paths = glob::glob(path.safe_str()?)?; for path in paths { let path = path?; @@ -578,7 +583,9 @@ impl CloudSyncClient { return Err(CloudSyncError::NoConfig(offer.offer_id().clone())); } - Ok(self.obtain_lock_raw(&id, mode, allowed_files).await?) + Ok(self + .obtain_lock_raw(&id, mode, allowed_files, &slug) + .await?) } pub async fn obtain_lock_raw<'a>( @@ -586,6 +593,7 @@ impl CloudSyncClient { id: &str, mode: CloudSyncLockMode, allowed_files: Vec, + slug: &str, ) -> Result { let (token, user_id) = acquire_auth(&self.auth).await?; @@ -613,6 +621,7 @@ impl CloudSyncClient { lock, mode, allowed_files, + slug, ) .await?) } diff --git a/maxima-lib/src/core/launch.rs b/maxima-lib/src/core/launch.rs index 6281672c..b68dfe9f 100644 --- a/maxima-lib/src/core/launch.rs +++ b/maxima-lib/src/core/launch.rs @@ -31,7 +31,11 @@ use crate::{ use thiserror::Error; #[cfg(unix)] -use crate::unix::fs::case_insensitive_path; +use crate::{ + core::manifest::{self, MANIFEST_RELATIVE_PATH}, + gameinfo::load_game_info_from_json, + unix::fs::case_insensitive_path, +}; use serde::{Deserialize, Serialize}; @@ -114,6 +118,7 @@ pub struct ActiveGameContext { launch_id: String, game_path: String, content_id: String, + slug: Option, offer: Option, mode: LaunchMode, injections: Vec, @@ -131,11 +136,13 @@ impl ActiveGameContext { offer: Option, mode: LaunchMode, process: Child, + slug: Option, ) -> Self { Self { launch_id: launch_id.to_owned(), game_path: game_path.to_owned(), content_id: content_id.to_owned(), + slug, offer, mode, injections: Vec::new(), @@ -158,6 +165,7 @@ impl ActiveGameContext { pub struct BootstrapLaunchArgs { pub path: String, pub args: Vec, + pub slug: String, } impl Display for LaunchMode { @@ -232,8 +240,14 @@ pub async fn start_game( let path = path.safe_str()?; info!("Game path: {}", path); + let slug = if let LaunchMode::Online(ref _offer_id) = mode { + offer.as_ref().map(|o| o.slug().to_owned()) + } else { + None + }; + #[cfg(unix)] - mx_linux_setup().await?; + mx_linux_setup(slug.as_deref()).await?; match mode { LaunchMode::Offline(_) => {} @@ -241,13 +255,19 @@ pub async fn start_game( let auth = LicenseAuth::AccessToken(maxima.access_token().await?); let offer = offer.as_ref().unwrap(); - if needs_license_update(&content_id).await? { + if needs_license_update(&content_id, slug.as_deref()).await? { info!( "Requesting new game license for {}...", offer.offer().display_name() ); - request_and_save_license(&auth, &content_id, path.to_owned().into()).await?; + request_and_save_license( + &auth, + &content_id, + path.to_owned().into(), + slug.as_deref(), + ) + .await?; } else { info!("Existing game license is still valid, not updating"); } @@ -278,8 +298,14 @@ pub async fn start_game( LaunchMode::OnlineOffline(_, ref persona, ref password) => { let auth = LicenseAuth::Direct(persona.to_owned(), password.to_owned()); - if needs_license_update(&content_id).await? { - request_and_save_license(&auth, &content_id, path.to_owned().into()).await?; + if needs_license_update(&content_id, slug.as_deref()).await? { + request_and_save_license( + &auth, + &content_id, + path.to_owned().into(), + slug.as_deref(), + ) + .await?; } else { info!("Existing game license is still valid, not updating"); } @@ -303,6 +329,7 @@ pub async fn start_game( let bootstrap_args = BootstrapLaunchArgs { path: path.to_string(), args: game_args, + slug: slug.clone().unwrap_or_default(), }; let b64 = general_purpose::STANDARD.encode(serde_json::to_string(&bootstrap_args)?); @@ -373,6 +400,7 @@ pub async fn start_game( offer, mode, child, + slug, )); Ok(()) @@ -399,12 +427,13 @@ async fn request_opaque_ooa_token(access_token: &str) -> Result Result<(), NativeError> { +pub async fn mx_linux_setup(slug: Option<&str>) -> Result<(), NativeError> { use crate::unix::wine::{ check_runtime_validity, check_wine_validity, get_lutris_runtimes, install_runtime, - install_wine, setup_wine_registry, + install_wine, setup_wine_registry, wine_prefix_dir, }; + std::fs::create_dir_all(wine_prefix_dir(slug).unwrap())?; info!("Verifying wine dependencies..."); let skip = std::env::var("MAXIMA_DISABLE_WINE_VERIFICATION").is_ok(); @@ -421,7 +450,13 @@ pub async fn mx_linux_setup() -> Result<(), NativeError> { } } - setup_wine_registry().await?; + setup_wine_registry(slug).await?; + let install_path = load_game_info_from_json(slug.unwrap()).unwrap().path; + let manifest = manifest::read(install_path.join(MANIFEST_RELATIVE_PATH)) + .await + .unwrap(); + + let _result = manifest.run_touchup(&install_path, slug.unwrap()).await; Ok(()) } diff --git a/maxima-lib/src/core/library.rs b/maxima-lib/src/core/library.rs index faa730cc..b8a09c3a 100644 --- a/maxima-lib/src/core/library.rs +++ b/maxima-lib/src/core/library.rs @@ -11,14 +11,19 @@ use super::{ SERVICE_REQUEST_GETPRELOADEDOWNEDGAMES, }, }; -#[cfg(unix)] -use crate::unix::fs::case_insensitive_path; -use crate::util::native::{NativeError, SafeStr}; -use crate::util::registry::{parse_partial_registry_path, parse_registry_path, RegistryError}; +use crate::util::registry::{parse_registry_path_json, RegistryError}; +use crate::{ + gameinfo::load_game_info_from_json, + util::native::{maxima_dir, NativeError, SafeStr}, +}; use derive_getters::Getters; +use log::info; use std::{collections::HashMap, path::PathBuf, time::SystemTimeError}; use thiserror::Error; +#[cfg(windows)] +use winapi::shared::cfg; + #[derive(Error, Debug)] pub enum LibraryError { #[error(transparent)] @@ -54,32 +59,55 @@ pub struct OwnedOffer { } impl OwnedOffer { - pub async fn is_installed(&self) -> bool { - // I would love to throw an error here but that's just not feasible. - // If you can't grab the path it may as well not be installed. - let Some(path) = &self.offer.install_check_override().as_ref() else { - return false; + #[cfg(windows)] + pub async fn check_install_win_registry(&self) -> bool { + use crate::util::registry::parse_registry_path_regkey; + + let path = match self.offer.install_check_override() { + Some(path) => path, + None => return false, }; - let path = match parse_registry_path(path).await { - Ok(path) => path, + if let Ok(manifest_path) = parse_registry_path_regkey(path).await { + use crate::gameinfo::GameInstallInfo; + + let gamedir = manifest_path.ancestors().nth(2).unwrap().to_path_buf(); // Strip off the manifest and just leave the game directory + let game_install_info: GameInstallInfo = GameInstallInfo::new(gamedir, None); + game_install_info.save_to_json(&self.slug); + manifest_path.exists() + } else { + false + } + } + + pub async fn is_installed(&self) -> bool { + let maxima_dir = match maxima_dir() { + Ok(dir) => dir, Err(_) => return false, }; - // If it wasn't replaced... - if path.starts_with("[") { - return false; + + let game_info_path = maxima_dir + .join("gameinfo") + .join(format!("{}.json", &self.slug)); + + #[cfg(windows)] + match game_info_path.exists() { + true => return true, + false => return self.check_install_win_registry().await, } - #[cfg(unix)] - let path = case_insensitive_path(path); - path.exists() + + #[cfg(not(windows))] + game_info_path.exists() } + // This is unused pub async fn install_check_path(&self) -> Result { - Ok(parse_registry_path( + Ok(parse_registry_path_json( &self .offer .install_check_override() .as_ref() .ok_or(ManifestError::NoInstallPath(self.slug.clone()))?, + Some(&self.slug), ) .await? .safe_str()? @@ -99,7 +127,7 @@ impl OwnedOffer { }; if let Some(path) = path { - Ok(parse_registry_path(path).await?) + Ok(parse_registry_path_json(path, Some(&self.slug)).await?) } else { Err(LibraryError::NoPath(self.slug.clone())) } @@ -123,34 +151,17 @@ impl OwnedOffer { } pub async fn local_manifest(&self) -> Result>, ManifestError> { - let path = if self - .offer - .install_check_override() - .as_ref() - .ok_or(ManifestError::NoInstallPath(self.slug.clone()))? - .contains("installerdata.xml") - { - let ic_path = PathBuf::from(self.install_check_path().await?); - #[cfg(unix)] - let ic_path = case_insensitive_path(ic_path); - ic_path - } else { - let path = PathBuf::from( - parse_partial_registry_path( - &self - .offer - .install_check_override() - .as_ref() - .ok_or(ManifestError::NoInstallPath(self.slug.clone()))?, - ) - .await? - .safe_str()? - .to_owned(), - ); - - path.join(MANIFEST_RELATIVE_PATH) + info!("Checking local manifest for `{}`", self.slug); + let game_install_info = match load_game_info_from_json(&self.slug) { + Ok(info) => info, + Err(_) => return Ok(None), // No info file yet, placeholder for now }; + let path = game_install_info.path().join(MANIFEST_RELATIVE_PATH); + info!("Checking for manifest at `{}`", path.display()); + if !path.exists() { + return Ok(None); + } Ok(Some(manifest::read(path).await?)) } diff --git a/maxima-lib/src/core/manifest/dip.rs b/maxima-lib/src/core/manifest/dip.rs index b8da5ceb..76098163 100644 --- a/maxima-lib/src/core/manifest/dip.rs +++ b/maxima-lib/src/core/manifest/dip.rs @@ -191,31 +191,34 @@ impl DiPManifest { } #[cfg(unix)] - pub async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError> { - use crate::{ - core::launch::mx_linux_setup, - unix::{ - fs::case_insensitive_path, - wine::{invalidate_mx_wine_registry, run_wine_command, CommandType}, - }, + pub async fn run_touchup( + &self, + install_path: &PathBuf, + slug: &str, + ) -> Result<(), ManifestError> { + use crate::unix::{ + fs::case_insensitive_path, + wine::{invalidate_mx_wine_registry, run_wine_command, CommandType}, }; - mx_linux_setup().await?; - let install_path = PathBuf::from(remove_trailing_slash( install_path.to_str().ok_or(ManifestError::Decode)?, )); let args = self.collect_touchup_args(&install_path)?; let path = install_path.join(&self.touchup.path()); let path = case_insensitive_path(path); - run_wine_command(path, Some(args), None, true, CommandType::Run).await?; + run_wine_command(path, Some(args), None, true, CommandType::Run, Some(slug)).await?; invalidate_mx_wine_registry().await; Ok(()) } #[cfg(windows)] - pub async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError> { + pub async fn run_touchup( + &self, + install_path: &PathBuf, + _slug: &str, + ) -> Result<(), ManifestError> { use crate::util::native::NativeError; use tokio::process::Command; diff --git a/maxima-lib/src/core/manifest/mod.rs b/maxima-lib/src/core/manifest/mod.rs index 42fa80f4..b1772167 100644 --- a/maxima-lib/src/core/manifest/mod.rs +++ b/maxima-lib/src/core/manifest/mod.rs @@ -33,14 +33,14 @@ pub const MANIFEST_RELATIVE_PATH: &str = "__Installer/installerdata.xml"; #[async_trait::async_trait] pub trait GameManifest: Send + std::fmt::Debug { - async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError>; + async fn run_touchup(&self, install_path: &PathBuf, slug: &str) -> Result<(), ManifestError>; fn execute_path(&self, trial: bool) -> Option; fn version(&self) -> Option; } #[async_trait::async_trait] impl GameManifest for DiPManifest { - async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError> { - self.run_touchup(install_path).await + async fn run_touchup(&self, install_path: &PathBuf, slug: &str) -> Result<(), ManifestError> { + self.run_touchup(install_path, slug).await } fn execute_path(&self, trial: bool) -> Option { @@ -54,8 +54,8 @@ impl GameManifest for DiPManifest { #[async_trait::async_trait] impl GameManifest for PreDiPManifest { - async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError> { - self.run_touchup(install_path).await + async fn run_touchup(&self, install_path: &PathBuf, slug: &str) -> Result<(), ManifestError> { + self.run_touchup(install_path, slug).await } fn execute_path(&self, _: bool) -> Option { diff --git a/maxima-lib/src/core/manifest/pre_dip.rs b/maxima-lib/src/core/manifest/pre_dip.rs index 0ec9e1b4..f549a016 100644 --- a/maxima-lib/src/core/manifest/pre_dip.rs +++ b/maxima-lib/src/core/manifest/pre_dip.rs @@ -106,17 +106,16 @@ impl PreDiPManifest { } #[cfg(unix)] - pub async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError> { - use crate::{ - core::launch::mx_linux_setup, - unix::{ - fs::case_insensitive_path, - wine::{invalidate_mx_wine_registry, run_wine_command, CommandType}, - }, + pub async fn run_touchup( + &self, + install_path: &PathBuf, + slug: &str, + ) -> Result<(), ManifestError> { + use crate::unix::{ + fs::case_insensitive_path, + wine::{invalidate_mx_wine_registry, run_wine_command, CommandType}, }; - mx_linux_setup().await?; - let install_path = PathBuf::from(remove_trailing_slash( install_path.to_str().ok_or(ManifestError::Decode)?, )); @@ -124,14 +123,18 @@ impl PreDiPManifest { let path = install_path.join(remove_leading_slash(&self.executable.file_path)); let path = case_insensitive_path(path); - run_wine_command(path, Some(args), None, true, CommandType::Run).await?; + run_wine_command(path, Some(args), None, true, CommandType::Run, Some(slug)).await?; invalidate_mx_wine_registry().await; Ok(()) } #[cfg(windows)] - pub async fn run_touchup(&self, install_path: &PathBuf) -> Result<(), ManifestError> { + pub async fn run_touchup( + &self, + install_path: &PathBuf, + _slug: &str, + ) -> Result<(), ManifestError> { use crate::util::native::NativeError; use tokio::process::Command; diff --git a/maxima-lib/src/core/mod.rs b/maxima-lib/src/core/mod.rs index 88ff044b..7a6642f1 100644 --- a/maxima-lib/src/core/mod.rs +++ b/maxima-lib/src/core/mod.rs @@ -42,6 +42,8 @@ use derive_getters::Getters; use log::{error, info, warn}; use strum_macros::IntoStaticStr; +use lazy_static::lazy_static; +use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; use tokio::sync::Mutex; diff --git a/maxima-lib/src/gameinfo/mod.rs b/maxima-lib/src/gameinfo/mod.rs new file mode 100644 index 00000000..698d148f --- /dev/null +++ b/maxima-lib/src/gameinfo/mod.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::util::native::maxima_dir; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum GameVersionError { + #[error(transparent)] + Native(#[from] crate::util::native::NativeError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error("game version info not found for `{0}`")] + NotFound(String), +} + +// The serializers are for making sure that None goes to and from an empty string + +fn prefix_from_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + if s.is_empty() { + Ok(None) + } else { + Ok(Some(PathBuf::from(s))) + } +} + +fn prefix_to_string(value: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + match value { + None => serializer.serialize_str(""), + Some(path) => serializer.serialize_str(path.to_string_lossy().as_ref()), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameInstallInfo { + pub path: PathBuf, + #[serde( + deserialize_with = "prefix_from_string", + serialize_with = "prefix_to_string" + )] + pub wine_prefix: Option, +} + +impl GameInstallInfo { + pub fn new(path: PathBuf, wine_prefix: Option) -> Self { + Self { path, wine_prefix } + } + + pub fn path(&self) -> PathBuf { + self.path.clone() + } + + pub fn wine_prefix(&self) -> Option { + self.wine_prefix.clone() + } + + // TODO: Maybe we can just query the slug by the filename of the path? Look into this later + pub fn save_to_json(&self, slug: &str) { + if let Ok(json) = serde_json::to_string_pretty(self) { + let mut path = maxima_dir(); + path.as_mut().unwrap().push("gameinfo"); + if let Ok(_) = std::fs::create_dir_all(&path.as_ref().unwrap()) { + path.as_mut().unwrap().push(format!("{}.json", slug)); + fs::write(path.unwrap(), json).unwrap(); + } + } + } +} + +pub fn load_game_info_from_json(slug: &str) -> Result { + let mut path = maxima_dir(); + path.as_mut().unwrap().push("gameinfo"); + path.as_mut().unwrap().push(format!("{}.json", slug)); + let json = fs::read_to_string(path.unwrap())?; + let game_install_info: GameInstallInfo = serde_json::from_str(&json)?; + Ok(game_install_info) +} diff --git a/maxima-lib/src/lib.rs b/maxima-lib/src/lib.rs index 0d878dbe..d95e8100 100644 --- a/maxima-lib/src/lib.rs +++ b/maxima-lib/src/lib.rs @@ -6,6 +6,7 @@ pub mod content; pub mod core; +pub mod gameinfo; pub mod lsx; pub mod ooa; pub mod rtm; diff --git a/maxima-lib/src/lsx/connection.rs b/maxima-lib/src/lsx/connection.rs index ec7b6839..e9b837ec 100644 --- a/maxima-lib/src/lsx/connection.rs +++ b/maxima-lib/src/lsx/connection.rs @@ -183,15 +183,23 @@ pub fn get_os_pid(context: &ActiveGameContext) -> Result { } #[cfg(target_os = "windows")] -pub async fn get_wine_pid(_launch_id: &str, _name: &str) -> Result { +pub async fn get_wine_pid( + _launch_id: &str, + _name: &str, + _slug: Option<&str>, +) -> Result { Ok(0) } #[cfg(target_os = "linux")] -pub async fn get_wine_pid(launch_id: &str, name: &str) -> Result { +pub async fn get_wine_pid( + launch_id: &str, + name: &str, + slug: Option<&str>, +) -> Result { use crate::core::background_service::wine_get_pid; - wine_get_pid(launch_id, name).await + wine_get_pid(launch_id, name, slug).await } pub struct Connection { @@ -236,7 +244,8 @@ impl Connection { .ok_or(NativeError::Stringify)? .to_owned(); - pid = get_wine_pid(&context.launch_id(), &filename).await; + pid = get_wine_pid(&context.launch_id(), &filename, context.slug().as_deref()) + .await; } else { warn!( "Failed to find game process while looking for PID {}", diff --git a/maxima-lib/src/lsx/request/account.rs b/maxima-lib/src/lsx/request/account.rs index 5ddad4f9..3edd36f9 100644 --- a/maxima-lib/src/lsx/request/account.rs +++ b/maxima-lib/src/lsx/request/account.rs @@ -33,7 +33,8 @@ pub async fn handle_query_entitlements_request( .include_child_groups(false) .entitlement_tag("".to_string()) .group_names([request.attr_Group.clone()].to_vec()) - .build().unwrap(), + .build() + .unwrap(), ) .await?; diff --git a/maxima-lib/src/lsx/request/license.rs b/maxima-lib/src/lsx/request/license.rs index 19261ac1..569a44cd 100644 --- a/maxima-lib/src/lsx/request/license.rs +++ b/maxima-lib/src/lsx/request/license.rs @@ -1,5 +1,5 @@ -use std::env; use log::{debug, info}; +use std::env; use crate::{ core::{auth::hardware::HardwareInfo, launch::LaunchMode}, @@ -28,6 +28,7 @@ pub async fn handle_license_request( let playing = maxima.playing().as_ref().unwrap(); let content_id = playing.content_id().to_owned(); let mode = playing.mode(); + let slug = playing.slug().clone(); let auth = match mode { LaunchMode::Offline(_) => { @@ -40,7 +41,7 @@ pub async fn handle_license_request( }; // TODO: how to get version - let hw_info = HardwareInfo::new(2); + let hw_info = HardwareInfo::new(2, slug.as_deref()); let license = request_license( &content_id, &hw_info.generate_hardware_hash(), diff --git a/maxima-lib/src/ooa/mod.rs b/maxima-lib/src/ooa/mod.rs index c61cf76e..06c4086c 100644 --- a/maxima-lib/src/ooa/mod.rs +++ b/maxima-lib/src/ooa/mod.rs @@ -172,8 +172,11 @@ pub enum LicenseAuth { Direct(String, String), } -pub async fn needs_license_update(content_id: &str) -> Result { - let path = get_license_dir()?.join(format!("{}.dlf", content_id)); +pub async fn needs_license_update( + content_id: &str, + slug: Option<&str>, +) -> Result { + let path = get_license_dir(slug)?.join(format!("{}.dlf", content_id)); if !path.exists() { return Ok(true); } @@ -200,6 +203,7 @@ pub async fn request_and_save_license( auth: &LicenseAuth, content_id: &str, mut game_path: PathBuf, + slug: Option<&str>, ) -> Result<(), LicenseError> { if game_path.is_file() { game_path = game_path.safe_parent()?.to_path_buf(); @@ -213,7 +217,7 @@ pub async fn request_and_save_license( let version = detect_ooa_version(game_path).await.unwrap_or(1); debug!("OOA version is {version}"); - let hw_info = HardwareInfo::new(version); + let hw_info = HardwareInfo::new(version, slug); let license = request_license( content_id, &hw_info.generate_hardware_hash(), @@ -222,7 +226,7 @@ pub async fn request_and_save_license( None, ) .await?; - save_licenses(&license, state).await?; + save_licenses(&license, state, slug).await?; Ok(()) } @@ -337,8 +341,12 @@ pub async fn save_license( Ok(()) } -pub async fn save_licenses(license: &License, state: OOAState) -> Result<(), LicenseError> { - let path = get_license_dir()?; +pub async fn save_licenses( + license: &License, + state: OOAState, + slug: Option<&str>, +) -> Result<(), LicenseError> { + let path = get_license_dir(slug)?; debug!("Saving the license {license:#?}"); save_license( @@ -359,19 +367,19 @@ pub async fn save_licenses(license: &License, state: OOAState) -> Result<(), Lic } #[cfg(windows)] -pub fn get_license_dir() -> Result { +pub fn get_license_dir(_slug: Option<&str>) -> Result { let path = format!("C:/{}", LICENSE_PATH.to_string()); create_dir_all(&path)?; Ok(PathBuf::from(path)) } #[cfg(unix)] -pub fn get_license_dir() -> Result { +pub fn get_license_dir(slug: Option<&str>) -> Result { use crate::unix::wine::wine_prefix_dir; let path = format!( "{}/drive_c/{}", - wine_prefix_dir()?.safe_str()?, + wine_prefix_dir(slug).unwrap().safe_str()?, LICENSE_PATH.to_string() ); create_dir_all(&path)?; diff --git a/maxima-lib/src/unix/wine.rs b/maxima-lib/src/unix/wine.rs index 50c61a69..11b1e8f4 100644 --- a/maxima-lib/src/unix/wine.rs +++ b/maxima-lib/src/unix/wine.rs @@ -4,7 +4,7 @@ use std::{ ffi::OsStr, fs::{create_dir_all, remove_dir_all, remove_file, File}, io::Read, - path::PathBuf, + path::{Path, PathBuf}, process::{ExitStatus, Stdio}, }; @@ -22,10 +22,15 @@ use tokio::{ }; use xz2::read::XzDecoder; -use crate::util::{ - github::{fetch_github_release, fetch_github_releases, github_download_asset, GithubRelease}, - native::{maxima_dir, DownloadError, NativeError, SafeParent, SafeStr, WineError}, - registry::RegistryError, +use crate::{ + gameinfo::load_game_info_from_json, + util::{ + github::{ + fetch_github_release, fetch_github_releases, github_download_asset, GithubRelease, + }, + native::{maxima_dir, DownloadError, NativeError, SafeParent, SafeStr, WineError}, + registry::RegistryError, + }, }; lazy_static! { @@ -70,9 +75,20 @@ struct Versions { umu: String, } -/// Returns internal prtoton pfx path -pub fn wine_prefix_dir() -> Result { - Ok(maxima_dir()?.join("wine/prefix")) +/// Returns internal proton pfx path +pub fn wine_prefix_dir(slug: Option<&str>) -> Result { + let game_install_info = load_game_info_from_json(slug.unwrap()).unwrap(); + let prefix_path = game_install_info.wine_prefix().unwrap(); + + if !prefix_path.exists() {} + if let Err(err) = create_dir_all(&prefix_path) { + warn!( + "Failed to create wine prefix directory at {:?}: {}", + prefix_path, err + ); + return Err(NativeError::Io(err)); + } + Ok(prefix_path) } pub fn proton_dir() -> Result { @@ -240,12 +256,14 @@ pub async fn run_wine_command, T: AsRef>( cwd: Option, want_output: bool, command_type: CommandType, + slug: Option<&str>, ) -> Result { let proton_path = proton_dir()?; - let proton_prefix_path = wine_prefix_dir()?; + let proton_prefix_path = wine_prefix_dir(slug).unwrap(); let eac_path = eac_dir()?; let umu_bin = umu_bin()?; + info!("Wine Prefix: {:?}", proton_prefix_path); let wine_path = env::var("MAXIMA_WINE_COMMAND").unwrap_or_else(|_| umu_bin.to_string_lossy().to_string()); @@ -330,8 +348,6 @@ pub(crate) async fn install_wine() -> Result<(), NativeError> { warn!("Failed to delete {:?} - {:?}", path, err); } - let _ = run_wine_command("", None::<[&str; 0]>, None, false, CommandType::Run).await; - Ok(()) } @@ -376,7 +392,7 @@ fn extract_archive( Ok(()) } -pub async fn setup_wine_registry() -> Result<(), NativeError> { +pub async fn setup_wine_registry(slug: Option<&str>) -> Result<(), NativeError> { let mut reg_content = "Windows Registry Editor Version 5.00\n\n".to_string(); // This supports text values only at the moment // if you need a dword - implement it @@ -427,6 +443,7 @@ pub async fn setup_wine_registry() -> Result<(), NativeError> { None, false, CommandType::Run, + slug, ) .await?; @@ -475,8 +492,11 @@ async fn parse_wine_registry(file_path: &str) -> WineRegistry { registry_map.clone() } -pub async fn parse_mx_wine_registry() -> Result { - let path = wine_prefix_dir()?.join("system.reg"); +pub async fn parse_mx_wine_registry(slug: Option<&str>) -> Result { + let path = wine_prefix_dir(slug) + .unwrap() + .join("pfx") + .join("system.reg"); if !path.exists() { return Ok(HashMap::new()); } @@ -499,8 +519,12 @@ fn normalize_key(key: &str) -> String { } } -pub async fn get_mx_wine_registry_value(query_key: &str) -> Result, RegistryError> { - let registry_map = parse_mx_wine_registry().await?; +#[cfg(false)] // Unused method for now, but may be useful in the future +pub async fn get_mx_wine_registry_value( + query_key: &str, + slug: Option<&str>, +) -> Result, RegistryError> { + let registry_map = parse_mx_wine_registry(slug).await?; let normalized_query_key = normalize_key(query_key); let value = if let Some(value) = registry_map.get(&normalized_query_key) { diff --git a/maxima-lib/src/util/registry.rs b/maxima-lib/src/util/registry.rs index 624fc0d0..9f926cf9 100644 --- a/maxima-lib/src/util/registry.rs +++ b/maxima-lib/src/util/registry.rs @@ -1,7 +1,8 @@ #[cfg(windows)] extern crate winapi; -use std::path::PathBuf; +use log::info; +use std::{path::PathBuf, str::FromStr}; use thiserror::Error; #[cfg(windows)] @@ -32,6 +33,7 @@ use winreg::{ #[cfg(unix)] use std::{collections::HashMap, env, fs}; +use crate::gameinfo::load_game_info_from_json; #[cfg(unix)] use crate::unix::fs::case_insensitive_path; @@ -103,7 +105,7 @@ pub fn check_registry_validity() -> Result<(), RegistryError> { } #[cfg(windows)] -async fn read_reg_key(path: &str) -> Result, RegistryError> { +async fn read_reg_key(path: &str, _slug: Option<&str>) -> Result, RegistryError> { if let (Some(hkey_segment), Some(value_segment)) = (path.find('\\'), path.rfind('\\')) { let sub_key = &path[(hkey_segment + 1)..value_segment]; let value_name = &path[(value_segment + 1)..]; @@ -175,19 +177,14 @@ async fn read_reg_key(path: &str) -> Result, RegistryError> { Ok(None) } -#[cfg(unix)] -async fn read_reg_key(path: &str) -> Result, RegistryError> { - use crate::unix::wine::get_mx_wine_registry_value; - Ok(get_mx_wine_registry_value(path).await?) -} - -pub async fn parse_registry_path(key: &str) -> Result { +#[cfg(windows)] // This is only used for checking if a game is installed on windows. Just keep it out of the way on unix +pub async fn parse_registry_path_regkey(key: &str) -> Result { let mut parts = key .split(|c| c == '[' || c == ']') .filter(|s| !s.is_empty()); let path = if let (Some(first), Some(second)) = (parts.next(), parts.next()) { - let path = match read_reg_key(first).await? { + let path = match read_reg_key(first, None).await? { Some(path) => path.replace("\\", "/").replace("//", "/"), None => return Ok(PathBuf::from(key.to_owned())), }; @@ -205,13 +202,33 @@ pub async fn parse_registry_path(key: &str) -> Result { Ok(path) } -pub async fn parse_partial_registry_path(key: &str) -> Result { +pub async fn parse_registry_path_json( + key: &str, + slug: Option<&str>, +) -> Result { + let game_install_info = + load_game_info_from_json(slug.unwrap()).map_err(|_| RegistryError::InvalidInstallKey)?; + let idx = key.rfind(']'); + // Path looks like [HKEY_LOCAL_MACHINE\SOFTWARE\BioWare\Mass Effect Legendary Edition\Install Dir]Game\Launcher\MassEffectLauncher.exe (note this could be something other than the exe like the manifest) + // Extract everything after the last ] and append it to the install path + let after_bracket = &key[(idx.unwrap() + 1)..]; + let path = game_install_info.path().join(after_bracket); + #[cfg(unix)] + let path = case_insensitive_path(path); + Ok(path) +} + +#[cfg(false)] // Block out unused method +pub async fn parse_partial_registry_path( + key: &str, + slug: Option<&str>, +) -> Result { let mut parts = key .split(|c| c == '[' || c == ']') .filter(|s| !s.is_empty()); let path = if let (Some(first), Some(_second)) = (parts.next(), parts.next()) { - let path = match read_reg_key(first).await? { + let path = match read_reg_key(first, slug).await? { Some(path) => path.replace("\\", "/"), None => return Ok(PathBuf::from(key.to_owned())), }; diff --git a/maxima-ui/res/locale/en_us.json b/maxima-ui/res/locale/en_us.json index 22d19a29..2a2864c6 100644 --- a/maxima-ui/res/locale/en_us.json +++ b/maxima-ui/res/locale/en_us.json @@ -94,8 +94,7 @@ }, "game_installation" : { "header": "Game Installation", - "default_folder": "Default installation folder", - "ignore_ood_warning": "Ignore out-of-date game launch warning" + "default_folder": "Default installation folder", "default_wine_prefix": "Default Wine prefix folder", "ignore_ood_warning": "Ignore out-of-date game launch warning" }, "performance" : { "header": "Performance", diff --git a/maxima-ui/src/bridge/get_games.rs b/maxima-ui/src/bridge/get_games.rs index f2a62cae..843f6c4d 100644 --- a/maxima-ui/src/bridge/get_games.rs +++ b/maxima-ui/src/bridge/get_games.rs @@ -1,14 +1,14 @@ use crate::{ bridge_thread::{BackendError, InteractThreadGameListResponse, MaximaLibResponse}, ui_image::UIImageCacheLoaderCommand, - GameDetailsWrapper, GameInfo, GameVersionInfo, + GameDetailsWrapper, GameInfo, GameSettings, GameVersionInfo, }; use egui::Context; -use log::{debug, error, info}; +use log::{debug, info}; use maxima::{ core::{ service_layer::{ - ServiceGame, ServiceGameHub, ServiceGameHubCollection, ServiceGameImagesRequestBuilder, + ServiceGame, ServiceGameHubCollection, ServiceGameImagesRequestBuilder, ServiceHeroBackgroundImageRequestBuilder, ServiceLayerClient, SERVICE_REQUEST_GAMEIMAGES, SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, }, @@ -199,12 +199,11 @@ pub async fn get_games_request( return Err(BackendError::LoggedOut); } - let owned_games = maxima.mut_library().games().await?; + let owned_games = maxima.mut_library().games().await?.clone(); for game in owned_games { let slug = game.base_offer().slug().clone(); info!("processing {}", &slug); - let downloads = game.base_offer().offer().downloads(); let opt = if downloads.len() == 1 { &downloads[0] @@ -233,11 +232,10 @@ pub async fn get_games_request( has_cloud_saves: game.base_offer().offer().has_cloud_save(), }; let slug = game_info.slug.clone(); - let settings = crate::GameSettings { - //TODO: eventually support cloud saves, the option is here for that but for now, keep it disabled in ui! - cloud_saves: true, - launch_args: String::new(), + let settings = GameSettings { exe_override: String::new(), + launch_args: String::new(), + cloud_saves: true, }; let res = MaximaLibResponse::GameInfoResponse(InteractThreadGameListResponse { game: game_info, diff --git a/maxima-ui/src/bridge_processor.rs b/maxima-ui/src/bridge_processor.rs index 9a98c035..c77d01b9 100644 --- a/maxima-ui/src/bridge_processor.rs +++ b/maxima-ui/src/bridge_processor.rs @@ -80,9 +80,7 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { } } } - DownloadFinished(_) => { - // idk - } + DownloadFinished(_) => {} DownloadQueueUpdate(current, queue) => { if let Some(current) = current { if !app.installing_now.as_ref().is_some_and(|n| n.offer == current) { diff --git a/maxima-ui/src/bridge_thread.rs b/maxima-ui/src/bridge_thread.rs index 456c1778..d47715f7 100644 --- a/maxima-ui/src/bridge_thread.rs +++ b/maxima-ui/src/bridge_thread.rs @@ -26,16 +26,16 @@ use maxima::{ }, LockedMaxima, Maxima, MaximaCreationError, MaximaOptionsBuilder, MaximaOptionsBuilderError, }, + gameinfo::GameInstallInfo, lsx::service::LSXServerError, rtm::RtmError, util::{ - native::NativeError, + native::{maxima_dir, NativeError}, registry::{check_registry_validity, set_up_registry, RegistryError}, }, }; use std::sync::mpsc::{SendError, TryRecvError}; use std::{ - panic, path::PathBuf, sync::mpsc::{Receiver, Sender}, time::{Duration, SystemTime}, @@ -82,8 +82,8 @@ pub enum MaximaLibRequest { GetFriendsRequest, GetGameDetailsRequest(String), StartGameRequest(GameInfo, Option), - InstallGameRequest(String, PathBuf), - LocateGameRequest(String), + InstallGameRequest(String, String, PathBuf, Option), // offer, slug, path, wine prefix (unix only) + LocateGameRequest(String, String, Option), // slug, path, wine prefix (unix only) ShutdownRequest, } @@ -441,9 +441,10 @@ impl BridgeThread { let context = ctx.clone(); async move { game_details_request(maxima, slug.clone(), channel, &context).await }.await } - MaximaLibRequest::LocateGameRequest(path) => { - #[cfg(unix)] - maxima::core::launch::mx_linux_setup().await?; + MaximaLibRequest::LocateGameRequest(slug, path, wine_prefix) => { + let game_install_info = + GameInstallInfo::new(PathBuf::from(path.clone()), wine_prefix); + game_install_info.save_to_json(&slug); let mut path = path; if path.ends_with("/") || path.ends_with("\\") { path.remove(path.len() - 1); @@ -451,7 +452,7 @@ impl BridgeThread { let path = PathBuf::from(path); let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await; if let Ok(manifest) = manifest { - let guh = manifest.run_touchup(&path).await; + let guh = manifest.run_touchup(&path, &slug).await; if let Err(err) = guh { let _ = backend_responder.send(MaximaLibResponse::LocateGameResponse( InteractThreadLocateGameResponse::Error( @@ -471,6 +472,10 @@ impl BridgeThread { )); } } else { + std::fs::remove_file( + maxima_dir().unwrap().join("gameinfo").join(format!("{}.json", slug)), + ) + .ok(); let _ = backend_responder.send(MaximaLibResponse::LocateGameResponse( InteractThreadLocateGameResponse::Error( InteractThreadLocateGameFailure { @@ -488,7 +493,7 @@ impl BridgeThread { ctx.request_repaint(); Ok(()) } - MaximaLibRequest::InstallGameRequest(offer, path) => { + MaximaLibRequest::InstallGameRequest(offer, slug, path, wine_prefix) => { let mut maxima = maxima_arc.lock().await; let builds = maxima.content_manager().service().available_builds(&offer).await?; @@ -498,10 +503,18 @@ impl BridgeThread { continue; }; + #[cfg(unix)] + let wine_prefix = wine_prefix; // We handle empty cases in the UI, so we can just pass it through here + + #[cfg(windows)] + let wine_prefix = None; + let game = QueuedGameBuilder::default() - .offer_id(offer) + .offer_id(offer.clone()) .build_id(build.build_id().to_owned()) .path(path.to_owned()) + .slug(slug.to_owned()) + .wine_prefix(wine_prefix) .build()?; Ok(maxima.content_manager().add_install(game).await?) } diff --git a/maxima-ui/src/main.rs b/maxima-ui/src/main.rs index e3e0278b..fded44ef 100644 --- a/maxima-ui/src/main.rs +++ b/maxima-ui/src/main.rs @@ -31,6 +31,7 @@ use egui_glow::glow; use app_bg_renderer::AppBgRenderer; use bridge_thread::{BackendError, BridgeThread, InteractThreadLocateGameResponse}; use game_view_bg_renderer::GameViewBgRenderer; +use maxima::util::native::maxima_dir; use renderers::{app_bg_renderer, game_view_bg_renderer}; use translation_manager::{positional_replace, TranslationManager}; @@ -238,6 +239,7 @@ pub enum BackendStallState { pub struct InstallModalState { locate_path: String, install_folder: String, + wine_prefix: String, locating: bool, locate_response: Option, should_close: bool, @@ -248,6 +250,7 @@ impl InstallModalState { Self { locate_path: String::new(), install_folder: settings.default_install_folder.clone(), + wine_prefix: settings.default_wine_prefix_path.clone(), locating: false, locate_response: None, should_close: false, @@ -333,6 +336,7 @@ impl FrontendPerformanceSettings { #[derive(serde::Serialize, serde::Deserialize)] pub struct FrontendSettings { default_install_folder: String, + default_wine_prefix_path: String, language: FrontendLanguage, ignore_ood_games: bool, game_settings: HashMap, @@ -343,6 +347,7 @@ impl FrontendSettings { pub fn new() -> Self { Self { default_install_folder: String::new(), + default_wine_prefix_path: String::new(), language: FrontendLanguage::SystemDefault, ignore_ood_games: false, game_settings: HashMap::new(), @@ -891,7 +896,16 @@ impl MaximaEguiApp { ui.add_enabled_ui(PathBuf::from(&self.installer_state.locate_path).exists(), |ui| { if ui.add_sized(button_size, egui::Button::new(&self.locale.localization.modals.game_install.locate_action.to_ascii_uppercase())).clicked() { - self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::LocateGameRequest(self.installer_state.locate_path.clone())).unwrap(); + #[cfg(unix)] + let wine_prefix = if self.installer_state.wine_prefix.is_empty() { + Some(maxima_dir().unwrap().join("wine/prefixes").join(slug)) + } else { + Some(PathBuf::from(&self.installer_state.wine_prefix)) + }; + #[cfg(not(unix))] + let wine_prefix: Option = None; + + self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::LocateGameRequest(slug.clone(), self.installer_state.locate_path.clone(), wine_prefix)).unwrap(); self.installer_state.locating = true; } }); @@ -909,14 +923,34 @@ impl MaximaEguiApp { }); let path = PathBuf::from(self.installer_state.install_folder.clone()); let valid = path.exists(); + + #[cfg(unix)] + { + ui.label("Wine prefix folder"); + ui.horizontal(|ui| { + let size = vec2(500.0 - (24.0 + ui.style().spacing.item_spacing.x), 30.0); + ui.add_sized(size, egui::TextEdit::singleline(&mut self.installer_state.wine_prefix).vertical_align(egui::Align::Center)); + }); + } + ui.add_enabled_ui(valid, |ui| { if ui.add_sized(button_size, egui::Button::new(&self.locale.localization.modals.game_install.fresh_action)).clicked() { + #[cfg(unix)] + let wine_prefix = if self.installer_state.wine_prefix.is_empty() { + + Some(maxima_dir().unwrap().join("wine/prefixes").join(slug)) + } else { + Some(PathBuf::from(&self.installer_state.wine_prefix)) + }; + #[cfg(not(unix))] + let wine_prefix: Option = None; + if self.installing_now.is_none() { self.installing_now = Some(QueuedDownload { slug: game.slug.clone(), offer: game.offer.clone(), downloaded_bytes: 0, total_bytes: 0 }); } else { self.install_queue.insert(game.offer.clone(),QueuedDownload { slug: game.slug.clone(), offer: game.offer.clone(), downloaded_bytes: 0, total_bytes: 0 }); } - self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::InstallGameRequest(game.offer.clone(), path.join(slug))).unwrap(); + self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::InstallGameRequest(game.offer.clone(), slug.clone(), path.join(slug), wine_prefix)).unwrap(); clear = true; } @@ -927,6 +961,22 @@ impl MaximaEguiApp { egui::Label::new(egui::RichText::new(format!("{}", path.join(slug).display())).color(Color32::WHITE)).selectable(false).ui(folder_hint); }); + #[cfg(unix)] + { + let effective_prefix = if self.installer_state.wine_prefix.is_empty() { + maxima::util::native::maxima_dir() + .ok() + .map(|d| d.join("wine/prefixes").join(slug)) + .map(|p| p.display().to_string()) + .unwrap_or_default() + } else { + self.installer_state.wine_prefix.clone() + }; + ui.horizontal_wrapped(|prefix_hint| { + egui::Label::new("Wine prefix:").selectable(false).ui(prefix_hint); + egui::Label::new(egui::RichText::new(&effective_prefix).color(Color32::WHITE)).selectable(false).ui(prefix_hint); + }); + } if !valid { egui::Label::new(egui::RichText::new(&self.locale.localization.modals.game_install.fresh_path_invalid).color(Color32::RED)).ui(ui); } diff --git a/maxima-ui/src/translation_manager.rs b/maxima-ui/src/translation_manager.rs index 9e089c1b..1a7158dc 100644 --- a/maxima-ui/src/translation_manager.rs +++ b/maxima-ui/src/translation_manager.rs @@ -172,6 +172,8 @@ pub struct LocalizedGameInstallationSettings { pub header: String, /// Label for a text box for a default path to install games pub default_folder: String, + /// Label for a text box for the default Wine prefix path + pub default_wine_prefix: String, /// Checkbox for ignoring the out-of-date launch warning pub ignore_ood_warning: String, } diff --git a/maxima-ui/src/views/settings_view.rs b/maxima-ui/src/views/settings_view.rs index 4f36b128..a9f574ff 100644 --- a/maxima-ui/src/views/settings_view.rs +++ b/maxima-ui/src/views/settings_view.rs @@ -34,6 +34,23 @@ pub fn settings_view(app: &mut MaximaEguiApp, ui: &mut Ui) { ); if ui.add_sized(vec2(100.0, 30.0), egui::Button::new("BROWSE")).clicked() {} }); + + #[cfg(unix)] + { + ui.label(&localization.game_installation.default_wine_prefix); + ui.horizontal(|ui| { + ui.add_sized( + vec2( + ui.available_width() - (100.0 + ui.spacing().item_spacing.x), + 30.0, + ), + egui::TextEdit::singleline(&mut app.settings.default_wine_prefix_path) + .vertical_align(egui::Align::Center), + ); + if ui.add_sized(vec2(100.0, 30.0), egui::Button::new("BROWSE")).clicked() {} + }); + } + ui.checkbox( &mut app.settings.ignore_ood_games, &app.locale.localization.settings_view.game_installation.ignore_ood_warning,