diff --git a/maxima-bootstrap/src/main.rs b/maxima-bootstrap/src/main.rs index 9cd16c2..0429789 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 de2ce96..9be9107 100644 --- a/maxima-cli/src/main.rs +++ b/maxima-cli/src/main.rs @@ -70,6 +70,7 @@ enum Mode { ListGames, LocateGame { path: String, + slug: String, }, CloudSync { game_slug: String, @@ -301,7 +302,7 @@ async fn startup() -> Result<()> { start_game(&offer_id, 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 } @@ -622,7 +623,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 +667,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 +769,10 @@ 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?; + manifest.run_touchup(&path, slug).await?; info!("Installed!"); Ok(()) } diff --git a/maxima-lib/Cargo.toml b/maxima-lib/Cargo.toml index 9f3a6e7..07a30d7 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 e982a19..6f93ad0 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 0000000..a47f6d5 --- /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 f16116d..7b3bd9e 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, }, @@ -38,6 +39,7 @@ pub struct QueuedGame { offer_id: String, build_id: String, path: PathBuf, + slug: String, } #[derive(Default, Getters, Serialize, Deserialize)] @@ -124,6 +126,7 @@ impl DownloadQueue { pub struct GameDownloader { offer_id: String, + slug: String, downloader: Arc, entries: Vec, @@ -149,8 +152,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,6 +173,7 @@ impl GameDownloader { Ok(GameDownloader { offer_id: game.offer_id.to_owned(), + slug: game.slug.to_owned(), downloader: Arc::new(downloader), entries, @@ -178,6 +189,7 @@ 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(); tokio::spawn(async move { let dl = GameDownloader::start_downloads( total_count, @@ -186,6 +198,7 @@ impl GameDownloader { cancel_token, completed_bytes, notify, + &slug, ) .await; if let Err(err) = dl { @@ -219,6 +232,7 @@ impl GameDownloader { cancel_token: CancellationToken, completed_bytes: Arc, notify: Arc, + slug: &str, ) -> Result<(), DownloaderError> { let mut handles = Vec::with_capacity(total_count); @@ -258,8 +272,7 @@ impl GameDownloader { info!("Files downloaded, running touchup..."); let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await?; - - manifest.run_touchup(path).await?; + manifest.run_touchup(path, &slug).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 4a98e43..252950d 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 c11df25..ecc5b39 100644 --- a/maxima-lib/src/core/auth/hardware.rs +++ b/maxima-lib/src/core/auth/hardware.rs @@ -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 wine_prefix_dir(slug) { + Ok(prefix) => prefix, + Err(_) => 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 6c3bc13..cdc8614 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 d383350..b169346 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 41e4cd7..396980e 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, @@ -106,20 +105,20 @@ fn home_dir() -> Result { } #[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)?.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 +127,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 +196,7 @@ pub struct CloudSyncLock<'a> { manifest: CloudSyncManifest, mode: CloudSyncLockMode, allowed_files: Vec, + slug: String, } impl<'a> CloudSyncLock<'a> { @@ -207,6 +207,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 +239,7 @@ impl<'a> CloudSyncLock<'a> { manifest, mode, allowed_files, + slug: slug.to_owned(), }) } @@ -274,7 +276,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 +412,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 +508,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 +561,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 +581,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 +591,7 @@ impl CloudSyncClient { id: &str, mode: CloudSyncLockMode, allowed_files: Vec, + slug: &str, ) -> Result { let (token, user_id) = acquire_auth(&self.auth).await?; @@ -613,6 +619,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 6281672..4f1644e 100644 --- a/maxima-lib/src/core/launch.rs +++ b/maxima-lib/src/core/launch.rs @@ -114,6 +114,7 @@ pub struct ActiveGameContext { launch_id: String, game_path: String, content_id: String, + slug: Option, offer: Option, mode: LaunchMode, injections: Vec, @@ -131,11 +132,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 +161,7 @@ impl ActiveGameContext { pub struct BootstrapLaunchArgs { pub path: String, pub args: Vec, + pub slug: String, } impl Display for LaunchMode { @@ -232,8 +236,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 +251,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 +294,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 +325,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 +396,7 @@ pub async fn start_game( offer, mode, child, + slug, )); Ok(()) @@ -399,12 +423,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)?)?; info!("Verifying wine dependencies..."); let skip = std::env::var("MAXIMA_DISABLE_WINE_VERIFICATION").is_ok(); @@ -421,7 +446,7 @@ pub async fn mx_linux_setup() -> Result<(), NativeError> { } } - setup_wine_registry().await?; + setup_wine_registry(slug).await?; Ok(()) } diff --git a/maxima-lib/src/core/library.rs b/maxima-lib/src/core/library.rs index faa730c..04386c1 100644 --- a/maxima-lib/src/core/library.rs +++ b/maxima-lib/src/core/library.rs @@ -13,7 +13,7 @@ use super::{ }; #[cfg(unix)] use crate::unix::fs::case_insensitive_path; -use crate::util::native::{NativeError, SafeStr}; +use crate::util::native::{maxima_dir, NativeError, SafeStr}; use crate::util::registry::{parse_partial_registry_path, parse_registry_path, RegistryError}; use derive_getters::Getters; use std::{collections::HashMap, path::PathBuf, time::SystemTimeError}; @@ -55,22 +55,26 @@ 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 { + let maxima_dir = maxima_dir().unwrap(); + let manifest_path = maxima_dir + .join("settings") + .join(format!("{}.json", self.slug)); + if !manifest_path.exists() { return false; - }; - let path = match parse_registry_path(path).await { - Ok(path) => path, + } + + let contents = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, Err(_) => return false, }; - // If it wasn't replaced... - if path.starts_with("[") { - return false; + + match serde_json::from_str::(&contents) { + Ok(json) => json + .get("installed") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + Err(_) => false, } - #[cfg(unix)] - let path = case_insensitive_path(path); - path.exists() } pub async fn install_check_path(&self) -> Result { @@ -80,6 +84,7 @@ impl OwnedOffer { .install_check_override() .as_ref() .ok_or(ManifestError::NoInstallPath(self.slug.clone()))?, + Some(&self.slug), ) .await? .safe_str()? @@ -99,7 +104,7 @@ impl OwnedOffer { }; if let Some(path) = path { - Ok(parse_registry_path(path).await?) + Ok(parse_registry_path(path, Some(&self.slug)).await?) } else { Err(LibraryError::NoPath(self.slug.clone())) } @@ -142,6 +147,7 @@ impl OwnedOffer { .install_check_override() .as_ref() .ok_or(ManifestError::NoInstallPath(self.slug.clone()))?, + Some(&self.slug), ) .await? .safe_str()? diff --git a/maxima-lib/src/core/manifest/dip.rs b/maxima-lib/src/core/manifest/dip.rs index b8da5ce..001b042 100644 --- a/maxima-lib/src/core/manifest/dip.rs +++ b/maxima-lib/src/core/manifest/dip.rs @@ -191,7 +191,11 @@ impl DiPManifest { } #[cfg(unix)] - 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::{ core::launch::mx_linux_setup, unix::{ @@ -200,7 +204,7 @@ impl DiPManifest { }, }; - mx_linux_setup().await?; + mx_linux_setup(Some(slug)).await?; let install_path = PathBuf::from(remove_trailing_slash( install_path.to_str().ok_or(ManifestError::Decode)?, @@ -208,14 +212,18 @@ impl DiPManifest { 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 42fa80f..b177216 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 0ec9e1b..1f71c97 100644 --- a/maxima-lib/src/core/manifest/pre_dip.rs +++ b/maxima-lib/src/core/manifest/pre_dip.rs @@ -106,7 +106,11 @@ impl PreDiPManifest { } #[cfg(unix)] - 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::{ core::launch::mx_linux_setup, unix::{ @@ -115,7 +119,7 @@ impl PreDiPManifest { }, }; - mx_linux_setup().await?; + mx_linux_setup(Some(slug)).await?; let install_path = PathBuf::from(remove_trailing_slash( install_path.to_str().ok_or(ManifestError::Decode)?, @@ -124,14 +128,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 88ff044..e8a2896 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; @@ -64,6 +66,7 @@ use self::{ }; use crate::{ content::manager::{ContentManager, ContentManagerError}, + gamesettings::GameSettingsManager, lsx::{self, service::LSXServerError, types::LSXRequestType}, rtm::client::{BasicPresence, RtmClient}, util::native::{maxima_dir, NativeError}, @@ -88,7 +91,8 @@ pub struct Maxima { #[getter(skip)] library: GameLibrary, - + #[getter(skip)] + game_settings: GameSettingsManager, playing: Option, lsx_port: u16, @@ -141,6 +145,11 @@ pub enum MaximaCreationError { pub type LockedMaxima = Arc>; +lazy_static! { + pub static ref GamePrefixMap: std::sync::Mutex> = + std::sync::Mutex::new(HashMap::new()); +} + impl Maxima { pub async fn new_with_options( options: MaximaOptions, @@ -216,6 +225,7 @@ impl Maxima { request_cache, dummy_local_user, pending_events: Vec::new(), + game_settings: GameSettingsManager::new(), }))) } @@ -415,6 +425,14 @@ impl Maxima { &mut self.library } + pub fn game_settings(&self) -> &GameSettingsManager { + &self.game_settings + } + + pub fn mut_game_settings(&mut self) -> &mut GameSettingsManager { + &mut self.game_settings + } + pub fn content_manager(&mut self) -> &mut ContentManager { &mut self.content_manager } diff --git a/maxima-lib/src/gamesettings/mod.rs b/maxima-lib/src/gamesettings/mod.rs new file mode 100644 index 0000000..36458dc --- /dev/null +++ b/maxima-lib/src/gamesettings/mod.rs @@ -0,0 +1,137 @@ +use std::collections::HashMap; + +use crate::{core::GamePrefixMap, util::native::maxima_dir}; +use log::info; +use serde::{Deserialize, Serialize}; +use serde_json; + +#[derive(Serialize, Deserialize, Clone)] +pub struct GameSettings { + pub cloud_saves: bool, + pub installed: bool, + pub launch_args: String, + pub exe_override: String, + pub wine_prefix: String, +} + +impl GameSettings { + pub fn new() -> Self { + Self { + cloud_saves: true, + installed: false, + launch_args: String::new(), + exe_override: String::new(), + wine_prefix: String::new(), + } + } + + pub fn new_with_slug(slug: &str) -> Self { + let mut settings = Self::new(); + settings.wine_prefix = format!("/mnt/games/Games/{}/", slug); + settings + } + + /// Public accessors for fields so consumers can read settings. + pub fn cloud_saves(&self) -> bool { + self.cloud_saves + } + + pub fn launch_args(&self) -> &str { + &self.launch_args + } + + pub fn exe_override(&self) -> &str { + &self.exe_override + } + + pub fn wine_prefix(&self) -> &str { + &self.wine_prefix + } + + /// Update mutable fields from UI-provided values while preserving any internal-only fields like `wine_prefix`. + pub fn update_from(&mut self, cloud_saves: bool, launch_args: String, exe_override: String) { + self.cloud_saves = cloud_saves; + self.launch_args = launch_args; + self.exe_override = exe_override; + } +} + +pub fn get_game_settings(slug: &str) -> GameSettings { + let path = match maxima_dir() { + Ok(dir) => dir.join("settings").join(format!("{}.json", slug)), + Err(_) => return GameSettings::new_with_slug(slug), + }; + + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(_) => return GameSettings::new_with_slug(slug), + }; + + let game_settings = + serde_json::from_str(&content).unwrap_or_else(|_| GameSettings::new_with_slug(slug)); + GamePrefixMap + .lock() + .unwrap() + .insert(slug.to_string(), game_settings.wine_prefix().to_string()); + game_settings +} + +pub fn save_game_settings(slug: &str, settings: &GameSettings) { + if settings.installed == false { + info!("Skipping save for {} as game is not installed.", slug); + return; + } + info!("Saving settings for {}...", slug); + if let Ok(dir) = maxima_dir() { + let settings_dir = dir.join("settings"); + // Ensure the settings directory exists + if let Err(err) = std::fs::create_dir_all(&settings_dir) { + info!("Failed to create settings dir {:?}: {}", settings_dir, err); + return; + } + + let path = settings_dir.join(format!("{}.json", slug)); + if let Ok(content) = serde_json::to_string_pretty(settings) { + match std::fs::write(&path, content) { + Ok(()) => info!("Saved settings to {:?}", path), + Err(err) => info!("Failed to write settings for {}: {}", slug, err), + } + } else { + info!("Failed to serialize settings for {}", slug); + } + } else { + info!( + "Failed to get maxima directory, cannot save settings for {}", + slug + ); + } +} + +#[derive(Clone)] +pub struct GameSettingsManager { + settings: HashMap, +} + +impl GameSettingsManager { + pub fn new() -> Self { + Self { + settings: HashMap::new(), + } + } + + pub fn get(&self, slug: &str) -> GameSettings { + self.settings + .get(slug) + .cloned() + .unwrap_or_else(|| GameSettings::new_with_slug(slug)) + } + + pub fn save(&mut self, slug: &str, settings: GameSettings) { + save_game_settings(slug, &settings); + self.settings.insert(slug.to_string(), settings.clone()); + GamePrefixMap + .lock() + .unwrap() + .insert(slug.to_string(), settings.wine_prefix().to_string()); + } +} diff --git a/maxima-lib/src/lib.rs b/maxima-lib/src/lib.rs index 0d878db..8493414 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 gamesettings; 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 ec7b683..e9b837e 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 5ddad4f..3edd36f 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 19261ac..569a44c 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 c61cf76..b621981 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( @@ -366,12 +374,12 @@ pub fn get_license_dir() -> Result { } #[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)?.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 50c61a6..66fe498 100644 --- a/maxima-lib/src/unix/wine.rs +++ b/maxima-lib/src/unix/wine.rs @@ -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::{ + core::GamePrefixMap, + util::{ + github::{ + fetch_github_release, fetch_github_releases, github_download_asset, GithubRelease, + }, + native::{maxima_dir, DownloadError, NativeError, SafeParent, SafeStr, WineError}, + registry::RegistryError, + }, }; lazy_static! { @@ -71,8 +76,32 @@ struct Versions { } /// Returns internal prtoton pfx path -pub fn wine_prefix_dir() -> Result { - Ok(maxima_dir()?.join("wine/prefix")) +pub fn wine_prefix_dir(slug: Option<&str>) -> Result { + if let Some(slug) = slug { + // Check if prefix is already in the cache map + if let Some(prefix) = GamePrefixMap.lock().unwrap().get(slug) { + return Ok(prefix.clone().into()); + } + + // Load settings from disk to get the wine_prefix + use crate::gamesettings::get_game_settings; + let settings = get_game_settings(slug); + let prefix = settings.wine_prefix(); + + // If settings have a non-empty wine_prefix, cache it and return it + if !prefix.is_empty() { + GamePrefixMap + .lock() + .unwrap() + .insert(slug.to_string(), prefix.to_string()); + return Ok(prefix.into()); + } + + // Fallback to default path + Ok(maxima_dir()?.join("wine/prefix").join(slug)) + } else { + Ok(maxima_dir()?.join("wine/prefix")) + } } pub fn proton_dir() -> Result { @@ -240,12 +269,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)?; 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 +361,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 +405,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 +456,7 @@ pub async fn setup_wine_registry() -> Result<(), NativeError> { None, false, CommandType::Run, + slug, ) .await?; @@ -475,8 +505,8 @@ 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)?.join("pfx").join("system.reg"); if !path.exists() { return Ok(HashMap::new()); } @@ -499,8 +529,11 @@ 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?; +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 624fc0d..b61a646 100644 --- a/maxima-lib/src/util/registry.rs +++ b/maxima-lib/src/util/registry.rs @@ -103,7 +103,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)..]; @@ -176,18 +176,18 @@ async fn read_reg_key(path: &str) -> Result, RegistryError> { } #[cfg(unix)] -async fn read_reg_key(path: &str) -> Result, RegistryError> { +async fn read_reg_key(path: &str, slug: Option<&str>) -> Result, RegistryError> { use crate::unix::wine::get_mx_wine_registry_value; - Ok(get_mx_wine_registry_value(path).await?) + Ok(get_mx_wine_registry_value(path, slug).await?) } -pub async fn parse_registry_path(key: &str) -> Result { +pub async fn parse_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("\\", "/").replace("//", "/"), None => return Ok(PathBuf::from(key.to_owned())), }; @@ -205,13 +205,16 @@ 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_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/src/bridge/get_games.rs b/maxima-ui/src/bridge/get_games.rs index f2a62ca..3e3365f 100644 --- a/maxima-ui/src/bridge/get_games.rs +++ b/maxima-ui/src/bridge/get_games.rs @@ -8,12 +8,13 @@ use log::{debug, error, info}; use maxima::{ core::{ service_layer::{ - ServiceGame, ServiceGameHub, ServiceGameHubCollection, ServiceGameImagesRequestBuilder, + ServiceGame, ServiceGameHubCollection, ServiceGameImagesRequestBuilder, ServiceHeroBackgroundImageRequestBuilder, ServiceLayerClient, SERVICE_REQUEST_GAMEIMAGES, SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, }, LockedMaxima, }, + gamesettings::get_game_settings, util::native::maxima_dir, }; use std::{fs, sync::mpsc::Sender}; @@ -198,13 +199,13 @@ pub async fn get_games_request( if !logged_in { return Err(BackendError::LoggedOut); } + let mut game_settings = maxima.mut_game_settings().clone(); let owned_games = maxima.mut_library().games().await?; 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,12 +234,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(), - exe_override: String::new(), - }; + // Grab persisted settings from Maxima's GameSettingsManager if available + let core_settings = get_game_settings(&slug); + game_settings.save(&slug, core_settings.clone()); + let settings = core_settings.clone(); let res = MaximaLibResponse::GameInfoResponse(InteractThreadGameListResponse { game: game_info, settings, diff --git a/maxima-ui/src/bridge_processor.rs b/maxima-ui/src/bridge_processor.rs index 9a98c03..65a023a 100644 --- a/maxima-ui/src/bridge_processor.rs +++ b/maxima-ui/src/bridge_processor.rs @@ -4,6 +4,7 @@ use crate::{ BackendStallState, GameDetails, GameDetailsWrapper, MaximaEguiApp, }; use log::{error, info, warn}; +use maxima::gamesettings::GameSettings; use std::sync::mpsc::TryRecvError; pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { @@ -44,7 +45,12 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { } ServiceStarted => app.backend_state = BackendStallState::Starting, GameInfoResponse(res) => { - app.games.insert(res.game.slug.clone(), res.game); + // Move the game out of the response, keep the slug for storing settings + let game = res.game; + let slug = game.slug.clone(); + // Store the game-specific settings coming from the backend into frontend settings + app.settings.game_settings.insert(slug.clone(), res.settings); + app.games.insert(slug, game); } GameDetailsResponse(res) => { let response = res.response; @@ -80,8 +86,36 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { } } } - DownloadFinished(_) => { - // idk + DownloadFinished(offer_id) => { + let mut slug = String::new(); + for (s, game) in &app.games { + if game.offer == offer_id { + slug = s.clone(); + break; + } + } + if slug.is_empty() { + continue; + } + + // update frontend settings + if let Some(mut settings) = app.settings.game_settings.remove(&slug) { + settings.installed = true; + app.settings.game_settings.insert(slug.clone(), settings.clone()); + + // update game info displayed + if let Some(game) = app.games.get_mut(&slug) { + game.installed = true; + } + + // persist to core + let _ = app.backend.backend_commander.send( + bridge_thread::MaximaLibRequest::SaveGameSettings( + slug.clone(), + settings.clone(), + ), + ); + } } DownloadQueueUpdate(current, queue) => { if let Some(current) = current { diff --git a/maxima-ui/src/bridge_thread.rs b/maxima-ui/src/bridge_thread.rs index 456c177..2e686c8 100644 --- a/maxima-ui/src/bridge_thread.rs +++ b/maxima-ui/src/bridge_thread.rs @@ -82,8 +82,10 @@ pub enum MaximaLibRequest { GetFriendsRequest, GetGameDetailsRequest(String), StartGameRequest(GameInfo, Option), - InstallGameRequest(String, PathBuf), - LocateGameRequest(String), + InstallGameRequest(String, String, PathBuf), + LocateGameRequest(String, String), + /// Persist UI-side game settings into the core GameSettingsManager + SaveGameSettings(String, GameSettings), ShutdownRequest, } @@ -402,8 +404,18 @@ impl BridgeThread { match ev { maxima::core::MaximaEvent::ReceivedLSXRequest(_, _) => {} maxima::core::MaximaEvent::InstallFinished(offer_id) => { + // Easy access to mutably update game settings + if let Ok(Some(title)) = + maxima.mut_library().title_by_base_offer(&offer_id).await + { + let slug = title.base_offer().slug().clone(); + let manager = maxima.mut_game_settings(); + let mut settings = manager.get(&slug); + settings.installed = true; + manager.save(&slug, settings); + } backend_responder - .send(MaximaLibResponse::DownloadFinished(offer_id))?; + .send(MaximaLibResponse::DownloadFinished(offer_id))?; // UI can handle updating settings, can easily access UI Frontend structures in DownloadFinished Self::update_queue(maxima.content_manager(), backend_responder.clone()); } } @@ -441,9 +453,9 @@ impl BridgeThread { let context = ctx.clone(); async move { game_details_request(maxima, slug.clone(), channel, &context).await }.await } - MaximaLibRequest::LocateGameRequest(path) => { + MaximaLibRequest::LocateGameRequest(slug, path) => { #[cfg(unix)] - maxima::core::launch::mx_linux_setup().await?; + maxima::core::launch::mx_linux_setup(Some(&slug)).await?; let mut path = path; if path.ends_with("/") || path.ends_with("\\") { path.remove(path.len() - 1); @@ -451,7 +463,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( @@ -488,7 +500,7 @@ impl BridgeThread { ctx.request_repaint(); Ok(()) } - MaximaLibRequest::InstallGameRequest(offer, path) => { + MaximaLibRequest::InstallGameRequest(offer, slug, path) => { let mut maxima = maxima_arc.lock().await; let builds = maxima.content_manager().service().available_builds(&offer).await?; @@ -499,15 +511,23 @@ impl BridgeThread { }; 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()) .build()?; Ok(maxima.content_manager().add_install(game).await?) } MaximaLibRequest::StartGameRequest(info, settings) => { Ok(start_game_request(maxima_arc.clone(), info, settings).await?) } + MaximaLibRequest::SaveGameSettings(slug, settings) => { + // Persist the UI settings into the core GameSettingsManager + let mut maxima = maxima_arc.lock().await; + let manager = maxima.mut_game_settings(); + manager.save(&slug, settings); + Ok(()) + } MaximaLibRequest::ShutdownRequest => break 'outer Ok(()), //TODO: kill the bridge thread }; if let Err(err) = action { diff --git a/maxima-ui/src/main.rs b/maxima-ui/src/main.rs index e3e0278..0359736 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::gamesettings::GameSettings; use renderers::{app_bg_renderer, game_view_bg_renderer}; use translation_manager::{positional_replace, TranslationManager}; @@ -186,23 +187,6 @@ pub enum GameDetailsWrapper { Available(GameDetails), } -#[derive(Clone, serde::Serialize, serde::Deserialize)] -pub struct GameSettings { - cloud_saves: bool, - launch_args: String, - exe_override: String, -} - -impl GameSettings { - pub fn new() -> Self { - Self { - cloud_saves: true, - launch_args: String::new(), - exe_override: String::new(), - } - } -} - #[derive(Clone)] pub struct GameVersionInfo { installed: String, @@ -599,31 +583,6 @@ fn tab_button(ui: &mut Ui, edit_var: &mut PageType, page: PageType, label: &str) // god-awful macro to do something incredibly simple because apparently wrapping it in a function has rustc fucking implode // say what you want about C++ footguns but rust is the polar fucking opposite, shooting you in the head for doing literally anything -macro_rules! set_app_modal { - ($arg1:expr, $arg2:expr) => { - if let Some(modal) = $arg2 { - match modal { - PopupModal::GameSettings(slug) => { - if $arg1.settings.game_settings.get(&slug).is_none() { - $arg1 - .settings - .game_settings - .insert(slug.clone(), crate::GameSettings::new()); - } - } - PopupModal::GameInstall(_) => { - $arg1.installer_state = InstallModalState::new(&$arg1.settings); - } - PopupModal::GameLaunchOOD(_) => {} - } - $arg1.modal = $arg2; - } else { - $arg1.modal = None; - } - }; -} - -pub(crate) use set_app_modal; impl MaximaEguiApp { fn tab_bar(&mut self, header: &mut Ui) { @@ -891,7 +850,7 @@ 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(); + self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::LocateGameRequest(slug.clone(), self.installer_state.locate_path.clone())).unwrap(); self.installer_state.locating = true; } }); @@ -916,7 +875,7 @@ impl MaximaEguiApp { } 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))).unwrap(); clear = true; } @@ -984,6 +943,17 @@ impl MaximaEguiApp { }); } if clear { + if let Some(PopupModal::GameSettings(slug)) = &self.modal { + if let Some(settings) = self.settings.game_settings.get(slug) { + // Send the updated settings to the backend to persist them + let _ = self.backend.backend_commander.send( + bridge_thread::MaximaLibRequest::SaveGameSettings( + slug.clone(), + settings.clone(), + ), + ); + } + } self.modal = None; } } diff --git a/maxima-ui/src/views/game_view.rs b/maxima-ui/src/views/game_view.rs index 5caae1e..15031a5 100644 --- a/maxima-ui/src/views/game_view.rs +++ b/maxima-ui/src/views/game_view.rs @@ -1,7 +1,7 @@ use crate::{ - bridge_thread, set_app_modal, translation_manager::TranslationManager, - widgets::enum_dropdown::enum_dropdown, GameDetails, GameDetailsWrapper, GameInfo, - InstallModalState, MaximaEguiApp, PageType, PopupModal, + bridge_thread, translation_manager::TranslationManager, widgets::enum_dropdown::enum_dropdown, + GameDetails, GameDetailsWrapper, GameInfo, InstallModalState, MaximaEguiApp, PageType, + PopupModal, }; use egui::{ pos2, vec2, Color32, Margin, Mesh, Pos2, Rect, RichText, Rounding, ScrollArea, Shape, Stroke, @@ -87,7 +87,7 @@ fn game_view_action_buttons(app: &mut MaximaEguiApp, game: &GameInfo, ui: &mut U if !app.settings.ignore_ood_games && &game.version.installed != &game.version.latest { - set_app_modal!(app, Some(PopupModal::GameLaunchOOD(game.slug.clone()))); + app.modal = Some(PopupModal::GameLaunchOOD(game.slug.clone())); } else { app.playing_game = Some(game.slug.clone()); let settings = app.settings.game_settings.get(&game.slug); @@ -114,14 +114,20 @@ fn game_view_action_buttons(app: &mut MaximaEguiApp, game: &GameInfo, ui: &mut U } else { let install_str = format!(" {} ", &localization.install.to_uppercase()); if game_view_action_button(install_str, buttons) { - set_app_modal!(app, Some(PopupModal::GameInstall(game.slug.clone()))); + app.installer_state = InstallModalState::new(&app.settings); + app.modal = Some(PopupModal::GameInstall(game.slug.clone())); } } } let settings_str = format!(" {} ", &localization.settings.to_uppercase()); if game_view_action_button(settings_str, buttons) { - set_app_modal!(app, Some(PopupModal::GameSettings(game.slug.clone()))); + if app.settings.game_settings.get(&game.slug).is_none() { + app.settings + .game_settings + .insert(game.slug.clone(), crate::GameSettings::new()); + } + app.modal = Some(PopupModal::GameSettings(game.slug.clone())); } }); });