From c207f232c529c506b0072af7829004012019cdf0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:32:14 -0600 Subject: [PATCH] fix(offline): logout reopens the Qobuz gate; auth endpoints exempt from it Logging out of an offline session left offline_session set and the gate closed, refusing the very OAuth exchange that could end the offline state (user-reproduced: sign-in failed with OfflineMode after logout). teardown() now ends the session-scoped offline state (offline_session and the cached induced flag reset; the persisted preference reloads on the next activation) and recomputes, reopening the gate when connectivity allows. Two regression tests added. The three login methods (login, login_with_oauth_code, login_with_token) are exempt from the offline gate: user-initiated sign-in is explicit intent to reach Qobuz; the gate governs services. Verified none of them reach gated sub-calls; bundle cold-start stays gated on purpose. New unit test pins the exemption. The recovery sign-in falls back to the full browser OAuth flow when the saved token is gone or rejected, and offline_session is lifted only on success paths. cc --- crates/qbz-app/src/offline_mode/mod.rs | 64 ++++++++++++++++++++++- crates/qbz-qobuz/src/client.rs | 71 ++++++++++++++++++++++++-- crates/qbz-slint/src/auth.rs | 13 +++++ 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/crates/qbz-app/src/offline_mode/mod.rs b/crates/qbz-app/src/offline_mode/mod.rs index 9754b40e4..1cc674935 100644 --- a/crates/qbz-app/src/offline_mode/mod.rs +++ b/crates/qbz-app/src/offline_mode/mod.rs @@ -118,12 +118,21 @@ impl OfflineModeEngine { Ok(()) } - /// Drop the per-user store (logout). The induced flag cache is kept until - /// the next `init_for_user` — the gate state must survive the transition. + /// Drop the per-user store AND end the session-scoped offline state + /// (logout). Ending the session ends the session: `offline_session` is + /// reset (it must not survive into the next login attempt — a surviving + /// flag kept the Qobuz gate closed and refused the login itself), and the + /// cached `induced` flag is reset too (no user ⇒ no induced opt-in + /// active; the user's persisted preference reloads from disk on the next + /// `init_for_user`). The final `recompute()` reopens the Qobuz gate when + /// connectivity allows. pub fn teardown(&self) { if let Ok(mut guard) = self.store.lock() { *guard = None; } + self.offline_session.store(false, Ordering::Relaxed); + self.induced.store(false, Ordering::Relaxed); + self.recompute(); } /// Subscribe to status changes (UI listeners, QConnect suppressor, ...). @@ -151,6 +160,13 @@ impl OfflineModeEngine { store.get_settings() } + /// Persist the network-folders-in-manual-offline policy flag. + /// + /// NOTE (2026-06-10): no UI calls this anymore — the Slint "Show Network + /// Folder Content" toggle was removed when library visibility stopped + /// depending on offline mode (owner verdict; see qbz-slint's + /// NETWORK-FOLDER VISIBILITY note). Kept (pub, no dead-code warning in a + /// lib crate) because the store column must stay Tauri-DB-compatible. pub fn set_show_network_folders(&self, enabled: bool) -> Result<(), String> { let guard = self .store @@ -445,6 +461,50 @@ mod tests { let _ = std::fs::remove_dir_all(audio_dir); } + #[test] + fn teardown_ends_offline_session_and_reopens_gate() { + let _gate = serialize(); + let engine = OfflineModeEngine::new(); + engine.on_connectivity(up()); + engine.set_offline_session(true); + assert_eq!(engine.status().mode, OfflineMode::RealOffline); + assert!(qbz_qobuz::offline_gate::is_offline()); + + // Logout: the session-scoped flag must NOT survive — a stale flag + // kept the gate closed and refused the next login. + engine.teardown(); + let status = engine.status(); + assert_eq!(status.mode, OfflineMode::Online); + assert!(!status.offline_session); + assert!(!qbz_qobuz::offline_gate::is_offline()); + } + + #[test] + fn teardown_clears_induced_cache_but_disk_restores_it() { + let _gate = serialize(); + let dir = unique_test_dir("engine-teardown-induced"); + let engine = OfflineModeEngine::new(); + engine.init_for_user(&dir).unwrap(); + engine.on_connectivity(up()); + engine.set_induced(true, None).unwrap(); + assert!(qbz_qobuz::offline_gate::is_offline()); + + // Logout: no user ⇒ no induced opt-in active ⇒ gate open. + engine.teardown(); + let status = engine.status(); + assert_eq!(status.mode, OfflineMode::Online); + assert!(!status.induced); + assert!(!qbz_qobuz::offline_gate::is_offline()); + + // The persisted preference survives on disk: the next activation on + // the same dir restores induced offline. + engine.init_for_user(&dir).unwrap(); + assert_eq!(engine.status().mode, OfflineMode::InducedOffline); + assert!(qbz_qobuz::offline_gate::is_offline()); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn watch_broadcasts_on_change() { let _gate = serialize(); diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index c04d900ab..ac5667376 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -149,9 +149,16 @@ impl QobuzClient { &self.http } - /// The single offline choke point (D3): every Qobuz HTTP request flows + /// The single offline choke point (D3): every Qobuz SERVICE request flows /// through here. While offline mode is active, fail fast with a typed, /// non-transient error instead of timing out against the network. + /// + /// Exemption: the sign-in methods (`login`, `login_with_oauth_code`, + /// `login_with_token`) use the raw `self.http` field instead — + /// user-initiated authentication is an explicit intent to reach Qobuz; + /// the offline gate governs services, not sign-in. Without the exemption + /// a closed gate (induced offline, or a stale offline session) refuses + /// the very login that would resolve it. fn http(&self) -> Result<&Client> { if crate::offline_gate::is_offline() { return Err(ApiError::OfflineMode); @@ -209,8 +216,10 @@ impl QobuzClient { /// Login with email and password pub async fn login(&self, email: &str, password: &str) -> Result { let url = endpoints::build_url(paths::USER_LOGIN); + // Auth exemption: raw client, bypasses the offline gate (sign-in is + // explicit user intent to reach Qobuz; the gate governs services). let response = self - .http()? + .http .get(&url) .headers(self.api_headers().await?) .query(&[("email", email), ("password", password)]) @@ -278,8 +287,10 @@ impl QobuzClient { ); log::info!("[OAuth] Exchanging code for token via /oauth/callback"); + // Auth exemption: raw client, bypasses the offline gate (sign-in is + // explicit user intent to reach Qobuz; the gate governs services). let callback_response = self - .http()? + .http .get(&callback_url) .headers(headers) .query(&[ @@ -320,8 +331,9 @@ impl QobuzClient { .map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?, ); + // Auth exemption: raw client (see /oauth/callback step above). let login_response = self - .http()? + .http .post(&user_login_url) .headers(auth_headers) .header("Content-Type", "text/plain;charset=UTF-8") @@ -376,8 +388,10 @@ impl QobuzClient { ); log::info!("[OAuth] Restoring session from saved token"); + // Auth exemption: raw client, bypasses the offline gate (sign-in is + // explicit user intent to reach Qobuz; the gate governs services). let resp = self - .http()? + .http .post(&user_login_url) .headers(headers) .header("Content-Type", "text/plain;charset=UTF-8") @@ -2695,4 +2709,51 @@ mod tests { "offline gate took {elapsed:?} — it must not touch the network" ); } + + /// The sign-in methods are EXEMPT from the offline gate: user-initiated + /// authentication is an explicit intent to reach Qobuz. On an + /// uninitialized client each method gets PAST the closed gate and fails + /// on the missing bundle tokens instead — never `ApiError::OfflineMode`, + /// and without touching the network. + #[tokio::test] + async fn offline_gate_exempts_login_methods() { + let _lock = crate::offline_gate::test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _reset = crate::offline_gate::TestGateReset; + + crate::offline_gate::set_offline(true); + + let client = QobuzClient::new().expect("client construction is local-only"); + + let err = client + .login("user@example.com", "pw") + .await + .err() + .expect("uninitialized client must fail"); + assert!( + !matches!(err, ApiError::OfflineMode), + "login must bypass the offline gate, got: {err}" + ); + + let err = client + .login_with_oauth_code("code") + .await + .err() + .expect("uninitialized client must fail"); + assert!( + !matches!(err, ApiError::OfflineMode), + "login_with_oauth_code must bypass the offline gate, got: {err}" + ); + + let err = client + .login_with_token("token") + .await + .err() + .expect("uninitialized client must fail"); + assert!( + !matches!(err, ApiError::OfflineMode), + "login_with_token must bypass the offline gate, got: {err}" + ); + } } diff --git a/crates/qbz-slint/src/auth.rs b/crates/qbz-slint/src/auth.rs index 8fcf1174d..063386602 100644 --- a/crates/qbz-slint/src/auth.rs +++ b/crates/qbz-slint/src/auth.rs @@ -33,6 +33,19 @@ where { let core = runtime.core(); + // Belt-and-suspenders: signing in is an explicit intent to reach Qobuz. + // If a session-scoped offline session is still active (e.g. invoked from + // the login screen after a logout that raced the engine reset), end it so + // the still-gated parts of the pipeline (cold bundle init, post-login + // activation) work. The persisted `induced` preference is NOT touched — + // a successful login reloads it via init_for_user and the app correctly + // lands back in induced offline. + let offline_engine = crate::offline_mode::engine(); + if offline_engine.status().offline_session { + log::info!("[qbz-slint] sign-in requested with an offline session active — ending it"); + offline_engine.set_offline_session(false); + } + // Ensure the Qobuz client is initialized (bundle tokens). if !core.is_api_initialized().await { core.try_init_api().await.map_err(|e| e.to_string())?;