diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs index 27309f4b..542e4486 100644 --- a/packages/oneclient_app/src/components/microsoft_login.rs +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -1,7 +1,8 @@ use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use freya::text_edit::Clipboard; -use oneclient_core::auth::MicrosoftLoginSession; +use oneclient_core::LauncherError; +use oneclient_core::auth::{AuthErrorGuidance, MicrosoftLoginSession}; use oneclient_core::notification::MicrosoftLoginStatus; use crate::components::{Button, Icon, IconType, OverlayPopup}; @@ -24,6 +25,7 @@ pub struct MicrosoftLogin { locked: bool, pub pending: bool, pub error: Option, + error_guidance: Option, } impl MicrosoftLogin { @@ -59,6 +61,7 @@ impl MicrosoftLogin { handle.clone(), handle.status.clone(), handle.error.clone(), + handle.error_guidance.clone(), ) }) } @@ -104,7 +107,11 @@ pub fn use_microsoft_login() -> MicrosoftLogin { let locked = status.as_ref().is_some_and(|s| s.current > 0); let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); let pending = in_flight && !*cancelled.read(); - let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin)); + let error_info = mutation_err_info(&finish).or_else(|| mutation_err_info(&begin)); + let (error, error_guidance) = match error_info { + Some(info) => (Some(info.message), info.guidance), + None => (None, None), + }; MicrosoftLogin { begin, @@ -115,19 +122,28 @@ pub fn use_microsoft_login() -> MicrosoftLogin { locked, pending, error, + error_guidance, } } -fn mutation_err_text(mutation: &UseMutation) -> Option +struct AuthErrorInfo { + message: String, + guidance: Option, +} + +fn mutation_err_info(mutation: &UseMutation) -> Option where - M: MutationCapability, - M::Err: std::fmt::Display, + M: MutationCapability, { - match &*mutation.read().state() { - MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()), - MutationStateData::Loading { + let read = mutation.read(); + match &*read.state() { + MutationStateData::Settled { res: Err(err), .. } + | MutationStateData::Loading { res: Some(Err(err)), - } => Some(err.to_string()), + } => Some(AuthErrorInfo { + message: err.to_string(), + guidance: err.auth_guidance(), + }), _ => None, } } @@ -137,13 +153,36 @@ fn microsoft_dialog( handle: MicrosoftLogin, status: Option, error: Option, + guidance: Option, ) -> impl IntoElement { - let locked = handle.locked; - let close_handle = handle.clone(); - let cancel_handle = handle; + let close = handle.clone(); + login_dialog( + login.auth_url().to_string(), + login.user_code().to_string(), + login.verification_uri().to_string(), + handle.locked, + status, + error, + guidance, + move || close.cancel(), + ) +} + +#[expect(clippy::too_many_arguments)] +pub(crate) fn login_dialog( + auth_url: String, + user_code: String, + verification_uri: String, + locked: bool, + status: Option, + error: Option, + guidance: Option, + on_close: impl FnMut() + Clone + 'static, +) -> impl IntoElement { + let mut close_scrim = on_close.clone(); + let mut close_cancel = on_close; OverlayPopup::new() - // Backdrop / Esc only dismiss while the flow is still cancellable, if its locked the popup can't be closed and cancelling aborts everything. - .on_close(move |()| close_handle.cancel()) + .on_close(move |()| close_scrim()) .child( rect() .width(Size::window_percent(100.)) @@ -154,6 +193,7 @@ fn microsoft_dialog( .vertical() .width(Size::px(420.)) .max_width(Size::window_percent(90.)) + .max_height(Size::window_percent(90.)) .cross_align(Alignment::Center) .spacing(18.) .padding(Gaps::new_all(28.)) @@ -167,17 +207,31 @@ fn microsoft_dialog( .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child(browser_dialog_body(login.auth_url().to_string())) - .child(dialog_divider()) - .child(device_code_dialog_body( - login.user_code().to_string(), - login.verification_uri().to_string(), - )) - .child(status_row(status, error)) + .child( + ScrollView::new() + .width(Size::fill()) + .height(Size::auto()) + .max_height(Size::window_percent(70.)) + .child( + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child(browser_dialog_body(auth_url)) + .child(dialog_divider()) + .child(device_code_dialog_body( + user_code, + verification_uri, + )) + .child(status_row(status, error)) + .maybe_child(guidance.map(guidance_block)), + ), + ) .maybe_child((!locked).then(|| { Button::new() .ghost() - .on_press(move |_| cancel_handle.cancel()) + .on_press(move |_| close_cancel()) .text("Cancel") .into_element() })), @@ -254,6 +308,58 @@ fn status_row(status: Option, error: Option) -> im .into_element() } +fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { + let mut steps = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); + + for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { + steps = steps.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{}.", index + 1)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(step) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(guidance.what_happened) + .font_size(12.) + .color(colors::fg_primary()), + ) + .child(steps) + .into_element() +} + fn browser_dialog_body(auth_url: String) -> impl IntoElement { rect() .vertical() diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 3078dc22..a1dbeb16 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -51,6 +51,7 @@ pub use local_image::LocalImage; pub use log_viewer::LogViewer; pub use major_version_card::MajorVersionCard; pub use microsoft_login::use_microsoft_login; +pub(crate) use microsoft_login::login_dialog; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index a7482976..eeb5e971 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -3,7 +3,9 @@ use freya::router::RouterContext; use oneclient_core::LauncherState; use oneclient_db::console::{ConsoleQueryResult, run_console_query}; -use crate::components::{Button, Icon, IconType, TextInput, toggle}; +use oneclient_core::auth::preview_samples; + +use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle}; use crate::hooks::use_dispatch; use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; @@ -68,6 +70,11 @@ impl Component for Debug { vec![ClusterUpdateSimulator.into_element()], )) .child(divider()) + .child(section( + "Auth Error Guidance", + vec![AuthGuidancePreview.into_element()], + )) + .child(divider()) .child(section("SQL Console", vec![SqlConsole.into_element()])) .child(divider()) .child(section( @@ -261,6 +268,71 @@ impl Component for ClusterUpdateSimulator { } } +#[derive(PartialEq)] +struct AuthGuidancePreview; + +impl Component for AuthGuidancePreview { + fn render(&self) -> impl IntoElement { + let selected = use_state(|| None::>); + let samples = preview_samples(); + + let mut controls = rect().vertical().width(Size::fill()).spacing(10.).child( + label() + .text("Each button opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.") + .font_size(13.) + .color(colors::fg_secondary()), + ); + + let mut labels: Vec<(&'static str, Option)> = vec![("No error (in progress)", None)]; + labels.extend(samples.iter().enumerate().map(|(i, s)| (s.label, Some(i)))); + + for chunk in labels.chunks(3) { + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + for (text, target) in chunk.iter().copied() { + let mut sel = selected; + let mut button = Button::new() + .secondary() + .text(text) + .on_press(move |_| sel.set(Some(target))); + if text.contains("No error") { + button = button.child(Icon::new(IconType::CheckCircle).size(16.)); + } else { + button = button.child(Icon::new(IconType::AlertTriangle).size(16.)); + } + row = row.child(rect().width(Size::flex(1.0)).child(button)); + } + for _ in chunk.len()..3 { + row = row.child(rect().width(Size::flex(1.0))); + } + controls = controls.child(row); + } + + let current = *selected.read(); + let popup = current.map(|choice| { + let (error, guidance) = match choice { + None => (None, None), + Some(i) => { + let s = &samples[i]; + (Some(s.message.clone()), s.guidance.clone()) + } + }; + let mut sel = selected; + login_dialog( + "https://login.live.com/oauth20_authorize (preview)".to_string(), + "ABCD-EFGH".to_string(), + "https://www.microsoft.com/link".to_string(), + false, + None, + error, + guidance, + move || sel.set(None), + ) + }); + + controls.maybe_child(popup).into_element() + } +} + #[derive(PartialEq)] struct SqlConsole; diff --git a/packages/oneclient_core/src/auth/diagnostics.rs b/packages/oneclient_core/src/auth/diagnostics.rs new file mode 100644 index 00000000..3e41cbda --- /dev/null +++ b/packages/oneclient_core/src/auth/diagnostics.rs @@ -0,0 +1,299 @@ +//! Adapted from Modrinth's `minecraft-auth-errors.ts` +//! (GPL-3.0) + +use reqwest::StatusCode; + +use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuthErrorGuidance { + pub what_happened: String, + pub steps_to_fix: Vec, +} + +impl AuthErrorGuidance { + fn new(what_happened: &str, steps_to_fix: &[&str]) -> Self { + Self { + what_happened: what_happened.to_string(), + steps_to_fix: steps_to_fix.iter().map(|s| (*s).to_string()).collect(), + } + } +} + +#[must_use] +pub fn diagnose_auth_error(err: &MinecraftAuthError) -> Option { + match err { + MinecraftAuthError::XboxError { error_code, .. } => guidance_for_xerr(*error_code), + + MinecraftAuthError::ServiceError { step, status_code } => { + guidance_for_service_error(*step, *status_code) + } + + MinecraftAuthError::DeserializeError { step, .. } => guidance_for_step(*step), + + MinecraftAuthError::RequestError { .. } => Some(network_guidance()), + + MinecraftAuthError::BrowserAuthorizationExpired + | MinecraftAuthError::BrowserLoginNotFound => Some(AuthErrorGuidance::new( + "The Microsoft sign-in window was closed or expired before sign-in finished.", + &[ + "Start the sign-in again", + "Complete the Microsoft sign-in in your browser without closing the window", + "If your browser blocked the redirect back, allow it and try once more", + ], + )), + + _ => None, + } +} + +fn guidance_for_xerr(code: u64) -> Option { + Some(match code { + 2_148_916_222 => AuthErrorGuidance::new( + "This account requires age verification to comply with UK regulations before it can sign in.", + &[ + "Go to the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Follow the instructions to verify your age", + "Once verified, try signing in again", + "For more help see UK age verification on Xbox (https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification)", + ], + ), + 2_148_916_227 => AuthErrorGuidance::new( + "This account was suspended for violating Xbox Community Standards.", + &[ + "Visit Xbox Support and review the enforcement details (https://support.xbox.com)", + "Submit an appeal if one is available", + ], + ), + 2_148_916_229 => AuthErrorGuidance::new( + "This account is restricted and does not have permission to play online.", + &[ + "Have a guardian sign in to Microsoft Family (https://account.microsoft.com/family/)", + "Update the online play permissions", + "Once finished, try signing in again", + ], + ), + 2_148_916_233 => AuthErrorGuidance::new( + "This account does not have an Xbox profile set up, or does not own Minecraft.", + &[ + "Make sure Minecraft is purchased on this account", + "Visit the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Complete Xbox profile setup if prompted", + "Once finished, try signing in again", + ], + ), + 2_148_916_234 => AuthErrorGuidance::new( + "This account has not accepted Xbox's Terms of Service.", + &[ + "Visit Xbox and sign in (https://www.xbox.com)", + "Accept the Terms if prompted", + "Once finished, try signing in again", + ], + ), + 2_148_916_235 => AuthErrorGuidance::new( + "Xbox Live is not available in your region, so sign-in is blocked.", + &[ + "Xbox services must be supported in your country before you can sign in", + "Check Xbox availability for supported regions (https://www.xbox.com/en-US/regions)", + ], + ), + 2_148_916_236 | 2_148_916_237 => AuthErrorGuidance::new( + "This account requires adult verification under South Korean regulations.", + &[ + "Visit Xbox and sign in (https://www.xbox.com)", + "Complete the identity verification process", + "Once finished, try signing in again", + ], + ), + 2_148_916_238 => AuthErrorGuidance::new( + "This account is underage and not linked to a Microsoft family group.", + &[ + "Review the Minecraft Family Setup guide (https://help.minecraft.net/hc/en-us/articles/4408968616077)", + "Join or create a family group as instructed", + "Once finished, try signing in again", + ], + ), + _ => return None, + }) +} + +fn guidance_for_service_error( + step: MinecraftAuthStep, + status: StatusCode, +) -> Option { + if step == MinecraftAuthStep::MinecraftToken { + if status == StatusCode::TOO_MANY_REQUESTS { + return Some(AuthErrorGuidance::new( + "Microsoft or Minecraft temporarily blocked the sign-in because there were too many recent attempts.", + &[ + "Wait about an hour before trying again", + "Restart OneClient after waiting", + "Try signing in once more", + "If it keeps happening, wait longer before retrying so the temporary limit can clear", + ], + )); + } + if status.is_server_error() { + return Some(AuthErrorGuidance::new( + "Minecraft's authentication service is returning a server error, so sign-in cannot finish right now.", + &[ + "Wait a few minutes and try signing in again", + "Check Xbox status for current service issues (https://support.xbox.com/xbox-live-status)", + "Try the official Minecraft Launcher to confirm whether Minecraft sign-in is affected there too (https://www.minecraft.net/en-us/download)", + "If the service is healthy and this keeps happening, contact support with the debug information", + ], + )); + } + } + + guidance_for_step(step) +} + +fn guidance_for_step(step: MinecraftAuthStep) -> Option { + Some(match step { + MinecraftAuthStep::RefreshToken | MinecraftAuthStep::AuthCodeExchange => { + AuthErrorGuidance::new( + "Your saved Microsoft sign-in has expired or was revoked, so your Minecraft session could not be renewed.", + &[ + "Sign out of the affected Minecraft account in OneClient", + "Sign in to the account again", + "Once the new sign-in finishes, try launching Minecraft again", + ], + ) + } + MinecraftAuthStep::XblAuthenticate => AuthErrorGuidance::new( + "Xbox rejected the first sign-in step. This is most often caused by your system clock or time zone being out of sync, or by a temporary Xbox block.", + &[ + "Open your system date and time settings", + "Turn on automatic time zone and automatic time, if available", + "Use the sync option in your settings to synchronise the clock", + "Restart OneClient and try signing in again", + "If it persists, check Xbox status (https://support.xbox.com/xbox-live-status)", + ], + ), + MinecraftAuthStep::XstsAuthorize => AuthErrorGuidance::new( + "Xbox rejected the request to authorize this account for Minecraft, but did not return a specific account restriction we recognise.", + &[ + "Sign in with the official Minecraft Launcher (https://www.minecraft.net/en-us/download)", + "Complete any prompts shown by Microsoft, Xbox, or Minecraft", + "Try signing in to OneClient again", + "If the official launcher also fails, follow the error shown there or contact Xbox Support", + ], + ), + MinecraftAuthStep::MinecraftProfile => AuthErrorGuidance::new( + "Minecraft services could not return a Java Edition profile for this account. This usually means the game was purchased recently, the Java profile is not finished being created, or the wrong Microsoft account is being used.", + &[ + "Sign in with the official Minecraft Launcher and launch Java Edition once (https://www.minecraft.net/en-us/download)", + "Wait up to an hour if the purchase or profile setup was recent", + "Make sure you are using the Microsoft account that owns Minecraft", + "Try signing in to OneClient again", + ], + ), + _ => return None, + }) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuthErrorSample { + pub label: &'static str, + pub message: String, + pub guidance: Option, +} + +#[must_use] +pub fn preview_samples() -> Vec { + use MinecraftAuthStep::{ + MinecraftProfile, MinecraftToken, RefreshToken, XblAuthenticate, XstsAuthorize, + }; + + let errors: Vec<(&'static str, MinecraftAuthError)> = vec![ + ( + "Xbox 403 (RUST-4)", + MinecraftAuthError::ServiceError { + step: XblAuthenticate, + status_code: StatusCode::FORBIDDEN, + }, + ), + ("No Xbox profile", xerr(2_148_916_233)), + ("Region blocked", xerr(2_148_916_235)), + ("Child account", xerr(2_148_916_238)), + ("Account suspended", xerr(2_148_916_227)), + ("Restricted / no online play", xerr(2_148_916_229)), + ("ToS not accepted", xerr(2_148_916_234)), + ("UK age verification", xerr(2_148_916_222)), + ("KR adult verification", xerr(2_148_916_236)), + ( + "Rate limited (429)", + MinecraftAuthError::ServiceError { + step: MinecraftToken, + status_code: StatusCode::TOO_MANY_REQUESTS, + }, + ), + ( + "MC service error (503)", + MinecraftAuthError::ServiceError { + step: MinecraftToken, + status_code: StatusCode::SERVICE_UNAVAILABLE, + }, + ), + ( + "Profile fetch failed", + MinecraftAuthError::ServiceError { + step: MinecraftProfile, + status_code: StatusCode::NOT_FOUND, + }, + ), + ( + "Session expired", + MinecraftAuthError::ServiceError { + step: RefreshToken, + status_code: StatusCode::BAD_REQUEST, + }, + ), + ( + "Generic Xbox reject", + MinecraftAuthError::ServiceError { + step: XstsAuthorize, + status_code: StatusCode::UNAUTHORIZED, + }, + ), + ]; + + let mut out: Vec = errors + .into_iter() + .map(|(label, err)| AuthErrorSample { + label, + message: err.to_string(), + guidance: diagnose_auth_error(&err), + }) + .collect(); + + out.push(AuthErrorSample { + label: "Network unreachable", + message: "error sending request for url (https://user.auth.xboxlive.com/user/authenticate): connection closed".to_string(), + guidance: Some(network_guidance()), + }); + out +} + +fn xerr(code: u64) -> MinecraftAuthError { + MinecraftAuthError::XboxError { + step: MinecraftAuthStep::XstsAuthorize, + error_code: code, + message: friendly_xbox_error(code).unwrap_or("Xbox rejected sign-in.").to_string(), + redirect: None, + } +} + +fn network_guidance() -> AuthErrorGuidance { + AuthErrorGuidance::new( + "OneClient could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.", + &[ + "Restart OneClient and try signing in again", + "Check that your internet connection is working", + "Allow OneClient through your firewall, antivirus, proxy, VPN, and hosts file rules", + "Try a different network, or temporarily disable VPN/proxy software if you use one", + "If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help", + ], + ) +} diff --git a/packages/oneclient_core/src/auth/error.rs b/packages/oneclient_core/src/auth/error.rs index 2dfae500..91873c38 100644 --- a/packages/oneclient_core/src/auth/error.rs +++ b/packages/oneclient_core/src/auth/error.rs @@ -2,7 +2,7 @@ use serde::Serialize; use crate::http::RequestError; -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum MinecraftAuthStep { DeviceCodeRequest, DeviceCodePoll, @@ -57,13 +57,34 @@ pub enum MinecraftAuthError { BrowserAuthorizationFailed { error: String }, #[error("failed to read user hash from Xbox token")] HashError, - #[error("Minecraft authentication error {error_code} during MSA step {step:?}: {message}")] + #[error("{message}")] XboxError { step: MinecraftAuthStep, error_code: u64, message: String, redirect: Option, }, + #[error( + "The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again." + )] + ServiceError { + step: MinecraftAuthStep, + status_code: reqwest::StatusCode, + }, +} + +#[must_use] +pub fn friendly_xbox_error(code: u64) -> Option<&'static str> { + Some(match code { + 2_148_916_227 => "This account has been banned or suspended from Xbox.", + 2_148_916_229 => "This account is a child account that must be added to a Family group by an adult before signing in.", + 2_148_916_233 => "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again.", + 2_148_916_234 => "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first.", + 2_148_916_235 => "Xbox Live is not available in your country or region, so sign-in is blocked.", + 2_148_916_236 | 2_148_916_237 => "This account requires adult verification (South Korea) before it can sign in.", + 2_148_916_238 => "This is a child account. An adult must add it to a Microsoft Family group before it can sign in.", + _ => return None, + }) } #[derive(Debug, thiserror::Error)] diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs index 2db82532..36c1ecf3 100644 --- a/packages/oneclient_core/src/auth/mod.rs +++ b/packages/oneclient_core/src/auth/mod.rs @@ -3,8 +3,12 @@ mod msa; mod offline; mod store; mod data; +mod diagnostics; pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep}; +pub use diagnostics::{ + diagnose_auth_error, preview_samples, AuthErrorGuidance, AuthErrorSample, +}; pub use offline::{offline_account, offline_uuid, validate_offline_username}; pub use store::CredentialsStore; pub use data::{ diff --git a/packages/oneclient_core/src/auth/msa.rs b/packages/oneclient_core/src/auth/msa.rs index a653d914..9015a3bc 100644 --- a/packages/oneclient_core/src/auth/msa.rs +++ b/packages/oneclient_core/src/auth/msa.rs @@ -16,7 +16,7 @@ use url::Url; use uuid::Uuid; use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; -use super::error::{MinecraftAuthError, MinecraftAuthStep}; +use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; use crate::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; const DEVICE_CODE_URL: &str = @@ -758,10 +758,20 @@ fn check_xbox_error( return Ok(()); } + let message = friendly_xbox_error(token.x_err) + .map(String::from) + .unwrap_or_else(|| { + if token.message.is_empty() { + format!("Xbox rejected sign-in (error {}).", token.x_err) + } else { + token.message.clone() + } + }); + Err(MinecraftAuthError::XboxError { step, error_code: token.x_err, - message: token.message.clone(), + message, redirect: token.redirect.clone(), }) } @@ -780,11 +790,17 @@ async fn parse_json_response Deserialize<'de>>( tracing::error!("[auth] step={step:?} status={status} body={text}"); } - serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { - step, - raw: text, - source, - status_code: status, + serde_json::from_str(&text).map_err(|source| { + if !status.is_success() { + MinecraftAuthError::ServiceError { step, status_code: status } + } else { + MinecraftAuthError::DeserializeError { + step, + raw: text, + source, + status_code: status, + } + } }) } diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs index 1dab37f9..43940637 100644 --- a/packages/oneclient_core/src/error.rs +++ b/packages/oneclient_core/src/error.rs @@ -69,4 +69,16 @@ pub enum LauncherError { Minecraft(String), } +impl LauncherError { + #[must_use] + pub fn auth_guidance(&self) -> Option { + match self { + LauncherError::AuthError(crate::auth::AuthError::Minecraft(err)) => { + crate::auth::diagnose_auth_error(err) + } + _ => None, + } + } +} + pub type LauncherResult = Result;