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:
3 files changed
+122
-54
No files matched your search
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user