diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index 09f17d884..ac07ab7e6 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -24,6 +24,7 @@ qbz-app = { path = "../qbz-app" } qbz-core = { path = "../qbz-core" } qbz-models = { path = "../qbz-models" } qbz-cache = { path = "../qbz-cache" } +qbz-credentials = { path = "../qbz-credentials" } [build-dependencies] slint-build = "1" diff --git a/crates/qbz-slint/src/auth.rs b/crates/qbz-slint/src/auth.rs index 3f509f7f6..2011b50f8 100644 --- a/crates/qbz-slint/src/auth.rs +++ b/crates/qbz-slint/src/auth.rs @@ -68,6 +68,7 @@ where .map_err(|e| e.to_string())? }; let user_id = session.user_id; + let token = session.user_auth_token.clone(); // Emit LoggedIn through the core (idempotent set_session). core.set_session(session).await.map_err(|e| e.to_string())?; @@ -75,10 +76,55 @@ where // Activate the per-user session (creates dirs, opens the session store). runtime.activate(user_id).await?; + // Persist the token so the next launch restores the session silently. + if let Err(e) = qbz_credentials::save_oauth_token(&token) { + log::warn!("[qbz-slint] failed to persist OAuth token: {e}"); + } + log::info!("[qbz-slint] login complete for user {user_id}"); Ok(user_id) } +/// Restore a previously saved session from the encrypted token store +/// (keyring + AES-256-GCM file — the same store the Tauri app uses). +/// +/// Returns `Ok(Some(user_id))` when a saved token is valid and the session +/// is activated, `Ok(None)` when there is no token. A token that exists +/// but is rejected by Qobuz is cleared and treated as `None`. +pub async fn restore_saved_session(runtime: &Arc>) -> Result, String> +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let token = match qbz_credentials::load_oauth_token() { + Ok(Some(token)) => token, + Ok(None) => return Ok(None), + Err(e) => { + log::warn!("[qbz-slint] could not read saved token: {e}"); + return Ok(None); + } + }; + + let core = runtime.core(); + if !core.is_api_initialized().await { + core.try_init_api().await.map_err(|e| e.to_string())?; + } + + match core.login_with_token(&token).await { + Ok(session) => { + let user_id = session.user_id; + core.set_session(session).await.map_err(|e| e.to_string())?; + runtime.activate(user_id).await?; + log::info!("[qbz-slint] restored saved session for user {user_id}"); + Ok(Some(user_id)) + } + Err(e) => { + log::warn!("[qbz-slint] saved token rejected, clearing: {e}"); + let _ = qbz_credentials::clear_oauth_token(); + Ok(None) + } + } +} + /// Accept connections until one carries the OAuth code, replying with a /// minimal success page. Browser noise (favicon requests, etc.) is answered /// and skipped. diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index e3d633598..5ae975a3f 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -9,8 +9,8 @@ //! pulls in the generated Rust bindings. //! //! Status: foundation tokens, login screen, app shell, functional -//! system-browser OAuth, and a real Discover / Home view fed by the -//! Qobuz discover index. +//! system-browser OAuth, saved-session restore, and a real Discover / +//! Home view fed by the Qobuz discover index with cached artwork. slint::include_modules!(); @@ -33,6 +33,59 @@ fn dispatch(command: AppCommand) { log::info!("[qbz-slint] AppCommand::{} dispatched", command.id()); } +/// Reveal the shell and load the Discover / Home view with real data, +/// then kick off cached artwork downloads. +async fn enter_shell( + runtime: Arc>, + weak: slint::Weak, + image_cache: artwork::ImageCache, +) { + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_loading(true); + w.set_screen(AppScreen::Shell); + }); + + match home::load_home(&runtime).await { + Ok(sections) => { + // Collect artwork jobs before the data is consumed by + // apply_sections. + let jobs: Vec = sections + .iter() + .enumerate() + .flat_map(|(section_idx, section)| { + section + .albums + .iter() + .enumerate() + .filter_map(move |(album_idx, card)| { + if card.artwork_url.is_empty() { + None + } else { + Some(artwork::ArtworkJob { + section_idx, + album_idx, + url: card.artwork_url.clone(), + }) + } + }) + }) + .collect(); + let weak_for_artwork = weak.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + home::apply_sections(&w, sections); + w.global::().set_loading(false); + }); + artwork::spawn_loads(jobs, weak_for_artwork, image_cache); + } + Err(e) => { + log::error!("[qbz-slint] discover load failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_loading(false); + }); + } + } +} + fn main() -> Result<(), Box> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); @@ -46,18 +99,29 @@ fn main() -> Result<(), Box> { let image_cache = artwork::open_cache(); artwork::spawn_evict(image_cache.clone()); - // Extract Qobuz bundle tokens in the background so OAuth is ready - // by the time the user signs in. + // Startup: initialize the core, then try to restore a saved session. + // A valid saved token jumps straight to the shell; otherwise the + // login screen stays. { let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let image_cache = image_cache.clone(); tokio_rt.spawn(async move { if let Err(e) = runtime.init().await { log::error!("[qbz-slint] core init failed: {e}"); } + match auth::restore_saved_session(&runtime).await { + Ok(Some(user_id)) => { + log::info!("[qbz-slint] session restored for user {user_id}"); + enter_shell(runtime, weak, image_cache).await; + } + Ok(None) => log::info!("[qbz-slint] no saved session — showing login"), + Err(e) => log::error!("[qbz-slint] session restore failed: {e}"), + } }); } - // Sign in via the system browser → real OAuth → Discover/Home. + // 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). let on_browser_login = { @@ -70,55 +134,12 @@ fn main() -> Result<(), Box> { let weak = weak.clone(); let image_cache = image_cache.clone(); handle.spawn(async move { - let user_id = match auth::login_via_system_browser(&runtime).await { - Ok(user_id) => user_id, - Err(e) => { - log::error!("[qbz-slint] sign-in failed: {e}"); - return; - } - }; - log::info!("[qbz-slint] authenticated as user {user_id}"); - - let _ = weak.upgrade_in_event_loop(move |w| { - w.global::().set_loading(true); - w.set_screen(AppScreen::Shell); - }); - - match home::load_home(&runtime).await { - Ok(sections) => { - // Collect artwork jobs before the data is consumed - // by apply_sections. - let jobs: Vec = sections - .iter() - .enumerate() - .flat_map(|(section_idx, section)| { - section.albums.iter().enumerate().filter_map( - move |(album_idx, card)| { - if card.artwork_url.is_empty() { - None - } else { - Some(artwork::ArtworkJob { - section_idx, - album_idx, - url: card.artwork_url.clone(), - }) - } - }, - ) - }) - .collect(); - let _ = weak.upgrade_in_event_loop(move |w| { - home::apply_sections(&w, sections); - w.global::().set_loading(false); - }); - artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); - } - Err(e) => { - log::error!("[qbz-slint] discover load failed: {e}"); - let _ = weak.upgrade_in_event_loop(|w| { - w.global::().set_loading(false); - }); + match auth::login_via_system_browser(&runtime).await { + Ok(user_id) => { + log::info!("[qbz-slint] authenticated as user {user_id}"); + enter_shell(runtime, weak, image_cache).await; } + Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"), } }); } @@ -166,7 +187,7 @@ fn main() -> Result<(), Box> { } }); - log::info!("[qbz-slint] window ready — login screen"); + log::info!("[qbz-slint] window ready"); window.run()?; Ok(()) }