From 8a734f642790058d842946ec0af2fd7b398c1f0a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:10:59 -0600 Subject: [PATCH] feat(login): narrate the browser OAuth flow on the login screen The screen sat inert while the system-browser OAuth ran: no waiting or authenticating feedback, and failures were log-only. New LoginState global (phase + error) driven by an on_phase callback in auth::login_via_system_browser (both call sites: login + recovery). The blue button now reads "Sign in with your browser", disables while an attempt is in flight, and the screen shows continue-in-browser text (with a no-focus hint), a Cancel link that aborts the pending flow, an authenticating state, and a sign-in-failed box. The redundant "Use your system browser instead" link and its AppCommand are removed. Five new msgids translated in all seven locale catalogs. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 16 +++ .../translations/en/LC_MESSAGES/qbz-ui.po | 16 +++ .../translations/es/LC_MESSAGES/qbz-ui.po | 16 +++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 16 +++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 16 +++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 16 +++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 16 +++ crates/qbz-ui/ui/app.slint | 8 +- crates/qbz-ui/ui/login/LoginScreen.slint | 78 +++++++++++-- crates/qbz-ui/ui/state.slint | 14 +++ crates/qbz/src/auth.rs | 22 +++- crates/qbz/src/commands.rs | 12 +- crates/qbz/src/main.rs | 103 +++++++++++++++--- 13 files changed, 312 insertions(+), 37 deletions(-) diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 0ca9db52f..c484e8b18 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4192,3 +4192,19 @@ msgstr "Oberflächengröße geändert — QBZ neu starten, um sie zu übernehmen msgid "Extra small" msgstr "Extra klein" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Mit deinem Browser anmelden" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Fahre in deinem Webbrowser fort — QBZ wartet, bis du die Anmeldung abgeschlossen hast." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Der Browser öffnet sich möglicherweise im Hintergrund, ohne den Fokus zu übernehmen — prüfe deine anderen Fenster." + +msgid "Signing you in…" +msgstr "Du wirst angemeldet…" + +msgid "Sign-in failed" +msgstr "Anmeldung fehlgeschlagen" diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index 919f544a9..2231b9ef3 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -78,3 +78,19 @@ msgstr "Interface size changed — restart QBZ to apply" msgid "Extra small" msgstr "Extra small" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Sign in with your browser" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continue in your web browser — QBZ is waiting for you to finish signing in." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "The browser may open in the background without taking focus — check your other windows." + +msgid "Signing you in…" +msgstr "Signing you in…" + +msgid "Sign-in failed" +msgstr "Sign-in failed" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index 7703a5337..08506c26a 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4249,3 +4249,19 @@ msgstr "Tamaño de la interfaz cambiado — reinicia QBZ para aplicar" msgid "Extra small" msgstr "Extra pequeño" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Inicia sesión con tu navegador" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continúa en tu navegador web — QBZ está esperando a que termines de iniciar sesión." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Puede que el navegador se abra en segundo plano sin tomar el foco — checa tus otras ventanas." + +msgid "Signing you in…" +msgstr "Iniciando tu sesión…" + +msgid "Sign-in failed" +msgstr "No se pudo iniciar sesión" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index d2a34063c..3aed2cd34 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4192,3 +4192,19 @@ msgstr "Taille de l'interface modifiée — redémarrez QBZ pour appliquer" msgid "Extra small" msgstr "Très petit" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Se connecter avec votre navigateur" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continuez dans votre navigateur web — QBZ attend que vous terminiez la connexion." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Le navigateur peut s'ouvrir en arrière-plan sans prendre le focus — vérifiez vos autres fenêtres." + +msgid "Signing you in…" +msgstr "Connexion en cours…" + +msgid "Sign-in failed" +msgstr "Échec de la connexion" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index f5b2a1a82..49410e1c4 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4139,3 +4139,19 @@ msgstr "インターフェースのサイズを変更しました — 適用す msgid "Extra small" msgstr "極小" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "ブラウザでサインイン" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "ウェブブラウザで続行してください — QBZ はサインインの完了を待っています。" + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "ブラウザは前面に出ずにバックグラウンドで開くことがあります — 他のウィンドウを確認してください。" + +msgid "Signing you in…" +msgstr "サインインしています…" + +msgid "Sign-in failed" +msgstr "サインインに失敗しました" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 68bc4dc97..ca0e11692 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4192,3 +4192,19 @@ msgstr "Tamanho da interface alterado — reinicie o QBZ para aplicar" msgid "Extra small" msgstr "Extra pequeno" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Entrar com o seu navegador" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Continue no seu navegador web — o QBZ está aguardando você concluir o login." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "O navegador pode abrir em segundo plano sem tomar o foco — verifique as suas outras janelas." + +msgid "Signing you in…" +msgstr "Entrando…" + +msgid "Sign-in failed" +msgstr "Falha ao entrar" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index 28b77105e..800a5163f 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4163,3 +4163,19 @@ msgstr "Размер интерфейса изменён — перезапус msgid "Extra small" msgstr "Очень маленький" + +# Login-screen browser-flow narration (2026-07-02) +msgid "Sign in with your browser" +msgstr "Войти через браузер" + +msgid "Continue in your web browser — QBZ is waiting for you to finish signing in." +msgstr "Продолжите в веб-браузере — QBZ ждёт, пока вы завершите вход." + +msgid "The browser may open in the background without taking focus — check your other windows." +msgstr "Браузер может открыться в фоне, не перехватывая фокус — проверьте другие окна." + +msgid "Signing you in…" +msgstr "Выполняется вход…" + +msgid "Sign-in failed" +msgstr "Не удалось войти" diff --git a/crates/qbz-ui/ui/app.slint b/crates/qbz-ui/ui/app.slint index e91b3c80b..8c52fe64c 100644 --- a/crates/qbz-ui/ui/app.slint +++ b/crates/qbz-ui/ui/app.slint @@ -29,7 +29,7 @@ export { ThemeState } from "foundation/theme-state.slint"; export { Typography } from "foundation/typography.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint"; +export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. @@ -69,7 +69,7 @@ export component AppWindow inherits Window { // Forwarded to the Rust layer. callback sign-in-via-browser(); - callback use-system-browser(); + callback cancel-login(); callback start-offline(); callback open-tos(); callback open-album(string); @@ -94,8 +94,8 @@ export component AppWindow inherits Window { sign-in-via-browser => { root.sign-in-via-browser(); } - use-system-browser => { - root.use-system-browser(); + cancel-login => { + root.cancel-login(); } start-offline => { root.start-offline(); diff --git a/crates/qbz-ui/ui/login/LoginScreen.slint b/crates/qbz-ui/ui/login/LoginScreen.slint index 6b78d6719..80d8eb1b5 100644 --- a/crates/qbz-ui/ui/login/LoginScreen.slint +++ b/crates/qbz-ui/ui/login/LoginScreen.slint @@ -14,13 +14,13 @@ import { Typography } from "../foundation/typography.slint"; import { QbzCheckbox } from "../primitives/QbzCheckbox.slint"; import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; import { QbzTextLink } from "../primitives/QbzTextLink.slint"; -import { OfflineState } from "../state.slint"; +import { OfflineState, LoginState } from "../state.slint"; export component LoginScreen inherits Rectangle { in-out property tos-accepted: true; callback sign-in-via-browser(); - callback use-system-browser(); + callback cancel-login(); callback start-offline(); callback open-tos(); @@ -109,9 +109,11 @@ export component LoginScreen inherits Rectangle { Rectangle { height: Spacing.lg; } // --- Sign in --------------------------------------------- + // Opens the user's default web browser (no embedded + // webview) — the label + status below must say so. QbzPrimaryButton { - label: @tr("Sign in via Browser"); - enabled: root.tos-accepted; + label: @tr("Sign in with your browser"); + enabled: root.tos-accepted && LoginState.phase == 0; clicked => { root.sign-in-via-browser(); } @@ -119,13 +121,67 @@ export component LoginScreen inherits Rectangle { Rectangle { height: Spacing.md; } - // --- Secondary links ------------------------------------- - HorizontalLayout { - alignment: center; - QbzTextLink { - label: @tr("Use your system browser instead"); - clicked => { - root.use-system-browser(); + // --- Browser-flow status --------------------------------- + // The browser may open in the background without stealing + // focus, and the code exchange takes a few seconds — the + // screen must narrate instead of sitting inert. + if LoginState.phase == 1: VerticalLayout { + spacing: 4px; + Text { + text: @tr("Continue in your web browser — QBZ is waiting for you to finish signing in."); + color: Theme.text-secondary; + font-size: Typography.body; + horizontal-alignment: center; + wrap: word-wrap; + } + Text { + text: @tr("The browser may open in the background without taking focus — check your other windows."); + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + wrap: word-wrap; + } + Rectangle { height: 4px; } + HorizontalLayout { + alignment: center; + QbzTextLink { + label: @tr("Cancel"); + clicked => { + root.cancel-login(); + } + } + } + } + if LoginState.phase == 2: Text { + text: @tr("Signing you in…"); + color: Theme.text-secondary; + font-size: Typography.body; + horizontal-alignment: center; + } + if LoginState.phase == 0 && LoginState.error != "": VerticalLayout { + spacing: 0px; + Rectangle { + background: Theme.surface-elevated; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + VerticalLayout { + padding: 12px; + spacing: 4px; + Text { + text: @tr("Sign-in failed"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + horizontal-alignment: center; + } + Text { + text: LoginState.error; + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + wrap: word-wrap; + } } } } diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 8829ab8d0..7526be4ad 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -5067,6 +5067,20 @@ export global OfflineState { : 0; } +// ============================ Login flow ================================== +// Browser sign-in progress, driven by the Rust auth flow (crate::auth via +// main.rs). The app has no embedded webview, so the login screen must +// narrate the system-browser OAuth — the browser may open in the background +// without stealing focus, and the code exchange takes a few seconds. +export global LoginState { + // 0 = idle · 1 = waiting for the user to finish in the browser · + // 2 = authorization code received, exchanging it for a session. + in property phase: 0; + // Last sign-in failure ("" = none). Cleared when a new attempt starts + // and on successful shell entry. + in property error: ""; +} + // Settings > Offline, MODE group — Rust-wired actions (crate::offline_mode). export global OfflineModeActions { // (Re)seed the MODE-group toggle states from the engine store. Fired by diff --git a/crates/qbz/src/auth.rs b/crates/qbz/src/auth.rs index b1572037c..7ce9d8fef 100644 --- a/crates/qbz/src/auth.rs +++ b/crates/qbz/src/auth.rs @@ -23,13 +23,29 @@ pub struct SessionInfo { pub subscription: String, } +/// Progress milestones of the browser OAuth, reported to the login UI so +/// the screen can narrate the flow (the browser may open in the background +/// without stealing focus, and the code exchange takes a few seconds). +#[derive(Clone, Copy, Debug)] +pub enum LoginPhase { + /// The browser was opened; waiting for the user to finish signing in + /// and for the redirect to land on the local listener. + WaitingForBrowser, + /// The authorization code was captured; exchanging it for a session. + Authenticating, +} + /// Run the full system-browser OAuth login. Returns the authenticated -/// session info on success. -pub async fn login_via_system_browser( +/// session info on success. `on_phase` fires at each milestone (see +/// [`LoginPhase`]); it may be called from this async context, so it must +/// hop to the UI thread itself. +pub async fn login_via_system_browser( runtime: &Arc>, + on_phase: F, ) -> Result where A: FrontendAdapter + Send + Sync + 'static, + F: Fn(LoginPhase) + Send + Sync, { let core = runtime.core(); @@ -65,6 +81,7 @@ where log::info!("[qbz-slint] opening system browser for OAuth (port {port})"); open::that(&oauth_url).map_err(|e| format!("Failed to open browser: {e}"))?; + on_phase(LoginPhase::WaitingForBrowser); let code = tokio::time::timeout(OAUTH_TIMEOUT, capture_oauth_code(listener)) .await @@ -72,6 +89,7 @@ where .ok_or_else(|| "OAuth login cancelled or no code received".to_string())?; log::info!("[qbz-slint] OAuth code captured, exchanging for session"); + on_phase(LoginPhase::Authenticating); let session = { let client_lock = core.client(); diff --git a/crates/qbz/src/commands.rs b/crates/qbz/src/commands.rs index 3980d6114..952674347 100644 --- a/crates/qbz/src/commands.rs +++ b/crates/qbz/src/commands.rs @@ -10,10 +10,10 @@ #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppCommand { /// Begin OAuth sign-in. Per the 2026-05-18 decision the POC uses the - /// external system-browser flow only (no in-app webview). + /// external system-browser flow only (no in-app webview). The former + /// separate "use system browser" link (and its command) was removed + /// 2026-07-02 — the one button IS the system-browser flow. SignInViaBrowser, - /// Explicitly use the system browser for OAuth. - UseSystemBrowser, /// Start an offline-only session with no Qobuz access. StartOffline, /// Open the Qobuz Terms of Service in the system browser. @@ -25,7 +25,6 @@ impl AppCommand { pub fn id(&self) -> &'static str { match self { Self::SignInViaBrowser => "sign_in_via_browser", - Self::UseSystemBrowser => "use_system_browser", Self::StartOffline => "start_offline", Self::OpenTermsOfService => "open_terms_of_service", } @@ -40,13 +39,12 @@ mod tests { fn command_ids_are_distinct_and_stable() { let all = [ AppCommand::SignInViaBrowser, - AppCommand::UseSystemBrowser, AppCommand::StartOffline, AppCommand::OpenTermsOfService, ]; let ids: Vec<&str> = all.iter().map(AppCommand::id).collect(); - assert_eq!(ids.len(), 4); + assert_eq!(ids.len(), 3); let unique: std::collections::HashSet<&str> = ids.iter().copied().collect(); - assert_eq!(unique.len(), 4, "command ids must be unique"); + assert_eq!(unique.len(), 3, "command ids must be unique"); } } diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index b2d04ea63..4576926fd 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -332,6 +332,11 @@ async fn enter_shell( let offline_state = w.global::(); offline_state.set_has_previous_session(true); offline_state.set_login_error("".into()); + // Reset the browser sign-in narration for the next visit to the + // login screen (logout → login). + let login_state = w.global::(); + login_state.set_phase(0); + login_state.set_error("".into()); seed_tray_appearance(&w, &tray); // Seed the My QBZ branding (label + icon) from the per-user store so // the sidebar row + Settings row paint the custom values immediately. @@ -6445,22 +6450,50 @@ fn main() -> Result<(), Box> { }); } - // Sign in via the system browser → real OAuth → shell. - // "Sign in via Browser" and "Use your system browser instead" are the - // same flow in the MVP (the in-app webview path is intentionally absent). + // Sign in via the system browser → real OAuth → shell. The app has no + // embedded webview: the one blue button opens the default browser, and + // LoginState narrates the flow (waiting / authenticating / error) so the + // login screen never sits inert while the OAuth is pending. + // The in-flight task is kept so the screen's Cancel link can abort it + // (dropping the task drops the one-shot listener and frees the port). + let login_task: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); let on_browser_login = { let runtime = app_runtime.clone(); let weak = window.as_weak(); let handle = tokio_rt.handle().clone(); let image_cache = image_cache.clone(); let settings_ctx = settings_ctx.clone(); + let login_task = login_task.clone(); move || { + // Runs on the UI thread: flip to the waiting state immediately so + // a second click cannot start a parallel flow, and clear any + // previous failure. + if let Some(w) = weak.upgrade() { + let login_state = w.global::(); + if login_state.get_phase() != 0 { + return; + } + login_state.set_error("".into()); + login_state.set_phase(1); + } let runtime = runtime.clone(); let weak = weak.clone(); let image_cache = image_cache.clone(); let settings_ctx = settings_ctx.clone(); - handle.spawn(async move { - match auth::login_via_system_browser(&runtime).await { + let task = handle.spawn(async move { + let phase_weak = weak.clone(); + let result = auth::login_via_system_browser(&runtime, move |phase| { + let value = match phase { + auth::LoginPhase::WaitingForBrowser => 1, + auth::LoginPhase::Authenticating => 2, + }; + let _ = phase_weak.upgrade_in_event_loop(move |w| { + w.global::().set_phase(value); + }); + }) + .await; + match result { Ok(session) => { log::info!( "[qbz-slint] authenticated as user {}", @@ -6468,9 +6501,17 @@ fn main() -> Result<(), Box> { ); enter_shell(runtime, weak, image_cache, settings_ctx, session).await; } - Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"), + Err(e) => { + log::error!("[qbz-slint] sign-in failed: {e}"); + let _ = weak.upgrade_in_event_loop(move |w| { + let login_state = w.global::(); + login_state.set_phase(0); + login_state.set_error(e.into()); + }); + } } }); + *login_task.lock().unwrap() = Some(task); } }; @@ -6481,11 +6522,23 @@ fn main() -> Result<(), Box> { login(); }); } + + // Cancel link on the login screen (visible only while waiting for the + // browser): abort the pending OAuth task and return to idle. Aborting + // drops the local listener; the browser tab just fails to redirect. { - let login = on_browser_login.clone(); - window.on_use_system_browser(move || { - dispatch(AppCommand::UseSystemBrowser); - login(); + let weak = window.as_weak(); + let login_task = login_task.clone(); + window.on_cancel_login(move || { + if let Some(task) = login_task.lock().unwrap().take() { + task.abort(); + } + if let Some(w) = weak.upgrade() { + let login_state = w.global::(); + login_state.set_phase(0); + login_state.set_error("".into()); + } + log::info!("[qbz-slint] browser sign-in cancelled by user"); }); } @@ -6520,6 +6573,7 @@ fn main() -> Result<(), Box> { let handle = tokio_rt.handle().clone(); let image_cache = image_cache.clone(); let settings_ctx = settings_ctx.clone(); + let login_task = login_task.clone(); window.on_recovery_login(move || { // Logged BEFORE the spawn: records the click arriving from the // UI chain even if the async attempt below stalls or fails. @@ -6528,7 +6582,7 @@ fn main() -> Result<(), Box> { let weak = weak.clone(); let image_cache = image_cache.clone(); let settings_ctx = settings_ctx.clone(); - handle.spawn(async move { + let task = handle.spawn(async move { // No pre-lift anywhere: the auth endpoints are EXEMPT from // the offline gate (qbz-qobuz client), so the token login and // the OAuth exchange pass the closed gate — and @@ -6559,9 +6613,27 @@ fn main() -> Result<(), Box> { "[qbz-slint] recovery login: saved session unusable — falling back to browser OAuth" ); let _ = weak.upgrade_in_event_loop(|w| { + // Seed the waiting narration before the browser + // opens so the screen never shows an idle button + // while the flow is already running. + let login_state = w.global::(); + login_state.set_error("".into()); + login_state.set_phase(1); w.set_screen(AppScreen::Login); }); - match auth::login_via_system_browser(&runtime).await { + let phase_weak = weak.clone(); + let login_result = + auth::login_via_system_browser(&runtime, move |phase| { + let value = match phase { + auth::LoginPhase::WaitingForBrowser => 1, + auth::LoginPhase::Authenticating => 2, + }; + let _ = phase_weak.upgrade_in_event_loop(move |w| { + w.global::().set_phase(value); + }); + }) + .await; + match login_result { Ok(session) => { log::info!( "[qbz-slint] recovery browser sign-in succeeded for user {}", @@ -6580,7 +6652,9 @@ fn main() -> Result<(), Box> { // the offline shell. let _ = weak.upgrade_in_event_loop(move |w| { toast::error(&w, format!("Sign-in failed: {e}")); - w.global::().set_login_error(e.into()); + let login_state = w.global::(); + login_state.set_phase(0); + login_state.set_error(e.into()); }); } } @@ -6598,6 +6672,9 @@ fn main() -> Result<(), Box> { } } }); + // Same slot the login screen's Cancel link aborts — the browser + // leg of this recovery flow is cancellable like a normal sign-in. + *login_task.lock().unwrap() = Some(task); }); }