feat: added status bar for auth states (#954)

This commit is contained in:
Maciek 2026-10-10 01:02:39 +02:00 • committed by GitHub
parent 97d4477c69
commit 6ee7bdb443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 617 additions and 78 deletions

View file

@ -0,0 +1,5 @@
---
default: patch
---
# Added status bar showing up if a player is logged out or he has a stale refresh token

View file

@ -91,7 +91,7 @@ pub use markdown::{Markdown, MarkdownStyle};
pub use memory_field::memory_field;
pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay;
pub(crate) use microsoft_login::login_dialog;
pub use microsoft_login::use_microsoft_login;
pub use microsoft_login::{MicrosoftLogin, use_microsoft_login};
pub use modpack_confirm_popup::ModpackConfirmPopup;
pub(crate) use navbar::window_controls;
pub use navbar::{AppNavbar, OnboardingNavbar};

View file

@ -2,9 +2,12 @@ use std::collections::HashSet;
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_net::status::{self, ServiceStatus};
use crate::components::{Icon, IconType};
use crate::hooks::{try_default_account, use_current_account};
use crate::routes::Route;
use crate::theme::colors;
const BAR_HEIGHT: f32 = 34.;
@ -18,32 +21,48 @@ const BAR_LAYER: u8 = 14;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Issue {
NoInternet,
SignedOut,
McAuthDown,
PolyfrostDown,
}
/// What the bar reacts to the network probe plus whether the active account
/// lost its sign-in
#[derive(Clone, PartialEq)]
struct Conditions {
status: ServiceStatus,
signed_out_as: Option<String>,
}
impl Issue {
fn is_active(self, s: &ServiceStatus) -> bool {
fn is_active(self, c: &Conditions) -> bool {
let s = &c.status;
match self {
Self::NoInternet => !s.online,
Self::SignedOut => c.signed_out_as.is_some(),
Self::McAuthDown => s.online && !s.mc_auth_up,
Self::PolyfrostDown => s.online && !s.polyfrost_up,
}
}
fn message(self) -> &'static str {
fn message(self, c: &Conditions) -> String {
match self {
Self::NoInternet => "No internet connection.",
Self::NoInternet => "No internet connection.".to_string(),
Self::SignedOut => format!(
"You have been signed out of {}. Sign in again to keep playing.",
c.signed_out_as.as_deref().unwrap_or("your account")
),
Self::McAuthDown => {
"Minecraft authentication servers are unreachable. Logging in may fail."
"Minecraft authentication servers are unreachable. Logging in may fail.".to_string()
}
Self::PolyfrostDown => "Polyfrost services are experiencing issues.",
Self::PolyfrostDown => "Polyfrost services are experiencing issues.".to_string(),
}
}
fn icon(self) -> IconType {
match self {
Self::NoInternet => IconType::Globe01,
Self::SignedOut => IconType::Users01,
Self::McAuthDown => IconType::AlertTriangle,
Self::PolyfrostDown => IconType::AlertCircle,
}
@ -51,7 +70,7 @@ impl Issue {
fn background(self) -> Color {
match self {
Self::NoInternet => colors::danger(),
Self::NoInternet | Self::SignedOut => colors::danger(),
Self::McAuthDown | Self::PolyfrostDown => AMBER,
}
}
@ -59,13 +78,23 @@ impl Issue {
fn closeable(self) -> bool {
!matches!(self, Self::NoInternet)
}
/// Where pressing the message takes the user if anywhere
fn route(self) -> Option<Route> {
matches!(self, Self::SignedOut).then_some(Route::SettingsAccounts {})
}
}
fn active_issues(s: &ServiceStatus) -> Vec<Issue> {
[Issue::NoInternet, Issue::McAuthDown, Issue::PolyfrostDown]
.into_iter()
.filter(|i| i.is_active(s))
.collect()
fn active_issues(c: &Conditions) -> Vec<Issue> {
[
Issue::NoInternet,
Issue::SignedOut,
Issue::McAuthDown,
Issue::PolyfrostDown,
]
.into_iter()
.filter(|i| i.is_active(c))
.collect()
}
#[derive(PartialEq)]
@ -75,6 +104,7 @@ impl Component for StatusBar {
fn render(&self) -> impl IntoElement {
let mut status = use_state(status::current);
let mut dismissed = use_state(HashSet::<Issue>::new);
let current_account = use_current_account();
use_hook(move || {
status::request_recheck();
@ -86,21 +116,35 @@ impl Component for StatusBar {
});
});
let conditions = move || Conditions {
status: *status.read(),
signed_out_as: try_default_account(&current_account)
.filter(|a| a.needs_sign_in())
.map(|a| a.username),
};
// A dismissal lasts only while its issue does so the next sign-out
// shows the bar again
use_side_effect(move || {
let s = *status.read();
let c = conditions();
let cur = dismissed.peek().clone();
let next: HashSet<Issue> = cur.iter().copied().filter(|i| i.is_active(&s)).collect();
let next: HashSet<Issue> = cur.iter().copied().filter(|i| i.is_active(&c)).collect();
if next != cur {
dismissed.set(next);
}
});
let s = *status.read();
let c = conditions();
let dset = dismissed.read();
let visible = active_issues(&s).into_iter().find(|i| !dset.contains(i));
let visible = active_issues(&c).into_iter().find(|i| !dset.contains(i));
match visible {
Some(issue) => StatusBanner { issue, dismissed }.into_element(),
Some(issue) => StatusBanner {
issue,
text: issue.message(&c),
dismissed,
}
.into_element(),
None => rect().into_element(),
}
}
@ -109,6 +153,7 @@ impl Component for StatusBar {
#[derive(PartialEq)]
struct StatusBanner {
issue: Issue,
text: String,
dismissed: State<HashSet<Issue>>,
}
@ -153,6 +198,35 @@ impl Component for StatusBanner {
// to the top of the content box inside the bar's padding
let leading = rect().width(Size::flex(1.0)).height(Size::fill());
let mut action_hover = use_state(|| false);
let action = issue.route().map(|route| {
rect()
.center()
.height(Size::px(22.))
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(if *action_hover.read() {
Color::WHITE.with_a(46)
} else {
Color::WHITE.with_a(26)
})
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| action_hover.set(true))
.on_pointer_leave(move |_| action_hover.set(false))
.on_press(move |_| {
let _ = RouterContext::get().push(route.clone());
})
.child(
label()
.text("Sign in")
.font_size(12.)
.font_weight(FontWeight::SEMI_BOLD)
.color(Color::WHITE),
)
.into_element()
});
let message = rect()
.horizontal()
.height(Size::fill())
@ -162,12 +236,13 @@ impl Component for StatusBanner {
.child(Icon::new(issue.icon()).size(15.).color(Color::WHITE))
.child(
label()
.text(issue.message())
.text(self.text.clone())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(Color::WHITE),
);
)
.maybe_child(action);
let trailing = rect()
.width(Size::flex(1.0))

View file

@ -1503,6 +1503,24 @@ impl Actions {
/// `syncing_bundles` gates every launch button so the readiness check must
/// happen before the flag is raised not inside the task
/// Lets the signed-out bar show at startup instead of on the first launch
pub fn check_sessions() {
let state = match launcher::state() {
Ok(state) => state,
Err(err) => {
tracing::error!("session check skipped, launcher not ready: {err:#}");
return;
}
};
spawn_forever(async move {
let signed_out = off_ui(async move { state.auth.check_expired_sessions().await }).await;
if signed_out {
super::invalidate_auth_queries(None).await;
}
});
}
pub fn sync_bundles(&self) {
let state = match launcher::state() {
Ok(state) => state,
@ -2078,6 +2096,12 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
return;
};
// The renewal may have just marked the account signed out the accounts
// page and the status bar must pick that up while the game still starts
if account.needs_sign_in() {
super::invalidate_auth_queries(None).await;
}
crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await;
let content = state.services.content();

View file

@ -43,35 +43,35 @@ pub use queries::{
accounts_have_microsoft, available_bundles, bundle_overrides_map, bundles_with_status_items,
category_list, changelog_entries, changelog_error, changelog_is_loading, cluster_art_url,
cluster_content_items, content_type_for_slug, disable_warnings, game_versions,
has_migration_data, invalidate_cluster_content_queries, invalidate_cluster_queries,
invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries,
invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries,
java_majors, java_runtimes, latest_changelog_version, loaded_image, loader_versions,
login_code_already_handled, migration_detection, mutation_error, mutation_is_pending,
mutation_is_running, mutation_ok, onboarding_bundles_items, package_meta_batch,
package_updates, pick_version_metadata, project_detail, provider_versions, query_error,
query_is_busy, query_is_loading, refresh_version_art_gallery, reset_login_code_dedup,
resolve_art_url, search_items, search_pending, search_total, settled_or_loading, stale_hashes,
terms_document, terms_error, terms_is_loading, try_account, try_accounts,
try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, try_default_account,
try_game_profile, try_global_analytics, try_leftovers, try_log_content, try_storage_report,
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_available_bundles, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates,
use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog,
use_cluster, use_cluster_analytics, use_cluster_content, use_cluster_logs,
use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings,
use_clusters, use_current_account, use_default_account, use_disable_warnings,
use_discard_leftovers, use_finish_microsoft_login, use_game_profile, use_game_versions,
use_global_analytics, use_java_majors, use_java_runtimes, use_leftovers, use_loader_versions,
use_local_image, use_log_action, use_log_content, use_migratable_routes, use_migration,
use_mods_folder_sync, use_named_profiles, use_onboarding_bundles, use_package_categories,
use_package_meta_batch, use_package_project, use_package_search, use_package_updates,
use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions,
use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action,
use_screenshot_folder_watch, use_set_default_account, use_shadowed_mods, use_storage_action,
use_storage_report, use_terms, use_upload_log, use_version_art, use_version_art_gallery,
use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata,
versions_total,
has_migration_data, invalidate_auth_queries, invalidate_cluster_content_queries,
invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image,
loader_versions, login_code_already_handled, migration_detection, mutation_error,
mutation_is_pending, mutation_is_running, mutation_ok, onboarding_bundles_items,
package_meta_batch, package_updates, pick_version_metadata, project_detail, provider_versions,
query_error, query_is_busy, query_is_loading, refresh_version_art_gallery,
reset_login_code_dedup, resolve_art_url, search_items, search_pending, search_total,
settled_or_loading, stale_hashes, terms_document, terms_error, terms_is_loading, try_account,
try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots,
try_default_account, try_game_profile, try_global_analytics, try_leftovers, try_log_content,
try_storage_report, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_available_bundles, use_begin_microsoft_login,
use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image,
use_cancel_microsoft_login, use_changelog, use_cluster, use_cluster_analytics,
use_cluster_content, use_cluster_logs, use_cluster_mutation, use_cluster_profile,
use_cluster_screenshots, use_cluster_settings, use_clusters, use_current_account,
use_default_account, use_disable_warnings, use_discard_leftovers, use_finish_microsoft_login,
use_game_profile, use_game_versions, use_global_analytics, use_java_majors, use_java_runtimes,
use_leftovers, use_loader_versions, use_local_image, use_log_action, use_log_content,
use_migratable_routes, use_migration, use_mods_folder_sync, use_named_profiles,
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
use_package_search, use_package_updates, use_package_versions_when, use_player_profile,
use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts,
use_remove_account, use_screenshot_action, use_screenshot_folder_watch,
use_set_default_account, use_shadowed_mods, use_storage_action, use_storage_report, use_terms,
use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders,
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
};
pub use queries::{
add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents,

View file

@ -113,7 +113,7 @@ pub fn try_account(query: &UseQuery<AccountQuery>) -> Option<MinecraftAccount> {
super::state::settled_or_loading(query).flatten()
}
async fn invalidate_auth_queries(account_id: Option<Uuid>) {
pub async fn invalidate_auth_queries(account_id: Option<Uuid>) {
QueriesStorage::<ListAccountsQuery>::invalidate_matching(ListAccountsKeys).await;
for fallback in [false, true] {
QueriesStorage::<DefaultAccountQuery>::invalidate_matching(DefaultAccountKeys { fallback })
@ -307,10 +307,10 @@ impl MutationCapability for RefreshAccountMutation {
.await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
if result.is_ok() {
invalidate_auth_queries(Some(keys.id)).await;
}
/// A failure can still change the account a rejected token marks it
/// signed out
async fn on_settled(&self, keys: &Self::Keys, _result: &Result<Self::Ok, Self::Err>) {
invalidate_auth_queries(Some(keys.id)).await;
}
}
@ -329,10 +329,8 @@ impl MutationCapability for RefreshAllAccountsMutation {
.await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
if result.is_ok() {
invalidate_auth_queries(None).await;
}
async fn on_settled(&self, _keys: &Self::Keys, _result: &Result<Self::Ok, Self::Err>) {
invalidate_auth_queries(None).await;
}
}

View file

@ -42,12 +42,13 @@ pub use auth::{
AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys,
CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys,
RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount,
UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error,
mutation_is_pending, mutation_is_running, mutation_ok, reset_login_code_dedup, try_account,
try_accounts, try_default_account, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login,
use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account,
use_refresh_all_accounts, use_remove_account, use_set_default_account,
UseSetDefaultAccount, accounts_have_microsoft, invalidate_auth_queries,
login_code_already_handled, mutation_error, mutation_is_pending, mutation_is_running,
mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account,
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account,
use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts,
use_remove_account, use_set_default_account,
};
pub use bundles::{
AvailableBundlesQuery, ClusterBundles, OnboardingBundlesQuery, available_bundles,

View file

@ -61,6 +61,7 @@ impl App for OneClientApp {
// it early leaves `syncing_bundles` stuck disabling every launch button
Ok(()) => {
startup.sync_bundles();
Actions::check_sessions();
microsoft_java::spawn_auto_install();
}
Err(err) => {

View file

@ -9,7 +9,10 @@ use oneclient_net::status::{self, ServiceStatus};
use crate::Actions;
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch};
use crate::hooks::{
invalidate_auth_queries, settled_or_loading, try_default_account, use_active_cluster_id,
use_clusters, use_current_account, use_dispatch,
};
use crate::notifications::{
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
OptionalModsGroup,
@ -97,6 +100,11 @@ impl Component for Debug {
vec![AuthGuidancePreview.into_element()],
))
.child(divider())
.child(section(
"Session Expiry",
vec![SessionExpirySimulator.into_element()],
))
.child(divider())
.child(section(
"Corruption Simulator",
vec![CorruptionSimulator.into_element()],
@ -936,6 +944,121 @@ impl Component for AuthGuidancePreview {
}
}
#[derive(PartialEq)]
struct SessionExpirySimulator;
#[derive(Clone, Copy)]
enum ExpiryKind {
/// Skips the network and flips the account straight to signed out
SignedOut,
/// Leaves detection to Microsoft on the next launch
RejectedToken,
}
impl Component for SessionExpirySimulator {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let current = use_current_account();
let account = try_default_account(&current).filter(|a| a.is_microsoft());
let target = account.as_ref().map(|a| (a.id, a.username.clone()));
let usable = account.as_ref().is_some_and(|a| !a.needs_sign_in());
let description = match &account {
Some(a) if a.needs_sign_in() => format!("{} is already signed out.", a.username),
Some(a) => format!("Acts on the active account, {}.", a.username),
None => "The active account must be a Microsoft account.".to_string(),
};
let button = |text: &'static str, icon: IconType, kind: ExpiryKind| {
let dispatch = dispatch.clone();
let target = target.clone();
Button::new()
.danger()
.enabled(usable)
.child(Icon::new(icon).size(16.))
.text(text)
.on_press(move |_| {
if let Some((id, username)) = target.clone() {
run_expiry(&dispatch, kind, id, username);
}
})
};
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
label()
.text(description)
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.spacing(8.)
.child(button(
"Force signed out",
IconType::AlertTriangle,
ExpiryKind::SignedOut,
))
.child(button(
"Invalidate refresh token",
IconType::RefreshCw01,
ExpiryKind::RejectedToken,
)),
)
.child(
label()
.text("Invalidate keeps the account looking fine until the next launch, where Microsoft answers invalid_grant.")
.font_size(11.)
.color(colors::fg_secondary()),
)
.into_element()
}
}
fn run_expiry(dispatch: &crate::Actions, kind: ExpiryKind, id: uuid::Uuid, username: String) {
let dispatch = dispatch.clone();
spawn(async move {
let result = match crate::launcher::state() {
Ok(state) => match kind {
ExpiryKind::SignedOut => state.auth.mark_signed_out(id).await,
ExpiryKind::RejectedToken => state.auth.corrupt_refresh_token(id).await,
}
.map_err(|err| err.to_string()),
Err(err) => Err(err.to_string()),
};
invalidate_auth_queries(Some(id)).await;
match result {
Ok(()) => {
let body = match kind {
ExpiryKind::SignedOut => format!("{username} is now signed out"),
ExpiryKind::RejectedToken => {
format!("{username} will be rejected on the next launch")
}
};
dispatch
.notify("Session expiry simulated")
.body(body)
.icon(IconType::AlertTriangle)
.send();
}
Err(err) => {
dispatch
.notify("Simulation failed")
.body(err)
.error()
.send();
}
}
});
}
/// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check
#[derive(PartialEq)]
struct CorruptionSimulator;

View file

@ -7,7 +7,8 @@ use uuid::Uuid;
use super::{section_header, settings_page};
use crate::components::{
Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login,
Avatar, Button, Icon, IconType, MicrosoftLogin, OverlayPopup, PlayerModel, TextInput,
use_microsoft_login,
};
use crate::hooks::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
@ -87,10 +88,12 @@ impl Component for SettingsAccounts {
username: account.username.clone(),
kind: account.kind,
expires: account.expires,
signed_out: account.needs_sign_in(),
is_default: Some(account.id) == default_id,
set_default,
remove,
refresh,
sign_in: msa.clone(),
}
.into_element()
})
@ -136,6 +139,10 @@ fn hero(
on_add_microsoft: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
let (name, subtitle) = match &account {
Some(account) if account.needs_sign_in() => (
account.username.clone(),
"Signed out. Sign in again to play",
),
Some(account) => (account.username.clone(), kind_label(account.kind)),
None => (
"No active account".to_string(),
@ -396,15 +403,20 @@ const REFRESH_SPIN_TIME: u64 = 800;
const REFRESHING_OPACITY: f32 = 0.7;
/// Only the identity half fades the sign-in and remove buttons stay crisp
const SIGNED_OUT_OPACITY: f32 = 0.45;
struct AccountRow {
id: Uuid,
username: String,
kind: AccountKind,
expires: DateTime<Utc>,
signed_out: bool,
is_default: bool,
set_default: crate::hooks::UseSetDefaultAccount,
remove: crate::hooks::UseRemoveAccount,
refresh: crate::hooks::UseRefreshAccount,
sign_in: MicrosoftLogin,
}
impl PartialEq for AccountRow {
@ -413,7 +425,9 @@ impl PartialEq for AccountRow {
&& self.username == other.username
&& self.kind == other.kind
&& self.expires == other.expires
&& self.signed_out == other.signed_out
&& self.is_default == other.is_default
&& self.sign_in.pending == other.sign_in.pending
}
}
@ -430,7 +444,11 @@ impl Component for AccountRow {
let refresh = self.refresh;
let is_microsoft = self.kind == AccountKind::Microsoft;
let expired = is_microsoft && self.expires <= Utc::now();
let signed_out = is_microsoft && self.signed_out;
// A signed-out entry has no token left to call expired
let expired = is_microsoft && !signed_out && self.expires <= Utc::now();
let identity_opacity = if signed_out { SIGNED_OUT_OPACITY } else { 1. };
let sign_in = self.sign_in.clone();
let mut refreshing = use_state(|| false);
let is_refreshing = *refreshing.read();
@ -457,7 +475,9 @@ impl Component for AccountRow {
0.
};
let border_color = if expired {
let border_color = if signed_out {
colors::component_border()
} else if expired {
colors::danger()
} else if is_default {
colors::brand()
@ -481,19 +501,22 @@ impl Component for AccountRow {
1.
})
.a11y_role(AccessibilityRole::Button)
.maybe(!is_default, |el| {
.maybe(!is_default && !signed_out, |el| {
el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) }))
})
.child(
Avatar::new(id.to_string())
.width(Size::px(AVATAR_SIZE_PX))
.height(Size::px(AVATAR_SIZE_PX)),
rect().opacity(identity_opacity).child(
Avatar::new(id.to_string())
.width(Size::px(AVATAR_SIZE_PX))
.height(Size::px(AVATAR_SIZE_PX)),
),
)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(4.)
.opacity(identity_opacity)
.child(
rect()
.horizontal()
@ -508,18 +531,37 @@ impl Component for AccountRow {
.color(colors::fg_primary()),
)
.maybe_child(is_default.then(default_badge))
.maybe_child(expired.then(expired_badge)),
.maybe_child(expired.then(expired_badge))
.maybe_child(signed_out.then(signed_out_badge)),
)
// The kind rides with the id a third badge and a full uuid do not both fit
.child(
label()
.text(format!("{} · {id}", kind_label(self.kind)))
.text(if signed_out {
"Session expired. Sign in again to use this account".to_string()
} else {
format!("{} · {id}", kind_label(self.kind))
})
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.maybe_child(is_microsoft.then(|| {
.maybe_child(signed_out.then(|| {
let pending = sign_in.pending;
Button::new()
.secondary()
.enabled(!pending)
.tooltip("Sign in to this Microsoft account again")
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
sign_in.start();
})
.child(Icon::new(IconType::Globe01).size(16.))
.text(if pending { "Signing in..." } else { "Sign in" })
.into_element()
}))
.maybe_child((is_microsoft && !signed_out).then(|| {
Button::new()
.ghost()
.icon()
@ -610,6 +652,18 @@ fn expired_badge() -> impl IntoElement {
)
}
fn signed_out_badge() -> impl IntoElement {
badge(
Icon::new(IconType::AlertTriangle)
.size(12.)
.color(colors::fg_secondary())
.into_element(),
"Signed out".to_string(),
colors::component_border(),
colors::fg_secondary(),
)
}
fn badge(icon: impl IntoElement, text: String, border: Color, fg: Color) -> impl IntoElement {
rect()
.horizontal()

View file

@ -18,6 +18,10 @@ pub struct MinecraftAccount {
pub expires: DateTime<Utc>,
#[serde(default = "default_account_kind")]
pub kind: AccountKind,
/// Microsoft rejected the refresh token the entry stays listed but cannot
/// launch until the user signs in to it again
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub signed_out: bool,
}
fn default_account_kind() -> AccountKind {
@ -33,6 +37,10 @@ impl MinecraftAccount {
self.kind == AccountKind::Offline
}
pub fn needs_sign_in(&self) -> bool {
self.is_microsoft() && self.signed_out
}
pub fn is_expired(&self) -> bool {
self.expires <= Utc::now() + chrono::TimeDelta::seconds(60)
}

View file

@ -69,6 +69,9 @@ pub enum MinecraftAuthError {
message: String,
redirect: Option<String>,
},
/// Retrying cannot fix this only a fresh interactive sign-in can
#[error("Microsoft rejected the saved sign-in ({error})")]
RefreshTokenRevoked { error: String },
#[error(
"The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again."
)]
@ -131,6 +134,11 @@ pub enum AuthError {
#[error("invalid offline username: {reason}")]
InvalidOfflineUsername { reason: String },
#[error(
"Your Microsoft sign-in for {username} has expired. Sign in to it again in Settings > Accounts."
)]
SessionExpired { username: String },
#[error("account {0} is not registered")]
AccountNotFound(uuid::Uuid),

View file

@ -482,9 +482,30 @@ async fn refresh_msa_token(
source,
})?;
parse_json_response(res, MinecraftAuthStep::RefreshToken)
let status = res.status();
let text = res
.text()
.await
.map(MsaToken::from_response)
.map_err(|source| MinecraftAuthError::RequestError {
step: MinecraftAuthStep::RefreshToken,
source,
})?;
if status.is_client_error()
&& let Ok(err) = serde_json::from_str::<OAuthErrorResponse>(&text)
&& is_revoked_refresh_error(&err.error)
{
tracing::warn!(error = %err.error, description = ?err.error_description, "Microsoft rejected the refresh token");
return Err(MinecraftAuthError::RefreshTokenRevoked { error: err.error });
}
parse_json_text(status, text, MinecraftAuthStep::RefreshToken).map(MsaToken::from_response)
}
/// `invalid_grant` covers expired revoked and password-changed tokens
/// `interaction_required` is MFA or consent neither can be answered silently
fn is_revoked_refresh_error(error: &str) -> bool {
matches!(error, "invalid_grant" | "interaction_required")
}
struct MsaToken {
@ -541,6 +562,7 @@ async fn account_from_msa_token(
#[allow(clippy::cast_possible_wrap)]
expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64),
kind: AccountKind::Microsoft,
signed_out: false,
})
}
@ -779,6 +801,14 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
.await
.map_err(|source| MinecraftAuthError::RequestError { step, source })?;
parse_json_text(status, text, step)
}
fn parse_json_text<T: for<'de> Deserialize<'de>>(
status: reqwest::StatusCode,
text: String,
step: MinecraftAuthStep,
) -> Result<T, MinecraftAuthError> {
if !status.is_success() {
tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status");
}

View file

@ -48,5 +48,6 @@ pub fn offline_account(username: String) -> MinecraftAccount {
refresh_token: String::new(),
expires: Utc::now() + Duration::days(3650),
kind: AccountKind::Offline,
signed_out: false,
}
}

View file

@ -222,6 +222,23 @@ impl AuthService {
Ok(())
}
/// Debug aid puts the account straight into the state a rejected refresh
/// token leaves behind
#[tracing::instrument(skip(self), fields(%id))]
pub async fn mark_signed_out(&self, id: Uuid) -> AuthResult<()> {
let guard = self.refresh_guard(id);
let _serialised = guard.lock().await;
self.store.lock().await.mark_signed_out(id).await
}
/// Debug aid see [`CredentialsStore::corrupt_refresh_token`]
#[tracing::instrument(skip(self), fields(%id))]
pub async fn corrupt_refresh_token(&self, id: Uuid) -> AuthResult<()> {
let guard = self.refresh_guard(id);
let _serialised = guard.lock().await;
self.store.lock().await.corrupt_refresh_token(id).await
}
pub async fn has_microsoft_account(&self) -> bool {
self.store.lock().await.has_microsoft_account()
}
@ -250,6 +267,11 @@ impl AuthService {
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult<MinecraftAccount> {
let existing = self.account_snapshot(id).await?;
if existing.needs_sign_in() {
return Err(AuthError::SessionExpired {
username: existing.username,
});
}
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
return Ok(existing);
}
@ -259,6 +281,11 @@ impl AuthService {
// Re-read under the guard whoever held it may have just refreshed
let existing = self.account_snapshot(id).await?;
if existing.needs_sign_in() {
return Err(AuthError::SessionExpired {
username: existing.username,
});
}
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
return Ok(existing);
}
@ -273,6 +300,13 @@ impl AuthService {
.await?;
Ok(refreshed)
}
Err(MinecraftAuthError::RefreshTokenRevoked { .. }) => {
tracing::warn!(username = %existing.username, "refresh token rejected, marking account signed out");
self.store.lock().await.mark_signed_out(id).await?;
Err(AuthError::SessionExpired {
username: existing.username,
})
}
Err(err) => {
let err = AuthError::from(err);
if store::is_transient_auth_error(&err) {
@ -290,21 +324,64 @@ impl AuthService {
self.renew_token(id, true).await
}
/// A signed-out account is skipped rather than failing the whole batch it
/// already shows as needing a sign-in
#[tracing::instrument(level = "debug", skip_all)]
pub async fn refresh_all_accounts(&self) -> AuthResult<Vec<MinecraftAccount>> {
let ids: Vec<Uuid> = self.store.lock().await.users.keys().copied().collect();
let mut refreshed = Vec::with_capacity(ids.len());
for id in ids {
refreshed.push(self.renew_token(id, true).await?);
match self.renew_token(id, true).await {
Ok(account) => refreshed.push(account),
Err(AuthError::SessionExpired { .. }) => {}
Err(err) => return Err(err),
}
}
Ok(refreshed)
}
/// A revoked refresh token is only discovered by trying it doing so at
/// startup surfaces the sign-out before the user presses play
/// Only lapsed tokens are tried a live one needs no renewal yet
/// Returns whether any account was newly marked signed out
#[tracing::instrument(level = "debug", skip_all)]
pub async fn check_expired_sessions(&self) -> bool {
let ids: Vec<Uuid> = self
.store
.lock()
.await
.users
.values()
.filter(|a| a.is_microsoft() && !a.needs_sign_in() && a.is_expired())
.map(|a| a.id)
.collect();
let mut signed_out = false;
for id in ids {
match self.renew_token(id, false).await {
Ok(_) => {}
Err(AuthError::SessionExpired { .. }) => signed_out = true,
Err(err) => tracing::warn!(%id, "startup session check failed: {err}"),
}
}
signed_out
}
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
pub async fn account_for_launch(&self, id: Uuid) -> AuthResult<MinecraftAccount> {
let account = self.renew_token(id, false).await?;
let account = match self.renew_token(id, false).await {
Ok(account) => account,
// A signed-out account still plays under its saved name and uuid
// with an empty token like an offline account singleplayer works
// and the UI keeps asking for a sign-in
Err(AuthError::SessionExpired { username }) => {
tracing::info!(%username, "launching signed-out account without a token");
self.account_snapshot(id).await?
}
Err(err) => return Err(err),
};
if account.is_offline() && !self.has_microsoft_account().await {
return Err(AuthError::OfflineRequiresMicrosoft);
@ -429,4 +506,67 @@ mod tests {
))
));
}
fn signed_out_service() -> (AuthService, Uuid) {
let mut account = crate::offline::offline_account("MsaUser".to_string());
account.kind = crate::data::AccountKind::Microsoft;
account.signed_out = true;
let id = account.id;
let mut store = CredentialsStore::default();
store.users.insert(id, account);
store.default_user = Some(id);
let (events, _rx) = EventBus::channel();
let net = RequestClient::new(NetConfig::default()).expect("net client");
(AuthService::with_store(store, net, events), id)
}
#[tokio::test]
async fn a_signed_out_account_still_launches_without_touching_the_network() {
let (service, id) = signed_out_service();
let account = service
.default_account_for_launch()
.await
.expect("a signed-out account must still launch")
.expect("the only account is the default");
assert_eq!(account.id, id);
assert!(account.needs_sign_in());
assert!(account.access_token.is_empty());
}
#[tokio::test]
async fn refresh_all_skips_signed_out_accounts() {
let (service, id) = signed_out_service();
let refreshed = service.refresh_all_accounts().await.unwrap();
assert!(refreshed.is_empty());
assert!(
service.get_account(id).await.is_some(),
"the entry must stay listed"
);
}
#[tokio::test]
async fn the_startup_check_leaves_live_and_signed_out_accounts_alone() {
let (service, signed_out_id) = signed_out_service();
let mut live = crate::offline::offline_account("LiveUser".to_string());
live.kind = crate::data::AccountKind::Microsoft;
live.expires = chrono::Utc::now() + chrono::TimeDelta::hours(12);
let live_id = live.id;
service.store.lock().await.users.insert(live_id, live);
// Neither account qualifies so this must return without a request
assert!(!service.check_expired_sessions().await);
assert!(!service.get_account(live_id).await.unwrap().needs_sign_in());
assert!(
service
.get_account(signed_out_id)
.await
.unwrap()
.needs_sign_in()
);
}
}

View file

@ -118,6 +118,35 @@ impl CredentialsStore {
Ok(())
}
/// Drops the dead tokens but keeps the entry and its default slot so the UI
/// can offer a sign-in for this exact account
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
pub async fn mark_signed_out(&mut self, id: Uuid) -> AuthResult<()> {
let Some(account) = self.users.get_mut(&id) else {
return Err(AuthError::AccountNotFound(id));
};
account.signed_out = true;
account.access_token.clear();
account.refresh_token.clear();
self.save().await?;
Ok(())
}
/// Debug aid swaps in a token Microsoft will reject and backdates expiry so
/// the next launch walks the real `invalid_grant` path
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
pub async fn corrupt_refresh_token(&mut self, id: Uuid) -> AuthResult<()> {
let Some(account) = self.users.get_mut(&id) else {
return Err(AuthError::AccountNotFound(id));
};
account.refresh_token = "oneclient-debug-invalid-refresh-token".to_string();
account.expires = chrono::DateTime::UNIX_EPOCH;
self.save().await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
pub async fn remove_account(&mut self, id: Uuid) -> AuthResult<Option<MinecraftAccount>> {
let removed = self.users.remove(&id);

View file

@ -105,3 +105,45 @@ async fn default_account_returns_expired_token_without_refreshing() {
assert_eq!(account.id, msa_id);
assert!(account.is_expired());
}
#[tokio::test]
async fn signed_out_account_stays_listed_and_default() {
isolate_launcher_dir();
let mut store = CredentialsStore::default();
let msa = fake_microsoft_account("MsaUser");
let msa_id = msa.id;
store.users.insert(msa_id, msa);
store.default_user = Some(msa_id);
store.mark_signed_out(msa_id).await.unwrap();
let reloaded = CredentialsStore::load().await.unwrap();
let account = reloaded.get_account(msa_id).expect("entry must survive");
assert!(account.needs_sign_in());
assert!(
account.refresh_token.is_empty(),
"the dead token must not be kept"
);
assert!(account.access_token.is_empty());
assert_eq!(reloaded.default_user, Some(msa_id));
}
#[test]
fn accounts_saved_before_the_flag_existed_load_as_signed_in() {
let account: oneclient_auth::MinecraftAccount = serde_json::from_str(&format!(
r#"{{"id":"{}","username":"MsaUser","access_token":"a","refresh_token":"r","expires":"2026-01-01T00:00:00Z","kind":"microsoft"}}"#,
Uuid::new_v4()
))
.unwrap();
assert!(!account.needs_sign_in());
}
#[test]
fn offline_accounts_never_need_sign_in() {
let mut account = offline_account("Steve".into());
account.signed_out = true;
assert!(!account.needs_sign_in());
}