feat(qt): "Where you left off" reopens the exact page, its arguments and its tab

The startup pref only knew the last ROOT view: `last_view` lives in the
ui_prefs.json the Slint build shares, whose vocabulary has no room for a
detail, so a session closed on an album came back on Library > Releases.

page_restore_qt keeps the page itself in a per-profile document
(last_page_qt.json: root, view, args, state), written as the user moves.
Openers that can rebuild their page record their arguments through the
new nav_qt::record_with (album, artist, playlist, label and its releases,
mixtape/collection detail, award and its albums, mix, artist releases,
discover browse); the Settings section, the search query and tab and the
purchased album are noted where they live; the mounted view's tab comes
from QbzShell.reportNavState. At startup the shell mounts that page's
view (no Home flash), the history is seeded with the page's root so Back
leads there, and the session entry re-runs the opener — one-shot, behind
the same gates as the local album restore (remember pref, crash chain,
launcher link, kiosk), skipping catalog pages while offline and Purchases
unless it is switched on. Pages only their opener could rebuild (scene,
musician, disco builder, metadata editor) keep the previous restorable
page on record; local albums stay with local_restore_qt.

ContentRouter now reports the active tab of any view that has one and no
richer state of its own (Discover, Library) and applies a Back/Forward
destination's saved tab, so tabs survive both a restart and the history.

cc
This commit is contained in:
vicrodh committed 2026-09-13 23:56:10 -06:00
1 parent dae03e3834
commit e278d931b7
13 files changed
+801 -35

No files matched your search

+41
View File
@@ -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()
}
}
+8 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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();
+5 -3
View File
@@ -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);
}
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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);
}
+19 -4
View File
@@ -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("{}")));
+1 -1
View File
@@ -2325,7 +2325,7 @@ pub(crate) async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, 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;
+57 -20
View File
@@ -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<R>(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<R>(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(&current, &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
+650
View File
@@ -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<String, Value>,
/// The tab the mounted view reported, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<Map<String, Value>>,
}
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<String> {
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<Page>,
pending: Option<(String, Map<String, Value>)>,
}
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<Page> {
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<String, Value>) -> Option<Page> {
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<Page> {
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::<Value>(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<Live> = Mutex::new(Live {
page: None,
pending: None,
});
static WRITE: Mutex<()> = Mutex::new(());
static STARTUP: OnceLock<Option<Page>> = OnceLock::new();
static TAKEN: AtomicBool = AtomicBool::new(false);
fn path() -> Option<PathBuf> {
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<Page> {
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<Page>) {
if let (Some(page), Some(path)) = (page, path()) {
save_at(&path, &page);
}
}
fn live<R>(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<Page> {
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<String> {
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<String> {
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<Page> {
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());
}
}
}
+3
View File
@@ -2366,6 +2366,9 @@ fn begin_open_album(album_id: String, verified_entitlement: Option<PurchaseAlbum
if album_id.is_empty() {
return;
}
// The route is pushed by the QML caller afterwards; the id waits for it
// (page_restore_qt keeps the pair for "Where you left off").
crate::page_restore_qt::note_args("purchase-album", serde_json::json!({ "id": &album_id }));
let generation = with_detail(|s| {
s.generation = next_generation();
s.loading = true;
+8
View File
@@ -2417,6 +2417,10 @@ async fn submit_page(
*page = PageState::default();
page.doc.query = q.clone();
page.doc.tab = tab.unwrap_or(0);
crate::page_restore_qt::note_args(
"search",
serde_json::json!({ "query": &q, "tab": page.doc.tab }),
);
page.doc.loading = true;
page.doc.local_revision = version.to_string();
publish_page(&page.doc);
@@ -2744,6 +2748,10 @@ pub fn tab_changed(tab: i32) {
let mut guard = PAGE.lock().unwrap();
if let Some(page) = guard.as_mut() {
page.doc.tab = tab;
crate::page_restore_qt::note_args(
"search",
serde_json::json!({ "query": &page.doc.query, "tab": tab }),
);
let doc = page.doc.clone();
drop(guard);
publish_page(&doc);