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:
vicrodh committed 2026-06-09 20:14:35 -06:00
1 parent 35403028da
commit 904b5f7b40
4 files changed
+439 -58

No files matched your search

+5 -1
View File
@@ -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))
}
}
}
+62 -7
View File
@@ -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
View File
@@ -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}");
}
});
});
+233
View File
@@ -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)");
});
}