feat(ui): Merge Microsoft Auth types into 1 popup

fix(ui): Microsoft Auth notification is no longer persistent on cancel
This commit is contained in:
LynithDev 2026-07-12 19:17:38 +02:00
parent eeaecd3de4
commit d0c97b4177
21 changed files with 606 additions and 630 deletions

View file

@ -147,6 +147,10 @@ impl CoreBridgeRuntime {
apply_game_failed(snapshots, cluster_id, message);
immediate = true;
}
Notification::MicrosoftLoginStatus(status) => {
snapshots.microsoft_login = status;
immediate = true;
}
other => {
let (notif, _timers, prompt) = engine.dispatch(inbox, other);
snapshots.notifications = notif;

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use oneclient_core::notification::LaunchStage;
use oneclient_core::notification::{LaunchStage, MicrosoftLoginStatus};
use oneclient_core::settings::LauncherSettings;
#[derive(Clone, Debug, Default, PartialEq)]
@ -99,4 +99,5 @@ pub struct BridgeSnapshot {
pub notifications: crate::notifications::NotificationSnapshot,
pub game: GameSnapshot,
pub account_switcher_open: bool,
pub microsoft_login: Option<MicrosoftLoginStatus>,
}

View file

@ -0,0 +1,334 @@
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::auth::MicrosoftLoginSession;
use oneclient_core::notification::MicrosoftLoginStatus;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{
CancelMicrosoftLoginKeys, login_code_already_handled, reset_login_code_dedup,
use_begin_microsoft_login, use_cancel_microsoft_login, use_finish_microsoft_login,
use_microsoft_login_status,
};
use crate::platform;
use crate::theme::colors;
use crate::ui::border_all_color;
#[derive(Clone)]
pub struct MicrosoftLogin {
begin: UseMutation<crate::hooks::BeginMicrosoftLoginMutation>,
cancel: UseMutation<crate::hooks::CancelMicrosoftLoginMutation>,
pending_login: State<Option<MicrosoftLoginSession>>,
cancelled: State<bool>,
status: Option<MicrosoftLoginStatus>,
locked: bool,
pub pending: bool,
pub error: Option<String>,
}
impl MicrosoftLogin {
pub fn start(&self) {
let mut cancelled = self.cancelled;
cancelled.set(false);
self.begin.mutate(());
}
fn cancel(&self) {
if self.locked {
return;
}
if let Some(session) = self.pending_login.peek().clone() {
self.cancel.mutate(CancelMicrosoftLoginKeys {
state_token: session.dedupe_key().to_string(),
});
}
reset_login_code_dedup();
let mut cancelled = self.cancelled;
let mut pending_login = self.pending_login;
cancelled.set(true);
pending_login.set(None);
}
pub fn popup(&self) -> Option<impl IntoElement> {
let handle = self.clone();
self.pending_login.read().clone().map(move |login| {
microsoft_dialog(
login,
handle.clone(),
handle.status.clone(),
handle.error.clone(),
)
})
}
}
pub fn use_microsoft_login() -> MicrosoftLogin {
let begin = use_begin_microsoft_login();
let finish = use_finish_microsoft_login();
let cancel = use_cancel_microsoft_login();
let mut pending_login = use_state(|| None::<MicrosoftLoginSession>);
let cancelled = use_state(|| false);
let status = use_microsoft_login_status();
use_side_effect(move || {
let session = match &*begin.read().state() {
MutationStateData::Settled {
res: Ok(session), ..
} => Some(session.clone()),
_ => None,
};
let Some(session) = session else { return };
if login_code_already_handled(session.dedupe_key()) {
return;
}
// open the browser immediately
platform::open_url(session.auth_url());
finish.mutate(session.clone());
pending_login.set(Some(session));
});
use_side_effect(move || {
if matches!(
&*finish.read().state(),
MutationStateData::Settled { res: Ok(_), .. }
) && pending_login.peek().is_some()
{
pending_login.set(None);
}
});
// is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress
let locked = status.as_ref().is_some_and(|s| s.current > 0);
let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading();
let pending = in_flight && !*cancelled.read();
let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin));
MicrosoftLogin {
begin,
cancel,
pending_login,
cancelled,
status,
locked,
pending,
error,
}
}
fn mutation_err_text<M>(mutation: &UseMutation<M>) -> Option<String>
where
M: MutationCapability,
M::Err: std::fmt::Display,
{
match &*mutation.read().state() {
MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
MutationStateData::Loading {
res: Some(Err(err)),
} => Some(err.to_string()),
_ => None,
}
}
fn microsoft_dialog(
login: MicrosoftLoginSession,
handle: MicrosoftLogin,
status: Option<MicrosoftLoginStatus>,
error: Option<String>,
) -> impl IntoElement {
let locked = handle.locked;
let close_handle = handle.clone();
let cancel_handle = handle;
OverlayPopup::new()
// Backdrop / Esc only dismiss while the flow is still cancellable, if its locked the popup can't be closed and cancelling aborts everything.
.on_close(move |()| close_handle.cancel())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(420.))
.max_width(Size::window_percent(90.))
.cross_align(Alignment::Center)
.spacing(18.)
.padding(Gaps::new_all(28.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Sign in to Microsoft")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(browser_dialog_body(login.auth_url().to_string()))
.child(dialog_divider())
.child(device_code_dialog_body(
login.user_code().to_string(),
login.verification_uri().to_string(),
))
.child(status_row(status, error))
.maybe_child((!locked).then(|| {
Button::new()
.ghost()
.on_press(move |_| cancel_handle.cancel())
.text("Cancel")
.into_element()
})),
),
)
.into_element()
}
fn dialog_divider() -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.spacing(10.)
.child(
rect()
.width(Size::flex(1.0))
.height(Size::px(1.))
.background(colors::component_border()),
)
.child(
label()
.text("or")
.font_size(11.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::flex(1.0))
.height(Size::px(1.))
.background(colors::component_border()),
)
.into_element()
}
fn status_row(status: Option<MicrosoftLoginStatus>, error: Option<String>) -> impl IntoElement {
if let Some(error) = error {
return rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::AlertTriangle)
.size(13.)
.color(colors::danger()),
)
.child(label().text(error).font_size(12.).color(colors::danger()))
.into_element();
}
let text = match &status {
Some(status) if status.total > 0 && status.current > 0 => {
format!("{} ({}/{})", status.label, status.current, status.total)
}
_ => "Waiting for you to finish signing in...".to_string(),
};
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::Loading02)
.size(13.)
.color(colors::brand()),
)
.child(
label()
.text(text)
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
label()
.text("We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&auth_url))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser again"),
)
.into_element()
}
fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement {
let copy_code = code.clone();
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
label()
.text("Or enter this code at the Microsoft sign-in page:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.center()
.padding(Gaps::new_symmetric(20., 16.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::component_bg())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(code)
.font_size(48.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = Clipboard::set(copy_code.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
}
})
.child(Icon::new(IconType::Copy01).size(16.))
.text("Copy code"),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&verification_uri))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser"),
),
)
.into_element()
}

