From a31894e0dc84f1d34d9129fa7dbe91a6703e659c Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:41:44 +0000 Subject: [PATCH] fix(catalog): align Alliance library loading with Mac contracts --- docs/alliance-library-audit.md | 110 ++++ docs/core-protocol.md | 25 + locales/en.json | 3 + native/opennow-core/Cargo.lock | 193 +++++++ native/opennow-core/Cargo.toml | 2 + .../opennow-core/src/account_connections.rs | 5 +- ...{store_catalog_page.rs => catalog_page.rs} | 16 +- native/opennow-core/src/cloudmatch.rs | 5 +- native/opennow-core/src/gfn.rs | 256 +++++----- native/opennow-core/src/gfn_catalog_tests.rs | 476 ++++++++++++++++++ native/opennow-core/src/main.rs | 4 +- native/opennow-core/src/persistent_storage.rs | 5 +- native/opennow-core/src/store_cache.rs | 4 +- native/opennow-core/src/store_index.rs | 6 +- opennow-qt/cmake/Tests.cmake | 21 + .../desktop/library/DesktopLibraryScreen.qml | 37 +- opennow-qt/qml/screens/LibraryScreen.qml | 57 ++- opennow-qt/qml/state/ShellStore.qml | 12 +- opennow-qt/qml/state/catalog/CatalogState.qml | 119 ++++- opennow-qt/src/acceptance/SmokeAcceptance.cpp | 5 + opennow-qt/tests/LibraryErrorAcceptance.qml | 82 +++ .../tests/catalogpaging/tst_librarypaging.qml | 316 ++++++++++++ opennow-qt/tests/tst_catalogpaging.cpp | 3 + 23 files changed, 1596 insertions(+), 166 deletions(-) create mode 100644 docs/alliance-library-audit.md rename native/opennow-core/src/{store_catalog_page.rs => catalog_page.rs} (93%) create mode 100644 native/opennow-core/src/gfn_catalog_tests.rs create mode 100644 opennow-qt/tests/LibraryErrorAcceptance.qml create mode 100644 opennow-qt/tests/catalogpaging/tst_librarypaging.qml create mode 100644 opennow-qt/tests/tst_catalogpaging.cpp diff --git a/docs/alliance-library-audit.md b/docs/alliance-library-audit.md new file mode 100644 index 000000000..6f899be69 --- /dev/null +++ b/docs/alliance-library-audit.md @@ -0,0 +1,110 @@ +# Alliance account-library audit + +This audit compares the Qt/Rust client at `a41fb3d5` with OpenNOW-Mac at +`7327dd7`. It covers provider selection, credentials, catalog routing, ownership, +and delivery to the library UI. The separate RTSPS Transport investigation is +not part of this library change. + +## Findings and implementation + +### Library delivery can exceed the Qt protocol limit + +`GfnService::library_catalog` previously combined up to 2,000 mapped games in +one result, with Qt requesting 1,000. `CoreClient::processStdout` rejects a JSON +line larger than 1 MiB. The Rust RPC log records success before Qt receives that +line, so an `outcome=ok` entry does not establish that the library rendered. +Mac does not have this process-message limit and walks its library pages within +the application. + +The core now returns complete cursor pages capped at 100 games and 768 KiB, +using the same bounded-page implementation as Store. Oversized pages are +refetched at the original cursor with a smaller count. Qt appends pages +progressively, retains the search, rejects cursor cycles, and cancels obsolete +requests when the account or core connection changes. A partial failure remains +an error rather than becoming a complete-looking library. + +### Provider metadata and catalog requests could use different routes + +Mac's `OPNGameService+Provider.swift` uses the configured control-plane session +for `/v2/serverInfo` and prefers top-level `vpcId` or `vpc_id`, then +`requestStatus.serverId`. The core previously read only `serverId`, bypassed the +configured proxy for this lookup, and cached its result without a proxy scope. + +The core now follows the Mac field precedence and uses the configured route for +discovery. Its metadata cache separates provider, account, token, and proxy +route. Subscription requests use that route too. The existing server-ID +fallback and endpoint allowlist remain intact. + +Public, unauthenticated probes during this audit returned only +`requestStatus.serverId` for NVIDIA, ABYA, and Turkcell. Their service URLs +passed the existing allowlist. Those observations do **not** establish that the +missing top-level VPC support caused this tester's failure, and no provider +hostname restriction was weakened. + +### Mapping discarded some server-provided library data + +Mac's `OPNGameService+Parsing.swift` accepts integer IDs and treats a selected +variant, or a nonempty status other than `NOT_OWNED`, as owned. The core accepted +only JSON string IDs and three exact ownership status strings. + +The shared catalog mapper now preserves integer IDs as strings and matches the +Mac ownership rule, including whitespace/case normalization for the ownership +decision. It retains the original status value. Public unauthenticated NVIDIA +queries returned string IDs; integer-ID handling is compatibility coverage, +not a claim about the tester's unseen payload. + +### Library success did not require a valid apps response + +Mac's page fetcher fails with `No apps data` when the apps object is missing. +The core silently treated missing/null apps or items as an empty library. It +also treated absent pagination as the end of the result. + +The core now requires a games array and valid pagination. Credential-free page +counts distinguish upstream rows, mapped rows, and rows left after search +without logging game titles, queries, cursors, or credentials. + +### ID-token expiry was independent of the tracked access-token expiry + +Mac validates ID-token expiry independently. The core refreshed only against +its access-token lifetime while preferring the ID token for authenticated +requests. It could also preserve an expired ID token when refresh omitted a +replacement. + +The core now includes known ID-token expiry in its refresh decision, avoids +sending an expired ID token when a usable access token remains, and drops an +expired ID token omitted by refresh. Catalog, CloudMatch, linked accounts, and +storage share this token-selection rule. It does not change server-side token +verification or treat decoded JWT claims as authorization. + +## Paths that already matched + +- Both clients use the same device-code OAuth client and scope, and prefer the + ID token for GFN catalog authorization. +- Both send `GFNJWT` authorization to `games.geforce.com/graphql`, using the + same NVIDIA client ID and version. +- Both use the library filter + `variants.gfn.library.status.notEquals = NOT_OWNED`. +- The library POST does not need an Alliance-specific user ID, external ID, or + `huId`. No speculative identity headers or public-catalog fallback were added. + +The Mac client can reconcile provider identity from returned session claims; +the core retains the provider selected for device authorization. This remains +an audit lead, not a demonstrated mismatch in the supplied logs. Changing +provider identity without an affected session capture would be speculative. + +## Verification and remaining limit + +`gfn_catalog_tests.rs` exercises a local HTTPS provider and library endpoint +with generated, in-memory certificates. It checks VPC precedence, authenticated +request fields, paging and encoded-size limits, mapping, malformed responses, +proxy isolation, and ID-token refresh. The Qt `qml-library-paging` test covers +progressive loading, empty filtered pages, cancellation, errors, and bounds. + +Run the core tests with `cargo test --locked --manifest-path +native/opennow-core/Cargo.toml`; run the registered Qt test with +`ctest --test-dir build/opennow-qt -R qml-library-paging --output-on-failure`. + +The tester's authenticated library response has not been captured. These are +demonstrated client defects and reference-parity fixes, not proof that the +affected Alliance account now loads or that its stream can start. Live +validation still requires that account on the tester's machine. diff --git a/docs/core-protocol.md b/docs/core-protocol.md index 385691ceb..03cf3bc35 100644 --- a/docs/core-protocol.md +++ b/docs/core-protocol.md @@ -60,6 +60,31 @@ the shell falls back to `imageUrl`, then `heroImageUrl`. Older cached objects an public-catalog games may omit `keyArtUrl`; it is an optional, additive field in protocol 1 and does not change the handshake or existing artwork fields. +### Account library pagination (`catalog.libraryPages.v1`) + +`catalog.library.list` returns one complete upstream page, not the whole account +library. The capability advertises this contract; the protocol-1 envelope and +1 MiB line limit are unchanged. The Qt shell +and core must be deployed together for this paged contract. + +Request: `{ "limit":100, "cursor":"", "searchQuery":"" }`. The limit defaults +to 100 and is capped at 100; cursor and search limits match Store pagination +below. Response: `{ "games":[], "count":0, "totalCount":0, +"hasNextPage":false, "nextCursor":"", "source":"account-library", +"fetchedAt":0 }`. + +Keep the search unchanged and pass `nextCursor` to the next request while +`hasNextPage` is true. Search is applied to each mapped account-library page, so +an empty filtered page can still have a continuation. `totalCount` is the +upstream account-library count before that local search filter. + +Library and Store share the 768 KiB encoded result budget and same-cursor +oversize retry policy. Missing apps/items or invalid pagination are errors, +not an empty library. The shell appends and deduplicates pages progressively, +cancels them on account changes/disconnection, and reports an error if a cursor +cycles or the 150-page limit is reached before completion. Partial games remain +visible alongside the error; they are not presented as a completed library. + ### Store pagination (`catalog.storePages.v1`) The protocol-1 envelope and 1 MiB limit are unchanged. This additive capability diff --git a/locales/en.json b/locales/en.json index 2a2fff4a7..3fb8bc41a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3140,6 +3140,9 @@ "s_qt_store_page_04": "Loaded %1 games. %2", "s_qt_store_page_05": "Loaded %1 of %2 games", "s_qt_store_page_06": "Load more", + "s_qt_library_page_01": "Could not start the library request. Try again.", + "s_qt_library_page_02": "The library returned an invalid page. Try again.", + "s_qt_library_page_03": "The library exceeded the page limit. Some games may be missing. Try again.", "s_qt_store_direct_launch": "Direct launch", "s_qt_membership_launch_unavailable": "Membership details unavailable. Please try again.", "s_qt_membership_launch_required": "This game requires a paid GeForce NOW membership.", diff --git a/native/opennow-core/Cargo.lock b/native/opennow-core/Cargo.lock index 8fd725b4a..ce613b989 100644 --- a/native/opennow-core/Cargo.lock +++ b/native/opennow-core/Cargo.lock @@ -46,6 +46,45 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -416,6 +455,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -455,6 +500,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1070,6 +1138,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -1146,6 +1220,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -1180,6 +1260,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num" version = "0.4.3" @@ -1213,6 +1303,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.47" @@ -1252,6 +1348,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1272,8 +1377,10 @@ dependencies = [ "md5", "qrcode", "rand 0.9.5", + "rcgen", "regex", "reqwest", + "rustls", "scrypt", "semver", "serde", @@ -1385,6 +1492,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1580,6 +1693,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "regex" version = "1.13.1" @@ -1679,6 +1805,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2065,6 +2200,37 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2660,6 +2826,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -2670,6 +2854,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/native/opennow-core/Cargo.toml b/native/opennow-core/Cargo.toml index e60ab7215..fc285a500 100644 --- a/native/opennow-core/Cargo.toml +++ b/native/opennow-core/Cargo.toml @@ -32,6 +32,8 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Securit [dev-dependencies] tempfile = "3" +rcgen = { version = "0.14", default-features = false, features = ["crypto", "ring"] } +rustls = { version = "0.23", default-features = false, features = ["std", "ring", "tls12"] } [profile.release] lto = "thin" diff --git a/native/opennow-core/src/account_connections.rs b/native/opennow-core/src/account_connections.rs index 2ad52f05d..e2d9ccf37 100644 --- a/native/opennow-core/src/account_connections.rs +++ b/native/opennow-core/src/account_connections.rs @@ -465,10 +465,7 @@ fn base_headers() -> Result { } fn session_token(auth: &AuthSession) -> Result<&str, ServiceError> { - auth.tokens - .id_token - .as_deref() - .or(Some(auth.tokens.access_token.as_str())) + Some(auth.tokens.session_token()) .filter(|value| !value.is_empty()) .ok_or_else(|| ServiceError { code: "authentication_required", diff --git a/native/opennow-core/src/store_catalog_page.rs b/native/opennow-core/src/catalog_page.rs similarity index 93% rename from native/opennow-core/src/store_catalog_page.rs rename to native/opennow-core/src/catalog_page.rs index 6a9568779..85745c439 100644 --- a/native/opennow-core/src/store_catalog_page.rs +++ b/native/opennow-core/src/catalog_page.rs @@ -1,4 +1,4 @@ -//! One Store RPC contains one complete upstream page, never an entire catalog. +//! One catalog RPC contains one complete upstream page, never an entire catalog. use crate::gfn::ServiceError; use serde_json::{Value, json}; @@ -21,14 +21,14 @@ impl PageRequest { _ => { return Err(error( "invalid_params", - "Store cursor and search must be strings", + "Catalog cursor and search must be strings", )); } }; if value.len() > max { return Err(error( "invalid_params", - "Store cursor or search exceeds its size limit", + "Catalog cursor or search exceeds its size limit", )); } Ok(value.to_owned()) @@ -54,7 +54,7 @@ fn encoded_size(value: &Value) -> Result { .map_err(|_| { error( "invalid_upstream_response", - "Store result could not be encoded", + "Catalog result could not be encoded", ) }) } @@ -63,7 +63,7 @@ pub fn bounded_result(value: Value) -> Result { if encoded_size(&value)? > RESULT_BUDGET { return Err(error( "catalog_response_too_large", - "Store response exceeds the safe page size", + "Catalog response exceeds the safe page size", )); } Ok(value) @@ -84,7 +84,7 @@ pub fn fetch_bounded_page( if count == 1 { return Err(error( "catalog_response_too_large", - "A Store game exceeds the safe page size", + "A catalog game exceeds the safe page size", )); } count = (count / 2).max(1); @@ -100,14 +100,14 @@ pub fn page_result( let has_more = info["hasNextPage"].as_bool().ok_or_else(|| { error( "invalid_upstream_response", - "Store response has no pagination state", + "Catalog response has no pagination state", ) })?; let next = info["endCursor"].as_str().unwrap_or(""); if has_more && (next.is_empty() || next == cursor || next.len() > MAX_CURSOR_BYTES) { return Err(error( "invalid_upstream_response", - "Store pagination did not advance", + "Catalog pagination did not advance", )); } Ok(json!({ diff --git a/native/opennow-core/src/cloudmatch.rs b/native/opennow-core/src/cloudmatch.rs index dc71226ef..1fe748270 100644 --- a/native/opennow-core/src/cloudmatch.rs +++ b/native/opennow-core/src/cloudmatch.rs @@ -1697,10 +1697,7 @@ fn session_phase(status: i64) -> &'static str { } fn session_token(auth: &AuthSession) -> &str { - auth.tokens - .id_token - .as_deref() - .unwrap_or(&auth.tokens.access_token) + auth.tokens.session_token() } fn parse_resolution(value: &str) -> (i64, i64) { diff --git a/native/opennow-core/src/gfn.rs b/native/opennow-core/src/gfn.rs index 1c447beaa..cb2edeaf8 100644 --- a/native/opennow-core/src/gfn.rs +++ b/native/opennow-core/src/gfn.rs @@ -216,11 +216,13 @@ pub struct Endpoints { pub client_token: String, pub userinfo: String, pub public_catalog: String, + pub library_catalog: String, } impl Default for Endpoints { fn default() -> Self { Self { + library_catalog: GRAPHQL_URL.to_owned(), service_urls: "https://pcs.geforcenow.com/v1/serviceUrls".to_owned(), device_authorize: "https://login.nvidia.com/device/authorize".to_owned(), token: "https://login.nvidia.com/token".to_owned(), @@ -280,6 +282,26 @@ pub struct AuthTokens { pub client_token_lifetime_ms: Option, } +impl AuthTokens { + fn id_token_expires_at(&self) -> Option { + jwt_claims(self.id_token.as_deref()?)?["exp"] + .as_u64()? + .checked_mul(1000) + } + + pub(crate) fn session_token(&self) -> &str { + self.id_token + .as_deref() + .filter(|token| { + !token.trim().is_empty() + && self + .id_token_expires_at() + .is_none_or(|expires| expires > now_ms()) + }) + .unwrap_or(&self.access_token) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthUser { @@ -752,7 +774,12 @@ impl GfnService { })); }; - let needs_refresh = current.tokens.expires_at <= now_ms() + TOKEN_REFRESH_WINDOW_MS; + let refresh_deadline = now_ms() + TOKEN_REFRESH_WINDOW_MS; + let needs_refresh = current.tokens.expires_at <= refresh_deadline + || current + .tokens + .id_token_expires_at() + .is_some_and(|expires| expires <= refresh_deadline); let needs_client_token = current .tokens .client_token @@ -1145,41 +1172,24 @@ impl GfnService { } pub fn library_catalog(&self, params: &Value, settings: &Value) -> Result { + let page = crate::catalog_page::PageRequest::parse(params)?; let client = client_for_settings(&self.client, settings).map_err(ServiceError::invalid)?; - let session_payload = self.session()?; - let session = serde_json::from_value::(session_payload["session"].clone()) - .map_err(|_| ServiceError { - code: "authentication_required", - message: "Sign in to load your GeForce NOW library".to_owned(), - })?; - let token = session - .tokens - .id_token - .as_deref() - .unwrap_or(&session.tokens.access_token); - let vpc_id = self.vpc_id(&session, token, None)?; - let limit = params["limit"].as_u64().unwrap_or(600).clamp(1, 2000) as usize; - let search = params["searchQuery"] - .as_str() - .unwrap_or("") - .trim() - .to_lowercase(); - let mut cursor = String::new(); - let mut games = Vec::new(); - let mut total_count = 0_u64; - - for _ in 0..25 { + let session = self.authenticated_session("Sign in to load your GeForce NOW library")?; + let token = session.tokens.session_token(); + let vpc_id = self.vpc_id(&session, token, settings, None)?; + let search = page.search.to_lowercase(); + crate::catalog_page::fetch_bounded_page(page.limit, |fetch_count| { crate::requests::check()?; let variables = json!({ "vpcId":vpc_id, "locale":"en_US", "sortString":"variants.gfn.library.lastPlayedDate:DESC,computedValues.libraryAddedDate:DESC,sortName:ASC", - "fetchCount":200, - "cursor":cursor, + "fetchCount":fetch_count, + "cursor":page.cursor, "filters":{"variants":{"gfn":{"library":{"status":{"notEquals":"NOT_OWNED"}}}}} }); let response = client - .post(GRAPHQL_URL) + .post(&self.endpoints.library_catalog) .headers(graphql_headers(token)?) .json(&json!({"query":LIBRARY_QUERY,"variables":variables})) .send() @@ -1197,41 +1207,36 @@ impl GfnService { }); } let apps = &payload["data"]["apps"]; - total_count = apps["pageInfo"]["totalCount"] - .as_u64() - .unwrap_or(total_count); - for app in apps["items"].as_array().into_iter().flatten() { - if let Some(game) = app_to_game(app) { - if search.is_empty() - || game["searchText"] - .as_str() - .is_some_and(|text| text.contains(&search)) - { - games.push(game); - } - } - if games.len() >= limit { - break; - } - } - if games.len() >= limit || !apps["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false) { - break; - } - let Some(next_cursor) = apps["pageInfo"]["endCursor"].as_str() else { - break; - }; - if next_cursor.is_empty() || next_cursor == cursor { - break; + let items = apps["items"].as_array().ok_or_else(|| ServiceError { + code: "invalid_upstream_response", + message: "Library response has no games array".to_owned(), + })?; + if items.len() > fetch_count { + return Err(ServiceError { + code: "invalid_upstream_response", + message: "Library response exceeds the requested page size".to_owned(), + }); } - cursor = next_cursor.to_owned(); - } - Ok(json!({ - "games":games, - "count":games.len(), - "totalCount":total_count.max(games.len() as u64), - "source":"account-library", - "fetchedAt":now_ms() - })) + let mut games: Vec = items.iter().filter_map(app_to_game).collect(); + let mapped_count = games.len(); + games.retain(|game| { + search.is_empty() + || game["searchText"] + .as_str() + .is_some_and(|text| text.contains(&search)) + }); + let mut result = + crate::catalog_page::page_result(&page.cursor, games, &apps["pageInfo"], now_ms())?; + result["source"] = json!("account-library"); + eprintln!( + "catalog.library.page upstream={} mapped={} returned={} hasNextPage={}", + items.len(), + mapped_count, + result["count"], + result["hasNextPage"], + ); + Ok(result) + }) } pub fn store_local_catalog( @@ -1268,23 +1273,20 @@ impl GfnService { pub fn store_catalog(&self, params: &Value, settings: &Value) -> Result { crate::requests::check()?; - let page = crate::store_catalog_page::PageRequest::parse(params)?; + let page = crate::catalog_page::PageRequest::parse(params)?; let client = client_for_settings(&self.client, settings).map_err(ServiceError::invalid)?; let session = self.authenticated_session("Sign in to browse the GeForce NOW store catalog")?; - let token = session - .tokens - .id_token - .as_deref() - .unwrap_or(&session.tokens.access_token); + let token = session.tokens.session_token(); let scope = self.store_cache_scope(&session, settings)?; let key = json!(["page", page.limit, page.cursor, page.search]); let refresh = params["refresh"].as_bool() == Some(true) && page.cursor.is_empty(); self.store_cache.load_or_fetch(&scope, &key, refresh, || { - let vpc_id = self.vpc_id(&session, token, Some(&self.store_cache.requests))?; + let vpc_id = + self.vpc_id(&session, token, settings, Some(&self.store_cache.requests))?; // Each retry starts at the SAME cursor. Never truncate a fetched page: // doing so would skip games when returning NVIDIA's end cursor. - crate::store_catalog_page::fetch_bounded_page(page.limit, |fetch_count| { + crate::catalog_page::fetch_bounded_page(page.limit, |fetch_count| { let searching = !page.search.is_empty(); let query = if searching { STORE_SEARCH_QUERY @@ -1324,12 +1326,7 @@ impl GfnService { message: "Store response has no games array".to_owned(), })?; let games: Vec = items.iter().filter_map(app_to_game).collect(); - crate::store_catalog_page::page_result( - &page.cursor, - games, - &apps["pageInfo"], - now_ms(), - ) + crate::catalog_page::page_result(&page.cursor, games, &apps["pageInfo"], now_ms()) }) }) } @@ -1370,12 +1367,9 @@ impl GfnService { &json!(["presentation", section]), false, || { - let token = session - .tokens - .id_token - .as_deref() - .unwrap_or(&session.tokens.access_token); - let vpc_id = self.vpc_id(&session, token, Some(&self.store_cache.requests))?; + let token = session.tokens.session_token(); + let vpc_id = + self.vpc_id(&session, token, settings, Some(&self.store_cache.requests))?; let (variables, request_type, sha, query) = match section { "panels" => ( json!({"vpcId":vpc_id,"locale":"en_US","panelNames":["MAIN"]}), @@ -1444,7 +1438,7 @@ impl GfnService { }; // Optional chrome must not enlarge the games response or restart the core. // An oversized/failed section is reported independently by the shell. - crate::store_catalog_page::bounded_result(json!({"section":section,"items":items})) + crate::catalog_page::bounded_result(json!({"section":section,"items":items})) }, )?; if section == "panels" && params["metadataOnly"] == true { @@ -1461,11 +1455,7 @@ impl GfnService { pub fn regions(&self) -> Result { let session = self.authenticated_session("Sign in to discover streaming regions")?; - let token = session - .tokens - .id_token - .as_deref() - .unwrap_or(&session.tokens.access_token); + let token = session.tokens.session_token(); let base = trusted_streaming_base(&session.provider.streaming_service_url)?; let url = base .join("v2/serverInfo") @@ -1494,17 +1484,13 @@ impl GfnService { .unwrap_or("") .cmp(right["name"].as_str().unwrap_or("")) }); - Ok(json!({"regions":regions,"vpcId":payload["requestStatus"]["serverId"]})) + Ok(json!({"regions":regions,"vpcId":server_catalog_vpc(&payload)})) } pub fn subscription(&self, settings: &Value) -> Result { let session = self.authenticated_session("Sign in to load subscription details")?; - let token = session - .tokens - .id_token - .as_deref() - .unwrap_or(&session.tokens.access_token); - let vpc_id = self.vpc_id(&session, token, None)?; + let token = session.tokens.session_token(); + let vpc_id = self.vpc_id(&session, token, settings, None)?; let steam_deck = settings["identifyAsSteamDeck"].as_bool().unwrap_or(false); let mut url = url::Url::parse(MES_URL).expect("MES URL is valid"); url.query_pairs_mut() @@ -1512,8 +1498,8 @@ impl GfnService { .append_pair("languageCode", "en_US") .append_pair("vpcId", &vpc_id) .append_pair("userId", &session.user.user_id); - let response = self - .client + let response = client_for_settings(&self.client, settings) + .map_err(ServiceError::invalid)? .get(url) .headers(lcars_headers( token, @@ -1662,6 +1648,7 @@ impl GfnService { &self, session: &AuthSession, token: &str, + settings: &Value, requests: Option<&crate::store_requests::StoreRequests>, ) -> Result { let Ok(base) = trusted_streaming_base(&session.provider.streaming_service_url) else { @@ -1673,9 +1660,16 @@ impl GfnService { let Ok(headers) = lcars_headers(token, "NATIVE", "NVIDIA-CLASSIC", false) else { return Ok("GFN-PC".to_owned()); }; + let proxy = config_from_settings(settings).map_err(ServiceError::invalid)?; + let route = proxy + .map(|config| config.cache_scope) + .unwrap_or_else(|| "direct".to_owned()); + let scope = json!([base.as_str(), route]).to_string(); self.server_vpc_cache - .resolve(base.as_str(), &session.user.user_id, token, || { - let request = self.client.get(url).headers(headers); + .resolve(&scope, &session.user.user_id, token, || { + let client = + client_for_settings(&self.client, settings).map_err(ServiceError::invalid)?; + let request = client.get(url).headers(headers); let response = match requests { Some(requests) => requests.send(request, "Store server info failed"), None => request @@ -1692,21 +1686,15 @@ impl GfnService { if !response.status().is_success() { return Ok(None); } - Ok(response.json::().ok().and_then(|payload| { - payload["requestStatus"]["serverId"] - .as_str() - .map(ToOwned::to_owned) - })) + Ok(response + .json::() + .ok() + .and_then(|payload| server_catalog_vpc(&payload))) }) } fn fetch_user_info(&self, tokens: &AuthTokens) -> Result { - if let Some(user) = tokens - .id_token - .as_deref() - .or(Some(tokens.access_token.as_str())) - .and_then(user_from_jwt) - { + if let Some(user) = user_from_jwt(tokens.session_token()) { if user.email.is_some() || user.avatar_url.is_some() { return Ok(user); } @@ -1895,8 +1883,16 @@ impl GfnService { .or_else(|| session.tokens.refresh_token.clone()), id_token: payload["id_token"] .as_str() + .filter(|token| !token.trim().is_empty()) .map(ToOwned::to_owned) - .or_else(|| session.tokens.id_token.clone()), + .or_else(|| { + session.tokens.id_token.clone().filter(|_| { + session + .tokens + .id_token_expires_at() + .is_none_or(|expires| expires > now_ms()) + }) + }), expires_at: now_ms() + payload["expires_in"].as_u64().unwrap_or(86_400) * 1000, auth_client_id: session.tokens.auth_client_id.clone(), client_token: payload["client_token"] @@ -2057,17 +2053,39 @@ fn public_game_to_info(item: &Value) -> Option { })) } +fn server_catalog_vpc(payload: &Value) -> Option { + [ + &payload["vpcId"], + &payload["vpc_id"], + &payload["requestStatus"]["serverId"], + ] + .into_iter() + .find_map(catalog_id) +} + +fn catalog_id(value: &Value) -> Option { + match value { + Value::String(value) if !value.trim().is_empty() => Some(value.trim().to_owned()), + Value::Number(value) => value.as_u64().map(|value| value.to_string()), + _ => None, + } +} + fn app_to_game(app: &Value) -> Option { - let id = app["id"].as_str()?.to_owned(); + let id = catalog_id(&app["id"])?; let title = app["title"].as_str()?.trim().to_owned(); if title.is_empty() { return None; } let variants = app["variants"].as_array().into_iter().flatten().filter_map(|variant| { - let variant_id = variant["id"].as_str()?.to_owned(); + let variant_id = catalog_id(&variant["id"])?; let store = variant["appStore"].as_str().unwrap_or("Unknown").to_owned(); let library_status = variant["gfn"]["library"]["status"].as_str().map(ToOwned::to_owned); - let in_library = library_status.as_deref().is_some_and(|status| matches!(status, "MANUAL" | "PLATFORM_SYNC" | "IN_LIBRARY")); + let in_library = variant["gfn"]["library"]["selected"].as_bool() == Some(true) + || library_status.as_deref().is_some_and(|status| { + let status = status.trim(); + !status.is_empty() && !status.eq_ignore_ascii_case("NOT_OWNED") + }); let supports_persistence = gfn_feature_enabled( &variant["gfn"]["features"], "IN_GAME_SETTINGS_PERSISTENCE_ENABLED", @@ -2586,12 +2604,16 @@ fn lcars_headers( Ok(headers) } -fn user_from_jwt(token: &str) -> Option { +fn jwt_claims(token: &str) -> Option { let encoded = token.split('.').nth(1)?; let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(encoded) .ok()?; - let payload = serde_json::from_slice::(&decoded).ok()?; + serde_json::from_slice::(&decoded).ok() +} + +fn user_from_jwt(token: &str) -> Option { + let payload = jwt_claims(token)?; let user_id = payload["sub"].as_str()?.to_owned(); let email = payload["email"].as_str().map(ToOwned::to_owned); let avatar_url = payload["picture"] @@ -2688,6 +2710,10 @@ fn required_string(payload: &Value, key: &str) -> Result { }) } +#[cfg(test)] +#[path = "gfn_catalog_tests.rs"] +mod catalog_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/native/opennow-core/src/gfn_catalog_tests.rs b/native/opennow-core/src/gfn_catalog_tests.rs new file mode 100644 index 000000000..007f4b1d0 --- /dev/null +++ b/native/opennow-core/src/gfn_catalog_tests.rs @@ -0,0 +1,476 @@ +use super::*; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::time::Instant; + +struct Exchange { + path: &'static str, + body: Value, +} + +fn catalog_fixture( + exchanges: Vec, + inspect: impl Fn(usize, &str, &[u8]) + Send + 'static, +) -> (GfnService, tempfile::TempDir, std::thread::JoinHandle<()>) { + let host = "prod.test.geforcenow.nvidiagrid.net"; + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec![host.to_owned()]).unwrap(); + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![cert.der().clone()], + rustls::pki_types::PrivatePkcs8KeyDer::from(signing_key.serialize_der()).into(), + ) + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let base = format!("https://{host}:{}/", address.port()); + let worker = std::thread::spawn(move || { + let config = Arc::new(config); + for (index, exchange) in exchanges.into_iter().enumerate() { + let deadline = Instant::now() + Duration::from_secs(10); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "missing catalog HTTP request"); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("{error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut stream = rustls::StreamOwned::new( + rustls::ServerConnection::new(config.clone()).unwrap(), + stream, + ); + let mut reader = BufReader::new(&mut stream); + let mut headers = String::new(); + let mut length = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + headers.push_str(&line); + } + assert_eq!(headers.split_whitespace().nth(1), Some(exchange.path)); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + inspect(index, &headers, &body); + let response = exchange.body.to_string(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); + stream.flush().unwrap(); + } + }); + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .resolve(host, address) + .add_root_certificate(reqwest::Certificate::from_der(cert.der()).unwrap()) + .build() + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let service = GfnService::with_client( + client, + Endpoints { + library_catalog: format!("{base}graphql"), + token: format!("{base}token"), + ..Endpoints::default() + }, + directory.path().to_owned(), + ); + let mut session: AuthSession = serde_json::from_value(json!({ + "provider":LoginProvider::default_nvidia(), + "tokens":{"accessToken":"test-access","idToken":"test-id","refreshToken":"test-refresh", + "clientToken":"test-client","authClientId":"test-client-id", + "expiresAt":now_ms()+3_600_000,"clientTokenExpiresAt":now_ms()+3_600_000}, + "user":{"userId":"test-account","displayName":"Test","membershipTier":"FREE"} + })) + .unwrap(); + session.provider.code = "PARTNER".to_owned(); + session.provider.streaming_service_url = base; + service.state.lock().unwrap().session = Some(session); + (service, directory, worker) +} + +fn server_info() -> Exchange { + Exchange { + path: "/v2/serverInfo", + body: json!({"vpcId":"PARTNER-CATALOG","requestStatus":{"serverId":"different-server"}}), + } +} + +fn app(id: u64, title: &str) -> Value { + json!({"id":id,"title":title,"variants":[{"id":id+1000,"appStore":"Steam","gfn":{"status":"AVAILABLE","library":{"status":"OWNED","selected":true}}}]}) +} + +fn library_page(items: Vec, next: Option<&str>) -> Exchange { + Exchange { + path: "/graphql", + body: json!({"data":{"apps":{"items":items,"pageInfo":{ + "hasNextPage":next.is_some(),"endCursor":next,"totalCount":1200 + }}}}), + } +} + +#[test] +fn alliance_library_uses_discovered_vpc_and_preserves_numeric_ids_and_ownership() { + let (service, _directory, server) = catalog_fixture( + vec![ + server_info(), + library_page(vec![app(42, "Partner game")], Some("next-page")), + ], + |index, headers, body| { + assert!( + headers + .to_ascii_lowercase() + .contains("authorization: gfnjwt test-id\r\n") + ); + if index == 1 { + let request: Value = serde_json::from_slice(body).unwrap(); + assert_eq!(request["variables"]["vpcId"], "PARTNER-CATALOG"); + assert_eq!(request["variables"]["fetchCount"], 100); + assert_eq!(request["variables"]["cursor"], ""); + assert_eq!( + request["variables"]["filters"]["variants"]["gfn"]["library"]["status"]["notEquals"], + "NOT_OWNED" + ); + } + }, + ); + let result = service + .library_catalog(&json!({"limit":1000}), &json!({})) + .unwrap(); + assert_eq!(result["games"][0]["id"], "42"); + assert_eq!(result["games"][0]["launchAppId"], "1042"); + assert_eq!(result["games"][0]["isInLibrary"], true); + assert_eq!(result["source"], "account-library"); + assert_eq!(result["hasNextPage"], true); + assert_eq!(result["nextCursor"], "next-page"); + server.join().unwrap(); +} + +#[test] +fn library_pages_shrink_without_skipping_the_original_cursor_or_exceeding_ipc() { + let title = "x".repeat(20_000); + let mut exchanges = vec![server_info()]; + for count in [100, 50, 25, 12] { + exchanges.push(library_page( + (0..count).map(|id| app(id, &title)).collect(), + Some(&format!("after-{count}")), + )); + } + let (service, _directory, server) = catalog_fixture(exchanges, |index, _, body| { + if index > 0 { + let request: Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + request["variables"]["fetchCount"], + [100, 50, 25, 12][index - 1] + ); + assert_eq!(request["variables"]["cursor"], "original-cursor"); + } + }); + let result = service + .library_catalog(&json!({"limit":100,"cursor":"original-cursor"}), &json!({})) + .unwrap(); + assert_eq!(result["games"].as_array().unwrap().len(), 12); + assert_eq!(result["nextCursor"], "after-12"); + assert!(serde_json::to_vec(&result).unwrap().len() <= crate::catalog_page::RESULT_BUDGET); + server.join().unwrap(); +} + +#[test] +fn library_search_keeps_empty_filtered_pages_and_server_pagination() { + let (service, _directory, server) = catalog_fixture( + vec![ + server_info(), + library_page(vec![app(1, "Other game")], Some("after-other")), + library_page(vec![app(2, "Wanted game")], None), + ], + |index, _, body| { + if index > 0 { + let request: Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + request["variables"]["cursor"], + if index == 1 { "" } else { "after-other" } + ); + } + }, + ); + let first = service + .library_catalog(&json!({"searchQuery":"wanted"}), &json!({})) + .unwrap(); + assert_eq!(first["count"], 0); + assert_eq!(first["hasNextPage"], true); + let last = service + .library_catalog( + &json!({"searchQuery":"wanted","cursor":first["nextCursor"]}), + &json!({}), + ) + .unwrap(); + assert_eq!(last["games"][0]["title"], "Wanted game"); + assert_eq!(last["hasNextPage"], false); + server.join().unwrap(); +} + +#[test] +fn library_rejects_missing_data_and_nonadvancing_pages_instead_of_empty_success() { + for body in [ + json!({}), + json!({"data":{"apps":null}}), + json!({"data":{"apps":{"items":[]}}}), + json!({"data":{"apps":{"items":[],"pageInfo":{"hasNextPage":true,"endCursor":"same"}}}}), + ] { + let (service, _directory, server) = catalog_fixture( + vec![ + server_info(), + Exchange { + path: "/graphql", + body, + }, + ], + |_, _, _| {}, + ); + let error = service + .library_catalog(&json!({"cursor":"same"}), &json!({})) + .unwrap_err(); + assert_eq!(error.code, "invalid_upstream_response"); + server.join().unwrap(); + } +} + +#[test] +fn explicit_catalog_vpcs_precede_server_ids_and_legacy_responses_still_work() { + for (payload, expected) in [ + ( + json!({"vpcId":"ally","vpc_id":"snake","requestStatus":{"serverId":"legacy"}}), + Some("ally"), + ), + ( + json!({"vpcId":" ","vpc_id":"snake","requestStatus":{"serverId":"legacy"}}), + Some("snake"), + ), + ( + json!({"requestStatus":{"serverId":"NPA-TKC-IST-01"}}), + Some("NPA-TKC-IST-01"), + ), + ( + json!({"requestStatus":{"serverId":"NP-NWK-03"}}), + Some("NP-NWK-03"), + ), + (json!({}), None), + ] { + assert_eq!(server_catalog_vpc(&payload).as_deref(), expected); + } +} + +#[test] +fn library_ownership_matches_mac_for_server_defined_states_and_selected_variants() { + for (status, selected, owned) in [ + ("MANUAL", false, true), + ("PLATFORM_SYNC", false, true), + ("OWNED", false, true), + (" installed ", false, true), + ("NOT_OWNED", false, false), + ("not_owned", false, false), + ("", false, false), + ("", true, true), + ("NOT_OWNED", true, true), + ] { + let mut item = app(1, "Test"); + item["variants"][0]["gfn"]["library"] = json!({"status":status,"selected":selected}); + let game = app_to_game(&item).unwrap(); + assert_eq!(game["isInLibrary"], owned); + assert_eq!(game["variants"][0]["libraryStatus"], status); + } + for invalid in [Value::Null, json!(true), json!(-5), json!(1.5), json!(" ")] { + let mut item = app(1, "Test"); + item["variants"][0]["id"] = invalid; + assert!(app_to_game(&item).is_none()); + } +} + +fn token(expiry: u64) -> String { + format!( + "header.{}.signature", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + json!({ + "sub":"test-account","email":"test@example.invalid","exp":expiry + }) + .to_string() + ) + ) +} + +#[test] +fn expired_id_tokens_are_refreshed_even_with_an_unexpired_access_token() { + let refreshed = token(now_ms() / 1000 + 3600); + let (service, _directory, server) = catalog_fixture( + vec![Exchange { + path: "/token", + body: json!({ + "access_token":refreshed,"expires_in":3600 + }), + }], + |_, _, body| { + let form: HashMap<_, _> = url::form_urlencoded::parse(body).collect(); + assert_eq!( + form["grant_type"], + "urn:ietf:params:oauth:grant-type:client_token" + ); + }, + ); + { + let mut state = service.state.lock().unwrap(); + let tokens = &mut state.session.as_mut().unwrap().tokens; + tokens.id_token = Some(token(now_ms() / 1000 - 60)); + assert_eq!(tokens.session_token(), "test-access"); + } + let result = service.session().unwrap(); + assert_eq!(result["refresh"]["outcome"], "refreshed"); + assert!(result["session"]["tokens"].get("idToken").is_none()); + assert_eq!(result["session"]["tokens"]["accessToken"], refreshed); + server.join().unwrap(); +} + +#[test] +fn proxy_route_changes_do_not_reuse_direct_catalog_vpc_metadata() { + let (service, _directory, server) = catalog_fixture(vec![server_info()], |_, _, _| {}); + let session = service.state.lock().unwrap().session.clone().unwrap(); + assert_eq!( + service + .vpc_id(&session, "test-id", &json!({}), None) + .unwrap(), + "PARTNER-CATALOG" + ); + server.join().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let proxy = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream: TcpStream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "proxy was bypassed or the direct VPC was reused" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("{error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut reader = BufReader::new(&mut stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.starts_with("CONNECT prod.test.geforcenow.nvidiagrid.net:")); + loop { + line.clear(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + } + write!( + stream, + "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + }); + let settings = + json!({"sessionProxyEnabled":true,"sessionProxyUrl":format!("http://{address}")}); + assert_eq!( + service + .vpc_id(&session, "test-id", &settings, None) + .unwrap(), + "GFN-PC" + ); + proxy.join().unwrap(); +} + +#[test] +fn library_cancellation_after_a_response_never_returns_a_completed_page() { + let requests = Arc::new(crate::requests::Requests::default()); + let permit = requests.admit("library", "catalog.library.list").unwrap(); + let cancellation = requests.clone(); + let (service, _directory, server) = catalog_fixture( + vec![server_info(), library_page(vec![app(1, "Test")], None)], + move |index, _, _| { + if index == 1 { + cancellation.cancel("library"); + } + }, + ); + let error = crate::requests::scope(permit.token.clone(), || { + service.library_catalog(&json!({}), &json!({})) + }) + .unwrap_err(); + assert_eq!(error.code, "cancelled"); + server.join().unwrap(); +} + +#[test] +fn library_rejects_a_server_ignoring_the_requested_page_size() { + let (service, _directory, server) = catalog_fixture( + vec![ + server_info(), + library_page(vec![app(1, "One"), app(2, "Two")], None), + ], + |_, _, _| {}, + ); + assert_eq!( + service + .library_catalog(&json!({"limit":1}), &json!({})) + .unwrap_err() + .code, + "invalid_upstream_response" + ); + server.join().unwrap(); +} + +#[test] +fn session_token_prefers_only_a_nonempty_unexpired_id_token() { + let (service, _directory, server) = catalog_fixture(vec![], |_, _, _| {}); + let mut tokens = service + .state + .lock() + .unwrap() + .session + .clone() + .unwrap() + .tokens; + let valid = token(now_ms() / 1000 + 3600); + for (id, expected) in [ + (Some(valid.clone()), valid.as_str()), + (Some(token(now_ms() / 1000 - 60)), "test-access"), + (Some(String::new()), "test-access"), + (None, "test-access"), + ] { + tokens.id_token = id; + assert_eq!(tokens.session_token(), expected); + } + server.join().unwrap(); +} diff --git a/native/opennow-core/src/main.rs b/native/opennow-core/src/main.rs index bd069160f..f35b9f1e9 100644 --- a/native/opennow-core/src/main.rs +++ b/native/opennow-core/src/main.rs @@ -2,6 +2,7 @@ mod account_connections; mod artwork_cache; +mod catalog_page; mod cloudmatch; mod community; mod console_profiles; @@ -17,7 +18,6 @@ mod requests; mod server_vpc_cache; mod settings; mod store_cache; -mod store_catalog_page; mod store_index; mod store_requests; mod streamer; @@ -310,7 +310,7 @@ fn dispatch(method: &str, params: &Value, core: &AppCore) -> DispatchResult { )); } Ok(( - json!({"protocolVersion":PROTOCOL_VERSION, "coreVersion":version::APPLICATION_VERSION, "capabilities":["settings", "gfn.deviceAuth", "gfn.providers", "gfn.publicCatalog", "catalog.storePages.v1", "catalog.storeLocal.v1", "gfn.accountLibrary", "gfn.regions", "gfn.subscription", "gfn.cloudmatch", "sessionProxy", "catalogArtworkCache.v1", "nativeStreamer.v7", "nativeStreamer.ownedNvstNegotiation", "nativeStreamer.dynamicSurface", "nativeStreamer.acceptanceEvidence", "liveAcceptance.v1", "osCredentialStore", "electronAccountMigration", "redactedDiagnostics", "mediaLibrary", "githubUpdateDiscovery", "discordRpc", "optInTelemetry", "feedback", "bugReports", "social.capabilitySurface"]}), + json!({"protocolVersion":PROTOCOL_VERSION, "coreVersion":version::APPLICATION_VERSION, "capabilities":["settings", "gfn.deviceAuth", "gfn.providers", "gfn.publicCatalog", "catalog.storePages.v1", "catalog.libraryPages.v1", "catalog.storeLocal.v1", "gfn.accountLibrary", "gfn.regions", "gfn.subscription", "gfn.cloudmatch", "sessionProxy", "catalogArtworkCache.v1", "nativeStreamer.v7", "nativeStreamer.ownedNvstNegotiation", "nativeStreamer.dynamicSurface", "nativeStreamer.acceptanceEvidence", "liveAcceptance.v1", "osCredentialStore", "electronAccountMigration", "redactedDiagnostics", "mediaLibrary", "githubUpdateDiscovery", "discordRpc", "optInTelemetry", "feedback", "bugReports", "social.capabilitySurface"]}), None, )) } diff --git a/native/opennow-core/src/persistent_storage.rs b/native/opennow-core/src/persistent_storage.rs index 882ca2cd5..2ee4e64dd 100644 --- a/native/opennow-core/src/persistent_storage.rs +++ b/native/opennow-core/src/persistent_storage.rs @@ -184,10 +184,7 @@ fn paywall_headers(token: &str) -> Result { } fn session_token(auth: &AuthSession) -> Result<&str, ServiceError> { - auth.tokens - .id_token - .as_deref() - .or(Some(auth.tokens.access_token.as_str())) + Some(auth.tokens.session_token()) .filter(|value| !value.is_empty()) .ok_or_else(|| ServiceError { code: "authentication_required", diff --git a/native/opennow-core/src/store_cache.rs b/native/opennow-core/src/store_cache.rs index 481fc76e6..e5485817f 100644 --- a/native/opennow-core/src/store_cache.rs +++ b/native/opennow-core/src/store_cache.rs @@ -1,6 +1,6 @@ //! Bounded, account/provider-scoped Store responses persisted across core restarts. //! Only successful protocol-sized results are cached; credentials never enter files. -use crate::{gfn::ServiceError, store_catalog_page::RESULT_BUDGET}; +use crate::{catalog_page::RESULT_BUDGET, gfn::ServiceError}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -90,7 +90,7 @@ impl StoreCache { let mut value = fetch()?; crate::requests::check()?; value["cacheHit"] = Value::Bool(false); - let value = crate::store_catalog_page::bounded_result(value)?; + let value = crate::catalog_page::bounded_result(value)?; let current = self.epoch.lock().expect("Store cache poisoned"); if epoch == *current { // Cache failures must not turn a successful catalog fetch into an diff --git a/native/opennow-core/src/store_index.rs b/native/opennow-core/src/store_index.rs index 18fe2abc0..a3e5c052f 100644 --- a/native/opennow-core/src/store_index.rs +++ b/native/opennow-core/src/store_index.rs @@ -1,6 +1,6 @@ //! Compact local title/facet index. Full game records stay in the page cache; //! only the selected page is materialized for Qt or the command palette. -use crate::{gfn::ServiceError, store_catalog_page}; +use crate::{catalog_page, gfn::ServiceError}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -171,7 +171,7 @@ impl StoreIndex { params: &Value, mut read: impl FnMut(&Value) -> Option, ) -> Result { - let page = store_catalog_page::PageRequest::parse(params)?; + let page = catalog_page::PageRequest::parse(params)?; let query = normalized(&page.search); let tokens: Vec<_> = query.split_whitespace().take(8).collect(); let filter = |name: &str| -> Result { @@ -260,7 +260,7 @@ impl StoreIndex { }) }) .collect::>()?; - store_catalog_page::fetch_bounded_page(page.limit.min(60), |limit| { + catalog_page::fetch_bounded_page(page.limit.min(60), |limit| { let count = selected.len().min(limit); let next = offset + count; let more = next < matches.len() || !self.complete; diff --git a/opennow-qt/cmake/Tests.cmake b/opennow-qt/cmake/Tests.cmake index 6f0080e33..bec374b17 100644 --- a/opennow-qt/cmake/Tests.cmake +++ b/opennow-qt/cmake/Tests.cmake @@ -1,5 +1,26 @@ include(CTest) if(BUILD_TESTING) + find_package(Qt6 ${Qt6_VERSION} EXACT REQUIRED COMPONENTS QuickTest) + qt_add_executable(opennow-catalogpaging-tests tests/tst_catalogpaging.cpp) + target_link_libraries(opennow-catalogpaging-tests PRIVATE Qt6::QuickTest) + add_test(NAME qml-library-paging COMMAND opennow-catalogpaging-tests + -input "${CMAKE_CURRENT_SOURCE_DIR}/tests/catalogpaging") + set_tests_properties(qml-library-paging PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + qt_add_resources(opennow-qt "library-error-acceptance" + PREFIX "/acceptance" BASE tests FILES tests/LibraryErrorAcceptance.qml) + foreach(surface desktop console) + add_test(NAME qml-library-error-${surface} + COMMAND opennow-qt --smoke-test --allow-multiple-instances --${surface} + --route library --smoke-library-error --reduced-motion) + set_tests_properties(qml-library-error-${surface} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) + endforeach() + add_test(NAME qml-library-error-desktop-compact-light + COMMAND opennow-qt --smoke-test --allow-multiple-instances --desktop + --route library --smoke-library-error --smoke-width 960 --smoke-light-theme --reduced-motion) + set_tests_properties(qml-library-error-desktop-compact-light PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) qt_add_executable(opennow-applicationicons-tests tests/tst_applicationicons.cpp) target_link_libraries(opennow-applicationicons-tests PRIVATE Qt6::Test Qt6::Gui) opennow_add_application_icons(opennow-applicationicons-tests) diff --git a/opennow-qt/qml/desktop/library/DesktopLibraryScreen.qml b/opennow-qt/qml/desktop/library/DesktopLibraryScreen.qml index bf9d02e2b..1396c8195 100644 --- a/opennow-qt/qml/desktop/library/DesktopLibraryScreen.qml +++ b/opennow-qt/qml/desktop/library/DesktopLibraryScreen.qml @@ -261,12 +261,45 @@ FocusScope { font.letterSpacing: 0.7 } + Item { + id: libraryErrorRow + x: 24 + y: filterRow.y + filterRow.height + 14 + width: parent.width - 48 + height: visible ? Math.max(libraryErrorText.implicitHeight, libraryRetry.height) : 0 + visible: ShellStore.catalogState === "error" + Text { + id: libraryErrorText + objectName: "libraryErrorText" + width: parent.width - libraryRetry.width - 16 + anchors.verticalCenter: parent.verticalCenter + text: ShellStore.catalogGames.length > 0 + ? qsTr("Loaded %1 games. %2").arg(ShellStore.catalogGames.length).arg(ShellStore.catalogError) + : ShellStore.catalogError + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: Theme.accentColor("coral") + font.family: DesktopTokens.bodyFont + font.pixelSize: DesktopTokens.captionSize + } + DesktopButton { + id: libraryRetry + objectName: "libraryRetry" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Try again") + enabled: !ShellStore.catalogLoading + onClicked: ShellStore.retryCatalog() + } + } + GridView { id: grid + objectName: "libraryGameGrid" // The delegate keeps a six-pixel focus/scale gutter. Offset the view by // that gutter so the artwork remains on Paper's 24/64 alignment lane. x: 18 - y: filterRow.y + filterRow.height + 14 + y: libraryErrorRow.y + (libraryErrorRow.visible ? libraryErrorRow.height + 14 : 0) width: parent.width - 36 height: parent.height - y clip: true @@ -302,7 +335,7 @@ FocusScope { anchors.centerIn: grid width: Math.min(grid.width - 48, 460) spacing: 12 - visible: root.games.length === 0 + visible: root.games.length === 0 && ShellStore.catalogState !== "error" Text { width: parent.width text: root.collection ? qsTr("No games in this view") : qsTr("No games found") diff --git a/opennow-qt/qml/screens/LibraryScreen.qml b/opennow-qt/qml/screens/LibraryScreen.qml index 5f43c13cc..0b32bf77a 100644 --- a/opennow-qt/qml/screens/LibraryScreen.qml +++ b/opennow-qt/qml/screens/LibraryScreen.qml @@ -150,7 +150,7 @@ FocusScope { rightPadding: 20 Accessible.name: qsTr("Search games") KeyNavigation.right: platformFilter - KeyNavigation.down: catalog + KeyNavigation.down: libraryErrorRow.visible ? libraryRetry : catalog onTextEdited: root.searchQuery = text onAccepted: catalog.forceActiveFocus() background: Rectangle { radius: 26; color: searchField.activeFocus ? Theme.glassStrong : Qt.rgba(1, 1, 1, 0.10); border.color: searchField.activeFocus ? Theme.focus : Theme.seam; border.width: searchField.activeFocus ? 3 : 1 } @@ -174,7 +174,7 @@ FocusScope { width: 157; height: 52 KeyNavigation.left: searchField KeyNavigation.right: genreFilter - KeyNavigation.down: catalog + KeyNavigation.down: libraryErrorRow.visible ? libraryRetry : catalog onExpandedChanged: if (expanded) { genreFilter.expanded = false; sortFilter.expanded = false } onOptionSelected: index => root.platformIndex = index } @@ -186,7 +186,7 @@ FocusScope { width: 137; height: 52 KeyNavigation.left: platformFilter KeyNavigation.right: sortFilter - KeyNavigation.down: catalog + KeyNavigation.down: libraryErrorRow.visible ? libraryRetry : catalog onExpandedChanged: if (expanded) { platformFilter.expanded = false; sortFilter.expanded = false } onOptionSelected: index => root.genreIndex = index } @@ -197,21 +197,57 @@ FocusScope { currentIndex: root.sortIndex width: 160; height: 52 KeyNavigation.left: genreFilter - KeyNavigation.down: catalog + KeyNavigation.down: libraryErrorRow.visible ? libraryRetry : catalog onExpandedChanged: if (expanded) { platformFilter.expanded = false; genreFilter.expanded = false } onOptionSelected: index => root.sortIndex = index } } + Item { + id: libraryErrorRow + x: 28; y: 90 + width: parent.width - 56 + height: visible ? Math.max(libraryErrorText.implicitHeight, libraryRetry.height) : 0 + visible: ShellStore.catalogState === "error" + Text { + id: libraryErrorText + objectName: "libraryErrorText" + width: parent.width - libraryRetry.width - 20 + anchors.verticalCenter: parent.verticalCenter + text: ShellStore.catalogGames.length > 0 + ? qsTr("Loaded %1 games. %2").arg(ShellStore.catalogGames.length).arg(ShellStore.catalogError) + : ShellStore.catalogError + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: Theme.label + font.family: Theme.bodyFont + font.pixelSize: 16 + } + GlassButton { + id: libraryRetry + objectName: "libraryRetry" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Try again") + glyph: "A" + primary: true + enabled: !ShellStore.catalogLoading + KeyNavigation.down: catalog + KeyNavigation.up: searchField + onClicked: ShellStore.retryCatalog() + } + } + Item { // The focus ring and selected scale both extend outside the tile. // Reserve a real gutter inside the clipped viewport for that motion. - x: 12; y: 76 + x: 12; y: libraryErrorRow.visible ? libraryErrorRow.y + libraryErrorRow.height : 76 width: parent.width - 24 - height: parent.height - 92 + height: parent.height - y - 16 clip: true GridView { id: catalog + objectName: "libraryGameGrid" x: 16; y: 28 width: 1092 height: parent.height - 44 @@ -222,7 +258,7 @@ FocusScope { Component.onCompleted: currentIndex = ShellStore.focusIndex("library") onCurrentIndexChanged: ShellStore.rememberFocus("library", currentIndex) focus: true - KeyNavigation.up: platformFilter + KeyNavigation.up: libraryErrorRow.visible ? libraryRetry : platformFilter keyNavigationWraps: false delegate: Item { id: gameDelegate @@ -255,10 +291,9 @@ FocusScope { Column { anchors.centerIn: parent spacing: 12 - visible: root.games.length === 0 - Text { anchors.horizontalCenter: parent.horizontalCenter; text: ShellStore.catalogState === "error" ? qsTr("Couldn’t reach the catalog") : qsTr("Loading GeForce NOW games…"); color: Theme.label; font.family: Theme.displayFont; font.pixelSize: 24; font.weight: Font.Black } - Text { anchors.horizontalCenter: parent.horizontalCenter; text: ShellStore.catalogGames.length > 0 ? qsTr("No games match these filters.") : (ShellStore.catalogState === "error" ? ShellStore.lastError : qsTr("The shell stays responsive while the Rust core fetches NVIDIA’s public list.")); color: Theme.textMuted; font.family: Theme.bodyFont; font.pixelSize: 14 } - GlassButton { anchors.horizontalCenter: parent.horizontalCenter; visible: ShellStore.catalogState === "error"; text: qsTr("Try again"); glyph: "A"; primary: true; onClicked: ShellStore.refreshCatalog("") } + visible: root.games.length === 0 && ShellStore.catalogState !== "error" + Text { anchors.horizontalCenter: parent.horizontalCenter; text: qsTr("Loading GeForce NOW games…"); color: Theme.label; font.family: Theme.displayFont; font.pixelSize: 24; font.weight: Font.Black } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: ShellStore.catalogGames.length > 0 ? qsTr("No games match these filters.") : qsTr("The shell stays responsive while the Rust core fetches NVIDIA’s public list."); color: Theme.textMuted; font.family: Theme.bodyFont; font.pixelSize: 14 } } } diff --git a/opennow-qt/qml/state/ShellStore.qml b/opennow-qt/qml/state/ShellStore.qml index 9226246ab..c298bc88c 100644 --- a/opennow-qt/qml/state/ShellStore.qml +++ b/opennow-qt/qml/state/ShellStore.qml @@ -25,6 +25,7 @@ QtObject { setSetting: root.setSetting applySetting: root.applySetting onAccessibilityAnnounced: message => root.accessibilityMessage = message + onErrorReported: message => root.lastError = message onStoreSessionReset: root.storeSessionReset() } @@ -164,6 +165,8 @@ QtObject { property alias selectedGame: catalogOwner.selectedGame property alias catalogTotalCount: catalogOwner.catalogTotalCount property alias catalogState: catalogOwner.catalogState + property alias catalogError: catalogOwner.catalogError + property alias catalogLoading: catalogOwner.catalogLoading property alias catalogSource: catalogOwner.catalogSource property alias storeGames: catalogOwner.storeGames property alias storeFacets: catalogOwner.storeFacets @@ -783,6 +786,10 @@ QtObject { return catalogOwner.refreshCatalog(searchQuery) } + function retryCatalog() { + return catalogOwner.retryCatalog() + } + function reloadCatalogForSession() { return catalogOwner.reloadCatalogForSession() } @@ -2605,7 +2612,7 @@ QtObject { root.authRestorePending = false if (root.sessionPersistenceMessage !== "") root.accessibilityMessage = root.sessionPersistenceMessage - if (root.authSession && root.catalogSource !== "account-library") + if (root.authSession || root.catalogState === "idle") root.reloadCatalogForSession() if (root.authSession) root.refreshAccountServices() @@ -2616,7 +2623,8 @@ QtObject { root.resolveDirectLaunch() } else if (requestId === root.catalogRequestId) { catalogOwner.acceptCatalog(result) - root.resolveDirectLaunch() + if (root.catalogState === "ready") + root.resolveDirectLaunch() } else if (requestId === root.storeRequestId) { root.acceptStorePage(result) } else if (requestId === root.storePresentationRequestId) { diff --git a/opennow-qt/qml/state/catalog/CatalogState.qml b/opennow-qt/qml/state/catalog/CatalogState.qml index 889a10bde..a07834bdc 100644 --- a/opennow-qt/qml/state/catalog/CatalogState.qml +++ b/opennow-qt/qml/state/catalog/CatalogState.qml @@ -10,6 +10,7 @@ QtObject { required property var setSetting required property var applySetting signal accessibilityAnnounced(string message) + signal errorReported(string message) signal storeSessionReset() property var catalogGames: [] property var selectedGame: null @@ -17,6 +18,17 @@ QtObject { property string catalogState: "idle" property string catalogSource: "public" property string catalogRequestId: "" + property string catalogError: "" + property string catalogSearchQuery: "" + property string catalogNextCursor: "" + property int catalogPageCount: 0 + property var catalogSeenCursors: Object.create(null) + property var catalogSeenGames: Object.create(null) + readonly property bool catalogLoading: catalogRequestId !== "" || catalogPageTimer.running + property Timer catalogPageTimer: Timer { + interval: 1 + onTriggered: root.requestCatalogPage() + } readonly property var gameCollections: settings.gameCollections || [] property string activeCollectionId: "" readonly property var activeCollection: collectionById(activeCollectionId) @@ -31,11 +43,16 @@ QtObject { activeCollectionId = "" } onReadyChanged: { + if (!ready && catalogSource === "account-library") + resetCatalog() + else if (!ready) + cancelCatalogRequests() if (!ready && collectionsBusy) { collectionRequestId = "" collectionError = qsTr("The collection could not be saved. Reconnect and try again.") } } + onSignedInChanged: resetCatalog() property Connections collectionResponses: Connections { target: root.coreClient @@ -194,23 +211,69 @@ QtObject { } function refreshCatalog(searchQuery) { - if (!ready || catalogRequestId !== "") + const query = String(searchQuery || "") + if (!ready || (catalogLoading && (!signedIn || query === catalogSearchQuery))) return + cancelCatalogRequests() + catalogError = "" + catalogSearchQuery = query + catalogNextCursor = "" + catalogPageCount = 0 + catalogSeenCursors = Object.create(null) + catalogSeenGames = Object.create(null) catalogState = catalogGames.length > 0 ? "refreshing" : "loading" catalogSource = signedIn ? "account-library" : "public" - catalogRequestId = coreClient.request(signedIn ? "catalog.library.list" : "catalog.public.list", { - limit: signedIn ? 1000 : 360, - searchQuery: searchQuery || "" + if (signedIn) { + requestCatalogPage() + } else { + catalogRequestId = coreClient.request("catalog.public.list", { + limit: 360, searchQuery: query + }, 30000) + if (!catalogRequestId) + failCatalog(qsTr("Could not start the library request. Try again.")) + } + } + + function requestCatalogPage() { + if (!ready || !signedIn || catalogSource !== "account-library" || catalogRequestId !== "" + || (catalogState !== "loading" && catalogState !== "refreshing")) + return + catalogRequestId = coreClient.request("catalog.library.list", { + limit: 100, cursor: catalogNextCursor, searchQuery: catalogSearchQuery }, 30000) + if (!catalogRequestId) + failCatalog(qsTr("Could not start the library request. Try again.")) } - function reloadCatalogForSession() { - if (catalogRequestId !== "") { - coreClient.cancel(catalogRequestId) - catalogRequestId = "" - } + function retryCatalog() { + refreshCatalog(catalogSearchQuery) + } + + function cancelCatalogRequests() { + catalogPageTimer.stop() + const requestId = catalogRequestId + catalogRequestId = "" + if (requestId) + coreClient.cancel(requestId) + } + + function resetCatalog() { + cancelCatalogRequests() catalogGames = [] + selectedGame = null + catalogTotalCount = 0 catalogState = "idle" + catalogSource = "public" + catalogError = "" + catalogSearchQuery = "" + catalogNextCursor = "" + catalogPageCount = 0 + catalogSeenCursors = Object.create(null) + catalogSeenGames = Object.create(null) + } + + function reloadCatalogForSession() { + resetCatalog() refreshCatalog("") reloadStoreForSession() } @@ -522,6 +585,41 @@ QtObject { } function acceptCatalog(result) { + if (catalogSource === "account-library") { + catalogRequestId = "" + if (!result || !Array.isArray(result.games) || result.games.length > 100 + || typeof result.hasNextPage !== "boolean" || typeof result.nextCursor !== "string" + || (result.hasNextPage && (!result.nextCursor.trim() + || catalogSeenCursors[result.nextCursor]))) { + failCatalog(qsTr("The library returned an invalid page. Try again.")) + return + } + const merged = catalogPageCount === 0 ? [] : catalogGames.slice() + for (const game of result.games) { + const key = gameIdentity(game) + if (key && !catalogSeenGames[key]) { + merged.push(game) + catalogSeenGames[key] = true + } + } + catalogGames = merged + catalogTotalCount = Math.max(merged.length, Number(result.totalCount || 0)) + catalogPageCount += 1 + if (!selectedGame && merged.length > 0) + selectedGame = merged[0] + if (!result.hasNextPage) { + catalogState = "ready" + return + } + if (catalogPageCount >= 150) { + failCatalog(qsTr("The library exceeded the page limit. Some games may be missing. Try again.")) + return + } + catalogNextCursor = result.nextCursor + catalogSeenCursors[result.nextCursor] = true + catalogPageTimer.restart() + return + } root.catalogGames = result.games || [] root.catalogTotalCount = Number(result.totalCount || root.catalogGames.length) if (!root.selectedGame && root.catalogGames.length > 0) @@ -540,8 +638,11 @@ QtObject { } function failCatalog(message) { + root.catalogPageTimer.stop() root.catalogState = "error" root.catalogRequestId = "" + root.catalogError = message + root.errorReported(message) } function failStore(message) { diff --git a/opennow-qt/src/acceptance/SmokeAcceptance.cpp b/opennow-qt/src/acceptance/SmokeAcceptance.cpp index ae225a3cf..fb54d5428 100644 --- a/opennow-qt/src/acceptance/SmokeAcceptance.cpp +++ b/opennow-qt/src/acceptance/SmokeAcceptance.cpp @@ -205,6 +205,7 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-background-stream"_s) || m_arguments.contains(u"--smoke-recording"_s) || m_arguments.contains(u"--smoke-collections"_s) + || m_arguments.contains(u"--smoke-library-error"_s) || m_arguments.contains(u"--smoke-steam-big-picture"_s) || m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) || m_arguments.contains(u"--smoke-idle-mode"_s) @@ -222,6 +223,8 @@ int AcceptanceSession::startSmokeWorkload() ? u"qrc:/acceptance/RecordingAcceptance.qml"_s : m_arguments.contains(u"--smoke-collections"_s) ? u"qrc:/acceptance/CollectionsAcceptance.qml"_s + : m_arguments.contains(u"--smoke-library-error"_s) + ? u"qrc:/acceptance/LibraryErrorAcceptance.qml"_s : m_arguments.contains(u"--smoke-steam-big-picture"_s) ? u"qrc:/acceptance/SteamBigPictureAcceptance.qml"_s : m_arguments.contains(u"--smoke-persistent-in-game-settings"_s) @@ -245,6 +248,7 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-recording"_s) || m_arguments.contains(u"--smoke-queue-drops"_s) || m_arguments.contains(u"--smoke-collections"_s) + || m_arguments.contains(u"--smoke-library-error"_s) || m_arguments.contains(u"--smoke-steam-big-picture"_s) || m_arguments.contains(u"--smoke-persistent-in-game-settings"_s)) { auto *client = fixture->property("client").value(); @@ -257,6 +261,7 @@ int AcceptanceSession::startSmokeWorkload() const bool ok = window && QMetaObject::invokeMethod(fixture, "run", Q_RETURN_ARG(QVariant, passed), Q_ARG(QVariant, QVariant::fromValue(window->contentItem()))) && passed.toBool() && !m_qmlWarningOccurred; if (ok && (m_arguments.contains(u"--smoke-collections"_s) + || m_arguments.contains(u"--smoke-library-error"_s) || m_arguments.contains(u"--smoke-queue-drops"_s))) { QTimer::singleShot(250, this, [this, window] { const auto shot = m_arguments.indexOf(u"--screenshot"_s); diff --git a/opennow-qt/tests/LibraryErrorAcceptance.qml b/opennow-qt/tests/LibraryErrorAcceptance.qml new file mode 100644 index 000000000..208eaabf8 --- /dev/null +++ b/opennow-qt/tests/LibraryErrorAcceptance.qml @@ -0,0 +1,82 @@ +import QtQuick +import OpenNOW + +QtObject { + property QtObject client: QtObject { + property string state: "ready" + property string lastError: "" + property int sequence: 0 + property var requests: [] + signal responseReceived(string requestId, var result) + signal requestFailed(string requestId, string code, string message) + signal eventReceived(string name, var payload) + function markUiReady() {} + function logShellDiagnostic(message) {} + function request(method, params, timeout) { + const id = "library-error-test-" + (++sequence) + requests.push({id: id, method: method, params: params}) + return id + } + function cancel(id) { requestFailed(id, "cancelled", "Cancelled"); return true } + } + + function check(condition, message) { + if (!condition) + throw new Error("Library error acceptance: " + message) + } + + function find(item, name) { + if (item.objectName === name) + return item + for (const child of item.children || []) { + const found = find(child, name) + if (found) + return found + } + return null + } + + function run(root) { + const status = find(root, "libraryErrorText") + const retry = find(root, "libraryRetry") + const grid = find(root, "libraryGameGrid") + check(status && retry && grid, "Library status controls missing") + const games = ["First game", "Second game", "Third game"].map((title, index) => ({ + id: "library-game-" + index, uuid: "library-game-" + index, title: title, + imageUrl: "qrc:/qt/qml/OpenNOW/res/brand/desktop-renew.jpg", + heroImageUrl: "qrc:/qt/qml/OpenNOW/res/brand/desktop-renew.jpg", + availableStores: ["Steam"], genres: [], + variants: [{id: "library-game-" + index, store: "Steam", inLibrary: true}] + })) + ShellStore.authSession = {user: {userId: "library-acceptance", displayName: "Library Test"}} + ShellStore.reloadCatalogForSession() + ShellStore.refreshCatalog("persistent query") + client.requestFailed(ShellStore.catalogRequestId, "upstream_error", "Library HTTP 503") + check(status.visible && retry.visible && status.text.indexOf("HTTP 503") >= 0, + "Empty library failure hidden") + retry.clicked() + const request = client.requests.find(value => value.id === ShellStore.catalogRequestId) + check(request && request.params.cursor === "" && request.params.searchQuery === "persistent query", + "Retry did not preserve the query and restart pagination") + client.responseReceived(ShellStore.catalogRequestId, { + games: games, totalCount: 1200, hasNextPage: true, nextCursor: "page-one" + }) + check(!status.visible && ShellStore.catalogGames.length === 3, "Recovery did not display games") + ShellStore.catalogOwnerState.requestCatalogPage() + client.responseReceived(ShellStore.catalogRequestId, { + games: [], totalCount: 1200, hasNextPage: true, nextCursor: "page-one" + }) + check(ShellStore.catalogState === "error" && status.visible && retry.visible, + "Partial library protocol failure hidden") + check(status.text.indexOf("Loaded 3 games.") >= 0 && status.text.indexOf("invalid page") >= 0, + "Partial progress and error are not both visible") + check(grid.visible && grid.count === 3 && find(root, "libraryGameGrid") === grid, + "Error replaced the game grid") + retry.clicked() + check(!status.visible && ShellStore.catalogGames.length === 3, "Retry discarded partial games") + client.requestFailed(ShellStore.catalogRequestId, "upstream_error", "Library HTTP 503. Try again to load the remaining games.") + check(status.visible && retry.enabled && grid.count === 3, + "Partial upstream failure is not recoverable") + return true + } +} diff --git a/opennow-qt/tests/catalogpaging/tst_librarypaging.qml b/opennow-qt/tests/catalogpaging/tst_librarypaging.qml new file mode 100644 index 000000000..f4514e3b9 --- /dev/null +++ b/opennow-qt/tests/catalogpaging/tst_librarypaging.qml @@ -0,0 +1,316 @@ +import QtQuick +import QtTest +import "../../qml/state/catalog" + +TestCase { + id: test + name: "LibraryPaging" + property string lastError: "" + property var client: QtObject { + property int sequence: 0 + property var requests: [] + property var cancelled: [] + property bool rejectRequest: false + signal responseReceived(string requestId, var result) + signal requestFailed(string requestId, string code, string message) + function request(method, params, timeout) { + if (rejectRequest) + return "" + const id = "library-test-" + (++sequence) + requests.push({id: id, method: method, params: params, timeout: timeout}) + return id + } + function cancel(id) { + cancelled.push(id) + requestFailed(id, "cancelled", "Cancelled") + return true + } + } + property CatalogState catalog: CatalogState { + coreClient: test.client + appController: null + ready: false + signedIn: true + settings: ({}) + setSetting: function() { return "" } + applySetting: function() {} + onErrorReported: message => test.lastError = message + } + property Connections responses: Connections { + target: test.client + function onResponseReceived(requestId, result) { + if (requestId !== "" && requestId === test.catalog.catalogRequestId) + test.catalog.acceptCatalog(result) + } + function onRequestFailed(requestId, code, message) { + if (requestId !== "" && requestId === test.catalog.catalogRequestId) + test.catalog.failCatalog(message) + } + } + + function init() { + catalog.ready = false + catalog.resetCatalog() + catalog.signedIn = true + client.requests = [] + client.cancelled = [] + client.rejectRequest = false + lastError = "" + catalog.ready = true + } + + function cleanup() { + catalog.ready = false + } + + function game(id) { + return {id: id, uuid: id, title: "Game " + id} + } + + function page(games, cursor, more) { + return {games: games, count: games.length, totalCount: 15000, + source: "account-library", fetchedAt: "2026-09-14T00:00:00Z", + nextCursor: cursor, hasNextPage: more} + } + + function respond(result) { + verify(catalog.catalogRequestId !== "", "No active request") + client.responseReceived(catalog.catalogRequestId, result) + } + + function nextRequest() { + tryVerify(function() { return catalog.catalogRequestId !== "" }, 1000) + return client.requests[client.requests.length - 1] + } + + function test_largeLibraryIsProgressiveAndDeduplicated() { + catalog.refreshCatalog("persistent query") + for (let index = 0; index < 12; ++index) { + const request = nextRequest() + compare(request.method, "catalog.library.list") + compare(request.params.limit, 100) + compare(request.params.searchQuery, "persistent query") + compare(request.params.cursor, index === 0 ? "" : "page-" + index) + const games = [game("shared")] + for (let row = 0; row < 99; ++row) + games.push(game("game-" + index + "-" + row)) + const count = client.requests.length + respond(page(games, "page-" + (index + 1), index < 11)) + compare(client.requests.length, count, "Continuation must not recurse synchronously") + compare(catalog.catalogGames.length, 1 + (index + 1) * 99) + compare(catalog.catalogState, index < 11 ? "loading" : "ready") + compare(catalog.catalogLoading, index < 11) + if (index < 11) { + catalog.refreshCatalog("persistent query") + compare(client.requests.length, count, "Refresh during timer gap restarted the walk") + } + } + compare(catalog.catalogGames.length, 1189) + } + + function test_emptyFilteredPagesContinue() { + catalog.refreshCatalog("rare match") + respond(page([], "empty-one", true)) + compare(catalog.catalogGames.length, 0) + compare(catalog.catalogState, "loading") + compare(nextRequest().params.searchQuery, "rare match") + respond(page([], "empty-two", true)) + compare(nextRequest().params.cursor, "empty-two") + respond(page([game("match")], "", false)) + compare(catalog.catalogState, "ready") + compare(catalog.catalogGames[0].id, "match") + verify(!catalog.catalogLoading) + } + + function test_invalidPage_data() { + return [ + {tag: "missing-cursor", result: {games: [], hasNextPage: true}}, + {tag: "empty-cursor", result: page([], "", true)}, + {tag: "whitespace-cursor", result: page([], " ", true)}, + {tag: "numeric-cursor", result: page([], 12, true)}, + {tag: "missing-more", result: {games: [], nextCursor: ""}}, + {tag: "string-more", result: page([], "next", "true")}, + {tag: "invalid-games", result: page({}, "", false)}, + {tag: "oversized-page", result: page(Array(101).fill(game("too-many")), "", false)} + ] + } + + function test_invalidPage(data) { + catalog.refreshCatalog("") + respond(data.result) + compare(catalog.catalogState, "error") + verify(catalog.catalogError.indexOf("invalid page") >= 0) + compare(lastError, catalog.catalogError) + verify(!catalog.catalogLoading) + wait(30) + compare(client.requests.length, 1) + } + + function test_cyclicCursorRetainsPartialGamesAndReportsError() { + catalog.refreshCatalog("") + respond(page([game("first")], "one", true)) + nextRequest() + respond(page([game("second")], "two", true)) + nextRequest() + respond(page([game("third")], "one", true)) + compare(catalog.catalogState, "error") + compare(catalog.catalogGames.length, 2) + verify(lastError.length > 0) + verify(!catalog.catalogLoading) + } + + function test_opaqueCursorsAndIdentitiesDoNotCollideWithObjectPrototype() { + catalog.refreshCatalog("") + respond(page([game("__proto__")], "__proto__", true)) + nextRequest() + respond(page([game("constructor")], "constructor", true)) + nextRequest() + respond(page([game("toString")], "", false)) + compare(catalog.catalogGames.length, 3) + compare(catalog.catalogState, "ready") + } + + function test_pageLimit_data() { + return [{tag: "exact-limit", more: false}, {tag: "over-limit", more: true}] + } + + function test_pageLimit(data) { + catalog.refreshCatalog("") + for (let index = 0; index < 150; ++index) { + nextRequest() + respond(page([game("game-" + index)], "cursor-" + index, index < 149 || data.more)) + } + compare(client.requests.length, 150) + compare(catalog.catalogGames.length, 150) + compare(catalog.catalogState, data.more ? "error" : "ready") + compare(catalog.catalogError.indexOf("page limit") >= 0, data.more) + verify(!catalog.catalogLoading) + wait(30) + compare(client.requests.length, 150) + } + + function test_failureThenRetryReplacesSnapshot() { + catalog.refreshCatalog("query") + respond(page([game("old")], "next", true)) + const failed = nextRequest() + client.requestFailed(failed.id, "upstream_error", "Library HTTP 503") + compare(catalog.catalogGames.length, 1) + compare(catalog.catalogState, "error") + compare(catalog.catalogError, "Library HTTP 503") + compare(lastError, "Library HTTP 503") + verify(!catalog.catalogLoading) + catalog.retryCatalog() + compare(catalog.catalogState, "refreshing") + compare(catalog.catalogError, "") + compare(nextRequest().params.cursor, "") + compare(nextRequest().params.searchQuery, "query") + respond(page([game("new")], "", false)) + compare(catalog.catalogGames.length, 1) + compare(catalog.catalogGames[0].id, "new") + compare(catalog.catalogState, "ready") + } + + function test_requestCannotStart() { + client.rejectRequest = true + catalog.refreshCatalog("") + compare(catalog.catalogState, "error") + verify(catalog.catalogError.indexOf("Could not start") >= 0) + verify(!catalog.catalogLoading) + } + + function test_newSearchCancelsObsoleteRequest() { + catalog.refreshCatalog("old") + const old = catalog.catalogRequestId + catalog.refreshCatalog("new") + compare(client.cancelled[0], old) + const current = catalog.catalogRequestId + client.responseReceived(old, page([game("stale")], "obsolete", true)) + client.requestFailed(old, "network", "Stale error") + compare(catalog.catalogRequestId, current) + compare(catalog.catalogError, "") + compare(catalog.catalogGames.length, 0) + respond(page([game("new")], "", false)) + compare(catalog.catalogGames[0].id, "new") + } + + function test_accountResetDuringTimerGap() { + catalog.refreshCatalog("account A") + respond(page([game("private")], "account-a", true)) + catalog.reloadCatalogForSession() + compare(catalog.catalogGames.length, 0) + compare(catalog.catalogTotalCount, 0) + compare(catalog.selectedGame, null) + compare(nextRequest().params.cursor, "") + compare(nextRequest().params.searchQuery, "") + respond(page([game("account-b")], "", false)) + wait(30) + compare(client.requests.length, 2) + compare(catalog.catalogGames[0].id, "account-b") + } + + function test_disconnectCancelsAndClearsAccountData_data() { + return [{tag: "in-flight", timerGap: false}, {tag: "timer-gap", timerGap: true}] + } + + function test_disconnectCancelsAndClearsAccountData(data) { + catalog.refreshCatalog("account") + respond(page([game("private")], "next", true)) + if (!data.timerGap) + nextRequest() + const old = catalog.catalogRequestId + const count = client.requests.length + catalog.ready = false + compare(catalog.catalogGames.length, 0) + compare(catalog.catalogTotalCount, 0) + compare(catalog.selectedGame, null) + compare(catalog.catalogState, "idle") + verify(!catalog.catalogLoading) + if (old !== "") { + verify(client.cancelled.indexOf(old) >= 0) + client.responseReceived(old, page([game("stale")], "", false)) + } + wait(30) + compare(client.requests.length, count) + catalog.ready = true + catalog.refreshCatalog("") + compare(nextRequest().params.cursor, "") + respond(page([game("restored")], "", false)) + compare(catalog.catalogGames[0].id, "restored") + } + + function test_signOutStopsContinuationAndPublicRemainsOneShot() { + catalog.refreshCatalog("") + respond(page([game("private")], "next", true)) + catalog.signedIn = false + verify(!catalog.catalogLoading) + compare(catalog.catalogGames.length, 0) + catalog.refreshCatalog("public query") + const request = nextRequest() + compare(request.method, "catalog.public.list") + compare(request.params.limit, 360) + compare(request.params.searchQuery, "public query") + verify(request.params.cursor === undefined) + respond({games: [game("public")], totalCount: 360}) + compare(catalog.catalogState, "ready") + wait(30) + compare(client.requests.length, 2) + catalog.refreshCatalog("") + compare(catalog.catalogState, "refreshing") + compare(catalog.catalogGames[0].id, "public") + } + + function test_publicSnapshotSurvivesDisconnect() { + catalog.signedIn = false + catalog.refreshCatalog("") + respond({games: [game("public")], totalCount: 360}) + catalog.ready = false + compare(catalog.catalogGames[0].id, "public") + compare(catalog.catalogState, "ready") + catalog.ready = true + catalog.refreshCatalog("") + compare(catalog.catalogState, "refreshing") + compare(catalog.catalogGames[0].id, "public") + respond({games: [game("fresh-public")], totalCount: 360}) + compare(catalog.catalogGames[0].id, "fresh-public") + } +} diff --git a/opennow-qt/tests/tst_catalogpaging.cpp b/opennow-qt/tests/tst_catalogpaging.cpp new file mode 100644 index 000000000..1359ff2d8 --- /dev/null +++ b/opennow-qt/tests/tst_catalogpaging.cpp @@ -0,0 +1,3 @@ +#include + +QUICK_TEST_MAIN(catalogpaging)