feat(slint): QConnect offline gate (D5) and slice-3 main wiring

QConnect is unavailable in any offline flavor: connect() refuses while
offline (toast + same reset path as the api gate), a watcher subscribed to
the engine force-disconnects through the exact manual-disconnect path on
every offline edge (badges and lifecycle clear identically), an end-of-
connect re-check closes the flip-while-connecting race, and the session
loop host skips its controller re-join while offline. No auto-reconnect on
the online edge by design. The shared remote stream feeder stays ungated
(it also serves Plex, which must survive induced offline).

main.rs carries the wiring for all three slice-3 pieces: the offline
force-disconnect spawn, the offline-gated save-as artwork fallback, and
the favorites disk seed in both shell entries.

cc
This commit is contained in:
vicrodh committed 2026-06-09 21:00:33 -06:00
1 parent 727c3fb4ba
commit f6177577c8
2 files changed
+124 -4

No files matched your search

+54 -4
View File
@@ -240,12 +240,22 @@ async fn enter_shell(
// Warm the shared favorite-track cache so track rows can show the // Warm the shared favorite-track cache so track rows can show the
// correct heart state from their first paint (album / artist / search // correct heart state from their first paint (album / artist / search
// / playlist / mix / favorites all read it). Background-fetched once. // / playlist / mix / favorites / queue all read it). The disk seed
// already ran at session activation (fav_cache::init_for_user); this
// refreshes from the network and writes the fresh set back — skipped
// while offline, where the disk seed is the truth.
{ {
let runtime = runtime.clone(); let runtime = runtime.clone();
tokio::spawn(async move { tokio::spawn(async move {
if crate::offline_mode::engine().is_offline() {
return;
}
match runtime.core().favorite_track_ids().await { match runtime.core().favorite_track_ids().await {
Ok(ids) => fav_cache::set_all(ids), Ok(ids) => {
// set_all mirrors to disk (blocking rusqlite) — keep it
// off the async worker.
let _ = tokio::task::spawn_blocking(move || fav_cache::set_all(ids)).await;
}
Err(e) => log::warn!("[qbz-slint] favorite cache load failed: {e}"), Err(e) => log::warn!("[qbz-slint] favorite cache load failed: {e}"),
} }
}); });
@@ -307,9 +317,11 @@ async fn enter_shell_offline(
// Offline-MODE engine: bind the per-user stores and flag the // Offline-MODE engine: bind the per-user stores and flag the
// unauthenticated offline session (D1 — session-scoped, never persisted; // unauthenticated offline session (D1 — session-scoped, never persisted;
// a later successful login clears it). // a later successful login clears it). The favorites-cache bind seeds
// hearts from disk — the start-offline gap Tauri never closed.
if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { if let Some(dir) = crate::offline_mode::user_data_dir(user_id) {
crate::offline_mode::init_for_user(&dir); crate::offline_mode::init_for_user(&dir);
crate::fav_cache::init_for_user(&dir);
} }
crate::offline_mode::engine().set_offline_session(true); crate::offline_mode::engine().set_offline_session(true);
@@ -4299,6 +4311,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
("track", "favorite") => { ("track", "favorite") => {
// Offline = read-only hearts (spec 4.3): refuse the
// toggle with a toast instead of letting the API fail.
if offline_mode::engine().is_offline() {
if let Some(w) = weak.upgrade() {
toast::info(&w, "Not available offline");
}
return;
}
// Toggle (not just add): read the cached state, flip it // Toggle (not just add): read the cached state, flip it
// optimistically across every visible track model + the // optimistically across every visible track model + the
// shared cache, then add/remove on the network. // shared cache, then add/remove on the network.
@@ -4932,7 +4952,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// shared qconnect-app hardening (watchdog/takeover/resync). Transport // shared qconnect-app hardening (watchdog/takeover/resync). Transport
// `*_if_remote` routing + device picker land in the QConnect UI-polish step. // `*_if_remote` routing + device picker land in the QConnect UI-polish step.
{ {
let _ = qconnect_service::init_service(app_runtime.clone(), window.as_weak()); let svc = qconnect_service::init_service(app_runtime.clone(), window.as_weak());
// D5 (offline-MODE): force-disconnect any live session on every
// transition into offline (induced or real).
svc.spawn_offline_force_disconnect(tokio_rt.handle());
let handle = tokio_rt.handle().clone(); let handle = tokio_rt.handle().clone();
let weak = window.as_weak(); let weak = window.as_weak();
window window
@@ -8684,6 +8707,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let Some(w) = weak.upgrade() else { let Some(w) = weak.upgrade() else {
return; return;
}; };
// Offline = read-only hearts (spec 4.3).
if offline_mode::engine().is_offline() {
toast::info(&w, "Not available offline");
return;
}
favorites::mark_track_removing(&w, &id); favorites::mark_track_removing(&w, &id);
if let Ok(tid) = id.parse::<u64>() { if let Ok(tid) = id.parse::<u64>() {
crate::fav_cache::set(tid, false); crate::fav_cache::set(tid, false);
@@ -8888,6 +8916,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
"remove-selected" => { "remove-selected" => {
// Offline = read-only hearts (spec 4.3).
if offline_mode::engine().is_offline() {
toast::info(&w, "Not available offline");
return;
}
let ids = favorites::selected_ids(&w); let ids = favorites::selected_ids(&w);
if ids.is_empty() { if ids.is_empty() {
return; return;
@@ -8959,6 +8992,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
else { else {
return; return;
}; };
// Offline: serve the shared disk-cache copy; never attempt
// the download.
if offline_mode::engine().is_offline() {
match artwork::cached_path_for(&url) {
Some(path) => {
if let Err(e) = tokio::fs::copy(&path, dest.path()).await {
log::error!(
"[qbz-slint] artwork save-as offline copy: {e}"
);
}
}
None => log::warn!(
"[qbz-slint] artwork save-as skipped offline: not in disk cache"
),
}
return;
}
let bytes = match reqwest::get(&url).await { let bytes = match reqwest::get(&url).await {
Ok(resp) => match resp.bytes().await { Ok(resp) => match resp.bytes().await {
Ok(b) => b, Ok(b) => b,
+70
View File
@@ -344,6 +344,50 @@ impl SlintQconnectService {
self.inner.lock().await.runtime.is_some() self.inner.lock().await.runtime.is_some()
} }
/// D5 (offline-MODE): force-disconnect on every transition INTO offline
/// (induced or real), so an established session never outlives the offline
/// gate. Spawned once at service init (main.rs, next to `init_service`).
/// Idempotent: skips when no runtime is alive. Uses the SAME `disconnect()`
/// path as the UI toggle (force-Offs lifecycle, shuts the transport down —
/// which also kills its 60s idle-retry rearm — disarms the watchdog and
/// clears the renderer/cast UI), then clears the bar's connected flag
/// exactly like the manual toggle does. Deliberately NO auto-reconnect on
/// the online edge: QConnect only reconnects through its existing
/// user-facing flows (D5).
pub fn spawn_offline_force_disconnect(self: &Arc<Self>, handle: &tokio::runtime::Handle) {
let service = Arc::clone(self);
handle.spawn(async move {
let mut rx = crate::offline_mode::engine().subscribe();
let mut was_offline = rx.borrow_and_update().is_offline();
loop {
if rx.changed().await.is_err() {
break;
}
let is_offline = rx.borrow_and_update().is_offline();
let entered_offline = is_offline && !was_offline;
was_offline = is_offline;
if !entered_offline {
continue;
}
if !service.is_running().await {
continue;
}
log::info!(
"[QConnect] Offline mode entered; force-disconnecting Qobuz Connect (D5)"
);
dev_push_event(&service.window, "-> force-disconnect (offline mode)".to_string());
if let Err(err) = service.disconnect().await {
log::warn!("[QConnect] offline force-disconnect failed: {err}");
}
// Mirror the manual toggle's tail: the bar's connect toggle off.
let _ = service.window.upgrade_in_event_loop(|w| {
use slint::ComponentHandle;
w.global::<crate::NowPlayingState>().set_qconnect_connected(false);
});
}
});
}
/// Report this device's playback state to the cloud while QBZ is the ACTIVE /// Report this device's playback state to the cloud while QBZ is the ACTIVE
/// LOCAL renderer (driven by the playback poll loop). Mirrors the Tauri /// LOCAL renderer (driven by the playback poll loop). Mirrors the Tauri
/// `v2_qconnect_report_playback_state` essentials: self-gates on /// `v2_qconnect_report_playback_state` essentials: self-gates on
@@ -443,6 +487,15 @@ impl SlintQconnectService {
if !self.runtime.core().is_api_initialized().await { if !self.runtime.core().is_api_initialized().await {
return Err("Qobuz API is not initialized; cannot start Qobuz Connect".to_string()); return Err("Qobuz API is not initialized; cannot start Qobuz Connect".to_string());
} }
// D5 (offline-MODE): QConnect is not available in ANY offline mode,
// induced or real — refuse before touching the transport. Sessions that
// were already up when offline was entered are torn down by the
// force-disconnect watcher (`spawn_offline_force_disconnect`).
if crate::offline_mode::engine().is_offline() {
log::info!("[QConnect] connect() refused: offline mode active (D5)");
crate::toast::error_weak(&self.window, "Qobuz Connect is unavailable while offline");
return Err("Qobuz Connect is unavailable while offline".to_string());
}
// Claim the connect slot ATOMICALLY before the transport-config await, so // Claim the connect slot ATOMICALLY before the transport-config await, so
// two concurrent connect()s can't both build a runtime (the second would // two concurrent connect()s can't both build a runtime (the second would
@@ -544,6 +597,15 @@ impl SlintQconnectService {
return Err(format!("qconnect bootstrap failed: {err}")); return Err(format!("qconnect bootstrap failed: {err}"));
} }
// D5 race close: offline may have been entered while this connect was
// in flight (the watcher skips while no runtime is alive). Re-check now
// that the runtime is set; any later flip is the watcher's job.
if crate::offline_mode::engine().is_offline() {
log::info!("[QConnect] offline mode entered during connect(); tearing down (D5)");
let _ = self.disconnect().await;
return Err("Qobuz Connect is unavailable while offline".to_string());
}
Ok(()) Ok(())
} }
@@ -2097,6 +2159,14 @@ impl SessionLoopHost for SlintSessionLoopHost {
} }
async fn bootstrap_after_reconnect(&self) { async fn bootstrap_after_reconnect(&self) {
// D5 (offline-MODE): never re-bootstrap presence while offline. The
// force-disconnect watcher is tearing the session down on the offline
// edge; a transport reconnect that sneaks in before that lands (induced
// offline keeps the network up) must stay dormant, not re-join.
if crate::offline_mode::engine().is_offline() {
log::info!("[QConnect] Reconnect bootstrap suppressed: offline mode active (D5)");
return;
}
if let Err(err) = bootstrap_remote_presence(&self.app, None).await { if let Err(err) = bootstrap_remote_presence(&self.app, None).await {
log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}"); log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}");
} }