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:
vicrodh committed 2026-07-02 10:10:59 -06:00
1 parent da566d2426
commit 8a734f6427
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 "Не удалось войти"
+4 -4
View File
@@ -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();
+67 -11
View File
@@ -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;
}
}
}
}
+14
View File
@@ -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
View File
@@ -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();
+5 -7
View File
@@ -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
View File
@@ -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);
});
}