Add readable MS errors

This commit is contained in:
Julian Chang 2026-07-21 13:37:03 +09:00
parent 24d9bc3458
commit 85d1e09cee
No known key found for this signature in database
8 changed files with 563 additions and 32 deletions

View file

@ -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<String>,
error_guidance: Option<AuthErrorGuidance>,
}
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<M>(mutation: &UseMutation<M>) -> Option<String>
struct AuthErrorInfo {
message: String,
guidance: Option<AuthErrorGuidance>,
}
fn mutation_err_info<M>(mutation: &UseMutation<M>) -> Option<AuthErrorInfo>
where
M: MutationCapability,
M::Err: std::fmt::Display,
M: MutationCapability<Err = LauncherError>,
{
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<MicrosoftLoginStatus>,
error: Option<String>,
guidance: Option<AuthErrorGuidance>,
) -> 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<MicrosoftLoginStatus>,
error: Option<String>,
guidance: Option<AuthErrorGuidance>,
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<MicrosoftLoginStatus>, error: Option<String>) -> 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()

View file

@ -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;

View file

@ -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::<Option<usize>>);
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<usize>)> = 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;

View file

@ -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<String>,
}
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<AuthErrorGuidance> {
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<AuthErrorGuidance> {
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<AuthErrorGuidance> {
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<AuthErrorGuidance> {
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<AuthErrorGuidance>,
}
#[must_use]
pub fn preview_samples() -> Vec<AuthErrorSample> {
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<AuthErrorSample> = 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",
],
)
}

View file

@ -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<String>,
},
#[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)]

View file

@ -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::{

View file

@ -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<T: for<'de> 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,
}
}
})
}

View file

@ -69,4 +69,16 @@ pub enum LauncherError {
Minecraft(String),
}
impl LauncherError {
#[must_use]
pub fn auth_guidance(&self) -> Option<crate::auth::AuthErrorGuidance> {
match self {
LauncherError::AuthError(crate::auth::AuthError::Minecraft(err)) => {
crate::auth::diagnose_auth_error(err)
}
_ => None,
}
}
}
pub type LauncherResult<T> = Result<T, LauncherError>;