mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
Fix Game Pass library sync when subscription plan rows are omitted (#1193)
This commit is contained in:
1 parent
49d1311808
commit
ece1df0da8
6 files changed
+185
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<String, String> {
|
||||
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map<String, String> {
|
||||
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<String>? = if (json.has("subscriptions")) {
|
||||
val subscriptions = json.getJSONArray("subscriptions")
|
||||
require(subscriptions.length() <= plans.size)
|
||||
val seen = HashSet<String>()
|
||||
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<String, JSONObject>()
|
||||
@@ -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<String> = 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()
|
||||
|
||||
@@ -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<String>()
|
||||
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)))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<Vec<Value>, &'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<Vec<Value>, &'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<Value, &'static str> {
|
||||
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::<Vec<_>>()}));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user