diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index ddaf56a0..607d6ecb 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -78,7 +78,7 @@ export type MessagePayload = { level: MessageLevel; message: string } export type MetadataError = { type: "FetchError"; data: string } | { type: "NotModdedManifest"; data: string } | { type: "NotVanillaManifest"; data: string } | { type: "ParseError"; data: string } | { type: "NoMatchingLoader"; data: string } | { type: "NoMatchingVersion"; data: string } -export type MinecraftAuthError = { type: "PublicKeyReading"; data: string } | { type: "PKCS8Error"; data: string } | { type: "SerializeError"; data: string } | { type: "DeserializeError"; data: string } | { type: "RequestError"; data: string } | { type: "SigningError"; data: string } | { type: "HashError"; data: string } | { type: "SessionIdError"; data: string } +export type MinecraftAuthError = { type: "PublicKeyReading"; data: string } | { type: "PKCS8Error"; data: string } | { type: "SerializeError"; data: string } | { type: "DeserializeError"; data: string } | { type: "RequestError"; data: string } | { type: "SigningError"; data: string } | { type: "HashError"; data: string } | { type: "SessionIdError"; data: string } | { type: "XboxError"; data: string } /** * A structure of all needed Minecraft credentials for logging in and account management. @@ -313,20 +313,12 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'core':'{"getGameVersions":[],"getScreenshots":["id"],"setDefaultUser":["uuid"],"removeUser":["uuid"],"isClusterRunning":["cluster_id"],"killProcess":["pid"],"updateClusterById":["id","request"],"writeSettings":["setting"],"getWorlds":["id"],"setDiscordRPCMessage":["message"],"removeCape":["access_token"],"getLinkedPackages":["cluster_id"],"getPackageBody":["provider","body"],"removeCluster":["id"],"syncCluster":["cluster_id"],"launchCluster":["id","uuid","search_for_java"],"getLogByName":["id","name"],"getProfileOrDefault":["name"],"getClusterById":["id"],"getLogs":["id"],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"getRunningProcessesByClusterId":["cluster_id"],"getGlobalProfile":[],"refreshAccounts":[],"getRunningProcesses":[],"readSettings":[],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getPackage":["provider","slug"],"fetchMinecraftProfile":["uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"setClusterStage":["id","stage"],"createSettingsProfile":["name"],"open":["input"],"createCluster":["options"],"updateClusterProfile":["name","profile"],"refreshAccount":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUsers":[],"getUser":["uuid"],"getUsersFromAuthor":["provider","author"],"getMultiplePackages":["provider","slugs"],"changeCape":["access_token","cape_uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"convertUsernameUUID":["username_uuid"],"removePackage":["cluster_id","package_hash"],"searchPackages":["provider","query"],"getClusters":[],"togglePackage":["cluster_id","package_hash"],"installModpack":["modpack","cluster_id"],"getProcessLogTail":["id","max_lines"],"fetchLoggedInProfile":["access_token"],"getDefaultUser":["fallback"]}', 'debug':'{"getBuildTimestamp":[],"getType":[],"getPackageVersion":[],"openDevTools":[],"getPlatform":[],"getLocale":[],"isInDev":[],"getArch":[],"getOsVersion":[],"getGitCommitHash":[],"getFamily":[]}', 'oneclient':'{"installUpdate":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"checkForUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"cacheArt":["path"],"refreshArt":["path"],"isBundleSyncing":[],"getVersions":[]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}' } -export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, +const ARGS_MAP = { 'core':'{"updateClusterById":["id","request"],"createSettingsProfile":["name"],"convertUsernameUUID":["username_uuid"],"refreshAccount":["uuid"],"killProcess":["pid"],"removeCluster":["id"],"writeSettings":["setting"],"getScreenshots":["id"],"getUsersFromAuthor":["provider","author"],"removePackage":["cluster_id","package_hash"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"changeSkin":["access_token","skin_url","skin_variant"],"getProcessLogTail":["id","max_lines"],"searchPackages":["provider","query"],"createCluster":["options"],"getPackageBody":["provider","body"],"getClusterById":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"fetchMinecraftProfile":["uuid"],"fetchLoggedInProfile":["access_token"],"getProfileOrDefault":["name"],"isClusterRunning":["cluster_id"],"getDefaultUser":["fallback"],"refreshAccounts":[],"getRunningProcesses":[],"getLogs":["id"],"openMsaLogin":[],"getClusters":[],"getLoadersForVersion":["mc_version"],"launchCluster":["id","uuid","search_for_java"],"getGlobalProfile":[],"removeUser":["uuid"],"getGameVersions":[],"setDefaultUser":["uuid"],"updateClusterProfile":["name","profile"],"setClusterStage":["id","stage"],"getUser":["uuid"],"getUsers":[],"getPackage":["provider","slug"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"syncCluster":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"changeCape":["access_token","cape_uuid"],"removeCape":["access_token"],"getWorlds":["id"],"getLogByName":["id","name"],"readSettings":[],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getLinkedPackages":["cluster_id"],"getMultiplePackages":["provider","slugs"],"getRunningProcessesByClusterId":["cluster_id"],"installModpack":["modpack","cluster_id"],"setDiscordRPCMessage":["message"],"open":["input"]}', 'oneclient':'{"refreshArt":["path"],"cacheArt":["path"],"getVersions":[],"getBundlesFor":["cluster_id"],"updateBundlePackages":["cluster_id"],"checkForUpdate":[],"installUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"isBundleSyncing":[],"extractBundleOverrides":["bundle_path","cluster_id"],"getClustersGroupedByMajor":[]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'debug':'{"getGitCommitHash":[],"isInDev":[],"getPackageVersion":[],"getArch":[],"getOsVersion":[],"getLocale":[],"getBuildTimestamp":[],"getType":[],"getFamily":[],"openDevTools":[],"getPlatform":[]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}' } +export type Router = { 'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, openCluster: (folderName: string) => Promise }, -'debug': { openDevTools: () => Promise, -isInDev: () => Promise, -getArch: () => Promise, -getFamily: () => Promise, -getLocale: () => Promise, -getType: () => Promise, -getPlatform: () => Promise, -getOsVersion: () => Promise, -getGitCommitHash: () => Promise, -getBuildTimestamp: () => Promise, -getPackageVersion: () => Promise }, 'oneclient': { getClustersGroupedByMajor: () => Promise>, getBundlesFor: (clusterId: number) => Promise, getVersions: () => Promise, @@ -392,9 +384,17 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, -'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise } }; +'debug': { openDevTools: () => Promise, +isInDev: () => Promise, +getArch: () => Promise, +getFamily: () => Promise, +getLocale: () => Promise, +getType: () => Promise, +getPlatform: () => Promise, +getOsVersion: () => Promise, +getGitCommitHash: () => Promise, +getBuildTimestamp: () => Promise, +getPackageVersion: () => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/overlay/AddAccountModal.tsx b/apps/oneclient/frontend/src/components/overlay/AddAccountModal.tsx index badab9fb..3485251a 100644 --- a/apps/oneclient/frontend/src/components/overlay/AddAccountModal.tsx +++ b/apps/oneclient/frontend/src/components/overlay/AddAccountModal.tsx @@ -1,12 +1,13 @@ import type { MinecraftCredentials } from '@/bindings.gen'; -import { AccountAvatar, Overlay } from '@/components'; +import { AccountAvatar, isMinecraftAuthError, MinecraftAuthErrorModal, Overlay } from '@/components'; import { bindings } from '@/main'; import { useCommandMut } from '@onelauncher/common'; import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; export function AddAccountModal() { return ( - + Add Account

Add a new account to OneClient

@@ -16,9 +17,11 @@ export function AddAccountModal() { export function AddAccountModalButton() { const queryClient = useQueryClient(); + const [authError, setAuthError] = useState(null); const { data: profile, isPending, mutate: login } = useCommandMut(bindings.core.openMsaLogin, { - onSuccess() { + onSuccess(data) { + setAuthError(null); queryClient.invalidateQueries({ queryKey: ['getUsers'], }); @@ -26,12 +29,21 @@ export function AddAccountModalButton() { queryKey: ['getDefaultUser'], }); }, + onError(error) { + console.error('[auth] AddAccountModal: openMsaLogin failed', error); + if (isMinecraftAuthError(error)) + setAuthError(error); + }, }); const onClick = () => { + setAuthError(null); login(); }; + if (authError) + return ; + return ( profile ? ( diff --git a/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx b/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx new file mode 100644 index 00000000..48ba6c72 --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx @@ -0,0 +1,184 @@ +import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors'; +import { Overlay } from '@/components'; +import { bindings } from '@/main'; +import { useCommandMut } from '@onelauncher/common'; +import { Button } from '@onelauncher/common/components'; +import { useQueryClient } from '@tanstack/react-query'; +import { AlertTriangleIcon, Copy01Icon, RefreshCw01Icon } from '@untitled-theme/icons-react'; +import { useState } from 'react'; +import { minecraftAuthErrors } from './minecraft-auth-errors'; + +/** + * Extracts an Xbox error code from a LauncherError or error string. + * Error format from backend: "Minecraft authentication error: during MSA step " + */ +export function extractXboxErrorCode(error: unknown): string | null { + const errorStr = typeof error === 'string' + ? error + : error instanceof Error + ? error.message + : typeof error === 'object' && error !== null + ? JSON.stringify(error) + : String(error); + + const match = errorStr.match(/Minecraft authentication error:\s*(\d+)/); + return match ? match[1] : null; +} + +/** + * Checks whether an error is a Minecraft/Xbox authentication error. + */ +export function isMinecraftAuthError(error: unknown): boolean { + return extractXboxErrorCode(error) !== null; +} + +function MatchedErrorContent({ + matchedError, +}: { + matchedError: MinecraftAuthErrorInfo; +}) { + return ( +
+
+

What happened

+

{matchedError.whatHappened}

+
+ +
+

How to fix it

+
    + {matchedError.stepsToFix.map((step, index) => ( +
  1. + + {index + 1} + + +
  2. + ))} +
+
+
+ ); +} + +function UnknownErrorContent() { + return ( +
+
+

Unknown error

+

+ We don't recognize this error and can't recommend specific steps to resolve it. +

+

+ Try visiting + {' '} + + Minecraft Login + + {' '} + and signing in, as it may prompt you with the necessary steps. +

+
+
+ ); +} + +export function MinecraftAuthErrorModal({ + error, +}: { + error: unknown; +}) { + const queryClient = useQueryClient(); + const [copied, setCopied] = useState(false); + + const errorCode = extractXboxErrorCode(error); + const matchedError = errorCode + ? minecraftAuthErrors.find(e => e.errorCode === errorCode) + : null; + + const { isPending: isRetrying, mutate: retryLogin } = useCommandMut(bindings.core.openMsaLogin, { + onSuccess(data) { + console.warn('[auth] MinecraftAuthErrorModal: retry openMsaLogin succeeded', data ? `user=${data.username} id=${data.id}` : 'returned null'); + queryClient.invalidateQueries({ queryKey: ['getUsers'] }); + queryClient.invalidateQueries({ queryKey: ['getDefaultUser'] }); + }, + onError(err) { + console.error('[auth] MinecraftAuthErrorModal: retry openMsaLogin failed', err); + }, + }); + + const debugInfo = typeof error === 'string' + ? error + : error instanceof Error + ? error.message + : JSON.stringify(error, null, 2); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(debugInfo); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + catch { + console.error('[auth] Failed to copy to clipboard'); + } + }; + + return ( + +
+
+ +
+ Sign-in Error +
+ +

+ Something went wrong while signing in with your Microsoft account. + {errorCode && ( + <> + {' '} + + (Error code: + {' '} + {errorCode} + ) + + + )} +

+ + {matchedError ? : } + +
+ + + +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx b/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx index afbbb8e6..0a4f7e4e 100644 --- a/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx +++ b/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx @@ -1,4 +1,4 @@ -import { Overlay, RawDebugInfo, SettingsRow, SettingsSwitch, useDebugInfo } from '@/components'; +import { minecraftAuthErrors, MinecraftAuthErrorModal, Overlay, RawDebugInfo, SettingsRow, SettingsSwitch, useDebugInfo } from '@/components'; import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; import { Button } from '@onelauncher/common/components'; @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; export function SuperSecretDevOptions() { const { createSetting } = useSettings(); const debugInfo = useDebugInfo(); + const [previewError, setPreviewError] = useState(null); const [launcherDir, setLauncherDir] = useState(''); @@ -48,9 +49,43 @@ export function SuperSecretDevOptions() { + +
+

Auth Error Preview

+
+ {minecraftAuthErrors.map(err => ( + + ))} + +
+
+ + {previewError && ( + { + if (!open) + setPreviewError(null); + }} + > + + + )}
); } diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index 3865c7a2..9a82efa7 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -4,6 +4,8 @@ export * from './BundleModListModal'; export * from './DebugInfo'; export * from './ImportSkinModal'; export * from './KillMinecraft'; +export * from './minecraft-auth-errors'; +export * from './MinecraftAuthErrorModal'; export * from './NoAccountPopup'; export * from './Overlay'; export * from './Popup'; diff --git a/apps/oneclient/frontend/src/components/overlay/minecraft-auth-errors.ts b/apps/oneclient/frontend/src/components/overlay/minecraft-auth-errors.ts new file mode 100644 index 00000000..bd96365f --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/minecraft-auth-errors.ts @@ -0,0 +1,90 @@ +export interface MinecraftAuthErrorInfo { + errorCode: string; + whatHappened: string; + stepsToFix: Array; +} + +export const minecraftAuthErrors: Array = [ + { + errorCode: '2148916222', + whatHappened: + 'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.', + stepsToFix: [ + 'Go to the Minecraft Login page and sign in', + 'Follow the instructions to verify your age', + 'Once verified, try signing in again', + 'For additional help, visit UK age verification on Xbox', + ], + }, + { + errorCode: '2148916233', + whatHappened: `This account doesn't have an Xbox profile set up or doesn't own Minecraft.`, + stepsToFix: [ + 'Make sure Minecraft is purchased on this account', + 'Visit Minecraft Login and sign in', + 'Complete Xbox profile setup if prompted', + 'Once finished, try signing in again', + ], + }, + { + errorCode: '2148916235', + whatHappened: `Xbox Live isn't available in your region, so sign-in is blocked.`, + stepsToFix: [ + 'Xbox services must be supported in your country before you can sign in', + 'Check Xbox Availability for supported regions', + ], + }, + { + errorCode: '2148916236', + whatHappened: 'This account requires adult verification under South Korean regulations.', + stepsToFix: [ + 'Visit Xbox and sign in', + 'Complete the identity verification process', + 'Once finished, try signing in again', + ], + }, + { + errorCode: '2148916237', + whatHappened: 'This account requires adult verification under South Korean regulations.', + stepsToFix: [ + 'Visit Xbox and sign in', + 'Complete the identity verification process', + 'Once finished, try signing in again', + ], + }, + { + errorCode: '2148916238', + whatHappened: 'This account is underage and not linked to a Microsoft family group.', + stepsToFix: [ + 'Review the Family Setup Guide', + 'Join or create a family group as instructed', + 'Once finished, try signing in again', + ], + }, + { + errorCode: '2148916227', + whatHappened: 'This account was suspended for violating Xbox Community Standards.', + stepsToFix: [ + 'Visit Xbox Support and review the enforcement details', + 'Submit an appeal if one is available', + ], + }, + { + errorCode: '2148916229', + whatHappened: `This account is restricted and doesn't have permission to play online.`, + stepsToFix: [ + 'Have a guardian sign in to Microsoft Family', + 'Update online play permissions', + 'Once finished, try signing in again', + ], + }, + { + errorCode: '2148916234', + whatHappened: `This account hasn't accepted Xbox's Terms of Service.`, + stepsToFix: [ + 'Visit Xbox and sign in', + 'Accept the Terms if prompted', + 'Once finished, try signing in again', + ], + }, +]; diff --git a/apps/oneclient/frontend/src/routes/onboarding/account.tsx b/apps/oneclient/frontend/src/routes/onboarding/account.tsx index 71608504..70bf2135 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/account.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/account.tsx @@ -1,5 +1,5 @@ import type { MinecraftCredentials } from '@/bindings.gen'; -import { AccountAvatar, SkinViewer } from '@/components'; +import { AccountAvatar, isMinecraftAuthError, MinecraftAuthErrorModal, Overlay, SkinViewer } from '@/components'; import { usePlayerProfile } from '@/hooks/usePlayerProfile'; import { bindings } from '@/main'; import { OnboardingNavigation } from '@/routes/onboarding/route'; @@ -7,6 +7,7 @@ import { useCommandMut, useCommandSuspense } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { useQueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; +import { useState } from 'react'; export const Route = createFileRoute('/onboarding/account')({ component: RouteComponent, @@ -52,16 +53,26 @@ function AccountPreview({ function RouteComponent() { const queryClient = useQueryClient(); - const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true)); + const [authError, setAuthError] = useState(null); + const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => { + return bindings.core.getDefaultUser(true); + }); const { data: profile, isPending, mutate: login } = useCommandMut(bindings.core.openMsaLogin, { - onSuccess() { + onSuccess(data) { + setAuthError(null); queryClient.invalidateQueries({ queryKey: ['getDefaultUser'], }); }, + onError(error) { + console.error('[auth] onboarding/account: openMsaLogin failed', error); + if (isMinecraftAuthError(error)) + setAuthError(error); + }, }); const onClick = () => { + setAuthError(null); login(); }; @@ -100,6 +111,19 @@ function RouteComponent() { )} + + {authError && ( + { + if (!open) + setAuthError(null); + }} + > + + + )} ); } diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index ef6d710f..8c0c957d 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -321,6 +321,7 @@ pub struct ProfileUpdate { #[taurpc::ipc_type] pub struct TauriLauncherApiImpl; +#[allow(clippy::too_many_lines)] #[taurpc::resolvers] impl TauriLauncherApi for TauriLauncherApiImpl { // MARK: Impl: clusters @@ -622,7 +623,13 @@ impl TauriLauncherApi for TauriLauncherApiImpl { ) -> LauncherResult> { use tauri::Manager; - let flow = api::credentials::begin().await?; + let flow = api::credentials::begin().await.map_err(|e| { + tracing::error!( + "[auth] open_msa_login() - credentials::begin() failed: {:?}", + e + ); + e + })?; let now = chrono::Utc::now(); @@ -643,7 +650,14 @@ impl TauriLauncherApi for TauriLauncherApiImpl { .inner_size(800.0, 600.0) .center() .focused(true) - .build()?; + .build() + .map_err(|e| { + tracing::error!( + "[auth] open_msa_login() - failed to create login window: {:?}", + e + ); + e + })?; win.request_user_attention(Some(tauri::UserAttentionType::Critical))?; @@ -652,18 +666,33 @@ impl TauriLauncherApi for TauriLauncherApiImpl { return Ok(None); } - if win - .url()? + let current_url = match win.url() { + Ok(u) => u, + Err(_) => { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + continue; + } + }; + + if current_url .as_str() .starts_with("https://login.live.com/oauth20_desktop.srf") - && let Some((_, code)) = win.url()?.query_pairs().find(|x| x.0 == "code") { - win.close()?; - let value = api::credentials::finish(&code.clone(), flow).await?; + if let Some((_, code)) = win.url()?.query_pairs().find(|x| x.0 == "code") { + win.close()?; + let value = api::credentials::finish(&code.clone(), flow) + .await + .map_err(|e| { + tracing::error!( + "[auth] open_msa_login() - credentials::finish() failed: {:?}", + e + ); + e + })?; - return Ok(Some(value)); + return Ok(Some(value)); + } } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; } diff --git a/packages/core/src/logger.rs b/packages/core/src/logger.rs index 6d5cd818..b372f3eb 100644 --- a/packages/core/src/logger.rs +++ b/packages/core/src/logger.rs @@ -18,7 +18,7 @@ pub async fn start_logger() { Core::get() .logger_filter .clone() - .unwrap_or_else(|| format!("{}=info", env!("CARGO_PKG_NAME"))), + .unwrap_or_else(|| format!("{}=debug", env!("CARGO_PKG_NAME"))), ) }); diff --git a/packages/core/src/store/credentials.rs b/packages/core/src/store/credentials.rs index 79cc2677..a0f50b55 100644 --- a/packages/core/src/store/credentials.rs +++ b/packages/core/src/store/credentials.rs @@ -36,18 +36,17 @@ impl CredentialsStore { #[tracing::instrument] pub async fn initialize() -> LauncherResult { let path = Dirs::get_auth_file().await?; - let store = io::read_json(&path).await.ok(); + let store = io::read_json::(&path).await.ok(); - store.map_or_else( - || { - Ok(Self { - users: HashMap::new(), - token: None, - default_user: None, - }) - }, - Ok, - ) + if let Some(s) = store { + Ok(s) + } else { + Ok(Self { + users: HashMap::new(), + token: None, + default_user: None, + }) + } } /// Save the current Minecraft credentials. @@ -143,6 +142,10 @@ impl CredentialsStore { }), Err(err) => { if valid_date { + tracing::error!( + "[auth] begin() - sisu_authenticate failed (device token was fresh): {:?}", + err + ); Err(err.into()) } else { let (key, token, current_date, _) = self.refresh(Utc::now(), false).await?; @@ -290,6 +293,7 @@ impl CredentialsStore { _ => {} } + tracing::error!("[auth] get_default() - token refresh failed: {:?}", err); Err(err) } } @@ -397,7 +401,7 @@ pub async fn device_token( key: &DeviceKey, current_date: DateTime, ) -> Result, MinecraftAuthError> { - let res = send_signed_request( + let res: SignedRequestResponse = send_signed_request( None, "https://device.auth.xboxlive.com/device/authenticate", "/device/authenticate", @@ -474,7 +478,10 @@ async fn sisu_authenticate( .headers .get("X-SessionId") .and_then(|x| x.to_str().ok()) - .ok_or_else(|| MinecraftAuthError::SessionIdError)? + .ok_or_else(|| { + tracing::error!("[auth] sisu_authenticate() - missing X-SessionId header in response"); + MinecraftAuthError::SessionIdError + })? .to_string(); Ok(( @@ -533,7 +540,15 @@ async fn oauth_token( source, })?; - let body = + if !status.is_success() { + tracing::error!( + "[auth] oauth_token() - non-success status={}, body={}", + status, + text + ); + } + + let body: OAuthToken = serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { step: MinecraftAuthStep::OAuthToken, raw: text, @@ -581,7 +596,15 @@ async fn oauth_refresh( source, })?; - let body = + if !status.is_success() { + tracing::error!( + "[auth] oauth_refresh() - non-success status={}, body={}", + status, + text + ); + } + + let body: OAuthToken = serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { step: MinecraftAuthStep::RefreshOAuthToken, raw: text, @@ -657,7 +680,7 @@ async fn xsts_authorize( key: &DeviceKey, current_date: DateTime, ) -> Result, MinecraftAuthError> { - let res = send_signed_request( + let res: SignedRequestResponse = send_signed_request( None, "https://xsts.auth.xboxlive.com/xsts/authorize", "/xsts/authorize", @@ -699,7 +722,13 @@ async fn minecraft_token(token: DeviceToken) -> Result Result Result Result { pub body: T, } +/// Xbox error response structure returned by Xbox Live APIs on authentication failure. +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct XboxErrorResponse { + /// The Xbox error code (e.g., 2148916233 for no Xbox profile). + #[serde(rename = "XErr")] + pub x_err: u64, + /// A human-readable error message from Xbox. + #[serde(rename = "Message")] + pub message: String, + /// An optional redirect URL for resolving the error. + #[serde(rename = "Redirect")] + pub redirect: Option, +} + #[allow(clippy::cast_possible_wrap)] #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] +#[allow(clippy::too_many_lines)] async fn send_signed_request( authorization: Option<&str>, url: &str, @@ -961,6 +1033,25 @@ async fn send_signed_request( .await .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + if !status.is_success() { + tracing::error!( + "[auth] send_signed_request() - step={:?}, non-success status={}, body={}", + step, + status, + body + ); + + // Try to parse as an Xbox error response (contains XErr code) + if let Ok(xbox_err) = serde_json::from_str::(&body) { + return Err(MinecraftAuthError::XboxError { + step, + error_code: xbox_err.x_err, + message: xbox_err.message, + redirect: xbox_err.redirect, + }); + } + } + let body = serde_json::from_str(&body).map_err(|source| MinecraftAuthError::DeserializeError { step, @@ -1061,4 +1152,11 @@ pub enum MinecraftAuthError { HashError, #[error("failed to read user xbox session ID")] SessionIdError, + #[error("Minecraft authentication error: {error_code} during MSA step {step:?}")] + XboxError { + step: MinecraftAuthStep, + error_code: u64, + message: String, + redirect: Option, + }, }