View file

@ -12,6 +12,7 @@ mod link_confirm;
mod local_image;
mod log_viewer;
mod major_version_card;
mod microsoft_login;
mod navbar;
mod notifications;
mod overlay_popup;
@ -43,8 +44,9 @@ pub use link_confirm::ConfirmLinkOverlay;
pub use local_image::LocalImage;
pub use log_viewer::LogViewer;
pub use major_version_card::MajorVersionCard;
pub use navbar::{AppNavbar, OnboardingNavbar};
pub use microsoft_login::use_microsoft_login;
pub(crate) use navbar::window_controls;
pub use navbar::{AppNavbar, OnboardingNavbar};
pub use notifications::NotificationCenter;
pub use overlay_popup::OverlayPopup;
pub use package_row::{CardLayout, PackageEntry, PackageRow};
@ -52,8 +54,8 @@ pub use pagination::Pagination;
pub use player_model::PlayerModel;
pub use recents_row::RecentsRow;
pub use screenshot_viewer::ScreenshotViewer;
pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub(crate) use scrollview::corrected_scroll;
pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub use segmented_control::{Segment, SegmentedControl};
pub use status_bar::StatusBar;
pub use tab_bar::{TabBar, TabItem};

View file

@ -15,38 +15,43 @@ pub use active_cluster::{
pub use dispatch::BridgeDispatch;
pub use queries::{
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, CachedImageQuery, ClusterAction, ClusterBundles,
ClusterLogsQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery,
RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, UploadLogKeys,
UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction,
UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft,
bundle_overrides_map, bundles_with_status_items, category_list, changelog_error,
changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug,
has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery,
CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles,
ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount,
UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE,
accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list,
changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
java_runtimes, loader_versions, login_code_already_handled, migration_detection,
mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch,
pick_version_metadata, project_detail, provider_versions, search_items, search_pending,
search_total, try_account, try_accounts, try_cluster_analytics, try_cluster_logs,
try_cluster_screenshots, try_default_account, try_game_profile, try_global_analytics,
try_log_content, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundles_with_status,
use_cached_image, use_changelog, 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_finish_microsoft_login,
use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions,
use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles,
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
use_package_search, use_package_versions, use_player_profile, use_player_skin,
use_provider_versions, use_refresh_account, use_refresh_all_accounts, use_remove_account,
use_screenshot_action, use_set_default_account, use_upload_log, use_version_metadata,
use_versions, version_list, versions_total,
pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items,
search_pending, search_total, try_account, try_accounts, try_cluster_analytics,
try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile,
try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, 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_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_finish_microsoft_login, use_game_profile,
use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action,
use_log_content, use_migration, use_named_profiles, use_onboarding_bundles,
use_package_categories, use_package_meta_batch, use_package_project, use_package_search,
use_package_versions, use_player_profile, use_player_skin, use_provider_versions,
use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action,
use_set_default_account, use_upload_log, use_version_metadata, use_versions, version_list,
versions_total,
};
use crate::{bridge::{
BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, OneClientBridge,
ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot,
}, notifications::NotificationSnapshot};
use crate::{
bridge::{
BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit,
OneClientBridge, ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot,
},
notifications::NotificationSnapshot,
};
use freya::prelude::*;
pub fn use_provide_bridge(bridge: &OneClientBridge) {
@ -96,6 +101,10 @@ pub fn use_game_snapshot() -> GameSnapshot {
use_snapshots().game
}
pub fn use_microsoft_login_status() -> Option<oneclient_core::notification::MicrosoftLoginStatus> {
use_snapshots().microsoft_login
}
pub fn use_java_snapshot() -> JavaSnapshot {
use_snapshots().java
}

View file

@ -6,7 +6,7 @@ use freya::query::{
QueryStateData, UseMutation, UseQuery, use_mutation, use_query,
};
use oneclient_core::LauncherError;
use oneclient_core::auth::{self, AccountKind, MinecraftAccount, MinecraftLogin};
use oneclient_core::auth::{self, AccountKind, MicrosoftLoginSession, MinecraftAccount};
use uuid::Uuid;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
@ -20,6 +20,10 @@ pub fn login_code_already_handled(user_code: &str) -> bool {
false
}
pub fn reset_login_code_dedup() {
*HANDLED_LOGIN_CODE.lock().unwrap() = None;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ListAccountsKeys;
@ -141,7 +145,7 @@ async fn invalidate_auth_queries(account_id: Option<Uuid>) {
pub struct BeginMicrosoftLoginMutation;
impl MutationCapability for BeginMicrosoftLoginMutation {
type Ok = MinecraftLogin;
type Ok = MicrosoftLoginSession;
type Err = LauncherError;
type Keys = ();
@ -156,7 +160,7 @@ pub struct FinishMicrosoftLoginMutation;
impl MutationCapability for FinishMicrosoftLoginMutation {
type Ok = MinecraftAccount;
type Err = LauncherError;
type Keys = MinecraftLogin;
type Keys = MicrosoftLoginSession;
async fn run(&self, flow: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::finish_microsoft_login(flow.clone()).await
@ -169,6 +173,27 @@ impl MutationCapability for FinishMicrosoftLoginMutation {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CancelMicrosoftLoginMutation;
/// Drops a pending browser login server-side when the user cancels before the
/// flow reaches the point of no return. Keyed by the session's CSRF state token.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CancelMicrosoftLoginKeys {
pub state_token: String,
}
impl MutationCapability for CancelMicrosoftLoginMutation {
type Ok = ();
type Err = LauncherError;
type Keys = CancelMicrosoftLoginKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::cancel_microsoft_login(&keys.state_token).await;
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AddMicrosoftAccountMutation;
@ -316,6 +341,10 @@ pub fn use_finish_microsoft_login() -> UseMutation<FinishMicrosoftLoginMutation>
use_mutation(Mutation::new(FinishMicrosoftLoginMutation))
}
pub fn use_cancel_microsoft_login() -> UseMutation<CancelMicrosoftLoginMutation> {
use_mutation(Mutation::new(CancelMicrosoftLoginMutation))
}
pub fn use_add_microsoft_account() -> UseMutation<AddMicrosoftAccountMutation> {
use_mutation(Mutation::new(AddMicrosoftAccountMutation))
}

View file

@ -21,13 +21,15 @@ pub use analytics::{
try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics,
};
pub use auth::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft,
login_code_already_handled, mutation_error, mutation_is_pending, try_account, try_accounts,
try_default_account, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_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,
AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys,
CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys,
RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount,
UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error,
mutation_is_pending, 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::{
ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items,

View file

@ -183,6 +183,7 @@ impl NotificationState {
Notification::InvalidateClusters => {}
Notification::InvalidateJava => {}
Notification::SyncComplete => {}
Notification::MicrosoftLoginStatus(_) => {}
Notification::GameStage { .. }
| Notification::GameLog { .. }
| Notification::GameFailed { .. } => {}
@ -268,7 +269,8 @@ impl NotificationState {
}
pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) {
self.active_toasts.retain(|toast| toast.entry_id != entry_id);
self.active_toasts
.retain(|toast| toast.entry_id != entry_id);
}
pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) {

View file

@ -1,23 +1,21 @@
use chrono::Utc;
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::auth::{AccountKind, MinecraftAccount, MinecraftLogin};
use oneclient_core::auth::{AccountKind, MinecraftAccount};
use crate::components::{
Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput,
use_microsoft_login,
};
use crate::hooks::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
accounts_have_microsoft, login_code_already_handled, try_accounts, try_default_account,
use_accounts, use_add_offline_account, use_begin_microsoft_login, use_current_account,
use_finish_microsoft_login, use_refresh_account, use_remove_account, use_set_default_account,
accounts_have_microsoft, try_accounts, try_default_account, use_accounts,
use_add_offline_account, use_current_account, use_refresh_account, use_remove_account,
use_set_default_account,
};
use crate::platform;
use crate::theme::colors;
use crate::ui::border_all_color;
#[derive(PartialEq)]
pub struct Accounts;
@ -26,44 +24,16 @@ impl Component for Accounts {
let accounts_query = use_accounts();
let default_query = use_current_account();
let begin = use_begin_microsoft_login();
let finish = use_finish_microsoft_login();
let msa = use_microsoft_login();
let add_offline = use_add_offline_account();
let set_default = use_set_default_account();
let remove = use_remove_account();
let refresh = use_refresh_account();
let mut username = use_state(String::new);
let mut pending_login = use_state(|| None::<MinecraftLogin>);
let mut handled_code = use_state(|| None::<String>);
let mut show_offline = use_state(|| false);
let mut closing_offline = use_state(|| false);
use_side_effect(move || {
let login = match &*begin.read().state() {
MutationStateData::Settled { res: Ok(login), .. } => Some(login.clone()),
_ => None,
};
let Some(login) = login else { return };
if login_code_already_handled(login.dedupe_key()) {
return;
}
handled_code.set(Some(login.dedupe_key().to_string()));
if let Some(url) = login.browser_url() {
platform::open_url(url);
}
finish.mutate(login.clone());
pending_login.set(Some(login));
});
use_side_effect(move || {
if matches!(&*finish.read().state(), MutationStateData::Settled { .. })
&& pending_login.peek().is_some()
{
pending_login.set(None);
}
});
use_side_effect(move || {
if !*closing_offline.read() {
return;
@ -84,15 +54,13 @@ impl Component for Accounts {
let accounts = try_accounts(&accounts_query).unwrap_or_default();
let default_account = try_default_account(&default_query);
let default_id = default_account.as_ref().map(|a| a.id);
let microsoft_pending =
begin.read().state().is_loading() || finish.read().state().is_loading();
let has_microsoft = accounts_have_microsoft(&accounts);
let offline_name = username.read().trim().to_string();
let offline_uuid = (!offline_name.is_empty())
.then(|| oneclient_core::auth::offline_uuid(&offline_name).to_string());
let offline_error = mutation_err_text(&add_offline);
let microsoft_error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin));
let on_confirm_offline = move |_| {
let name = username.peek().trim().to_string();
@ -144,10 +112,13 @@ impl Component for Accounts {
)
.child(add_bar(
has_microsoft,
microsoft_pending,
microsoft_error,
msa.pending,
msa.error.clone(),
move |_| show_offline.set(true),
move |_| begin.mutate(()),
{
let msa = msa.clone();
move |_| msa.start()
},
))
.child(
ScrollArea::new()
@ -166,12 +137,7 @@ impl Component for Accounts {
show_offline,
)
}))
.maybe_child(
pending_login
.read()
.clone()
.map(|login| microsoft_dialog(login, pending_login, handled_code)),
)
.maybe_child(msa.popup())
.into_element()
}
}
@ -187,33 +153,6 @@ fn render_panel(account: Option<MinecraftAccount>) -> impl IntoElement {
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
// .maybe_child(account.as_ref().map(|a| {
// Avatar::new(a.id.to_string())
// .width(Size::px(120.))
// .height(Size::px(120.))
// .into_element()
// }))
// .maybe_child(account.as_ref().map(|a| {
// label()
// .text(a.username.clone())
// .font_size(18.)
// .font_weight(FontWeight::SEMI_BOLD)
// .color(colors::fg_primary())
// .into_element()
// }))
// .maybe_child(account.is_none().then(|| {
// label()
// .text("No account selected")
// .font_size(14.)
// .color(colors::fg_secondary())
// .into_element()
// }))
// .child(
// label()
// .text("3D preview coming soon")
// .font_size(11.)
// .color(colors::fg_secondary()),
// )
.maybe_child(account.as_ref().map(|account| {
PlayerModel::new(account.id)
.yaw(-0.5)
@ -509,166 +448,6 @@ fn account_row(
.into_element()
}
fn microsoft_dialog(
login: MinecraftLogin,
mut pending_login: State<Option<MinecraftLogin>>,
mut handled_code: State<Option<String>>,
) -> impl IntoElement {
let body = match &login {
MinecraftLogin::DeviceCode(flow) => {
device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone())
.into_element()
}
MinecraftLogin::Browser(flow) => {
browser_dialog_body(flow.auth_url.clone()).into_element()
}
};
let mut close_pending = pending_login;
let mut close_handled = handled_code;
OverlayPopup::new()
.on_close(move |()| {
pending_login.set(None);
handled_code.set(None);
})
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(420.))
.max_width(Size::window_percent(90.))
.cross_align(Alignment::Center)
.spacing(18.)
.padding(Gaps::new_all(28.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Sign in to Microsoft")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(body)
.child(waiting_row())
.child(
Button::new()
.ghost()
.on_press(move |_| {
close_pending.set(None);
close_handled.set(None);
})
.text("Cancel"),
),
),
)
.into_element()
}
fn waiting_row() -> impl IntoElement {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::Loading02)
.size(13.)
.color(colors::brand()),
)
.child(
label()
.text("Waiting for you to finish signing in...")
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&auth_url))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser again"),
)
.into_element()
}
fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement {
let copy_code = code.clone();
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Enter this code on the Microsoft sign-in page:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.center()
.padding(Gaps::new_symmetric(20., 16.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::component_bg())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(code)
.font_size(48.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = Clipboard::set(copy_code.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
}
})
.child(Icon::new(IconType::Copy01).size(16.))
.text("Copy code"),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&verification_uri))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser"),
),
)
.into_element()
}
fn kind_badge(kind: AccountKind) -> impl IntoElement {
let (icon, text) = match kind {
AccountKind::Microsoft => (IconType::Globe01, "Microsoft"),

View file

@ -19,24 +19,18 @@ impl Component for SettingsLauncher {
let v = settings.discord_enabled;
move || v
});
let browser_login = use_state({
let v = settings.microsoft_login_use_browser;
move || v
});
let mut first = use_state(|| true);
{
let settings = settings.clone();
use_side_effect(move || {
let discord = *discord_rpc.read();
let browser = *browser_login.read();
if *first.peek() {
first.set(false);
return;
}
let mut next = settings.clone();
next.discord_enabled = discord;
next.microsoft_login_use_browser = browser;
dispatch.set_settings(next);
});
}
@ -52,12 +46,6 @@ impl Component for SettingsLauncher {
"Enable Discord Rich Presence.",
toggle(discord_rpc),
))
.child(settings_row(
IconType::Globe01,
"Browser sign-in",
"Sign in to Microsoft by opening your browser instead of entering a code.",
toggle(browser_login),
))
.child(section_header("FOLDERS AND FILES"))
.child(settings_row(
IconType::Folder,

View file

@ -1,17 +1,10 @@
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::auth::{MinecraftAccount, MinecraftLogin};
use oneclient_core::auth::MinecraftAccount;
use crate::components::{Avatar, Button, Icon, IconType, OverlayPopup};
use crate::hooks::{
login_code_already_handled, try_default_account, use_begin_microsoft_login,
use_current_account, use_finish_microsoft_login,
};
use crate::platform;
use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login};
use crate::hooks::{try_default_account, use_current_account};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::onboarding::{
onboarding_illustration, onboarding_nav, onboarding_page, step_heading,
};
@ -22,42 +15,10 @@ pub struct OnboardingAccount;
impl Component for OnboardingAccount {
fn render(&self) -> impl IntoElement {
let account_query = use_current_account();
let begin = use_begin_microsoft_login();
let finish = use_finish_microsoft_login();
let mut pending_login = use_state(|| None::<MinecraftLogin>);
use_side_effect(move || {
let login = match &*begin.read().state() {
MutationStateData::Settled { res: Ok(login), .. } => Some(login.clone()),
_ => None,
};
let Some(login) = login else { return };
if login_code_already_handled(login.dedupe_key()) {
return;
}
// Browser flow: open the system browser straight away so the user
// lands on the Microsoft sign-in page without copying a code.
if let Some(url) = login.browser_url() {
platform::open_url(url);
}
finish.mutate(login.clone());
pending_login.set(Some(login));
});
use_side_effect(move || {
if matches!(&*finish.read().state(), MutationStateData::Settled { .. })
&& pending_login.peek().is_some()
{
pending_login.set(None);
}
});
let msa = use_microsoft_login();
let account = try_default_account(&account_query);
let has_account = account.is_some();
let pending = begin.read().state().is_loading() || finish.read().state().is_loading();
let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin));
let content = rect()
.vertical()
@ -69,7 +30,11 @@ impl Component for OnboardingAccount {
))
.child(match &account {
Some(account) => account_preview(account).into_element(),
None => sign_in_card(pending, error, move |_| begin.mutate(())).into_element(),
None => {
let start = msa.clone();
sign_in_card(msa.pending, msa.error.clone(), move |_| start.start())
.into_element()
}
})
.into_element();
@ -87,26 +52,7 @@ impl Component for OnboardingAccount {
.width(Size::fill())
.height(Size::fill())
.child(page)
.maybe_child(
pending_login
.read()
.clone()
.map(|login| microsoft_dialog(login, pending_login)),
)
}
}
fn mutation_err_text<M>(mutation: &UseMutation<M>) -> Option<String>
where
M: MutationCapability,
M::Err: std::fmt::Display,
{
match &*mutation.read().state() {
MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
MutationStateData::Loading {
res: Some(Err(err)),
} => Some(err.to_string()),
_ => None,
.maybe_child(msa.popup())
}
}
@ -114,23 +60,7 @@ fn account_preview(account: &MinecraftAccount) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
// .height(Size::px(260.))
.spacing(24.)
// .cross_align(Alignment::Center)
// .child(
// rect()
// .width(Size::px(200.))
// .height(Size::fill())
// .corner_radius(CornerRadius::new_all(16.))
// .background(colors::page_elevated())
// .border(border_all_color(1., colors::component_border()))
// .child(
// PlayerModel::new(account.id)
// .yaw(-0.5)
// .width(Size::fill())
// .height(Size::fill()),
// ),
// )
.child(
rect()
.horizontal()
@ -205,153 +135,3 @@ fn sign_in_card(
}))
.into_element()
}
fn microsoft_dialog(
login: MinecraftLogin,
mut pending_login: State<Option<MinecraftLogin>>,
) -> impl IntoElement {
let body = match &login {
MinecraftLogin::DeviceCode(flow) => {
device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone())
.into_element()
}
MinecraftLogin::Browser(flow) => {
browser_dialog_body(flow.auth_url.clone()).into_element()
}
};
OverlayPopup::new()
.on_close(move |()| pending_login.set(None))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(420.))
.max_width(Size::window_percent(90.))
.cross_align(Alignment::Center)
.spacing(18.)
.padding(Gaps::new_all(28.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Sign in to Microsoft")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(body)
.child(waiting_row())
.child(
Button::new()
.ghost()
.on_press(move |_| pending_login.set(None))
.text("Cancel"),
),
),
)
.into_element()
}
fn waiting_row() -> impl IntoElement {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::Loading02)
.size(13.)
.color(colors::brand()),
)
.child(
label()
.text("Waiting for you to finish signing in...")
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&auth_url))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser again"),
)
.into_element()
}
fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement {
let copy_code = code.clone();
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Enter this code on the Microsoft sign-in page:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.center()
.padding(Gaps::new_symmetric(20., 16.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::component_bg())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(code)
.font_size(48.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = Clipboard::set(copy_code.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
}
})
.child(Icon::new(IconType::Copy01).size(16.))
.text("Copy code"),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&verification_uri))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser"),
),
)
.into_element()
}

