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
This commit is contained in:
13 files changed
+312
-37
No files matched your search
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 "サインインに失敗しました"
|
||||
@@ -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"
|
||||
@@ -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 "Не удалось войти"
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <int> phase: 0;
|
||||
// Last sign-in failure ("" = none). Cleared when a new attempt starts
|
||||
// and on successful shell entry.
|
||||
in property <string> 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
|
||||
|
||||
+20
-2
@@ -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<A>(
|
||||
/// 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<A, F>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
on_phase: F,
|
||||
) -> Result<SessionInfo, String>
|
||||
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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+90
-13
@@ -332,6 +332,11 @@ async fn enter_shell(
|
||||
let offline_state = w.global::<OfflineState>();
|
||||
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::<LoginState>();
|
||||
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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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<std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>> =
|
||||
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::<LoginState>();
|
||||
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::<LoginState>().set_phase(value);
|
||||
});
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(session) => {
|
||||
log::info!(
|
||||
"[qbz-slint] authenticated as user {}",
|
||||
@@ -6468,9 +6501,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
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::<LoginState>();
|
||||
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<dyn std::error::Error>> {
|
||||
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::<LoginState>();
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
"[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::<LoginState>();
|
||||
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::<LoginState>().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<dyn std::error::Error>> {
|
||||
// the offline shell.
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
toast::error(&w, format!("Sign-in failed: {e}"));
|
||||
w.global::<OfflineState>().set_login_error(e.into());
|
||||
let login_state = w.global::<LoginState>();
|
||||
login_state.set_phase(0);
|
||||
login_state.set_error(e.into());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6598,6 +6672,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user