feat(qbz-slint): restore saved sessions from the encrypted token store

The MVP no longer requires re-authenticating on every launch. On
startup it loads the saved OAuth token via qbz-credentials, validates
it against Qobuz, and — if valid — activates the session and goes
straight to the Discover/Home view. A rejected token is cleared and
the login screen is shown instead. Successful sign-ins now persist the
token to the shared encrypted store.

- auth.rs: save_oauth_token after login, restore_saved_session at
  startup.
- main.rs: enter_shell helper shared by the login and restore paths.

cc
This commit is contained in:
vicrodh committed 2026-05-18 13:42:17 -06:00
1 parent aa64f3fafb
commit 3871143bd3
3 files changed
+122 -54

No files matched your search

+1
View File
@@ -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"
+46
View File
@@ -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<A>(runtime: &Arc<AppRuntime<A>>) -> Result<Option<u64>, 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.
+75 -54
View File
@@ -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<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
image_cache: artwork::ImageCache,
) {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<HomeState>().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<artwork::ArtworkJob> = 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::<HomeState>().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::<HomeState>().set_loading(false);
});
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
@@ -46,18 +99,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<HomeState>().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<artwork::ArtworkJob> = 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::<HomeState>().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::<HomeState>().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<dyn std::error::Error>> {
}
});
log::info!("[qbz-slint] window ready — login screen");
log::info!("[qbz-slint] window ready");
window.run()?;
Ok(())
}