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:
2 files changed
+124
-4
No files matched your search
@@ -240,12 +240,22 @@ async fn enter_shell(
|
||||
|
||||
// Warm the shared favorite-track cache so track rows can show the
|
||||
// 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();
|
||||
tokio::spawn(async move {
|
||||
if crate::offline_mode::engine().is_offline() {
|
||||
return;
|
||||
}
|
||||
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}"),
|
||||
}
|
||||
});
|
||||
@@ -307,9 +317,11 @@ async fn enter_shell_offline(
|
||||
|
||||
// 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).
|
||||
// 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) {
|
||||
crate::offline_mode::init_for_user(&dir);
|
||||
crate::fav_cache::init_for_user(&dir);
|
||||
}
|
||||
crate::offline_mode::engine().set_offline_session(true);
|
||||
|
||||
@@ -4299,6 +4311,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
("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
|
||||
// optimistically across every visible track model + the
|
||||
// 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
|
||||
// `*_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 weak = window.as_weak();
|
||||
window
|
||||
@@ -8684,6 +8707,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some(w) = weak.upgrade() else {
|
||||
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);
|
||||
if let Ok(tid) = id.parse::<u64>() {
|
||||
crate::fav_cache::set(tid, false);
|
||||
@@ -8888,6 +8916,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
"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);
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
@@ -8959,6 +8992,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
else {
|
||||
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 {
|
||||
Ok(resp) => match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
|
||||
@@ -344,6 +344,50 @@ impl SlintQconnectService {
|
||||
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
|
||||
/// LOCAL renderer (driven by the playback poll loop). Mirrors the Tauri
|
||||
/// `v2_qconnect_report_playback_state` essentials: self-gates on
|
||||
@@ -443,6 +487,15 @@ impl SlintQconnectService {
|
||||
if !self.runtime.core().is_api_initialized().await {
|
||||
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
|
||||
// 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}"));
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -2097,6 +2159,14 @@ impl SessionLoopHost for SlintSessionLoopHost {
|
||||
}
|
||||
|
||||
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 {
|
||||
log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user