feat(slint): real offline session entry, token-survival fix, grace producers
Start offline on the login screen now enters a full offline session instead of an empty shell: session scaffolding at the last user, local library DB + mixtape migrations, offline cache + cached-ids warm, per-user pref stores, tray and media controls, playback poll loop, settings snapshot - mirroring Tauri's activate_offline_session checklist. Refuses when no previous session exists (never opens the empty user-0 profile). New offline_mode glue binds the shared engine + connectivity actor for the app lifetime and owns the per-user SubscriptionStateStore: mark_valid on every successful login, mark_invalid only on an explicit IneligibleUser verdict, binary 30-day-grace playback predicate, and the Tauri-mirrored purge-at-activation consumer (previously the Slint build never opened the store, leaving the grace model inert). Token-survival fix: a network-failed session restore no longer clears the saved OAuth token; only an explicit auth rejection does. login_with_token now preserves the typed ApiError (CoreError::Api) so callers can tell the two classes apart. cc
This commit is contained in:
4 files changed
+439
-58
No files matched your search
@@ -297,7 +297,11 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
recoverable: true,
|
||||
})
|
||||
.await;
|
||||
Err(CoreError::AuthFailed(e.to_string()))
|
||||
// Preserve the typed ApiError: callers must distinguish an
|
||||
// explicit auth rejection (clear the saved token) from a
|
||||
// network-class failure (keep it) — stringifying here made
|
||||
// that impossible and caused the token-clearing-on-boot bug.
|
||||
Err(CoreError::Api(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,17 @@ where
|
||||
let client_lock = core.client();
|
||||
let guard = client_lock.read().await;
|
||||
let client = guard.as_ref().ok_or("Qobuz client not initialized")?;
|
||||
client
|
||||
.login_with_oauth_code(&code)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
match client.login_with_oauth_code(&code).await {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
// D4 producer: ONLY an explicit ineligible-account verdict
|
||||
// starts the grace clock. Generic 401/network errors never do.
|
||||
if matches!(e, qbz_qobuz::ApiError::IneligibleUser) {
|
||||
crate::offline_mode::subscription_mark_invalid();
|
||||
}
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
let user_id = session.user_id;
|
||||
let display_name = session.display_name.clone();
|
||||
@@ -91,6 +98,15 @@ where
|
||||
crate::offline::activate(user_id).await;
|
||||
crate::offline_cache::load_cached_ids().await;
|
||||
|
||||
// Offline-MODE per-user binding (after offline::activate so the purge
|
||||
// consumer can reach the cache), then the D4 valid verdict and the D2
|
||||
// recovery: a successful login ends any unauthenticated offline session.
|
||||
if let Some(dir) = crate::offline_mode::user_data_dir(user_id) {
|
||||
crate::offline_mode::init_for_user(&dir);
|
||||
}
|
||||
crate::offline_mode::subscription_mark_valid();
|
||||
crate::offline_mode::engine().set_offline_session(false);
|
||||
|
||||
// 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}");
|
||||
@@ -104,12 +120,28 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// True only for an EXPLICIT auth rejection from Qobuz: a 401 on the token
|
||||
/// login (`AuthenticationError`) or an ineligible-account verdict. Network
|
||||
/// failures, the offline gate, 5xx, rate limiting, parse errors and unknown
|
||||
/// statuses (`ApiResponse`) all return false — on those the saved token must
|
||||
/// be KEPT (spec §4.1 D1: the boot token-clearing bug).
|
||||
fn is_auth_rejection(error: &qbz_core::CoreError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
qbz_core::CoreError::Api(
|
||||
qbz_qobuz::ApiError::AuthenticationError(_) | qbz_qobuz::ApiError::IneligibleUser
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(SessionInfo))` 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`.
|
||||
/// exists but is explicitly rejected by Qobuz is cleared and treated as
|
||||
/// `None`; on network-class failures the token is kept so the session can
|
||||
/// still be restored later (offline boot, D2 recovery).
|
||||
pub async fn restore_saved_session<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
) -> Result<Option<SessionInfo>, String>
|
||||
@@ -139,6 +171,13 @@ where
|
||||
runtime.activate(user_id).await?;
|
||||
crate::offline::activate(user_id).await;
|
||||
crate::offline_cache::load_cached_ids().await;
|
||||
// Offline-MODE per-user binding + D4 valid verdict + D2 recovery
|
||||
// (same ordering as login_via_system_browser).
|
||||
if let Some(dir) = crate::offline_mode::user_data_dir(user_id) {
|
||||
crate::offline_mode::init_for_user(&dir);
|
||||
}
|
||||
crate::offline_mode::subscription_mark_valid();
|
||||
crate::offline_mode::engine().set_offline_session(false);
|
||||
log::info!("[qbz-slint] restored saved session for user {user_id}");
|
||||
Ok(Some(SessionInfo {
|
||||
user_id,
|
||||
@@ -146,9 +185,24 @@ where
|
||||
subscription,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] saved token rejected, clearing: {e}");
|
||||
Err(e) if is_auth_rejection(&e) => {
|
||||
log::warn!("[qbz-slint] saved token rejected by Qobuz, clearing: {e}");
|
||||
let _ = qbz_credentials::clear_oauth_token();
|
||||
// D4 producer: only the explicit ineligible verdict starts the
|
||||
// grace clock; a plain 401 does not.
|
||||
if matches!(
|
||||
&e,
|
||||
qbz_core::CoreError::Api(qbz_qobuz::ApiError::IneligibleUser)
|
||||
) {
|
||||
crate::offline_mode::subscription_mark_invalid();
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
// Network-class failure (offline boot, timeout, 5xx, ...): KEEP
|
||||
// the token. The login screen shows with the session intact so
|
||||
// "Start offline" / the D2 recovery banner can use it later.
|
||||
log::warn!("[qbz-slint] session restore failed, keeping saved token: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +217,7 @@ where
|
||||
let _ = qbz_credentials::clear_oauth_token();
|
||||
let _ = runtime.core().logout().await;
|
||||
crate::offline::deactivate().await;
|
||||
crate::offline_mode::teardown();
|
||||
runtime.deactivate().await?;
|
||||
log::info!("[qbz-slint] logged out");
|
||||
Ok(())
|
||||
|
||||
+139
-50
@@ -67,6 +67,7 @@ mod tag_editor;
|
||||
mod offline;
|
||||
mod offline_cache;
|
||||
mod offline_manager;
|
||||
mod offline_mode;
|
||||
mod playlist;
|
||||
mod playlist_manager;
|
||||
mod plex_auth;
|
||||
@@ -98,18 +99,18 @@ 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,
|
||||
settings_ctx: Arc<settings::SettingsCtx>,
|
||||
session: auth::SessionInfo,
|
||||
) {
|
||||
/// Per-user shell wiring shared by the online and offline session entries.
|
||||
/// None of it requires a Qobuz session: local library DB binding (+ mixtape
|
||||
/// migrations), per-user pref stores, system tray and media controls.
|
||||
/// Returns the tray settings snapshot for the UI seeding.
|
||||
fn init_shell_for_user(
|
||||
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
user_id: u64,
|
||||
) -> tray_settings::TraySettings {
|
||||
// Bind the local library DB to this user (folders / playlist
|
||||
// settings live in the per-user library.db).
|
||||
library_db::set_user(session.user_id);
|
||||
library_db::set_user(user_id);
|
||||
|
||||
// Run the Mixtapes & Collections schema migrations against the same
|
||||
// per-user library.db (the mixtape tables live in that file). Mirrors
|
||||
@@ -125,22 +126,22 @@ async fn enter_shell(
|
||||
|
||||
// Bind tray settings to this user (per-user tray_settings.db, shared with
|
||||
// the Tauri build) and snapshot them to seed the settings UI.
|
||||
tray_settings::init_for_user(session.user_id);
|
||||
tray_settings::init_for_user(user_id);
|
||||
let tray = tray_settings::get();
|
||||
|
||||
// Bind Plex connection settings to this user (per-user plex_settings.db,
|
||||
// Slint-only). Seeded into PlexSettingsState lazily on panel open.
|
||||
plex_settings::init_for_user(session.user_id);
|
||||
plex_settings::init_for_user(user_id);
|
||||
|
||||
// Bind "My QBZ" nav branding (custom label + icon) to this user
|
||||
// (per-user myqbz_branding.json). Seeded into MyQbzBrandingState below so
|
||||
// the sidebar row + Settings row reflect the persisted values on entry.
|
||||
myqbz_prefs::init_for_user(session.user_id);
|
||||
// (per-user myqbz_branding.json). Seeded into MyQbzBrandingState by the
|
||||
// caller so the sidebar row + Settings row reflect the persisted values.
|
||||
myqbz_prefs::init_for_user(user_id);
|
||||
|
||||
// Bind per-collection DETAIL view-prefs (toolbar viewMode/sort/filter) to
|
||||
// this user (per-user collection_view_prefs.json). Restored on collection
|
||||
// open, cleared on delete (spec 12 §18).
|
||||
myqbz_view_prefs::init_for_user(session.user_id);
|
||||
myqbz_view_prefs::init_for_user(user_id);
|
||||
|
||||
// Create the system tray from this user's persisted settings (gated by
|
||||
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
|
||||
@@ -163,17 +164,60 @@ async fn enter_shell(
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
|
||||
tray
|
||||
}
|
||||
|
||||
/// Background-load the Audio + Playback settings into the Settings page —
|
||||
/// store reads and device enumeration are blocking and fully local. Shared
|
||||
/// by the online and offline session entries.
|
||||
fn spawn_settings_snapshot_load(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
settings_ctx: Arc<settings::SettingsCtx>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let ctx_for_load = settings_ctx.clone();
|
||||
match tokio::task::spawn_blocking(move || settings::load_snapshot(&ctx_for_load)).await {
|
||||
Ok(snap) => {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
settings::apply_snapshot(&w, snap);
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] settings load task failed: {e}"),
|
||||
}
|
||||
// Bit-perfect (ALSA + hw) forces local volume to 100% at startup so
|
||||
// the bar reflects unity gain before Settings is ever opened. No-op
|
||||
// otherwise (and while controlling a peer). Mirrors Tauri.
|
||||
settings::apply_startup_bitperfect_volume(&settings_ctx, &runtime, &weak).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Seed the tray settings UI from the persisted per-user store.
|
||||
fn seed_tray_appearance(w: &AppWindow, tray: &tray_settings::TraySettings) {
|
||||
let appearance = w.global::<AppearanceState>();
|
||||
appearance.set_tray_enable(tray.enable_tray);
|
||||
appearance.set_tray_minimize_to_tray(tray.minimize_to_tray);
|
||||
appearance.set_tray_close_to_tray(tray.close_to_tray);
|
||||
appearance.set_tray_mac_hide_dock(tray.mac_hide_dock);
|
||||
appearance.set_tray_icon_theme_index(tray_settings::icon_theme_index(&tray.tray_icon_theme));
|
||||
}
|
||||
|
||||
/// 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,
|
||||
settings_ctx: Arc<settings::SettingsCtx>,
|
||||
session: auth::SessionInfo,
|
||||
) {
|
||||
let tray = init_shell_for_user(&runtime, &weak, session.user_id);
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let state = w.global::<SessionState>();
|
||||
state.set_user_name(session.display_name.into());
|
||||
state.set_subscription(session.subscription.into());
|
||||
// Seed the tray settings UI from the persisted per-user store.
|
||||
let appearance = w.global::<AppearanceState>();
|
||||
appearance.set_tray_enable(tray.enable_tray);
|
||||
appearance.set_tray_minimize_to_tray(tray.minimize_to_tray);
|
||||
appearance.set_tray_close_to_tray(tray.close_to_tray);
|
||||
appearance.set_tray_mac_hide_dock(tray.mac_hide_dock);
|
||||
appearance.set_tray_icon_theme_index(tray_settings::icon_theme_index(&tray.tray_icon_theme));
|
||||
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.
|
||||
myqbz_prefs::seed(&w);
|
||||
@@ -204,27 +248,7 @@ async fn enter_shell(
|
||||
|
||||
// Load Audio + Playback settings into the Settings page in the
|
||||
// background — store reads and device enumeration are blocking.
|
||||
{
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
let weak = weak.clone();
|
||||
let runtime = runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
let ctx_for_load = settings_ctx.clone();
|
||||
match tokio::task::spawn_blocking(move || settings::load_snapshot(&ctx_for_load)).await
|
||||
{
|
||||
Ok(snap) => {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
settings::apply_snapshot(&w, snap);
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] settings load task failed: {e}"),
|
||||
}
|
||||
// Bit-perfect (ALSA + hw) forces local volume to 100% at startup so
|
||||
// the bar reflects unity gain before Settings is ever opened. No-op
|
||||
// otherwise (and while controlling a peer). Mirrors Tauri.
|
||||
settings::apply_startup_bitperfect_volume(&settings_ctx, &runtime, &weak).await;
|
||||
});
|
||||
}
|
||||
spawn_settings_snapshot_load(runtime.clone(), weak.clone(), settings_ctx.clone());
|
||||
|
||||
// Load the genre-filter parents + persisted selection, then seed
|
||||
// the popup state. Done before the discover load so the first
|
||||
@@ -244,6 +268,66 @@ async fn enter_shell(
|
||||
});
|
||||
}
|
||||
|
||||
/// Offline session entry — "Start offline" on the login screen (spec §4.1).
|
||||
///
|
||||
/// Mirrors the subset of `enter_shell` + Tauri's `activate_offline_session`
|
||||
/// that works without a Qobuz session: session scaffolding, local library,
|
||||
/// offline cache, per-user pref stores, tray/media controls, the playback
|
||||
/// poll loop and the settings snapshot. Everything Qobuz-bound is skipped
|
||||
/// (sidebar playlists, favorites warm, genre filter, home/discover load) —
|
||||
/// the engine's gate refuses those calls anyway (D3).
|
||||
async fn enter_shell_offline(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
settings_ctx: Arc<settings::SettingsCtx>,
|
||||
) -> Result<(), String> {
|
||||
// Never open the empty user-0 profile: offline mode needs a previous
|
||||
// session's data (Tauri falls back to user 0; the port refuses — the
|
||||
// login UI hides the link in that case, this is the backstop).
|
||||
let Some(user_id) = qbz_app::user_data::UserDataPaths::load_last_user_id() else {
|
||||
return Err("no previous session — offline mode requires a prior login".to_string());
|
||||
};
|
||||
|
||||
// Session scaffolding at the last user (session store, runtime state).
|
||||
runtime.activate_offline().await?;
|
||||
|
||||
// Offline cache (shared index.db + library.db) + the in-memory cached-ids
|
||||
// set the track rows read. Must precede offline_mode::init_for_user so
|
||||
// the subscription purge consumer can reach the cache.
|
||||
crate::offline::activate(user_id).await;
|
||||
crate::offline_cache::load_cached_ids().await;
|
||||
|
||||
// Local library, per-user pref stores, tray, media controls.
|
||||
let tray = init_shell_for_user(&runtime, &weak, user_id);
|
||||
|
||||
// Offline-MODE engine: bind the per-user stores and flag the
|
||||
// unauthenticated offline session (D1 — session-scoped, never persisted;
|
||||
// a later successful login clears it).
|
||||
if let Some(dir) = crate::offline_mode::user_data_dir(user_id) {
|
||||
crate::offline_mode::init_for_user(&dir);
|
||||
}
|
||||
crate::offline_mode::engine().set_offline_session(true);
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
seed_tray_appearance(&w, &tray);
|
||||
myqbz_prefs::seed(&w);
|
||||
// No HomeState loading spinner: the discover load is skipped offline
|
||||
// (the gating slice adds the placeholder views).
|
||||
w.set_screen(AppScreen::Shell);
|
||||
});
|
||||
|
||||
// Playback poll loop — local/cached playback and queue advance work
|
||||
// offline. Same lifetime semantics as the online entry.
|
||||
playback::start_poll_loop(runtime.clone(), weak.clone(), tokio::runtime::Handle::current());
|
||||
|
||||
// Load Audio + Playback settings into the Settings page in the
|
||||
// background — fully local, same path as the online entry.
|
||||
spawn_settings_snapshot_load(runtime.clone(), weak.clone(), settings_ctx.clone());
|
||||
|
||||
log::info!("[qbz-slint] offline session entered for user {user_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The shared genre-filter selection expanded to descendant ids, as the
|
||||
/// `Option<Vec<u64>>` the discover endpoints take (None = no filter).
|
||||
/// Shared by the home re-fetch and the DiscoverBrowse "View all" page.
|
||||
@@ -3358,6 +3442,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Audio + Playback settings stores, opened once for the app lifetime.
|
||||
let settings_ctx = settings::SettingsCtx::open();
|
||||
|
||||
// Offline-MODE engine: connectivity monitoring runs for the whole app
|
||||
// lifetime (login screen included — the restore flow and the D2 recovery
|
||||
// banner both depend on it). Per-user state binds later on activation.
|
||||
offline_mode::start();
|
||||
|
||||
// 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.
|
||||
@@ -3434,21 +3523,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Offline: activate an offline-only session, then show the shell.
|
||||
// Offline: enter a full offline session at the last user (local library,
|
||||
// offline cache, settings — no Qobuz auth), then show the shell.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
window.on_start_offline(move || {
|
||||
dispatch(AppCommand::StartOffline);
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
handle.spawn(async move {
|
||||
match runtime.activate_offline().await {
|
||||
Ok(()) => {
|
||||
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Shell));
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] offline start failed: {e}"),
|
||||
if let Err(e) = enter_shell_offline(runtime, weak, settings_ctx).await {
|
||||
log::error!("[qbz-slint] offline start failed: {e}");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Slint-side glue for the shared offline-MODE engine.
|
||||
//!
|
||||
//! Offline MODE = the app operating without Qobuz — NOT the offline CACHE
|
||||
//! (downloads; that glue lives in `offline.rs` / `offline_cache.rs`). The
|
||||
//! engine, connectivity actor and persisted settings are frontend-agnostic
|
||||
//! (`qbz_app::offline_mode`, ADR-006); this module only owns the process
|
||||
//! globals and the per-user binding, following the `tray_settings.rs`
|
||||
//! template.
|
||||
//!
|
||||
//! It also owns the per-user `SubscriptionStateStore` binding (D4): the
|
||||
//! login flows record valid/ineligible verdicts here, and the grace check
|
||||
//! consults it. The purge-at-activation consumer mirrors Tauri's
|
||||
//! `session_lifecycle.rs` (the Slint build never opened the store before).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use qbz_app::offline_mode::{ConnectivityActor, OfflineModeEngine};
|
||||
use qbz_app::settings::subscription::SubscriptionStateStore;
|
||||
use qbz_app::user_data::UserDataPaths;
|
||||
|
||||
/// Process-global engine. Exists from first use; per-user state binds via
|
||||
/// [`init_for_user`], connectivity via [`start`].
|
||||
static ENGINE: LazyLock<Arc<OfflineModeEngine>> =
|
||||
LazyLock::new(|| Arc::new(OfflineModeEngine::new()));
|
||||
|
||||
/// The connectivity actor, spawned once per process by [`start`].
|
||||
static CONNECTIVITY: OnceLock<ConnectivityActor> = OnceLock::new();
|
||||
|
||||
/// Per-user subscription state (D4). `None` until a session (online or
|
||||
/// offline) is activated; consumers fail open in that window.
|
||||
static SUBSCRIPTION: Mutex<Option<SubscriptionStateStore>> = Mutex::new(None);
|
||||
|
||||
pub fn engine() -> Arc<OfflineModeEngine> {
|
||||
Arc::clone(&ENGINE)
|
||||
}
|
||||
|
||||
/// Spawn the connectivity actor and attach it to the engine. Called once
|
||||
/// from `main` after the tokio runtime is up (both spawns need the runtime
|
||||
/// context); the monitoring runs for the whole app lifetime, login screen
|
||||
/// included (the restore flow and the D2 recovery banner read it).
|
||||
pub fn start() {
|
||||
if CONNECTIVITY.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let actor = ConnectivityActor::spawn();
|
||||
engine().attach_connectivity(&actor);
|
||||
if CONNECTIVITY.set(actor).is_err() {
|
||||
log::warn!("[qbz-slint] offline mode: connectivity actor already started");
|
||||
} else {
|
||||
log::info!("[qbz-slint] offline mode: connectivity monitoring started");
|
||||
}
|
||||
}
|
||||
|
||||
/// Force an immediate connectivity re-probe (Settings "Check now",
|
||||
/// consumed by the Settings slice).
|
||||
#[allow(dead_code)]
|
||||
pub fn request_recheck() {
|
||||
if let Some(actor) = CONNECTIVITY.get() {
|
||||
actor.request_recheck();
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory both the
|
||||
/// engine store and the subscription store live in. Matches the Tauri
|
||||
/// per-user path (and `tray_settings::user_dir`).
|
||||
pub fn user_data_dir(user_id: u64) -> Option<PathBuf> {
|
||||
Some(
|
||||
dirs::data_dir()?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(user_id.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bind the engine + subscription store to the active user's data dir.
|
||||
/// Called on every session activation (login, restore, offline entry),
|
||||
/// AFTER `crate::offline::activate` so the purge consumer can reach the
|
||||
/// offline cache. Best-effort: failures are logged, never block entry.
|
||||
///
|
||||
/// Must run within the tokio runtime context (the purge check spawns).
|
||||
pub fn init_for_user(base_dir: &Path) {
|
||||
if let Err(e) = engine().init_for_user(base_dir) {
|
||||
log::error!("[qbz-slint] offline mode engine init failed: {e}");
|
||||
}
|
||||
match SubscriptionStateStore::new_at(base_dir) {
|
||||
Ok(store) => {
|
||||
if let Ok(mut guard) = SUBSCRIPTION.lock() {
|
||||
*guard = Some(store);
|
||||
}
|
||||
}
|
||||
// Fail-open: no store means no recorded invalidity, playback allowed.
|
||||
Err(e) => log::error!("[qbz-slint] subscription state store open failed: {e}"),
|
||||
}
|
||||
spawn_subscription_purge_check();
|
||||
}
|
||||
|
||||
/// Drop the per-user state on logout. The engine keeps its in-memory mode
|
||||
/// (the Qobuz gate must survive the transition); only the stores close.
|
||||
pub fn teardown() {
|
||||
engine().teardown();
|
||||
if let Ok(mut guard) = SUBSCRIPTION.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
/// D4 producer: a successful login verdict. Clears any running grace clock.
|
||||
pub fn subscription_mark_valid() {
|
||||
let now = now_unix_secs();
|
||||
match SUBSCRIPTION.lock() {
|
||||
Ok(guard) => match guard.as_ref() {
|
||||
Some(store) => {
|
||||
if let Err(e) = store.mark_valid(now) {
|
||||
log::error!("[qbz-slint] subscription mark_valid failed: {e}");
|
||||
}
|
||||
}
|
||||
None => log::warn!("[qbz-slint] subscription mark_valid: no store open"),
|
||||
},
|
||||
Err(e) => log::error!("[qbz-slint] subscription store lock poisoned: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// D4 producer: an EXPLICIT ineligible-account login verdict
|
||||
/// (`ApiError::IneligibleUser`). Generic 401/network errors must never
|
||||
/// reach this — the grace clock only starts on a real verdict.
|
||||
///
|
||||
/// An ineligible verdict can arrive before any session activation (the
|
||||
/// failed login never activates), so when no store is open this falls back
|
||||
/// to transiently opening the LAST user's store.
|
||||
pub fn subscription_mark_invalid() {
|
||||
let now = now_unix_secs();
|
||||
if let Ok(guard) = SUBSCRIPTION.lock() {
|
||||
if let Some(store) = guard.as_ref() {
|
||||
if let Err(e) = store.mark_invalid(now) {
|
||||
log::error!("[qbz-slint] subscription mark_invalid failed: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(user_id) = UserDataPaths::load_last_user_id() else {
|
||||
log::warn!("[qbz-slint] subscription mark_invalid: no previous user, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(dir) = user_data_dir(user_id) else {
|
||||
log::warn!("[qbz-slint] subscription mark_invalid: data dir unavailable");
|
||||
return;
|
||||
};
|
||||
match SubscriptionStateStore::new_at(&dir) {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.mark_invalid(now) {
|
||||
log::error!("[qbz-slint] subscription mark_invalid failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] subscription state store open failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// D4 consumer: may the offline cache serve FULL tracks right now? Binary —
|
||||
/// within the 30-day grace window yes, past it no; there is NO 30-second
|
||||
/// preview path. Fail-open `true` when no store is bound (consumed by the
|
||||
/// playback gating slice).
|
||||
#[allow(dead_code)]
|
||||
pub fn offline_playback_allowed() -> bool {
|
||||
let now = now_unix_secs();
|
||||
SUBSCRIPTION
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|store| store.offline_playback_allowed(now).unwrap_or(true))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Mirror of Tauri's activation-time purge consumer
|
||||
/// (`session_lifecycle.rs` `activate_session`, lines ~237-264): when the
|
||||
/// subscription has been invalid past the grace window, purge the offline
|
||||
/// cache once and record the purge. Runs detached so session entry never
|
||||
/// blocks on it.
|
||||
fn spawn_subscription_purge_check() {
|
||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||
log::warn!("[qbz-slint] subscription purge check: no tokio runtime, skipped");
|
||||
return;
|
||||
};
|
||||
handle.spawn(async move {
|
||||
let now = now_unix_secs();
|
||||
// Read the verdict without holding the lock across awaits.
|
||||
let should_purge = SUBSCRIPTION
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|store| store.should_purge_offline_cache(now).ok())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !should_purge {
|
||||
return;
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[qbz-slint] Subscription invalid beyond the grace window. Purging offline cache."
|
||||
);
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
// init order puts offline::activate before init_for_user; this
|
||||
// only triggers if that ordering regresses. Re-checked next
|
||||
// activation, so the purge is deferred, not lost.
|
||||
log::warn!("[qbz-slint] purge deferred: offline cache not active");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = qbz_offline_cache::purge_all_cached_files(&off, &off.library_db).await {
|
||||
log::error!("[qbz-slint] failed to purge offline cache: {e}");
|
||||
return;
|
||||
}
|
||||
// Resync the in-memory cached-ids set the track rows read.
|
||||
crate::offline_cache::load_cached_ids().await;
|
||||
if let Ok(guard) = SUBSCRIPTION.lock() {
|
||||
if let Some(store) = guard.as_ref() {
|
||||
let _ = store.mark_offline_cache_purged(now);
|
||||
}
|
||||
}
|
||||
log::info!("[qbz-slint] offline cache purged (subscription grace elapsed)");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user