From cdeee17c2be5b8b9a333b977b3e2d373b94dfe0a Mon Sep 17 00:00:00 2001 From: bigfoot547 Date: Fri, 31 Jan 2025 02:32:19 -0600 Subject: do clippy stuff and change line endings --- src/auth.rs | 671 ++++++++++++++++++++--------------------- src/auth/mcservices.rs | 184 +++++------ src/auth/msa.rs | 7 +- src/auth/types.rs | 256 ++++++++-------- src/auth/types/property_map.rs | 100 +++--- src/launcher.rs | 59 ++-- src/launcher/assets.rs | 644 +++++++++++++++++++-------------------- src/launcher/constants.rs | 4 +- src/launcher/download.rs | 10 +- src/launcher/jre.rs | 19 +- src/launcher/jre/manifest.rs | 2 +- src/launcher/rules.rs | 2 +- src/launcher/runner.rs | 17 +- src/launcher/settings.rs | 4 +- src/launcher/strsub.rs | 4 +- src/launcher/version.rs | 22 +- src/version.rs | 16 +- src/version/manifest.rs | 2 +- 18 files changed, 1005 insertions(+), 1018 deletions(-) (limited to 'src') diff --git a/src/auth.rs b/src/auth.rs index 1eb65ed..7336e64 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,336 +1,335 @@ -mod types; -mod msa; -mod mcservices; - -use std::error::Error; -use std::fmt::{Display, Formatter}; -use std::future::Future; -use std::time::{Duration, SystemTime}; -use chrono::{DateTime, TimeDelta, Utc}; -use log::debug; -use oauth2::{AccessToken, DeviceAuthorizationUrl, DeviceCodeErrorResponse, EmptyExtraDeviceAuthorizationFields, EndpointNotSet, EndpointSet, HttpClientError, RefreshToken, RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl}; -use oauth2::basic::{BasicErrorResponse, BasicErrorResponseType, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, BasicTokenResponse}; -use reqwest::{IntoUrl, Method, RequestBuilder}; -pub use types::*; -use crate::auth::msa::{XSTS_RP_MINECRAFT_SERVICES, XSTS_RP_XBOX_LIVE}; -use crate::util::USER_AGENT; - -#[derive(Debug)] -pub enum AuthError { - // An unexpected error happened while performing a request - Request { what: &'static str, error: reqwest::Error }, - OAuthRequestToken { what: &'static str, error: RequestTokenError, BasicErrorResponse> }, - OAuthRequestDeviceCode { what: &'static str, error: RequestTokenError, DeviceCodeErrorResponse> }, - - // Some internal auth error (unrecoverable) - Internal(String), - - // Device code auth was cancelled - Cancel(Option>), - - // Device code auth timed out - Timeout, - - // Requires interactive authentication - RequireInteractive(&'static str), - - // XSTS error - AuthXError { what: &'static str, x_error: u64, message: Option }, - - // You don't own the game! - EntitlementError -} - -impl Display for AuthError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - AuthError::Request { what, error } => write!(f, "auth request error ({}): {}", what, error), - AuthError::OAuthRequestToken { what, error } => write!(f, "oauth error requesting token ({what}): {error}"), - AuthError::OAuthRequestDeviceCode { what, error } => write!(f, "oauth error with device code ({what}): {error}"), - AuthError::Internal(msg) => write!(f, "internal auth error: {}", msg), - AuthError::Cancel(Some(error)) => write!(f, "operation cancelled: {error}"), - AuthError::Cancel(None) => f.write_str("operation cancelled"), - AuthError::Timeout => f.write_str("interactive authentication timed out"), - AuthError::RequireInteractive(why) => write!(f, "user must log in interactively: {why}"), - AuthError::AuthXError { what, x_error, message } => write!(f, "XSTS error: {what} ({x_error} -> {})", message.as_ref().map_or("", |s| s.as_str())), - AuthError::EntitlementError => f.write_str("no minecraft entitlement (do you own the game?)") - } - } -} - -impl Error for AuthError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - AuthError::Request { error, .. } => Some(error), - AuthError::OAuthRequestToken { error, .. } => Some(error), - AuthError::OAuthRequestDeviceCode { error, .. } => Some(error), - AuthError::Cancel(Some(error)) => Some(error.as_ref()), - _ => None - } - } -} - -impl Token { - fn is_expired(&self, now: DateTime) -> bool { - self.expire.is_some_and(|exp| now >= exp) - } -} - -macro_rules! create_oauth_client { - ($is_azure_client_id:expr, $client_id:expr) => { - oauth2::Client::new($client_id) - .set_token_uri(TokenUrl::new(if $is_azure_client_id { AZURE_TOKEN_URL.into() } else { NON_AZURE_TOKEN_URL.into() }).expect("hardcoded url")) - .set_device_authorization_url(DeviceAuthorizationUrl::new(if $is_azure_client_id { AZURE_DEVICE_CODE_URL.into() } else { NON_AZURE_DEVICE_CODE_URL.into() }).expect("hardcoded url")) - as oauth2::Client - } -} - -const AZURE_TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; -const AZURE_DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; -const NON_AZURE_TOKEN_URL: &str = "https://login.live.com/oauth20_token.srf"; -const NON_AZURE_DEVICE_CODE_URL: &str = "https://login.live.com/oauth20_connect.srf"; - -const AZURE_LOGIN_SCOPES: &[&str] = ["XboxLive.signin", "offline_access"].as_slice(); -const NON_AZURE_LOGIN_SCOPES: &[&str] = ["service::user.auth.xboxlive.com::MBI_SSL"].as_slice(); - -fn build_json_request(client: &reqwest::Client, url: impl IntoUrl, method: Method) -> RequestBuilder { - client.request(method, url) - .header(reqwest::header::USER_AGENT, USER_AGENT) - .header(reqwest::header::ACCEPT, "application/json") -} - -impl MsaUser { - pub fn create_client() -> reqwest::Client { - reqwest::ClientBuilder::new() - .redirect(reqwest::redirect::Policy::none()) - .build().expect("building client should succeed") - } - - fn scopes_iter(&self) -> impl Iterator { - let to_scope = |f: &&str| Scope::new(String::from(*f)); - - if self.is_azure_client_id { - AZURE_LOGIN_SCOPES.iter().map(to_scope) - } else { - NON_AZURE_LOGIN_SCOPES.iter().map(to_scope) - } - } - - // uses an access token from, for example, a device code grant logs into xbox live - async fn xbl_login(&mut self, client: &reqwest::Client, token: &AccessToken) -> Result<(), AuthError> { - debug!("Logging into xbox live using access token"); - self.xbl_token = Some(msa::xbox_live_login(client, token, self.is_azure_client_id).await?); - - Ok(()) - } - - // logs into xbox live using a refresh token - // (panics if no refresh token present) - async fn xbl_login_refresh(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { - debug!("Using refresh token for XBL login"); - let oauth_client = create_oauth_client!(self.is_azure_client_id, self.client_id.clone()); - let refresh_token = self.refresh_token.as_ref().expect("refresh_access_token called with no refresh token"); - - let tokenres: BasicTokenResponse = oauth_client - .exchange_refresh_token(refresh_token) - .add_scopes(self.scopes_iter()) - .add_extra_param("response_type", "device_code") - .request_async(client) - .await.map_err(|e| AuthError::OAuthRequestToken { what: "refresh", error: e })?; - - self.refresh_token = tokenres.refresh_token().cloned(); - - self.xbl_login(client, tokenres.access_token()).await - } - - async fn xbl_login_device(&mut self, client: &reqwest::Client, handle_device: D) -> Result<(), AuthError> - where - D: FnOnce(StandardDeviceAuthorizationResponse) -> DF, - DF: Future - { - debug!("Using device authorization for XBL login"); - let oauth_client = create_oauth_client!(self.is_azure_client_id, self.client_id.clone()); - let device_auth: StandardDeviceAuthorizationResponse = oauth_client.exchange_device_code() - .add_scopes(self.scopes_iter()) - .add_extra_param("response_type", "device_code") - .request_async(client) - .await.map_err(|e| AuthError::OAuthRequestToken { what: "device code", error: e })?; - - handle_device(device_auth.clone()).await; - - let tokenres = oauth_client.exchange_device_access_token(&device_auth) - .set_max_backoff_interval(Duration::from_secs(20u64)) - .request_async(client, tokio::time::sleep, None) - .await.map_err(|e| AuthError::OAuthRequestDeviceCode { what: "device access code", error: e })?; - - self.refresh_token = tokenres.refresh_token().cloned(); - - self.xbl_login(client, tokenres.access_token()).await - } - - // ensure we have an xbox live token for this person - // tasks for this function: - // - check if the XBL token is valid/not expired - // - if it is expired, try to use refresh token to get a new one - // - get rid of auth token if yeah - async fn ensure_xbl(&mut self, client: &reqwest::Client, now: DateTime) -> Result<(), AuthError> { - if self.xbl_token.as_ref().is_some_and(|tok| !tok.is_expired(now)) { - debug!("XBL token valid. Using it."); - return Ok(()) - } - - if self.refresh_token.is_none() { - return Err(AuthError::RequireInteractive("no refresh token")); - } - - debug!("XBL token expired. Trying to refresh it."); - self.xbl_login_refresh(client).await - .map_err(|e| match &e { - AuthError::OAuthRequestToken { error, .. } => match error { - RequestTokenError::ServerResponse(res) => match res.error() { - BasicErrorResponseType::Extension(s) if s == "interaction_required" || s == "consent_required" => { - AuthError::RequireInteractive("msa requested interactive logon") - }, - _ => e - }, - _ => e - }, - _ => e - })?; - - self.mc_token = None; - - Ok(()) - } - - // function's tasks: - // - if the minecraft services token invalid/expired/missing, do the following - // - get minecraftservices xsts token - // - use minecraftservices to get mojang token with that xsts token - async fn ensure_mc_token(&mut self, client: &reqwest::Client, now: DateTime) -> Result<(), AuthError> { - if self.mc_token.as_ref().is_some_and(|tok| !tok.is_expired(now)) { - debug!("Mojang token valid. Using it."); - return Ok(()) - } - - debug!("Mojang token has expired. Must log in again."); - let xbl_token = self.xbl_token.as_ref().expect("ensure_mc_token requires xbl token").value.as_str(); - let (user_hash, mc_xsts_tok) = match msa::xsts_request(client, xbl_token, XSTS_RP_MINECRAFT_SERVICES).await? { - msa::XSTSAuthResponse::Success(res) => { - let user_hash = res.get_user_hash() - .map_or(Err(AuthError::Internal("malformed response: no user hash".into())), |h| Ok(h.to_owned()))?; - (user_hash, res.into_token()) - }, - msa::XSTSAuthResponse::Error(e) => return Err(e.into()) - }; - - debug!("Got MinecraftServices XSTS, logging in."); - self.mc_token = Some(mcservices::login_with_xbox(client, mc_xsts_tok.as_str(), user_hash.as_str()).await?); - - Ok(()) - } - - async fn load_xbox_info(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { - debug!("Loading Xbox info..."); - let xbl_token = self.xbl_token.as_ref().expect("xbl token missing").value.as_str(); - - let res = match msa::xsts_request(client, xbl_token, XSTS_RP_XBOX_LIVE).await? { - msa::XSTSAuthResponse::Success(res) => res, - msa::XSTSAuthResponse::Error(e) => return Err(e.into()) - }; - - let Some(xuid) = res.get_xuid() else { - return Err(AuthError::Internal("missing xuid for user".into())); - }; - - self.xuid = Some(xuid); - self.gamertag = res.get_gamertag().map(|s| s.to_owned()); - - debug!("Xbox info loaded: (xuid {xuid}, gamertag {})", res.get_gamertag().unwrap_or("")); - - Ok(()) - } - - async fn load_profile(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { - self.load_xbox_info(client).await?; - - let mc_token = self.mc_token.as_ref().expect("minecraft token missing").value.as_str(); - - if !mcservices::owns_the_game(client, mc_token).await? { - return Err(AuthError::EntitlementError); - } - - let player_info = mcservices::get_player_info(client, mc_token).await?; - let player_profile = mcservices::get_player_profile(client, player_info.id).await - .map_err(|e| AuthError::Request { what: "looking up profile", error: e })?; - - self.player_info = Some(player_info); - self.player_profile = Some(player_profile); - - Ok(()) - } - - async fn log_in_silent(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { - let now: DateTime = DateTime::from(SystemTime::now()) + TimeDelta::hours(12); - - self.ensure_xbl(client, now).await?; - self.ensure_mc_token(client, now).await?; - self.load_profile(client).await?; - - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[tokio::test] - async fn abc() { - simple_logger::SimpleLogger::new().with_colors(true).with_level(log::LevelFilter::Trace).init().unwrap(); - - let mut user = match tokio::fs::read_to_string("test_stuff/test.json").await { - Ok(s) => serde_json::from_str::(&s).unwrap(), - Err(e) if e.kind() == tokio::io::ErrorKind::NotFound => { - MsaUser { - player_profile: None, - xuid: None, - gamertag: None, - player_info: None, - client_id: ClientId::new("00000000402b5328".into()), - is_azure_client_id: false, - mc_token: None, - xbl_token: None, - refresh_token: None - } - }, - Err(e) => panic!("i/o error: {}", e) - }; - - let client = MsaUser::create_client(); - - loop { - match user.log_in_silent(&client).await { - Ok(_) => break, - Err(AuthError::RequireInteractive(s)) => { - debug!("Requires interactive auth: {s}") - }, - Err(e) => { - panic!("{}", e); - } - } - - user.xbl_login_device(&client, |d| async move { - let d = dbg!(d); - debug!("User code: {}", d.user_code().secret()); - () - }).await.unwrap() - } - - debug!("User: {user:?}"); - - let user_str = serde_json::to_string_pretty(&user).unwrap(); - tokio::fs::write("test_stuff/test.json", user_str.as_str()).await.unwrap(); - } -} +mod types; +mod msa; +mod mcservices; + +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::future::Future; +use std::time::{Duration, SystemTime}; +use chrono::{DateTime, TimeDelta, Utc}; +use log::debug; +use oauth2::{AccessToken, DeviceAuthorizationUrl, DeviceCodeErrorResponse, EndpointNotSet, EndpointSet, HttpClientError, RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardRevocableToken, TokenResponse, TokenUrl}; +use oauth2::basic::{BasicErrorResponse, BasicErrorResponseType, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, BasicTokenResponse}; +use reqwest::{IntoUrl, Method, RequestBuilder}; +pub use types::*; +use crate::auth::msa::{XSTS_RP_MINECRAFT_SERVICES, XSTS_RP_XBOX_LIVE}; +use crate::util::USER_AGENT; + +#[derive(Debug)] +pub enum AuthError { + // An unexpected error happened while performing a request + Request { what: &'static str, error: reqwest::Error }, + OAuthRequestToken { what: &'static str, error: RequestTokenError, BasicErrorResponse> }, + OAuthRequestDeviceCode { what: &'static str, error: RequestTokenError, DeviceCodeErrorResponse> }, + + // Some internal auth error (unrecoverable) + Internal(String), + + // Device code auth was cancelled + Cancel(Option>), + + // Device code auth timed out + Timeout, + + // Requires interactive authentication + RequireInteractive(&'static str), + + // XSTS error + AuthXError { what: &'static str, x_error: u64, message: Option }, + + // You don't own the game! + EntitlementError +} + +impl Display for AuthError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AuthError::Request { what, error } => write!(f, "auth request error ({}): {}", what, error), + AuthError::OAuthRequestToken { what, error } => write!(f, "oauth error requesting token ({what}): {error}"), + AuthError::OAuthRequestDeviceCode { what, error } => write!(f, "oauth error with device code ({what}): {error}"), + AuthError::Internal(msg) => write!(f, "internal auth error: {}", msg), + AuthError::Cancel(Some(error)) => write!(f, "operation cancelled: {error}"), + AuthError::Cancel(None) => f.write_str("operation cancelled"), + AuthError::Timeout => f.write_str("interactive authentication timed out"), + AuthError::RequireInteractive(why) => write!(f, "user must log in interactively: {why}"), + AuthError::AuthXError { what, x_error, message } => write!(f, "XSTS error: {what} ({x_error} -> {})", message.as_ref().map_or("", |s| s.as_str())), + AuthError::EntitlementError => f.write_str("no minecraft entitlement (do you own the game?)") + } + } +} + +impl Error for AuthError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + AuthError::Request { error, .. } => Some(error), + AuthError::OAuthRequestToken { error, .. } => Some(error), + AuthError::OAuthRequestDeviceCode { error, .. } => Some(error), + AuthError::Cancel(Some(error)) => Some(error.as_ref()), + _ => None + } + } +} + +impl Token { + fn is_expired(&self, now: DateTime) -> bool { + self.expire.is_some_and(|exp| now >= exp) + } +} + +macro_rules! create_oauth_client { + ($is_azure_client_id:expr, $client_id:expr) => { + oauth2::Client::new($client_id) + .set_token_uri(TokenUrl::new(if $is_azure_client_id { AZURE_TOKEN_URL.into() } else { NON_AZURE_TOKEN_URL.into() }).expect("hardcoded url")) + .set_device_authorization_url(DeviceAuthorizationUrl::new(if $is_azure_client_id { AZURE_DEVICE_CODE_URL.into() } else { NON_AZURE_DEVICE_CODE_URL.into() }).expect("hardcoded url")) + as oauth2::Client + } +} + +const AZURE_TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; +const AZURE_DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; +const NON_AZURE_TOKEN_URL: &str = "https://login.live.com/oauth20_token.srf"; +const NON_AZURE_DEVICE_CODE_URL: &str = "https://login.live.com/oauth20_connect.srf"; + +const AZURE_LOGIN_SCOPES: &[&str] = ["XboxLive.signin", "offline_access"].as_slice(); +const NON_AZURE_LOGIN_SCOPES: &[&str] = ["service::user.auth.xboxlive.com::MBI_SSL"].as_slice(); + +fn build_json_request(client: &reqwest::Client, url: impl IntoUrl, method: Method) -> RequestBuilder { + client.request(method, url) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .header(reqwest::header::ACCEPT, "application/json") +} + +impl MsaUser { + pub fn create_client() -> reqwest::Client { + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build().expect("building client should succeed") + } + + fn scopes_iter(&self) -> impl Iterator { + let to_scope = |f: &&str| Scope::new(String::from(*f)); + + if self.is_azure_client_id { + AZURE_LOGIN_SCOPES.iter().map(to_scope) + } else { + NON_AZURE_LOGIN_SCOPES.iter().map(to_scope) + } + } + + // uses an access token from, for example, a device code grant logs into xbox live + async fn xbl_login(&mut self, client: &reqwest::Client, token: &AccessToken) -> Result<(), AuthError> { + debug!("Logging into xbox live using access token"); + self.xbl_token = Some(msa::xbox_live_login(client, token, self.is_azure_client_id).await?); + + Ok(()) + } + + // logs into xbox live using a refresh token + // (panics if no refresh token present) + async fn xbl_login_refresh(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { + debug!("Using refresh token for XBL login"); + let oauth_client = create_oauth_client!(self.is_azure_client_id, self.client_id.clone()); + let refresh_token = self.refresh_token.as_ref().expect("refresh_access_token called with no refresh token"); + + let tokenres: BasicTokenResponse = oauth_client + .exchange_refresh_token(refresh_token) + .add_scopes(self.scopes_iter()) + .add_extra_param("response_type", "device_code") + .request_async(client) + .await.map_err(|e| AuthError::OAuthRequestToken { what: "refresh", error: e })?; + + self.refresh_token = tokenres.refresh_token().cloned(); + + self.xbl_login(client, tokenres.access_token()).await + } + + pub async fn xbl_login_device(&mut self, client: &reqwest::Client, handle_device: D) -> Result<(), AuthError> + where + D: FnOnce(StandardDeviceAuthorizationResponse) -> DF, + DF: Future + { + debug!("Using device authorization for XBL login"); + let oauth_client = create_oauth_client!(self.is_azure_client_id, self.client_id.clone()); + let device_auth: StandardDeviceAuthorizationResponse = oauth_client.exchange_device_code() + .add_scopes(self.scopes_iter()) + .add_extra_param("response_type", "device_code") + .request_async(client) + .await.map_err(|e| AuthError::OAuthRequestToken { what: "device code", error: e })?; + + handle_device(device_auth.clone()).await; + + let tokenres = oauth_client.exchange_device_access_token(&device_auth) + .set_max_backoff_interval(Duration::from_secs(20u64)) + .request_async(client, tokio::time::sleep, None) + .await.map_err(|e| AuthError::OAuthRequestDeviceCode { what: "device access code", error: e })?; + + self.refresh_token = tokenres.refresh_token().cloned(); + + self.xbl_login(client, tokenres.access_token()).await + } + + // ensure we have an xbox live token for this person + // tasks for this function: + // - check if the XBL token is valid/not expired + // - if it is expired, try to use refresh token to get a new one + // - get rid of auth token if yeah + async fn ensure_xbl(&mut self, client: &reqwest::Client, now: DateTime) -> Result<(), AuthError> { + if self.xbl_token.as_ref().is_some_and(|tok| !tok.is_expired(now)) { + debug!("XBL token valid. Using it."); + return Ok(()) + } + + if self.refresh_token.is_none() { + return Err(AuthError::RequireInteractive("no refresh token")); + } + + debug!("XBL token expired. Trying to refresh it."); + self.xbl_login_refresh(client).await + .map_err(|e| match &e { + AuthError::OAuthRequestToken { error: RequestTokenError::ServerResponse(res), .. } => match res.error() { + BasicErrorResponseType::Extension(s) if s == "interaction_required" || s == "consent_required" => { + AuthError::RequireInteractive("msa requested interactive logon") + }, + _ => e + }, + _ => e + })?; + + self.mc_token = None; + + Ok(()) + } + + // function's tasks: + // - if the minecraft services token invalid/expired/missing, do the following + // - get minecraftservices xsts token + // - use minecraftservices to get mojang token with that xsts token + async fn ensure_mc_token(&mut self, client: &reqwest::Client, now: DateTime) -> Result<(), AuthError> { + if self.mc_token.as_ref().is_some_and(|tok| !tok.is_expired(now)) { + debug!("Mojang token valid. Using it."); + return Ok(()) + } + + debug!("Mojang token has expired. Must log in again."); + let xbl_token = self.xbl_token.as_ref().expect("ensure_mc_token requires xbl token").value.as_str(); + let (user_hash, mc_xsts_tok) = match msa::xsts_request(client, xbl_token, XSTS_RP_MINECRAFT_SERVICES).await? { + msa::XSTSAuthResponse::Success(res) => { + let user_hash = res.get_user_hash() + .map_or(Err(AuthError::Internal("malformed response: no user hash".into())), |h| Ok(h.to_owned()))?; + (user_hash, res.into_token()) + }, + msa::XSTSAuthResponse::Error(e) => return Err(e.into()) + }; + + debug!("Got MinecraftServices XSTS, logging in."); + self.mc_token = Some(mcservices::login_with_xbox(client, mc_xsts_tok.as_str(), user_hash.as_str()).await?); + + Ok(()) + } + + async fn load_xbox_info(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { + debug!("Loading Xbox info..."); + let xbl_token = self.xbl_token.as_ref().expect("xbl token missing").value.as_str(); + + let res = match msa::xsts_request(client, xbl_token, XSTS_RP_XBOX_LIVE).await? { + msa::XSTSAuthResponse::Success(res) => res, + msa::XSTSAuthResponse::Error(e) => return Err(e.into()) + }; + + let Some(xuid) = res.get_xuid() else { + return Err(AuthError::Internal("missing xuid for user".into())); + }; + + self.xuid = Some(xuid); + self.gamertag = res.get_gamertag().map(|s| s.to_owned()); + + debug!("Xbox info loaded: (xuid {xuid}, gamertag {})", res.get_gamertag().unwrap_or("")); + + Ok(()) + } + + async fn load_profile(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { + self.load_xbox_info(client).await?; + + let mc_token = self.mc_token.as_ref().expect("minecraft token missing").value.as_str(); + + debug!("Checking if you own the game..."); + if !mcservices::owns_the_game(client, mc_token).await? { + return Err(AuthError::EntitlementError); + } + + debug!("Getting your profile info..."); + let player_info = mcservices::get_player_info(client, mc_token).await?; + let player_profile = mcservices::get_player_profile(client, player_info.id).await + .map_err(|e| AuthError::Request { what: "looking up profile", error: e })?; + + self.player_info = Some(player_info); + self.player_profile = Some(player_profile); + + Ok(()) + } + + pub async fn log_in_silent(&mut self, client: &reqwest::Client) -> Result<(), AuthError> { + let now: DateTime = DateTime::from(SystemTime::now()) + TimeDelta::hours(12); + + self.ensure_xbl(client, now).await?; + self.ensure_mc_token(client, now).await?; + self.load_profile(client).await?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use oauth2::ClientId; + use super::*; + + #[tokio::test] + async fn abc() { + simple_logger::SimpleLogger::new().with_colors(true).with_level(log::LevelFilter::Trace).init().unwrap(); + + let mut user = match tokio::fs::read_to_string("test_stuff/test.json").await { + Ok(s) => serde_json::from_str::(&s).unwrap(), + Err(e) if e.kind() == tokio::io::ErrorKind::NotFound => { + MsaUser { + player_profile: None, + xuid: None, + gamertag: None, + player_info: None, + client_id: ClientId::new("00000000402b5328".into()), + is_azure_client_id: false, + mc_token: None, + xbl_token: None, + refresh_token: None + } + }, + Err(e) => panic!("i/o error: {}", e) + }; + + let client = MsaUser::create_client(); + + loop { + match user.log_in_silent(&client).await { + Ok(_) => break, + Err(AuthError::RequireInteractive(s)) => { + debug!("Requires interactive auth: {s}") + }, + Err(e) => { + panic!("{}", e); + } + } + + user.xbl_login_device(&client, |d| async move { + let d = dbg!(d); + debug!("User code: {}", d.user_code().secret()); + }).await.unwrap() + } + + debug!("User: {user:?}"); + + let user_str = serde_json::to_string_pretty(&user).unwrap(); + tokio::fs::write("test_stuff/test.json", user_str.as_str()).await.unwrap(); + } +} diff --git a/src/auth/mcservices.rs b/src/auth/mcservices.rs index 4305363..45ef795 100644 --- a/src/auth/mcservices.rs +++ b/src/auth/mcservices.rs @@ -1,92 +1,92 @@ -use std::time::{Duration, SystemTime}; -use chrono::{DateTime, Utc}; -use reqwest::{IntoUrl, Method, RequestBuilder}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use super::{AuthError, MinecraftPlayerInfo, PlayerProfile}; -use super::types::Token; - -const MINECRAFT_LOGIN: &str = "https://api.minecraftservices.com/authentication/login_with_xbox"; -const MINECRAFT_ENTITLEMENTS: &str = "https://api.minecraftservices.com/entitlements"; -const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile"; - -const MINECRAFT_SESSION_PROFILE: &str = "https://sessionserver.mojang.com/session/minecraft/profile/"; - -fn build_authenticated(client: &reqwest::Client, url: impl IntoUrl, method: Method, mc_token: &str) -> RequestBuilder { - super::build_json_request(client, url, method) - .bearer_auth(mc_token) -} - -#[derive(Serialize, Debug)] -#[serde(rename_all = "camelCase")] -struct MinecraftXboxLoginRequest<'a> { - identity_token: &'a str, - ensure_legacy_enabled: bool -} - -#[derive(Deserialize, Debug)] -struct MinecraftXboxLoginResponse { - access_token: String, - expires_in: u64 -} - -pub async fn login_with_xbox(client: &reqwest::Client, xsts_token: &str, user_hash: &str) -> Result { - let tok = format!("XBL3.0 x={user_hash};{xsts_token}"); - let req = MinecraftXboxLoginRequest { - identity_token: tok.as_str(), - ensure_legacy_enabled: true - }; - - let res: MinecraftXboxLoginResponse = super::build_json_request(client, MINECRAFT_LOGIN, Method::POST) - .json(&req).send().await - .and_then(|r| r.error_for_status()) - .map_err(|e| AuthError::Request { what: "minecraft xbox login", error: e })? - .json().await - .map_err(|e| AuthError::Request { what: "minecraft xbox login (decode)", error: e })?; - - let now: DateTime = SystemTime::now().into(); - - Ok(Token { - value: res.access_token, - expire: Some(now + Duration::from_secs(res.expires_in)) - }) -} - -#[derive(Deserialize, Debug)] -struct EntitlementItem { - name: String - // we don't care about the signature -} - -#[derive(Deserialize, Debug)] -struct EntitlementResponse { - #[serde(default)] - items: Vec -} - -pub async fn owns_the_game(client: &reqwest::Client, token: &str) -> Result { - let res: EntitlementResponse = build_authenticated(client, MINECRAFT_ENTITLEMENTS, Method::GET, token) - .send().await - .and_then(|r| r.error_for_status()) - .map_err(|e| AuthError::Request { what: "entitlements", error: e })? - .json().await - .map_err(|e| AuthError::Request { what: "entitlements (receive)", error: e})?; - - Ok(res.items.iter().filter(|i| i.name == "game_minecraft" || i.name == "product_minecraft").next().is_some()) -} - -pub async fn get_player_info(client: &reqwest::Client, token: &str) -> Result { - build_authenticated(client, MINECRAFT_PROFILE, Method::GET, token) - .send().await - .and_then(|r| r.error_for_status()) - .map_err(|e| AuthError::Request { what: "player info", error: e })? - .json().await - .map_err(|e| AuthError::Request { what: "player info (receive)", error: e }) -} - -pub async fn get_player_profile(client: &reqwest::Client, uuid: Uuid) -> Result { - super::build_json_request(client, format!("{}{}?unsigned=false", MINECRAFT_SESSION_PROFILE, uuid.as_simple()), Method::GET) - .send().await - .and_then(|r| r.error_for_status())? - .json().await -} +use std::time::{Duration, SystemTime}; +use chrono::{DateTime, Utc}; +use reqwest::{IntoUrl, Method, RequestBuilder}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use super::{AuthError, MinecraftPlayerInfo, PlayerProfile}; +use super::types::Token; + +const MINECRAFT_LOGIN: &str = "https://api.minecraftservices.com/authentication/login_with_xbox"; +const MINECRAFT_ENTITLEMENTS: &str = "https://api.minecraftservices.com/entitlements"; +const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile"; + +const MINECRAFT_SESSION_PROFILE: &str = "https://sessionserver.mojang.com/session/minecraft/profile/"; + +fn build_authenticated(client: &reqwest::Client, url: impl IntoUrl, method: Method, mc_token: &str) -> RequestBuilder { + super::build_json_request(client, url, method) + .bearer_auth(mc_token) +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct MinecraftXboxLoginRequest<'a> { + identity_token: &'a str, + ensure_legacy_enabled: bool +} + +#[derive(Deserialize, Debug)] +struct MinecraftXboxLoginResponse { + access_token: String, + expires_in: u64 +} + +pub async fn login_with_xbox(client: &reqwest::Client, xsts_token: &str, user_hash: &str) -> Result { + let tok = format!("XBL3.0 x={user_hash};{xsts_token}"); + let req = MinecraftXboxLoginRequest { + identity_token: tok.as_str(), + ensure_legacy_enabled: true + }; + + let res: MinecraftXboxLoginResponse = super::build_json_request(client, MINECRAFT_LOGIN, Method::POST) + .json(&req).send().await + .and_then(|r| r.error_for_status()) + .map_err(|e| AuthError::Request { what: "minecraft xbox login", error: e })? + .json().await + .map_err(|e| AuthError::Request { what: "minecraft xbox login (decode)", error: e })?; + + let now: DateTime = SystemTime::now().into(); + + Ok(Token { + value: res.access_token, + expire: Some(now + Duration::from_secs(res.expires_in)) + }) +} + +#[derive(Deserialize, Debug)] +struct EntitlementItem { + name: String + // we don't care about the signature +} + +#[derive(Deserialize, Debug)] +struct EntitlementResponse { + #[serde(default)] + items: Vec +} + +pub async fn owns_the_game(client: &reqwest::Client, token: &str) -> Result { + let res: EntitlementResponse = build_authenticated(client, MINECRAFT_ENTITLEMENTS, Method::GET, token) + .send().await + .and_then(|r| r.error_for_status()) + .map_err(|e| AuthError::Request { what: "entitlements", error: e })? + .json().await + .map_err(|e| AuthError::Request { what: "entitlements (receive)", error: e})?; + + Ok(res.items.iter().any(|i| i.name == "game_minecraft" || i.name == "product_minecraft")) +} + +pub async fn get_player_info(client: &reqwest::Client, token: &str) -> Result { + build_authenticated(client, MINECRAFT_PROFILE, Method::GET, token) + .send().await + .and_then(|r| r.error_for_status()) + .map_err(|e| AuthError::Request { what: "player info", error: e })? + .json().await + .map_err(|e| AuthError::Request { what: "player info (receive)", error: e }) +} + +pub async fn get_player_profile(client: &reqwest::Client, uuid: Uuid) -> Result { + super::build_json_request(client, format!("{}{}?unsigned=false", MINECRAFT_SESSION_PROFILE, uuid.as_simple()), Method::GET) + .send().await + .and_then(|r| r.error_for_status())? + .json().await +} diff --git a/src/auth/msa.rs b/src/auth/msa.rs index 47e088b..404329b 100644 --- a/src/auth/msa.rs +++ b/src/auth/msa.rs @@ -112,8 +112,7 @@ impl XSTSAuthSuccessResponse { } fn get_display_claim(&self, name: &str) -> Option<&str> { - self.display_claims.xui.iter().filter(|m| m.contains_key(name)) - .next().map_or(None, |f| f.get(name).map(|s| s.as_str())) + self.display_claims.xui.iter().find(|m| m.contains_key(name)).and_then(|f| f.get(name).map(|s| s.as_str())) } pub(super) fn get_user_hash(&self) -> Option<&str> { @@ -121,8 +120,7 @@ impl XSTSAuthSuccessResponse { } pub(super) fn get_xuid(&self) -> Option { - self.get_display_claim("xid") - .map_or(None, |s| Some(Uuid::from_u64_pair(0, s.parse().ok()?))) + self.get_display_claim("xid").and_then(|s| s.parse().ok()).map(|n| Uuid::from_u64_pair(0, n)) } pub(super) fn get_gamertag(&self) -> Option<&str> { @@ -130,6 +128,7 @@ impl XSTSAuthSuccessResponse { } } +#[allow(clippy::from_over_into)] impl Into for XSTSAuthErrorResponse { fn into(self) -> AuthError { AuthError::AuthXError { diff --git a/src/auth/types.rs b/src/auth/types.rs index 348bdf8..79b89c9 100644 --- a/src/auth/types.rs +++ b/src/auth/types.rs @@ -1,128 +1,128 @@ -pub mod property_map; -pub use property_map::PropertyMap; - -use std::fmt::{Debug, Formatter}; -use chrono::{DateTime, Utc}; -use multimap::MultiMap; -use oauth2::RefreshToken; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Serialize, Deserialize)] -pub struct Property { - pub name: String, - pub value: String, - pub signature: Option -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PlayerProfile { - #[serde(with = "uuid::serde::simple")] - pub id: Uuid, - pub name: String, - - #[serde(default, skip_serializing_if = "MultiMap::is_empty", with = "property_map")] - pub properties: MultiMap -} - -#[derive(Serialize, Deserialize)] -pub(super) struct Token { - pub value: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub expire: Option> -} - -struct RedactedValue; -impl Debug for RedactedValue { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.write_str("[redacted]") - } -} - -impl Debug for Token { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Token") - .field("value", &RedactedValue) - .field("expire", &self.expire) - .finish() - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum SkinState { - Active, - Inactive -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum SkinVariant { - Classic, - Slim -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkinInfo { - pub id: Uuid, - pub state: SkinState, - pub url: String, - pub texture_key: Option, - pub variant: Option, - pub alias: Option -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MinecraftPlayerInfo { - #[serde(with = "uuid::serde::simple")] - pub id: Uuid, - pub name: String, - - #[serde(default)] - pub skins: Vec, - #[serde(default)] - pub capes: Vec, - - #[serde(default)] - pub demo: bool, - - #[serde(default)] - pub legacy: bool, - - // todo: profile actions (idk the format) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct MsaUser { - #[serde(skip_serializing_if = "Option::is_none")] - pub player_profile: Option, - pub xuid: Option, - pub gamertag: Option, - - #[serde(skip)] // this information is transient - pub player_info: Option, - - pub(super) client_id: oauth2::ClientId, - - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(super) is_azure_client_id: bool, - - pub(super) mc_token: Option, - pub(super) xbl_token: Option, - pub(super) refresh_token: Option -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum User { - Dummy(PlayerProfile), - MSA(MsaUser) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthenticationDatabase { - pub users: Vec -} +pub mod property_map; +pub use property_map::PropertyMap; + +use std::fmt::{Debug, Formatter}; +use chrono::{DateTime, Utc}; +use multimap::MultiMap; +use oauth2::RefreshToken; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Property { + pub name: String, + pub value: String, + pub signature: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlayerProfile { + #[serde(with = "uuid::serde::simple")] + pub id: Uuid, + pub name: String, + + #[serde(default, skip_serializing_if = "MultiMap::is_empty", with = "property_map")] + pub properties: PropertyMap +} + +#[derive(Serialize, Deserialize)] +pub(super) struct Token { + pub value: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub expire: Option> +} + +struct RedactedValue; +impl Debug for RedactedValue { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str("[redacted]") + } +} + +impl Debug for Token { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Token") + .field("value", &RedactedValue) + .field("expire", &self.expire) + .finish() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum SkinState { + Active, + Inactive +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum SkinVariant { + Classic, + Slim +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkinInfo { + pub id: Uuid, + pub state: SkinState, + pub url: String, + pub texture_key: Option, + pub variant: Option, + pub alias: Option +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftPlayerInfo { + #[serde(with = "uuid::serde::simple")] + pub id: Uuid, + pub name: String, + + #[serde(default)] + pub skins: Vec, + #[serde(default)] + pub capes: Vec, + + #[serde(default)] + pub demo: bool, + + #[serde(default)] + pub legacy: bool, + + // todo: profile actions (idk the format) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MsaUser { + #[serde(skip_serializing_if = "Option::is_none")] + pub player_profile: Option, + pub xuid: Option, + pub gamertag: Option, + + #[serde(skip)] // this information is transient + pub player_info: Option, + + pub(super) client_id: oauth2::ClientId, + + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(super) is_azure_client_id: bool, + + pub(super) mc_token: Option, + pub(super) xbl_token: Option, + pub(super) refresh_token: Option +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum User { + Dummy(PlayerProfile), + MSA(Box) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthenticationDatabase { + pub users: Vec +} diff --git a/src/auth/types/property_map.rs b/src/auth/types/property_map.rs index ff67416..964b06f 100644 --- a/src/auth/types/property_map.rs +++ b/src/auth/types/property_map.rs @@ -1,50 +1,50 @@ -use std::fmt::Formatter; -use multimap::MultiMap; -use serde::de::{SeqAccess, Visitor}; -use serde::{Deserializer, Serializer}; -use serde::ser::SerializeSeq; -use crate::auth::Property; - -pub type PropertyMap = MultiMap; - -pub fn serialize(value: &PropertyMap, serializer: S) -> Result -where - S: Serializer -{ - let mut seq = serializer.serialize_seq(Some(value.keys().len()))?; - - for (_, prop) in value.flat_iter() { - seq.serialize_element(prop)?; - } - - seq.end() -} - -pub fn deserialize<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de> -{ - struct PropertyMapVisitor; - - impl<'de> Visitor<'de> for PropertyMapVisitor { - type Value = PropertyMap; - - fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { - formatter.write_str("a property map") - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: SeqAccess<'de>, - { - let mut map = MultiMap::new() as PropertyMap; - while let Some(prop) = seq.next_element::()? { - map.insert(prop.name.clone(), prop); - } - - Ok(map) - } - } - - deserializer.deserialize_seq(PropertyMapVisitor) -} +use std::fmt::Formatter; +use multimap::MultiMap; +use serde::de::{SeqAccess, Visitor}; +use serde::{Deserializer, Serializer}; +use serde::ser::SerializeSeq; +use crate::auth::Property; + +pub type PropertyMap = MultiMap; + +pub fn serialize(value: &PropertyMap, serializer: S) -> Result +where + S: Serializer +{ + let mut seq = serializer.serialize_seq(Some(value.keys().len()))?; + + for (_, prop) in value.flat_iter() { + seq.serialize_element(prop)?; + } + + seq.end() +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de> +{ + struct PropertyMapVisitor; + + impl<'de> Visitor<'de> for PropertyMapVisitor { + type Value = PropertyMap; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a property map") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut map = MultiMap::new() as PropertyMap; + while let Some(prop) = seq.next_element::()? { + map.insert(prop.name.clone(), prop); + } + + Ok(map) + } + } + + deserializer.deserialize_seq(PropertyMapVisitor) +} diff --git a/src/launcher.rs b/src/launcher.rs index d414305..0f2b442 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -32,7 +32,7 @@ use tokio_stream::wrappers::ReadDirStream; use download::{MultiDownloader, VerifiedDownload}; use rules::{CompatCheck, IncompatibleError}; use version::{VersionList, VersionResolveError, VersionResult}; -use crate::version::{Library, OSRestriction, OperatingSystem, DownloadType, DownloadInfo, LibraryExtractRule, CompleteVersion, FeatureMatcher, ClientLogging}; +use crate::version::{Library, OSRestriction, OperatingSystem, DownloadType, LibraryExtractRule, FeatureMatcher, ClientLogging}; use assets::{AssetError, AssetRepository}; use crate::util::{self, AsJavaPath}; @@ -243,14 +243,13 @@ impl Launcher { let home = fs::canonicalize(home.as_ref()).await?; let versions_home = home.join("versions"); - let versions; debug!("Version list online?: {online}"); - if online { - versions = VersionList::online(versions_home.as_ref()).await?; + let versions = if online { + VersionList::online(versions_home.as_ref()).await? } else { - versions = VersionList::offline(versions_home.as_ref()).await?; - } + VersionList::offline(versions_home.as_ref()).await? + }; let assets_path = home.join("assets"); @@ -271,7 +270,7 @@ impl Launcher { } fn choose_lib_classifier<'lib>(&self, lib: &'lib Library) -> Option<&'lib str> { - lib.natives.as_ref().map_or(None, |n| n.get(&self.system_info.os)).map(|s| s.as_str()) + lib.natives.as_ref().and_then(|n| n.get(&self.system_info.os)).map(|s| s.as_str()) } async fn log_config_ensure(&self, config: &ClientLogging) -> Result { @@ -298,8 +297,8 @@ impl Launcher { debug!("Logger config {} is at {}", id, path.display()); - util::ensure_file(&path, dlinfo.url.as_ref().map(|s| s.as_str()), dlinfo.size, dlinfo.sha1, self.online, false).await - .map_err(|e| LaunchError::EnsureFile(e))?; + util::ensure_file(&path, dlinfo.url.as_deref(), dlinfo.size, dlinfo.sha1, self.online, false).await + .map_err(LaunchError::EnsureFile)?; struct PathSub<'a>(&'a Path); impl<'a> SubFunc<'a> for PathSub<'a> { @@ -345,15 +344,15 @@ impl Launcher { let ver_res = self.versions.get_version_lazy(version_id.as_ref()); let ver = match ver_res { - VersionResult::Remote(mv) => Cow::Owned(self.versions.load_remote_version(mv).await.map_err(|e| LaunchError::LoadVersion(e))?), + VersionResult::Remote(mv) => Cow::Owned(self.versions.load_remote_version(mv).await.map_err(LaunchError::LoadVersion)?), VersionResult::Complete(cv) => Cow::Borrowed(cv), VersionResult::None => { - return Err(LaunchError::UnknownVersion(version_id.into_owned()).into()) + return Err(LaunchError::UnknownVersion(version_id.into_owned())) } }; - let ver = self.versions.resolve_version(ver.as_ref()).await.map_err(|e| LaunchError::ResolveVersion(e))?; - ver.rules_apply(&self.system_info, &feature_matcher).map_err(|e| LaunchError::IncompatibleVersion(e))?; + let ver = self.versions.resolve_version(ver.as_ref()).await.map_err(LaunchError::ResolveVersion)?; + ver.rules_apply(&self.system_info, &feature_matcher).map_err(LaunchError::IncompatibleVersion)?; info!("Resolved launch version {}!", ver.id); @@ -409,7 +408,7 @@ impl Launcher { } let log_arg; - if let Some(logging) = ver.logging.as_ref().map_or(None, |l| l.client.as_ref()) { + if let Some(logging) = ver.logging.as_ref().and_then(|l| l.client.as_ref()) { log_arg = Some(self.log_config_ensure(logging).await?); } else { log_arg = None; @@ -421,9 +420,9 @@ impl Launcher { if let Some(idx_download) = ver.asset_index.as_ref() { let asset_idx_name = idx_download.id.as_ref().or(ver.assets.as_ref()).map(String::as_str); let asset_idx = self.assets.load_index(idx_download, asset_idx_name).await - .map_err(|e| LaunchError::Assets(e))?; + .map_err(LaunchError::Assets)?; - self.assets.ensure_assets(&asset_idx).await.map_err(|e| LaunchError::Assets(e))?; + self.assets.ensure_assets(&asset_idx).await.map_err(LaunchError::Assets)?; (asset_idx_name, Some(asset_idx)) } else { @@ -441,8 +440,8 @@ impl Launcher { info!("Downloading client jar {}", client_path.display()); - util::ensure_file(client_path.as_path(), client.url.as_ref().map(|s| s.as_str()), client.size, client.sha1, self.online, false).await - .map_err(|e| LaunchError::EnsureFile(e))?; + util::ensure_file(client_path.as_path(), client.url.as_deref(), client.size, client.sha1, self.online, false).await + .map_err(LaunchError::EnsureFile)?; client_jar_path = Some(client_path); } else { @@ -460,7 +459,7 @@ impl Launcher { let game_assets = if let Some(asset_idx) = asset_idx.as_ref() { info!("Reconstructing assets"); self.assets.reconstruct_assets(asset_idx, inst_home.as_path(), asset_idx_name).await - .map_err(|e| LaunchError::Assets(e))? + .map_err(LaunchError::Assets)? } else { None }; @@ -469,7 +468,7 @@ impl Launcher { let classpath = env::join_paths(downloads.values() .map(|job| job.get_path().as_java_path()) .chain(client_jar_path.iter().map(|p| p.as_path().as_java_path()))) - .map_err(|e| LaunchError::LibraryClasspathError(e))? + .map_err(LaunchError::LibraryClasspathError)? .into_string() .unwrap_or_else(|os| { warn!("Classpath contains invalid UTF-8. The game may not launch correctly."); @@ -524,7 +523,7 @@ impl Launcher { // yuck let jvm_args = profile.iter_arguments().map(OsString::from) - .chain(runner::build_arguments(&info, ver.as_ref(), ArgumentType::JVM).drain(..)) + .chain(runner::build_arguments(&info, ver.as_ref(), ArgumentType::Jvm).drain(..)) .chain(log_arg.iter().map(OsString::from)).collect(); let game_args = runner::build_arguments(&info, ver.as_ref(), ArgumentType::Game); @@ -572,7 +571,7 @@ struct LibraryExtractJob { rule: Option } -const ARCH_BITS: &'static str = formatcp!("{}", usize::BITS); +const ARCH_BITS: &str = formatcp!("{}", usize::BITS); impl LibraryRepository { fn get_artifact_base_dir(name: &str) -> Option { @@ -610,9 +609,7 @@ impl LibraryRepository { } fn get_artifact_path(name: &str, classifier: Option<&str>) -> Option { - let Some(mut p) = Self::get_artifact_base_dir(name) else { - return None; - }; + let mut p = Self::get_artifact_base_dir(name)?; p.push(Self::get_artifact_filename(name, classifier)?); Some(p) @@ -655,9 +652,9 @@ impl LibraryRepository { if !ftype.is_dir() { return Ok(false); } let Some(ftime) = entry.file_name().to_str() - .map_or(None, |s| constants::NATIVES_DIR_PATTERN.captures(s)) - .map_or(None, |c| c.get(1)) - .map_or(None, |cap| cap.as_str().parse::().ok()) else { + .and_then(|s| constants::NATIVES_DIR_PATTERN.captures(s)) + .and_then(|c| c.get(1)) + .and_then(|cap| cap.as_str().parse::().ok()) else { return Ok(false); }; @@ -684,7 +681,7 @@ impl LibraryRepository { }).await } - async fn extract_natives<'lib>(&self, libs: Vec) -> Result { + async fn extract_natives(&self, libs: Vec) -> Result { fs::create_dir_all(&self.natives).await.map_err(|e| LaunchError::IO { what: "creating natives directory", error: e @@ -706,8 +703,8 @@ impl LibraryRepository { debug!("Extracting natives for {}", job.source.display()); tally += extract::extract_zip(&job.source, &natives_dir, |name| job.rule.as_ref().is_none_or(|rules| - rules.exclude.iter().filter(|ex| - name.starts_with(ex.as_str())).next().is_none()))?; + rules.exclude.iter().any(|ex| + name.starts_with(ex.as_str()))))?; } Ok((natives_dir, tally)) diff --git a/src/launcher/assets.rs b/src/launcher/assets.rs index dacd01d..7c5dcf3 100644 --- a/src/launcher/assets.rs +++ b/src/launcher/assets.rs @@ -1,322 +1,322 @@ -use std::error::Error; -use std::ffi::OsStr; -use std::fmt::{Display, Formatter}; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::path::Component::Normal; -use futures::{stream, TryStreamExt}; -use log::{debug, info, warn}; -use reqwest::Client; -use sha1_smol::Sha1; -use tokio::{fs, io}; -use tokio::fs::File; -use crate::assets::{Asset, AssetIndex}; -use crate::launcher::download::{MultiDownloader, VerifiedDownload}; -use crate::util; -use crate::util::{FileVerifyError, IntegrityError}; -use crate::version::DownloadInfo; - -const INDEX_PATH: &'static str = "indexes"; -const OBJECT_PATH: &'static str = "objects"; - -pub struct AssetRepository { - online: bool, - home: PathBuf -} - -#[derive(Debug)] -pub enum AssetError { - InvalidId(Option), - IO { what: &'static str, error: io::Error }, - IndexParse(serde_json::Error), - Offline, - MissingURL, - DownloadIndex(reqwest::Error), - Integrity(IntegrityError), - AssetObjectDownload, - AssetVerifyError(FileVerifyError), - AssetNameError(&'static str) -} - -impl Display for AssetError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - AssetError::InvalidId(None) => f.write_str("missing asset index id"), - AssetError::InvalidId(Some(id)) => write!(f, "invalid asset index id: {}", id), - AssetError::IO { what, error } => write!(f, "i/o error ({}): {}", what, error), - AssetError::IndexParse(error) => write!(f, "error parsing asset index: {}", error), - AssetError::Offline => f.write_str("cannot download asset index while offline"), - AssetError::MissingURL => f.write_str("missing asset index URL"), - AssetError::DownloadIndex(e) => write!(f, "error downloading asset index: {}", e), - AssetError::Integrity(e) => write!(f, "asset index integrity error: {}", e), - AssetError::AssetObjectDownload => f.write_str("asset object download failed"), - AssetError::AssetVerifyError(e) => write!(f, "error verifying asset object: {e}"), - AssetError::AssetNameError(e) => write!(f, "invalid asset name: {e}") - } - } -} - -impl Error for AssetError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - AssetError::IO { error, .. } => Some(error), - AssetError::IndexParse(error) => Some(error), - AssetError::DownloadIndex(error) => Some(error), - AssetError::Integrity(error) => Some(error), - AssetError::AssetVerifyError(error) => Some(error), - _ => None - } - } -} - -impl From<(&'static str, io::Error)> for AssetError { - fn from((what, error): (&'static str, io::Error)) -> Self { - AssetError::IO { what, error } - } -} - -impl AssetRepository { - pub async fn new(online: bool, home: impl AsRef) -> Result { - let home = home.as_ref().to_owned(); - - match fs::create_dir_all(&home).await { - Ok(_) => (), - Err(e) => match e.kind() { - ErrorKind::AlreadyExists => (), - _ => return Err(e) - } - }; - - Ok(AssetRepository { - online, - home - }) - } - - pub fn get_home(&self) -> &Path { - self.home.as_path() - } - - fn get_index_path(&self, id: &str) -> Result { - let mut indexes_path: PathBuf = [self.home.as_ref(), OsStr::new(INDEX_PATH)].iter().collect(); - let Some(Normal(path)) = Path::new(id).components().last() else { - return Err(AssetError::InvalidId(Some(id.into()))); - }; - - let path = path.to_str().ok_or(AssetError::InvalidId(Some(path.to_string_lossy().into())))?; - - // FIXME: change this once "add_extension" is stabilized - indexes_path.push(format!("{}.json", path)); - - Ok(indexes_path) - } - - pub async fn load_index(&self, index: &DownloadInfo, id: Option<&str>) -> Result { - let Some(id) = id else { - return Err(AssetError::InvalidId(None)); - }; - - info!("Loading asset index {}", id); - - let path = self.get_index_path(id)?; - debug!("Asset index {} is located at {}", id, path.display()); - - match util::verify_file(&path, index.size, index.sha1).await { - Ok(_) => { - debug!("Asset index {} verified on disk. Loading it.", id); - let idx_data = fs::read_to_string(&path).await.map_err(|e| AssetError::IO { - what: "reading asset index", - error: e - })?; - - return Ok(serde_json::from_str(&idx_data).map_err(|e| AssetError::IndexParse(e))?); - }, - Err(FileVerifyError::Open(_, e)) => match e.kind() { - ErrorKind::NotFound => { - debug!("Asset index {} not found on disk. Must download it.", id); - }, - _ => return Err(("opening asset index", e).into()) - }, - Err(FileVerifyError::Integrity(_, e)) => { - info!("Asset index {} has mismatched integrity: {}, must download it.", id, e); - let _ = fs::remove_file(&path).await.map_err(|e| warn!("Error deleting modified index {}: {} (ignored)", id, e)); - }, - Err(FileVerifyError::Read(_, e)) => return Err(("reading asset index", e).into()) - } - - if !self.online { - warn!("Must download asset index {}, but the launcher is in offline mode. Please try again in online mode.", id); - return Err(AssetError::Offline); - } - - let Some(url) = index.url.as_ref() else { - return Err(AssetError::MissingURL); - }; - - debug!("Downloading asset index {} from {}", id, url); - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await.map_err(|e| AssetError::IO { - what: "creating asset index folder", - error: e - })?; - } - - let idx_text = reqwest::get(url).await - .map_err(|e| AssetError::DownloadIndex(e))? - .text().await - .map_err(|e| AssetError::DownloadIndex(e))?; - - if index.size.is_some_and(|s| s != idx_text.len()) { - return Err(AssetError::Integrity(IntegrityError::SizeMismatch { - expect: index.size.unwrap(), - actual: idx_text.len() - })); - } - - if let Some(expect) = index.sha1 { - let actual = Sha1::from(&idx_text).digest(); - - if actual != expect { - return Err(AssetError::Integrity(IntegrityError::Sha1Mismatch { expect, actual })); - } - } - - debug!("Saving downloaded asset index to {}", path.display()); - fs::write(&path, &idx_text).await.map_err(|e| AssetError::IO { - what: "writing asset index", - error: e - })?; - - Ok(serde_json::from_str(&idx_text).map_err(|e| AssetError::IndexParse(e))?) - } - - fn get_object_url(obj: &Asset) -> String { - format!("{}{:02x}/{}", super::constants::URL_RESOURCE_BASE, obj.hash.bytes()[0], obj.hash) - } - - pub fn get_object_path(&self, obj: &Asset) -> PathBuf { - let hex_digest = obj.hash.to_string(); - [self.home.as_ref(), OsStr::new(OBJECT_PATH), OsStr::new(&hex_digest[..2]), OsStr::new(&hex_digest)].iter().collect() - } - - async fn ensure_dir(path: impl AsRef) -> Result<(), io::Error> { - match fs::create_dir(path).await { - Ok(_) => Ok(()), - Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()), - Err(e) => Err(e) - } - } - - pub async fn ensure_assets(&self, index: &AssetIndex) -> Result<(), AssetError> { - let mut downloads = Vec::new(); - let objects_path = [self.home.as_ref(), OsStr::new(OBJECT_PATH)].iter().collect::(); - - Self::ensure_dir(&objects_path).await.map_err(|e| AssetError::IO { - what: "creating objects directory", - error: e - })?; - - for object in index.objects.values() { - let path = self.get_object_path(object); - - Self::ensure_dir(path.parent().unwrap()).await.map_err(|error| AssetError::IO { error, what: "creating directory for object" })?; - - downloads.push(VerifiedDownload::new(&Self::get_object_url(object), &path, Some(object.size), Some(object.hash))); - } - - if self.online { - info!("Downloading {} asset objects...", downloads.len()); - let client = Client::new(); - MultiDownloader::with_concurrent(downloads.iter_mut(), 32).perform(&client).await - .inspect_err(|e| warn!("asset download failed: {e}")) - .try_fold((), |_, _| async {Ok(())}) - .await - .map_err(|_| AssetError::AssetObjectDownload)?; - } else { - info!("Verifying {} asset objects...", downloads.len()); - super::download::verify_files(downloads.iter_mut()).await.map_err(|e| AssetError::AssetVerifyError(e))?; - } - - Ok(()) - } - - pub async fn reconstruct_assets(&self, index: &AssetIndex, instance_path: &Path, index_id: Option<&str>) -> Result, AssetError> { - let target_path: PathBuf; - let Some(index_id) = index_id else { - return Err(AssetError::InvalidId(None)); - }; - - if index.virtual_assets { - target_path = [self.home.as_ref(), OsStr::new("virtual"), OsStr::new(index_id)].iter().collect(); - } else if index.map_to_resources { - target_path = [instance_path, Path::new("resources")].iter().collect(); - } else { - info!("This asset index does not request a virtual assets folder. Nothing to be done."); - return Ok(None); - } - - info!("Reconstructing virtual assets for {}", index_id); - - fs::create_dir_all(&target_path).await.map_err(|e| AssetError::from(("creating virtual assets directory", e)))?; - - stream::iter(index.objects.values() - .map(|object| { - let obj_path = util::check_path(object.name.as_str()).map_err(AssetError::AssetNameError)?; - let obj_path = target_path.join(obj_path); - - Ok((object, obj_path)) - })) - .try_filter_map(|(object, obj_path)| async move { - match util::verify_file(&obj_path, Some(object.size), Some(object.hash)).await { - Ok(_) => { - debug!("Not copying asset {}, integrity matches.", object.name); - Ok(None) - } - Err(FileVerifyError::Open(_, e)) if e.kind() == ErrorKind::NotFound => { - debug!("Copying asset {}, file does not exist.", object.name); - Ok(Some((object, obj_path))) - }, - Err(FileVerifyError::Integrity(_, e)) => { - debug!("Copying asset {}: {}", object.name, e); - Ok(Some((object, obj_path))) - }, - Err(e) => { - debug!("Error while reconstructing assets: {e}"); - Err(AssetError::AssetVerifyError(e)) - } - } - }) - .try_for_each_concurrent(32, |(object, obj_path)| async move { - if let Some(parent) = obj_path.parent() { - fs::create_dir_all(parent).await - .inspect_err(|e| debug!("Error creating directory for asset object {}: {e}", object.name)) - .map_err(|e| AssetError::from(("creating asset object directory", e)))?; - } - - let mut fromfile = File::open(self.get_object_path(object)).await - .map_err(|e| AssetError::from(("opening source object", e)))?; - let mut tofile = File::create(&obj_path).await - .map_err(|e| AssetError::from(("creating target object", e)))?; - - io::copy(&mut fromfile, &mut tofile).await.map_err(|e| AssetError::from(("copying asset object", e)))?; - debug!("Copied object {} to {}.", object.name, obj_path.display()); - Ok(()) - }).await.map(|_| Some(target_path)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_it() { - let digest_str = "ad1115931887a73cd596300f2c93f84adf39521d"; - assert_eq!(AssetRepository::get_object_url(&Asset { - name: String::from("test"), - hash: digest_str.parse().unwrap(), - size: 0usize - }), "https://resources.download.minecraft.net/ad/ad1115931887a73cd596300f2c93f84adf39521d"); - } -} +use std::error::Error; +use std::ffi::OsStr; +use std::fmt::{Display, Formatter}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::path::Component::Normal; +use futures::{stream, TryStreamExt}; +use log::{debug, info, warn}; +use reqwest::Client; +use sha1_smol::Sha1; +use tokio::{fs, io}; +use tokio::fs::File; +use crate::assets::{Asset, AssetIndex}; +use crate::launcher::download::{MultiDownloader, VerifiedDownload}; +use crate::util; +use crate::util::{FileVerifyError, IntegrityError}; +use crate::version::DownloadInfo; + +const INDEX_PATH: &str = "indexes"; +const OBJECT_PATH: &str = "objects"; + +pub struct AssetRepository { + online: bool, + home: PathBuf +} + +#[derive(Debug)] +pub enum AssetError { + InvalidId(Option), + IO { what: &'static str, error: io::Error }, + IndexParse(serde_json::Error), + Offline, + MissingURL, + DownloadIndex(reqwest::Error), + Integrity(IntegrityError), + AssetObjectDownload, + AssetVerifyError(FileVerifyError), + AssetNameError(&'static str) +} + +impl Display for AssetError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AssetError::InvalidId(None) => f.write_str("missing asset index id"), + AssetError::InvalidId(Some(id)) => write!(f, "invalid asset index id: {}", id), + AssetError::IO { what, error } => write!(f, "i/o error ({}): {}", what, error), + AssetError::IndexParse(error) => write!(f, "error parsing asset index: {}", error), + AssetError::Offline => f.write_str("cannot download asset index while offline"), + AssetError::MissingURL => f.write_str("missing asset index URL"), + AssetError::DownloadIndex(e) => write!(f, "error downloading asset index: {}", e), + AssetError::Integrity(e) => write!(f, "asset index integrity error: {}", e), + AssetError::AssetObjectDownload => f.write_str("asset object download failed"), + AssetError::AssetVerifyError(e) => write!(f, "error verifying asset object: {e}"), + AssetError::AssetNameError(e) => write!(f, "invalid asset name: {e}") + } + } +} + +impl Error for AssetError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + AssetError::IO { error, .. } => Some(error), + AssetError::IndexParse(error) => Some(error), + AssetError::DownloadIndex(error) => Some(error), + AssetError::Integrity(error) => Some(error), + AssetError::AssetVerifyError(error) => Some(error), + _ => None + } + } +} + +impl From<(&'static str, io::Error)> for AssetError { + fn from((what, error): (&'static str, io::Error)) -> Self { + AssetError::IO { what, error } + } +} + +impl AssetRepository { + pub async fn new(online: bool, home: impl AsRef) -> Result { + let home = home.as_ref().to_owned(); + + match fs::create_dir_all(&home).await { + Ok(_) => (), + Err(e) => match e.kind() { + ErrorKind::AlreadyExists => (), + _ => return Err(e) + } + }; + + Ok(AssetRepository { + online, + home + }) + } + + pub fn get_home(&self) -> &Path { + self.home.as_path() + } + + fn get_index_path(&self, id: &str) -> Result { + let mut indexes_path: PathBuf = [self.home.as_ref(), OsStr::new(INDEX_PATH)].iter().collect(); + let Some(Normal(path)) = Path::new(id).components().last() else { + return Err(AssetError::InvalidId(Some(id.into()))); + }; + + let path = path.to_str().ok_or(AssetError::InvalidId(Some(path.to_string_lossy().into())))?; + + // FIXME: change this once "add_extension" is stabilized + indexes_path.push(format!("{}.json", path)); + + Ok(indexes_path) + } + + pub async fn load_index(&self, index: &DownloadInfo, id: Option<&str>) -> Result { + let Some(id) = id else { + return Err(AssetError::InvalidId(None)); + }; + + info!("Loading asset index {}", id); + + let path = self.get_index_path(id)?; + debug!("Asset index {} is located at {}", id, path.display()); + + match util::verify_file(&path, index.size, index.sha1).await { + Ok(_) => { + debug!("Asset index {} verified on disk. Loading it.", id); + let idx_data = fs::read_to_string(&path).await.map_err(|e| AssetError::IO { + what: "reading asset index", + error: e + })?; + + return serde_json::from_str(&idx_data).map_err(AssetError::IndexParse); + }, + Err(FileVerifyError::Open(_, e)) => match e.kind() { + ErrorKind::NotFound => { + debug!("Asset index {} not found on disk. Must download it.", id); + }, + _ => return Err(("opening asset index", e).into()) + }, + Err(FileVerifyError::Integrity(_, e)) => { + info!("Asset index {} has mismatched integrity: {}, must download it.", id, e); + let _ = fs::remove_file(&path).await.map_err(|e| warn!("Error deleting modified index {}: {} (ignored)", id, e)); + }, + Err(FileVerifyError::Read(_, e)) => return Err(("reading asset index", e).into()) + } + + if !self.online { + warn!("Must download asset index {}, but the launcher is in offline mode. Please try again in online mode.", id); + return Err(AssetError::Offline); + } + + let Some(url) = index.url.as_ref() else { + return Err(AssetError::MissingURL); + }; + + debug!("Downloading asset index {} from {}", id, url); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.map_err(|e| AssetError::IO { + what: "creating asset index folder", + error: e + })?; + } + + let idx_text = reqwest::get(url).await + .map_err(AssetError::DownloadIndex)? + .text().await + .map_err(AssetError::DownloadIndex)?; + + if index.size.is_some_and(|s| s != idx_text.len()) { + return Err(AssetError::Integrity(IntegrityError::SizeMismatch { + expect: index.size.unwrap(), + actual: idx_text.len() + })); + } + + if let Some(expect) = index.sha1 { + let actual = Sha1::from(&idx_text).digest(); + + if actual != expect { + return Err(AssetError::Integrity(IntegrityError::Sha1Mismatch { expect, actual })); + } + } + + debug!("Saving downloaded asset index to {}", path.display()); + fs::write(&path, &idx_text).await.map_err(|e| AssetError::IO { + what: "writing asset index", + error: e + })?; + + serde_json::from_str(&idx_text).map_err(AssetError::IndexParse) + } + + fn get_object_url(obj: &Asset) -> String { + format!("{}{:02x}/{}", super::constants::URL_RESOURCE_BASE, obj.hash.bytes()[0], obj.hash) + } + + pub fn get_object_path(&self, obj: &Asset) -> PathBuf { + let hex_digest = obj.hash.to_string(); + [self.home.as_ref(), OsStr::new(OBJECT_PATH), OsStr::new(&hex_digest[..2]), OsStr::new(&hex_digest)].iter().collect() + } + + async fn ensure_dir(path: impl AsRef) -> Result<(), io::Error> { + match fs::create_dir(path).await { + Ok(_) => Ok(()), + Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e) + } + } + + pub async fn ensure_assets(&self, index: &AssetIndex) -> Result<(), AssetError> { + let mut downloads = Vec::new(); + let objects_path = [self.home.as_ref(), OsStr::new(OBJECT_PATH)].iter().collect::(); + + Self::ensure_dir(&objects_path).await.map_err(|e| AssetError::IO { + what: "creating objects directory", + error: e + })?; + + for object in index.objects.values() { + let path = self.get_object_path(object); + + Self::ensure_dir(path.parent().unwrap()).await.map_err(|error| AssetError::IO { error, what: "creating directory for object" })?; + + downloads.push(VerifiedDownload::new(&Self::get_object_url(object), &path, Some(object.size), Some(object.hash))); + } + + if self.online { + info!("Downloading {} asset objects...", downloads.len()); + let client = Client::new(); + MultiDownloader::with_concurrent(downloads.iter_mut(), 32).perform(&client).await + .inspect_err(|e| warn!("asset download failed: {e}")) + .try_fold((), |_, _| async {Ok(())}) + .await + .map_err(|_| AssetError::AssetObjectDownload)?; + } else { + info!("Verifying {} asset objects...", downloads.len()); + super::download::verify_files(downloads.iter_mut()).await.map_err(AssetError::AssetVerifyError)?; + } + + Ok(()) + } + + pub async fn reconstruct_assets(&self, index: &AssetIndex, instance_path: &Path, index_id: Option<&str>) -> Result, AssetError> { + let target_path: PathBuf; + let Some(index_id) = index_id else { + return Err(AssetError::InvalidId(None)); + }; + + if index.virtual_assets { + target_path = [self.home.as_ref(), OsStr::new("virtual"), OsStr::new(index_id)].iter().collect(); + } else if index.map_to_resources { + target_path = [instance_path, Path::new("resources")].iter().collect(); + } else { + info!("This asset index does not request a virtual assets folder. Nothing to be done."); + return Ok(None); + } + + info!("Reconstructing virtual assets for {}", index_id); + + fs::create_dir_all(&target_path).await.map_err(|e| AssetError::from(("creating virtual assets directory", e)))?; + + stream::iter(index.objects.values() + .map(|object| { + let obj_path = util::check_path(object.name.as_str()).map_err(AssetError::AssetNameError)?; + let obj_path = target_path.join(obj_path); + + Ok((object, obj_path)) + })) + .try_filter_map(|(object, obj_path)| async move { + match util::verify_file(&obj_path, Some(object.size), Some(object.hash)).await { + Ok(_) => { + debug!("Not copying asset {}, integrity matches.", object.name); + Ok(None) + } + Err(FileVerifyError::Open(_, e)) if e.kind() == ErrorKind::NotFound => { + debug!("Copying asset {}, file does not exist.", object.name); + Ok(Some((object, obj_path))) + }, + Err(FileVerifyError::Integrity(_, e)) => { + debug!("Copying asset {}: {}", object.name, e); + Ok(Some((object, obj_path))) + }, + Err(e) => { + debug!("Error while reconstructing assets: {e}"); + Err(AssetError::AssetVerifyError(e)) + } + } + }) + .try_for_each_concurrent(32, |(object, obj_path)| async move { + if let Some(parent) = obj_path.parent() { + fs::create_dir_all(parent).await + .inspect_err(|e| debug!("Error creating directory for asset object {}: {e}", object.name)) + .map_err(|e| AssetError::from(("creating asset object directory", e)))?; + } + + let mut fromfile = File::open(self.get_object_path(object)).await + .map_err(|e| AssetError::from(("opening source object", e)))?; + let mut tofile = File::create(&obj_path).await + .map_err(|e| AssetError::from(("creating target object", e)))?; + + io::copy(&mut fromfile, &mut tofile).await.map_err(|e| AssetError::from(("copying asset object", e)))?; + debug!("Copied object {} to {}.", object.name, obj_path.display()); + Ok(()) + }).await.map(|_| Some(target_path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_it() { + let digest_str = "ad1115931887a73cd596300f2c93f84adf39521d"; + assert_eq!(AssetRepository::get_object_url(&Asset { + name: String::from("test"), + hash: digest_str.parse().unwrap(), + size: 0usize + }), "https://resources.download.minecraft.net/ad/ad1115931887a73cd596300f2c93f84adf39521d"); + } +} diff --git a/src/launcher/constants.rs b/src/launcher/constants.rs index db90d2f..4506ab5 100644 --- a/src/launcher/constants.rs +++ b/src/launcher/constants.rs @@ -7,8 +7,8 @@ pub const URL_JRE_MANIFEST: &str = "https://piston-meta.mojang.com/v1/products/j pub const NATIVES_PREFIX: &str = "natives-"; -pub const DEF_INSTANCE_NAME: &'static str = "default"; -pub const DEF_PROFILE_NAME: &'static str = "default"; +pub const DEF_INSTANCE_NAME: &str = "default"; +pub const DEF_PROFILE_NAME: &str = "default"; // https://github.com/unmojang/FjordLauncher/pull/14/files // https://login.live.com/oauth20_authorize.srf?client_id=00000000402b5328&redirect_uri=ms-xal-00000000402b5328://auth&response_type=token&display=touch&scope=service::user.auth.xboxlive.com::MBI_SSL%20offline_access&prompt=select_account diff --git a/src/launcher/download.rs b/src/launcher/download.rs index 3a89d79..132cd7f 100644 --- a/src/launcher/download.rs +++ b/src/launcher/download.rs @@ -55,7 +55,7 @@ pub struct PhaseDownloadError<'j, T: Download> { job: &'j T } -impl<'j, T: Download> Debug for PhaseDownloadError<'j, T> { +impl Debug for PhaseDownloadError<'_, T> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("PhaseDownloadError") .field("phase", &self.phase) @@ -65,13 +65,13 @@ impl<'j, T: Download> Debug for PhaseDownloadError<'j, T> { } } -impl<'j, T: Download> Display for PhaseDownloadError<'j, T> { +impl Display for PhaseDownloadError<'_, T> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "error while {} ({}): {}", self.phase, self.job, self.inner) } } -impl<'j, T: Download> Error for PhaseDownloadError<'j, T> { +impl Error for PhaseDownloadError<'_, T> { fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&*self.inner) } @@ -100,7 +100,7 @@ impl<'j, T: Download + 'j, I: Iterator> MultiDownloader<'j, T, } pub async fn perform(self, client: &'j Client) -> impl TryStream> { - stream::iter(self.jobs.into_iter()).map(move |job| Ok(async move { + stream::iter(self.jobs).map(move |job| Ok(async move { macro_rules! map_err { ($result:expr, $phase:expr, $job:expr) => { match $result { @@ -124,7 +124,7 @@ impl<'j, T: Download + 'j, I: Iterator> MultiDownloader<'j, T, map_err!(job.handle_chunk(bytes.as_ref()).await, Phase::HandleChunk, job); } - job.finish().await.map_err(|e| PhaseDownloadError::new(Phase::Finish, e.into(), job))?; + job.finish().await.map_err(|e| PhaseDownloadError::new(Phase::Finish, e, job))?; Ok(()) })).try_buffer_unordered(self.nconcurrent) diff --git a/src/launcher/jre.rs b/src/launcher/jre.rs index 0b92c20..31034b5 100644 --- a/src/launcher/jre.rs +++ b/src/launcher/jre.rs @@ -67,13 +67,13 @@ impl JavaRuntimeRepository { .inspect_err(|e| warn!("Failed to create directory for JRE component {}: {}", component, e)) .map_err(|e| JavaRuntimeError::IO { what: "creating component directory", error: e })?; - util::ensure_file(&manifest_path, info.url.as_ref().map(|s| s.as_str()), info.size, info.sha1, self.online, false).await + util::ensure_file(&manifest_path, info.url.as_deref(), info.size, info.sha1, self.online, false).await .map_err(JavaRuntimeError::EnsureFile)?; let manifest_file = fs::read_to_string(&manifest_path).await .map_err(|e| JavaRuntimeError::IO { what: "reading runtimes manifest", error: e })?; - Ok(serde_json::from_str(&manifest_file).map_err(|e| JavaRuntimeError::Deserialize { what: "runtime manifest", error: e })?) + serde_json::from_str(&manifest_file).map_err(|e| JavaRuntimeError::Deserialize { what: "runtime manifest", error: e }) } // not very descriptive function name @@ -86,7 +86,7 @@ impl JavaRuntimeRepository { return Err(JavaRuntimeError::UnsupportedComponent { arch: JRE_ARCH, component: component.to_owned() }); }; - let Some(runtime) = runtime_component.iter().filter(|r| r.availability.progress == 100).next() else { + let Some(runtime) = runtime_component.iter().find(|r| r.availability.progress == 100) else { if !runtime_components.is_empty() { warn!("Weird: the only java runtimes in {JRE_ARCH}.{component} has a progress of less than 100!"); } @@ -102,17 +102,16 @@ impl JavaRuntimeRepository { let entry = entry?; let rel_path = entry.path().strip_prefix(path).expect("walkdir escaped root (???)"); - if rel_path.components().filter(|c| !matches!(c, Component::CurDir)).next().is_none() { + if !rel_path.components().any(|c| !matches!(&c, Component::CurDir)) { // if this path is trivial (points at the root), ignore it continue; } - let rel_path_str; - if std::path::MAIN_SEPARATOR != '/' { - rel_path_str = rel_path.to_str().map(|s| s.replace(std::path::MAIN_SEPARATOR, "/")); + let rel_path_str = if std::path::MAIN_SEPARATOR != '/' { + rel_path.to_str().map(|s| s.replace(std::path::MAIN_SEPARATOR, "/")) } else { - rel_path_str = rel_path.to_str().map(String::from); - } + rel_path.to_str().map(String::from) + }; if !rel_path_str.as_ref().is_some_and(|s| manifest.files.get(s) .is_some_and(|f| (f.is_file() == entry.file_type().is_file()) @@ -175,7 +174,7 @@ impl JavaRuntimeRepository { Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()), Err(e) => { warn!("Could not create directory {} for JRE!", ent_path.display()); - return Err(JavaRuntimeError::IO { what: "creating directory", error: e }); + Err(JavaRuntimeError::IO { what: "creating directory", error: e }) } } }).await diff --git a/src/launcher/jre/manifest.rs b/src/launcher/jre/manifest.rs index 887871a..3fd6484 100644 --- a/src/launcher/jre/manifest.rs +++ b/src/launcher/jre/manifest.rs @@ -37,7 +37,7 @@ pub enum JavaRuntimeFile { File { #[serde(default)] executable: bool, - downloads: FileDownloads + downloads: Box }, Directory, Link { diff --git a/src/launcher/rules.rs b/src/launcher/rules.rs index 69c967d..29a36d1 100644 --- a/src/launcher/rules.rs +++ b/src/launcher/rules.rs @@ -80,7 +80,7 @@ impl seal::CompatCheckInner for CompleteVersion { } fn get_incompatibility_reason(&self) -> Option<&str> { - self.incompatibility_reason.as_ref().map(|s| s.as_str()) + self.incompatibility_reason.as_deref() } } diff --git a/src/launcher/runner.rs b/src/launcher/runner.rs index f7fd025..afdfc7f 100644 --- a/src/launcher/runner.rs +++ b/src/launcher/runner.rs @@ -21,7 +21,7 @@ const PATH_SEP: &str = ";"; #[cfg(not(windows))] const PATH_SEP: &str = ":"; -impl<'rep, 'l, F: FeatureMatcher> SubFunc<'rep> for LaunchArgSub<'rep, 'l, F> { +impl<'rep, F: FeatureMatcher> SubFunc<'rep> for LaunchArgSub<'rep, '_, F> { fn substitute(&self, key: &str) -> Option> { match key { "assets_index_name" => self.0.asset_index_name.as_ref().map(|s| Cow::Borrowed(s.as_str())), @@ -51,12 +51,11 @@ impl<'rep, 'l, F: FeatureMatcher> SubFunc<'rep> for LaunchArgSub<'rep, 'l, F> { "user_properties" => Some(Cow::Borrowed("{}")), // TODO "user_property_map" => Some(Cow::Borrowed("[]")), // TODO "user_type" => Some(Cow::Borrowed("legacy")), // TODO - "version_name" => Some(Cow::Borrowed(&self.0.version_id.as_ref())), + "version_name" => Some(Cow::Borrowed(self.0.version_id.as_ref())), "version_type" => self.0.version_type.as_ref().map(|s| Cow::Borrowed(s.to_str())), _ => { if let Some(asset_key) = key.strip_prefix("asset=") { - return self.0.asset_index.as_ref() - .map_or(None, |idx| idx.objects.get(asset_key)) + return self.0.asset_index.as_ref().and_then(|idx| idx.objects.get(asset_key)) .map(|obj| Cow::Owned(self.0.launcher.assets.get_object_path(obj).as_java_path().to_string_lossy().into_owned())) } @@ -68,16 +67,16 @@ impl<'rep, 'l, F: FeatureMatcher> SubFunc<'rep> for LaunchArgSub<'rep, 'l, F> { #[derive(Clone, Copy)] pub enum ArgumentType { - JVM, + Jvm, Game } -pub fn build_arguments<'l, F: FeatureMatcher>(launch: &LaunchInfo<'l, F>, version: &CompleteVersion, arg_type: ArgumentType) -> Vec { +pub fn build_arguments(launch: &LaunchInfo<'_, F>, version: &CompleteVersion, arg_type: ArgumentType) -> Vec { let sub = LaunchArgSub(launch); let system_info = &launch.launcher.system_info; - if let Some(arguments) = version.arguments.as_ref().map_or(None, |args| match arg_type { - ArgumentType::JVM => args.jvm.as_ref(), + if let Some(arguments) = version.arguments.as_ref().and_then(|args| match arg_type { + ArgumentType::Jvm => args.jvm.as_ref(), ArgumentType::Game => args.game.as_ref() }) { arguments.iter() @@ -86,7 +85,7 @@ pub fn build_arguments<'l, F: FeatureMatcher>(launch: &LaunchInfo<'l, F>, versio .map(|s| OsString::from(strsub::replace_string(s, &sub).into_owned())).collect() } else if let Some(arguments) = version.minecraft_arguments.as_ref() { match arg_type { - ArgumentType::JVM => { + ArgumentType::Jvm => { [ "-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", diff --git a/src/launcher/settings.rs b/src/launcher/settings.rs index 4dfc4ac..8453653 100644 --- a/src/launcher/settings.rs +++ b/src/launcher/settings.rs @@ -89,7 +89,7 @@ impl Settings { } pub fn get_path(&self) -> Option<&Path> { - self.path.as_ref().map(|p| p.as_path()) + self.path.as_deref() } pub async fn save_to(&self, path: impl AsRef) -> Result<(), SettingsError> { @@ -184,7 +184,7 @@ impl Instance { } } -const DEF_JVM_ARGUMENTS: [&'static str; 7] = [ +const DEF_JVM_ARGUMENTS: [&str; 7] = [ "-Xmx2G", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseG1GC", diff --git a/src/launcher/strsub.rs b/src/launcher/strsub.rs index 0d2357d..5764405 100644 --- a/src/launcher/strsub.rs +++ b/src/launcher/strsub.rs @@ -69,7 +69,7 @@ pub fn replace_string<'inp, 'rep>(input: &'inp str, sub: &impl SubFunc<'rep>) -> let spec_start = idx + VAR_BEGIN.len(); // the start of the "spec" (area inside {}) // first, check if this is escaped - if let Some((prev_idx, ESCAPE)) = input[..idx].char_indices().rev().next() { + if let Some((prev_idx, ESCAPE)) = input[..idx].char_indices().next_back() { let s = ret.get_or_insert_default(); s.push_str(&input[cursor..prev_idx]); @@ -94,7 +94,7 @@ pub fn replace_string<'inp, 'rep>(input: &'inp str, sub: &impl SubFunc<'rep>) -> }; let after = spec_end + VAR_END.len(); - if let Some(subst) = sub.substitute(name).map_or_else(|| def.map(|d| Cow::Borrowed(d)), |v| Some(v)) { + if let Some(subst) = sub.substitute(name).map_or_else(|| def.map(Cow::Borrowed), Some) { let s = ret.get_or_insert_default(); s.push_str(&input[cursor..idx]); s.push_str(subst.as_ref()); diff --git a/src/launcher/version.rs b/src/launcher/version.rs index 328f0a9..49525b0 100644 --- a/src/launcher/version.rs +++ b/src/launcher/version.rs @@ -40,14 +40,9 @@ impl RemoteVersionList { async fn download_version(&self, ver: &VersionManifestVersion, path: &Path) -> Result> { // ensure parent directory exists info!("Downloading version {}.", ver.id); - match tokio::fs::create_dir_all(path.parent().expect("version .json has no parent (impossible)")).await { - Err(e) => { - if e.kind() != ErrorKind::AlreadyExists { - warn!("failed to create {} parent dirs: {e}", path.display()); - return Err(e.into()); - } - }, - Ok(()) => {} + if let Err(e) = tokio::fs::create_dir_all(path.parent().expect("version .json has no parent (impossible)")).await { + warn!("failed to create {} parent dirs: {e}", path.display()); + return Err(e.into()); } // download it @@ -254,7 +249,7 @@ impl VersionList { Self::create_dir_for(home).await?; let remote = RemoteVersionList::new().await?; - let local = LocalVersionList::load_versions(home.as_ref(), |s| remote.versions.contains_key(s)).await?; + let local = LocalVersionList::load_versions(home, |s| remote.versions.contains_key(s)).await?; Ok(VersionList { remote: Some(remote), @@ -280,16 +275,15 @@ impl VersionList { } pub fn get_version_lazy(&self, id: &str) -> VersionResult { - self.remote.as_ref() - .map_or(None, |r| r.versions.get(id).map(VersionResult::from)) + self.remote.as_ref().and_then(|r| r.versions.get(id).map(VersionResult::from)) .or_else(|| self.local.versions.get(id).map(VersionResult::from)) .unwrap_or(VersionResult::None) } pub fn get_profile_version_id<'v>(&self, ver: &'v ProfileVersion) -> Option> { match ver { - ProfileVersion::LatestRelease => self.remote.as_ref().map_or(None, |r| Some(Cow::Owned(r.latest.release.clone()))), - ProfileVersion::LatestSnapshot => self.remote.as_ref().map_or(None, |r| Some(Cow::Owned(r.latest.snapshot.clone()))), + ProfileVersion::LatestRelease => self.remote.as_ref().map(|r| Cow::Owned(r.latest.release.clone())), + ProfileVersion::LatestSnapshot => self.remote.as_ref().map(|r| Cow::Owned(r.latest.snapshot.clone())), ProfileVersion::Specific(ver) => Some(Cow::Borrowed(ver)) } } @@ -346,7 +340,7 @@ impl VersionList { let inherited_ver = match self.get_version_lazy(inherit.as_str()) { VersionResult::Complete(v) => Cow::Borrowed(v), VersionResult::Remote(v) => - Cow::Owned(self.load_remote_version(v).await.map_err(|e| VersionResolveError::Unknown(e))?), + Cow::Owned(self.load_remote_version(v).await.map_err(VersionResolveError::Unknown)?), VersionResult::None => { warn!("Cannot resolve version {}, it inherits an unknown version {inherit}", ver.id); return Err(VersionResolveError::MissingVersion(inherit)); diff --git a/src/version.rs b/src/version.rs index 2388390..6e9ad3f 100644 --- a/src/version.rs +++ b/src/version.rs @@ -43,7 +43,7 @@ impl Deref for WrappedRegex { } struct RegexVisitor; -impl<'de> Visitor<'de> for RegexVisitor { +impl Visitor<'_> for RegexVisitor { type Value = WrappedRegex; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -52,8 +52,8 @@ impl<'de> Visitor<'de> for RegexVisitor { fn visit_str(self, v: &str) -> Result where - E: de::Error, { - Regex::new(v).map_err(de::Error::custom).map(|r| WrappedRegex(r)) + E: Error, { + Regex::new(v).map_err(Error::custom).map(WrappedRegex) } } @@ -212,7 +212,7 @@ pub struct Library { impl Library { pub fn get_canonical_name(&self) -> String { - canonicalize_library_name(self.name.as_str(), self.natives.as_ref().map_or(None, |_| Some("__ozone_natives"))) + canonicalize_library_name(self.name.as_str(), self.natives.as_ref().map(|_| "__ozone_natives")) } } @@ -287,7 +287,7 @@ pub struct CompleteVersion { impl CompleteVersion { pub fn get_jar(&self) -> &String { - &self.jar.as_ref().unwrap_or(&self.id) + self.jar.as_ref().unwrap_or(&self.id) } pub fn apply_child(&mut self, other: &CompleteVersion) { @@ -358,7 +358,7 @@ where { struct DateTimeVisitor; - impl<'de> Visitor<'de> for DateTimeVisitor { + impl Visitor<'_> for DateTimeVisitor { type Value = Option>; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { @@ -406,7 +406,7 @@ where fn visit_str(self, v: &str) -> Result where - E: de::Error, { + E: Error, { Ok(FromStr::from_str(v).unwrap()) } @@ -441,7 +441,7 @@ where fn visit_str(self, v: &str) -> Result where - E: de::Error, { + E: Error, { Ok(vec![FromStr::from_str(v).unwrap()]) } diff --git a/src/version/manifest.rs b/src/version/manifest.rs index 18653f3..b2b8524 100644 --- a/src/version/manifest.rs +++ b/src/version/manifest.rs @@ -48,7 +48,7 @@ impl VersionType { struct VersionTypeVisitor; -impl<'de> Visitor<'de> for VersionTypeVisitor { +impl Visitor<'_> for VersionTypeVisitor { type Value = VersionType; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { -- cgit v1.2.3-70-g09d2