diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 03e4c700..50fbbaf9 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -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; diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs index 24054666..baa7908f 100644 --- a/packages/oneclient_app/src/bridge/snapshot.rs +++ b/packages/oneclient_app/src/bridge/snapshot.rs @@ -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, } diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs new file mode 100644 index 00000000..b84e4df4 --- /dev/null +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -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, + cancel: UseMutation, + pending_login: State>, + cancelled: State, + status: Option, + locked: bool, + pub pending: bool, + pub error: Option, +} + +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 { + 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::); + 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(mutation: &UseMutation) -> Option +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, + error: Option, +) -> 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, error: Option) -> 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() +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 40b3a7e2..44411060 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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}; diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index c775b6b2..13bcde19 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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 { + use_snapshots().microsoft_login +} + pub fn use_java_snapshot() -> JavaSnapshot { use_snapshots().java } diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index dfed2d9b..9392940f 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -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> = 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) { 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 { 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 { + 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 use_mutation(Mutation::new(FinishMicrosoftLoginMutation)) } +pub fn use_cancel_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(CancelMicrosoftLoginMutation)) +} + pub fn use_add_microsoft_account() -> UseMutation { use_mutation(Mutation::new(AddMicrosoftAccountMutation)) } diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 4b806c7a..ac8cd273 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/notifications/mod.rs b/packages/oneclient_app/src/notifications/mod.rs index 19d515f9..d7d4f6f0 100644 --- a/packages/oneclient_app/src/notifications/mod.rs +++ b/packages/oneclient_app/src/notifications/mod.rs @@ -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) { diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs index ec4a886c..c94bc143 100644 --- a/packages/oneclient_app/src/view/app/accounts.rs +++ b/packages/oneclient_app/src/view/app/accounts.rs @@ -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::); - let mut handled_code = use_state(|| None::); 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) -> 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>, - mut handled_code: State>, -) -> 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"), diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index c015d426..cc518d2d 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -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, diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs index e050d245..707bd39a 100644 --- a/packages/oneclient_app/src/view/onboarding/account.rs +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -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::); - - 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(mutation: &UseMutation) -> Option -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>, -) -> 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() -} diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs index 2eaa4964..3f97b19b 100644 --- a/packages/oneclient_core/examples/auth_msa.rs +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -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: {}", diff --git a/packages/oneclient_core/src/auth/data.rs b/packages/oneclient_core/src/auth/data.rs index b29cb80c..e0eecabb 100644 --- a/packages/oneclient_core/src/auth/data.rs +++ b/packages/oneclient_core/src/auth/data.rs @@ -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 } } diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs index 8e2827ca..2160f2a8 100644 --- a/packages/oneclient_core/src/auth/mod.rs +++ b/packages/oneclient_core/src/auth/mod.rs @@ -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 { +pub async fn begin_microsoft_login() -> LauncherResult { 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 { +pub async fn finish_microsoft_login( + session: MicrosoftLoginSession, +) -> LauncherResult { 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) { diff --git a/packages/oneclient_core/src/auth/msa.rs b/packages/oneclient_core/src/auth/msa.rs index ff3884aa..412ea214 100644 --- a/packages/oneclient_core/src/auth/msa.rs +++ b/packages/oneclient_core/src/auth/msa.rs @@ -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 { 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 { 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 { + 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 = 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 { - 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 { + let code = wait_for_redirect(pending).await?; + exchange_auth_code(client, &code, pending).await } async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result { diff --git a/packages/oneclient_core/src/auth/store.rs b/packages/oneclient_core/src/auth/store.rs index a9c7b8e4..c26380b8 100644 --- a/packages/oneclient_core/src/auth/store.rs +++ b/packages/oneclient_core/src/auth/store.rs @@ -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 { - 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, diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 1a60a38d..3e6595d1 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -160,6 +160,9 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { 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")); } diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index 46fab4a4..e8741584 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -99,6 +99,17 @@ pub enum Notification { InvalidateJava, SyncComplete, + + /// Live status of Microsoft Auth + /// `None` clears the status + MicrosoftLoginStatus(Option), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MicrosoftLoginStatus { + pub label: String, + pub current: u64, + pub total: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/packages/oneclient_core/src/notification/mod.rs b/packages/oneclient_core/src/notification/mod.rs index 8a86e66e..80e44880 100644 --- a/packages/oneclient_core/src/notification/mod.rs +++ b/packages/oneclient_core/src/notification/mod.rs @@ -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 -} \ No newline at end of file + PromptTimedOut, +} diff --git a/packages/oneclient_core/src/notification/service.rs b/packages/oneclient_core/src/notification/service.rs index a605cec9..c898410b 100644 --- a/packages/oneclient_core/src/notification/service.rs +++ b/packages/oneclient_core/src/notification/service.rs @@ -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) { + 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); } diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index cd930f99..9991984f 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -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(),