diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..3c1f3636 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[resolver] +incompatible-rust-versions = "fallback" \ No newline at end of file diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 0533aae5..998d596a 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -1,9 +1,9 @@ name: Maxima Build -on: [push] +on: [push, pull_request] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request && github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -15,9 +15,9 @@ jobs: strategy: fail-fast: false matrix: - rust-version: [nightly] + rust-version: ["1.96", "stable", "nightly"] + experimental: [false, false, true] os: [ubuntu-latest, windows-latest] - experimental: [false] env: CARGO_TERM_COLOR: always @@ -26,18 +26,10 @@ jobs: - name: Checkout the repo uses: actions/checkout@v4 - - name: Cache Rust ${{ matrix.rust-version }} - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}\rust\.rustup - key: rust-${{ matrix.rust-version }}-${{ hashFiles('**/rust-toolchain') }} - - name: Setup Rust ${{ matrix.rust-version }} - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@master with: - profile: minimal toolchain: ${{ matrix.rust-version }} - override: true components: rustfmt, clippy - name: Install Protoc @@ -56,6 +48,15 @@ jobs: cache-on-failure: true save-if: ${{ github.ref == 'refs/heads/master' }} + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + - name: Format check + run: cargo fmt --all -- --check + + - name: Tests + run: cargo test --workspace + - name: Build Maxima if: runner.os == 'Windows' run: | diff --git a/.gitignore b/.gitignore index 8635fee0..a3d2ea4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /target /.vscode /.idea - -Cargo.lock +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 491cbaf7..67c4985c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -resolver = "2" +resolver = "3" members = [ "maxima-bootstrap", "maxima-cli", @@ -10,9 +10,58 @@ members = [ "maxima-resources", ] +[workspace.dependencies] +# Common Workspace Dependencies for all Maxima libs +tokio = { version = "1.53.1", features = ["rt-multi-thread", "time", "net", "sync", "macros", "fs", "process"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +rustls = { version = "0.23.43", features = ["aws-lc-rs"] } +reqwest = { version = "0.13.4", features = ["stream", "rustls"], default-features = false } +base64 = "0.23.1" +thiserror = "2.0.20" +anyhow = "1.0.104" +log = "0.4.34" +regex = "1.13.1" +futures = "0.3.34" +clap = { version = "4.6.6", features = ["derive"] } +inquire = "0.9.4" +strum = { version = "0.28.0", features = ["derive"] } +strum_macros = "0.28.0" +url = "2.5.8" +directories = "6.0.0" +hex = "0.4.3" + +# Common Target Specific Dependencies +windows-sys = { version = "0.61.2", features = ["Win32_System", "Win32_System_Threading", "Win32_System_Console", "Win32_System_Console"] } +winreg = "0.56.0" +windows-service = "0.8.1" +is_elevated = "0.1.2" + +[patch.crates-io] +async-compression = { git = "https://github.com/sjp761/async-compression.git", rev = "a226c72f754f942ad187cc65cb1f03df5ae26b7b", version = "0.4.42" } +flate2 = { git = "https://github.com/sjp761/flate2-rs.git", rev = "45fc9f6a56a97513b285029fee14213a2d0fe2e5", version = "1.1.10" } + +[workspace.package] +version = "0.1.0" +edition = "2024" +authors = ["Nick Whelan ", "Sean Kahler ", "Maxima Contributors"] +rust-version = "1.96.1" + [profile.release] strip = true +lto = "thin" +codegen-units = 1 +panic = "abort" -[patch.crates-io] -flate2 = { git = 'https://github.com/ArmchairDevelopers/flate2-rs.git' } -async-compression = { git = 'https://github.com/ArmchairDevelopers/async-compression.git' } +[profile.release-debug] +inherits = "release" +strip = false +debug = 1 + +[profile.dev] +opt-level = 0 +debug = true +split-debuginfo = "unpacked" + +[profile.dev.package."*"] +opt-level = 1 diff --git a/README.md b/README.md index cf8aabc2..baadf0c1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Maxima itself is a library (`maxima-lib`), with provided CLI (`maxima-cli`), TUI ![UI](images/UI.png) +**Current developing stack:** + +Rust 1.90.1 | UI : egui / glow | Requests : reqwest | Async support & work : tokio | Windows layer : windows-sys | Linux layer : homemade | macOS layer : homemade and many under the hood plugins that make this program possible. + **Features:** - EA Authentication - Downloading/Updating games @@ -24,20 +28,21 @@ Maxima itself is a library (`maxima-lib`), with provided CLI (`maxima-cli`), TUI - Locating games (aka. game importing) - Running games under [proton](https://github.com/GloriousEggroll/proton-ge-custom) on Linux/SteamDeck - `proton-ge` is automatically installed together with [umu](https://github.com/Open-Wine-Components/umu-launcher). - -**In-Dev:** - - macOS support - - Support for launching Maxima through Epic/Steam + +**Developer Roadmap:** + - macOS support (sjp761) + - Chat system + - Friend Adding/Removing/Inviting + - Support for launching Maxima through Epic/Steam (Link2EA protocol) + - Cleaner/Stabler downloader implementation **Planned:** - Library documentation/examples - Support for installing DLCs - Full EA Desktop interoperability. Games installed with EA Desktop already appear on Maxima, but to take it a step further we'd like the ability to, for example, start a download on EA Desktop and continue it on Maxima. - - Cleaner/Stabler downloader implementation - Progressive/Selective installs - Some games are able to start without being fully installed, and some games contain language-specific files. - Support for the store (buying games) - - Friend Adding/Removing/Inviting - Status setting; locked to "online" at the moment - Refactoring Maxima to new architecture allowing multiple frontends to co-exist diff --git a/maxima-bootstrap/Cargo.toml b/maxima-bootstrap/Cargo.toml index 4efd25dc..043d5f71 100644 --- a/maxima-bootstrap/Cargo.toml +++ b/maxima-bootstrap/Cargo.toml @@ -1,38 +1,19 @@ [package] name = "maxima-bootstrap" description = "Maxima handler for custom EA protocols" -version = "0.1.0" -authors = ["Sean Kahler "] -edition = "2021" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } [dependencies] maxima-lib = { path = "../maxima-lib" } -tokio = { version = "1.28.2", features = [ - "full", - "rt", - "rt-multi-thread", - "time", - "net", -] } -serde = { version = "1.0.164", features = ["derive"] } -serde_json = "1.0.97" -reqwest = { version = "0.11.18", features = ["stream", "rustls-tls"], default-features = false } -base64 = "0.21.2" -url = "2.4.0" -querystring = "1.1.0" +tokio = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true, features = ["query"] } +base64 = { workspace = true } +url = { workspace = true } urlencoding = "2.1.3" -thiserror = "2.0.12" - -[target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ - "memoryapi", - "handleapi", - "synchapi", - "wincon", - "consoleapi", -] } -winreg = "0.50.0" -windows-service = "0.6.0" +thiserror = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] cacao = "0.3.2" diff --git a/maxima-bootstrap/src/main.rs b/maxima-bootstrap/src/main.rs index 9cd16c2f..abe05c67 100644 --- a/maxima-bootstrap/src/main.rs +++ b/maxima-bootstrap/src/main.rs @@ -8,13 +8,13 @@ use std::string::FromUtf8Error; use thiserror::Error; use tokio::process::Command; -use base64::{engine::general_purpose, Engine}; +use base64::{Engine, engine::general_purpose}; use maxima::core::launch::BootstrapLaunchArgs; +use maxima::util::BackgroundServiceControlError; use maxima::util::native::NativeError; #[cfg(windows)] use maxima::util::service::{is_service_valid, register_service}; -use maxima::util::BackgroundServiceControlError; -use url::Url; +use url::{Url, form_urlencoded}; #[cfg(target_os = "macos")] mod macos; @@ -70,19 +70,18 @@ async fn handle_launch_args() -> Result { let result = run(&args).await; if cfg!(debug_assertions) || std::env::var("MAXIMA_DEBUG").is_ok() { - println!("Args: {:?}", &args); + println!("Args: {:?}", args); let str_result = result .as_ref() .map_err(|e| { let source = e.source(); - let error_str = if source.is_some() { + + if source.is_some() { source.unwrap().to_string() } else { e.to_string() - }; - - error_str + } }) .err() .unwrap_or("Success".to_string()); @@ -116,22 +115,24 @@ async fn platform_launch(args: BootstrapLaunchArgs) -> Result<(), NativeError> { let mut binding = Command::new(args.path); let child = binding.args(args.args); - let status = child.spawn()?.wait().await?; + let _status = child.spawn()?.wait().await?; // bail!("{}", status.code().unwrap()); Ok(()) } #[cfg(unix)] async fn platform_launch(args: BootstrapLaunchArgs) -> Result<(), NativeError> { - use maxima::unix::wine::run_wine_command; use maxima::unix::wine::CommandType; + use maxima::unix::wine::run_wine_command; + use maxima::unix::wine::wine_prefix_dir; run_wine_command( - args.path, + args.path.into(), Some(args.args), None, false, CommandType::WaitForExitAndRun, + &wine_prefix_dir(&args.slug).unwrap(), ) .await?; @@ -153,20 +154,30 @@ async fn run(args: &[String]) -> Result { if arg.starts_with("origin2") { let url = Url::parse(arg)?; - let query = querystring::querify(url.query().unwrap()); - let _offer_id = query.iter().find(|(x, _)| *x == "offerIds").unwrap().1; - let cmd_params = query.iter().find(|(x, _)| *x == "cmdParams").unwrap().1; + let mut offer_id = None; + let mut cmd_params = None; + + for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) { + match key.as_ref() { + "offerIds" => offer_id = Some(value), + "cmdParams" => cmd_params = Some(value), + _ => {} + } + } + + let _offer_id = offer_id.unwrap(); + let cmd_params = cmd_params.unwrap(); let mut child = Command::new(current_exe()?.with_file_name("maxima-cli.exe")); child.env( "MAXIMA_LAUNCH_ARGS", - urlencoding::decode(cmd_params)? + urlencoding::decode(cmd_params.as_ref())? .into_owned() .replace("\\\"", "\""), ); println!( "{}", - urlencoding::decode(cmd_params)? + urlencoding::decode(cmd_params.as_ref())? .into_owned() .replace("\\\"", "\"") ); @@ -178,7 +189,15 @@ async fn run(args: &[String]) -> Result { } if arg.starts_with("qrc") { - let query = arg.split("login_successful.html?").collect::>()[1]; + let raw_query = arg + .split("login_successful.html?") + .nth(1) + .unwrap_or_default(); + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in form_urlencoded::parse(raw_query.as_bytes()) { + serializer.append_pair(key.as_ref(), value.as_ref()); + } + let query = serializer.finish(); reqwest::get(format!("http://127.0.0.1:31033/auth?{}", query)).await?; return Ok(true); diff --git a/maxima-cli/Cargo.toml b/maxima-cli/Cargo.toml index 7bbedf68..457e5075 100644 --- a/maxima-cli/Cargo.toml +++ b/maxima-cli/Cargo.toml @@ -1,27 +1,21 @@ [package] name = "maxima-cli" -version = "0.1.0" -authors = ["Sean Kahler "] -edition = "2021" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } [dependencies] maxima-lib = { path = "../maxima-lib" } -clap = { version = "4.3.5", features = [ "derive" ] } -tokio = { version = "1.28.2", features = [ "full", "rt", "rt-multi-thread", "time", "net" ] } -serde = { version = "1.0.164", features = [ "derive" ] } -serde_json = "1.0.97" -anyhow = "1.0.71" -lazy_static = "1.4.0" -base64 = "0.21.2" -log = "0.4.19" -regex = "1.8.4" -inquire = "0.6.2" -futures = "0.3.30" +clap = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +log = { workspace = true } +regex = { workspace = true } +inquire = { workspace = true } +rustls = { workspace = true } [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi" ] } -winreg = "0.50.0" -is_elevated = "0.1.2" +is_elevated = { workspace = true } [build-dependencies] maxima-resources = { path = "../maxima-resources" } diff --git a/maxima-cli/src/main.rs b/maxima-cli/src/main.rs index de2ce960..d7a543e9 100644 --- a/maxima-cli/src/main.rs +++ b/maxima-cli/src/main.rs @@ -1,12 +1,18 @@ use clap::{Parser, Subcommand}; +use maxima::content::manager::ProgressCallback; +use maxima::core::manifest::handle_touchup_request; +use std::sync::atomic::{AtomicUsize, Ordering}; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use inquire::{Select, Text}; -use lazy_static::lazy_static; use log::{debug, error, info, warn}; use regex::Regex; -use std::{path::PathBuf, sync::Arc, time::Instant}; +use std::{ + path::PathBuf, + sync::{Arc, LazyLock}, + time::Instant, +}; #[cfg(windows)] use is_elevated::is_elevated; @@ -18,37 +24,30 @@ use maxima::{ }; use maxima::{ - content::{ - downloader::ZipDownloader, - manager::{QueuedGame, QueuedGameBuilder}, - ContentService, - }, + content::{ContentService, downloader::ZipDownloader, manager::QueuedGameBuilder}, core::{ + LockedMaxima, Maxima, MaximaEvent, MaximaOptionsBuilder, auth::{ - context::AuthContext, - login::{begin_oauth_login_flow, manual_login}, - nucleus_auth_exchange, nucleus_token_exchange, TokenResponse, + TokenResponse, context::AuthContext, login::begin_oauth_login_flow, + nucleus_auth_exchange, nucleus_token_exchange, }, clients::JUNO_PC_CLIENT_ID, cloudsync::CloudSyncLockMode, launch::{self, LaunchMode, LaunchOptions}, library::OwnedTitle, - manifest::{self, MANIFEST_RELATIVE_PATH}, service_layer::{ + SERVICE_REQUEST_GETBASICPLAYER, SERVICE_REQUEST_GETLEGACYCATALOGDEFS, ServiceGetBasicPlayerRequestBuilder, ServiceGetLegacyCatalogDefsRequestBuilder, - ServiceLegacyOffer, ServicePlayer, SERVICE_REQUEST_GETBASICPLAYER, - SERVICE_REQUEST_GETLEGACYCATALOGDEFS, + ServiceLegacyOffer, ServicePlayer, }, - LockedMaxima, Maxima, MaximaEvent, MaximaOptionsBuilder, }, ooa, rtm::client::BasicPresence, util::{log::init_logger, native::take_foreground_focus, registry::check_registry_validity}, }; -lazy_static! { - static ref MANUAL_LOGIN_PATTERN: Regex = Regex::new(r"^(.*):(.*)$").unwrap(); -} +static MANUAL_LOGIN_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^(.*):(.*)$").expect("manual login regex should be valid")); #[derive(Subcommand, Debug)] enum Mode { @@ -70,6 +69,7 @@ enum Mode { ListGames, LocateGame { path: String, + slug: String, }, CloudSync { game_slug: String, @@ -119,18 +119,16 @@ struct Args { #[command(subcommand)] mode: Option, - #[arg(long)] - #[clap(global = true)] + #[arg(long, global = true)] login: Option, } #[tokio::main] async fn main() { - let result = startup().await; - - if let Some(e) = result.err() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + if let Err(e) = startup().await { match std::env::var("RUST_BACKTRACE") { - Ok(_) => error!("{}:\n{}", e, e.backtrace().to_string()), + Ok(_) => error!("{}:\n{}", e, e.backtrace()), Err(_) => error!("{}: {}", e, e.root_cause()), } } @@ -175,21 +173,7 @@ pub async fn login_flow(login_override: Option) -> Result let mut auth_context = AuthContext::new()?; if let Some(access_token) = &login_override { - let access_token = if let Some(captures) = MANUAL_LOGIN_PATTERN.captures(&access_token) { - let persona = &captures[1]; - let password = &captures[2]; - - let login_result = manual_login(persona, password).await; - if login_result.is_err() { - bail!("Login failed: {}", login_result.err().unwrap().to_string()); - } - - login_result.unwrap() - } else { - access_token.to_owned() - }; - - auth_context.set_access_token(&access_token); + auth_context.set_access_token(access_token); let code = nucleus_auth_exchange(&auth_context, JUNO_PC_CLIENT_ID, "code").await?; auth_context.set_code(&code); } else { @@ -204,12 +188,8 @@ pub async fn login_flow(login_override: Option) -> Result info!("Received login..."); } - let token_res = nucleus_token_exchange(&auth_context).await; - if token_res.is_err() { - bail!("Login failed: {}", token_res.err().unwrap().to_string()); - } - - Ok(token_res?) + let token_res = nucleus_token_exchange(&auth_context).await?; + Ok(token_res) } async fn startup() -> Result<()> { @@ -282,26 +262,27 @@ async fn startup() -> Result<()> { let offer_id = if login.is_none() { let mut maxima = maxima_arc.lock().await; let offer = maxima.mut_library().game_by_base_slug(&slug).await; - // TODO: ideally this function should return an Error type, but this frontend makes that complicated - if let Err(err) = offer { - bail!("Error fetching offer for slug `{}`: {}", slug, err); - } else { - let offer = offer.unwrap(); - // TODO: could do a match here as well, same problem as above - if offer.is_some() { - offer.unwrap().offer_id().to_owned() - } else { - bail!("No owned offer found for '{}'", slug); - } + match offer { + Ok(Some(offer)) => offer.offer_id().to_owned(), + Ok(None) => bail!("No owned offer found for '{}'", slug), + Err(err) => bail!("Error fetching offer for slug `{}`: {}", slug, err), } } 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,83 +336,79 @@ 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(); - for game in maxima.mut_library().games().await? { - if !game.base_offer().is_installed().await { - continue; - } - - owned_games.push(game); - } - - let owned_games_strs = 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() - }; + let game = select_owned_game( + maxima_arc.clone(), + "What game would you like to play?", + Some(true), + ) + .await?; - start_game(&offer_id, None, Vec::new(), None, maxima_arc.clone()).await?; + let offer_id = game.base_offer().offer_id().clone(); + let slug = game.base_offer().slug().clone(); + start_game(&offer_id, &slug, None, Vec::new(), None, maxima_arc.clone()).await?; Ok(()) } async fn interactive_install_game(maxima_arc: LockedMaxima) -> Result<()> { + let game = select_owned_game( + maxima_arc.clone(), + "What game would you like to install?", + Some(false), + ) + .await?; let mut maxima = maxima_arc.lock().await; - let offer_id = { - let mut owned_games = Vec::new(); - for game in maxima.mut_library().games().await? { - if game.base_offer().is_installed().await { - continue; - } - - owned_games.push(game); - } - - let owned_games_strs = owned_games - .iter() - .map(|g| g.name()) - .collect::>(); - - 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() - }; + let offer_id = game.base_offer().offer_id().clone(); + let slug = game.base_offer().slug().clone(); let builds = maxima .content_manager() .service() .available_builds(&offer_id) .await?; - let build = builds.live_build(); - if build.is_none() { - bail!("Couldn't find a suitable game build"); - } - let build = build.unwrap(); - info!("Installing game build {}", build.to_string()); + let Some(build) = builds.live_build() else { + bail!("Couldn't find a suitable game build"); + }; + info!("Installing game build {}", build); let path = PathBuf::from( - Text::new("Where would you like to install the game? (must be an absolute path)") - .prompt()?, + tokio::task::spawn_blocking(|| { + Text::new("Where would you like to install the game? (must be an absolute path)") + .prompt() + }) + .await??, ); if !path.is_absolute() { error!("Path {:?} is not absolute.", path); return Ok(()); } + #[cfg(unix)] + let wine_prefix = { + let input = tokio::task::spawn_blocking(|| { + Text::new("Where do you want to store the Wine prefix? (must be an absolute path)") + .prompt() + }) + .await??; + let p = PathBuf::from(input); + if !p.is_absolute() { + error!("Wine prefix {:?} is not absolute.", p); + return Ok(()); + } + Some(p) + }; + + #[cfg(not(unix))] + let wine_prefix = None; + let game = QueuedGameBuilder::default() .offer_id(offer_id) .build_id(build.build_id().to_owned()) .path(path.clone()) + .slug(slug) + .wine_prefix(wine_prefix) .build()?; let start_time = Instant::now(); @@ -439,33 +416,47 @@ async fn interactive_install_game(maxima_arc: LockedMaxima) -> Result<()> { drop(maxima); + const IDLE_TICK_LIMIT: usize = 5; + let mut idle_ticks = 0usize; + let mut saw_downloader = false; + loop { - let mut maxima = maxima_arc.lock().await; + { + let mut maxima = maxima_arc.lock().await; - for event in maxima.consume_pending_events() { - match event { - MaximaEvent::ReceivedLSXRequest(_pid, _request) => (), - _ => {} + for event in maxima.consume_pending_events() { + let MaximaEvent::ReceivedLSXRequest(_, _) = event else { + continue; + }; + debug!("Received LSX request event"); } - } - maxima.update().await; + maxima.update().await; - if let Some(downloader) = maxima.content_manager().current() { - info!("Downloading: {}%/100%", downloader.percentage_done()); - } else { - break; + if let Some(downloader) = maxima.content_manager().current() { + saw_downloader = true; + idle_ticks = 0; + info!( + "Downloading: {:.1}%/100%, {} bytes out of {}", + downloader.percentage_done(), + downloader.completed_bytes(), + downloader.bytes_total() + ); + } else if saw_downloader { + break; + } else { + idle_ticks += 1; + if idle_ticks >= IDLE_TICK_LIMIT { + bail!("Installer produced no download activity"); + } + } } - - drop(maxima); tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - let end_time = Instant::now(); - let elapsed_time = end_time - start_time; - + let elapsed_time = Instant::now() - start_time; info!( - "Download took {}.{}", + "Download took {}.{:03}s", elapsed_time.as_secs(), elapsed_time.subsec_millis() ); @@ -483,21 +474,18 @@ async fn download_specific_file( let content_service = ContentService::new(maxima.auth_storage().clone()); let builds = content_service.available_builds(offer).await?; - let build = builds.build(build_id); - if build.is_none() { + let Some(build) = builds.builds.iter().find(|b| b.build_id() == build_id) else { bail!("Couldn't find the game build {}", build_id); - } - - let build = build.unwrap(); - info!("Downloading file from game build {}", build.to_string()); + }; + info!("Downloading file from game build {}", build); let url = content_service - .download_url(offer, Some(&build.build_id())) + .download_url(offer, Some(build.build_id())) .await?; debug!("URL: {}", url.url()); - let downloader = ZipDownloader::new("test-game", &url.url(), "C:/DownloadTest").await?; + let downloader = ZipDownloader::new(url.url()).await?; let num_of_entries = downloader.manifest().entries().len(); info!("Entries: {}", num_of_entries); @@ -510,45 +498,69 @@ async fn download_specific_file( bail!("Couldn't find the file {}", file); } + let completed_bytes = Arc::new(AtomicUsize::new(0)); + let on_progress: ProgressCallback = Box::new(move |bytes| { + completed_bytes.fetch_add(bytes, Ordering::SeqCst); + }); let ele = entry.unwrap(); - downloader.download_single_file(ele, None).await.unwrap(); + let output_dir = std::env::current_dir()?.join("downloads"); + downloader + .download_single_file(ele, &output_dir, on_progress) + .await?; - info!( - "Downloaded file {} from game build {}", - file, - build.to_string() - ); + info!("Downloaded file {} from game build {}", file, build); Ok(()) } -async fn generate_download_links(maxima_arc: LockedMaxima) -> Result<()> { +async fn select_owned_game( + maxima_arc: LockedMaxima, + prompt: &str, + installed: Option, +) -> Result { let mut maxima = maxima_arc.lock().await; + let mut games = Vec::new(); + for game in maxima.mut_library().games().await? { + if let Some(installed) = installed { + if game.base_offer().is_installed().await != installed { + continue; + } + } + games.push(game.clone()); + } - let content_service = ContentService::new(maxima.auth_storage().clone()); - - let owned_games = maxima.mut_library().games().await?; - let owned_games_strs = owned_games + let names = games .iter() - .map(|g| g.name()) + .map(|game| game.name()) .collect::>(); + let name = Select::new(prompt, names).prompt()?; - let name = Select::new( + Ok(games + .into_iter() + .find(|game| game.name() == name) + .expect("selected game should exist")) +} + +async fn generate_download_links(maxima_arc: LockedMaxima) -> Result<()> { + let game = select_owned_game( + maxima_arc.clone(), "What game would you like to list builds for?", - owned_games_strs, + None, ) - .prompt()?; - let game = owned_games.iter().find(|g| g.name() == name).unwrap(); + .await?; + let maxima = maxima_arc.lock().await; + + let content_service = ContentService::new(maxima.auth_storage().clone()); info!("Working..."); let builds = content_service - .available_builds(&game.base_offer().offer_id()) + .available_builds(game.base_offer().offer_id()) .await?; let mut strs = String::new(); for build in builds.builds { let url = content_service - .download_url(&game.base_offer().offer_id(), Some(&build.build_id())) + .download_url(game.base_offer().offer_id(), Some(build.build_id())) .await; if url.is_err() { continue; @@ -609,12 +621,7 @@ async fn juno_token_refresh(maxima_arc: LockedMaxima) -> Result<()> { bail!("Login failed!"); } - let token_res = nucleus_token_exchange(&context).await; - if token_res.is_err() { - bail!("Login failed: {}", token_res.err().unwrap().to_string()); - } - - let token_res = token_res.unwrap(); + let token_res = nucleus_token_exchange(&context).await?; info!("Access Token: {}", token_res.access_token()); info!("Refresh Token: {:?}", token_res.refresh_token()); info!("Token Type: {}", token_res.token_type()); @@ -622,7 +629,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 @@ -653,7 +660,7 @@ async fn get_user_by_id(maxima_arc: LockedMaxima, user_id: &str) -> Result<()> { let player: ServicePlayer = maxima .service_layer() .request( - SERVICE_REQUEST_GETBASICPLAYER, + &SERVICE_REQUEST_GETBASICPLAYER, ServiceGetBasicPlayerRequestBuilder::default() .pd(user_id.to_string()) .build()?, @@ -661,61 +668,54 @@ async fn get_user_by_id(maxima_arc: LockedMaxima, user_id: &str) -> Result<()> { .await?; info!("Name: {}", player.display_name()); - dbg!(player); Ok(()) } -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()), - // Err(err) => error!("{}", err), - // }; - +async fn get_game_by_slug(_maxima_arc: LockedMaxima, _slug: &str) -> Result<()> { + // TODO: implement Ok(()) } async fn test_rtm_connection(maxima_arc: LockedMaxima) -> Result<()> { - let mut maxima = maxima_arc.lock().await; - let friends = maxima.friends(0).await?; + let friends = { + let mut maxima = maxima_arc.lock().await; + let friends = maxima.friends(0).await?; - let rtm = maxima.rtm(); - rtm.login().await?; - rtm.set_presence(BasicPresence::Online, "Test", "Origin.OFR.50.0002148") - .await?; + let rtm = maxima.rtm(); + rtm.login().await?; + rtm.set_presence(BasicPresence::Online, "Test", "Origin.OFR.50.0002148") + .await?; - let players: Vec = friends.iter().map(|f| f.id().to_owned()).collect(); - info!("Subscribed to {} players", players.len()); + let players: Vec = friends.iter().map(|f| f.id().to_owned()).collect(); + info!("Subscribed to {} players", players.len()); - rtm.subscribe(&players).await?; - drop(maxima); + rtm.subscribe_all().await?; - loop { - let mut maxima = maxima_arc.lock().await; - maxima.rtm().heartbeat().await?; + friends + }; + loop { { + let mut maxima = maxima_arc.lock().await; + maxima.rtm().heartbeat().await?; let store = maxima.rtm().presence_store().lock().await; - for entry in store.iter() { + for (id, presence) in store.iter() { + let name = friends + .iter() + .find(|x| *x.id() == *id) + .map(|f| f.display_name()) + .map_or("unknown", |v| v); info!( "{}/{} is {:?}: In {}", - friends - .iter() - .find(|x| x.id().to_owned() == *entry.0) - .unwrap() - .display_name(), - entry.0, - entry.1.basic(), - entry.1.status() + name, + id, + presence.basic(), + presence.status() ); } } - - drop(maxima); - - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + tokio::time::sleep(std::time::Duration::from_secs(30)).await; } } @@ -724,7 +724,7 @@ async fn get_legacy_catalog_def(maxima_arc: LockedMaxima, offer_id: &str) -> Res let defs: Vec = maxima .service_layer() .request( - SERVICE_REQUEST_GETLEGACYCATALOGDEFS, + &SERVICE_REQUEST_GETLEGACYCATALOGDEFS, ServiceGetLegacyCatalogDefsRequestBuilder::default() .offer_ids(vec![offer_id.to_owned()]) .locale(maxima.locale().clone()) @@ -768,10 +768,9 @@ async fn list_games(maxima_arc: LockedMaxima) -> Result<()> { Ok(()) } -async fn locate_game(maxima_arc: LockedMaxima, path: &str) -> Result<()> { - let path = PathBuf::from(path); - let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await?; - manifest.run_touchup(&path).await?; +async fn locate_game(_maxima_arc: LockedMaxima, install_path: &str, slug: &str) -> Result<()> { + let path: PathBuf = PathBuf::from(install_path); + handle_touchup_request(path, None, slug).await?; info!("Installed!"); Ok(()) } @@ -809,6 +808,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, @@ -825,7 +825,7 @@ async fn start_game( let players: Vec = friends.iter().map(|f| f.id().to_owned()).collect(); info!("Subscribed to {} players", players.len()); - maxima.rtm().subscribe(&players).await?; + maxima.rtm().subscribe_all().await?; } } @@ -855,21 +855,20 @@ async fn start_game( } loop { - let mut maxima = maxima_arc.lock().await; - - for event in maxima.consume_pending_events() { - match event { - MaximaEvent::ReceivedLSXRequest(_pid, _request) => (), - _ => {} + { + let mut maxima = maxima_arc.lock().await; + for event in maxima.consume_pending_events() { + let MaximaEvent::ReceivedLSXRequest(_, _) = event else { + continue; + }; + debug!("Received LSX request event"); } - } - maxima.update().await; - if maxima.playing().is_none() { - break; + maxima.update().await; + if maxima.playing().is_none() { + break; + } } - - drop(maxima); tokio::time::sleep(std::time::Duration::from_secs(1)).await; } diff --git a/maxima-lib/Cargo.toml b/maxima-lib/Cargo.toml index 9f3a6e77..774fb276 100644 --- a/maxima-lib/Cargo.toml +++ b/maxima-lib/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "maxima-lib" -version = "1.0.0" -edition = "2021" -authors = ["Sean Kahler "] +version = { workspace = true } +edition = { workspace = true } +authors = { workspace = true } categories = ["games"] description = "A free and open-source replacement for the EA Desktop Launcher" keywords = ["electronic-arts", "linux-gaming"] @@ -10,99 +10,101 @@ license = "GPL-3.0" readme = "README.md" repository = "https://github.com/ArmchairDevelopers/Maxima" documentation = "https://docs.rs/maxima" -rust-version = "1.81.0" +rust-version = { workspace = true } [dependencies] -tokio = { version = "1.28.2", features = [ - "full", - "rt", - "rt-multi-thread", - "time", - "net", -] } -moka = { version = "0.12.1", features = ["sync"] } -serde = { version = "1.0.164", features = ["derive"] } -serde_json = "1.0.97" -quick-xml = { version = "0.30.0", features = ["serialize"] } -paste = "1.0.12" -hex = "0.4.3" -lazy_static = "1.4.0" -reqwest = { version = "0.11.18", features = ["stream", "rustls-tls", "gzip", "json"], default-features = false } -base64 = "0.21.2" -base62 = "2.2.0" -log = "0.4.19" -enable-ansi-support = "0.2.1" -chrono = "0.4.26" -regex = "1.8.4" -directories = "5.0.1" -open = "5.0.0" -querystring = "1.1.0" -ureq = { version = "2.9.1", features = ["tls"] } -sysinfo = "0.29.7" -strum_macros = "0.25.2" -sha2-const = "0.1.2" -tar = "0.4" -flate2 = { version = "=1.0.28", default-features = false, features = ["zlib-default"] } +scopeguard = "1.2.0" +tokio = { workspace = true } +moka = { version = "0.12.16", features = ["sync"] } +serde = { workspace = true } +serde_json = { workspace = true } +quick-xml = { version = "0.42.0", features = ["serialize"] } +pastey = "0.2.3" +hex = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "form", "query", "gzip", "json"] } +base64 = { workspace = true } +log = { workspace = true } +enable-ansi-support = "0.3.1" +chrono = "0.4.45" +regex = { workspace = true } +directories = { workspace = true } +open = "5.4.2" +ureq = { version = "3.4.0", features = ["rustls"] } +sysinfo = "0.39.6" +strum_macros = { workspace = true } +sha2 = "0.11.0" +tar = "0.4.46" xz2 = "0.1.7" -zstd = "0.13.0" -toml = "0.8.8" -bytebuffer = "2.2.0" -derive-getters = "0.3.0" -derive_builder = "0.12.0" -mac_address = "1.1.5" -filetime = "0.2.23" -rand = "0.9.0" -ring = "0.17.7" -futures = "0.3.30" -prost = "0.12.3" -encoding = "0.2.33" -async-compression = { version = "0.4.5", features = ["tokio", "deflate"] } -tokio-util = { version = "0.7.10", features = ["io", "compat"] } -uuid = "1.7.0" -tokio-rustls = "0.23.1" -rustls = "0.20.0" -webpki-roots = "0.22.0" -aes = "0.8.4" -ecb = { version = "0.1.2", features = ["std"] } -cbc = { version = "0.1.2", features = ["std"] } -glob = "0.3.1" -md5 = "0.7.0" -bytes = "1.5.0" -async-trait = "0.1.81" -crc32fast = "1.4.2" -async-recursion = "1.1.1" +toml = "1.1.4" +bytebuffer = "2.3.0" +derive-getters = "0.5.0" +derive_builder = "0.20.2" +mac_address = "1.1.8" +rand = "0.10.2" +ring = "0.17.14" +futures = { workspace = true } +prost = "0.14.4" +encoding_rs = "0.8.35" +tokio-util = { version = "0.7.19", features = ["io", "compat"] } +uuid = "1.25.0" +tokio-rustls = "0.26.4" +rustls = { workspace = true } +webpki-roots = "1.0.9" +aes = "0.9.2" +ecb = { version = "0.2.1", features = ["alloc", "block-padding", "zeroize"] } +cbc = { version = "0.2.1", features = ["alloc", "block-padding", "zeroize"] } +glob = "0.3.4" +md5 = "0.8.1" +bytes = "1.12.1" +async-trait = "0.1.92" +crc32fast = "1.5.1" pelite = "0.10.0" -gethostname = "0.5.0" -thiserror = "2.0.12" -url = "2.5.2" -http = "0.2.12" +gethostname = "1.1.0" +thiserror = { workspace = true } +url = { workspace = true } +http = "1.5.0" +globset = "0.4.20" +rustls-pki-types = "1.15.1" +tokio-stream = "0.1.19" +sha1 = "0.11.0" +libz-sys = "1.1.29" +anyhow = { workspace = true } +dirs = "6.0.0" +async-compression = { version = "0.4.42", features = ["tokio", "deflate"] } [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ - "memoryapi", - "handleapi", - "synchapi", - "wincon", - "consoleapi", - "psapi", - "wininet", - "winerror", - "iphlpapi", - "tcpmib", - "winsvc", - "impl-default" +windows-sys = { workspace = true, features = [ + "Win32_System_LibraryLoader", + "Win32_UI_Shell", + "Win32_System_ProcessStatus", + "Win32_Security_Authorization", + "Win32_Security", + "Win32_Networking_WinInet", + "Win32_Foundation", + "Win32_System_WindowsProgramming", + "Win32_System_Threading", + "Win32_System_Console", + "Win32_System_Registry", + "Win32_System_Memory", + "Win32_System_Diagnostics_Debug", + "Win32_UI_WindowsAndMessaging", + "Win32_Storage_FileSystem", + "Win32_System_Services", ] } -winreg = "0.51.0" -windows-service = "0.6.0" -is_elevated = "0.1.2" -widestring = "1.0.2" -wmi = "0.13.1" +winreg = { workspace = true } +windows-service = { workspace = true } +is_elevated = { workspace = true } +widestring = "1.2.1" +wmi = "0.18.4" + +[target.'cfg(unix)'.dependencies] +which = "8.0" [target.'cfg(target_os = "macos")'.dependencies] -smbios-lib = "0.9.1" +smbios-lib = "0.9.4" [build-dependencies] -prost-build = "0.12.3" +prost-build = "0.14.4" [lib] name = "maxima" diff --git a/maxima-lib/build.rs b/maxima-lib/build.rs index ea64d751..a334786a 100644 --- a/maxima-lib/build.rs +++ b/maxima-lib/build.rs @@ -1,3 +1,14 @@ fn main() -> std::io::Result<()> { - prost_build::compile_protos(&["src/rtm/proto/rtm.proto"], &["src/rtm/proto/"]) + prost_build::compile_protos( + &[ + "src/rtm/proto/rtm.proto", + "src/rtm/proto/common.proto", + "src/rtm/proto/presence.proto", + "src/rtm/proto/chat.proto", + "src/rtm/proto/config.proto", + "src/rtm/proto/player.proto", + "src/rtm/proto/antelope_common.proto", + ], + &["src/rtm/proto/"], + ) } diff --git a/maxima-lib/src/content/downloader.rs b/maxima-lib/src/content/downloader.rs index e982a190..eb0c5386 100644 --- a/maxima-lib/src/content/downloader.rs +++ b/maxima-lib/src/content/downloader.rs @@ -1,520 +1,229 @@ +use crate::content::zlib::restore_zlib_state; use std::{ - cmp, - io::{self, Cursor, Read, Seek, SeekFrom, Write}, + io, path::{Path, PathBuf}, pin::Pin, - prelude, - sync::{Arc, Mutex}, - task, + task::{self, Poll}, }; -use crate::{ - content::{ - manager::DownloaderError, - zip::{CompressionType, ZipFile, ZipFileEntry}, - zlib::{restore_zlib_state, write_zlib_state}, - }, - util::{ - hash::hash_file_crc32, - native::{maxima_dir, NativeError, SafeParent, SafeStr}, - }, -}; +use anyhow::{Result, bail}; use async_compression::tokio::write::DeflateDecoder; -use async_trait::async_trait; -use bytes::{Buf, BufMut, Bytes, BytesMut}; -use derive_getters::Getters; -use flate2::bufread::DeflateDecoder as BufreadDeflateDecoder; +use bytes::{Bytes, BytesMut}; use futures::{Stream, StreamExt, TryStreamExt}; -use log::{debug, error, info, warn}; -use reqwest::Client; +use log::{debug, error}; +use reqwest::{Client, StatusCode}; use strum_macros::Display; -use thiserror::Error; use tokio::{ - fs::{create_dir, create_dir_all, File, OpenOptions}, - io::{AsyncReadExt, AsyncSeekExt, AsyncWrite, BufReader, BufWriter}, - runtime::Handle, + fs::{OpenOptions, create_dir_all}, + io::{AsyncWrite, AsyncWriteExt, BufReader}, }; use tokio_util::compat::FuturesAsyncReadCompatExt; -fn zstate_path(id: &str, path: &str) -> Result { - let mut path = maxima_dir()?.join("temp/downloader").join(id).join(path); - path.set_extension("eazstate"); - std::fs::create_dir_all(path.safe_parent()?)?; - Ok(path) -} +use crate::content::{manager::ProgressCallback, zip::CompressionType, zlib::write_zlib_state}; -#[async_trait] -trait DownloadDecoder: Send { - fn save_state(&mut self, buf: &mut BytesMut); - fn restore_state(&mut self, buf: &mut Bytes); +use super::zip::{ZipFile, ZipFileEntry}; - fn seek(&mut self, pos: SeekFrom) -> Result<(), DownloaderError>; +/// How often we persist the zlib state, in bytes of *compressed* input. +const STATE_SAVE_INTERVAL: usize = 1_048_576; +const MAX_RETRIES: u32 = 3; - fn write_in_pos(&self) -> u64; - fn write_out_pos(&self) -> u64; +#[derive(Debug)] +enum DecoderRestoreError { + CacheEmpty, +} - fn get_mut<'b>(&mut self) -> Arc>; +trait RestorableDecoder { + fn save_state(&mut self); + fn restore_state(&mut self) -> Result<(u64, u64), DecoderRestoreError>; } -struct ZLibDeflateDecoder { - decoder: Arc>>>, +struct RestorableDeflateDecoder { + inner: DeflateDecoder, + file_name: String, + file_path: PathBuf, + bytes_written: usize, + bytes_since_last: usize, + should_save: bool, } -impl ZLibDeflateDecoder { - fn new(writer: BufWriter) -> Self { +impl RestorableDeflateDecoder { + fn new(decoder: DeflateDecoder, file_name: String, file_path: PathBuf) -> Self { Self { - decoder: Arc::new(Mutex::new(DeflateDecoder::new(writer))), + inner: decoder, + file_name, + file_path, + bytes_written: 0, + bytes_since_last: 0, + should_save: false, } } -} -#[async_trait] -impl DownloadDecoder for ZLibDeflateDecoder { - fn save_state(&mut self, buf: &mut BytesMut) { - let mut decoder = self.decoder.lock().unwrap(); - let zstream = decoder.inner_mut().decoder_mut().inner.decompress.get_raw(); - write_zlib_state(buf, zstream); + fn update_bytes_written(&mut self, bytes: usize) { + self.bytes_written += bytes; + self.bytes_since_last += bytes; + self.should_save = self.bytes_since_last >= STATE_SAVE_INTERVAL; } - fn restore_state(&mut self, buf: &mut Bytes) { - let mut decoder = self.decoder.lock().unwrap(); - let decompress = &mut decoder.inner_mut().decoder_mut().inner.decompress; - decompress.reset(false); - let zstream = decompress.get_raw(); - restore_zlib_state(buf, zstream); - } - - fn seek(&mut self, pos: SeekFrom) -> Result<(), DownloaderError> { - let mut decoder = self.decoder.lock().unwrap(); - let file = decoder.get_mut(); - - let handle = Handle::current(); - let _ = handle.enter(); - futures::executor::block_on(file.seek(pos))?; - - Ok(()) - } + fn state_path(&self) -> Result { + let dir = dirs::cache_dir() + .ok_or_else(|| anyhow::anyhow!("no cache dir"))? + .join("Maxima"); + std::fs::create_dir_all(&dir)?; - fn write_in_pos(&self) -> u64 { - let mut decoder = self.decoder.lock().unwrap(); - let decompress = &mut decoder.inner_mut().decoder_mut().inner.decompress; - let zstream = decompress.get_raw(); - zstream.total_in as u64 + let safe_name = self.file_name.replace(['/', '\\'], "_"); + Ok(dir.join(format!("{}.state", safe_name))) } - fn write_out_pos(&self) -> u64 { - let mut decoder = self.decoder.lock().unwrap(); - let decompress = &mut decoder.inner_mut().decoder_mut().inner.decompress; - let zstream = decompress.get_raw(); - zstream.total_out as u64 + fn zstream(&mut self) -> &mut libz_sys::z_stream { + self.inner + .inner_mut() + .decoder_mut() + .inner + .decompress + .get_raw() } - fn get_mut(&mut self) -> Arc> { - self.decoder.clone() + pub fn get_mut(&mut self) -> &mut W { + self.inner.get_mut() } } -struct NoopDecoder { - writer: Arc>>, - pos: u64, -} - -impl NoopDecoder { - pub fn new(writer: BufWriter) -> Self { - Self { - writer: Arc::new(Mutex::new(writer)), - pos: 0, +impl RestorableDecoder for RestorableDeflateDecoder { + fn save_state(&mut self) { + let mut buf = BytesMut::new(); + write_zlib_state(&mut buf, self.zstream()); + + match self.state_path() { + Ok(path) => { + if let Err(e) = std::fs::write(&path, buf) { + error!("Failed to write state {}: {e}", path.display()); + } else { + self.bytes_since_last = 0; + debug!("Serialized zlib state"); + } + } + Err(e) => error!("Failed to resolve state path: {e}"), } } -} - -#[async_trait] -impl DownloadDecoder for NoopDecoder { - fn save_state(&mut self, buf: &mut BytesMut) { - self.pos = self.writer.lock().unwrap().buffer().len() as u64; - buf.put_u64(self.pos); - } - fn restore_state(&mut self, buf: &mut Bytes) { - self.pos = buf.get_u64(); + #[cfg(unix)] + fn restore_state(&mut self) -> Result<(u64, u64), DecoderRestoreError> { + return Err(DecoderRestoreError::CacheEmpty); } - fn seek(&mut self, pos: SeekFrom) -> Result<(), DownloaderError> { - let mut file = self.writer.lock().unwrap(); + #[cfg(windows)] + fn restore_state(&mut self) -> Result<(u64, u64), DecoderRestoreError> { + use log::info; - let handle = Handle::current(); - let _ = handle.enter(); - futures::executor::block_on(file.seek(pos))?; - - Ok(()) - } - - fn write_in_pos(&self) -> u64 { - self.pos - } + if !self.file_path.exists() || std::fs::metadata(self.file_path.clone()).unwrap().len() == 0 + { + return Err(DecoderRestoreError::CacheEmpty); + } + let path = self + .state_path() + .map_err(|_| DecoderRestoreError::CacheEmpty)?; - fn write_out_pos(&self) -> u64 { - self.pos - } + if !path.exists() { + debug!("No cache available."); + return Err(DecoderRestoreError::CacheEmpty); + } - fn get_mut<'b>(&mut self) -> Arc> { - self.writer.clone() - } -} + info!("Got some cache!"); + let mut bytes = Bytes::from(std::fs::read(path).unwrap()); -trait AsyncWriteWrapper: AsyncWrite + Unpin + Send {} -impl AsyncWriteWrapper for T {} + let decompress = &mut self.inner.inner_mut().decoder_mut().inner.decompress; + decompress.reset(false); -struct AsyncWriterWrapper<'a> { - id: String, - path: String, - zlib_state_file: std::fs::File, - decoder: &'a mut Box, - inner: Arc>, -} + { + let zstream = decompress.get_raw(); + restore_zlib_state(&mut bytes, zstream); + } + debug!("reset and restored zlib state"); -impl<'a> AsyncWriterWrapper<'a> { - async fn new( - id: String, - path: String, - decoder: &'a mut Box, - ) -> Result { - let inner = decoder.get_mut(); - Ok(AsyncWriterWrapper { - id: id.to_owned(), - path: path.to_owned(), - zlib_state_file: std::fs::OpenOptions::new() - .write(true) - .create(true) - .open(zstate_path(&id, &path)?)?, - decoder, - inner, - }) + Ok((decompress.total_in(), decompress.total_out())) } } -impl<'a> AsyncWrite for AsyncWriterWrapper<'a> { +impl AsyncWrite for RestorableDeflateDecoder { fn poll_write( mut self: Pin<&mut Self>, cx: &mut task::Context<'_>, buf: &[u8], - ) -> task::Poll> { - let poll_result = { - let mut binding = self.inner.lock().unwrap(); - let inner = Pin::new(&mut *binding); - inner.poll_write(cx, buf) - }; - - // State serialization is disabled for now. - // let mut bytes = BytesMut::new(); - // self.decoder.save_state(&mut bytes); + ) -> Poll> { + let poll_result = Pin::new(&mut self.inner).poll_write(cx, buf); - // self.zlib_state_file.seek(SeekFrom::Start(0))?; - // self.zlib_state_file.write(&bytes)?; - - poll_result - } - - fn poll_flush( - self: Pin<&mut Self>, - cx: &mut task::Context<'_>, - ) -> task::Poll> { - Pin::new(&mut *self.inner.lock().unwrap()).poll_flush(cx) - } - - fn poll_shutdown( - self: Pin<&mut Self>, - cx: &mut task::Context<'_>, - ) -> task::Poll> { - Pin::new(&mut *self.inner.lock().unwrap()).poll_shutdown(cx) - } -} - -#[derive(Error, Debug)] -pub enum DownloadError { - #[error("download failed ({0} bytes")] - DownloadFailed(usize), - #[error("failed to download chunk `{entry}`: {error}")] - ChunkDownload { - entry: String, - error: reqwest::Error, - }, - #[error("failed to copy chunk `{entry}`: {error}")] - ChunkCopy { - entry: String, - error: std::io::Error, - }, -} - -#[derive(PartialEq, Debug)] -enum EntryDownloadState { - Fresh, - Resumable, - Complete, - Borked, -} - -struct DownloadContext { - id: String, - path: PathBuf, -} - -type BytesDownloadedCallback = Box; - -struct EntryDownloadRequest<'a> { - context: &'a DownloadContext, - url: &'a str, - entry: &'a ZipFileEntry, - client: Client, - decoder: Box, - callback: Option, -} - -impl<'a> EntryDownloadRequest<'a> { - pub fn new( - context: &'a DownloadContext, - url: &'a str, - entry: &'a ZipFileEntry, - client: Client, - decoder: Box, - callback: Option, - ) -> Self { - Self { - context, - url, - entry, - client, - decoder, - callback, + if let Poll::Ready(Ok(n)) = &poll_result { + self.update_bytes_written(*n); } - } - - async fn state( - context: &DownloadContext, - entry: &ZipFileEntry, - ) -> Result { - let path = context.path.join(entry.name()); - let file_size = File::open(&path).await?.metadata().await?.len() as i64; - - if file_size == 0 { - return Ok(EntryDownloadState::Fresh); - } - - let entry_size = *entry.uncompressed_size(); - let size_match = entry_size == file_size; - - if !size_match { - warn!("Size mismatch: {}/{}", entry_size, file_size); - if file_size > entry_size { - return Ok(EntryDownloadState::Borked); - } - - return Ok(EntryDownloadState::Borked); + #[cfg(windows)] + if self.should_save + && let Poll::Ready(Ok(())) = Pin::new(&mut self.inner).poll_flush(cx) + { + debug!("save interval reached, serializing state..."); + self.save_state(); } - // We must be calculating the hash incorrectly or something - // let hash = match hash_file_crc32(&path) { - // Ok(hash) => hash, - // Err(_) => { - // warn!("Failed to retrieve hash for file {}", entry.name()); - // 0 - // } - // }; - - // let hash_match = *entry.crc32() != hash; - // if !hash_match { - // warn!("Hash mismatch"); - // return EntryDownloadState::Borked; - // } - - Ok(EntryDownloadState::Complete) + poll_result } - async fn download(&mut self) -> Result<(), DownloadError> { - let mut tries = 0; - while tries < 5 { - // State serialization is disabled for now. - //let start = self.decoder.write_in_pos() as i64; - - let start = 0; - - debug!( - "Downloading {} from {} to {} ({})", - self.entry.name(), - start, - self.entry.compressed_size(), - self.entry.uncompressed_size() - ); - let end = *self.entry.compressed_size(); - - let result = self.download_range(start, end).await; - if result.is_ok() { - break; - } - - tries += 1; - } - - Ok(()) + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) } - /// End is not inclusive - pub async fn download_range(&mut self, start: i64, end: i64) -> Result<(), DownloaderError> { - let offset = self.entry.data_offset(); - let range = format!("bytes={}-{}", offset + start as i64, offset + end - 1); - - let data = match self - .client - .get(self.url) - .header("range", range) - .send() - .await - { - Ok(res) => res, - Err(err) => { - error!("Failed to download ({}): {}", self.entry.name(), err); - return Err(DownloaderError::Download(DownloadError::ChunkDownload { - entry: self.entry.name().clone(), - error: err, - })); - } - }; - - let stream = data.bytes_stream(); - let counting_stream = ByteCountingStream::new(stream, self.callback.as_ref()); - let stream = counting_stream.into_async_read(); - let mut stream_reader = BufReader::new(stream.compat()); - - // State deserialization is disabled for now. - // let out_pos = self.decoder.write_out_pos(); - // self.decoder.seek(SeekFrom::Start(out_pos)); - - let mut wrapper = AsyncWriterWrapper::new( - self.context.id.to_owned(), - self.entry.name().to_owned(), - &mut self.decoder, - ) - .await?; - - let result = tokio::io::copy(&mut stream_reader, &mut wrapper).await; - if let Err(err) = result { - return Err(DownloaderError::Download(DownloadError::ChunkCopy { - entry: self.entry.name().clone(), - error: err, - })); - } - - Ok(()) + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) } } -#[derive(Getters)] pub struct ZipDownloader { - id: String, url: String, - path: PathBuf, client: Client, manifest: ZipFile, } impl ZipDownloader { - pub async fn new>( - id: &str, - zip_url: &str, - path: P, - ) -> Result - where - PathBuf: From

, - { - let path = PathBuf::from(path); - if !path.is_absolute() { - return Err(DownloaderError::PathNotAbsolute(path)); + fn clear_state(file_name: &str) { + if let Some(dir) = dirs::cache_dir() { + let safe_name = file_name.replace(['/', '\\'], "_"); + let _ = std::fs::remove_file(dir.join("Maxima").join(format!("{}.state", safe_name))); } + } - let manifest = ZipFile::fetch(zip_url).await?; - + pub async fn new(url: &str) -> Result { + let manifest = ZipFile::fetch(url).await?; Ok(Self { - id: id.to_owned(), - url: zip_url.to_owned(), - path, + url: url.to_owned(), client: Client::builder().build()?, manifest, }) } - pub async fn read_zip_entry_bytes( - &self, - entry: &ZipFileEntry, - length: u64, - ) -> Result { - let offset = entry.data_offset(); - let compressed_size = *entry.compressed_size(); - - let range_header = format!("bytes={}-{}", offset, offset + compressed_size - 1); - - let response = self - .client - .get(&self.url) - .header("Range", range_header) - .send() - .await?; - - if !response.status().is_success() - && response.status() != reqwest::StatusCode::PARTIAL_CONTENT - { - return Err(DownloaderError::Http(response.status())); - } - - let compressed_data = response.bytes().await?; - let decompressed_data = match entry.compression_type() { - CompressionType::None => { - let entry_size = *entry.uncompressed_size() as u64; - let available_length = std::cmp::min(length, entry_size); - - if available_length > compressed_data.len() as u64 { - return Err(DownloaderError::EntrySize { - requested: available_length, - entry: compressed_data.len(), - }); - } - - Bytes::copy_from_slice(&compressed_data[..available_length as usize]) - } - CompressionType::Deflate => { - let mut decoder = BufreadDeflateDecoder::new(Cursor::new(&compressed_data)); - let mut limited_reader = decoder.take(length); - let mut decompressed_data = Vec::with_capacity(length as usize); - limited_reader.read_to_end(&mut decompressed_data)?; - - Bytes::from(decompressed_data) - } - any => { - return Err(DownloaderError::CompressionType(any.to_owned())); - } - }; - - Ok(decompressed_data) + pub fn manifest(&self) -> &ZipFile { + &self.manifest } pub async fn download_single_file( &self, entry: &ZipFileEntry, - callback: Option, - ) -> Result { - let file_path = self.path.join(entry.name()); + output_dir: &Path, + progress_callback: ProgressCallback, + ) -> Result { + let file_path = output_dir.join(entry.name()); + + if let Some(parent) = file_path.parent().filter(|p| !p.exists()) { + debug!("Creating {}", parent.display()); + create_dir_all(parent).await?; + } - if !file_path.exists() { - if !file_path.safe_parent()?.exists() { - create_dir_all(&file_path.safe_parent()?).await?; - } + let name = entry.name().replace('\\', "/"); + let file_path = output_dir.join(&name); - if entry.name().ends_with("/") && !file_path.exists() { - // This is a folder, create the dir - debug!("{} is a directory", entry.name()); - create_dir(file_path).await?; - return Ok(0); + if name.ends_with('/') { + if !file_path.exists() { + create_dir_all(&file_path).await?; } + return Ok(0); } if *entry.uncompressed_size() == 0 { @@ -522,117 +231,160 @@ impl ZipDownloader { return Ok(0); } - let offset = entry.data_offset(); - debug!("Type: {:?}", entry.compression_type()); - debug!("Compressed Size: {}", entry.compressed_size()); - debug!("Offset: {}", offset); - - let file = OpenOptions::new() - .write(true) - .create(true) - .open(&file_path) - .await?; - - let context = DownloadContext { - id: self.id.to_owned(), - path: self.path.clone(), - }; - - let state = EntryDownloadRequest::state(&context, entry).await?; - if state == EntryDownloadState::Complete { - if let Some(callback) = callback { - callback(*entry.compressed_size() as usize); - } - return Ok(0); + let mut file_opts = OpenOptions::new(); + if file_path.exists() { + file_opts.append(true); // Existence check because the file is created with read only perms with append (bug in tokio maybe?) + } else { + file_opts.write(true); } - if state == EntryDownloadState::Borked { - warn!("Found borked file {}", entry.name()); - file.set_len(*entry.uncompressed_size() as u64).await?; - } + let file = file_opts.create(true).open(&file_path).await?; + let mut compressed_offset = 0; let writer = tokio::io::BufWriter::new(file); - - let mut decoder: Box = match entry.compression_type() { - CompressionType::None => Box::new(NoopDecoder::new(writer)), - CompressionType::Deflate => Box::new(ZLibDeflateDecoder::new(writer)), + let mut writer: Box = match entry.compression_type() { + CompressionType::None => { + compressed_offset = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + Box::new(writer) + } + CompressionType::Deflate => { + let decoder = DeflateDecoder::new(writer); + let mut decoder = + RestorableDeflateDecoder::new(decoder, entry.name().into(), file_path.clone()); + + match decoder.restore_state() { + Ok((bytes_in, _bytes_out)) => { + // bytes_in = compressed bytes consumed (from zlib state) + compressed_offset = bytes_in; // Use this for HTTP range + // Note: we don't need bytes_out here, but we keep it for set_len + decoder.get_mut().get_mut().set_len(_bytes_out).await?; + } + Err(err) => { + decoder.get_mut().get_mut().set_len(0).await?; + debug!("Failed to restore state for {}: {:?}", entry.name(), err); + } + } + Box::new(decoder) + } }; + progress_callback(compressed_offset as usize); + let offset = entry.data_offset(); + let start_offset = offset + compressed_offset as i64; // Add compressed offset, not decompressed + let end_offset = offset + entry.compressed_size() - 1; + + debug!( + "Requesting range: {}-{} (entry compressed size: {})", + start_offset, + end_offset, + entry.compressed_size() + ); - if state == EntryDownloadState::Resumable { - let state_file = zstate_path(&self.id, &entry.name())?; - if state_file.exists() { - let mut buf = Bytes::from(tokio::fs::read(state_file).await?); - decoder.restore_state(&mut buf); - } else { - tokio::fs::create_dir_all(state_file.safe_parent()?).await?; - } + // Ensure we don't request past the end or invalid ranges + if compressed_offset >= *entry.compressed_size() as u64 { + debug!("{} already fully downloaded, skipping", entry.name()); + writer.shutdown().await?; + Self::clear_state(entry.name()); + return Ok(compressed_offset); + } + if start_offset > end_offset { + bail!( + "Invalid range calculation: start {} > end {}", + start_offset, + end_offset + ); } - let mut request = EntryDownloadRequest::new( - &context, - &self.url, - entry, - self.client.clone(), - decoder, - callback, - ); + let range = format!("bytes={}-{}", start_offset, end_offset); + debug!("Type: {:?} | range: {}", entry.compression_type(), range); + + let mut attempt = 0; + let data = loop { + attempt += 1; + match self + .client + .get(&self.url) + .header("range", range.clone()) + .send() + .await + { + Ok(res) => { + // A resuming request that gets 200 instead of 206 would + // replay the whole stream and corrupt the output. + if compressed_offset > 0 && res.status() != StatusCode::PARTIAL_CONTENT { + bail!("server ignored Range request (status {})", res.status()); + } + if !res.status().is_success() { + bail!("download failed with status {}", res.status()); + } + break res; + } + Err(err) if attempt < MAX_RETRIES => { + error!( + "Failed to download ({}) attempt {attempt}: {err}", + file_path.display() + ); + tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await; + } + Err(err) => return Err(err.into()), + } + }; + + let stream = ByteCountingStream::new(data.bytes_stream(), progress_callback); + let mut stream_reader = BufReader::new(stream.into_async_read().compat()); - request.download().await?; - Ok(0) + tokio::io::copy(&mut stream_reader, &mut writer).await?; + writer.shutdown().await?; // flush decoder + BufWriter to disk + Ok(compressed_offset) } } -struct ByteCountingStream<'a, S> { +struct ByteCountingStream { inner: S, byte_count: usize, - callback: Option<&'a BytesDownloadedCallback>, + progress_callback: ProgressCallback, } -impl<'a, S> ByteCountingStream<'a, S> +impl ByteCountingStream where - S: Stream>, + S: Stream>, { - fn new(inner: S, callback: Option<&'a BytesDownloadedCallback>) -> Self { - ByteCountingStream { + fn new(inner: S, progress_callback: ProgressCallback) -> Self { + Self { inner, byte_count: 0, - callback, + progress_callback, } } +} - fn byte_count(&self) -> usize { - self.byte_count - } +#[derive(Debug, Display)] +pub enum DownloadError { + DownloadFailed(usize), } -impl<'a, S> Stream for ByteCountingStream<'a, S> +impl std::error::Error for DownloadError {} + +impl Stream for ByteCountingStream where - S: Stream> + Unpin, + S: Stream> + Unpin, { - type Item = Result; + type Item = io::Result; - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { + fn poll_next(mut self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll> { match self.inner.poll_next_unpin(cx) { - std::task::Poll::Ready(Some(Ok(chunk))) => { + Poll::Ready(Some(Ok(chunk))) => { + (self.progress_callback)(chunk.len()); self.byte_count += chunk.len(); - - if let Some(callback) = &self.callback { - callback(chunk.len()); - } - - std::task::Poll::Ready(Some(Ok(chunk))) - } - std::task::Poll::Ready(Some(Err(err))) => { - error!("Downloader error: {:?}", err); - std::task::Poll::Ready(Some(Err(futures::io::Error::other( - DownloadError::DownloadFailed(self.byte_count), - )))) + Poll::Ready(Some(Ok(chunk))) } - std::task::Poll::Ready(None) => std::task::Poll::Ready(None), - std::task::Poll::Pending => std::task::Poll::Pending, + Poll::Ready(Some(Err(_e))) => Poll::Ready(Some(Err(io::Error::other( + DownloadError::DownloadFailed(self.byte_count), + )))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, } } } diff --git a/maxima-lib/src/content/exclusion.rs b/maxima-lib/src/content/exclusion.rs new file mode 100644 index 00000000..59a4197c --- /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..a1f2980d 100644 --- a/maxima-lib/src/content/manager.rs +++ b/maxima-lib/src/content/manager.rs @@ -1,8 +1,9 @@ +use crate::{content::exclusion::get_exclusion_list, core::manifest::handle_touchup_request}; use std::{ path::PathBuf, sync::{ - atomic::{AtomicUsize, Ordering}, Arc, + atomic::{AtomicUsize, Ordering}, }, }; @@ -18,33 +19,33 @@ use tokio_util::sync::CancellationToken; use crate::{ content::{ - downloader::{DownloadError, ZipDownloader}, - zip::{self, CompressionType, ZipError, ZipFileEntry}, ContentService, + downloader::{DownloadError, ZipDownloader}, + zip::{CompressionType, ZipError, ZipFileEntry}, }, core::{ - auth::storage::LockedAuthStorage, - manifest::{self, ManifestError, MANIFEST_RELATIVE_PATH}, + MaximaEvent, auth::storage::LockedAuthStorage, manifest::ManifestError, service_layer::ServiceLayerError, - MaximaEvent, }, - util::native::{maxima_dir, NativeError}, + util::native::{NativeError, maxima_dir}, }; const QUEUE_FILE: &str = "download_queue.json"; +const MAX_CONCURRENT_DOWNLOADS: usize = 16; #[derive(Default, Builder, Getters, Clone, Serialize, Deserialize, PartialEq)] pub struct QueuedGame { offer_id: String, build_id: String, path: PathBuf, + slug: String, + wine_prefix: Option, } #[derive(Default, Getters, Serialize, Deserialize)] pub struct DownloadQueue { current: Option, paused: bool, - queued: Vec, completed: Vec, } @@ -89,45 +90,66 @@ pub enum DownloaderError { EntrySize { requested: u64, entry: usize }, #[error("unsupported compression type `{0:?}`")] CompressionType(CompressionType), + #[error(transparent)] + Other(#[from] anyhow::Error), } impl DownloadQueue { - pub(crate) async fn load() -> Result { + pub(crate) async fn load() -> Result { let file = maxima_dir()?.join(QUEUE_FILE); if !file.exists() { return Ok(Self::default()); } - let data = fs::read_to_string(file).await?; - let result = serde_json::from_str(&data); - if result.is_err() { - return Ok(Self::default()); + let data = fs::read_to_string(&file).await?; + match serde_json::from_str(&data) { + Ok(queue) => Ok(queue), + Err(err) => { + // Keep the corrupt file for inspection instead of silently discarding it + let backup = file.with_extension("json.bak"); + error!( + "Corrupt download queue, backing up to {}: {err}", + backup.display() + ); + let _ = fs::rename(&file, &backup).await; + Ok(Self::default()) + } } - - Ok(result?) } pub(crate) async fn save(&self) -> Result<(), ContentManagerError> { let file = maxima_dir()?.join(QUEUE_FILE); - fs::write(file, serde_json::to_string(&self)?).await?; + fs::write(file, serde_json::to_string(self)?).await?; Ok(()) } pub fn push_to_current(&mut self, game: QueuedGame) { - if let Some(current) = &self.current { - self.queued.push(current.clone()); + if let Some(current) = self.current.take() { + self.queued.push(current); } + self.current = Some(game); + } - self.current = Some(game.clone()); + /// FIFO: take the oldest queued game. + fn pop_next(&mut self) -> Option { + if self.queued.is_empty() { + None + } else { + Some(self.queued.remove(0)) + } } } +pub type ProgressCallback = Box; + pub struct GameDownloader { offer_id: String, - + slug: String, + path: PathBuf, + wine_prefix: Option, downloader: Arc, entries: Vec, - + output_dir: PathBuf, cancel_token: CancellationToken, completed_bytes: Arc, total_count: usize, @@ -146,11 +168,15 @@ impl GameDownloader { debug!("URL: {}", url.url()); - let downloader = ZipDownloader::new(&game.offer_id, &url.url(), &game.path).await?; - + let downloader = ZipDownloader::new(url.url()).await?; + let exclusion_list = get_exclusion_list(game.slug()); let mut entries = Vec::new(); 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()); } @@ -159,13 +185,16 @@ impl GameDownloader { .iter() .map(|x| *x.compressed_size() as usize) .sum::() - + 1; // Add 1 to account for running touchup at the end. Bad solution, but we're a bit rushed + + 1; // +1 accounts for the touchup step at the end 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, + output_dir: game.path.clone(), cancel_token: CancellationToken::new(), completed_bytes: Arc::new(AtomicUsize::new(0)), total_count, @@ -175,22 +204,28 @@ impl GameDownloader { } pub fn download(&self) { - let (downloader_arc, entries, cancel_token, completed_bytes, notify) = + let (downloader_arc, entries, cancel_token, completed_bytes, notify, output_dir) = self.prepare_download_vars(); - let total_count = self.total_count; + let notify_done = notify.clone(); + + let slug = self.slug.clone(); + let wine_prefix = self.wine_prefix.clone(); tokio::spawn(async move { let dl = GameDownloader::start_downloads( - total_count, downloader_arc, entries, cancel_token, completed_bytes, notify, + output_dir, + slug, + wine_prefix, ) .await; if let Err(err) = dl { - error!("Error when downloading!: `{:?}", err) + error!("Error when downloading!: `{err:?}`"); } + notify_done.notify_one(); }); } @@ -202,6 +237,7 @@ impl GameDownloader { CancellationToken, Arc, Arc, + PathBuf, ) { ( self.downloader.clone(), @@ -209,23 +245,25 @@ impl GameDownloader { self.cancel_token.clone(), self.completed_bytes.clone(), self.notify.clone(), + self.output_dir.clone(), ) } async fn start_downloads( - total_count: usize, downloader_arc: Arc, entries: Vec, cancel_token: CancellationToken, completed_bytes: Arc, notify: Arc, + output_dir: PathBuf, // Install dir + slug: String, + wine_prefix_path: Option, ) -> Result<(), DownloaderError> { - let mut handles = Vec::with_capacity(total_count); + let mut handles = Vec::with_capacity(entries.len()); - for i in 0..total_count { + for ele in entries { let downloader = downloader_arc.clone(); - let ele = entries[i].clone(); - + let output_dir = output_dir.clone(); let cancel_token = cancel_token.clone(); let completed_bytes = completed_bytes.clone(); @@ -234,10 +272,12 @@ impl GameDownloader { info!("Ele: {:?}", ele); } + let on_progress: ProgressCallback = Box::new(move |bytes| { + completed_bytes.fetch_add(bytes, Ordering::SeqCst); + }); + tokio::select! { - result = downloader.download_single_file(&ele, Some(Box::new(move |bytes| { - completed_bytes.fetch_add(bytes, Ordering::SeqCst); - }))) => { + result = downloader.download_single_file(&ele, &output_dir, on_progress) => { if let Err(err) = result { error!("File download failed: {}", err); } @@ -250,20 +290,21 @@ impl GameDownloader { } let _results = futures::stream::iter(handles) - .buffer_unordered(16) - .collect::>() + .buffer_unordered(MAX_CONCURRENT_DOWNLOADS) + .collect::>() .await; - let path = downloader_arc.path(); + // If we were cancelled mid-flight, don't run touchup or report completion + if cancel_token.is_cancelled() { + info!("Download cancelled, skipping touchup"); + notify.notify_one(); + return Ok(()); + } - info!("Files downloaded, running touchup..."); - let manifest = manifest::read(path.join(MANIFEST_RELATIVE_PATH)).await?; + handle_touchup_request(output_dir, wine_prefix_path, &slug).await?; - manifest.run_touchup(path).await?; info!("Installation finished!"); - completed_bytes.fetch_add(1, Ordering::SeqCst); - notify.notify_one(); Ok(()) } @@ -278,7 +319,7 @@ impl GameDownloader { } pub fn is_done(&self) -> bool { - self.completed_bytes.load(Ordering::SeqCst) == self.total_bytes + self.completed_bytes.load(Ordering::SeqCst) >= self.total_bytes } pub fn percentage_done(&self) -> f64 { @@ -294,9 +335,13 @@ impl GameDownloader { self.total_bytes } - pub fn offer_id(&self) -> &String { + pub fn offer_id(&self) -> &str { &self.offer_id } + + pub fn completed_bytes(&self) -> usize { + self.completed_bytes.load(Ordering::SeqCst) + } } #[derive(Getters)] @@ -316,13 +361,12 @@ impl ContentManager { } pub async fn add_install(&mut self, game: QueuedGame) -> Result<(), ContentManagerError> { - if self.queue.queued.is_empty() && self.queue.current == None && self.current.is_none() { + if self.queue.queued.is_empty() && self.queue.current.is_none() && self.current.is_none() { self.install_now(game).await?; } else { self.queue.queued.push(game); self.queue.save().await?; } - Ok(()) } @@ -332,19 +376,12 @@ impl ContentManager { self.current = None; } - if let Some(current) = &self.queue.current { - if current == &game { - self.install_direct(game).await?; - return Ok(()); - } - - self.queue.queued.push(current.clone()); - } - self.install_direct(game).await?; Ok(()) } + // Starts installation of a game immediately, without checking the queue. If another + // download is in progress, it will be cancelled. async fn install_direct(&mut self, game: QueuedGame) -> Result<(), ContentManagerError> { if self.current.is_some() { return Err(ContentManagerError::DownloadInProgress); @@ -359,21 +396,116 @@ impl ContentManager { Ok(()) } + // Cancels the current download and removes the game from the queue, if present. + pub async fn cancel_install(&mut self, offer_id: &str) -> Result<(), ContentManagerError> { + // Stop the in-flight download first so nothing keeps writing to disk. + if let Some(current) = &self.current + && current.offer_id() == offer_id + { + current.cancel(); + current.wait().await; // let the task settle before mutating state + self.current = None; + } + + if self + .queue + .current + .as_ref() + .is_some_and(|g| g.offer_id() == offer_id) + { + self.queue.current = None; + } + + self.queue.queued.retain(|g| g.offer_id() != offer_id); + self.queue.completed.retain(|g| g.offer_id() != offer_id); + self.queue.paused = false; + + self.queue.save().await?; + Ok(()) + } + + /// Pauses the active download. Partial files and saved zlib states stay + /// on disk, so it can be resumed later via `install_now` with the same game. + pub async fn pause_install(&mut self, offer_id: &str) -> Result<(), ContentManagerError> { + if let Some(current) = &self.current + && current.offer_id() == offer_id + { + current.cancel(); + current.wait().await; + self.current = None; + self.queue.paused = true; + self.queue.save().await?; + } + // If it's only queued (not downloading yet), there's nothing to pause. + Ok(()) + } + + /// Moves a queued install to the front, pausing whatever is currently + /// downloading and starting the requested game immediately. + pub async fn move_install_to_top(&mut self, offer_id: &str) -> Result<(), ContentManagerError> { + if self + .queue + .current + .as_ref() + .is_some_and(|g| g.offer_id() == offer_id) + { + return Ok(()); // already at the top + } + + let Some(index) = self + .queue + .queued + .iter() + .position(|g| g.offer_id() == offer_id) + else { + return Ok(()); // not in the queue at all + }; + + let game = self.queue.queued.remove(index); + + // Pause the current download and requeue it at the front. + if let Some(current) = self.current.take() { + current.cancel(); + current.wait().await; + } + if let Some(current) = self.queue.current.take() { + self.queue.queued.insert(0, current); + } + + self.install_direct(game).await + } + pub(crate) async fn update(&mut self) -> Result, ContentManagerError> { let mut event = None; - if let Some(current) = &self.current { - if current.is_done() { - event = Some(MaximaEvent::InstallFinished(current.offer_id.to_owned())); - self.current = None; - self.queue.current = None; + if let Some(current) = &self.current + && current.is_done() + { + let finished = self + .queue + .current + .take() + .expect("queue.current out of sync with active download"); + self.queue.completed.push(finished); + event = Some(MaximaEvent::InstallFinished(current.offer_id.to_owned())); + self.current = None; + self.queue.paused = false; // Reset pause flag when done + self.queue.save().await?; + } - if let Some(game) = self.queue.queued.pop() { - self.install_now(game).await?; - } + if self.current.is_none() + && !self.queue.paused + && let Some(game) = self.queue.pop_next() + { + self.install_direct(game).await?; + } - self.queue.save().await?; - } + if self.current.is_none() + && self.queue.current.is_some() + && !self.queue.paused + && let Some(game) = self.queue.current.take() + { + self.install_direct(game).await?; } Ok(event) diff --git a/maxima-lib/src/content/mod.rs b/maxima-lib/src/content/mod.rs index 4a98e432..e3b8e2e1 100644 --- a/maxima-lib/src/content/mod.rs +++ b/maxima-lib/src/content/mod.rs @@ -1,21 +1,38 @@ +use anyhow::Result; use std::time::Duration; use crate::core::{ auth::storage::LockedAuthStorage, cache::DynamicCache, service_layer::{ - ServiceAvailableBuild, ServiceAvailableBuilds, ServiceAvailableBuildsBuilder, - ServiceAvailableBuildsRequestBuilder, ServiceDownloadUrlMetadata, + SERVICE_REQUEST_AVAILABLEBUILDS, SERVICE_REQUEST_DOWNLOADURL, ServiceAvailableBuild, + ServiceAvailableBuildsRequestBuilder, ServiceDownloadType, ServiceDownloadUrlMetadata, ServiceDownloadUrlRequestBuilder, ServiceLayerClient, ServiceLayerError, - SERVICE_REQUEST_AVAILABLEBUILDS, SERVICE_REQUEST_DOWNLOADURL, }, }; pub mod downloader; +pub mod exclusion; pub mod manager; pub mod zip; pub mod zlib; +#[derive(Clone)] +pub struct ServiceAvailableBuilds { + pub builds: Vec, +} + +impl ServiceAvailableBuilds { + pub fn live_build(&self) -> Option<&ServiceAvailableBuild> { + self.builds.iter().find(|b| { + b.download_type() + .as_ref() + .unwrap_or(&ServiceDownloadType::None) + == &ServiceDownloadType::Live + }) + } +} + pub struct ContentService { service_layer: ServiceLayerClient, request_cache: DynamicCache, @@ -47,16 +64,14 @@ impl ContentService { let builds: Vec = self .service_layer .request( - SERVICE_REQUEST_AVAILABLEBUILDS, + &SERVICE_REQUEST_AVAILABLEBUILDS, ServiceAvailableBuildsRequestBuilder::default() .offer_id(offer_id.to_owned()) .build()?, ) .await?; - let builds = ServiceAvailableBuildsBuilder::default() - .builds(builds) - .build()?; + let builds = ServiceAvailableBuilds { builds }; self.request_cache.insert(cache_key, builds.clone()); Ok(builds) } @@ -74,7 +89,7 @@ impl ContentService { let url: ServiceDownloadUrlMetadata = self .service_layer .request( - SERVICE_REQUEST_DOWNLOADURL, + &SERVICE_REQUEST_DOWNLOADURL, ServiceDownloadUrlRequestBuilder::default() .offer_id(offer_id.to_owned()) .build_id(build_id.unwrap_or_default().to_owned()) diff --git a/maxima-lib/src/content/zip.rs b/maxima-lib/src/content/zip.rs index be5e8b01..c0797e8d 100644 --- a/maxima-lib/src/content/zip.rs +++ b/maxima-lib/src/content/zip.rs @@ -1,9 +1,9 @@ use bytebuffer::{ByteBuffer, Endian}; use derive_getters::Getters; -use encoding::{all::WINDOWS_1252, DecoderTrap, Encoding}; +use encoding_rs::WINDOWS_1252; use log::{debug, warn}; -use reqwest::header::ToStrError; use reqwest::Client; +use reqwest::header::ToStrError; use std::cmp; use std::string::FromUtf8Error; use thiserror::Error; @@ -165,10 +165,11 @@ impl ZipFileEntry { debug!("Using UTF-8..."); String::from_utf8(name_bytes)? } else { - match WINDOWS_1252.decode(&name_bytes, DecoderTrap::Strict) { - Ok(s) => s, - Err(_) => return Err(EntryError::Decode), + let (s, _, had_errors) = WINDOWS_1252.decode(&name_bytes); + if had_errors { + return Err(EntryError::Decode); } + s.into_owned() }; entry.extra_field = data.read_bytes(extra_field_len as usize)?; @@ -247,7 +248,7 @@ impl EndOfCentralDirectory { } let signature = data.read_u32()?; - if signature as u32 != ZIP_EOCD_SIGNATURE { + if signature != ZIP_EOCD_SIGNATURE { return Err(EOCDError::Signature(signature)); } @@ -271,7 +272,7 @@ impl EndOfCentralDirectory { } let signature = data.read_u32()?; - if signature as u32 != ZIP64_EOCD_SIGNATURE { + if signature != ZIP64_EOCD_SIGNATURE { return Err(ZipError::Signature(signature)); } @@ -283,12 +284,12 @@ impl EndOfCentralDirectory { data.read_u16()?; data.read_u16()?; - self.disk_number = data.read_u32()? as u32; - self.disk_number_with_cd = data.read_u32()? as u32; + self.disk_number = data.read_u32()?; + self.disk_number_with_cd = data.read_u32()?; self.disk_entries = data.read_i64()? as u64; self.total_entries = data.read_i64()? as u64; self.cd_size = data.read_i64()? as u64; - self.cd_offset = data.read_i64()? as i64; + self.cd_offset = data.read_i64()?; self.comment_length = 0; Ok(()) @@ -418,7 +419,7 @@ impl ZipFile { idx: i, total: eocd.total_entries, err: format!("{:?}", err).to_string(), - }) + }); } Ok(e) => e, }; diff --git a/maxima-lib/src/content/zlib.rs b/maxima-lib/src/content/zlib.rs index 10e5c9c8..6b0a46e9 100644 --- a/maxima-lib/src/content/zlib.rs +++ b/maxima-lib/src/content/zlib.rs @@ -1,23 +1,20 @@ use std::{ - os::raw::{c_char, c_int, c_uchar, c_uint, c_ulong, c_ushort, c_void}, + os::raw::{c_char, c_int, c_uint, c_ulong, c_ushort}, ptr, }; use bytes::{Buf, BufMut, Bytes, BytesMut}; -use flate2::raw::{gz_headerp, mz_stream, z_streamp}; -use log::error; +use libz_sys::{gz_headerp, z_stream as mz_stream, z_streamp}; pub const Z_ENOUGH_LENS: usize = 852; pub const Z_ENOUGH_DISTS: usize = 592; pub const Z_ENOUGH: usize = Z_ENOUGH_LENS + Z_ENOUGH_DISTS; -const Z_MAGIC: u32 = u32::from_be_bytes(*b"ZSTA"); - #[repr(C)] #[derive(Copy, Clone)] pub struct ZCode { - pub op: c_uchar, - pub bits: c_uchar, + pub op: u8, + pub bits: u8, pub val: c_ushort, } @@ -73,70 +70,46 @@ pub struct ZInflateState { pub was: c_uint, } -#[cfg(not(windows))] -macro_rules! if_win { - ($_zng:tt, $not_zng:tt) => { - $not_zng - }; -} - -#[cfg(windows)] -macro_rules! if_win { - ($zng:tt, $_not_zng:tt) => { - $zng - }; -} - -type ZSize = if_win!(u32, c_ulong); -type ZChecksum = if_win!(u32, c_ulong); - -pub(crate) fn write_zlib_state(buf: &mut BytesMut, stream: &mut mz_stream) { - buf.put_u32(Z_MAGIC); - +pub fn write_zlib_state(buf: &mut BytesMut, stream: &mut mz_stream) { + // `as u64` casts: c_ulong is u32 on Windows, u64 on 64-bit Unix. buf.put_u64(stream.total_in as u64); buf.put_u64(stream.total_out as u64); buf.put_i32(stream.data_type); buf.put_u64(stream.adler as u64); let state = stream.state as *mut ZInflateState; - let state_ref = unsafe { &mut *state }; + assert!(!state.is_null(), "zlib stream has no inflate state"); + let state_ref = unsafe { &*state }; - let size = std::mem::size_of::(); - let mut buffer = vec![0; size]; + let size = size_of::(); + let mut blob = vec![0u8; size]; unsafe { - ptr::copy_nonoverlapping(state, buffer.as_mut_ptr() as *mut ZInflateState, 1); - } - - for byte in buffer { - buf.put_u8(byte); + ptr::copy_nonoverlapping(state as *const u8, blob.as_mut_ptr(), size); } + buf.extend_from_slice(&blob); if !state_ref.window.is_null() { - let window_size = 1 << state_ref.wbits; - let mut window_buffer = vec![0; window_size]; - unsafe { - ptr::copy_nonoverlapping(state_ref.window, window_buffer.as_mut_ptr(), window_size); - } - - for byte in window_buffer { - buf.put_i8(byte); - } + let window_size = 1usize << state_ref.wbits; + let window = + unsafe { std::slice::from_raw_parts(state_ref.window as *const u8, window_size) }; + buf.extend_from_slice(window); } - let mut lencode_index = unsafe { state_ref.lencode.offset_from(state_ref.codes.as_ptr()) }; - let mut distcode_index = unsafe { state_ref.distcode.offset_from(state_ref.codes.as_ptr()) }; - let mut next_index = unsafe { state_ref.next.offset_from(state_ref.codes.as_ptr()) }; - - if lencode_index > Z_ENOUGH.try_into().unwrap() { - lencode_index = 0; - } - - if distcode_index > Z_ENOUGH.try_into().unwrap() { - distcode_index = 0; - } - - if next_index > Z_ENOUGH.try_into().unwrap() { - next_index = 0; + let base = state_ref.codes.as_ptr(); + let lencode_index = unsafe { state_ref.lencode.offset_from(base) }; + let distcode_index = unsafe { state_ref.distcode.offset_from(base) }; + let next_index = unsafe { state_ref.next.offset_from(base) }; + + for (name, idx) in [ + ("lencode", lencode_index), + ("distcode", distcode_index), + ("next", next_index), + ] { + assert!( + (0..Z_ENOUGH as isize).contains(&idx), + "Can't serialize this zlib state, {name} out of range \ + (lencode: {lencode_index}, distcode: {distcode_index}, next: {next_index})" + ); } buf.put_u32(lencode_index as u32); @@ -147,51 +120,39 @@ pub(crate) fn write_zlib_state(buf: &mut BytesMut, stream: &mut mz_stream) { buf.put_u32(state_ref.distbits); } -pub(crate) fn restore_zlib_state(buf: &mut Bytes, stream: &mut mz_stream) { - if buf.get_u32() != Z_MAGIC { - error!("Invalid magic number while reading zlib state"); - return; - } - - stream.total_in = buf.get_u64() as ZSize; - stream.total_out = buf.get_u64() as ZSize; +pub fn restore_zlib_state(buf: &mut Bytes, stream: &mut mz_stream) { + stream.total_in = buf.get_u64() as _; + stream.total_out = buf.get_u64() as _; stream.data_type = buf.get_i32(); - stream.adler = buf.get_u64() as ZChecksum; + stream.adler = buf.get_u64() as _; let state = stream.state as *mut ZInflateState; - let state_ref = unsafe { &mut *state }; + assert!(!state.is_null(), "zlib stream has no inflate state"); - let size = std::mem::size_of::(); - for i in 0..size { - let byte = buf.get_u8(); - unsafe { - ptr::copy_nonoverlapping( - &byte, - (state as *mut c_uchar).offset(i.try_into().unwrap()), - 1, - ); - } - } + let size = size_of::(); + let blob = buf.copy_to_bytes(size); unsafe { - (*state).strm = &mut *stream; + ptr::copy_nonoverlapping(blob.as_ptr(), state as *mut u8, size); + (*state).strm = stream; } + let state_ref = unsafe { &mut *state }; + if !state_ref.window.is_null() { - let window_size = 1 << state_ref.wbits; + let window_size = 1usize << state_ref.wbits; + let window = buf.copy_to_bytes(window_size); - let streamp = stream as z_streamp; + // libz-sys wraps C function pointers in Option; + let zalloc = stream.zalloc; state_ref.window = - unsafe { (stream.zalloc)(streamp as *mut c_void, 1, window_size) } as *mut i8; - - for i in 0..window_size { - let byte = buf.get_u8(); - unsafe { - ptr::copy_nonoverlapping( - &byte, - state_ref.window.offset(i.try_into().unwrap()) as *mut u8, - 1, - ); - } + unsafe { zalloc(stream.opaque, 1, window_size as c_uint) as *mut c_char }; + assert!( + !state_ref.window.is_null(), + "zalloc failed for inflate window" + ); + + unsafe { + ptr::copy_nonoverlapping(window.as_ptr(), state_ref.window as *mut u8, window_size); } } @@ -199,13 +160,21 @@ pub(crate) fn restore_zlib_state(buf: &mut Bytes, stream: &mut mz_stream) { let distcode = buf.get_u32() as isize; let nextcode = buf.get_u32() as isize; - if lencode > Z_ENOUGH.try_into().unwrap() { - panic!("Can't deserialize this zlib state, lencode too high!"); + for (name, idx) in [ + ("lencode", lencode), + ("distcode", distcode), + ("next", nextcode), + ] { + assert!( + (0..Z_ENOUGH as isize).contains(&idx), + "Can't deserialize this zlib state, {name} out of range" + ); } - state_ref.lencode = unsafe { state_ref.codes.as_ptr().offset(lencode) as *mut ZCode }; - state_ref.distcode = unsafe { state_ref.codes.as_ptr().offset(distcode) as *mut ZCode }; - state_ref.next = unsafe { state_ref.codes.as_ptr().offset(nextcode) as *mut ZCode }; + let base = state_ref.codes.as_mut_ptr(); + state_ref.lencode = unsafe { base.offset(lencode) }; + state_ref.distcode = unsafe { base.offset(distcode) }; + state_ref.next = unsafe { base.offset(nextcode) }; state_ref.lenbits = buf.get_u32(); state_ref.distbits = buf.get_u32(); diff --git a/maxima-lib/src/core/auth/context.rs b/maxima-lib/src/core/auth/context.rs index df782719..a994cfe6 100644 --- a/maxima-lib/src/core/auth/context.rs +++ b/maxima-lib/src/core/auth/context.rs @@ -5,10 +5,10 @@ use crate::core::{ clients::JUNO_PC_CLIENT_ID, endpoints::API_NUCLEUS_AUTH, }; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rand::random; use ring::hmac::HMAC_SHA256; -use sha2_const::Sha256; +use sha2::{Digest, Sha256}; /// Context with utilities for auth flow pub struct AuthContext<'a> { @@ -42,12 +42,14 @@ impl AuthContext<'_> { fn generate_code_verifier() -> String { let rand_bytes: [u8; 32] = random(); - URL_SAFE_NO_PAD.encode(&rand_bytes) + URL_SAFE_NO_PAD.encode(rand_bytes) } fn generate_challenge(code_verifier: &String) -> String { - let hash = Sha256::new().update(code_verifier.as_bytes()).finalize(); - URL_SAFE_NO_PAD.encode(hash) + let mut hash = Sha256::new(); + hash.update(code_verifier.as_bytes()); + let result = hash.finalize(); + URL_SAFE_NO_PAD.encode(result) } pub fn generate_pc_sign(&self) -> Result { diff --git a/maxima-lib/src/core/auth/hardware.rs b/maxima-lib/src/core/auth/hardware.rs index c11df25d..00c4f272 100644 --- a/maxima-lib/src/core/auth/hardware.rs +++ b/maxima-lib/src/core/auth/hardware.rs @@ -2,7 +2,7 @@ use crate::util::hash::hash_fnv1a; use gethostname::gethostname; use hex::ToHex; use regex::Regex; -use ring::digest::SHA1_FOR_LEGACY_USE_ONLY; +use sha1::{Digest, Sha1}; use std::arch::x86_64::CpuidResult; use thiserror::Error; @@ -53,17 +53,16 @@ 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; - use wmi::{COMLibrary, FilterValue, WMIConnection}; + use wmi::{FilterValue, WMIConnection}; use crate::util::wmi_utils; let wmi_thread = std::thread::spawn(move || { - let com_con = COMLibrary::new().unwrap(); - let wmi_con = WMIConnection::new(com_con).unwrap(); + let wmi_con = WMIConnection::new().unwrap(); let os_data: Vec = wmi_con.query().unwrap(); let bios_data: Vec = wmi_con.query().unwrap(); @@ -93,34 +92,34 @@ impl HardwareInfo { let mut board_manufacturer = "Microsoft Corporation"; let mut board_sn = "None"; - if let Some(board_info) = board_data.get(0) { + if let Some(board_info) = board_data.first() { board_manufacturer = board_info.manufacturer.as_str(); board_sn = board_info.serial_number.as_str(); } let mut bios_manufacturer = "Microsoft Corporation"; let mut bios_sn = "None"; - if let Some(bios_info) = bios_data.get(0) { + if let Some(bios_info) = bios_data.first() { bios_manufacturer = bios_info.manufacturer.as_str(); bios_sn = bios_info.serial_number.as_str(); } let mut os_install_date = "1970-01-0100:00:00.000000000+0000"; let mut os_sn = "None"; - if let Some(os_info) = os_data.get(0) { + if let Some(os_info) = os_data.first() { os_install_date = os_info.install_date.as_str(); os_sn = os_info.serial_number.as_str(); } let mut disk_sn = "None"; - if let Some(disk_info) = disk_data.get(0) { + if let Some(disk_info) = disk_data.first() { disk_sn = disk_info.serial_number.as_str(); } let volume_sn = format!("{:08x}", get_c_drive_volume_serial()); let mut gpu_pnp_id: Option = None; - if let Some(gpu_info) = gpu_data.get(0) { + if let Some(gpu_info) = gpu_data.first() { let device_id = &gpu_info.pnp_device_id; let device_id = device_id[..device_id.rfind('\\').unwrap_or(device_id.len())].to_string(); @@ -149,7 +148,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 +166,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,11 +245,11 @@ 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::{ - table_load_from_device, SMBiosBaseboardInformation, SMBiosSystemInformation, + SMBiosBaseboardInformation, SMBiosSystemInformation, table_load_from_device, }; use crate::util::system_profiler_utils::SPDisplaysDataType; @@ -273,7 +272,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 +281,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 +298,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 @@ -348,8 +351,8 @@ impl HardwareInfo { pub fn get_cpu_details() -> CpuDetails { use core::arch::x86_64::__cpuid; - let m = unsafe { __cpuid(0) }; - let flags = unsafe { __cpuid(1) }; + let m = { __cpuid(0) }; + let flags = { __cpuid(1) }; let mut man = Vec::new(); for val in [m.ebx, m.edx, m.ecx] { @@ -360,7 +363,7 @@ impl HardwareInfo { let mut brand_name = Vec::with_capacity(47); 'outer: for eax in [0x80000002, 0x80000003, 0x80000004] { - let part = unsafe { __cpuid(eax) }; + let part = { __cpuid(eax) }; for val in [part.eax, part.ebx, part.ecx, part.edx] { for b in val.to_ne_bytes() { if b == 0 { @@ -456,11 +459,12 @@ impl HardwareInfo { final_data.push(';'); } log::debug!("Hardware hash string \"{}\"", final_data); - let digest = ring::digest::digest(&SHA1_FOR_LEGACY_USE_ONLY, final_data.as_bytes()); + let mut hasher = Sha1::new(); + hasher.update(final_data.as_bytes()); + let digest = hasher.finalize(); if self.version < 4 { // they fucked up the format and used :x instead of :02x digest - .as_ref() .iter() .map(|byte| format!("{:x}", byte)) .collect::>() @@ -472,22 +476,23 @@ impl HardwareInfo { } #[cfg(unix)] -fn get_root_creation_str() -> String { +fn get_root_creation_str(slug: Option<&str>) -> String { + //Option for slug incase of PCSign use crate::unix::wine::wine_prefix_dir; - use chrono::{TimeZone, Utc}; + use chrono::DateTime; 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 date_str = match fs::metadata(wine_prefix.join("drive_c")) { + let wine_prefix = match slug { + Some(slug) => wine_prefix_dir(slug).unwrap(), + None => return date_str, + }; + let date_str = match fs::metadata(wine_prefix.join("pfx").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); + let datetime = + DateTime::from_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(); } @@ -549,10 +554,9 @@ fn extract_diskutil_volume_uuid(output: &str) -> Option<&str> { #[cfg(target_os = "windows")] fn get_c_drive_volume_serial() -> u32 { use std::ptr; - use winapi::shared::minwindef::DWORD; - use winapi::um::fileapi::GetVolumeInformationW; + use windows_sys::Win32::Storage::FileSystem::GetVolumeInformationW; - let mut serial: DWORD = 0; + let mut serial: u32 = 0; let res = unsafe { GetVolumeInformationW( "C:\\" @@ -585,7 +589,7 @@ fn get_ea_mac_address() -> Option { match mac { Some(address) => { - let mac = hex::encode(&address.bytes()); + let mac = hex::encode(address.bytes()); Some("$".to_owned() + &mac) } None => None, diff --git a/maxima-lib/src/core/auth/login.rs b/maxima-lib/src/core/auth/login.rs index 8a7209f4..cb818210 100644 --- a/maxima-lib/src/core/auth/login.rs +++ b/maxima-lib/src/core/auth/login.rs @@ -1,16 +1,17 @@ -use lazy_static::lazy_static; use regex::Regex; +use std::sync::LazyLock; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::net::TcpListener; +use url::form_urlencoded; use crate::core::{auth::storage::AuthError, clients::JUNO_PC_CLIENT_ID}; use super::context::AuthContext; -lazy_static! { - static ref HTTP_PATTERN: Regex = - Regex::new(r"^([A-Za-z]+) +(.*) +(HTTP/[0-9][.][0-9])").unwrap(); -} +static HTTP_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"^([A-Za-z]+) +(.*) +(HTTP/[0-9][.][0-9])") + .expect("HTTP pattern regex should be valid") +}); pub async fn begin_oauth_login_flow<'a>(context: &mut AuthContext<'a>) -> Result<(), AuthError> { open::that(context.nucleus_auth_url(JUNO_PC_CLIENT_ID, "code")?)?; @@ -35,23 +36,25 @@ pub async fn begin_oauth_login_flow<'a>(context: &mut AuthContext<'a>) -> Result let query = path_and_query .split_once("?") .map(|(_, qs)| qs.trim()) - .map(querystring::querify) + .map(|qs| { + let normalized = form_urlencoded::Serializer::new(String::new()) + .extend_pairs(form_urlencoded::parse(qs.as_bytes())) + .finish(); + + form_urlencoded::parse(normalized.as_bytes()) + .into_owned() + .collect::>() + }) .ok_or(AuthError::Query)?; - for query in query { - if query.0 == "code" { - context.set_code(query.1); + for (key, value) in query { + if key == "code" { + context.set_code(value.as_str()); return Ok(()); } } - return Err(AuthError::NoAuthCode.into()); + return Err(AuthError::NoAuthCode); } } } - -// Use the OOA API to retrieve an access token without a captcha -#[deprecated(note = "This method of login was patched and this function will be removed soon")] -pub async fn manual_login(_persona: &str, _password: &str) -> Result { - unimplemented!(); -} diff --git a/maxima-lib/src/core/auth/mod.rs b/maxima-lib/src/core/auth/mod.rs index b8852804..7d25a4b9 100644 --- a/maxima-lib/src/core/auth/mod.rs +++ b/maxima-lib/src/core/auth/mod.rs @@ -12,9 +12,10 @@ use super::{ use crate::core::auth::storage::{AuthError, TokenError}; use context::AuthContext; use derive_getters::Getters; -use reqwest::{redirect, Client, Url}; +use reqwest::{Client, Url, redirect}; use serde::Deserialize; use thiserror::Error; +use url::form_urlencoded::{Serializer, parse}; pub async fn nucleus_auth_exchange<'a>( auth_context: &AuthContext<'a>, @@ -61,18 +62,20 @@ pub async fn nucleus_auth_exchange<'a>( url.query() }; - let query = querystring::querify(query.ok_or(AuthError::Query)?); + let query = query.ok_or(AuthError::Query)?; + let normalized_query = Serializer::new(String::new()) + .extend_pairs(parse(query.as_bytes())) + .finish(); if response_type == "token" { response_type = "access_token"; } - let token = query - .iter() - .find(|(x, _)| *x == response_type) - .ok_or(AuthError::Query)? - .1; - Ok(token.to_owned()) + let token = parse(normalized_query.as_bytes()) + .find(|(key, _)| key == response_type) + .map(|(_, value)| value.into_owned()) + .ok_or(AuthError::Query)?; + Ok(token) } #[derive(Error, Debug)] @@ -104,8 +107,8 @@ pub async fn nucleus_token_exchange( let query = vec![ ("grant_type", "authorization_code"), - ("code", &auth_context.code().ok_or(TokenError::Absent)?), - ("code_verifier", &auth_context.code_verifier()), + ("code", auth_context.code().ok_or(TokenError::Absent)?), + ("code_verifier", auth_context.code_verifier()), ("client_id", JUNO_PC_CLIENT_ID), ("client_secret", JUNO_PC_CLIENT_SECRET), ("redirect_uri", "qrc:///html/login_successful.html"), 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/auth/storage.rs b/maxima-lib/src/core/auth/storage.rs index 546534f1..c8c5bcfa 100644 --- a/maxima-lib/src/core/auth/storage.rs +++ b/maxima-lib/src/core/auth/storage.rs @@ -1,11 +1,9 @@ use super::{ - nucleus_connect_token_refresh, token_info::NucleusTokenInfo, TokenRefreshError, TokenResponse, + TokenRefreshError, TokenResponse, nucleus_connect_token_refresh, token_info::NucleusTokenInfo, }; use crate::core::auth::hardware::HardwareHashError; -use crate::ooa::LicenseError; -use crate::util::native::{maxima_dir, NativeError}; +use crate::util::native::{NativeError, maxima_dir}; use log::info; -use reqwest::header::ToStrError; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::{ diff --git a/maxima-lib/src/core/background_service_nix.rs b/maxima-lib/src/core/background_service_nix.rs index d383350c..d657fe68 100644 --- a/maxima-lib/src/core/background_service_nix.rs +++ b/maxima-lib/src/core/background_service_nix.rs @@ -1,17 +1,17 @@ -use base64::{engine::general_purpose, Engine}; -use lazy_static::lazy_static; +use base64::{Engine, engine::general_purpose}; use log::debug; use regex::Regex; use serde::Serialize; +use std::sync::LazyLock; use crate::{ - unix::wine::{run_wine_command, CommandType}, - util::native::{module_path, NativeError, SafeParent, SafeStr}, + unix::wine::{CommandType, run_wine_command, wine_prefix_dir}, + util::native::{NativeError, SafeParent, SafeStr, module_path}, }; -lazy_static! { - static ref PID_PATTERN: Regex = Regex::new(r"wine-helper: PID (.*)").unwrap(); -} +static PID_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"wine-helper: PID (.*)").expect("PID pattern regex should be valid") +}); #[derive(Default, Serialize)] pub struct WineGetPidArgs { @@ -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 { @@ -33,16 +37,19 @@ 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 { @@ -76,11 +87,13 @@ pub async fn request_library_injection(pid: u32, path: &str) -> Result<(), Nativ module_path()? .safe_parent()? .join("wine-helper.exe") - .safe_str()?, + .safe_str()? + .into(), Some(vec!["inject", b64.as_str()]), None, false, CommandType::RunInPrefix, + &wine_prefix_dir(slug.unwrap()).unwrap(), ) .await?; diff --git a/maxima-lib/src/core/background_service_win.rs b/maxima-lib/src/core/background_service_win.rs index 0a31d372..8f85fb5b 100644 --- a/maxima-lib/src/core/background_service_win.rs +++ b/maxima-lib/src/core/background_service_win.rs @@ -4,8 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::core::error::BackgroundServiceClientError; use crate::util::dll_injector::DllInjector; -use crate::util::native::NativeError; -use crate::util::registry::{set_up_registry, RegistryError}; +use crate::util::registry::set_up_registry; use is_elevated::is_elevated; pub const BACKGROUND_SERVICE_PORT: u16 = 13021; @@ -16,6 +15,11 @@ pub struct ServiceLibraryInjectionRequest { pub path: String, } +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ServiceTouchupRequest { + pub output_dir: String, +} + pub async fn request_library_injection( pid: u32, path: &str, diff --git a/maxima-lib/src/core/cache.rs b/maxima-lib/src/core/cache.rs index b8c290dd..284e911a 100644 --- a/maxima-lib/src/core/cache.rs +++ b/maxima-lib/src/core/cache.rs @@ -2,7 +2,14 @@ use std::{any::Any, borrow::Borrow, hash::Hash, sync::Arc, time::Duration}; use moka::sync::Cache; -/// Note that values are cloned when retrieved +/// A cache keyed by `K` whose values are type-erased via `dyn Any`. +/// +/// Note that values are cloned when retrieved. Because the value type isn't +/// part of `K`, nothing prevents two call sites from using the same key with +/// different `T`s; `get` guards against this by returning `None` on a type +/// mismatch rather than panicking, but such mismatches still won't be caught +/// at compile time. Callers should keep cache keys unambiguous (e.g. by +/// namespacing them per value type). pub struct DynamicCache { cache: Cache>, } @@ -31,9 +38,8 @@ impl DynamicCache { Q: Hash + Eq + ?Sized, T: Sync + Send + Clone + 'static, { - match self.cache.get(key) { - None => None, - Some(cached) => Some((*cached.downcast::().unwrap()).clone()), - } + self.cache + .get(key) + .and_then(|cached| cached.downcast_ref::().cloned()) } } diff --git a/maxima-lib/src/core/cloudsync.rs b/maxima-lib/src/core/cloudsync.rs index 41e4cd75..0846b34b 100644 --- a/maxima-lib/src/core/cloudsync.rs +++ b/maxima-lib/src/core/cloudsync.rs @@ -1,7 +1,7 @@ /// Cloud sync is a process and a half. Here's a high-level overview of it: /// # Reading (pre-launch) /// - Call `/lock/read`, which gives us a link to an XML on S3: -/// a manifest of all files in the cloud, along with their MD5 hash and last modified date +/// a manifest of all files in the cloud, along with their MD5 hash and last modified date /// - If you feel the need to update the local files: /// - Call `/lock/authorize` with a `Vec` containing the corresponding `href` from the manifest /// - This will return an XML with S3 targets corresponding to the files. Download them (the response body is the file, verbatim) and write them. @@ -13,14 +13,9 @@ /// - 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, -}; +use super::{auth::storage::LockedAuthStorage, endpoints::API_CLOUDSYNC, library::OwnedOffer}; use crate::util::native::{NativeError, SafeParent, SafeStr}; use derive_getters::Getters; -use futures::StreamExt; use log::{debug, error}; use reqwest::{Client, ClientBuilder}; use serde::{Deserialize, Serialize}; @@ -34,6 +29,7 @@ use tokio::{ fs::{File, OpenOptions}, io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, }; +use tokio_stream::StreamExt; const AUTH_HEADER: &str = "X-Origin-AuthToken"; const LOCK_HEADER: &str = "X-Origin-Sync-Lock"; @@ -99,27 +95,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()) + .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 +126,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"); @@ -173,7 +171,7 @@ async fn calc_file_md5(file: File, mode: HashMode) -> Result Ok(format!("{:x}", digest)), @@ -197,6 +195,7 @@ pub struct CloudSyncLock<'a> { manifest: CloudSyncManifest, mode: CloudSyncLockMode, allowed_files: Vec, + slug: String, } impl<'a> CloudSyncLock<'a> { @@ -207,17 +206,14 @@ impl<'a> CloudSyncLock<'a> { lock: String, mode: CloudSyncLockMode, allowed_files: Vec, + slug: &str, ) -> Result { let res = client.get(manifest_url).send().await?; let manifest: CloudSyncManifest = { - let mut manifest = if let Ok(text) = res.text().await { + let manifest = if let Ok(text) = res.text().await { let result = quick_xml::de::from_str(&text); - if let Ok(manifest) = result { - manifest - } else { - None - } + result.unwrap_or_default() } else { None }; @@ -238,6 +234,7 @@ impl<'a> CloudSyncLock<'a> { manifest, mode, allowed_files, + slug: slug.to_owned(), }) } @@ -262,10 +259,10 @@ impl<'a> CloudSyncLock<'a> { /// The file syncing functions are some real hastily written code at the moment. /// Lots of stuff could be better and merged between them. TODO: Clean it up. pub async fn sync_files(&self) -> Result<(), CloudSyncError> { - Ok(match self.mode { + match self.mode { CloudSyncLockMode::Read => self.sync_read_files().await, CloudSyncLockMode::Write => self.sync_write_files().await, - }?) + } } async fn sync_read_files(&self) -> Result<(), CloudSyncError> { @@ -274,21 +271,22 @@ 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; - let md5 = if let Ok(file) = file { + let should_download = if let Ok(file) = file { let md5 = calc_file_md5(file, HashMode::Hex).await?; - if let Some(_) = self.manifest.file_by_md5(&md5) { - debug!("Skipping CloudSync read {}", &path.display()); - continue; - } - md5 + self.manifest.file_by_md5(&md5).is_none() } else { - continue; + true // File isn't there, download it. }; + if !should_download { + debug!("Skipping CloudSync read {}", path.display()); + continue; + } + value.request.push(CloudSyncRequest { attr_id: i.to_string(), verb: "GET".to_owned(), @@ -305,7 +303,9 @@ impl<'a> CloudSyncLock<'a> { } let (token, user_id) = acquire_auth(self.auth).await?; - let body = quick_xml::se::to_string(&value)?.replace("CloudSyncRequests", "requests"); + let body = quick_xml::se::to_string(&value) + .unwrap() + .replace("CloudSyncRequests", "requests"); let res = self .client @@ -322,7 +322,7 @@ impl<'a> CloudSyncLock<'a> { for i in 0..authorizations.request.len() { let auth_req = &authorizations.request[i]; - let mut req = self.client.get(&auth_req.url); + let req = self.client.get(&auth_req.url); let res = req.send().await?; if !res.status().is_success() { // If the request is invalid, S3 will return an error *and* include that error in the body. @@ -346,6 +346,7 @@ impl<'a> CloudSyncLock<'a> { tokio::fs::create_dir_all(path.safe_parent()?).await?; let mut file = OpenOptions::new() .write(true) + .truncate(true) .create(true) .open(path) .await?; @@ -404,13 +405,13 @@ impl<'a> CloudSyncLock<'a> { let md5 = calc_file_md5(file.try_clone().await?, HashMode::Hex).await?; let base62 = calc_file_md5(file.try_clone().await?, HashMode::Base62).await?; - if let Some(file) = self.manifest.file_by_md5(&md5) { - debug!("Skipping CloudSync write {}", &path.display()); - skipped.push(file); + if let Some(existing) = self.manifest.file_by_md5(&md5) { + debug!("Skipping CloudSync write {}", path.display()); + skipped.push(existing.clone()); continue; } - let name = unsubstitute_paths(&path)?; + let name = unsubstitute_paths(path, Some(&self.slug))?; let write_data = WriteData::File { name, file, @@ -443,10 +444,10 @@ impl<'a> CloudSyncLock<'a> { manifest.file.clear(); for ele in skipped { - manifest.file.push(ele.clone()); + manifest.file.push(ele); } - for (_, write_data) in &data { + for write_data in data.values() { if let WriteData::File { name, file, base62, .. } = write_data @@ -462,8 +463,9 @@ impl<'a> CloudSyncLock<'a> { } } - let manifest = - quick_xml::se::to_string(&manifest)?.replace("CloudSyncManifest", "manifest"); + let manifest = quick_xml::se::to_string(&manifest) + .unwrap() + .replace("CloudSyncManifest", "manifest"); data.insert( 0, WriteData::Text { @@ -474,7 +476,9 @@ impl<'a> CloudSyncLock<'a> { } let (token, user_id) = acquire_auth(self.auth).await?; - let body = quick_xml::se::to_string(&auth_reqs)?.replace("CloudSyncRequests", "requests"); + let body = quick_xml::se::to_string(&auth_reqs) + .unwrap() + .replace("CloudSyncRequests", "requests"); let res = self .client @@ -492,7 +496,7 @@ impl<'a> CloudSyncLock<'a> { for i in 0..authorizations.request.len() { let auth_req = &authorizations.request[i]; let mut req = self.client.put(&auth_req.url); - for header in &auth_req.headers.header.clone().unwrap_or(Vec::new()) { + for header in &auth_req.headers.header.clone().unwrap_or_default() { req = req.header(&header.attr_key, &header.attr_value); } @@ -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 } @@ -544,26 +548,35 @@ impl CloudSyncClient { pub fn new(auth: LockedAuthStorage) -> Self { Self { auth, - client: ClientBuilder::default().gzip(true).build().unwrap(), + client: ClientBuilder::default() + .gzip(true) + .build() + .unwrap_or_else(|_| Client::new()), } } - pub async fn obtain_lock<'a>( + pub async fn obtain_lock( &self, offer: &OwnedOffer, mode: CloudSyncLockMode, - ) -> Result { + ) -> Result, CloudSyncError> { + let multiplayer_id = offer + .offer() + .multiplayer_id() + .as_deref() + .unwrap_or_else(|| offer.offer().offer_id()); let id = format!( "{}_{}", offer.offer().primary_master_title_id(), - offer.offer().multiplayer_id().as_ref().unwrap() + multiplayer_id ); + 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,15 +591,16 @@ impl CloudSyncClient { return Err(CloudSyncError::NoConfig(offer.offer_id().clone())); } - Ok(self.obtain_lock_raw(&id, mode, allowed_files).await?) + self.obtain_lock_raw(&id, mode, allowed_files, &slug).await } - pub async fn obtain_lock_raw<'a>( + pub async fn obtain_lock_raw( &self, id: &str, mode: CloudSyncLockMode, allowed_files: Vec, - ) -> Result { + slug: &str, + ) -> Result, CloudSyncError> { let (token, user_id) = acquire_auth(&self.auth).await?; let res = self @@ -606,15 +620,16 @@ impl CloudSyncClient { let text = res.text().await?; let sync: CloudSyncSync = quick_xml::de::from_str(&text)?; - Ok(CloudSyncLock::new( + CloudSyncLock::new( &self.auth, &self.client, sync.manifest, lock, mode, allowed_files, + slug, ) - .await?) + .await } } @@ -689,7 +704,7 @@ macro_rules! cloudsync_type { ),* $(,)? } ) => { - paste::paste! { + pastey::paste! { // Main struct definition $(#[$message_attr])* #[derive(Default, Debug, Clone, Serialize, Deserialize, Getters, PartialEq)] @@ -739,7 +754,7 @@ impl CloudSyncManifest { continue; } - return Some(&ele); + return Some(ele); } } diff --git a/maxima-lib/src/core/concurrency.rs b/maxima-lib/src/core/concurrency.rs index 856806a6..6f3104fb 100644 --- a/maxima-lib/src/core/concurrency.rs +++ b/maxima-lib/src/core/concurrency.rs @@ -1,4 +1,4 @@ -use futures::{stream, Future, StreamExt}; +use futures::{Future, StreamExt, stream}; pub async fn execute_batch_concurrent( buffer: usize, diff --git a/maxima-lib/src/core/ecommerce.rs b/maxima-lib/src/core/ecommerce.rs index 3fb7109f..217e6ca4 100644 --- a/maxima-lib/src/core/ecommerce.rs +++ b/maxima-lib/src/core/ecommerce.rs @@ -50,7 +50,7 @@ pub async fn request_offer_data( locale: &str, ) -> Result { let res = Client::new() - .get(&format!("{}/public/{}/{}", API_ECOMMERCE, offer, locale)) + .get(format!("{}/public/{}/{}", API_ECOMMERCE, offer, locale)) .header("AuthToken", access_token) .send() .await?; @@ -80,7 +80,7 @@ macro_rules! ecommerce_type { ),* $(,)? } ) => { - paste::paste! { + pastey::paste! { // Main struct definition $(#[$message_attr])* #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -102,7 +102,7 @@ macro_rules! ecommerce_type { macro_rules! ecommerce_enum { ($name:ident, { $($field:tt)* }) => { - paste::paste! { + pastey::paste! { #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum [] { diff --git a/maxima-lib/src/core/graphql/AddOnGameCard.gql b/maxima-lib/src/core/graphql/AddOnGameCard.gql new file mode 100644 index 00000000..a655d4e6 --- /dev/null +++ b/maxima-lib/src/core/graphql/AddOnGameCard.gql @@ -0,0 +1,41 @@ +query AddOnGameCard($locale: Locale!, $slug: String!) { + game(locale: $locale, slug: $slug) { + id + title + keyArt { + aspect1x1Image { + path + } + } + baseGame { + id + keyArt { + aspect1x1Image { + path + } + } + } + products { + items { + originOfferId + lifecycleStatus { + ...lifecycleStatusFragment + } + } + } + ...gamePlatformsFragment + } +} + +fragment lifecycleStatusFragment on LifecycleStatus { + lifecycleType + downloadDate + playableStartDate + playableEndDate + acquisitionStartDate + acquisitionEndDate + revealDate + sunsetDate + isEarlyAccess + __typename +} diff --git a/maxima-lib/src/core/graphql/SearchPlayer.gql b/maxima-lib/src/core/graphql/SearchPlayer.gql index dd67bbf0..923516d3 100644 --- a/maxima-lib/src/core/graphql/SearchPlayer.gql +++ b/maxima-lib/src/core/graphql/SearchPlayer.gql @@ -1,5 +1,5 @@ query SearchPlayer($searchText: String!, $pageNumber: Int!, $pageSize: Int!, $isMutualFriendsEnabled: Boolean = false) { - players(searchText: $searchText, paging: {pageNumber: $pageNumber, pageSize: $pageSize}) { + players(searchText: $searchText, paging: {pageNumber: $pageNumber, pageSize: $pageSize}) @connection(key: "playersBySearchText", filter: ["searchText"]) { items { ...PlayerWithMutualFriendsCount __typename diff --git a/maxima-lib/src/core/graphql/inGamePresenceData.gql b/maxima-lib/src/core/graphql/inGamePresenceData.gql index c3c351e4..80844131 100644 --- a/maxima-lib/src/core/graphql/inGamePresenceData.gql +++ b/maxima-lib/src/core/graphql/inGamePresenceData.gql @@ -1,50 +1,38 @@ query inGamePresenceData($offerIds: [String!]!, $locale: Locale = "en-us") { - gameProducts(locale: $locale, offerIds: $offerIds) { - items { - id - gameSlug - baseItem(availabilities: [VISIBLE]) { + gameProducts(locale: $locale, offerIds: $offerIds) { + items { id - title - shortDescription - baseGameSlug - lifecycleStatus { - revealDate - __typename - } - gameType - playableInPlatforms - prereleaseGameType - keyArt { + gameSlug + baseItem(availabilities: [VISIBLE]) { + id title - aspect1x1Image { - path - height - width - __typename - } - aspect16x9Image { - path - height - width - __typename + shortDescription + baseGameSlug + gameType + playableInPlatforms + prereleaseGameType + keyArt { + title + aspect1x1Image { + path + height + width + } + aspect16x9Image { + path + height + width + } } - __typename - } - primaryLogo { - title - largestImage { - path - height - width - __typename + primaryLogo { + title + largestImage { + path + height + width + } } - __typename } - __typename } - __typename } - __typename } -} diff --git a/maxima-lib/src/core/launch.rs b/maxima-lib/src/core/launch.rs index 6281672c..3bcece06 100644 --- a/maxima-lib/src/core/launch.rs +++ b/maxima-lib/src/core/launch.rs @@ -1,4 +1,4 @@ -use base64::{engine::general_purpose, Engine}; +use base64::{Engine, engine::general_purpose}; use derive_getters::Getters; use log::{error, info}; use std::{env, fmt::Display, path::PathBuf, sync::Arc}; @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::{ core::{ + Maxima, auth::{ context::AuthContext, nucleus_auth_exchange, @@ -19,9 +20,8 @@ use crate::{ cloudsync::{CloudSyncError, CloudSyncLockMode}, library::{LibraryError, OwnedOffer}, service_layer::ServiceLayerError, - Maxima, }, - ooa::{needs_license_update, request_and_save_license, LicenseAuth, LicenseError}, + ooa::{LicenseAuth, LicenseError, needs_license_update, request_and_save_license}, util::{ native::{NativeError, SafeParent, SafeStr}, registry::bootstrap_path, @@ -67,9 +67,7 @@ pub enum LaunchError { NotInstalled(String), #[error("bootstrap was not found! Please re-install maxima")] BootstrapMissing, - #[error( - "content ID (`{0}`) was specified as an offer ID when launching in OnlineOffline mode" - )] + #[error("content ID (`{0}`) was specified as an offer ID when launching in OnlineOffline mode")] ContentIdAsOfferId(String), } @@ -102,10 +100,7 @@ pub enum LaunchMode { impl LaunchMode { // What an awful name pub fn is_online_offline(&self) -> bool { - match self { - LaunchMode::OnlineOffline(_, _, _) => true, - _ => false, - } + matches!(self, LaunchMode::OnlineOffline(_, _, _)) } } @@ -114,6 +109,7 @@ pub struct ActiveGameContext { launch_id: String, game_path: String, content_id: String, + slug: Option, offer: Option, mode: LaunchMode, injections: Vec, @@ -123,6 +119,10 @@ pub struct ActiveGameContext { } impl ActiveGameContext { + pub fn current_offer(&self) -> Option { + self.offer.clone() + } + pub fn new( launch_id: &str, game_path: &str, @@ -131,11 +131,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 +160,7 @@ impl ActiveGameContext { pub struct BootstrapLaunchArgs { pub path: String, pub args: Vec, + pub slug: String, } impl Display for LaunchMode { @@ -227,13 +230,25 @@ pub async fn start_game( }; let dir = path.safe_parent()?.safe_str()?; + #[cfg(unix)] let path = case_insensitive_path(path.clone()); + let path = path.safe_str()?; + + #[cfg(unix)] + let path = path.replace("\\", "/"); + 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().unwrap()).await?; match mode { LaunchMode::Offline(_) => {} @@ -241,13 +256,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 +299,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 +330,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)?); @@ -312,15 +340,15 @@ pub async fn start_game( let launch_id = Uuid::new_v4().to_string(); child - .current_dir(PathBuf::from(path).safe_parent()?) - .env("MXLaunchId", launch_id.to_owned()) + .current_dir(PathBuf::from(dir)) + .env("MXLaunchId", &launch_id) .env("EAAuthCode", "unavailable") .env("EAEgsProxyIpcPort", "0") .env("EAEntitlementSource", "EA") .env("EAExternalSource", "EA") .env("EAFreeTrialGame", "false") .env("EAGameLocale", maxima.locale.full_str()) - .env("EAGenericAuthToken", access_token.to_owned()) + .env("EAGenericAuthToken", &access_token) .env("EALaunchCode", "unavailable") .env("EALaunchOwner", "EA") .env( @@ -344,7 +372,7 @@ pub async fn start_game( .env("EAOnErrorExitRetCode", "1"); match mode { - LaunchMode::Offline(_) => todo!(), + LaunchMode::Offline(_) => return Err(LaunchError::Offline), LaunchMode::Online(ref offer_id) => { let short_token = request_opaque_ooa_token(&access_token).await?; @@ -363,7 +391,7 @@ pub async fn start_game( } }; - let child = child.spawn().expect("Failed to start child"); + let child = child.spawn().map_err(|e| LaunchError::Native(e.into()))?; maxima.playing = Some(ActiveGameContext::new( &launch_id, @@ -373,6 +401,7 @@ pub async fn start_game( offer, mode, child, + slug, )); Ok(()) @@ -380,7 +409,7 @@ pub async fn start_game( async fn request_opaque_ooa_token(access_token: &str) -> Result { let mut context = AuthContext::new()?; - context.set_access_token(&access_token); + context.set_access_token(access_token); context.set_token_format("OPAQUE"); context.set_expires_in(550); @@ -399,19 +428,17 @@ async fn request_opaque_ooa_token(access_token: &str) -> Result Result<(), NativeError> { +pub async fn mx_linux_setup(slug: &str) -> Result<(), NativeError> { use crate::unix::wine::{ - check_runtime_validity, check_wine_validity, get_lutris_runtimes, install_runtime, - install_wine, setup_wine_registry, + check_runtime_validity, get_lutris_runtimes, install_runtime, 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(); if !skip { - if !check_wine_validity().await? { - install_wine().await?; - } let runtimes = get_lutris_runtimes().await?; if !check_runtime_validity("eac_runtime", &runtimes).await? { install_runtime("eac_runtime", &runtimes).await?; @@ -421,8 +448,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 faa730cc..23654b7a 100644 --- a/maxima-lib/src/core/library.rs +++ b/maxima-lib/src/core/library.rs @@ -1,24 +1,28 @@ use super::{ auth::storage::LockedAuthStorage, locale::Locale, - manifest::{self, GameManifest, ManifestError, MANIFEST_RELATIVE_PATH}, + manifest::{self, GameManifest, MANIFEST_RELATIVE_PATH, ManifestError}, service_layer::{ + SERVICE_REQUEST_GETLEGACYCATALOGDEFS, SERVICE_REQUEST_GETPRELOADEDOWNEDGAMES, ServiceGameProductType, ServiceGetLegacyCatalogDefsRequestBuilder, ServiceGetPreloadedOwnedGamesRequest, ServiceGetPreloadedOwnedGamesRequestBuilder, ServiceGetPreloadedOwnedGamesRequestBuilderError, ServiceLayerClient, ServiceLayerError, ServiceLegacyOffer, ServicePlatform, ServiceStorefront, ServiceUser, - ServiceUserGameProduct, SERVICE_REQUEST_GETLEGACYCATALOGDEFS, - SERVICE_REQUEST_GETPRELOADEDOWNEDGAMES, + ServiceUserGameProduct, }, }; -#[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::{RegistryError, parse_registry_path_json}; +use crate::{ + gameinfo::load_game_info_from_json, + util::native::{NativeError, SafeStr}, +}; use derive_getters::Getters; +use log::info; use std::{collections::HashMap, path::PathBuf, time::SystemTimeError}; use thiserror::Error; +const LIBRARY_CACHE_TTL_SECS: u64 = 1200; + #[derive(Error, Debug)] pub enum LibraryError { #[error(transparent)] @@ -54,32 +58,49 @@ 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. + #[cfg(windows)] + pub async fn check_install_win_registry(&self) -> bool { + use crate::util::registry::parse_partial_registry_path; + let Some(path) = &self.offer.install_check_override().as_ref() else { + log::warn!("[{}] No install_check_override", self.slug); return false; }; - let path = match parse_registry_path(path).await { - Ok(path) => path, + + if let Ok(gamedir) = parse_partial_registry_path(path).await { + use crate::gameinfo::GameInstallInfo; + let game_install_info: GameInstallInfo = GameInstallInfo::new(gamedir.clone(), None); + game_install_info.save_to_json(&self.slug); + gamedir.exists() + } else { + false + } + } + + pub async fn is_installed(&self) -> bool { + let gameinfo = match load_game_info_from_json(&self.slug) { + Ok(info) => info, Err(_) => return false, }; - // If it wasn't replaced... - if path.starts_with("[") { + if gameinfo.path().exists() { + true + } else { + #[cfg(windows)] + return self.check_install_win_registry().await; + + #[cfg(not(windows))] return false; } - #[cfg(unix)] - let path = case_insensitive_path(path); - path.exists() } + // This is unused pub async fn install_check_path(&self) -> Result { - Ok(parse_registry_path( - &self - .offer + 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()? @@ -92,17 +113,16 @@ impl OwnedOffer { None => return Err(LibraryError::NoManifest(self.slug.clone())), }; - let path = if let Some(path) = manifest.execute_path(trial) { - &Some(path) - } else { - self.offer.execute_path_override() + let raw = manifest + .execute_path(trial) + .map(|p| p.to_owned()) + .or_else(|| self.offer.execute_path_override().clone()); + + let Some(raw) = raw else { + return Err(LibraryError::NoPath(self.slug.clone())); }; - if let Some(path) = path { - Ok(parse_registry_path(path).await?) - } else { - Err(LibraryError::NoPath(self.slug.clone())) - } + Ok(parse_registry_path_json(&raw, Some(&self.slug)).await?) } pub async fn installed_version(&self) -> Result { @@ -123,35 +143,18 @@ 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 }; - Ok(Some(manifest::read(path).await?)) + 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::load_manifest_from_disk(path).await?)) } pub fn offer_id(&self) -> &String { @@ -225,6 +228,15 @@ fn group_offers(products: Vec) -> Vec { } } + // Promote any product_map entry that has no corresponding base_products entry + for (slug, products) in &product_map { + if !base_products.contains_key(slug) + && let Some(first) = products.first() + { + base_products.insert(slug.clone(), first.clone()); + } + } + let mut titles = Vec::new(); for (base_slug, base_offer) in base_products { @@ -258,7 +270,7 @@ impl OwnedTitle { } pub fn base_game(&self) -> Option { - for offer in self.offers.iter() { + if let Some(offer) = self.offers.first() { if offer .product .product() @@ -350,7 +362,7 @@ impl GameLibrary { .duration_since(std::time::UNIX_EPOCH)? .as_secs(); - if now - self.last_request > 1200 { + if now - self.last_request > LIBRARY_CACHE_TTL_SECS { self.request_owned_games().await?; } @@ -378,7 +390,7 @@ impl GameLibrary { let user: ServiceUser = self .service_layer - .request(SERVICE_REQUEST_GETPRELOADEDOWNEDGAMES, request) + .request(&SERVICE_REQUEST_GETPRELOADEDOWNEDGAMES, request) .await?; user.owned_game_products() .as_ref() @@ -395,7 +407,7 @@ impl GameLibrary { let defs: Vec = self .service_layer .request( - SERVICE_REQUEST_GETLEGACYCATALOGDEFS, + &SERVICE_REQUEST_GETLEGACYCATALOGDEFS, ServiceGetLegacyCatalogDefsRequestBuilder::default() .offer_ids(offer_ids) .locale(locale) @@ -423,7 +435,7 @@ impl GameLibrary { } let mut titles = group_offers(offers); - titles.sort_by(|a, b| a.name().to_lowercase().cmp(&b.name().to_lowercase())); + titles.sort_by_key(|a| a.name().to_lowercase()); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? @@ -437,7 +449,7 @@ impl GameLibrary { fn library_request( locale: &Locale, r#type: ServiceGameProductType, - entitlement_enabled: bool, + _entitlement_enabled: bool, page: u32, ) -> Result { Ok(ServiceGetPreloadedOwnedGamesRequestBuilder::default() diff --git a/maxima-lib/src/core/manifest/dip.rs b/maxima-lib/src/core/manifest/dip.rs index b8da5ceb..5a907a84 100644 --- a/maxima-lib/src/core/manifest/dip.rs +++ b/maxima-lib/src/core/manifest/dip.rs @@ -1,11 +1,55 @@ #![allow(non_snake_case)] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::{core::manifest::ManifestError, util::native::platform_path}; +use crate::{ + core::manifest::{ManifestError, bytes_to_string}, + util::native::platform_path, +}; use derive_getters::Getters; use serde::Deserialize; +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct DiPContentIDs { + #[serde(rename = "contentID", default)] + pub items: Vec, +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct DiPUninstall { + pub path: String, +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct DiPInstallManifest { + #[serde(rename = "filePath")] + pub file_path: String, +} + +/// SimCity 3000 Unlimited +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct DiPGameTitle { + #[serde(rename = "@locale")] + pub locale: String, + #[serde(rename = "$text")] + pub title: String, +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct DiPGameTitles { + #[serde(rename = "gameTitle", default)] + pub items: Vec, +} + +/// SimCity 3000 Unlimited Launcher +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct DiPName { + #[serde(rename = "@locale")] + pub locale: String, + #[serde(rename = "$text")] + pub value: String, +} + macro_rules! dip_type { ( $(#[$message_attr:meta])* @@ -23,11 +67,9 @@ macro_rules! dip_type { ),* $(,)? } ) => { - paste::paste! { - // Main struct definition + pastey::paste! { $(#[$message_attr])* #[derive(Default, Debug, Clone, Deserialize, PartialEq, Getters)] - #[serde(rename_all = "camelCase")] pub struct [] { $( $(#[$attr_field_attr])* @@ -43,31 +85,20 @@ macro_rules! dip_type { } } -dip_type!( - Launcher; - attr { - uid: String, - }, - data { - file_path: String, - execute_elevated: Option, - #[serde(default)] - trial: bool, - } -); - dip_type!( FeatureFlags; attr { - allowMultipleInstances: bool, - autoUpdateEnabled: bool, - dynamicContentSupportEnabled: bool, - enableDifferentialUpdate: bool, - enableOriginInGameAPI: bool, - forceTouchupInstallerAfterUpdate: bool, - languageChangeSupportEnabled: bool, - treatUpdatesAsMandatory: bool, - useGameVersionFromManifest: bool, + allowMultipleInstances: String, + autoUpdateEnabled: String, + dynamicContentSupportEnabled: String, + enableDifferentialUpdate: String, + enableOriginInGameAPI: String, + #[serde(default)] + enableVersionUpdate: String, + forceTouchupInstallerAfterUpdate: String, + languageChangeSupportEnabled: String, + treatUpdatesAsMandatory: String, + useGameVersionFromManifest: String, }, data {} ); @@ -83,8 +114,10 @@ dip_type!( dip_type!( Requirements; attr { + #[serde(default)] + clientVersion: String, osMinVersion: String, - osReqs64Bit: bool, + osReqs64Bit: String, }, data {} ); @@ -99,10 +132,31 @@ dip_type!( } ); +dip_type!( + Launcher; + attr { + uid: String, + }, + data { + #[serde(default)] + name: Vec, + filePath: String, + #[serde(default)] + parameters: Option, + #[serde(default)] + requires64BitOS: String, + #[serde(default)] + trial: String, + #[serde(default)] + requiresMetal: String, + } +); + dip_type!( Runtime; attr {}, data { + #[serde(default)] launcher: Vec, } ); @@ -111,29 +165,15 @@ dip_type!( Touchup; attr {}, data { - file_path: String, + filePath: String, parameters: String, + #[serde(default)] + updateParameters: String, + #[serde(default)] + repairParameters: String, } ); -fn remove_leading_slash(path: &str) -> &str { - path.strip_prefix('/').unwrap_or(path) -} - -fn remove_trailing_slash(path: &str) -> &str { - path.strip_suffix('/').unwrap_or(path) -} - -fn remove_trailing_backslash(path: &str) -> &str { - path.strip_suffix('\\').unwrap_or(path) -} - -impl DiPTouchup { - pub fn path(&self) -> &str { - remove_leading_slash(&self.file_path) - } -} - dip_type!( Manifest; attr { @@ -141,8 +181,16 @@ dip_type!( }, data { buildMetaData: DiPBuildMetaData, + #[serde(rename = "contentIDs", default)] + contentIds: DiPContentIDs, + #[serde(default)] + gameTitles: DiPGameTitles, + #[serde(default)] + uninstall: DiPUninstall, runtime: DiPRuntime, touchup: DiPTouchup, + #[serde(default)] + installManifest: DiPInstallManifest, } ); @@ -154,78 +202,163 @@ dip_type!( } ); -/// https://www.reddit.com/r/rust/comments/11co87m/comment/ja4sy88 -fn bytes_to_string(bytes: Vec) -> Option { - if let Ok(v) = String::from_utf8(bytes.clone()) { - return Some(v); +fn remove_leading_slash(path: &str) -> &str { + path.strip_prefix('/').unwrap_or(path) +} + +fn remove_trailing_slash(path: &str) -> &str { + path.strip_suffix('/').unwrap_or(path) +} + +fn remove_trailing_backslash(path: &str) -> &str { + path.strip_suffix('\\').unwrap_or(path) +} + +fn split_args(s: &str) -> Result, ManifestError> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for ch in s.chars() { + match ch { + '"' => in_quotes = !in_quotes, + ' ' if !in_quotes => { + if !current.is_empty() { + args.push(current.clone()); + current.clear(); + } + } + _ => current.push(ch), + } + } + + if in_quotes { + return Err(ManifestError::Decode); + } + + if !current.is_empty() { + args.push(current); } - let u16_bytes: Vec = bytes - .chunks_exact(2) - .into_iter() - .map(|a| u16::from_ne_bytes([a[0], a[1]])) - .collect(); + Ok(args) +} + +impl DiPTouchup { + pub fn path(&self) -> &str { + remove_leading_slash(&self.filePath) + } +} - if let Ok(v) = String::from_utf16(&u16_bytes) { - return Some(v); +impl DiPLauncher { + pub fn is_trial(&self) -> bool { + self.trial == "1" || self.trial.eq_ignore_ascii_case("true") } - None + pub fn display_name(&self, locale: &str) -> Option<&str> { + self.name + .iter() + .find(|n| n.locale == locale) + .or_else(|| self.name.iter().find(|n| n.locale == "en_US")) + .map(|n| n.value.as_str()) + } } impl DiPManifest { - pub async fn read(path: &PathBuf) -> Result { + pub async fn read(path: &Path) -> Result { let bytes = tokio::fs::read(path).await?; let string = bytes_to_string(bytes).ok_or(ManifestError::Decode)?; Ok(quick_xml::de::from_str(&string)?) } - pub fn execute_path(&self, trial: bool) -> Option { - let launcher = self.runtime.launcher.iter().find(|l| l.trial == trial); - launcher.map(|l| l.file_path.clone()) + pub fn version(&self) -> &str { + &self.buildMetaData.gameVersion.attr_version + } + + pub fn locale(&self) -> &str { + self.gameTitles + .items + .iter() + .find(|t| t.locale == "en_US") + .map(|t| t.locale.as_str()) + .or_else(|| self.gameTitles.items.first().map(|t| t.locale.as_str())) + .unwrap_or("en_US") + } + + pub fn execute_path(&self, trial: bool) -> Option<&str> { + self.runtime + .launcher + .iter() + .find(|l| l.is_trial() == trial) + .map(|l| l.filePath.as_str()) } - pub fn version(&self) -> Option { - Some(self.buildMetaData.gameVersion.attr_version().clone()) + fn collect_touchup_args( + &self, + install_path: &Path, + locale: &str, + ) -> Result, ManifestError> { + let install_str = platform_path( + remove_trailing_backslash(install_path.to_str().ok_or(ManifestError::Decode)?) + .replace('/', "\\"), + ) + .to_str() + .ok_or(ManifestError::Decode)? + .to_string(); + + let expanded = self + .touchup + .parameters + .replace("{locale}", locale) + .replace("{installLocation}", &install_str); + + split_args(&expanded) } #[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, + wine_prefix_path: Option, + ) -> Result<(), ManifestError> { + use crate::unix::{ + fs::case_insensitive_path, + wine::{CommandType, invalidate_mx_wine_registry, run_wine_command}, }; - 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 args = self.collect_touchup_args(&install_path, self.locale())?; 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?; - + let path = case_insensitive_path(path).to_string_lossy().to_string(); + run_wine_command( + path.into(), + Some(args), + None, + true, + CommandType::Run, + &wine_prefix_path.unwrap(), + ) + .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, + _wine_prefix_path: Option, + ) -> Result<(), ManifestError> { use crate::util::native::NativeError; use tokio::process::Command; - let args = self.collect_touchup_args(install_path)?; - let path = install_path.join(&self.touchup.path()); + let args = self.collect_touchup_args(install_path, self.locale())?; + let path = install_path.join(self.touchup.path()); - let mut binding = Command::new(path); - let child = binding.args(args); + let status = Command::new(&path).args(&args).spawn()?.wait().await?; - let status = child.spawn()?.wait().await?; if !status.success() { return Err(ManifestError::Native(NativeError::Command( status.code().unwrap_or(0), @@ -234,22 +367,4 @@ impl DiPManifest { Ok(()) } - - fn collect_touchup_args(&self, install_path: &PathBuf) -> Result, ManifestError> { - let mut args = Vec::new(); - for arg in self.touchup.parameters.split(" ") { - let arg = arg.replace("{locale}", "en_US").replace( - "\"{installLocation}\"", - platform_path( - remove_trailing_backslash(install_path.to_str().ok_or(ManifestError::Decode)?) - .replace("/", "\\"), - ) - .to_str() - .ok_or(ManifestError::Decode)?, - ); - - args.push(PathBuf::from(arg)); - } - Ok(args) - } } diff --git a/maxima-lib/src/core/manifest/mod.rs b/maxima-lib/src/core/manifest/mod.rs index 42fa80f4..cacfe9a0 100644 --- a/maxima-lib/src/core/manifest/mod.rs +++ b/maxima-lib/src/core/manifest/mod.rs @@ -12,6 +12,8 @@ pub enum ManifestError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] + ServiceRun(#[from] reqwest::Error), + #[error(transparent)] Xml(#[from] DeError), #[error(transparent)] Native(#[from] crate::util::native::NativeError), @@ -20,7 +22,9 @@ pub enum ManifestError { #[error("failed to decode DiPManifest. Weird encoding?")] Decode, - #[error("Unsupported Manifest.\nDiP Attempt: `{dip_attempt:?}`\nPreDiP Attempt: `{pre_dip_attempt:?}`")] + #[error( + "Unsupported Manifest.\nDiP Attempt: `{dip_attempt:?}`\nPreDiP Attempt: `{pre_dip_attempt:?}`" + )] Unsupported { dip_attempt: Box, pre_dip_attempt: Box, @@ -33,52 +37,148 @@ 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, + wine_prefix_path: Option, + ) -> Result<(), ManifestError>; fn execute_path(&self, trial: bool) -> Option; fn version(&self) -> Option; + fn needs_touchup_on_locate(&self) -> bool; } #[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, + wine_prefix_path: Option, + ) -> Result<(), ManifestError> { + self.run_touchup(install_path, wine_prefix_path).await } fn execute_path(&self, trial: bool) -> Option { self.execute_path(trial) + .map(std::string::ToString::to_string) } fn version(&self) -> Option { - self.version() + Some(self.version().to_string()) + } + + fn needs_touchup_on_locate(&self) -> bool { + self.buildMetaData() + .featureFlags() + .attr_forceTouchupInstallerAfterUpdate + .eq_ignore_ascii_case("true") } } #[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, + wine_prefix_path: Option, + ) -> Result<(), ManifestError> { + self.run_touchup(install_path, wine_prefix_path).await } fn execute_path(&self, _: bool) -> Option { - None // pre-dip games don't have an exe field, most if not all just use info in the offer + None // pre-dip games don't have an exe field, usually they put the exe in the "exclude" section for some reason } - fn version(&self) -> Option { - self.version() + fn version(&self) -> std::option::Option { + Some(self.version().to_string()) + } + + fn needs_touchup_on_locate(&self) -> bool { + false // pre-DiP games never had this concept } } -pub async fn read(path: PathBuf) -> Result, ManifestError> { - let dip_attempt = DiPManifest::read(&path).await; - if let Ok(manifest) = dip_attempt { - return Ok(Box::new(manifest)); +/// https://www.reddit.com/r/rust/comments/11co87m/comment/ja4sy88 +fn bytes_to_string(bytes: Vec) -> Option { + if let Ok(v) = String::from_utf8(bytes.clone()) { + return Some(v); } - let pre_dip_attempt = PreDiPManifest::read(&path).await; - if let Ok(manifest) = pre_dip_attempt { - return Ok(Box::new(manifest)); + + let u16_bytes: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|a| u16::from_ne_bytes([a[0], a[1]])) + .collect(); + + String::from_utf16(&u16_bytes).ok() +} + +pub fn from_bytes(bytes: &[u8]) -> Result { + let string = bytes_to_string(bytes.to_vec()).ok_or(ManifestError::Decode)?; + Ok(quick_xml::de::from_str(&string)?) +} + +pub async fn load_manifest_from_disk( + path_to_xml: PathBuf, +) -> Result, ManifestError> { + let bytes = tokio::fs::read(&path_to_xml).await?; + + if let Ok(m) = from_bytes::(&bytes) { + return Ok(Box::new(m)); } - Err(ManifestError::Unsupported { - dip_attempt: dip_attempt.unwrap_err().into(), - pre_dip_attempt: pre_dip_attempt.unwrap_err().into(), - }) + match from_bytes::(&bytes) { + Ok(m) => Ok(Box::new(m)), + Err(pre_dip_err) => Err(ManifestError::Unsupported { + dip_attempt: Box::new(ManifestError::Decode), + pre_dip_attempt: Box::new(pre_dip_err), + }), + } +} + +// 2 different thing for windows and unix as only windows needs elevation, + +#[cfg(windows)] +pub async fn handle_touchup_request( + install_path: PathBuf, + _wine_prefix_path: Option, + slug: &str, +) -> Result<(), ManifestError> { + use crate::{ + core::background_service::{BACKGROUND_SERVICE_PORT, ServiceTouchupRequest}, + gameinfo::GameInstallInfo, + }; + + let client = reqwest::Client::new(); + let _ = client + .post(format!( + "http://127.0.0.1:{}/touchup", + BACKGROUND_SERVICE_PORT + )) + .json(&ServiceTouchupRequest { + output_dir: install_path.to_string_lossy().into_owned(), + }) + .send() + .await?; + let game_install_info = GameInstallInfo::new(install_path, None); + game_install_info.save_to_json(slug); + Ok(()) +} + +#[cfg(unix)] +pub async fn handle_touchup_request( + install_path: PathBuf, + wine_prefix_path: Option, + slug: &str, +) -> Result<(), ManifestError> { + use crate::gameinfo::GameInstallInfo; + + let manifest = load_manifest_from_disk(install_path.join(MANIFEST_RELATIVE_PATH)) + .await + .unwrap(); + manifest + .run_touchup(&install_path, wine_prefix_path.clone()) + .await?; + let game_install_info = GameInstallInfo::new(install_path, wine_prefix_path); + game_install_info.save_to_json(slug); + Ok(()) } diff --git a/maxima-lib/src/core/manifest/pre_dip.rs b/maxima-lib/src/core/manifest/pre_dip.rs index 0ec9e1b4..94fcb3b9 100644 --- a/maxima-lib/src/core/manifest/pre_dip.rs +++ b/maxima-lib/src/core/manifest/pre_dip.rs @@ -1,9 +1,29 @@ #![allow(non_snake_case)] -use crate::{core::manifest::ManifestError, util::native::platform_path}; +use crate::{ + core::manifest::{ManifestError, bytes_to_string}, + util::native::platform_path, +}; use derive_getters::Getters; use serde::Deserialize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct PreDiPContentIDs { + #[serde(rename = "contentID", default)] + pub items: Vec, +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct PreDiPUninstall { + pub path: String, +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct PreDiPInstallManifest { + #[serde(rename = "filePath")] + pub file_path: String, +} macro_rules! predip_type { ( @@ -22,11 +42,9 @@ macro_rules! predip_type { ),* $(,)? } ) => { - paste::paste! { - // Main struct definition + pastey::paste! { $(#[$message_attr])* #[derive(Default, Debug, Clone, Deserialize, PartialEq, Getters)] - #[serde(rename_all = "camelCase")] pub struct [] { $( $(#[$attr_field_attr])* @@ -42,26 +60,82 @@ macro_rules! predip_type { } } +predip_type!( + FeatureFlags; + attr { + // Attribute names on vary across installerdata.xml + // schema versions (e.g. `treatUpdatesAsMandatory` vs + // `treatUpdatesAsMandatoryEnabled`, `enableDifferentialUpdate` vs + // `UnableDifferentialUpdate`), so default each to an empty string + // when absent rather than failing to deserialize. + #[serde(default)] + forceTouchupInstallerAfterUpdate: String, + #[serde(default)] + autoUpdateEnabled: String, + #[serde(default)] + treatUpdatesAsMandatoryEnabled: String, + #[serde(default)] + useGameVersionFromManifestEnabled: String, + #[serde(default)] + UnableDifferentialUpdate: String, + }, + data {} +); + +predip_type!( + Os; + attr { + minVersion: String, + }, + data {} +); + predip_type!( Executable; attr {}, data { - file_path: String, + filePath: String, parameters: String, + // Not every installerdata.xml specifies update/repair parameters. + #[serde(default)] + updateParameters: String, + #[serde(default)] + repairParameters: String, } ); -fn remove_leading_slash(path: &str) -> &str { - path.strip_prefix('/').unwrap_or(path) -} - -fn remove_trailing_slash(path: &str) -> &str { - path.strip_suffix('/').unwrap_or(path) -} +predip_type!( + LocaleInfo; + attr { + locale: String, + }, + data { + title: String, + // Some installerdata.xml files contain multiple entries + // within a single block, which a plain `String` field + // can't deserialize. + #[serde(default)] + eula: Vec, + #[serde(default)] + exclude: Vec, + } +); -fn remove_trailing_backslash(path: &str) -> &str { - path.strip_suffix('\\').unwrap_or(path) -} +predip_type!( + Metadata; + attr {}, + data { + // Not every installerdata.xml includes a element; + // fall back to defaults (all-empty strings) when it's absent. + #[serde(default)] + featureFlags: PreDiPFeatureFlags, + os: PreDiPOs, + #[serde(default)] + ignore: Vec, + #[serde(default)] + localeInfo: Vec, + } +); predip_type!( Manifest; @@ -70,78 +144,153 @@ predip_type!( manifestVersion: String, }, data { + #[serde(rename = "contentIDs")] + contentIds: PreDiPContentIDs, + // Not every installerdata.xml includes an element. + #[serde(default)] + uninstall: Option, + metadata: PreDiPMetadata, executable: PreDiPExecutable, + installManifest: PreDiPInstallManifest, } ); -/// https://www.reddit.com/r/rust/comments/11co87m/comment/ja4sy88 -fn bytes_to_string(bytes: Vec) -> Option { - if let Ok(v) = String::from_utf8(bytes.clone()) { - return Some(v); - } - - let u16_bytes: Vec = bytes - .chunks_exact(2) - .into_iter() - .map(|a| u16::from_ne_bytes([a[0], a[1]])) - .collect(); +fn remove_leading_slash(path: &str) -> &str { + path.strip_prefix('/').unwrap_or(path) +} - if let Ok(v) = String::from_utf16(&u16_bytes) { - return Some(v); - } +fn remove_trailing_slash(path: &str) -> &str { + path.strip_suffix('/').unwrap_or(path) +} - None +fn remove_trailing_backslash(path: &str) -> &str { + path.strip_suffix('\\').unwrap_or(path) } impl PreDiPManifest { - pub async fn read(path: &PathBuf) -> Result { + pub async fn read(path: &Path) -> Result { let bytes = tokio::fs::read(path).await?; let string = bytes_to_string(bytes).ok_or(ManifestError::Decode)?; - Ok(quick_xml::de::from_str(&string)?) } - pub fn version(&self) -> Option { - Some(self.attr_gameVersion.clone()) + pub fn version(&self) -> &str { + &self.attr_gameVersion + } + + pub fn locale(&self) -> &str { + self.metadata + .localeInfo + .iter() + .find(|l| l.attr_locale == "en_US") + .map(|l| l.attr_locale.as_str()) + .or_else(|| { + self.metadata + .localeInfo + .first() + .map(|l| l.attr_locale.as_str()) + }) + .unwrap_or("en_US") + } + + fn collect_touchup_args( + &self, + install_path: &Path, + locale: &str, + ) -> Result, ManifestError> { + let install_str = platform_path( + remove_trailing_backslash(install_path.to_str().ok_or(ManifestError::Decode)?) + .replace('/', "\\"), + ) + .to_str() + .ok_or(ManifestError::Decode)? + .to_string(); + + let expanded = self + .executable + .parameters + .replace("{locale}", locale) + .replace("{installLocation}", &install_str); + + Self::split_args(&expanded) + } + + fn split_args(s: &str) -> Result, ManifestError> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for ch in s.chars() { + match ch { + '"' => in_quotes = !in_quotes, + ' ' if !in_quotes => { + if !current.is_empty() { + args.push(current.clone()); + current.clear(); + } + } + _ => current.push(ch), + } + } + + if in_quotes { + // Unterminated quote — malformed manifest + return Err(ManifestError::Decode); + } + + if !current.is_empty() { + args.push(current); + } + + Ok(args) } #[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, + wine_prefix_path: Option, + ) -> Result<(), ManifestError> { + use crate::unix::{ + fs::case_insensitive_path, + wine::{CommandType, invalidate_mx_wine_registry, run_wine_command}, }; - 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 args = self.collect_touchup_args(&install_path, self.locale())?; - 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?; + let path = install_path.join(remove_leading_slash(&self.executable.filePath)); + let path = case_insensitive_path(path).to_string_lossy().to_string(); + run_wine_command( + path.into(), + Some(args), + None, + true, + CommandType::Run, + &wine_prefix_path.unwrap(), + ) + .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, + _wine_prefix_path: Option, + ) -> Result<(), ManifestError> { use crate::util::native::NativeError; use tokio::process::Command; - let args = self.collect_touchup_args(install_path)?; - let path = install_path.join(&self.executable.file_path); + let args = self.collect_touchup_args(install_path, self.locale())?; + let path = install_path.join(remove_leading_slash(&self.executable.filePath)); - let mut binding = Command::new(path); - let child = binding.args(args); + let status = Command::new(&path).args(&args).spawn()?.wait().await?; - let status = child.spawn()?.wait().await?; if !status.success() { return Err(ManifestError::Native(NativeError::Command( status.code().unwrap_or(0), @@ -150,22 +299,4 @@ impl PreDiPManifest { Ok(()) } - - fn collect_touchup_args(&self, install_path: &PathBuf) -> Result, ManifestError> { - let mut args = Vec::new(); - for arg in self.executable.parameters.split(" ") { - let arg = arg.replace("{locale}", "en_US").replace( - "\"{installLocation}\"", - platform_path( - remove_trailing_backslash(install_path.to_str().ok_or(ManifestError::Decode)?) - .replace("/", "\\"), - ) - .to_str() - .ok_or(ManifestError::Decode)?, - ); - - args.push(PathBuf::from(arg)); - } - Ok(args) - } } diff --git a/maxima-lib/src/core/mod.rs b/maxima-lib/src/core/mod.rs index 88ff044b..8a3ac02b 100644 --- a/maxima-lib/src/core/mod.rs +++ b/maxima-lib/src/core/mod.rs @@ -29,7 +29,7 @@ pub mod background_service { use std::{ env, - fs::{create_dir_all, File}, + fs::{File, create_dir_all}, io, os::raw::c_char, path::PathBuf, @@ -53,20 +53,19 @@ use self::{ library::GameLibrary, locale::Locale, service_layer::{ - ServiceAvatarListBuilder, ServiceAvatarListBuilderError, ServiceFriends, - ServiceGetBasicPlayerRequestBuilder, ServiceGetMyFriendsRequestBuilder, + SERVICE_REQUEST_GETBASICPLAYER, SERVICE_REQUEST_GETMYFRIENDS, + SERVICE_REQUEST_GETUSERPLAYER, ServiceAvatarListBuilder, ServiceAvatarListBuilderError, + ServiceFriends, ServiceGetBasicPlayerRequestBuilder, ServiceGetMyFriendsRequestBuilder, ServiceGetUserPlayerRequest, ServiceImage, ServiceImageBuilder, ServiceImageBuilderError, ServiceLayerClient, ServiceLayerError, ServicePlayer, ServicePlayerBuilder, ServicePlayerBuilderError, ServiceUser, ServiceUserBuilder, ServiceUserBuilderError, - SERVICE_REQUEST_GETBASICPLAYER, SERVICE_REQUEST_GETMYFRIENDS, - SERVICE_REQUEST_GETUSERPLAYER, }, }; use crate::{ content::manager::{ContentManager, ContentManagerError}, lsx::{self, service::LSXServerError, types::LSXRequestType}, rtm::client::{BasicPresence, RtmClient}, - util::native::{maxima_dir, NativeError}, + util::native::{NativeError, maxima_dir}, }; #[derive(Clone, IntoStaticStr)] @@ -75,6 +74,8 @@ pub enum MaximaEvent { ReceivedLSXRequest(u32, LSXRequestType), /// Offer ID. Use `maxima.mut_library().title_by_base_offer(id)` for details InstallFinished(String), + /// Offer ID, error message + InstallFailed(String, String), } pub type MaximaLSXEventCallback = extern "C" fn(*const c_char); @@ -263,7 +264,7 @@ impl Maxima { let user: ServiceUser = self .service_layer .request( - SERVICE_REQUEST_GETUSERPLAYER, + &SERVICE_REQUEST_GETUSERPLAYER, ServiceGetUserPlayerRequest {}, ) .await?; @@ -273,8 +274,8 @@ impl Maxima { Ok(user) } - pub async fn friends(&self, page: u32) -> Result, ServiceLayerError> { - let cache_key = format!("friends_{}", page); + pub async fn friends(&self, offset: u32) -> Result, ServiceLayerError> { + let cache_key = format!("friends_{}", offset); if let Some(cached) = self.request_cache.get(&cache_key) { return Ok(cached); } @@ -282,10 +283,10 @@ impl Maxima { let friends: ServiceFriends = self .service_layer .request( - SERVICE_REQUEST_GETMYFRIENDS, + &SERVICE_REQUEST_GETMYFRIENDS, ServiceGetMyFriendsRequestBuilder::default() .limit(100) - .offset(page) + .offset(offset) .is_mutual_friends_enabled(false) .build() .unwrap(), @@ -295,7 +296,7 @@ impl Maxima { let friends: Vec = friends .friends() .items() - .into_iter() + .iter() .map(|x| x.player().clone()) .collect(); @@ -308,14 +309,12 @@ impl Maxima { } pub fn consume_pending_events(&mut self) -> Vec { - let events = self.pending_events.clone(); - self.pending_events.clear(); - events + std::mem::take(&mut self.pending_events) } pub async fn player_by_id(&self, id: &str) -> Result { - if let Some(user) = &self.dummy_local_user { - return Ok(user + if let Some(dummy) = &self.dummy_local_user { + return Ok(dummy .player() .as_ref() .ok_or(ServiceLayerError::MissingField)? @@ -330,7 +329,7 @@ impl Maxima { let data: ServicePlayer = self .service_layer .request( - SERVICE_REQUEST_GETBASICPLAYER, + &SERVICE_REQUEST_GETBASICPLAYER, ServiceGetBasicPlayerRequestBuilder::default() .pd(id.to_string()) .build() @@ -341,9 +340,9 @@ impl Maxima { let avatars = data.avatar(); let avatars = avatars.as_ref().ok_or(ServiceLayerError::MissingField)?; - let _ = self.cache_avatar_image(&id, avatars.large()).await; - let _ = self.cache_avatar_image(&id, avatars.medium()).await; - let _ = self.cache_avatar_image(&id, avatars.small()).await; + let _ = self.cache_avatar_image(id, avatars.large()).await; + let _ = self.cache_avatar_image(id, avatars.medium()).await; + let _ = self.cache_avatar_image(id, avatars.small()).await; self.request_cache.insert(cache_key, data.clone()); Ok(data) @@ -364,7 +363,9 @@ impl Maxima { return Ok(()); } - let response = reqwest::get(image.path()).await?; + // Reuse the ServiceLayerClient's client so avatar downloads share its + // connection pool instead of spinning up an ungated one. + let response = self.service_layer.client().get(image.path()).send().await?; let body: Vec = response.bytes().await?.to_vec(); let mut file = File::create(path)?; @@ -384,7 +385,7 @@ impl Maxima { self.player_by_id(id).await?; } - if let Some(_) = &self.dummy_local_user { + if self.dummy_local_user.is_some() { return Ok(path); } @@ -423,18 +424,24 @@ impl Maxima { &mut self.rtm } + /// Sets the port the LSX server listens on. pub fn set_lsx_port(&mut self, port: u16) { self.lsx_port = port; } - pub(super) fn set_lsx_connections(&mut self, connections: u16) { - self.lsx_connections = connections; + pub fn inc_connected_lsx(&mut self) { + self.lsx_connections += 1; + } + + pub fn dec_connected_lsx(&mut self) { + if self.lsx_connections > 0 { + self.lsx_connections -= 1; + } } pub fn set_player_started(&mut self) { - match &mut self.playing { - Some(ref mut playing) => playing.set_started(), - None => return, + if let Some(playing) = &mut self.playing { + playing.set_started() } } @@ -459,29 +466,29 @@ impl Maxima { } let playing = self.playing.as_mut().unwrap(); - match playing.process_mut().try_wait() { - Ok(None) => return, - _ => (), + if let Ok(None) = playing.process_mut().try_wait() { + return; } info!("Game stopped"); - if let Some(offer) = playing.offer() { - if *playing.cloud_saves() && offer.offer().has_cloud_save() { - let result = self - .cloud_sync - .obtain_lock(offer, CloudSyncLockMode::Write) - .await; - match result { - Err(err) => error!("Failed to obtain CloudSync write lock: {}", err), - Ok(lock) => { - let result = lock.sync_files().await; - if let Err(err) = result { - error!("Failed to write to CloudSync: {}", err); - } - - lock.release().await.ok(); + if let Some(offer) = playing.offer() + && *playing.cloud_saves() + && offer.offer().has_cloud_save() + { + let result = self + .cloud_sync + .obtain_lock(offer, CloudSyncLockMode::Write) + .await; + match result { + Err(err) => error!("Failed to obtain CloudSync write lock: {}", err), + Ok(lock) => { + let result = lock.sync_files().await; + if let Err(err) = result { + error!("Failed to write to CloudSync: {}", err); } + + lock.release().await.ok(); } } } diff --git a/maxima-lib/src/core/service_layer.rs b/maxima-lib/src/core/service_layer.rs index 8d4fc467..77496b62 100644 --- a/maxima-lib/src/core/service_layer.rs +++ b/maxima-lib/src/core/service_layer.rs @@ -1,11 +1,17 @@ #![allow(non_snake_case)] -use log::debug; +use log::{debug, error, warn}; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use sha2_const::Sha256; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::sync::Arc; +use std::sync::LazyLock; +use std::time::Duration; use thiserror::Error; +use tokio::sync::{Mutex, Semaphore}; +use tokio::time::{Instant, sleep}; use derive_builder::Builder; use derive_getters::Getters; @@ -49,6 +55,12 @@ pub enum ServiceLayerError { status_code: StatusCode, message: String, }, + #[error("Rate-limited or forbidden on `{operation}` after {retries} retries: `{message}`")] + RateLimit { + operation: String, + retries: u32, + message: String, + }, #[error("GraphQL error in operation `{operation}`: `{error:?}`")] GraphQL { operation: String, @@ -98,20 +110,22 @@ pub struct ServiceLayerGraphQLRequest { macro_rules! load_graphql_request { ($type:ident, $operation:expr, $key:expr) => {{ let content = include_str!(concat!("graphql/", $operation, ".gql")); - let hash = Sha256::new().update(content.as_bytes()).finalize(); + let mut hash = Sha256::new(); + hash.update(content.as_bytes()); + let result = hash.finalize(); ServiceLayerGraphQLRequest { query: content, operation: $operation, key: $key, - hash, + hash: result.into(), r#type: ServiceLayerRequestType::$type, } }}; } macro_rules! define_graphql_request { - ($type:ident, $operation:expr, $key:expr) => { paste::paste! { - pub const []: &ServiceLayerGraphQLRequest = &load_graphql_request!($type, stringify!($operation), stringify!($key)); + ($type:ident, $operation:expr, $key:expr) => { pastey::paste! { + pub static []: LazyLock = LazyLock::new(|| load_graphql_request!($type, stringify!($operation), stringify!($key))); }} } @@ -136,6 +150,9 @@ define_graphql_request!(ContentfulProxy, GetHeroBackgroundImage, gameHubCollecti pub struct ServiceLayerClient { auth: LockedAuthStorage, client: Client, + semaphore: Arc, + last_request: Arc>>, + min_request_interval: Duration, } impl ServiceLayerClient { @@ -143,9 +160,17 @@ impl ServiceLayerClient { Self { auth, client: Client::new(), + semaphore: Arc::new(Semaphore::new(3)), + last_request: Arc::new(Mutex::new(None)), + min_request_interval: Duration::from_millis(100), } } + /// Exposes the underlying `reqwest::Client` + pub fn client(&self) -> &Client { + &self.client + } + pub async fn request( &self, operation: &ServiceLayerGraphQLRequest, @@ -171,6 +196,87 @@ impl ServiceLayerClient { variables: T, full_query: bool, ) -> Result + where + T: Serialize, + R: for<'a> Deserialize<'a>, + { + const MAX_RETRIES: u32 = 3; + const BASE_DELAY_MS: u64 = 500; + + let _permit = self + .semaphore + .acquire() + .await + .map_err(|_| ServiceLayerError::Http { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: "semaphore closed".to_string(), + })?; + + // Enforce minimum interval between requests without holding a lock across await + let wait = { + let mut last = self.last_request.lock().await; + let now = Instant::now(); + let wait = last.map(|instant| { + let elapsed = instant.elapsed(); + if elapsed < self.min_request_interval { + self.min_request_interval - elapsed + } else { + Duration::ZERO + } + }); + *last = Some(now + wait.unwrap_or(Duration::ZERO)); + wait + }; + if let Some(duration) = wait + && duration > Duration::ZERO + { + sleep(duration).await; + } + + let mut last_error = None; + + for attempt in 0..=MAX_RETRIES { + match self.do_request(operation, &variables, full_query).await { + Ok(res) => return Ok(res), + Err(err) => { + let should_retry = matches!( + &err, + ServiceLayerError::Http { status_code, .. } + if *status_code == StatusCode::TOO_MANY_REQUESTS + ); + + if should_retry && attempt < MAX_RETRIES { + let delay = Duration::from_millis(BASE_DELAY_MS * 2u64.pow(attempt)); + warn!( + "Request `{}` returned {}; waiting {:?} before retry {}/{}", + operation.operation, + match &err { + ServiceLayerError::Http { status_code, .. } => + status_code.to_string(), + _ => "error".to_string(), + }, + delay, + attempt + 1, + MAX_RETRIES + ); + sleep(delay).await; + last_error = Some(err); + } else { + return Err(err); + } + } + } + } + + Err(last_error.unwrap_or_else(|| ServiceLayerError::NoData)) + } + + async fn do_request( + &self, + operation: &ServiceLayerGraphQLRequest, + variables: T, + full_query: bool, + ) -> Result where T: Serialize, R: for<'a> Deserialize<'a>, @@ -234,44 +340,43 @@ impl ServiceLayerClient { ); let result = serde_json::from_str::(text.as_str())?; - let errors = result.get("errors"); - if let Some(errors) = errors { - let errors = errors.as_array().unwrap(); - let error = if let Value::Object(o) = &errors[0] { - o - } else { - return Err(ServiceLayerError::GraphQL { - operation: operation.operation.to_string(), - error: None, - }); - }; + if let Some(errors) = result.get("errors") { + let message = errors + .as_array() + .and_then(|arr| arr.first()) + .and_then(|first| first.get("message")) + .and_then(|msg| msg.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| errors.to_string()); return Err(ServiceLayerError::GraphQL { operation: operation.operation.to_string(), - error: if let Some(error) = error.get("message") { - Some(error.to_string()) - } else { - None - }, + error: Some(message), }); } let data = result .get("data") .ok_or(ServiceLayerError::NoData)? - .as_object() - .ok_or(ServiceLayerError::NoData)? .get(operation.key) - .ok_or(ServiceLayerError::NoData)? - .to_owned(); - - Ok(serde_json::from_value::(data)?) + .ok_or_else(|| ServiceLayerError::MissingField)? + .clone(); + Ok(serde_json::from_value::(data).map_err(|err| { + error!( + "Failed to deserialize response for operation `{}` (key `{}`): {}. Response data: {}", + operation.operation, + operation.key, + err, + result.get("data").unwrap_or(&Value::Null) + ); + err + })?) } } macro_rules! service_layer_type { ($name:ident, { $($field:tt)* }) => { - paste::paste! { + pastey::paste! { #[derive(Clone, Debug, Serialize, Deserialize, Getters, Builder)] #[serde(rename_all = "camelCase")] #[repr(C)] @@ -284,7 +389,7 @@ macro_rules! service_layer_type { macro_rules! service_layer_enum { ($name:ident, { $($field:tt)* }) => { - paste::paste! { + pastey::paste! { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[repr(C)] @@ -428,6 +533,7 @@ service_layer_enum!(OwnershipMethod, { SteamSubscription, Epic, EpicVault, + // Might even have Amazon Luna as "Luna" but since i've never seen any request like it yet... }); service_layer_enum!(OwnershipStatus, { @@ -505,12 +611,12 @@ service_layer_enum!(DownloadType, { None, }); -impl ServiceDownloadType { - pub fn to_string(&self) -> String { +impl fmt::Display for ServiceDownloadType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ServiceDownloadType::Staged => "Staged".to_owned(), - ServiceDownloadType::Live => "Live".to_owned(), - ServiceDownloadType::None => "None".to_owned(), + ServiceDownloadType::Staged => write!(f, "Staged"), + ServiceDownloadType::Live => write!(f, "Live"), + ServiceDownloadType::None => write!(f, "None"), } } } @@ -523,19 +629,21 @@ service_layer_type!(AvailableBuild, { build_live_date: Option, }); -impl ServiceAvailableBuild { - pub fn to_string(&self) -> String { - let mut str = self.game_version.to_owned().unwrap_or("UnkVer".to_owned()); +impl ServiceAvailableBuild {} + +impl fmt::Display for ServiceAvailableBuild { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.game_version.as_deref().unwrap_or("UnkVer"))?; if let Some(download_type) = &self.download_type { - str += &("/".to_owned() + &*download_type.to_string()); + write!(f, "/{download_type}")?; } if let Some(build_live_date) = &self.build_live_date { - str += &(" - ".to_owned() + build_live_date); + write!(f, " - {build_live_date}")?; } - str + Ok(()) } } diff --git a/maxima-lib/src/gameinfo/mod.rs b/maxima-lib/src/gameinfo/mod.rs new file mode 100644 index 00000000..9e927687 --- /dev/null +++ b/maxima-lib/src/gameinfo/mod.rs @@ -0,0 +1,86 @@ +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 GameInfoError { + #[error(transparent)] + Native(#[from] crate::util::native::NativeError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error("game 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() + } + + pub fn save_to_json(&self, slug: &str) { + if let Ok(json) = serde_json::to_string_pretty(self) { + let path = maxima_dir().unwrap().join("gameinfo"); + if std::fs::create_dir_all(&path).is_ok() { + let gameinfo_path = path.join(format!("{}.json", slug)); + fs::write(gameinfo_path, json).unwrap(); + } + } + } +} + +pub fn load_game_info_from_json(slug: &str) -> Result { + let path = maxima_dir()? + .join("gameinfo") + .join(format!("{}.json", slug)); + let json = fs::read_to_string(path)?; + 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..75abdb8e 100644 --- a/maxima-lib/src/lib.rs +++ b/maxima-lib/src/lib.rs @@ -1,11 +1,6 @@ -#![feature(type_ascription)] -#![feature(slice_pattern)] -#![feature(string_remove_matches)] -#![feature(trait_alias)] -#![feature(type_alias_impl_trait)] - 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..2b0766d9 100644 --- a/maxima-lib/src/lsx/connection.rs +++ b/maxima-lib/src/lsx/connection.rs @@ -1,18 +1,14 @@ use derive_getters::Getters; -use lazy_static::lazy_static; use log::{debug, error, warn}; use quick_xml::DeError; +use rand::rand_core::Rng; use regex::Regex; -use std::{ - io::{ErrorKind, Read, Write}, - net::TcpStream, - path::PathBuf, - sync::Arc, - time::Duration, -}; -use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt}; +use std::{io::ErrorKind, path::PathBuf, sync::LazyLock}; +use sysinfo::{Pid, System}; use thiserror::Error; -use tokio::sync::{MutexGuard, RwLock}; +use tokio::net::TcpStream; +use tokio::sync::mpsc::Sender; +use tokio::{io::AsyncWriteExt, sync::MutexGuard}; use super::{ request::{ @@ -37,14 +33,12 @@ use super::{ voip::handle_voip_status_request, }, types::{ - create_lsx_message, LSXChallenge, LSXEvent, LSXEventType, LSXMessageType, LSXRequest, - LSXResponse, LSX, + LSX, LSXChallenge, LSXEvent, LSXEventType, LSXMessageType, LSXRequest, LSXResponse, + create_lsx_message, }, }; use crate::{ - core::{ - auth::storage::TokenError, launch::ActiveGameContext, LockedMaxima, Maxima, MaximaEvent, - }, + core::{LockedMaxima, Maxima, MaximaEvent, launch::ActiveGameContext}, lsx::{request::LSXRequestError, types::LSXRequestType}, util::{ native::NativeError, @@ -55,7 +49,9 @@ use crate::{ #[derive(Error, Debug)] pub enum LSXConnectionError { #[error(transparent)] - Xml(#[from] DeError), + XmlDeserialize(#[from] DeError), + #[error(transparent)] + XmlSerialize(#[from] quick_xml::se::SeError), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] @@ -74,19 +70,17 @@ pub enum LSXConnectionError { const CORE_SENDER: &str = "EALS"; const CHALLENGE_BUILD: &str = "release"; -const CHALLENGE_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb"; // Need to figure out how to generate this -const CHALLENGE_VERSION: &str = "10,5,30,15625"; - -lazy_static! { - static ref LSX_PATTERN: Regex = Regex::new(r".*?").unwrap(); -} +//const CHALLENGE_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb"; +const CHALLENGE_VERSION: &str = "10,5,64,37936"; // is it Origin-related (this is the last version) or EA Desktop (would be way above, needs checkings) +static LSX_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r".*?").expect("LSX pattern regex should be valid")); macro_rules! lsx_message_matcher { ( $connection_var:expr, $message_var:expr, $message_type:ty; $($name:ident $handler:ident),* $(,)? ) => { - paste::paste! { + pastey::paste! { match $message_var { $( $message_type::$name(msg) => $handler($connection_var, msg).await, @@ -104,60 +98,30 @@ pub enum EncryptionState { #[derive(Getters)] pub struct ConnectionState { - #[getter(skip)] maxima: LockedMaxima, + access_token: String, challenge: String, encryption: EncryptionState, pid: u32, - /// Message responses that are waiting to be sent - queued_messages: Vec, } -pub type LockedConnectionState = Arc>; - impl ConnectionState { /// Enable encryption on the packet after next pub fn enable_encryption(&mut self, encryption_key: [u8; 16]) { self.encryption = EncryptionState::Ready(encryption_key); } - - pub async fn maxima(&mut self) -> MutexGuard<'_, Maxima> { - self.maxima.lock().await - } - - pub fn maxima_arc(&mut self) -> LockedMaxima { - self.maxima.clone() - } - - pub async fn access_token(&mut self) -> Result { - self.maxima().await.access_token().await - } - - pub fn queue_message(&mut self, message: LSX) -> Result<(), LSXConnectionError> { - let mut str = quick_xml::se::to_string(&message)?; - debug!("Queuing LSX Message: {}", str); - - if let EncryptionState::Enabled(key) = self.encryption { - str = simple_encrypt(str.as_bytes(), &key) - }; - - str += "\0"; - self.queued_messages.push(str); - Ok(()) - } } pub fn get_os_pid(context: &ActiveGameContext) -> Result { let mut pid = None; let sys = System::new_all(); - for e in sys.processes() { - let (p_pid, process) = e; + for (p_pid, process) in sys.processes() { if process.cmd().is_empty() { continue; } - let mut cmd = process.cmd()[0].to_owned(); + let mut cmd = process.cmd()[0].to_string_lossy().into_owned(); // Wine path handling if cfg!(unix) && cmd.starts_with("Z:") { @@ -169,7 +133,8 @@ pub fn get_os_pid(context: &ActiveGameContext) -> Result { } for ele in process.environ() { - let (key, value) = ele.split_once('=').unwrap_or((ele, "")); + let env = ele.to_string_lossy(); + let (key, value) = env.split_once('=').unwrap_or((&env, "")); if key != "MXLaunchId" || value != context.launch_id() { continue; } @@ -183,66 +148,72 @@ 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 { - maxima: LockedMaxima, stream: TcpStream, - state: LockedConnectionState, + state: ConnectionState, + tx: Sender, } impl Connection { pub async fn new( maxima_arc: LockedMaxima, - stream: TcpStream, + mut stream: TcpStream, + tx: Sender, ) -> Result { stream.set_nodelay(true)?; - stream.set_nonblocking(true)?; - stream.set_read_timeout(Some(Duration::from_secs(1)))?; let maxima: MutexGuard<'_, Maxima> = maxima_arc.lock().await; let context: &ActiveGameContext = match maxima.playing() { - Some(context) => context, + Some(ctx) => ctx, None => { - stream.shutdown(std::net::Shutdown::Both)?; + stream.shutdown().await?; return Err(LSXConnectionError::GameContext); } }; // The PID system is mainly for Kyber injection let mut pid = get_os_pid(context); - if cfg!(unix) { - if let Ok(os_pid) = pid { - let sys = System::new_all(); - if let Some(process) = sys.process(Pid::from_u32(os_pid)) { - let filename = PathBuf::from( - process.cmd()[0] - .to_owned() - .replace("Z:", "") - .replace('\\', "/"), - ) - .file_name() - .ok_or(NativeError::FileName)? - .to_str() - .ok_or(NativeError::Stringify)? - .to_owned(); - - pid = get_wine_pid(&context.launch_id(), &filename).await; - } else { - warn!( - "Failed to find game process while looking for PID {}", - os_pid - ); - } + if cfg!(unix) + && let Ok(os_pid) = pid + { + let sys = System::new_all(); + if let Some(process) = sys.process(Pid::from_u32(os_pid)) { + let filename = PathBuf::from( + process.cmd()[0] + .to_string_lossy() + .replace("Z:", "") + .replace('\\', "/"), + ) + .file_name() + .ok_or(NativeError::FileName)? + .to_str() + .ok_or(NativeError::Stringify)? + .to_owned(); + + pid = get_wine_pid(context.launch_id(), &filename, context.slug().as_deref()).await; + } else { + warn!( + "Failed to find game process while looking for PID {}", + os_pid + ); } } @@ -252,73 +223,80 @@ impl Connection { warn!("Failed to find PID through launch ID, things may not work!"); } - let state = Arc::new(RwLock::new(ConnectionState { + // Generate fresh 16-byte challenge per connection + let challenge: String = { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) + }; + + drop(maxima); + + let state = ConnectionState { maxima: maxima_arc.clone(), - challenge: CHALLENGE_KEY.to_string(), + access_token: maxima_arc.lock().await.access_token().await.unwrap(), + challenge, encryption: EncryptionState::Disabled, pid: pid.unwrap_or(0), - queued_messages: Vec::new(), - })); - - Ok(Self { - maxima: maxima_arc.clone(), - stream, - state, - }) - } - - // State + }; - pub async fn maxima(&self) -> MutexGuard { - self.maxima.lock().await + Ok(Self { stream, state, tx }) } // Initialization - pub async fn send_challenge(&mut self) -> Result<(), LSXConnectionError> { + pub async fn queue_challenge(&mut self) -> Result<(), LSXConnectionError> { let challenge = create_lsx_message(LSXMessageType::Event(LSXEvent { sender: CORE_SENDER.to_string(), value: LSXEventType::Challenge(LSXChallenge { attr_build: CHALLENGE_BUILD.to_string(), - attr_key: self.state.read().await.challenge.to_owned(), + attr_key: self.state.challenge.to_owned(), attr_version: CHALLENGE_VERSION.to_string(), }), })); - self.state.write().await.queue_message(challenge)?; + self.queue_message(challenge).await?; Ok(()) } - pub async fn listen(&mut self) -> Result<(), LSXConnectionError> { - let mut buffer = [0; 1024 * 8]; + pub async fn write_message(&mut self, message: String) -> Result<(), LSXConnectionError> { + self.stream.write_all(message.as_bytes()).await?; + let _ = self.stream.flush().await; + Ok(()) + } - let n = match self.stream.read(&mut buffer) { - Ok(n) if n == 0 => { - return Err(LSXConnectionError::Closed); - } - Ok(n) => n, - Err(err) => { - let kind = err.kind(); - if kind == ErrorKind::WouldBlock { - return Ok(()); - } - return Err(LSXConnectionError::Internal(kind)); - } + pub async fn queue_message(&mut self, message: LSX) -> Result<(), LSXConnectionError> { + let mut str = quick_xml::se::to_string(&message)?; + debug!("Queuing LSX Message: {}", str); + + if let EncryptionState::Enabled(key) = self.state.encryption { + str = simple_encrypt(str.as_bytes(), &key) }; - let state = self.state.write().await; + str += "\0"; // Sent strings need to be null terminated + let _ = self.tx.send(str).await; + Ok(()) + } + + pub async fn read_incoming_messages(&mut self) -> Result<(), LSXConnectionError> { + self.stream.readable().await?; + let mut buffer = [0; 1024 * 8]; + let n = match self.stream.try_read(&mut buffer) { + Ok(0) => return Err(LSXConnectionError::Closed), + Ok(n) => n, + Err(e) if e.kind() == ErrorKind::WouldBlock => return Ok(()), + Err(e) => return Err(e.into()), + }; - let trimmed_buffer = &buffer[..n]; - let message = if let EncryptionState::Enabled(key) = state.encryption { + let trimmed_buffer = &buffer[..(n - 1)]; + let message = if let EncryptionState::Enabled(key) = self.state.encryption { simple_decrypt(trimmed_buffer, &key) } else { String::from_utf8_lossy(trimmed_buffer).trim().to_owned() }; - drop(state); - for mat in LSX_PATTERN.find_iter(message.as_str()) { - if let Err(err) = self.process_message(mat.as_str()).await { + if let Err(err) = self.process_incoming_message(mat.as_str()).await { error!("Failed to process message: {}", err); } } @@ -326,92 +304,56 @@ impl Connection { Ok(()) } - pub async fn process_queue(&mut self) -> Result<(), LSXConnectionError> { - let mut state = self.state.write().await; - for message in &state.queued_messages { - if let Err(err) = self.stream.write(message.as_bytes()) { - error!("Failed to send LSX message: {}", err); - } - } - - if !state.queued_messages.is_empty() { - self.stream.flush()?; - } - - state.queued_messages.clear(); - Ok(()) - } - // Message Processing - async fn process_message(&mut self, message: &str) -> Result<(), LSXConnectionError> { + async fn process_incoming_message(&mut self, message: &str) -> Result<(), LSXConnectionError> { debug!("Received LSX Message: {}", message); let mut message = message.to_string(); - message.remove_matches("version=\"\" "); + // replace unstable remove_matches (when most of the devs seem to think that it's stable since 2024 ??) w/ regex + message = message.replace(r#"version="" "#, ""); let lsx_message: LSX = quick_xml::de::from_str(message.as_str())?; - let state = self.state.clone(); - tokio::spawn(async move { - let reply: Result, LSXConnectionError> = match lsx_message.value - { - LSXMessageType::Event(msg) => Connection::process_event_message(&state, msg).await, - LSXMessageType::Request(msg) => { - Connection::process_request_message(&state, msg).await - } - LSXMessageType::Response(_) => unimplemented!(), - }; - - let reply: Option = match reply { - Ok(reply) => reply, - Err(err) => { - error!("Failed to process LSX message: {}", err); - return; - } - }; - - if let Some(reply) = reply { - let mut state = state.write().await; - let result = state.queue_message(LSX { value: reply }); - - if let Err(err) = result { - error!("Failed to queue LSX message: {}", err); - return; - } + let reply = match lsx_message.value { + LSXMessageType::Request(msg) => self.process_request_message(msg).await?, + LSXMessageType::Event(_) => { + None // Blank for now } - - let mut state = state.write().await; - if let EncryptionState::Ready(key) = state.encryption { - state.encryption = EncryptionState::Enabled(key); + LSXMessageType::Response(_) => { + warn!( + "Unexpected LSX Response message received, ignoring (server message type sent to server)" + ); + None } - }); + }; - Ok(()) - } + if let Some(reply) = reply { + self.queue_message(LSX { value: reply }).await?; + } - async fn process_event_message( - _: &LockedConnectionState, - _: LSXEvent, - ) -> Result, LSXConnectionError> { - Ok(None) + if let EncryptionState::Ready(key) = &self.state.encryption { + self.state.encryption = EncryptionState::Enabled(*key); + } + + Ok(()) } async fn process_request_message( - state: &LockedConnectionState, + &mut self, message: LSXRequest, ) -> Result, LSXConnectionError> { { - let pid = *state.read().await.pid(); - state - .write() - .await - .maxima() + let (maxima_arc, pid) = { (self.state.maxima.clone(), *self.state.pid()) }; + + maxima_arc + .lock() .await .call_event(MaximaEvent::ReceivedLSXRequest(pid, message.value.clone())); } let result = lsx_message_matcher!( - state.clone(), message.value, LSXRequestType; + + &mut self.state, message.value, LSXRequestType; ChallengeResponse handle_challenge_response, GetBlockList handle_get_block_list_request, diff --git a/maxima-lib/src/lsx/request/account.rs b/maxima-lib/src/lsx/request/account.rs index 5ddad4f9..c353a579 100644 --- a/maxima-lib/src/lsx/request/account.rs +++ b/maxima-lib/src/lsx/request/account.rs @@ -1,10 +1,10 @@ +use crate::lsx::connection::ConnectionState; use crate::{ core::service_layer::{ - ServiceEntitlement, ServiceSdkEntitlementsRequestBuilder, ServiceSdkEntitlementsResult, - SERVICE_REQUEST_SDKENTITLEMENTS, + SERVICE_REQUEST_SDKENTITLEMENTS, ServiceEntitlement, ServiceSdkEntitlementsRequestBuilder, + ServiceSdkEntitlementsResult, }, lsx::{ - connection::LockedConnectionState, request::LSXRequestError, types::{ LSXEntitlement, LSXQueryEntitlements, LSXQueryEntitlementsResponse, LSXResponseType, @@ -14,10 +14,10 @@ use crate::{ }; pub async fn handle_query_entitlements_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXQueryEntitlements, ) -> Result, LSXRequestError> { - let maxima = state.write().await.maxima_arc(); + let maxima = state.maxima().clone(); let maxima = maxima.lock().await; let service_layer = maxima.service_layer(); @@ -25,7 +25,7 @@ pub async fn handle_query_entitlements_request( let response: ServiceSdkEntitlementsResult = service_layer .request( - SERVICE_REQUEST_SDKENTITLEMENTS, + &SERVICE_REQUEST_SDKENTITLEMENTS, ServiceSdkEntitlementsRequestBuilder::default() .page_number(1) .page_size(100) @@ -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?; @@ -45,8 +46,8 @@ pub async fn handle_query_entitlements_request( lsx_entitlements.push(LSXEntitlement { attr_LastModifiedDate: "0000-00-00T00:00:00".to_string(), // it's like this in EAD too attr_EntitlementId: entitlement.id().parse::()?, - attr_UseCount: entitlement.use_count().clone(), - attr_Version: entitlement.version().clone(), + attr_UseCount: *entitlement.use_count(), + attr_Version: *entitlement.version(), attr_ItemId: entitlement.product_id().clone(), attr_ResourceId: String::new(), attr_GrantDate: entitlement.grant_date().to_string(), diff --git a/maxima-lib/src/lsx/request/auth.rs b/maxima-lib/src/lsx/request/auth.rs index ff1fd392..05952344 100644 --- a/maxima-lib/src/lsx/request/auth.rs +++ b/maxima-lib/src/lsx/request/auth.rs @@ -3,7 +3,7 @@ use log::{error, info}; use crate::{ core::auth::{context::AuthContext, nucleus_auth_exchange}, lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXAuthCode, LSXGetAuthCode, LSXResponseType}, }, @@ -11,7 +11,7 @@ use crate::{ }; pub async fn handle_auth_code_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXGetAuthCode, ) -> Result, LSXRequestError> { let client_id = request.attr_ClientId; @@ -19,8 +19,8 @@ pub async fn handle_auth_code_request( let mut context = AuthContext::new()?; - let access_token = state.write().await.access_token().await?; - context.set_access_token(&access_token); + let access_token = state.access_token(); + context.set_access_token(access_token); let auth_res = nucleus_auth_exchange(&context, &client_id, "code").await; let auth_code = match auth_res { diff --git a/maxima-lib/src/lsx/request/challenge.rs b/maxima-lib/src/lsx/request/challenge.rs index 2ad92fc5..f6dcc1c5 100644 --- a/maxima-lib/src/lsx/request/challenge.rs +++ b/maxima-lib/src/lsx/request/challenge.rs @@ -2,7 +2,7 @@ use log::{debug, info}; use crate::{ lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXChallengeAccepted, LSXChallengeResponse, LSXResponseType}, }, @@ -11,10 +11,10 @@ use crate::{ }; pub async fn handle_challenge_response( - state: LockedConnectionState, + state: &mut ConnectionState, message: LSXChallengeResponse, ) -> Result, LSXRequestError> { - let valid = check_challenge_response(&message.attr_response, &state.read().await.challenge()); + let valid = check_challenge_response(&message.attr_response, state.challenge()); if !valid { return Err(LSXRequestError::InvalidChallengeResponse); } @@ -33,7 +33,7 @@ pub async fn handle_challenge_response( ); let encryption_key = make_lsx_key(seed); - state.write().await.enable_encryption(encryption_key); + state.enable_encryption(encryption_key); debug!( "Encryption key: {}, version: {}", diff --git a/maxima-lib/src/lsx/request/config.rs b/maxima-lib/src/lsx/request/config.rs index b7d9134c..ba7da967 100644 --- a/maxima-lib/src/lsx/request/config.rs +++ b/maxima-lib/src/lsx/request/config.rs @@ -1,16 +1,16 @@ -use lazy_static::lazy_static; +use std::sync::LazyLock; use crate::{ lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXGetConfig, LSXGetConfigResponse, LSXResponseType, LSXService}, }, make_lsx_handler_response, }; -lazy_static! { - static ref SERVICES: Vec = vec![ +static SERVICES: LazyLock> = LazyLock::new(|| { + vec![ LSXService::new("EbisuSDK", "SDK"), LSXService::new("EbisuSDK", "PROFILE"), LSXService::new("XMPP", "PRESENCE"), @@ -45,17 +45,14 @@ lazy_static! { LSXService::new("PI", "PROGRESSIVE_INSTALLATION"), LSXService::new("PI", "PROGRESSIVE_INSTALLATION_EVENT"), LSXService::new("EbisuSDK", "CONTENT"), - ]; -} + ] +}); pub async fn handle_config_request( - _: LockedConnectionState, + _: &mut ConnectionState, _: LSXGetConfig, ) -> Result, LSXRequestError> { - let mut services: Vec = Vec::new(); - for service in SERVICES.iter() { - services.push(service.clone()); - } + let services = &SERVICES; - make_lsx_handler_response!(Response, GetConfigResponse, { service: services }) + make_lsx_handler_response!(Response, GetConfigResponse, { service: services.to_vec() }) } diff --git a/maxima-lib/src/lsx/request/core.rs b/maxima-lib/src/lsx/request/core.rs index 21591c71..30e85ed1 100644 --- a/maxima-lib/src/lsx/request/core.rs +++ b/maxima-lib/src/lsx/request/core.rs @@ -1,7 +1,7 @@ use crate::{ core::settings::MaximaSetting, lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{ LSXGetInternetConnectedState, LSXGetSetting, LSXGetSettingResponse, @@ -12,7 +12,7 @@ use crate::{ }; pub async fn handle_settings_request( - _: LockedConnectionState, + _: &mut ConnectionState, request: LSXGetSetting, ) -> Result, LSXRequestError> { let setting = match request.attr_SettingId { @@ -25,7 +25,7 @@ pub async fn handle_settings_request( } pub async fn handle_connectivity_request( - _: LockedConnectionState, + _: &mut ConnectionState, _: LSXGetInternetConnectedState, ) -> Result, LSXRequestError> { // TODO Actually check this @@ -33,7 +33,7 @@ pub async fn handle_connectivity_request( } pub async fn handle_set_downloader_util_request( - _: LockedConnectionState, + _: &mut ConnectionState, _: LSXSetDownloaderUtilization, ) -> Result, LSXRequestError> { // TODO Actually set this diff --git a/maxima-lib/src/lsx/request/game.rs b/maxima-lib/src/lsx/request/game.rs index 6e90a5ad..5d976b44 100644 --- a/maxima-lib/src/lsx/request/game.rs +++ b/maxima-lib/src/lsx/request/game.rs @@ -5,7 +5,7 @@ const LANGUAGES: &str = use crate::{ lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{ LSXGameInfoId, LSXGetAllGameInfo, LSXGetAllGameInfoResponse, LSXGetGameInfo, @@ -16,7 +16,7 @@ use crate::{ }; pub async fn handle_game_info_request( - _: LockedConnectionState, + _: &mut ConnectionState, request: LSXGetGameInfo, ) -> Result, LSXRequestError> { let game_info = match request.attr_GameInfoId { @@ -30,27 +30,71 @@ pub async fn handle_game_info_request( // -/// Just realized we're still telling every game that it's titanfall. -/// Should fix that at some point! pub async fn handle_all_game_info_request( - _: LockedConnectionState, - _: LSXGetAllGameInfo, + state: &mut ConnectionState, + _request: LSXGetAllGameInfo, ) -> Result, LSXRequestError> { + let current_offer = { + let maxima = state.maxima().lock().await; + + maxima + .playing() + .as_ref() + .and_then(|context| context.current_offer()) + }; + + let (display_name, available_version, installed_version, full_game_release_date) = + match current_offer.as_ref() { + Some(owned_offer) => { + let display_name = owned_offer.offer().display_name().to_string(); + let available_version = owned_offer + .offer() + .downloads() + .first() + .and_then(|download| download.game_version().clone()) + .unwrap_or_else(|| "0".to_string()); + let installed_version = owned_offer + .installed_version() + .await + .ok() + .unwrap_or_else(|| "0".to_string()); + let full_game_release_date = owned_offer + .offer() + .downloads() + .first() + .map(|download| download.build_live_date().clone()) + .unwrap_or_else(|| "0000-00-00T00:00:00".to_string()); + + ( + display_name, + available_version, + installed_version, + full_game_release_date, + ) + } + None => ( + "Unknown Game".to_string(), + "0".to_string(), + "0".to_string(), + "0000-00-00T00:00:00".to_string(), + ), + }; + make_lsx_handler_response!(Response, GetAllGameInfoResponse, { attr_FullGamePurchased: true, attr_FullGameReleased: true, - attr_InstalledVersion: "0".to_string(), + attr_InstalledVersion: installed_version, attr_MaxGroupSize: 16, attr_Languages: LANGUAGES.to_string(), attr_Expiration: "0000-00-00T00:00:00".to_string(), attr_UpToDate: true, attr_HasExpiration: false, attr_EntitlementSource: "STEAM".to_string(), - attr_AvailableVersion: "1.0.1.3".to_string(), - attr_DisplayName: "Titanfall® 2 Deluxe Edition".to_string(), + attr_AvailableVersion: available_version, + attr_DisplayName: display_name, attr_FreeTrial: false, attr_InstalledLanguage: "en_US".to_string(), - attr_FullGameReleaseDate: "2016-10-28T04:00:00".to_string(), + attr_FullGameReleaseDate: full_game_release_date, attr_SystemTime: "2023-06-22T04:00:00".to_string() }) } diff --git a/maxima-lib/src/lsx/request/igo.rs b/maxima-lib/src/lsx/request/igo.rs index 52577b30..04e6d6b8 100644 --- a/maxima-lib/src/lsx/request/igo.rs +++ b/maxima-lib/src/lsx/request/igo.rs @@ -1,19 +1,18 @@ use log::info; use crate::lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXResponseType, LSXShowIGOWindow}, }; pub async fn handle_show_igo_window_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXShowIGOWindow, ) -> Result, LSXRequestError> { info!("Got request to show user {}", request.target_id); - let arc = state.write().await.maxima_arc(); - let maxima = arc.lock().await; + let maxima = state.maxima().lock().await; let data = maxima.player_by_id(&request.target_id.to_string()).await?; info!("{:?}", data); diff --git a/maxima-lib/src/lsx/request/license.rs b/maxima-lib/src/lsx/request/license.rs index 19261ac1..0cabf103 100644 --- a/maxima-lib/src/lsx/request/license.rs +++ b/maxima-lib/src/lsx/request/license.rs @@ -1,19 +1,19 @@ -use std::env; use log::{debug, info}; +use std::env; use crate::{ core::{auth::hardware::HardwareInfo, launch::LaunchMode}, lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXRequestLicense, LSXRequestLicenseResponse, LSXResponseType}, }, make_lsx_handler_response, - ooa::{request_license, LicenseAuth}, + ooa::{LicenseAuth, request_license}, }; pub async fn handle_license_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXRequestLicense, ) -> Result, LSXRequestError> { info!("Requesting OOA License and Denuvo Token"); @@ -22,12 +22,12 @@ pub async fn handle_license_request( return make_lsx_handler_response!(Response, RequestLicenseResponse, { attr_License: token.to_owned() }); } - let arc = state.write().await.maxima_arc(); - let mut maxima = arc.lock().await; + let mut maxima = state.maxima().lock().await; 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 +40,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/lsx/request/offer.rs b/maxima-lib/src/lsx/request/offer.rs index b3f9d1e4..1c3f8d9c 100644 --- a/maxima-lib/src/lsx/request/offer.rs +++ b/maxima-lib/src/lsx/request/offer.rs @@ -1,9 +1,9 @@ use crate::{ core::service_layer::{ - ServiceAddonSearchRequestBuilder, ServiceAddonSearchResultRoot, SERVICE_REQUEST_ADDONSEARCH, + SERVICE_REQUEST_ADDONSEARCH, ServiceAddonSearchRequestBuilder, ServiceAddonSearchResultRoot, }, lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXOffer, LSXQueryOffers, LSXQueryOffersResponse, LSXResponseType}, }, @@ -11,7 +11,7 @@ use crate::{ }; pub async fn handle_query_offers_request( - conn: LockedConnectionState, + conn: &mut ConnectionState, request: LSXQueryOffers, ) -> Result, LSXRequestError> { let mut rtn: Vec = Vec::new(); @@ -22,12 +22,11 @@ pub async fn handle_query_offers_request( String::new() }; - let mut conn = conn.write().await; - let maxima = conn.maxima().await; + let maxima = conn.maxima().lock().await; let offers: ServiceAddonSearchResultRoot = maxima .service_layer() .request( - SERVICE_REQUEST_ADDONSEARCH, + &SERVICE_REQUEST_ADDONSEARCH, ServiceAddonSearchRequestBuilder::default() .platform(String::new()) .category_id(category) @@ -48,19 +47,19 @@ pub async fn handle_query_offers_request( attr_InventoryAvailable: 0, attr_PurchaseDate: "0000-00-00T00:00:00".to_string(), attr_DownloadSize: 0, - attr_bCanPurchase: offer.user_can_purchase().clone(), + attr_bCanPurchase: *offer.user_can_purchase(), attr_Price: offer.list_price().to_string(), attr_Type: offer.origin_display_type().to_string(), attr_LocalizedOriginalPrice: offer.display_price().to_string(), attr_InventoryCap: 0, attr_Description: offer.long_description().to_string(), - attr_bHidden: !offer.is_published().clone(), + attr_bHidden: !*offer.is_published(), attr_PlayableDate: offer.published_date().to_string(), attr_Name: offer.display_name().to_string(), attr_ImageId: String::new(), - attr_bIsDiscounted: offer.is_discount().clone(), + attr_bIsDiscounted: *offer.is_discount(), attr_OfferId: offer.offer_id().clone(), - attr_bIsOwned: offer.is_owned().clone(), + attr_bIsOwned: *offer.is_owned(), }) } diff --git a/maxima-lib/src/lsx/request/profile.rs b/maxima-lib/src/lsx/request/profile.rs index e8759cd6..71d751c8 100644 --- a/maxima-lib/src/lsx/request/profile.rs +++ b/maxima-lib/src/lsx/request/profile.rs @@ -1,12 +1,12 @@ use log::{debug, info}; use crate::core::service_layer::{ - ServiceFriends, ServiceGetMyFriendsRequestBuilder, ServiceLayerError, - SERVICE_REQUEST_GETMYFRIENDS, + SERVICE_REQUEST_GETMYFRIENDS, ServiceFriends, ServiceGetMyFriendsRequestBuilder, + ServiceLayerError, }; use crate::{ lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{ LSXBlockedUser, LSXErrorSuccess, LSXFriend, LSXFriendState, LSXGetBlockList, @@ -18,77 +18,71 @@ use crate::{ }, make_lsx_handler_response, rtm::client::{BasicPresence, RichPresenceBuilder}, - util::native::{platform_path, NativeError, SafeStr}, + util::native::{SafeStr, platform_path}, }; pub async fn handle_profile_request( - state: LockedConnectionState, + state: &mut ConnectionState, _: LSXGetProfile, ) -> Result, LSXRequestError> { - let arc = state.write().await.maxima_arc(); - let maxima = arc.lock().await; + let maxima = state.maxima().lock().await; let user = maxima.local_user().await?; - let path = platform_path(maxima.avatar_image(&user.id(), 208, 208).await?); + let path = platform_path(maxima.avatar_image(user.id(), 208, 208).await?); let player = user .player() .as_ref() .ok_or(ServiceLayerError::MissingField)?; let name = player.unique_name(); - debug!("Got profile for {} {:?}", &name, path); - - make_lsx_handler_response!(Response, GetProfileResponse, { - attr_Persona: name.to_owned(), - attr_SubscriberLevel: 0, - attr_CommerceCurrency: "USD".to_string(), - attr_IsTrialSubscriber: false, - attr_Country: "US".to_string(), - attr_UserId: user.id().parse::()?, - attr_GeoCountry: "US".to_string(), - attr_AvatarId: path.safe_str()?.to_string(), - attr_IsSubscriber: false, - attr_IsSteamSubscriber: false, - attr_PersonaId: player.psd().parse::()?, - attr_IsUnderAge: false, - attr_UserIndex: 0, - }) + debug!("Got profile for {} {:?}", name, path); + + return make_lsx_handler_response!(Response, GetProfileResponse, { + attr_Persona: name.to_owned(), + attr_SubscriberLevel: 0, + attr_CommerceCurrency: "USD".to_string(), + attr_IsTrialSubscriber: false, + attr_Country: "US".to_string(), + attr_UserId: user.id().parse::()?, + attr_GeoCountry: "US".to_string(), + attr_AvatarId: path.safe_str()?.to_string(), + attr_IsSubscriber: false, + attr_IsSteamSubscriber: false, + attr_PersonaId: player.psd().parse::()?, + attr_IsUnderAge: false, + attr_UserIndex: 0, + }); } pub async fn handle_presence_request( - _: LockedConnectionState, + _: &mut ConnectionState, _: LSXGetPresence, ) -> Result, LSXRequestError> { make_lsx_handler_response!(Response, GetPresenceResponse, { - attr_UserId: 1005663144213, - attr_Presence: LSXPresence::Ingame, - attr_Title: None, - attr_TitleId: None, - attr_MultiplayerId: None, - attr_RichPresence: None, - attr_GamePresence: None, - attr_SessionId: None, - attr_Group: None, - attr_GroupId: None, + attr_UserId: 1005663144213, + attr_Presence: LSXPresence::Ingame, + attr_Title: None, + attr_TitleId: None, + attr_MultiplayerId: None, + attr_RichPresence: None, + attr_GamePresence: None, + attr_SessionId: None, + attr_Group: None, + attr_GroupId: None, }) } pub async fn handle_set_presence_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXSetPresence, ) -> Result, LSXRequestError> { info!( "Setting Presence to {:?}: {}", request.attr_Presence, - request - .attr_RichPresence - .to_owned() - .or(Some(String::new())) - .unwrap() + request.attr_RichPresence.to_owned().unwrap_or_default() ); - let arc = state.write().await.maxima_arc(); - let mut maxima = arc.lock().await; + let mut maxima = state.maxima().lock().await; let playing = maxima.playing().as_ref().unwrap(); if playing.mode().is_online_offline() { @@ -114,13 +108,12 @@ pub async fn handle_set_presence_request( } pub async fn handle_query_presence_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXQueryPresence, ) -> Result, LSXRequestError> { let mut friends = Vec::new(); - let mut state = state.write().await; - let mut maxima = state.maxima().await; + let mut maxima = state.maxima().lock().await; let presence_store = maxima.rtm().presence_store().lock().await; for user in request.Users { @@ -156,11 +149,10 @@ pub async fn handle_query_presence_request( } pub async fn handle_query_friends_request( - state: LockedConnectionState, + state: &mut ConnectionState, _: LSXQueryFriends, ) -> Result, LSXRequestError> { - let mut state = state.write().await; - let mut maxima = state.maxima().await; + let mut maxima = state.maxima().lock().await; let friends = maxima.friends(0).await?; let presence_store = maxima.rtm().presence_store().lock().await; @@ -171,7 +163,7 @@ pub async fn handle_query_friends_request( continue; } - let mut presence = presence_store.get(ele.id()).unwrap_or_else(|| { + let presence = presence_store.get(ele.id()).unwrap_or_else(|| { RichPresenceBuilder::default() .basic(BasicPresence::Offline) .status(String::new()) @@ -219,17 +211,16 @@ pub async fn handle_query_friends_request( } pub async fn handle_get_block_list_request( - state: LockedConnectionState, + state: &mut ConnectionState, _: LSXGetBlockList, ) -> Result, LSXRequestError> { let mut list: Vec = Vec::new(); - let mut maxima = state.write().await; - let maxima = maxima.maxima().await; + let maxima = state.maxima().lock().await; let friends: ServiceFriends = maxima .service_layer() .request( - SERVICE_REQUEST_GETMYFRIENDS, + &SERVICE_REQUEST_GETMYFRIENDS, ServiceGetMyFriendsRequestBuilder::default() .limit(100) .offset(0) @@ -255,13 +246,12 @@ pub async fn handle_get_block_list_request( } pub async fn handle_query_image_request( - state: LockedConnectionState, + state: &mut ConnectionState, request: LSXQueryImage, ) -> Result, LSXRequestError> { let parts = request.attr_ImageId.split(":").collect::>(); - let arc = state.write().await.maxima_arc(); - let maxima = arc.lock().await; + let maxima = state.maxima().lock().await; let path = platform_path( maxima @@ -269,13 +259,12 @@ pub async fn handle_query_image_request( .await?, ); - let mut images = Vec::new(); - images.push(LSXImage { + let images = vec![LSXImage { attr_ImageId: request.attr_ImageId, attr_Width: request.attr_Width, attr_Height: request.attr_Height, attr_ResourcePath: path.safe_str()?.to_string(), - }); + }]; make_lsx_handler_response!(Response, QueryImageResponse, { attr_Result: 1, image: images, }) } diff --git a/maxima-lib/src/lsx/request/progressive_install.rs b/maxima-lib/src/lsx/request/progressive_install.rs index 07b74fb3..364aebd7 100644 --- a/maxima-lib/src/lsx/request/progressive_install.rs +++ b/maxima-lib/src/lsx/request/progressive_install.rs @@ -1,6 +1,6 @@ use crate::{ lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{ LSXAreChunksInstalled, LSXAreChunksInstalledResponse, @@ -12,7 +12,7 @@ use crate::{ }; pub async fn handle_pi_availability_request( - _: LockedConnectionState, + _: &mut ConnectionState, _: LSXIsProgressiveInstallationAvailable, ) -> Result, LSXRequestError> { make_lsx_handler_response!(Response, IsProgressiveInstallationAvailableResponse, { @@ -22,7 +22,7 @@ pub async fn handle_pi_availability_request( } pub async fn handle_pi_installed_chunks_request( - _: LockedConnectionState, + _: &mut ConnectionState, request: LSXAreChunksInstalled, ) -> Result, LSXRequestError> { make_lsx_handler_response!(Response, AreChunksInstalledResponse, { diff --git a/maxima-lib/src/lsx/request/voip.rs b/maxima-lib/src/lsx/request/voip.rs index 61f846f4..534d8288 100644 --- a/maxima-lib/src/lsx/request/voip.rs +++ b/maxima-lib/src/lsx/request/voip.rs @@ -1,6 +1,6 @@ use crate::{ lsx::{ - connection::LockedConnectionState, + connection::ConnectionState, request::LSXRequestError, types::{LSXGetVoipStatus, LSXGetVoipStatusResponse, LSXResponseType}, }, @@ -8,7 +8,7 @@ use crate::{ }; pub async fn handle_voip_status_request( - _: LockedConnectionState, + _: &mut ConnectionState, _: LSXGetVoipStatus, ) -> Result, LSXRequestError> { make_lsx_handler_response!(Response, GetVoipStatusResponse, { attr_Available: false, attr_Active: false }) diff --git a/maxima-lib/src/lsx/service.rs b/maxima-lib/src/lsx/service.rs index e5cef99c..81eb8d49 100644 --- a/maxima-lib/src/lsx/service.rs +++ b/maxima-lib/src/lsx/service.rs @@ -1,7 +1,6 @@ -use std::{io::ErrorKind, net::TcpListener, time::Duration}; - use log::{info, warn}; -use tokio::time::sleep; +use tokio::net::TcpListener; +use tokio::sync::mpsc::Receiver; use crate::lsx::connection::LSXConnectionError; use crate::{core::LockedMaxima, lsx::connection::Connection}; @@ -16,62 +15,63 @@ pub enum LSXServerError { } pub async fn start_server(port: u16, maxima: LockedMaxima) -> Result<(), LSXServerError> { - let addr = "127.0.0.1:".to_string() + port.to_string().as_str(); + let addr = format!("127.0.0.1:{}", port); - let listener = TcpListener::bind(&addr)?; - listener.set_nonblocking(true)?; + let listener = TcpListener::bind(&addr).await?; info!("Listening on: {}", addr); - let mut connections: Vec = Vec::new(); - loop { - let mut idx = 0 as usize; - while idx < connections.len() { - let connection = &mut connections[idx]; - - if let Err(_) = connection.process_queue().await { - warn!("Failed to process LSX message queue"); - } - - if let Err(_) = connection.listen().await { - warn!("LSX connection closed"); - connections.remove(idx); - maxima - .lock() - .await - .set_lsx_connections(connections.len() as u16); - continue; - } - - idx = idx + 1; - } - - let (socket, addr) = match listener.accept() { + let (socket, addr) = match listener.accept().await { Ok(s) => s, - Err(err) => { - let kind = err.kind(); - if kind == ErrorKind::WouldBlock { - sleep(Duration::from_millis(20)).await; - continue; - } - return Err(LSXServerError::Io(err)); - } + Err(err) => return Err(LSXServerError::Io(err)), }; info!("New LSX connection: {:?}", addr); + let (tx, rx) = tokio::sync::mpsc::channel(100); + let mut conn = match Connection::new(maxima.clone(), socket, tx).await { + Ok(c) => c, + Err(err) => { + warn!("Failed to establish LSX connection: {}", err); + continue; + } + }; - let conn = Connection::new(maxima.clone(), socket).await; - if let Err(err) = conn { - warn!("Failed to establish LSX connection: {}", err); - continue; - } - - let mut conn = conn?; - conn.send_challenge().await?; - connections.push(conn); + conn.queue_challenge().await?; let mut maxima = maxima.lock().await; - maxima.set_lsx_connections(connections.len() as u16); + maxima.inc_connected_lsx(); maxima.set_player_started(); + drop(maxima); + + tokio::spawn(async move { + if let Err(err) = handle_client(conn, rx).await { + warn!("LSX connection error: {}", err); + } + }); + } +} + +pub async fn handle_client( + mut conn: Connection, + mut rx: Receiver, +) -> Result<(), LSXServerError> { + loop { + tokio::select! { + res = conn.read_incoming_messages() => { + match res { + Ok(_) => continue, + Err(err) => { + warn!("LSX connection error: {}", err); + return Err(LSXServerError::Conn(err)); + } + } + }, + Some(msg) = rx.recv() => { + if let Err(err) = conn.write_message(msg).await { + warn!("LSX connection error: {}", err); + return Err(LSXServerError::Conn(err)); + } + }, + } } } diff --git a/maxima-lib/src/lsx/types.rs b/maxima-lib/src/lsx/types.rs index 88d5feae..9f2d9134 100644 --- a/maxima-lib/src/lsx/types.rs +++ b/maxima-lib/src/lsx/types.rs @@ -22,7 +22,7 @@ macro_rules! lsx_message { ),* $(,)? } ) => { - paste::paste! { + pastey::paste! { // Main struct definition $(#[$message_attr])* #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -147,7 +147,7 @@ pub fn create_lsx_message(r#type: LSXMessageType) -> LSX { #[macro_export] macro_rules! make_lsx_handler_response { ($reply_type:ty, $reply_name:ident, $reply_initializer:tt) => { - paste::paste! { + pastey::paste! { Ok(Some([]::$reply_name( [] $reply_initializer ))) diff --git a/maxima-lib/src/ooa/mod.rs b/maxima-lib/src/ooa/mod.rs index c61cf76e..084238b4 100644 --- a/maxima-lib/src/ooa/mod.rs +++ b/maxima-lib/src/ooa/mod.rs @@ -1,28 +1,21 @@ -use aes::cipher::block_padding::UnpadError; -use aes::cipher::{ - block_padding::Pkcs7, generic_array::GenericArray, BlockDecryptMut, BlockEncryptMut, KeyIvInit, -}; +use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::Pkcs7}; use chrono::{DateTime, Duration, Utc}; use log::{debug, warn}; -use std::string::FromUtf8Error; -use std::{ - fs::create_dir_all, - io::{Read, Write}, - path::PathBuf, -}; -use tokio::fs::{self, File}; +use std::{fs::create_dir_all, path::PathBuf, string::FromUtf8Error, sync::LazyLock}; +use tokio::fs; -use base64::{engine::general_purpose, DecodeError, Engine}; +use base64::{DecodeError, Engine, engine::general_purpose}; use crate::core::{auth::hardware::HardwareInfo, endpoints::API_PROXY_NOVAFUSION_LICENSES}; #[cfg(unix)] use crate::unix::fs::case_insensitive_path; -use crate::util::native::{NativeError, SafeParent, SafeStr}; -use lazy_static::lazy_static; +#[cfg(unix)] +use crate::util::native::SafeStr; +use crate::util::native::{NativeError, SafeParent}; use quick_xml::DeError; use regex::Regex; use reqwest::header::ToStrError; -use reqwest::{Client, StatusCode}; +use reqwest::{Client, StatusCode, Url}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -33,12 +26,12 @@ pub const OOA_CRYPTO_KEY: [u8; 16] = [ type Aes128CbcEnc = cbc::Encryptor; type Aes128CbcDec = cbc::Decryptor; -lazy_static! { - static ref EMAIL_PATTERN: Regex = Regex::new( - r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})" +static EMAIL_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new( + r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})", ) - .unwrap(); -} + .expect("email regex should be valid") +}); const LICENSE_PATH: &str = "ProgramData/Electronic Arts/EA Services/License"; @@ -78,8 +71,6 @@ pub enum LicenseError { #[error(transparent)] ToStr(#[from] ToStrError), #[error(transparent)] - Unpad(#[from] UnpadError), - #[error(transparent)] Utf8(#[from] FromUtf8Error), #[error(transparent)] DeError(#[from] DeError), @@ -96,6 +87,9 @@ pub enum LicenseError { #[error("license request failed: `{0}`")] Http(String), + + #[error("xml parsing failed: `{0}`")] + SeError(#[from] quick_xml::se::SeError), } pub fn detect_ooa_state(game_path: PathBuf) -> OOAState { @@ -151,18 +145,18 @@ pub async fn detect_ooa_version(game_path: PathBuf) -> Result if major >= 5 { // >=5.0.0 - return Ok(4); + Ok(4) } else if major == 4 && (minor > 7 || (minor == 7 && patch >= 6)) { // >=4.7.6 - return Ok(3); + Ok(3) } else if major == 4 && minor == 7 { // >=4.7.0 - return Ok(2); + Ok(2) } else if major == 4 { // >=4.0.0 - return Ok(1); + Ok(1) } else { - return Ok(0); + Ok(0) } } @@ -172,8 +166,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 +197,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 +211,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 +220,7 @@ pub async fn request_and_save_license( None, ) .await?; - save_licenses(&license, state).await?; + save_licenses(&license, state, slug).await?; Ok(()) } @@ -266,9 +264,11 @@ pub async fn request_license( query.push(("requestType", request_type)); } + let mut url = Url::parse(API_PROXY_NOVAFUSION_LICENSES).unwrap(); + url.query_pairs_mut().extend_pairs(query.iter().copied()); + let res = Client::new() - .get(API_PROXY_NOVAFUSION_LICENSES) - .query(&query) + .get(url) .header("X-Requester-Id", "Origin Online Activation") .header("User-Agent", "EACTransaction") .send() @@ -290,22 +290,19 @@ pub async fn request_license( } pub fn decrypt_license(data: &[u8]) -> Result { - let key = GenericArray::from_slice(&OOA_CRYPTO_KEY); - let iv = GenericArray::from_slice(&[0u8; 16]); - let cipher = Aes128CbcDec::new(key, iv); + let iv = [0u8; 16]; + let cipher = Aes128CbcDec::new((&OOA_CRYPTO_KEY).into(), (&iv).into()); - let decrypted_data = cipher.decrypt_padded_vec_mut::(data)?; + let decrypted_data = cipher.decrypt_padded_vec::(data).unwrap(); let data_str = String::from_utf8(decrypted_data)?; Ok(quick_xml::de::from_str(&data_str)?) } pub fn encrypt_license(data: &str) -> Result, LicenseError> { - let key = GenericArray::from_slice(&OOA_CRYPTO_KEY); - let iv = GenericArray::from_slice(&[0u8; 16]); - - let cipher = Aes128CbcEnc::new(key, iv); - Ok(cipher.encrypt_padded_vec_mut::(data.as_bytes())) + let iv = [0u8; 16]; + let cipher = Aes128CbcEnc::new((&OOA_CRYPTO_KEY).into(), (&iv).into()); + Ok(cipher.encrypt_padded_vec::(data.as_bytes())) } pub async fn save_license( @@ -317,7 +314,9 @@ pub async fn save_license( data.push_str(quick_xml::se::to_string(license)?.as_str()); if !data.contains("") { - data.remove_matches(""); + // replace unstable remove_matches (when most of the devs seem to think that it's stable since 2024 ??) w/ regex + let re = Regex::new(r#""#).unwrap(); + data = re.replace_all(&data, "").to_string(); } let encrypted_data = encrypt_license(&data)?; @@ -337,19 +336,23 @@ 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( - &license, + license, state, path.join(format!("{}.dlf", license.content_id)), ) .await?; save_license( - &license, + license, state, path.join(format!("{}_cached.dlf", license.content_id)), ) @@ -359,19 +362,19 @@ pub async fn save_licenses(license: &License, state: OOAState) -> Result<(), Lic } #[cfg(windows)] -pub fn get_license_dir() -> Result { - let path = format!("C:/{}", LICENSE_PATH.to_string()); +pub fn get_license_dir(_slug: Option<&str>) -> Result { + let path = format!("C:/{}", LICENSE_PATH); 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()).unwrap().safe_str()?, LICENSE_PATH.to_string() ); create_dir_all(&path)?; diff --git a/maxima-lib/src/rtm/client.rs b/maxima-lib/src/rtm/client.rs index f5ad3288..d56134c7 100644 --- a/maxima-lib/src/rtm/client.rs +++ b/maxima-lib/src/rtm/client.rs @@ -1,23 +1,24 @@ -use std::{io, sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration}; use core::future::Future; use derive_builder::Builder; use derive_getters::Getters; -use log::{debug, error, info, warn}; +use log::{debug, error, info}; use moka::sync::Cache; use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, Mutex}; +use tokio::sync::{Mutex, mpsc}; use super::{ + RtmError, connection::RtmConnectionManager, proto::{ - communication_v1, success_v1, BasicPresenceType, HeartbeatV1, LoginV3Response, Player, - PresenceUpdateV1, RichPresenceType, RichPresenceV1, SessionCleanupV1, + BasicPresenceType, HeartbeatV1, LoginV3Response, Player, PresenceSubscribeAllFriendsV1, + PresenceUpdateV1, RichPresenceType, RichPresenceV1, SessionCleanupV1, communication_v1, + success_v1, }, - RtmError, }; use crate::{ - core::auth::storage::{AuthError, LockedAuthStorage, TokenError}, + core::auth::storage::{AuthError, LockedAuthStorage}, rtm::proto::{LoginRequestV3, PlatformV1, PresenceSubscribeV1, PresenceV1, UserType}, }; @@ -28,19 +29,21 @@ macro_rules! send_and_forget_rtm_request { } macro_rules! send_rtm_request { - ($connection_manager: expr, $request_body_name: ident, $comm_name: ident, $response_body_name: ident, $response_comm_name: ident, $comm_initializer:tt) => { + ($connection_manager:expr, $request_body_name:ident, $comm_name:ident, $response_body_name:ident, $response_comm_name:ident, $comm_initializer:tt) => { { fn _rtm_transform( fut: impl Future> + Send, ) -> impl Future> + Send { async move { match fut.await? { - communication_v1::Body::Success(success) => match success.body { - Some(body) => match body { - success_v1::Body::$response_body_name(data) => Ok(data), - any => Err(RtmError::InvalidResponse(any)), - }, - None => Err(RtmError::NoBody), + communication_v1::Body::Success(success) => { + match success.body { + Some(body) => match body { + success_v1::Body::$response_body_name(data) => Ok(data), + body => Err(RtmError::InvalidResponse(body)), + }, + None => Err(RtmError::NoBody), + } } communication_v1::Body::Error(err) => Err(RtmError::V1(err)), any => Err(RtmError::InvalidVariant(any)), @@ -48,9 +51,13 @@ macro_rules! send_rtm_request { } } - _rtm_transform($connection_manager.send_request(communication_v1::Body::$request_body_name($comm_name $comm_initializer))) + _rtm_transform( + $connection_manager.send_request( + communication_v1::Body::$request_body_name($comm_name $comm_initializer) + ) + ) } - } + }; } #[derive(Serialize, Deserialize)] @@ -144,17 +151,12 @@ impl RtmClient { let cloned_presence_store = client.presence_store.clone(); tokio::spawn(async move { - loop { - match receiver_tx.recv().await { - Some(body) => { - if let Err(err) = - RtmClient::process_update(body, cloned_presence_store.clone()).await - { - error!("Failed to process update: {}", err); - } - } - None => break, - }; + while let Some(body) = receiver_tx.recv().await { + if let Err(err) = + RtmClient::process_update(body, cloned_presence_store.clone()).await + { + error!("Failed to process update: {}", err); + } } }); @@ -167,16 +169,11 @@ impl RtmClient { ) -> Result<(), RtmError> { match body { communication_v1::Body::Presence(presence) => { - if presence.client_version.is_none() { + if presence.client_version.is_empty() { return Ok(()); } - let res: ClientVersion = serde_json::from_str( - presence - .client_version - .as_ref() - .ok_or(RtmError::InvalidClientVersion)?, - )?; + let res: ClientVersion = serde_json::from_str(&presence.client_version)?; if res.client_type != "Client" && res.client_type != "LegacyClient" { return Ok(()); @@ -230,12 +227,13 @@ impl RtmClient { product_id: "origin".to_owned(), platform: PlatformV1::Pc as i32, client_version: serde_json::to_string(&client_version)?, - session_key: None, - force_disconnect_session_key: None, + session_key: String::new(), + force_disconnect_session_key: String::new(), }).await?; for ele in res.connected_sessions { - let platform = PlatformV1::try_from(ele.platform)?; + let platform = + PlatformV1::try_from(ele.platform).unwrap_or(PlatformV1::UnknownPlatform); if platform != PlatformV1::Pc { continue; } @@ -250,11 +248,9 @@ impl RtmClient { pub async fn set_presence( &mut self, basic_presence: BasicPresence, - status: &str, - offer_id: &str, + game_name: &str, // e.g. "Apex Legends" + offer_id: &str, // e.g. "Origin.OFR.50.0002148" ) -> Result<(), RtmError> { - info!("Updating RTM presence to '{}'", status); - let rpc_data = CustomRichPresenceData { game_product_id: offer_id.to_owned(), version: 1, @@ -268,12 +264,17 @@ impl RtmClient { BasicPresence::Online => BasicPresenceType::Online, }; + // status is the JSON availability string, not a label + let status_json = serde_json::to_string(&serde_json::json!({ + "presenceavailability": basic_presence_type.as_str_name().to_lowercase() + }))?; + send_and_forget_rtm_request!(self.conn_man, PresenceUpdate, PresenceUpdateV1, { - status: "".to_owned(), + status: status_json, basic_presence_type: basic_presence_type as i32, user_defined_presence: "".to_owned(), rich_presence: Some(RichPresenceV1 { - game: status.to_owned(), + game: game_name.to_owned(), // game name, not status text platform: PlatformV1::Pc as i32, game_mode_type: "".to_owned(), game_mode: "".to_owned(), @@ -288,13 +289,28 @@ impl RtmClient { } /// Subscribe to a list of user IDs' presences - pub async fn subscribe(&mut self, players: &Vec) -> Result<(), RtmError> { + pub async fn subscribe( + &mut self, + _persona: Vec, + players: &[String], + ) -> Result<(), RtmError> { send_and_forget_rtm_request!(self.conn_man, PresenceSubscribe, PresenceSubscribeV1, { + persona_id: vec![], players: players.iter().map(|id| Player{ player_id: id.to_owned(), product_id: String::from("origin"), }).collect() }) .await } + pub async fn subscribe_all(&mut self) -> Result<(), RtmError> { + send_and_forget_rtm_request!( + self.conn_man, + PresenceSubscribeAllFriendsV1, + PresenceSubscribeAllFriendsV1, + {} + ) + .await + } + pub async fn session_cleanup(&mut self, session_key: &str) -> Result<(), RtmError> { send_and_forget_rtm_request!(self.conn_man, SessionCleanupV1, SessionCleanupV1, { session_key: session_key.to_owned() diff --git a/maxima-lib/src/rtm/connection.rs b/maxima-lib/src/rtm/connection.rs index 9da67017..f4ec9c1e 100644 --- a/maxima-lib/src/rtm/connection.rs +++ b/maxima-lib/src/rtm/connection.rs @@ -1,19 +1,20 @@ use std::{ collections::HashMap, error::Error, - io::{self, ErrorKind}, + io::{self}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use super::proto::{communication_v1, Communication, CommunicationV1}; use super::RtmError; +use super::proto::{Communication, CommunicationV1, communication_v1}; use log::{error, warn}; use prost::{ - bytes::{Buf, BufMut, BytesMut}, Message, + bytes::{Buf, BufMut, BytesMut}, }; -use rustls::{ClientConfig, OwnedTrustAnchor}; +use rustls::{ClientConfig, RootCertStore}; +use rustls_pki_types::ServerName; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream, @@ -92,25 +93,16 @@ impl RtmConnectionManager { request_rx: &mut mpsc::Receiver, update_presence_tx: &mut mpsc::Sender, ) -> Result<(), Box> { - let anchors = TLS_SERVER_ROOTS.0.iter().map(|ta| { - OwnedTrustAnchor::from_subject_spki_name_constraints( - ta.subject, - ta.spki, - ta.name_constraints, - ) - }); - - let mut store = rustls::RootCertStore::empty(); - store.add_server_trust_anchors(anchors); + let mut store = RootCertStore::empty(); + store.extend(TLS_SERVER_ROOTS.iter().cloned()); let config = ClientConfig::builder() - .with_safe_defaults() .with_root_certificates(store) .with_no_client_auth(); let connector = TlsConnector::from(Arc::new(config)); - let domain = rustls::ServerName::try_from(RTM_DOMAIN)?; + let domain = ServerName::try_from(RTM_DOMAIN)?; let mut tls_stream = connector.connect(domain, stream).await?; let mut pending_responses: HashMap> = HashMap::new(); @@ -136,25 +128,32 @@ impl RtmConnectionManager { expected_size = bytes.get_i32(); } + if expected_size <= 0 || expected_size as usize > 4 * 1024 * 1024 { + warn!("Invalid RTM frame size: {}", expected_size); + break; + } + if bytes.len() < expected_size as usize { break; } - let buf = bytes.clone().freeze().slice(0..expected_size as usize); + let buf = bytes.split_to(expected_size as usize).freeze(); let msg = Communication::decode(buf)?; - let id = &msg.v1.as_ref().ok_or(RtmError::NoBody)?.request_id; + let id = match msg.body.as_ref() { + Some(crate::rtm::proto::communication::Body::V1(v1)) => &v1.request_id, + _ => return Err(Box::new(RtmError::NoBody)), + }; if let Some(tx) = pending_responses.remove(id) { - tx.send(msg).unwrap(); - } else if id.is_empty() { - if let Some(body) = &msg.v1.as_ref().ok_or(RtmError::NoBody)?.body { - update_presence_tx.send(body.clone()).await?; - } + let _ = tx.send(msg); + } else if id.is_empty() + && let Some(crate::rtm::proto::communication::Body::V1(v1)) = &msg.body + && let Some(body) = &v1.body { + update_presence_tx.send(body.clone()).await?; } - bytes.advance(expected_size as usize); expected_size = -1; } }, @@ -167,10 +166,12 @@ impl RtmConnectionManager { request = request_rx.recv(), if expected_size == -1 => { if let Some(request) = request { let communication = Communication { - v1: Some(CommunicationV1 { - request_id: request.id.to_owned(), - body: Some(request.payload), - }), + body: Some(crate::rtm::proto::communication::Body::V1( + CommunicationV1 { + request_id: request.id.to_owned(), + body: Some(request.payload), + } + )), }; let mut buf = BytesMut::new(); @@ -207,15 +208,13 @@ impl RtmConnectionManager { .await?; match response_rx.await { - Ok(response) => Ok(response - .v1 - .ok_or(RtmError::NoBody)? - .body - .ok_or(RtmError::NoBody)?), - Err(_) => Err(RtmError::Io(io::Error::new( - ErrorKind::Other, - "Failed to receive response", - ))), + Ok(response) => match response.body { + Some(crate::rtm::proto::communication::Body::V1(v1)) => { + v1.body.ok_or(RtmError::NoBody) + } + _ => Err(RtmError::NoBody), + }, + Err(_) => Err(RtmError::Io(io::Error::other("Failed to receive response"))), } } diff --git a/maxima-lib/src/rtm/mod.rs b/maxima-lib/src/rtm/mod.rs index 42bb13bf..6d1dd393 100644 --- a/maxima-lib/src/rtm/mod.rs +++ b/maxima-lib/src/rtm/mod.rs @@ -1,5 +1,5 @@ pub mod proto { - include!(concat!(env!("OUT_DIR"), "/eadp.rtm.rs")); + include!(concat!(env!("OUT_DIR"), "/antelope.rtm.rs")); } pub mod client; diff --git a/maxima-lib/src/rtm/proto/README.md b/maxima-lib/src/rtm/proto/README.md index db23b0cc..db4f6be2 100644 --- a/maxima-lib/src/rtm/proto/README.md +++ b/maxima-lib/src/rtm/proto/README.md @@ -1,6 +1,6 @@ # RTM Protocol -These are scraped from the `igoMain-settings-social` webpack chunk of EA Desktop's frontend using Qt's remote debugger. +These are scraped from the webpack chunks of EA Desktop's frontend using Qt's remote debugger. A useful way of testing these protos and figuring out new ones is passing a captured message into protoc's `--decode_raw` mode. -EADP stands for EA Digital Platform. +EADP stands for EA Digital Platform. Antelope is the name of the RTM protocol. diff --git a/maxima-lib/src/rtm/proto/antelope_common.proto b/maxima-lib/src/rtm/proto/antelope_common.proto new file mode 100644 index 00000000..5700e79f --- /dev/null +++ b/maxima-lib/src/rtm/proto/antelope_common.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package antelope.rtm; + +message ProductId { + string id = 1; +} + +message PlayerNetworkId { + string id = 1; +} + +message DevicePlatformId { + string id = 1; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/chat.proto b/maxima-lib/src/rtm/proto/chat.proto new file mode 100644 index 00000000..c8579a2a --- /dev/null +++ b/maxima-lib/src/rtm/proto/chat.proto @@ -0,0 +1,250 @@ +syntax = "proto3"; + +import "player.proto"; +import "common.proto"; +import "enum.proto"; + +package antelope.rtm; + +enum TypingEventType { + NO_EVENT = 0; + COMPOSING = 1; + CUSTOM_EVENT = 2; +} + +enum InviteChannelScope { + NO_INVITE_SCOPE = 0; + IN_GAME = 1; + CROSS_GAME = 2; + ALL = 3; +} + +enum ChannelMembershipChangeType { + NOTHING = 0; + ADDED = 1; + REMOVED = 2; +} + +enum StickyMessageChangeType { + NO_STICKY = 0; + STICKY_MESSAGE_ADDED = 1; + STICKY_MESSAGE_REMOVED = 2; +} + +message ChatInitiateV1 { + repeated string personaIds = 1; + repeated Player players = 2; + bool crossGame = 3; +} + +message ChatLeaveV1 { + string channelId = 1; +} + +message ChatMembersRequestV1 { + repeated string channelId = 1; + ChannelType channelType = 2; + int32 fromIndex = 3; + int32 pageSize = 4; +} + +message ChatChannelsRequestV1 { + bool includeUnreadMessageCount = 1; + bool includeLastMessageInfo = 2; + InviteChannelScope inviteChannelScope = 3; + repeated ChannelType channelTypes = 4; +} + +message ChatChannelsRequestV2 { + bool includeLastMessageInfo = 1; + bool includeUnreadMessageInfo = 2; + repeated ChannelMessageType unreadMessageTypes = 3; + bool includeCustomProperties = 4; + InviteChannelScope inviteChannelScope = 5; + repeated ChannelType channelTypes = 6; +} + +message WorldChatConfigurationRequestV1 { +} + +message WorldChatChannelsRequestV1 { + string worldName = 1; + int32 fromIndex = 2; + int32 pageSize = 3; +} + +message WorldChatAssignV1 { + string worldName = 1; + int32 shardIndex = 2; +} + +message MuteUserV1 { + string channelId = 1; + string personaId = 2; + Player player = 3; +} + +message UnmuteUserV1 { + string channelId = 1; + string personaId = 2; + Player player = 3; +} + +message PromoteStickyMessageRequestV1 { + string channelId = 1; + string messageId = 2; + string timestamp = 3; + int32 index = 4; +} + +message RemoveStickyMessageRequestV1 { + string channelId = 1; + int32 index = 2; +} + +message FetchStickyMessagesRequestV1 { + string channelId = 1; +} + +message ChatTypingEventRequestV1 { + string channelId = 1; + TypingEventType event = 2; + string customTypingEvent = 3; +} + +message ChatMembersV1 { + string channelId = 1; + repeated PersonaV1 users = 2; + int32 totalMemberCount = 3; + repeated PlayerInfo players = 4; +} + +message ChatChannelsV1 { + repeated ChannelV1 channels = 1; + repeated ChannelMuteListV1 muteList = 2; + string lastOnlineTimestamp = 3; +} + +message WorldChatResponseV1 { + string channelId = 1; + int32 remainingDailyShardSwitchCount = 2; + int32 shardIndex = 3; + string worldName = 4; + string previousChannelId = 5; + int32 previousShardIndex = 6; + repeated ChannelMuteListV1 muteList = 7; + string channelName = 8; +} + +message WorldChatConfigurationResponseV1 { + int32 remainingDailyShardSwitchCount = 1; + int32 maxShards = 2; + int32 maxShardSize = 3; + int32 maxWorldNamesPerGame = 4; + int32 maxDailyShardSwitchCount = 5; +} + +message WorldChatChannelsResponseV1 { + string worldName = 1; + repeated WorldChatShard shard = 2; + int32 totalShardCount = 3; +} + +message WorldChatShard { + int32 index = 1; + int32 size = 2; +} + +message StickyMessageResponseV1 { + string channelId = 1; + repeated StickyMessageV1 message = 2; +} + +message ChatConnectedV1 { + PersonaV1 user = 1; + PlayerInfo players = 2; +} + +message ChatDisconnectedV1 { + PersonaV1 user = 1; + PlayerInfo players = 2; +} + +message ChatLeftV1 { + PersonaV1 user = 1; + PlayerInfo players = 2; +} + +message ChatUserMutedV1 { + PersonaV1 mutedUser = 1; + PersonaV1 mutedByUser = 2; + PlayerInfo mutedPlayer = 3; + PlayerInfo mutedByPlayer = 4; +} + +message ChatUserUnmutedV1 { + PersonaV1 unmutedUser = 1; + PersonaV1 unmutedByUser = 2; + PlayerInfo unmutedPlayer = 3; + PlayerInfo unmutedByPlayer = 4; +} + +message ChannelMembershipChangeV1 { + ChannelMembershipChangeType type = 1; + repeated PersonaV1 users = 2; + repeated PlayerInfo players = 3; +} + +message StickyMessageChangedV1 { + StickyMessageChangeType stickyMessageChangeType = 1; + StickyMessageV1 stickyMessage = 2; + int32 index = 3; +} + +message ChatTypingEventV1 { + TypingEventType event = 1; + string customTypingEvent = 2; + Player fromPlayer = 3; + string fromDisplayName = 4; + string fromNickName = 5; + string fromPlatformDisplayName = 6; + string fromPlatformNickName = 7; +} + +message WorldChatMembershipChangeV1 { + ChannelMembershipChangeType type = 1; + PlayerInfo playerInfo = 2; +} + +message GroupMemberAttributeChangeV1 { + Player targetPlayer = 1; + string targetDisplayName = 2; + string targetNickName = 3; + string updatedTargetAttributes = 4; + Player initiatorPlayer = 5; + string initiatorDisplayName = 6; + string initiatorNickName = 7; + string initiatorAttributes = 8; +} + +message GroupAttributeChangeV1 { + PlayerInfo initiatorPlayerInfo = 1; + string attributeName = 2; + string attributeDetail = 3; + string oldValue = 4; + string newValue = 5; + string timestamp = 6; +} + +message GroupMemberRoleChangeV1 { + PlayerInfo initiatorPlayerInfo = 1; + PlayerInfo targetPlayerInfo = 2; + string newRole = 3; + string timestamp = 4; +} + +message ChatChannelUpdateV1 { + string channelId = 1; + string lastReadTimestamp = 2; + string customProperties = 3; +} diff --git a/maxima-lib/src/rtm/proto/common.proto b/maxima-lib/src/rtm/proto/common.proto index d49cc049..a854cab8 100644 --- a/maxima-lib/src/rtm/proto/common.proto +++ b/maxima-lib/src/rtm/proto/common.proto @@ -1,57 +1,185 @@ syntax = "proto3"; -package eadp.rtm; - -enum PlatformV1 { - NONE_PLATFORM = 0; - UNKNOWN_PLATFORM = 1; - MOBILE = 2; - PC = 3; - WEB = 4; - XBOXLIVE = 5; - PSN = 6; - SWITCH = 7; - STEAM = 8; - XBSK = 9; - PS5 = 10; + +import "config.proto"; +import "player.proto"; +import "presence.proto"; +import "antelope_common.proto"; + +package antelope.rtm; + +enum ChannelType { + NO_CHANNEL_TYPE = 0; + EADP_GROUP_SERVICE = 1; + INVITE_CHANNEL = 2; + WORLD_CHAT = 3; + GAME = 4; +} + +enum MutedByV1 { + NO_MUTE = 0; + ADMIN_MUTED = 1; + USER_MUTED = 2; +} + +enum ContentTypeValue { + NO_CONTENT = 0; + GIFT = 1; + GAME_INVITE = 2; + OTHER = 3; +} + +enum MessageType { + NO_MESSAGE = 0; + REGULAR_MESSAGE = 1; + STICKY_MESSAGE = 2; + QUICK_MESSAGE = 3; } enum UserType { - NONE_USER_TYPE = 0; - PERSONA = 1; - NUCLEUS = 2; + NONE_USERTYPE = 0; + PERSONA = 1; + NUCLEUS = 2; +} + +message PersonaV1 { + string personaId = 1; + string displayName = 2; + string nickName = 3; +} + +message SuccessV1 { + oneof body { + LoginV2Success loginV2Success = 1; + ChatInitiateSuccessV1 chatInitiateSuccess = 2; + LoginV3Response loginV3Response = 3; + } +} + +message LoginV2Success { + string sessionKey = 1; +} + +message ChatInitiateSuccessV1 { + ChannelV1 channel = 1; +} + +message MutedSetV1 { + repeated MutedByV1 mutedBy = 1; +} + +message ChannelV1 { + string channelId = 1; + string channelName = 2; + ChannelType type = 3; + int32 unreadMessageCount = 4; + TextMessageV1 lastMessageInfo = 5; + string lastReadTimestamp = 6; + string customProperties = 7; + bool crossGame = 8; +} + +message ChannelMuteListV1 { + string channelId = 1; + repeated PersonaV1 mutedUsers = 2; + repeated MutedSetV1 mutedBy = 3; + repeated Player mutedPlayers = 4; +} + +message HeartbeatV1 { +} + +message StickyMessageV1 { + string stickyAuthorPersonaId = 1; + string stickyAuthorDisplayName = 2; + string timestamp = 3; + TextMessageV1 textMessage = 4; + string messageId = 5; + string stickyAuthorNickName = 6; + string stickyAuthorPlatformDisplayName = 7; + string stickyAuthorPlatformNickName = 8; +} + +message TextMessageV1 { + string fromPersonaId = 1; + string fromDisplayName = 2; + string timestamp = 3; + string text = 4; + string originalText = 5; + Player player = 6; + string fromNickName = 7; + string fromPlatformDisplayName = 8; + string fromPlatformNickName = 9; + string metadata = 10; + MessageType messageType = 11; + StringId textMessageId = 12; + MessageContext messageContext = 13; + repeated ChatPropertyBagEntry chatPropertyBag = 14; +} + +message ChatPropertyBagEntry { + string key = 1; + ChatPropertyValue value = 2; +} + +message ChatPropertyValue { + oneof value { + string stringValue = 1; + int64 integerValue = 2; + double floatValue = 3; + ChatPropertyTimestamp chatPropertyTimestampValue = 4; + ChatPropertyDuration chatPropertyDurationValue = 5; + } } -enum BasicPresenceType { - NONE_PRESENCE = 0; - UNKNOWN_PRESENCE = 1; - OFFLINE = 2; - ONLINE = 3; - DND = 4; - AWAY = 5; - INVISIBLE = 6; - CUSTOM = 7; +message ChatPropertyTimestamp { + int64 seconds = 1; + int32 nanos = 2; } -enum RichPresenceType { - NONE_RICH_PRESENCE = 0; - UNKNOWN_RICH_PRESENCE = 1; - LFG = 2; - BROADCASTING = 3; +message ChatPropertyDuration { + int64 seconds = 1; + int32 nanos = 2; } -message RichPresenceV1 { - string game = 1; - PlatformV1 platform = 2; - string gameModeType = 3; - string gameMode = 4; - string gameSessionData = 5; - RichPresenceType richPresenceType = 6; - string startTimestamp = 7; - string endTimestamp = 8; - string customRichPresenceData = 9; +message MessageContext { + .antelope.rtm.PlayerNetworkId senderPlayerNetworkId = 1; + .antelope.rtm.DevicePlatformId senderDevicePlatformId = 2; + .antelope.rtm.ProductId senderProductId = 3; + .antelope.rtm.PlayerNetworkId receiverPlayerNetworkId = 4; + .antelope.rtm.DevicePlatformId receiverDevicePlatformId = 5; + ContentType contentType = 6; } -message Player { - string playerId = 1; - string productId = 2; +message ContentType { + oneof body { + ContentTypeValue type = 1; + string customContentType = 2; + } } + +message LoginV3Response { + string sessionKey = 1; + repeated SessionV1 connectedSessions = 2; +} + +message SessionV1 { + string sessionKey = 1; + PlatformV1 platform = 2; + string clientVersion = 3; + string timestamp = 4; +} + +message SessionRequestV1 { +} + +message SessionResponseV1 { + repeated SessionV1 connectedSessions = 1; +} + +message SessionCleanupV1 { + string sessionKey = 1; +} + +message StringId { + string id = 1; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/config.proto b/maxima-lib/src/rtm/proto/config.proto new file mode 100644 index 00000000..edd87431 --- /dev/null +++ b/maxima-lib/src/rtm/proto/config.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package antelope.rtm; + +message ServerConfigRequestV1 { +} + +message ServerConfigV1 { + RateLimitConfigV1 rateLimitConfig = 1; +} + +message RateLimitConfigV1 { + double channelUpdateLimit = 1; + double typingEventLimit = 2; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/enum.proto b/maxima-lib/src/rtm/proto/enum.proto new file mode 100644 index 00000000..2a07e4d2 --- /dev/null +++ b/maxima-lib/src/rtm/proto/enum.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package antelope.rtm; + +enum ChannelMessageType { + NO_CHANNEL_MESSAGE_TYPE = 0; + TEXT_MESSAGE = 1; + BINARY_MESSAGE = 2; + GROUP_MEMBERSHIP_CHANGE = 3; + CHAT_CONNECTED = 4; + CHAT_DISCONNECTED = 5; + CHAT_LEFT = 6; + CUSTOM_MESSAGE = 7; + CHAT_USER_MUTED = 8; + CHAT_USER_UNMUTED = 9; + CHANNEL_MEMBERSHIP_CHANGE = 10; + STICKY_MESSAGE_CHANGED = 11; + CHAT_TYPING_EVENT = 12; + WORLD_CHAT_MEMBERSHIP_CHANGE = 13; + GROUP_MEMBER_ATTRIBUTE_CHANGE = 14; + GROUP_ATTRIBUTE_CHANGE = 15; + GROUP_MEMBER_ROLE_CHANGE = 16; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/error.proto b/maxima-lib/src/rtm/proto/error.proto new file mode 100644 index 00000000..865458b4 --- /dev/null +++ b/maxima-lib/src/rtm/proto/error.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +import "common.proto"; + +package antelope.rtm; + +message ErrorV1 { + oneof body { + ChatMembersRequestErrorV1 chatMembersRequestError = 3; + ValidationErrorV1 validationError = 4; + AssignErrorV1 assignError = 5; + ChatTypingEventRequestErrorV1 chatTypingEventRequestError = 6; + LoginErrorV1 loginError = 7; + } + string errorCode = 1; + string errorMessage = 2; +} + +message ChatMembersRequestErrorV1 { + string channelId = 1; +} + +message ValidationErrorV1 { + repeated FieldViolationV1 fieldViolation = 1; +} + +message FieldViolationV1 { + string fieldName = 1; + string violationMessage = 2; +} + +message AssignErrorV1 { + string worldName = 1; + int32 shardIndex = 2; +} + +message ChatTypingEventRequestErrorV1 { + string channelId = 1; +} + +message LoginErrorV1 { + repeated SessionV1 connectedSessions = 1; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/moderation.proto b/maxima-lib/src/rtm/proto/moderation.proto new file mode 100644 index 00000000..c5ecdb9a --- /dev/null +++ b/maxima-lib/src/rtm/proto/moderation.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package antelope.rtm; + +enum ModerationRoleType { + NO_MODERATION_ROLE = 0; + MODERATION_ROLE_VOLUNTEER = 1; + MODERATION_ROLE_EMPLOYEE = 2; +} + +message GetRolesRequest { +} + +message GetRolesResponse { + repeated ModerationRoleType roles = 1; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/notification.proto b/maxima-lib/src/rtm/proto/notification.proto new file mode 100644 index 00000000..c613f841 --- /dev/null +++ b/maxima-lib/src/rtm/proto/notification.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +import "chat.proto"; +import "common.proto"; +import "presence.proto"; + +package antelope.rtm; + +enum SessionNotificationTypeV1 { + NO_SESSION_NOTIFICATION = 0; + DEFAULT = 1; + FORCE_DISCONNECT = 2; + USER_LOGGED_IN = 3; + USER_LOGGED_OUT = 4; + CHAT_CHANNEL_UPDATE = 5; +} + +message NotificationV1 { + string type = 1; + string payload = 2; +} + +message UserMembershipChangeV1 { + ChannelMembershipChangeType type = 1; + ChannelV1 channel = 2; +} + +message SessionNotificationV1 { + SessionNotificationTypeV1 type = 1; + string message = 2; + PlatformV1 platform = 3; + int32 sessionLimitRemaining = 4; + SessionV1 connectedSessions = 5; + ChatChannelUpdateV1 chatChannelUpdate = 6; +} + +message WorldChatNotificationV1 { + string sessionKey = 1; + string worldName = 2; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/player.proto b/maxima-lib/src/rtm/proto/player.proto new file mode 100644 index 00000000..dd5a3e95 --- /dev/null +++ b/maxima-lib/src/rtm/proto/player.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package antelope.rtm; + +message Player { + string playerId = 1; + string productId = 2; +} + +message PlayerInfo { + Player player = 1; + string displayName = 2; + string nickName = 3; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/point_to_point.proto b/maxima-lib/src/rtm/proto/point_to_point.proto new file mode 100644 index 00000000..cccba60e --- /dev/null +++ b/maxima-lib/src/rtm/proto/point_to_point.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +import "player.proto"; + +package antelope.rtm; + +enum Domain { + NO_DOMAIN = 0; + USER = 1; +} + +message AddressV1 { + Domain domain = 1; + string id = 2; + Player player = 3; +} + +message CustomMessage { + string type = 1; + bytes payload = 2; + string payloadType = 3; +} + +message PointToPointMessageV1 { + oneof messageContent { + CustomMessage customMessage = 3; + } + AddressV1 from = 1; + AddressV1 to = 2; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/preference.proto b/maxima-lib/src/rtm/proto/preference.proto new file mode 100644 index 00000000..1ed36ae5 --- /dev/null +++ b/maxima-lib/src/rtm/proto/preference.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package antelope.rtm; + +message UpdatePreferenceRequestV1 { + oneof body { + TranslationPreferenceV1 translation = 1; + } +} + +message GetPreferenceRequestV1 { +} + +message GetPreferenceResponseV1 { + TranslationPreferenceV1 translationPreference = 1; +} + +message TranslationPreferenceV1 { + bool autoTranslate = 1; + string language = 2; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/presence.proto b/maxima-lib/src/rtm/proto/presence.proto new file mode 100644 index 00000000..bf9c9ffa --- /dev/null +++ b/maxima-lib/src/rtm/proto/presence.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; + +import "player.proto"; + +package antelope.rtm; + +enum BasicPresenceType { + NONE_PRESENCE = 0; + UNKNOWN_PRESENCE = 1; + OFFLINE = 2; + ONLINE = 3; + DND = 4; + AWAY = 5; + INVISIBLE = 6; + CUSTOM = 7; +} + +enum RichPresenceType { + NONE_RICH_PRESENCE = 0; + UNKNOWN_RICH_PRESENCE = 1; + LFG = 2; + BROADCASTING = 3; +} + +enum PlatformV1 { + NONE_PLATFORM = 0; + UNKNOWN_PLATFORM = 1; + MOBILE = 2; + PC = 3; + WEB = 4; + XBOXLIVE = 5; + PSN = 6; + SWITCH = 7; + STEAM = 8; + XBSK = 9; + PS5 = 10; +} + +message PresenceSubscribeV1 { + repeated string personaId = 1; + repeated Player players = 2; +} + +message PresenceSubscribeAllFriendsV1 { +} + +message PresenceUnsubscribeV1 { + repeated string personaId = 1; + repeated Player players = 2; +} + +message PresenceUpdateV1 { + string status = 1; + BasicPresenceType basicPresenceType = 2; + string userDefinedPresence = 3; + RichPresenceV1 richPresence = 4; +} + +message PresenceV1 { + string personaId = 1; + string status = 2; + string timestamp = 3; + Player player = 4; + BasicPresenceType basicPresenceType = 5; + string userDefinedPresence = 6; + RichPresenceV1 richPresence = 7; + string sessionKey = 8; + string clientVersion = 9; + PlatformV1 platform = 10; +} + +message PresenceUpdateErrorV1 { + string errorCode = 1; + string errorMessage = 2; +} + +message PresenceSubscriptionErrorV1 { + string personaId = 1; + string errorCode = 2; + string errorMessage = 3; + Player player = 4; +} + +message PresenceSubscriptionAllFriendsErrorV1 { + string errorCode = 1; + string errorMessage = 2; +} + +message RichPresenceV1 { + string game = 1; + PlatformV1 platform = 2; + string gameModeType = 3; + string gameMode = 4; + string gameSessionData = 5; + RichPresenceType richPresenceType = 6; + string startTimestamp = 7; + string endTimestamp = 8; + string customRichPresenceData = 9; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/requests.proto b/maxima-lib/src/rtm/proto/requests.proto deleted file mode 100644 index 77741052..00000000 --- a/maxima-lib/src/rtm/proto/requests.proto +++ /dev/null @@ -1,40 +0,0 @@ -syntax = "proto3"; -package eadp.rtm; - -import "common.proto"; - -message PresenceSubscribeV1 { - repeated Player players = 2; -} - -message LoginRequestV3 { - string token = 1; - - bool reconnect = 2; - bool heartbeat = 3; - - UserType userType = 4; - string productId = 5; - PlatformV1 platform = 6; - string clientVersion = 7; - - optional string sessionKey = 8; - optional string forceDisconnectSessionKey = 9; -} - -message ReconnectRequestV1 { - string sessionKey = 1; -} - -message PresenceUpdateV1 { - string status = 1; - BasicPresenceType basicPresenceType = 2; - string userDefinedPresence = 3; - RichPresenceV1 richPresence = 4; -} - -message HeartbeatV1 {} - -message SessionCleanupV1 { - string sessionKey = 1; -} diff --git a/maxima-lib/src/rtm/proto/responses.proto b/maxima-lib/src/rtm/proto/responses.proto deleted file mode 100644 index f1e24c90..00000000 --- a/maxima-lib/src/rtm/proto/responses.proto +++ /dev/null @@ -1,52 +0,0 @@ -syntax = "proto3"; -package eadp.rtm; - -import "common.proto"; - -message SessionV1 { - string sessionKey = 1; - PlatformV1 platform = 2; - string clientVersion = 3; - string timestamp = 4; -} - -message LoginV3Response { - string sessionKey = 1; - repeated SessionV1 connectedSessions = 2; -} - -message SuccessV1 { - oneof body { - LoginV3Response loginV3Response = 3; - } -} - -message ErrorV1 { - string errorCode = 1; - string errorMessage = 2; - - oneof body { - LoginErrorV1 loginError = 7; - } -} - -message LoginErrorV1 { - repeated SessionV1 connectedSessions = 1; -} - -message PresenceV1 { - string personaId = 1; - - optional string status = 2; - optional string timestamp = 3; - - Player player = 4; - BasicPresenceType basicPresenceType = 5; - - optional string userDefinedPresence = 6; - optional RichPresenceV1 richPresence = 7; - - optional string sessionKey = 8; - optional string clientVersion = 9; - optional PlatformV1 platform = 10; -} diff --git a/maxima-lib/src/rtm/proto/rtm.proto b/maxima-lib/src/rtm/proto/rtm.proto index 692b9e4e..af748fb8 100644 --- a/maxima-lib/src/rtm/proto/rtm.proto +++ b/maxima-lib/src/rtm/proto/rtm.proto @@ -1,26 +1,107 @@ syntax = "proto3"; -package eadp.rtm; +import "presence.proto"; import "common.proto"; -import "requests.proto"; -import "responses.proto"; +import "chat.proto"; +import "notification.proto"; +import "error.proto"; +import "point_to_point.proto"; +import "moderation.proto"; +import "preference.proto"; +import "config.proto"; + +package antelope.rtm; message Communication { + oneof body { CommunicationV1 v1 = 1; + } } message CommunicationV1 { - string requestId = 1; - - oneof body { - PresenceSubscribeV1 presenceSubscribe = 2; - PresenceUpdateV1 presenceUpdate = 5; - PresenceV1 presence = 7; - ErrorV1 error = 13; - ReconnectRequestV1 reconnectRequest = 14; - SuccessV1 success = 15; - HeartbeatV1 heartbeat = 20; - LoginRequestV3 loginRequestV3 = 46; - SessionCleanupV1 sessionCleanupV1 = 49; - } + oneof body { + PresenceSubscribeV1 presenceSubscribe = 2; + PresenceUnsubscribeV1 presenceUnsubscribe = 3; + PresenceSubscriptionErrorV1 presenceSubscriptionError = 4; + PresenceUpdateV1 presenceUpdate = 5; + PresenceUpdateErrorV1 presenceUpdateError = 6; + PresenceV1 presence = 7; + NotificationV1 notification = 8; + ChatInitiateV1 chatInitiate = 9; + ChatLeaveV1 chatLeave = 10; + ChatMembersRequestV1 chatMembersRequest = 11; + ChatMembersV1 chatMembers = 12; + ErrorV1 error = 13; + ReconnectRequestV1 reconnectRequest = 14; + SuccessV1 success = 15; + PointToPointMessageV1 pointToPointMessage = 16; + ChatChannelsRequestV1 chatChannelsRequest = 17; + ChatChannelsV1 chatChannels = 18; + LoginRequestV1 loginRequest = 19; + HeartbeatV1 heartbeat = 20; + WorldChatAssignV1 worldChatAssign = 21; + WorldChatResponseV1 worldChatResponse = 22; + MuteUserV1 muteUser = 23; + UnmuteUserV1 unmuteUser = 24; + UserMembershipChangeV1 userMembershipChange = 25; + WorldChatConfigurationRequestV1 worldChatConfigurationRequestV1 = 26; + WorldChatConfigurationResponseV1 worldChatConfigurationResponse = 27; + WorldChatChannelsRequestV1 worldChatChannelsRequestV1 = 28; + WorldChatChannelsResponseV1 worldChatChannelsResponse = 29; + PromoteStickyMessageRequestV1 promoteStickyMessageRequest = 30; + RemoveStickyMessageRequestV1 removeStickyMessageRequest = 31; + FetchStickyMessagesRequestV1 fetchStickyMessagesRequest = 32; + StickyMessageResponseV1 stickyMessageResponse = 33; + GetRolesRequest rolesRequest = 34; + GetRolesResponse rolesResponse = 35; + UpdatePreferenceRequestV1 preferenceRequest = 36; + GetPreferenceRequestV1 getPreferenceRequest = 37; + GetPreferenceResponseV1 getPreferenceResponse = 38; + LoginRequestV2 loginRequestV2 = 39; + SessionNotificationV1 sessionNotification = 40; + ChatTypingEventRequestV1 chatTypingEventRequest = 41; + ChatChannelsRequestV2 chatChannelsRequestV2 = 42; + ChatChannelUpdateV1 chatChannelUpdate = 43; + ServerConfigRequestV1 serverConfigRequest = 44; + ServerConfigV1 serverConfig = 45; + LoginRequestV3 loginRequestV3 = 46; + SessionRequestV1 sessionRequestV1 = 47; + SessionResponseV1 sessionResponseV1 = 48; + SessionCleanupV1 sessionCleanupV1 = 49; + WorldChatNotificationV1 worldChatNotificationV1 = 50; + PresenceSubscribeAllFriendsV1 presenceSubscribeAllFriendsV1 = 51; + PresenceSubscriptionAllFriendsErrorV1 presenceSubscriptionAllFriendsErrorV1 = 52; + } + string requestId = 1; +} + +message ReconnectRequestV1 { + string sessionKey = 1; +} + +message LoginRequestV1 { + string token = 1; + bool reconnect = 2; + bool heartbeat = 3; +} + +message LoginRequestV2 { + string token = 1; + bool reconnect = 2; + bool heartbeat = 3; + UserType userType = 4; + string productId = 5; + bool singleSessionForceLogout = 6; } + +message LoginRequestV3 { + string token = 1; + bool reconnect = 2; + bool heartbeat = 3; + UserType userType = 4; + string productId = 5; + PlatformV1 platform = 6; + string clientVersion = 7; + string sessionKey = 8; + string forceDisconnectSessionKey = 9; +} \ No newline at end of file diff --git a/maxima-lib/src/rtm/proto/social.proto b/maxima-lib/src/rtm/proto/social.proto new file mode 100644 index 00000000..af631794 --- /dev/null +++ b/maxima-lib/src/rtm/proto/social.proto @@ -0,0 +1,254 @@ +syntax = "proto3"; + +import "common.proto"; + +package antelope.rtm; + +enum CommunicationType { + NONE_COMMUNICATION_TYPE = 0; + LOGIN_REQUEST = 1; + LOGIN_RESPONSE = 2; + PUBLISH_TEXT_REQUEST = 3; + PUBLISH_BINARY_REQUEST = 4; + PUBLISH_RESPONSE = 5; + CHANNEL_MESSAGE = 6; + SUBSCRIBE_REQUEST = 7; + SUBSCRIBE_RESPONSE = 8; + UNSUBSCRIBE_REQUEST = 9; + UNSUBSCRIBE_RESPONSE = 10; + HISTORY_REQUEST = 11; + HISTORY_RESPONSE = 12; + LOGOUT_REQUEST = 13; +} + +enum ChannelMessageType { + NONE_TEXT_MESSAGE = 0; + TEXT_MESSAGE = 1; + BINARY_MESSAGE = 2; + GROUP_MEMBERSHIP_CHANGE = 3; + CHAT_CONNECTED = 4; + CHAT_DISCONNECTED = 5; + CHAT_LEFT = 6; + CUSTOM_MESSAGE = 7; + CHAT_USER_MUTED = 8; + CHAT_USER_UNMUTED = 9; + CHANNEL_MEMBERSHIP_CHANGE = 10; + STICKY_MESSAGE_CHANGED = 11; + CHAT_TYPING_EVENT = 12; + WORLD_CHAT_MEMBERSHIP_CHANGE = 13; + GROUP_MEMBER_ATTRIBUTE_CHANGE = 14; + GROUP_ATTRIBUTE_CHANGE = 15; + GROUP_MEMBER_ROLE_CHANGE = 16; +} + +enum GroupMembershipChangeType { + NO_CHANGE = 0; + LEAVE = 1; + JOIN = 2; + KICKED = 3; +} + +enum MutedBy { + NO_MUTE = 0; + ADMIN = 1; + USER = 2; +} + +message Communication { + oneof body { + LoginRequest loginRequest = 2; + LoginResponse loginResponse = 3; + PublishTextRequest publishTextRequest = 4; + PublishBinaryRequest publishBinaryRequest = 5; + PublishResponse publishResponse = 6; + ChannelMessage channelMessage = 7; + SubscribeRequest subscribeRequest = 8; + SubscribeResponse subscribeResponse = 9; + UnsubscribeRequest unsubscribeRequest = 10; + UnsubscribeResponse unsubscribeResponse = 11; + HistoryRequest historyRequest = 12; + HistoryResponse historyResponse = 13; + LogoutRequest logoutRequest = 14; + } + Header header = 1; +} + +message Header { + CommunicationType type = 1; + string requestId = 2; +} + +message Player { + string playerId = 1; + string productId = 4; +} + +message Channel { + string channelId = 1; + string channelName = 2; + antelope.rtm.ChannelType type = 3; + bool crossGame = 4; +} + +message ChannelMessage { + oneof content { + TextMessage textMessage = 5; + BinaryMessage binaryMessage = 6; + GroupMembershipChange membershipChange = 7; + antelope.rtm.ChatConnectedV1 chatConnected = 8; + antelope.rtm.ChatDisconnectedV1 chatDisconnected = 9; + antelope.rtm.ChatLeftV1 chatLeft = 10; + antelope.rtm.CustomMessage customMessage = 11; + antelope.rtm.ChatUserMutedV1 chatUserMuted = 12; + antelope.rtm.ChatUserUnmutedV1 chatUserUnmuted = 13; + antelope.rtm.ChannelMembershipChangeV1 channelMembershipChange = 14; + antelope.rtm.StickyMessageChangedV1 stickyMessageChanged = 15; + antelope.rtm.ChatTypingEventV1 chatTypingEvent = 16; + antelope.rtm.WorldChatMembershipChangeV1 worldChatMembershipChange = 17; + antelope.rtm.GroupMemberAttributeChangeV1 groupMemberAttributeChange = 18; + antelope.rtm.GroupAttributeChangeV1 groupAttributeChange = 19; + antelope.rtm.GroupMemberRoleChangeV1 groupMemberRoleChange = 20; + } + string messageId = 1; + string channelId = 2; + string timestamp = 3; + ChannelMessageType type = 4; +} + +message TextMessage { + string fromPersonaId = 1; + string fromDisplayName = 2; + string text = 3; + string originalText = 4; + Player fromPlayer = 5; + string fromNickName = 6; + string fromPlatformDisplayName = 7; + string fromPlatformNickName = 8; + string metadata = 9; + antelope.rtm.MessageType messageType = 10; + antelope.rtm.StringId textMessageId = 11; + antelope.rtm.MessageContext messageContext = 12; + repeated antelope.rtm.ChatPropertyBagEntry chatPropertyBag = 13; +} + +message BinaryMessage { + string fromPersonaId = 1; + string fromDisplayName = 2; + bytes blob = 3; + Player fromPlayer = 4; + string fromNickName = 5; + string fromPlatformDisplayName = 6; + string fromPlatformNickName = 7; + string metadata = 8; +} + +message GroupMembershipChange { + string targetPersonaId = 1; + string targetDisplayName = 2; + string initiatorPersonaId = 3; + string initiatorDisplayName = 4; + GroupMembershipChangeType type = 5; + Player targetPlayer = 6; + Player initiatingPLayer = 7; + string targetNickName = 8; + string initiatorNickName = 9; + string targetAttributes = 10; + string initiatorAttributes = 11; +} + +message MutedUser { + string personaId = 1; + repeated MutedBy mutedBy = 2; +} + +message ChannelMuteList { + string channelId = 1; + repeated MutedUser mutedUsers = 2; +} + +message MutedUserV1 { + string personaId = 1; + repeated antelope.rtm.MutedByV1 mutedBy = 2; + Player player = 3; +} + +message LoginRequest { + string token = 1; + bool reconnect = 2; + bool heartbeat = 3; +} + +message LogoutRequest { +} + +message PublishTextRequest { + string channelId = 1; + string text = 2; + antelope.rtm.MessageType messageType = 3; + int32 index = 4; + string metadata = 5; + antelope.rtm.StringId textMessageId = 6; + antelope.rtm.MessageContext messageContext = 7; + repeated antelope.rtm.ChatPropertyBagEntry chatProperties = 8; +} + +message PublishBinaryRequest { + string channelId = 1; + bytes blob = 2; + string metadata = 3; +} + +message SubscribeRequest { + string channelId = 1; +} + +message UnsubscribeRequest { + string channelId = 1; +} + +message HistoryRequest { + string channelId = 1; + string timestamp = 2; + int32 recordCount = 3; + string primaryFilterType = 4; + string secondaryFilterType = 5; +} + +message LoginResponse { + bool success = 1; + repeated Channel channels = 2; + repeated ChannelMuteList muteList = 3; + string errorCode = 4; + string errorMessage = 5; +} + +message PublishResponse { + bool success = 1; + string errorCode = 2; + string errorMessage = 3; + string messageId = 4; + string timestamp = 5; +} + +message SubscribeResponse { + string channelId = 1; + bool success = 2; + string errorCode = 3; + string errorMessage = 4; + repeated MutedUserV1 mutedUsers = 5; +} + +message UnsubscribeResponse { + string channelId = 1; + bool success = 2; + string errorCode = 3; + string errorMessage = 4; +} + +message HistoryResponse { + string channelId = 1; + bool success = 2; + repeated ChannelMessage messages = 3; + string errorCode = 4; + string errorMessage = 5; +} \ No newline at end of file diff --git a/maxima-lib/src/unix/wine.rs b/maxima-lib/src/unix/wine.rs index 50c61a69..08f6e9e5 100644 --- a/maxima-lib/src/unix/wine.rs +++ b/maxima-lib/src/unix/wine.rs @@ -1,17 +1,18 @@ +use regex::Regex; +use std::ffi::OsString; +use std::fs::create_dir_all; +use std::path::PathBuf; +use std::sync::LazyLock; use std::{ collections::HashMap, env, ffi::OsStr, - fs::{create_dir_all, remove_dir_all, remove_file, File}, + fs::remove_dir_all, io::Read, - path::PathBuf, process::{ExitStatus, Stdio}, }; -use flate2::read::GzDecoder; -use lazy_static::lazy_static; use log::{info, warn}; -use regex::Regex; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use tar::Archive; @@ -22,15 +23,14 @@ 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::native::{DownloadError, NativeError, SafeParent, SafeStr, WineError, maxima_dir}, }; -lazy_static! { - static ref PROTON_PATTERN: Regex = Regex::new(r"GE-Proton\d+-\d+\.tar\.gz").unwrap(); -} +static PROTON_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"GE-Proton\d+-\d+\.tar\.gz").expect("proton pattern regex should be valid") +}); // A Proton verb to use pub enum CommandType { @@ -65,18 +65,39 @@ pub(crate) struct LutrisRuntime { #[derive(Serialize, Deserialize, Default)] #[serde(default)] struct Versions { - proton: String, eac_runtime: String, 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: &str) -> Result { + let game_install_info = load_game_info_from_json(slug).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 { - Ok(maxima_dir()?.join("wine/proton")) +pub fn proton_dir() -> Result { + if let Ok(path) = env::var("MAXIMA_PROTON_PATH") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path.into()); + } else { + warn!( + "MAXIMA_PROTON_PATH is set to {} but it doesn't exist", + path.display() + ); + } + } + Ok("GE-Proton".into()) } pub fn wine_dir() -> Result { @@ -87,8 +108,14 @@ pub fn eac_dir() -> Result { Ok(maxima_dir()?.join("wine/eac_runtime")) } -pub fn umu_bin() -> Result { - Ok(maxima_dir()?.join("wine/umu/umu-run")) +pub fn umu_script_path() -> Result { + if let Ok(path) = env::var("MAXIMA_UMU_LOCATION") { + Ok(PathBuf::from(path)) + } else if let Ok(path) = which::which("umu-run") { + Ok(path) + } else { + Ok(maxima_dir()?.join("wine/umu/umu-run")) + } } fn versions() -> Result { @@ -107,26 +134,7 @@ fn set_versions(versions: Versions) -> Result<(), NativeError> { Ok(()) } -pub(crate) async fn check_wine_validity() -> Result { - if !proton_dir()?.exists() { - return Ok(false); - } - - let version = versions()?.proton; - - let release = get_wine_release(); - if let Err(err) = release { - if !version.is_empty() { - warn!("Failed to check wine release, rate limited?"); - return Ok(true); - } - - return Err(NativeError::Wine(err)); - } - - Ok(version == release?.tag_name) -} - +#[cfg(target_os = "linux")] pub(crate) async fn get_lutris_runtimes() -> Result, WineError> { let client = reqwest::Client::builder() .user_agent("ArmchairDevelopers/Maxima") @@ -148,7 +156,7 @@ pub(crate) async fn check_runtime_validity( _ => { return Err(NativeError::Wine(WineError::UnimplementedRuntime( key.to_string(), - ))) + ))); } }; let path = wine_dir()?.join(key); @@ -179,12 +187,12 @@ pub(crate) async fn install_runtime( _ => { return Err(NativeError::Wine(WineError::UnimplementedRuntime( key.to_string(), - ))) + ))); } }; let res = match ureq::get(&runtime.url) - .set("User-Agent", "ArmchairDevelopers/Maxima") + .header("User-Agent", "ArmchairDevelopers/Maxima") .call() { Err(err) => return Err(NativeError::Download(DownloadError::Request1(err))), @@ -196,7 +204,7 @@ pub(crate) async fn install_runtime( } let mut body: Vec = vec![]; - res.into_reader().read_to_end(&mut body)?; + res.into_body().as_reader().read_to_end(&mut body)?; if path.exists() { remove_dir_all(&path)?; @@ -218,39 +226,23 @@ pub(crate) async fn install_runtime( set_versions(versions) } -fn get_wine_release() -> Result { - let releases = fetch_github_releases("GloriousEggroll", "proton-ge-custom")?; - - let mut release = None; - for r in releases { - if r.tag_name.ends_with("LoL") { - continue; - } - - release = Some(r); - break; - } - - release.ok_or(WineError::Fetch) -} - +#[cfg(target_os = "linux")] pub async fn run_wine_command, T: AsRef>( - arg: T, + arg: OsString, args: Option, cwd: Option, want_output: bool, command_type: CommandType, + proton_prefix_path: &PathBuf, ) -> Result { let proton_path = proton_dir()?; - let proton_prefix_path = wine_prefix_dir()?; let eac_path = eac_dir()?; - let umu_bin = umu_bin()?; + let umu_script_path = umu_script_path()?; - let wine_path = - env::var("MAXIMA_WINE_COMMAND").unwrap_or_else(|_| umu_bin.to_string_lossy().to_string()); + info!("Wine Prefix: {:?}", proton_prefix_path); // Create command with all necessary wine env variables - let mut binding = Command::new(wine_path.clone()); + let mut binding = Command::new(umu_script_path.clone()); let mut child = binding .env("WINEPREFIX", proton_prefix_path) .env("GAMEID", "umu-0") @@ -261,9 +253,9 @@ pub async fn run_wine_command, T: AsRef>( .env("UMU_ZENITY", "1") .env("WINEDEBUG", "fixme-all") .env("LD_PRELOAD", "") // Fixes some log errors for some games - .arg(arg); + .arg(&arg); - if !wine_path.ends_with("umu-run") { + if env::var("MAXIMA_NORTHSTAR").is_ok() { // wsock32 is used as a proxy for Northstar (Titanfall 2). TODO: provide user-facing option for this! child = child.env( "WINEDLLOVERRIDES", @@ -277,11 +269,18 @@ pub async fn run_wine_command, T: AsRef>( if let Some(cwd) = cwd { child.current_dir(cwd); + } else if arg.to_string_lossy().contains("/") { + // Some games (like Mass Effect LE) require the working directory to be set to the game directory, other it crashes + let mut cwd_dir = PathBuf::from(arg.clone()); + cwd_dir.pop(); + info!("Setting current working directory to: {:?}", cwd_dir); + child.current_dir(cwd_dir); } let status: ExitStatus; let mut output_str = String::new(); + info!("Running command: {:?}", child); if want_output { let output = child .stdout(Stdio::piped()) @@ -304,53 +303,7 @@ pub async fn run_wine_command, T: AsRef>( Ok(output_str.to_string()) } -pub(crate) async fn install_wine() -> Result<(), NativeError> { - let release = get_wine_release()?; - let asset = match release - .assets - .iter() - .find(|x| PROTON_PATTERN.captures(&x.name).is_some()) - { - Some(asset) => asset, - None => return Err(NativeError::Wine(WineError::Fetch)), - }; - - let dir = maxima_dir()?.join("downloads"); - create_dir_all(&dir)?; - - let path = dir.join(&asset.name); - github_download_asset(asset, &path)?; - extract_wine(&path)?; - - let mut versions = versions()?; - versions.proton = release.tag_name; - set_versions(versions)?; - - if let Err(err) = remove_file(&path) { - warn!("Failed to delete {:?} - {:?}", path, err); - } - - let _ = run_wine_command("", None::<[&str; 0]>, None, false, CommandType::Run).await; - - Ok(()) -} - -fn extract_wine(archive_path: &PathBuf) -> Result<(), NativeError> { - info!("Extracting proton..."); - - let dir = proton_dir()?; - if dir.exists() { - remove_dir_all(&dir)?; - } - - create_dir_all(&dir)?; - - let archive_file = File::open(archive_path)?; - let archive_decoder = GzDecoder::new(archive_file); - let archive = Archive::new(archive_decoder); - extract_archive(dir, archive) -} - +#[cfg(target_os = "linux")] fn extract_archive( dir: PathBuf, mut archive: Archive, @@ -376,7 +329,7 @@ fn extract_archive( Ok(()) } -pub async fn setup_wine_registry() -> Result<(), NativeError> { +pub async fn setup_wine_registry(slug: &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 @@ -422,11 +375,12 @@ pub async fn setup_wine_registry() -> Result<(), NativeError> { } run_wine_command( - "regedit", + "regedit".into(), Some(vec![path.safe_str()?]), None, false, CommandType::Run, + &wine_prefix_dir(slug).unwrap(), ) .await?; @@ -437,9 +391,8 @@ pub async fn setup_wine_registry() -> Result<(), NativeError> { pub type WineRegistry = HashMap; -lazy_static! { - static ref MX_WINE_REGISTRY: Mutex = Mutex::new(WineRegistry::new()); -} +static MX_WINE_REGISTRY: LazyLock> = + LazyLock::new(|| Mutex::new(WineRegistry::new())); async fn parse_wine_registry(file_path: &str) -> WineRegistry { let mut registry_map = MX_WINE_REGISTRY.lock().await; @@ -475,8 +428,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: &str) -> Result { + let path = wine_prefix_dir(slug) + .unwrap() + .join("pfx") + .join("system.reg"); if !path.exists() { return Ok(HashMap::new()); } @@ -499,8 +455,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/dll_injector.rs b/maxima-lib/src/util/dll_injector.rs index 7d785325..6a38b1bf 100644 --- a/maxima-lib/src/util/dll_injector.rs +++ b/maxima-lib/src/util/dll_injector.rs @@ -1,17 +1,15 @@ -use std::ffi::CString; +use std::ffi::{CString, c_void}; use std::mem; use std::ptr; use thiserror::Error; -use winapi::shared::minwindef::LPVOID; -use winapi::um::errhandlingapi::GetLastError; -use winapi::um::handleapi::CloseHandle; -use winapi::um::libloaderapi::{GetModuleHandleA, GetProcAddress}; -use winapi::um::memoryapi::{VirtualAllocEx, VirtualFreeEx, WriteProcessMemory}; -use winapi::um::processthreadsapi::{CreateRemoteThread, OpenProcess}; -use winapi::um::synchapi::WaitForSingleObject; -use winapi::um::winbase::INFINITE; -use winapi::um::winnt::{ - HANDLE, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, PROCESS_ALL_ACCESS, +use windows_sys::Win32::{ + Foundation::{CloseHandle, GetLastError, HANDLE}, + System::Diagnostics::Debug::WriteProcessMemory, + System::LibraryLoader::{GetModuleHandleA, GetProcAddress}, + System::Memory::{ + MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocEx, VirtualFreeEx, + }, + System::Threading::{CreateRemoteThread, OpenProcess, PROCESS_ALL_ACCESS, WaitForSingleObject}, }; #[derive(Debug, Error)] @@ -77,7 +75,7 @@ impl DllInjector { let result = WriteProcessMemory( process_handle, remote_memory, - dll_path_bytes.as_ptr() as LPVOID, + dll_path_bytes.as_ptr() as *const c_void, dll_path_size, &mut bytes_written as *mut usize, ); @@ -87,15 +85,16 @@ impl DllInjector { } let kernel32_cstring = CString::new("kernel32.dll").unwrap(); - let kernel32_handle = GetModuleHandleA(kernel32_cstring.as_ptr()); + let kernel32_handle = GetModuleHandleA(kernel32_cstring.as_ptr() as *const u8); if kernel32_handle.is_null() { return Err(InjectionError::GetModuleHandleFailed(GetLastError())); } let load_library_cstring = CString::new("LoadLibraryA").unwrap(); - let load_library_addr = GetProcAddress(kernel32_handle, load_library_cstring.as_ptr()); + let load_library_addr = + GetProcAddress(kernel32_handle, load_library_cstring.as_ptr() as *const u8); - if load_library_addr.is_null() { + if load_library_addr.is_none() { return Err(InjectionError::GetProcAddressFailed(GetLastError())); } @@ -113,7 +112,7 @@ impl DllInjector { return Err(InjectionError::CreateRemoteThreadFailed(GetLastError())); } - WaitForSingleObject(thread_handle, INFINITE); + WaitForSingleObject(thread_handle, u32::MAX); CloseHandle(thread_handle); Ok(()) @@ -135,7 +134,7 @@ impl Drop for ProcessHandleGuard { struct RemoteMemoryGuard { process_handle: HANDLE, - address: LPVOID, + address: *mut c_void, } impl Drop for RemoteMemoryGuard { diff --git a/maxima-lib/src/util/github.rs b/maxima-lib/src/util/github.rs index ec79fcca..1f11d6af 100644 --- a/maxima-lib/src/util/github.rs +++ b/maxima-lib/src/util/github.rs @@ -30,13 +30,13 @@ pub fn fetch_github_releases( ); let res = ureq::get(&url) - .set("User-Agent", "ArmchairDevelopers/Maxima") + .header("User-Agent", "ArmchairDevelopers/Maxima") .call()?; if res.status() != StatusCode::OK { - return Err(DownloadError::Http(res.into_string()?)); + return Err(DownloadError::Http(res.into_body().read_to_string()?)); } - let text = res.into_string()?; + let text = res.into_body().read_to_string()?; let result = serde_json::from_str(text.as_str())?; Ok(result) } @@ -52,13 +52,13 @@ pub fn fetch_github_release( ); let res = ureq::get(&url) - .set("User-Agent", "ArmchairDevelopers/Maxima") + .header("User-Agent", "ArmchairDevelopers/Maxima") .call()?; if res.status() != StatusCode::OK { - return Err(DownloadError::Http(res.into_string()?)); + return Err(DownloadError::Http(res.into_body().read_to_string()?)); } - let text = res.into_string()?; + let text = res.into_body().read_to_string()?; let result = serde_json::from_str(text.as_str())?; Ok(result) } @@ -68,11 +68,14 @@ pub fn github_download_asset(asset: &GithubAsset, path: &PathBuf) -> Result<(), let res = ureq::get(&asset.browser_download_url).call()?; if res.status() != StatusCode::OK { - return Err(DownloadError::Http(res.into_string()?)); + return Err(DownloadError::Http(res.into_body().read_to_string()?)); } let mut body: Vec = vec![]; - res.into_reader().take(asset.size).read_to_end(&mut body)?; + res.into_body() + .into_reader() + .take(asset.size) + .read_to_end(&mut body)?; std::fs::write(path, body)?; Ok(()) diff --git a/maxima-lib/src/util/lsx_crypto.rs b/maxima-lib/src/util/lsx_crypto.rs new file mode 100644 index 00000000..21ccabeb --- /dev/null +++ b/maxima-lib/src/util/lsx_crypto.rs @@ -0,0 +1,104 @@ +use aes::Aes128; +use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit, block_padding::Pkcs7}; + +use ecb::{Decryptor, Encryptor}; +use hex; +use thiserror::Error; + +type EcbEnc = Encryptor; +type EcbDec = Decryptor; + +const KEY_SIZE: usize = 16; +const DEFAULT_SEED: u32 = 7; + +#[derive(Debug, Error)] +pub enum CryptoError { + #[error("Input cannot be empty")] + EmptyInput, + #[error("Decryption failed: {0}")] + DecryptionFailed(String), + #[error("UTF-8 conversion error")] + Utf8Error(#[from] std::string::FromUtf8Error), +} + +#[derive(Clone, Debug)] +struct Random { + state: u64, +} + +impl Random { + fn new(seed: u32) -> Self { + Self { state: seed as u64 } + } + fn set_seed(&mut self, seed: u32) { + self.state = seed as u64; + } + /// LCG constants from Numerical Recipes / glibc + fn next(&mut self) -> u32 { + self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1); + (self.state >> 32) as u32 + } +} + +#[derive(Clone, Debug)] +pub struct Crypto { + pub(crate) key: [u8; KEY_SIZE], + rng: Random, +} + +impl Crypto { + pub fn new(seed: u32) -> Self { + let mut s = Self { + key: [0u8; KEY_SIZE], + rng: Random::new(DEFAULT_SEED), + }; + s.set_key(seed); + s + } + + pub fn set_key(&mut self, seed: u32) { + if seed == 0 { + for (i, b) in self.key.iter_mut().enumerate() { + *b = i as u8; + } + } else { + let new_seed = self.rng.next().wrapping_add(seed); + self.rng.set_seed(new_seed); + for b in self.key.iter_mut() { + *b = self.rng.next() as u8; + } + } + } + + pub fn encrypt(&self, plain: &str) -> Result, CryptoError> { + if plain.is_empty() { + return Err(CryptoError::EmptyInput); + } + Ok(EcbEnc::new(&self.key.into()).encrypt_padded_vec::(plain.as_bytes())) + } + + pub fn decrypt(&self, cipher: &[u8]) -> Result { + if cipher.is_empty() { + return Err(CryptoError::EmptyInput); + } + let pt = EcbDec::new(&self.key.into()) + .decrypt_padded_vec::(cipher) + .map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?; + String::from_utf8(pt).map_err(CryptoError::from) + } + + /// Encrypt `key` with current AES key, hex-encode, derive new seed from first 2 ASCII bytes, + /// rotate internal key to session key, return hex response. + pub fn prepare_challenge_response(&mut self, key: &str) -> Result { + let ct = self.encrypt(key)?; + let hex_str = hex::encode(ct); + let b = hex_str.as_bytes(); + let seed = ((b[0] as u32) << 8) | (b[1] as u32); + self.set_key(seed); + Ok(hex_str) + } + + pub fn current_key(&self) -> [u8; 16] { + self.key + } +} diff --git a/maxima-lib/src/util/mod.rs b/maxima-lib/src/util/mod.rs index 6e49d964..e1bfbbc7 100644 --- a/maxima-lib/src/util/mod.rs +++ b/maxima-lib/src/util/mod.rs @@ -1,6 +1,7 @@ pub mod github; pub mod hash; pub mod log; +pub mod lsx_crypto; pub mod native; pub mod registry; pub mod simple_crypto; diff --git a/maxima-lib/src/util/native.rs b/maxima-lib/src/util/native.rs index 5adfc60f..4267aeb7 100644 --- a/maxima-lib/src/util/native.rs +++ b/maxima-lib/src/util/native.rs @@ -1,5 +1,4 @@ use std::{ - env, fs::create_dir_all, num::ParseIntError, path::{Path, PathBuf}, @@ -13,15 +12,12 @@ use std::{ }; #[cfg(windows)] -use winapi::{ - shared::windef::HWND, - um::{ - libloaderapi::{GetModuleFileNameW, GetModuleHandleW}, - wincon::GetConsoleWindow, - winuser::{ - EnumWindows, FindWindowA, GetWindowThreadProcessId, IsWindowVisible, - SetForegroundWindow, - }, +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM}, + System::Console::GetConsoleWindow, + System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}, + UI::WindowsAndMessaging::{ + EnumWindows, FindWindowA, GetWindowThreadProcessId, IsWindowVisible, SetForegroundWindow, }, }; @@ -137,23 +133,20 @@ pub enum NativeError { } #[cfg(windows)] -unsafe extern "system" fn enum_windows_proc( - hwnd: HWND, - _l_param: winapi::shared::minwindef::LPARAM, -) -> winapi::shared::minwindef::BOOL { +unsafe extern "system" fn enum_windows_proc(hwnd: HWND, _l_param: LPARAM) -> i32 { let mut window_process_id: u32 = 0; - GetWindowThreadProcessId(hwnd, &mut window_process_id); + unsafe { GetWindowThreadProcessId(hwnd, &mut window_process_id) }; - if window_process_id != std::process::id() || IsWindowVisible(hwnd) == 0 { - return winapi::shared::minwindef::TRUE; + if window_process_id != std::process::id() || unsafe { IsWindowVisible(hwnd) } == 0 { + return 1; } - if IsWindowVisible(hwnd) != 0 { - SetForegroundWindow(hwnd); + if unsafe { IsWindowVisible(hwnd) } != 0 { + unsafe { SetForegroundWindow(hwnd) }; } - winapi::shared::minwindef::TRUE + 1 } #[cfg(windows)] pub fn get_hwnd() -> Result { @@ -161,7 +154,7 @@ pub fn get_hwnd() -> Result { EnumWindows(Some(enum_windows_proc), 0); let window_name = CString::new("Maxima").expect("Failed to create native string"); - let mut hwnd = FindWindowA(std::ptr::null(), window_name.as_ptr()); + let mut hwnd = FindWindowA(std::ptr::null(), window_name.as_ptr() as *const u8); if !hwnd.is_null() { return Ok(hwnd); } @@ -198,9 +191,9 @@ pub fn module_path() -> Result { .collect::>(); maxima_mod_name.push(0); - let mut hmodule = unsafe { GetModuleHandleW(maxima_mod_name.as_mut_ptr()) }; + let mut hmodule = unsafe { GetModuleHandleW(maxima_mod_name.as_mut_ptr() as *const u16) }; if hmodule.is_null() { - hmodule = unsafe { GetModuleHandleW(std::ptr::null_mut()) }; + hmodule = unsafe { GetModuleHandleW(std::ptr::null()) }; } if hmodule.is_null() { @@ -245,6 +238,8 @@ pub fn maxima_dir() -> Result { #[cfg(unix)] pub fn maxima_dir() -> Result { + use std::env; + let home = if let Ok(home) = env::var("XDG_DATA_HOME") { home } else if let Ok(home) = env::var("HOME") { diff --git a/maxima-lib/src/util/registry.rs b/maxima-lib/src/util/registry.rs index 624fc0d0..7445dd69 100644 --- a/maxima-lib/src/util/registry.rs +++ b/maxima-lib/src/util/registry.rs @@ -1,6 +1,3 @@ -#[cfg(windows)] -extern crate winapi; - use std::path::PathBuf; use thiserror::Error; @@ -11,31 +8,27 @@ use std::ptr; use widestring::U16CString; #[cfg(windows)] -use winapi::{ - shared::{minwindef::HKEY, winerror::ERROR_CANCELLED}, - um::{ - errhandlingapi::GetLastError, - shellapi::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SEE_MASK_NO_CONSOLE}, - }, - um::{ - winnt::KEY_QUERY_VALUE, - winreg::{RegCloseKey, RegOpenKeyExW, RegQueryValueExW}, - }, +use windows_sys::Win32::{ + Foundation::{CloseHandle, ERROR_CANCELLED, GetLastError}, + System::Registry::{HKEY, KEY_QUERY_VALUE, RegCloseKey, RegOpenKeyExW, RegQueryValueExW}, + System::Threading::{INFINITE, WaitForSingleObject}, + UI::Shell::{SEE_MASK_NO_CONSOLE, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW}, }; #[cfg(windows)] use winreg::{ - enums::{HKEY_CLASSES_ROOT, HKEY_LOCAL_MACHINE, KEY_WRITE}, RegKey, + enums::{HKEY_CLASSES_ROOT, HKEY_LOCAL_MACHINE, KEY_WRITE}, }; #[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; -use super::native::{module_path, NativeError, SafeParent, SafeStr}; +use super::native::{NativeError, SafeParent, SafeStr, module_path}; #[cfg(target_pointer_width = "64")] pub const REG_ARCH_PATH: &str = "SOFTWARE\\WOW6432Node"; @@ -76,6 +69,91 @@ pub enum RegistryError { QrcUnregistered, } +#[cfg(windows)] +unsafe fn open_and_query_reg( + sub_key: &str, + value_name: &str, +) -> Result, RegistryError> { + let hkey = HKEY_LOCAL_MACHINE as HKEY; + let mut handle = ptr::null_mut(); + + if unsafe { + RegOpenKeyExW( + hkey, + U16CString::from_str(sub_key)?.as_ptr(), + 0, + KEY_QUERY_VALUE, + &mut handle, + ) + } != 0 + { + return Err(RegistryError::Key(sub_key.to_string())); + } + + let dw_type = ptr::null_mut(); + let mut dw_size = 0; + + if unsafe { + RegQueryValueExW( + handle, + U16CString::from_str(value_name)?.as_ptr(), + ptr::null_mut(), + dw_type, + ptr::null_mut(), + &mut dw_size, + ) + } != 0 + { + unsafe { RegCloseKey(handle) }; + return Err(RegistryError::Value { + value: value_name.to_string(), + key: sub_key.to_string(), + }); + } + + if dw_size == 0 { + unsafe { RegCloseKey(handle) }; + return Err(RegistryError::Value { + value: value_name.to_string(), + key: sub_key.to_string(), + }); + } + + let mut buf: Vec = vec![0; dw_size as usize / 2]; + if unsafe { + RegQueryValueExW( + handle, + U16CString::from_str(value_name)?.as_ptr(), + ptr::null_mut(), + dw_type, + buf.as_mut_ptr() as *mut u8, + &mut dw_size, + ) + } != 0 + { + unsafe { RegCloseKey(handle) }; + return Err(RegistryError::Value { + value: value_name.to_string(), + key: sub_key.to_string(), + }); + } + + unsafe { RegCloseKey(handle) }; + Ok(Some(String::from_utf16_lossy(&buf[..buf.len() - 1]))) +} + +#[cfg(windows)] +fn inject_wow6432node(sub_key: &str) -> Option { + // Only rewrite paths under SOFTWARE\ that aren't already redirected + let prefix = "SOFTWARE\\"; + if let Some(rest) = sub_key.strip_prefix(prefix) + && !rest.starts_with("WOW6432Node\\") + { + return Some(format!("SOFTWARE\\WOW6432Node\\{}", rest)); + } + None +} + #[cfg(windows)] pub fn check_registry_validity() -> Result<(), RegistryError> { let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); @@ -103,127 +181,80 @@ 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)..]; - let hkey = HKEY_LOCAL_MACHINE as HKEY; - let mut handle = ptr::null_mut(); - - unsafe { - if RegOpenKeyExW( - hkey, - U16CString::from_str(sub_key)?.as_ptr(), - 0, - KEY_QUERY_VALUE, - &mut handle, - ) != 0 - { - return Err(RegistryError::Key(sub_key.to_string())); - } - - let dw_type = ptr::null_mut(); - let mut dw_size = 0; - - if RegQueryValueExW( - handle, - U16CString::from_str(value_name)?.as_ptr(), - ptr::null_mut(), - dw_type, - ptr::null_mut(), - &mut dw_size, - ) != 0 - { - RegCloseKey(handle); - return Err(RegistryError::Value { - value: value_name.to_string(), - key: sub_key.to_string(), - }); - } - - if dw_size <= 0 { - RegCloseKey(handle); - return Err(RegistryError::Value { - value: value_name.to_string(), - key: sub_key.to_string(), - }); - } + // First attempt: path as-is + let result = unsafe { open_and_query_reg(sub_key, value_name) }; - let mut buf: Vec = vec![0; dw_size as usize / 2]; - if RegQueryValueExW( - handle, - U16CString::from_str(value_name)?.as_ptr(), - ptr::null_mut(), - dw_type, - buf.as_mut_ptr() as *mut u8, - &mut dw_size, - ) != 0 - { - RegCloseKey(handle); - return Err(RegistryError::Value { - value: value_name.to_string(), - key: sub_key.to_string(), - }); + match result { + Ok(v) => return Ok(v), + Err(_) => { + // Second attempt: inject WOW6432Node if applicable + if let Some(wow_sub_key) = inject_wow6432node(sub_key) { + return unsafe { open_and_query_reg(&wow_sub_key, value_name) }; + } + return Err(RegistryError::Key(sub_key.to_string())); } - - RegCloseKey(handle); - return Ok(Some(String::from_utf16_lossy(&buf[..buf.len() - 1]))); } } 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_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) } -pub async fn parse_registry_path(key: &str) -> Result { - let mut parts = key - .split(|c| c == '[' || c == ']') - .filter(|s| !s.is_empty()); +#[cfg(windows)] +pub async fn parse_registry_path_regkey(key: &str) -> Result { + let mut parts = key.split(['[', ']']).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? { + if let (Some(first), Some(second)) = (parts.next(), parts.next()) { + let path = match read_reg_key(first, None).await? { Some(path) => path.replace("\\", "/").replace("//", "/"), - None => return Ok(PathBuf::from(key.to_owned())), + None => return Err(RegistryError::InvalidInstallKey), }; let second = second.replace("\\", "/"); let second = second.strip_prefix("/").unwrap_or(&second); - return Ok([path, second.to_owned()].iter().collect()); + Ok([path, second.to_owned()].iter().collect()) } else { - PathBuf::from(key.to_owned()) - }; - - #[cfg(unix)] - let path = case_insensitive_path(path); - Ok(path) + Err(RegistryError::InvalidInstallKey) + } } +// [HKEY_LOCAL_MACHINE\SOFTWARE\BioWare\Mass Effect Legendary Edition\Install Dir]Game\Launcher\MassEffectLauncher.exe +// Will replace the registry key with the install dir and will only return that (IE will drop everything after the last ]) +#[cfg(windows)] pub async fn parse_partial_registry_path(key: &str) -> Result { - let mut parts = key - .split(|c| c == '[' || c == ']') - .filter(|s| !s.is_empty()); + let mut parts = key.split(['[', ']']).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? { + if let (Some(first), Some(_second)) = (parts.next(), parts.next()) { + let path = match read_reg_key(first, None).await? { Some(path) => path.replace("\\", "/"), - None => return Ok(PathBuf::from(key.to_owned())), + None => return Err(RegistryError::InvalidInstallKey), }; - return Ok(PathBuf::from(path.to_owned())); + Ok(PathBuf::from(path.to_owned())) } else { - PathBuf::from(key.to_owned()) - }; - - #[cfg(unix)] - let path = case_insensitive_path(path); - Ok(path) + Err(RegistryError::InvalidInstallKey) + } } #[cfg(windows)] @@ -264,8 +295,8 @@ pub fn launch_bootstrap() -> Result<(), NativeError> { let file = file.encode_utf16().chain(Some(0)).collect::>(); let parameters = parameters.encode_utf16().chain(Some(0)).collect::>(); - let mut shell_execute_info = winapi::um::shellapi::SHELLEXECUTEINFOW { - cbSize: std::mem::size_of::() as u32, + let mut shell_execute_info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, lpVerb: verb.as_ptr(), lpFile: file.as_ptr(), lpParameters: parameters.as_ptr(), @@ -274,12 +305,17 @@ pub fn launch_bootstrap() -> Result<(), NativeError> { }; unsafe { - ShellExecuteExW(&mut shell_execute_info); - + let ok = ShellExecuteExW(&mut shell_execute_info); let err = GetLastError(); - if err == ERROR_CANCELLED { + + if ok == 0 || err == ERROR_CANCELLED { return Err(NativeError::Elevation(file1)); } + + if !shell_execute_info.hProcess.is_null() { + WaitForSingleObject(shell_execute_info.hProcess, INFINITE); + CloseHandle(shell_execute_info.hProcess); + } } Ok(()) diff --git a/maxima-lib/src/util/service_win.rs b/maxima-lib/src/util/service_win.rs index 453d4743..17594b8a 100644 --- a/maxima-lib/src/util/service_win.rs +++ b/maxima-lib/src/util/service_win.rs @@ -3,14 +3,19 @@ use std::ffi::{CString, OsStr, OsString}; use std::path::PathBuf; use std::time::Duration; use widestring::U16CString; -use winapi::shared::sddl::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, - ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, -}; -use winapi::um::winnt::{DACL_SECURITY_INFORMATION, LPWSTR, PSECURITY_DESCRIPTOR}; -use winapi::um::winsvc::{ - OpenSCManagerA, OpenServiceA, QueryServiceObjectSecurity, SetServiceObjectSecurity, - SC_MANAGER_ALL_ACCESS, SERVICE_ALL_ACCESS, +use windows_sys::Win32::{ + Foundation::{LocalFree, ERROR_INSUFFICIENT_BUFFER}, + Security::{ + Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }, + DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + }, + System::Services::{ + CloseServiceHandle, OpenSCManagerA, OpenServiceA, QueryServiceObjectSecurity, + SetServiceObjectSecurity, SC_MANAGER_CONNECT, SERVICE_ALL_ACCESS, + }, }; use windows_service::service::{ ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceState, ServiceType, @@ -45,10 +50,7 @@ pub fn register_service() -> Result<(), BackgroundServiceControlError> { // Update the existing service, if it exists let existing_service = service_manager.open_service( OsString::from(SERVICE_NAME), - ServiceAccess::START - | ServiceAccess::STOP - | ServiceAccess::CHANGE_CONFIG - | ServiceAccess::QUERY_STATUS, + ServiceAccess::START | ServiceAccess::STOP | ServiceAccess::CHANGE_CONFIG | ServiceAccess::QUERY_STATUS, ); if let Ok(service) = existing_service { @@ -56,100 +58,107 @@ pub fn register_service() -> Result<(), BackgroundServiceControlError> { let state = service.query_status()?.current_state; if state == ServiceState::Running { - let result = service.stop(); - if result.is_err() { - // Noop - } + let _ = service.stop(); } service.change_config(&service_info)?; + unsafe { init_service_security()?; } service.start(&[OsStr::new("")])?; return Ok(()); } let service = service_manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?; service.set_description(SERVICE_DISPLAY_NAME)?; - - // Allow the service to be started without administrative rights unsafe { init_service_security()? }; Ok(()) } pub unsafe fn init_service_security() -> Result<(), BackgroundServiceControlError> { - let hscm = OpenSCManagerA( - std::ptr::null(), - CString::new("ServicesActive")?.as_ptr(), - SC_MANAGER_ALL_ACCESS, - ); - - let hservice = OpenServiceA( - hscm, - CString::new(SERVICE_NAME)?.as_ptr(), - SERVICE_ALL_ACCESS, - ); - + let hscm = unsafe { + OpenSCManagerA( + std::ptr::null(), + CString::new("ServicesActive")?.as_ptr() as *const u8, + SC_MANAGER_CONNECT, + ) + }; + if hscm.is_null() { + return Err(BackgroundServiceControlError::ServiceObjectSecurity( + std::io::Error::last_os_error(), + )); + } + let _close_scm = scopeguard::guard(hscm, |h| unsafe { CloseServiceHandle(h); }); + + let hservice = unsafe { + OpenServiceA( + hscm, + CString::new(SERVICE_NAME)?.as_ptr() as *const u8, + SERVICE_ALL_ACCESS, + ) + }; if hservice.is_null() { return Err(BackgroundServiceControlError::Absent); } + let _close_service = scopeguard::guard(hservice, |h| unsafe { CloseServiceHandle(h); }); - // Query the service object security + // Query the service object security (first call to get required buffer size). let mut bytes_required: u32 = 0; - let result = QueryServiceObjectSecurity( - hservice, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - 0, - &mut bytes_required, - ); - + let result = unsafe { + QueryServiceObjectSecurity( + hservice, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + 0, + &mut bytes_required, + ) + }; if result == 0 { - // The initial call failed; check if the error was related to buffer size let last_error = std::io::Error::last_os_error(); - let raw_error = (&last_error).raw_os_error().unwrap() as u32; - if raw_error != winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER { + let raw_error = last_error.raw_os_error().unwrap_or(0) as u32; + if raw_error != ERROR_INSUFFICIENT_BUFFER { return Err(BackgroundServiceControlError::ServiceObjectSecurity(last_error)); } } - // Allocate a buffer for the security descriptor let mut security_descriptor_buffer: Vec = vec![0; bytes_required as usize]; let security_descriptor = security_descriptor_buffer.as_mut_ptr() as PSECURITY_DESCRIPTOR; - // Query the service object security again with the correct buffer - let result = QueryServiceObjectSecurity( - hservice, - DACL_SECURITY_INFORMATION, - security_descriptor, - bytes_required, - &mut bytes_required, - ); - + let result = unsafe { + QueryServiceObjectSecurity( + hservice, + DACL_SECURITY_INFORMATION, + security_descriptor, + bytes_required, + &mut bytes_required, + ) + }; if result == 0 { return Err(BackgroundServiceControlError::ServiceObjectSecurity( std::io::Error::last_os_error(), )); } - // Convert the security descriptor to a string - let mut sddl_string: LPWSTR = std::ptr::null_mut(); + // Convert the security descriptor to an SDDL string. + let mut sddl_string: *mut u16 = std::ptr::null_mut(); let mut sddl_string_len: u32 = 0; - let result = ConvertSecurityDescriptorToStringSecurityDescriptorW( - security_descriptor, - SDDL_REVISION_1.into(), - DACL_SECURITY_INFORMATION, - &mut sddl_string, - &mut sddl_string_len, - ); - + let result = unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + security_descriptor, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut sddl_string, + &mut sddl_string_len, + ) + }; if result == 0 { return Err(BackgroundServiceControlError::SecurityDescriptorToString( std::io::Error::last_os_error(), )); } + let _free_sddl = scopeguard::guard(sddl_string, |p| unsafe { LocalFree(p as *mut _); }); - let sddl = U16CString::from_ptr_str(sddl_string).to_string_lossy(); - let sddl_to_add = "(A;;RPWPCR;;;BU)"; + let sddl = unsafe { U16CString::from_ptr_str(sddl_string).to_string_lossy() }; + let sddl_to_add = "(A;;CCLCRPWPLOCRRC;;;BU)"; if sddl.contains(sddl_to_add) { return Ok(()); } @@ -159,32 +168,36 @@ pub unsafe fn init_service_security() -> Result<(), BackgroundServiceControlErro let mut amended_security_descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); let mut amended_security_descriptor_len: u32 = 0; - let result = ConvertStringSecurityDescriptorToSecurityDescriptorW( - U16CString::from_str(amended_sddl.as_str())?.as_ptr(), - SDDL_REVISION_1.into(), - &mut amended_security_descriptor, - &mut amended_security_descriptor_len, - ); - + let result = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + U16CString::from_str(amended_sddl.as_str())?.as_ptr(), + SDDL_REVISION_1, + &mut amended_security_descriptor, + &mut amended_security_descriptor_len, + ) + }; if result == 0 { return Err(BackgroundServiceControlError::StringToSecurityDescriptor( std::io::Error::last_os_error(), )); } - - // Set the service object security with the amended security descriptor - let result = SetServiceObjectSecurity( - hservice, - DACL_SECURITY_INFORMATION, - amended_security_descriptor, - ); - + let _free_sd = + scopeguard::guard(amended_security_descriptor, |p| unsafe { LocalFree(p as *mut _); }); + + let result = unsafe { + SetServiceObjectSecurity( + hservice, + DACL_SECURITY_INFORMATION, + amended_security_descriptor, + ) + }; if result == 0 { return Err(BackgroundServiceControlError::SecurityAttributes( std::io::Error::last_os_error(), )); } + info!("Reapplied service DACL for {}", SERVICE_NAME); Ok(()) } @@ -233,12 +246,13 @@ pub fn is_service_running() -> Result { } pub async fn start_service() -> Result<(), BackgroundServiceControlError> { - let service_manager = service_manager(false)?; - - let service_result = - service_manager.open_service(OsString::from(SERVICE_NAME), ServiceAccess::START)?; + { + let service_manager = service_manager(false)?; + let service_result = + service_manager.open_service(OsString::from(SERVICE_NAME), ServiceAccess::START)?; - service_result.start(&[OsStr::new("")])?; + service_result.start(&[OsStr::new("")])?; + } while !is_service_running()? { tokio::time::sleep(Duration::from_millis(100)).await; @@ -248,12 +262,13 @@ pub async fn start_service() -> Result<(), BackgroundServiceControlError> { } pub async fn stop_service() -> Result<(), BackgroundServiceControlError> { - let service_manager = service_manager(false)?; + { + let service_manager = service_manager(false)?; + let service_result = + service_manager.open_service(OsString::from(SERVICE_NAME), ServiceAccess::STOP)?; - let service_result = - service_manager.open_service(OsString::from(SERVICE_NAME), ServiceAccess::STOP)?; - - service_result.stop()?; + service_result.stop()?; + } while is_service_running()? { tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/maxima-lib/src/util/simple_crypto.rs b/maxima-lib/src/util/simple_crypto.rs index d07c338d..27d5c69b 100644 --- a/maxima-lib/src/util/simple_crypto.rs +++ b/maxima-lib/src/util/simple_crypto.rs @@ -1,34 +1,34 @@ -use std::{io::Write, num::Wrapping, str}; +use std::{num::Wrapping, str}; -use aes::cipher::{ - block_padding::Pkcs7, generic_array::GenericArray, BlockDecryptMut, BlockEncryptMut, KeyInit, -}; +use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit, block_padding::Pkcs7}; use chrono::Datelike; - -const CRYPTO_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; +use hex; type Aes128EcbEnc = ecb::Encryptor; type Aes128EcbDec = ecb::Decryptor; +const CRYPTO_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + const PRIME_10K: u32 = 104729; const PRIME_20K: u32 = 224737; const PRIME_30K: u32 = 350377; -pub fn simple_decrypt(data: &[u8], key: &[u8; 16]) -> String { - let data = get_array(str::from_utf8(data).unwrap()); - - let key = GenericArray::from_slice(key); - let result = Aes128EcbDec::new(key) - .decrypt_padded_vec_mut::(&data) - .unwrap(); - - String::from_utf8(result).unwrap() +/// Encrypt data with AES-128-ECB-PKCS7, return hex string. +pub fn simple_encrypt(data: &[u8], key: &[u8; 16]) -> String { + let ct = Aes128EcbEnc::new(key.into()).encrypt_padded_vec::(data); + hex::encode(ct) } -pub fn simple_encrypt(data: &[u8], key: &[u8; 16]) -> String { - let key = GenericArray::from_slice(key); - let res = Aes128EcbEnc::new(key).encrypt_padded_vec_mut::(data); - hex::encode(res) +/// Decrypt hex-encoded AES-128-ECB-PKCS7 data. +pub fn simple_decrypt(data: &[u8], key: &[u8; 16]) -> String { + let Ok(ciphertext) = hex::decode(data) else { + return String::new(); + }; + Aes128EcbDec::new(key.into()) + .decrypt_padded_vec::(&ciphertext) + .ok() + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default() } pub fn check_challenge_response(response: &str, challenge: &str) -> bool { @@ -72,28 +72,6 @@ impl CRandom { } } -fn get_array(s: &str) -> Vec { - let mut m = std::io::Cursor::new(Vec::new()); - let mut sb = String::new(); - let filter = "0123456789abcdef"; - let s = s.to_lowercase(); - for c in s.chars() { - if filter.contains(c) { - sb.push(c); - } - } - if sb.len() % 2 != 0 { - return Vec::new(); - } - let s = sb.as_str(); - for i in 0..(sb.len() / 2) { - let byte_str = &s[(i * 2)..((i * 2) + 2)]; - let byte = u8::from_str_radix(byte_str, 16).expect("Failed to parse byte from string"); - m.write(&[byte]).expect("Failed to write byte to stream"); - } - m.into_inner() -} - /// This code is required to launch games and changes daily pub fn rtp_handshake() -> u32 { let current_date = chrono::Utc::now(); diff --git a/maxima-resources/Cargo.toml b/maxima-resources/Cargo.toml index 5ef40c6f..749bbe9b 100644 --- a/maxima-resources/Cargo.toml +++ b/maxima-resources/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "maxima-resources" -version = "0.1.0" -authors = ["Sean Kahler "] -edition = "2021" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } [dependencies] winres = "0.1.12" diff --git a/maxima-service/Cargo.toml b/maxima-service/Cargo.toml index aa68d90d..1100a966 100644 --- a/maxima-service/Cargo.toml +++ b/maxima-service/Cargo.toml @@ -1,35 +1,31 @@ [package] name = "maxima-service" -version = "0.1.0" -authors = ["Sean Kahler "] -edition = "2021" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } [dependencies] maxima-lib = { path = "../maxima-lib" } -tokio = { version = "1.28.2", features = [ "full", "rt", "rt-multi-thread", "time", "net" ] } -serde = { version = "1.0.164", features = [ "derive" ] } -serde_json = "1.0.97" -paste = "1.0.12" -reqwest = { version = "0.11.18", features = ["stream", "rustls-tls"], default-features = false } -base64 = "0.21.2" -log = "0.4.19" -regex = "1.8.4" -directories = "5.0.1" -structured-logger = "1.0.1" -actix-web = "4.8.0" -sha2 = "0.10.7" -derive_more = "0.99.17" -hex = "0.4.3" -thiserror = "2.0.12" +tokio = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +structured-logger = "1.0.5" +actix-web = "4.15.0" +sha2 = "0.11.0" +hex = { workspace = true } +thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi" ] } -winreg = "0.50.0" -windows-service = "0.6.0" +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_ProcessStatus", +] } +windows-service = { workspace = true } [build-dependencies] maxima-resources = { path = "../maxima-resources" } [[bin]] name = "maxima-service" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/maxima-service/src/service/error.rs b/maxima-service/src/service/error.rs index 673002be..cc64301d 100644 --- a/maxima-service/src/service/error.rs +++ b/maxima-service/src/service/error.rs @@ -1,5 +1,4 @@ -use actix_web::{error, http::header::ContentType, HttpResponse}; -use reqwest::StatusCode; +use actix_web::{HttpResponse, error, http::StatusCode, http::header::ContentType}; use thiserror::Error; #[derive(Error, Debug)] @@ -14,9 +13,15 @@ pub enum ServerError { Native(#[from] maxima::util::native::NativeError), #[error(transparent)] Service(#[from] windows_service::Error), + #[error(transparent)] + Touchup(#[from] maxima::core::manifest::ManifestError), #[error("attempted to inject into invalid process")] InvalidInjectionTarget, + #[error("timed out waiting for the HTTP server to bind")] + BindTimeout, + #[error("HTTP server stopped unexpectedly")] + HttpServerStopped, } impl error::ResponseError for ServerError { diff --git a/maxima-service/src/service/hash.rs b/maxima-service/src/service/hash.rs index 64e9f58e..f3911b04 100644 --- a/maxima-service/src/service/hash.rs +++ b/maxima-service/src/service/hash.rs @@ -4,14 +4,15 @@ use std::ffi::OsString; use std::fs; use std::os::windows::ffi::OsStringExt; use std::ptr::null_mut; -use winapi::um::handleapi::CloseHandle; -use winapi::um::processthreadsapi::OpenProcess; -use winapi::um::psapi::GetModuleFileNameExW; -use winapi::um::winnt::PROCESS_QUERY_INFORMATION; +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::ProcessStatus::GetModuleFileNameExW, + System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION}, +}; pub fn get_sha256_hash_of_pid(pid: u32) -> Result<[u8; 32], NativeError> { unsafe { - let process_handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid); + let process_handle: HANDLE = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid); if process_handle.is_null() { return Err(NativeError::CantFindProcess); } diff --git a/maxima-service/src/service/mod.rs b/maxima-service/src/service/mod.rs index f8bfb689..5f2c9468 100644 --- a/maxima-service/src/service/mod.rs +++ b/maxima-service/src/service/mod.rs @@ -1,15 +1,21 @@ +use std::ffi::OsString; use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::mpsc as std_mpsc; +use std::thread; +use std::time::Duration; -use actix_web::{get, post, web, HttpResponse, Responder}; -use log::info; +use actix_web::{HttpResponse, Responder, get, post, web}; +use log::{error, info}; +use maxima::core::background_service::{ + BACKGROUND_SERVICE_PORT, ServiceLibraryInjectionRequest, ServiceTouchupRequest, +}; +use maxima::util::dll_injector::{DllInjector, InjectionError}; +use maxima::util::native::SafeParent; use maxima::util::registry::set_up_registry; use maxima::util::service::SERVICE_NAME; -use std::ffi::OsString; -use std::path::Path; -use std::sync::mpsc::{self, Receiver}; -use std::thread; -use std::time::Duration; use structured_logger::json::new_writer; +use tokio::sync::oneshot; use windows_service::service::{ ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, }; @@ -20,60 +26,223 @@ use windows_service::{ use crate::service::error::ServerError; use crate::service::hash::get_sha256_hash_of_pid; -use maxima::core::background_service::{ServiceLibraryInjectionRequest, BACKGROUND_SERVICE_PORT}; -use maxima::util::dll_injector::{DllInjector, InjectionError}; -use maxima::util::native::SafeParent; pub(crate) mod error; mod hash; define_windows_service!(ffi_service_main, service_main); +const START_TIMEOUT: Duration = Duration::from_secs(30); +const STOP_TIMEOUT: Duration = Duration::from_secs(30); +const SERVICE_POLL_INTERVAL: Duration = Duration::from_millis(250); +const ERROR_TIMEOUT: u32 = 1460; +const ERROR_UNEXPECTED_EXIT: u32 = 1; + +enum Event { + Bound, + Exited(std::io::Result<()>), +} + fn service_main(arguments: Vec) { - if let Err(_e) = bootstrap_service(arguments) { - // Handle error in some way. + if let Err(error) = bootstrap_service(arguments) { + error!("Service main failed: {error}"); + } +} + +fn service_status( + state: ServiceState, + controls_accepted: ServiceControlAccept, + exit_code: ServiceExitCode, + checkpoint: u32, + wait_hint: Duration, +) -> ServiceStatus { + ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted, + exit_code, + checkpoint, + wait_hint, + process_id: None, } } -fn bootstrap_service(_arguments: Vec) -> windows_service::Result<()> { - let (shutdown_tx, shutdown_rx) = mpsc::channel(); +fn start_pending_status() -> ServiceStatus { + service_status( + ServiceState::StartPending, + ServiceControlAccept::empty(), + ServiceExitCode::Win32(0), + 1, + START_TIMEOUT, + ) +} + +fn running_status() -> ServiceStatus { + service_status( + ServiceState::Running, + ServiceControlAccept::STOP, + ServiceExitCode::Win32(0), + 0, + Duration::default(), + ) +} + +fn stop_pending_status() -> ServiceStatus { + service_status( + ServiceState::StopPending, + ServiceControlAccept::empty(), + ServiceExitCode::Win32(0), + 1, + STOP_TIMEOUT, + ) +} + +fn stopped_status(exit_code: ServiceExitCode) -> ServiceStatus { + service_status( + ServiceState::Stopped, + ServiceControlAccept::empty(), + exit_code, + 0, + Duration::default(), + ) +} + +fn bootstrap_service(_arguments: Vec) -> Result<(), ServerError> { + let (shutdown_tx, shutdown_rx) = std_mpsc::channel::<()>(); + let (_tx, _rx) = std_mpsc::channel::(); + let (stop_tx, stop_rx) = oneshot::channel::<()>(); let event_handler = move |control_event| -> ServiceControlHandlerResult { match control_event { - ServiceControl::Stop | ServiceControl::Interrogate => { + ServiceControl::Stop => { let _ = shutdown_tx.send(()); ServiceControlHandlerResult::NoError } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, _ => ServiceControlHandlerResult::NotImplemented, } }; let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; - let next_status = ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP, - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, + status_handle.set_service_status(start_pending_status())?; + + let log_path = Path::new("C:/ProgramData/Maxima/Logs/MaximaBackgroundService.log"); + std::fs::create_dir_all(log_path.safe_parent()?)?; + let log_file = File::create(log_path)?; + + structured_logger::Builder::new() + .with_default_writer(new_writer(log_file)) + .init(); + + info!("Starting Background Service"); + + let _handle = thread::spawn(move || run_(_tx, stop_rx)); + + match _rx.recv_timeout(START_TIMEOUT) { + Ok(Event::Bound) => { + info!("HTTP server bound to 127.0.0.1:{BACKGROUND_SERVICE_PORT}"); + } + + Ok(Event::Exited(Err(error))) => { + error!("HTTP server failed before readiness: {error}"); + + status_handle.set_service_status(stopped_status(ServiceExitCode::Win32( + error.raw_os_error().unwrap_or(ERROR_UNEXPECTED_EXIT as i32) as u32, + )))?; + + let _ = _handle.join(); + return Err(error.into()); + } + + Ok(Event::Exited(Ok(()))) => { + error!("HTTP server stopped before it became ready"); + + status_handle.set_service_status(stopped_status(ServiceExitCode::Win32( + ERROR_UNEXPECTED_EXIT, + )))?; + + let _ = _handle.join(); + return Err(ServerError::HttpServerStopped); + } + + Err(std_mpsc::RecvTimeoutError::Timeout) => { + error!("Timed out waiting for HTTP server bind"); + + let _ = stop_tx.send(()); + let _ = _handle.join(); + + status_handle + .set_service_status(stopped_status(ServiceExitCode::Win32(ERROR_TIMEOUT)))?; + + return Err(ServerError::BindTimeout); + } + + Err(std_mpsc::RecvTimeoutError::Disconnected) => { + error!("HTTP server thread ended without reporting readiness"); + + let _ = _handle.join(); + + status_handle.set_service_status(stopped_status(ServiceExitCode::Win32( + ERROR_UNEXPECTED_EXIT, + )))?; + + return Err(ServerError::HttpServerStopped); + } + } + + status_handle.set_service_status(running_status())?; + + let (stopped_by_scm, exit_code) = loop { + match shutdown_rx.recv_timeout(SERVICE_POLL_INTERVAL) { + Ok(()) => break (true, ServiceExitCode::Win32(0)), + + Err(std_mpsc::RecvTimeoutError::Timeout) => match _rx.try_recv() { + Ok(Event::Exited(Ok(()))) => { + error!("HTTP server stopped unexpectedly"); + break (false, ServiceExitCode::Win32(ERROR_UNEXPECTED_EXIT)); + } + + Ok(Event::Exited(Err(error))) => { + error!("HTTP server failed unexpectedly: {error}"); + + let win32_code = + error.raw_os_error().unwrap_or(ERROR_UNEXPECTED_EXIT as i32) as u32; + + break (false, ServiceExitCode::Win32(win32_code)); + } + + Ok(Event::Bound) => { + error!("Received unexpected duplicate HTTP bound event"); + } + + Err(std_mpsc::TryRecvError::Empty) => {} + + Err(std_mpsc::TryRecvError::Disconnected) => { + error!("HTTP server event channel disconnected"); + break (false, ServiceExitCode::Win32(ERROR_UNEXPECTED_EXIT)); + } + }, + + Err(std_mpsc::RecvTimeoutError::Disconnected) => { + error!("Service control channel disconnected"); + break (false, ServiceExitCode::Win32(ERROR_UNEXPECTED_EXIT)); + } + } }; - status_handle.set_service_status(next_status)?; - run_service(shutdown_rx).expect("Failed to run service"); + if stopped_by_scm { + info!("Stopping Background Service after SCM stop request"); + } else { + error!("Stopping Background Service because the HTTP server exited unexpectedly"); + } - info!("Shutting down..."); + status_handle.set_service_status(stop_pending_status())?; - status_handle.set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Stopped, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - })?; + let _ = stop_tx.send(()); + let _ = _handle.join(); + + status_handle.set_service_status(stopped_status(exit_code))?; Ok(()) } @@ -81,81 +250,100 @@ fn bootstrap_service(_arguments: Vec) -> windows_service::Result<()> { #[get("/set_up_registry")] async fn req_set_up_registry() -> impl Responder { info!("Setting up registry"); - if let Err(err) = set_up_registry() { - return format!("Error: {}", err); + + if let Err(error) = set_up_registry() { + return format!("Error: {error}"); } - format!("Done") + "Done".to_owned() } pub fn inject_dll(pid: u32, dll_path: &str) -> Result<(), InjectionError> { - let injector = DllInjector::new(pid); - injector.inject(dll_path) + DllInjector::new(pid).inject(dll_path) } // This is for KYBER. Ideally this would be moved to a separate Kyber service, -// but it isn't a great user experience to have to install two windows services. -// We'll eventually find a better workaround and move this somewhere else. +// but it isn't a great user experience to have to install two Windows services. #[post("/inject_library")] -async fn req_inject_library(body: web::Bytes) -> Result { - info!("Injecting..."); +async fn req_inject_library(body: web::Bytes) -> Result { + info!("Injecting library"); - let req: ServiceLibraryInjectionRequest = serde_json::from_slice(&body)?; + let request: ServiceLibraryInjectionRequest = serde_json::from_slice(&body)?; + let hash = get_sha256_hash_of_pid(request.pid)?; - let hash_result = get_sha256_hash_of_pid(req.pid)?; - - // Ensure we're only injecting into STAR WARS Battlefront 2. Ideally we would check - // the hash of the dll as well, but there isn't a great way to do that since there - // are multiple release channels and dev builds. - if hex::encode(hash_result) - != "7880e40d79e981b064baaf06f10785601222c6e227a656b70112c24b1f82e2ce" - { - return Err(self::ServerError::InvalidInjectionTarget); + if hex::encode(hash) != "7880e40d79e981b064baaf06f10785601222c6e227a656b70112c24b1f82e2ce" { + return Err(ServerError::InvalidInjectionTarget); } - inject_dll(req.pid, &req.path)?; + inject_dll(request.pid, &request.path)?; Ok(HttpResponse::Ok().body("Injected")) } -fn run_service(shutdown_rx: Receiver<()>) -> Result<(), self::ServerError> { - let log_path = Path::new("C:/ProgramData/Maxima/Logs/MaximaBackgroundService.log"); - std::fs::create_dir_all(log_path.safe_parent()?)?; - let log_file = File::create(log_path)?; +// Replaces the current touchup techniques using Maxima's service to leverage administrative privileges for file access. +// sheesh + sheesh = omegasheesh +#[post("/touchup")] +async fn req_touchup(body: web::Bytes) -> Result { + info!("Running touchup"); - structured_logger::Builder::new() - .with_default_writer(new_writer(log_file)) - .init(); + let request: ServiceTouchupRequest = serde_json::from_slice(&body)?; + let manifest = maxima::core::manifest::load_manifest_from_disk( + Path::new(&request.output_dir).join(maxima::core::manifest::MANIFEST_RELATIVE_PATH), + ) + .await?; + manifest + .run_touchup(&PathBuf::from(request.output_dir), None) + .await?; - info!("Started Background Service"); - - thread::spawn(|| { - actix_web::rt::System::new() - .block_on(async || -> std::io::Result<()> { - use actix_web::{App, HttpServer}; - - let _ = HttpServer::new(|| { - App::new() - .service(req_set_up_registry) - .service(req_inject_library) - }) - .bind(("127.0.0.1", BACKGROUND_SERVICE_PORT))? - .run() - .await; - Ok(()) - }()) - .expect(&format!( - "Failed to bind to 127.0.0.1:{}", - BACKGROUND_SERVICE_PORT - )); - }); + Ok(HttpResponse::Ok().body("Done")) +} - let _ = shutdown_rx.recv(); +fn run_(_tx: std_mpsc::Sender, stop_rx: oneshot::Receiver<()>) { + actix_web::rt::System::new().block_on(async move { + use actix_web::{App, HttpServer}; - Ok(()) + let http_server = match HttpServer::new(|| { + App::new() + .service(req_set_up_registry) + .service(req_inject_library) + .service(req_touchup) + }) + .bind(("127.0.0.1", BACKGROUND_SERVICE_PORT)) + { + Ok(server) => server, + Err(error) => { + let _ = _tx.send(Event::Exited(Err(error))); + return; + } + }; + + let server = http_server.run(); + let handle = server.handle(); + tokio::pin!(server); + + if _tx.send(Event::Bound).is_err() { + handle.stop(true).await; + let _ = (&mut server).await; + return; + } + + tokio::select! { + result = &mut server => { + let _ = _tx.send(Event::Exited(result)); + } + + _ = stop_rx => { + info!("Stopping HTTP server"); + handle.stop(true).await; + + let result = (&mut server).await; + let _ = _tx.send(Event::Exited(result)); + } + } + }); } -pub fn start_service() -> Result<(), self::ServerError> { - service_dispatcher::start("MaximaBackgroundService", ffi_service_main)?; +pub fn start_service() -> Result<(), ServerError> { + service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; Ok(()) } diff --git a/maxima-tui/Cargo.toml b/maxima-tui/Cargo.toml index d468b23c..e27885d3 100644 --- a/maxima-tui/Cargo.toml +++ b/maxima-tui/Cargo.toml @@ -1,30 +1,24 @@ [package] name = "maxima-tui" -version = "0.1.0" -authors = ["Sean Kahler "] -edition = "2021" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } [dependencies] maxima-lib = { path = "../maxima-lib" } -clap = { version = "4.3.5", features = [ "derive" ] } -tokio = { version = "1.28.2", features = [ "full", "rt", "rt-multi-thread", "time", "net" ] } -serde = { version = "1.0.164", features = [ "derive" ] } -serde_json = "1.0.97" -anyhow = "1.0.71" -lazy_static = "1.4.0" -base64 = "0.21.2" -log = "0.4.19" -regex = "1.8.4" -inquire = "0.6.2" -futures = "0.3.30" -ratatui = "0.27.0" -strum = "0.26.0" -color-eyre = "0.6.3" +clap = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +log = { workspace = true } +regex = { workspace = true } +inquire = { workspace = true } +ratatui = "0.30.2" +strum = { workspace = true } +color-eyre = "0.6.5" +tokio-stream = "0.1.19" [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi" ] } -winreg = "0.50.0" -is_elevated = "0.1.2" +is_elevated = { workspace = true } [build-dependencies] maxima-resources = { path = "../maxima-resources" } diff --git a/maxima-tui/src/main.rs b/maxima-tui/src/main.rs index f8eea414..c5428490 100644 --- a/maxima-tui/src/main.rs +++ b/maxima-tui/src/main.rs @@ -1,17 +1,9 @@ -use clap::{Parser, Subcommand}; - -use futures::StreamExt; -use inquire::Select; -use lazy_static::lazy_static; -use log::{debug, error, info, warn}; +use log::{error, info, warn}; use regex::Regex; use service::{BridgeThread, MaximaLibRequest, MaximaLibResponse}; +use tokio_stream::StreamExt; -use std::{ - io::stdout, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{io::stdout, sync::LazyLock, time::Duration}; #[cfg(windows)] use is_elevated::is_elevated; @@ -22,49 +14,25 @@ use maxima::{ util::service::{is_service_running, is_service_valid, register_service_user, start_service}, }; +use maxima::core::{LockedMaxima, launch::LaunchMode}; use maxima::{ - content::downloader::ZipDownloader, core::{ - auth::{nucleus_token_exchange, TokenResponse}, - clients::JUNO_PC_CLIENT_ID, - launch::LaunchMode, - library::OwnedTitle, - service_layer::{ - ServiceGetBasicPlayerRequestBuilder, ServiceGetLegacyCatalogDefsRequestBuilder, - ServiceLegacyOffer, ServicePlayer, SERVICE_REQUEST_GETBASICPLAYER, - SERVICE_REQUEST_GETLEGACYCATALOGDEFS, - }, - LockedMaxima, MaximaOptionsBuilder, - }, - ooa, - rtm::client::BasicPresence, -}; -use maxima::{ - content::ContentService, - core::{ - auth::{ - context::AuthContext, - login::{begin_oauth_login_flow, manual_login}, - nucleus_auth_exchange, - }, + MaximaEvent, launch::{self, LaunchOptions}, - service_layer::ServiceUserGameProduct, - Maxima, MaximaEvent, }, - util::{log::init_logger, native::take_foreground_focus, registry::check_registry_validity}, + util::{native::take_foreground_focus, registry::check_registry_validity}, }; -lazy_static! { - static ref MANUAL_LOGIN_PATTERN: Regex = Regex::new(r"^(.*):(.*)$").unwrap(); -} +static MANUAL_LOGIN_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^(.*):(.*)$").expect("manual login regex should be valid")); -use anyhow::{bail, Result}; +use anyhow::Result; use color_eyre::config::HookBuilder; use ratatui::{ crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }, prelude::*, style::palette::tailwind, @@ -112,7 +80,10 @@ impl App { } fn run(&mut self, terminal: &mut Terminal) -> Result<()> { - self.bridge.tx.send(MaximaLibRequest::LoginRequest).unwrap(); + let tx = self.bridge.tx.clone(); + tokio::spawn(async move { + tx.send(MaximaLibRequest::LoginRequest).await.ok(); + }); while self.state == AppState::Running { self.draw(terminal)?; @@ -124,7 +95,9 @@ impl App { } fn draw(&self, terminal: &mut Terminal) -> Result<()> { - terminal.draw(|frame| frame.render_widget(self, frame.size()))?; + terminal + .draw(|frame| frame.render_widget(self, frame.area())) + .unwrap(); Ok(()) } @@ -133,15 +106,15 @@ impl App { return Ok(()); } - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - use KeyCode::*; - match key.code { - Char('l') | Right => self.next_tab(), - Char('h') | Left => self.previous_tab(), - Char('q') | Esc => self.quit(), - _ => {} - } + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + use KeyCode::*; + match key.code { + Char('l') | Right => self.next_tab(), + Char('h') | Left => self.previous_tab(), + Char('q') | Esc => self.quit(), + _ => {} } } Ok(()) @@ -291,7 +264,13 @@ impl Widget for SelectedTab { impl SelectedTab { /// Return tab's name as a styled `Line` fn title(self) -> Line<'static> { - format!(" {self} ") + let title = match self { + Self::Games => "Games", + Self::Downloads => "Downloads", + Self::Settings => "Settings", + }; + + format!(" {title} ") .fg(tailwind::SLATE.c200) .bg(self.palette().c900) .into() @@ -338,7 +317,7 @@ async fn main() { if let Some(e) = result.err() { match std::env::var("RUST_BACKTRACE") { - Ok(_) => error!("{}:\n{}", e, e.backtrace().to_string()), + Ok(_) => error!("{}:\n{}", e, e.backtrace()), Err(_) => error!("{}", e), } } @@ -470,9 +449,8 @@ async fn start_game( let mut maxima = maxima_arc.lock().await; for event in maxima.consume_pending_events() { - match event { - MaximaEvent::ReceivedLSXRequest(_pid, _request) => (), - _ => {} + if let MaximaEvent::ReceivedLSXRequest(_pid, _request) = event { + () } } diff --git a/maxima-tui/src/service.rs b/maxima-tui/src/service.rs index 412ffa24..bacf8780 100644 --- a/maxima-tui/src/service.rs +++ b/maxima-tui/src/service.rs @@ -1,12 +1,12 @@ -use std::sync::mpsc::{self, Receiver, Sender}; +use tokio::sync::mpsc::{self, Receiver, Sender}; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use log::info; use maxima::core::{ + LockedMaxima, Maxima, MaximaOptionsBuilder, auth::{ - context::AuthContext, login::begin_oauth_login_flow, nucleus_token_exchange, TokenResponse, + TokenResponse, context::AuthContext, login::begin_oauth_login_flow, nucleus_token_exchange, }, - LockedMaxima, Maxima, MaximaOptionsBuilder, }; pub struct InteractThreadLoginResponse { @@ -43,27 +43,27 @@ pub struct BridgeThread { impl BridgeThread { pub fn new() -> Self { - let (tx0, rx1) = mpsc::channel(); - let (tx1, rx0) = mpsc::channel(); + let (tx0, rx1) = mpsc::channel(1); + let (tx1, rx0) = mpsc::channel(1); tokio::task::spawn(async move { - let die_fallback_transmitter = tx1.clone(); - //panic::set_hook(Box::new( |_| {})); - let result = BridgeThread::run(rx1, tx1).await; - if result.is_err() { - die_fallback_transmitter - .send(MaximaLibResponse::InteractionThreadDiedResponse) - .unwrap(); - panic!("Interact thread failed! {}", result.err().unwrap()); - } else { - info!("Interact thread shut down") + let die_fallback = tx1.clone(); + + if let Err(err) = BridgeThread::run(rx1, tx1).await { + let _ = die_fallback.send(MaximaLibResponse::InteractionThreadDiedResponse); + panic!("Interact thread failed! {err}"); } + + info!("Interact thread shut down"); }); Self { rx: rx0, tx: tx0 } } - async fn run(rx1: Receiver, tx1: Sender) -> Result<()> { + async fn run( + mut rx1: Receiver, + tx1: Sender, + ) -> Result<()> { let maxima_arc: LockedMaxima = Maxima::new_with_options( MaximaOptionsBuilder::default() .dummy_local_user(false) @@ -74,93 +74,88 @@ impl BridgeThread { { let maxima = maxima_arc.lock().await; - if maxima.start_lsx(maxima_arc.clone()).await.is_ok() { - info!("LSX started"); - } else { + + let Ok(()) = maxima.start_lsx(maxima_arc.clone()).await else { info!("LSX failed to start!"); - } + return Ok(()); + }; + info!("LSX started"); - let mut auth_storage = maxima.auth_storage().lock().await; - let logged_in = auth_storage.logged_in().await?; - if logged_in { - drop(auth_storage); + let logged_in = { + let mut auth_storage = maxima.auth_storage().lock().await; + auth_storage.logged_in().await? + }; + if logged_in { let user = maxima.local_user().await?; let message = MaximaLibResponse::LoginResponse(InteractThreadLoginResponse { success: true, name: user.player().as_ref().unwrap().display_name().to_owned(), }); - - tx1.send(message)?; + tx1.send(message).await.ok(); } else { - tx1.send(MaximaLibResponse::LoginCacheEmpty)?; + tx1.send(MaximaLibResponse::LoginCacheEmpty).await.ok(); } } - 'outer: loop { - let request = rx1.try_recv(); - if request.is_err() { + 'main: loop { + let Ok(request) = rx1.try_recv() else { continue; - } + }; - match request? { + let _result: Result<()> = match request { MaximaLibRequest::LoginRequest => { - let channel = tx1.clone(); - let maxima = maxima_arc.clone(); - async move { - let maxima = maxima.lock().await; - + let login = async || { + let maxima = maxima_arc.lock().await; let mut auth_storage = maxima.auth_storage().lock().await; - let logged_in = auth_storage.logged_in().await?; - if !logged_in { - let res = login_flow().await.unwrap(); - maxima.auth_storage().lock().await.add_account(&res); - }; - - channel - .send(MaximaLibResponse::LoginResponse( - InteractThreadLoginResponse { - success: true, - name: maxima - .local_user() - .await? - .player() - .as_ref() - .unwrap() - .display_name() - .to_owned(), - }, - )) - .unwrap(); - - Ok::<(), anyhow::Error>(()) - } - .await?; + + if !auth_storage.logged_in().await? { + let res = login_flow().await?; + auth_storage.add_account(&res).await?; + } + + tx1.send(MaximaLibResponse::LoginResponse( + InteractThreadLoginResponse { + success: true, + name: maxima + .local_user() + .await? + .player() + .as_ref() + .unwrap() + .display_name() + .to_owned(), + }, + )) + .await + .ok(); + + Ok(()) + }; + login().await } + MaximaLibRequest::GetGamesRequest => { - let channel = tx1.clone(); - let maxima = maxima_arc.clone(); + todo!("GetGamesRequest") } MaximaLibRequest::GetFriendsRequest => { - let channel = tx1.clone(); - let maxima = maxima_arc.clone(); + todo!("GetFriendsRequest") } - MaximaLibRequest::GetGameImagesRequest(slug) => { - let channel = tx1.clone(); - let maxima = maxima_arc.clone(); + MaximaLibRequest::GetGameImagesRequest(_) => { + todo!("GetGameImagesRequest") } - MaximaLibRequest::GetUserAvatarRequest(id, url) => { - let channel = tx1.clone(); + MaximaLibRequest::GetUserAvatarRequest(_, _) => { + todo!("GetUserAvatarRequest") } - MaximaLibRequest::GetGameDetailsRequest(slug) => { - let channel = tx1.clone(); - let maxima = maxima_arc.clone(); + MaximaLibRequest::GetGameDetailsRequest(_) => { + todo!("GetGameDetailsRequest") } - MaximaLibRequest::StartGameRequest(offer_id, hardcode) => { - //start_game_request(maxima_arc.clone(), offer_id.clone(), hardcode).await; + MaximaLibRequest::StartGameRequest(_, _) => { + todo!("StartGameRequest") } - MaximaLibRequest::ShutdownRequest => break 'outer Ok(()), - } + + MaximaLibRequest::ShutdownRequest => break 'main Ok(()), + }; } } } @@ -177,7 +172,7 @@ pub async fn login_flow() -> Result { let token_res = nucleus_token_exchange(&auth_context).await; if token_res.is_err() { - bail!("Login failed: {}", token_res.err().unwrap().to_string()); + bail!("Login failed: {}", token_res.err().unwrap()); } Ok(token_res?) diff --git a/maxima-ui/Cargo.toml b/maxima-ui/Cargo.toml index 702e9c08..ddfde8bf 100644 --- a/maxima-ui/Cargo.toml +++ b/maxima-ui/Cargo.toml @@ -2,34 +2,33 @@ name = "maxima-ui" version = "0.1.0" authors = ["Nick Whelan "] -edition = "2021" +edition = "2024" [dependencies] maxima-lib = { path = "../maxima-lib" } -winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi", "psapi", "wininet", "winerror", "iphlpapi", "tcpmib" ] } -anyhow = "1.0.71" -clap = { version = "4.3.8", features = ["derive"] } -eframe = { version = "0.28.0", features = [ "persistence" ] } -egui = "0.28.0" -egui_extras = {version = "0.28.0", features = [ "svg", "all_loaders" ] } -image = { version = "0.24.6", features = [ "png", "jpeg" ] } -reqwest = { version = "0.11.18", features = ["stream", "rustls-tls"], default-features = false } -serde = "1.0.171" -serde_json = "1.0.100" -tokio = { version = "1.28.2", features = ["full"] } -log = "0.4.19" -egui_demo_lib = "0.28.0" -puffin_http = "0.15.0" -puffin = "0.18.1" -strum = "0.25.0" -strum_macros = "0.25.3" -sys-locale = "0.3.1" -humansize = { version = "2.0.0", features = ["no_alloc"] } -fuzzy-matcher = "*" -thiserror = "2.0.12" +anyhow = { workspace = true } +clap = { workspace = true } +eframe = { version = "0.36.1", default-features = false, features = [ "persistence", "glow", "wayland", "x11" ] } +egui = "0.36.1" +egui_extras = {version = "0.36.1", features = [ "svg", "all_loaders" ] } +image = { version = "0.25.10", features = [ "png", "jpeg" ] } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +log = { workspace = true } +puffin_http = "0.17.0" +puffin = "0.20.0" +rustls = { workspace = true } +strum = { workspace = true } +strum_macros = { workspace = true } +sys-locale = "0.3.2" +humansize = { version = "2.1.3", features = ["no_alloc"] } +fuzzy-matcher = "0.3.7" +thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] -is_elevated = "0.1.2" +is_elevated = { workspace = true } [build-dependencies] maxima-resources = { path = "../maxima-resources" } diff --git a/maxima-ui/res/locale/en_us.json b/maxima-ui/res/locale/en_us.json index 22d19a29..d4f85b12 100644 --- a/maxima-ui/res/locale/en_us.json +++ b/maxima-ui/res/locale/en_us.json @@ -94,7 +94,8 @@ }, "game_installation" : { "header": "Game Installation", - "default_folder": "Default installation folder", + "default_folder": "Default installation folder", + "default_wine_prefix": "Default Wine prefix folder", "ignore_ood_warning": "Ignore out-of-date game launch warning" }, "performance" : { @@ -104,7 +105,8 @@ }, "locale" : { "default": "System Default", - "en_us": "English" + "en_us": "English", + "fr_fr": "French" }, "modals" : { "close": "Close", diff --git a/maxima-ui/res/locale/fr_fr.json b/maxima-ui/res/locale/fr_fr.json new file mode 100644 index 00000000..70722cd4 --- /dev/null +++ b/maxima-ui/res/locale/fr_fr.json @@ -0,0 +1,142 @@ +{ + "errors" : { + "view_not_impl" : "Vue non implémentée", + "view_coming_soon": "Bientôt disponible !", + "critical_thread_crashed" : "Un thread critique en arrière-plan a planté, et l'application ne peut pas continuer à fonctionner." + }, + "startup_flow": { + "starting": "Démarrage...", + "logging_in": "Connexion en cours...", + "login_header": "Vous n'êtes pas connecté.", + "login_button": "Se connecter avec EA", + "service_installer_header": "Configuration du service requise", + "service_installer_description": "Maxima doit installer un service Windows pour effectuer certaines tâches en arrière-plan. Vous serez invité à fournir des droits d'administrateur.", + "service_installer_button": "Installer le service" + }, + "menubar" : { + "games" : "Jeux", + "store" : "Boutique", + "friends" : "Amis", + "settings" : "Paramètres", + "downloads" : "Téléchargements" + }, + "profile_menu" : { + "view_profile" : "Voir le profil", + "view_wishlist" : "Voir la liste de souhaits" + }, + "games_view" : { + "toolbar" : { + "genre_filter" : "Afficher", + "genre_options" : { + "all" : "Tous les genres", + "shooter" : "Tireur", + "simulation" : "Simulation" + }, + "platform_filter" : "Plateforme" , + "platform_options" : { + "all" : "Toutes les plateformes", + "windows" : "Windows", + "mac" : "Mac" + }, + "search_bar_hint" : "Rechercher des jeux", + "running_suffix" : "En cours", + "out_of_date_suffix" : "Mise à jour disponible" + }, + "main" : { + "play" : "▶ Jouer", + "stop" : "⏹ Arrêter", + "pause" : "⏸ Pause", + "resume" : "➡ Reprendre", + "install": "⮋ Installer", + "uninstall" : "⛼ Désinstaller", + "settings" : "⛭ Paramètres", + "playtime" : "Temps de jeu", + "achievements" : "Succès", + "no_loaded_games" : "Vous n'avez aucun jeu, ou ils ne sont pas encore chargés." + }, + "details" : { + "min_system_req" : "Configuration minimale requise", + "rec_system_req" : "Configuration recommandée" + } + }, + "friends_view" : { + "toolbar" : { + "online" : "En ligne", + "all" : "Tous", + "pending" : "En attente", + "blocked" : "Bloqués", + "add_friend" : "Ajouter un ami", + "filter_options" : { + "name" : "Nom", + "game" : "Jeu" + }, + "search_hint" : "Rechercher des amis" + }, + "friend_actions" : { + "profile" : "Profil", + "chat" : "Discussion", + "unfriend" : "Supprimer" + }, + "status" : { + "unknown" : "Inconnu", + "do_not_disturb" : "Ne pas déranger", + "away" : "Absent", + "online" : "En ligne", + "offline" : "Hors ligne", + "presence_basic" : "Sur {game}", + "presence_rich" : "Sur {game} : {rich}" + } + }, + "settings_view": { + "interface" : { + "header": "Interface", + "language": "Langue" + }, + "game_installation" : { + "header": "Installation du jeu", + "default_folder": "Dossier d'installation par défaut", + "default_wine_prefix": "Préfixe Wine par défaut", + "ignore_ood_warning": "Ignorer l'avertissement de lancement de jeu obsolète" + }, + "performance" : { + "header": "Performance", + "disable_blur": "Désactiver les effets de flou" + } + }, + "locale" : { + "default": "Par défaut du système", + "en_us": "Anglais", + "fr_fr": "Français" + }, + "modals" : { + "close": "Fermer", + "game_install" : { + "header": "Installer {game}", + "locate_installed": "Localiser une installation existante du jeu", + "locate_action": "Localiser", + "locate_in_progress": "Localisation en cours...", + "locate_failed": "Échec de la localisation.", + "fresh_download": "Installer une nouvelle copie", + "fresh_path_confirmation": "Le jeu sera installé à :", + "fresh_path_invalid": "Chemin invalide", + "fresh_action": "Installer" + }, + "game_settings" : { + "header": "Paramètres du jeu pour {game}", + "not_installed": "Le jeu n'est pas installé", + "cloud_saves": "Sauvegardes dans le cloud", + "launch_arguments": "Arguments de lancement", + "executable_override": "Remplacement de l'exécutable", + "uninstall": "Désinstaller", + "version": "Version {version}" + }, + "game_launch_out_of_date" : { + "header" : "Lancement d'un jeu obsolète", + "warning" : "Vous lancez une version plus ancienne de {gamename} que la version actuelle. Les fonctionnalités en ligne peuvent ne pas être disponibles.", + "really_warning" : "{gamename} a demandé que les mises à jour soient obligatoires. Vous pouvez choisir d'ignorer cela, mais procédez avec prudence. Lancez à vos risques et périls, et sauvegardez vos données.", + "comparison": "Installé : {local}, Dernier : {online}", + "ok_i_get_it" : "Ne plus m'avertir", + "launch" : "Lancer quand même" + } + } +} diff --git a/maxima-ui/src/bridge/game_details.rs b/maxima-ui/src/bridge/game_details.rs index 3edcda31..a889584e 100644 --- a/maxima-ui/src/bridge/game_details.rs +++ b/maxima-ui/src/bridge/game_details.rs @@ -1,29 +1,29 @@ use egui::Context; -use std::sync::mpsc::Sender; +use tokio::sync::mpsc::UnboundedSender; use crate::{ + GameDetails, bridge_thread::{BackendError, InteractThreadGameDetailsResponse, MaximaLibResponse}, util::markdown::html_to_easymark, - GameDetails, }; use maxima::core::{ + LockedMaxima, service_layer::{ - ServiceGameSystemRequirements, ServiceGameSystemRequirementsRequestBuilder, - SERVICE_REQUEST_GAMESYSTEMREQUIREMENTS, + SERVICE_REQUEST_GAMESYSTEMREQUIREMENTS, ServiceGameSystemRequirements, + ServiceGameSystemRequirementsRequestBuilder, }, - LockedMaxima, }; pub async fn game_details_request( maxima_arc: LockedMaxima, slug: String, - channel: Sender, + channel: UnboundedSender, ctx: &Context, ) -> Result<(), BackendError> { let maxima = maxima_arc.lock().await; let rq = maxima.service_layer().request( - SERVICE_REQUEST_GAMESYSTEMREQUIREMENTS, + &SERVICE_REQUEST_GAMESYSTEMREQUIREMENTS, ServiceGameSystemRequirementsRequestBuilder::default() .slug(slug.clone()) .locale(maxima.locale().short_str().to_owned()) @@ -32,8 +32,6 @@ pub async fn game_details_request( ); let rq: ServiceGameSystemRequirements = rq.await?; - //TODO: parse async - let (min, rec) = if rq.system_requirements().len() >= 1 { ( Some(html_to_easymark(rq.system_requirements()[0].minimum())), @@ -54,7 +52,7 @@ pub async fn game_details_request( system_requirements_rec: rec, }, }); - let _ = channel.send(res); + channel.send(res).ok(); egui::Context::request_repaint(&ctx); Ok(()) } diff --git a/maxima-ui/src/bridge/get_friends.rs b/maxima-ui/src/bridge/get_friends.rs index 86b6648b..9d38eda6 100644 --- a/maxima-ui/src/bridge/get_friends.rs +++ b/maxima-ui/src/bridge/get_friends.rs @@ -1,7 +1,7 @@ use egui::Context; use log::debug; use maxima::{core::LockedMaxima, rtm::client::BasicPresence}; -use std::sync::mpsc::Sender; +use tokio::sync::mpsc::UnboundedSender; use crate::{ bridge_thread::{BackendError, InteractThreadFriendListResponse, MaximaLibResponse}, @@ -11,8 +11,8 @@ use crate::{ pub async fn get_friends_request( maxima_arc: LockedMaxima, - channel: Sender, - remote_provider_channel: Sender, + channel: UnboundedSender, + remote_provider_channel: UnboundedSender, ctx: &Context, ) -> Result<(), BackendError> { debug!("received request to load friends"); @@ -24,10 +24,12 @@ pub async fn get_friends_request( let friends = maxima.friends(0).await?; for friend in friends { - remote_provider_channel.send(UIImageCacheLoaderCommand::ProvideRemote( - crate::ui_image::UIImageType::Avatar(friend.id().to_string()), - friend.avatar().as_ref().unwrap().medium().path().to_string(), - ))?; + remote_provider_channel + .send(UIImageCacheLoaderCommand::ProvideRemote( + crate::ui_image::UIImageType::Avatar(friend.id().to_string()), + friend.avatar().as_ref().unwrap().medium().path().to_string(), + )) + .ok(); let friend_info = UIFriend { name: friend.display_name().to_string(), id: friend.id().to_string(), @@ -39,7 +41,7 @@ pub async fn get_friends_request( let res = MaximaLibResponse::FriendInfoResponse(InteractThreadFriendListResponse { friend: friend_info, }); - channel.send(res)?; + channel.send(res).ok(); ctx.request_repaint(); } diff --git a/maxima-ui/src/bridge/get_games.rs b/maxima-ui/src/bridge/get_games.rs index f2a62cae..c89d3e23 100644 --- a/maxima-ui/src/bridge/get_games.rs +++ b/maxima-ui/src/bridge/get_games.rs @@ -1,22 +1,22 @@ use crate::{ + GameDetailsWrapper, GameInfo, GameSettings, GameVersionInfo, bridge_thread::{BackendError, InteractThreadGameListResponse, MaximaLibResponse}, ui_image::UIImageCacheLoaderCommand, - GameDetailsWrapper, GameInfo, GameVersionInfo, }; use egui::Context; use log::{debug, error, info}; use maxima::{ core::{ + LockedMaxima, service_layer::{ - ServiceGame, ServiceGameHub, ServiceGameHubCollection, ServiceGameImagesRequestBuilder, + SERVICE_REQUEST_GAMEIMAGES, SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, ServiceGame, + ServiceGameHubCollection, ServiceGameImagesRequestBuilder, ServiceHeroBackgroundImageRequestBuilder, ServiceLayerClient, - SERVICE_REQUEST_GAMEIMAGES, SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, }, - LockedMaxima, }, util::native::maxima_dir, }; -use std::{fs, sync::mpsc::Sender}; +use tokio::sync::mpsc::UnboundedSender; fn get_preferred_bg_hero(heroes: &Option) -> Option { let heroes = match heroes { @@ -103,7 +103,7 @@ async fn handle_images( has_hero: bool, has_logo: bool, has_background: bool, - channel: Sender, + channel: UnboundedSender, service_layer: ServiceLayerClient, ) -> Result<(), BackendError> { debug!("handling image downloads for {}", &slug); @@ -112,7 +112,7 @@ async fn handle_images( } else { Some( service_layer.request( - SERVICE_REQUEST_GAMEIMAGES, + &SERVICE_REQUEST_GAMEIMAGES, ServiceGameImagesRequestBuilder::default() .should_fetch_context_image(!has_logo) .should_fetch_backdrop_images(!has_hero) @@ -127,7 +127,7 @@ async fn handle_images( } else { Some( service_layer.request( - SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, + &SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, ServiceHeroBackgroundImageRequestBuilder::default() .game_slug(slug.clone()) .locale(locale.clone()) @@ -144,23 +144,29 @@ async fn handle_images( if !has_hero { if let Some(hero) = get_preferred_hero_image(&images_0).await { - channel.send(UIImageCacheLoaderCommand::ProvideRemote( - crate::ui_image::UIImageType::Hero(slug.clone()), - hero, - ))? + channel + .send(UIImageCacheLoaderCommand::ProvideRemote( + crate::ui_image::UIImageType::Hero(slug.clone()), + hero, + )) + .ok(); } } if !has_logo { if let Some(logo) = get_logo_image(&images_0) { - channel.send(UIImageCacheLoaderCommand::ProvideRemote( - crate::ui_image::UIImageType::Logo(slug.clone()), - logo, - ))? + channel + .send(UIImageCacheLoaderCommand::ProvideRemote( + crate::ui_image::UIImageType::Logo(slug.clone()), + logo, + )) + .ok(); } else { - channel.send(UIImageCacheLoaderCommand::Stub( - crate::ui_image::UIImageType::Logo(slug.clone()), - ))? + channel + .send(UIImageCacheLoaderCommand::Stub( + crate::ui_image::UIImageType::Logo(slug.clone()), + )) + .ok(); } } @@ -174,10 +180,12 @@ async fn handle_images( if !has_background { if let Some(background_image) = get_preferred_bg_hero(&images_1) { - channel.send(UIImageCacheLoaderCommand::ProvideRemote( - crate::ui_image::UIImageType::Background(slug), - background_image, - ))? + channel + .send(UIImageCacheLoaderCommand::ProvideRemote( + crate::ui_image::UIImageType::Background(slug), + background_image, + )) + .ok(); } } @@ -186,30 +194,41 @@ async fn handle_images( pub async fn get_games_request( maxima_arc: LockedMaxima, - channel: Sender, - channel1: Sender, + channel: UnboundedSender, + channel1: UnboundedSender, ctx: &Context, ) -> Result<(), BackendError> { debug!("received request to load games"); - let mut maxima = maxima_arc.lock().await; - let service_layer = maxima.service_layer().clone(); - let locale = maxima.locale().short_str().to_owned(); - let logged_in = maxima.auth_storage().lock().await.current().is_some(); - if !logged_in { - return Err(BackendError::LoggedOut); - } - let owned_games = maxima.mut_library().games().await?; + let (service_layer, locale, owned_games) = { + let mut maxima = maxima_arc.lock().await; + let service_layer = maxima.service_layer().clone(); + let locale = maxima.locale().short_str().to_owned(); + let logged_in = maxima.auth_storage().lock().await.current().is_some(); + if !logged_in { + return Err(BackendError::LoggedOut); + } + let owned_games = maxima.mut_library().games().await?.clone(); + (service_layer, locale, owned_games) + }; 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] } else { - downloads.iter().find(|item| item.download_type() == "LIVE").unwrap() + match downloads.iter().find(|item| item.download_type() == "LIVE") { + Some(item) => item, + None => { + error!( + "Cannot find LIVE download for game with multiple downloads: {}", + &slug + ); + continue; + } + } }; let version = if let Ok(version) = game.base_offer().installed_version().await { @@ -232,25 +251,32 @@ pub async fn get_games_request( installed: game.base_offer().is_installed().await, 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, - settings, - }); - channel.send(res)?; + + channel + .send(MaximaLibResponse::GameInfoResponse( + InteractThreadGameListResponse { + game: game_info, + settings, + }, + )) + .ok(); let bg = maxima_dir()?.join("cache/ui/images/").join(&slug).join("background.jpg"); let game_hero = maxima_dir()?.join("cache/ui/images/").join(&slug).join("hero.jpg"); let game_logo = maxima_dir()?.join("cache/ui/images/").join(&slug).join("logo.png"); - let has_hero = fs::metadata(&game_hero).is_ok(); - let has_logo = fs::metadata(&game_logo).is_ok(); - let has_background = fs::metadata(&bg).is_ok(); + + let (has_hero, has_logo, has_background) = tokio::join!( + async { tokio::fs::metadata(&game_hero).await.is_ok() }, + async { tokio::fs::metadata(&game_logo).await.is_ok() }, + async { tokio::fs::metadata(&bg).await.is_ok() }, + ); if !has_hero || !has_logo || !has_background { //we're like 20 tasks deep i swear but this shit's gonna be real fast, trust @@ -259,7 +285,7 @@ pub async fn get_games_request( let channel_send = channel1.clone(); let service_layer_send = service_layer.clone(); tokio::task::spawn(async move { - handle_images( + let _ = handle_images( slug_send, locale_send, has_hero, @@ -268,7 +294,7 @@ pub async fn get_games_request( channel_send, service_layer_send, ) - .await + .await; }); tokio::task::yield_now().await; } diff --git a/maxima-ui/src/bridge/login_oauth.rs b/maxima-ui/src/bridge/login_oauth.rs index 9813ee9d..2882b748 100644 --- a/maxima-ui/src/bridge/login_oauth.rs +++ b/maxima-ui/src/bridge/login_oauth.rs @@ -2,17 +2,17 @@ use crate::bridge_thread::{BackendError, InteractThreadLoginResponse, MaximaLibR use egui::Context; use maxima::{ core::{ + LockedMaxima, auth::{context::AuthContext, login, nucleus_token_exchange}, service_layer::ServiceLayerError, - LockedMaxima, }, util::native::take_foreground_focus, }; -use std::sync::mpsc::Sender; +use tokio::sync::mpsc::UnboundedSender; pub async fn login_oauth( maxima_arc: LockedMaxima, - channel: Sender, + channel: UnboundedSender, ctx: &Context, ) -> Result<(), BackendError> { let maxima = maxima_arc.lock().await; @@ -30,7 +30,7 @@ pub async fn login_oauth( you: user.player().as_ref().ok_or(ServiceLayerError::MissingField)?.to_owned(), })); - channel.send(message)?; + channel.send(message).ok(); take_foreground_focus()?; ctx.request_repaint(); diff --git a/maxima-ui/src/bridge/start_game.rs b/maxima-ui/src/bridge/start_game.rs index e5742e6c..25dec6cc 100644 --- a/maxima-ui/src/bridge/start_game.rs +++ b/maxima-ui/src/bridge/start_game.rs @@ -1,8 +1,8 @@ -use crate::{bridge_thread::BackendError, GameInfo, GameSettings}; -use log::{debug, error, info}; +use crate::{GameInfo, GameSettings}; +use log::{debug, info}; use maxima::core::{ - launch::{self, LaunchError, LaunchMode, LaunchOptions}, LockedMaxima, + launch::{self, LaunchError, LaunchMode, LaunchOptions}, }; pub async fn start_game_request( diff --git a/maxima-ui/src/bridge_processor.rs b/maxima-ui/src/bridge_processor.rs index 9a98c035..41b572f5 100644 --- a/maxima-ui/src/bridge_processor.rs +++ b/maxima-ui/src/bridge_processor.rs @@ -1,10 +1,10 @@ use crate::{ + BackendStallState, GameDetails, GameDetailsWrapper, MaximaEguiApp, bridge_thread::{self, BackendError}, views::downloads_view::QueuedDownload, - BackendStallState, GameDetails, GameDetailsWrapper, MaximaEguiApp, }; -use log::{error, info, warn}; -use std::sync::mpsc::TryRecvError; +use log::{info, warn}; +use tokio::sync::mpsc::error::TryRecvError; pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { puffin::profile_function!(); @@ -29,14 +29,14 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { app.user_name = res.you.display_name().clone(); app.user_id = res.you.id().clone(); app.backend_state = BackendStallState::BingChilling; - app.backend + let _ = app + .backend .backend_commander - .send(bridge_thread::MaximaLibRequest::GetGamesRequest) - .unwrap(); - app.backend + .send(bridge_thread::MaximaLibRequest::GetGamesRequest); + let _ = app + .backend .backend_commander - .send(bridge_thread::MaximaLibRequest::GetFriendsRequest) - .unwrap(); + .send(bridge_thread::MaximaLibRequest::GetFriendsRequest); } LoginCacheEmpty => app.backend_state = BackendStallState::UserNeedsToLogIn, ServiceNeedsStarting => { @@ -48,12 +48,7 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { } GameDetailsResponse(res) => { let response = res.response; - - for (slug, game) in &mut app.games { - if !slug.eq(&res.slug) { - continue; - } - + if let Some(game) = app.games.get_mut(&res.slug) { game.details = GameDetailsWrapper::Available(GameDetails { time: response.time, achievements_unlocked: response.achievements_unlocked, @@ -69,8 +64,21 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { NonFatalError(err) => app.nonfatal_errors.push(*err), ActiveGameChanged(slug) => app.playing_game = slug, LocateGameResponse(res) => { + if matches!( + res, + bridge_thread::InteractThreadLocateGameResponse::Success + ) { + let _ = app + .backend + .backend_commander + .send(bridge_thread::MaximaLibRequest::GetGamesRequest); + } app.installer_state.locate_response = Some(res); app.installer_state.locating = false; + app.backend + .backend_commander + .send(bridge_thread::MaximaLibRequest::GetGamesRequest) + .unwrap(); } DownloadProgressChanged(offer_id, progress) => { if let Some(dl_ing) = app.installing_now.as_mut() { @@ -81,67 +89,68 @@ pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { } } DownloadFinished(_) => { - // idk + app.backend + .backend_commander + .send(bridge_thread::MaximaLibRequest::GetGamesRequest) + .unwrap(); } DownloadQueueUpdate(current, queue) => { if let Some(current) = current { if !app.installing_now.as_ref().is_some_and(|n| n.offer == current) { - app.installing_now = Some(QueuedDownload { - slug: { - // This sucks! - let mut rtn: String = String::new(); - for (slug, game) in &app.games { - if game.offer.eq(¤t) { - // "but it's less code in the nest" - // "WHO CARES" - // (it was the same amount overall) - rtn = slug.to_string(); - break; - } - } - rtn + let slug = find_slug_for_offer(&app.games, ¤t); + if !slug.is_empty() { + app.installing_now = Some(QueuedDownload { + slug, + offer: current, + downloaded_bytes: 0, + total_bytes: 0, + }); + } + } + + app.install_queue.clear(); + for offer in queue { + let slug = find_slug_for_offer(&app.games, &offer); + if slug.is_empty() { + continue; + } + app.install_queue.insert( + offer.clone(), + QueuedDownload { + slug, + offer, + downloaded_bytes: 0, + total_bytes: 0, }, - offer: current, - downloaded_bytes: 0, - total_bytes: 0, - }) + ); } } else { app.installing_now = None; } - - app.install_queue.clear(); - for offer in queue { - let i_fucking_hate_this = QueuedDownload { - slug: { - let mut rtn: String = String::new(); - for (slug, game) in &app.games { - if game.offer.eq(&offer) { - rtn = slug.to_string(); - break; - } - } - rtn - }, - offer: offer.clone(), - downloaded_bytes: 0, - total_bytes: 0, - }; - app.install_queue.insert(offer, i_fucking_hate_this); - } + } + DownloadFailed(offer, reason) => { + app.installing_now = None; + app.nonfatal_errors.push(BackendError::DownloadFailed(offer, reason)); } } ctx.request_repaint(); } - Err(variant) => { - match variant { - TryRecvError::Empty => {} - TryRecvError::Disconnected => { - app.critical_error = Some(BackendError::ChannelDisconnected); - } - } + Err(TryRecvError::Empty) => break 'outer, + Err(TryRecvError::Disconnected) => { + app.critical_error = Some(BackendError::ChannelDisconnected); break 'outer; } } } } + +fn find_slug_for_offer( + games: &std::collections::HashMap, + offer: &str, +) -> String { + games + .iter() + .find(|(_, g)| g.offer == offer) + .map(|(slug, _)| slug.clone()) + .unwrap_or_default() +} diff --git a/maxima-ui/src/bridge_thread.rs b/maxima-ui/src/bridge_thread.rs index 456c1778..41f65e87 100644 --- a/maxima-ui/src/bridge_thread.rs +++ b/maxima-ui/src/bridge_thread.rs @@ -1,7 +1,13 @@ use egui::Context; use log::{error, info, warn}; +use maxima::core::manifest::handle_touchup_request; +use thiserror::Error; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::unbounded_channel; use crate::{ + GameDetails, GameInfo, GameSettings, bridge::{ game_details::game_details_request, get_friends::get_friends_request, get_games::get_games_request, login_oauth::login_oauth, start_game::start_game_request, @@ -9,37 +15,32 @@ use crate::{ event_thread::{EventThread, MaximaEventRequest, MaximaEventResponse}, ui_image::UIImageCacheLoaderCommand, views::friends_view::UIFriend, - GameDetails, GameInfo, GameSettings, }; use maxima::{ content::manager::{ ContentManager, ContentManagerError, QueuedGameBuilder, QueuedGameBuilderError, }, core::{ + LockedMaxima, Maxima, MaximaCreationError, MaximaEvent, MaximaOptionsBuilder, + MaximaOptionsBuilderError, auth::storage::{AuthError, TokenError}, launch::LaunchError, library::LibraryError, - manifest::{self, ManifestError, MANIFEST_RELATIVE_PATH}, + manifest::{self, MANIFEST_RELATIVE_PATH, ManifestError}, service_layer::{ ServiceGameImagesRequestBuilderError, ServiceHeroBackgroundImageRequestBuilderError, ServiceLayerError, ServicePlayer, }, - LockedMaxima, Maxima, MaximaCreationError, MaximaOptionsBuilder, MaximaOptionsBuilderError, }, + gameinfo::GameInstallInfo, lsx::service::LSXServerError, rtm::RtmError, util::{ native::NativeError, - registry::{check_registry_validity, set_up_registry, RegistryError}, + registry::{RegistryError, check_registry_validity, set_up_registry}, }, }; -use std::sync::mpsc::{SendError, TryRecvError}; -use std::{ - panic, - path::PathBuf, - sync::mpsc::{Receiver, Sender}, - time::{Duration, SystemTime}, -}; +use std::{path::PathBuf, time::Duration}; // TODO(headassbtw): integrate these all into the enums pub struct InteractThreadLoginResponse { @@ -82,8 +83,11 @@ 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) + PauseInstallRequest(String), + MoveInstallToTopRequest(String), + LocateGameRequest(String, String, Option), // slug, path, wine prefix (unix only) + CancelInstallRequest(String), ShutdownRequest, } @@ -102,17 +106,17 @@ pub enum MaximaLibResponse { ActiveGameChanged(Option), DownloadProgressChanged(String, InteractThreadDownloadProgressResponse), DownloadFinished(String), + DownloadFailed(String, String), DownloadQueueUpdate(Option, Vec), } pub struct BridgeThread { - pub backend_listener: Receiver, - pub backend_commander: Sender, - - pub rtm_listener: Receiver, - pub rtm_commander: Sender, // currently unused except for shutdown + pub backend_listener: UnboundedReceiver, + pub backend_commander: UnboundedSender, + pub rtm_listener: UnboundedReceiver, + pub rtm_commander: UnboundedSender, } -#[derive(thiserror::Error, Debug)] +#[derive(Error, Debug)] pub enum BackendError { #[error(transparent)] Auth(#[from] AuthError), @@ -141,10 +145,6 @@ pub enum BackendError { #[error(transparent)] Rtm(#[from] RtmError), #[error(transparent)] - SendResponse(#[from] SendError), - #[error(transparent)] - SendImageCacheLoaderCommand(#[from] SendError), - #[error(transparent)] ServiceGameImagesRequestBuilder(#[from] ServiceGameImagesRequestBuilderError), #[error(transparent)] ServiceHeroBackgroundImageRequestBuilder(#[from] ServiceHeroBackgroundImageRequestBuilderError), @@ -153,18 +153,22 @@ pub enum BackendError { #[error(transparent)] Token(#[from] TokenError), #[error(transparent)] - TryRecv(#[from] TryRecvError), + Other(#[from] anyhow::Error), #[error("backend-frontend communication channel disconnected")] ChannelDisconnected, + #[error("no live build available for the requested offer")] + NoLiveBuildAvailable, #[error("tried to perform an action that requires being logged in, but was logged out")] LoggedOut, + #[error("download failed for `{0}`: {1}")] + DownloadFailed(String, String), } impl BridgeThread { fn update_queue( content_manager: &ContentManager, - backend_responder: Sender, + backend_responder: UnboundedSender, ) { let current = if let Some(now) = content_manager.queue().current() { Some(now.offer_id().to_owned()) @@ -178,39 +182,44 @@ impl BridgeThread { queue.push(game.offer_id().to_owned()); } - backend_responder - .send(MaximaLibResponse::DownloadQueueUpdate(current, queue)) - .unwrap(); + backend_responder.send(MaximaLibResponse::DownloadQueueUpdate(current, queue)).ok(); } - pub fn new(ctx: &Context, remote_provider_channel: Sender) -> Self { + pub fn new( + ctx: &Context, + remote_provider_channel: UnboundedSender, + ) -> Self { puffin::profile_function!(); - let (backend_commander, backend_cmd_listener) = std::sync::mpsc::channel(); - let (backend_responder, backend_listener) = std::sync::mpsc::channel(); - - let (rtm_commander, rtm_cmd_listener) = std::sync::mpsc::channel(); - let (rtm_responder, rtm_listener) = std::sync::mpsc::channel(); + let (backend_commander, backend_cmd_listener) = unbounded_channel(); + let (backend_responder, backend_listener) = unbounded_channel(); + let (rtm_commander, rtm_cmd_listener) = unbounded_channel(); + let (rtm_responder, rtm_listener) = unbounded_channel(); let context = ctx.clone(); + let backend_responder_for_task = backend_responder.clone(); + let remote_provider_for_task = remote_provider_channel.clone(); + + // you dare spawn a thread without catching my sneaky sneaky panics mister Potter ?? tokio::task::spawn(async move { - let die_fallback_transmitter = backend_responder.clone(); - //panic::set_hook(Box::new( |_| {})); - let result = BridgeThread::run( + let die_fallback = backend_responder_for_task.clone(); + match BridgeThread::run( backend_cmd_listener, - backend_responder, + backend_responder_for_task, rtm_cmd_listener, rtm_responder, - remote_provider_channel, + remote_provider_for_task, &context, ) - .await; - if let Err(err) = result { - die_fallback_transmitter - .send(MaximaLibResponse::CriticalError(Box::from(err))) - .unwrap(); - } else { - info!("Interact thread shut down") + .await + { + Ok(()) => (), + Err(err) => { + error!("BridgeThread task: run() returned Err: {}", err); + let _ = die_fallback.send(MaximaLibResponse::NonFatalError(Box::from(err))); + } } + + info!("Interact thread shut down"); }); Self { @@ -222,23 +231,22 @@ impl BridgeThread { } async fn run( - backend_cmd_listener: Receiver, - backend_responder: Sender, - rtm_cmd_listener: Receiver, - rtm_responder: Sender, - remote_provider_channel: Sender, + mut backend_cmd_listener: UnboundedReceiver, + backend_responder: UnboundedSender, + rtm_cmd_listener: UnboundedReceiver, + rtm_responder: UnboundedSender, + remote_provider_channel: UnboundedSender, ctx: &Context, ) -> Result<(), BackendError> { // first things first check registry // the flow is different for windows/linux but windows needs an extra user prompt, // so we're doing both here, instead of selectively cfg'd functions! #[cfg(not(windows))] - { - if let Err(err) = check_registry_validity() { - warn!("{}, fixing...", err); - set_up_registry()?; - } + if let Err(err) = check_registry_validity() { + warn!("{}, fixing...", err); + set_up_registry()?; } + #[cfg(windows)] { use maxima::{ @@ -250,21 +258,23 @@ impl BridgeThread { }, }, }; + if !is_elevated::is_elevated() { if !is_service_valid()? { info!("Installing service..."); - backend_responder.send(MaximaLibResponse::ServiceNeedsStarting)?; - 'wait_for_user_to_authorize: loop { - let request = backend_cmd_listener.try_recv(); - if request.is_err() { - continue; - } + backend_responder.send(MaximaLibResponse::ServiceNeedsStarting).ok(); - match request.unwrap() { + 'wait_for_auth: loop { + let Some(request) = backend_cmd_listener.recv().await else { + info!("Backend command channel closed, shutting down"); + return Ok(()); + }; + + match request { MaximaLibRequest::StartService => { register_service_user()?; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - break 'wait_for_user_to_authorize; + tokio::time::sleep(Duration::from_millis(200)).await; + break 'wait_for_auth; } MaximaLibRequest::ShutdownRequest => return Ok(()), _ => {} @@ -275,12 +285,49 @@ impl BridgeThread { if !is_service_running()? { info!("Starting service..."); start_service().await?; + + let service_addr = "127.0.0.1:13021"; + let mut ready = false; + for attempt in 0..50 { + match tokio::net::TcpStream::connect(service_addr).await { + Ok(_) => { + ready = true; + info!("Service HTTP endpoint ready after {} attempts", attempt + 1); + break; + } + Err(e) => { + if attempt % 5 == 0 { + warn!( + "Service not ready (attempt {}/50): {} — kind: {:?}", + attempt + 1, + e, + e.kind() + ); + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + } + if !ready { + return Err(BackendError::BackgroundServiceClient( + maxima::core::error::BackgroundServiceClientError::Request( + "Service did not become ready after 50 attempts".to_string(), + ), + )); + } } } if let Err(err) = check_registry_validity() { warn!("{}, fixing...", err); - request_registry_setup().await?; + if let Err(e) = request_registry_setup().await { + let source_chain: Vec = + std::iter::successors(Some(&e as &dyn std::error::Error), |e| e.source()) + .map(|e| e.to_string()) + .collect(); + error!("Registry setup failed: {} (source: {:?})", e, source_chain); + return Err(e.into()); + } } } let maxima_arc: LockedMaxima = Maxima::new_with_options( @@ -301,22 +348,18 @@ impl BridgeThread { }; if !logged_in { - backend_responder.send(MaximaLibResponse::LoginCacheEmpty)?; - 'outer: loop { - let request = backend_cmd_listener.try_recv(); - if request.is_err() { - continue; - } + backend_responder.send(MaximaLibResponse::LoginCacheEmpty).ok(); - match request? { + 'login: loop { + let Some(request) = backend_cmd_listener.recv().await else { + return Ok(()); + }; + match request { MaximaLibRequest::LoginRequestOauth => { - let channel = backend_responder.clone(); - let maxima = maxima_arc.clone(); - let context = ctx.clone(); - async move { login_oauth(maxima, channel, &context).await } + login_oauth(maxima_arc.clone(), backend_responder.clone(), ctx) .await .expect("// TODO(headassbtw): panic message"); - break 'outer; + break 'login; } MaximaLibRequest::ShutdownRequest => return Ok(()), _ => {} @@ -329,14 +372,18 @@ impl BridgeThread { let user = maxima.local_user().await?; if logged_in { - let message = MaximaLibResponse::LoginResponse(Ok(InteractThreadLoginResponse { - you: user.player().as_ref().unwrap().to_owned(), - })); - backend_responder.send(message)?; + backend_responder + .send(MaximaLibResponse::LoginResponse(Ok( + InteractThreadLoginResponse { + you: user.player().as_ref().unwrap().to_owned(), + }, + ))) + .ok(); } - let res = remote_provider_channel.send(UIImageCacheLoaderCommand::ProvideRemote( - crate::ui_image::UIImageType::Avatar(user.id().to_string()), - user.player() + + let avatar_result: Result<(), BackendError> = (|| async { + let avatar = user + .player() .as_ref() .ok_or(ServiceLayerError::MissingField)? .avatar() @@ -344,11 +391,23 @@ impl BridgeThread { .ok_or(ServiceLayerError::MissingField)? .medium() .path() - .to_string(), - )); - if let Err(err) = res { - error!("failed to send user pfp to loader: {:?}", err); + .to_string(); + + remote_provider_channel + .send(UIImageCacheLoaderCommand::ProvideRemote( + crate::ui_image::UIImageType::Avatar(user.id().to_string()), + avatar, + )) + .ok(); + Ok(()) + })() + .await; + + if let Err(e) = avatar_result { + warn!("Failed to load user avatar: {}", e); + // continue anyway } + ctx.request_repaint(); } @@ -359,161 +418,201 @@ impl BridgeThread { rtm_responder, ); - let mut future = SystemTime::now(); - future = future.checked_add(Duration::from_millis(50)).unwrap(); + let mut next_tick = tokio::time::Instant::now() + Duration::from_millis(50); let mut playing_cache: Option = None; - 'outer: loop { - let now = SystemTime::now(); - if now >= future { - // this sucks but it's non-blocking so oh well what are you going to do about it! it's on a non-ui thread anyway, i'm wasteful with it - future = now.checked_add(Duration::from_millis(50)).unwrap(); + 'main: loop { + tokio::select! { + _ = tokio::time::sleep_until(next_tick) => { + next_tick = tokio::time::Instant::now() + Duration::from_millis(50); let mut maxima = maxima_arc.lock().await; maxima.update().await; - let now_playing = maxima.playing(); - if let Some(ctx) = now_playing { - if let Some(offer) = ctx.offer() { - if playing_cache.is_none() { + // progress reporting + match maxima.playing() { + Some(ctx) if playing_cache.is_none() => { + if let Some(offer) = ctx.offer() { playing_cache = Some(offer.slug().clone()); - backend_responder.send(MaximaLibResponse::ActiveGameChanged(Some( - offer.slug().clone(), - )))?; + backend_responder + .send(MaximaLibResponse::ActiveGameChanged(Some( + offer.slug().clone(), + ))) + .ok(); } } - } else { - if playing_cache.is_some() { + None if playing_cache.is_some() => { playing_cache = None; - backend_responder.send(MaximaLibResponse::ActiveGameChanged(None)).unwrap(); - }; + backend_responder.send(MaximaLibResponse::ActiveGameChanged(None)).ok(); + } + _ => {} } - if let Some(dl) = maxima.content_manager().current() { + if let Some(dl) = maxima.content_manager().current() { backend_responder.send(MaximaLibResponse::DownloadProgressChanged( dl.offer_id().to_string(), InteractThreadDownloadProgressResponse { bytes: dl.bytes_downloaded(), bytes_total: dl.bytes_total(), }, - ))?; + )).ok(); } for ev in maxima.consume_pending_events() { match ev { - maxima::core::MaximaEvent::ReceivedLSXRequest(_, _) => {} - maxima::core::MaximaEvent::InstallFinished(offer_id) => { - backend_responder - .send(MaximaLibResponse::DownloadFinished(offer_id))?; + MaximaEvent::InstallFinished(offer_id) => { + backend_responder.send(MaximaLibResponse::DownloadFinished(offer_id)).ok(); + Self::update_queue(maxima.content_manager(), backend_responder.clone()); + } + MaximaEvent::InstallFailed(offer_id, reason) => { + backend_responder.send(MaximaLibResponse::DownloadFailed(offer_id, reason)).ok(); Self::update_queue(maxima.content_manager(), backend_responder.clone()); } + _ => {} } } } - let request = backend_cmd_listener.try_recv(); - if request.is_err() { - continue; - } - let action = match request? { - MaximaLibRequest::LoginRequestOauth | MaximaLibRequest::StartService => { - error!("bro tried to log in twice"); - Ok(()) - } - MaximaLibRequest::GetGamesRequest => { - let channel = backend_responder.clone(); - let channel1 = remote_provider_channel.clone(); - let maxima = maxima_arc.clone(); - let context = ctx.clone(); - async move { get_games_request(maxima, channel, channel1, &context).await } - .await - } - MaximaLibRequest::GetFriendsRequest => { - let channel = backend_responder.clone(); - let channel1 = remote_provider_channel.clone(); - let maxima = maxima_arc.clone(); - let context = ctx.clone(); - async move { get_friends_request(maxima, channel, channel1, &context).await } - .await - } - MaximaLibRequest::GetGameDetailsRequest(slug) => { - let channel = backend_responder.clone(); - let maxima = maxima_arc.clone(); - 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?; - let mut path = path; - if path.ends_with("/") || path.ends_with("\\") { - path.remove(path.len() - 1); + request = backend_cmd_listener.recv() => { + let Some(request) = request else { + continue 'main; + }; + let result = match request { + MaximaLibRequest::LoginRequestOauth | MaximaLibRequest::StartService => { + error!("bro tried to log in twice"); + Ok(()) + } + + MaximaLibRequest::GetGamesRequest => { + let maxima = maxima_arc.clone(); + let responder = backend_responder.clone(); + let remote = remote_provider_channel.clone(); + let context = ctx.clone(); + tokio::task::spawn(async move { + if let Err(e) = + get_games_request(maxima, responder.clone(), remote, &context).await + { + let _ = + responder.send(MaximaLibResponse::NonFatalError(Box::from(e))).ok(); + } + }); + Ok(()) + } + MaximaLibRequest::GetFriendsRequest => { + let f = async || { + get_friends_request( + maxima_arc.clone(), + backend_responder.clone(), + remote_provider_channel.clone(), + ctx, + ) + .await + }; + f().await + } + MaximaLibRequest::GetGameDetailsRequest(slug) => { + let f = async || { + game_details_request( + maxima_arc.clone(), + slug, + backend_responder.clone(), + ctx, + ) + .await + }; + f().await } - 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; - if let Err(err) = guh { - let _ = backend_responder.send(MaximaLibResponse::LocateGameResponse( - InteractThreadLocateGameResponse::Error( - InteractThreadLocateGameFailure { - reason: err, - xml_path: path - .join(MANIFEST_RELATIVE_PATH) - .to_str() - .unwrap() - .to_string(), - }, - ), - )); - } else { - let _ = backend_responder.send(MaximaLibResponse::LocateGameResponse( - InteractThreadLocateGameResponse::Success, - )); + MaximaLibRequest::LocateGameRequest(slug, mut path, wine_prefix) => { + if path.ends_with('/') || path.ends_with('\\') { + path.pop(); } - } else { - let _ = backend_responder.send(MaximaLibResponse::LocateGameResponse( - InteractThreadLocateGameResponse::Error( + + let path = PathBuf::from(path); + let manifest_path = path.join(MANIFEST_RELATIVE_PATH); + let response = match manifest::load_manifest_from_disk(manifest_path.clone()).await { + Ok(manifest) => { + let should_force_touchup = manifest.needs_touchup_on_locate(); + if should_force_touchup { + match handle_touchup_request(path, wine_prefix, &slug).await { + Ok(()) => InteractThreadLocateGameResponse::Success, + Err(err) => { + warn!("Touchup failed during locate, treating as success: {}", err); + InteractThreadLocateGameResponse::Success + } + } + } else { + #[cfg(windows)] + let game_install_info = GameInstallInfo::new(path, None); + + #[cfg(unix)] + let game_install_info = GameInstallInfo::new(path, wine_prefix); + + game_install_info.save_to_json(&slug); + InteractThreadLocateGameResponse::Success + } + } + Err(err) => InteractThreadLocateGameResponse::Error( InteractThreadLocateGameFailure { - reason: manifest.unwrap_err(), - xml_path: path - .join(MANIFEST_RELATIVE_PATH) - .to_str() - .unwrap() - .to_string(), + reason: err, + xml_path: manifest_path.to_string_lossy().into_owned(), }, ), - )); + }; + + let _ = backend_responder + .send(MaximaLibResponse::LocateGameResponse(response)) + .ok(); + info!("finished locating"); + ctx.request_repaint(); + Ok(()) } - info!("finished locating"); - ctx.request_repaint(); - Ok(()) - } - MaximaLibRequest::InstallGameRequest(offer, path) => { - let mut maxima = maxima_arc.lock().await; - let builds = - maxima.content_manager().service().available_builds(&offer).await?; - let build = if let Some(build) = builds.live_build() { - build - } else { - continue; - }; - - let game = QueuedGameBuilder::default() - .offer_id(offer) - .build_id(build.build_id().to_owned()) - .path(path.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::ShutdownRequest => break 'outer Ok(()), //TODO: kill the bridge thread - }; - if let Err(err) = action { - let _ = backend_responder.send(MaximaLibResponse::NonFatalError(Box::from(err))); - } + MaximaLibRequest::InstallGameRequest(offer, slug, path, wine_prefix) => { + let mut maxima = maxima_arc.lock().await; + let builds = maxima.content_manager().service().available_builds(&offer).await?; + let build = builds.live_build().ok_or(BackendError::NoLiveBuildAvailable)?; + let game = QueuedGameBuilder::default() + .offer_id(offer) + .build_id(build.build_id().to_owned()) + .path(path) + .slug(slug) + .wine_prefix(wine_prefix) + .build()?; + maxima.content_manager().add_install(game).await?; + Self::update_queue(maxima.content_manager(), backend_responder.clone()); + Ok(()) + } + MaximaLibRequest::CancelInstallRequest(offer) => { + let mut maxima = maxima_arc.lock().await; + maxima.content_manager().cancel_install(&offer).await?; + Self::update_queue(maxima.content_manager(), backend_responder.clone()); + Ok(()) + } + MaximaLibRequest::PauseInstallRequest(offer) => { + let mut maxima = maxima_arc.lock().await; + maxima.content_manager().pause_install(&offer).await?; + Self::update_queue(maxima.content_manager(), backend_responder.clone()); + Ok(()) + } + MaximaLibRequest::MoveInstallToTopRequest(offer) => { + let mut maxima = maxima_arc.lock().await; + maxima.content_manager().move_install_to_top(&offer).await?; + Self::update_queue(maxima.content_manager(), backend_responder.clone()); + Ok(()) + } + MaximaLibRequest::StartGameRequest(info, settings) => { + Ok(start_game_request(maxima_arc.clone(), info, settings).await?) + } + MaximaLibRequest::ShutdownRequest => { + info!("BridgeThread: received shutdown request"); + break 'main Ok(()); + } + }; + + if let Err(err) = result { + backend_responder.send(MaximaLibResponse::NonFatalError(Box::from(err))).ok(); + } + } + } puffin::GlobalProfiler::lock().new_frame(); } } diff --git a/maxima-ui/src/desktop.rs b/maxima-ui/src/desktop.rs index 2787456f..c4947173 100644 --- a/maxima-ui/src/desktop.rs +++ b/maxima-ui/src/desktop.rs @@ -1,5 +1,5 @@ use log::{error, info}; -use maxima::util::native::{maxima_dir, NativeError, SafeParent}; +use maxima::util::native::{NativeError, SafeParent, maxima_dir}; use std::collections::HashMap; use std::process::{Command, Output}; use thiserror::Error; diff --git a/maxima-ui/src/enum_locale_map.rs b/maxima-ui/src/enum_locale_map.rs index db0f4d0a..3fbca027 100644 --- a/maxima-ui/src/enum_locale_map.rs +++ b/maxima-ui/src/enum_locale_map.rs @@ -1,10 +1,10 @@ use crate::{ + FrontendLanguage, translation_manager::TranslationManager, views::{ friends_view::{FriendsViewBarPage, FriendsViewBarStatusFilter}, game_view::{GameViewBarGenre, GameViewBarPlatform}, }, - FrontendLanguage, }; pub trait EnumToString { @@ -72,6 +72,7 @@ impl EnumToString for TranslationManager { match variant { FrontendLanguage::SystemDefault => &self.localization.locale.default, FrontendLanguage::EnUS => &self.localization.locale.en_us, + FrontendLanguage::FrFR => &self.localization.locale.fr_fr, } } fn get_string(&self, variant: &mut FrontendLanguage) -> &str { diff --git a/maxima-ui/src/event_processor.rs b/maxima-ui/src/event_processor.rs index d9fffc12..a38ff7a5 100644 --- a/maxima-ui/src/event_processor.rs +++ b/maxima-ui/src/event_processor.rs @@ -1,4 +1,4 @@ -use crate::{event_thread, MaximaEguiApp}; +use crate::{MaximaEguiApp, event_thread}; pub fn frontend_processor(app: &mut MaximaEguiApp, ctx: &egui::Context) { puffin::profile_function!(); diff --git a/maxima-ui/src/event_thread.rs b/maxima-ui/src/event_thread.rs index f824e666..9338351b 100644 --- a/maxima-ui/src/event_thread.rs +++ b/maxima-ui/src/event_thread.rs @@ -1,13 +1,16 @@ use egui::Context; -use std::sync::mpsc::{Receiver, Sender}; +use tokio::{ + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + time::Duration, +}; use crate::bridge_thread::BackendError; -use log::info; +use log::{error, info}; use maxima::core::{ + LockedMaxima, service_layer::{ - ServiceFriends, ServiceGetMyFriendsRequestBuilder, SERVICE_REQUEST_GETMYFRIENDS, + SERVICE_REQUEST_GETMYFRIENDS, ServiceFriends, ServiceGetMyFriendsRequestBuilder, }, - LockedMaxima, }; // TODO(headassbtw): integrate this into the enum too (out of scope for the PR i wrote this in) @@ -31,17 +34,15 @@ impl EventThread { pub fn new( ctx: &Context, maxima: LockedMaxima, - rtm_cmd_listener: Receiver, - rtm_responder: Sender, + rtm_cmd_listener: UnboundedReceiver, + rtm_responder: UnboundedSender, ) -> Self { let context = ctx.clone(); tokio::task::spawn(async move { - let result = EventThread::run(rtm_cmd_listener, rtm_responder, &context, maxima).await; - if result.is_err() { - panic!("Event thread failed! {}", result.err().unwrap()); - } else { - info!("Event thread shut down") + match EventThread::run(rtm_cmd_listener, rtm_responder, &context, maxima).await { + Ok(()) => info!("Event thread shut down cleanly"), + Err(e) => error!("Event thread error: {e}"), } }); @@ -49,17 +50,21 @@ impl EventThread { } async fn run( - rtm_cmd_listener: Receiver, - rtm_responder: Sender, + mut rtm_cmd_listener: UnboundedReceiver, + rtm_responder: UnboundedSender, ctx: &Context, maxima_arc: LockedMaxima, ) -> Result<(), BackendError> { let mut maxima = maxima_arc.lock().await; + let user = maxima.local_user().await?; + let player = user.player().as_ref().unwrap(); + let persona_id: Vec = vec![player.psd().to_string()]; + let friends: ServiceFriends = maxima .service_layer() .request( - SERVICE_REQUEST_GETMYFRIENDS, + &SERVICE_REQUEST_GETMYFRIENDS, ServiceGetMyFriendsRequestBuilder::default() .offset(0) .limit(100) @@ -76,41 +81,50 @@ impl EventThread { friends.friends().items().iter().map(|f| f.id().to_owned()).collect(); info!("Subscribed to {} players", players.len()); - rtm.subscribe(&players).await?; + rtm.subscribe(persona_id, &players).await?; drop(maxima); - 'outer: loop { - let mut maxima = maxima_arc.lock().await; - maxima.rtm().heartbeat().await?; - - { - let store = maxima.rtm().presence_store().lock().await; - for entry in store.iter() { - let _ = rtm_responder.send(MaximaEventResponse::FriendStatusResponse( - EventThreadFriendStatusResponse { - id: entry.0.to_string(), - presence: entry.1, - }, - )); - // This can cause excessive repainting if it keeps updating friends we know about - egui::Context::request_repaint(&ctx); - } - } + let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(30)); + heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - drop(maxima); + // Presence polling interval — separate from heartbeat + let mut presence_interval = tokio::time::interval(Duration::from_millis(500)); + presence_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let request = rtm_cmd_listener.try_recv(); - if request.is_err() { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - continue; - } + loop { + tokio::select! { + _ = heartbeat_interval.tick() => { + let mut maxima = maxima_arc.lock().await; + if let Err(e) = maxima.rtm().heartbeat().await { + error!("RTM heartbeat failed: {e}"); + } + } - match request? { - MaximaEventRequest::SubscribeToFriendPresence => {} - MaximaEventRequest::ShutdownRequest => break 'outer Ok(()), - } + _ = presence_interval.tick() => { + let mut maxima = maxima_arc.lock().await; + let store = maxima.rtm().presence_store().lock().await; + for entry in store.iter() { + let _ = rtm_responder + .send(MaximaEventResponse::FriendStatusResponse( + EventThreadFriendStatusResponse { + id: entry.0.to_string(), + presence: entry.1, + }, + )) + .ok(); + } + if store.entry_count() > 0 { + ctx.request_repaint(); + } + } - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + request = rtm_cmd_listener.recv() => { + match request { + Some(MaximaEventRequest::ShutdownRequest) | None => return Ok(()), + Some(MaximaEventRequest::SubscribeToFriendPresence) => {} + } + } + } } } } diff --git a/maxima-ui/src/main.rs b/maxima-ui/src/main.rs index e3e0278b..e6b39b89 100644 --- a/maxima-ui/src/main.rs +++ b/maxima-ui/src/main.rs @@ -1,25 +1,24 @@ -#![feature(slice_pattern)] -use clap::{arg, command, Parser}; +use crate::ui_image::{UIImageCache, UIImageType}; +use clap::Parser; use desktop::check_desktop_icon; use egui::{ - pos2, - style::{ScrollStyle, Spacing}, - style::{WidgetVisuals, Widgets}, - vec2, Align2, Color32, FontData, FontDefinitions, FontFamily, FontId, Layout, Margin, Rect, - Response, Rounding, Stroke, Style, TextureId, Ui, Vec2, ViewportBuilder, Visuals, Widget, + Align2, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Layout, Margin, + Rect, Response, Stroke, Style, TextureId, Ui, UiBuilder, Vec2, ViewportBuilder, Visuals, + Widget, pos2, + style::{ScrollStyle, Spacing, WidgetVisuals, Widgets}, + vec2, }; use log::error; use maxima::{core::library::OwnedOffer, util::log::init_logger}; -use std::{collections::HashMap, default::Default, ops::RangeInclusive, path::PathBuf}; +use std::{collections::HashMap, ops::RangeInclusive, path::PathBuf, sync::Arc}; use strum_macros::EnumIter; -use ui_image::{UIImageCache, UIImageType}; use views::{ debug_view::debug_view, - downloads_view::{downloads_view, QueuedDownload}, + downloads_view::{QueuedDownload, downloads_view}, friends_view::{ - friends_view, FriendsViewBar, FriendsViewBarPage, FriendsViewBarStatusFilter, UIFriend, + FriendsViewBar, FriendsViewBarPage, FriendsViewBarStatusFilter, UIFriend, friends_view, }, - game_view::{games_view, GameViewBar, GameViewBarGenre, GameViewBarPlatform}, + game_view::{GameViewBar, GameViewBarGenre, GameViewBarPlatform, games_view}, settings_view::settings_view, undefined_view::{coming_soon_view, undefined_view}, }; @@ -28,12 +27,11 @@ use eframe::egui_glow; use egui_extras::{Size, StripBuilder}; use egui_glow::glow; -use app_bg_renderer::AppBgRenderer; -use bridge_thread::{BackendError, BridgeThread, InteractThreadLocateGameResponse}; -use game_view_bg_renderer::GameViewBgRenderer; -use renderers::{app_bg_renderer, game_view_bg_renderer}; -use translation_manager::{positional_replace, TranslationManager}; - +use crate::bridge_thread::{BackendError, BridgeThread, InteractThreadLocateGameResponse}; +use crate::renderers::app_bg_renderer::AppBgRenderer; +use crate::renderers::game_view_bg_renderer::GameViewBgRenderer; +use crate::translation_manager::{TranslationManager, positional_replace}; +use maxima::util::native::maxima_dir; pub mod bridge; pub mod util; mod views; @@ -66,12 +64,17 @@ struct Args { allow_bf3: bool, } -#[tokio::main] -async fn main() { +fn main() { + // Create the tokio runtime manually so eframe::run_native doesn't block + // an async worker thread (which can stall spawned async tasks). + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _rt_guard = rt.enter(); + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); init_logger(); let mut args = Args::parse(); - if !std::env::var("MAXIMA_PACKAGED").is_ok_and(|var| var == "1") { + if std::env::var("MAXIMA_PACKAGED").is_ok_and(|var| var == "1") { if let Err(err) = check_desktop_icon() { error!("Failed to register desktop icon! {}", err); } @@ -108,12 +111,12 @@ async fn main() { .with_inner_size([1280.0, 720.0]) .with_min_inner_size([940.0, 480.0]) .with_app_id("io.github.ArmchairDevelopers.Maxima") - .with_icon( + .with_icon(Arc::new( eframe::icon_data::from_png_bytes( &include_bytes!("../../maxima-resources/assets/logo.png")[..], ) .unwrap(), - ), + )), ..Default::default() }; eframe::run_native( @@ -121,12 +124,10 @@ async fn main() { native_options, Box::new(move |cc| { let app = MaximaEguiApp::new(cc, args); - // Run initialization code that needs access to the UI here, but DO NOT run any long-runtime functions here, - // as it's before the UI is shown + // TODO: Implement no_login bypass (skip auth initialization) if args.no_login { - return Ok(Box::new(app)); + log::warn!("--no-login is not yet implemented, ignoring"); } - Ok(Box::new(app)) }), ) @@ -195,6 +196,12 @@ pub struct GameSettings { impl GameSettings { pub fn new() -> Self { + Self::default() + } +} + +impl Default for GameSettings { + fn default() -> Self { Self { cloud_saves: true, launch_args: String::new(), @@ -238,6 +245,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 +256,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, @@ -298,7 +307,6 @@ pub struct MaximaEguiApp { backend: BridgeThread, /// what the backend doin? backend_state: BackendStallState, - /// what type of login we're using /// Slug of the game currently running, may not be fully accurate but it's good enough to let the user know the button was clicked playing_game: Option, /// Currently downloading game @@ -309,30 +317,33 @@ pub struct MaximaEguiApp { installer_state: InstallModalState, /// User Settings for the frontend settings: FrontendSettings, + /// Map of offer IDs to slugs for quick lookup + pub offer_to_slug: HashMap, } -#[derive(serde::Serialize, serde::Deserialize, PartialEq, EnumIter)] +#[derive(Default, serde::Serialize, serde::Deserialize, PartialEq, EnumIter)] pub enum FrontendLanguage { + #[default] SystemDefault, EnUS, + FrFR, } -#[derive(serde::Serialize, serde::Deserialize, Copy, Clone)] +#[derive(Default, serde::Serialize, serde::Deserialize, Copy, Clone)] pub struct FrontendPerformanceSettings { disable_blur: bool, } impl FrontendPerformanceSettings { pub fn new() -> Self { - Self { - disable_blur: false, - } + Self::default() } } -#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Default, 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, @@ -341,13 +352,7 @@ pub struct FrontendSettings { impl FrontendSettings { pub fn new() -> Self { - Self { - default_install_folder: String::new(), - language: FrontendLanguage::SystemDefault, - ignore_ood_games: false, - game_settings: HashMap::new(), - performance_settings: FrontendPerformanceSettings::new(), - } + Self::default() } } @@ -376,7 +381,6 @@ impl MaximaEguiApp { faint_bg_color: Color32::from_rgb(15, 20, 34), extreme_bg_color: Color32::from_rgb(20, 20, 20), window_fill: Color32::BLACK, - //override_text_color: Some(Color32::WHITE), hyperlink_color: F9B233, widgets: Widgets { hovered: WidgetVisuals { @@ -384,7 +388,7 @@ impl MaximaEguiApp { bg_fill: F9B233, bg_stroke: Stroke::NONE, fg_stroke: Stroke::new(1.0, Color32::BLACK), - rounding: Rounding::ZERO, + corner_radius: CornerRadius::ZERO, expansion: -1.0, }, inactive: WidgetVisuals { @@ -392,7 +396,7 @@ impl MaximaEguiApp { bg_fill: Color32::BLACK, bg_stroke: Stroke::new(2.0, Color32::WHITE), fg_stroke: Stroke::new(1.5, Color32::WHITE), - rounding: Rounding::same(2.0), + corner_radius: CornerRadius::same(2), expansion: -2.0, }, active: WidgetVisuals { @@ -400,7 +404,7 @@ impl MaximaEguiApp { bg_fill: WIDGET_HOVER.linear_multiply(0.6), bg_stroke: Stroke::NONE, fg_stroke: Stroke::new(2.0, WIDGET_HOVER.linear_multiply(0.6)), - rounding: Rounding::ZERO, + corner_radius: CornerRadius::ZERO, expansion: 0.0, }, open: WidgetVisuals { @@ -408,7 +412,7 @@ impl MaximaEguiApp { bg_fill: WIDGET_HOVER.linear_multiply(0.0), bg_stroke: Stroke::NONE, fg_stroke: Stroke::new(2.0, WIDGET_HOVER.linear_multiply(0.0)), - rounding: Rounding::ZERO, + corner_radius: CornerRadius::ZERO, expansion: 0.0, }, ..Default::default() @@ -422,7 +426,9 @@ impl MaximaEguiApp { fonts.font_data.insert( "ibm_plex".to_owned(), - FontData::from_static(include_bytes!("../fonts/IBMPlexSans-Regular.ttf")), + std::sync::Arc::new(FontData::from_static(include_bytes!( + "../fonts/IBMPlexSans-Regular.ttf" + ))), ); fonts @@ -433,7 +439,7 @@ impl MaximaEguiApp { fonts.families.get_mut(&FontFamily::Monospace).unwrap().push("ibm_plex".to_owned()); - cc.egui_ctx.set_style(style); + cc.egui_ctx.set_global_style(style); cc.egui_ctx.set_fonts(fonts); #[cfg(debug_assertions)] @@ -485,6 +491,7 @@ impl MaximaEguiApp { install_queue: HashMap::new(), installer_state: InstallModalState::new(&settings), settings, + offer_to_slug: HashMap::new(), } } } @@ -495,14 +502,14 @@ pub fn tab_bar_button(ui: &mut Ui, res: Response) { puffin::profile_function!(); let mut res2 = Rect::clone(&res.rect); res2.min.y = res2.max.y - 4.; - ui.painter().vline( - res2.min.x + 2.0, - RangeInclusive::new(res2.min.y, res2.max.y), + ui.painter().hline( + RangeInclusive::new(res2.min.x, res2.max.x), + res2.max.y - 2.0, Stroke::new(2.0, ACCENT_COLOR), ); ui.painter().rect_filled( res2, - Rounding::ZERO, + CornerRadius::ZERO, if res.hovered() { ACCENT_COLOR } else { @@ -513,39 +520,39 @@ pub fn tab_bar_button(ui: &mut Ui, res: Response) { /// We used to have a semi-functional implementation that only worked on Mac, but, as i would say, we do not care 🗣️🗣️🗣️ fn custom_window_frame( - enabled: bool, // disables the entire app, used for if the bg thread crashes - crash_text: String, - ctx: &egui::Context, - _: &mut eframe::Frame, + enabled: bool, + crash_text: &str, + ui: &mut egui::Ui, + _frame: &mut eframe::Frame, _title: &str, add_contents: impl FnOnce(&mut egui::Ui), ) { puffin::profile_function!(); use egui::*; - let panel_frame = egui::Frame { - fill: Color32::RED, - rounding: 0.0.into(), - stroke: Stroke::NONE, - outer_margin: Margin { - left: APP_MARGIN.x, - right: APP_MARGIN.x, - top: APP_MARGIN.y + if !enabled { APP_MARGIN.y * 3.0 } else { 0.0 }, - bottom: APP_MARGIN.y, - }, - ..Default::default() - }; + let panel_frame = egui::Frame::NONE.inner_margin(Margin { + left: APP_MARGIN.x as i8, + right: APP_MARGIN.x as i8, + top: APP_MARGIN.y as i8 + + if !enabled { + (APP_MARGIN.y * 3.0) as i8 + } else { + 0 + }, + bottom: APP_MARGIN.y as i8, + }); - CentralPanel::default().frame(panel_frame).show(ctx, |ui| { + // senpai ? could you draw all over my panel uwu + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { if !enabled { let warning_rect = Rect { - min: Pos2::ZERO, + min: ui.max_rect().min, max: pos2( - ui.available_width() + APP_MARGIN.x * 2.0, - APP_MARGIN.y * 3.0, + ui.max_rect().max.x, + ui.max_rect().min.y + APP_MARGIN.y * 3.0, ), }; - ui.painter().rect_filled(warning_rect, Rounding::same(0.0), Color32::RED); + ui.painter().rect_filled(warning_rect, CornerRadius::ZERO, Color32::RED); ui.painter().text( warning_rect.center(), Align2::CENTER_CENTER, @@ -554,47 +561,52 @@ fn custom_window_frame( Color32::BLACK, ); } - ui.add_enabled_ui(enabled, add_contents); + + panel_frame.show(ui, |ui| { + ui.add_enabled_ui(enabled, add_contents); + }); }); } /// Wrapper/helper for the tab buttons in the top left of the app fn tab_button(ui: &mut Ui, edit_var: &mut PageType, page: PageType, label: &str) { - puffin::profile_function!(); - ui.style_mut().visuals.widgets.inactive.rounding = Rounding::ZERO; - ui.style_mut().visuals.widgets.active.rounding = Rounding::ZERO; - ui.style_mut().visuals.widgets.hovered.rounding = Rounding::ZERO; - ui.style_mut().visuals.widgets.inactive.expansion = -1.0; - ui.style_mut().visuals.widgets.active.expansion = -1.0; - ui.style_mut().visuals.widgets.hovered.expansion = -1.0; - - if edit_var == &page { - ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::WHITE; - ui.style_mut().visuals.widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::BLACK); - ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.active.weak_bg_fill = Color32::WHITE; - ui.style_mut().visuals.widgets.active.fg_stroke = Stroke::new(2.0, Color32::BLACK); - ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::WHITE; - ui.style_mut().visuals.widgets.hovered.fg_stroke = Stroke::new(2.0, Color32::BLACK); - ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; - } else { - ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().visuals.widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::WHITE); - ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.active.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().visuals.widgets.active.fg_stroke = Stroke::new(2.0, F9B233); - ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().visuals.widgets.hovered.fg_stroke = Stroke::new(2.0, F9B233); - ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; - } - let text = egui::RichText::new(label.to_uppercase()).size(16.0); + ui.scope(|ui| { + puffin::profile_function!(); + ui.style_mut().visuals.widgets.inactive.corner_radius = CornerRadius::ZERO; + ui.style_mut().visuals.widgets.active.corner_radius = CornerRadius::ZERO; + ui.style_mut().visuals.widgets.hovered.corner_radius = CornerRadius::ZERO; + ui.style_mut().visuals.widgets.inactive.expansion = -1.0; + ui.style_mut().visuals.widgets.active.expansion = -1.0; + ui.style_mut().visuals.widgets.hovered.expansion = -1.0; + + if edit_var == &page { + ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::WHITE; + ui.style_mut().visuals.widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::BLACK); + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.active.weak_bg_fill = Color32::WHITE; + ui.style_mut().visuals.widgets.active.fg_stroke = Stroke::new(2.0, Color32::BLACK); + ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::WHITE; + ui.style_mut().visuals.widgets.hovered.fg_stroke = Stroke::new(2.0, Color32::BLACK); + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + } else { + ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::WHITE); + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.active.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.active.fg_stroke = Stroke::new(2.0, F9B233); + ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.hovered.fg_stroke = Stroke::new(2.0, F9B233); + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + } + let text = egui::RichText::new(label.to_uppercase()).size(16.0); - let test = ui.add_sized([120.0, 28.0], egui::Button::new(text)); - if test.clicked() { - *edit_var = page.clone(); - } + let test = ui.add_sized([120.0, 28.0], egui::Button::new(text)); + if test.clicked() { + *edit_var = page; + } + }); } // god-awful macro to do something incredibly simple because apparently wrapping it in a function has rustc fucking implode @@ -630,14 +642,14 @@ impl MaximaEguiApp { puffin::profile_function!(); let navbar = egui::Frame::default() .stroke(Stroke::new(2.0, Color32::WHITE)) - .inner_margin(Margin::same(0.0)) - .outer_margin(Margin::same(2.0)) - .rounding(Rounding::same(4.0)); + .inner_margin(Margin::same(0)) + .outer_margin(Margin::same(2)) + .corner_radius(CornerRadius::same(4)); navbar.show(header, |ui| { ui.horizontal(|ui| { let loc = &self.locale.localization.menubar; ui.spacing_mut().item_spacing.x = 0.0; - ui.style_mut().visuals.widgets.inactive.rounding = Rounding::ZERO; + ui.style_mut().visuals.widgets.inactive.corner_radius = CornerRadius::ZERO; tab_button(ui, &mut self.page_view, PageType::Games, &loc.games); tab_button(ui, &mut self.page_view, PageType::Store, &loc.store); tab_button(ui, &mut self.page_view, PageType::Settings, &loc.settings); @@ -672,9 +684,10 @@ impl MaximaEguiApp { }); rtl.painter().rect( img_response.rect.expand(1.0), - Rounding::same(4.0), + CornerRadius::same(4), Color32::TRANSPARENT, stroke, + egui::StrokeKind::Inside, ); let point = img_response.rect.left_center() + vec2(-rtl.spacing().item_spacing.x, 2.0); @@ -699,6 +712,14 @@ impl MaximaEguiApp { FontId::proportional(10.0), Color32::WHITE, ); + } else { + rtl.painter().text( + point, + Align2::RIGHT_CENTER, + &self.user_name, + FontId::proportional(15.0), + Color32::WHITE, + ); } } else { rtl.painter().text( @@ -713,7 +734,7 @@ impl MaximaEguiApp { } fn main(&mut self, app_rect: Rect, ui: &mut Ui) { - let outside_spacing = ui.spacing().item_spacing.x.clone(); + let outside_spacing = ui.spacing().item_spacing.x; ui.add_enabled_ui(self.modal.is_none(), |ui| { ui.spacing_mut().item_spacing.y = outside_spacing; let strip = StripBuilder::new(ui).size(Size::exact(38.0)).size(Size::remainder()); @@ -753,7 +774,7 @@ impl MaximaEguiApp { max: avail_rect.max, }; - main.allocate_ui_at_rect(bigmain_rect, |bigmain| { + main.scope_builder(UiBuilder::new().max_rect(bigmain_rect), |bigmain| { puffin::profile_scope!("main view"); match self.page_view { PageType::Games => games_view(self, bigmain), @@ -764,7 +785,7 @@ impl MaximaEguiApp { _ => undefined_view(self, bigmain), } }); - main.allocate_ui_at_rect(friends_rect, |friends| { + main.scope_builder(UiBuilder::new().max_rect(friends_rect), |friends| { friends_view(self, friends); }); }); @@ -772,12 +793,15 @@ impl MaximaEguiApp { }); let mut clear = false; if let Some(modal) = &self.modal { - ui.allocate_ui_at_rect(app_rect, |contents| { + ui.scope_builder(UiBuilder::new().max_rect(app_rect), |contents| { egui::Frame::default() .fill(Color32::from_black_alpha(200)) - .outer_margin(Margin::symmetric((app_rect.width() - 600.0) / 2.0, (app_rect.height() - 400.0) / 2.0)) - .inner_margin(Margin::same(12.0)) - .rounding(Rounding::same(8.0)) + .outer_margin(Margin::symmetric( + ((app_rect.width() - 600.0) / 2.0).max(0.0) as i8, + ((app_rect.height() - 400.0) / 2.0).max(0.0) as i8, + )) + .inner_margin(Margin::same(12)) + .corner_radius(CornerRadius::same(8)) .stroke(Stroke::new(4.0, Color32::WHITE)) .show(contents, |ui| { ui.style_mut().spacing.interact_size = vec2(100.0, 30.0); @@ -800,11 +824,14 @@ impl MaximaEguiApp { ui.add_enabled(game.has_cloud_saves, egui::Checkbox::new(&mut settings.cloud_saves, &self.locale.localization.modals.game_settings.cloud_saves)); ui.label(&self.locale.localization.modals.game_settings.launch_arguments); - ui.add_sized(vec2(ui.available_width(), ui.style().spacing.interact_size.y), egui::TextEdit::singleline(&mut settings.launch_args).vertical_align(egui::Align::Center)); + let launch_args_size = vec2(ui.available_width(), ui.style().spacing.interact_size.y); + ui.add_sized( + launch_args_size, + egui::TextEdit::singleline(&mut settings.launch_args).vertical_align(egui::Align::Center) + ); ui.separator(); - let button_size = vec2(100.0, 30.0); ui.label(&self.locale.localization.modals.game_settings.executable_override); @@ -816,7 +843,8 @@ impl MaximaEguiApp { ui.separator(); } - ui.allocate_space(ui.available_size_before_wrap() - vec2(0.0, ui.spacing().interact_size.y)); + let remaining_space = ui.available_size_before_wrap() - vec2(0.0, ui.spacing().interact_size.y); + ui.allocate_space(remaining_space); ui.horizontal(|ui| { ui.label(positional_replace!(self.locale.localization.modals.game_settings.version, "version", &game.version.installed)); @@ -891,7 +919,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; + + let _ = self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::LocateGameRequest(slug.clone(), self.installer_state.locate_path.clone(), wine_prefix)); self.installer_state.locating = true; } }); @@ -904,19 +941,39 @@ impl MaximaEguiApp { ui.horizontal(|ui| { ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::new(2.0, F9B233); ui.style_mut().visuals.widgets.hovered.expansion = -2.0; - ui.style_mut().visuals.widgets.hovered.rounding = Rounding::same(2.0); + ui.style_mut().visuals.widgets.hovered.corner_radius = CornerRadius::same(2); ui.add_sized(size, egui::TextEdit::singleline(&mut self.installer_state.install_folder).vertical_align(egui::Align::Center)); }); 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(); + let _ = self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::InstallGameRequest(game.offer.clone(), slug.clone(), path.join(slug), wine_prefix)); clear = true; } @@ -931,6 +988,22 @@ impl MaximaEguiApp { egui::Label::new(egui::RichText::new(&self.locale.localization.modals.game_install.fresh_path_invalid).color(Color32::RED)).ui(ui); } } + #[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 self.installer_state.should_close { clear = true; } @@ -958,7 +1031,11 @@ impl MaximaEguiApp { ui.label(positional_replace!(&self.locale.localization.modals.game_launch_out_of_date.comparison, "local", &game.version.installed, "online", &game.version.latest)); ui.with_layout(Layout::bottom_up(egui::Align::Min), |ui| { - if ui.add_sized([ui.available_size_before_wrap().x, ui.spacing().interact_size.y], egui::Button::new(&self.locale.localization.modals.game_launch_out_of_date.launch)).clicked() { + let launch_btn_size = [ui.available_size_before_wrap().x, ui.spacing().interact_size.y]; + if ui.add_sized( + launch_btn_size, + egui::Button::new(&self.locale.localization.modals.game_launch_out_of_date.launch) + ).clicked() { self.playing_game = Some(game.slug.clone()); let settings = self.settings.game_settings.get(&game.slug); let settings = if let Some(settings) = settings { @@ -967,10 +1044,7 @@ impl MaximaEguiApp { None }; let _ = self.backend.backend_commander.send( - crate::bridge_thread::MaximaLibRequest::StartGameRequest( - game.clone(), - settings, - ), + crate::bridge_thread::MaximaLibRequest::StartGameRequest(game.clone(), settings), ); clear = true } @@ -979,7 +1053,8 @@ impl MaximaEguiApp { }); } } - ui.allocate_space(ui.available_size_before_wrap()); + let final_fill_size = ui.available_size_before_wrap(); + ui.allocate_space(final_fill_size); }); }); } @@ -1009,49 +1084,50 @@ impl MaximaEguiApp { ) .clicked() { - self.backend + let _ = self + .backend .backend_commander - .send(bridge_thread::MaximaLibRequest::LoginRequestOauth) - .unwrap(); + .send(bridge_thread::MaximaLibRequest::LoginRequestOauth); self.backend_state = BackendStallState::LoggingIn; } } -} - -impl eframe::App for MaximaEguiApp { - fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, "settings", &self.settings); - } - - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - puffin::profile_function!(); - bridge_processor::frontend_processor(self, ctx); - event_processor::frontend_processor(self, ctx); - custom_window_frame( - self.critical_error.is_none(), - self.locale.localization.errors.critical_thread_crashed.clone(), - ctx, - frame, - "Maxima", - |ui| { - if let Some(render) = &self.app_bg_renderer { - let mut fullrect = ui.available_rect_before_wrap().clone(); - fullrect.min -= APP_MARGIN; - fullrect.max += APP_MARGIN; - let has_game_img = self.backend_state == BackendStallState::BingChilling - && self.games.len() > 0; - let gaming = self.page_view == PageType::Games && has_game_img; - let how_game: f32 = ctx - .animate_bool(egui::Id::new("MainAppBackgroundGamePageFadeBool"), gaming); - if has_game_img { - if self.game_sel.is_empty() && self.games.len() > 0 { - if let Some(key) = self.games.keys().next() { - self.game_sel = key.clone() - } - } + fn render_main_ui(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + if let Some(render) = &self.app_bg_renderer { + let mut fullrect = ui.available_rect_before_wrap(); + fullrect.min -= APP_MARGIN; + fullrect.max += APP_MARGIN; + + let has_game_img = + self.backend_state == BackendStallState::BingChilling && !self.games.is_empty(); + let gaming = self.page_view == PageType::Games && has_game_img; + let how_game: f32 = + ctx.animate_bool(egui::Id::new("MainAppBackgroundGamePageFadeBool"), gaming); + + if has_game_img { + if self.game_sel.is_empty() && !self.games.is_empty() { + if let Some(key) = self.games.keys().min_by(|a, b| { + let na = &self.games[*a].name; + let nb = &self.games[*b].name; + na.to_lowercase().cmp(&nb.to_lowercase()) + }) { + self.game_sel = key.clone(); + } + } - match &self.img_cache.get(ui_image::UIImageType::Background( + match &self.img_cache.get(ui_image::UIImageType::Background( + self.games[&self.game_sel].slug.clone(), + )) { + Some(tex) => render.draw( + ui, + fullrect, + tex.size_vec2(), + tex.id(), + how_game, + self.settings.performance_settings, + ), + None => { + match &self.img_cache.get(ui_image::UIImageType::Hero( self.games[&self.game_sel].slug.clone(), )) { Some(tex) => render.draw( @@ -1063,119 +1139,127 @@ impl eframe::App for MaximaEguiApp { self.settings.performance_settings, ), None => { - match &self.img_cache.get(ui_image::UIImageType::Hero( - self.games[&self.game_sel].slug.clone(), - )) { - Some(tex) => render.draw( - ui, - fullrect, - tex.size_vec2(), - tex.id(), - how_game, - self.settings.performance_settings, - ), - None => { - render.draw( - ui, - fullrect, - fullrect.size(), - TextureId::Managed(1), - 0.0, - self.settings.performance_settings, - ); - } - } + render.draw( + ui, + fullrect, + fullrect.size(), + TextureId::Managed(1), + 0.0, + self.settings.performance_settings, + ); } } - } else { - render.draw( - ui, - fullrect, - fullrect.size(), - TextureId::Managed(1), - 0.0, - self.settings.performance_settings, - ); } } - let app_rect = ui.available_rect_before_wrap().clone(); - match self.backend_state { - BackendStallState::Starting => { - ui.painter().text( - app_rect.center(), - Align2::CENTER_CENTER, - &self.locale.localization.startup_flow.starting, - FontId::proportional(30.0), - Color32::WHITE, - ); - ui.put(app_rect, egui::Spinner::new().size(300.0)); - } - BackendStallState::UserNeedsToInstallService => { - let main_block_rect = ui.painter().text( - app_rect.center(), - Align2::CENTER_BOTTOM, - &self.locale.localization.startup_flow.service_installer_description, - egui::FontId::proportional(20.0), - Color32::GRAY, - ); - ui.painter().text( - main_block_rect.center_top() - vec2(0.0, 4.0), - Align2::CENTER_BOTTOM, - &self.locale.localization.startup_flow.service_installer_header, - egui::FontId::proportional(30.0), - Color32::WHITE, - ); - let button_rect = Rect { - min: main_block_rect.center_bottom() + vec2(-60.0, 4.0), - max: main_block_rect.center_bottom() + vec2(60.0, 34.0), - }; - if ui - .put( - button_rect, - egui::Button::new( - &self - .locale - .localization - .startup_flow - .service_installer_button - .to_ascii_uppercase(), - ), - ) - .clicked() - { - self.backend - .backend_commander - .send(bridge_thread::MaximaLibRequest::StartService) - .unwrap(); - self.backend_state = BackendStallState::Starting; - } - } - BackendStallState::UserNeedsToLogIn => { - self.login(app_rect, ui); - } - BackendStallState::LoggingIn => { - ui.painter().text( - app_rect.center(), - Align2::CENTER_CENTER, - &self.locale.localization.startup_flow.logging_in, - FontId::proportional(30.0), - Color32::WHITE, - ); - ui.put(app_rect, egui::Spinner::new().size(300.0)); - } - BackendStallState::BingChilling => { - self.main(app_rect, ui); - } + } else { + render.draw( + ui, + fullrect, + fullrect.size(), + TextureId::Managed(1), + 0.0, + self.settings.performance_settings, + ); + } + } + + let app_rect = ui.available_rect_before_wrap(); + match self.backend_state { + BackendStallState::Starting => { + ui.painter().text( + app_rect.center(), + Align2::CENTER_CENTER, + &self.locale.localization.startup_flow.starting, + FontId::proportional(30.0), + Color32::WHITE, + ); + ui.put(app_rect, egui::Spinner::new().size(300.0)); + } + BackendStallState::UserNeedsToInstallService => { + let main_block_rect = ui.painter().text( + app_rect.center(), + Align2::CENTER_BOTTOM, + &self.locale.localization.startup_flow.service_installer_description, + egui::FontId::proportional(20.0), + Color32::GRAY, + ); + ui.painter().text( + main_block_rect.center_top() - vec2(0.0, 4.0), + Align2::CENTER_BOTTOM, + &self.locale.localization.startup_flow.service_installer_header, + egui::FontId::proportional(30.0), + Color32::WHITE, + ); + let button_rect = Rect { + min: main_block_rect.center_bottom() + vec2(-60.0, 4.0), + max: main_block_rect.center_bottom() + vec2(60.0, 34.0), }; - }, - ); - puffin::GlobalProfiler::lock().new_frame(); + if ui + .put( + button_rect, + egui::Button::new( + &self + .locale + .localization + .startup_flow + .service_installer_button + .to_ascii_uppercase(), + ), + ) + .clicked() + { + self.backend + .backend_commander + .send(bridge_thread::MaximaLibRequest::StartService) + .unwrap_or(()); + self.backend_state = BackendStallState::Starting; + } + } + BackendStallState::UserNeedsToLogIn => { + self.login(app_rect, ui); + } + BackendStallState::LoggingIn => { + ui.painter().text( + app_rect.center(), + Align2::CENTER_CENTER, + &self.locale.localization.startup_flow.logging_in, + FontId::proportional(30.0), + Color32::WHITE, + ); + ui.put(app_rect, egui::Spinner::new().size(300.0)); + } + BackendStallState::BingChilling => { + self.main(app_rect, ui); + } + }; + } +} + +impl eframe::App for MaximaEguiApp { + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, "settings", &self.settings); } fn on_exit(&mut self, _gl: Option<&glow::Context>) { - self.backend - .backend_commander - .send(bridge_thread::MaximaLibRequest::ShutdownRequest) - .unwrap(); + let _ = + self.backend.backend_commander.send(bridge_thread::MaximaLibRequest::ShutdownRequest); + } + + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + puffin::profile_function!(); + bridge_processor::frontend_processor(self, ui.ctx()); + event_processor::frontend_processor(self, ui.ctx()); + + let ctx = ui.ctx().clone(); + custom_window_frame( + self.critical_error.is_none(), + self.critical_error.as_ref().map(|e| format!("{:?}", e)).as_deref().unwrap_or(""), + ui, + frame, + "Maxima", + |ui_inner| { + self.render_main_ui(ui_inner, &ctx); + }, + ); } } diff --git a/maxima-ui/src/renderers/app_bg_renderer.rs b/maxima-ui/src/renderers/app_bg_renderer.rs index 3e0d492f..33c21fd4 100644 --- a/maxima-ui/src/renderers/app_bg_renderer.rs +++ b/maxima-ui/src/renderers/app_bg_renderer.rs @@ -1,12 +1,11 @@ -use eframe::egui_glow; use eframe::glow::{BLEND, TEXTURE_2D}; use egui::mutex::Mutex; use egui::{TextureId, Vec2}; -use egui_glow::glow; use log::error; use std::sync::Arc; use crate::FrontendPerformanceSettings; +use crate::egui_glow; /// FUCK pub struct AppBgRenderer { @@ -55,15 +54,15 @@ impl AppBgRenderer { #[allow(unsafe_code)] //MOM COME PICK ME UP, THEY'RE USING UNSAFE CODE struct ABGUnsafe { //I say this despite having used C++ for years before rust - program: glow::Program, - app_dimensions: glow::NativeUniformLocation, - img_dimensions: glow::NativeUniformLocation, - flags: glow::NativeUniformLocation, + program: egui_glow::glow::Program, + app_dimensions: egui_glow::glow::NativeUniformLocation, + img_dimensions: egui_glow::glow::NativeUniformLocation, + flags: egui_glow::glow::NativeUniformLocation, } impl ABGUnsafe { - fn new(gl: &glow::Context) -> Option { - use glow::HasContext as _; + fn new(gl: &egui_glow::glow::Context) -> Option { + use egui_glow::glow::HasContext as _; let glsl_version = egui_glow::ShaderVersion::get(gl); @@ -80,8 +79,8 @@ impl ABGUnsafe { let fsource = include_str!("../../shaders/abg.frag"); let shader_src = [ - (glow::VERTEX_SHADER, vsource), - (glow::FRAGMENT_SHADER, fsource), + (egui_glow::glow::VERTEX_SHADER, vsource), + (egui_glow::glow::FRAGMENT_SHADER, fsource), ]; let shaders: Vec<_> = shader_src @@ -130,15 +129,15 @@ impl ABGUnsafe { fn paint( &self, - gl: &glow::Context, + gl: &egui_glow::glow::Context, size: Vec2, img_size: Vec2, - img: glow::Texture, + img: egui_glow::glow::Texture, game_fade: f32, flags: FrontendPerformanceSettings, ) { puffin::profile_function!(); - use glow::HasContext as _; + use egui_glow::glow::HasContext as _; unsafe { gl.use_program(Some(self.program)); gl.uniform_3_f32(Some(&self.app_dimensions), size.x, size.y, game_fade); @@ -158,10 +157,10 @@ impl ABGUnsafe { //glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); //glBlendFuncSeparate(); /* - gl.blend_func(glow::FUNC_ADD, glow::ONE_MINUS_SRC_ALPHA); + gl.blend_func(egui_glow::glow::FUNC_ADD, egui_glow::glow::ONE_MINUS_SRC_ALPHA); */ - //gl.draw_arrays(glow::TRIANGLES, 6, 6); - gl.draw_arrays(glow::TRIANGLES, 0, 12); + //gl.draw_arrays(egui_glow::glow::TRIANGLES, 6, 6); + gl.draw_arrays(egui_glow::glow::TRIANGLES, 0, 12); } } } diff --git a/maxima-ui/src/renderers/game_view_bg_renderer.rs b/maxima-ui/src/renderers/game_view_bg_renderer.rs index f7c8690a..7b95d25b 100644 --- a/maxima-ui/src/renderers/game_view_bg_renderer.rs +++ b/maxima-ui/src/renderers/game_view_bg_renderer.rs @@ -2,7 +2,7 @@ // :) use eframe::egui_glow; -use eframe::glow::{NativeUniformLocation, BLEND, TEXTURE_2D}; +use eframe::glow::{BLEND, NativeUniformLocation, TEXTURE_2D}; use egui::mutex::Mutex; use egui::{TextureId, Vec2}; use egui_glow::glow; diff --git a/maxima-ui/src/translation_manager.rs b/maxima-ui/src/translation_manager.rs index 9e089c1b..9612daa1 100644 --- a/maxima-ui/src/translation_manager.rs +++ b/maxima-ui/src/translation_manager.rs @@ -97,6 +97,8 @@ pub struct LocalizedLocaleInfo { pub default: String, /// English (US) pub en_us: String, // I will deny all PRs that attempt to add british english. + /// French (France) + pub fr_fr: String, // Tabarnak j'veux pô ajouter l'canadien ça m'caliss, j'vais m'faire pogner par la police de la langue française } #[derive(Deserialize)] @@ -172,6 +174,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, } @@ -338,10 +342,11 @@ pub(crate) use positional_replace; impl TranslationManager { /// Gets an instance of LocalizedStrings for the specified locale code pub fn get_for_locale(code: &str) -> LocalizedStrings { - let english = include_str!("../res/locale/en_us.json").to_owned(); - let locale_json: &str = language_include_matcher!(code, &english; + let english = include_str!("../res/locale/en_us.json"); + let locale_json: &str = language_include_matcher!( + code, english; "en-US" => "en_us", - //"de-bug" => "de_bug" + "fr-FR" => "fr_fr", ); serde_json::from_str(locale_json).unwrap() @@ -358,6 +363,7 @@ impl TranslationManager { } } FrontendLanguage::EnUS => "en-US".to_owned(), + FrontendLanguage::FrFR => "fr-FR".to_owned(), }; let s = Self::get_for_locale(locale.as_str()); diff --git a/maxima-ui/src/ui_image.rs b/maxima-ui/src/ui_image.rs index 58e40e74..7ebaa2e1 100644 --- a/maxima-ui/src/ui_image.rs +++ b/maxima-ui/src/ui_image.rs @@ -5,18 +5,17 @@ use std::{ fmt::Display, fs, path::PathBuf, - sync::{ - mpsc::{Receiver, Sender}, - Arc, Mutex, - }, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc::{ + UnboundedReceiver, UnboundedSender, error::TryRecvError, unbounded_channel, }; use tokio::{fs::File, io}; -use core::slice::SlicePattern; use log::{debug, error, info}; -use image::io::Reader as ImageReader; -use maxima::util::native::{maxima_dir, NativeError, SafeStr}; +use image::ImageReader; +use maxima::util::native::{NativeError, SafeStr, maxima_dir}; #[derive(Clone, PartialEq, Eq, Hash, std::fmt::Debug)] pub enum UIImageType { @@ -43,7 +42,7 @@ impl Display for UIImageType { pub struct UIImageCache { cache: Arc>>>, // none represents loading, lack of represents untouched - commander: Sender, + commander: UnboundedSender, pub placeholder_avatar: TextureHandle, } @@ -78,9 +77,9 @@ pub enum ImageLoadError { } impl UIImageCache { - pub fn new(ctx: egui::Context) -> (Self, Sender) { + pub fn new(ctx: egui::Context) -> (Self, UnboundedSender) { let cache = Arc::new(Mutex::new(HashMap::new())); - let (tx, rx) = std::sync::mpsc::channel::(); + let (tx, rx) = unbounded_channel::(); let tx1 = tx.clone(); let yeah = load_image_bytes(include_bytes!("../res/usericon_tmp.png")).unwrap(); @@ -137,7 +136,7 @@ impl UIImageCache { // not sure why it *wouldn't* have a parent but i'm just being safe // i don't have a way to catch-all the slugs atm, so this is the better solution, it's infrequent and a non-ui thread anyway if !fs::metadata(&parent).is_ok() { - let res = fs::create_dir_all(&parent)?; + fs::create_dir_all(&parent)?; } } if let Some(remote) = remotes.get(&needle) { @@ -146,9 +145,10 @@ impl UIImageCache { let mut file = File::create(&path).await?; let body = result.bytes().await?; - io::copy(&mut body.as_slice(), &mut file).await?; + let mut body_sliced: &[u8] = body.as_ref(); + io::copy(&mut body_sliced, &mut file).await?; - let ci = load_image_bytes(&body)?; + let ci = load_image_bytes(body.as_ref())?; cache.lock().unwrap().insert( needle, Some(context.load_texture( @@ -201,17 +201,18 @@ impl UIImageCache { async fn run( context: egui::Context, - commander: Receiver, + mut commander: UnboundedReceiver, cache: Arc>>>, ) { let mut remotes: HashMap = HashMap::new(); 'outer: loop { match commander.try_recv() { - Err(error) => { - if error == std::sync::mpsc::TryRecvError::Disconnected { - break 'outer; - } + Err(TryRecvError::Disconnected) => { + break 'outer; + } + Err(TryRecvError::Empty) => { + tokio::task::yield_now().await; } Ok(request) => match request { UIImageCacheLoaderCommand::ProvideRemote(needle, target) => { diff --git a/maxima-ui/src/util/easy_mark/easy_mark_editor.rs b/maxima-ui/src/util/easy_mark/easy_mark_editor.rs new file mode 100644 index 00000000..9c0ea256 --- /dev/null +++ b/maxima-ui/src/util/easy_mark/easy_mark_editor.rs @@ -0,0 +1,276 @@ +// Copied from https://github.com/emilk/egui/tree/main/crates/egui_demo_lib/src/easy_mark +// Prevent using a whole demo lib for easy_mark. + +use egui::{ + Key, KeyboardShortcut, Modifiers, ScrollArea, TextBuffer, TextEdit, Ui, text::CCursorRange, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +#[serde(default)] +pub struct EasyMarkEditor { + code: String, + highlight_editor: bool, + show_rendered: bool, + + #[serde(skip)] + highlighter: crate::util::easy_mark::MemoizedEasymarkHighlighter, +} + +impl PartialEq for EasyMarkEditor { + fn eq(&self, other: &Self) -> bool { + (&self.code, self.highlight_editor, self.show_rendered) + == (&other.code, other.highlight_editor, other.show_rendered) + } +} + +impl Default for EasyMarkEditor { + fn default() -> Self { + Self { + code: DEFAULT_CODE.trim().to_owned(), + highlight_editor: true, + show_rendered: true, + highlighter: Default::default(), + } + } +} + +impl EasyMarkEditor { + pub fn panels(&mut self, ui: &mut egui::Ui) { + egui::CentralPanel::default().show(ui, |ui| { + self.ui(ui); + }); + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + egui::Grid::new("controls").show(ui, |ui| { + let _response = ui.button("Hotkeys").on_hover_ui(nested_hotkeys_ui); + ui.checkbox(&mut self.show_rendered, "Show rendered"); + ui.checkbox(&mut self.highlight_editor, "Highlight editor"); + egui::reset_button(ui, self, "Reset"); + ui.end_row(); + }); + ui.separator(); + + if self.show_rendered { + ui.columns(2, |columns| { + ScrollArea::vertical() + .id_salt("source") + .show(&mut columns[0], |ui| self.editor_ui(ui)); + ScrollArea::vertical().id_salt("rendered").show(&mut columns[1], |ui| { + // TODO(emilk): we can save some more CPU by caching the rendered output. + crate::util::easy_mark::easy_mark(ui, &self.code); + }); + }); + } else { + ScrollArea::vertical().id_salt("source").show(ui, |ui| self.editor_ui(ui)); + } + } + + fn editor_ui(&mut self, ui: &mut egui::Ui) { + let Self { + code, highlighter, .. + } = self; + + let response = if self.highlight_editor { + let mut layouter = |ui: &egui::Ui, easymark: &dyn TextBuffer, wrap_width: f32| { + let mut layout_job = highlighter.highlight(ui.style(), easymark.as_str()); + layout_job.wrap.max_width = wrap_width; + ui.fonts_mut(|f| f.layout_job(layout_job)) + }; + + ui.add( + egui::TextEdit::multiline(code) + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace) // for cursor height + .layouter(&mut layouter), + ) + } else { + ui.add(egui::TextEdit::multiline(code).desired_width(f32::INFINITY)) + }; + + if let Some(mut state) = TextEdit::load_state(ui.ctx(), response.id) + && let Some(mut ccursor_range) = state.cursor.char_range() + { + let any_change = shortcuts(ui, code, &mut ccursor_range); + if any_change { + state.cursor.set_char_range(Some(ccursor_range)); + state.store(ui.ctx(), response.id); + } + } + } +} + +pub const SHORTCUT_BOLD: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::B); +pub const SHORTCUT_CODE: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::N); +pub const SHORTCUT_ITALICS: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::I); +pub const SHORTCUT_SUBSCRIPT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::L); +pub const SHORTCUT_SUPERSCRIPT: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::COMMAND, Key::Y); +pub const SHORTCUT_STRIKETHROUGH: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::Q); +pub const SHORTCUT_UNDERLINE: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::W); +pub const SHORTCUT_INDENT: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::E); + +fn nested_hotkeys_ui(ui: &mut egui::Ui) { + egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { + let mut label = |shortcut, what| { + ui.label(what); + ui.weak(ui.ctx().format_shortcut(&shortcut)); + ui.end_row(); + }; + + label(SHORTCUT_BOLD, "*bold*"); + label(SHORTCUT_CODE, "`code`"); + label(SHORTCUT_ITALICS, "/italics/"); + label(SHORTCUT_SUBSCRIPT, "$subscript$"); + label(SHORTCUT_SUPERSCRIPT, "^superscript^"); + label(SHORTCUT_STRIKETHROUGH, "~strikethrough~"); + label(SHORTCUT_UNDERLINE, "_underline_"); + label(SHORTCUT_INDENT, "two spaces"); // Placeholder for tab indent + }); +} + +fn shortcuts(ui: &Ui, code: &mut dyn TextBuffer, ccursor_range: &mut CCursorRange) -> bool { + let mut any_change = false; + + if ui.input_mut(|i| i.consume_shortcut(&SHORTCUT_INDENT)) { + // This is a placeholder till we can indent the active line + any_change = true; + let [primary, _secondary] = ccursor_range.sorted_cursors(); + + let advance = code.insert_text(" ", primary.index); + ccursor_range.primary.index += advance; + ccursor_range.secondary.index += advance; + } + + for (shortcut, surrounding) in [ + (SHORTCUT_BOLD, "*"), + (SHORTCUT_CODE, "`"), + (SHORTCUT_ITALICS, "/"), + (SHORTCUT_SUBSCRIPT, "$"), + (SHORTCUT_SUPERSCRIPT, "^"), + (SHORTCUT_STRIKETHROUGH, "~"), + (SHORTCUT_UNDERLINE, "_"), + ] { + if ui.input_mut(|i| i.consume_shortcut(&shortcut)) { + any_change = true; + toggle_surrounding(code, ccursor_range, surrounding); + } + } + + any_change +} + +/// E.g. toggle *strong* with `toggle_surrounding(&mut text, &mut cursor, "*")` +fn toggle_surrounding( + code: &mut dyn TextBuffer, + ccursor_range: &mut CCursorRange, + surrounding: &str, +) { + let [primary, secondary] = ccursor_range.sorted_cursors(); + + let surrounding_ccount = surrounding.chars().count(); + + let prefix_crange = primary.index.saturating_sub(surrounding_ccount)..primary.index; + let suffix_crange = secondary.index..secondary.index.saturating_add(surrounding_ccount); + let already_surrounded = code.char_range(prefix_crange.clone()) == surrounding + && code.char_range(suffix_crange.clone()) == surrounding; + + if already_surrounded { + code.delete_char_range(suffix_crange); + code.delete_char_range(prefix_crange); + ccursor_range.primary.index -= surrounding_ccount; + ccursor_range.secondary.index -= surrounding_ccount; + } else { + code.insert_text(surrounding, secondary.index); + let advance = code.insert_text(surrounding, primary.index); + + ccursor_range.primary.index += advance; + ccursor_range.secondary.index += advance; + } +} + +// ---------------------------------------------------------------------------- + +const DEFAULT_CODE: &str = r#" +# EasyMark +EasyMark is a markup language, designed for extreme simplicity. + +``` +WARNING: EasyMark is still an evolving specification, +and is also missing some features. +``` + +---------------- + +# At a glance +- inline text: + - normal, `code`, *strong*, ~strikethrough~, _underline_, /italics/, ^raised^, $small$ + - `\` escapes the next character + - [hyperlink](https://github.com/emilk/egui) + - Embedded URL: +- `# ` header +- `---` separator (horizontal line) +- `> ` quote +- `- ` bullet list +- `1. ` numbered list +- \`\`\` code fence +- a^2^ + b^2^ = c^2^ +- $Remember to read the small print$ + +# Design +> /"Why do what everyone else is doing, when everyone else is already doing it?" +> \- Emil + +Goals: +1. easy to parse +2. easy to learn +3. similar to markdown + +[The reference parser](https://github.com/emilk/egui/blob/main/crates/egui_demo_lib/src/easy_mark/easy_mark_parser.rs) is \~250 lines of code, using only the Rust standard library. The parser uses no look-ahead or recursion. + +There is never more than one way to accomplish the same thing, and each special character is only used for one thing. For instance `*` is used for *strong* and `-` is used for bullet lists. There is no alternative way to specify the *strong* style or getting a bullet list. + +Similarity to markdown is kept when possible, but with much less ambiguity and some improvements (like _underlining_). + +# Details +All style changes are single characters, so it is `*strong*`, NOT `**strong**`. Style is reset by a matching character, or at the end of the line. + +Style change characters and escapes (`\`) work everywhere except for in inline code, code blocks and in URLs. + +You can mix styles. For instance: /italics _underline_/ and *strong `code`*. + +You can use styles on URLs: ~my webpage is at ~. + +Newlines are preserved. If you want to continue text on the same line, just do so. Alternatively, escape the newline by ending the line with a backslash (`\`). \ +Escaping the newline effectively ignores it. + +The style characters are chosen to be similar to what they are representing: + `_` = _underline_ + `~` = ~strikethrough~ (`-` is used for bullet points) + `/` = /italics/ + `*` = *strong* + `$` = $small$ + `^` = ^raised^ + +# To do +- Sub-headers (`## h2`, `### h3` etc) +- Hotkey Editor +- International keyboard algorithm for non-letter keys +- ALT+SHIFT+Num1 is not a functioning hotkey +- Tab Indent Increment/Decrement CTRL+], CTRL+[ + +- Images + - we want to be able to optionally specify size (width and\/or height) + - centering of images is very desirable + - captioning (image with a text underneath it) + - `![caption=My image][width=200][center](url)` ? +- Nicer URL:s + - `` and `[url](url)` do the same thing yet look completely different. + - let's keep similarity with images +- Tables +- Inspiration: +"#; diff --git a/maxima-ui/src/util/easy_mark/easy_mark_highlighter.rs b/maxima-ui/src/util/easy_mark/easy_mark_highlighter.rs new file mode 100644 index 00000000..cf960274 --- /dev/null +++ b/maxima-ui/src/util/easy_mark/easy_mark_highlighter.rs @@ -0,0 +1,192 @@ +// Copied from https://github.com/emilk/egui/tree/main/crates/egui_demo_lib/src/easy_mark +// Prevent using a whole demo lib for easy_mark. + +use crate::util::easy_mark::easy_mark_parser; + +/// Highlight easymark, memoizing previous output to save CPU. +/// +/// In practice, the highlighter is fast enough not to need any caching. +#[derive(Default)] +pub struct MemoizedEasymarkHighlighter { + style: egui::Style, + code: String, + output: egui::text::LayoutJob, +} + +impl MemoizedEasymarkHighlighter { + pub fn highlight(&mut self, egui_style: &egui::Style, code: &str) -> egui::text::LayoutJob { + if (&self.style, self.code.as_str()) != (egui_style, code) { + self.style = egui_style.clone(); + code.clone_into(&mut self.code); + self.output = highlight_easymark(egui_style, code); + } + self.output.clone() + } +} + +pub fn highlight_easymark(egui_style: &egui::Style, mut text: &str) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + let mut style = easy_mark_parser::Style::default(); + let mut start_of_line = true; + + while !text.is_empty() { + if start_of_line && text.starts_with("```") { + let end = text.find("\n```").map_or_else(|| text.len(), |i| i + 4); + job.append( + &text[..end], + 0.0, + format_from_style( + egui_style, + &easy_mark_parser::Style { + code: true, + ..Default::default() + }, + ), + ); + text = &text[end..]; + style = Default::default(); + continue; + } + + if text.starts_with('`') { + style.code = true; + let end = text[1..].find(&['`', '\n'][..]).map_or_else(|| text.len(), |i| i + 2); + job.append(&text[..end], 0.0, format_from_style(egui_style, &style)); + text = &text[end..]; + style.code = false; + continue; + } + + let mut skip; + + if text.starts_with('\\') && text.len() >= 2 { + skip = 2; + } else if start_of_line && text.starts_with(' ') { + // we don't preview indentation, because it is confusing + skip = 1; + } else if start_of_line && text.starts_with("# ") { + style.heading = true; + skip = 2; + } else if start_of_line && text.starts_with("> ") { + style.quoted = true; + skip = 2; + // we don't preview indentation, because it is confusing + } else if start_of_line && text.starts_with("- ") { + skip = 2; + // we don't preview indentation, because it is confusing + } else if text.starts_with('*') { + skip = 1; + if style.strong { + // Include the character that is ending this style: + job.append(&text[..skip], 0.0, format_from_style(egui_style, &style)); + text = &text[skip..]; + skip = 0; + } + style.strong ^= true; + } else if text.starts_with('$') { + skip = 1; + if style.small { + // Include the character that is ending this style: + job.append(&text[..skip], 0.0, format_from_style(egui_style, &style)); + text = &text[skip..]; + skip = 0; + } + style.small ^= true; + } else if text.starts_with('^') { + skip = 1; + if style.raised { + // Include the character that is ending this style: + job.append(&text[..skip], 0.0, format_from_style(egui_style, &style)); + text = &text[skip..]; + skip = 0; + } + style.raised ^= true; + } else { + skip = 0; + } + // Note: we don't preview underline, strikethrough and italics because it confuses things. + + // Swallow everything up to the next special character: + let line_end = text[skip..].find('\n').map_or_else(|| text.len(), |i| skip + i + 1); + let end = text[skip..] + .find(&['*', '`', '~', '_', '/', '$', '^', '\\', '<', '['][..]) + .map_or_else(|| text.len(), |i| (skip + i).max(1)); + + if line_end <= end { + job.append( + &text[..line_end], + 0.0, + format_from_style(egui_style, &style), + ); + text = &text[line_end..]; + start_of_line = true; + style = Default::default(); + } else { + job.append(&text[..end], 0.0, format_from_style(egui_style, &style)); + text = &text[end..]; + start_of_line = false; + } + } + + job +} + +fn format_from_style( + egui_style: &egui::Style, + emark_style: &easy_mark_parser::Style, +) -> egui::text::TextFormat { + use egui::{Align, Color32, Stroke, TextStyle}; + + let color = if emark_style.strong || emark_style.heading { + egui_style.visuals.strong_text_color() + } else if emark_style.quoted { + egui_style.visuals.weak_text_color() + } else { + egui_style.visuals.text_color() + }; + + let text_style = if emark_style.heading { + TextStyle::Heading + } else if emark_style.code { + TextStyle::Monospace + } else if emark_style.small | emark_style.raised { + TextStyle::Small + } else { + TextStyle::Body + }; + + let background = if emark_style.code { + egui_style.visuals.code_bg_color + } else { + Color32::TRANSPARENT + }; + + let underline = if emark_style.underline { + Stroke::new(1.0, color) + } else { + Stroke::NONE + }; + + let strikethrough = if emark_style.strikethrough { + Stroke::new(1.0, color) + } else { + Stroke::NONE + }; + + let valign = if emark_style.raised { + Align::TOP + } else { + Align::BOTTOM + }; + + egui::text::TextFormat { + font_id: text_style.resolve(egui_style), + color, + background, + italics: emark_style.italics, + underline, + strikethrough, + valign, + ..Default::default() + } +} diff --git a/maxima-ui/src/util/easy_mark/easy_mark_parser.rs b/maxima-ui/src/util/easy_mark/easy_mark_parser.rs new file mode 100644 index 00000000..29326ff8 --- /dev/null +++ b/maxima-ui/src/util/easy_mark/easy_mark_parser.rs @@ -0,0 +1,359 @@ +// Copied from https://github.com/emilk/egui/tree/main/crates/egui_demo_lib/src/easy_mark +// Prevent using a whole demo lib for easy_mark. + +//! A parser for `EasyMark`: a very simple markup language. +//! +//! WARNING: `EasyMark` is subject to change. +// +//! # `EasyMark` design goals: +//! 1. easy to parse +//! 2. easy to learn +//! 3. similar to markdown + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Item<'a> { + /// `\n` + // TODO(emilk): add Style here so empty heading still uses up the right amount of space. + Newline, + + /// Text + Text(Style, &'a str), + + /// title, url + Hyperlink(Style, &'a str, &'a str), + + /// leading space before e.g. a [`Self::BulletPoint`]. + Indentation(usize), + + /// > + QuoteIndent, + + /// - a point well made. + BulletPoint, + + /// 1. numbered list. The string is the number(s). + NumberedPoint(&'a str), + + /// --- + Separator, + + /// language, code + CodeBlock(&'a str, &'a str), +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct Style { + /// # heading (large text) + pub heading: bool, + + /// > quoted (slightly dimmer color or other font style) + pub quoted: bool, + + /// `code` (monospace, some other color) + pub code: bool, + + /// self.strong* (emphasized, e.g. bold) + pub strong: bool, + + /// _underline_ + pub underline: bool, + + /// ~strikethrough~ + pub strikethrough: bool, + + /// /italics/ + pub italics: bool, + + /// $small$ + pub small: bool, + + /// ^raised^ + pub raised: bool, +} + +/// Parser for the `EasyMark` markup language. +/// +/// See the module-level documentation for details. +/// +/// # Example: +/// ``` +/// # use egui_demo_lib::easy_mark::parser::Parser; +/// for item in Parser::new("Hello *world*!") { +/// } +/// +/// ``` +pub struct Parser<'a> { + /// The remainder of the input text + s: &'a str, + + /// Are we at the start of a line? + start_of_line: bool, + + /// Current self.style. Reset after a newline. + style: Style, +} + +impl<'a> Parser<'a> { + pub fn new(s: &'a str) -> Self { + Self { + s, + start_of_line: true, + style: Style::default(), + } + } + + /// `1. `, `42. ` etc. + fn numbered_list(&mut self) -> Option> { + let n_digits = self.s.chars().take_while(|c| c.is_ascii_digit()).count(); + if n_digits > 0 && self.s.chars().skip(n_digits).take(2).eq(". ".chars()) { + let number = &self.s[..n_digits]; + self.s = &self.s[(n_digits + 2)..]; + self.start_of_line = false; + return Some(Item::NumberedPoint(number)); + } + None + } + + // ```{language}\n{code}``` + fn code_block(&mut self) -> Option> { + if let Some(language_start) = self.s.strip_prefix("```") + && let Some(newline) = language_start.find('\n') + { + let language = &language_start[..newline]; + let code_start = &language_start[newline + 1..]; + if let Some(end) = code_start.find("\n```") { + let code = &code_start[..end].trim(); + self.s = &code_start[end + 4..]; + self.start_of_line = false; + return Some(Item::CodeBlock(language, code)); + } else { + self.s = ""; + return Some(Item::CodeBlock(language, code_start)); + } + } + None + } + + // `code` + fn inline_code(&mut self) -> Option> { + if let Some(rest) = self.s.strip_prefix('`') { + self.s = rest; + self.start_of_line = false; + self.style.code = true; + let rest_of_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())]; + if let Some(end) = rest_of_line.find('`') { + let item = Item::Text(self.style, &self.s[..end]); + self.s = &self.s[end + 1..]; + self.style.code = false; + return Some(item); + } else { + let end = rest_of_line.len(); + let item = Item::Text(self.style, rest_of_line); + self.s = &self.s[end..]; + self.style.code = false; + return Some(item); + } + } + None + } + + /// `` or `[link](url)` + fn url(&mut self) -> Option> { + if self.s.starts_with('<') { + let this_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())]; + if let Some(url_end) = this_line.find('>') { + let url = &self.s[1..url_end]; + self.s = &self.s[url_end + 1..]; + self.start_of_line = false; + return Some(Item::Hyperlink(self.style, url, url)); + } + } + + // [text](url) + if self.s.starts_with('[') { + let this_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())]; + if let Some(bracket_end) = this_line.find(']') { + let text = &this_line[1..bracket_end]; + if this_line[bracket_end + 1..].starts_with('(') + && let Some(parens_end) = this_line[bracket_end + 2..].find(')') + { + let parens_end = bracket_end + 2 + parens_end; + let url = &self.s[bracket_end + 2..parens_end]; + self.s = &self.s[parens_end + 1..]; + self.start_of_line = false; + return Some(Item::Hyperlink(self.style, text, url)); + } + } + } + None + } +} + +impl<'a> Iterator for Parser<'a> { + type Item = Item<'a>; + + fn next(&mut self) -> Option { + loop { + if self.s.is_empty() { + return None; + } + + // \n + if self.s.starts_with('\n') { + self.s = &self.s[1..]; + self.start_of_line = true; + self.style = Style::default(); + return Some(Item::Newline); + } + + // Ignore line break (continue on the same line) + if self.s.starts_with("\\\n") && self.s.len() >= 2 { + self.s = &self.s[2..]; + self.start_of_line = false; + continue; + } + + // \ escape (to show e.g. a backtick) + if self.s.starts_with('\\') && self.s.len() >= 2 { + let text = &self.s[1..2]; + self.s = &self.s[2..]; + self.start_of_line = false; + return Some(Item::Text(self.style, text)); + } + + if self.start_of_line { + // leading space (indentation) + if self.s.starts_with(' ') { + let length = self.s.find(|c| c != ' ').unwrap_or(self.s.len()); + self.s = &self.s[length..]; + self.start_of_line = true; // indentation doesn't count + return Some(Item::Indentation(length)); + } + + // # Heading + if let Some(after) = self.s.strip_prefix("# ") { + self.s = after; + self.start_of_line = false; + self.style.heading = true; + continue; + } + + // > quote + if let Some(after) = self.s.strip_prefix("> ") { + self.s = after; + self.start_of_line = true; // quote indentation doesn't count + self.style.quoted = true; + return Some(Item::QuoteIndent); + } + + // - bullet point + if self.s.starts_with("- ") { + self.s = &self.s[2..]; + self.start_of_line = false; + return Some(Item::BulletPoint); + } + + // `1. `, `42. ` etc. + if let Some(item) = self.numbered_list() { + return Some(item); + } + + // --- separator + if let Some(after) = self.s.strip_prefix("---") { + self.s = after.trim_start_matches('-'); // remove extra dashes + self.s = self.s.strip_prefix('\n').unwrap_or(self.s); // remove trailing newline + self.start_of_line = false; + return Some(Item::Separator); + } + + // ```{language}\n{code}``` + if let Some(item) = self.code_block() { + return Some(item); + } + } + + // `code` + if let Some(item) = self.inline_code() { + return Some(item); + } + + if let Some(rest) = self.s.strip_prefix('*') { + self.s = rest; + self.start_of_line = false; + self.style.strong = !self.style.strong; + continue; + } + if let Some(rest) = self.s.strip_prefix('_') { + self.s = rest; + self.start_of_line = false; + self.style.underline = !self.style.underline; + continue; + } + if let Some(rest) = self.s.strip_prefix('~') { + self.s = rest; + self.start_of_line = false; + self.style.strikethrough = !self.style.strikethrough; + continue; + } + if let Some(rest) = self.s.strip_prefix('/') { + self.s = rest; + self.start_of_line = false; + self.style.italics = !self.style.italics; + continue; + } + if let Some(rest) = self.s.strip_prefix('$') { + self.s = rest; + self.start_of_line = false; + self.style.small = !self.style.small; + continue; + } + if let Some(rest) = self.s.strip_prefix('^') { + self.s = rest; + self.start_of_line = false; + self.style.raised = !self.style.raised; + continue; + } + + // `` or `[link](url)` + if let Some(item) = self.url() { + return Some(item); + } + + // Swallow everything up to the next special character: + let end = self + .s + .find(&['*', '`', '~', '_', '/', '$', '^', '\\', '<', '[', '\n'][..]) + .map_or_else(|| self.s.len(), |special| special.max(1)); + + let item = Item::Text(self.style, &self.s[..end]); + self.s = &self.s[end..]; + self.start_of_line = false; + return Some(item); + } + } +} + +#[test] +fn test_easy_mark_parser() { + let items: Vec<_> = Parser::new("~strikethrough `code`~").collect(); + assert_eq!( + items, + vec![ + Item::Text( + Style { + strikethrough: true, + ..Default::default() + }, + "strikethrough " + ), + Item::Text( + Style { + code: true, + strikethrough: true, + ..Default::default() + }, + "code" + ), + ] + ); +} diff --git a/maxima-ui/src/util/easy_mark/easy_mark_viewer.rs b/maxima-ui/src/util/easy_mark/easy_mark_viewer.rs new file mode 100644 index 00000000..c28409b2 --- /dev/null +++ b/maxima-ui/src/util/easy_mark/easy_mark_viewer.rs @@ -0,0 +1,178 @@ +// Copied from https://github.com/emilk/egui/tree/main/crates/egui_demo_lib/src/easy_mark +// Prevent using a whole demo lib for easy_mark. + +use super::easy_mark_parser as easy_mark; +use egui::{ + Align, Align2, Hyperlink, Layout, Response, RichText, Sense, Separator, Shape, TextStyle, Ui, + vec2, +}; + +/// Parse and display a VERY simple and small subset of Markdown. +pub fn easy_mark(ui: &mut Ui, easy_mark: &str) { + easy_mark_it(ui, easy_mark::Parser::new(easy_mark)); +} + +pub fn easy_mark_it<'em>(ui: &mut Ui, items: impl Iterator>) { + let initial_size = vec2( + ui.available_width(), + ui.spacing().interact_size.y, // Assume there will be + ); + + let layout = Layout::left_to_right(Align::BOTTOM).with_main_wrap(true); + + ui.allocate_ui_with_layout(initial_size, layout, |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let row_height = ui.text_style_height(&TextStyle::Body); + ui.set_row_height(row_height); + + for item in items { + item_ui(ui, item); + } + }); +} + +pub fn item_ui(ui: &mut Ui, item: easy_mark::Item<'_>) { + let row_height = ui.text_style_height(&TextStyle::Body); + let one_indent = row_height / 2.0; + + match item { + easy_mark::Item::Newline => { + // ui.label("\n"); // too much spacing (paragraph spacing) + ui.allocate_exact_size(vec2(0.0, row_height), Sense::hover()); // make sure we take up some height + ui.end_row(); + ui.set_row_height(row_height); + } + + easy_mark::Item::Text(style, text) => { + let label = rich_text_from_style(text, &style); + if style.small && !style.raised { + ui.with_layout(Layout::left_to_right(Align::BOTTOM), |ui| { + ui.set_min_height(row_height); + ui.label(label); + }); + } else { + ui.label(label); + } + } + easy_mark::Item::Hyperlink(style, text, url) => { + let label = rich_text_from_style(text, &style); + if style.small && !style.raised { + ui.with_layout(Layout::left_to_right(Align::BOTTOM), |ui| { + ui.set_height(row_height); + ui.add(Hyperlink::from_label_and_url(label, url)); + }); + } else { + ui.add(Hyperlink::from_label_and_url(label, url)); + } + } + + easy_mark::Item::Separator => { + ui.add(Separator::default().horizontal()); + } + easy_mark::Item::Indentation(indent) => { + let indent = indent as f32 * one_indent; + ui.allocate_exact_size(vec2(indent, row_height), Sense::hover()); + } + easy_mark::Item::QuoteIndent => { + let rect = ui.allocate_exact_size(vec2(2.0 * one_indent, row_height), Sense::hover()).0; + let rect = rect.expand2(ui.style().spacing.item_spacing * 0.5); + ui.painter().line_segment( + [rect.center_top(), rect.center_bottom()], + (1.0, ui.visuals().weak_text_color()), + ); + } + easy_mark::Item::BulletPoint => { + ui.allocate_exact_size(vec2(one_indent, row_height), Sense::hover()); + bullet_point(ui, one_indent); + ui.allocate_exact_size(vec2(one_indent, row_height), Sense::hover()); + } + easy_mark::Item::NumberedPoint(number) => { + let width = 3.0 * one_indent; + numbered_point(ui, width, number); + ui.allocate_exact_size(vec2(one_indent, row_height), Sense::hover()); + } + easy_mark::Item::CodeBlock(_language, code) => { + let where_to_put_background = ui.painter().add(Shape::Noop); + let mut rect = ui.monospace(code).rect; + rect = rect.expand(1.0); // looks better + rect.max.x = ui.max_rect().max.x; + let code_bg_color = ui.visuals().code_bg_color; + ui.painter().set( + where_to_put_background, + Shape::rect_filled(rect, 1.0, code_bg_color), + ); + } + } +} + +fn rich_text_from_style(text: &str, style: &easy_mark::Style) -> RichText { + let easy_mark::Style { + heading, + quoted, + code, + strong, + underline, + strikethrough, + italics, + small, + raised, + } = *style; + + let small = small || raised; // Raised text is also smaller + + let mut rich_text = RichText::new(text); + if heading && !small { + rich_text = rich_text.heading().strong(); + } + if small && !heading { + rich_text = rich_text.small(); + } + if code { + rich_text = rich_text.code(); + } + if strong { + rich_text = rich_text.strong(); + } else if quoted { + rich_text = rich_text.weak(); + } + if underline { + rich_text = rich_text.underline(); + } + if strikethrough { + rich_text = rich_text.strikethrough(); + } + if italics { + rich_text = rich_text.italics(); + } + if raised { + rich_text = rich_text.raised(); + } + rich_text +} + +fn bullet_point(ui: &mut Ui, width: f32) -> Response { + let row_height = ui.text_style_height(&TextStyle::Body); + let (rect, response) = ui.allocate_exact_size(vec2(width, row_height), Sense::hover()); + ui.painter().circle_filled( + rect.center(), + rect.height() / 8.0, + ui.visuals().strong_text_color(), + ); + response +} + +fn numbered_point(ui: &mut Ui, width: f32, number: &str) -> Response { + let font_id = TextStyle::Body.resolve(ui.style()); + let row_height = ui.fonts_mut(|f| f.row_height(&font_id)); + let (rect, response) = ui.allocate_exact_size(vec2(width, row_height), Sense::hover()); + let text = format!("{number}."); + let text_color = ui.visuals().strong_text_color(); + ui.painter().text( + rect.right_center(), + Align2::RIGHT_CENTER, + text, + font_id, + text_color, + ); + response +} diff --git a/maxima-ui/src/util/easy_mark/mod.rs b/maxima-ui/src/util/easy_mark/mod.rs new file mode 100644 index 00000000..265421cf --- /dev/null +++ b/maxima-ui/src/util/easy_mark/mod.rs @@ -0,0 +1,14 @@ +// Copied from https://github.com/emilk/egui/tree/main/crates/egui_demo_lib/src/easy_mark +// Prevent using a whole demo lib for easy_mark. + +//! Experimental markup language + +mod easy_mark_editor; +mod easy_mark_highlighter; +pub mod easy_mark_parser; +mod easy_mark_viewer; + +pub use easy_mark_editor::EasyMarkEditor; +pub use easy_mark_highlighter::MemoizedEasymarkHighlighter; +pub use easy_mark_parser as parser; +pub use easy_mark_viewer::easy_mark; diff --git a/maxima-ui/src/util/markdown.rs b/maxima-ui/src/util/markdown.rs index 7c6c01c6..29dd9346 100644 --- a/maxima-ui/src/util/markdown.rs +++ b/maxima-ui/src/util/markdown.rs @@ -4,6 +4,8 @@ pub fn html_to_easymark(html: &str) -> String { .replace("\n", "") .replace("*", "\\*") .replace("
", "\n") + .replace("

", "") + .replace("
", "\n") .replace("", "*") .replace("", "*") .replace("", "") // WE DO NOT CARE 🗣️ diff --git a/maxima-ui/src/util/mod.rs b/maxima-ui/src/util/mod.rs index 163a4fba..23e313ed 100644 --- a/maxima-ui/src/util/mod.rs +++ b/maxima-ui/src/util/mod.rs @@ -1 +1,2 @@ +pub mod easy_mark; pub mod markdown; diff --git a/maxima-ui/src/views/downloads_view.rs b/maxima-ui/src/views/downloads_view.rs index 65cc692b..7f9a8cd9 100644 --- a/maxima-ui/src/views/downloads_view.rs +++ b/maxima-ui/src/views/downloads_view.rs @@ -1,8 +1,10 @@ -use egui::{pos2, vec2, Align2, Color32, FontId, Mesh, Rect, Rounding, Shape, Stroke, Ui, Widget}; +use crate::{APP_MARGIN, MaximaEguiApp, bridge_thread::MaximaLibRequest}; +use egui::{ + Align2, Color32, CornerRadius, FontId, Mesh, Rect, Shape, Stroke, StrokeKind, Ui, Widget, pos2, + vec2, +}; use humansize::DECIMAL; -use crate::{MaximaEguiApp, APP_MARGIN}; - #[derive(Clone)] pub struct QueuedDownload { pub slug: String, @@ -20,7 +22,9 @@ fn render_queued(app: &mut MaximaEguiApp, ui: &mut Ui, game: &QueuedDownload, is let container_size = vec2(ui.available_width(), 160.0); ui.allocate_ui(container_size, |ui| { let game_dl = game; - let game = app.games.get_mut(&game.slug).unwrap(); + let Some(game) = app.games.get_mut(&game_dl.slug) else { + return; // game not loaded yet, skip rendering this frame + }; let (hero, logo) = { ( app.img_cache.get(crate::ui_image::UIImageType::Hero(game.slug.clone())), @@ -32,19 +36,23 @@ fn render_queued(app: &mut MaximaEguiApp, ui: &mut Ui, game: &QueuedDownload, is max: ui.cursor().min + container_size, }; - let corner_radius = 8.0; + let corner_radius = 8; - ui.painter().rect_filled(rect, Rounding::same(corner_radius), Color32::BLACK); + ui.painter().rect_filled(rect, CornerRadius::same(corner_radius), Color32::BLACK); - let img_rounding = Rounding { + let img_rounding = CornerRadius { nw: corner_radius, - ne: 0.0, + ne: 0, sw: corner_radius, - se: 0.0, + se: 0, }; let img_response = if let Some(img) = hero { let img_size = vec2((160.0 / img.size_vec2().y) * img.size_vec2().x, 160.0); - ui.add(egui::Image::new((img.id(), img_size)).rounding(img_rounding).max_size(img_size)) + ui.add( + egui::Image::new((img.id(), img_size)) + .corner_radius(img_rounding) + .max_size(img_size), + ) } else { let response = ui.allocate_response(vec2(284.0, 16.0), egui::Sense::click()); ui.painter().rect_filled(response.rect, img_rounding, Color32::GRAY); @@ -84,9 +92,10 @@ fn render_queued(app: &mut MaximaEguiApp, ui: &mut Ui, game: &QueuedDownload, is ui.painter().rect( rect, - Rounding::same(corner_radius), + CornerRadius::same(corner_radius), Color32::TRANSPARENT, Stroke::new(2.0, Color32::WHITE), + StrokeKind::Outside, ); ui.painter().text( @@ -103,20 +112,21 @@ fn render_queued(app: &mut MaximaEguiApp, ui: &mut Ui, game: &QueuedDownload, is let button_size = 40.0; let right_button_rect = Rect { min: pos2( - rect.max.x - ((corner_radius - 1.0) + button_size), - rect.min.y + (corner_radius - 1.0), + rect.max.x - ((corner_radius as f32 - 1.0) + button_size), + rect.min.y + (corner_radius as f32 - 1.0), ), max: pos2( - rect.max.x - (corner_radius - 1.0), - rect.min.y + (corner_radius - 1.0) + button_size, + rect.max.x - (corner_radius as f32 - 1.0), + rect.min.y + (corner_radius as f32 - 1.0) + button_size, ), }; - let left_button_rect = right_button_rect.translate(vec2(0.0, button_size + corner_radius)); + let left_button_rect = + right_button_rect.translate(vec2(0.0, button_size + corner_radius as f32)); if is_current { let progress_bar_rect = Rect { min: img_response.rect.max + vec2(0.0, -18.0), - max: rect.max - vec2(corner_radius, corner_radius), + max: rect.max - vec2(corner_radius as f32, corner_radius as f32), }; let progress_bar_progress = Rect { @@ -131,11 +141,11 @@ fn render_queued(app: &mut MaximaEguiApp, ui: &mut Ui, game: &QueuedDownload, is ui.painter().rect_filled( progress_bar_rect, - Rounding::same(0.0), + CornerRadius::same(0), Color32::from_white_alpha(60), ); - ui.painter().rect_filled(progress_bar_progress, Rounding::same(0.0), Color32::WHITE); + ui.painter().rect_filled(progress_bar_progress, CornerRadius::same(0), Color32::WHITE); ui.painter().text( progress_bar_rect.min - vec2(0.0, 8.0), @@ -158,26 +168,44 @@ fn render_queued(app: &mut MaximaEguiApp, ui: &mut Ui, game: &QueuedDownload, is Color32::WHITE, ); + let offer = game_dl.offer.clone(); if ui.put(left_button_rect, egui::Button::new("🗙")).clicked() { - //TODO: Remove + let _ = app + .backend + .backend_commander + .send(MaximaLibRequest::CancelInstallRequest(offer)); } if ui.put(right_button_rect, egui::Button::new("⏸")).clicked() { - //TODO: Pause + let _ = app + .backend + .backend_commander + .send(MaximaLibRequest::PauseInstallRequest(game_dl.offer.clone())); + } + if ui.put(right_button_rect, egui::Button::new("⮉")).clicked() { + let _ = app.backend.backend_commander.send( + MaximaLibRequest::MoveInstallToTopRequest(game_dl.offer.clone()), + ); } } else { ui.painter().text( - img_response.rect.max + vec2(18.0, -corner_radius), + img_response.rect.max + vec2(18.0, -(corner_radius as f32)), Align2::LEFT_BOTTOM, "Queued", FontId::proportional(22.0), Color32::WHITE, ); + let offer = game_dl.offer.clone(); if ui.put(left_button_rect, egui::Button::new("🗙")).clicked() { - //TODO: Remove + let _ = app + .backend + .backend_commander + .send(MaximaLibRequest::CancelInstallRequest(offer)); } if ui.put(right_button_rect, egui::Button::new("⮉")).clicked() { - //TODO: Move to top + let _ = app.backend.backend_commander.send( + MaximaLibRequest::MoveInstallToTopRequest(game_dl.offer.clone()), + ); } } }); diff --git a/maxima-ui/src/views/friends_view.rs b/maxima-ui/src/views/friends_view.rs index 1afcf154..7f280653 100644 --- a/maxima-ui/src/views/friends_view.rs +++ b/maxima-ui/src/views/friends_view.rs @@ -1,10 +1,10 @@ -use egui::{pos2, vec2, Align2, Color32, FontId, Id, Rect, Rounding, Stroke, Ui, Vec2}; -use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +use egui::{Align2, Color32, CornerRadius, FontId, Id, Rect, Stroke, Ui, Vec2, pos2, vec2}; +use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use maxima::rtm::client::BasicPresence; use crate::{ - translation_manager::positional_replace, widgets::enum_dropdown::enum_dropdown, MaximaEguiApp, - FRIEND_INGAME_COLOR, + FRIEND_INGAME_COLOR, MaximaEguiApp, translation_manager::positional_replace, + widgets::enum_dropdown::enum_dropdown, }; use strum_macros::EnumIter; @@ -50,11 +50,12 @@ const DARK_GREY: Color32 = Color32::from_rgb(64, 64, 64); const PFP_SIZE: f32 = 36.0; const PFP_CORNER_RADIUS: f32 = 2.0; const PFP_ELEMENT_SIZE: f32 = PFP_SIZE + PFP_CORNER_RADIUS * 2.0; -const FRIEND_HIGHLIGHT_ROUNDING: Rounding = Rounding { - nw: 6.0, - ne: 4.0, - sw: 6.0, - se: 4.0, +const FRIEND_HIGHLIGHT_ROUNDING: CornerRadius = CornerRadius { + // le corner radius oui oui, le rounding is no exist non non + nw: 6, + ne: 4, + sw: 6, + se: 4, }; // the status border is flawed somehow, this "fixes" it slightly more than if i didn't const ITEM_SPACING: Vec2 = vec2(5.0, 5.0); @@ -106,25 +107,25 @@ pub fn friends_view(app: &mut MaximaEguiApp, ui: &mut Ui) { ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::WHITE); ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(2.0, DARK_GREY); - ui.visuals_mut().widgets.inactive.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.inactive.corner_radius = CornerRadius::same(2); ui.visuals_mut().widgets.active.bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.active.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.active.fg_stroke = Stroke::new(2.0, Color32::WHITE); ui.visuals_mut().widgets.active.bg_stroke = Stroke::new(2.0, DARK_GREY); - ui.visuals_mut().widgets.active.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.active.corner_radius = CornerRadius::same(2); ui.visuals_mut().widgets.hovered.bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.hovered.fg_stroke = Stroke::new(2.0, F9B233); ui.visuals_mut().widgets.hovered.bg_stroke = Stroke::new(2.0, F9B233); - ui.visuals_mut().widgets.hovered.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.hovered.corner_radius = CornerRadius::same(2); ui.visuals_mut().widgets.open.bg_fill = DARK_GREY; ui.visuals_mut().widgets.open.weak_bg_fill = DARK_GREY; ui.visuals_mut().widgets.open.fg_stroke = Stroke::new(2.0, Color32::WHITE); ui.visuals_mut().widgets.open.bg_stroke = Stroke::new(2.0, DARK_GREY); - ui.visuals_mut().widgets.open.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.open.corner_radius = CornerRadius::same(2); if ui.add_sized( [ui.available_width(), 20.0], @@ -188,13 +189,13 @@ pub fn friends_view(app: &mut MaximaEguiApp, ui: &mut Ui) { // scrollbar ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::WHITE; - ui.style_mut().visuals.widgets.inactive.rounding = Rounding::same(4.0); - ui.style_mut().visuals.widgets.active.rounding = Rounding::same(4.0); - ui.style_mut().visuals.widgets.hovered.rounding = Rounding::same(4.0); + ui.style_mut().visuals.widgets.inactive.corner_radius = CornerRadius::same(4); + ui.style_mut().visuals.widgets.active.corner_radius = CornerRadius::same(4); + ui.style_mut().visuals.widgets.hovered.corner_radius = CornerRadius::same(4); ui.style_mut().spacing.scroll.floating = false; let clip_rect = ui.available_rect_before_wrap().clone(); egui::ScrollArea::new([false,friend_rect_hovered]) - .id_source("FriendsListFriendListScrollArea") //hmm yes, the friends list is made of friends list + .id_salt("FriendsListFriendListScrollArea") //hmm yes, the friends list is made of friends list .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) .auto_shrink([false, false]) .max_width(max_width) @@ -299,7 +300,7 @@ pub fn friends_view(app: &mut MaximaEguiApp, ui: &mut Ui) { main_painter.image(app.img_cache.placeholder_avatar.id(), pfp_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } - main_painter.rect(outline_rect, Rounding::same(4.0), Color32::TRANSPARENT, Stroke::new(2.0, friend_color)); + main_painter.rect(outline_rect, CornerRadius::same(4), Color32::TRANSPARENT, Stroke::new(2.0, friend_color), egui::StrokeKind::Outside); let text_col = if main_res.is_pointer_button_down_on() || main_res.hovered() || buttons { Color32::BLACK diff --git a/maxima-ui/src/views/game_view.rs b/maxima-ui/src/views/game_view.rs index 5caae1ea..406e941f 100644 --- a/maxima-ui/src/views/game_view.rs +++ b/maxima-ui/src/views/game_view.rs @@ -1,14 +1,14 @@ +use crate::util::easy_mark::easy_mark; use crate::{ - bridge_thread, set_app_modal, translation_manager::TranslationManager, - widgets::enum_dropdown::enum_dropdown, GameDetails, GameDetailsWrapper, GameInfo, - InstallModalState, MaximaEguiApp, PageType, PopupModal, + GameDetails, GameDetailsWrapper, GameInfo, InstallModalState, MaximaEguiApp, PageType, + PopupModal, bridge_thread, set_app_modal, translation_manager::TranslationManager, + widgets::enum_dropdown::enum_dropdown, }; use egui::{ - pos2, vec2, Color32, Margin, Mesh, Pos2, Rect, RichText, Rounding, ScrollArea, Shape, Stroke, - Ui, Widget, + Color32, CornerRadius, Margin, Mesh, Pos2, Rect, RichText, ScrollArea, Shape, Stroke, Ui, + UiBuilder, UiKind, Widget, pos2, vec2, }; -use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; - +use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use strum_macros::EnumIter; #[derive(Debug, PartialEq, Default, EnumIter)] @@ -41,7 +41,7 @@ fn skeleton_text_block(ui: &mut egui::Ui, width: f32, height: f32) { let mut skeleton_rect = ui.available_rect_before_wrap(); skeleton_rect.set_width(width); skeleton_rect.set_height(height); - ui.painter().rect_filled(skeleton_rect, Rounding::same(2.0), SKELETON_TEXT_COLOR); + ui.painter().rect_filled(skeleton_rect, CornerRadius::same(2), SKELETON_TEXT_COLOR); ui.allocate_space(vec2(width, height)); } @@ -49,16 +49,16 @@ fn skeleton_text_block1(ui: &mut egui::Ui, width: f32, width1: f32, height: f32) let mut rect = ui.available_rect_before_wrap(); rect.set_width(width); rect.set_height(height); - ui.painter().rect_filled(rect, Rounding::same(2.0), SKELETON_INFO_COLOR); + ui.painter().rect_filled(rect, CornerRadius::same(2), SKELETON_INFO_COLOR); rect.min.x = rect.max.x + ui.spacing().item_spacing.x; rect.set_width(width1); - ui.painter().rect_filled(rect, Rounding::same(2.0), SKELETON_TEXT_COLOR); + ui.painter().rect_filled(rect, CornerRadius::same(2), SKELETON_TEXT_COLOR); ui.allocate_space(vec2(width + width1 + ui.spacing().item_spacing.x, height)); } fn game_view_action_button(text: String, ui: &mut Ui) -> bool { egui::Button::new(egui::RichText::new(text).size(20.0).color(Color32::WHITE)) - .rounding(Rounding::same(2.0)) + .corner_radius(CornerRadius::same(2)) .min_size(vec2(50.0, 40.0)) .ui(ui) .clicked() @@ -67,7 +67,7 @@ fn game_view_action_button(text: String, ui: &mut Ui) -> bool { fn game_view_action_buttons(app: &mut MaximaEguiApp, game: &GameInfo, ui: &mut Ui) { let localization = &app.locale.localization.games_view.main; let buttons_frame = egui::Frame::default() - .outer_margin(Margin::symmetric(0.0, 8.0)) + .outer_margin(Margin::symmetric(0, 8)) .fill(Color32::TRANSPARENT); buttons_frame.show(ui, |buttons| { puffin::profile_scope!("action buttons"); @@ -142,7 +142,7 @@ fn game_view_system_requirements( min.set_min_width(req_width); min.set_max_width(req_width); min.heading(&locale.localization.games_view.details.min_system_req); - egui_demo_lib::easy_mark::easy_mark(min, min_requirements); + easy_mark(min, min_requirements); }); } @@ -152,7 +152,7 @@ fn game_view_system_requirements( rec.set_min_width(req_width); rec.set_max_width(req_width); rec.heading(&locale.localization.games_view.details.rec_system_req); - egui_demo_lib::easy_mark::easy_mark(rec, rec_requirements); + easy_mark(rec, rec_requirements); }); } } else { @@ -190,10 +190,32 @@ fn game_view_system_requirements( pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { puffin::profile_function!(); - if app.games.len() < 1 { + if app.games.is_empty() { return; } - let game: GameInfo = if let Some(game) = app.games.get_mut(&app.game_sel) { + let game_details: Option = { + let game = if let Some(g) = app.games.get_mut(&app.game_sel) { + g + } else { + return; + }; + match &game.details { + GameDetailsWrapper::Unloaded => { + app.backend + .backend_commander + .send(bridge_thread::MaximaLibRequest::GetGameDetailsRequest( + game.slug.clone(), + )) + .unwrap_or_else(|_| ()); + game.details = GameDetailsWrapper::Loading; + None + } + GameDetailsWrapper::Loading => None, + GameDetailsWrapper::Available(details) => Some(details.clone()), + } + }; + + let game: GameInfo = if let Some(game) = app.games.get(&app.game_sel) { game.clone() } else { return; @@ -205,21 +227,6 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { ) }; - let game_details: Option = match &game.details { - GameDetailsWrapper::Unloaded => { - app.backend - .backend_commander - .send(bridge_thread::MaximaLibRequest::GetGameDetailsRequest( - game.slug.clone(), - )) - .unwrap(); - app.games.get_mut(&app.game_sel).unwrap().details = GameDetailsWrapper::Loading; - None - } - GameDetailsWrapper::Loading => None, - GameDetailsWrapper::Available(details) => Some(details.clone()), - }; - let mut hero_rect = Rect::clone(&ui.available_rect_before_wrap()); let aspect_ratio: f32 = if let Some(handle) = &hero { handle.aspect_ratio() @@ -239,15 +246,14 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { ui.vertical(|ui| { // scrollbar ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::WHITE; - ui.style_mut().visuals.widgets.inactive.rounding = Rounding::same(4.0); - ui.style_mut().visuals.widgets.active.rounding = Rounding::same(4.0); - ui.style_mut().visuals.widgets.hovered.rounding = Rounding::same(4.0); + ui.style_mut().visuals.widgets.inactive.corner_radius = CornerRadius::same(4); + ui.style_mut().visuals.widgets.active.corner_radius = CornerRadius::same(4); + ui.style_mut().visuals.widgets.hovered.corner_radius = CornerRadius::same(4); let mut logo_transition_frac = 0.0; - ScrollArea::vertical() - .auto_shrink(false) - .id_source("GameViewPanel_ScrollerArea") - .show(ui, |ui| { + ScrollArea::vertical().auto_shrink(false).id_salt("GameViewPanel_ScrollerArea").show( + ui, + |ui| { puffin::profile_scope!("details"); let hero_height_capped = hero_rect.size().y.max(0.0); ui.allocate_space(vec2(0.0, hero_height_capped)); @@ -278,56 +284,57 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { ); } } else { - ui.painter().rect_filled(hero_rect, Rounding::same(0.0), Color32::TRANSPARENT); + ui.painter().rect_filled( + hero_rect, + CornerRadius::same(0), + Color32::TRANSPARENT, + ); } - if hero_vis_frac < 1.0 - /* && hero.is_some() */ - { - // TODO: find a better solution - let mut mesh = Mesh::default(); - - let hero_tint = if ui.is_enabled() { + if hero_vis_frac < 1.0 { + let full_tint = if ui.is_enabled() { Color32::from_black_alpha(20) } else { Color32::from_black_alpha(174) }; - mesh.colored_vertex( - hero_rect.left_bottom() - vec2(0.0, hero_rect.height() * hero_vis_frac), - hero_tint, - ); - mesh.colored_vertex( - hero_rect.right_bottom() - vec2(0.0, hero_rect.height() * hero_vis_frac), - hero_tint, - ); - mesh.colored_vertex(hero_rect.right_top(), hero_tint); - mesh.colored_vertex(hero_rect.left_top(), hero_tint); + let fade_tint = Color32::from_black_alpha(0); + + let bottom_y = + hero_rect.left_bottom() - vec2(0.0, hero_rect.height() * hero_vis_frac); + + let mut mesh = Mesh::default(); + // bottom edge fades to transparent — reads as the hero "dissolving" + mesh.colored_vertex(bottom_y, fade_tint); + mesh.colored_vertex(bottom_y + vec2(hero_rect.width(), 0.0), fade_tint); + mesh.colored_vertex(hero_rect.right_top(), full_tint); + mesh.colored_vertex(hero_rect.left_top(), full_tint); mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); ui.painter().add(Shape::mesh(mesh)); } let avoid_scrollbar_margin = Margin { - left: 0.0, - right: ui.style().spacing.scroll.bar_width - + ui.style().spacing.scroll.bar_inner_margin, - top: 0.0, - bottom: 0.0, + left: 0, + right: (ui.style().spacing.scroll.bar_width + + ui.style().spacing.scroll.bar_inner_margin) + as i8, + top: 0, + bottom: 0, }; - let mut bar_rounding = Rounding::same(3.0); - bar_rounding.nw = 0.0; - bar_rounding.ne = 0.0; + let mut bar_rounding = CornerRadius::same(3); + bar_rounding.nw = 0; + bar_rounding.ne = 0; let play_bar_frame = egui::Frame::default() .outer_margin(avoid_scrollbar_margin) - .rounding(Rounding::ZERO); + .corner_radius(CornerRadius::ZERO); play_bar_frame.show(ui, |ui| { ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 0.0; let stats_frame = egui::Frame::default() .fill(Color32::WHITE) - .rounding(bar_rounding) - .inner_margin(Margin::same(4.0)); + .corner_radius(bar_rounding) + .inner_margin(Margin::same(4)); stats_frame.show(ui, |stats| { puffin::profile_scope!("stats"); stats.horizontal(|stats| { @@ -357,7 +364,7 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { ); stats.label( RichText::new(format!( - ": {:?} / {:?}", + ": {} / {}", details.achievements_unlocked, details.achievements_total )) @@ -368,7 +375,7 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { skeleton_rect.set_width(126.0); stats.painter().rect_filled( skeleton_rect, - Rounding::same(2.0), + CornerRadius::same(2), SKELETON_TEXT_COLOR, ); stats.allocate_space(vec2(126.0, 0.0)); @@ -377,7 +384,7 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { skeleton_rect.set_width(126.0); stats.painter().rect_filled( skeleton_rect, - Rounding::same(2.0), + CornerRadius::same(2), SKELETON_TEXT_COLOR, ); } @@ -403,39 +410,40 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { dlc.allocate_exact_size(vec2(400.0, 200.0), egui::Sense::click()); dlc.painter().rect_filled( rect, - Rounding::same(4.0), + CornerRadius::same(4), Color32::from_black_alpha(128), ); - dlc.allocate_ui_at_rect(rect, |dlc| { - let _ = match package.product().status() { - maxima::core::service_layer::ServiceOwnershipStatus::Active => true, - maxima::core::service_layer::ServiceOwnershipStatus::Disabled => false, - }; - - dlc.heading(format!("{}", package.offer().display_name())); + dlc.scope_builder(UiBuilder::new().max_rect(rect), |dlc| { + let is_active = matches!( + package.product().status(), + maxima::core::service_layer::ServiceOwnershipStatus::Active + ); + dlc.add_enabled_ui(is_active, |dlc| { + dlc.heading(package.offer().display_name()); + }); }); } }); { puffin::profile_scope!("filler"); - for _idx in 0..75 { - ui.heading(""); - } + ui.allocate_space(vec2( + ui.available_width(), + 75.0 * ui.text_style_height(&egui::TextStyle::Heading), + )); } }); - }); // ScrollArea + }, + ); // ScrollArea if let Some(handle) = logo { let logo_size_pre = if handle.size_vec2().x >= handle.size_vec2().y { - // wider than it is tall, scale based on X as max let mult_frac = 320.0 / handle.size_vec2().x; handle.size_vec2().y * mult_frac } else { - // taller than it is wide, scale based on Y - // fringe edge case, here in case EA decides they want to pull something really fucking stupid - 0.0 // TODO: CALCULATE IT + let mult_frac = 160.0 / handle.size_vec2().y; + handle.size_vec2().x * mult_frac }; - let frac2 = logo_transition_frac.clone(); + let frac2 = logo_transition_frac; let logo_size = vec2( egui::lerp(320.0..=160.0, frac2), egui::lerp(logo_size_pre..=(logo_size_pre / 2.0), frac2), @@ -456,24 +464,34 @@ pub fn game_view_details_panel(app: &mut MaximaEguiApp, ui: &mut Ui) { } fn game_list_button_context_menu(app: &MaximaEguiApp, game: &GameInfo, ui: &mut Ui) { - ui.add_enabled_ui(app.playing_game.is_none(), |play_button| { - if play_button.button("▶ Play").clicked() { - let settings = app.settings.game_settings.get(&game.slug); - let settings = if let Some(settings) = settings { - Some(settings.to_owned()) - } else { - None - }; - let _ = app.backend.backend_commander.send( - crate::bridge_thread::MaximaLibRequest::StartGameRequest(game.clone(), settings), - ); - play_button.close_menu(); - } - }); - ui.separator(); - if ui.button("UNINSTALL").clicked() { - ui.close_menu(); + let is_ood = !app.settings.ignore_ood_games && game.version.installed != game.version.latest; + if is_ood { + ui.add_enabled_ui(false, |play_button| { + play_button.button("⚠ Update required"); + }); + } else { + ui.add_enabled_ui(app.playing_game.is_none(), |play_button| { + if play_button.button("▶ Play").clicked() { + let settings = app.settings.game_settings.get(&game.slug); + let settings = if let Some(settings) = settings { + Some(settings.to_owned()) + } else { + None + }; + let _ = app.backend.backend_commander.send( + crate::bridge_thread::MaximaLibRequest::StartGameRequest( + game.clone(), + settings, + ), + ); + play_button.close_kind(UiKind::Menu); + } + }); } + ui.separator(); + ui.add_enabled_ui(false, |ui| { + ui.button("UNINSTALL (not yet implemented)"); + }); } const F9B233: Color32 = Color32::from_rgb(249, 178, 51); @@ -487,8 +505,8 @@ fn show_game_list_buttons(app: &mut MaximaEguiApp, ui: &mut Ui) { 10. * app.game_view_bar.game_size, ); ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::WHITE; //scroll bar - //create a rect that takes up all the vertical space in the window, and prohibits anything from going beyond that without us knowing, so we can add a scroll bar - //because apparently some dumb fucks (me) buy EA games and can overflow the list on the default window size + //create a rect that takes up all the vertical space in the window, and prohibits anything from going beyond that without us knowing, so we can add a scroll bar + //because apparently some dumb fucks (me) buy EA games and can overflow the list on the default window size ui.vertical(|ui| { ui.vertical(|filter_chunk| { filter_chunk.visuals_mut().extreme_bg_color = Color32::TRANSPARENT; @@ -499,25 +517,25 @@ fn show_game_list_buttons(app: &mut MaximaEguiApp, ui: &mut Ui) { filter_chunk.visuals_mut().widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::WHITE); filter_chunk.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(2.0, DARK_GREY); - filter_chunk.visuals_mut().widgets.inactive.rounding = Rounding::same(2.0); + filter_chunk.visuals_mut().widgets.inactive.corner_radius = CornerRadius::same(2); filter_chunk.visuals_mut().widgets.active.bg_fill = Color32::TRANSPARENT; filter_chunk.visuals_mut().widgets.active.weak_bg_fill = Color32::TRANSPARENT; filter_chunk.visuals_mut().widgets.active.fg_stroke = Stroke::new(2.0, Color32::WHITE); filter_chunk.visuals_mut().widgets.active.bg_stroke = Stroke::new(2.0, DARK_GREY); - filter_chunk.visuals_mut().widgets.active.rounding = Rounding::same(2.0); + filter_chunk.visuals_mut().widgets.active.corner_radius = CornerRadius::same(2); filter_chunk.visuals_mut().widgets.hovered.bg_fill = Color32::TRANSPARENT; filter_chunk.visuals_mut().widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; filter_chunk.visuals_mut().widgets.hovered.fg_stroke = Stroke::new(2.0, F9B233); filter_chunk.visuals_mut().widgets.hovered.bg_stroke = Stroke::new(2.0, F9B233); - filter_chunk.visuals_mut().widgets.hovered.rounding = Rounding::same(2.0); + filter_chunk.visuals_mut().widgets.hovered.corner_radius = CornerRadius::same(2); filter_chunk.visuals_mut().widgets.open.bg_fill = DARK_GREY; filter_chunk.visuals_mut().widgets.open.weak_bg_fill = DARK_GREY; filter_chunk.visuals_mut().widgets.open.fg_stroke = Stroke::new(2.0, Color32::WHITE); filter_chunk.visuals_mut().widgets.open.bg_stroke = Stroke::new(2.0, DARK_GREY); - filter_chunk.visuals_mut().widgets.open.rounding = Rounding::same(2.0); + filter_chunk.visuals_mut().widgets.open.corner_radius = CornerRadius::same(2); filter_chunk.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); @@ -555,9 +573,9 @@ fn show_game_list_buttons(app: &mut MaximaEguiApp, ui: &mut Ui) { // scrollbar ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::WHITE; - ui.style_mut().visuals.widgets.inactive.rounding = Rounding::same(4.0); - ui.style_mut().visuals.widgets.active.rounding = Rounding::same(4.0); - ui.style_mut().visuals.widgets.hovered.rounding = Rounding::same(4.0); + ui.style_mut().visuals.widgets.inactive.corner_radius = CornerRadius::same(4); + ui.style_mut().visuals.widgets.active.corner_radius = CornerRadius::same(4); + ui.style_mut().visuals.widgets.hovered.corner_radius = CornerRadius::same(4); egui::ScrollArea::vertical() .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) @@ -641,7 +659,7 @@ fn show_game_list_buttons(app: &mut MaximaEguiApp, ui: &mut Ui) { (egui::TextureId::Managed(0), vec2(0.0, 0.0)), name, ) - .rounding(Rounding::same(0.0)), + .corner_radius(CornerRadius::same(0)), ); list_response .context_menu(|ui| game_list_button_context_menu(app, game, ui)); @@ -657,7 +675,7 @@ fn show_game_list_buttons(app: &mut MaximaEguiApp, ui: &mut Ui) { pub fn games_view(app: &mut MaximaEguiApp, ui: &mut Ui) { puffin::profile_function!(); - if app.games.len() < 1 { + if app.games.is_empty() { ui.with_layout( egui::Layout::centered_and_justified(egui::Direction::RightToLeft), |ui| { @@ -679,11 +697,11 @@ pub fn games_view(app: &mut MaximaEguiApp, ui: &mut Ui) { max: ui.available_rect_before_wrap().max, }; - ui.allocate_ui_at_rect(list_rect, |list| { - show_game_list_buttons(app, list); + ui.scope_builder(UiBuilder::new().max_rect(list_rect), |ui| { + show_game_list_buttons(app, ui); }); - ui.allocate_ui_at_rect(game_rect, |games| { - game_view_details_panel(app, games); + ui.scope_builder(UiBuilder::new().max_rect(game_rect), |ui| { + game_view_details_panel(app, ui); }); } } diff --git a/maxima-ui/src/views/settings_view.rs b/maxima-ui/src/views/settings_view.rs index 4f36b128..5aad12b8 100644 --- a/maxima-ui/src/views/settings_view.rs +++ b/maxima-ui/src/views/settings_view.rs @@ -1,6 +1,6 @@ -use egui::{vec2, Ui}; +use egui::{Ui, vec2}; -use crate::{widgets::enum_dropdown::enum_dropdown, MaximaEguiApp}; +use crate::{MaximaEguiApp, widgets::enum_dropdown::enum_dropdown}; pub fn settings_view(app: &mut MaximaEguiApp, ui: &mut Ui) { let localization = &app.locale.localization.settings_view; @@ -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, diff --git a/maxima-ui/src/widgets/enum_dropdown.rs b/maxima-ui/src/widgets/enum_dropdown.rs index fc4efea7..7f007223 100644 --- a/maxima-ui/src/widgets/enum_dropdown.rs +++ b/maxima-ui/src/widgets/enum_dropdown.rs @@ -1,4 +1,4 @@ -use egui::{Color32, Rounding, Stroke}; +use egui::{Color32, CornerRadius, Stroke}; use crate::enum_locale_map::EnumToString; use strum::IntoEnumIterator; @@ -25,34 +25,34 @@ where ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::WHITE); ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(2.0, DARK_GREY); - ui.visuals_mut().widgets.inactive.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.inactive.corner_radius = CornerRadius::same(2); ui.visuals_mut().widgets.active.bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.active.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.active.fg_stroke = Stroke::new(2.0, Color32::WHITE); ui.visuals_mut().widgets.active.bg_stroke = Stroke::new(2.0, DARK_GREY); - ui.visuals_mut().widgets.active.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.active.corner_radius = CornerRadius::same(2); ui.visuals_mut().widgets.hovered.bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.hovered.fg_stroke = Stroke::new(2.0, F9B233); ui.visuals_mut().widgets.hovered.bg_stroke = Stroke::new(2.0, F9B233); - ui.visuals_mut().widgets.hovered.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.hovered.corner_radius = CornerRadius::same(2); ui.visuals_mut().widgets.open.bg_fill = DARK_GREY; ui.visuals_mut().widgets.open.weak_bg_fill = DARK_GREY; ui.visuals_mut().widgets.open.fg_stroke = Stroke::new(2.0, Color32::WHITE); ui.visuals_mut().widgets.open.bg_stroke = Stroke::new(2.0, DARK_GREY); - ui.visuals_mut().widgets.open.rounding = Rounding::same(2.0); + ui.visuals_mut().widgets.open.corner_radius = CornerRadius::same(2); egui::ComboBox::new(id + "combo", label) .width(width) .selected_text(enum_holder.get_string(val)) .show_ui(ui, |contents| { - let rounding = Rounding::same(2.0); - contents.visuals_mut().widgets.inactive.rounding = rounding; - contents.visuals_mut().widgets.active.rounding = rounding; - contents.visuals_mut().widgets.hovered.rounding = rounding; + let corner_radius = CornerRadius::same(2); + contents.visuals_mut().widgets.inactive.corner_radius = corner_radius; + contents.visuals_mut().widgets.active.corner_radius = corner_radius; + contents.visuals_mut().widgets.hovered.corner_radius = corner_radius; contents.visuals_mut().selection.bg_fill = F9B233; contents.visuals_mut().selection.stroke = Stroke::new(2.0, Color32::BLACK); contents.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(2.0, F9B233);