diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 4edca5036..d60bb20cf 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -297,7 +297,11 @@ impl QbzCore { 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)) } } } diff --git a/crates/qbz-slint/src/auth.rs b/crates/qbz-slint/src/auth.rs index 5c529085c..fe271a986 100644 --- a/crates/qbz-slint/src/auth.rs +++ b/crates/qbz-slint/src/auth.rs @@ -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( runtime: &Arc>, ) -> Result, 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(()) diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 4f4437c2f..9418ed99b 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -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>, - weak: slint::Weak, - image_cache: artwork::ImageCache, - settings_ctx: Arc, - 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>, + weak: &slint::Weak, + 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>, + weak: slint::Weak, + settings_ctx: Arc, +) { + 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::(); + 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>, + weak: slint::Weak, + image_cache: artwork::ImageCache, + settings_ctx: Arc, + 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::(); 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::(); - 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>, + weak: slint::Weak, + settings_ctx: Arc, +) -> 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>` 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> { // 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> { }); } - // 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}"); } }); }); diff --git a/crates/qbz-slint/src/offline_mode.rs b/crates/qbz-slint/src/offline_mode.rs new file mode 100644 index 000000000..7274bb36c --- /dev/null +++ b/crates/qbz-slint/src/offline_mode.rs @@ -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> = + LazyLock::new(|| Arc::new(OfflineModeEngine::new())); + +/// The connectivity actor, spawned once per process by [`start`]. +static CONNECTIVITY: OnceLock = 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> = Mutex::new(None); + +pub fn engine() -> Arc { + 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(); + } +} + +/// `/qbz/users//` — 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 { + 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)"); + }); +}