From ece1df0da8d0d4e35022dfe2df502623cb9a0f86 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:14:06 -0400 Subject: [PATCH] Fix Game Pass library sync when subscription plan rows are omitted (#1193) --- .../launcher/stores/xbox/XboxBackend.kt | 8 +- .../launcher/stores/xbox/XboxCatalog.kt | 26 ++++- .../launcher/stores/xbox/XboxCatalogTest.kt | 49 ++++++++++ tools/xbox/README.md | 9 +- .../droiddeck-library-subscriptions.patch | 95 +++++++++++++++++++ tools/xbox/prepare.py | 7 +- 6 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tools/xbox/droiddeck-library-subscriptions.patch diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index c8f1d27d1..24d7ff622 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -54,8 +54,12 @@ object XboxBackend : StoreBackend { } } if (cached != null && !cached.needsRefresh(force)) return@Thread - val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market) - val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() } + val response = Xodus.run(app, listOf("library", "--json", "--market", market)) + val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") }) + val products = XboxCatalog.rawProducts(licenses.keys.toList(), market) + val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() } + StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " + + "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}") synchronized(this) { if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread access = licenses diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index 276919ec3..fa7a6b5fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -11,10 +11,25 @@ internal object XboxCatalog { private val productId = Regex("[A-Z0-9]{12}") private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") - fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map { + fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map { require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds require(age in -60..299) + val memberships: Set? = if (json.has("subscriptions")) { + val subscriptions = json.getJSONArray("subscriptions") + require(subscriptions.length() <= plans.size) + val seen = HashSet() + buildSet { + for (i in 0 until subscriptions.length()) { + val row = subscriptions.getJSONObject(i) + val id = row.getString("productId") + require(id in plans && seen.add(id)) + val status = row.getString("status") + val expiry = timestamp(row.getString("endDate")) + if (status == "Active" && expiry.isAfter(now)) add(id) + } + } + } else null val array = json.getJSONArray("entitlements") require(array.length() <= 10_000) val rows = LinkedHashMap() @@ -30,6 +45,7 @@ internal object XboxCatalog { } fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") && row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now) + fun activePlan(id: String) = rows[id]?.let(::active) ?: (id in memberships.orEmpty()) fun kind(id: String, visited: Set = emptySet()): String? { if (id in blocked || id in visited || visited.size >= 16) return null val row = rows[id] ?: return null @@ -37,10 +53,14 @@ internal object XboxCatalog { val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } if (parent == null) return if (id in plans) null else "owned" if (parent in blocked) return null - if (parent in plans) return if (rows[parent]?.let(::active) == true) "gamepass" else null + if (parent in plans) return if (activePlan(parent)) "gamepass" else null return kind(parent, visited + id) } - return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() + val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() + val missingPlan = rows.values.count { row -> row.optString("satisfiedByProductId").let { it in plans && !activePlan(it) } } + report("library entitlements=${array.length()} invalid=${array.length() - rows.size} restricted=${rows.values.count { !active(it) }} " + + "unverified-plan=$missingPlan owned=${result.values.count { it == "owned" }} gamepass=${result.values.count { it == "gamepass" }}") + return result } private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant() diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt index 683696f14..c5e6927e7 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -19,6 +19,55 @@ class XboxCatalogTest { private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test") .put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList())) private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now) + private fun membership() = JSONObject().put("productId", plan).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z") + private fun withMembership(json: JSONObject, vararg plans: JSONObject) = json.put("subscriptions", JSONArray(plans.toList())) + + @Test fun separateMembershipRestoresSubscriptionChildrenWithoutInventingOwnership() { + val json = withMembership(envelope(row(game), row(parent, plan)), membership()) + assertEquals(mapOf(game to "owned", parent to "gamepass"), XboxCatalog.access(json, "test@example.test", "US", now)) + assertTrue(XboxCatalog.access(withMembership(envelope(), membership()), "test@example.test", "US", now).isEmpty()) + assertEquals(mapOf(game to "gamepass", parent to "gamepass"), XboxCatalog.access( + withMembership(envelope(row(game, parent), row(parent, plan)), membership()), "test@example.test", "US", now)) + } + + @Test fun separateMembershipPreservesProductRestrictionsAndExistingPlanEvidence() { + for (member in listOf(membership().put("status", "Expired"), membership().put("endDate", now.toString()))) { + assertTrue(XboxCatalog.access(withMembership(envelope(row(game, plan)), member), "test@example.test", "US", now).isEmpty()) + } + assertEquals(mapOf(game to "gamepass"), XboxCatalog.access( + withMembership(envelope(row(game, plan), row(plan))), "test@example.test", "US", now)) + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to now.toString())) { + for (rows in listOf(arrayOf(row(game, plan).put(key, value)), arrayOf(row(game, plan), row(plan).put(key, value)))) { + assertTrue(XboxCatalog.access(withMembership(envelope(*rows), membership()), "test@example.test", "US", now).isEmpty()) + } + } + } + + @Test fun malformedMembershipFailsTheRefreshInsteadOfBecomingAnEmptySuccessfulLibrary() { + for (json in listOf( + withMembership(envelope(row(game, plan)), membership().put("endDate", "invalid")), + withMembership(envelope(row(game, plan)), membership(), membership()), + withMembership(envelope(row(game, plan)), membership().put("productId", game)), + envelope(row(game, plan)).put("subscriptions", JSONObject.NULL), + )) { + assertThrows(Exception::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun membershipCannotCrossAccountMarketOrFreshnessBoundaries() { + for ((key, value) in listOf("account" to "other@example.test", "market" to "GB", "checkedAt" to "2020-01-01T00:00:00Z")) { + val json = withMembership(envelope(row(game, plan)).put(key, value), membership()) + assertThrows(IllegalArgumentException::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun libraryDiagnosticsContainCountsWithoutAccountOrProductIdentifiers() { + val lines = mutableListOf() + XboxCatalog.access(envelope(row(game), row(parent, plan)), "test@example.test", "US", now, lines::add) + assertEquals(listOf("library entitlements=2 invalid=0 restricted=0 unverified-plan=1 owned=1 gamepass=0"), lines) + assertFalse(lines.single().contains(game) || lines.single().contains(parent) || lines.single().contains("@")) + } @Test fun purchasedAndSubscriptionAccessRemainDistinct() { assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan), row(plan))) diff --git a/tools/xbox/README.md b/tools/xbox/README.md index b06825748..abe39f8c9 100644 --- a/tools/xbox/README.md +++ b/tools/xbox/README.md @@ -5,7 +5,14 @@ service. It does not install the Nexus desktop launcher or x86 Linux Wine. `backend.lock.json` pins Xodus, the Nexus commit, Rust, and the ordered patch checksums. `runtime.lock.json` pins the Nexus Windows runtime release and each extracted PE DLL. `prepare.py` verifies both source revisions and all Nexus patch -checksums before applying the two local transport/launch patches. +checksums before applying the local transport, launch and library patches. + +`droiddeck-library-subscriptions.patch` queries fresh subscription membership +alongside product entitlements using the same Store session, account and market. +Android can validate a Game Pass child's parent plan when that plan is absent +from the product response. A failed membership query fails the refresh and +preserves the previous library. Empty membership results add no plan evidence; +valid plans already present in the product response retain their existing checks. Build on AArch64 Ubuntu 24.04 with Rust from the lock, a C/C++ compiler, OpenSSL development headers, pkg-config, protoc, CMake, Git, Python 3 and xz: diff --git a/tools/xbox/droiddeck-library-subscriptions.patch b/tools/xbox/droiddeck-library-subscriptions.patch new file mode 100644 index 000000000..833fab085 --- /dev/null +++ b/tools/xbox/droiddeck-library-subscriptions.patch @@ -0,0 +1,95 @@ +--- a/crates/xodus-cli/src/commands/library.rs ++++ b/crates/xodus-cli/src/commands/library.rs +@@ -96,6 +96,48 @@ + eprintln!("NEXUS_LIBRARY_DIAGNOSTIC_V1 {stage} {status} {count}"); + } + ++const SUBSCRIPTION_PLANS: &[&str] = &[ ++ "CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", ++ "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD", ++]; ++ ++fn subscription_rows(body: &Value) -> Result, &'static str> { ++ let rows = body["entitlements"].as_object().ok_or("subscriptions-shape")?; ++ if rows.len() > SUBSCRIPTION_PLANS.len() { return Err("subscriptions-count"); } ++ let mut plans = Vec::new(); ++ for (id, row) in rows { ++ if !SUBSCRIPTION_PLANS.contains(&id.as_str()) { return Err("subscriptions-product"); } ++ let status = row["status"].as_str().ok_or("subscriptions-status")?; ++ if status != "Active" { continue; } ++ let expiry = row["endDate"].as_str().filter(|s| s.len() < 50) ++ .filter(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()).ok_or("subscriptions-expiry")?; ++ plans.push(json!({"productId":id,"status":status,"endDate":expiry})); ++ } ++ Ok(plans) ++} ++ ++async fn subscriptions(client: &reqwest::Client, ticket: &str, market: &str) -> Result, &'static str> { ++ let url = format!("https://beige.xboxservices.com/PCGAFD/entitlements/subscriptions?market={market}&language=en-US&appVersion=0.2.3"); ++ let cv = format!("{}.0", STANDARD_NO_PAD.encode(uuid::Uuid::new_v4().as_bytes())); ++ let mut response = client.post(url) ++ .header("Authorization", ticket).header("X-MS-API-Version", "1.1") ++ .header("Calling-App-Name", "Xodus-Nexus").header("MS-CV", cv) ++ .json(&json!({"productIds":SUBSCRIPTION_PLANS})) ++ .send().await.map_err(|_| "subscriptions-request")?; ++ if !response.status().is_success() { ++ diagnostic("subscriptions-http", response.status().as_u16(), 0); ++ return Err("subscriptions-http"); ++ } ++ if response.content_length().is_some_and(|n| n > 262_144) { return Err("subscriptions-size"); } ++ let mut bytes = Vec::new(); ++ while let Some(chunk) = response.chunk().await.map_err(|_| "subscriptions-read")? { ++ if bytes.len() + chunk.len() > 262_144 { return Err("subscriptions-size"); } ++ bytes.extend_from_slice(&chunk); ++ } ++ let body: Value = serde_json::from_slice(&bytes).map_err(|_| "subscriptions-json")?; ++ subscription_rows(&body) ++} ++ + async fn query(tokens: &TokenManager, market: &str) -> Result { + if market.len() != 2 || !market.bytes().all(|b| b.is_ascii_uppercase()) { + return Err("market"); +@@ -155,6 +197,7 @@ + let page: Value = serde_json::from_slice(&bytes).map_err(|_| "entitlements-json")?; + all.add(&page)?; + if continuation.is_none() { ++ let subscriptions = subscriptions(&client, &ticket, market).await?; + if tokens.get_user().map_err(|_| "account-check")?.username != account { + return Err("account-changed"); + } +@@ -162,7 +205,7 @@ + for (reason, count) in &all.reasons { diagnostic(reason, 0, *count); } + diagnostic(if all.skipped > 0 { "partial" } else { "complete" }, 0, all.skipped); + return Ok(json!({"schema":1,"account":account,"market":market, +- "checkedAt":chrono::Utc::now().to_rfc3339(),"skippedEntitlements":all.skipped, ++ "checkedAt":chrono::Utc::now().to_rfc3339(),"skippedEntitlements":all.skipped,"subscriptions":subscriptions, + "entitlements":all.entries.into_values().collect::>()})); + } + } +@@ -200,6 +243,26 @@ + #[cfg(test)] + mod tests { + use super::*; ++ #[test] ++ fn subscription_membership_is_separate_from_product_entitlements() { ++ let body = json!({"entitlements":{"CFQ7TTC0KGQ8":{ ++ "status":"Active","endDate":"2099-01-01T00:00:00Z","autoRenew":true,"private":"not-output"}}}); ++ assert_eq!(subscription_rows(&body).unwrap(), vec![json!({"productId":"CFQ7TTC0KGQ8", ++ "status":"Active","endDate":"2099-01-01T00:00:00Z"})]); ++ assert!(subscription_rows(&json!({"entitlements":{}})).unwrap().is_empty()); ++ assert!(subscription_rows(&json!({"entitlements":{"CFQ7TTC0KGQ8":{"status":"Expired"}}})).unwrap().is_empty()); ++ } ++ ++ #[test] ++ fn malformed_membership_is_an_error_not_an_empty_subscription_list() { ++ for body in [json!({}), json!({"entitlements":[]}), ++ json!({"entitlements":{"unknown":{"status":"Active"}}}), ++ json!({"entitlements":{"CFQ7TTC0KGQ8":{"status":true}}}), ++ json!({"entitlements":{"CFQ7TTC0KGQ8":{"status":"Active","endDate":"invalid"}}})] { ++ assert!(subscription_rows(&body).is_err()); ++ } ++ } ++ + const A: &str = "9AAAAAAAAAAA"; + const B: &str = "9BBBBBBBBBBB"; + const C: &str = "9CCCCCCCCCCC"; diff --git a/tools/xbox/prepare.py b/tools/xbox/prepare.py index ff7bb15c6..30afc1b01 100755 --- a/tools/xbox/prepare.py +++ b/tools/xbox/prepare.py @@ -9,7 +9,8 @@ import sys recipe = Path(__file__).resolve().parent cache = Path(sys.argv[1]).resolve() lock = json.loads((recipe / 'backend.lock.json').read_text()) -identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + (recipe / 'droiddeck-credentials.patch').read_bytes() + (recipe / 'droiddeck-launch-view.patch').read_bytes()).hexdigest() +local_patches = ['droiddeck-credentials.patch', 'droiddeck-launch-view.patch', 'droiddeck-library-subscriptions.patch'] +identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + b''.join((recipe / name).read_bytes() for name in local_patches)).hexdigest() source = cache / ('source-' + identity[:16]) if not (source / '.prepared').is_file(): if source.exists(): @@ -29,8 +30,8 @@ if not (source / '.prepared').is_file(): file = nexus / 'assets' / patch['file'] assert hashlib.sha256(file.read_bytes()).hexdigest() == patch['sha256'] subprocess.run(['git', '-C', str(source), 'apply', str(file)], check=True) - subprocess.run(['git', '-C', str(source), 'apply', str(recipe / 'droiddeck-credentials.patch')], check=True) - subprocess.run(['git', '-C', str(source), 'apply', str(recipe / 'droiddeck-launch-view.patch')], check=True) + for name in local_patches: + subprocess.run(['git', '-C', str(source), 'apply', str(recipe / name)], check=True) (source / '.prepared').write_text(identity + '\n') assert (source / '.prepared').read_text().strip() == identity print(source)