diff --git a/crates/qbz-qt/qml/shell/ContentRouter.qml b/crates/qbz-qt/qml/shell/ContentRouter.qml index b29ce1a74..000532a9d 100644 --- a/crates/qbz-qt/qml/shell/ContentRouter.qml +++ b/crates/qbz-qt/qml/shell/ContentRouter.qml @@ -44,6 +44,46 @@ Item { } } + // THE PAGE'S TAB, LIVE (2026-09-13). Any mounted view with an `activeTab` + // string that does not report a richer state of its own (Local Library + // does, and restores it itself) reports its tab on every change: nav_qt + // stamps it on the history entry, so Back/Forward land on the tab the + // user left, and page_restore_qt keeps it in the per-profile page + // document, so "Where you left off" reopens on it. Never during the + // two-phase route commit, when the item on screen is still the outgoing + // view while `currentView` already names the next one. + Connections { + target: viewLoader.item + ignoreUnknownSignals: true + function onActiveTabChanged() { root._reportTab() } + } + function _reportTab() { + var item = viewLoader.item + if (!item || root._armed || root.kiosk) + return + if (typeof item.activeTab !== "string" || typeof item.navigationStateJson === "string") + return + QbzShell.reportNavState(QbzShell.currentView, JSON.stringify({ "activeTab": item.activeTab })) + } + // The other direction: a Back/Forward destination whose entry carries a + // tab (armed by nav_qt::step before `currentView` is published) mounts + // on it. Views that restore their own state consume the arming + // themselves and are left alone. + function _restoreTab() { + var item = viewLoader.item + if (!item || root.kiosk || QbzShell.restoreStateScope === "") + return + if (QbzShell.restoreStateScope !== QbzShell.currentView) + return + if (typeof item.activeTab !== "string" || typeof item.restoreNavigationState === "function") + return + var saved = null + try { saved = JSON.parse(QbzShell.stateRestore) } catch (e) { saved = null } + QbzShell.restoreStateScope = "" + if (saved && typeof saved.activeTab === "string" && saved.activeTab !== "") + item.activeTab = saved.activeTab + } + // The mounted view. The replacement for AppShell's four `viewLoader.item` // references (multi-select: the two Ctrl+A / Escape routers and the two // duck-typed reporters) — a QML `id` is scoped to its own document, so @@ -595,6 +635,7 @@ Item { && typeof viewLoader.item.activeTab === "string" root._itemTabRequest = viewLoader.item !== null && typeof viewLoader.item.tabNavigationRequest !== "undefined" + root._restoreTab() } } diff --git a/crates/qbz-qt/src/artist_releases_qt.rs b/crates/qbz-qt/src/artist_releases_qt.rs index 1a751f072..927afc2c2 100644 --- a/crates/qbz-qt/src/artist_releases_qt.rs +++ b/crates/qbz-qt/src/artist_releases_qt.rs @@ -276,7 +276,14 @@ pub fn open(artist_id: String, artist_name: String, release_type: String) { // The route is a TWO-FILE contract (nav_qt.rs:9-16): this records the id, // AppShell.qml's Loader ternary mounts it. `QbzShell.navigateTo` is NOT // usable — it carries no payload and clears LAST_DETAIL (main.rs:1607). - crate::nav_qt::record("artistreleases"); + crate::nav_qt::record_with( + "artistreleases", + serde_json::json!({ + "artistId": &artist_id, + "artistName": &artist_name, + "releaseType": &release_type + }), + ); publish(); fetch(generation, artist_id, release_type, 0, true); } diff --git a/crates/qbz-qt/src/award_qt.rs b/crates/qbz-qt/src/award_qt.rs index b74892395..04b0afb86 100644 --- a/crates/qbz-qt/src/award_qt.rs +++ b/crates/qbz-qt/src/award_qt.rs @@ -484,7 +484,7 @@ pub fn open_award(award_id: String, fallback_name: String) { if id.is_empty() { return; } - crate::nav_qt::record("award"); + crate::nav_qt::record_with("award", serde_json::json!({ "id": &id, "name": &fallback_name })); let generation = with_state(|s| { s.generation = s.generation.wrapping_add(1); s.id = id.clone(); @@ -721,7 +721,7 @@ pub fn open_albums() { if id.is_empty() { return; } - crate::nav_qt::record("awardalbums"); + crate::nav_qt::record_with("awardalbums", serde_json::json!({ "id": &id, "name": &name })); let generation = with_state(|s| { s.generation = s.generation.wrapping_add(1); s.all_albums.clear(); diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 0fd46c632..ebaefafd0 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -293,11 +293,13 @@ impl qbz_bridge::QbzBridge { crate::settings_string(key.to_string(), value.to_string()); } - /// Purely bridge-local state — no crate handler, nothing to persist. The - /// Slint global is not persisted either: Settings always opens on Audio, - /// the section only has to survive a Loader unmount WITHIN a session. + /// Bridge-local state with one reader outside: the section only has to + /// survive a Loader unmount WITHIN a session (Settings opens on Audio), + /// and it rides to page_restore_qt so "Where you left off" reopens + /// Settings on the same section. pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) { if index == 11 && !crate::orbit_qt::enabled() { return; } + crate::page_restore_qt::note_args("settings", serde_json::json!({ "section": index })); self.as_mut().set_settings_section(index); } diff --git a/crates/qbz-qt/src/browse_qt.rs b/crates/qbz-qt/src/browse_qt.rs index 741b2d97c..a883ec441 100644 --- a/crates/qbz-qt/src/browse_qt.rs +++ b/crates/qbz-qt/src/browse_qt.rs @@ -151,7 +151,10 @@ pub fn open_discover_browse(endpoint: String, title: String) { log::warn!("[qbz-qt] open_discover_browse with an empty endpoint; ignoring"); return; } - crate::nav_qt::record("discoverbrowse"); + crate::nav_qt::record_with( + "discoverbrowse", + serde_json::json!({ "endpoint": &endpoint, "title": &title }), + ); let generation = { let Ok(mut s) = DISCOVER.lock() else { return; diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index f8f29f697..2d0726568 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -710,7 +710,7 @@ pub(crate) fn open_mix(kind: String) { return; } }; - crate::nav_qt::record("mix"); + crate::nav_qt::record_with("mix", serde_json::json!({ "kind": &kind })); let (title, subtitle) = mix_meta(&kind); let generation = with_mix(|s| { s.generation = s.generation.wrapping_add(1); diff --git a/crates/qbz-qt/src/label_qt.rs b/crates/qbz-qt/src/label_qt.rs index 9bf345f5f..8f164f96b 100644 --- a/crates/qbz-qt/src/label_qt.rs +++ b/crates/qbz-qt/src/label_qt.rs @@ -231,7 +231,7 @@ pub fn open_label(label_id: String) { log::warn!("[qbz-qt] open_label: not a Qobuz label id: {label_id}"); return; }; - crate::nav_qt::record("label"); + crate::nav_qt::record_with("label", serde_json::json!({ "id": id.to_string() })); let generation = { let Ok(mut s) = LABEL.lock() else { return; @@ -770,7 +770,7 @@ pub fn open_releases() { s.query.clear(); (s.releases_generation, id) }; - crate::nav_qt::record("labelreleases"); + crate::nav_qt::record_with("labelreleases", serde_json::json!({ "id": id.to_string() })); publish_releases(); fetch_releases(generation, id); } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 67219117d..b14b6614a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -290,6 +290,7 @@ mod disc_meta_qt; mod label_qt; mod local_media_info_qt; mod nav_qt; +mod page_restore_qt; mod now_playing; mod offline_fwd; mod output_labels; @@ -822,6 +823,11 @@ fn on_session_entered() { let ordinary_entry = nav_qt::shell_entry_view(); let logged_off = offline_fwd::engine().status().offline_session; let local_album = local_restore_qt::take_startup_album(); + // The exact page (page_restore_qt: a detail, a root's tab, a Settings + // section, a search). Consumed here even when another door wins, so a + // re-entry in the same process never replays it. `ordinary_entry` already + // names its entry view — the shell mounted that view at construction. + let page = page_restore_qt::take_startup_page(); let entry_view = if local_album.is_some() { "localalbum".to_string() } else if logged_off { @@ -841,6 +847,15 @@ fn on_session_entered() { } else if logged_off { let landing = settings_qt::local_landing_tab(kiosk_profile_qt::active()); navigate_to_tab("local", &landing); + } else if let Some(page) = page { + // Re-run the page's opener on the view the shell already mounted. A + // page that cannot come back (a catalog page while offline, a hidden + // Purchases surface, stale arguments) steps back onto the root that + // was seeded beneath it. + if !page_restore_qt::restore(&page) { + nav_qt::back(); + hydrate_view(&nav_qt::current_view()); + } } now_playing::publish_current(); // Phase 3: fetch Discover > Home (online sessions only — the offline @@ -1431,7 +1446,7 @@ pub(crate) fn open_album(album_id: String) { if offline_fwd::engine().status().is_offline() { return; } - nav_qt::record("album"); + nav_qt::record_with("album", serde_json::json!({ "id": &album_id })); *LAST_DETAIL.lock().unwrap() = ("album".to_string(), album_id.clone()); let runtime = app(); album_bridge::ui(move |mut b| { @@ -1465,7 +1480,7 @@ pub(crate) fn open_artist(artist_id: String) { if offline_fwd::engine().status().is_offline() { return; } - nav_qt::record("artist"); + nav_qt::record_with("artist", serde_json::json!({ "id": &artist_id })); *LAST_DETAIL.lock().unwrap() = ("artist".to_string(), artist_id.clone()); // Warm the Library feed in the background: the page's "In library" tab is // built off it, and on a cold session (or right after an account switch — @@ -1903,7 +1918,7 @@ pub(crate) fn open_playlist(playlist_id: String) { // QBZ as a player without Qobuz — refusing them while offline would gate // local files behind a network the user does not have. if local_playlist_qt::is_local_id(&playlist_id) { - nav_qt::record("playlist"); + nav_qt::record_with("playlist", serde_json::json!({ "id": &playlist_id })); ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); let runtime = app(); spawn(async move { @@ -1917,7 +1932,7 @@ pub(crate) fn open_playlist(playlist_id: String) { log::warn!("[qbz-qt] open_playlist: invalid id {playlist_id}"); return; }; - nav_qt::record("playlist"); + nav_qt::record_with("playlist", serde_json::json!({ "id": &playlist_id })); // Clear the previous playlist before the fetch — same stale-render as the // album and artist views had. ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index 2cc7ff804..fef26fc17 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -2325,7 +2325,7 @@ pub(crate) async fn load(runtime: &Arc>, id: String) /// Grid card click: push the route, then load. pub(crate) fn open(id: String) { - crate::nav_qt::record("mixtapedetail"); + crate::nav_qt::record_with("mixtapedetail", serde_json::json!({ "id": &id })); let runtime = crate::app(); crate::spawn(async move { load(&runtime, id).await; diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index f55796e79..448c3a640 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -32,16 +32,18 @@ //! only the `contextual` branch navigates; `weak`/`none` publish a document //! that a global overlay renders (`musician_qt.rs`). //! -//! `purchase-album` is in that same context-carrying class (its album id lives -//! in the purchases controller, not in the id), and `purchases` is left out of -//! the restore set for a DIFFERENT reason: it is opt-in and ships hidden -//! (`show_purchases`, default false), so restoring onto it could open the app -//! on a surface whose own entry point is switched off. Neither is in -//! `VIEW_TO_PREF` below, so `startup_view()` can never resolve to either — and -//! neither is offered by the Startup page dropdown, whose own list -//! (`settings_qt::STARTUP_PAGE_VALUES`) would have to grow a matching entry -//! first. The Slint build DOES restore `purchases` (`qbz/src/main.rs:637`); -//! bringing that across is an additive change that needs both halves. +//! `purchase-album` and `purchases` are not in `VIEW_TO_PREF` below either: +//! the album id lives in the purchases controller, and the surface is opt-in +//! (`show_purchases`, default false). Since 2026-09-13 page_restore_qt brings +//! both back — the controller notes the album id, and the restore checks the +//! opt-in first, so a hidden surface never reopens the app. +//! +//! WHERE YOU LEFT OFF, EXACTLY (2026-09-13): every route push goes through +//! `record` / `record_with` (the latter carries the opener's arguments, kept +//! on the entry), and page_restore_qt keeps the current page, its arguments +//! and its tab in a per-profile document. `startup_view()` answers with that +//! page's entry view, the history is seeded with the page's root, and the +//! session entry re-runs the opener. //! //! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the //! NavRail's fifth tile and the route the kiosk's full-screen player lives on. @@ -76,6 +78,10 @@ struct Entry { /// scroll scope, Rust stores and returns it without interpreting it. state_scope: String, state_json: String, + /// The opener's arguments (`record_with`), Null for a plain route push: + /// what page_restore_qt keeps for a restart, re-noted when a step lands + /// back on the entry. + args: serde_json::Value, } struct NavHistory { @@ -125,6 +131,9 @@ pub fn set_live_state(scope: &str, json: &str) { live.0.push_str(scope); live.1.clear(); live.1.push_str(json); + drop(live); + // The page's tab rides along to the page document (page_restore_qt). + crate::page_restore_qt::note_state(scope, json); } fn live_state() -> (String, String) { @@ -154,11 +163,12 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { // restored view with a history rooted at Home would offer a Back to a // page the user never opened this session. entries: vec![Entry { - view: startup_view(), + view: startup_root(), scope: String::new(), scroll: 0.0, state_scope: String::new(), state_json: String::new(), + args: serde_json::Value::Null, }], index: 0, }); @@ -192,11 +202,12 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { /// owner's own prefs today read `last_view = "local-library"`, written by Slint. /// Unknown values in either direction resolve to Home rather than guessing. /// -/// This root-only fallback does not carry detail IDs. Local albums now have -/// a separate per-profile context in local_restore_qt, restored at session -/// entry with a missing-entity fallback. Other details, Search and Settings are -/// excluded too — transient and config respectively, and reopening the app -/// inside either is a surprise, not "where you left off". +/// This root-only value carries no detail ids and never will: it is the +/// SHARED file. The exact page — the album, the artist, the playlist, the +/// search, the Settings section, and the tab a root view was on — lives in +/// page_restore_qt's per-profile document since 2026-09-13 (local albums in +/// local_restore_qt's), restored at session entry with this root seeded +/// beneath it and used as the fallback when the page cannot come back. const VIEW_TO_PREF: &[(&str, &str)] = &[ ("home", "home"), ("library", "favorites"), @@ -291,6 +302,12 @@ pub fn crash_level() -> u8 { /// on Home every time. That is the "renders, persists, drives nothing" defect /// class this port has a name for. pub fn startup_view() -> String { + // The exact page, when page_restore_qt has one to reopen (it applies the + // same "remember" and crash-chain gates, plus the launcher-link and kiosk + // ones): its entry view is what the shell mounts. + if let Some(view) = crate::page_restore_qt::startup_view() { + return view; + } if crate::settings_qt::pref_str("startup_page", "home") != "remember" { return "home".to_string(); } @@ -306,6 +323,12 @@ pub fn startup_view() -> String { resolved.to_string() } +/// The history seed: the root beneath a restored page (so Back leads there), +/// else the startup view itself. +fn startup_root() -> String { + crate::page_restore_qt::startup_root().unwrap_or_else(startup_view) +} + /// The view the SHELL should mount on at this session entry. /// /// `init_shell_for_user` is MULTI-ENTRY (login, session restore, "Start @@ -325,7 +348,14 @@ pub fn shell_entry_view() -> String { } pub fn record(view: &str) { - record_entry(view, false); + record_entry(view, false, serde_json::Value::Null); +} + +/// `record` for an opener that can rebuild its page from arguments: they +/// travel with the entry and into page_restore_qt's document. `args` is a +/// JSON object of plain values (an id, a name, a kind). +pub fn record_with(view: &str, args: serde_json::Value) { + record_entry(view, false, args); } /// A user-selected Local Library tab is a distinct history destination even @@ -356,12 +386,12 @@ fn record_view_tab(view: &str, tab: &str, state: &str) { return; } set_live_state(view, state); - record_entry(view, true); + record_entry(view, true, serde_json::Value::Null); next["activeTab"] = serde_json::json!(tab); set_live_state(view, &next.to_string()); } -fn record_entry(view: &str, force: bool) { +fn record_entry(view: &str, force: bool, args: serde_json::Value) { // Persist BEFORE the history mutation so the write reflects the view the // user actually reached, and only for the safe set — a detail view leaves // the stored value on the last safe root, which is exactly what the @@ -369,6 +399,8 @@ fn record_entry(view: &str, force: bool) { if let Some(pref) = pref_for_view(view) { crate::settings_qt::save_pref("last_view", serde_json::json!(pref)); } + // The exact page, for "Where you left off" (page_restore_qt). + crate::page_restore_qt::note_page(view, &args); let (can_back, can_forward, current, pushed) = with_history(|h| { let mut pushed = false; if force || h.entries[h.index].view != view { @@ -390,6 +422,7 @@ fn record_entry(view: &str, force: bool) { scroll: 0.0, state_scope: String::new(), state_json: String::new(), + args, }); h.index += 1; pushed = true; @@ -445,7 +478,7 @@ pub fn forward() { /// move the cursor, and hand the destination's saved position to the shell so /// its scroll container can pick it up when it lays out. fn step(delta: isize) { - let (can_back, can_forward, current, scope, scroll, state_scope, state_json) = + let (can_back, can_forward, current, scope, scroll, state_scope, state_json, args) = with_history(|h| { let next = h.index as isize + delta; if next >= 0 && (next as usize) < h.entries.len() { @@ -468,12 +501,16 @@ fn step(delta: isize) { e.scroll, e.state_scope.clone(), e.state_json.clone(), + e.args.clone(), ) }); // The destination is now the live page: seed the live pair with what it is // about to restore to, so leaving it again before it has reported anything // does not stamp the OUTGOING page's offset onto it. set_live_scroll(&scope, scroll); + // The destination is the page again (its arguments travel on the entry); + // note it BEFORE the state, so the tab lands on the right page. + crate::page_restore_qt::note_page(¤t, &args); set_live_state(&state_scope, &state_json); // Arm only for a position worth restoring. A page that was at the top // needs no restore, and arming for 0 would leave a scope standing until diff --git a/crates/qbz-qt/src/page_restore_qt.rs b/crates/qbz-qt/src/page_restore_qt.rs new file mode 100644 index 000000000..9a27505c6 --- /dev/null +++ b/crates/qbz-qt/src/page_restore_qt.rs @@ -0,0 +1,650 @@ +//! Where the user left off — THE PAGE, not just its section (2026-09-13). +//! +//! `nav_qt`'s `last_view` pref remembers the last ROOT view, in the vocabulary +//! the Slint build shares through `ui_prefs.json`, so "Startup page = Where +//! you left off" could only ever reopen on Home, Library, Local Library, +//! Mixtapes or Collections: a session closed on an album came back on +//! Library > Releases. The rule now is the page the user was on — with its +//! arguments (the album, the artist, the playlist, the search query, the +//! Settings section…) and the tab it was showing. +//! +//! Two layers, both written as the user moves: +//! - `last_view` (`nav_qt::record_entry`) keeps its meaning and its shared +//! vocabulary: the last root, which is also the history seeded UNDER a +//! restored page (so Back has somewhere to go) and the fallback when the +//! page cannot come back. +//! - this module's `last_page_qt.json`, per profile beside +//! `local_navigation_qt.json`: `{"page": {root, view, args, state}}`. +//! `view` and `args` come from `nav_qt::record_with` — every restorable +//! opener records its own arguments — or from `note_args` for the pages +//! whose arguments live outside their route push (the Settings section, +//! the search query, the purchased album); `state` is the tab the mounted +//! view reported through `QbzShell.reportNavState`. +//! +//! The restore is ONE-SHOT per process and honours the gates the local album +//! restore honours: the pref must say "remember", a crash chain bypasses it +//! (nav_qt's ladder), an explicit launcher link outranks it, and the kiosk +//! keeps its own navigation model. The shell mounts the page's view at +//! construction (no Home flash), the history is seeded with the page's root, +//! and `restore()` re-runs the opener at session entry. A page that needs the +//! catalog is skipped while the session is offline, Purchases stays behind its +//! opt-in, and a page that cannot come back lands on the root instead. +//! +//! Local albums stay with `local_restore_qt`, which owns their filter context: +//! this module never stores `localalbum`. Leaving the app on a page that only +//! its opener could rebuild (`scene`, `musician`, `discobuilder`, +//! `metadataeditor`) keeps the previous restorable page in the document, so +//! the restart lands one step back rather than on Home. + +use serde_json::{json, Map, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +const FILE: &str = "last_page_qt.json"; +const MAX_ARGS_BYTES: usize = 8 * 1024; +const MAX_STATE_BYTES: usize = 64 * 1024; + +/// Routes whose page can be rebuilt from what is stored. Everything else in +/// nav_qt's id list is left out on purpose (see the module header). +const RESTORABLE: &[&str] = &[ + "home", + "library", + "local", + "mixtapes", + "collections", + "album", + "artist", + "playlist", + "label", + "labelreleases", + "mixtapedetail", + "award", + "awardalbums", + "mix", + "artistreleases", + "discoverbrowse", + "playlistbrowse", + "recentalbums", + "mostplayedalbums", + "purchases", + "purchase-album", + "settings", + "search", + "queue-view", + "blacklist", + "playlistmanager", + "offlinemanager", + "libraryfolders", +]; + +/// Pages that cannot show anything without the catalog: skipped while the +/// session is offline (a Qobuz playlist joins them at restore time; a local +/// one does not). +const NEEDS_CATALOG: &[&str] = &[ + "album", + "artist", + "label", + "labelreleases", + "award", + "awardalbums", + "mix", + "artistreleases", + "discoverbrowse", + "playlistbrowse", + "purchases", + "purchase-album", + "search", +]; + +/// The roots nav_qt persists as `last_view` (its `VIEW_TO_PREF` set). +const ROOTS: &[&str] = &["home", "library", "local", "mixtapes", "collections"]; + +/// The persisted page. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Page { + /// The last root view: the history seed and the fallback. + #[serde(default)] + pub root: String, + /// The route id the user was on. + #[serde(default)] + pub view: String, + /// The opener's arguments. + #[serde(default)] + pub args: Map, + /// The tab the mounted view reported, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option>, +} + +impl Page { + fn valid(&self) -> bool { + RESTORABLE.contains(&self.view.as_str()) + && serde_json::to_string(&self.args).is_ok_and(|s| s.len() <= MAX_ARGS_BYTES) + && self + .state + .as_ref() + .is_none_or(|s| serde_json::to_string(s).is_ok_and(|t| t.len() <= MAX_STATE_BYTES)) + } + + /// The view the shell records at session entry. The two listings that + /// read their parent's state come back THROUGH that parent — the entry + /// records it, `restore` opens the parent and then the listing, and the + /// history reads root -> parent -> listing, the way it was reached. + pub fn entry_view(&self) -> &str { + match self.view.as_str() { + "labelreleases" => "label", + "awardalbums" => "award", + other => other, + } + } + + fn arg(&self, key: &str) -> String { + self.args + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + } + + fn arg_i64(&self, key: &str) -> i64 { + self.args.get(key).and_then(Value::as_i64).unwrap_or(0) + } + + fn tab(&self) -> Option { + self.state + .as_ref() + .and_then(|s| s.get("activeTab")) + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .map(str::to_string) + } +} + +// --------------------------------------------------------------------------- +// The live document (pure — the globals below wrap it) +// --------------------------------------------------------------------------- + +/// What the process currently believes the page is, plus arguments noted +/// for a route that has not been recorded yet (`note_args` before the QML +/// side pushes the route — the purchased album). +#[derive(Default)] +struct Live { + page: Option, + pending: Option<(String, Map)>, +} + +impl Live { + /// A route was recorded (`nav_qt::record_entry` / `step`). Returns the + /// page to persist when something changed. + fn note_page(&mut self, view: &str, args: &Value) -> Option { + let page = self.page.get_or_insert_with(Page::default); + let before = page.clone(); + if ROOTS.contains(&view) { + page.root = view.to_string(); + } + if RESTORABLE.contains(&view) { + let pending = self + .pending + .take() + .filter(|(pending_view, _)| pending_view == view) + .map(|(_, args)| args); + let explicit = match args { + Value::Object(map) if !map.is_empty() => Some(map.clone()), + _ => None, + }; + page.view = view.to_string(); + page.args = explicit.or(pending).unwrap_or_default(); + page.state = None; + } + (*page != before).then(|| page.clone()) + } + + /// Arguments for a route: applied in place when it is the current page, + /// otherwise kept for its next record. + fn note_args(&mut self, view: &str, args: Map) -> Option { + if serde_json::to_string(&args).is_ok_and(|s| s.len() > MAX_ARGS_BYTES) { + return None; + } + if let Some(page) = self.page.as_mut() { + if page.view == view { + if page.args == args { + return None; + } + page.args = args; + return Some(page.clone()); + } + } + self.pending = Some((view.to_string(), args)); + None + } + + /// The mounted view reported its state: only the tab is restart + /// material (filters and selections are history material, and Local + /// Library persists its own richer set). + fn note_state(&mut self, scope: &str, state: &str) -> Option { + if state.len() > MAX_STATE_BYTES { + return None; + } + let page = self.page.as_mut()?; + if page.view != scope { + return None; + } + let tab = serde_json::from_str::(state) + .ok() + .and_then(|v| v.get("activeTab").cloned()) + .filter(|t| t.as_str().is_some_and(|s| !s.is_empty())); + let next = tab.map(|t| Map::from_iter([("activeTab".to_string(), t)])); + if page.state == next { + return None; + } + page.state = next; + Some(page.clone()) + } +} + +static LIVE: Mutex = Mutex::new(Live { + page: None, + pending: None, +}); +static WRITE: Mutex<()> = Mutex::new(()); +static STARTUP: OnceLock> = OnceLock::new(); +static TAKEN: AtomicBool = AtomicBool::new(false); + +fn path() -> Option { + use qbz_app::user_data::UserDataPaths; + // nav_qt's tests push routes through the real `record`: they must never + // write into the developer's profile. + if cfg!(test) { + return None; + } + // Same identity as local_restore_qt, including the guest profile. + let user = UserDataPaths::load_last_user_id().unwrap_or(0); + Some(UserDataPaths::data_dir_for(user).ok()?.join(FILE)) +} + +fn save_at(path: &Path, page: &Page) { + let _guard = WRITE.lock().unwrap_or_else(|e| e.into_inner()); + let Some(mut doc) = crate::settings_qt::read_json_object(path) else { + return; + }; + let next = json!(page); + if doc.get("page") != Some(&next) { + doc.insert("page".into(), next); + crate::settings_qt::write_json_object_atomic(path, &doc); + } +} + +fn load_at(path: &Path) -> Option { + let doc = crate::settings_qt::read_json_object(path)?; + let page: Page = serde_json::from_value(doc.get("page")?.clone()).ok()?; + page.valid().then_some(page) +} + +fn persist(page: Option) { + if let (Some(page), Some(path)) = (page, path()) { + save_at(&path, &page); + } +} + +fn live(f: impl FnOnce(&mut Live) -> R) -> R { + f(&mut LIVE.lock().unwrap_or_else(|e| e.into_inner())) +} + +// --------------------------------------------------------------------------- +// Writers (nav_qt, the bridges) +// --------------------------------------------------------------------------- + +/// A route reached the top of the history (record) or the cursor moved onto +/// it (back/forward). `args` is the opener's argument object, or Null. +pub fn note_page(view: &str, args: &Value) { + if crate::kiosk_profile_qt::active() { + return; + } + persist(live(|l| l.note_page(view, args))); +} + +/// Arguments for a route that does not carry them on its route push. +pub fn note_args(view: &str, args: Value) { + if crate::kiosk_profile_qt::active() { + return; + } + let Value::Object(args) = args else { return }; + persist(live(|l| l.note_args(view, args))); +} + +/// The mounted view's live state (`nav_qt::set_live_state`). +pub fn note_state(scope: &str, state: &str) { + if crate::kiosk_profile_qt::active() { + return; + } + persist(live(|l| l.note_state(scope, state))); +} + +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +fn startup_page_at( + path: &Path, + remember: bool, + crash_level: u8, + link: bool, + kiosk: bool, +) -> Option { + if !remember || crash_level >= 2 || link || kiosk { + return None; + } + load_at(path) +} + +/// The page this process opens on, decided ONCE (the shell bridge asks at +/// construction, the history seed and the session entry ask later, and all +/// three must agree). `None` = the ordinary `last_view` startup. +fn startup_page() -> Option<&'static Page> { + STARTUP + .get_or_init(|| { + let path = path()?; + let page = startup_page_at( + &path, + crate::settings_qt::pref_str("startup_page", "home") == "remember", + crate::nav_qt::crash_level(), + crate::deep_link_qt::has_pending(), + crate::kiosk_profile_qt::active(), + )?; + log::info!( + "[qbz-qt] startup: remember -> page {:?} args {} tab {:?} (root {:?})", + page.view, + Value::Object(page.args.clone()), + page.tab(), + page.root + ); + Some(page) + }) + .as_ref() +} + +/// The view the shell mounts at construction and records at session entry. +pub fn startup_view() -> Option { + startup_page().map(|p| p.entry_view().to_string()) +} + +/// The root seeded beneath the restored page, so Back leads somewhere. +pub fn startup_root() -> Option { + startup_page() + .map(|p| p.root.clone()) + .filter(|root| ROOTS.contains(&root.as_str())) +} + +/// Only the first session entry of this process restores; a launcher link +/// that arrived after the shell was built still outranks the page. +pub fn take_startup_page() -> Option { + if TAKEN.swap(true, Ordering::AcqRel) { + return None; + } + if crate::deep_link_qt::has_pending() || crate::kiosk_profile_qt::active() { + return None; + } + startup_page().cloned() +} + +/// Re-run the page's opener at session entry. The entry view is already +/// recorded and hydrated by the caller; a detail's opener records the same +/// route again (a no-op push) and loads its document. `false` = the page +/// cannot come back and the caller lands on the root instead. +pub fn restore(page: &Page) -> bool { + let status = crate::offline_fwd::engine().status(); + let offline = status.is_offline(); + let id = page.arg("id"); + let needs_catalog = NEEDS_CATALOG.contains(&page.view.as_str()) + || (page.view == "playlist" && !crate::local_playlist_qt::is_local_id(&id)); + if offline && needs_catalog { + log::info!( + "[qbz-qt] startup: page {:?} needs the catalog and the session is offline; landing on the root", + page.view + ); + return false; + } + let with_id = |open: &dyn Fn(String)| -> bool { + if id.is_empty() { + return false; + } + open(id.clone()); + true + }; + match page.view.as_str() { + "home" | "library" => { + if let Some(tab) = page.tab() { + crate::navigate_to_tab(&page.view, &tab); + } + true + } + // Local Library restores its own tab (local_restore_qt); the MyQBZ + // grids were hydrated with the entry. + "local" | "mixtapes" | "collections" => true, + "album" => with_id(&crate::open_album), + "artist" => with_id(&crate::open_artist), + "playlist" => with_id(&crate::open_playlist), + "label" => with_id(&crate::label_qt::open_label), + "labelreleases" => { + if !with_id(&crate::label_qt::open_label) { + return false; + } + crate::label_qt::open_releases(); + true + } + "mixtapedetail" => with_id(&crate::myqbz_detail_qt::open), + "award" => { + let name = page.arg("name"); + with_id(&|id| crate::award_qt::open_award(id, name.clone())) + } + "awardalbums" => { + let name = page.arg("name"); + if !with_id(&|id| crate::award_qt::open_award(id, name.clone())) { + return false; + } + crate::award_qt::open_albums(); + true + } + "mix" => { + let kind = page.arg("kind"); + if kind.is_empty() { + return false; + } + crate::foryou_qt::open_mix(kind); + true + } + "artistreleases" => { + let artist_id = page.arg("artistId"); + if artist_id.is_empty() { + return false; + } + crate::artist_releases_qt::open( + artist_id, + page.arg("artistName"), + page.arg("releaseType"), + ); + true + } + "discoverbrowse" => { + let endpoint = page.arg("endpoint"); + if endpoint.is_empty() { + return false; + } + crate::browse_qt::open_discover_browse(endpoint, page.arg("title")); + true + } + "playlistbrowse" => { + crate::browse_qt::open_playlist_browse(); + true + } + "recentalbums" => { + crate::browse_qt::open_recent_albums(); + true + } + "mostplayedalbums" => { + crate::browse_qt::open_most_played_albums(); + true + } + // Opt-in surface: never reopen the app on a page whose own entry + // point is switched off. The list loads itself on mount. + "purchases" => crate::settings_qt::pref_bool("show_purchases", false), + "purchase-album" => { + if !crate::settings_qt::pref_bool("show_purchases", false) { + return false; + } + with_id(&crate::purchases_qt::open_album) + } + "settings" => { + // Same guard as the bridge's `settings_set_section`. + let section = page.arg_i64("section").clamp(0, 64) as i32; + let section = if section == 11 && !crate::orbit_qt::enabled() { + 0 + } else { + section + }; + crate::ui(move |mut b| b.as_mut().set_settings_section(section)); + true + } + "search" => { + let query = page.arg("query"); + if query.chars().count() < 2 { + return false; + } + let tab = page.arg_i64("tab").clamp(0, 16) as i32; + let runtime = crate::app(); + crate::spawn(async move { + crate::search_qt::submit(&runtime, &query, Some(tab)).await; + }); + true + } + // Mounted by the entry; reads live bridges or refreshes itself. + "queue-view" | "libraryfolders" => true, + "blacklist" => { + crate::blacklist_qt::open_manager(); + true + } + "playlistmanager" => { + crate::playlist_manager_qt::navigate(); + true + } + "offlinemanager" => { + crate::offline_manager_qt::open(); + true + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(view: &str) -> Page { + Page { + root: "library".into(), + view: view.into(), + args: Map::new(), + state: None, + } + } + + #[test] + fn a_detail_keeps_its_arguments_and_a_root_becomes_the_fallback() { + let mut live = Live::default(); + assert!(live.note_page("library", &Value::Null).is_some()); + let album = live + .note_page("album", &json!({"id": "123"})) + .expect("the page changed"); + assert_eq!(album.view, "album"); + assert_eq!(album.root, "library"); + assert_eq!(album.args.get("id").and_then(Value::as_str), Some("123")); + assert_eq!(album.entry_view(), "album"); + // The same push again changes nothing. + assert!(live.note_page("album", &json!({"id": "123"})).is_none()); + // A page only its opener could rebuild keeps the album on record. + assert!(live.note_page("scene", &Value::Null).is_none()); + assert_eq!(live.page.as_ref().unwrap().view, "album"); + // Back onto Home: root and view move together. + let home = live.note_page("home", &Value::Null).unwrap(); + assert_eq!((home.root.as_str(), home.view.as_str()), ("home", "home")); + assert!(home.args.is_empty()); + } + + #[test] + fn arguments_noted_before_the_route_push_ride_that_push() { + let mut live = Live::default(); + live.note_page("purchases", &Value::Null); + assert!(live + .note_args( + "purchase-album", + json!({"id": "9"}).as_object().unwrap().clone() + ) + .is_none()); + let detail = live.note_page("purchase-album", &Value::Null).unwrap(); + assert_eq!(detail.args.get("id").and_then(Value::as_str), Some("9")); + // On the current page they apply in place; unchanged ones are quiet. + live.note_page("settings", &Value::Null); + let settings = live + .note_args( + "settings", + json!({"section": 4}).as_object().unwrap().clone(), + ) + .unwrap(); + assert_eq!(settings.arg_i64("section"), 4); + assert!(live + .note_args( + "settings", + json!({"section": 4}).as_object().unwrap().clone() + ) + .is_none()); + } + + #[test] + fn only_the_tab_of_the_current_page_is_kept_from_a_state_report() { + let mut live = Live::default(); + live.note_page("library", &Value::Null); + let tabbed = live + .note_state("library", r#"{"activeTab":"albums","filter":"x"}"#) + .unwrap(); + assert_eq!(tabbed.tab().as_deref(), Some("albums")); + assert!(tabbed.state.as_ref().unwrap().get("filter").is_none()); + assert!(live + .note_state("home", r#"{"activeTab":"forYou"}"#) + .is_none()); + assert!(live + .note_state("library", "not json") + .is_some_and(|p| p.state.is_none())); + // A new page starts without a tab. + let album = live.note_page("album", &json!({"id": "1"})).unwrap(); + assert!(album.state.is_none()); + } + + #[test] + fn the_document_round_trips_and_the_gates_bypass_it_without_deleting_it() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("users/1/last_page_qt.json"); + let mut saved = page("labelreleases"); + saved.args.insert("id".into(), json!("77")); + save_at(&file, &saved); + assert_eq!( + startup_page_at(&file, true, 1, false, false), + Some(saved.clone()) + ); + assert_eq!(saved.entry_view(), "label"); + for (remember, crash, link, kiosk) in [ + (false, 1, false, false), + (true, 2, false, false), + (true, 1, true, false), + (true, 1, false, true), + ] { + assert!(startup_page_at(&file, remember, crash, link, kiosk).is_none()); + } + assert_eq!(startup_page_at(&file, true, 1, false, false), Some(saved)); + // An unrestorable or malformed page is ignored, never crashed on. + for bad in [json!({"view": "scene"}), json!({"view": 7}), json!("album")] { + std::fs::write(&file, json!({"page": bad}).to_string()).unwrap(); + assert!(startup_page_at(&file, true, 1, false, false).is_none()); + } + } +} diff --git a/crates/qbz-qt/src/purchases_qt.rs b/crates/qbz-qt/src/purchases_qt.rs index a6a7becab..0f905e3ba 100644 --- a/crates/qbz-qt/src/purchases_qt.rs +++ b/crates/qbz-qt/src/purchases_qt.rs @@ -2366,6 +2366,9 @@ fn begin_open_album(album_id: String, verified_entitlement: Option