View file

@ -1,7 +1,7 @@
use std::process::Command;
use oneclient_core::auth::{self, MinecraftLogin};
use oneclient_core::auth::{self};
use oneclient_core::{dev, logger, LauncherResult};
#[tokio::main]
@ -9,23 +9,18 @@ async fn main() -> LauncherResult<()> {
logger::init_debug()?;
dev::initialize().await?;
let flow = auth::begin_microsoft_login().await?;
let session = auth::begin_microsoft_login().await?;
match &flow {
MinecraftLogin::DeviceCode(device) => {
println!("{}\n", device.message);
println!("Verification URL: {}", device.verification_uri);
println!("Enter this code: {}\n", device.user_code);
try_open_browser(&device.verification_uri);
}
MinecraftLogin::Browser(browser) => {
println!("Opening browser to sign in...\n");
try_open_browser(&browser.auth_url);
}
}
println!("Sign in with EITHER method:\n");
println!(" Browser: opening {}", session.browser.auth_url);
try_open_browser(&session.browser.auth_url);
println!(
" Device code: go to {} and enter {}\n",
session.device.verification_uri, session.device.user_code
);
println!("Waiting for you to sign in...");
let account = auth::finish_microsoft_login(flow).await?;
let account = auth::finish_microsoft_login(session).await?;
println!(
"Signed in as {} ({})\n token expires: {}",

View file

@ -52,23 +52,25 @@ pub struct BrowserLogin {
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MinecraftLogin {
DeviceCode(DeviceCodeLogin),
Browser(BrowserLogin),
pub struct MicrosoftLoginSession {
pub browser: BrowserLogin,
pub device: DeviceCodeLogin,
}
impl MinecraftLogin {
impl MicrosoftLoginSession {
pub fn dedupe_key(&self) -> &str {
match self {
MinecraftLogin::DeviceCode(flow) => &flow.user_code,
MinecraftLogin::Browser(flow) => &flow.state,
}
&self.browser.state
}
pub fn browser_url(&self) -> Option<&str> {
match self {
MinecraftLogin::Browser(flow) => Some(&flow.auth_url),
MinecraftLogin::DeviceCode(_) => None,
}
pub fn auth_url(&self) -> &str {
&self.browser.auth_url
}
pub fn user_code(&self) -> &str {
&self.device.user_code
}
pub fn verification_uri(&self) -> &str {
&self.device.verification_uri
}
}

View file

@ -7,64 +7,70 @@ mod data;
pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep};
pub use offline::{offline_account, offline_uuid, validate_offline_username};
pub use store::CredentialsStore;
pub use data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount, MinecraftLogin};
pub use data::{
AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount,
};
pub use msa::PendingBrowserLogin;
use uuid::Uuid;
use crate::LauncherResult;
use crate::notification::MicrosoftLoginStatus;
use crate::state::LauncherState;
pub async fn begin_microsoft_login() -> LauncherResult<MinecraftLogin> {
pub async fn begin_microsoft_login() -> LauncherResult<MicrosoftLoginSession> {
let state = LauncherState::get()?;
let use_browser = state.settings.read().microsoft_login_use_browser;
let client = state.services.requester.http();
if use_browser {
let (login, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?;
state
.microsoft_logins
.lock()
.await
.insert(login.state.clone(), pending);
Ok(MinecraftLogin::Browser(login))
} else {
let client = state.services.requester.http();
let flow = msa::begin_device_login(client).await.map_err(AuthError::from)?;
Ok(MinecraftLogin::DeviceCode(flow))
}
let (browser, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?;
let device = msa::begin_device_login(client).await.map_err(AuthError::from)?;
state
.microsoft_logins
.lock()
.await
.insert(browser.state.clone(), pending);
Ok(MicrosoftLoginSession { browser, device })
}
pub async fn finish_microsoft_login(flow: MinecraftLogin) -> LauncherResult<MinecraftAccount> {
pub async fn finish_microsoft_login(
session: MicrosoftLoginSession,
) -> LauncherResult<MinecraftAccount> {
let state = LauncherState::get()?;
match flow {
MinecraftLogin::DeviceCode(flow) => {
let mut auth = state.auth.lock().await;
auth.finish_device_login(&flow, &state.services).await
}
MinecraftLogin::Browser(login) => {
let pending = state
.microsoft_logins
.lock()
.await
.remove(&login.state)
.ok_or(AuthError::Minecraft(
MinecraftAuthError::BrowserLoginNotFound,
))?;
let pending = state
.microsoft_logins
.lock()
.await
.remove(&session.browser.state)
.ok_or(AuthError::Minecraft(
MinecraftAuthError::BrowserLoginNotFound,
))?;
let client = state.services.requester.http();
let progress_id = Uuid::new_v4();
let notifier = state.services.notifier.clone();
let account = msa::finish_browser_login(client, pending, |label, current, total| {
notifier.send_progress(&progress_id, label, current, total);
})
.await
.map_err(AuthError::from)?;
let client = state.services.requester.http();
let notifier = state.services.notifier.clone();
let mut auth = state.auth.lock().await;
auth.commit_account(account, &state.services).await
}
}
let result = msa::finish_dual_login(
client,
pending,
&session.device,
|label, current, total| {
notifier.microsoft_login_status(Some(MicrosoftLoginStatus {
label: label.to_string(),
current,
total,
}));
},
)
.await
.map_err(AuthError::from);
state.services.notifier.microsoft_login_status(None);
let account = result?;
let mut auth = state.auth.lock().await;
auth.commit_account(account, &state.services).await
}
pub async fn cancel_microsoft_login(state_token: &str) {

View file

@ -29,23 +29,67 @@ const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0
const AUTH_STEPS: u64 = 5;
const BROWSER_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub async fn begin_device_login(client: &Client) -> Result<DeviceCodeLogin, MinecraftAuthError> {
request_device_code(client).await
}
pub async fn finish_device_login(
pub async fn finish_dual_login(
client: &Client,
flow: &DeviceCodeLogin,
pending: PendingBrowserLogin,
device: &DeviceCodeLogin,
on_progress: impl Fn(&str, u64, u64),
) -> Result<MinecraftAccount, MinecraftAuthError> {
on_progress("Waiting for sign-in", 0, AUTH_STEPS);
let msa = poll_device_token(client, flow).await?;
let deadline = Duration::from_secs(device.expires_in);
let msa = tokio::time::timeout(deadline, race_msa_token(client, &pending, device))
.await
.map_err(|_| MinecraftAuthError::DeviceAuthorizationExpired)??;
account_from_msa_token(client, msa, on_progress).await
}
async fn race_msa_token(
client: &Client,
pending: &PendingBrowserLogin,
device: &DeviceCodeLogin,
) -> Result<MsaToken, MinecraftAuthError> {
let browser = browser_msa_token(client, pending);
let device = poll_device_token(client, device);
tokio::pin!(browser, device);
let mut browser_done = false;
let mut device_done = false;
let mut last_err: Option<MinecraftAuthError> = None;
loop {
tokio::select! {
res = &mut browser, if !browser_done => match res {
Ok(token) => return Ok(token),
Err(err) => {
browser_done = true;
if device_done {
return Err(err);
}
last_err = Some(err);
}
},
res = &mut device, if !device_done => match res {
Ok(token) => return Ok(token),
Err(err) => {
device_done = true;
if browser_done {
return Err(err);
}
last_err = Some(err);
}
},
else => return Err(last_err.unwrap_or(MinecraftAuthError::DeviceAuthorizationExpired)),
}
}
}
pub struct PendingBrowserLogin {
listener: TcpListener,
pkce_verifier: String,
@ -95,18 +139,12 @@ pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin)
Ok((login, pending))
}
pub async fn finish_browser_login(
async fn browser_msa_token(
client: &Client,
pending: PendingBrowserLogin,
on_progress: impl Fn(&str, u64, u64),
) -> Result<MinecraftAccount, MinecraftAuthError> {
on_progress("Waiting for sign-in", 0, AUTH_STEPS);
let code = tokio::time::timeout(BROWSER_LOGIN_TIMEOUT, wait_for_redirect(&pending))
.await
.map_err(|_| MinecraftAuthError::BrowserAuthorizationExpired)??;
let msa = exchange_auth_code(client, &code, &pending).await?;
account_from_msa_token(client, msa, on_progress).await
pending: &PendingBrowserLogin,
) -> Result<MsaToken, MinecraftAuthError> {
let code = wait_for_redirect(pending).await?;
exchange_auth_code(client, &code, pending).await
}
async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result<String, MinecraftAuthError> {

View file

@ -5,7 +5,7 @@ use uuid::Uuid;
use super::error::AuthError;
use super::offline::{offline_account, validate_offline_username};
use super::data::{AccountKind, DeviceCodeLogin, MinecraftAccount};
use super::data::{AccountKind, MinecraftAccount};
use crate::paths;
use crate::state::LauncherServices;
use crate::LauncherResult;
@ -57,23 +57,6 @@ impl CredentialsStore {
self.users.get(&id)
}
pub async fn finish_device_login(
&mut self,
flow: &DeviceCodeLogin,
services: &LauncherServices,
) -> LauncherResult<MinecraftAccount> {
let client = services.requester.http();
let progress_id = Uuid::new_v4();
let notifier = services.notifier.clone();
let account = super::msa::finish_device_login(client, flow, |label, current, total| {
notifier.send_progress(&progress_id, label, current, total);
})
.await
.map_err(AuthError::from)?;
self.commit_account(account, services).await
}
pub async fn commit_account(
&mut self,
account: MinecraftAccount,

View file

@ -160,6 +160,9 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver<Notification>) {
Notification::InvalidateClusters => {}
Notification::InvalidateJava => {}
Notification::SyncComplete => {}
Notification::MicrosoftLoginStatus(status) => {
mp.suspend(|| tracing::info!(?status, "microsoft login status"));
}
Notification::GameStage { cluster_id, stage } => {
mp.suspend(|| tracing::info!(cluster_id, ?stage, "game stage"));
}

View file

@ -99,6 +99,17 @@ pub enum Notification {
InvalidateJava,
SyncComplete,
/// Live status of Microsoft Auth
/// `None` clears the status
MicrosoftLoginStatus(Option<MicrosoftLoginStatus>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MicrosoftLoginStatus {
pub label: String,
pub current: u64,
pub total: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -1,6 +1,6 @@
pub use data::{
GroupedProgressEvent, LaunchStage, Notification, NotificationLevel, PromptKind, TaskPhase,
UserChoice,
GroupedProgressEvent, LaunchStage, MicrosoftLoginStatus, Notification, NotificationLevel,
PromptKind, TaskPhase, UserChoice,
};
pub use grouped::{GroupedProgressChild, GroupedProgressSession};
pub use service::NotificationService;
@ -14,5 +14,5 @@ pub enum NotificationError {
#[error("Notification service is down")]
ServiceDown,
#[error("Notification prompt timed out")]
PromptTimedOut
}
PromptTimedOut,
}

View file

@ -2,7 +2,8 @@ use tokio::sync::{mpsc, oneshot};
use uuid::Uuid;
use crate::notification::{
LaunchStage, Notification, NotificationError, NotificationLevel, PromptKind, UserChoice,
LaunchStage, MicrosoftLoginStatus, Notification, NotificationError, NotificationLevel,
PromptKind, UserChoice,
};
#[derive(Clone)]
@ -80,6 +81,15 @@ impl NotificationService {
reply_rx.await.map_err(|_| NotificationError::ServiceDown)
}
pub fn microsoft_login_status(&self, status: Option<MicrosoftLoginStatus>) {
if let Err(err) = self
.channel
.send(Notification::MicrosoftLoginStatus(status))
{
tracing::error!("{err}");
}
}
pub fn send_info(&self, title: &str, body: &str) {
self.send_message(title, body, NotificationLevel::Info);
}

View file

@ -28,7 +28,6 @@ pub struct LauncherSettings {
pub auto_update: bool,
pub enable_gamemode: bool,
pub discord_enabled: bool,
pub microsoft_login_use_browser: bool,
pub max_concurrent_requests: usize,
pub global_game_settings: GameSettingsProfile,
pub allow_parallel_running_clusters: bool,
@ -59,7 +58,6 @@ impl Default for LauncherSettings {
log_debug: false,
auto_update: true,
discord_enabled: true,
microsoft_login_use_browser: true,
enable_gamemode: false,
max_concurrent_requests: 25,
global_game_settings: GameSettingsProfile::default_global_profile(),