Add readable MS errors
This commit is contained in:
parent
24d9bc3458
commit
85d1e09cee
8 changed files with 563 additions and 32 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
use freya::query::{MutationCapability, MutationStateData, UseMutation};
|
use freya::query::{MutationCapability, MutationStateData, UseMutation};
|
||||||
use freya::text_edit::Clipboard;
|
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 oneclient_core::notification::MicrosoftLoginStatus;
|
||||||
|
|
||||||
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
||||||
|
|
@ -24,6 +25,7 @@ pub struct MicrosoftLogin {
|
||||||
locked: bool,
|
locked: bool,
|
||||||
pub pending: bool,
|
pub pending: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
error_guidance: Option<AuthErrorGuidance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MicrosoftLogin {
|
impl MicrosoftLogin {
|
||||||
|
|
@ -59,6 +61,7 @@ impl MicrosoftLogin {
|
||||||
handle.clone(),
|
handle.clone(),
|
||||||
handle.status.clone(),
|
handle.status.clone(),
|
||||||
handle.error.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 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 in_flight = begin.read().state().is_loading() || finish.read().state().is_loading();
|
||||||
let pending = in_flight && !*cancelled.read();
|
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 {
|
MicrosoftLogin {
|
||||||
begin,
|
begin,
|
||||||
|
|
@ -115,19 +122,28 @@ pub fn use_microsoft_login() -> MicrosoftLogin {
|
||||||
locked,
|
locked,
|
||||||
pending,
|
pending,
|
||||||
error,
|
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
|
where
|
||||||
M: MutationCapability,
|
M: MutationCapability<Err = LauncherError>,
|
||||||
M::Err: std::fmt::Display,
|
|
||||||
{
|
{
|
||||||
match &*mutation.read().state() {
|
let read = mutation.read();
|
||||||
MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
|
match &*read.state() {
|
||||||
MutationStateData::Loading {
|
MutationStateData::Settled { res: Err(err), .. }
|
||||||
|
| MutationStateData::Loading {
|
||||||
res: Some(Err(err)),
|
res: Some(Err(err)),
|
||||||
} => Some(err.to_string()),
|
} => Some(AuthErrorInfo {
|
||||||
|
message: err.to_string(),
|
||||||
|
guidance: err.auth_guidance(),
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -137,13 +153,36 @@ fn microsoft_dialog(
|
||||||
handle: MicrosoftLogin,
|
handle: MicrosoftLogin,
|
||||||
status: Option<MicrosoftLoginStatus>,
|
status: Option<MicrosoftLoginStatus>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
guidance: Option<AuthErrorGuidance>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let locked = handle.locked;
|
let close = handle.clone();
|
||||||
let close_handle = handle.clone();
|
login_dialog(
|
||||||
let cancel_handle = handle;
|
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()
|
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_scrim())
|
||||||
.on_close(move |()| close_handle.cancel())
|
|
||||||
.child(
|
.child(
|
||||||
rect()
|
rect()
|
||||||
.width(Size::window_percent(100.))
|
.width(Size::window_percent(100.))
|
||||||
|
|
@ -154,6 +193,7 @@ fn microsoft_dialog(
|
||||||
.vertical()
|
.vertical()
|
||||||
.width(Size::px(420.))
|
.width(Size::px(420.))
|
||||||
.max_width(Size::window_percent(90.))
|
.max_width(Size::window_percent(90.))
|
||||||
|
.max_height(Size::window_percent(90.))
|
||||||
.cross_align(Alignment::Center)
|
.cross_align(Alignment::Center)
|
||||||
.spacing(18.)
|
.spacing(18.)
|
||||||
.padding(Gaps::new_all(28.))
|
.padding(Gaps::new_all(28.))
|
||||||
|
|
@ -167,17 +207,31 @@ fn microsoft_dialog(
|
||||||
.font_weight(FontWeight::SEMI_BOLD)
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
.color(colors::fg_primary()),
|
.color(colors::fg_primary()),
|
||||||
)
|
)
|
||||||
.child(browser_dialog_body(login.auth_url().to_string()))
|
.child(
|
||||||
.child(dialog_divider())
|
ScrollView::new()
|
||||||
.child(device_code_dialog_body(
|
.width(Size::fill())
|
||||||
login.user_code().to_string(),
|
.height(Size::auto())
|
||||||
login.verification_uri().to_string(),
|
.max_height(Size::window_percent(70.))
|
||||||
))
|
.child(
|
||||||
.child(status_row(status, error))
|
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(|| {
|
.maybe_child((!locked).then(|| {
|
||||||
Button::new()
|
Button::new()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_press(move |_| cancel_handle.cancel())
|
.on_press(move |_| close_cancel())
|
||||||
.text("Cancel")
|
.text("Cancel")
|
||||||
.into_element()
|
.into_element()
|
||||||
})),
|
})),
|
||||||
|
|
@ -254,6 +308,58 @@ fn status_row(status: Option<MicrosoftLoginStatus>, error: Option<String>) -> im
|
||||||
.into_element()
|
.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 {
|
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
|
||||||
rect()
|
rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ pub use local_image::LocalImage;
|
||||||
pub use log_viewer::LogViewer;
|
pub use log_viewer::LogViewer;
|
||||||
pub use major_version_card::MajorVersionCard;
|
pub use major_version_card::MajorVersionCard;
|
||||||
pub use microsoft_login::use_microsoft_login;
|
pub use microsoft_login::use_microsoft_login;
|
||||||
|
pub(crate) use microsoft_login::login_dialog;
|
||||||
pub(crate) use navbar::window_controls;
|
pub(crate) use navbar::window_controls;
|
||||||
pub use navbar::{AppNavbar, OnboardingNavbar};
|
pub use navbar::{AppNavbar, OnboardingNavbar};
|
||||||
pub use notifications::NotificationCenter;
|
pub use notifications::NotificationCenter;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ use freya::router::RouterContext;
|
||||||
use oneclient_core::LauncherState;
|
use oneclient_core::LauncherState;
|
||||||
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
|
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::hooks::use_dispatch;
|
||||||
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind};
|
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind};
|
||||||
use crate::routes::Route;
|
use crate::routes::Route;
|
||||||
|
|
@ -68,6 +70,11 @@ impl Component for Debug {
|
||||||
vec![ClusterUpdateSimulator.into_element()],
|
vec![ClusterUpdateSimulator.into_element()],
|
||||||
))
|
))
|
||||||
.child(divider())
|
.child(divider())
|
||||||
|
.child(section(
|
||||||
|
"Auth Error Guidance",
|
||||||
|
vec![AuthGuidancePreview.into_element()],
|
||||||
|
))
|
||||||
|
.child(divider())
|
||||||
.child(section("SQL Console", vec![SqlConsole.into_element()]))
|
.child(section("SQL Console", vec![SqlConsole.into_element()]))
|
||||||
.child(divider())
|
.child(divider())
|
||||||
.child(section(
|
.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)]
|
#[derive(PartialEq)]
|
||||||
struct SqlConsole;
|
struct SqlConsole;
|
||||||
|
|
||||||
|
|
|
||||||
299
packages/oneclient_core/src/auth/diagnostics.rs
Normal file
299
packages/oneclient_core/src/auth/diagnostics.rs
Normal 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",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ use serde::Serialize;
|
||||||
|
|
||||||
use crate::http::RequestError;
|
use crate::http::RequestError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
pub enum MinecraftAuthStep {
|
pub enum MinecraftAuthStep {
|
||||||
DeviceCodeRequest,
|
DeviceCodeRequest,
|
||||||
DeviceCodePoll,
|
DeviceCodePoll,
|
||||||
|
|
@ -57,13 +57,34 @@ pub enum MinecraftAuthError {
|
||||||
BrowserAuthorizationFailed { error: String },
|
BrowserAuthorizationFailed { error: String },
|
||||||
#[error("failed to read user hash from Xbox token")]
|
#[error("failed to read user hash from Xbox token")]
|
||||||
HashError,
|
HashError,
|
||||||
#[error("Minecraft authentication error {error_code} during MSA step {step:?}: {message}")]
|
#[error("{message}")]
|
||||||
XboxError {
|
XboxError {
|
||||||
step: MinecraftAuthStep,
|
step: MinecraftAuthStep,
|
||||||
error_code: u64,
|
error_code: u64,
|
||||||
message: String,
|
message: String,
|
||||||
redirect: Option<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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ mod msa;
|
||||||
mod offline;
|
mod offline;
|
||||||
mod store;
|
mod store;
|
||||||
mod data;
|
mod data;
|
||||||
|
mod diagnostics;
|
||||||
|
|
||||||
pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep};
|
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 offline::{offline_account, offline_uuid, validate_offline_username};
|
||||||
pub use store::CredentialsStore;
|
pub use store::CredentialsStore;
|
||||||
pub use data::{
|
pub use data::{
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount};
|
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};
|
use crate::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES};
|
||||||
|
|
||||||
const DEVICE_CODE_URL: &str =
|
const DEVICE_CODE_URL: &str =
|
||||||
|
|
@ -758,10 +758,20 @@ fn check_xbox_error(
|
||||||
return Ok(());
|
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 {
|
Err(MinecraftAuthError::XboxError {
|
||||||
step,
|
step,
|
||||||
error_code: token.x_err,
|
error_code: token.x_err,
|
||||||
message: token.message.clone(),
|
message,
|
||||||
redirect: token.redirect.clone(),
|
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}");
|
tracing::error!("[auth] step={step:?} status={status} body={text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError {
|
serde_json::from_str(&text).map_err(|source| {
|
||||||
step,
|
if !status.is_success() {
|
||||||
raw: text,
|
MinecraftAuthError::ServiceError { step, status_code: status }
|
||||||
source,
|
} else {
|
||||||
status_code: status,
|
MinecraftAuthError::DeserializeError {
|
||||||
|
step,
|
||||||
|
raw: text,
|
||||||
|
source,
|
||||||
|
status_code: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,16 @@ pub enum LauncherError {
|
||||||
Minecraft(String),
|
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>;
|
pub type LauncherResult<T> = Result<T, LauncherError>;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue