From 9786526c1f87ddf3b2a17c1c0e711969e2cef2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Wed, 3 Jun 2026 12:44:02 +0200 Subject: [PATCH 01/72] PS Plus Cloud Play: region catalog, PS4/PS5 streaming, cross-gen library + ownership (Qt/iOS/Android) Reworks PlayStation Plus Cloud Play end to end so it works in English-only regions (e.g. Hungary), streams owned PS4 and PS5 titles correctly, shows cross-gen editions, and classifies ownership (full game vs trial vs add-on). Applied across the Qt (C++/QML), iOS (Swift) and Android (Kotlin) clients. Catalog / region - Store-locale fallback chain (lang-COUNTRY -> en-COUNTRY -> en-US) so the imagic catalog loads in every region; the validated locale persists. - Accept PS4 (not just PS5) cloud titles in the merge; capture streamingSupported=false subscription titles into the library-stream supplement from every subscription list (these stream via the legacy Kamaji/kratos path even though they are absent from public cloud browse). - Scope the views: Game Catalog = PS Plus subscription lists (plusCatalog tag); Library "all" = full streamable universe + owned; Library "owned" = owned. - Catalog falls back to the imagic catalog when the legacy PS Now /user/stores browse 404s (it does in many regions). - Dedupe per game per platform so cross-gen PS4/PS5 editions both appear. - Broaden the owned-games filter; match owned entitlements by conceptId in addition to product id / stable key. Owned-title streaming (entitlement resolution) - PS5 streams the owned PRODUCT id, not the entitlement id: a cross-gen upgrade (PS4 purchase + free PS5 copy) carries a stale original-SKU entitlement id that Gaikai's cloud catalog has no game for (-> noGameForEntitlementId); product_id is the current streamable SKU. (Fixed Alan Wake Remastered, Death Stranding DC.) - When several SKUs collapse to one edition (base game + bonus/upgrade/avatars), keep the canonical full-game entitlement -- the one whose entitlement id EQUALS its product_id. Package/feature flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3), so the id==product_id signal is what selects the real game over a DLC product Gaikai can't stream. - PS4 streams the catalog's streamable variant (e.g. God of War's "...N" SKU whose Kamaji container holds the PS-Now license_type=4 SKU), not the owned download SKU; derive the streaming platform from the owned product (cross-gen catalog entries list the other generation). PS4 (CUSA) -> Kamaji/psnow; PS5 (PPSA) -> direct Gaikai (cronos). Datacenter ping no longer hard-fails on a measurement error (Qt). Ownership classification (feature_type) - feature_type 3/5 = full game owned, 1 = trial / free-to-play, 0 = add-on/DLC. - Drop feature_type==0 extras from the owned set (DLC/themes/avatars are never a base game). Keep trials and free-to-play; a trial is kept as its own card so the full version still shows separately as "Add Game" (a trial does not collapse into the full-game catalog entry). Cross-gen owned-library split - Key owned-edition identity on conceptId + PLATFORM (matching the catalog tab) in both the owned cross-reference dedupe and the library merge, so a title owned on PS4 and PS5 (e.g. Days Gone + Days Gone Remastered) shows two separate, independently-streamable cards. Platform labels - Derive PS4/PS5 from the title id (CUSA/PPSA) instead of the hard-coded platform="ps5" -- Android at display time (CloudGameAdapter), iOS in the parser/deserializer (self-correcting the cache); Qt already did. Catalog ownership UX - Cross-reference the catalog against owned entitlements (mark-only): owned -> "Stream", non-owned modern cloud titles -> "Add Game"; OWNED / NOT OWNED badge. Build - Remove the committed machine-specific org.gradle.java.home (an absolute Windows path that broke every non-Windows / CI Gradle build); document selecting the JDK 21 daemon per-machine via JAVA_HOME / ~/.gradle / the IDE Gradle JDK setting. Verified - Qt/macOS (Hungary / PS Plus Premium): catalog loads region-wide; owned PS4 (God of War) and PS5 (Alan Wake Remastered, Death Stranding DC) stream from Library and Catalog; cross-gen Days Gone shows + streams both editions; a trial (Cyberpunk) shows its own Stream card plus an "Add Game" card for the full version; adding the PS5 Remaster lets Spider-Man stream; labels and OWNED/NOT OWNED badges correct. - iOS: swiftc -parse clean. Android: compiles (compileDebugKotlin). Mobile not device-re-tested for the latest streaming/ownership pass. Upstream reconciliation (PR #15) - Sits on top of the merged "PS5 cloud ownership matching" PR (#15) and incorporates its useful additions: bundle-sibling expansion (a bundle entitlement, e.g. RE7 Gold, expands to its component games via componentIdsByProductId) and stable-key matching on the entitlement id. These are grafted onto our cross-reference as additive fallbacks (they only fire when our direct cascade finds no match), keeping our dedupe (conceptId+platform + canonical-entitlement rank), feature_type filtering and field convention where the two approaches differed. Known limitations - Some PS Plus titles are download-only (no cloud-streaming SKU, e.g. Far Cry 5, original Spider-Man PS4): indistinguishable in the catalog from streamable PS4 titles, so they appear but fail at sessions/start with noGameForEntitlementId. - PS5 catalog-only titles must be added to the library externally (PS App) first. - PS3 titles absent from the modern imagic API; PS5 HEVC video-decode freeze is a separate pipeline issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metallic/chiaki/cloudplay/CloudLocale.kt | 25 + .../chiaki/cloudplay/api/PSKamajiSession.kt | 38 + .../cloudplay/api/PsCloudCatalogService.kt | 86 ++- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 267 +++++-- .../chiaki/cloudplay/model/CloudGame.kt | 4 +- .../repository/CloudGameRepository.kt | 135 +++- .../com/metallic/chiaki/common/Preferences.kt | 13 + .../metallic/chiaki/main/CloudGameAdapter.kt | 19 +- .../metallic/chiaki/main/CloudPlayFragment.kt | 12 +- android/gradle.properties | 7 +- gui/include/cloudcatalogbackend.h | 11 +- gui/src/cloudcatalogbackend.cpp | 709 ++++++++++++------ gui/src/cloudstreaming/psgaikaistreaming.cpp | 21 +- gui/src/cloudstreaming/pskamajisession.cpp | 71 +- gui/src/cloudstreamingbackend.cpp | 97 +-- gui/src/qml/CloudGameCard.qml | 71 +- gui/src/qml/CloudPlayView.qml | 176 ++++- ios/Pylux/Models/CloudModels.swift | 79 +- ios/Pylux/Services/CloudCatalogService.swift | 156 ++-- ios/Pylux/Services/PSKamajiSession.swift | 55 +- ios/Pylux/Services/PsCloudOwnership.swift | 200 +++-- ios/Pylux/Views/CloudPlayView.swift | 30 +- 22 files changed, 1653 insertions(+), 629 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt index a3d572e3..66f81380 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt @@ -25,6 +25,31 @@ object CloudLocale return "$lang-${cty.uppercase()}" } + /** + * Ordered store locales to try when fetching the catalog. Sony serves a fixed set of + * language-COUNTRY combinations: the country is always valid but the language may not be + * (a Hungarian-language account yields "hu-HU", which 404s, while "en-HU" works). Fall back + * to English for the same country, then en-US, so the catalog loads in every region. + * Each pair is (canonical "ll-CC" for storage, lowercased "ll-cc" for the imagic URL). + */ + fun fallbackChain(stored: String): List> + { + val (country, language) = parseStorePath(stored) + val seen = LinkedHashSet() + val chain = mutableListOf>() + fun add(lang: String, ctry: String) + { + val canonical = "$lang-$ctry" + val imagic = canonical.lowercase() + if (seen.add(imagic)) + chain.add(canonical to imagic) + } + add(language, country) + add("en", country) + add("en", "US") + return chain + } + /** Non-fatal warning when locale could not be learned from Kamaji (catalog may use en-US). */ fun unconfiguredWarning(): String = "Could not detect your PlayStation region. The catalog may not match your store." diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index b995de55..4a6675d1 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -412,6 +412,44 @@ class PSKamajiSession( } } + + // Full-game fallback: PS Plus catalog titles have no license_type==4 entitlement; their + // full-game entitlement is license_type 0 with packageType "*GD". Prefer the one whose id + // matches the requested product's title id so cross-gen picks the consistent platform. + if (streamingEntitlementId.isEmpty()) + { + val requestedTitleId = productId.split("-").getOrNull(1)?.split("_")?.firstOrNull() ?: "" + fun pickFullGameEntitlement(skuObj: JSONObject, requireTitleMatch: Boolean): Boolean + { + val ents = skuObj.optJSONArray("entitlements") ?: return false + for (j in 0 until ents.length()) + { + val ent = ents.getJSONObject(j) + val entId = ent.optString("id", "") + val pkg = ent.optString("packageType", "") + if (entId.isEmpty() || !pkg.endsWith("GD")) continue + if (requireTitleMatch && requestedTitleId.isNotEmpty() && !entId.contains(requestedTitleId)) continue + streamingEntitlementId = entId + sku = skuObj.optString("id", "") + Log.i(TAG, "Found full-game Entitlement ID (PS Plus catalog fallback): $streamingEntitlementId packageType: $pkg titleMatch: $requireTitleMatch") + return true + } + return false + } + for (requireTitleMatch in listOf(true, false)) + { + if (json.has("default_sku") && pickFullGameEntitlement(json.getJSONObject("default_sku"), requireTitleMatch)) break + if (streamingEntitlementId.isEmpty() && json.has("skus")) + { + val skus = json.getJSONArray("skus") + for (i in 0 until skus.length()) + { + if (pickFullGameEntitlement(skus.getJSONObject(i), requireTitleMatch)) break + } + } + if (streamingEntitlementId.isNotEmpty()) break + } + } // Try to extract platform from playable_platform if (json.has("playable_platform")) { diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt index e80a54b0..e927bd74 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt @@ -124,6 +124,7 @@ class PsCloudCatalogService productIdAliases: LinkedHashMap, ): Int { + val plusCatalog = isPlusCatalogList(categoryList) // subscription catalog vs all-ps5 universe var rows = 0 for (i in 0 until jsonArray.length()) { @@ -132,64 +133,80 @@ class PsCloudCatalogService for (j in 0 until games.length()) { val gameObj = games.getJSONObject(j) - if (!isPs5Game(gameObj)) + // Accept PS4 and PS5; the old PS5-only gate dropped PS4-only PS-Plus-catalog + // titles (e.g. God of War 2018) before they could reach the supplement below. + if (!isCloudDeviceGame(gameObj)) continue - if (categoryList == "plus-games-list" - && !gameObj.optBoolean("streamingSupported", false)) + // Subscription-catalog titles with streamingSupported=false → library-stream + // supplement, captured from EVERY subscription list (not just plus-games-list). + if (plusCatalog && !gameObj.optBoolean("streamingSupported", false)) { val productId = gameObj.optString("productId", "") if (productId.isNotEmpty()) + { + gameObj.put("plusCatalog", true) plusSupplementByProductId.putIfAbsent(productId, gameObj) + } continue } - if (!isPs5StreamingGame(gameObj)) + if (!isCloudStreamingGame(gameObj)) continue - val key = conceptKey(gameObj) + val key = editionKey(gameObj) // per game per platform (cross-gen split) val productId = gameObj.optString("productId", "") if (key.isEmpty() || productId.isEmpty()) continue if (byConceptId.containsKey(key)) { - val canonicalProductId = byConceptId[key]?.optString("productId", "") ?: "" + val existing = byConceptId[key] + val canonicalProductId = existing?.optString("productId", "") ?: "" if (canonicalProductId.isNotEmpty() && productId != canonicalProductId && !productIdAliases.containsKey(productId)) { productIdAliases[productId] = canonicalProductId } + // Lists fetch in parallel; upgrade the flag so subscription membership wins + // regardless of arrival order. + if (plusCatalog && existing != null && !existing.optBoolean("plusCatalog", false)) + existing.put("plusCatalog", true) continue } + gameObj.put("plusCatalog", plusCatalog) byConceptId[key] = gameObj } } return rows } - private fun isPs5Game(gameObj: JSONObject): Boolean + // PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not in these imagic lists). + // A PS4-only title such as God of War (2018) is streamable when owned even though it + // carries device ["PS4"], so the catalog must not discard it. + // The PS Plus subscription catalog = these curated lists (≈ what Sony lists). all-ps5-list is + // the full streamable universe and must NOT count as subscription catalog. + private fun isPlusCatalogList(categoryList: String): Boolean = + categoryList == "plus-games-list" || categoryList == "plus-classics-list" || + categoryList == "ubisoft-classics-list" || categoryList == "plus-monthly-games-list" + + private fun isCloudDeviceGame(gameObj: JSONObject): Boolean { val devices = gameObj.optJSONArray("device") ?: return false for (i in 0 until devices.length()) { - if (devices.optString(i) == "PS5") + val d = devices.optString(i) + if (d == "PS5" || d == "PS4") return true } return false } - private fun isPs5StreamingGame(gameObj: JSONObject): Boolean + private fun isCloudStreamingGame(gameObj: JSONObject): Boolean { if (!gameObj.optBoolean("streamingSupported", false)) return false - val devices = gameObj.optJSONArray("device") ?: return false - for (i in 0 until devices.length()) - { - if (devices.optString(i) == "PS5") - return true - } - return false + return isCloudDeviceGame(gameObj) } private fun conceptKey(gameObj: JSONObject): String @@ -205,6 +222,23 @@ class PsCloudCatalogService return gameObj.optString("productId", "") } + // Platform token from a product id (CUSA = PS4, PPSA = PS5). + private fun ps5PlatformToken(productId: String): String = when + { + productId.contains("PPSA") -> "ps5" + productId.contains("CUSA") -> "ps4" + else -> "" + } + + // Dedupe identity: one entry per game PER PLATFORM, so cross-gen PS4/PS5 editions (e.g. Deliver + // Us The Moon) both appear, while duplicate same-platform SKUs still collapse. + private fun editionKey(gameObj: JSONObject): String + { + val c = conceptKey(gameObj) + if (c.isEmpty()) return "" + return c + "|" + ps5PlatformToken(gameObj.optString("productId", "")) + } + private fun jsonToCloudGame(gameObj: JSONObject): CloudGame? { val productId = gameObj.optString("productId", "") @@ -261,11 +295,12 @@ class PsCloudCatalogService name = gameName, imageUrl = finalCoverUrl, landscapeImageUrl = finalLandscapeUrl, - platform = "ps5", + platform = ps5PlatformToken(productId).ifEmpty { "ps5" }, serviceType = "pscloud", conceptUrl = conceptUrl, conceptId = conceptKey(gameObj), - isOwned = false + isOwned = false, + plusCatalog = gameObj.optBoolean("plusCatalog", false) ) } @@ -315,16 +350,17 @@ class PsCloudCatalogService kotlinx.coroutines.delay(PsCloudOwnership.PAGE_COOLDOWN_MS) val rawEntitlements = fetchEntitlementsPaginated(oauthToken) - val componentIdsByProductId = HashMap>() - for (e in rawEntitlements) - { - if (e.productId.isNotEmpty() && e.id.isNotEmpty()) - componentIdsByProductId.getOrPut(e.productId) { mutableListOf() }.add(e.id) - } val filtered = PsCloudOwnership.filterOwnedPs5Games(rawEntitlements) + // Map each bundle product_id -> the entitlement ids sharing it, so a bundle (e.g. RE7 Gold) + // expands to its component games during cross-reference (upstream PR #15 bundle-sibling match). + val componentIds = mutableMapOf>() + for (ent in rawEntitlements) + if (ent.productId.isNotEmpty() && ent.id.isNotEmpty()) + componentIds.getOrPut(ent.productId) { mutableListOf() }.add(ent.id) + return PsCloudOwnership.crossReferenceOwnedGames( - filtered, publicCatalog, plusLibrarySupplement, productIdAliases, componentIdsByProductId + filtered, publicCatalog, plusLibrarySupplement, productIdAliases, componentIds ) } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt index 6ae7e5eb..0f6805e9 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt @@ -15,7 +15,9 @@ object PsCloudOwnership val productId: String, val activeFlag: Boolean, val packageType: String, - val name: String + val name: String, + val conceptId: String, + val featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC ) private data class CatalogIndex( @@ -26,25 +28,44 @@ object PsCloudOwnership fun filterOwnedPs5Games(entitlements: List): List { return entitlements.filter { ent -> - ent.packageType == "PSGD" && - ent.activeFlag && + // Previously required packageType == "PSGD" (PS5 only), which dropped owned PS4 + // titles (e.g. God of War 2018) and PS3 titles. Accept every active game entitlement; + // streamability is enforced downstream by the cross-reference (deduped by conceptId), + // so non-streamable / add-on entitlements are harmlessly dropped there. + ent.activeFlag && !ent.productId.startsWith("IP") && - !ent.productId.startsWith("SUB") + !ent.productId.startsWith("SUB") && + // Hide EXTRAS: feature_type==0 is DLC/add-ons/themes/avatars/tracks, never a base game + // (games are ft 1=trial/free or 3/5=full). Safe -- it can never hide a game. + ent.featureType != 0 } } + /** Normalize a conceptId (imagic encodes it as a number) to a non-empty string, else "". */ + private fun conceptIdString(value: Any?): String = when (value) + { + is Number -> value.toLong().let { if (it > 0) it.toString() else "" } + is String -> value + else -> "" + } + fun parseEntitlement(obj: JSONObject): Entitlement? { val id = obj.optString("id", "") if (id.isEmpty()) return null val gameMeta = obj.optJSONObject("game_meta") ?: JSONObject() val name = gameMeta.optString("name", id) + val conceptId = conceptIdString(gameMeta.opt("conceptId")) + .ifEmpty { conceptIdString(gameMeta.opt("concept_id")) } + .ifEmpty { conceptIdString(obj.opt("conceptId")) } return Entitlement( id = id, productId = obj.optString("product_id", ""), activeFlag = obj.optBoolean("active_flag", false), packageType = gameMeta.optString("package_type", ""), - name = name + name = name, + conceptId = conceptId, + featureType = obj.optInt("feature_type", 0) ) } @@ -66,106 +87,143 @@ object PsCloudOwnership val supplementMap = catalogMapFirstWins(plusLibrarySupplement) val browseStableKey = buildStableKeyIndex(publicCatalog) val supplementStableKey = buildStableKeyIndex(plusLibrarySupplement) + val browseByConcept = buildConceptIdIndex(publicCatalog) + val supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) val byKey = linkedMapOf() + val byKeyRank = mutableMapOf() - fun emitMatch(meta: CloudGame, ent: Entitlement) + // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping OUR + // convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct + // match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). + fun emit(meta: CloudGame, ent: Entitlement) { val displayName = meta.name.ifEmpty { ent.name } val game = meta.copy( name = displayName, isOwned = true, entitlementId = ent.id, - storeProductId = ent.productId + storeProductId = ent.productId, + featureType = ent.featureType ) val key = ownedDedupeKey(meta, ent) - val existing = byKey[key] - byKey[key] = if (existing == null) game else preferOwnedEntry(existing, game) + val candidateRank = ownedStreamRank(ent) + if (byKey[key] == null) + { + byKey[key] = game + byKeyRank[key] = candidateRank + } + // Keep the best streaming candidate: the canonical full-game entitlement (its product_id is + // the real streamable game, not a DLC/bonus product Gaikai rejects). + else if (candidateRank > (byKeyRank[key] ?: -1)) + { + byKey[key] = game + byKeyRank[key] = candidateRank + } } for (ent in filteredEntitlements) { + val stable = productIdStableKey(ent.productId) + val entStable = productIdStableKey(ent.id) val skipStableDemo = ent.name.contains("demo", ignoreCase = true) - val matches = mutableListOf() - - if (ent.productId.isNotEmpty() && catalogMap.containsKey(ent.productId)) - { - matches.add(catalogMap.getValue(ent.productId)) - } - else if (ent.id.isNotEmpty() && catalogMap.containsKey(ent.id)) - { - matches.add(catalogMap.getValue(ent.id)) - } - else if (ent.productId.isNotEmpty() && ent.id == ent.productId - && supplementMap.containsKey(ent.productId)) - { - matches.add(supplementMap.getValue(ent.productId)) + val meta = when { + ent.productId.isNotEmpty() && catalogMap.containsKey(ent.productId) -> + catalogMap[ent.productId] + ent.id.isNotEmpty() && catalogMap.containsKey(ent.id) -> + catalogMap[ent.id] + // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). + ent.conceptId.isNotEmpty() && browseByConcept.containsKey(ent.conceptId) -> + browseByConcept[ent.conceptId] + ent.conceptId.isNotEmpty() && supplementByConcept.containsKey(ent.conceptId) -> + supplementByConcept[ent.conceptId] + ent.productId.isNotEmpty() && ent.id == ent.productId + && supplementMap.containsKey(ent.productId) -> + supplementMap[ent.productId] + stable != null && !skipStableDemo && browseStableKey.containsKey(stable) -> + browseStableKey[stable] + stable != null && !skipStableDemo && supplementStableKey.containsKey(stable) -> + supplementStableKey[stable] + // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade + // entitlement ids whose stable key matches a catalog row even when product_id did not. + entStable != null && !skipStableDemo && browseStableKey.containsKey(entStable) -> + browseStableKey[entStable] + entStable != null && !skipStableDemo && supplementStableKey.containsKey(entStable) -> + supplementStableKey[entStable] + else -> null } - else + + if (meta != null) { - val entitlementStableKey = productIdStableKey(ent.id) - if (entitlementStableKey != null && !skipStableDemo - && browseStableKey.containsKey(entitlementStableKey)) - { - matches.add(browseStableKey.getValue(entitlementStableKey)) - } - else if (entitlementStableKey != null && !skipStableDemo - && supplementStableKey.containsKey(entitlementStableKey)) - { - matches.add(supplementStableKey.getValue(entitlementStableKey)) - } + emit(meta, ent) + continue } - if (matches.isEmpty()) + // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no + // direct catalog row, but its component entitlement ids each map to a component game. + val seenPids = mutableSetOf() + for (siblingId in componentIdsByProductId[ent.productId] ?: emptyList()) { - val seenProductIds = mutableSetOf() - for (siblingId in componentIdsByProductId[ent.productId].orEmpty()) - { - val siblingMeta = when - { - catalogMap.containsKey(siblingId) -> catalogMap[siblingId] - supplementMap.containsKey(siblingId) -> supplementMap[siblingId] - else -> - { - val siblingStableKey = productIdStableKey(siblingId) - if (siblingStableKey != null && !skipStableDemo) - browseStableKey[siblingStableKey] - ?: supplementStableKey[siblingStableKey] - else - null - } - } ?: continue - if (siblingMeta.productId.isEmpty() || siblingMeta.productId in seenProductIds) - continue - seenProductIds.add(siblingMeta.productId) - matches.add(siblingMeta) - } + val siblingMeta = when { + catalogMap.containsKey(siblingId) -> catalogMap[siblingId] + supplementMap.containsKey(siblingId) -> supplementMap[siblingId] + else -> { + val s2 = productIdStableKey(siblingId) + if (s2 != null && !skipStableDemo) browseStableKey[s2] ?: supplementStableKey[s2] else null + } + } ?: continue + if (siblingMeta.productId.isEmpty() || seenPids.contains(siblingMeta.productId)) continue + seenPids.add(siblingMeta.productId) + emit(siblingMeta, ent) } - - if (matches.isEmpty()) - continue - - for (meta in matches) - emitMatch(meta, ent) } return byKey.values.toList() } + // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen + // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing + // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. private fun ownedDedupeKey(meta: CloudGame, ent: Entitlement): String { - if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}" + if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}:${platformToken(ent.productId)}" if (meta.productId.isNotEmpty()) return "p:${meta.productId}" if (ent.id.isNotEmpty()) return "e:${ent.id}" return "u:${meta.productId}:${ent.id}" } - private fun preferOwnedEntry(existing: CloudGame, candidate: CloudGame): CloudGame + /** Platform token from a product id (CUSA = PS4, PPSA = PS5). */ + private fun platformToken(productId: String): String = when { - return when - { - existing.entitlementId.isEmpty() && candidate.entitlementId.isNotEmpty() -> candidate - else -> existing - } + productId.contains("PPSA") -> "ps5" + productId.contains("CUSA") -> "ps4" + else -> "" + } + + /** A full-game entitlement (vs add-on/avatar): base game has a *GD package_type. */ + private fun isFullGameEntitlement(ent: Entitlement): Boolean = + ent.featureType == 3 || ent.packageType.endsWith("GD") + + // Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). + // Bonus/upgrade SKUs collapse to the same conceptId+platform as the base game; package/feature + // flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3). + // The reliable signal: the base game's entitlement id EQUALS its product_id, while bonus/upgrade + // SKUs carry a different id -- so prefer the canonical full-game entitlement. + private fun ownedStreamRank(ent: Entitlement): Int + { + var rank = 0 + if (ent.productId.isNotEmpty() && ent.productId == ent.id) rank += 4 // canonical base-game SKU + if (isFullGameEntitlement(ent)) rank += 2 + if (ent.id.isNotEmpty()) rank += 1 + return rank + } + + /** conceptId + platform; the owned product id (storeProductId) takes precedence so the owned + * edition's platform is used, else the catalog product id. */ + private fun conceptPlatformKey(game: CloudGame): String + { + if (game.conceptId.isEmpty()) return "" + val pid = if (game.storeProductId.isNotEmpty()) game.storeProductId else game.productId + return "${game.conceptId}|${platformToken(pid)}" } private fun catalogMapFirstWins(games: List): MutableMap @@ -210,9 +268,21 @@ object PsCloudOwnership return index } + private fun buildConceptIdIndex(games: List): Map + { + val index = linkedMapOf() + for (game in games) + { + if (game.conceptId.isNotEmpty() && game.conceptId !in index) + index[game.conceptId] = game + } + return index + } + fun mergeOwnedIntoBrowseCatalog( browseCatalog: List, - ownedCrossRef: List + ownedCrossRef: List, + addUnmatched: Boolean = true // false = only mark ownership (Catalog tab), never add ): List { val games = browseCatalog.toMutableList() @@ -220,7 +290,10 @@ object PsCloudOwnership for (owned in ownedCrossRef) { - val catalogMatch = findCatalogIndexForOwned(owned, catalogIndex) + // Trials / free-to-play (feature_type 1) are kept as their OWN card so the user can Stream + // the trial/free build, while the full version still shows separately as a not-owned + // "Add Game" card -- so a trial must NOT collapse into the full-game catalog entry. + val catalogMatch = if (owned.featureType == 1) -1 else findCatalogIndexForOwned(owned, catalogIndex) if (catalogMatch >= 0) { val existing = games[catalogMatch] @@ -232,6 +305,7 @@ object PsCloudOwnership continue } + if (!addUnmatched) continue val entry = owned.copy(isOwned = true) registerInCatalogIndex(entry, games.size, catalogIndex) games.add(entry) @@ -247,12 +321,45 @@ object PsCloudOwnership { if (game.serviceType.equals("pscloud", ignoreCase = true)) { - if (game.entitlementId.isNotEmpty()) return game.entitlementId + // Stream the owned PRODUCT id (storeProductId) before the entitlement id: for cross-gen + // upgrades the entitlement id is the stale original SKU Gaikai has no game for. if (game.storeProductId.isNotEmpty()) return game.storeProductId + if (game.entitlementId.isNotEmpty()) return game.entitlementId } return game.productId } + // A PlayStation title id encodes its platform: CUSAxxxxx = PS4, PPSAxxxxx = PS5. This is more + // reliable than the catalog device list and decides the streaming path: PS4 catalog titles go + // through Kamaji (psnow) to acquire the streaming entitlement, PS5 streams directly (pscloud). + fun streamPlatform(game: CloudGame): String + { + // Prefer the OWNED product id (storeProductId): for a cross-gen title you upgraded, the catalog + // productId may be the other generation (Alan Wake catalog = PS4 CUSA, but you own the PS5 PPSA). + val p = game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } } + return when + { + p.contains("PPSA") -> "ps5" + p.contains("CUSA") -> "ps4" + else -> game.platform.ifEmpty { "ps5" } + } + } + + /** Real legacy PS Now games stay psnow; otherwise route by title-id platform. */ + fun streamServiceType(game: CloudGame): String + { + if (game.serviceType.equals("psnow", ignoreCase = true)) return "psnow" + return if (streamPlatform(game) == "ps4") "psnow" else "pscloud" + } + + /** Identifier for startCompleteCloudSession: psnow sends the product id (Kamaji converts it + * and acquires via PS Plus); pscloud sends the owned entitlement id (direct). */ + fun streamIdentifier(game: CloudGame): String + { + return if (streamServiceType(game) == "psnow") game.productId.ifEmpty { streamingIdentifier(game) } + else streamingIdentifier(game) + } + private fun buildCatalogIndex(games: List): CatalogIndex { val byProductId = mutableMapOf() @@ -266,8 +373,9 @@ object PsCloudOwnership { if (game.productId.isNotEmpty()) catalogIndex.byProductId[game.productId] = index - if (game.conceptId.isNotEmpty()) - catalogIndex.byConceptId[game.conceptId] = index + val conceptKey = conceptPlatformKey(game) + if (conceptKey.isNotEmpty()) + catalogIndex.byConceptId[conceptKey] = index if (game.entitlementId.isNotEmpty() && game.entitlementId != game.productId) catalogIndex.byProductId[game.entitlementId] = index } @@ -280,8 +388,11 @@ object PsCloudOwnership return catalogIndex.byProductId.getValue(owned.entitlementId) if (owned.storeProductId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.storeProductId)) return catalogIndex.byProductId.getValue(owned.storeProductId) - if (owned.conceptId.isNotEmpty() && catalogIndex.byConceptId.containsKey(owned.conceptId)) - return catalogIndex.byConceptId.getValue(owned.conceptId) + // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog + // entry (and vice-versa); cross-gen editions stay as separate library cards. + val conceptKey = conceptPlatformKey(owned) + if (conceptKey.isNotEmpty() && catalogIndex.byConceptId.containsKey(conceptKey)) + return catalogIndex.byConceptId.getValue(conceptKey) return -1 } } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt index fb8d75bd..aae1e084 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt @@ -17,7 +17,9 @@ data class CloudGame( val conceptId: String = "", // Imagic conceptId for catalog dedupe (PS5 cloud) val isOwned: Boolean = false, // Whether user owns this game (PS5 games) val entitlementId: String = "", // PSCloud: entitlement id for streaming (Qt gameData.id) - val storeProductId: String = "" // PSCloud: product_id from entitlements API + val storeProductId: String = "", // PSCloud: product_id from entitlements API + val plusCatalog: Boolean = false, // In the PS Plus subscription catalog (vs full streamable universe) + val featureType: Int = 0 // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on ) /** diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index f92b1f6e..008419c3 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -29,7 +29,7 @@ class CloudGameRepository( companion object { private const val TAG = "CloudGameRepository" - private const val CACHE_DIR = "cloud_catalog_cache" + private const val CACHE_DIR = "cloud_catalog_cache_v2" // v2: catalog games carry plusCatalog tag fun invalidateCatalogCache(context: Context) { @@ -49,7 +49,7 @@ class CloudGameRepository( } private const val PSNOW_CACHE_FILE = "psnow_catalog.json" private const val PSCLOUD_ALL_CACHE_FILE = "pscloud_catalog.json" - private const val PSCLOUD_OWNED_CACHE_FILE = "pscloud_owned.json" + private const val PSCLOUD_OWNED_CACHE_FILE = "pscloud_owned_v2.json" // v2: ft0 filter + rank dedupe + featureType private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours } @@ -86,14 +86,60 @@ class CloudGameRepository( // Fetch from network Log.i(TAG, "Fetching fresh PSNow catalog from network") val result = psnowCatalogService.fetchPsnowCatalog(npssoToken) - - // Cache if successful - if (result is PsnResult.Success) + + // Cache and return only if the legacy PS Now browse store actually returned games. + if (result is PsnResult.Success && result.data.isNotEmpty()) { cacheGames(result.data, PSNOW_CACHE_FILE) + return@withContext result + } + + // The legacy PS Now (Kamaji) browse store is region-locked / deprecated and 404s in + // many regions (e.g. Hungary). Fall back to the PS Plus subscription catalog (~630), + // NOT the full ~4000 streamable universe (that is the Library "all" view). + Log.w(TAG, "PSNow catalog unavailable/empty, falling back to PS Plus subscription catalog") + fetchPlusCatalog(npssoToken, forceRefresh) + } + } + + /** + * Fetch the PS Plus subscription catalog (Catalog tab): plusCatalog browse titles + the + * library-stream supplement, NOT the full all-ps5 universe. No ownership merge — every + * subscription title is shown as streamable. Mirrors Qt ps5PlusCatalogGames. + */ + suspend fun fetchPlusCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> + { + return withContext(Dispatchers.IO) + { + CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) + try + { + val stored = preferences.getCloudLanguage() + val catalog = fetchPs5CatalogV3(stored, forceRefresh) + var games = (catalog.browseGames.filter { it.plusCatalog } + catalog.plusLibrarySupplement) + .sortedBy { it.name.lowercase() } + // Mark owned subscription titles so owned -> Stream and non-owned -> Add Game. + // addUnmatched=false keeps the Catalog the pure subscription set (mark only). + if (npssoToken.isNotEmpty()) + { + try + { + val ownedCrossRef = pscloudCatalogService.getOwnedPs5CloudGames( + npssoToken, catalog.browseGames, catalog.plusLibrarySupplement, catalog.productIdAliases) + games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog(games, ownedCrossRef, addUnmatched = false) + } + catch (e: Exception) + { + Log.w(TAG, "Catalog ownership marking failed; showing as not owned", e) + } + } + PsnResult.Success(games) + } + catch (e: Exception) + { + Log.e(TAG, "Failed to fetch PS Plus subscription catalog", e) + PsnResult.Error("Failed to fetch catalog: ${e.message}", e) } - - result } } @@ -117,19 +163,8 @@ class CloudGameRepository( try { val stored = preferences.getCloudLanguage() - val locale = com.metallic.chiaki.cloudplay.CloudLocale.toImagicLocale(stored) - Log.i(TAG, "Fetching PS5 Cloud catalog locale=$stored imagic=$locale forceRefresh=$forceRefresh") - - val catalog = (if (!forceRefresh) loadCachedPs5CatalogV3(stored) else null) - ?: run { - lastCatalogFetchWarning = null - val fetched = pscloudCatalogService.fetchPs5CloudCatalog(locale) - if (fetched.shouldCacheV3) - cachePs5CatalogV3(fetched, stored) - lastCatalogFetchWarning = fetched.catalogFetchWarning - fetched - } - + Log.i(TAG, "Fetching PS5 Cloud catalog stored=$stored forceRefresh=$forceRefresh") + val catalog = fetchPs5CatalogV3(stored, forceRefresh) val gamesWithOwnership = crossReferenceOwnership(catalog, npssoToken) if (gamesWithOwnership.isNotEmpty()) cacheGames(gamesWithOwnership, PSCLOUD_ALL_CACHE_FILE) @@ -143,6 +178,42 @@ class CloudGameRepository( } } + /** + * Fetch the PS5 imagic catalog, trying the store-locale fallback chain + * (session locale -> en-COUNTRY -> en-US) since Sony 404s unsupported locales (e.g. hu-HU). + * Persists the locale that works. Returns the cached v3 catalog when available. + */ + private suspend fun fetchPs5CatalogV3(stored: String, forceRefresh: Boolean): Ps5CloudCatalogResult + { + if (!forceRefresh) + loadCachedPs5CatalogV3(stored)?.let { return it } + + lastCatalogFetchWarning = null + var lastError: Exception? = null + for ((canonical, imagic) in com.metallic.chiaki.cloudplay.CloudLocale.fallbackChain(stored)) + { + try + { + val fetched = pscloudCatalogService.fetchPs5CloudCatalog(imagic) + if (canonical != stored) + { + Log.i(TAG, "PS5 store locale settled on $canonical (was $stored)") + preferences.setCloudLanguage(canonical) + } + if (fetched.shouldCacheV3) + cachePs5CatalogV3(fetched, canonical) + lastCatalogFetchWarning = fetched.catalogFetchWarning + return fetched + } + catch (e: Exception) + { + Log.i(TAG, "PS5 imagic locale $imagic failed, trying next tier: ${e.message}") + lastError = e + } + } + throw (lastError ?: Exception("All imagic locales failed to load")) + } + /** * Cross-reference public catalog with owned games to mark ownership status */ @@ -189,17 +260,7 @@ class CloudGameRepository( try { val stored = preferences.getCloudLanguage() - val locale = com.metallic.chiaki.cloudplay.CloudLocale.toImagicLocale(stored) - - val catalog = (if (!forceRefresh) loadCachedPs5CatalogV3(stored) else null) - ?: run { - lastCatalogFetchWarning = null - val fetched = pscloudCatalogService.fetchPs5CloudCatalog(locale) - if (fetched.shouldCacheV3) - cachePs5CatalogV3(fetched, stored) - lastCatalogFetchWarning = fetched.catalogFetchWarning - fetched - } + val catalog = fetchPs5CatalogV3(stored, forceRefresh) val games = pscloudCatalogService.getOwnedPs5CloudGames( npssoToken, @@ -267,7 +328,9 @@ class CloudGameRepository( conceptId = obj.optString("conceptId", ""), isOwned = obj.optBoolean("isOwned", false), entitlementId = obj.optString("entitlementId", ""), - storeProductId = obj.optString("storeProductId", "") + storeProductId = obj.optString("storeProductId", ""), + plusCatalog = obj.optBoolean("plusCatalog", false), + featureType = obj.optInt("featureType", 0) )) } @@ -305,6 +368,8 @@ class CloudGameRepository( obj.put("isOwned", game.isOwned) obj.put("entitlementId", game.entitlementId) obj.put("storeProductId", game.storeProductId) + obj.put("plusCatalog", game.plusCatalog) + obj.put("featureType", game.featureType) jsonArray.put(obj) } @@ -420,7 +485,9 @@ class CloudGameRepository( conceptId = obj.optString("conceptId", ""), isOwned = obj.optBoolean("isOwned", false), entitlementId = obj.optString("entitlementId", ""), - storeProductId = obj.optString("storeProductId", "") + storeProductId = obj.optString("storeProductId", ""), + plusCatalog = obj.optBoolean("plusCatalog", false), + featureType = obj.optInt("featureType", 0) ) ) } @@ -444,6 +511,8 @@ class CloudGameRepository( obj.put("isOwned", game.isOwned) obj.put("entitlementId", game.entitlementId) obj.put("storeProductId", game.storeProductId) + obj.put("plusCatalog", game.plusCatalog) + obj.put("featureType", game.featureType) jsonArray.put(obj) } return jsonArray diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index 7747f390..f4e558ef 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -260,6 +260,19 @@ class Preferences(context: Context) fun setCloudLanguageFromSession(language: String?, country: String?) { val locale = com.metallic.chiaki.cloudplay.CloudLocale.fromSession(language, country) ?: return + if (isCloudLanguageConfigured()) + { + // The country is the real region signal; the language part may get auto-corrected by + // the imagic fetch (e.g. hu-HU settles on en-HU). Only re-save when the country changes, + // otherwise we'd clobber the validated locale on every Kamaji session and thrash the cache. + val storedCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(getCloudLanguage()).first + val sessionCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(locale).first + if (storedCountry == sessionCountry) + { + Log.i("Preferences", "Kamaji session country unchanged ($sessionCountry), keeping validated locale ${getCloudLanguage()}") + return + } + } setCloudLanguage(locale) } diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt index 3ca12bfc..5cc5a744 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt @@ -97,11 +97,20 @@ class CloudGameAdapter( fun bind(game: CloudGame) { binding.gameNameTextView.text = game.name - binding.gamePlatformTextView.text = when (game.platform.lowercase()) { - "ps3" -> "3" - "ps4" -> "4" - "ps5" -> "5" - else -> game.platform.takeLast(1) + // Derive the badge from the title id (PPSA = PS5, CUSA = PS4) like the Qt client does, + // since the catalog parser tags everything "ps5"; fall back to the platform field. + binding.gamePlatformTextView.text = run { + val pid = game.productId.ifEmpty { game.storeProductId } + when { + pid.contains("PPSA") -> "5" + pid.contains("CUSA") -> "4" + else -> when (game.platform.lowercase()) { + "ps3" -> "3" + "ps4" -> "4" + "ps5" -> "5" + else -> game.platform.takeLast(1) + } + } } if (showOwnershipBadge && game.serviceType == "pscloud") { diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index c20ba0ff..ff18783f 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -509,7 +509,7 @@ class CloudPlayFragment : Fragment() // Update section viewModel.setCurrentSection("psnow") - adapter.showOwnershipBadge = false + adapter.showOwnershipBadge = true // owned/not-owned shown in Catalog too binding.sortOptionLayout.visibility = android.view.View.VISIBLE binding.filterOptionLayout.visibility = android.view.View.VISIBLE updateSortButtonText() @@ -1120,9 +1120,7 @@ class CloudPlayFragment : Fragment() private fun onGameClicked(game: CloudGame) { val isPscloud = game.serviceType == "pscloud" - val isAllGamesFilter = !viewModel.preferences.getPsCloudFilterOwned() - - if (isPscloud && isAllGamesFilter && !game.isOwned) + if (isPscloud && !game.isOwned) { // Show dialog to add game to library showAddToLibraryDialog(game) @@ -1341,9 +1339,11 @@ class CloudPlayFragment : Fragment() try { val backend = CloudStreamingBackend(requireContext(), viewModel.preferences) + // Route by the title-id platform: PS4 catalog titles go through Kamaji (psnow) to + // acquire the streaming entitlement; PS5 streams directly (pscloud). val result = backend.startCompleteCloudSession( - serviceType = game.serviceType, - gameIdentifier = PsCloudOwnership.streamingIdentifier(game), + serviceType = PsCloudOwnership.streamServiceType(game), + gameIdentifier = PsCloudOwnership.streamIdentifier(game), gameName = game.name, npssoToken = npssoToken, onProgress = { message -> diff --git a/android/gradle.properties b/android/gradle.properties index 357391df..a2e2757f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -8,9 +8,10 @@ # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -# Force Gradle to use Java 21 (required for AGP 8.7+) -# Java 21 is LTS and works perfectly with AGP 8.7 and Gradle 8.9+ -org.gradle.java.home=C\:\\Program Files\\Android\\Android Studio2\\jbr +# This build must run Gradle on a JDK 21 (AGP does not support newer JDKs). +# Do NOT hardcode a machine-specific org.gradle.java.home here -- an absolute path +# breaks every other machine/OS/CI. Select the JDK 21 daemon per-machine via JAVA_HOME, +# ~/.gradle/gradle.properties (org.gradle.java.home), or your IDE's "Gradle JDK" setting. # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index f750b97e..0e63ef01 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -107,6 +107,12 @@ private slots: QMap plusLibrarySupplementByProductId; QMap productIdAliases; // alternate imagic productId -> canonical browse productId int totalGamesSeen = 0; + // Store-locale fallback: Sony serves a fixed set of language-COUNTRY locales. + // The country is always valid but the language may not be (e.g. hu-HU 404s, + // en-HU works). We try the session locale, then en-COUNTRY, then en-US. + QStringList localeChain; + int localeTierIndex = 0; + QString activeLocale; // canonical "ll-CC" form for the tier currently being fetched } ps5State; // Owned games fetching state @@ -132,7 +138,9 @@ private slots: QJsonArray plusLibrarySupplement; QJsonArray ownedGames; QMap productIdAliases; - QMap componentIdsByProductId; // product_id -> all sibling entitlement ids (full list) + // Bundle product_id -> its component entitlement ids, for bundle-sibling matching (from + // upstream PR #15): a bundle entitlement (e.g. RE7 Gold) expands to its component games. + QMap componentIdsByProductId; bool catalogFetched; bool ownedGamesFetched; } crossReferenceState; @@ -153,6 +161,7 @@ private slots: void handlePsnowSessionResponse(); void handlePsnowStoresResponse(); void handlePsnowRootContainerResponse(); + void startPs5ImagicListFetch(); // fires the six imagic list requests for ps5State.activeLocale void executeGameDetailsFetch(const QString &productId); QJsonArray filterStreamingSupportedGames(const QJsonArray &games); QJsonArray filterOwnedPs5Games(const QJsonArray &entitlements); diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 993211f3..a408f376 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -134,13 +134,13 @@ QString CloudCatalogBackend::getCachedData(const QString &key, int maxAge) QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) { - const QString cached = getCachedData(QStringLiteral("ps5_cloud_catalog_v3"), maxAge); + const QString cached = getCachedData(QStringLiteral("ps5_cloud_catalog_v6"), maxAge); if (cached.isEmpty()) return QString(); const QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); if (!doc.isObject()) { - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v3"))); + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); return QString(); } @@ -149,7 +149,7 @@ QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) if (!cachedLocale.isEmpty() && cachedLocale != expectedLocale) { qInfo() << "[CACHE LOCALE MISMATCH] PS5 catalog v3 locale" << cachedLocale << "!=" << expectedLocale << ", refetching"; - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v3"))); + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); return QString(); } @@ -460,19 +460,23 @@ void CloudCatalogBackend::handlePsnowSessionResponse() // Save country and language from session response to settings QString country = data["country"].toString(); QString language = data["language"].toString(); - if (!country.isEmpty() && !language.isEmpty()) { + if (!country.isEmpty() && !language.isEmpty() && settings) { // Format: language-COUNTRY (e.g., "nl-NL" or "en-US") - QString locale = QString("%1-%2").arg(language, country.toUpper()); - if (settings) { - QString previousLocale = settings->GetCloudLanguagePSCloud(); - settings->SetCloudLanguagePSCloud(locale); - qInfo() << "[PSNOW] Saved locale from session:" << locale; - - // Invalidate cache if locale changed - if (previousLocale != locale) { - qInfo() << "[PSNOW] Locale changed from" << previousLocale << "to" << locale << "- invalidating cache"; - invalidateCache(); - } + const QString sessionLocale = QString("%1-%2").arg(language.toLower(), country.toUpper()); + const QString previousLocale = settings->GetCloudLanguagePSCloud(); + // The country is the real region signal; the language part may get + // auto-corrected later (the imagic fetch settles e.g. hu-HU on en-HU). + // Only re-save when the country actually changes, otherwise we'd clobber + // the validated locale on every visit and thrash the cache. + const QString previousCountry = previousLocale.section(QLatin1Char('-'), 1, 1).toUpper(); + if (previousCountry != country.toUpper()) { + settings->SetCloudLanguagePSCloud(sessionLocale); + qInfo() << "[PSNOW] Region changed, saved locale from session:" << sessionLocale + << "(was" << previousLocale << ") - invalidating cache"; + invalidateCache(); + } else if (settings->GetLogVerbose()) { + qInfo() << "[PSNOW] Session country unchanged (" << country + << "), keeping validated locale" << previousLocale; } } @@ -872,6 +876,42 @@ void CloudCatalogBackend::processPsnowCatalogComplete() namespace { +// Canonicalize a "language-COUNTRY" locale to lowercase-language / uppercase-country. +static QString canonicalStoreLocale(const QString &raw) +{ + QString s = raw.trimmed(); + if (s.isEmpty()) + return QStringLiteral("en-US"); + const QStringList parts = s.split(QLatin1Char('-')); + QString lang = parts.value(0).toLower(); + QString country = parts.value(1).toUpper(); + if (lang.isEmpty()) + lang = QStringLiteral("en"); + if (country.isEmpty()) + country = QStringLiteral("US"); + return lang + QLatin1Char('-') + country; +} + +// Build the ordered list of store locales to try. Sony's storefront/imagic endpoints +// only serve a fixed set of language-COUNTRY combinations: the country is always +// served, but the language may not be (e.g. a Hungarian-language account yields +// "hu-HU", which 404s, while "en-HU" works). Fall back to English for the same +// country, then en-US, so the catalog loads in every region. +static QStringList buildStoreLocaleFallbackChain(const QString &stored) +{ + const QString canonical = canonicalStoreLocale(stored); + const QString country = canonical.section(QLatin1Char('-'), 1, 1); + QStringList chain; + auto add = [&chain](const QString &loc) { + if (!loc.isEmpty() && !chain.contains(loc)) + chain.append(loc); + }; + add(canonical); + add(QStringLiteral("en-") + country); + add(QStringLiteral("en-US")); + return chain; +} + static const QStringList kPs5ImagicCategoryLists = { QStringLiteral("plus-games-list"), QStringLiteral("ubisoft-classics-list"), @@ -881,21 +921,25 @@ static const QStringList kPs5ImagicCategoryLists = { QStringLiteral("all-ps5-list"), }; -static bool isPs5Game(const QJsonObject &gameObj) +// PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not present in these +// imagic lists). A PS4-only title such as God of War (2018) is streamable when +// owned even though it carries device ["PS4"], so the catalog must not discard it. +static bool isCloudDeviceGame(const QJsonObject &gameObj) { const QJsonArray devices = gameObj.value(QStringLiteral("device")).toArray(); for (const QJsonValue &device : devices) { - if (device.toString() == QLatin1String("PS5")) + const QString d = device.toString(); + if (d == QLatin1String("PS5") || d == QLatin1String("PS4")) return true; } return false; } -static bool isPs5StreamingGame(const QJsonObject &gameObj) +static bool isCloudStreamingGame(const QJsonObject &gameObj) { if (!gameObj.value(QStringLiteral("streamingSupported")).toBool()) return false; - return isPs5Game(gameObj); + return isCloudDeviceGame(gameObj); } static QString ps5CloudConceptKey(const QJsonObject &gameObj) @@ -913,6 +957,60 @@ static QString ps5CloudConceptKey(const QJsonObject &gameObj) return gameObj.value(QStringLiteral("productId")).toString(); } +// Platform token from a product id's title id: CUSA = PS4, PPSA = PS5. +static QString ps5CloudPlatformToken(const QString &productId) +{ + if (productId.contains(QLatin1String("PPSA"))) + return QStringLiteral("ps5"); + if (productId.contains(QLatin1String("CUSA"))) + return QStringLiteral("ps4"); + return QString(); +} + +// Catalog dedupe identity: one entry per game PER PLATFORM, so a cross-gen title that Sony lists +// as separate PS4 and PS5 editions (e.g. Deliver Us The Moon) shows as two cards, while duplicate +// same-platform SKUs still collapse. (conceptId alone collapsed the PS4/PS5 editions into one.) +static QString ps5CloudEditionKey(const QJsonObject &gameObj) +{ + const QString concept = ps5CloudConceptKey(gameObj); + if (concept.isEmpty()) + return QString(); + return concept + QLatin1Char('|') + + ps5CloudPlatformToken(gameObj.value(QStringLiteral("productId")).toString()); +} + +// A "full game" entitlement (vs an add-on / avatar / theme). PSN marks the base game with +// feature_type 3 and a *GD package_type (PSGD/PS4GD); add-ons use feature_type 0 and +// PS4MISC/PSAL/etc. Used to keep the base game when collapsing same-platform SKUs. +static bool ps5CloudIsFullGameEntitlement(const QJsonObject &ownedGameObj) +{ + if (ownedGameObj.value(QStringLiteral("feature_type")).toInt() == 3) + return true; + const QString pt = ownedGameObj.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("package_type")).toString(); + return pt.endsWith(QStringLiteral("GD")); +} + +// Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). +// Upgrade / bonus / cross-buy SKUs collapse to the same conceptId+platform as the base game, so we +// must pick which one's product_id the card streams. The package_type/feature_type flags are not +// enough: Death Stranding DC's "Bonus Content" SKU is ALSO PSGD + feature_type 3, identical to the +// game. The reliable signal is that the BASE GAME's entitlement id EQUALS its product_id (e.g. +// ...DEATHSTRANDINGEU == ...DEATHSTRANDINGEU), while bonus/upgrade SKUs carry a different id (the +// bonus is product_id ...DEATHSTRADCDDE01 but id ...PPSA02624...). Prefer the canonical full-game +// entitlement so getStreamingIdentifier streams the real game's product_id, not a DLC product that +// Gaikai has no game for (-> noGameForEntitlementId). +static int ps5CloudOwnedStreamRank(const QJsonObject &ownedGameObj) +{ + const QString id = ownedGameObj.value(QStringLiteral("id")).toString(); + const QString pid = ownedGameObj.value(QStringLiteral("product_id")).toString(); + int rank = 0; + if (!pid.isEmpty() && pid == id) rank += 4; // canonical product (the base game SKU) + if (ps5CloudIsFullGameEntitlement(ownedGameObj)) rank += 2; // full game (feature_type 3 / *GD) + if (!id.isEmpty()) rank += 1; // has a real entitlement id + return rank; +} + static QString ps5CloudProductIdStableKey(const QString &productId) { if (productId.isEmpty()) @@ -946,6 +1044,53 @@ static QMap buildStableKeyIndex(const QJsonArray &games) return index; } +// imagic encodes conceptId as a JSON number; entitlements (if present) may use a +// number or string. Normalize to a non-empty decimal string, else empty. +static QString ps5CloudConceptIdString(const QJsonValue &conceptIdVal) +{ + if (conceptIdVal.isDouble()) { + const qint64 c = static_cast(conceptIdVal.toDouble()); + return c > 0 ? QString::number(c) : QString(); + } + if (conceptIdVal.isString()) + return conceptIdVal.toString(); + return QString(); +} + +// conceptId is region-stable (227770 for God of War 2018) whereas product IDs are +// region-prefixed (EP9000 vs UP9000) and vary by edition, so it is the most reliable +// owned->catalog match when both sides carry one. +static QMap buildConceptIdIndex(const QJsonArray &games) +{ + QMap index; + for (const QJsonValue &game : games) { + if (!game.isObject()) + continue; + const QJsonObject gameObj = game.toObject(); + const QString concept = ps5CloudConceptIdString(gameObj.value(QStringLiteral("conceptId"))); + if (concept.isEmpty() || index.contains(concept)) + continue; + index.insert(concept, gameObj); + } + return index; +} + +// Pull a conceptId out of an owned entitlement, checking the field names the +// commerce API and our merged objects use. Returns empty if none is present. +static QString ownedEntitlementConceptId(const QJsonObject &ownedGameObj) +{ + QString concept = ps5CloudConceptIdString(ownedGameObj.value(QStringLiteral("conceptId"))); + if (concept.isEmpty()) + concept = ps5CloudConceptIdString(ownedGameObj.value(QStringLiteral("concept_id"))); + if (concept.isEmpty()) { + const QJsonObject gameMeta = ownedGameObj.value(QStringLiteral("game_meta")).toObject(); + concept = ps5CloudConceptIdString(gameMeta.value(QStringLiteral("conceptId"))); + if (concept.isEmpty()) + concept = ps5CloudConceptIdString(gameMeta.value(QStringLiteral("concept_id"))); + } + return concept; +} + static QJsonObject productIdAliasesToJson(const QMap &aliases) { QJsonObject obj; @@ -965,6 +1110,18 @@ static QMap productIdAliasesFromJson(const QJsonObject &obj) return aliases; } +// The PS Plus subscription "Game Catalog" (what Sony lists on the PS Plus games page) is the +// union of these curated lists. The other source we fetch, "all-ps5-list", is the entire +// cloud-streamable PS5 universe (~7000 titles) — useful for matching owned games but NOT the +// subscription catalog, so it must not inflate the Catalog tab. +static bool isPlusCatalogList(const QString &categoryList) +{ + return categoryList == QLatin1String("plus-games-list") + || categoryList == QLatin1String("plus-classics-list") + || categoryList == QLatin1String("ubisoft-classics-list") + || categoryList == QLatin1String("plus-monthly-games-list"); +} + static void mergeImagicListIntoPs5Catalog(const QString &categoryList, const QJsonDocument &doc, QMap &gamesByConceptId, @@ -972,6 +1129,7 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, QMap &productIdAliases, int &totalGamesSeen) { + const bool plusCatalog = isPlusCatalogList(categoryList); if (!doc.isArray()) return; @@ -985,36 +1143,53 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, if (!game.isObject()) continue; QJsonObject gameObj = game.toObject(); - if (!isPs5Game(gameObj)) + // Accept both PS4 and PS5 cloud titles. The old PS5-only gate silently + // dropped PS4-only PS-Plus-catalog games (e.g. God of War 2018) before + // they could reach the library-stream supplement below. + if (!isCloudDeviceGame(gameObj)) continue; - // Plus catalog titles excluded from public cloud browse (library-stream candidates) - if (categoryList == QLatin1String("plus-games-list") + // Subscription-catalog titles excluded from public cloud browse (library-stream + // candidates): streamingSupported=false but streamable once owned/acquired. Capture + // these from EVERY subscription list (plus-games, classics, ubisoft, monthly) so the + // Game Catalog includes them too — not just plus-games-list. + if (plusCatalog && !gameObj.value(QStringLiteral("streamingSupported")).toBool()) { const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - if (!productId.isEmpty()) + if (!productId.isEmpty()) { + gameObj.insert(QStringLiteral("plusCatalog"), true); plusLibrarySupplementByProductId.insert(productId, gameObj); + } continue; } - if (!isPs5StreamingGame(gameObj)) + if (!isCloudStreamingGame(gameObj)) continue; - const QString key = ps5CloudConceptKey(gameObj); + // Dedupe per game PER PLATFORM so cross-gen PS4/PS5 editions both appear. + const QString key = ps5CloudEditionKey(gameObj); const QString productId = gameObj.value(QStringLiteral("productId")).toString(); if (key.isEmpty() || productId.isEmpty()) continue; if (gamesByConceptId.contains(key)) { - const QString canonicalProductId = - gamesByConceptId.value(key).value(QStringLiteral("productId")).toString(); + QJsonObject existing = gamesByConceptId.value(key); + const QString canonicalProductId = existing.value(QStringLiteral("productId")).toString(); if (!canonicalProductId.isEmpty() && productId != canonicalProductId && !productIdAliases.contains(productId)) { productIdAliases.insert(productId, canonicalProductId); } + // Lists are fetched in parallel, so a title may be seen first via all-ps5-list + // (not subscription) and later via a subscription list. Upgrade the flag so the + // subscription membership wins regardless of arrival order. + if (plusCatalog && !existing.value(QStringLiteral("plusCatalog")).toBool()) { + existing.insert(QStringLiteral("plusCatalog"), true); + gamesByConceptId.insert(key, existing); + } continue; } + gameObj.insert(QStringLiteral("plusCatalog"), plusCatalog); gamesByConceptId.insert(key, gameObj); } } @@ -1024,10 +1199,6 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) { - // Get locale from unified language setting and convert to lowercase for API - QString localeSetting = settings ? settings->GetCloudLanguagePSCloud() : "en-US"; - QString locale = localeSetting.toLower(); // Convert "en-US" to "en-us" - // Check cache first QString cached = getCachedPs5CatalogV3(CACHE_DURATION_CATALOG); if (!cached.isEmpty()) { @@ -1038,9 +1209,26 @@ void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) } return; } - + qInfo() << "[API CALL] Fetching PS5 cloud catalog (6 imagic lists, cache miss or expired)"; ps5State.callback = callback; + + // Build the store-locale fallback chain (session locale -> en-COUNTRY -> en-US) + // and start with the first tier. Tiers escalate only when a whole tier 404s. + ps5State.localeChain = + buildStoreLocaleFallbackChain(settings ? settings->GetCloudLanguagePSCloud() + : QStringLiteral("en-US")); + ps5State.localeTierIndex = 0; + startPs5ImagicListFetch(); +} + +void CloudCatalogBackend::startPs5ImagicListFetch() +{ + ps5State.activeLocale = ps5State.localeChain.value(ps5State.localeTierIndex, + QStringLiteral("en-US")); + const QString locale = ps5State.activeLocale.toLower(); // imagic wants "en-us" + + // Reset per-tier accumulators so a failed tier leaves nothing behind. ps5State.gamesByConceptId.clear(); ps5State.plusLibrarySupplementByProductId.clear(); ps5State.productIdAliases.clear(); @@ -1050,6 +1238,11 @@ void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) ps5State.failedLists.clear(); ps5State.pendingListFetches = kPs5ImagicCategoryLists.size(); + if (settings && settings->GetLogVerbose()) { + qInfo() << "[API CALL] PS5 imagic fetch using locale tier" << ps5State.localeTierIndex + << ":" << ps5State.activeLocale; + } + for (const QString &categoryList : kPs5ImagicCategoryLists) { const QString url = QStringLiteral( "https://www.playstation.com/bin/imagic/gameslist?locale=%1&categoryList=%2") @@ -1110,6 +1303,17 @@ void CloudCatalogBackend::handlePs5ImagicListResponse() ps5State.pendingListFetches--; if (ps5State.pendingListFetches <= 0) { if (ps5State.succeededListFetches <= 0) { + // The whole tier failed (typically a 404 for an unsupported store + // locale such as hu-HU). Escalate to the next locale tier before + // giving up, so regions Sony only serves in English still load. + if (ps5State.localeTierIndex + 1 < ps5State.localeChain.size()) { + ps5State.localeTierIndex++; + qWarning() << "[API] All imagic lists failed for locale" + << ps5State.activeLocale << "- retrying with" + << ps5State.localeChain.value(ps5State.localeTierIndex); + startPs5ImagicListFetch(); + return; + } if (ps5State.callback.isCallable()) { ps5State.callback.call({false, QStringLiteral("All imagic lists failed to load"), @@ -1152,9 +1356,20 @@ void CloudCatalogBackend::finalizePs5CloudCatalogFetch() qInfo() << " Product ID aliases (same conceptId):" << ps5State.productIdAliases.size(); } + // Persist the locale that actually worked so game-details fetches and the + // cache locale check all agree on it (e.g. a hu-HU account settles on en-HU). + const QString workingLocale = !ps5State.activeLocale.isEmpty() + ? ps5State.activeLocale + : (settings ? settings->GetCloudLanguagePSCloud() + : QStringLiteral("en-US")); + if (settings && settings->GetCloudLanguagePSCloud() != workingLocale) { + qInfo() << "[PSCLOUD] Store locale settled on" << workingLocale + << "(was" << settings->GetCloudLanguagePSCloud() << ")"; + settings->SetCloudLanguagePSCloud(workingLocale); + } + QJsonObject result; - result.insert(QStringLiteral("locale"), - settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US")); + result.insert(QStringLiteral("locale"), workingLocale); result[QStringLiteral("games")] = allGames; result[QStringLiteral("total")] = allGames.size(); result[QStringLiteral("plusLibrarySupplement")] = plusSupplementGames; @@ -1164,7 +1379,7 @@ void CloudCatalogBackend::finalizePs5CloudCatalogFetch() const QJsonDocument resultDoc(result); if (ps5State.allPs5ListSucceeded) - setCachedData(QStringLiteral("ps5_cloud_catalog_v3"), resultDoc); + setCachedData(QStringLiteral("ps5_cloud_catalog_v6"), resultDoc); QString callbackMessage = QStringLiteral("Success"); if (!ps5State.failedLists.isEmpty()) { @@ -1525,6 +1740,9 @@ void CloudCatalogBackend::handleOwnedGamesResponse() // Filter for PS5 games (package_type=PSGD) QJsonArray ps5Games = filterOwnedPs5Games(ownedGamesState.accumulatedEntitlements); + // Map each bundle product_id -> the entitlement ids that share it, so a bundle (e.g. RE7 Gold, + // whose components each carry the bundle product_id but a distinct entitlement id) can expand to + // its component games during cross-reference (upstream PR #15's bundle-sibling matching). QMap componentIds; for (const QJsonValue &ent : ownedGamesState.accumulatedEntitlements) { if (!ent.isObject()) @@ -1535,11 +1753,11 @@ void CloudCatalogBackend::handleOwnedGamesResponse() if (!pid.isEmpty() && !eid.isEmpty()) componentIds[pid].append(eid); } - + if (settings && settings->GetLogVerbose()) { qInfo() << " PS5 games (PSGD):" << ps5Games.size(); } - + QJsonObject result; result["games"] = ps5Games; result["total"] = ps5Games.size(); @@ -1547,12 +1765,12 @@ void CloudCatalogBackend::handleOwnedGamesResponse() for (auto it = componentIds.cbegin(); it != componentIds.cend(); ++it) componentObj.insert(it.key(), QJsonArray::fromStringList(it.value())); result[QStringLiteral("componentIdsByProductId")] = componentObj; - + QJsonDocument resultDoc(result); - + // Cache the result setCachedData("ps5_cloud_library", resultDoc); - + // If cross-reference is active, populate its state if (crossReferenceState.callback.isCallable() && !crossReferenceState.ownedGamesFetched) { crossReferenceState.ownedGames = ps5Games; @@ -1579,78 +1797,83 @@ QJsonArray CloudCatalogBackend::filterOwnedPs5Games(const QJsonArray &entitlemen QJsonArray ps5Games; for (const QJsonValue &ent : entitlements) { - if (ent.isObject()) { - QJsonObject entObj = ent.toObject(); - - // Check for game_meta and package_type - if (entObj.contains("game_meta") && entObj["game_meta"].isObject()) { - QJsonObject gameMeta = entObj["game_meta"].toObject(); - QString packageType = gameMeta["package_type"].toString(); - - // Filter for PS5 games (PSGD) - if (packageType == "PSGD") { - // Skip inactive games (active_flag must be true) - bool activeFlag = entObj.contains("active_flag") && entObj["active_flag"].toBool(); - if (!activeFlag) { - continue; - } - - // Skip subscriptions/services (Product IDs starting with IP or SUB) - QString productId = entObj["product_id"].toString(); - if (!productId.startsWith("IP") && !productId.startsWith("SUB")) { - // Extract cover image from game_meta.icon_url (this is the primary field for entitlements API) - QString coverImageUrl; - - // Check game_meta.icon_url first (this is where the API returns images) - if (gameMeta.contains("icon_url")) { - coverImageUrl = gameMeta["icon_url"].toString(); - } - - // Fallback: try extractCoverImageFromGameObject for images array if present - if (coverImageUrl.isEmpty()) { - coverImageUrl = extractCoverImageFromGameObject(gameMeta); - } - if (coverImageUrl.isEmpty()) { - coverImageUrl = extractCoverImageFromGameObject(entObj); - } - - // Additional fallbacks for other common image field names - if (coverImageUrl.isEmpty()) { - if (gameMeta.contains("imageUrl")) { - coverImageUrl = gameMeta["imageUrl"].toString(); - } else if (gameMeta.contains("image_url")) { - coverImageUrl = gameMeta["image_url"].toString(); - } else if (gameMeta.contains("thumbnail_url")) { - coverImageUrl = gameMeta["thumbnail_url"].toString(); - } else if (entObj.contains("imageUrl")) { - coverImageUrl = entObj["imageUrl"].toString(); - } else if (entObj.contains("image_url")) { - coverImageUrl = entObj["image_url"].toString(); - } else if (entObj.contains("thumbnail_url")) { - coverImageUrl = entObj["thumbnail_url"].toString(); - } - } - - if (!coverImageUrl.isEmpty()) { - entObj["imageUrl"] = coverImageUrl; - if (settings && settings->GetLogVerbose()) { - QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; - qInfo() << " Extracted cover image for PS5 game:" << gameName << "from icon_url"; - } - } else { - if (settings && settings->GetLogVerbose()) { - QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; - qInfo() << " No image found in entitlement response for PS5 game:" << gameName; - } - } - - ps5Games.append(entObj); - } - } + if (!ent.isObject()) + continue; + QJsonObject entObj = ent.toObject(); + + // Must look like a game entitlement (has game_meta). + if (!entObj.contains("game_meta") || !entObj["game_meta"].isObject()) + continue; + QJsonObject gameMeta = entObj["game_meta"].toObject(); + const QString packageType = gameMeta["package_type"].toString(); + + // Skip inactive entitlements (active_flag must be true). + const bool activeFlag = entObj.contains("active_flag") && entObj["active_flag"].toBool(); + if (!activeFlag) + continue; + + // Skip subscriptions/services (Product IDs starting with IP or SUB). + const QString productId = entObj["product_id"].toString(); + if (productId.startsWith("IP") || productId.startsWith("SUB")) + continue; + + // Hide EXTRAS: feature_type==0 is DLC / add-ons / themes / avatars / season passes / cross-buy + // "tracks" (PS4MISC/PSAL/PSTRACK/PS4AC/...), NEVER a base game -- every *GD game package is + // feature_type 1 (trial / free-to-play) or 3/5 (full game). Dropping ft==0 keeps add-ons from + // cluttering the library or marking a game "owned" via DLC, and is safe (it can't hide a game). + // Trials/free (ft1) and full games (ft3/5) are KEPT; the trial-vs-full split is handled when + // merging owned games into the catalog (a trial stays its own card so the full version can + // still show "Add Game"). + if (entObj.value(QStringLiteral("feature_type")).toInt() == 0) + continue; + + // Previously this required package_type == "PSGD" (PS5 only), which dropped + // owned PS4 titles (e.g. God of War 2018) and PS3 titles. We now accept every + // active game entitlement; streamability is enforced downstream by the catalog + // cross-reference (only titles present in the streamable catalog/supplement are + // shown), and matches are deduped by conceptId, so non-streamable or add-on + // entitlements are harmlessly dropped there. + const QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; + if (settings && settings->GetLogVerbose()) { + qInfo() << " Owned entitlement:" << gameName + << "package_type:" << (packageType.isEmpty() ? QStringLiteral("(none)") : packageType) + << "product_id:" << productId; + } + + // Extract cover image from game_meta.icon_url (the primary field for the entitlements API). + QString coverImageUrl; + if (gameMeta.contains("icon_url")) { + coverImageUrl = gameMeta["icon_url"].toString(); + } + if (coverImageUrl.isEmpty()) { + coverImageUrl = extractCoverImageFromGameObject(gameMeta); + } + if (coverImageUrl.isEmpty()) { + coverImageUrl = extractCoverImageFromGameObject(entObj); + } + // Additional fallbacks for other common image field names. + if (coverImageUrl.isEmpty()) { + if (gameMeta.contains("imageUrl")) { + coverImageUrl = gameMeta["imageUrl"].toString(); + } else if (gameMeta.contains("image_url")) { + coverImageUrl = gameMeta["image_url"].toString(); + } else if (gameMeta.contains("thumbnail_url")) { + coverImageUrl = gameMeta["thumbnail_url"].toString(); + } else if (entObj.contains("imageUrl")) { + coverImageUrl = entObj["imageUrl"].toString(); + } else if (entObj.contains("image_url")) { + coverImageUrl = entObj["image_url"].toString(); + } else if (entObj.contains("thumbnail_url")) { + coverImageUrl = entObj["thumbnail_url"].toString(); } } + if (!coverImageUrl.isEmpty()) { + entObj["imageUrl"] = coverImageUrl; + } + + ps5Games.append(entObj); } - + return ps5Games; } @@ -1722,6 +1945,7 @@ void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) qInfo() << "[CROSS-REF] Loaded owned PS5 games from cache:" << crossReferenceState.ownedGames.size() << "games"; } } + // Bundle->components map for bundle-sibling matching (upstream PR #15). if (obj.contains(QStringLiteral("componentIdsByProductId")) && obj.value(QStringLiteral("componentIdsByProductId")).isObject()) { const QJsonObject m = obj.value(QStringLiteral("componentIdsByProductId")).toObject(); @@ -1735,7 +1959,7 @@ void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) } } } - + // If we have both from cache, process immediately if (catalogFromCache && ownedFromCache) { processCrossReferenceComplete(); @@ -2072,7 +2296,7 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi } // Fallback to catalog (may not have landscape images) - cacheKey = "ps5_cloud_catalog_v3"; + cacheKey = "ps5_cloud_catalog_v6"; isPsCloudLibrary = false; } else { qWarning() << "getGameLandscapeImage: Unknown service type:" << serviceType; @@ -2080,7 +2304,7 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi } // Load cache - use very large maxAge to never invalidate cache (read-only operation) - QString cached = (cacheKey == QLatin1String("ps5_cloud_catalog_v3")) + QString cached = (cacheKey == QLatin1String("ps5_cloud_catalog_v6")) ? getCachedPs5CatalogV3(INT_MAX) : getCachedData(cacheKey, INT_MAX); if (cached.isEmpty()) { @@ -2262,20 +2486,29 @@ void CloudCatalogBackend::processCrossReferenceComplete() buildStableKeyIndex(crossReferenceState.cloudCatalogGames); const QMap supplementStableKey = buildStableKeyIndex(crossReferenceState.plusLibrarySupplement); + const QMap browseByConcept = + buildConceptIdIndex(crossReferenceState.cloudCatalogGames); + const QMap supplementByConcept = + buildConceptIdIndex(crossReferenceState.plusLibrarySupplement); if (settings && settings->GetLogVerbose()) { qInfo() << "[CROSS-REF] Cloud catalog map size:" << cloudCatalogMap.size(); qInfo() << "[CROSS-REF] Product ID aliases:" << crossReferenceState.productIdAliases.size(); qInfo() << "[CROSS-REF] Plus library supplement map size:" << plusSupplementMap.size(); + qInfo() << "[CROSS-REF] Concept-id index (browse/supplement):" + << browseByConcept.size() << "/" << supplementByConcept.size(); qInfo() << "[CROSS-REF] Owned games count:" << crossReferenceState.ownedGames.size(); } QJsonArray filteredGames; int matchedCount = 0; - int t1Count = 0; - int t2Count = 0; - int t3Count = 0; - int t4Count = 0; + int productIdMatchCount = 0; + int entitlementIdMatchCount = 0; + int supplementMatchCount = 0; + int conceptIdBrowseMatchCount = 0; + int conceptIdSupplementMatchCount = 0; + int stableKeyBrowseMatchCount = 0; + int stableKeySupplementMatchCount = 0; QMap ownedByKey; for (const QJsonValue &ownedGame : crossReferenceState.ownedGames) { @@ -2288,84 +2521,16 @@ void CloudCatalogBackend::processCrossReferenceComplete() const QString entName = ownedGameObj.value(QStringLiteral("game_meta")).toObject() .value(QStringLiteral("name")).toString(); const bool skipStableDemo = entName.contains(QStringLiteral("demo"), Qt::CaseInsensitive); - - QList> matches; - int matchTier = 0; - - if (!productId.isEmpty() && cloudCatalogMap.contains(productId)) { - matches.append({cloudCatalogMap.value(productId), false}); - matchTier = 1; - } else if (!entitlementId.isEmpty() && cloudCatalogMap.contains(entitlementId)) { - matches.append({cloudCatalogMap.value(entitlementId), false}); - matchTier = 2; - } else if (!productId.isEmpty() && !entitlementId.isEmpty() - && entitlementId == productId && plusSupplementMap.contains(productId)) { - matches.append({plusSupplementMap.value(productId), true}); - matchTier = 2; - } else { - const QString entitlementStableKey = ps5CloudProductIdStableKey(entitlementId); - if (!entitlementStableKey.isEmpty() && !skipStableDemo - && browseStableKey.contains(entitlementStableKey)) { - matches.append({browseStableKey.value(entitlementStableKey), false}); - matchTier = 3; - } else if (!entitlementStableKey.isEmpty() && !skipStableDemo - && supplementStableKey.contains(entitlementStableKey)) { - matches.append({supplementStableKey.value(entitlementStableKey), true}); - matchTier = 3; - } - } - - if (matches.isEmpty()) { - QSet seenProductIds; - for (const QString &siblingId : - crossReferenceState.componentIdsByProductId.value(productId)) { - QJsonObject siblingMeta; - bool siblingFromSupplement = false; - if (cloudCatalogMap.contains(siblingId)) { - siblingMeta = cloudCatalogMap.value(siblingId); - } else if (plusSupplementMap.contains(siblingId)) { - siblingMeta = plusSupplementMap.value(siblingId); - siblingFromSupplement = true; - } else { - const QString siblingStableKey = ps5CloudProductIdStableKey(siblingId); - if (!siblingStableKey.isEmpty() && !skipStableDemo) { - if (browseStableKey.contains(siblingStableKey)) { - siblingMeta = browseStableKey.value(siblingStableKey); - } else if (supplementStableKey.contains(siblingStableKey)) { - siblingMeta = supplementStableKey.value(siblingStableKey); - siblingFromSupplement = true; - } - } - } - if (siblingMeta.isEmpty()) - continue; - const QString matchedPid = - siblingMeta.value(QStringLiteral("productId")).toString(); - if (matchedPid.isEmpty() || seenProductIds.contains(matchedPid)) - continue; - seenProductIds.insert(matchedPid); - matches.append({siblingMeta, siblingFromSupplement}); - } - if (!matches.isEmpty()) - matchTier = 4; - } - - if (matches.isEmpty()) - continue; - - switch (matchTier) { - case 1: t1Count++; break; - case 2: t2Count++; break; - case 3: t3Count++; break; - case 4: t4Count++; break; - default: break; - } - - for (const QPair &match : matches) { - const QJsonObject meta = match.first; - const bool fromSupplement = match.second; + const QString stableKey = ps5CloudProductIdStableKey(productId); + const QString entStableKey = ps5CloudProductIdStableKey(entitlementId); + const QString ownedConceptId = ownedEntitlementConceptId(ownedGameObj); + + // Enrich the owned entitlement with a matched catalog row and dedupe it into ownedByKey, in + // OUR field convention (catalogProductId = streamable catalog variant, productId = owned + // product, conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct + // match, or once per component for a bundle (upstream PR #15 bundle-sibling expansion). + auto emitOwned = [&](const QJsonObject &meta, bool fromSupplement) { QJsonObject entry = ownedGameObj; - if (meta.contains(QStringLiteral("name"))) { const QString imagicName = meta.value(QStringLiteral("name")).toString(); if (!imagicName.isEmpty()) @@ -2378,47 +2543,126 @@ void CloudCatalogBackend::processCrossReferenceComplete() if (meta.contains(QStringLiteral("conceptUrl"))) { entry.insert(QStringLiteral("conceptUrl"), meta.value(QStringLiteral("conceptUrl"))); } - // Identify the owned entry by the MATCHED CATALOG ROW (productId + - // conceptId) so the QML merge (findPs5CloudCatalogIndexForOwned) can - // link it back to the catalog card. Using the entitlement's bundle - // product_id here breaks T3/T4 matches whose entitlement id/product_id - // do not equal any catalog productId (e.g. RE7 base reached via the - // RE7 Gold bundle). The entitlement product_id is retained as - // storeProductId for streaming/store lookups. - const QString catalogProductId = meta.value(QStringLiteral("productId")).toString(); - entry.insert(QStringLiteral("productId"), - !catalogProductId.isEmpty() ? catalogProductId : productId); - entry.insert(QStringLiteral("storeProductId"), productId); - - // conceptId may be a JSON number or string; normalize to a string. - const QJsonValue conceptVal = meta.value(QStringLiteral("conceptId")); - const QString conceptId = conceptVal.isString() - ? conceptVal.toString() - : (conceptVal.isDouble() - ? QString::number(static_cast(conceptVal.toDouble())) - : QString()); - if (!conceptId.isEmpty()) - entry.insert(QStringLiteral("conceptId"), conceptId); + // Carry the catalog device list so the UI can tell PS4 from PS5 and route PS4 via Kamaji. + if (meta.contains(QStringLiteral("device"))) { + entry.insert(QStringLiteral("device"), meta.value(QStringLiteral("device"))); + } + // Cloud streaming binds to the catalog product variant (carries the PS Plus streaming + // offer), not the owned download product (e.g. God of War owned ...GODOFWAR vs catalog + // ...GODOFWARN). Keep the catalog productId so PS4 streaming converts the right variant. + const QString metaProductId = meta.value(QStringLiteral("productId")).toString(); + if (!metaProductId.isEmpty()) + entry.insert(QStringLiteral("catalogProductId"), metaProductId); + entry.insert(QStringLiteral("productId"), productId); entry.insert(QStringLiteral("streamingSupported"), !fromSupplement); - // Dedupe by the MATCHED CATALOG identity (conceptId, then catalog - // productId). Using the entitlement product_id here collapses every - // bundle sibling (e.g. RE7 Gold -> RE7 base + Village) into a single - // entry, dropping all but the first match. - const QString dedupeKey = !conceptId.isEmpty() ? QStringLiteral("c:") + conceptId - : !catalogProductId.isEmpty() ? QStringLiteral("p:") + catalogProductId + const QString conceptId = ps5CloudConceptIdString(meta.value(QStringLiteral("conceptId"))); + if (!conceptId.isEmpty()) + entry.insert(QStringLiteral("conceptId"), conceptId); + // Dedupe identity = conceptId + PLATFORM (the catalog edition key): a cross-gen title + // owned on PS4 and PS5 stays as two cards; same-platform SKUs (bonus/avatars) merge. + const QString platformToken = ps5CloudPlatformToken(productId); + const QString dedupeKey = !conceptId.isEmpty() ? QStringLiteral("c:") + conceptId + QLatin1Char(':') + platformToken + : !productId.isEmpty() ? QStringLiteral("p:") + productId : !entitlementId.isEmpty() ? QStringLiteral("e:") + entitlementId - : QStringLiteral("u:") + catalogProductId + QLatin1Char(':') + entitlementId; - + : QStringLiteral("u:") + productId + QLatin1Char(':') + entitlementId; if (ownedByKey.contains(dedupeKey)) { const QJsonObject existing = ownedByKey.value(dedupeKey); - const QString existingEntId = existing.value(QStringLiteral("id")).toString(); - if (existingEntId.isEmpty() && !entitlementId.isEmpty()) + // Keep the best streaming candidate: the canonical full-game entitlement (its + // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). + if (ps5CloudOwnedStreamRank(entry) > ps5CloudOwnedStreamRank(existing)) ownedByKey.insert(dedupeKey, entry); } else { ownedByKey.insert(dedupeKey, entry); } matchedCount++; + }; + + QJsonObject meta; + bool found = false; + bool fromSupplement = false; + + if (!productId.isEmpty() && cloudCatalogMap.contains(productId)) { + meta = cloudCatalogMap.value(productId); + found = true; + productIdMatchCount++; + } else if (!entitlementId.isEmpty() && cloudCatalogMap.contains(entitlementId)) { + meta = cloudCatalogMap.value(entitlementId); + found = true; + entitlementIdMatchCount++; + } else if (!ownedConceptId.isEmpty() && browseByConcept.contains(ownedConceptId)) { + meta = browseByConcept.value(ownedConceptId); + found = true; + conceptIdBrowseMatchCount++; + } else if (!ownedConceptId.isEmpty() && supplementByConcept.contains(ownedConceptId)) { + meta = supplementByConcept.value(ownedConceptId); + found = true; + fromSupplement = true; + conceptIdSupplementMatchCount++; + } else if (!productId.isEmpty() && !entitlementId.isEmpty() + && entitlementId == productId && plusSupplementMap.contains(productId)) { + meta = plusSupplementMap.value(productId); + found = true; + fromSupplement = true; + supplementMatchCount++; + } else if (!stableKey.isEmpty() && !skipStableDemo && browseStableKey.contains(stableKey)) { + meta = browseStableKey.value(stableKey); + found = true; + stableKeyBrowseMatchCount++; + } else if (!stableKey.isEmpty() && !skipStableDemo + && supplementStableKey.contains(stableKey)) { + meta = supplementStableKey.value(stableKey); + found = true; + fromSupplement = true; + stableKeySupplementMatchCount++; + } else if (!entStableKey.isEmpty() && !skipStableDemo && browseStableKey.contains(entStableKey)) { + // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade + // entitlement ids whose stable key matches a catalog row even when product_id did not. + meta = browseStableKey.value(entStableKey); + found = true; + stableKeyBrowseMatchCount++; + } else if (!entStableKey.isEmpty() && !skipStableDemo && supplementStableKey.contains(entStableKey)) { + meta = supplementStableKey.value(entStableKey); + found = true; + fromSupplement = true; + stableKeySupplementMatchCount++; + } + + if (found) { + emitOwned(meta, fromSupplement); + continue; + } + + // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no + // direct catalog row, but its component entitlement ids each map to a component game. Emit + // each distinct component as its own owned entry (via emitOwned, so OUR dedupe/rank apply). + QStringList seenMetaPids; + for (const QString &siblingId : crossReferenceState.componentIdsByProductId.value(productId)) { + QJsonObject siblingMeta; + bool siblingFromSupplement = false; + if (cloudCatalogMap.contains(siblingId)) { + siblingMeta = cloudCatalogMap.value(siblingId); + } else if (plusSupplementMap.contains(siblingId)) { + siblingMeta = plusSupplementMap.value(siblingId); + siblingFromSupplement = true; + } else { + const QString siblingStableKey = ps5CloudProductIdStableKey(siblingId); + if (!siblingStableKey.isEmpty() && !skipStableDemo) { + if (browseStableKey.contains(siblingStableKey)) { + siblingMeta = browseStableKey.value(siblingStableKey); + } else if (supplementStableKey.contains(siblingStableKey)) { + siblingMeta = supplementStableKey.value(siblingStableKey); + siblingFromSupplement = true; + } + } + } + if (siblingMeta.isEmpty()) + continue; + const QString siblingPid = siblingMeta.value(QStringLiteral("productId")).toString(); + if (siblingPid.isEmpty() || seenMetaPids.contains(siblingPid)) + continue; + seenMetaPids.append(siblingPid); + emitOwned(siblingMeta, siblingFromSupplement); } } @@ -2427,10 +2671,13 @@ void CloudCatalogBackend::processCrossReferenceComplete() if (settings && settings->GetLogVerbose()) { qInfo() << "[CROSS-REF] Matched games (cloud streamable):" << matchedCount; - qInfo() << "[CROSS-REF] T1 (product_id):" << t1Count; - qInfo() << "[CROSS-REF] T2 (entitlement id):" << t2Count; - qInfo() << "[CROSS-REF] T3 (stable key on id):" << t3Count; - qInfo() << "[CROSS-REF] T4 (bundle siblings):" << t4Count; + qInfo() << "[CROSS-REF] By product_id:" << productIdMatchCount; + qInfo() << "[CROSS-REF] By entitlement id (fallback):" << entitlementIdMatchCount; + qInfo() << "[CROSS-REF] By Plus library supplement:" << supplementMatchCount; + qInfo() << "[CROSS-REF] By conceptId (browse):" << conceptIdBrowseMatchCount; + qInfo() << "[CROSS-REF] By conceptId (supplement):" << conceptIdSupplementMatchCount; + qInfo() << "[CROSS-REF] By stable product id key (browse):" << stableKeyBrowseMatchCount; + qInfo() << "[CROSS-REF] By stable product id key (supplement):" << stableKeySupplementMatchCount; } QJsonObject result; @@ -2457,8 +2704,8 @@ void CloudCatalogBackend::processCrossReferenceComplete() void CloudCatalogBackend::invalidatePs5CatalogCache() { for (const QString &key : - {QStringLiteral("ps5_cloud_catalog_v3"), QStringLiteral("ps5_cloud_catalog_v2"), - QStringLiteral("ps5_cloud_catalog")}) { + {QStringLiteral("ps5_cloud_catalog_v6"), QStringLiteral("ps5_cloud_catalog_v5"), QStringLiteral("ps5_cloud_catalog_v4"), QStringLiteral("ps5_cloud_catalog_v3"), + QStringLiteral("ps5_cloud_catalog_v2"), QStringLiteral("ps5_cloud_catalog")}) { const QString path = getCacheFilePath(key); if (QFile::exists(path)) { QFile::remove(path); @@ -2471,7 +2718,7 @@ void CloudCatalogBackend::invalidateCache() { // Invalidate specific cache files (PSNOW, PS5 cloud catalog, and PS5 cloud library) QString psnowPath = getCacheFilePath("psnow_catalog"); - QString ps5CatalogPath = getCacheFilePath("ps5_cloud_catalog_v3"); + QString ps5CatalogPath = getCacheFilePath("ps5_cloud_catalog_v6"); QString ps5CatalogV2Path = getCacheFilePath("ps5_cloud_catalog_v2"); QString ps5LibraryPath = getCacheFilePath("ps5_cloud_library"); diff --git a/gui/src/cloudstreaming/psgaikaistreaming.cpp b/gui/src/cloudstreaming/psgaikaistreaming.cpp index 9cc4d09e..d0e569e2 100644 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ b/gui/src/cloudstreaming/psgaikaistreaming.cpp @@ -1206,9 +1206,13 @@ void PSGaikaiStreaming::step11_GetDatacenters() if(pingResultsMap.contains(datacenterName)) { // Use actual ping result - allResults.append(pingResultsMap[datacenterName]); + QJsonObject measured = pingResultsMap[datacenterName]; + measured["measured"] = true; // real RTT measurement + allResults.append(measured); } else { - // Use dummy data (999ms RTT) for datacenters that weren't pinged + // Use dummy data (999ms RTT) for datacenters that weren't pinged. + // Mark it unmeasured so the latency gate doesn't treat a failed + // measurement as genuinely-high latency. QJsonObject dummyResult; dummyResult["dataCenter"] = datacenterName; dummyResult["rtt"] = 999; @@ -1218,6 +1222,7 @@ void PSGaikaiStreaming::step11_GetDatacenters() dummyResult["port"] = dc["port"].toInt(); dummyResult["publicIp"] = dc["publicIp"].toString(); dummyResult["maxBandwidth"] = dc["maxBandwidth"].toInt(); + dummyResult["measured"] = false; allResults.append(dummyResult); } } @@ -1314,17 +1319,25 @@ void PSGaikaiStreaming::step12_SelectDatacenter(QJsonArray pingResults) } } - // Validate ping for auto-selected datacenters (manual selection bypasses this check) + // Validate ping for auto-selected datacenters (manual selection bypasses this check). + // Only gate on a REAL measurement: when the ping couldn't complete the result is a + // fabricated 999ms placeholder (measured=false), which must not be mistaken for genuine + // high latency — otherwise a transient ping failure blocks an otherwise-fine datacenter. bool isAutoSelected = (selectedDatacenterSetting == "Auto" || selectedDatacenterSetting.isEmpty()); if (isAutoSelected) { + const bool measured = selectedDatacenterPingResult.value("measured").toBool(false); int rtt_ms = selectedDatacenterPingResult["rtt"].toInt(0); - if (rtt_ms > 80) { + if (measured && rtt_ms > 80) { qWarning() << "Selected datacenter ping too high:" << selectedDatacenter << "RTT:" << rtt_ms << "ms (max: 80ms)"; emit pingTimeoutError(); emit AllocationError("Ping must be < 80ms to start a cloud session"); emit Finished(); return; } + if (!measured) { + qWarning() << "Datacenter latency could not be measured for" << selectedDatacenter + << "- proceeding without the latency gate (ping measurement failed, not necessarily high latency)"; + } } emit AllocationProgress(QString("Selecting Datacenter (%1) - Step 9 of 10").arg(selectedDatacenter)); diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index 71366781..f866592c 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -478,15 +478,76 @@ void PSKamajiSession::handleProductIdConversionResponse(QNetworkReply *reply) if (!streamingEntitlementId.isEmpty()) break; } } - - // Determine platform from playable_platform strings (pick highest: PS4 > PS3) + + // PS Plus catalog titles (e.g. PS4 games via PS Plus Premium) don't carry a per-game + // streaming license (license_type == 4) like the old PS Now catalog did — their full-game + // entitlement is license_type 0 with packageType "PS4GD"/"PS5GD"/"PSGD", streamable via the + // PS Plus subscription. Fall back to that full-game entitlement so step0_5e can acquire it. + if (streamingEntitlementId.isEmpty()) { + // Title id of the requested product, e.g. "EP1464-CUSA24653_00-..." -> "CUSA24653". + // Cross-gen containers list BOTH the PS4 (CUSA) and PS5 (PPSA) full-game entitlements; + // we must pick the one matching the requested product so the entitlement platform stays + // consistent with the streaming session (a PS5 entitlement on a PS4/kratos session makes + // the senkusha ping server never ack -> 0/5 pings -> allocation fails). + QString requestedTitleId; + { + const QStringList dashParts = productId.split(QLatin1Char('-')); + if (dashParts.size() >= 2) + requestedTitleId = dashParts[1].split(QLatin1Char('_')).value(0); + } + auto pickFullGameEntitlement = [&](const QJsonObject &skuObj, bool requireTitleMatch) -> bool { + if (!skuObj.contains("entitlements") || !skuObj["entitlements"].isArray()) + return false; + const QJsonArray entitlements = skuObj["entitlements"].toArray(); + for (const QJsonValue &entValue : entitlements) { + const QJsonObject ent = entValue.toObject(); + const QString entId = ent["id"].toString(); + const QString pkgType = ent["packageType"].toString(); + // Full game digital ("*GD"); skip add-ons (PS4AL), themes (PS4MISC), etc. + if (entId.isEmpty() || !pkgType.endsWith(QStringLiteral("GD"))) + continue; + if (requireTitleMatch && !requestedTitleId.isEmpty() && !entId.contains(requestedTitleId)) + continue; + streamingEntitlementId = entId; + sku = skuObj["id"].toString(); + streamingSku = sku; + qInfo() << "Found full-game Entitlement ID (PS Plus catalog fallback):" + << streamingEntitlementId << "packageType:" << pkgType << "SKU:" << sku + << "titleMatch:" << requireTitleMatch; + return true; + } + return false; + }; + // Pass 1: prefer the entitlement matching the requested product's title id (platform-consistent). + // Pass 2: fall back to any full-game entitlement. + for (bool requireTitleMatch : {true, false}) { + if (streamingEntitlementId.isEmpty() && pickFullGameEntitlement(defaultSku, requireTitleMatch)) + break; + if (streamingEntitlementId.isEmpty() && obj.contains("skus") && obj["skus"].isArray()) { + const QJsonArray skus = obj["skus"].toArray(); + for (const QJsonValue &skuValue : skus) { + if (pickFullGameEntitlement(skuValue.toObject(), requireTitleMatch)) + break; + } + } + if (!streamingEntitlementId.isEmpty()) + break; + } + } + + // Determine platform from playable_platform strings (pick highest: PS5 > PS4 > PS3) if (!playablePlatformArray.isEmpty()) { + bool hasPS5 = false; bool hasPS4 = false; bool hasPS3 = false; for (const QJsonValue &platformValue : playablePlatformArray) { QString platformStr = platformValue.toString(); + // Check PS5 first ("PS5™"/"PS5"); PS4/PS5 cross-gen containers may list both. + if (platformStr.contains("PS5", Qt::CaseInsensitive)) { + hasPS5 = true; + } // Check for PS4 (handles "PS4™" and "PS4") - if (platformStr.contains("PS4", Qt::CaseInsensitive)) { + else if (platformStr.contains("PS4", Qt::CaseInsensitive)) { hasPS4 = true; } // Check for PS3 (handles "PS3™" and "PS3") @@ -494,7 +555,9 @@ void PSKamajiSession::handleProductIdConversionResponse(QNetworkReply *reply) hasPS3 = true; } } - if (hasPS4) { + if (hasPS5) { + detectedPlatform = "ps5"; + } else if (hasPS4) { detectedPlatform = "ps4"; } else if (hasPS3) { detectedPlatform = "ps3"; diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 66161249..1bb20856 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -131,109 +131,58 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q oauthApiPath = "/v1"; // ACCOUNT_BASE already includes /api } - // Determine ChiakiTarget (device/console type used by Chiaki core). - // PSCLOUD should be treated as PS5. - // PSNOW target will be determined after platform is detected from API response. - ChiakiTarget target; - if (serviceType == "pscloud") { - target = CHIAKI_TARGET_PS5_1; - } else { // psnow - will be updated based on detected platform - target = CHIAKI_TARGET_PS4_9; // Default, will be updated if PS3 is detected - } - + // ChiakiTarget (console type for the Chiaki core). PSCLOUD = PS5; PSNOW refined after the + // Kamaji platform detection. + ChiakiTarget target = (serviceType == "pscloud") ? CHIAKI_TARGET_PS5_1 : CHIAKI_TARGET_PS4_9; qInfo() << "Using DUID:" << sharedDuid; - qInfo() << "Determined ChiakiTarget:" << target; - - // For PSNOW: Create Kamaji session handler (Steps 0.5a-0.5d) - // For PSCLOUD: Skip Kamaji entirely - PSKamajiSession *kamajiSession = nullptr; - QString finalEntitlementId = gameIdentifier; - + + // PS4 / PS3 (PSNOW) titles go through a Kamaji session: the PS4 store container exposes the + // streaming/full-game entitlement, which Kamaji converts and acquires via PS Plus. + // PS5 (PSCLOUD) titles skip Kamaji: PS5 store containers carry NO entitlements/skus to + // convert, so we stream the owned entitlement id directly via Gaikai (cronos). if (serviceType == "psnow") { qInfo() << "=== PSNOW Flow: Starting Kamaji Session ==="; - // Create Kamaji session with productId (will be converted to entitlementId) - // Platform will be automatically detected from the API response - kamajiSession = new PSKamajiSession( - settings, - sharedDuid, - gameIdentifier, // productId for PSNOW - CloudConfig::ACCOUNT_BASE, - redirectUri, - userAgent, - this - ); - - // When Kamaji completes, continue to Gaikai allocation - // Connect PS Plus subscription error signal + PSKamajiSession *kamajiSession = new PSKamajiSession( + settings, sharedDuid, gameIdentifier, CloudConfig::ACCOUNT_BASE, + KamajiConsts::REDIRECT_URI, KamajiConsts::USER_AGENT, this); + connect(kamajiSession, &PSKamajiSession::psPlusSubscriptionError, this, [this]() { QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - qmlBackend->setShowPSPlusSubscriptionDialog(true); - } + if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); }); - - // Connect account privacy settings error signal connect(kamajiSession, &PSKamajiSession::accountPrivacySettingsError, this, [this](QString upgradeUrl) { - qInfo() << "Account privacy settings error - URL:" << upgradeUrl; QmlBackend *qmlBackend = qobject_cast(parent()); if (qmlBackend) { qmlBackend->setAccountPrivacyUpgradeUrl(upgradeUrl); qmlBackend->setShowAccountPrivacySettingsDialog(true); - qInfo() << "Dialog triggered with URL length:" << upgradeUrl.length(); } }); - - connect(kamajiSession, &PSKamajiSession::sessionComplete, this, - [this, kamajiSession, callback, sharedDuid, serviceType, gameIdentifier, target, redirectUri, userAgent, oauthApiPath](bool success, QString message, QString entitlementId) { + connect(kamajiSession, &PSKamajiSession::sessionComplete, this, + [this, kamajiSession, callback, sharedDuid, serviceType, target, redirectUri, userAgent, oauthApiPath](bool success, QString message, QString entitlementId) { if (!success) { qWarning() << "Kamaji session creation failed:" << message; - - // Clear game image on error setGameImageUrl(QString()); - - // Emit sessionError to dismiss loading screen QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), + if (qmlBackend) + emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), QString("Session creation failed: %1").arg(message)); - } - - if (callback.isCallable()) { + if (callback.isCallable()) callback.call({false, QString("Session creation failed: %1").arg(message)}); - } kamajiSession->deleteLater(); return; } - qInfo() << "=== Kamaji Session Created, Starting Allocation ==="; qInfo() << "Converted Entitlement ID:" << entitlementId; - - // Get platform from Kamaji session (detected from API response) - QString detectedPlatform = kamajiSession->getPlatform(); - qInfo() << "Detected platform from Kamaji session:" << detectedPlatform; - - // Update target based on detected platform - ChiakiTarget platformTarget = target; - if (detectedPlatform == "ps3") { - platformTarget = CHIAKI_TARGET_PS4_9; // PS3 games use PS4 target for streaming - } else { - platformTarget = CHIAKI_TARGET_PS4_9; // PS4 games use PS4 target - } - - // Continue to Gaikai allocation with converted entitlementId + QString detectedPlatform = kamajiSession->getPlatform(); // ps4 / ps3 + ChiakiTarget platformTarget = CHIAKI_TARGET_PS4_9; // PS4 and PS3 both stream as PS4 startGaikaiAllocation(serviceType, detectedPlatform, entitlementId, sharedDuid, redirectUri, userAgent, oauthApiPath, platformTarget, callback, kamajiSession); }); - - // Start the Kamaji authentication flow kamajiSession->startSessionCreation(); } else { - // PSCLOUD: Skip Kamaji, start directly with Gaikai - // PSCLOUD always uses PS5 platform - QString ps5Platform = "ps5"; - qInfo() << "=== PSCLOUD Flow: Skipping Kamaji, Starting Gaikai Directly ==="; - qInfo() << "Using PS5 platform for PSCLOUD"; - startGaikaiAllocation(serviceType, ps5Platform, finalEntitlementId, sharedDuid, + // PSCLOUD: stream the owned entitlement id directly (no Kamaji — PS5 containers have none). + qInfo() << "=== PSCLOUD Flow: Direct Gaikai (PS5), entitlement:" << gameIdentifier << "==="; + startGaikaiAllocation(serviceType, QStringLiteral("ps5"), gameIdentifier, sharedDuid, redirectUri, userAgent, oauthApiPath, target, callback, nullptr); } } diff --git a/gui/src/qml/CloudGameCard.qml b/gui/src/qml/CloudGameCard.qml index a1648531..a65a3b98 100644 --- a/gui/src/qml/CloudGameCard.qml +++ b/gui/src/qml/CloudGameCard.qml @@ -16,11 +16,18 @@ Rectangle { property string cachedImageUrl: "" property string libraryFilter: "owned" // "owned" or "all" or "favorites" - filter mode for Game Library property var qrCodeDialog: null // Reference to QR code dialog + // In the modern PS Plus catalog (imagic; isPsnow=false) a game you don't own can't be streamed + // until it's added to your library: Gaikai rejects an unowned PS5 entitlement, and the legacy + // Kamaji $0-acquire only works for the old PS Now free-SKU titles, not modern Extra/Premium ones + // (e.g. Far Cry 5's streaming SKU is paid, so the acquire 500s). So ANY non-owned catalog game + // shows "Add Game" (QR to the store / Add-to-Library); owned games stream directly. Legacy + // PS Now browse cards (isPsnow) keep one-click Stream — free streaming is the PS Now model. + readonly property bool needsAddToLibrary: !isPsnow && gameData && !gameData.isOwned property bool isFavorite: false // Whether this game is favorited // Steam library shortcut: only when Steamworks build + Steam client (same gate as createCloudSteamShortcut usefulness) readonly property bool showCloudSteamShortcut: Chiaki.cloudSteamShortcutEnabled - && !(!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) + && !needsAddToLibrary signal streamGame(string productId, string platform, string serviceType) signal createShortcut(string productId, string entitlementId, string platform, string serviceType, string gameName) @@ -78,16 +85,23 @@ Rectangle { // Get the identifier to use for streaming (entitlement ID for PSCloud, product ID for PSNOW) function getStreamingIdentifier() { if (!gameData) return ""; - if (isPsnow) { - // PSNOW: use product ID (will be converted to entitlement ID by Kamaji) - return getProductId(); - } else { - // PSCloud: use entitlement ID (the 'id' field), fallback to product_id if id doesn't exist - if (gameData.id) return gameData.id; // Entitlement ID for PSCloud library games - if (gameData.product_id) return gameData.product_id; // Fallback - if (gameData.productId) return gameData.productId; // Fallback for catalog games - return ""; + if (isPsnow) return getProductId(); // legacy PS Now browse catalog + if (streamPlatform() === "ps4") { + // PS4 catalog: send the CUSA product id; Kamaji converts it and acquires the + // streaming entitlement via PS Plus (PS4 store containers expose the entitlement). + let p = streamProductId(); + return p !== "" ? p : getProductId(); } + // PS5: stream the owned PRODUCT id via the direct Gaikai path -- NOT the entitlement `id`. + // For cross-gen titles you upgraded (PS4 purchase + free PS5 copy), Sony's entitlement id + // is the stale ORIGINAL SKU (e.g. Alan Wake Remastered's old CUSA24653 license; Death + // Stranding's pre-Director's-Cut PPSA02624 SKU). Gaikai's cloud catalog has no game mapped + // to that stale id -> noGameForEntitlementId. The owned product_id is the current streamable + // PS5 SKU (Alan Wake -> PPSA01925; Death Stranding DC -> PPSA01968), which Gaikai accepts. + if (gameData.product_id) return gameData.product_id; + if (gameData.productId) return gameData.productId; + if (gameData.id) return gameData.id; + return ""; } function getPlatform() { @@ -120,12 +134,33 @@ Rectangle { } return "ps4"; } else { - return "ps5"; + return streamPlatform(); } } - + + // The product id to stream. Cloud streaming binds to the *catalog* product variant (the + // streamable representative, e.g. God of War's ...GODOFWARN or Alan Wake's PS5 PPSA id), + // not the user's owned download/trial/cross-gen entitlement — so prefer catalogProductId. + function streamProductId() { + if (!gameData) return ""; + return gameData.catalogProductId || gameData.product_id || gameData.productId || gameData.id || ""; + } + + // Platform to stream, from the chosen product's title id: CUSAxxxxx = PS4, PPSAxxxxx = PS5. + // This drives the streaming path (PS4 = kratos, PS5 = cronos); both go through the Kamaji + // acquire-flow. More reliable than the catalog "device" list (cross-gen titles list both) + // or whichever entitlement the user owns. Defaults to PS5 (the modern catalog). + function streamPlatform() { + let p = String(streamProductId()); + if (p.indexOf("PPSA") !== -1) return "ps5"; + if (p.indexOf("CUSA") !== -1) return "ps4"; + return "ps5"; + } + function getServiceType() { - return isPsnow ? "psnow" : "pscloud"; + if (isPsnow) return "psnow"; // legacy PS Now browse catalog + // serviceType selects the Gaikai spec/consts/virtType: psnow = PS4/kratos, pscloud = PS5/cronos. + return (streamPlatform() === "ps4") ? "psnow" : "pscloud"; } function getImageUrl() { @@ -308,8 +343,8 @@ Rectangle { height: 22 radius: 4 color: gameData && gameData.isOwned ? "#4CAF50" : "#FF9800" - visible: !isPsnow && libraryFilter === "all" - + visible: !isPsnow && (libraryFilter === "all" || libraryFilter === "catalog") + Label { id: ownedLabel anchors.centerIn: parent @@ -416,7 +451,7 @@ Rectangle { console.log("[CloudGameCard] qrCodeDialog:", qrCodeDialog); // Check if this is a non-owned game in "All" filter mode - if (!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) { + if (needsAddToLibrary) { console.log("[CloudGameCard] Condition met for QR code - showing dialog"); // Show QR code dialog with conceptUrl let conceptUrl = gameData.conceptUrl || gameData.concept_url; @@ -451,7 +486,7 @@ Rectangle { Label { anchors.centerIn: parent text: { - if (!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) { + if (needsAddToLibrary) { return qsTr("Add Game") } return qsTr("Stream Game") @@ -531,7 +566,7 @@ Rectangle { // Cross/A button (Enter/Space) - Stream game or show QR code if (event.key === Qt.Key_Return || event.key === Qt.Key_Space || event.key === Qt.Key_Enter) { // Check if this is a non-owned game in "All" filter mode - if (!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) { + if (needsAddToLibrary) { // Show QR code dialog with conceptUrl let conceptUrl = gameData.conceptUrl || gameData.concept_url; if (conceptUrl && qrCodeDialog) { diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 1ffc2fc4..40bd35f9 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -32,6 +32,9 @@ Pane { property string authErrorMessage: "" // Persistent auth error message property string libraryFilter: "all" // "all", "owned", or "favorites" - filter for Game Library property string catalogFilter: "all" // "all" or "favorites" - filter for Game Catalog + // When the legacy PS Now (Kamaji) browse store is unavailable for the region, + // the Game Catalog falls back to the modern imagic cloud catalog (pscloud). + property bool catalogImagicFallback: false property var ownedProductIds: [] // Set of product IDs that are owned (for filtering) property var favoriteProductIds: [] // Set of product IDs that are favorited property var qrCodeDialogRef: null // Reference to QR code dialog for child components @@ -164,6 +167,7 @@ Pane { filteredGames = []; currentPageGames = []; isLoading = true; + catalogImagicFallback = false; // attempt the legacy PS Now browse store first Chiaki.cloudCatalog.fetchPsnowCatalog(function(success, message, jsonData) { isLoading = false; if (success && jsonData) { @@ -197,15 +201,94 @@ Pane { showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); } } else { - console.error("Failed to fetch PSNOW catalog:", message); + // The legacy PS Now (Kamaji) browse store is region-locked / deprecated + // and 404s in many regions (e.g. Hungary). Fall back to the modern imagic + // cloud catalog so the Game Catalog shows streamable titles everywhere. + console.warn("PSNOW catalog unavailable, falling back to imagic cloud catalog:", message); + loadCatalogImagicFallback(); + } + }); + } + + // Game Catalog fallback: source the streamable PS4/PS5 cloud titles from the imagic + // catalog (the same source the Library uses) and mark which the user owns, so owned + // titles stream and the rest offer "Add Game". Presented as pscloud, not psnow. + // The PS Plus subscription catalog (what Sony lists on the PS Plus games page, ~630 in HU): + // browse titles tagged plusCatalog + the library-stream supplement (catalog titles with + // streamingSupported=false, e.g. God of War). Excludes the full ~7000-title all-ps5 universe, + // which is fetched only to match the games you own. + function ps5PlusCatalogGames(data) { + let games = []; + if (data && data.games && Array.isArray(data.games)) { + for (let i = 0; i < data.games.length; i++) { + if (data.games[i] && data.games[i].plusCatalog) + games.push(data.games[i]); + } + } + if (data && data.plusLibrarySupplement && Array.isArray(data.plusLibrarySupplement)) { + for (let i = 0; i < data.plusLibrarySupplement.length; i++) + games.push(data.plusLibrarySupplement[i]); + } + return games; + } + + function loadCatalogImagicFallback() { + catalogImagicFallback = true; + isLoading = true; + Chiaki.cloudCatalog.fetchPs5CloudCatalog(function(success, message, jsonData) { + if (!success || !jsonData) { + isLoading = false; allGames = []; filteredGames = []; currentPageGames = []; + console.error("Failed to fetch imagic cloud catalog:", message); showErrorToast(qsTr("API Error"), message || qsTr("Failed to fetch game catalog")); + return; + } + let browseGames = []; + try { + let data = JSON.parse(jsonData); + // Game Catalog = the PS Plus subscription catalog only (not the full streamable universe). + browseGames = ps5PlusCatalogGames(data); + } catch (e) { + isLoading = false; + console.error("Failed to parse imagic cloud catalog:", e); + showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); + return; } + if (message && message !== "Success" && message !== "Cached") + showErrorToast(qsTr("Partial Catalog"), message); + // Mark which subscription titles you already own, so a non-owned PS5 catalog game shows + // "Add Game" (it must be added to your library before Gaikai will stream it) while PS4 + // titles and owned games show "Stream". addUnmatchedOwned=false keeps the Catalog the + // pure subscription set (we only mark ownership, never add owned-but-uncatalogued games). + Chiaki.cloudCatalog.getOwnedPs5CloudGames(function(ownedSuccess, ownedMessage, ownedJsonData) { + let ownedGames = []; + if (ownedSuccess && ownedJsonData) { + try { + let ownedData = JSON.parse(ownedJsonData); + if (ownedData.games && Array.isArray(ownedData.games)) + ownedGames = ownedData.games; + } catch (e) { + console.warn("Catalog: failed to parse owned games for ownership marking:", e); + } + } + let merged = mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames, false); + sortPs5CloudLibraryGames(merged.games); + allGames = merged.games; + ownedProductIds = Array.from(merged.ownedIds); + isLoading = false; + applySearchFilter(); + Qt.callLater(() => { + if (gamesGrid.count > 0) { + gamesGrid.currentIndex = 0; + gamesGrid.forceActiveFocus(); + } + }); + }); }); } - + function ps5CloudProductId(game) { if (!game) return ""; @@ -221,6 +304,28 @@ Pane { return String(conceptId); } + // Platform from the title id (PPSA = PS5, CUSA = PS4), falling back to the device array. + function ps5CloudPlatformToken(game) { + let pid = ps5CloudProductId(game) || ps5CloudStreamingId(game) || ""; + if (pid.indexOf("PPSA") !== -1) return "ps5"; + if (pid.indexOf("CUSA") !== -1) return "ps4"; + let dev = game ? game.device : null; + if (Array.isArray(dev)) { + if (dev.indexOf("PS5") !== -1) return "ps5"; + if (dev.indexOf("PS4") !== -1) return "ps4"; + } + return ""; + } + + // Edition identity = conceptId + platform, so cross-gen editions (PS4 + PS5) of the same + // game are treated as distinct entries instead of being merged by conceptId alone. + function ps5CloudConceptPlatformKey(game) { + let c = ps5CloudConceptId(game); + if (!c) + return ""; + return c + "|" + ps5CloudPlatformToken(game); + } + function ps5CloudStreamingId(game) { if (!game) return ""; @@ -235,9 +340,9 @@ Pane { let productId = ps5CloudProductId(game); if (productId) byProductId[productId] = i; - let conceptId = ps5CloudConceptId(game); - if (conceptId) - byConceptId[conceptId] = i; + let conceptKey = ps5CloudConceptPlatformKey(game); + if (conceptKey) + byConceptId[conceptKey] = i; let streamId = ps5CloudStreamingId(game); if (streamId && streamId !== productId) byProductId[streamId] = i; @@ -249,9 +354,9 @@ Pane { let productId = ps5CloudProductId(game); if (productId) catalogIndex.byProductId[productId] = index; - let conceptId = ps5CloudConceptId(game); - if (conceptId) - catalogIndex.byConceptId[conceptId] = index; + let conceptKey = ps5CloudConceptPlatformKey(game); + if (conceptKey) + catalogIndex.byConceptId[conceptKey] = index; let streamId = ps5CloudStreamingId(game); if (streamId && streamId !== productId) catalogIndex.byProductId[streamId] = index; @@ -264,9 +369,11 @@ Pane { let streamId = ps5CloudStreamingId(ownedGame); if (streamId && catalogIndex.byProductId.hasOwnProperty(streamId)) return catalogIndex.byProductId[streamId]; - let conceptId = ps5CloudConceptId(ownedGame); - if (conceptId && catalogIndex.byConceptId.hasOwnProperty(conceptId)) - return catalogIndex.byConceptId[conceptId]; + // Match by conceptId + platform so an owned PS4 edition does NOT match a PS5-only catalog + // entry (and vice-versa); cross-gen editions stay as separate library cards. + let conceptKey = ps5CloudConceptPlatformKey(ownedGame); + if (conceptKey && catalogIndex.byConceptId.hasOwnProperty(conceptKey)) + return catalogIndex.byConceptId[conceptKey]; return -1; } @@ -282,7 +389,12 @@ Pane { }); } - function mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames) { + // addUnmatchedOwned: when true (Library), owned games not found in the browse list are + // appended; when false (Catalog), we only MARK ownership on catalog entries and never add + // owned-but-not-in-catalog titles — so the Catalog stays the pure subscription catalog. + function mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames, addUnmatchedOwned) { + if (addUnmatchedOwned === undefined) + addUnmatchedOwned = true; let games = browseGames.slice(); let catalogIndex = buildPs5CloudCatalogIndex(games); let ownedIds = new Set(); @@ -296,7 +408,12 @@ Pane { if (streamId) ownedIds.add(streamId); - let catalogMatch = findPs5CloudCatalogIndexForOwned(ownedGame, catalogIndex); + // Trials / free-to-play (feature_type 1) are kept as their OWN library card so the user + // can Stream the trial/free build, while the full version still appears separately as a + // not-owned "Add Game" card. So a trial must NOT collapse into the full-game catalog + // entry. Full games (ft 3/5) merge normally (mark the catalog entry owned). + let isTrialTier = ownedGame && ownedGame.feature_type === 1; + let catalogMatch = isTrialTier ? -1 : findPs5CloudCatalogIndexForOwned(ownedGame, catalogIndex); if (catalogMatch >= 0) { let existing = games[catalogMatch]; existing.isOwned = true; @@ -304,7 +421,12 @@ Pane { if (streamId) existing.id = streamId; let ownedProductId = ps5CloudProductId(ownedGame); - if (ownedProductId) { + // Carry the OWNED product id onto the catalog card only for PS5 (PPSA): an owned PS5 + // product IS the streamable entitlement (streamed directly via cronos). For PS4 (CUSA) + // the owned DOWNLOAD product (e.g. ...GODOFWAR) has NO PS Now streaming SKU -- the + // catalog entry's own productId (e.g. ...GODOFWARN, the "N" variant) is what Kamaji + // converts to a streaming entitlement -- so leave the catalog productId intact. + if (ownedProductId && ps5CloudPlatformToken(ownedProductId) === "ps5") { if (!existing.product_id) existing.product_id = ownedProductId; if (!existing.productId) @@ -314,6 +436,9 @@ Pane { continue; } + if (!addUnmatchedOwned) + continue; // Catalog: don't add owned titles that aren't in the subscription catalog + let entry = Object.assign({}, ownedGame); entry.isOwned = true; if (!entry.productId && entry.product_id) @@ -333,9 +458,12 @@ Pane { filteredGames = []; currentPageGames = []; isLoading = true; - + + // Library "all" = the PS Plus catalog with your owned titles merged in (owned ones show + // "Stream Game", the rest "Add Game"). Library "owned" = only the games you own. The + // Game Catalog tab is the all-streamable view where everything shows "Stream Game". if (libraryFilter === "all") { - // Fetch all streamable games from game catalog + // Fetch the catalog, then merge owned games in (marking ownership + adding owned extras). Chiaki.cloudCatalog.fetchPs5CloudCatalog(function(success, message, jsonData) { if (success && jsonData) { try { @@ -365,7 +493,11 @@ Pane { ownershipErrorMsg = ownedMessage || qsTr("Failed to verify game ownership"); } - let merged = mergeOwnedPs5CloudIntoBrowseCatalog(data.games, ownedGames); + // Library "all" = the full streamable universe (every PS4/PS5 cloud + // title) with owned titles merged in; non-owned show "Add Game". + // (The Game Catalog tab is the curated subscription view.) + let browse = (data.games && Array.isArray(data.games)) ? data.games : []; + let merged = mergeOwnedPs5CloudIntoBrowseCatalog(browse, ownedGames); ownedProductIds = Array.from(merged.ownedIds); allGames = merged.games; isLoading = false; @@ -1370,8 +1502,14 @@ Pane { gameData: modelData focus: false // GridView handles focus, not individual cards activeFocusOnTab: false - isPsnow: currentSection === "catalog" - libraryFilter: root.libraryFilter + // The catalog is normally PS Now; when it falls back to the imagic + // cloud catalog the cards are pscloud (correct streaming path/platform). + isPsnow: currentSection === "catalog" && !catalogImagicFallback + // Catalog cards: every subscription title is streamable, so use a non-"all" + // value to suppress the "Add Game" state — all of them show "Stream Game". + // Library cards use the real filter ("all" enables Add Game for non-owned). + libraryFilter: (currentSection === "catalog" && catalogImagicFallback) + ? "catalog" : root.libraryFilter qrCodeDialog: root.qrCodeDialogRef // Bind isFavorite to favoriteProductIds array changes diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 83048d0b..3d6b27c1 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -19,11 +19,14 @@ struct CloudGame: Identifiable, Hashable { var isOwned: Bool // Whether user owns this game (PS5) var entitlementId: String // PSCloud: entitlement id for streaming (Qt gameData.id) var storeProductId: String // PSCloud: product_id from entitlements API + var plusCatalog: Bool // In the PS Plus subscription catalog (vs the full streamable universe) + var featureType: Int // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on init(productId: String, name: String, imageUrl: String, landscapeImageUrl: String = "", platform: String = "ps4", serviceType: String = "psnow", conceptUrl: String = "", conceptId: String = "", isOwned: Bool = false, - entitlementId: String = "", storeProductId: String = "") { + entitlementId: String = "", storeProductId: String = "", plusCatalog: Bool = false, + featureType: Int = 0) { self.id = productId self.name = name self.imageUrl = imageUrl @@ -35,16 +38,50 @@ struct CloudGame: Identifiable, Hashable { self.isOwned = isOwned self.entitlementId = entitlementId self.storeProductId = storeProductId + self.plusCatalog = plusCatalog + self.featureType = featureType } - /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. + /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. Stream the owned PRODUCT id + /// (storeProductId), NOT the entitlement id: for cross-gen titles you upgraded, Sony's entitlement + /// id is the stale ORIGINAL SKU (Alan Wake's old CUSA license; Death Stranding's pre-DC SKU) that + /// Gaikai's cloud catalog has no game for -> noGameForEntitlementId. product_id is the current SKU. var streamingIdentifier: String { if serviceType.lowercased() == "pscloud" { - if !entitlementId.isEmpty { return entitlementId } if !storeProductId.isEmpty { return storeProductId } + if !entitlementId.isEmpty { return entitlementId } } return id } + + // A PlayStation title id encodes its platform: CUSAxxxxx = PS4, PPSAxxxxx = PS5. This is + // more reliable than the catalog device list, and decides the streaming path: PS4 goes + // through Kamaji (psnow) to acquire the streaming entitlement, PS5 streams directly (pscloud). + var streamPlatform: String { + // Prefer the OWNED product id (storeProductId): for a cross-gen title you upgraded, the catalog + // `id` may be the OTHER generation (Alan Wake's catalog entry is PS4 CUSA, but you own the PS5 + // PPSA), and the owned product is what decides the streaming path. + let p = !storeProductId.isEmpty ? storeProductId : (!id.isEmpty ? id : entitlementId) + if p.contains("PPSA") { return "ps5" } + if p.contains("CUSA") { return "ps4" } + return platform.isEmpty ? "ps5" : platform + } + + /// Service type to stream with: real legacy PS Now games stay psnow; otherwise route by the + /// title-id platform (PS4 catalog titles need the Kamaji acquire-flow, PS5 stays direct). + var streamServiceType: String { + if serviceType.lowercased() == "psnow" { return "psnow" } + return streamPlatform == "ps4" ? "psnow" : "pscloud" + } + + /// Identifier to send to startCompleteCloudSession: PS4/psnow sends the product id (Kamaji + /// converts it to an entitlement); PS5/pscloud sends the owned entitlement id (direct). + var streamIdentifier: String { + if streamServiceType == "psnow" { + return id.isEmpty ? streamingIdentifier : id + } + return streamingIdentifier + } } // MARK: - CloudStreamSession (matches Android CloudStreamSession.kt) @@ -191,6 +228,26 @@ enum CloudLocaleSettings { return (country, lang) } + /// Ordered store locales to try when fetching the catalog. Sony serves a fixed set of + /// language-COUNTRY combinations: the country is always valid but the language may not be + /// (a Hungarian-language account yields "hu-HU", which 404s, while "en-HU" works). Fall + /// back to English for the same country, then en-US, so the catalog loads in every region. + /// Each tuple is (canonical "ll-CC" for storage, lowercased "ll-cc" for the imagic URL). + static func fallbackChain() -> [(canonical: String, imagic: String)] { + let (country, lang) = parseStorePath(stored) + var seen = Set() + var chain: [(String, String)] = [] + func add(_ l: String, _ c: String) { + let canonical = "\(l)-\(c)" + let imagic = canonical.lowercased() + if seen.insert(imagic).inserted { chain.append((canonical, imagic)) } + } + add(lang, country) + add("en", country) + add("en", "US") + return chain + } + static func fromSession(language: String?, country: String?) -> String? { let lang = language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let cty = country?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -204,10 +261,18 @@ enum CloudLocaleSettings { "Kamaji session: no language/country in response (stored=%{public}s)", stored) return } - if isConfigured && locale == stored { - os_log(.info, log: cloudLocaleLog, - "Kamaji session locale unchanged: %{public}s", locale) - return + if isConfigured { + // The country is the real region signal; the language part may get auto-corrected + // by the imagic fetch (e.g. hu-HU settles on en-HU). Only re-save when the country + // changes, otherwise we'd clobber the validated locale on every Kamaji session. + let storedCountry = parseStorePath(stored).country + let sessionCountry = parseStorePath(locale).country + if storedCountry == sessionCountry { + os_log(.info, log: cloudLocaleLog, + "Kamaji session country unchanged (%{public}s), keeping validated locale %{public}s", + sessionCountry, stored) + return + } } setStored(locale) } diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 9538b55a..94be7133 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -18,9 +18,9 @@ final class CloudCatalogService { private static let cacheDuration: TimeInterval = 86400 // 24 hours private static let psnowCacheFile = "psnow_catalog.json" - private static let ps5PublicCacheFile = "ps5_cloud_catalog_v3.json" - private static let pscloudAllCacheFile = "pscloud_catalog.json" - private static let pscloudOwnedCacheFile = "pscloud_owned.json" + private static let ps5PublicCacheFile = "ps5_cloud_catalog_v4.json" // v4: adds plusCatalog tag + broader supplement + private static let pscloudAllCacheFile = "pscloud_catalog_v2.json" + private static let pscloudOwnedCacheFile = "pscloud_owned_v3.json" // v3: ft0 filter + rank dedupe + featureType private static var cacheDir: URL = { let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] @@ -95,7 +95,8 @@ final class CloudCatalogService { "platform": g.platform, "serviceType": g.serviceType, "conceptUrl": g.conceptUrl, "conceptId": g.conceptId, "isOwned": g.isOwned, - "entitlementId": g.entitlementId, "storeProductId": g.storeProductId + "entitlementId": g.entitlementId, "storeProductId": g.storeProductId, + "plusCatalog": g.plusCatalog, "featureType": g.featureType ] } @@ -106,13 +107,15 @@ final class CloudCatalogService { productId: pid, name: name, imageUrl: d["imageUrl"] as? String ?? "", landscapeImageUrl: d["landscapeImageUrl"] as? String ?? "", - platform: d["platform"] as? String ?? "ps4", + platform: { let p = ps5PlatformToken(pid); return p.isEmpty ? (d["platform"] as? String ?? "ps4") : p }(), serviceType: d["serviceType"] as? String ?? "psnow", conceptUrl: d["conceptUrl"] as? String ?? "", conceptId: d["conceptId"] as? String ?? "", isOwned: d["isOwned"] as? Bool ?? false, entitlementId: d["entitlementId"] as? String ?? "", - storeProductId: d["storeProductId"] as? String ?? "" + storeProductId: d["storeProductId"] as? String ?? "", + plusCatalog: d["plusCatalog"] as? Bool ?? false, + featureType: (d["featureType"] as? NSNumber)?.intValue ?? 0 ) } @@ -124,10 +127,9 @@ final class CloudCatalogService { private func loadPs5CloudCatalog(forceRefresh: Bool) -> Ps5CloudCatalogResult { let stored = CloudLocaleSettings.stored - let locale = CloudLocaleSettings.imagicLocale os_log(.info, log: catalogLog, - "PS5 catalog stored=%{public}s imagic=%{public}s forceRefresh=%{public}s", - stored, locale, forceRefresh ? "yes" : "no") + "PS5 catalog stored=%{public}s forceRefresh=%{public}s", + stored, forceRefresh ? "yes" : "no") if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { os_log(.info, log: catalogLog, "PS5 catalog: using disk cache") lastCatalogFetchWarning = nil @@ -135,20 +137,33 @@ final class CloudCatalogService { } lastCatalogFetchWarning = nil - guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: locale) else { - return Ps5CloudCatalogResult( - browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], - shouldCacheV3: false - ) - } - if fetched.shouldCacheV3, - !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { - cachePs5CatalogV3(fetched, locale: stored) - } - if let warning = fetched.catalogFetchWarning { - lastCatalogFetchWarning = warning + // Try the store-locale fallback chain (session locale -> en-COUNTRY -> en-US). A whole + // tier returning nil means it 404'd for an unsupported locale; escalate to the next. + for tier in CloudLocaleSettings.fallbackChain() { + guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: tier.imagic) else { + os_log(.info, log: catalogLog, + "PS5 imagic locale %{public}s failed, trying next tier", tier.imagic) + continue + } + // Persist the locale that actually worked so game details and the cache agree on it. + if tier.canonical != stored { + os_log(.info, log: catalogLog, + "PS5 store locale settled on %{public}s (was %{public}s)", tier.canonical, stored) + CloudLocaleSettings.setStored(tier.canonical) + } + if fetched.shouldCacheV3, + !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { + cachePs5CatalogV3(fetched, locale: tier.canonical) + } + if let warning = fetched.catalogFetchWarning { + lastCatalogFetchWarning = warning + } + return fetched } - return fetched + return Ps5CloudCatalogResult( + browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], + shouldCacheV3: false + ) } private func loadCachedPs5CatalogV3(expectedLocale: String) -> Ps5CloudCatalogResult? { @@ -258,36 +273,46 @@ final class CloudCatalogService { allPs5ListSucceeded = true } + let isPlus = isPlusCatalogList(categoryList) // subscription catalog vs the all-ps5 universe for category in categories { guard let gameArray = category["games"] as? [[String: Any]] else { continue } totalRows += gameArray.count for gameObj in gameArray { - guard isPs5Game(gameObj) else { continue } + // Accept PS4 and PS5; the old PS5-only gate dropped PS4-only PS-Plus-catalog + // titles (e.g. God of War 2018) before they could reach the supplement below. + guard isCloudDeviceGame(gameObj) else { continue } - if categoryList == "plus-games-list", - (gameObj["streamingSupported"] as? Bool) != true { + // Subscription-catalog titles with streamingSupported=false → library-stream + // supplement, captured from EVERY subscription list (not just plus-games-list). + if isPlus, (gameObj["streamingSupported"] as? Bool) != true { let productId = gameObj["productId"] as? String ?? "" if !productId.isEmpty, plusSupplementByProductId[productId] == nil { - plusSupplementByProductId[productId] = gameObj + var g = gameObj; g["plusCatalog"] = true + plusSupplementByProductId[productId] = g } continue } - guard isPs5StreamingGame(gameObj) else { continue } - let key = conceptKey(for: gameObj) + guard isCloudStreamingGame(gameObj) else { continue } + let key = editionKey(for: gameObj) // per game per platform (cross-gen split) let productId = gameObj["productId"] as? String ?? "" guard !key.isEmpty, !productId.isEmpty else { continue } - if let existing = byConceptId[key] { + if var existing = byConceptId[key] { let canonicalProductId = existing["productId"] as? String ?? "" if !canonicalProductId.isEmpty, productId != canonicalProductId, productIdAliases[productId] == nil { productIdAliases[productId] = canonicalProductId } + if isPlus, (existing["plusCatalog"] as? Bool) != true { + existing["plusCatalog"] = true + byConceptId[key] = existing + } continue } - byConceptId[key] = gameObj + var g = gameObj; g["plusCatalog"] = isPlus + byConceptId[key] = g order.append(key) } } @@ -326,15 +351,24 @@ final class CloudCatalogService { ) } - private func isPs5Game(_ gameObj: [String: Any]) -> Bool { + // PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not in these imagic lists). + // A PS4-only title such as God of War (2018) is streamable when owned even though it + // carries device ["PS4"], so the catalog must not discard it. + private func isCloudDeviceGame(_ gameObj: [String: Any]) -> Bool { guard let devices = gameObj["device"] as? [String] else { return false } - return devices.contains("PS5") + return devices.contains("PS5") || devices.contains("PS4") } - private func isPs5StreamingGame(_ gameObj: [String: Any]) -> Bool { + private func isCloudStreamingGame(_ gameObj: [String: Any]) -> Bool { guard (gameObj["streamingSupported"] as? Bool) == true else { return false } - guard let devices = gameObj["device"] as? [String] else { return false } - return devices.contains("PS5") + return isCloudDeviceGame(gameObj) + } + + // The PS Plus subscription catalog = these curated lists (≈ what Sony lists). all-ps5-list is + // the full streamable universe and must NOT count as subscription catalog. + private func isPlusCatalogList(_ categoryList: String) -> Bool { + return categoryList == "plus-games-list" || categoryList == "plus-classics-list" + || categoryList == "ubisoft-classics-list" || categoryList == "plus-monthly-games-list" } private func conceptKey(for gameObj: [String: Any]) -> String { @@ -344,6 +378,21 @@ final class CloudCatalogService { return gameObj["productId"] as? String ?? "" } + // Platform token from a product id (CUSA = PS4, PPSA = PS5). + private func ps5PlatformToken(_ productId: String) -> String { + if productId.contains("PPSA") { return "ps5" } + if productId.contains("CUSA") { return "ps4" } + return "" + } + + // Dedupe identity: one entry per game PER PLATFORM, so cross-gen PS4/PS5 editions (e.g. Deliver + // Us The Moon) both appear, while duplicate same-platform SKUs still collapse. + private func editionKey(for gameObj: [String: Any]) -> String { + let c = conceptKey(for: gameObj) + if c.isEmpty { return "" } + return c + "|" + ps5PlatformToken(gameObj["productId"] as? String ?? "") + } + private func cloudGameFromImagic(_ gameObj: [String: Any]) -> CloudGame? { let productId = gameObj["productId"] as? String ?? "" guard !productId.isEmpty else { return nil } @@ -358,9 +407,10 @@ final class CloudCatalogService { return CloudGame( productId: productId, name: name, imageUrl: imageUrl, landscapeImageUrl: imageUrl, - platform: "ps5", serviceType: "pscloud", + platform: { let p = ps5PlatformToken(productId); return p.isEmpty ? "ps5" : p }(), serviceType: "pscloud", conceptUrl: conceptUrl, conceptId: conceptKey(for: gameObj), - isOwned: false + isOwned: false, + plusCatalog: gameObj["plusCatalog"] as? Bool ?? false ) } @@ -413,6 +463,25 @@ final class CloudCatalogService { return allGames } + // MARK: - PS Plus Subscription Catalog (Catalog tab) + + /// The PS Plus subscription catalog: plusCatalog browse titles + the library-stream supplement + /// (the ~630 set Sony lists), NOT the full all-ps5 universe. No ownership fetch — every + /// subscription title is shown as streamable. Mirrors Qt ps5PlusCatalogGames + Catalog tab. + func fetchPlusCatalogGames(npssoToken: String = "", forceRefresh: Bool = false) -> [CloudGame] { + let catalog = loadPs5CloudCatalog(forceRefresh: forceRefresh) + var games = catalog.browseGames.filter { $0.plusCatalog } + games.append(contentsOf: catalog.plusLibrarySupplement) + games.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + // Mark which subscription titles you already own, so owned games show "Stream" and non-owned + // show "Add Game" (they must be added to your library first). addUnmatched:false keeps the + // Catalog the pure subscription set (mark only; never add owned-but-uncatalogued games). + guard !npssoToken.isEmpty else { return games } + let owned = fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: forceRefresh) + return PsCloudOwnership.mergeOwnedIntoBrowseCatalog( + browseCatalog: games, ownedCrossRef: owned, addUnmatched: false) + } + // MARK: - PS5 Cloud Library: Owned Only /// Mirrors CloudCatalogBackend::getOwnedPs5CloudGames (owned tab) @@ -466,18 +535,21 @@ final class CloudCatalogService { return nil } let rawEntitlements = rawObjects.compactMap { PsCloudOwnership.parseEntitlement($0) } - var componentIdsByProductId: [String: [String]] = [:] - for e in rawEntitlements where !e.productId.isEmpty && !e.id.isEmpty { - componentIdsByProductId[e.productId, default: []].append(e.id) - } let filtered = PsCloudOwnership.filterOwnedPs5Games(rawEntitlements) + // Map each bundle product_id -> the entitlement ids sharing it, so a bundle (e.g. RE7 Gold) + // expands to its component games during cross-reference (upstream PR #15 bundle-sibling match). + var componentIds: [String: [String]] = [:] + for ent in rawEntitlements where !ent.productId.isEmpty && !ent.id.isEmpty { + componentIds[ent.productId, default: []].append(ent.id) + } + return PsCloudOwnership.crossReferenceOwnedGames( filteredEntitlements: filtered, publicCatalog: publicCatalog, plusLibrarySupplement: plusLibrarySupplement, productIdAliases: productIdAliases, - componentIdsByProductId: componentIdsByProductId + componentIdsByProductId: componentIds ) } diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 6ce3325c..8b1cb55d 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -161,33 +161,56 @@ final class PSKamajiSession { var sku = "" var detectedPlatform = "ps4" - // Check default_sku for streaming entitlements (license_type == 4) - if let defaultSku = json["default_sku"] as? [String: Any], - let ents = defaultSku["entitlements"] as? [[String: Any]] { + // PS Now streaming entitlements have license_type == 4. Check default_sku, then skus. + func pickStreamingEntitlement(_ skuObj: [String: Any]) -> Bool { + guard let ents = skuObj["entitlements"] as? [[String: Any]] else { return false } for ent in ents { if (ent["license_type"] as? Int) == 4, let id = ent["id"] as? String, !id.isEmpty { - eid = id; sku = defaultSku["id"] as? String ?? ""; break + eid = id; sku = skuObj["id"] as? String ?? ""; return true } } + return false } - - // Fallback to skus array + if let defaultSku = json["default_sku"] as? [String: Any] { _ = pickStreamingEntitlement(defaultSku) } if eid.isEmpty, let skus = json["skus"] as? [[String: Any]] { - for skuObj in skus { - if let ents = skuObj["entitlements"] as? [[String: Any]] { - for ent in ents { - if (ent["license_type"] as? Int) == 4, let id = ent["id"] as? String, !id.isEmpty { - eid = id; sku = skuObj["id"] as? String ?? ""; break - } - } + for skuObj in skus where pickStreamingEntitlement(skuObj) { break } + } + + // Full-game fallback: PS Plus catalog titles have no license_type==4 entitlement; their + // full-game entitlement is license_type 0 with packageType "*GD". Prefer the one whose id + // matches the requested product's title id so cross-gen picks the consistent platform. + if eid.isEmpty { + let requestedTitleId: String = { + let dash = productId.split(separator: "-") + guard dash.count >= 2 else { return "" } + return String(dash[1].split(separator: "_").first ?? "") + }() + func pickFullGameEntitlement(_ skuObj: [String: Any], requireTitleMatch: Bool) -> Bool { + guard let ents = skuObj["entitlements"] as? [[String: Any]] else { return false } + for ent in ents { + guard let id = ent["id"] as? String, !id.isEmpty else { continue } + let pkg = ent["packageType"] as? String ?? "" + guard pkg.hasSuffix("GD") else { continue } + if requireTitleMatch, !requestedTitleId.isEmpty, !id.contains(requestedTitleId) { continue } + eid = id; sku = skuObj["id"] as? String ?? ""; return true + } + return false + } + for requireTitleMatch in [true, false] { + if let defaultSku = json["default_sku"] as? [String: Any], + pickFullGameEntitlement(defaultSku, requireTitleMatch: requireTitleMatch) { break } + if let skus = json["skus"] as? [[String: Any]] { + var found = false + for skuObj in skus where pickFullGameEntitlement(skuObj, requireTitleMatch: requireTitleMatch) { found = true; break } + if found { break } } - if !eid.isEmpty { break } } } - // Detect platform + // Detect platform (PS5 > PS4 > PS3) if let platforms = json["playable_platform"] as? [String] { - if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS4") }) { detectedPlatform = "ps4" } + if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS5") }) { detectedPlatform = "ps5" } + else if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS4") }) { detectedPlatform = "ps4" } else if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS3") }) { detectedPlatform = "ps3" } } diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index d6778e95..5a352bd4 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -9,6 +9,8 @@ struct PsCloudEntitlement { let activeFlag: Bool let packageType: String let name: String + let conceptId: String + let featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC } enum PsCloudOwnership { @@ -22,10 +24,18 @@ enum PsCloudOwnership { static func filterOwnedPs5Games(_ entitlements: [PsCloudEntitlement]) -> [PsCloudEntitlement] { entitlements.filter { ent in - guard ent.packageType == "PSGD" else { return false } + // Previously required packageType == "PSGD" (PS5 only), which dropped owned + // PS4 titles (e.g. God of War 2018) and PS3 titles. Accept every active game + // entitlement; streamability is enforced downstream by the catalog cross-reference + // (matches are deduped by conceptId), so non-streamable / add-on entitlements are + // harmlessly dropped there. guard ent.activeFlag else { return false } let pid = ent.productId guard !pid.hasPrefix("IP"), !pid.hasPrefix("SUB") else { return false } + // Hide EXTRAS: feature_type==0 is DLC / add-ons / themes / avatars / cross-buy "tracks", + // never a base game (games are feature_type 1=trial/free or 3/5=full). Safe: can't hide a + // game. Trials/free and full games are kept; the trial-vs-full split is handled at merge. + guard ent.featureType != 0 else { return false } return true } } @@ -54,10 +64,16 @@ enum PsCloudOwnership { let browseStableKey = buildStableKeyIndex(publicCatalog) let supplementStableKey = buildStableKeyIndex(plusLibrarySupplement) + let browseByConcept = buildConceptIdIndex(publicCatalog) + let supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) var byKey: [String: CloudGame] = [:] + var byKeyRank: [String: Int] = [:] - func emitMatch(meta: CloudGame, ent: PsCloudEntitlement) { + // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping + // OUR convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a + // direct match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). + func emit(_ meta: CloudGame, _ ent: PsCloudEntitlement) { let displayName = meta.name.isEmpty ? ent.name : meta.name let game = CloudGame( productId: meta.id, @@ -70,83 +86,125 @@ enum PsCloudOwnership { conceptId: meta.conceptId, isOwned: true, entitlementId: ent.id, - storeProductId: ent.productId + storeProductId: ent.productId, + featureType: ent.featureType ) - let key = ownedDedupeKey(meta: meta, ent: ent) - if let existing = byKey[key] { - byKey[key] = preferOwnedEntry(existing: existing, candidate: game) + let candidateRank = ownedStreamRank(ent) + if byKey[key] != nil { + // Keep the best streaming candidate: the canonical full-game entitlement (its + // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). + if candidateRank > (byKeyRank[key] ?? -1) { + byKey[key] = game + byKeyRank[key] = candidateRank + } } else { byKey[key] = game + byKeyRank[key] = candidateRank } } for ent in filteredEntitlements { + let stable = productIdStableKey(ent.productId) + let entStable = productIdStableKey(ent.id) let skipStableDemo = ent.name.localizedCaseInsensitiveContains("demo") - var matches: [CloudGame] = [] - + let meta: CloudGame? if !ent.productId.isEmpty, let g = catalogMap[ent.productId] { - matches.append(g) + meta = g } else if !ent.id.isEmpty, let g = catalogMap[ent.id] { - matches.append(g) + meta = g + } else if !ent.conceptId.isEmpty, let g = browseByConcept[ent.conceptId] { + // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). + meta = g + } else if !ent.conceptId.isEmpty, let g = supplementByConcept[ent.conceptId] { + meta = g } else if !ent.productId.isEmpty, ent.id == ent.productId, let g = supplementMap[ent.productId] { - matches.append(g) + meta = g + } else if let stable, !skipStableDemo, let g = browseStableKey[stable] { + meta = g + } else if let stable, !skipStableDemo, let g = supplementStableKey[stable] { + meta = g + } else if let entStable, !skipStableDemo, let g = browseStableKey[entStable] { + // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade + // entitlement ids whose stable key matches a catalog row even when product_id did not. + meta = g + } else if let entStable, !skipStableDemo, let g = supplementStableKey[entStable] { + meta = g } else { - let entitlementStableKey = productIdStableKey(ent.id) - if let entitlementStableKey, !skipStableDemo, - let g = browseStableKey[entitlementStableKey] { - matches.append(g) - } else if let entitlementStableKey, !skipStableDemo, - let g = supplementStableKey[entitlementStableKey] { - matches.append(g) - } + meta = nil } - if matches.isEmpty { - var seenProductIds: Set = [] - for siblingId in componentIdsByProductId[ent.productId] ?? [] { - let siblingMeta: CloudGame? - if let g = catalogMap[siblingId] { - siblingMeta = g - } else if let g = supplementMap[siblingId] { - siblingMeta = g - } else if let siblingStableKey = productIdStableKey(siblingId), !skipStableDemo { - siblingMeta = browseStableKey[siblingStableKey] - ?? supplementStableKey[siblingStableKey] - } else { - siblingMeta = nil - } - - guard let meta = siblingMeta else { continue } - if meta.id.isEmpty || seenProductIds.contains(meta.id) { continue } - seenProductIds.insert(meta.id) - matches.append(meta) - } + if let meta { + emit(meta, ent) + continue } - if matches.isEmpty { continue } - - for meta in matches { - emitMatch(meta: meta, ent: ent) + // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no + // direct catalog row, but its component entitlement ids each map to a component game. + var seenPids = Set() + for siblingId in componentIdsByProductId[ent.productId] ?? [] { + let siblingMeta: CloudGame? + if let g = catalogMap[siblingId] { + siblingMeta = g + } else if let g = supplementMap[siblingId] { + siblingMeta = g + } else if let sStable = productIdStableKey(siblingId), !skipStableDemo { + siblingMeta = browseStableKey[sStable] ?? supplementStableKey[sStable] + } else { + siblingMeta = nil + } + guard let sMeta = siblingMeta, !sMeta.id.isEmpty, !seenPids.contains(sMeta.id) else { continue } + seenPids.insert(sMeta.id) + emit(sMeta, ent) } } return Array(byKey.values) } + // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen + // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing + // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. private static func ownedDedupeKey(meta: CloudGame, ent: PsCloudEntitlement) -> String { - if !meta.conceptId.isEmpty { return "c:\(meta.conceptId)" } + if !meta.conceptId.isEmpty { return "c:\(meta.conceptId):\(platformToken(ent.productId))" } if !meta.id.isEmpty { return "p:\(meta.id)" } if !ent.id.isEmpty { return "e:\(ent.id)" } return "u:\(meta.id):\(ent.id)" } - private static func preferOwnedEntry(existing: CloudGame, candidate: CloudGame) -> CloudGame { - if existing.entitlementId.isEmpty, !candidate.entitlementId.isEmpty { - return candidate - } - return existing + // Platform token from a product id (CUSA = PS4, PPSA = PS5). + static func platformToken(_ productId: String) -> String { + if productId.contains("PPSA") { return "ps5" } + if productId.contains("CUSA") { return "ps4" } + return "" + } + + // A "full game" entitlement (vs add-on/avatar/theme): PSN marks the base game with a *GD + // package_type (PSGD/PS4GD); add-ons use PS4MISC/PSAL/etc. + private static func isFullGameEntitlement(_ ent: PsCloudEntitlement) -> Bool { + ent.featureType == 3 || ent.packageType.hasSuffix("GD") + } + + // Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). + // Bonus/upgrade SKUs collapse to the same conceptId+platform as the base game; package/feature + // flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3). + // The reliable signal: the base game's entitlement id EQUALS its product_id, while bonus/upgrade + // SKUs carry a different id -- so prefer the canonical full-game entitlement. + private static func ownedStreamRank(_ ent: PsCloudEntitlement) -> Int { + var rank = 0 + if !ent.productId.isEmpty && ent.productId == ent.id { rank += 4 } // canonical base-game SKU + if isFullGameEntitlement(ent) { rank += 2 } + if !ent.id.isEmpty { rank += 1 } + return rank + } + + // conceptId + platform for an owned/catalog game; the owned product id (storeProductId) takes + // precedence so the owned edition's platform is used, else the catalog product id. + private static func conceptPlatformKey(_ game: CloudGame) -> String { + guard !game.conceptId.isEmpty else { return "" } + let pid = game.storeProductId.isEmpty ? game.id : game.storeProductId + return "\(game.conceptId)|\(platformToken(pid))" } /// Tokenize on '-' and '_'; identity is all tokens except the last (store SKU). @@ -173,15 +231,38 @@ enum PsCloudOwnership { return index } + private static func buildConceptIdIndex(_ games: [CloudGame]) -> [String: CloudGame] { + var index: [String: CloudGame] = [:] + for game in games where !game.conceptId.isEmpty { + if index[game.conceptId] == nil { + index[game.conceptId] = game + } + } + return index + } + + /// Normalize a conceptId (imagic encodes it as a number) to a non-empty string, else nil. + static func conceptIdString(_ value: Any?) -> String? { + if let i = value as? Int { return i > 0 ? String(i) : nil } + if let d = value as? Double { return d > 0 ? String(Int(d)) : nil } + if let s = value as? String, !s.isEmpty { return s } + return nil + } + static func mergeOwnedIntoBrowseCatalog( browseCatalog: [CloudGame], - ownedCrossRef: [CloudGame] + ownedCrossRef: [CloudGame], + addUnmatched: Bool = true // false = only mark ownership on catalog entries (Catalog tab) ) -> [CloudGame] { var games = browseCatalog var catalogIndex = buildCatalogIndex(games) for owned in ownedCrossRef { - let catalogMatch = findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) + // Trials / free-to-play (feature_type 1) are kept as their OWN card so the user can Stream + // the trial/free build, while the full version still shows separately as a not-owned + // "Add Game" card -- so a trial must NOT collapse into the full-game catalog entry. + let isTrialTier = owned.featureType == 1 + let catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) if catalogMatch >= 0 { var existing = games[catalogMatch] existing.isOwned = true @@ -191,6 +272,7 @@ enum PsCloudOwnership { continue } + guard addUnmatched else { continue } var entry = owned entry.isOwned = true registerInCatalogIndex(entry, index: games.count, catalogIndex: &catalogIndex) @@ -207,12 +289,18 @@ enum PsCloudOwnership { guard let id = obj["id"] as? String, !id.isEmpty else { return nil } let gameMeta = obj["game_meta"] as? [String: Any] ?? [:] let name = (gameMeta["name"] as? String) ?? id + let conceptId = conceptIdString(gameMeta["conceptId"]) + ?? conceptIdString(gameMeta["concept_id"]) + ?? conceptIdString(obj["conceptId"]) + ?? "" return PsCloudEntitlement( id: id, productId: (obj["product_id"] as? String) ?? "", activeFlag: (obj["active_flag"] as? Bool) ?? false, packageType: (gameMeta["package_type"] as? String) ?? "", - name: name + name: name, + conceptId: conceptId, + featureType: (obj["feature_type"] as? NSNumber)?.intValue ?? 0 ) } @@ -230,7 +318,8 @@ enum PsCloudOwnership { catalogIndex: inout CatalogIndex ) { if !game.id.isEmpty { catalogIndex.byProductId[game.id] = index } - if !game.conceptId.isEmpty { catalogIndex.byConceptId[game.conceptId] = index } + let conceptKey = conceptPlatformKey(game) + if !conceptKey.isEmpty { catalogIndex.byConceptId[conceptKey] = index } if !game.entitlementId.isEmpty, game.entitlementId != game.id { catalogIndex.byProductId[game.entitlementId] = index } @@ -240,7 +329,10 @@ enum PsCloudOwnership { if !owned.id.isEmpty, let idx = catalogIndex.byProductId[owned.id] { return idx } if !owned.entitlementId.isEmpty, let idx = catalogIndex.byProductId[owned.entitlementId] { return idx } if !owned.storeProductId.isEmpty, let idx = catalogIndex.byProductId[owned.storeProductId] { return idx } - if !owned.conceptId.isEmpty, let idx = catalogIndex.byConceptId[owned.conceptId] { return idx } + // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog + // entry (and vice-versa); cross-gen editions stay as separate library cards. + let conceptKey = conceptPlatformKey(owned) + if !conceptKey.isEmpty, let idx = catalogIndex.byConceptId[conceptKey] { return idx } return -1 } } diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index bd3bea81..98d8e7cf 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -114,7 +114,13 @@ final class CloudPlayViewModel: ObservableObject { switch section { case .catalog: - loadedGames = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken) + let psnow = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken) + // The legacy PS Now (Kamaji) browse store 404s in many regions. Fall back to the + // PS Plus subscription catalog (~630), NOT the full ~4000 universe — the Library + // "all" view is the full-universe browse. + loadedGames = psnow.isEmpty + ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken) + : psnow case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken) @@ -171,7 +177,12 @@ final class CloudPlayViewModel: ObservableObject { switch section { case .catalog: - loadedGames = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken, forceRefresh: true) + let psnow = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken, forceRefresh: true) + // Fall back to the PS Plus subscription catalog when the legacy PS Now store is + // unavailable for the region (Library "all" is the full-universe browse). + loadedGames = psnow.isEmpty + ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken, forceRefresh: true) + : psnow case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: true) @@ -195,9 +206,11 @@ final class CloudPlayViewModel: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let gameIdentifier = game.streamingIdentifier + // Route by the title-id platform: PS4 catalog titles go through Kamaji (psnow) to + // acquire the streaming entitlement; PS5 streams directly (pscloud). + let gameIdentifier = game.streamIdentifier let gameName = game.name - let serviceType = game.serviceType + let serviceType = game.streamServiceType var cancelled = false do { @@ -538,11 +551,12 @@ struct CloudPlayView: View { .padding(.vertical, 8) } - /// Matches Android `CloudPlayFragment.onGameClicked`: PS Cloud + All filter + not owned → add-to-library, else stream. + /// Any non-owned modern cloud-catalog game (PS4 or PS5) must be added to your library before it + /// can stream — Gaikai rejects an unowned PS5 entitlement, and modern PS-Plus PS4 titles (e.g. + /// Far Cry 5) have no free Kamaji SKU. Owned games stream directly. (Legacy PS Now is psnow.) private func handleGameTap(_ game: CloudGame) { let isPscloud = game.serviceType.lowercased() == "pscloud" - let isAllGamesFilter = !viewModel.showOwnedOnly - if viewModel.currentSection == .library && isPscloud && isAllGamesFilter && !game.isOwned { + if isPscloud && !game.isOwned { let url = game.conceptUrl.trimmingCharacters(in: .whitespacesAndNewlines) if url.isEmpty { showMissingConceptAlert = true @@ -593,7 +607,7 @@ struct CloudPlayView: View { CloudGameCardView( game: game, isFavorite: viewModel.favoriteIds.contains(game.id), - showOwnershipBadge: viewModel.currentSection == .library, + showOwnershipBadge: true, // owned/not-owned shown in Library AND Catalog (pscloud cards) onTap: { handleGameTap(game) }, From 36e5affbde4eacddb9834ac393499801fd80a299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Wed, 3 Jun 2026 22:00:14 +0200 Subject: [PATCH 02/72] Add PS3 Classics cloud streaming (region-generic, all platforms) PS Plus Premium streams ~250-330 PS3 Classics that never appear in the imagic/gameslist catalog the rest of cloud play uses. Source them from the public pcnow ("Apollo") container API and stream them via the existing Gaikai konan path. - Catalog: new fetchPs3Catalog walks the public Apollo PS3 container (no auth), paginated; surfaced in the Game Catalog and Library "all" views (not "owned"). PS3 cards always show "Stream Game". - Region-generic: pcnow has two Classics id families -- Americas/SCEA (store MSF192018, UP/NPUA/BLUS ids, child APOLLOPS3GAMES) and PAL/SCEE (store MSF192014, EP/NPEA/NPEB/BLES ids, child APOLLOPS3). The account region group selects the store; everything outside the Americas -> PAL. - Streaming: for legacy (non-CUSA/PPSA) ids, resolve product->entitlement in the region-group store, and skip the regional checkout/acquire on a 404 (Premium auto-authorizes at Gaikai; the checkout is unavailable in regions without a pcnow storefront, e.g. Hungary). - PS4 (CUSA) / PS5 (PPSA) paths unchanged. Ported across macOS (Qt), iOS (Swift), Android (Kotlin). macOS + Android verified streaming on a real PS Plus Premium account; iOS compile-verified. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/PsnApiConstants.kt | 38 ++++- .../chiaki/cloudplay/api/PSKamajiSession.kt | 36 ++++- .../cloudplay/api/PsCloudCatalogService.kt | 118 ++++++++++++++ .../repository/CloudGameRepository.kt | 41 +++++ .../metallic/chiaki/main/CloudPlayFragment.kt | 46 +++--- .../chiaki/main/CloudPlayViewModel.kt | 59 ++++++- gui/include/cloudcatalogbackend.h | 22 ++- gui/include/cloudstreaming/pskamajisession.h | 33 ++++ gui/src/cloudcatalogbackend.cpp | 146 ++++++++++++++++++ gui/src/cloudstreaming/pskamajisession.cpp | 37 ++++- gui/src/qml/CloudPlayView.qml | 64 +++++++- ios/Pylux/Models/CloudModels.swift | 33 ++++ ios/Pylux/Services/CloudCatalogService.swift | 79 ++++++++++ ios/Pylux/Services/PSKamajiSession.swift | 33 +++- ios/Pylux/Views/CloudPlayView.swift | 12 +- 15 files changed, 757 insertions(+), 40 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt index 3decfc13..39235f76 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt @@ -22,7 +22,43 @@ object PsnApiConstants const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" const val PS4_SCOPES = "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" - + const val ROOT_CONTAINER_ID = "STORE-MSF75508-PSNOWALLGAMES" } +/** + * PS3 / Classics pcnow store helpers, by account region group. + * Mirrors KamajiConsts (gui/include/cloudstreaming/pskamajisession.h) exactly. + * + * pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: + * - SCEA / Americas -> store MSF192018, US-region ids (UP/NPUA/BLUS), + * PS3 child container "APOLLOPS3GAMES" + * - SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP/NPEA/NPEB/BLES), + * PS3 child container "APOLLOPS3" + * JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to + * PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region + * group, so the catalog must be browsed + resolved in the account's group. Region is keyed + * by the ACCOUNT's region group, NOT by parsing the product-id prefix. + */ +object KamajiClassics +{ + private val AMERICAS = setOf( + "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", "PY", "UY", + "CR", "GT", "HN", "NI", "PA", "SV", "DO" + ) + + fun isAmericasClassicsRegion(countryCode: String): Boolean = + AMERICAS.contains(countryCode.uppercase()) + + /** Country path to use for container/conversion calls (US for Americas, GB for PAL). */ + fun classicsStoreCountry(accountCountry: String): String = + if (isAmericasClassicsRegion(accountCountry)) "US" else "GB" + + /** Fully-qualified PS3 catalog container id for the account's region group. */ + fun classicsPs3ContainerId(accountCountry: String): String = + if (isAmericasClassicsRegion(accountCountry)) + "STORE-MSF192018-APOLLOPS3GAMES" + else + "STORE-MSF192014-APOLLOPS3" +} + diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index 4a6675d1..a632df15 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -309,8 +309,23 @@ class PSKamajiSession( try { val localeSetting = preferences.getCloudLanguage() - val (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) + var (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) Log.i(TAG, "Using locale from settings: $localeSetting -> country=$country, language=$language") + + // PS3 / Classics product ids (NPEA/NPEB/BLES or NPUA/NPUB/BLUS -- anything that isn't a modern + // CUSA/PPSA id) come from the public Apollo catalog, which we walk in the account's region + // group (Americas -> US store, everything else -> PAL/GB). Resolve them against that SAME + // region's container so the lookup finds the product and returns the PSNW entitlement the + // account is authorized for at Gaikai. The account's own locale country can be a region with + // no pcnow storefront (e.g. Hungary -> "Storefront not found") and the raw locale 404s, so map + // to the region-group store. Must match the PS3 catalog source (fetchPs3ClassicsCatalog). + val isLegacyClassicsId = !productId.contains("CUSA") && !productId.contains("PPSA") + if (isLegacyClassicsId) + { + country = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(country) + language = "en" + Log.i(TAG, "Legacy Classics id -> region-group container: country=$country, language=$language") + } val url = "$storeBase/container/$country/$language/19/$productId?useOffers=true&gkb=1&gkb2=1" Log.d(TAG, "Step 0.5d: Convert Product ID") @@ -560,9 +575,24 @@ class PSKamajiSession( return true } - // User doesn't have entitlement (404), try to acquire it + // User doesn't have the per-game entitlement on the account (404). + // PS3 / Classics: the streaming entitlement is granted by the PS Plus subscription (a free + // 100%-off checkout), but that checkout requires a pcnow storefront in the account's region + // -- which many regions (e.g. Hungary) don't have, so the acquire fails with "Against + // Eligibility Rule". On a real PS5 the subscription alone grants streaming with no purchase, + // so skip the acquire and let Gaikai validate the Premium subscription directly. If Gaikai + // genuinely needs the entitlement, it returns noGameForEntitlementId downstream. + // CUSA/PS4 and PPSA/PS5 keep the existing acquire behavior. + val isLegacyClassicsId = !productId.contains("CUSA") && !productId.contains("PPSA") + if (isLegacyClassicsId) + { + Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai") + return true + } + + // PS4/PS5 catalog: try to acquire it via checkout. Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire") - + // Step 0.5e.3: Checkout preview // Throws PsPlusSubscriptionException if user doesn't have required subscription val previewOk = step0_5e3_CheckoutPreview(sessionId) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt index e927bd74..cfcce94e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt @@ -477,6 +477,124 @@ class PsCloudCatalogService return all } + // --------------------------------------------------------------------------- + // PS3 Classics catalog (public Apollo container walk) + // + // The PS Plus PC ("Apollo") app browses the streamable catalog through the public pcnow + // container API at psnow.playstation.com. There is a dedicated PS3 container that lists the + // streamable PS3 titles with their PS3 product ids (NPUA/NPUB/BLUS/EP9000/...) -- none of + // which appear in the imagic gameslist the rest of the catalog uses. The container API needs + // no OAuth or per-account session (unlike /user/stores, which 404s in regions where the PC + // app is unavailable, e.g. Hungary), so we can walk it directly in any region. The resulting + // titles carry playable_platform ["PS3"] and stream via the existing PSNOW -> Gaikai konan + // path. Mirrors CloudCatalogBackend::fetchPs3Catalog() (Qt). + // + // pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is + // authorized at Gaikai only for the family of its own region group, so the catalog must be + // browsed in that group. See KamajiClassics.classicsStoreCountry / classicsPs3ContainerId. + // --------------------------------------------------------------------------- + suspend fun fetchPs3ClassicsCatalog(accountCountry: String): List + { + val storeCountry = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) + val containerId = com.metallic.chiaki.cloudplay.KamajiClassics.classicsPs3ContainerId(accountCountry) + val containerUrl = + "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/$storeCountry/en/19/$containerId" + + Log.i(TAG, "=== Fetching PS3 Classics catalog (region group $storeCountry for account country $accountCountry) ===") + + val games = mutableListOf() + var start = 0 + var totalResults = -1 + + while (true) + { + val url = "$containerUrl?useOffers=true&gkb=1&gkb2=1&start=$start&size=100" + val response = HttpClient.get( + url = url, + headers = mapOf( + "Accept" to "application/json", + "User-Agent" to PsnApiConstants.USER_AGENT + ) + ) + + if (response.statusCode != 200) + { + Log.w(TAG, "PS3 catalog page fetch failed (HTTP ${response.statusCode})") + if (games.isEmpty()) + throw Exception("Failed to fetch PS3 Classics catalog: HTTP ${response.statusCode}") + break // Partial data already collected: return what we have. + } + + val obj = JSONObject(response.body) + if (totalResults < 0) + totalResults = obj.optInt("total_results", 0) + + val links = obj.optJSONArray("links") ?: JSONArray() + var productCount = 0 + for (i in 0 until links.length()) + { + val g = links.optJSONObject(i) ?: continue + if (g.optString("container_type") != "product") + continue + ps3JsonToCloudGame(g)?.let { games.add(it); productCount++ } + } + + Log.i(TAG, " PS3 page products: $productCount, accumulated: ${games.size} of $totalResults") + + start += 100 + if (productCount == 0 || start >= totalResults) + break + } + + Log.i(TAG, " PS3 Classics catalog complete: ${games.size} titles") + return games + } + + // Map a single pcnow PS3 container product into a CloudGame. The streaming id is the + // product `id` (Kamaji converts it -> entitlement -> Gaikai). Platform is detected from + // playable_platform containing "PS3" like the PSNow parser does. + private fun ps3JsonToCloudGame(gameObj: JSONObject): CloudGame? + { + val productId = gameObj.optString("id") + val name = gameObj.optString("name") + if (productId.isEmpty() || name.isEmpty()) + return null + + val (coverUrl, landscapeUrl) = extractImageUrls(gameObj) + var imageUrl = coverUrl + var landscapeImageUrl = landscapeUrl + if (imageUrl.startsWith("http://")) + imageUrl = imageUrl.replace("http://", "https://") + if (landscapeImageUrl.startsWith("http://")) + landscapeImageUrl = landscapeImageUrl.replace("http://", "https://") + + // Detect PS3 from playable_platform (matches the PSNow parser); default to ps3 for this + // container since every product in it is a streamable PS3 Classic. + var platform = "ps3" + val playablePlatformArray = gameObj.optJSONArray("playable_platform") + if (playablePlatformArray != null && playablePlatformArray.length() > 0) + { + for (i in 0 until playablePlatformArray.length()) + { + val platformStr = playablePlatformArray.optString(i, "").uppercase() + if (platformStr.contains("PS3")) + { + platform = "ps3" + break + } + } + } + + return CloudGame( + productId = productId, + name = name, + imageUrl = imageUrl, + landscapeImageUrl = landscapeImageUrl, + platform = platform, + serviceType = "psnow" // subscription-streamable via the PSNow/Gaikai konan path + ) + } + /** * Extract both cover and landscape image URLs from game object * Returns Pair diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 008419c3..85ec3f85 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -52,6 +52,10 @@ class CloudGameRepository( private const val PSCLOUD_OWNED_CACHE_FILE = "pscloud_owned_v2.json" // v2: ft0 filter + rank dedupe + featureType private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours + + // Region-group-specific so an Americas/PAL switch doesn't serve stale ids (e.g. "_US"/"_GB"). + private fun ps3ClassicsCacheFile(accountCountry: String): String = + "ps3_classics_catalog_${com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry)}.json" } private val psnowCatalogService = PsnCatalogService(preferences) @@ -280,6 +284,43 @@ class CloudGameRepository( } } + /** + * Fetch the streamable PS3 Classics (public Apollo container) with region-keyed caching. + * Subscription-streamable (never "owned"), so callers append these to the Catalog and the + * Library "all" view only. Mirrors CloudCatalogBackend::fetchPs3Catalog() (Qt). + */ + suspend fun fetchPs3ClassicsCatalog(forceRefresh: Boolean = false): PsnResult> + { + return withContext(Dispatchers.IO) + { + CloudLocaleBootstrap.ensureConfigured(preferences, preferences.getNpssoToken()) + // Account country = country part of the store locale (e.g. "en-HU" -> "HU"). + val (accountCountry, _) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudLanguage()) + val cacheFile = ps3ClassicsCacheFile(accountCountry) + + if (!forceRefresh) + { + loadCachedGames(cacheFile)?.let { cached -> + Log.i(TAG, "Returning ${cached.size} PS3 Classics from cache ($cacheFile)") + return@withContext PsnResult.Success(cached) + } + } + + try + { + val games = pscloudCatalogService.fetchPs3ClassicsCatalog(accountCountry) + if (games.isNotEmpty()) + cacheGames(games, cacheFile) + PsnResult.Success(games) + } + catch (e: Exception) + { + Log.w(TAG, "Failed to fetch PS3 Classics catalog", e) + PsnResult.Error("Failed to fetch PS3 Classics catalog: ${e.message}", e) + } + } + } + /** * Load games from cache if valid */ diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index ff18783f..439cedc5 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -408,8 +408,9 @@ class CloudPlayFragment : Fragment() val currentlyOwned = viewModel.preferences.getPsCloudFilterOwned() viewModel.preferences.setPsCloudFilterOwned(!currentlyOwned) updateOwnedToggleButton() - // Re-fetch with new filter - viewModel.fetchPs5CloudCatalog(showOnlyOwned = !currentlyOwned) + // Re-fetch with new filter. PS3 Classics only in the streamable "all" view (not "owned"). + val newShowOnlyOwned = !currentlyOwned + viewModel.fetchPs5CloudCatalog(showOnlyOwned = newShowOnlyOwned, appendPs3Classics = !newShowOnlyOwned) } // Icon buttons in header @@ -517,8 +518,9 @@ class CloudPlayFragment : Fragment() // Update favorites icon to match new section updateFavoritesIcon() - - viewModel.fetchPsnowCatalog() + + // Append the streamable PS3 Classics (public Apollo container) to the Catalog after it loads. + viewModel.fetchPsnowCatalog(appendPs3Classics = true) } private fun selectLibraryTab() @@ -552,11 +554,13 @@ class CloudPlayFragment : Fragment() val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() val isFavoritesFilter = preferences.getPsCloudFilterFavorites() - + + // PS3 Classics belong in the streamable "all" view only (never the "owned" list). The + // favorites filter draws from the same "all" set, so include PS3 there too. if (isFavoritesFilter) { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, appendPs3Classics = true) } else { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, appendPs3Classics = !isOwnedFilter) } } @@ -612,9 +616,10 @@ class CloudPlayFragment : Fragment() val currentSection = viewModel.getCurrentSection() if (currentSection == "pscloud") { val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true) + // PS3 Classics belong in the streamable "all" view only, never the "owned" list. + viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true, appendPs3Classics = !isOwnedFilter) } else { - viewModel.fetchPsnowCatalog(forceRefresh = true) + viewModel.fetchPsnowCatalog(forceRefresh = true, appendPs3Classics = true) } } @@ -675,11 +680,12 @@ class CloudPlayFragment : Fragment() if (currentSection == "pscloud") { val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true) + // PS3 Classics belong in the streamable "all" view only, never the "owned" list. + viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true, appendPs3Classics = !isOwnedFilter) } else { - viewModel.fetchPsnowCatalog(forceRefresh = true) + viewModel.fetchPsnowCatalog(forceRefresh = true, appendPs3Classics = true) } } @@ -730,8 +736,8 @@ class CloudPlayFragment : Fragment() ) viewModel.setSortedGames(sortedGames) } else { - // Catalog: Reload from cache to restore original API order - viewModel.fetchPsnowCatalog(forceRefresh = false) + // Catalog: Reload from cache to restore original API order (PS3 Classics included) + viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) } } 1 -> { @@ -805,22 +811,22 @@ class CloudPlayFragment : Fragment() // Game Library when (selectedItem) { 0 -> { - // All Games + // All Games (streamable universe includes PS3 Classics) preferences.setPsCloudFilterFavorites(false) preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false, appendPs3Classics = true) } 1 -> { - // Owned Games + // Owned Games (PS3 Classics are subscription-streamable, never "owned") preferences.setPsCloudFilterFavorites(false) preferences.setPsCloudFilterOwned(true) viewModel.fetchPs5CloudCatalog(showOnlyOwned = true, forceRefresh = false) } 2 -> { - // Favorites + // Favorites (drawn from the "all" set, so include PS3 Classics) preferences.setPsCloudFilterFavorites(true) preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false, appendPs3Classics = true) } } } else { @@ -829,12 +835,12 @@ class CloudPlayFragment : Fragment() 0 -> { // All Games preferences.setPsnowFilterFavorites(false) - viewModel.fetchPsnowCatalog(forceRefresh = false) + viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) } 1 -> { // Favorites preferences.setPsnowFilterFavorites(true) - viewModel.fetchPsnowCatalog(forceRefresh = false) + viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) } } } diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt index eb20f075..e76cd176 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt @@ -62,7 +62,7 @@ class CloudPlayViewModel( /** * Fetch PSNow catalog from network/cache */ - fun fetchPsnowCatalog(forceRefresh: Boolean = false) + fun fetchPsnowCatalog(forceRefresh: Boolean = false, appendPs3Classics: Boolean = false) { viewModelScope.launch { try @@ -70,11 +70,11 @@ class CloudPlayViewModel( _loading.value = true _error.value = null _warning.value = null - + Log.i(TAG, "Fetching PSNow catalog (forceRefresh=$forceRefresh)") - + val npssoToken = preferences.getNpssoToken() - + when (val result = repository.fetchPsnowCatalog(npssoToken, forceRefresh)) { is PsnResult.Success -> @@ -82,6 +82,9 @@ class CloudPlayViewModel( allGames = result.data Log.i(TAG, "Successfully loaded ${allGames.size} games") applySearchFilter() + // PS3 Classics are subscription-streamable -> always shown in the Catalog. + if (appendPs3Classics) + fetchPs3ClassicsCatalog(forceRefresh) } is PsnResult.Error -> { @@ -106,7 +109,7 @@ class CloudPlayViewModel( * Fetch PS5 Cloud catalog from network/cache * @param showOnlyOwned If true, fetches only user's owned games; if false, fetches all PS5 games */ - fun fetchPs5CloudCatalog(showOnlyOwned: Boolean = false, forceRefresh: Boolean = false) + fun fetchPs5CloudCatalog(showOnlyOwned: Boolean = false, forceRefresh: Boolean = false, appendPs3Classics: Boolean = false) { viewModelScope.launch { try @@ -149,6 +152,9 @@ class CloudPlayViewModel( Log.i(TAG, "Successfully loaded ${allGames.size} PS5 games") repository.lastCatalogFetchWarning?.let { _warning.value = it } applySearchFilter() + // Library "all" (streamable universe) includes PS3 Classics; "owned" does not. + if (appendPs3Classics) + fetchPs3ClassicsCatalog(forceRefresh) } is PsnResult.Error -> { @@ -171,6 +177,49 @@ class CloudPlayViewModel( } } + /** + * Fetch the streamable PS3 Classics (public Apollo container) and APPEND them to the + * already-displayed list. Additive: it never replaces the PS4/PS5 catalog already loaded, + * so it works whether the primary catalog came from PS Now or the imagic fallback. PS3 + * Classics are subscription-streamable, so they belong in the Game Catalog and in the + * Library "all" view -- but NOT the "owned" view. Mirrors CloudPlayView.qml appendPs3Catalog(). + */ + fun fetchPs3ClassicsCatalog(forceRefresh: Boolean = false) + { + viewModelScope.launch { + try + { + when (val result = repository.fetchPs3ClassicsCatalog(forceRefresh)) + { + is PsnResult.Success -> + { + if (result.data.isNotEmpty()) + { + // De-dupe by productId in case of a re-entrant append. + val existingIds = allGames.mapTo(HashSet()) { it.productId } + val toAdd = result.data.filter { existingIds.add(it.productId) } + if (toAdd.isNotEmpty()) + { + allGames = allGames + toAdd + Log.i(TAG, "Appended ${toAdd.size} PS3 Classics to catalog") + applySearchFilter() + } + } + } + is PsnResult.Error -> + { + // Non-fatal: PS3 Classics are supplementary to the primary catalog. + Log.w(TAG, "PS3 Classics catalog unavailable: ${result.message}") + } + } + } + catch (e: Exception) + { + Log.w(TAG, "Unexpected error fetching PS3 Classics catalog", e) + } + } + } + /** * Get current section */ diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index 0e63ef01..75c968d7 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -41,6 +41,11 @@ class CloudCatalogBackend : public QObject // Main catalog fetching methods Q_INVOKABLE void fetchPsnowCatalog(const QJSValue &callback); + // Streamable PS3 Classics catalog. Walks the PUBLIC pcnow (Apollo) container + // STORE-MSF192018-APOLLOPS3GAMES (no OAuth/session needed -- the container API is + // open), returning ~300 PS3 titles that imagic/gameslist never lists. These stream + // via the PSNOW -> Gaikai konan path the streaming code already supports. + Q_INVOKABLE void fetchPs3Catalog(const QJSValue &callback); Q_INVOKABLE void fetchPs5CloudCatalog(const QJSValue &callback); Q_INVOKABLE void fetchOwnedPs5Games(const QJSValue &callback); Q_INVOKABLE void getOwnedPs5CloudGames(const QJSValue &callback); @@ -95,7 +100,18 @@ private slots: QString duid; bool authInProgress; } psnowState; - + + // PS3 Classics catalog fetching state (public Apollo PS3 container, paginated). + // containerUrl is resolved per account region group (Americas vs PAL) at fetch time. + struct Ps3FetchState { + QJSValue callback; + QJsonArray allGames; + QString containerUrl; + int currentStart = 0; + int totalResults = -1; + bool inProgress = false; + } ps3State; + // PS5 catalog fetching state (six imagic lists, merged like Sony's PS5 cloud finder) struct Ps5FetchState { QJSValue callback; @@ -152,6 +168,10 @@ private slots: void ensureCacheDirectory(); void fetchPsnowCategory(int categoryIndex); void processPsnowCatalogComplete(); + QString ps3AccountCountry() const; + void fetchPs3CatalogPage(); + void handlePs3CatalogPageResponse(); + void finishPs3Catalog(); void fetchOwnedGamesOAuthToken(); void fetchPsnowOAuthToken(); void fetchPsnowSession(); diff --git a/gui/include/cloudstreaming/pskamajisession.h b/gui/include/cloudstreaming/pskamajisession.h index 0750ea8d..d3314aa5 100644 --- a/gui/include/cloudstreaming/pskamajisession.h +++ b/gui/include/cloudstreaming/pskamajisession.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -29,6 +30,38 @@ namespace KamajiConsts { static const QString REFERER = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"; static const QString REDIRECT_URI = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html"; static const QString USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo"; + + // --- PS3 / Classics pcnow store, by account region group --------------------- + // pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: + // * SCEA / Americas -> store MSF192018, US-region ids (UP*/NPUA*/BLUS*), + // PS3 child container "APOLLOPS3GAMES" + // * SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP*/NPEA*/NPEB*/BLES*), + // PS3 child container "APOLLOPS3" + // JP / Asia have no Apollo store (the PC app isn't offered there), so they fall + // back to PAL. A PS Plus account is authorized at Gaikai only for the id family of + // its own region group, so we must browse + resolve in the account's group. + inline bool isAmericasClassicsRegion(const QString &countryCode) { + static const QSet kAmericas = { + QStringLiteral("US"), QStringLiteral("CA"), QStringLiteral("MX"), + QStringLiteral("BR"), QStringLiteral("AR"), QStringLiteral("CL"), + QStringLiteral("CO"), QStringLiteral("PE"), QStringLiteral("EC"), + QStringLiteral("BO"), QStringLiteral("PY"), QStringLiteral("UY"), + QStringLiteral("CR"), QStringLiteral("GT"), QStringLiteral("HN"), + QStringLiteral("NI"), QStringLiteral("PA"), QStringLiteral("SV"), + QStringLiteral("DO") }; + return kAmericas.contains(countryCode.toUpper()); + } + // Country path to use for container/conversion calls (US for Americas, GB for PAL). + inline QString classicsStoreCountry(const QString &accountCountry) { + return isAmericasClassicsRegion(accountCountry) ? QStringLiteral("US") + : QStringLiteral("GB"); + } + // Fully-qualified PS3 catalog container id for the account's region group. + inline QString classicsPs3ContainerId(const QString &accountCountry) { + return isAmericasClassicsRegion(accountCountry) + ? QStringLiteral("STORE-MSF192018-APOLLOPS3GAMES") + : QStringLiteral("STORE-MSF192014-APOLLOPS3"); + } } /** diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index a408f376..e45179ea 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -874,6 +874,152 @@ void CloudCatalogBackend::processPsnowCatalogComplete() emit catalogUpdated(); } +// --------------------------------------------------------------------------- +// PS3 Classics catalog (public Apollo container walk) +// +// The PS Plus PC ("Apollo") app browses the streamable catalog through the public +// pcnow container API at psnow.playstation.com. There is a dedicated PS3 container, +// STORE-MSF192018-APOLLOPS3GAMES, that lists ~300 streamable PS3 titles with their +// PS3 product ids (NPUA/NPUB/BLUS/BCUS) -- none of which appear in the imagic +// gameslist the rest of the catalog uses. The container API needs no OAuth or +// per-account session (unlike /user/stores, which 404s in regions where the PC app +// is unavailable, e.g. Hungary), so we can walk it directly in any region. The +// resulting titles carry playable_platform ["PS3"] and stream via the existing +// PSNOW -> Gaikai konan path. +// --------------------------------------------------------------------------- +// Resolve the account's region group from its store locale (e.g. "en-HU" -> "HU"). +// pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is +// authorized at Gaikai only for the family of its own region group, so the catalog must +// be browsed in that group. See KamajiConsts::classicsStoreCountry / classicsPs3ContainerId. +QString CloudCatalogBackend::ps3AccountCountry() const +{ + QString locale = settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US"); + QStringList parts = locale.split(QLatin1Char('-')); + QString cc = parts.size() > 1 ? parts[1] : QStringLiteral("US"); + return cc.toUpper(); +} + +void CloudCatalogBackend::fetchPs3Catalog(const QJSValue &callback) +{ + const QString cc = ps3AccountCountry(); + // Region-group-specific cache key so an Americas/PAL switch doesn't serve stale ids. + const QString cacheKey = QStringLiteral("ps3_catalog_") + KamajiConsts::classicsStoreCountry(cc); + QString cached = getCachedData(cacheKey, CACHE_DURATION_CATALOG); + if (!cached.isEmpty()) { + qInfo() << "[CACHE] Using cached PS3 catalog"; + QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); + if (callback.isCallable()) + callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); + return; + } + + if (ps3State.inProgress) { + qInfo() << "[PS3] Catalog fetch already in progress"; + if (callback.isCallable()) + callback.call({false, "Request already in progress", QJSValue()}); + return; + } + + ps3State.containerUrl = QStringLiteral( + "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/en/19/%2") + .arg(KamajiConsts::classicsStoreCountry(cc), KamajiConsts::classicsPs3ContainerId(cc)); + qInfo() << "[API CALL] Fetching PS3 Classics catalog (region group" + << KamajiConsts::classicsStoreCountry(cc) << "for account country" << cc << ")"; + ps3State.callback = callback; + ps3State.allGames = QJsonArray(); + ps3State.currentStart = 0; + ps3State.totalResults = -1; + ps3State.inProgress = true; + fetchPs3CatalogPage(); +} + +void CloudCatalogBackend::fetchPs3CatalogPage() +{ + QString url = QString("%1?useOffers=true&gkb=1&gkb2=1&start=%2&size=100") + .arg(ps3State.containerUrl) + .arg(ps3State.currentStart); + + if (settings && settings->GetLogVerbose()) { + qInfo() << "=== CloudCatalogBackend: PS3 catalog page ==="; + qInfo() << " URL:" << url; + } + + QNetworkRequest req{QUrl(url)}; + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); + + QNetworkReply *reply = networkManager->get(req); + connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePs3CatalogPageResponse); +} + +void CloudCatalogBackend::handlePs3CatalogPageResponse() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply) return; + reply->deleteLater(); + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray data = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError || statusCode != 200) { + QString errorMsg = QString("PS3 catalog fetch failed (HTTP %1): %2") + .arg(statusCode).arg(reply->errorString()); + qWarning() << "CloudCatalogBackend:" << errorMsg; + if (ps3State.allGames.isEmpty()) { + ps3State.inProgress = false; + if (ps3State.callback.isCallable()) + ps3State.callback.call({false, errorMsg, QJSValue()}); + return; + } + // Partial data already collected: return what we have. + finishPs3Catalog(); + return; + } + + QJsonObject obj = QJsonDocument::fromJson(data).object(); + if (ps3State.totalResults < 0) + ps3State.totalResults = obj.value("total_results").toInt(); + + QJsonArray links = obj.value("links").toArray(); + int productCount = 0; + for (const QJsonValue &v : links) { + QJsonObject g = v.toObject(); + if (g.value("container_type").toString() != QLatin1String("product")) + continue; + QString img = extractCoverImageFromGameObject(g); + if (!img.isEmpty()) + g["imageUrl"] = img; + ps3State.allGames.append(g); + productCount++; + } + + if (settings && settings->GetLogVerbose()) + qInfo() << " PS3 page games:" << productCount << "accumulated:" << ps3State.allGames.size() + << "of" << ps3State.totalResults; + + ps3State.currentStart += 100; + if (productCount > 0 && ps3State.currentStart < ps3State.totalResults) { + fetchPs3CatalogPage(); + } else { + finishPs3Catalog(); + } +} + +void CloudCatalogBackend::finishPs3Catalog() +{ + QJsonObject result; + result["games"] = ps3State.allGames; + result["total"] = ps3State.allGames.size(); + QJsonDocument resultDoc(result); + setCachedData(QStringLiteral("ps3_catalog_") + KamajiConsts::classicsStoreCountry(ps3AccountCountry()), resultDoc); + + qInfo() << "[PS3] Catalog complete:" << ps3State.allGames.size() << "PS3 titles"; + + ps3State.inProgress = false; + if (ps3State.callback.isCallable()) + ps3State.callback.call({true, "Success", QJSValue(QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)))}); +} + namespace { // Canonicalize a "language-COUNTRY" locale to lowercase-language / uppercase-country. diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index f866592c..6435d56b 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -325,7 +325,22 @@ void PSKamajiSession::step0_5d_ConvertProductId() QStringList localeParts = locale.split("-"); QString country = localeParts.size() > 1 ? localeParts[1].toUpper() : "US"; QString language = localeParts[0].toLower(); - + + // PS3 / Classics product ids (NPEA/NPEB/BLES/BCES or NPUA/NPUB/BLUS/BCUS -- anything + // that isn't a modern CUSA/PPSA id) come from the public Apollo catalog, which we walk + // in the account's region group (Americas -> US store, everything else -> PAL/GB). + // Resolve them against that SAME region's container so the lookup finds the product and + // returns the PSNW entitlement the account is authorized for at Gaikai. The account's + // own locale country can be a region with no pcnow storefront (e.g. Hungary -> "Storefront + // not found"), and the wrong region's ids return 401 "invalidEntitlement", so map to the + // region-group store. Must match CloudCatalogBackend's PS3 catalog source. + const bool isLegacyClassicsId = !productId.contains(QLatin1String("CUSA")) + && !productId.contains(QLatin1String("PPSA")); + if (isLegacyClassicsId) { + country = KamajiConsts::classicsStoreCountry(country); + language = QStringLiteral("en"); + } + QString url = QString("https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/%2/19/%3?useOffers=true&gkb=1&gkb2=1") .arg(country, language, productId); @@ -904,10 +919,24 @@ void PSKamajiSession::handleCheckEntitlementResponse(QNetworkReply *reply) step5_GetAuthCode(); return; } else if (statusCode == 404) { - // User doesn't have entitlement - try to acquire it + // User doesn't have the per-game entitlement on the account. + const bool isLegacyClassicsId = !productId.contains(QLatin1String("CUSA")) + && !productId.contains(QLatin1String("PPSA")); + if (isLegacyClassicsId) { + // PS3 / Classics: the streaming entitlement is granted by the PS Plus + // subscription (a free 100%-off checkout), but that checkout requires a + // pcnow storefront in the account's region -- which many regions (e.g. + // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". + // On a real PS5 the subscription alone grants streaming with no purchase, so + // skip the acquire and let Gaikai validate the Premium subscription directly. + // If Gaikai genuinely needs the entitlement on the account, it returns + // noGameForEntitlementId downstream and we learn the wall is at Gaikai. + qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai"; + step5_GetAuthCode(); + return; + } + // PS4/PS5 catalog: try to acquire it via checkout. qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire"; - - // Continue to checkout preview step0_5e_CheckoutPreview(); return; } else { diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 40bd35f9..ddd963f2 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -180,6 +180,7 @@ Pane { authErrorMessage = ""; } applySearchFilter(); + appendPs3Catalog(); // Set focus after games are loaded Qt.callLater(() => { if (gamesGrid.count > 0) { @@ -279,6 +280,7 @@ Pane { ownedProductIds = Array.from(merged.ownedIds); isLoading = false; applySearchFilter(); + appendPs3Catalog(); Qt.callLater(() => { if (gamesGrid.count > 0) { gamesGrid.currentIndex = 0; @@ -289,6 +291,53 @@ Pane { }); } + // True for streamable PS3 Classics (from the public Apollo PS3 container). They carry + // playable_platform ["PS3"] and a PS3 product id, and must stream via the PSNOW/konan path. + function gameIsPs3(g) { + if (!g) + return false; + let pp = g.playable_platform; + if (!pp) + return false; + let arr = []; + if (Array.isArray(pp)) + arr = pp; + else if (typeof pp === "object" && pp.length !== undefined) { + for (let i = 0; i < pp.length; i++) arr.push(pp[i]); + } else if (typeof pp === "string") + arr = [pp]; + for (let i = 0; i < arr.length; i++) + if (String(arr[i]).indexOf("PS3") !== -1) return true; + return false; + } + + // Fetch the streamable PS3 Classics (public Apollo container) and append them to the + // current catalog. Additive: it never replaces the PS4/PS5 catalog already loaded, so + // it works regardless of whether the primary catalog came from PS Now or the imagic + // fallback. PS3 belongs only in the subscription Catalog (not the owned Library). + function appendPs3Catalog() { + // PS3 Classics are subscription-streamable, so they belong in the Game Catalog and + // in the Library "all" (streamable universe) view -- but NOT the "owned" view. + if (currentSection === "library" && libraryFilter !== "all") + return; + Chiaki.cloudCatalog.fetchPs3Catalog(function(success, message, jsonData) { + if (!success || !jsonData) { + console.warn("PS3 Classics catalog unavailable:", message); + return; + } + try { + let d = JSON.parse(jsonData); + if (d.games && Array.isArray(d.games) && d.games.length > 0) { + allGames = allGames.concat(d.games); + applySearchFilter(); + console.log("[CloudPlayView] Appended", d.games.length, "PS3 Classics to catalog"); + } + } catch (e) { + console.warn("Failed to parse PS3 catalog:", e); + } + }); + } + function ps5CloudProductId(game) { if (!game) return ""; @@ -501,6 +550,7 @@ Pane { ownedProductIds = Array.from(merged.ownedIds); allGames = merged.games; isLoading = false; + appendPs3Catalog(); // PS3 Classics are part of the streamable "all" view // Handle ownership check failure with user-visible feedback if (ownershipCheckFailed) { @@ -1504,12 +1554,20 @@ Pane { activeFocusOnTab: false // The catalog is normally PS Now; when it falls back to the imagic // cloud catalog the cards are pscloud (correct streaming path/platform). - isPsnow: currentSection === "catalog" && !catalogImagicFallback + // PS3 Classics (appended from the Apollo container) are always PS Now: + // isPsnow=true makes the card read playable_platform -> "ps3" and route + // to the PSNOW/konan streaming path regardless of the catalog source. + isPsnow: (currentSection === "catalog" && !catalogImagicFallback) + || gameIsPs3(modelData) // Catalog cards: every subscription title is streamable, so use a non-"all" // value to suppress the "Add Game" state — all of them show "Stream Game". // Library cards use the real filter ("all" enables Add Game for non-owned). - libraryFilter: (currentSection === "catalog" && catalogImagicFallback) - ? "catalog" : root.libraryFilter + // PS3 Classics are subscription-streamable (never "owned"), so they always + // show "Stream Game" regardless of section/filter. + libraryFilter: gameIsPs3(modelData) + ? "catalog" + : ((currentSection === "catalog" && catalogImagicFallback) + ? "catalog" : root.libraryFilter) qrCodeDialog: root.qrCodeDialogRef // Bind isFavorite to favoriteProductIds array changes diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 3d6b27c1..2f523556 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -172,6 +172,39 @@ enum CloudApiConstants { static let accountBase = "https://ca.account.sony.com/api" } +// MARK: - PS3 / Classics region (mirrors KamajiConsts in gui/include/cloudstreaming/pskamajisession.h) + +/// pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: +/// * SCEA / Americas -> store MSF192018, US-region ids (UP*/NPUA*/BLUS*), +/// PS3 child container "APOLLOPS3GAMES" +/// * SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP*/NPEA*/NPEB*/BLES*), +/// PS3 child container "APOLLOPS3" +/// JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to +/// PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region +/// group, so we must browse + resolve in the account's group. +enum ClassicsRegion { + private static let americas: Set = [ + "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", + "PY", "UY", "CR", "GT", "HN", "NI", "PA", "SV", "DO" + ] + + static func isAmericasClassicsRegion(_ countryCode: String) -> Bool { + return americas.contains(countryCode.uppercased()) + } + + /// Country path to use for container/conversion calls (US for Americas, GB for PAL). + static func classicsStoreCountry(_ accountCountry: String) -> String { + return isAmericasClassicsRegion(accountCountry) ? "US" : "GB" + } + + /// Fully-qualified PS3 catalog container id for the account's region group. + static func classicsPs3ContainerId(_ accountCountry: String) -> String { + return isAmericasClassicsRegion(accountCountry) + ? "STORE-MSF192018-APOLLOPS3GAMES" + : "STORE-MSF192014-APOLLOPS3" + } +} + // MARK: - Gaikai Allocation Result struct GaikaiAllocationResult { diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 94be7133..4adac4cf 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -638,6 +638,85 @@ final class CloudCatalogService { return allGames } + // MARK: - PS3 Classics Catalog (public Apollo container walk) + // + // The PS Plus PC ("Apollo") app browses the streamable catalog through the public pcnow + // container API at psnow.playstation.com. There is a dedicated PS3 container (e.g. + // STORE-MSF192018-APOLLOPS3GAMES for Americas) that lists ~300 streamable PS3 titles with + // their PS3 product ids (NPUA/NPUB/BLUS/BCUS) — none of which appear in the imagic gameslist + // the rest of the catalog uses. The container API needs no OAuth or per-account session + // (unlike /user/stores, which 404s in regions where the PC app is unavailable, e.g. Hungary), + // so we can walk it directly in any region. The resulting titles carry playable_platform + // ["PS3"] and stream via the existing PSNOW -> Gaikai konan path. + + /// Resolve the account's region group from its stored store locale (e.g. "en-HU" -> "HU"). + /// pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is + /// authorized at Gaikai only for the family of its own region group, so the catalog must be + /// browsed in that group. See ClassicsRegion.classicsStoreCountry / classicsPs3ContainerId. + private func ps3AccountCountry() -> String { + return CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored).country + } + + /// Fetch the streamable PS3 Classics from the public Apollo container for the account's + /// region group. Mirrors Qt CloudCatalogBackend::fetchPs3Catalog. PUBLIC API: no auth. + func fetchPs3Catalog(forceRefresh: Bool = false) -> [CloudGame] { + let country = ps3AccountCountry() + let storeCountry = ClassicsRegion.classicsStoreCountry(country) + // Region-group-specific cache key so an Americas/PAL switch doesn't serve stale ids. + let cacheFile = "ps3_catalog_\(storeCountry).json" + if !forceRefresh, let cached = loadCachedGames(cacheFile) { + os_log(.info, log: catalogLog, "Returning %d PS3 Classics from cache", cached.count) + return cached + } + + let containerId = ClassicsRegion.classicsPs3ContainerId(country) + let containerUrl = "\(CloudApiConstants.storeBase)/container/\(storeCountry)/en/19/\(containerId)" + os_log(.info, log: catalogLog, + "=== Fetching PS3 Classics catalog (region group %{public}s for account country %{public}s) ===", + storeCountry, country) + + var allGames: [CloudGame] = [] + var start = 0 + var totalResults = -1 + + while true { + let url = "\(containerUrl)?useOffers=true&gkb=1&gkb2=1&start=\(start)&size=100" + guard let response = CloudHttpClient.get(url: url, headers: [ + "Accept": "application/json", + "User-Agent": CloudApiConstants.kamajiUserAgent + ]), response.statusCode == 200, + let data = response.body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + os_log(.error, log: catalogLog, "PS3 catalog page failed at start=%d", start) + break + } + + if totalResults < 0 { + totalResults = (json["total_results"] as? Int) + ?? (json["total_results"] as? NSNumber)?.intValue ?? 0 + } + + let links = json["links"] as? [[String: Any]] ?? [] + var productCount = 0 + for link in links { + guard (link["container_type"] as? String) == "product" else { continue } + guard let game = parsePsnowGameObject(link) else { continue } + allGames.append(game) + productCount += 1 + } + + os_log(.info, log: catalogLog, "PS3 page games: %d accumulated: %d of %d", + productCount, allGames.count, totalResults) + + start += 100 + if productCount == 0 || start >= totalResults { break } + } + + os_log(.info, log: catalogLog, "PS3 Classics catalog: %d titles", allGames.count) + if !allGames.isEmpty { cacheGames(allGames, filename: cacheFile) } + return allGames + } + // MARK: - PSNow helpers private func fetchPsnowOAuthCode(npssoToken: String, duid: String) -> String? { diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 8b1cb55d..64cf6bcf 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -143,9 +143,26 @@ final class PSKamajiSession { let sku: String } + // PS3 / Classics product ids (NPEA/NPEB/BLES/BCES or NPUA/NPUB/BLUS/BCUS — anything that + // isn't a modern CUSA/PPSA id) come from the public Apollo catalog, which we walk in the + // account's region group (Americas -> US store, everything else -> PAL/GB). Resolve them + // against that SAME region's container so the lookup finds the product and returns the PSNW + // entitlement the account is authorized for at Gaikai. The account's own locale country can + // be a region with no pcnow storefront (e.g. Hungary -> "Storefront not found"), so map to + // the region-group store. Must match CloudCatalogService's PS3 catalog source. + private var isLegacyClassicsId: Bool { + return !productId.contains("CUSA") && !productId.contains("PPSA") + } + private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) - let url = "\(storeBase)/container/\(storePath.country)/\(storePath.language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" + var country = storePath.country + var language = storePath.language + if isLegacyClassicsId { + country = ClassicsRegion.classicsStoreCountry(country) + language = "en" + } + let url = "\(storeBase)/container/\(country)/\(language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" os_log(.info, log: kamajiLog, "Store container locale: %{public}s", CloudLocaleSettings.stored) guard let response = CloudHttpClient.get(url: url, headers: [ @@ -230,6 +247,20 @@ final class PSKamajiSession { if hasEntitlement == nil { return false } if hasEntitlement == true { return true } + // Entitlement not found (404). For PS3 / Classics (legacy non-CUSA/PPSA ids) the streaming + // entitlement is granted by the PS Plus subscription via a free 100%-off checkout, but that + // checkout requires a pcnow storefront in the account's region — which many regions (e.g. + // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". On a real PS5 + // the subscription alone grants streaming with no purchase, so skip the acquire and let + // Gaikai validate the Premium subscription directly. If Gaikai genuinely needs the + // entitlement, it returns noGameForEntitlementId downstream and we learn the wall is there. + if isLegacyClassicsId { + os_log(.info, log: kamajiLog, + "Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai") + return true + } + + // PS4/PS5 catalog: try to acquire it via checkout. // Step 0.5e.3: Checkout preview guard step0_5e3_CheckoutPreview(sessionId: sessionId) else { return false } diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 98d8e7cf..5a5b7ec7 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -110,7 +110,7 @@ final class CloudPlayViewModel: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let loadedGames: [CloudGame] + var loadedGames: [CloudGame] switch section { case .catalog: @@ -121,11 +121,15 @@ final class CloudPlayViewModel: ObservableObject { loadedGames = psnow.isEmpty ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken) : psnow + // PS3 Classics are subscription-streamable, so they belong in the Game Catalog. + loadedGames += self.catalogService.fetchPs3Catalog() case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken) } else { loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken) + // PS3 Classics are part of the streamable "all" universe (never the "owned" view). + loadedGames += self.catalogService.fetchPs3Catalog() } } @@ -166,7 +170,7 @@ final class CloudPlayViewModel: ObservableObject { let ownedOnly = showOwnedOnly Task.detached(priority: .userInitiated) { [weak self] in - let loadedGames: [CloudGame] + var loadedGames: [CloudGame] = [] defer { Task { @MainActor in self?.loading = false @@ -183,11 +187,15 @@ final class CloudPlayViewModel: ObservableObject { loadedGames = psnow.isEmpty ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken, forceRefresh: true) : psnow + // PS3 Classics are subscription-streamable, so they belong in the Game Catalog. + loadedGames += self.catalogService.fetchPs3Catalog(forceRefresh: true) case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: true) } else { loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken, forceRefresh: true) + // PS3 Classics are part of the streamable "all" universe (never the "owned" view). + loadedGames += self.catalogService.fetchPs3Catalog(forceRefresh: true) } } From 773c9a95ba1526f5ba3d220246aa67c13a62fd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Wed, 3 Jun 2026 22:08:54 +0200 Subject: [PATCH 03/72] Fix crash when starting a stream in portrait on tablets Starting a cloud stream locks the activity to landscape via requestedOrientation. On large tablets, portrait<->landscape also changes screenLayout/smallestScreenSize, which MainActivity didn't declare in configChanges -- so Android recreated the activity, detached CloudPlayFragment, and the in-flight startCloudStreaming coroutine then crashed on requireActivity() ("Fragment not attached to an activity"). Declare screenLayout|smallestScreenSize so MainActivity handles the rotation itself instead of being recreated, keeping the fragment attached. Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ee1ffd17..d4cc4420 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ + android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize"> From a05e6d186545b143ceeb6f055236eb0cc3fc21f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Thu, 4 Jun 2026 14:26:47 +0200 Subject: [PATCH 04/72] Cloud library: stream the owned full game for disc-upgrade titles PS Plus disc-upgrade entitlements (feature_type 5, e.g. Horizon Forbidden West EP9000-PPSA01521) are the SKU the imagic browse catalog binds the concept to, but Gaikai refuses to cloud-stream them ("disc-upgrade-unsupported"). The owned streamable edition (e.g. the Complete Edition PPSA17903) is a different title id that is absent from the catalog and -- like every commerce-API entitlement -- carries no conceptId, so the owned cross-reference never matches it and only the unstreamable disc-upgrade SKU survives the dedupe. Add a disc-upgrade rescue to the owned cross-reference on all platforms (Qt/iOS/Android): when a concept's surviving owned SKU is a disc upgrade, adopt the product id of a same-name full-game (feature_type 3) owned SKU so the card streams the edition Gaikai accepts. Since the only in-data bridge is the title name, it is guarded to stay safe: same platform only (a PS5 disc upgrade can never resolve to a PS4 CUSA SKU), prefer the canonical base game (product_id == entitlement id), and bail on genuine ambiguity rather than guess. Verified on macOS: Horizon Forbidden West now streams PPSA17903 instead of the rejected PPSA01521. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 67 +++++++++++++++++ gui/src/cloudcatalogbackend.cpp | 75 +++++++++++++++++++ ios/Pylux/Services/PsCloudOwnership.swift | 71 ++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt index 0f6805e9..5a054b8e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt @@ -2,11 +2,13 @@ package com.metallic.chiaki.cloudplay.api +import android.util.Log import com.metallic.chiaki.cloudplay.model.CloudGame import org.json.JSONObject object PsCloudOwnership { + private const val TAG = "PsCloudOwnership" const val PAGE_SIZE = 300 const val PAGE_COOLDOWN_MS = 100L @@ -177,6 +179,64 @@ object PsCloudOwnership } } + // Disc-upgrade rescue (mirrors cloudcatalogbackend.cpp). feature_type 5 is a PS4-disc -> PS5 + // *disc upgrade* license; Gaikai refuses to cloud-stream it ("disc-upgrade-unsupported"). The + // browse catalog often binds the concept to exactly that SKU (e.g. Horizon Forbidden West + // concept 10000886 -> PPSA01521), while the user's streamable full-game entitlement is a + // DIFFERENT title id (e.g. Complete Edition PPSA17903) that is absent from the catalog and + // carries no conceptId -- so it never matches and only the disc-upgrade SKU survives. When a + // concept winner is a disc upgrade, adopt a same-name full-game (feature_type 3) owned SKU's + // product id so the card streams the edition Gaikai accepts. + // + // Entitlements carry no conceptId and the disc-upgrade SKU shares no id/sku with the real + // edition, so the only in-data bridge is the title name. To keep that safe: SAME PLATFORM only + // (a PS5/PPSA disc upgrade must never resolve to a PS4/CUSA SKU), prefer the canonical base game + // (product_id == entitlement id), and BAIL on genuine ambiguity rather than guess. + for (key in byKey.keys.toList()) + { + val game = byKey[key] ?: continue + if (game.featureType != 5) continue + val discPid = game.storeProductId + val discPlatform = platformToken(discPid) + val discEnt = filteredEntitlements.firstOrNull { + it.productId == discPid && it.featureType == 5 + } ?: continue + val discName = normalizeTitle(discEnt.name) + if (discName.isEmpty()) continue + val canonical = mutableListOf() // base-game SKUs (product_id == entitlement id) + val other = mutableListOf() // non-canonical full-game SKUs + for (cand in filteredEntitlements) + { + if (cand.featureType != 3) continue + if (normalizeTitle(cand.name) != discName) continue + val candPid = cand.productId + if (candPid.isEmpty() || candPid == discPid) continue + if (platformToken(candPid) != discPlatform) continue + if (candPid == cand.id) + { + if (candPid !in canonical) canonical.add(candPid) + } + else if (candPid !in other) + { + other.add(candPid) + } + } + val replacement = when + { + canonical.size == 1 -> canonical[0] + canonical.isEmpty() && other.size == 1 -> other[0] + else -> null + } + if (replacement == null) + { + if (canonical.isNotEmpty() || other.isNotEmpty()) + Log.w(TAG, "disc-upgrade rescue: ambiguous candidates for $discName -- leaving disc SKU") + continue + } + byKey[key] = game.copy(storeProductId = replacement) + Log.i(TAG, "disc-upgrade rescue: $discName $discPid -> $replacement") + } + return byKey.values.toList() } @@ -199,6 +259,13 @@ object PsCloudOwnership else -> "" } + /** Lowercase, strip trademark/registered/service-mark glyphs, and collapse whitespace so two owned + * entitlements for the same game compare equal across punctuation/spacing differences. */ + private fun normalizeTitle(raw: String): String = + raw.lowercase() + .replace("™", "").replace("®", "").replace("℠", "") + .trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.joinToString(" ") + /** A full-game entitlement (vs add-on/avatar): base game has a *GD package_type. */ private fun isFullGameEntitlement(ent: Entitlement): Boolean = ent.featureType == 3 || ent.packageType.endsWith("GD") diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index e45179ea..ad3337a0 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -2812,6 +2812,81 @@ void CloudCatalogBackend::processCrossReferenceComplete() } } + // Disc-upgrade rescue. feature_type 5 marks a PS4-disc -> PS5 *disc upgrade* license; Gaikai + // refuses to cloud-stream it ("disc-upgrade-unsupported"). The browse catalog often binds a + // concept to exactly that SKU (e.g. Horizon Forbidden West concept 10000886 -> PPSA01521), while + // the user's streamable full-game entitlement is a DIFFERENT title id (e.g. Complete Edition + // PPSA17903) that is absent from the catalog and carries no conceptId -- so the cross-reference + // never matches it and only the disc-upgrade SKU survives the dedupe. When a concept winner is a + // disc upgrade, adopt the product id of a same-name full-game (feature_type 3) owned SKU so the + // card streams the edition Gaikai accepts. + // + // Entitlements carry no conceptId (the commerce API omits it) and the disc-upgrade SKU shares no + // id/sku with the real edition, so the only in-data bridge is the title name. To keep that safe: + // - SAME PLATFORM only (a PS5/PPSA disc upgrade must never pull in a PS4/CUSA SKU of a + // same-named game), + // - prefer the CANONICAL base game (product_id == entitlement id, i.e. not a bundle/add-on SKU), + // - and BAIL on genuine ambiguity (two distinct base games sharing one name) rather than guess. + auto normalizeTitle = [](const QString &raw) { + return raw.toLower().remove(QChar(0x2122)).remove(QChar(0x00AE)).remove(QChar(0x2120)).simplified(); + }; + for (auto it = ownedByKey.begin(); it != ownedByKey.end(); ++it) { + QJsonObject entry = it.value(); + if (entry.value(QStringLiteral("feature_type")).toInt() != 5) + continue; + const QString discPid = entry.value(QStringLiteral("product_id")).toString(); + const QString discPlatform = ps5CloudPlatformToken(discPid); + const QString discName = normalizeTitle(entry.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("name")).toString()); + if (discName.isEmpty()) + continue; + QStringList canonicalPids; // base-game SKUs (product_id == entitlement id) + QStringList otherPids; // non-canonical full-game SKUs (bundle/edition products) + for (const QJsonValue &candVal : crossReferenceState.ownedGames) { + if (!candVal.isObject()) + continue; + const QJsonObject cand = candVal.toObject(); + // Require a standard digital full game (feature_type 3) -- not another disc upgrade, + // DLC/add-on (ft 0) or trial (ft 1) -- whose name matches the disc-upgrade title. + if (cand.value(QStringLiteral("feature_type")).toInt() != 3) + continue; + const QString candName = normalizeTitle(cand.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("name")).toString()); + if (candName != discName) + continue; + const QString candPid = cand.value(QStringLiteral("product_id")).toString(); + if (candPid.isEmpty() || candPid == discPid) + continue; + if (ps5CloudPlatformToken(candPid) != discPlatform) + continue; // never cross platforms (PS5 disc upgrade must not resolve to a PS4 SKU) + const QString candId = cand.value(QStringLiteral("id")).toString(); + if (candPid == candId) { + if (!canonicalPids.contains(candPid)) canonicalPids.append(candPid); + } else { + if (!otherPids.contains(candPid)) otherPids.append(candPid); + } + } + // A single canonical base game wins; else a single non-canonical full game; else bail. + QString replacementPid; + if (canonicalPids.size() == 1) + replacementPid = canonicalPids.first(); + else if (canonicalPids.isEmpty() && otherPids.size() == 1) + replacementPid = otherPids.first(); + if (replacementPid.isEmpty()) { + if (!canonicalPids.isEmpty() || !otherPids.isEmpty()) + qWarning() << "[CROSS-REF] disc-upgrade rescue: ambiguous full-game candidates for" + << discName << "canonical=" << canonicalPids << "other=" << otherPids + << "-- leaving disc-upgrade SKU in place"; + continue; + } + entry.insert(QStringLiteral("product_id"), replacementPid); + entry.insert(QStringLiteral("productId"), replacementPid); + entry.insert(QStringLiteral("catalogProductId"), replacementPid); + it.value() = entry; + qInfo() << "[CROSS-REF] disc-upgrade rescue:" << discName << ":" << discPid + << "-> streamable" << replacementPid; + } + for (const QJsonObject &gameObj : ownedByKey) filteredGames.append(gameObj); diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index 5a352bd4..1d7b07ee 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -1,6 +1,9 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL import Foundation +import os.log + +private let ownershipLog = OSLog(subsystem: "com.pylux.stream", category: "CloudOwnership") /// Raw entitlement fields from Sony internal_entitlements API. struct PsCloudEntitlement { @@ -160,6 +163,64 @@ enum PsCloudOwnership { } } + // Disc-upgrade rescue (mirrors cloudcatalogbackend.cpp). feature_type 5 is a PS4-disc -> PS5 + // *disc upgrade* license; Gaikai refuses to cloud-stream it ("disc-upgrade-unsupported"). The + // browse catalog often binds the concept to exactly that SKU (e.g. Horizon Forbidden West + // concept 10000886 -> PPSA01521), while the user's streamable full-game entitlement is a + // DIFFERENT title id (e.g. Complete Edition PPSA17903) that is absent from the catalog and + // carries no conceptId -- so it never matches and only the disc-upgrade SKU survives. When a + // concept winner is a disc upgrade, adopt a same-name full-game (feature_type 3) owned SKU's + // product id so the card streams the edition Gaikai accepts. + // + // Entitlements carry no conceptId and the disc-upgrade SKU shares no id/sku with the real + // edition, so the only in-data bridge is the title name. To keep that safe: SAME PLATFORM only + // (a PS5/PPSA disc upgrade must never resolve to a PS4/CUSA SKU), prefer the canonical base + // game (product_id == entitlement id), and BAIL on genuine ambiguity rather than guess. + for key in Array(byKey.keys) { + guard let game = byKey[key], game.featureType == 5 else { continue } + let discPid = game.storeProductId + let discPlatform = platformToken(discPid) + guard let discEnt = filteredEntitlements.first(where: { + $0.productId == discPid && $0.featureType == 5 + }) else { continue } + let discName = normalizeTitle(discEnt.name) + guard !discName.isEmpty else { continue } + var canonical: [String] = [] // base-game SKUs (product_id == entitlement id) + var other: [String] = [] // non-canonical full-game SKUs + for cand in filteredEntitlements where cand.featureType == 3 { + guard normalizeTitle(cand.name) == discName else { continue } + let candPid = cand.productId + guard !candPid.isEmpty, candPid != discPid else { continue } + guard platformToken(candPid) == discPlatform else { continue } + if candPid == cand.id { + if !canonical.contains(candPid) { canonical.append(candPid) } + } else if !other.contains(candPid) { + other.append(candPid) + } + } + let replacement: String? + if canonical.count == 1 { + replacement = canonical[0] + } else if canonical.isEmpty, other.count == 1 { + replacement = other[0] + } else { + replacement = nil + } + guard let rep = replacement else { + if !canonical.isEmpty || !other.isEmpty { + os_log(.info, log: ownershipLog, + "disc-upgrade rescue: ambiguous candidates for %{public}s -- leaving disc SKU", + discName) + } + continue + } + var updated = game + updated.storeProductId = rep + byKey[key] = updated + os_log(.info, log: ownershipLog, "disc-upgrade rescue: %{public}s %{public}s -> %{public}s", + discName, discPid, rep) + } + return Array(byKey.values) } @@ -180,6 +241,16 @@ enum PsCloudOwnership { return "" } + // Lowercase, strip trademark/registered/service-mark glyphs, and collapse whitespace so two owned + // entitlements for the same game compare equal across punctuation/spacing differences. + private static func normalizeTitle(_ raw: String) -> String { + let stripped = raw.lowercased() + .replacingOccurrences(of: "\u{2122}", with: "") + .replacingOccurrences(of: "\u{00AE}", with: "") + .replacingOccurrences(of: "\u{2120}", with: "") + return stripped.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + // A "full game" entitlement (vs add-on/avatar/theme): PSN marks the base game with a *GD // package_type (PSGD/PS4GD); add-ons use PS4MISC/PSAL/etc. private static func isFullGameEntitlement(_ ent: PsCloudEntitlement) -> Bool { From 48c0bf3c92737e201a51d0c1345dee4a33e044ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Thu, 4 Jun 2026 17:29:29 +0200 Subject: [PATCH 05/72] Cloud library: always use the owned PS5 product id on merged catalog cards The "all" library view merges owned entitlements into the browse catalog. For PS5 (PPSA) the override of the catalog card's product id was guarded (if (!existing.product_id)), so it applied only when the catalog card had no id. When the browse row carries a product id -- e.g. Horizon Forbidden West's concept is bound to the disc-upgrade SKU PPSA01521 -- the guard kept that unstreamable id even though the cross-reference had rescued the owned full game (PPSA17903), so Gaikai rejected it with "disc-upgrade-unsupported". Override unconditionally for PS5, matching the iOS and Android merges (which always copy the owned storeProductId). The owned PS5 product IS the streamable entitlement, so it must win over the catalog's fixed per-concept SKU. PS4 (CUSA) is unaffected (the whole block is PS5-only). Fixes Horizon Forbidden West failing to stream on the Steam Deck / Linux build while macOS and Android worked -- the guard only happened to pass on those when the catalog cache had a null product id (data-dependent). Co-Authored-By: Claude Opus 4.8 --- gui/src/qml/CloudPlayView.qml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index ddd963f2..9827bbef 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -475,11 +475,16 @@ Pane { // the owned DOWNLOAD product (e.g. ...GODOFWAR) has NO PS Now streaming SKU -- the // catalog entry's own productId (e.g. ...GODOFWARN, the "N" variant) is what Kamaji // converts to a streaming entitlement -- so leave the catalog productId intact. + // + // Override UNCONDITIONALLY for PS5 (matching the iOS/Android merge, which always copy + // storeProductId): the catalog card carries one fixed SKU per concept, but you can only + // stream the edition you actually own. When they differ -- e.g. the catalog SKU is a + // disc-upgrade you can't stream and the cross-reference rescued you to the owned full + // game -- the catalog card already has a (wrong) product_id, so a guarded assignment + // would keep the unstreamable id. The owned product id must win. if (ownedProductId && ps5CloudPlatformToken(ownedProductId) === "ps5") { - if (!existing.product_id) - existing.product_id = ownedProductId; - if (!existing.productId) - existing.productId = ownedProductId; + existing.product_id = ownedProductId; + existing.productId = ownedProductId; } games[catalogMatch] = existing; continue; From fbef1ee271636404e836b113ee8d525ccdb67a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Thu, 4 Jun 2026 19:25:37 +0200 Subject: [PATCH 06/72] Cloud library: fix PS5 owned-id override never running in the "all" view ps5CloudPlatformToken() takes a GAME OBJECT (it reads game.productId / game.id), but the "all"-view merge passed it the product-id STRING. A string has no .productId/.id/.device, so it always returned "", the `=== "ps5"` test was never true, and the block that copies the owned product id onto the matched catalog card never executed -- for any game. That left the catalog card's own (often unstreamable) SKU in place. For Horizon Forbidden West the catalog binds the concept to the disc-upgrade SKU PPSA01521, so the "all" filter streamed that and Gaikai rejected it ("disc-upgrade-unsupported"), while the "owned" filter worked (it uses the cross-reference output directly, which already carries the rescued PPSA17903). Pass the game object so the platform check resolves to "ps5" and the owned product id wins. Pre-existing bug -- the earlier guard/un-guard edits were both inside this dead block, which is why neither changed anything. iOS/Android were unaffected (their merges copy storeProductId with no platform-token check). Co-Authored-By: Claude Opus 4.8 --- gui/src/qml/CloudPlayView.qml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 9827bbef..d7539af5 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -476,13 +476,16 @@ Pane { // catalog entry's own productId (e.g. ...GODOFWARN, the "N" variant) is what Kamaji // converts to a streaming entitlement -- so leave the catalog productId intact. // - // Override UNCONDITIONALLY for PS5 (matching the iOS/Android merge, which always copy + // Override unconditionally for PS5 (matching the iOS/Android merge, which always copy // storeProductId): the catalog card carries one fixed SKU per concept, but you can only // stream the edition you actually own. When they differ -- e.g. the catalog SKU is a // disc-upgrade you can't stream and the cross-reference rescued you to the owned full - // game -- the catalog card already has a (wrong) product_id, so a guarded assignment - // would keep the unstreamable id. The owned product id must win. - if (ownedProductId && ps5CloudPlatformToken(ownedProductId) === "ps5") { + // game (Horizon: PPSA01521 -> PPSA17903) -- the catalog card's product_id is the wrong + // (unstreamable) one, so the owned product id must win. + // NOTE: ps5CloudPlatformToken takes a GAME OBJECT, not a product-id string -- passing + // the string here made it always return "" so this override never ran (the bug that + // broke the "all" view while the "owned" view, which uses the cross-ref directly, worked). + if (ownedProductId && ps5CloudPlatformToken(ownedGame) === "ps5") { existing.product_id = ownedProductId; existing.productId = ownedProductId; } From 966feb53ce1730fca5af5c154023553aef258648 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 6 Jun 2026 21:41:53 -0700 Subject: [PATCH 07/72] Android cloud: don't cache catalog when ownership check fails; warn to re-login The PS Plus cloud catalog cross-references the user's owned entitlements to mark games "Stream" (owned) vs "Add Game" (not owned). When that resolution failed (expired npsso / OAuth login_required, or a network error) the failure was silently swallowed: every game was marked "Not Owned" AND the result was cached for 24h, so all owned games showed "Add Game" until the cache expired, with no indication to re-login. - crossReferenceOwnership now propagates the failure instead of swallowing it. - fetchPs5CloudCatalog / fetchPlusCatalog / fetchPsnowCatalog skip caching the ownership-merged result on failure (the raw v3 catalog + PS3 classics still cache), so the next open retries once the session is valid again. - Surface a clear warning: session-expired vs network, distinguished by the OAuth/entitlements error. Reset the warning per fetch. Also fix a stray indentation glitch in CloudPlayFragment.onGameClicked from the ported PR. Verified on-device (Pixel 6, US PS Plus, currently-expired token): the all- "Not Owned" pscloud_catalog.json is no longer written on ownership failure. Co-authored-by: Cursor --- .../repository/CloudGameRepository.kt | 70 ++++++++++++++----- .../metallic/chiaki/main/CloudPlayFragment.kt | 2 +- .../chiaki/main/CloudPlayViewModel.kt | 1 + 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 85ec3f85..7913912e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -53,6 +53,11 @@ class CloudGameRepository( private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours + private const val OWNERSHIP_SESSION_WARNING = + "Your PlayStation session has expired. Please log in again to see your owned games." + private const val OWNERSHIP_NETWORK_WARNING = + "Couldn't verify your owned games (network error). Pull to refresh to try again." + // Region-group-specific so an Americas/PAL switch doesn't serve stale ids (e.g. "_US"/"_GB"). private fun ps3ClassicsCacheFile(accountCountry: String): String = "ps3_classics_catalog_${com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry)}.json" @@ -76,6 +81,8 @@ class CloudGameRepository( { return withContext(Dispatchers.IO) { + lastCatalogFetchWarning = null + // Check cache first if not forcing refresh if (!forceRefresh) { @@ -94,7 +101,8 @@ class CloudGameRepository( // Cache and return only if the legacy PS Now browse store actually returned games. if (result is PsnResult.Success && result.data.isNotEmpty()) { - cacheGames(result.data, PSNOW_CACHE_FILE) + if (!isOwnershipVerificationFailure(lastCatalogFetchWarning)) + cacheGames(result.data, PSNOW_CACHE_FILE) return@withContext result } @@ -102,7 +110,11 @@ class CloudGameRepository( // many regions (e.g. Hungary). Fall back to the PS Plus subscription catalog (~630), // NOT the full ~4000 streamable universe (that is the Library "all" view). Log.w(TAG, "PSNow catalog unavailable/empty, falling back to PS Plus subscription catalog") - fetchPlusCatalog(npssoToken, forceRefresh) + val plusResult = fetchPlusCatalog(npssoToken, forceRefresh) + if (plusResult is PsnResult.Success && plusResult.data.isNotEmpty() + && !isOwnershipVerificationFailure(lastCatalogFetchWarning)) + cacheGames(plusResult.data, PSNOW_CACHE_FILE) + plusResult } } @@ -115,6 +127,7 @@ class CloudGameRepository( { return withContext(Dispatchers.IO) { + lastCatalogFetchWarning = null CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) try { @@ -135,6 +148,7 @@ class CloudGameRepository( catch (e: Exception) { Log.w(TAG, "Catalog ownership marking failed; showing as not owned", e) + lastCatalogFetchWarning = ownershipFailureWarning(e) } } PsnResult.Success(games) @@ -154,6 +168,7 @@ class CloudGameRepository( { return withContext(Dispatchers.IO) { + lastCatalogFetchWarning = null CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) if (!forceRefresh) @@ -169,8 +184,19 @@ class CloudGameRepository( val stored = preferences.getCloudLanguage() Log.i(TAG, "Fetching PS5 Cloud catalog stored=$stored forceRefresh=$forceRefresh") val catalog = fetchPs5CatalogV3(stored, forceRefresh) - val gamesWithOwnership = crossReferenceOwnership(catalog, npssoToken) - if (gamesWithOwnership.isNotEmpty()) + var ownershipFailed = false + val gamesWithOwnership = try + { + crossReferenceOwnership(catalog, npssoToken) + } + catch (e: Exception) + { + ownershipFailed = true + Log.w(TAG, "Ownership cross-reference failed; not caching merged catalog", e) + lastCatalogFetchWarning = ownershipFailureWarning(e) + catalog.browseGames.map { it.copy(isOwned = false) } + } + if (gamesWithOwnership.isNotEmpty() && !ownershipFailed) cacheGames(gamesWithOwnership, PSCLOUD_ALL_CACHE_FILE) PsnResult.Success(gamesWithOwnership) } @@ -226,22 +252,28 @@ class CloudGameRepository( if (npssoToken.isEmpty()) return catalog.browseGames.map { it.copy(isOwned = false) } - return try - { - val ownedCrossRef = pscloudCatalogService.getOwnedPs5CloudGames( - npssoToken, - catalog.browseGames, - catalog.plusLibrarySupplement, - catalog.productIdAliases - ) - PsCloudOwnership.mergeOwnedIntoBrowseCatalog(catalog.browseGames, ownedCrossRef) - } - catch (e: Exception) - { - Log.w(TAG, "Failed to cross-reference ownership, returning games as not owned", e) - catalog.browseGames.map { it.copy(isOwned = false) } - } + val ownedCrossRef = pscloudCatalogService.getOwnedPs5CloudGames( + npssoToken, + catalog.browseGames, + catalog.plusLibrarySupplement, + catalog.productIdAliases + ) + return PsCloudOwnership.mergeOwnedIntoBrowseCatalog(catalog.browseGames, ownedCrossRef) } + + private fun ownershipFailureWarning(e: Exception): String + { + val msg = e.message?.lowercase() ?: "" + val isAuth = msg.contains("login_required") + || msg.contains("failed to extract oauth token") + || msg.contains("no location header in oauth") + || (msg.contains("oauth") && Regex("http 4\\d\\d").containsMatchIn(msg)) + || (msg.contains("entitlements") && (msg.contains("http 401") || msg.contains("http 403"))) + return if (isAuth) OWNERSHIP_SESSION_WARNING else OWNERSHIP_NETWORK_WARNING + } + + private fun isOwnershipVerificationFailure(warning: String?): Boolean = + warning == OWNERSHIP_SESSION_WARNING || warning == OWNERSHIP_NETWORK_WARNING /** * Fetch owned PS5 games (user's library) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index 047e036e..3986997f 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -1144,7 +1144,7 @@ class CloudPlayFragment : Fragment() private fun onGameClicked(game: CloudGame) { val isPscloud = game.serviceType == "pscloud" - if (isPscloud && !game.isOwned) + if (isPscloud && !game.isOwned) { // Show dialog to add game to library showAddToLibraryDialog(game) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt index e76cd176..13700657 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt @@ -81,6 +81,7 @@ class CloudPlayViewModel( { allGames = result.data Log.i(TAG, "Successfully loaded ${allGames.size} games") + repository.lastCatalogFetchWarning?.let { _warning.value = it } applySearchFilter() // PS3 Classics are subscription-streamable -> always shown in the Catalog. if (appendPs3Classics) From 01294b9ab08eb5f7c1b868ec451fde55ddbd1dfa Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 13 Jun 2026 00:31:45 -0700 Subject: [PATCH 08/72] Unify cloud catalog across Qt, Android, and iOS with ownership merge fixes and Cloud Play UI redesign. Fix cross-buy PS5 streaming by deriving serviceType from entitlement platform_id and using platform-disciplined merge so PS4 licenses cannot corrupt PS5 cards. Add unified catalog assembly with streamability gate and Apollo region fallback on mobile, align Qt/Android/iOS Cloud Play headers and filters, and add iOS CachedAsyncImage for reliable cover art loading. Co-authored-by: Cursor --- .../chiaki/cloudplay/PsnApiConstants.kt | 28 +- .../chiaki/cloudplay/api/PSKamajiSession.kt | 42 +- .../cloudplay/api/PsCloudCatalogService.kt | 126 +- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 285 +++- .../chiaki/cloudplay/api/PsnCatalogService.kt | 123 +- .../chiaki/cloudplay/model/CloudGame.kt | 7 +- .../repository/CloudGameRepository.kt | 305 ++-- .../com/metallic/chiaki/common/Preferences.kt | 32 + .../metallic/chiaki/main/CloudGameAdapter.kt | 27 +- .../metallic/chiaki/main/CloudPlayFragment.kt | 551 ++---- .../chiaki/main/CloudPlayViewModel.kt | 260 +-- .../com/metallic/chiaki/main/MainActivity.kt | 6 +- .../app/src/main/res/drawable/ic_filter.xml | 10 + .../main/res/layout/fragment_cloud_play.xml | 124 +- gui/include/cloudcatalogbackend.h | 30 + gui/include/cloudstreaming/pskamajisession.h | 9 + gui/include/qmlsettings.h | 15 + gui/include/settings.h | 12 + gui/src/cloudcatalogbackend.cpp | 823 ++++++++- gui/src/cloudstreaming/pskamajisession.cpp | 39 +- gui/src/qml/CloudGameCard.qml | 69 +- gui/src/qml/CloudPlayView.qml | 1504 ++++++----------- gui/src/qml/MainView.qml | 9 +- gui/src/qmlsettings.cpp | 33 + gui/src/settings.cpp | 35 + ios/Pylux.xcodeproj/project.pbxproj | 4 + ios/Pylux/Models/CloudModels.swift | 50 +- ios/Pylux/Services/CloudCatalogService.swift | 264 ++- ios/Pylux/Services/PSKamajiSession.swift | 38 +- ios/Pylux/Services/PsCloudOwnership.swift | 232 ++- ios/Pylux/Services/SecureStore.swift | 18 +- ios/Pylux/Views/CachedAsyncImage.swift | 101 ++ ios/Pylux/Views/CloudPlayView.swift | 357 ++-- 33 files changed, 3200 insertions(+), 2368 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_filter.xml create mode 100644 ios/Pylux/Views/CachedAsyncImage.swift diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt index 39235f76..40215833 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt @@ -27,18 +27,21 @@ object PsnApiConstants } /** - * PS3 / Classics pcnow store helpers, by account region group. + * pcnow ("Apollo") PS Now store helpers, by account region group. * Mirrors KamajiConsts (gui/include/cloudstreaming/pskamajisession.h) exactly. * - * pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: - * - SCEA / Americas -> store MSF192018, US-region ids (UP/NPUA/BLUS), - * PS3 child container "APOLLOPS3GAMES" - * - SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP/NPEA/NPEB/BLES), - * PS3 child container "APOLLOPS3" + * pcnow (the PS Plus PC "Apollo" backend) has only TWO region-group store families: + * - SCEA / Americas -> store MSF192018, US-region ids (UP/NPUA/BLUS) + * - SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP/NPEA/NPEB/BLES) * JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to * PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region * group, so the catalog must be browsed + resolved in the account's group. Region is keyed * by the ACCOUNT's region group, NOT by parsing the product-id prefix. + * + * The APOLLOROOT container is the single PS Now catalog root: ONE walk returns both PS3 and + * PS4 (distinguished only by playable_platform). It is browsed natively via /user/stores + * (session base_url) in supported regions, or directly via the public region-group container + * (no OAuth/session) as a fallback in regions where /user/stores has no storefront (e.g. HU). */ object KamajiClassics { @@ -47,6 +50,10 @@ object KamajiClassics "CR", "GT", "HN", "NI", "PA", "SV", "DO" ) + // PS Now catalog root store ids per region group (returns PS3 + PS4 in one walk). + const val APOLLOROOT_AMERICAS = "STORE-MSF192018-APOLLOROOT" + const val APOLLOROOT_PAL = "STORE-MSF192014-APOLLOROOT" + fun isAmericasClassicsRegion(countryCode: String): Boolean = AMERICAS.contains(countryCode.uppercase()) @@ -54,11 +61,8 @@ object KamajiClassics fun classicsStoreCountry(accountCountry: String): String = if (isAmericasClassicsRegion(accountCountry)) "US" else "GB" - /** Fully-qualified PS3 catalog container id for the account's region group. */ - fun classicsPs3ContainerId(accountCountry: String): String = - if (isAmericasClassicsRegion(accountCountry)) - "STORE-MSF192018-APOLLOPS3GAMES" - else - "STORE-MSF192014-APOLLOPS3" + /** Fully-qualified APOLLOROOT (PS Now: PS3 + PS4) container id for the account's region group. */ + fun apolloRootContainerId(accountCountry: String): String = + if (isAmericasClassicsRegion(accountCountry)) APOLLOROOT_AMERICAS else APOLLOROOT_PAL } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index a632df15..8cad7b12 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -312,19 +312,18 @@ class PSKamajiSession( var (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) Log.i(TAG, "Using locale from settings: $localeSetting -> country=$country, language=$language") - // PS3 / Classics product ids (NPEA/NPEB/BLES or NPUA/NPUB/BLUS -- anything that isn't a modern - // CUSA/PPSA id) come from the public Apollo catalog, which we walk in the account's region - // group (Americas -> US store, everything else -> PAL/GB). Resolve them against that SAME - // region's container so the lookup finds the product and returns the PSNW entitlement the - // account is authorized for at Gaikai. The account's own locale country can be a region with - // no pcnow storefront (e.g. Hungary -> "Storefront not found") and the raw locale 404s, so map - // to the region-group store. Must match the PS3 catalog source (fetchPs3ClassicsCatalog). - val isLegacyClassicsId = !productId.contains("CUSA") && !productId.contains("PPSA") - if (isLegacyClassicsId) + // Region-group fallback: when /user/stores has no storefront for the account's region, the + // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container + // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same + // region-group store -- the account's own locale country would 404 ("Storefront not found"). + // Driven by the account-level fallback flag, so PS3 and PS4 behave identically. In native + // mode the account's own locale resolves both. + val fallbackRegion = preferences.getCloudFallbackRegion() + if (fallbackRegion.isNotEmpty()) { - country = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(country) + country = fallbackRegion language = "en" - Log.i(TAG, "Legacy Classics id -> region-group container: country=$country, language=$language") + Log.i(TAG, "Fallback mode -> region-group container: country=$country, language=$language") } val url = "$storeBase/container/$country/$language/19/$productId?useOffers=true&gkb=1&gkb2=1" @@ -576,21 +575,20 @@ class PSKamajiSession( } // User doesn't have the per-game entitlement on the account (404). - // PS3 / Classics: the streaming entitlement is granted by the PS Plus subscription (a free - // 100%-off checkout), but that checkout requires a pcnow storefront in the account's region - // -- which many regions (e.g. Hungary) don't have, so the acquire fails with "Against - // Eligibility Rule". On a real PS5 the subscription alone grants streaming with no purchase, - // so skip the acquire and let Gaikai validate the Premium subscription directly. If Gaikai - // genuinely needs the entitlement, it returns noGameForEntitlementId downstream. - // CUSA/PS4 and PPSA/PS5 keep the existing acquire behavior. - val isLegacyClassicsId = !productId.contains("CUSA") && !productId.contains("PPSA") - if (isLegacyClassicsId) + // Region-group fallback: the free 100%-off checkout that grants the streaming entitlement + // requires a pcnow storefront in the account's region -- which unsupported regions (e.g. + // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". Skip it and let + // Gaikai validate the Premium subscription directly (if it genuinely needs the entitlement + // it returns noGameForEntitlementId downstream, surfaced via the region banner). Driven by + // the account-level fallback flag, so PS3 and PS4 behave identically. + // Native (supported region): run the normal checkout-acquire for both PS3 and PS4. + if (preferences.isCloudFallbackMode()) { - Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai") + Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai") return true } - // PS4/PS5 catalog: try to acquire it via checkout. + // Native mode: try to acquire it via checkout (PS3 + PS4 + PS5 alike). Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire") // Step 0.5e.3: Checkout preview diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt index cfcce94e..f5876fcb 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt @@ -336,12 +336,17 @@ class PsCloudCatalogService /** * Mirrors CloudCatalogBackend::getOwnedPs5CloudGames cross-reference (network). + * + * @param psnowCatalog the PS Now APOLLOROOT catalog (PS3 + PS4). Prepended to the imagic + * publicCatalog so owned PS4/PS3 entitlements resolve to their (psnow) catalog row and + * keep the Kamaji streaming route; imagic still provides PS5 rows + conceptIds. */ suspend fun getOwnedPs5CloudGames( npssoToken: String, publicCatalog: List, plusLibrarySupplement: List = emptyList(), productIdAliases: Map = emptyMap(), + psnowCatalog: List = emptyList(), ): List { if (npssoToken.isEmpty()) return emptyList() @@ -359,8 +364,9 @@ class PsCloudCatalogService if (ent.productId.isNotEmpty() && ent.id.isNotEmpty()) componentIds.getOrPut(ent.productId) { mutableListOf() }.add(ent.id) + val combinedCatalog = if (psnowCatalog.isEmpty()) publicCatalog else psnowCatalog + publicCatalog return PsCloudOwnership.crossReferenceOwnedGames( - filtered, publicCatalog, plusLibrarySupplement, productIdAliases, componentIds + filtered, combinedCatalog, plusLibrarySupplement, productIdAliases, componentIds ) } @@ -477,124 +483,6 @@ class PsCloudCatalogService return all } - // --------------------------------------------------------------------------- - // PS3 Classics catalog (public Apollo container walk) - // - // The PS Plus PC ("Apollo") app browses the streamable catalog through the public pcnow - // container API at psnow.playstation.com. There is a dedicated PS3 container that lists the - // streamable PS3 titles with their PS3 product ids (NPUA/NPUB/BLUS/EP9000/...) -- none of - // which appear in the imagic gameslist the rest of the catalog uses. The container API needs - // no OAuth or per-account session (unlike /user/stores, which 404s in regions where the PC - // app is unavailable, e.g. Hungary), so we can walk it directly in any region. The resulting - // titles carry playable_platform ["PS3"] and stream via the existing PSNOW -> Gaikai konan - // path. Mirrors CloudCatalogBackend::fetchPs3Catalog() (Qt). - // - // pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is - // authorized at Gaikai only for the family of its own region group, so the catalog must be - // browsed in that group. See KamajiClassics.classicsStoreCountry / classicsPs3ContainerId. - // --------------------------------------------------------------------------- - suspend fun fetchPs3ClassicsCatalog(accountCountry: String): List - { - val storeCountry = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) - val containerId = com.metallic.chiaki.cloudplay.KamajiClassics.classicsPs3ContainerId(accountCountry) - val containerUrl = - "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/$storeCountry/en/19/$containerId" - - Log.i(TAG, "=== Fetching PS3 Classics catalog (region group $storeCountry for account country $accountCountry) ===") - - val games = mutableListOf() - var start = 0 - var totalResults = -1 - - while (true) - { - val url = "$containerUrl?useOffers=true&gkb=1&gkb2=1&start=$start&size=100" - val response = HttpClient.get( - url = url, - headers = mapOf( - "Accept" to "application/json", - "User-Agent" to PsnApiConstants.USER_AGENT - ) - ) - - if (response.statusCode != 200) - { - Log.w(TAG, "PS3 catalog page fetch failed (HTTP ${response.statusCode})") - if (games.isEmpty()) - throw Exception("Failed to fetch PS3 Classics catalog: HTTP ${response.statusCode}") - break // Partial data already collected: return what we have. - } - - val obj = JSONObject(response.body) - if (totalResults < 0) - totalResults = obj.optInt("total_results", 0) - - val links = obj.optJSONArray("links") ?: JSONArray() - var productCount = 0 - for (i in 0 until links.length()) - { - val g = links.optJSONObject(i) ?: continue - if (g.optString("container_type") != "product") - continue - ps3JsonToCloudGame(g)?.let { games.add(it); productCount++ } - } - - Log.i(TAG, " PS3 page products: $productCount, accumulated: ${games.size} of $totalResults") - - start += 100 - if (productCount == 0 || start >= totalResults) - break - } - - Log.i(TAG, " PS3 Classics catalog complete: ${games.size} titles") - return games - } - - // Map a single pcnow PS3 container product into a CloudGame. The streaming id is the - // product `id` (Kamaji converts it -> entitlement -> Gaikai). Platform is detected from - // playable_platform containing "PS3" like the PSNow parser does. - private fun ps3JsonToCloudGame(gameObj: JSONObject): CloudGame? - { - val productId = gameObj.optString("id") - val name = gameObj.optString("name") - if (productId.isEmpty() || name.isEmpty()) - return null - - val (coverUrl, landscapeUrl) = extractImageUrls(gameObj) - var imageUrl = coverUrl - var landscapeImageUrl = landscapeUrl - if (imageUrl.startsWith("http://")) - imageUrl = imageUrl.replace("http://", "https://") - if (landscapeImageUrl.startsWith("http://")) - landscapeImageUrl = landscapeImageUrl.replace("http://", "https://") - - // Detect PS3 from playable_platform (matches the PSNow parser); default to ps3 for this - // container since every product in it is a streamable PS3 Classic. - var platform = "ps3" - val playablePlatformArray = gameObj.optJSONArray("playable_platform") - if (playablePlatformArray != null && playablePlatformArray.length() > 0) - { - for (i in 0 until playablePlatformArray.length()) - { - val platformStr = playablePlatformArray.optString(i, "").uppercase() - if (platformStr.contains("PS3")) - { - platform = "ps3" - break - } - } - } - - return CloudGame( - productId = productId, - name = name, - imageUrl = imageUrl, - landscapeImageUrl = landscapeImageUrl, - platform = platform, - serviceType = "psnow" // subscription-streamable via the PSNow/Gaikai konan path - ) - } - /** * Extract both cover and landscape image URLs from game object * Returns Pair diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt index 5a054b8e..bb3c3629 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt @@ -19,7 +19,11 @@ object PsCloudOwnership val packageType: String, val name: String, val conceptId: String, - val featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC + val featureType: Int, // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC + // Structured platform from entitlement_attributes[].platform_id ("ps5"/"ps4"/"ps3"). The + // authoritative stream-backend signal -- NOT a CUSA/PPSA id prefix, since a cross-buy PS4 + // license can carry a PS5-looking product_id wrapper (Red Dead's PS4 license has ...PPSA30528). + val platformId: String = "" ) private data class CatalogIndex( @@ -60,6 +64,20 @@ object PsCloudOwnership val conceptId = conceptIdString(gameMeta.opt("conceptId")) .ifEmpty { conceptIdString(gameMeta.opt("concept_id")) } .ifEmpty { conceptIdString(obj.opt("conceptId")) } + // Structured platform from entitlement_attributes[].platform_id. Sony also returns a numeric + // top-level "serviceType" here that is unrelated to our routing -- we never read it. + var platformId = "" + val attrs = obj.optJSONArray("entitlement_attributes") + if (attrs != null) + { + // Scan for the first RECOGNIZED platform (ps5/ps4/ps3); skip any unknown value so a junk + // attribute ordered first can't shadow a real one (mirrors Qt ownedEntitlementServiceType). + for (i in 0 until attrs.length()) + { + val p = attrs.optJSONObject(i)?.optString("platform_id", "")?.lowercase() ?: "" + if (p == "ps5" || p == "ps4" || p == "ps3") { platformId = p; break } + } + } return Entitlement( id = id, productId = obj.optString("product_id", ""), @@ -67,10 +85,51 @@ object PsCloudOwnership packageType = gameMeta.optString("package_type", ""), name = name, conceptId = conceptId, - featureType = obj.optInt("feature_type", 0) + featureType = obj.optInt("feature_type", 0), + platformId = platformId ) } + /** Canonical stream service for an owned entitlement from its structured platform_id: + * ps5 -> pscloud (cronos), ps4/ps3 -> psnow (Kamaji). Empty if platform_id is absent. */ + fun entServiceType(ent: Entitlement): String = when (ent.platformId) + { + "ps5" -> "pscloud" + "ps4", "ps3" -> "psnow" + else -> "" + } + + /** Stream backend for an owned entitlement, with Qt's exact fallback (streamServiceTypeForGame): + * 1) the structured platform_id (authoritative -- a cross-buy PS4 wrapper has platform_id "ps4" + * even though its product_id is a PS5-looking PPSA, so it correctly stays psnow/Kamaji); else + * 2) the entitlement's own product-id TOKEN (CUSA = PS4/Kamaji, PPSA = PS5/cronos). PS Plus classics + * (e.g. Blood Omen, product ...PPSA24270...) carry NO platform_id and match a PS Now/Apollo + * (psnow) browse row by concept -- inheriting meta.serviceType would mis-route them to Kamaji and + * fail. The product-id token routes them to cronos like Qt. Only when neither token is present do + * we fall back to the matched row's serviceType. */ + private fun ownedServiceType(ent: Entitlement, meta: CloudGame): String + { + val svc = entServiceType(ent) + if (svc.isNotEmpty()) return svc + val tok = ent.productId + " " + ent.id + return when + { + tok.contains("CUSA") -> "psnow" + tok.contains("PPSA") -> "pscloud" + else -> meta.serviceType + } + } + + /** Platform class (ps5/ps4) for owned dedupe, from platform_id; falls back to the product-id token + * only when platform_id is absent (never relied on for the CUSA/PPSA wrapper-prone cross-buy case, + * which always carries a platform_id). */ + private fun entPlatform(ent: Entitlement): String = when (ent.platformId) + { + "ps5" -> "ps5" + "ps4", "ps3" -> "ps4" + else -> platformToken(ent.productId) + } + fun crossReferenceOwnedGames( filteredEntitlements: List, publicCatalog: List, @@ -100,8 +159,13 @@ object PsCloudOwnership fun emit(meta: CloudGame, ent: Entitlement) { val displayName = meta.name.ifEmpty { ent.name } + // The owned card's serviceType comes from the ENTITLEMENT's platform_id (pscloud == PS5, + // psnow == PS3/PS4), not the matched catalog row's: a cross-buy PS4 license can match a PS5 + // catalog row by shared product_id, but it must still route as PS4/Kamaji. + val ownedService = ownedServiceType(ent, meta) val game = meta.copy( name = displayName, + serviceType = ownedService, isOwned = true, entitlementId = ent.id, storeProductId = ent.productId, @@ -133,6 +197,8 @@ object PsCloudOwnership catalogMap[ent.productId] ent.id.isNotEmpty() && catalogMap.containsKey(ent.id) -> catalogMap[ent.id] + // Inert in practice: PSN entitlements carry no conceptId (see findCatalogIndexForOwned note), so this + // platform-blind concept lookup almost never fires; owned games match by exact id above. // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). ent.conceptId.isNotEmpty() && browseByConcept.containsKey(ent.conceptId) -> browseByConcept[ent.conceptId] @@ -245,7 +311,16 @@ object PsCloudOwnership // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. private fun ownedDedupeKey(meta: CloudGame, ent: Entitlement): String { - if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}:${platformToken(ent.productId)}" + // Platform from the ENTITLEMENT's structured platform_id (NOT the matched catalog row's, and NOT + // a product-id prefix): a cross-buy title gives the user up to three entitlements that resolve to + // ONE catalog row -- a clean PS4 (CUSA, platform_id ps4), a real PS5 (PPSA, platform_id ps5), and + // a PS5-wrapper PS4 license (id CUSA, product_id ...PPSA..., platform_id ps4). The real PS5 and + // the PS5-wrapper PS4 must stay in SEPARATE buckets (ps5 vs ps4) so the PS5 entitlement is not + // discarded by a same-key collision; the merge then stamps the PS5 card from the PS5 entitlement + // and DROPS the PS4 wrapper (it can't claim a PS5 card). Collapsing by the catalog row's platform + // instead let the wrapper win and threw away the real PS5 license (the Blood Omen / GTA V PS5 + // streaming failure). + if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}:${entPlatform(ent)}" if (meta.productId.isNotEmpty()) return "p:${meta.productId}" if (ent.id.isNotEmpty()) return "e:${ent.id}" return "u:${meta.productId}:${ent.id}" @@ -284,13 +359,24 @@ object PsCloudOwnership return rank } - /** conceptId + platform; the owned product id (storeProductId) takes precedence so the owned - * edition's platform is used, else the catalog product id. */ + /** conceptId + platform. Platform comes from the canonical serviceType (pscloud == ps5, psnow == + * ps4-class) -- filled for owned cards from the entitlement's platform_id -- so an owned cross-buy + * PS4 license whose product_id is a PS5-looking wrapper buckets to the PS4 edition, not the PS5 + * one. Falls back to the product-id token when serviceType is absent (non-owned imagic rows). */ private fun conceptPlatformKey(game: CloudGame): String { if (game.conceptId.isEmpty()) return "" - val pid = if (game.storeProductId.isNotEmpty()) game.storeProductId else game.productId - return "${game.conceptId}|${platformToken(pid)}" + return "${game.conceptId}|${platformClassForCard(game)}" + } + + /** Platform CLASS of a catalog/owned card (ps5 or ps4). Mirrors Qt gamePlatformStructured + + * ps5CloudPlatformToken fallback in mergeOwnedIntoBrowseCatalog. */ + private fun platformClassForCard(game: CloudGame): String + { + val st = game.serviceType.lowercase() + if (st == "pscloud") return "ps5" + if (st == "psnow") return "ps4" + return platformToken(game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } }) } private fun catalogMapFirstWins(games: List): MutableMap @@ -364,12 +450,37 @@ object PsCloudOwnership if (catalogMatch >= 0) { val existing = games[catalogMatch] - games[catalogMatch] = existing.copy( - isOwned = true, - entitlementId = owned.entitlementId.ifEmpty { existing.entitlementId }, - storeProductId = owned.storeProductId.ifEmpty { existing.storeProductId } - ) - continue + val ownedService = owned.serviceType.lowercase() + val existingService = existing.serviceType.lowercase() + val existingClass = platformClassForCard(existing) + // The card's stream identity must come from the OWNED entitlement of THIS card's + // platform. Cross-buy editions share one product_id (Red Dead's PS4 license and PS5 + // license both carry ...PPSA30528...), so matching by product_id alone lets a PS4 + // entitlement land on the PS5 card. Rule: a PS5 (pscloud) claim is authoritative; a + // PS4/PS3 (psnow) entitlement must NEVER overwrite a PS5-class card. Mirrors Qt + // mergeOwnedIntoBrowseCatalog exactly (cloudcatalogbackend.cpp). + if (ownedService == "pscloud") + { + games[catalogMatch] = existing.copy( + isOwned = true, + serviceType = "pscloud", + entitlementId = owned.entitlementId.ifEmpty { existing.entitlementId }, + storeProductId = owned.storeProductId.ifEmpty { existing.storeProductId } + ) + continue + } + if (ownedService == "psnow" && existingService != "pscloud" && existingClass != "ps5") + { + games[catalogMatch] = existing.copy( + isOwned = true, + serviceType = "psnow", + entitlementId = owned.entitlementId.ifEmpty { existing.entitlementId }, + storeProductId = owned.storeProductId.ifEmpty { existing.storeProductId } + ) + continue + } + // psnow entitlement whose matched card is PS5-class: not this card's edition -- fall + // through to addUnmatched; streamability gate drops non-viable wrappers (Qt path). } if (!addUnmatched) continue @@ -388,21 +499,33 @@ object PsCloudOwnership { if (game.serviceType.equals("pscloud", ignoreCase = true)) { - // Stream the owned PRODUCT id (storeProductId) before the entitlement id: for cross-gen - // upgrades the entitlement id is the stale original SKU Gaikai has no game for. - if (game.storeProductId.isNotEmpty()) return game.storeProductId + // PS5/cronos streams the owned PS5 entitlement's OWN id (entitlementId), resolved from the + // entitlement's platform_id during cross-reference. Canonical SKUs (Red Dead, Alan Wake) + // have id == product_id == ...PPSA...; a classic whose product_id is a non-streamable + // wrapper (Blood Omen) has the ...PPSA..SLUS license id. Never a PS4/CUSA cross-buy id -- + // the platform-disciplined merge guarantees a PS5 card carries only PS5 entitlement data. if (game.entitlementId.isNotEmpty()) return game.entitlementId + if (game.storeProductId.isNotEmpty()) return game.storeProductId } return game.productId } - // A PlayStation title id encodes its platform: CUSAxxxxx = PS4, PPSAxxxxx = PS5. This is more - // reliable than the catalog device list and decides the streaming path: PS4 catalog titles go - // through Kamaji (psnow) to acquire the streaming entitlement, PS5 streams directly (pscloud). + // Platform that drives the streaming path (PS4 = Kamaji, PS5 = cronos). serviceType is the + // canonical signal but with one asymmetry: `psnow` is always PS3/PS4-class (set on PS Now browse + // rows and filled for owned PS3/PS4 cards from platform_id), while `pscloud` is authoritative ONLY + // for OWNED cards (filled from the entitlement's platform_id) -- non-owned imagic browse rows are + // blanket-labeled `pscloud` yet include a few PS4 titles, so for those we use the clean id token + // (PS4 there streams via PS Now/Kamaji, not cronos). Mirrors canonical Qt, whose non-owned imagic + // rows simply carry no serviceType and so fall through to the same token path. fun streamPlatform(game: CloudGame): String { - // Prefer the OWNED product id (storeProductId): for a cross-gen title you upgraded, the catalog - // productId may be the other generation (Alan Wake catalog = PS4 CUSA, but you own the PS5 PPSA). + val st = game.serviceType.lowercase() + if (st == "psnow") return "ps4" + // isOwned gate: imagic browse rows are blanket-tagged serviceType="pscloud" (see catalog parse), so + // only treat "pscloud" as PS5/cronos when actually OWNED; non-owned rows fall through to the product-id + // token below, routing non-owned PS4 imagic titles to PS Now (matches Qt, whose imagic rows carry no + // serviceType at all). + if (st == "pscloud" && game.isOwned) return "ps5" val p = game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } } return when { @@ -412,7 +535,8 @@ object PsCloudOwnership } } - /** Real legacy PS Now games stay psnow; otherwise route by title-id platform. */ + /** Route by the (platform_id-disciplined) streaming platform: PS3/PS4 via Kamaji (psnow), PS5 + * direct (pscloud). */ fun streamServiceType(game: CloudGame): String { if (game.serviceType.equals("psnow", ignoreCase = true)) return "psnow" @@ -447,6 +571,17 @@ object PsCloudOwnership catalogIndex.byProductId[game.entitlementId] = index } + // IMPORTANT (this cost real debugging time): PSN *owned entitlements* carry NO conceptId in practice + // -- their game_meta is just { name, package_type, icon_url }. So every conceptId-based step below is + // effectively INERT for owned games: an owned entitlement resolves to a catalog row by EXACT ID ONLY + // (product_id -> entitlement id -> store product id). The conceptId machinery's live job is catalog-row + // edition dedup (edition / conceptPlatformKey), NOT owned->catalog matching. + // + // Also: a PS4 CROSS-BUY license can carry a PS5-looking PPSA *product_id* wrapper while its real PS4 + // component is the CUSA *id* (platform_id stays "ps4"). product_id is matched against the catalog FIRST, + // so such a ps4 license can land on a PS5 (PPSA) row. NEVER infer platform from a product-id prefix for + // owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge + // guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. private fun findCatalogIndexForOwned(owned: CloudGame, catalogIndex: CatalogIndex): Int { if (owned.productId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.productId)) @@ -462,4 +597,110 @@ object PsCloudOwnership return catalogIndex.byConceptId.getValue(conceptKey) return -1 } + + // --------------------------------------------------------------------------------------------- + // Unified-page assembly: acquisition tag + concept-sibling streamability gate + // --------------------------------------------------------------------------------------------- + + /** Acquisition tag for the single unified list. */ + const val CATEGORY_OWNED = "owned" + const val CATEGORY_STREAMABLE = "streamable" + const val CATEGORY_PURCHASEABLE = "purchaseable" + + /** + * One tag per game, priority Owned > Streamable > Purchaseable: + * - owned -> entitlement resolved to a streamable row (Stream) + * - streamable -> not owned, PS Now subscription title (PS3/PS4 via Kamaji) (Stream) + * - purchaseable -> not owned, PS Plus catalog title (PS5 via Gaikai) (Add to Library) + */ + fun categoryFor(game: CloudGame): String = when + { + game.isOwned -> CATEGORY_OWNED + streamServiceType(game) == "psnow" -> CATEGORY_STREAMABLE + else -> CATEGORY_PURCHASEABLE + } + + /** + * Concept-sibling streamability gate index, built from the ACTUAL streamable catalog: + * - APOLLOROOT (PS3 + PS4) — streamable via Kamaji + * - main imagic browse (streamingSupported=true) — streamable via Gaikai (PS5 + a few PS4) + * + * A title is streamable iff it OR a same-conceptId sibling resolves into that catalog. This is + * deterministic (concept/id membership), so it never "remembers failures" or hides + * intermittently. Keeps cross-gen true positives (e.g. owned PS5 Horizon ZD Remastered via its + * PS4 sibling in APOLLOROOT) and drops no-streamable-path titles (e.g. FOR HONOR). + */ + class StreamabilityIndex( + apolloCatalog: List, // PS Now APOLLOROOT (PS3 + PS4) + imagicBrowse: List, // imagic streamingSupported=true set + imagicConceptRows: List, // browse + supplement: rows carrying conceptId<->productId + ) + { + private val productKeys = HashSet() // raw product ids + stable keys + private val streamableConceptIds = HashSet() + + init + { + fun addProduct(productId: String) + { + if (productId.isEmpty()) return + productKeys.add(productId) + productIdStableKey(productId)?.let { productKeys.add(it) } + } + apolloCatalog.forEach { addProduct(it.productId) } + imagicBrowse.forEach { + addProduct(it.productId) + if (it.conceptId.isNotEmpty()) streamableConceptIds.add(it.conceptId) + } + // Bridge APOLLOROOT membership -> conceptId. APOLLOROOT rows carry no conceptId, so use + // any imagic row (browse OR supplement) whose product id IS in APOLLOROOT to mark its + // concept streamable. A cross-gen sibling sharing that concept (e.g. the PS5 edition) is + // then kept even though it lives only in the supplement. + for (row in imagicConceptRows) + { + if (row.conceptId.isEmpty()) continue + val keys = listOfNotNull( + row.productId.takeIf { it.isNotEmpty() }, + productIdStableKey(row.productId) + ) + if (keys.any { it in productKeys }) + streamableConceptIds.add(row.conceptId) + } + } + + fun isStreamable(game: CloudGame): Boolean + { + for (p in listOf(game.productId, game.storeProductId, game.entitlementId)) + { + if (p.isEmpty()) continue + if (p in productKeys) return true + val stable = productIdStableKey(p) + if (stable != null && stable in productKeys) return true + } + return game.conceptId.isNotEmpty() && game.conceptId in streamableConceptIds + } + } + + /** + * Drop owned titles with no streamable path (native mode only). Non-owned rows already come + * straight from the streamable catalog, so they are never gated. + */ + fun applyStreamabilityGate(games: List, index: StreamabilityIndex): List + { + val kept = mutableListOf() + var dropped = 0 + for (game in games) + { + if (!game.isOwned || index.isStreamable(game)) + kept.add(game) + else + { + dropped++ + Log.i(TAG, "streamability gate: dropped owned non-streamable '${game.name}' (${game.productId})") + } + } + if (dropped > 0) + Log.i(TAG, "streamability gate: dropped $dropped owned non-streamable titles") + return kept + } } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt index 0853d3ef..f542f802 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt @@ -6,7 +6,6 @@ import android.util.Log import com.metallic.chiaki.cloudplay.DuidUtil import com.metallic.chiaki.cloudplay.PsnApiConstants import com.metallic.chiaki.cloudplay.model.CloudGame -import com.metallic.chiaki.cloudplay.model.PsnResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray @@ -34,38 +33,50 @@ class PsnCatalogService( private var country: String? = null private var language: String? = null private val duid = DuidUtil.generateDuid() - + + /** + * Outcome of the native PS Now (APOLLOROOT) catalog fetch. + * - storesAvailable=false, authError=false -> /user/stores had no storefront for this account's + * region (unsupported region, e.g. HU): caller should fall back to the public region-group walk. + * - authError=true -> OAuth/session failed (expired NPSSO token). + */ + data class NativeCatalogOutcome( + val games: List, + val storesAvailable: Boolean, + val authError: Boolean + ) + /** - * Fetch PSNow catalog with complete authentication flow - * Matches: CloudCatalogBackend::fetchPsnowCatalog() + * Native PS Now catalog fetch (one APOLLOROOT walk: PS3 + PS4) using the account's own + * /user/stores base_url. Matches: CloudCatalogBackend::fetchPsnowCatalog(). */ - suspend fun fetchPsnowCatalog(npssoToken: String): PsnResult> = withContext(Dispatchers.IO) + suspend fun fetchNativeCatalog(npssoToken: String): NativeCatalogOutcome = withContext(Dispatchers.IO) { try { gameLogCounter = 0 // Reset counter for new catalog fetch - Log.i(TAG, "=== Starting PSNow Catalog Fetch ===") - - // Step 1: OAuth authentication + Log.i(TAG, "=== Starting PSNow (APOLLOROOT) Catalog Fetch ===") + + // Step 1: OAuth authentication (failure here = expired token) val oauthCode = fetchOAuthCode(npssoToken) - ?: return@withContext PsnResult.Error("OAuth authentication failed") - - // Step 2: Create Kamaji session + ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = true) + + // Step 2: Create Kamaji session (failure here = expired token) val sessionId = createKamajiSession(oauthCode) - ?: return@withContext PsnResult.Error("Failed to create Kamaji session") - + ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = true) + jsessionId = sessionId - - // Step 3: Fetch stores to get base URL + + // Step 3: Fetch stores to get base URL. No base_url => region not supported (fallback). val storesBaseUrl = fetchStores() - ?: return@withContext PsnResult.Error("Failed to fetch stores") - + ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = false) + baseUrl = storesBaseUrl - + // Step 4: Fetch root container to get category links val categoryUrls = fetchRootContainer() - ?: return@withContext PsnResult.Error("Failed to fetch root container") - + ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = true, authError = false) + // Step 5: Fetch all category pages val allGames = mutableListOf() for ((categoryName, categoryUrl) in categoryUrls) @@ -74,16 +85,78 @@ class PsnCatalogService( val games = fetchCategoryGames(categoryUrl) allGames.addAll(games) } - - Log.i(TAG, "=== PSNow Catalog Fetch Complete: ${allGames.size} games ===") - PsnResult.Success(allGames) + + Log.i(TAG, "=== PSNow Catalog Fetch Complete: ${allGames.size} games (native) ===") + NativeCatalogOutcome(allGames, storesAvailable = true, authError = false) } catch (e: Exception) { - Log.e(TAG, "Error fetching PSNow catalog", e) - PsnResult.Error("Failed to fetch catalog: ${e.message}", e) + Log.e(TAG, "Error fetching native PSNow catalog", e) + NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = false) } } + + /** + * Fallback PS Now catalog fetch: walk the PUBLIC region-group APOLLOROOT container directly + * (no OAuth/session), used when /user/stores has no storefront for the account's region. + * Returns the same PS3 + PS4 set as the native walk. Mirrors the public-container technique + * previously used for the dedicated PS3 fetch; APOLLOROOT already includes PS3. + */ + suspend fun fetchApolloRootCatalog(accountCountry: String): List = withContext(Dispatchers.IO) + { + val storeCountry = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) + val containerId = com.metallic.chiaki.cloudplay.KamajiClassics.apolloRootContainerId(accountCountry) + val containerUrl = "${PsnApiConstants.STORE_BASE}/container/$storeCountry/en/19/$containerId" + + Log.i(TAG, "=== Fetching APOLLOROOT catalog (region group $storeCountry for account $accountCountry) ===") + + val games = mutableListOf() + var start = 0 + var totalResults = -1 + + while (true) + { + val url = "$containerUrl?useOffers=true&gkb=1&gkb2=1&start=$start&size=100" + val response = HttpClient.get( + url = url, + headers = mapOf( + "Accept" to "application/json", + "User-Agent" to PsnApiConstants.USER_AGENT + ) + ) + + if (response.statusCode != 200) + { + Log.w(TAG, "APOLLOROOT page fetch failed (HTTP ${response.statusCode})") + if (games.isEmpty()) + throw Exception("Failed to fetch APOLLOROOT catalog: HTTP ${response.statusCode}") + break // Partial data already collected: return what we have. + } + + val obj = JSONObject(response.body) + if (totalResults < 0) + totalResults = obj.optInt("total_results", 0) + + val links = obj.optJSONArray("links") ?: JSONArray() + var productCount = 0 + for (i in 0 until links.length()) + { + val g = links.optJSONObject(i) ?: continue + if (g.optString("container_type") != "product") + continue + parseGameObject(g)?.let { games.add(it); productCount++ } + } + + Log.i(TAG, " APOLLOROOT page products: $productCount, accumulated: ${games.size} of $totalResults") + + start += 100 + if (productCount == 0 || start >= totalResults) + break + } + + Log.i(TAG, " APOLLOROOT catalog complete: ${games.size} titles") + games + } /** * Step 1: OAuth authentication with NPSSO token diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt index aae1e084..6e7ae925 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt @@ -19,7 +19,12 @@ data class CloudGame( val entitlementId: String = "", // PSCloud: entitlement id for streaming (Qt gameData.id) val storeProductId: String = "", // PSCloud: product_id from entitlements API val plusCatalog: Boolean = false, // In the PS Plus subscription catalog (vs full streamable universe) - val featureType: Int = 0 // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on + val featureType: Int = 0, // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on + // Unified-page acquisition tag, assigned once at catalog-assembly time: + // "owned" -> entitlement resolves to a streamable row (Stream) + // "streamable" -> not owned, PS Now subscription title (Stream) + // "purchaseable" -> not owned, PS Plus catalog title (Add to Library, then Stream) + val category: String = "" ) /** diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 7913912e..b20855d8 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -47,9 +47,7 @@ class CloudGameRepository( Log.w(TAG, "Error invalidating catalog cache", e) } } - private const val PSNOW_CACHE_FILE = "psnow_catalog.json" - private const val PSCLOUD_ALL_CACHE_FILE = "pscloud_catalog.json" - private const val PSCLOUD_OWNED_CACHE_FILE = "pscloud_owned_v2.json" // v2: ft0 filter + rank dedupe + featureType + private const val UNIFIED_CACHE_FILE = "unified_catalog_v4.json" // v4: Qt-aligned merge (existingClass guard + explicit pscloud/psnow serviceType stamp) private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours @@ -57,10 +55,6 @@ class CloudGameRepository( "Your PlayStation session has expired. Please log in again to see your owned games." private const val OWNERSHIP_NETWORK_WARNING = "Couldn't verify your owned games (network error). Pull to refresh to try again." - - // Region-group-specific so an Americas/PAL switch doesn't serve stale ids (e.g. "_US"/"_GB"). - private fun ps3ClassicsCacheFile(accountCountry: String): String = - "ps3_classics_catalog_${com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry)}.json" } private val psnowCatalogService = PsnCatalogService(preferences) @@ -75,139 +69,132 @@ class CloudGameRepository( private set /** - * Fetch PSNow catalog with caching + * Unified cloud catalog: ONE merged, deduped, tagged list across PS3/PS4 (PS Now/Kamaji) and + * PS5 (imagic/Gaikai). Each game carries a `category` tag (owned / streamable / purchaseable). + * + * Sources: + * - PS Now APOLLOROOT walk (PS3 + PS4): native via /user/stores, or public region-group + * fallback when the account's region has no storefront. + * - imagic browse (PS5, streamingSupported=true) = purchaseable universe. + * Owned entitlements (PS4 + PS5) are cross-referenced against both (supplement + aliases + + * conceptId recognition retained). In native mode the concept-sibling streamability gate drops + * owned titles with no streamable path (e.g. FOR HONOR); in fallback mode the gate is skipped + * so nothing is hidden when the catalog isn't authoritative. */ - suspend fun fetchPsnowCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> + suspend fun fetchUnifiedCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> { return withContext(Dispatchers.IO) { lastCatalogFetchWarning = null + CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - // Check cache first if not forcing refresh if (!forceRefresh) { - val cachedGames = loadCachedGames(PSNOW_CACHE_FILE) - if (cachedGames != null) - { - Log.i(TAG, "Returning ${cachedGames.size} PSNow games from cache") - return@withContext PsnResult.Success(cachedGames) + loadCachedGames(UNIFIED_CACHE_FILE)?.let { cached -> + Log.i(TAG, "Returning ${cached.size} unified games from cache") + return@withContext PsnResult.Success(cached) } } - - // Fetch from network - Log.i(TAG, "Fetching fresh PSNow catalog from network") - val result = psnowCatalogService.fetchPsnowCatalog(npssoToken) - - // Cache and return only if the legacy PS Now browse store actually returned games. - if (result is PsnResult.Success && result.data.isNotEmpty()) - { - if (!isOwnershipVerificationFailure(lastCatalogFetchWarning)) - cacheGames(result.data, PSNOW_CACHE_FILE) - return@withContext result - } - // The legacy PS Now (Kamaji) browse store is region-locked / deprecated and 404s in - // many regions (e.g. Hungary). Fall back to the PS Plus subscription catalog (~630), - // NOT the full ~4000 streamable universe (that is the Library "all" view). - Log.w(TAG, "PSNow catalog unavailable/empty, falling back to PS Plus subscription catalog") - val plusResult = fetchPlusCatalog(npssoToken, forceRefresh) - if (plusResult is PsnResult.Success && plusResult.data.isNotEmpty() - && !isOwnershipVerificationFailure(lastCatalogFetchWarning)) - cacheGames(plusResult.data, PSNOW_CACHE_FILE) - plusResult - } - } + val (accountCountry, _) = + com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudLanguage()) - /** - * Fetch the PS Plus subscription catalog (Catalog tab): plusCatalog browse titles + the - * library-stream supplement, NOT the full all-ps5 universe. No ownership merge — every - * subscription title is shown as streamable. Mirrors Qt ps5PlusCatalogGames. - */ - suspend fun fetchPlusCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> - { - return withContext(Dispatchers.IO) - { - lastCatalogFetchWarning = null - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - try + // --- 1) PS Now APOLLOROOT (PS3 + PS4): native, else region-group fallback ---------- + val native = psnowCatalogService.fetchNativeCatalog(npssoToken) + var apolloGames: List = emptyList() + var nativeMode = false + var fallbackRegion = "" + when { - val stored = preferences.getCloudLanguage() - val catalog = fetchPs5CatalogV3(stored, forceRefresh) - var games = (catalog.browseGames.filter { it.plusCatalog } + catalog.plusLibrarySupplement) - .sortedBy { it.name.lowercase() } - // Mark owned subscription titles so owned -> Stream and non-owned -> Add Game. - // addUnmatched=false keeps the Catalog the pure subscription set (mark only). - if (npssoToken.isNotEmpty()) + native.storesAvailable && native.games.isNotEmpty() -> { - try - { - val ownedCrossRef = pscloudCatalogService.getOwnedPs5CloudGames( - npssoToken, catalog.browseGames, catalog.plusLibrarySupplement, catalog.productIdAliases) - games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog(games, ownedCrossRef, addUnmatched = false) - } - catch (e: Exception) - { - Log.w(TAG, "Catalog ownership marking failed; showing as not owned", e) - lastCatalogFetchWarning = ownershipFailureWarning(e) - } + apolloGames = native.games + nativeMode = true + } + native.authError -> + { + // Expired token: can't verify owned games. Still show a public catalog. + lastCatalogFetchWarning = OWNERSHIP_SESSION_WARNING + apolloGames = tryApolloRootFallback(accountCountry) + } + else -> + { + // /user/stores has no storefront for this region: public region-group walk. + apolloGames = tryApolloRootFallback(accountCountry) + if (apolloGames.isNotEmpty()) + fallbackRegion = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) } - PsnResult.Success(games) } - catch (e: Exception) + preferences.setCloudFallbackRegion(fallbackRegion) + Log.i(TAG, "PS Now APOLLOROOT: ${apolloGames.size} games (nativeMode=$nativeMode, fallbackRegion='$fallbackRegion')") + + // --- 2) imagic PS5 catalog (browse + supplement + aliases) ------------------------- + val imagic = try { - Log.e(TAG, "Failed to fetch PS Plus subscription catalog", e) - PsnResult.Error("Failed to fetch catalog: ${e.message}", e) + fetchPs5CatalogV3(preferences.getCloudLanguage(), forceRefresh) } - } - } - - /** - * Fetch PS5 Cloud catalog with caching - */ - suspend fun fetchPs5CloudCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> - { - return withContext(Dispatchers.IO) - { - lastCatalogFetchWarning = null - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - - if (!forceRefresh) + catch (e: Exception) { - loadCachedGames(PSCLOUD_ALL_CACHE_FILE)?.let { cached -> - Log.i(TAG, "Returning ${cached.size} PS5 games from cache (ownership included)") - return@withContext PsnResult.Success(cached) - } + Log.e(TAG, "imagic PS5 catalog fetch failed", e) + if (apolloGames.isEmpty()) + return@withContext PsnResult.Error("Failed to fetch catalog: ${e.message}", e) + Ps5CloudCatalogResult(emptyList(), emptyList(), emptyMap()) } - try + // --- 3) owned cross-reference (skip on expired token) ------------------------------ + var owned: List = emptyList() + if (npssoToken.isNotEmpty() && !native.authError) { - val stored = preferences.getCloudLanguage() - Log.i(TAG, "Fetching PS5 Cloud catalog stored=$stored forceRefresh=$forceRefresh") - val catalog = fetchPs5CatalogV3(stored, forceRefresh) - var ownershipFailed = false - val gamesWithOwnership = try + try { - crossReferenceOwnership(catalog, npssoToken) + owned = pscloudCatalogService.getOwnedPs5CloudGames( + npssoToken, imagic.browseGames, imagic.plusLibrarySupplement, + imagic.productIdAliases, psnowCatalog = apolloGames + ) } catch (e: Exception) { - ownershipFailed = true - Log.w(TAG, "Ownership cross-reference failed; not caching merged catalog", e) + Log.w(TAG, "Ownership cross-reference failed; showing as not owned", e) lastCatalogFetchWarning = ownershipFailureWarning(e) - catalog.browseGames.map { it.copy(isOwned = false) } } - if (gamesWithOwnership.isNotEmpty() && !ownershipFailed) - cacheGames(gamesWithOwnership, PSCLOUD_ALL_CACHE_FILE) - PsnResult.Success(gamesWithOwnership) } - catch (e: Exception) + + // --- 4) assemble the streamable universe (PS Now PS3/PS4 + imagic PS5) ------------- + val ps5Browse = imagic.browseGames.filter { PsCloudOwnership.streamPlatform(it) == "ps5" } + val universe = apolloGames + ps5Browse + var games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog(universe, owned, addUnmatched = true) + + // --- 5) concept-sibling streamability gate (native mode only) ---------------------- + if (nativeMode) { - Log.e(TAG, "Failed to fetch PS5 catalog", e) - PsnResult.Error("Failed to fetch PS5 catalog: ${e.message}", e) + val index = PsCloudOwnership.StreamabilityIndex( + apolloCatalog = apolloGames, + imagicBrowse = imagic.browseGames, + imagicConceptRows = imagic.browseGames + imagic.plusLibrarySupplement + ) + games = PsCloudOwnership.applyStreamabilityGate(games, index) } + + // --- 6) tag + cache ---------------------------------------------------------------- + games = games.map { it.copy(category = PsCloudOwnership.categoryFor(it)) } + if (games.isNotEmpty() && !isOwnershipVerificationFailure(lastCatalogFetchWarning)) + cacheGames(games, UNIFIED_CACHE_FILE) + PsnResult.Success(games) } } - + + /** Best-effort public region-group APOLLOROOT walk (no session). Empty list on failure. */ + private suspend fun tryApolloRootFallback(accountCountry: String): List = + try + { + psnowCatalogService.fetchApolloRootCatalog(accountCountry) + } + catch (e: Exception) + { + Log.w(TAG, "APOLLOROOT region-group fallback failed", e) + emptyList() + } + /** * Fetch the PS5 imagic catalog, trying the store-locale fallback chain * (session locale -> en-COUNTRY -> en-US) since Sony 404s unsupported locales (e.g. hu-HU). @@ -244,23 +231,6 @@ class CloudGameRepository( throw (lastError ?: Exception("All imagic locales failed to load")) } - /** - * Cross-reference public catalog with owned games to mark ownership status - */ - private suspend fun crossReferenceOwnership(catalog: Ps5CloudCatalogResult, npssoToken: String): List - { - if (npssoToken.isEmpty()) - return catalog.browseGames.map { it.copy(isOwned = false) } - - val ownedCrossRef = pscloudCatalogService.getOwnedPs5CloudGames( - npssoToken, - catalog.browseGames, - catalog.plusLibrarySupplement, - catalog.productIdAliases - ) - return PsCloudOwnership.mergeOwnedIntoBrowseCatalog(catalog.browseGames, ownedCrossRef) - } - private fun ownershipFailureWarning(e: Exception): String { val msg = e.message?.lowercase() ?: "" @@ -274,84 +244,6 @@ class CloudGameRepository( private fun isOwnershipVerificationFailure(warning: String?): Boolean = warning == OWNERSHIP_SESSION_WARNING || warning == OWNERSHIP_NETWORK_WARNING - - /** - * Fetch owned PS5 games (user's library) - */ - suspend fun fetchOwnedPs5Games(npssoToken: String, forceRefresh: Boolean = false): PsnResult> - { - return withContext(Dispatchers.IO) - { - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - - if (!forceRefresh) - { - loadCachedGames(PSCLOUD_OWNED_CACHE_FILE)?.let { cached -> - Log.i(TAG, "Returning ${cached.size} owned PS5 games from cache") - return@withContext PsnResult.Success(cached) - } - } - - Log.i(TAG, "Fetching owned PS5 games from network (forceRefresh=$forceRefresh)") - try - { - val stored = preferences.getCloudLanguage() - val catalog = fetchPs5CatalogV3(stored, forceRefresh) - - val games = pscloudCatalogService.getOwnedPs5CloudGames( - npssoToken, - catalog.browseGames, - catalog.plusLibrarySupplement, - catalog.productIdAliases - ) - if (games.isNotEmpty()) - cacheGames(games, PSCLOUD_OWNED_CACHE_FILE) - PsnResult.Success(games) - } - catch (e: Exception) - { - Log.e(TAG, "Failed to fetch owned PS5 games", e) - PsnResult.Error("Failed to fetch owned PS5 games: ${e.message}", e) - } - } - } - - /** - * Fetch the streamable PS3 Classics (public Apollo container) with region-keyed caching. - * Subscription-streamable (never "owned"), so callers append these to the Catalog and the - * Library "all" view only. Mirrors CloudCatalogBackend::fetchPs3Catalog() (Qt). - */ - suspend fun fetchPs3ClassicsCatalog(forceRefresh: Boolean = false): PsnResult> - { - return withContext(Dispatchers.IO) - { - CloudLocaleBootstrap.ensureConfigured(preferences, preferences.getNpssoToken()) - // Account country = country part of the store locale (e.g. "en-HU" -> "HU"). - val (accountCountry, _) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudLanguage()) - val cacheFile = ps3ClassicsCacheFile(accountCountry) - - if (!forceRefresh) - { - loadCachedGames(cacheFile)?.let { cached -> - Log.i(TAG, "Returning ${cached.size} PS3 Classics from cache ($cacheFile)") - return@withContext PsnResult.Success(cached) - } - } - - try - { - val games = pscloudCatalogService.fetchPs3ClassicsCatalog(accountCountry) - if (games.isNotEmpty()) - cacheGames(games, cacheFile) - PsnResult.Success(games) - } - catch (e: Exception) - { - Log.w(TAG, "Failed to fetch PS3 Classics catalog", e) - PsnResult.Error("Failed to fetch PS3 Classics catalog: ${e.message}", e) - } - } - } /** * Load games from cache if valid @@ -403,7 +295,8 @@ class CloudGameRepository( entitlementId = obj.optString("entitlementId", ""), storeProductId = obj.optString("storeProductId", ""), plusCatalog = obj.optBoolean("plusCatalog", false), - featureType = obj.optInt("featureType", 0) + featureType = obj.optInt("featureType", 0), + category = obj.optString("category", "") )) } @@ -443,6 +336,7 @@ class CloudGameRepository( obj.put("storeProductId", game.storeProductId) obj.put("plusCatalog", game.plusCatalog) obj.put("featureType", game.featureType) + obj.put("category", game.category) jsonArray.put(obj) } @@ -553,6 +447,11 @@ class CloudGameRepository( imageUrl = obj.getString("imageUrl"), landscapeImageUrl = landscapeImageUrl, platform = obj.optString("platform", "ps5"), + // Deliberate Qt<->mobile divergence: Qt leaves imagic browse rows with NO serviceType and derives + // platform from the clean catalog product-id token. Mobile instead blanket-tags imagic rows "pscloud" + // and COMPENSATES with an isOwned gate in streamPlatform (a non-owned "pscloud" row falls back to the + // product-id token, so a non-owned PS4 imagic title still routes to PS Now, not cronos). Both reach the + // same routing -- do NOT naively "fix" one side to match the other. serviceType = obj.optString("serviceType", "pscloud"), conceptUrl = obj.optString("conceptUrl", ""), conceptId = obj.optString("conceptId", ""), diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index b3c1fd70..bb2f329e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -424,8 +424,10 @@ class Preferences(context: Context) // Cloud Play UI state private val LAST_CLOUD_SECTION_KEY = "last_cloud_section" private val PSCLOUD_FILTER_OWNED_KEY = "pscloud_filter_owned" + private val CLOUD_FALLBACK_REGION_KEY = "cloud_fallback_region" private val LAST_MAIN_TAB_KEY = "last_main_tab" private val CLOUD_SORT_STATE_KEY = "cloud_sort_state" + private val CLOUD_TAG_FILTERS_KEY = "cloud_tag_filters" private val FAVORITE_GAMES_KEY = "favorite_games" private val PSNOW_FILTER_FAVORITES_KEY = "psnow_filter_favorites" private val PSCLOUD_FILTER_FAVORITES_KEY = "pscloud_filter_favorites" @@ -458,6 +460,25 @@ class Preferences(context: Context) sharedPreferences.edit().putString(LAST_CLOUD_SECTION_KEY, section).apply() } + /** + * PS Now region-group fallback. Empty string = native mode (account's own /user/stores + * storefront is authoritative). A non-empty value (the region-group store country, "US" + * or "GB") = fallback mode: the catalog came from a foreign region group, so the + * concept-sibling streamability gate is skipped and stream-conversion/acquire remap to + * the region-group store. Recomputed on every catalog refresh (self-healing). + */ + fun getCloudFallbackRegion(): String + { + return sharedPreferences.getString(CLOUD_FALLBACK_REGION_KEY, "") ?: "" + } + + fun setCloudFallbackRegion(region: String) + { + sharedPreferences.edit().putString(CLOUD_FALLBACK_REGION_KEY, region).apply() + } + + fun isCloudFallbackMode(): Boolean = getCloudFallbackRegion().isNotEmpty() + fun getPsCloudFilterOwned(): Boolean { return sharedPreferences.getBoolean(PSCLOUD_FILTER_OWNED_KEY, false) @@ -487,6 +508,17 @@ class Preferences(context: Context) { sharedPreferences.edit().putInt(CLOUD_SORT_STATE_KEY, sortState).apply() } + + /** Persisted acquisition-tag filter selection for the unified cloud page (empty = show all). */ + fun getCloudTagFilters(): Set + { + return sharedPreferences.getStringSet(CLOUD_TAG_FILTERS_KEY, emptySet()) ?: emptySet() + } + + fun setCloudTagFilters(tags: Set) + { + sharedPreferences.edit().putStringSet(CLOUD_TAG_FILTERS_KEY, tags).apply() + } // Favorite games management fun getFavoriteGames(): Set diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt index 7e16af75..da7b147e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt @@ -138,14 +138,27 @@ class CloudGameAdapter( } } - if (showOwnershipBadge && game.serviceType == "pscloud") { + // Acquisition-tag badge (unified page): Owned (green) / Streamable (blue) / + // Purchaseable (orange). Fall back through the canonical tagger (streamServiceType-based), + // not raw serviceType, so a non-owned PS4 cloud-browse row isn't mislabeled "Add Game". + val category = game.category.ifEmpty { + com.metallic.chiaki.cloudplay.api.PsCloudOwnership.categoryFor(game) + } + if (showOwnershipBadge) { binding.ownershipBadge.visibility = android.view.View.VISIBLE - if (game.isOwned) { - binding.ownershipBadge.text = "Owned" - binding.ownershipBadge.setBackgroundColor(0xCC4CAF50.toInt()) - } else { - binding.ownershipBadge.text = "Not Owned" - binding.ownershipBadge.setBackgroundColor(0xCCFF9800.toInt()) + when (category) { + "owned" -> { + binding.ownershipBadge.text = "Owned" + binding.ownershipBadge.setBackgroundColor(0xCC4CAF50.toInt()) + } + "streamable" -> { + binding.ownershipBadge.text = "Streamable" + binding.ownershipBadge.setBackgroundColor(0xCC2196F3.toInt()) + } + else -> { + binding.ownershipBadge.text = "Add Game" + binding.ownershipBadge.setBackgroundColor(0xCCFF9800.toInt()) + } } } else { binding.ownershipBadge.visibility = android.view.View.GONE diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index 3986997f..1f9294ab 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -135,7 +135,7 @@ class CloudPlayFragment : Fragment() }).get(CloudPlayViewModel::class.java) setupRecyclerView() - setupCloudTabs() + setupHeaderControls() setupSearchView() setupSettingsFab() setupScrollListener() @@ -244,17 +244,8 @@ class CloudPlayFragment : Fragment() private fun loadCatalog() { hideLoginRequiredState() - - // Load based on last selected section (default to PSNow) - val currentSection = viewModel.getCurrentSection() - if (currentSection == "pscloud") - { - selectLibraryTab() - } - else - { - selectCatalogTab() - } + updateFilterSummary() + viewModel.fetchCatalog() } private fun showLoginRequiredState() @@ -429,79 +420,102 @@ class CloudPlayFragment : Fragment() imm.hideSoftInputFromWindow(binding.searchView.windowToken, 0) } - private fun setupCloudTabs() + // Acquisition-tag filter categories and their display labels (dropdown order). + private val tagFilterCategories = listOf( + PsCloudOwnership.CATEGORY_OWNED, + PsCloudOwnership.CATEGORY_STREAMABLE, + PsCloudOwnership.CATEGORY_PURCHASEABLE + ) + private val tagFilterLabels = listOf("Owned", "Streamable", "Store") + + private fun setupHeaderControls() { - // Catalog tab button - binding.catalogTabButton.setOnClickListener { - selectCatalogTab() - } - - // Library tab button - binding.libraryTabButton.setOnClickListener { - selectLibraryTab() - } - - // All/Owned toggle (Library only) - binding.ownedToggleButton.setOnClickListener { - val currentlyOwned = viewModel.preferences.getPsCloudFilterOwned() - viewModel.preferences.setPsCloudFilterOwned(!currentlyOwned) - updateOwnedToggleButton() - // Re-fetch with new filter. PS3 Classics only in the streamable "all" view (not "owned"). - val newShowOnlyOwned = !currentlyOwned - viewModel.fetchPs5CloudCatalog(showOnlyOwned = newShowOnlyOwned, appendPs3Classics = !newShowOnlyOwned) - } - - // Icon buttons in header - binding.headerFavoritesButton.setOnClickListener { - toggleFavoritesFilter() - } - - binding.headerSortButton.setOnClickListener { - showSortMenu() - } - - binding.headerSearchButton.setOnClickListener { - toggleSearch() - } - - binding.headerRefreshButton.setOnClickListener { - refreshCurrentSection() - } + binding.headerFilterButton.setOnClickListener { showFilterMenu() } + binding.filterSummary.setOnClickListener { showFilterMenu() } + binding.headerFavoritesButton.setOnClickListener { toggleFavoritesFilter() } + binding.headerSortButton.setOnClickListener { showSortMenu() } + binding.headerSearchButton.setOnClickListener { toggleSearch() } + binding.headerRefreshButton.setOnClickListener { refreshGamesList() } binding.root.enableFocusableInTouchModeForTv(requireContext()) fun highlightButton(v: View, hasFocus: Boolean) { if (hasFocus) { - v.background = android.graphics.drawable.GradientDrawable().apply { + v.foreground = android.graphics.drawable.GradientDrawable().apply { shape = android.graphics.drawable.GradientDrawable.RECTANGLE cornerRadius = 24f setColor(0x30FFD700.toInt()) setStroke(2, 0xCCFFD700.toInt()) } } else { - v.background = null + v.foreground = null } } val focusHighlight = View.OnFocusChangeListener { v, hasFocus -> highlightButton(v, hasFocus) } - binding.catalogTabButton.onFocusChangeListener = focusHighlight - binding.libraryTabButton.onFocusChangeListener = focusHighlight - binding.ownedToggleButton.onFocusChangeListener = focusHighlight + binding.filterSummary.onFocusChangeListener = focusHighlight + binding.headerFilterButton.onFocusChangeListener = focusHighlight binding.headerFavoritesButton.onFocusChangeListener = focusHighlight binding.headerSortButton.onFocusChangeListener = focusHighlight binding.headerSearchButton.onFocusChangeListener = focusHighlight binding.headerRefreshButton.onFocusChangeListener = focusHighlight - - // Initialize icon colors + + adapter.showOwnershipBadge = true + binding.sortOptionLayout.visibility = android.view.View.VISIBLE + binding.filterOptionLayout.visibility = android.view.View.GONE + updateSortButtonText() updateHeaderIconColors() + updateFilterSummary() + } + + /** Multi-select acquisition-tag filter dropdown (Owned / Streamable / Purchaseable). */ + private fun showFilterMenu() + { + // Empty active set means "all" — show every box checked so the dialog reflects that. + val allActive = viewModel.activeTagFilters.isEmpty() + val checked = BooleanArray(tagFilterCategories.size) { + allActive || viewModel.isTagFilterActive(tagFilterCategories[it]) + } + requireContext().alertDialogBuilder() + .setTitle("Filter games") + .setMultiChoiceItems(tagFilterLabels.toTypedArray(), checked) { _, which, isChecked -> + checked[which] = isChecked + } + .setPositiveButton("Apply") { dialog, _ -> + val selected = tagFilterCategories.filterIndexed { i, _ -> checked[i] }.toSet() + // All (or none) selected collapses to the "All games" state. + val normalized = if (selected.isEmpty() || selected.size == tagFilterCategories.size) + emptySet() else selected + viewModel.setTagFilters(normalized) + updateFilterSummary() + dialog.dismiss() + } + .setNeutralButton("Show all") { dialog, _ -> + viewModel.setTagFilters(emptySet()) + updateFilterSummary() + dialog.dismiss() + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + /** Summary label + filter-icon highlight reflecting the active acquisition-tag filter. */ + private fun updateFilterSummary() + { + val active = viewModel.activeTagFilters + binding.filterSummary.text = if (active.isEmpty()) "All games" + else tagFilterCategories.filter { it in active } + .joinToString(" · ") { tagFilterLabels[tagFilterCategories.indexOf(it)] } + val on = active.isNotEmpty() + binding.headerFilterButton.setColorFilter( + if (on) resources.getColor(android.R.color.holo_blue_light, null) + else resources.getColor(android.R.color.white, null) + ) + binding.headerFilterButton.alpha = if (on) 1.0f else 0.45f } private fun updateHeaderIconColors() { val whiteTranslucent = resources.getColor(android.R.color.white, null) - - // Update favorites icon updateFavoritesIcon() - - // Other icons - default white translucent binding.headerSortButton.setColorFilter(whiteTranslucent) binding.headerSortButton.alpha = 0.45f binding.headerSearchButton.setColorFilter(whiteTranslucent) @@ -509,16 +523,10 @@ class CloudPlayFragment : Fragment() binding.headerRefreshButton.setColorFilter(whiteTranslucent) binding.headerRefreshButton.alpha = 0.45f } - + private fun updateFavoritesIcon() { - val currentSection = viewModel.getCurrentSection() - val favActive = if (currentSection == "pscloud") { - preferences.getPsCloudFilterFavorites() - } else { - preferences.getPsnowFilterFavorites() - } - + val favActive = preferences.getPsCloudFilterFavorites() binding.headerFavoritesButton.setImageResource( if (favActive) R.drawable.ic_star else R.drawable.ic_star_outline ) @@ -528,147 +536,31 @@ class CloudPlayFragment : Fragment() ) binding.headerFavoritesButton.alpha = if (favActive) 1.0f else 0.45f } - - private fun selectCatalogTab() - { - // Update button styles (selected) - binding.catalogTabButton.setTextColor(resources.getColor(android.R.color.white, null)) - binding.catalogTabButton.setTypeface(null, android.graphics.Typeface.BOLD) - binding.catalogTabButton.setBackgroundResource(R.drawable.cloud_tab_selected) - binding.catalogTabButton.alpha = 1.0f - - // Unselected style - binding.libraryTabButton.setTextColor(resources.getColor(android.R.color.white, null)) - binding.libraryTabButton.setTypeface(null, android.graphics.Typeface.NORMAL) - binding.libraryTabButton.alpha = 0.45f - binding.libraryTabButton.setBackgroundColor(android.graphics.Color.TRANSPARENT) - - // Hide All/Owned toggle for Catalog - binding.ownedToggleButton.visibility = android.view.View.GONE - - // Update section - viewModel.setCurrentSection("psnow") - adapter.showOwnershipBadge = true // owned/not-owned shown in Catalog too - binding.sortOptionLayout.visibility = android.view.View.VISIBLE - binding.filterOptionLayout.visibility = android.view.View.VISIBLE - updateSortButtonText() - updateFilterButtonText() - - // Update favorites icon to match new section - updateFavoritesIcon() - // Append the streamable PS3 Classics (public Apollo container) to the Catalog after it loads. - viewModel.fetchPsnowCatalog(appendPs3Classics = true) - } - - private fun selectLibraryTab() - { - // Update button styles (selected) - binding.libraryTabButton.setTextColor(resources.getColor(android.R.color.white, null)) - binding.libraryTabButton.setTypeface(null, android.graphics.Typeface.BOLD) - binding.libraryTabButton.setBackgroundResource(R.drawable.cloud_tab_selected) - binding.libraryTabButton.alpha = 1.0f - - // Unselected style - binding.catalogTabButton.setTextColor(resources.getColor(android.R.color.white, null)) - binding.catalogTabButton.setTypeface(null, android.graphics.Typeface.NORMAL) - binding.catalogTabButton.alpha = 0.45f - binding.catalogTabButton.setBackgroundColor(android.graphics.Color.TRANSPARENT) - - // Show All/Owned toggle for Library - binding.ownedToggleButton.visibility = android.view.View.VISIBLE - updateOwnedToggleButton() - - // Update section - viewModel.setCurrentSection("pscloud") - adapter.showOwnershipBadge = true - binding.sortOptionLayout.visibility = android.view.View.VISIBLE - binding.filterOptionLayout.visibility = android.view.View.VISIBLE - updateSortButtonText() - updateFilterButtonText() - - // Update favorites icon to match new section - updateFavoritesIcon() - - val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - val isFavoritesFilter = preferences.getPsCloudFilterFavorites() - - // PS3 Classics belong in the streamable "all" view only (never the "owned" list). The - // favorites filter draws from the same "all" set, so include PS3 there too. - if (isFavoritesFilter) { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, appendPs3Classics = true) - } else { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, appendPs3Classics = !isOwnedFilter) - } - } - - private fun updateOwnedToggleButton() - { - val isOwned = viewModel.preferences.getPsCloudFilterOwned() - binding.ownedToggleButton.text = if (isOwned) "Owned" else "All" - binding.ownedToggleButton.setTextColor( - if (isOwned) resources.getColor(android.R.color.holo_green_light, null) - else resources.getColor(android.R.color.white, null) - ) - binding.ownedToggleButton.alpha = if (isOwned) 1.0f else 0.6f - binding.ownedToggleButton.setBackgroundResource( - if (isOwned) R.drawable.cloud_tab_owned_selected - else R.drawable.cloud_tab_owned_unselected - ) - } - private fun toggleFavoritesFilter() { - val currentSection = viewModel.getCurrentSection() - val currentlyActive = if (currentSection == "pscloud") { - preferences.getPsCloudFilterFavorites() - } else { - preferences.getPsnowFilterFavorites() - } - - // Toggle the preference - val newState = !currentlyActive - if (currentSection == "pscloud") { - preferences.setPsCloudFilterFavorites(newState) - } else { - preferences.setPsnowFilterFavorites(newState) - } - - // Update icon to match new state + preferences.setPsCloudFilterFavorites(!preferences.getPsCloudFilterFavorites()) updateFavoritesIcon() - - // Re-filter games - use correct item IDs - if (currentSection == "pscloud") { - // Library: 0=All, 1=Owned, 2=Favorites - val selectedItem = if (newState) 2 else 0 - applyFilterState(currentSection, selectedItem) - } else { - // Catalog: 0=All, 1=Favorites - val selectedItem = if (newState) 1 else 0 - applyFilterState(currentSection, selectedItem) - } + // Favorites filter is applied in the games observer; re-run it by re-emitting the list. + viewModel.setSortedGames(viewModel.getAllCachedGames()) } - - private fun refreshCurrentSection() - { - val currentSection = viewModel.getCurrentSection() - if (currentSection == "pscloud") { - val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - // PS3 Classics belong in the streamable "all" view only, never the "owned" list. - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true, appendPs3Classics = !isOwnedFilter) - } else { - viewModel.fetchPsnowCatalog(forceRefresh = true, appendPs3Classics = true) - } + + /** Games you can play right now (owned + subscription/trial streamable) sort ahead of + * store titles that must first be added to your library. */ + private fun isPlayableNow(game: CloudGame): Boolean = + game.category != PsCloudOwnership.CATEGORY_PURCHASEABLE + + private fun sortGames(games: List): List = when (sortState) { + 1 -> games.sortedBy { it.name.lowercase() } + 2 -> games.sortedByDescending { it.name.lowercase() } + else -> games.sortedWith( + compareByDescending { isPlayableNow(it) }.thenBy { it.name.lowercase() } + ) } - + private fun showSortMenu() { - val currentSection = viewModel.getCurrentSection() - val sortOptions = when (currentSection) { - "pscloud" -> arrayOf("Owned First", "Name: A → Z", "Name: Z → A") - else -> arrayOf("Recent", "Name: A → Z", "Name: Z → A") - } - + val sortOptions = arrayOf("Playable First", "Name: A → Z", "Name: Z → A") requireContext().alertDialogBuilder() .setTitle("Sort") .setSingleChoiceItems(sortOptions, sortState) { dialog, which -> @@ -677,246 +569,75 @@ class CloudPlayFragment : Fragment() } .show() } - + private fun setupSettingsFab() { binding.settingsFab.setOnClickListener { expandSettingsFab(!binding.settingsFab.isExpanded) } - binding.settingsDialBackground.setOnClickListener { expandSettingsFab(false) } - - // Refresh button and label binding.refreshButton.setOnClickListener { refreshGamesList() } binding.refreshLabelButton.setOnClickListener { refreshGamesList() } - - // Sort button and label binding.sortButton.setOnClickListener { showSortMenu(binding.sortButton) } binding.sortLabelButton.setOnClickListener { showSortMenu(binding.sortLabelButton) } - - // Filter button and label (owned/all games) - binding.filterButton.setOnClickListener { showFilterMenu(binding.filterButton) } - binding.filterLabelButton.setOnClickListener { showFilterMenu(binding.filterLabelButton) } - updateSortButtonText() } - + private fun expandSettingsFab(expand: Boolean) { binding.settingsFab.isExpanded = expand binding.settingsFab.isActivated = binding.settingsFab.isExpanded } - + private fun refreshGamesList() { expandSettingsFab(false) - - // Keep current sort state when refreshing - val currentSection = viewModel.getCurrentSection() - if (currentSection == "pscloud") - { - val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - // PS3 Classics belong in the streamable "all" view only, never the "owned" list. - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true, appendPs3Classics = !isOwnedFilter) - } - else - { - viewModel.fetchPsnowCatalog(forceRefresh = true, appendPs3Classics = true) - } + viewModel.fetchCatalog(forceRefresh = true) } - + private fun showSortMenu(anchor: android.view.View) { expandSettingsFab(false) - - val currentSection = viewModel.getCurrentSection() val popup = androidx.appcompat.widget.PopupMenu(requireContext(), anchor) - - // Different default sort for Library vs Catalog - if (currentSection == "pscloud") { - popup.menu.add(0, 0, 0, "Owned First (Default)") - } else { - popup.menu.add(0, 0, 0, "Recent (Default)") - } + popup.menu.add(0, 0, 0, "Playable First (Default)") popup.menu.add(0, 1, 1, "Name: A → Z") popup.menu.add(0, 2, 2, "Name: Z → A") - - // Highlight current selection with radio button style popup.menu.findItem(sortState)?.isChecked = true popup.menu.setGroupCheckable(0, true, true) - popup.setOnMenuItemClickListener { item -> applySortState(item.itemId) true } - popup.show() } - + private fun applySortState(newSortState: Int) { sortState = newSortState preferences.setCloudSortState(sortState) updateSortButtonText() - - val currentGames = viewModel.games.value ?: return - val currentSection = viewModel.getCurrentSection() - - when (sortState) { - 0 -> { - // Default: Different behavior for Library vs Catalog - if (currentSection == "pscloud") { - // Library: Sort by ownership (owned first), then maintain order - val sortedGames = currentGames.sortedWith( - compareByDescending { it.isOwned } - ) - viewModel.setSortedGames(sortedGames) - } else { - // Catalog: Reload from cache to restore original API order (PS3 Classics included) - viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) - } - } - 1 -> { - // A->Z - val sortedGames = currentGames.sortedBy { it.name.lowercase() } - viewModel.setSortedGames(sortedGames) - } - 2 -> { - // Z->A - val sortedGames = currentGames.sortedByDescending { it.name.lowercase() } - viewModel.setSortedGames(sortedGames) - } - } + // Re-emit the full list; the games observer applies favorites + sort. + viewModel.setSortedGames(viewModel.getAllCachedGames()) } - + private fun updateSortButtonText() { - val currentSection = viewModel.getCurrentSection() val text = when (sortState) { - 0 -> if (currentSection == "pscloud") "Sort: Owned" else "Sort: Recent" 1 -> "Sort: A→Z" 2 -> "Sort: Z→A" - else -> if (currentSection == "pscloud") "Sort: Owned" else "Sort: Recent" + else -> "Sort: Playable" } binding.sortLabelButton.text = text } - - private fun showFilterMenu(anchor: android.view.View) - { - expandSettingsFab(false) - - val currentSection = viewModel.getCurrentSection() - val popup = androidx.appcompat.widget.PopupMenu(requireContext(), anchor) - - if (currentSection == "pscloud") { - // Game Library: All Games, Owned Games, Favorites - popup.menu.add(0, 0, 0, "Show: All Games") - popup.menu.add(0, 1, 1, "Show: Owned Only") - popup.menu.add(0, 2, 2, "Show: Favorites") - - // Highlight current selection - val currentItem = when { - preferences.getPsCloudFilterFavorites() -> 2 - preferences.getPsCloudFilterOwned() -> 1 - else -> 0 - } - popup.menu.findItem(currentItem)?.isChecked = true - } else { - // Game Catalog: All Games, Favorites - popup.menu.add(0, 0, 0, "Show: All Games") - popup.menu.add(0, 1, 1, "Show: Favorites") - - // Highlight current selection - val currentItem = if (preferences.getPsnowFilterFavorites()) 1 else 0 - popup.menu.findItem(currentItem)?.isChecked = true - } - - popup.menu.setGroupCheckable(0, true, true) - - popup.setOnMenuItemClickListener { item -> - applyFilterState(currentSection, item.itemId) - true - } - - popup.show() - } - - private fun applyFilterState(currentSection: String, selectedItem: Int) - { - if (currentSection == "pscloud") { - // Game Library - when (selectedItem) { - 0 -> { - // All Games (streamable universe includes PS3 Classics) - preferences.setPsCloudFilterFavorites(false) - preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false, appendPs3Classics = true) - } - 1 -> { - // Owned Games (PS3 Classics are subscription-streamable, never "owned") - preferences.setPsCloudFilterFavorites(false) - preferences.setPsCloudFilterOwned(true) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = true, forceRefresh = false) - } - 2 -> { - // Favorites (drawn from the "all" set, so include PS3 Classics) - preferences.setPsCloudFilterFavorites(true) - preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false, appendPs3Classics = true) - } - } - } else { - // Game Catalog - when (selectedItem) { - 0 -> { - // All Games - preferences.setPsnowFilterFavorites(false) - viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) - } - 1 -> { - // Favorites - preferences.setPsnowFilterFavorites(true) - viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) - } - } - } - - updateFilterButtonText() - updateFavoritesIcon() - } - - private fun updateFilterButtonText() - { - val currentSection = viewModel.getCurrentSection() - val text = if (currentSection == "pscloud") { - // Game Library - when { - preferences.getPsCloudFilterFavorites() -> "Show: Favorites" - preferences.getPsCloudFilterOwned() -> "Show: Owned" - else -> "Show: All" - } - } else { - // Game Catalog - if (preferences.getPsnowFilterFavorites()) "Show: Favorites" else "Show: All" - } - binding.filterLabelButton.text = text - } - + private fun filterAndDisplayFavorites() { val favoriteIds = preferences.getFavoriteGames() val allGames = viewModel.getAllCachedGames() val favoriteGames = allGames.filter { favoriteIds.contains(it.productId) } - - // Apply current sort state - val sortedGames = when (sortState) { - 1 -> favoriteGames.sortedBy { it.name.lowercase() } - 2 -> favoriteGames.sortedByDescending { it.name.lowercase() } - else -> favoriteGames - } - + val sortedGames = sortGames(favoriteGames) adapter.games = sortedGames updateEmptyState(sortedGames.isEmpty()) } @@ -958,15 +679,9 @@ class CloudPlayFragment : Fragment() preferences.removeFavoriteGame(game.productId) } - // If currently showing favorites, refresh the list - val currentSection = viewModel.getCurrentSection() - if (currentSection == "psnow" && preferences.getPsnowFilterFavorites()) { - // Refresh catalog favorites - refreshGamesList() - } else if (currentSection == "pscloud" && preferences.getPsCloudFilterFavorites()) { - // Refresh game library favorites - refreshGamesList() - } + // If favorites filter is active, un-favoriting should drop the card immediately. + if (preferences.getPsCloudFilterFavorites()) + viewModel.setSortedGames(viewModel.getAllCachedGames()) } private fun setupSearchView() @@ -994,36 +709,16 @@ class CloudPlayFragment : Fragment() return@Observer } - // Check if favorites filter is active for current section - val currentSection = viewModel.getCurrentSection() - val isFavoritesFilter = if (currentSection == "pscloud") { - preferences.getPsCloudFilterFavorites() - } else { - preferences.getPsnowFilterFavorites() - } - - // Filter for favorites if that filter is active - val filteredGames = if (isFavoritesFilter) { + // Favorites filter (single unified toggle). + val filteredGames = if (preferences.getPsCloudFilterFavorites()) { val favoriteIds = preferences.getFavoriteGames() games.filter { favoriteIds.contains(it.productId) } } else { games } - - // Apply saved sort state when games are loaded - val sortedGames = when (sortState) { - 0 -> { - // Default sort: Owned first for Library, original order for Catalog - if (currentSection == "pscloud") { - filteredGames.sortedWith(compareByDescending { it.isOwned }) - } else { - filteredGames - } - } - 1 -> filteredGames.sortedBy { it.name.lowercase() } // A->Z - 2 -> filteredGames.sortedByDescending { it.name.lowercase() } // Z->A - else -> filteredGames - } + + // Apply saved sort state: 0 = streaming-first (default), 1 = A→Z, 2 = Z→A. + val sortedGames = sortGames(filteredGames) adapter.games = sortedGames updateEmptyState(sortedGames.isEmpty()) @@ -1061,6 +756,16 @@ class CloudPlayFragment : Fragment() if (warning.isNullOrEmpty()) return@Observer Toast.makeText(requireContext(), warning, Toast.LENGTH_LONG).show() }) + + viewModel.fallbackRegion.observe(viewLifecycleOwner, Observer { region -> + if (region.isNullOrEmpty()) { + binding.regionBanner.visibility = View.GONE + } else { + binding.regionBanner.text = + "PlayStation cloud isn't offered natively in your region — showing the $region catalog. Some titles may not stream." + binding.regionBanner.visibility = View.VISIBLE + } + }) } private fun updateEmptyState(isEmpty: Boolean) @@ -1143,8 +848,12 @@ class CloudPlayFragment : Fragment() private fun onGameClicked(game: CloudGame) { - val isPscloud = game.serviceType == "pscloud" - if (isPscloud && !game.isOwned) + // Route on the canonical acquisition tag, not raw serviceType: only a "purchaseable" title + // (not owned, PS Plus catalog / PS5) needs Add-to-Library; "streamable" (PS Now) and owned + // titles stream directly. Raw serviceType=="pscloud" would mis-handle a non-owned PS4 + // cloud-browse row (which streams via PS Now). + val category = game.category.ifEmpty { PsCloudOwnership.categoryFor(game) } + if (category == PsCloudOwnership.CATEGORY_PURCHASEABLE) { // Show dialog to add game to library showAddToLibraryDialog(game) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt index 13700657..d071ccd1 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metallic.chiaki.cloudplay.CloudLocale +import com.metallic.chiaki.cloudplay.api.PsCloudOwnership import com.metallic.chiaki.cloudplay.model.CloudGame import com.metallic.chiaki.cloudplay.model.PsnResult import com.metallic.chiaki.cloudplay.repository.CloudGameRepository @@ -16,53 +17,60 @@ import com.metallic.chiaki.common.Preferences import kotlinx.coroutines.launch /** - * ViewModel for Cloud Play tab - * Manages PSNow catalog data and UI state + * ViewModel for the unified Cloud Play page. + * + * One catalog source (repository.fetchUnifiedCatalog) feeds a single tagged list. The UI filters + * that list by acquisition tag (owned / streamable / purchaseable) plus the search query; an empty + * tag set means "show all". The region-group fallback flag is surfaced for the banner. */ class CloudPlayViewModel( private val context: Context, - val preferences: Preferences // Made public for access from CloudPlayFragment + val preferences: Preferences ) : ViewModel() { companion object { private const val TAG = "CloudPlayViewModel" } - + private val repository = CloudGameRepository(context, preferences) - + private val _games = MutableLiveData>() val games: LiveData> get() = _games - + private val _loading = MutableLiveData() val loading: LiveData get() = _loading - + private val _error = MutableLiveData() val error: LiveData get() = _error private val _warning = MutableLiveData() val warning: LiveData get() = _warning - + + private val _fallbackRegion = MutableLiveData() + val fallbackRegion: LiveData get() = _fallbackRegion + private val _searchQuery = MutableLiveData() val searchQuery: LiveData get() = _searchQuery - + private var allGames: List = emptyList() - private var currentSection: String = "psnow" // "psnow" or "pscloud" - + + // Active acquisition-tag filters; empty = show all. Restored from prefs, persisted on change. + var activeTagFilters: Set = preferences.getCloudTagFilters() + private set + init { _loading.value = false _error.value = null _searchQuery.value = "" - - // Load last selected section from preferences - currentSection = preferences.getLastCloudSection() + _fallbackRegion.value = preferences.getCloudFallbackRegion() } - + /** - * Fetch PSNow catalog from network/cache + * Fetch the unified cloud catalog (PS Now PS3/PS4 + PS5), tagged by acquisition category. */ - fun fetchPsnowCatalog(forceRefresh: Boolean = false, appendPs3Classics: Boolean = false) + fun fetchCatalog(forceRefresh: Boolean = false) { viewModelScope.launch { try @@ -71,21 +79,17 @@ class CloudPlayViewModel( _error.value = null _warning.value = null - Log.i(TAG, "Fetching PSNow catalog (forceRefresh=$forceRefresh)") - val npssoToken = preferences.getNpssoToken() + Log.i(TAG, "Fetching unified cloud catalog (forceRefresh=$forceRefresh)") - when (val result = repository.fetchPsnowCatalog(npssoToken, forceRefresh)) + when (val result = repository.fetchUnifiedCatalog(npssoToken, forceRefresh)) { is PsnResult.Success -> { allGames = result.data - Log.i(TAG, "Successfully loaded ${allGames.size} games") + Log.i(TAG, "Loaded ${allGames.size} unified games") repository.lastCatalogFetchWarning?.let { _warning.value = it } - applySearchFilter() - // PS3 Classics are subscription-streamable -> always shown in the Catalog. - if (appendPs3Classics) - fetchPs3ClassicsCatalog(forceRefresh) + applyFilters() } is PsnResult.Error -> { @@ -101,184 +105,57 @@ class CloudPlayViewModel( } finally { - _loading.value = false - } - } - } - - /** - * Fetch PS5 Cloud catalog from network/cache - * @param showOnlyOwned If true, fetches only user's owned games; if false, fetches all PS5 games - */ - fun fetchPs5CloudCatalog(showOnlyOwned: Boolean = false, forceRefresh: Boolean = false, appendPs3Classics: Boolean = false) - { - viewModelScope.launch { - try - { - _loading.value = true - _error.value = null - _warning.value = null - - val npssoToken = preferences.getNpssoToken() - - if (showOnlyOwned) - { - Log.i(TAG, "Fetching owned PS5 games (forceRefresh=$forceRefresh)") - - when (val result = repository.fetchOwnedPs5Games(npssoToken, forceRefresh)) - { - is PsnResult.Success -> - { - allGames = result.data - Log.i(TAG, "Successfully loaded ${allGames.size} owned PS5 games") - repository.lastCatalogFetchWarning?.let { _warning.value = it } - applySearchFilter() - } - is PsnResult.Error -> - { - Log.e(TAG, "Failed to fetch owned PS5 games: ${result.message}", result.exception) - _error.value = result.message - } - } - } - else - { - Log.i(TAG, "Fetching all PS5 Cloud catalog (forceRefresh=$forceRefresh)") - - when (val result = repository.fetchPs5CloudCatalog(npssoToken, forceRefresh)) - { - is PsnResult.Success -> - { - allGames = result.data - Log.i(TAG, "Successfully loaded ${allGames.size} PS5 games") - repository.lastCatalogFetchWarning?.let { _warning.value = it } - applySearchFilter() - // Library "all" (streamable universe) includes PS3 Classics; "owned" does not. - if (appendPs3Classics) - fetchPs3ClassicsCatalog(forceRefresh) - } - is PsnResult.Error -> - { - Log.e(TAG, "Failed to fetch PS5 catalog: ${result.message}", result.exception) - _error.value = result.message - } - } - } - } - catch (e: Exception) - { - Log.e(TAG, "Unexpected error fetching PS5 catalog", e) - _error.value = "Unexpected error: ${e.message}" - } - finally - { + _fallbackRegion.value = preferences.getCloudFallbackRegion() updateLocaleWarningIfNeeded() _loading.value = false } } } - - /** - * Fetch the streamable PS3 Classics (public Apollo container) and APPEND them to the - * already-displayed list. Additive: it never replaces the PS4/PS5 catalog already loaded, - * so it works whether the primary catalog came from PS Now or the imagic fallback. PS3 - * Classics are subscription-streamable, so they belong in the Game Catalog and in the - * Library "all" view -- but NOT the "owned" view. Mirrors CloudPlayView.qml appendPs3Catalog(). - */ - fun fetchPs3ClassicsCatalog(forceRefresh: Boolean = false) - { - viewModelScope.launch { - try - { - when (val result = repository.fetchPs3ClassicsCatalog(forceRefresh)) - { - is PsnResult.Success -> - { - if (result.data.isNotEmpty()) - { - // De-dupe by productId in case of a re-entrant append. - val existingIds = allGames.mapTo(HashSet()) { it.productId } - val toAdd = result.data.filter { existingIds.add(it.productId) } - if (toAdd.isNotEmpty()) - { - allGames = allGames + toAdd - Log.i(TAG, "Appended ${toAdd.size} PS3 Classics to catalog") - applySearchFilter() - } - } - } - is PsnResult.Error -> - { - // Non-fatal: PS3 Classics are supplementary to the primary catalog. - Log.w(TAG, "PS3 Classics catalog unavailable: ${result.message}") - } - } - } - catch (e: Exception) - { - Log.w(TAG, "Unexpected error fetching PS3 Classics catalog", e) - } - } - } - /** - * Get current section - */ - fun getCurrentSection(): String + fun toggleTagFilter(tag: String) { - return currentSection + activeTagFilters = if (tag in activeTagFilters) activeTagFilters - tag else activeTagFilters + tag + preferences.setCloudTagFilters(activeTagFilters) + applyFilters() } - - /** - * Set current section and save to preferences - */ - fun setCurrentSection(section: String) + + fun setTagFilters(tags: Set) { - currentSection = section - preferences.setLastCloudSection(section) - Log.i(TAG, "Current section set to: $section") + activeTagFilters = tags + preferences.setCloudTagFilters(activeTagFilters) + applyFilters() } - - /** - * Update search query and filter results - */ + + fun isTagFilterActive(tag: String): Boolean = tag in activeTagFilters + fun setSearchQuery(query: String) { _searchQuery.value = query - applySearchFilter() + applyFilters() } - - /** - * Apply current search filter to games - */ - private fun applySearchFilter() + + private fun applyFilters() { val query = _searchQuery.value ?: "" - if (query.isEmpty()) - { - _games.value = allGames - } - else - { - val filtered = allGames.filter { game -> + var filtered = allGames + + if (activeTagFilters.isNotEmpty()) + filtered = filtered.filter { it.category in activeTagFilters } + + if (query.isNotEmpty()) + filtered = filtered.filter { game -> game.name.contains(query, ignoreCase = true) || game.productId.contains(query, ignoreCase = true) } - _games.value = filtered - } + + _games.value = filtered } - - /** - * Clear current error message - */ + fun clearError() { _error.value = null } - - /** - * Clear cached catalog data - */ + fun clearCache() { viewModelScope.launch { @@ -286,35 +163,23 @@ class CloudPlayViewModel( Log.i(TAG, "Cache cleared") } } - - /** - * Clear current games list (used when logging out or when token is invalid) - */ + fun clearGames() { allGames = emptyList() _games.value = emptyList() Log.i(TAG, "Games list cleared") } - - /** - * Update games with a sorted list - */ + + /** Apply an externally sorted ordering (search/tag filters re-applied on top is not needed). */ fun setSortedGames(sortedGames: List) { allGames = sortedGames - applySearchFilter() - Log.i(TAG, "Games list updated with sorted data") - } - - /** - * Get all cached games (for filtering favorites) - */ - fun getAllCachedGames(): List - { - return allGames + applyFilters() } + fun getAllCachedGames(): List = allGames + private fun updateLocaleWarningIfNeeded() { if (!_warning.value.isNullOrEmpty()) @@ -323,4 +188,3 @@ class CloudPlayViewModel( _warning.value = CloudLocale.unconfiguredWarning() } } - diff --git a/android/app/src/main/java/com/metallic/chiaki/main/MainActivity.kt b/android/app/src/main/java/com/metallic/chiaki/main/MainActivity.kt index 71b7597a..358ae8ac 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/MainActivity.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/MainActivity.kt @@ -212,7 +212,7 @@ class MainActivity : AppCompatActivity() } val secondaryIds = setOf( - R.id.catalogTabButton, R.id.libraryTabButton, R.id.ownedToggleButton, + R.id.filterSummary, R.id.headerFilterButton, R.id.headerFavoritesButton, R.id.headerSortButton, R.id.headerSearchButton, R.id.headerRefreshButton ) @@ -243,7 +243,7 @@ class MainActivity : AppCompatActivity() } fun focusSecondaryHeader() { - window.decorView.findViewById(R.id.catalogTabButton)?.requestFocus() + window.decorView.findViewById(R.id.headerFilterButton)?.requestFocus() } fun focusFab() { @@ -380,7 +380,7 @@ class MainActivity : AppCompatActivity() val hostRv = if (currentPage == 0) window.decorView.findViewById(R.id.hostsRecyclerView) else null val secondaryIds = setOf( - R.id.catalogTabButton, R.id.libraryTabButton, R.id.ownedToggleButton, + R.id.filterSummary, R.id.headerFilterButton, R.id.headerFavoritesButton, R.id.headerSortButton, R.id.headerSearchButton, R.id.headerRefreshButton ) diff --git a/android/app/src/main/res/drawable/ic_filter.xml b/android/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..d1e39b1e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/fragment_cloud_play.xml b/android/app/src/main/res/layout/fragment_cloud_play.xml index 8358d88a..404f3e9c 100644 --- a/android/app/src/main/res/layout/fragment_cloud_play.xml +++ b/android/app/src/main/res/layout/fragment_cloud_play.xml @@ -27,66 +27,25 @@ android:paddingHorizontal="10dp" android:paddingVertical="6dp"> - - + - - - - - - - - - - - + android:layout_height="34dp" + android:gravity="center_vertical" + android:text="All games" + android:textSize="13sp" + android:textColor="#FFFFFF" + android:fontFamily="sans-serif-medium" + android:maxLines="1" + android:ellipsize="end" + android:paddingHorizontal="6dp" + android:drawablePadding="6dp" + android:focusable="true" + android:clickable="true" + android:background="?attr/selectableItemBackground" /> - + + + - + - + + + + productIdAliases; + } unifiedState; + // PS3 Classics catalog fetching state (public Apollo PS3 container, paginated). // containerUrl is resolved per account region group (Americas vs PAL) at fetch time. struct Ps3FetchState { @@ -159,6 +181,8 @@ private slots: QMap componentIdsByProductId; bool catalogFetched; bool ownedGamesFetched; + QJsonArray psnowCatalogGames; + bool unifiedMode = false; } crossReferenceState; // Helper methods @@ -181,6 +205,12 @@ private slots: void handlePsnowSessionResponse(); void handlePsnowStoresResponse(); void handlePsnowRootContainerResponse(); + void unifiedNativeProbeFailed(bool authError); + void startUnifiedApolloFallback(); + void fetchUnifiedApolloPage(); + void continueUnifiedAfterApollo(); + void startUnifiedOwnedCrossRef(); + void assembleUnifiedCatalog(const QJsonArray &ownedCrossRef); void startPs5ImagicListFetch(); // fires the six imagic list requests for ps5State.activeLocale void executeGameDetailsFetch(const QString &productId); QJsonArray filterStreamingSupportedGames(const QJsonArray &games); diff --git a/gui/include/cloudstreaming/pskamajisession.h b/gui/include/cloudstreaming/pskamajisession.h index d3314aa5..388fc34e 100644 --- a/gui/include/cloudstreaming/pskamajisession.h +++ b/gui/include/cloudstreaming/pskamajisession.h @@ -62,6 +62,15 @@ namespace KamajiConsts { ? QStringLiteral("STORE-MSF192018-APOLLOPS3GAMES") : QStringLiteral("STORE-MSF192014-APOLLOPS3"); } + + // PS Now catalog root store ids per region group (returns PS3 + PS4 in one walk). + static const QString APOLLOROOT_AMERICAS = QStringLiteral("STORE-MSF192018-APOLLOROOT"); + static const QString APOLLOROOT_PAL = QStringLiteral("STORE-MSF192014-APOLLOROOT"); + + /** Fully-qualified APOLLOROOT (PS Now: PS3 + PS4) container id for the account's region group. */ + inline QString apolloRootContainerId(const QString &accountCountry) { + return isAmericasClassicsRegion(accountCountry) ? APOLLOROOT_AMERICAS : APOLLOROOT_PAL; + } } /** diff --git a/gui/include/qmlsettings.h b/gui/include/qmlsettings.h index 272560b6..670debb0 100644 --- a/gui/include/qmlsettings.h +++ b/gui/include/qmlsettings.h @@ -95,6 +95,9 @@ class QmlSettings : public QObject Q_PROPERTY(QString lastSelectedCloudSection READ lastSelectedCloudSection WRITE setLastSelectedCloudSection NOTIFY lastSelectedCloudSectionChanged) Q_PROPERTY(QString cloudLibraryFilter READ cloudLibraryFilter WRITE setCloudLibraryFilter NOTIFY cloudLibraryFilterChanged) Q_PROPERTY(QString cloudCatalogFilter READ cloudCatalogFilter WRITE setCloudCatalogFilter NOTIFY cloudCatalogFilterChanged) + Q_PROPERTY(QString cloudFallbackRegion READ cloudFallbackRegion WRITE setCloudFallbackRegion NOTIFY cloudFallbackRegionChanged) + Q_PROPERTY(QString cloudTagFilters READ cloudTagFilters WRITE setCloudTagFilters NOTIFY cloudTagFiltersChanged) + Q_PROPERTY(int cloudSortState READ cloudSortState WRITE setCloudSortState NOTIFY cloudSortStateChanged) Q_PROPERTY(QString cloudFavorites READ cloudFavorites WRITE setCloudFavorites NOTIFY cloudFavoritesChanged) Q_PROPERTY(bool mouseTouchEnabled READ mouseTouchEnabled WRITE setMouseTouchEnabled NOTIFY mouseTouchEnabledChanged) Q_PROPERTY(bool keyboardEnabled READ keyboardEnabled WRITE setKeyboardEnabled NOTIFY keyboardEnabledChanged) @@ -569,6 +572,15 @@ class QmlSettings : public QObject QString cloudCatalogFilter() const; void setCloudCatalogFilter(const QString &filter); + QString cloudFallbackRegion() const; + void setCloudFallbackRegion(const QString ®ion); + + QString cloudTagFilters() const; + void setCloudTagFilters(const QString &filtersJson); + + int cloudSortState() const; + void setCloudSortState(int sortState); + QString cloudFavorites() const; void setCloudFavorites(const QString &favorites); @@ -725,6 +737,9 @@ class QmlSettings : public QObject void lastSelectedCloudSectionChanged(); void cloudLibraryFilterChanged(); void cloudCatalogFilterChanged(); + void cloudFallbackRegionChanged(); + void cloudTagFiltersChanged(); + void cloudSortStateChanged(); void cloudFavoritesChanged(); void mouseTouchEnabledChanged(); void keyboardEnabledChanged(); diff --git a/gui/include/settings.h b/gui/include/settings.h index 3bec518f..870533a8 100644 --- a/gui/include/settings.h +++ b/gui/include/settings.h @@ -450,6 +450,18 @@ class Settings : public QObject QString GetCloudCatalogFilter() const; void SetCloudCatalogFilter(QString filter); + /** PS Now region-group fallback. Empty = native mode; "US"/"GB" = fallback mode. */ + QString GetCloudFallbackRegion() const; + void SetCloudFallbackRegion(const QString ®ion); + bool IsCloudFallbackMode() const; + + /** Persisted acquisition-tag filter JSON array; empty/[] = show all. */ + QString GetCloudTagFilters() const; + void SetCloudTagFilters(const QString &filtersJson); + + int GetCloudSortState() const; + void SetCloudSortState(int sortState); + QString GetCloudFavorites() const; void SetCloudFavorites(QString favorites); diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index ad3337a0..5ee4bff0 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -26,6 +26,8 @@ #include #include #include +#include +#include #include Q_DECLARE_LOGGING_CATEGORY(chiakiGui) @@ -234,6 +236,10 @@ void CloudCatalogBackend::fetchPsnowOAuthToken() QString npsso = getNpSsoToken(); if (npsso.isEmpty()) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = "NPSSO token is required for Game Catalog. Please login to PSN and enter a valid NPSSO token."; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -318,6 +324,10 @@ void CloudCatalogBackend::handlePsnowOAuthResponse() if (redirectUrl.isEmpty() || statusCode != 302) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = "OAuth request failed for PSNOW catalog"; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -342,6 +352,10 @@ void CloudCatalogBackend::handlePsnowOAuthResponse() if (code.isEmpty()) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = "No authorization code in OAuth response"; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -400,6 +414,10 @@ void CloudCatalogBackend::handlePsnowSessionResponse() if (reply->error() != QNetworkReply::NoError || statusCode != 200) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = QString("Session creation failed: %1").arg(reply->errorString()); qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -411,6 +429,10 @@ void CloudCatalogBackend::handlePsnowSessionResponse() QJsonDocument doc = QJsonDocument::fromJson(response); if (!doc.isObject()) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = "Invalid JSON in session response"; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -425,6 +447,10 @@ void CloudCatalogBackend::handlePsnowSessionResponse() if (header["status_code"].toString() != "0x0000") { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = QString("Session failed with status: %1").arg(header["status_code"].toString()); qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -449,6 +475,10 @@ void CloudCatalogBackend::handlePsnowSessionResponse() if (psnowState.jsessionId.isEmpty()) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(true); + return; + } QString errorMsg = "No JSESSIONID in session response"; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -523,6 +553,10 @@ void CloudCatalogBackend::handlePsnowStoresResponse() if (reply->error() != QNetworkReply::NoError || statusCode != 200) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(false); + return; + } QString errorMsg = QString("Stores request failed: %1").arg(reply->errorString()); qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -534,6 +568,10 @@ void CloudCatalogBackend::handlePsnowStoresResponse() QJsonDocument doc = QJsonDocument::fromJson(response); if (!doc.isObject()) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(false); + return; + } QString errorMsg = "Invalid JSON in stores response"; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -548,6 +586,10 @@ void CloudCatalogBackend::handlePsnowStoresResponse() if (header["status_code"].toString() != "0x0000") { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(false); + return; + } QString errorMsg = QString("Stores request failed with status: %1").arg(header["status_code"].toString()); qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -559,6 +601,10 @@ void CloudCatalogBackend::handlePsnowStoresResponse() QString baseUrl = data["base_url"].toString(); if (baseUrl.isEmpty()) { psnowState.authInProgress = false; + if (psnowState.unifiedMode) { + unifiedNativeProbeFailed(false); + return; + } QString errorMsg = "No base_url in stores response"; qWarning() << "CloudCatalogBackend:" << errorMsg; if (psnowState.callback.isCallable()) { @@ -861,6 +907,17 @@ void CloudCatalogBackend::processPsnowCatalogComplete() result["total"] = finalGames.size(); QJsonDocument resultDoc(result); + + if (psnowState.unifiedMode) { + psnowState.unifiedMode = false; + psnowState.authInProgress = false; + unifiedState.apolloGames = finalGames; + unifiedState.nativeMode = true; + qInfo() << "[UNIFIED] PS Now APOLLOROOT native:" << finalGames.size() << "games"; + continueUnifiedAfterApollo(); + emit catalogUpdated(); + return; + } // Cache the result setCachedData("psnow_catalog", resultDoc); @@ -874,6 +931,227 @@ void CloudCatalogBackend::processPsnowCatalogComplete() emit catalogUpdated(); } +// --------------------------------------------------------------------------- +// Unified cloud catalog (mirrors Android CloudGameRepository.fetchUnifiedCatalog) +// --------------------------------------------------------------------------- + +void CloudCatalogBackend::fetchUnifiedCatalog(const QJSValue &callback) +{ + // v2: platform_id-disciplined merge (PS5-class cards never claimed by a PS4 cross-buy license). + // Bumped so users upgrading from a pre-platform_id binary rebuild instead of serving a stale, + // possibly mis-merged catalog until TTL. + const QString cached = getCachedData(QStringLiteral("unified_catalog_v2"), CACHE_DURATION_CATALOG); + if (!cached.isEmpty()) { + qInfo() << "[CACHE] Using cached unified catalog"; + if (callback.isCallable()) + callback.call({true, QStringLiteral("Cached"), QJSValue(cached)}); + return; + } + + if (unifiedState.active) { + if (callback.isCallable()) + callback.call({false, QStringLiteral("Unified catalog fetch already in progress"), QJSValue()}); + return; + } + + unifiedState = UnifiedFetchState{}; + unifiedState.active = true; + unifiedState.callback = callback; + + qInfo() << "[UNIFIED] Starting unified catalog fetch (native APOLLOROOT probe)"; + psnowState.callback = QJSValue(); + psnowState.unifiedMode = true; + psnowState.allGames = QJsonArray(); + psnowState.categories = QStringList(); + psnowState.currentCategoryIndex = 0; + psnowState.authInProgress = true; + psnowState.oauthCode.clear(); + psnowState.jsessionId.clear(); + psnowState.baseUrl.clear(); + psnowState.duid.clear(); + fetchPsnowOAuthToken(); +} + +void CloudCatalogBackend::unifiedNativeProbeFailed(bool authError) +{ + if (!unifiedState.active) + return; + + psnowState.authInProgress = false; + psnowState.unifiedMode = false; + unifiedState.authError = authError; + + if (authError) + unifiedState.warning = QStringLiteral( + "Your PSN session may have expired. Owned-game status could not be verified."); + + qInfo() << "[UNIFIED] Native APOLLOROOT probe failed (authError=" << authError + << "), trying region-group fallback"; + startUnifiedApolloFallback(); +} + +void CloudCatalogBackend::startUnifiedApolloFallback() +{ + const QString accountCountry = ps3AccountCountry(); + const QString storeCountry = KamajiConsts::classicsStoreCountry(accountCountry); + const QString containerId = KamajiConsts::apolloRootContainerId(accountCountry); + unifiedState.apolloContainerUrl = QStringLiteral( + "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/en/19/%2") + .arg(storeCountry, containerId); + unifiedState.apolloGames = QJsonArray(); + unifiedState.apolloStart = 0; + unifiedState.apolloTotal = -1; + + qInfo() << "[UNIFIED] Fetching APOLLOROOT fallback (region group" << storeCountry + << "for account" << accountCountry << ")"; + fetchUnifiedApolloPage(); +} + +void CloudCatalogBackend::fetchUnifiedApolloPage() +{ + const QString url = QStringLiteral("%1?useOffers=true&gkb=1&gkb2=1&start=%2&size=100") + .arg(unifiedState.apolloContainerUrl) + .arg(unifiedState.apolloStart); + + QNetworkRequest req{QUrl(url)}; + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); + + QNetworkReply *reply = networkManager->get(req); + connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handleUnifiedApolloPageResponse); +} + +void CloudCatalogBackend::handleUnifiedApolloPageResponse() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply) + return; + reply->deleteLater(); + + const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray data = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError || statusCode != 200) { + if (unifiedState.apolloGames.isEmpty()) { + finishUnifiedFetch(false, QStringLiteral("Failed to fetch APOLLOROOT catalog: HTTP %1").arg(statusCode)); + return; + } + qWarning() << "[UNIFIED] APOLLOROOT partial fetch ended with HTTP" << statusCode; + continueUnifiedAfterApollo(); + return; + } + + const QJsonObject obj = QJsonDocument::fromJson(data).object(); + if (unifiedState.apolloTotal < 0) + unifiedState.apolloTotal = obj.value(QStringLiteral("total_results")).toInt(); + + int productCount = 0; + for (const QJsonValue &v : obj.value(QStringLiteral("links")).toArray()) { + const QJsonObject g = v.toObject(); + if (g.value(QStringLiteral("container_type")).toString() != QLatin1String("product")) + continue; + QJsonObject game = g; + const QString img = extractCoverImageFromGameObject(game); + if (!img.isEmpty()) + game.insert(QStringLiteral("imageUrl"), img); + unifiedState.apolloGames.append(game); + productCount++; + } + + unifiedState.apolloStart += 100; + if (productCount > 0 && unifiedState.apolloStart < unifiedState.apolloTotal) + fetchUnifiedApolloPage(); + else { + qInfo() << "[UNIFIED] APOLLOROOT fallback complete:" << unifiedState.apolloGames.size() << "titles"; + continueUnifiedAfterApollo(); + } +} + +void CloudCatalogBackend::continueUnifiedAfterApollo() +{ + if (!unifiedState.nativeMode && !unifiedState.apolloGames.isEmpty()) + unifiedState.fallbackRegion = KamajiConsts::classicsStoreCountry(ps3AccountCountry()); + + if (settings) + settings->SetCloudFallbackRegion(unifiedState.nativeMode ? QString() : unifiedState.fallbackRegion); + + qInfo() << "[UNIFIED] PS Now APOLLOROOT:" << unifiedState.apolloGames.size() + << "games (nativeMode=" << unifiedState.nativeMode + << "fallbackRegion='" << unifiedState.fallbackRegion << "')"; + + if (unifiedState.apolloGames.isEmpty() && !unifiedState.authError) { + finishUnifiedFetch(false, QStringLiteral("Failed to fetch cloud catalog")); + return; + } + + ps5State.callback = QJSValue(); + fetchPs5CloudCatalog(QJSValue()); +} + +void CloudCatalogBackend::startUnifiedOwnedCrossRef() +{ + crossReferenceState = CrossReferenceState{}; + crossReferenceState.unifiedMode = true; + crossReferenceState.psnowCatalogGames = unifiedState.apolloGames; + crossReferenceState.cloudCatalogGames = unifiedState.imagicBrowse; + crossReferenceState.plusLibrarySupplement = unifiedState.imagicSupplement; + crossReferenceState.productIdAliases = unifiedState.productIdAliases; + crossReferenceState.catalogFetched = true; + crossReferenceState.ownedGamesFetched = false; + + const QString npsso = getNpSsoToken(); + if (npsso.isEmpty() || unifiedState.authError) { + assembleUnifiedCatalog(QJsonArray()); + return; + } + + QString cachedOwned = getCachedData(QStringLiteral("ps5_cloud_library"), CACHE_DURATION_CATALOG); + if (!cachedOwned.isEmpty()) { + const QJsonDocument doc = QJsonDocument::fromJson(cachedOwned.toUtf8()); + if (doc.isObject() && doc.object().value(QStringLiteral("games")).isArray()) { + crossReferenceState.ownedGames = doc.object().value(QStringLiteral("games")).toArray(); + crossReferenceState.ownedGamesFetched = true; + if (doc.object().contains(QStringLiteral("componentIdsByProductId"))) { + const QJsonObject m = doc.object().value(QStringLiteral("componentIdsByProductId")).toObject(); + for (auto it = m.begin(); it != m.end(); ++it) { + QStringList ids; + for (const QJsonValue &v : it.value().toArray()) + ids.append(v.toString()); + crossReferenceState.componentIdsByProductId.insert(it.key(), ids); + } + } + processCrossReferenceComplete(); + return; + } + } + + fetchOwnedPs5Games(QJSValue()); +} + +// NOTE: assembleUnifiedCatalog() is defined further below, AFTER the anonymous-namespace +// helper block, because it uses normalizeApolloGame / isPs5PlatformGame / +// mergeOwnedIntoBrowseCatalog / StreamabilityIndex / applyStreamabilityGate / categoryForGame, +// which have internal linkage and must be defined before use. + +void CloudCatalogBackend::finishUnifiedFetch(bool success, const QString &message, + const QJsonObject &payload) +{ + const QJSValue cb = unifiedState.callback; + unifiedState = UnifiedFetchState{}; + psnowState.unifiedMode = false; + + if (cb.isCallable()) { + if (success) { + const QString jsonStr = QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact)); + cb.call({true, message, QJSValue(jsonStr)}); + } else { + cb.call({false, message, QJSValue()}); + } + } + if (success) + emit catalogUpdated(); +} + // --------------------------------------------------------------------------- // PS3 Classics catalog (public Apollo container walk) // @@ -1113,6 +1391,51 @@ static QString ps5CloudPlatformToken(const QString &productId) return QString(); } +// Platform CLASS (ps5/ps4) from the canonical serviceType axis: pscloud == PS5 (cronos), +// psnow == PS3/PS4 (Kamaji, routed as ps4-class). serviceType is set on PS Now browse rows and is +// filled in for owned entitlements from PSN's structured platform_id, so this never parses CUSA/PPSA +// out of an id (a cross-buy PS4 entitlement can carry a PS5-looking product_id wrapper). Returns +// empty when serviceType is absent (e.g. non-owned imagic browse rows) -- callers fall back to the +// clean catalog product-id token there. +static QString gamePlatformStructured(const QJsonObject &game) +{ + const QString st = game.value(QStringLiteral("serviceType")).toString().toLower(); + if (st == QLatin1String("pscloud")) return QStringLiteral("ps5"); + if (st == QLatin1String("psnow")) return QStringLiteral("ps4"); + return QString(); +} + +// serviceType (pscloud == PS5/cronos, psnow == PS3/PS4/Kamaji) for an owned entitlement, derived +// from PSN's structured platform_id (entitlement_attributes[].platform_id) -- NOT a CUSA/PPSA id +// prefix, since a cross-buy PS4 license can carry a PS5-looking product_id wrapper. Empty if unknown. +static QString ownedEntitlementServiceType(const QJsonObject &ent) +{ + const QJsonArray attrs = ent.value(QStringLiteral("entitlement_attributes")).toArray(); + for (const QJsonValue &a : attrs) { + if (!a.isObject()) + continue; + const QString pid = a.toObject().value(QStringLiteral("platform_id")).toString().toLower(); + if (pid == QLatin1String("ps5")) + return QStringLiteral("pscloud"); + if (pid == QLatin1String("ps4") || pid == QLatin1String("ps3")) + return QStringLiteral("psnow"); + } + return QString(); +} + +// Normalize an owned entitlement's stream-backend tag in place: drop Sony's raw numeric +// `serviceType` (which is unrelated to our routing and collides with our string field), then set +// OUR canonical serviceType ("pscloud"/"psnow") from platform_id. Leaves serviceType absent when +// platform_id is unknown so callers fall back to the clean catalog product-id token. Applied at the +// owned-entitlement ingestion gate (fresh fetch) and when loading owned games from a stale cache. +static void sanitizeOwnedEntitlementServiceType(QJsonObject &ent) +{ + ent.remove(QStringLiteral("serviceType")); + const QString svc = ownedEntitlementServiceType(ent); + if (!svc.isEmpty()) + ent.insert(QStringLiteral("serviceType"), svc); +} + // Catalog dedupe identity: one entry per game PER PLATFORM, so a cross-gen title that Sony lists // as separate PS4 and PS5 editions (e.g. Deliver Us The Moon) shows as two cards, while duplicate // same-platform SKUs still collapse. (conceptId alone collapsed the PS4/PS5 editions into one.) @@ -1121,8 +1444,10 @@ static QString ps5CloudEditionKey(const QJsonObject &gameObj) const QString concept = ps5CloudConceptKey(gameObj); if (concept.isEmpty()) return QString(); - return concept + QLatin1Char('|') - + ps5CloudPlatformToken(gameObj.value(QStringLiteral("productId")).toString()); + QString platform = gamePlatformStructured(gameObj); + if (platform.isEmpty()) + platform = ps5CloudPlatformToken(gameObj.value(QStringLiteral("productId")).toString()); + return concept + QLatin1Char('|') + platform; } // A "full game" entitlement (vs an add-on / avatar / theme). PSN marks the base game with @@ -1268,6 +1593,350 @@ static bool isPlusCatalogList(const QString &categoryList) || categoryList == QLatin1String("plus-monthly-games-list"); } +static const QString kCategoryOwned = QStringLiteral("owned"); +static const QString kCategoryStreamable = QStringLiteral("streamable"); +static const QString kCategoryPurchaseable = QStringLiteral("purchaseable"); + +static QString gameProductId(const QJsonObject &game) +{ + const QString pid = game.value(QStringLiteral("productId")).toString(); + if (!pid.isEmpty()) + return pid; + return game.value(QStringLiteral("product_id")).toString(); +} + +static QString gameEntitlementId(const QJsonObject &game) +{ + const QString id = game.value(QStringLiteral("id")).toString(); + const QString pid = gameProductId(game); + if (!id.isEmpty() && id != pid) + return id; + return QString(); +} + +static QString conceptPlatformKey(const QJsonObject &game) +{ + const QString concept = ps5CloudConceptIdString(game.value(QStringLiteral("conceptId"))); + if (concept.isEmpty()) + return QString(); + QString platform = gamePlatformStructured(game); + if (platform.isEmpty()) { + QString pid = game.value(QStringLiteral("storeProductId")).toString(); + if (pid.isEmpty()) + pid = gameProductId(game); + platform = ps5CloudPlatformToken(pid); + } + return concept + QLatin1Char('|') + platform; +} + +struct CatalogIndexMaps { + QMap byProductId; + QMap byConceptId; +}; + +static void registerInCatalogIndex(const QJsonObject &game, int index, CatalogIndexMaps *idx) +{ + const QString productId = gameProductId(game); + if (!productId.isEmpty()) + idx->byProductId.insert(productId, index); + const QString conceptKey = conceptPlatformKey(game); + if (!conceptKey.isEmpty()) + idx->byConceptId.insert(conceptKey, index); + const QString entId = gameEntitlementId(game); + if (!entId.isEmpty()) + idx->byProductId.insert(entId, index); +} + +// IMPORTANT (this cost real debugging time): PSN *owned entitlements* carry NO conceptId in practice +// -- their game_meta is just { name, package_type, icon_url }. So every conceptId-based step below is +// effectively INERT for owned games: an owned entitlement resolves to a catalog row by EXACT ID ONLY +// (product_id -> entitlement id -> store product id). The conceptId machinery's live job is catalog-row +// edition dedup (edition / conceptPlatformKey), NOT owned->catalog matching. +// +// Also: a PS4 CROSS-BUY license can carry a PS5-looking PPSA *product_id* wrapper while its real PS4 +// component is the CUSA *id* (platform_id stays "ps4"). product_id is matched against the catalog FIRST, +// so such a ps4 license can land on a PS5 (PPSA) row. NEVER infer platform from a product-id prefix for +// owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge +// guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. +static int findCatalogIndexForOwned(const QJsonObject &owned, const CatalogIndexMaps &idx) +{ + const QString productId = gameProductId(owned); + if (!productId.isEmpty() && idx.byProductId.contains(productId)) + return idx.byProductId.value(productId); + const QString entId = gameEntitlementId(owned); + if (!entId.isEmpty() && idx.byProductId.contains(entId)) + return idx.byProductId.value(entId); + const QString storePid = owned.value(QStringLiteral("storeProductId")).toString(); + if (!storePid.isEmpty() && idx.byProductId.contains(storePid)) + return idx.byProductId.value(storePid); + const QString conceptKey = conceptPlatformKey(owned); + if (!conceptKey.isEmpty() && idx.byConceptId.contains(conceptKey)) + return idx.byConceptId.value(conceptKey); + return -1; +} + +static CatalogIndexMaps buildCatalogIndex(const QJsonArray &games) +{ + CatalogIndexMaps idx; + for (int i = 0; i < games.size(); ++i) { + if (games.at(i).isObject()) + registerInCatalogIndex(games.at(i).toObject(), i, &idx); + } + return idx; +} + +static QString streamServiceTypeForGame(const QJsonObject &game) +{ + // The canonical serviceType wins: it is set on PS Now browse rows and filled in for owned cards + // from PSN's platform_id (psnow == PS3/PS4 -> Kamaji, pscloud == PS5 -> cronos). + const QString st = game.value(QStringLiteral("serviceType")).toString().toLower(); + if (st == QLatin1String("psnow") || st == QLatin1String("pscloud")) + return st; + // Non-owned imagic browse rows have no serviceType; their product ids are clean (PPSA = PS5, + // CUSA = PS4), so derive from the catalog id token. + QString p = game.value(QStringLiteral("storeProductId")).toString(); + if (p.isEmpty()) + p = gameProductId(game); + if (p.isEmpty()) + p = gameEntitlementId(game); + if (p.contains(QLatin1String("CUSA"))) + return QStringLiteral("psnow"); + return QStringLiteral("pscloud"); +} + +static QString categoryForGame(const QJsonObject &game) +{ + if (game.value(QStringLiteral("isOwned")).toBool()) + return kCategoryOwned; + if (streamServiceTypeForGame(game) == QLatin1String("psnow")) + return kCategoryStreamable; + return kCategoryPurchaseable; +} + +static bool isPs5PlatformGame(const QJsonObject &game) +{ + QString p = gameProductId(game); + if (p.isEmpty()) + p = gameEntitlementId(game); + if (p.contains(QLatin1String("PPSA"))) + return true; + const QJsonArray devices = game.value(QStringLiteral("device")).toArray(); + for (const QJsonValue &d : devices) { + if (d.toString() == QLatin1String("PS5")) + return true; + } + return false; +} + +static QJsonArray mergeOwnedIntoBrowseCatalog(const QJsonArray &browseCatalog, + const QJsonArray &ownedCrossRef, + bool addUnmatched) +{ + QJsonArray games = browseCatalog; + CatalogIndexMaps catalogIndex = buildCatalogIndex(games); + + for (const QJsonValue &ownedVal : ownedCrossRef) { + if (!ownedVal.isObject()) + continue; + QJsonObject ownedGame = ownedVal.toObject(); + const bool isTrialTier = ownedGame.value(QStringLiteral("feature_type")).toInt() == 1; + const int catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(ownedGame, catalogIndex); + + if (catalogMatch >= 0) { + QJsonObject existing = games.at(catalogMatch).toObject(); + const QString ownedService = ownedGame.value(QStringLiteral("serviceType")).toString().toLower(); + const QString existingService = existing.value(QStringLiteral("serviceType")).toString().toLower(); + const QString ownedProductId = gameProductId(ownedGame); + // Platform CLASS of the matched card. Non-owned imagic browse rows carry NO serviceType, + // so fall back to the clean catalog product-id token (PPSA == ps5). This is what tells us + // the card is a PS5/cronos edition even before any owned claim stamps serviceType=pscloud. + QString existingClass = gamePlatformStructured(existing); + if (existingClass.isEmpty()) + existingClass = ps5CloudPlatformToken(gameProductId(existing)); + + // The card's stream identity must come from the OWNED entitlement of THIS card's platform. + // Cross-buy editions share one product_id (Red Dead's PS4 license id ...CUSA36842... and + // its PS5 license both carry product_id ...PPSA30528...), so matching by product_id alone + // lets a PS4 entitlement land on the PS5 card. Rule: a PS5 (pscloud) claim is authoritative + // and always stamps the PS5 entitlement's OWN id; a PS4/PS3 (psnow) entitlement must NEVER + // overwrite a card already claimed by PS5. A CUSA id is therefore never sent to cronos. + if (ownedService == QLatin1String("pscloud")) { + existing.insert(QStringLiteral("isOwned"), true); + // PS5/cronos streams the PS5 entitlement's own id (resolved from platform_id), + // ALWAYS -- whether canonical (id == product_id == ...PPSA..., e.g. Red Dead, Alan + // Wake) or a classic whose product_id is a non-streamable wrapper (id ...PPSA..SLUS, + // e.g. Blood Omen). Never the browse row's representative id (often a PS4/CUSA cross-buy). + const QString ownedId = ownedGame.value(QStringLiteral("id")).toString(); + if (!ownedId.isEmpty()) + existing.insert(QStringLiteral("id"), ownedId); + if (!ownedProductId.isEmpty()) { + existing.insert(QStringLiteral("product_id"), ownedProductId); + existing.insert(QStringLiteral("productId"), ownedProductId); + } + existing.insert(QStringLiteral("serviceType"), QStringLiteral("pscloud")); + games[catalogMatch] = existing; + continue; + } + if (ownedService == QLatin1String("psnow") + && existingService != QLatin1String("pscloud") + && existingClass != QLatin1String("ps5")) { + existing.insert(QStringLiteral("isOwned"), true); + // psnow (PS3/PS4 -> Kamaji) streams the catalog product variant (streamProductId), so + // the id is informational; keep stamping a distinct entitlement id when present. + const QString streamId = gameEntitlementId(ownedGame); + if (!streamId.isEmpty()) + existing.insert(QStringLiteral("id"), streamId); + existing.insert(QStringLiteral("serviceType"), QStringLiteral("psnow")); + games[catalogMatch] = existing; + continue; + } + // psnow entitlement whose matched card is PS5-class (serviceType=pscloud, OR an unstamped + // imagic browse row whose product-id token is PPSA): this PS4 cross-buy license is not this + // card's edition. Leave the PS5 card untouched and fall through to add it as its own (PS4) + // card / register it for a later same-platform match. (Without the platform-class check, an + // empty serviceType on an imagic PS5 row would let a CUSA id be stamped onto a cronos card.) + } + + if (!addUnmatched) + continue; + + QJsonObject entry = ownedGame; + entry.insert(QStringLiteral("isOwned"), true); + if (!entry.contains(QStringLiteral("productId")) && entry.contains(QStringLiteral("product_id"))) + entry.insert(QStringLiteral("productId"), entry.value(QStringLiteral("product_id")).toString()); + // serviceType (pscloud == PS5/cronos, psnow == PS4/Kamaji) is already set on the owned + // entitlement from its platform_id; nothing extra to stamp here (no id-prefix parsing). + registerInCatalogIndex(entry, games.size(), &catalogIndex); + games.append(entry); + } + + // Owned first, then name (mirrors Android mergeOwnedIntoBrowseCatalog sort). + QList sorted; + for (const QJsonValue &v : games) + sorted.append(v); + std::sort(sorted.begin(), sorted.end(), [](const QJsonValue &a, const QJsonValue &b) { + const QJsonObject ao = a.toObject(); + const QJsonObject bo = b.toObject(); + const bool aOwned = ao.value(QStringLiteral("isOwned")).toBool(); + const bool bOwned = bo.value(QStringLiteral("isOwned")).toBool(); + if (aOwned != bOwned) + return aOwned > bOwned; + QString nameA = ao.value(QStringLiteral("name")).toString(); + if (nameA.isEmpty()) + nameA = ao.value(QStringLiteral("game_meta")).toObject().value(QStringLiteral("name")).toString(); + QString nameB = bo.value(QStringLiteral("name")).toString(); + if (nameB.isEmpty()) + nameB = bo.value(QStringLiteral("game_meta")).toObject().value(QStringLiteral("name")).toString(); + return nameA.compare(nameB, Qt::CaseInsensitive) < 0; + }); + QJsonArray out; + for (const QJsonValue &v : sorted) + out.append(v); + return out; +} + +class StreamabilityIndex { +public: + StreamabilityIndex(const QJsonArray &apolloCatalog, + const QJsonArray &imagicBrowse, + const QJsonArray &imagicConceptRows) + { + auto addProduct = [this](const QString &productId) { + if (productId.isEmpty()) + return; + productKeys.insert(productId); + const QString stable = ps5CloudProductIdStableKey(productId); + if (!stable.isEmpty()) + productKeys.insert(stable); + }; + for (const QJsonValue &v : apolloCatalog) { + if (v.isObject()) + addProduct(gameProductId(v.toObject())); + } + for (const QJsonValue &v : imagicBrowse) { + if (!v.isObject()) + continue; + const QJsonObject g = v.toObject(); + addProduct(gameProductId(g)); + const QString concept = ps5CloudConceptIdString(g.value(QStringLiteral("conceptId"))); + if (!concept.isEmpty()) + streamableConceptIds.insert(concept); + } + for (const QJsonValue &v : imagicConceptRows) { + if (!v.isObject()) + continue; + const QJsonObject row = v.toObject(); + const QString concept = ps5CloudConceptIdString(row.value(QStringLiteral("conceptId"))); + if (concept.isEmpty()) + continue; + const QStringList keys = { + gameProductId(row), + ps5CloudProductIdStableKey(gameProductId(row)) + }; + for (const QString &k : keys) { + if (!k.isEmpty() && productKeys.contains(k)) { + streamableConceptIds.insert(concept); + break; + } + } + } + } + + bool isStreamable(const QJsonObject &game) const + { + const QStringList ids = { + gameProductId(game), + game.value(QStringLiteral("storeProductId")).toString(), + gameEntitlementId(game) + }; + for (const QString &p : ids) { + if (p.isEmpty()) + continue; + if (productKeys.contains(p)) + return true; + const QString stable = ps5CloudProductIdStableKey(p); + if (!stable.isEmpty() && productKeys.contains(stable)) + return true; + } + const QString concept = ps5CloudConceptIdString(game.value(QStringLiteral("conceptId"))); + return !concept.isEmpty() && streamableConceptIds.contains(concept); + } + +private: + QSet productKeys; + QSet streamableConceptIds; +}; + +static QJsonArray applyStreamabilityGate(const QJsonArray &games, const StreamabilityIndex &index) +{ + QJsonArray kept; + int dropped = 0; + for (const QJsonValue &v : games) { + if (!v.isObject()) + continue; + const QJsonObject game = v.toObject(); + if (!game.value(QStringLiteral("isOwned")).toBool() || index.isStreamable(game)) + kept.append(game); + else + dropped++; + } + if (dropped > 0) + qInfo() << "[UNIFIED] streamability gate: dropped" << dropped << "owned non-streamable titles"; + return kept; +} + +static QJsonObject normalizeApolloGame(const QJsonObject &raw) +{ + QJsonObject g = raw; + if (!g.contains(QStringLiteral("productId"))) { + const QString id = g.value(QStringLiteral("id")).toString(); + if (!id.isEmpty()) + g.insert(QStringLiteral("productId"), id); + } + g.insert(QStringLiteral("serviceType"), QStringLiteral("psnow")); + return g; +} + static void mergeImagicListIntoPs5Catalog(const QString &categoryList, const QJsonDocument &doc, QMap &gamesByConceptId, @@ -1343,6 +2012,61 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, } // namespace +void CloudCatalogBackend::assembleUnifiedCatalog(const QJsonArray &ownedCrossRef) +{ + QJsonArray apolloNormalized; + for (const QJsonValue &v : unifiedState.apolloGames) { + if (v.isObject()) + apolloNormalized.append(normalizeApolloGame(v.toObject())); + } + + QJsonArray ps5Browse; + for (const QJsonValue &v : unifiedState.imagicBrowse) { + if (v.isObject() && isPs5PlatformGame(v.toObject())) + ps5Browse.append(v); + } + + QJsonArray universe = apolloNormalized; + for (const QJsonValue &v : ps5Browse) + universe.append(v); + + QJsonArray games = mergeOwnedIntoBrowseCatalog(universe, ownedCrossRef, true); + + if (unifiedState.nativeMode) { + QJsonArray conceptRows = unifiedState.imagicBrowse; + for (const QJsonValue &v : unifiedState.imagicSupplement) + conceptRows.append(v); + const StreamabilityIndex index(apolloNormalized, unifiedState.imagicBrowse, conceptRows); + games = applyStreamabilityGate(games, index); + } + + QJsonArray tagged; + for (const QJsonValue &v : games) { + if (!v.isObject()) + continue; + QJsonObject g = v.toObject(); + g.insert(QStringLiteral("category"), categoryForGame(g)); + tagged.append(g); + } + + QJsonObject payload; + payload.insert(QStringLiteral("games"), tagged); + payload.insert(QStringLiteral("total"), tagged.size()); + payload.insert(QStringLiteral("nativeMode"), unifiedState.nativeMode); + payload.insert(QStringLiteral("fallbackRegion"), unifiedState.fallbackRegion); + if (!unifiedState.warning.isEmpty()) + payload.insert(QStringLiteral("warning"), unifiedState.warning); + + if (!tagged.isEmpty() && !unifiedState.authError) + setCachedData(QStringLiteral("unified_catalog_v2"), QJsonDocument(payload)); + + QString message = QStringLiteral("Success"); + if (!unifiedState.warning.isEmpty()) + message = unifiedState.warning; + + finishUnifiedFetch(true, message, payload); +} + void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) { // Check cache first @@ -1350,6 +2074,16 @@ void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) if (!cached.isEmpty()) { qInfo() << "[CACHE] Using cached PS5 cloud catalog"; QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); + if (unifiedState.active && doc.isObject()) { + const QJsonObject obj = doc.object(); + unifiedState.imagicBrowse = obj.value(QStringLiteral("games")).toArray(); + unifiedState.imagicSupplement = obj.value(QStringLiteral("plusLibrarySupplement")).toArray(); + if (obj.contains(QStringLiteral("productIdAliases"))) + unifiedState.productIdAliases = + productIdAliasesFromJson(obj.value(QStringLiteral("productIdAliases")).toObject()); + startUnifiedOwnedCrossRef(); + return; + } if (callback.isCallable()) { callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); } @@ -1460,7 +2194,17 @@ void CloudCatalogBackend::handlePs5ImagicListResponse() startPs5ImagicListFetch(); return; } - if (ps5State.callback.isCallable()) { + if (unifiedState.active) { + if (unifiedState.apolloGames.isEmpty()) { + finishUnifiedFetch(false, QStringLiteral("Failed to fetch catalog")); + } else { + qWarning() << "[UNIFIED] imagic PS5 catalog fetch failed; continuing with PS Now only"; + unifiedState.imagicBrowse = QJsonArray(); + unifiedState.imagicSupplement = QJsonArray(); + unifiedState.productIdAliases.clear(); + startUnifiedOwnedCrossRef(); + } + } else if (ps5State.callback.isCallable()) { ps5State.callback.call({false, QStringLiteral("All imagic lists failed to load"), QJSValue()}); @@ -1534,6 +2278,15 @@ void CloudCatalogBackend::finalizePs5CloudCatalogFetch() qWarning() << "[API]" << callbackMessage; } + if (unifiedState.active) { + unifiedState.imagicBrowse = allGames; + unifiedState.imagicSupplement = plusSupplementGames; + unifiedState.productIdAliases = ps5State.productIdAliases; + startUnifiedOwnedCrossRef(); + emit catalogUpdated(); + return; + } + if (crossReferenceState.callback.isCallable() && !crossReferenceState.catalogFetched) { crossReferenceState.cloudCatalogGames = allGames; crossReferenceState.plusLibrarySupplement = plusSupplementGames; @@ -1918,7 +2671,8 @@ void CloudCatalogBackend::handleOwnedGamesResponse() setCachedData("ps5_cloud_library", resultDoc); // If cross-reference is active, populate its state - if (crossReferenceState.callback.isCallable() && !crossReferenceState.ownedGamesFetched) { + if ((crossReferenceState.callback.isCallable() || crossReferenceState.unifiedMode) + && !crossReferenceState.ownedGamesFetched) { crossReferenceState.ownedGames = ps5Games; crossReferenceState.componentIdsByProductId = componentIds; crossReferenceState.ownedGamesFetched = true; @@ -2017,6 +2771,17 @@ QJsonArray CloudCatalogBackend::filterOwnedPs5Games(const QJsonArray &entitlemen entObj["imageUrl"] = coverImageUrl; } + // SINGLE INGESTION GATE for an owned entitlement's stream backend. Sony's raw entitlement + // JSON carries its OWN numeric `serviceType` (e.g. 0) that is unrelated to our routing and + // collides with our string field name, so strip it here first. Then set OUR canonical + // serviceType from PSN's structured platform_id (entitlement_attributes[].platform_id): + // ps5 -> pscloud (cronos), ps4/ps3 -> psnow (Kamaji). NOT from a CUSA/PPSA id prefix: a + // cross-buy PS4 license can carry a PS5-looking product_id wrapper (Blood Omen's PS4 license + // UP8489-CUSA49771_00-... has product_id ...PPSA24270...), which the prefix would mis-route. + // If platform_id is absent we leave serviceType unset and let downstream fall back to the + // clean catalog product-id token (non-owned imagic rows only). + sanitizeOwnedEntitlementServiceType(entObj); + ps5Games.append(entObj); } @@ -2601,6 +3366,16 @@ void CloudCatalogBackend::processCrossReferenceComplete() QMap cloudCatalogMap; QMap plusSupplementMap; + // PS Now APOLLOROOT rows are prepended so owned PS3/PS4 entitlements resolve (Android psnowCatalog). + for (const QJsonValue &game : crossReferenceState.psnowCatalogGames) { + if (!game.isObject()) + continue; + QJsonObject gameObj = normalizeApolloGame(game.toObject()); + const QString productId = gameProductId(gameObj); + if (!productId.isEmpty() && !cloudCatalogMap.contains(productId)) + cloudCatalogMap.insert(productId, gameObj); + } + for (const QJsonValue &game : crossReferenceState.cloudCatalogGames) { if (game.isObject()) { QJsonObject gameObj = game.toObject(); @@ -2628,12 +3403,14 @@ void CloudCatalogBackend::processCrossReferenceComplete() } } - const QMap browseStableKey = - buildStableKeyIndex(crossReferenceState.cloudCatalogGames); + QJsonArray combinedBrowse = crossReferenceState.psnowCatalogGames; + for (const QJsonValue &v : crossReferenceState.cloudCatalogGames) + combinedBrowse.append(v); + + const QMap browseStableKey = buildStableKeyIndex(combinedBrowse); const QMap supplementStableKey = buildStableKeyIndex(crossReferenceState.plusLibrarySupplement); - const QMap browseByConcept = - buildConceptIdIndex(crossReferenceState.cloudCatalogGames); + const QMap browseByConcept = buildConceptIdIndex(combinedBrowse); const QMap supplementByConcept = buildConceptIdIndex(crossReferenceState.plusLibrarySupplement); @@ -2662,6 +3439,12 @@ void CloudCatalogBackend::processCrossReferenceComplete() continue; QJsonObject ownedGameObj = ownedGame.toObject(); + // Defensive re-normalization before assembly: owned games reach here either from the fresh + // ingestion gate (already sanitized) OR from a possibly-stale ps5_cloud_library cache written + // by an older binary (which may still carry Sony's numeric serviceType). The same single + // helper guarantees serviceType is OUR routing value (from platform_id) so the per-platform + // dedupe below never falls back to id-prefix parsing. + sanitizeOwnedEntitlementServiceType(ownedGameObj); const QString productId = ownedGameObj.value(QStringLiteral("product_id")).toString(); const QString entitlementId = ownedGameObj.value(QStringLiteral("id")).toString(); const QString entName = ownedGameObj.value(QStringLiteral("game_meta")).toObject() @@ -2707,7 +3490,13 @@ void CloudCatalogBackend::processCrossReferenceComplete() entry.insert(QStringLiteral("conceptId"), conceptId); // Dedupe identity = conceptId + PLATFORM (the catalog edition key): a cross-gen title // owned on PS4 and PS5 stays as two cards; same-platform SKUs (bonus/avatars) merge. - const QString platformToken = ps5CloudPlatformToken(productId); + // Platform comes from the entitlement's structured platform_id (stamped as `platform`), + // NOT the product_id prefix -- a cross-buy PS4 license can carry a PS5-looking product_id + // wrapper, which the prefix would mis-bucket onto the PS5 edition (and could then stamp + // the PS5 card with the PS4/CUSA streaming id). + QString platformToken = gamePlatformStructured(entry); + if (platformToken.isEmpty()) + platformToken = ps5CloudPlatformToken(productId); const QString dedupeKey = !conceptId.isEmpty() ? QStringLiteral("c:") + conceptId + QLatin1Char(':') + platformToken : !productId.isEmpty() ? QStringLiteral("p:") + productId : !entitlementId.isEmpty() ? QStringLiteral("e:") + entitlementId @@ -2907,7 +3696,10 @@ void CloudCatalogBackend::processCrossReferenceComplete() QJsonDocument resultDoc(result); - if (crossReferenceState.callback.isCallable()) { + const bool unifiedMode = crossReferenceState.unifiedMode; + if (unifiedMode) { + assembleUnifiedCatalog(filteredGames); + } else if (crossReferenceState.callback.isCallable()) { QString jsonStr = QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)); crossReferenceState.callback.call({true, "Success", QJSValue(jsonStr)}); } @@ -2915,11 +3707,13 @@ void CloudCatalogBackend::processCrossReferenceComplete() crossReferenceState.callback = QJSValue(); crossReferenceState.cloudCatalogGames = QJsonArray(); crossReferenceState.plusLibrarySupplement = QJsonArray(); + crossReferenceState.psnowCatalogGames = QJsonArray(); crossReferenceState.ownedGames = QJsonArray(); crossReferenceState.productIdAliases.clear(); crossReferenceState.componentIdsByProductId.clear(); crossReferenceState.catalogFetched = false; crossReferenceState.ownedGamesFetched = false; + crossReferenceState.unifiedMode = false; } void CloudCatalogBackend::invalidatePs5CatalogCache() @@ -2942,6 +3736,9 @@ void CloudCatalogBackend::invalidateCache() QString ps5CatalogPath = getCacheFilePath("ps5_cloud_catalog_v6"); QString ps5CatalogV2Path = getCacheFilePath("ps5_cloud_catalog_v2"); QString ps5LibraryPath = getCacheFilePath("ps5_cloud_library"); + QString unifiedPath = getCacheFilePath("unified_catalog_v2"); + // Also clear the superseded v1 unified cache from older binaries. + QFile::remove(getCacheFilePath("unified_catalog_v1")); bool invalidated = false; if (QFile::exists(psnowPath)) { @@ -2973,6 +3770,12 @@ void CloudCatalogBackend::invalidateCache() qInfo() << "[CACHE INVALIDATED] Removed PS5 cloud library cache"; invalidated = true; } + + if (QFile::exists(unifiedPath)) { + QFile::remove(unifiedPath); + qInfo() << "[CACHE INVALIDATED] Removed unified catalog cache"; + invalidated = true; + } if (!invalidated) { qInfo() << "[CACHE INVALIDATED] No cache files found to invalidate"; diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index 6435d56b..b3960d22 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -326,19 +326,15 @@ void PSKamajiSession::step0_5d_ConvertProductId() QString country = localeParts.size() > 1 ? localeParts[1].toUpper() : "US"; QString language = localeParts[0].toLower(); - // PS3 / Classics product ids (NPEA/NPEB/BLES/BCES or NPUA/NPUB/BLUS/BCUS -- anything - // that isn't a modern CUSA/PPSA id) come from the public Apollo catalog, which we walk - // in the account's region group (Americas -> US store, everything else -> PAL/GB). - // Resolve them against that SAME region's container so the lookup finds the product and - // returns the PSNW entitlement the account is authorized for at Gaikai. The account's - // own locale country can be a region with no pcnow storefront (e.g. Hungary -> "Storefront - // not found"), and the wrong region's ids return 401 "invalidEntitlement", so map to the - // region-group store. Must match CloudCatalogBackend's PS3 catalog source. - const bool isLegacyClassicsId = !productId.contains(QLatin1String("CUSA")) - && !productId.contains(QLatin1String("PPSA")); - if (isLegacyClassicsId) { - country = KamajiConsts::classicsStoreCountry(country); + // Region-group fallback: when /user/stores has no storefront for the account's region, the + // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container + // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same + // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. + if (settings && settings->IsCloudFallbackMode()) { + country = settings->GetCloudFallbackRegion(); language = QStringLiteral("en"); + qInfo() << "Kamaji Step 0.5d: Fallback mode -> region-group container: country=" + << country << "language=" << language; } QString url = QString("https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/%2/19/%3?useOffers=true&gkb=1&gkb2=1") @@ -919,23 +915,14 @@ void PSKamajiSession::handleCheckEntitlementResponse(QNetworkReply *reply) step5_GetAuthCode(); return; } else if (statusCode == 404) { - // User doesn't have the per-game entitlement on the account. - const bool isLegacyClassicsId = !productId.contains(QLatin1String("CUSA")) - && !productId.contains(QLatin1String("PPSA")); - if (isLegacyClassicsId) { - // PS3 / Classics: the streaming entitlement is granted by the PS Plus - // subscription (a free 100%-off checkout), but that checkout requires a - // pcnow storefront in the account's region -- which many regions (e.g. - // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". - // On a real PS5 the subscription alone grants streaming with no purchase, so - // skip the acquire and let Gaikai validate the Premium subscription directly. - // If Gaikai genuinely needs the entitlement on the account, it returns - // noGameForEntitlementId downstream and we learn the wall is at Gaikai. - qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai"; + // Region-group fallback: unsupported regions have no pcnow storefront, so the + // free checkout-acquire fails. Skip it and let Gaikai validate the subscription. + // Native (supported region): run the normal checkout-acquire for PS3 + PS4 + PS5 alike. + if (settings && settings->IsCloudFallbackMode()) { + qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai"; step5_GetAuthCode(); return; } - // PS4/PS5 catalog: try to acquire it via checkout. qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire"; step0_5e_CheckoutPreview(); return; diff --git a/gui/src/qml/CloudGameCard.qml b/gui/src/qml/CloudGameCard.qml index a65a3b98..f0919aae 100644 --- a/gui/src/qml/CloudGameCard.qml +++ b/gui/src/qml/CloudGameCard.qml @@ -12,7 +12,7 @@ Rectangle { property bool isHovered: false property bool isCurrentItem: GridView.isCurrentItem || false property bool hasFocus: isCurrentItem && GridView.view.activeFocus - property bool isPsnow: true // true for PSNOW, false for PS5 Cloud + property bool isPsnow: isPsnowGame() property string cachedImageUrl: "" property string libraryFilter: "owned" // "owned" or "all" or "favorites" - filter mode for Game Library property var qrCodeDialog: null // Reference to QR code dialog @@ -22,7 +22,7 @@ Rectangle { // (e.g. Far Cry 5's streaming SKU is paid, so the acquire 500s). So ANY non-owned catalog game // shows "Add Game" (QR to the store / Add-to-Library); owned games stream directly. Legacy // PS Now browse cards (isPsnow) keep one-click Stream — free streaming is the PS Now model. - readonly property bool needsAddToLibrary: !isPsnow && gameData && !gameData.isOwned + readonly property bool needsAddToLibrary: gameData && gameData.category === "purchaseable" property bool isFavorite: false // Whether this game is favorited // Steam library shortcut: only when Steamworks build + Steam client (same gate as createCloudSteamShortcut usefulness) @@ -45,6 +45,21 @@ Rectangle { return `image://svg/button-${type}#${buttonName}`; } + function isPsnowGame() { + if (!gameData) return false; + if (gameData.serviceType === "psnow" || gameData.category === "streamable") + return true; + let p = String(streamProductId()); + if (p.indexOf("CUSA") !== -1) return true; + let pp = gameData.playable_platform; + if (pp) { + let arr = Array.isArray(pp) ? pp : (typeof pp === "string" ? [pp] : []); + for (let i = 0; i < arr.length; i++) + if (String(arr[i]).indexOf("PS3") !== -1) return true; + } + return false; + } + // Extract game information function getGameName() { if (!gameData) return qsTr("Unknown Game"); @@ -92,15 +107,16 @@ Rectangle { let p = streamProductId(); return p !== "" ? p : getProductId(); } - // PS5: stream the owned PRODUCT id via the direct Gaikai path -- NOT the entitlement `id`. - // For cross-gen titles you upgraded (PS4 purchase + free PS5 copy), Sony's entitlement id - // is the stale ORIGINAL SKU (e.g. Alan Wake Remastered's old CUSA24653 license; Death - // Stranding's pre-Director's-Cut PPSA02624 SKU). Gaikai's cloud catalog has no game mapped - // to that stale id -> noGameForEntitlementId. The owned product_id is the current streamable - // PS5 SKU (Alan Wake -> PPSA01925; Death Stranding DC -> PPSA01968), which Gaikai accepts. + // PS5 (cronos): stream the owned PS5 entitlement `id`, which the backend resolved from the + // entitlement's structured platform_id (the catalog merge stamps the platform-matching PS5 + // license here). For a classic like Blood Omen that id is ...PPSA24270_00-SLUS000270000000 + // (NOT the ...-0499... store wrapper product_id, which Gaikai has no game for -> + // noGameForEntitlementId). For a cross-gen upgrade (Alan Wake, Death Stranding) the PS5 + // entitlement id equals its product_id (PPSA01925 / PPSA01968), so it is absent here and we + // fall through to the product id. No id-prefix guessing, no force-to-PS5. + if (gameData.id) return gameData.id; if (gameData.product_id) return gameData.product_id; if (gameData.productId) return gameData.productId; - if (gameData.id) return gameData.id; return ""; } @@ -146,11 +162,14 @@ Rectangle { return gameData.catalogProductId || gameData.product_id || gameData.productId || gameData.id || ""; } - // Platform to stream, from the chosen product's title id: CUSAxxxxx = PS4, PPSAxxxxx = PS5. - // This drives the streaming path (PS4 = kratos, PS5 = cronos); both go through the Kamaji - // acquire-flow. More reliable than the catalog "device" list (cross-gen titles list both) - // or whichever entitlement the user owns. Defaults to PS5 (the modern catalog). + // Platform that drives the streaming path (PS4 = kratos/Kamaji, PS5 = cronos). The canonical + // signal is serviceType (pscloud == PS5, psnow == PS3/PS4 -> ps4-class), which the backend sets + // on PS Now browse rows and fills in for owned cards from PSN's platform_id -- so a cross-buy PS4 + // entitlement carrying a PS5-looking product_id wrapper is NOT mis-classified. Falls back to the + // clean catalog id token only when serviceType is absent. Defaults to PS5 (the modern catalog). function streamPlatform() { + if (gameData && gameData.serviceType === "pscloud") return "ps5"; + if (gameData && gameData.serviceType === "psnow") return "ps4"; let p = String(streamProductId()); if (p.indexOf("PPSA") !== -1) return "ps5"; if (p.indexOf("CUSA") !== -1) return "ps4"; @@ -158,6 +177,8 @@ Rectangle { } function getServiceType() { + if (gameData && (gameData.serviceType === "psnow" || gameData.serviceType === "pscloud")) + return gameData.serviceType; // canonical value (set on browse rows / from platform_id) if (isPsnow) return "psnow"; // legacy PS Now browse catalog // serviceType selects the Gaikai spec/consts/virtType: psnow = PS4/kratos, pscloud = PS5/cronos. return (streamPlatform() === "ps4") ? "psnow" : "pscloud"; @@ -333,22 +354,32 @@ Rectangle { } } - // Owned/Not Owned badge - Top Right + // Category badge - Top Right (owned / streamable / purchaseable) Rectangle { anchors.top: parent.top anchors.right: parent.right anchors.topMargin: 8 anchors.rightMargin: 8 - width: ownedLabel.implicitWidth + 12 + width: categoryLabel.implicitWidth + 12 height: 22 radius: 4 - color: gameData && gameData.isOwned ? "#4CAF50" : "#FF9800" - visible: !isPsnow && (libraryFilter === "all" || libraryFilter === "catalog") + visible: gameData && gameData.category + color: { + if (!gameData) return "#FF9800"; + if (gameData.category === "owned") return "#4CAF50"; + if (gameData.category === "streamable") return "#2196F3"; + return "#FF9800"; + } Label { - id: ownedLabel + id: categoryLabel anchors.centerIn: parent - text: gameData && gameData.isOwned ? qsTr("OWNED") : qsTr("NOT OWNED") + text: { + if (!gameData || !gameData.category) return ""; + if (gameData.category === "owned") return qsTr("OWNED"); + if (gameData.category === "streamable") return qsTr("STREAMABLE"); + return qsTr("ADD GAME"); + } font.pixelSize: 10 font.weight: Font.Bold color: "white" diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index d7539af5..b91715e5 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -17,7 +17,7 @@ Pane { property var showConfirmDialogFunc: null // Expose child components for navigation - readonly property Item catalogButtonItem: catalogButton + readonly property Item catalogButtonItem: searchContainer readonly property Item searchContainerItem: searchContainer readonly property Item refreshButtonItem: refreshButton @@ -26,18 +26,18 @@ Pane { property var allGames: [] property var filteredGames: [] property var currentPageGames: [] - property string currentSection: "catalog" // "catalog" or "library" property bool isLoading: false property string searchQuery: "" - property string authErrorMessage: "" // Persistent auth error message - property string libraryFilter: "all" // "all", "owned", or "favorites" - filter for Game Library - property string catalogFilter: "all" // "all" or "favorites" - filter for Game Catalog - // When the legacy PS Now (Kamaji) browse store is unavailable for the region, - // the Game Catalog falls back to the modern imagic cloud catalog (pscloud). - property bool catalogImagicFallback: false - property var ownedProductIds: [] // Set of product IDs that are owned (for filtering) - property var favoriteProductIds: [] // Set of product IDs that are favorited - property var qrCodeDialogRef: null // Reference to QR code dialog for child components + property string authErrorMessage: "" + property string fallbackRegion: "" + property var activeTagFilters: [] // empty = show all; values: owned, streamable, purchaseable + property bool showFavoritesOnly: false + property int sortState: 0 // 0=Playable First, 1=A-Z, 2=Z-A + property var favoriteProductIds: [] + property var qrCodeDialogRef: null + + readonly property var tagFilterCategories: ["owned", "streamable", "purchaseable"] + readonly property var tagFilterLabels: [qsTr("Owned"), qsTr("Streamable"), qsTr("Store")] // Clean blue background CleanBlueBackground { @@ -57,21 +57,18 @@ Pane { } Component.onCompleted: { - // Load saved cloud section on startup - let savedSection = Chiaki.settings.lastSelectedCloudSection; - if (savedSection === "library" || savedSection === "catalog") { - currentSection = savedSection; - } - // Load saved filters - let savedLibraryFilter = Chiaki.settings.cloudLibraryFilter; - if (savedLibraryFilter === "owned" || savedLibraryFilter === "all" || savedLibraryFilter === "favorites") { - libraryFilter = savedLibraryFilter; - } - let savedCatalogFilter = Chiaki.settings.cloudCatalogFilter; - if (savedCatalogFilter === "all" || savedCatalogFilter === "favorites") { - catalogFilter = savedCatalogFilter; + fallbackRegion = Chiaki.settings.cloudFallbackRegion || ""; + sortState = Chiaki.settings.cloudSortState || 0; + let savedTagFilters = Chiaki.settings.cloudTagFilters; + if (savedTagFilters) { + try { + let parsed = JSON.parse(savedTagFilters); + if (Array.isArray(parsed)) + activeTagFilters = parsed; + } catch (e) { + console.warn("Failed to parse cloud tag filters:", e); + } } - // Load saved favorites let savedFavorites = Chiaki.settings.cloudFavorites; if (savedFavorites) { try { @@ -81,37 +78,39 @@ Pane { favoriteProductIds = []; } } - // Load games when component is first created - Qt.callLater(() => { - if (currentSection === "catalog") { - loadPsnowCatalog(); - } else { - loadPs5CloudLibrary(); - } - }); + Qt.callLater(() => loadUnifiedCatalog()); + initialFocusTimer.restart(); } - // Watch for visibility changes to reload if needed onVisibleChanged: { - if (visible && allGames.length === 0) { - // Only load if we don't have games yet - if (currentSection === "catalog") { - loadPsnowCatalog(); - } else { - loadPs5CloudLibrary(); - } + if (visible) { + if (allGames.length === 0) + loadUnifiedCatalog(); + initialFocusTimer.restart(); } } StackView.onActivated: { - // Also load when StackView activates this view - Qt.callLater(() => { - if (currentSection === "catalog") { - loadPsnowCatalog(); + Qt.callLater(() => loadUnifiedCatalog()); + initialFocusTimer.restart(); + } + + // Pins default focus to the first game card (or the filter toggle if games + // haven't loaded yet) after startup focus churn settles, so the search field + // never holds focus by default. Runs late enough to override the window's + // initial active-focus assignment. + Timer { + id: initialFocusTimer + interval: 150 + repeat: false + onTriggered: { + if (gamesGrid.count > 0) { + gamesGrid.currentIndex = 0; + gamesGrid.forceActiveFocus(); } else { - loadPs5CloudLibrary(); + filterToggle.forceActiveFocus(); } - }); + } } // Handle Escape/B button for quit confirmation dialog @@ -135,582 +134,163 @@ Pane { return; } - switch (event.key) { - case Qt.Key_PageUp: - // L1 button - switch to Game Catalog - if (currentSection !== "catalog") { - switchSection("catalog"); - event.accepted = true; - } - break; - case Qt.Key_PageDown: - // R1 button - switch to Game Library - if (currentSection !== "library") { - switchSection("library"); - event.accepted = true; - } - break; - } - } - - function loadPsnowCatalog() { - // Check NPSSO token - show warning if missing (but still load games) - let npssoToken = Chiaki.settings.psnNpssoToken; - if (!npssoToken || npssoToken.trim().length === 0) { - authErrorMessage = "NPSSO token is required for Game Catalog and Game Library. Please login and enter a valid NPSSO token. You also need a valid PS Plus subscription."; - } else { - authErrorMessage = ""; // Clear auth error if token exists - } - - // Clear old cards immediately when starting to load - allGames = []; - filteredGames = []; - currentPageGames = []; - isLoading = true; - catalogImagicFallback = false; // attempt the legacy PS Now browse store first - Chiaki.cloudCatalog.fetchPsnowCatalog(function(success, message, jsonData) { - isLoading = false; - if (success && jsonData) { - try { - let data = JSON.parse(jsonData); - if (data.games && Array.isArray(data.games)) { - allGames = data.games; - // Don't clear auth error on success - keep it if token is still missing - if (npssoToken && npssoToken.trim().length > 0) { - authErrorMessage = ""; - } - applySearchFilter(); - appendPs3Catalog(); - // Set focus after games are loaded - Qt.callLater(() => { - if (gamesGrid.count > 0) { - gamesGrid.currentIndex = 0; - gamesGrid.forceActiveFocus(); - } - }); - } else { - allGames = []; - filteredGames = []; - currentPageGames = []; - showErrorToast(qsTr("Error"), qsTr("No games found in catalog")); - } - } catch (e) { - console.error("Failed to parse PSNOW catalog:", e); - allGames = []; - filteredGames = []; - currentPageGames = []; - showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); - } - } else { - // The legacy PS Now (Kamaji) browse store is region-locked / deprecated - // and 404s in many regions (e.g. Hungary). Fall back to the modern imagic - // cloud catalog so the Game Catalog shows streamable titles everywhere. - console.warn("PSNOW catalog unavailable, falling back to imagic cloud catalog:", message); - loadCatalogImagicFallback(); - } - }); } - // Game Catalog fallback: source the streamable PS4/PS5 cloud titles from the imagic - // catalog (the same source the Library uses) and mark which the user owns, so owned - // titles stream and the rest offer "Add Game". Presented as pscloud, not psnow. - // The PS Plus subscription catalog (what Sony lists on the PS Plus games page, ~630 in HU): - // browse titles tagged plusCatalog + the library-stream supplement (catalog titles with - // streamingSupported=false, e.g. God of War). Excludes the full ~7000-title all-ps5 universe, - // which is fetched only to match the games you own. - function ps5PlusCatalogGames(data) { - let games = []; - if (data && data.games && Array.isArray(data.games)) { - for (let i = 0; i < data.games.length; i++) { - if (data.games[i] && data.games[i].plusCatalog) - games.push(data.games[i]); - } - } - if (data && data.plusLibrarySupplement && Array.isArray(data.plusLibrarySupplement)) { - for (let i = 0; i < data.plusLibrarySupplement.length; i++) - games.push(data.plusLibrarySupplement[i]); + function tagFilterSummary() { + if (!activeTagFilters || activeTagFilters.length === 0) + return qsTr("All games"); + let labels = []; + for (let i = 0; i < tagFilterCategories.length; i++) { + if (activeTagFilters.indexOf(tagFilterCategories[i]) !== -1) + labels.push(tagFilterLabels[i]); } - return games; - } - - function loadCatalogImagicFallback() { - catalogImagicFallback = true; - isLoading = true; - Chiaki.cloudCatalog.fetchPs5CloudCatalog(function(success, message, jsonData) { - if (!success || !jsonData) { - isLoading = false; - allGames = []; - filteredGames = []; - currentPageGames = []; - console.error("Failed to fetch imagic cloud catalog:", message); - showErrorToast(qsTr("API Error"), message || qsTr("Failed to fetch game catalog")); - return; - } - let browseGames = []; - try { - let data = JSON.parse(jsonData); - // Game Catalog = the PS Plus subscription catalog only (not the full streamable universe). - browseGames = ps5PlusCatalogGames(data); - } catch (e) { - isLoading = false; - console.error("Failed to parse imagic cloud catalog:", e); - showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); - return; - } - if (message && message !== "Success" && message !== "Cached") - showErrorToast(qsTr("Partial Catalog"), message); - // Mark which subscription titles you already own, so a non-owned PS5 catalog game shows - // "Add Game" (it must be added to your library before Gaikai will stream it) while PS4 - // titles and owned games show "Stream". addUnmatchedOwned=false keeps the Catalog the - // pure subscription set (we only mark ownership, never add owned-but-uncatalogued games). - Chiaki.cloudCatalog.getOwnedPs5CloudGames(function(ownedSuccess, ownedMessage, ownedJsonData) { - let ownedGames = []; - if (ownedSuccess && ownedJsonData) { - try { - let ownedData = JSON.parse(ownedJsonData); - if (ownedData.games && Array.isArray(ownedData.games)) - ownedGames = ownedData.games; - } catch (e) { - console.warn("Catalog: failed to parse owned games for ownership marking:", e); - } - } - let merged = mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames, false); - sortPs5CloudLibraryGames(merged.games); - allGames = merged.games; - ownedProductIds = Array.from(merged.ownedIds); - isLoading = false; - applySearchFilter(); - appendPs3Catalog(); - Qt.callLater(() => { - if (gamesGrid.count > 0) { - gamesGrid.currentIndex = 0; - gamesGrid.forceActiveFocus(); - } - }); - }); - }); - } - - // True for streamable PS3 Classics (from the public Apollo PS3 container). They carry - // playable_platform ["PS3"] and a PS3 product id, and must stream via the PSNOW/konan path. - function gameIsPs3(g) { - if (!g) - return false; - let pp = g.playable_platform; - if (!pp) - return false; - let arr = []; - if (Array.isArray(pp)) - arr = pp; - else if (typeof pp === "object" && pp.length !== undefined) { - for (let i = 0; i < pp.length; i++) arr.push(pp[i]); - } else if (typeof pp === "string") - arr = [pp]; - for (let i = 0; i < arr.length; i++) - if (String(arr[i]).indexOf("PS3") !== -1) return true; - return false; - } - - // Fetch the streamable PS3 Classics (public Apollo container) and append them to the - // current catalog. Additive: it never replaces the PS4/PS5 catalog already loaded, so - // it works regardless of whether the primary catalog came from PS Now or the imagic - // fallback. PS3 belongs only in the subscription Catalog (not the owned Library). - function appendPs3Catalog() { - // PS3 Classics are subscription-streamable, so they belong in the Game Catalog and - // in the Library "all" (streamable universe) view -- but NOT the "owned" view. - if (currentSection === "library" && libraryFilter !== "all") - return; - Chiaki.cloudCatalog.fetchPs3Catalog(function(success, message, jsonData) { - if (!success || !jsonData) { - console.warn("PS3 Classics catalog unavailable:", message); - return; - } - try { - let d = JSON.parse(jsonData); - if (d.games && Array.isArray(d.games) && d.games.length > 0) { - allGames = allGames.concat(d.games); - applySearchFilter(); - console.log("[CloudPlayView] Appended", d.games.length, "PS3 Classics to catalog"); - } - } catch (e) { - console.warn("Failed to parse PS3 catalog:", e); - } - }); - } - - function ps5CloudProductId(game) { - if (!game) - return ""; - return game.productId || game.product_id || ""; + return labels.length > 0 ? labels.join(" · ") : qsTr("All games"); } - function ps5CloudConceptId(game) { - if (!game) - return ""; - let conceptId = game.conceptId; - if (conceptId === undefined || conceptId === null || conceptId === "") - return ""; - return String(conceptId); + function isTagFilterActive(tag) { + return !activeTagFilters || activeTagFilters.length === 0 + || activeTagFilters.indexOf(tag) !== -1; } - // Platform from the title id (PPSA = PS5, CUSA = PS4), falling back to the device array. - function ps5CloudPlatformToken(game) { - let pid = ps5CloudProductId(game) || ps5CloudStreamingId(game) || ""; - if (pid.indexOf("PPSA") !== -1) return "ps5"; - if (pid.indexOf("CUSA") !== -1) return "ps4"; - let dev = game ? game.device : null; - if (Array.isArray(dev)) { - if (dev.indexOf("PS5") !== -1) return "ps5"; - if (dev.indexOf("PS4") !== -1) return "ps4"; - } - return ""; + function setTagFilters(tags) { + activeTagFilters = tags; + Chiaki.settings.cloudTagFilters = JSON.stringify(tags); + applySearchFilter(); } - // Edition identity = conceptId + platform, so cross-gen editions (PS4 + PS5) of the same - // game are treated as distinct entries instead of being merged by conceptId alone. - function ps5CloudConceptPlatformKey(game) { - let c = ps5CloudConceptId(game); - if (!c) - return ""; - return c + "|" + ps5CloudPlatformToken(game); + function toggleTagFilter(tag) { + // Empty active set means "all selected", so start from every category and remove from there. + let current = (!activeTagFilters || activeTagFilters.length === 0) + ? tagFilterCategories.slice() + : activeTagFilters.slice(); + let idx = current.indexOf(tag); + if (idx !== -1) + current.splice(idx, 1); + else + current.push(tag); + if (current.length === 0 || current.length === tagFilterCategories.length) + setTagFilters([]); + else + setTagFilters(current); } - function ps5CloudStreamingId(game) { - if (!game) - return ""; - return game.id || ""; + function isPlayableNow(game) { + return game && game.category !== "purchaseable"; } - function buildPs5CloudCatalogIndex(games) { - let byProductId = {}; - let byConceptId = {}; - for (let i = 0; i < games.length; i++) { - let game = games[i]; - let productId = ps5CloudProductId(game); - if (productId) - byProductId[productId] = i; - let conceptKey = ps5CloudConceptPlatformKey(game); - if (conceptKey) - byConceptId[conceptKey] = i; - let streamId = ps5CloudStreamingId(game); - if (streamId && streamId !== productId) - byProductId[streamId] = i; + function sortGames(games) { + let sorted = games.slice(); + if (sortState === 1) { + sorted.sort((a, b) => gameName(a).localeCompare(gameName(b))); + } else if (sortState === 2) { + sorted.sort((a, b) => gameName(b).localeCompare(gameName(a))); + } else { + sorted.sort((a, b) => { + let pa = isPlayableNow(a) ? 1 : 0; + let pb = isPlayableNow(b) ? 1 : 0; + if (pa !== pb) return pb - pa; + return gameName(a).localeCompare(gameName(b)); + }); } - return { byProductId: byProductId, byConceptId: byConceptId }; - } - - function registerPs5CloudGameInCatalogIndex(game, index, catalogIndex) { - let productId = ps5CloudProductId(game); - if (productId) - catalogIndex.byProductId[productId] = index; - let conceptKey = ps5CloudConceptPlatformKey(game); - if (conceptKey) - catalogIndex.byConceptId[conceptKey] = index; - let streamId = ps5CloudStreamingId(game); - if (streamId && streamId !== productId) - catalogIndex.byProductId[streamId] = index; - } - - function findPs5CloudCatalogIndexForOwned(ownedGame, catalogIndex) { - let productId = ps5CloudProductId(ownedGame); - if (productId && catalogIndex.byProductId.hasOwnProperty(productId)) - return catalogIndex.byProductId[productId]; - let streamId = ps5CloudStreamingId(ownedGame); - if (streamId && catalogIndex.byProductId.hasOwnProperty(streamId)) - return catalogIndex.byProductId[streamId]; - // Match by conceptId + platform so an owned PS4 edition does NOT match a PS5-only catalog - // entry (and vice-versa); cross-gen editions stay as separate library cards. - let conceptKey = ps5CloudConceptPlatformKey(ownedGame); - if (conceptKey && catalogIndex.byConceptId.hasOwnProperty(conceptKey)) - return catalogIndex.byConceptId[conceptKey]; - return -1; + return sorted; } - function sortPs5CloudLibraryGames(games) { - games.sort(function(a, b) { - if (a.isOwned && !b.isOwned) - return -1; - if (!a.isOwned && b.isOwned) - return 1; - let nameA = (a.name || (a.game_meta && a.game_meta.name) || "").toLowerCase(); - let nameB = (b.name || (b.game_meta && b.game_meta.name) || "").toLowerCase(); - return nameA.localeCompare(nameB); - }); + function gameName(game) { + if (!game) return ""; + if (game.name) return game.name; + if (game.game_meta && game.game_meta.name) return game.game_meta.name; + return ""; } - // addUnmatchedOwned: when true (Library), owned games not found in the browse list are - // appended; when false (Catalog), we only MARK ownership on catalog entries and never add - // owned-but-not-in-catalog titles — so the Catalog stays the pure subscription catalog. - function mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames, addUnmatchedOwned) { - if (addUnmatchedOwned === undefined) - addUnmatchedOwned = true; - let games = browseGames.slice(); - let catalogIndex = buildPs5CloudCatalogIndex(games); - let ownedIds = new Set(); - - for (let i = 0; i < ownedGames.length; i++) { - let ownedGame = ownedGames[i]; - let productId = ps5CloudProductId(ownedGame); - if (productId) - ownedIds.add(productId); - let streamId = ps5CloudStreamingId(ownedGame); - if (streamId) - ownedIds.add(streamId); - - // Trials / free-to-play (feature_type 1) are kept as their OWN library card so the user - // can Stream the trial/free build, while the full version still appears separately as a - // not-owned "Add Game" card. So a trial must NOT collapse into the full-game catalog - // entry. Full games (ft 3/5) merge normally (mark the catalog entry owned). - let isTrialTier = ownedGame && ownedGame.feature_type === 1; - let catalogMatch = isTrialTier ? -1 : findPs5CloudCatalogIndexForOwned(ownedGame, catalogIndex); - if (catalogMatch >= 0) { - let existing = games[catalogMatch]; - existing.isOwned = true; - let streamId = ps5CloudStreamingId(ownedGame); - if (streamId) - existing.id = streamId; - let ownedProductId = ps5CloudProductId(ownedGame); - // Carry the OWNED product id onto the catalog card only for PS5 (PPSA): an owned PS5 - // product IS the streamable entitlement (streamed directly via cronos). For PS4 (CUSA) - // the owned DOWNLOAD product (e.g. ...GODOFWAR) has NO PS Now streaming SKU -- the - // catalog entry's own productId (e.g. ...GODOFWARN, the "N" variant) is what Kamaji - // converts to a streaming entitlement -- so leave the catalog productId intact. - // - // Override unconditionally for PS5 (matching the iOS/Android merge, which always copy - // storeProductId): the catalog card carries one fixed SKU per concept, but you can only - // stream the edition you actually own. When they differ -- e.g. the catalog SKU is a - // disc-upgrade you can't stream and the cross-reference rescued you to the owned full - // game (Horizon: PPSA01521 -> PPSA17903) -- the catalog card's product_id is the wrong - // (unstreamable) one, so the owned product id must win. - // NOTE: ps5CloudPlatformToken takes a GAME OBJECT, not a product-id string -- passing - // the string here made it always return "" so this override never ran (the bug that - // broke the "all" view while the "owned" view, which uses the cross-ref directly, worked). - if (ownedProductId && ps5CloudPlatformToken(ownedGame) === "ps5") { - existing.product_id = ownedProductId; - existing.productId = ownedProductId; - } - games[catalogMatch] = existing; - continue; - } - - if (!addUnmatchedOwned) - continue; // Catalog: don't add owned titles that aren't in the subscription catalog - - let entry = Object.assign({}, ownedGame); - entry.isOwned = true; - if (!entry.productId && entry.product_id) - entry.productId = entry.product_id; - - registerPs5CloudGameInCatalogIndex(entry, games.length, catalogIndex); - games.push(entry); + function loadUnifiedCatalog() { + let npssoToken = Chiaki.settings.psnNpssoToken; + if (!npssoToken || npssoToken.trim().length === 0) { + authErrorMessage = qsTr("NPSSO token is required for cloud games. Please login and enter a valid NPSSO token. You also need a valid PS Plus subscription."); + } else { + authErrorMessage = ""; } - sortPs5CloudLibraryGames(games); - return { games: games, ownedIds: ownedIds }; - } + // The grid is about to be emptied. If it currently holds focus, its cards + // vanish and the (now empty) grid swallows arrow keys, leaving focus + // black-holed. Park focus on the filter toggle so the header stays + // navigable while loading; the post-load callback restores it to the + // first card once games are back. + if (gamesGrid.activeFocus) + filterToggle.forceActiveFocus(); - function loadPs5CloudLibrary() { - // Clear old cards immediately when starting to load allGames = []; filteredGames = []; currentPageGames = []; isLoading = true; - // Library "all" = the PS Plus catalog with your owned titles merged in (owned ones show - // "Stream Game", the rest "Add Game"). Library "owned" = only the games you own. The - // Game Catalog tab is the all-streamable view where everything shows "Stream Game". - if (libraryFilter === "all") { - // Fetch the catalog, then merge owned games in (marking ownership + adding owned extras). - Chiaki.cloudCatalog.fetchPs5CloudCatalog(function(success, message, jsonData) { - if (success && jsonData) { - try { - let data = JSON.parse(jsonData); - if (data.games && Array.isArray(data.games)) { - if (message && message !== "Success" && message !== "Cached") - showErrorToast(qsTr("Partial Catalog"), message); - // Also fetch owned games to mark which ones are owned - Chiaki.cloudCatalog.getOwnedPs5CloudGames(function(ownedSuccess, ownedMessage, ownedJsonData) { - let ownershipCheckFailed = false; - let ownershipErrorMsg = ""; - let ownedGames = []; - - if (ownedSuccess && ownedJsonData) { - try { - let ownedData = JSON.parse(ownedJsonData); - if (ownedData.games && Array.isArray(ownedData.games)) - ownedGames = ownedData.games; - } catch (e) { - console.warn("Failed to parse owned games for filtering:", e); - ownershipCheckFailed = true; - ownershipErrorMsg = qsTr("Failed to parse ownership data. Some games may show incorrect ownership status."); - } - } else { - console.warn("Failed to fetch owned games:", ownedMessage); - ownershipCheckFailed = true; - ownershipErrorMsg = ownedMessage || qsTr("Failed to verify game ownership"); - } - - // Library "all" = the full streamable universe (every PS4/PS5 cloud - // title) with owned titles merged in; non-owned show "Add Game". - // (The Game Catalog tab is the curated subscription view.) - let browse = (data.games && Array.isArray(data.games)) ? data.games : []; - let merged = mergeOwnedPs5CloudIntoBrowseCatalog(browse, ownedGames); - ownedProductIds = Array.from(merged.ownedIds); - allGames = merged.games; - isLoading = false; - appendPs3Catalog(); // PS3 Classics are part of the streamable "all" view - - // Handle ownership check failure with user-visible feedback - if (ownershipCheckFailed) { - // Check if it's an auth error - show persistent banner - if (ownershipErrorMsg.includes("NPSSO") || ownershipErrorMsg.includes("login") || - ownershipErrorMsg.includes("Authentication") || ownershipErrorMsg.includes("PS Plus") || - ownershipErrorMsg.includes("token") || ownershipErrorMsg.includes("expired")) { - authErrorMessage = ownershipErrorMsg + " " + qsTr("Owned games cannot be identified."); - } else { - // Show toast for non-auth errors - authErrorMessage = ""; // Clear any previous auth error - showErrorToast(qsTr("Ownership Check Failed"), - ownershipErrorMsg + " " + qsTr("Some games may show 'Add Game' instead of 'Stream Game'.")); - } - } else { - authErrorMessage = ""; // Clear auth error on full success - } - - applySearchFilter(); - // Set focus after games are loaded - Qt.callLater(() => { - if (gamesGrid.count > 0) { - gamesGrid.currentIndex = 0; - gamesGrid.forceActiveFocus(); - } - }); - }); - } else { - allGames = []; - filteredGames = []; - currentPageGames = []; - authErrorMessage = ""; // Clear auth error on success - isLoading = false; - showErrorToast(qsTr("Error"), qsTr("No cloud streamable games found")); - } - } catch (e) { - console.error("Failed to parse game catalog:", e); - allGames = []; - filteredGames = []; - currentPageGames = []; - isLoading = false; - showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); - } - } else { - console.error("Failed to fetch game catalog:", message); - allGames = []; - filteredGames = []; - currentPageGames = []; - isLoading = false; - let errorMsg = message || qsTr("Failed to fetch game catalog"); - showErrorToast(qsTr("API Error"), errorMsg); - } - }); - } else { - // Fetch only owned games (cross-referenced) - Chiaki.cloudCatalog.getOwnedPs5CloudGames(function(success, message, jsonData) { - isLoading = false; - if (success && jsonData) { - try { - let data = JSON.parse(jsonData); - if (data.games && Array.isArray(data.games)) { - for (let i = 0; i < data.games.length; i++) - data.games[i].isOwned = true; - - sortPs5CloudLibraryGames(data.games); - - let ownedIds = new Set(); - for (let i = 0; i < data.games.length; i++) { - let productId = ps5CloudProductId(data.games[i]); - if (productId) - ownedIds.add(productId); - let streamId = ps5CloudStreamingId(data.games[i]); - if (streamId) - ownedIds.add(streamId); - } - ownedProductIds = Array.from(ownedIds); - - allGames = data.games; - authErrorMessage = ""; // Clear auth error on success - applySearchFilter(); - // Set focus after games are loaded - Qt.callLater(() => { - if (gamesGrid.count > 0) { - gamesGrid.currentIndex = 0; - gamesGrid.forceActiveFocus(); - } - }); - } else { - allGames = []; - filteredGames = []; - currentPageGames = []; - authErrorMessage = ""; // Clear auth error on success - showErrorToast(qsTr("Error"), qsTr("No cloud streamable games found in library")); + Chiaki.cloudCatalog.fetchUnifiedCatalog(function(success, message, jsonData) { + isLoading = false; + if (!success || !jsonData) { + allGames = []; + filteredGames = []; + currentPageGames = []; + showErrorToast(qsTr("API Error"), message || qsTr("Failed to fetch game catalog")); + return; + } + try { + let data = JSON.parse(jsonData); + if (data.games && Array.isArray(data.games)) { + allGames = data.games; + fallbackRegion = data.fallbackRegion || ""; + Chiaki.settings.cloudFallbackRegion = fallbackRegion; + if (data.warning) + authErrorMessage = data.warning; + else if (npssoToken && npssoToken.trim().length > 0) + authErrorMessage = ""; + if (message && message !== "Success" && message !== "Cached") + showErrorToast(qsTr("Partial Catalog"), message); + applySearchFilter(); + Qt.callLater(() => { + if (gamesGrid.count > 0 + && !searchField.activeFocus + && !tagFilterPopup.opened) { + gamesGrid.currentIndex = 0; + gamesGrid.forceActiveFocus(); } - } catch (e) { - console.error("Failed to parse PS5 cloud library:", e); - allGames = []; - filteredGames = []; - currentPageGames = []; - showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse library data: %1").arg(e.toString())); - } + }); } else { - console.error("Failed to fetch PS5 cloud library:", message); - allGames = []; - filteredGames = []; - currentPageGames = []; - // Check if it's an authentication error - let errorMsg = message || qsTr("Failed to fetch PS5 cloud library"); - if (errorMsg.includes("NPSSO") || errorMsg.includes("login") || errorMsg.includes("Authentication") || errorMsg.includes("PS Plus")) { - authErrorMessage = errorMsg; - } else { - authErrorMessage = ""; - showErrorToast(qsTr("API Error"), errorMsg); - } + showErrorToast(qsTr("Error"), qsTr("No games found in catalog")); } - }); - } + } catch (e) { + console.error("Failed to parse unified catalog:", e); + showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); + } + }); } - + function applySearchFilter() { let hadFocus = searchField && searchField.activeFocus; let gamesToFilter = allGames.slice(); - - // Apply filter based on current section and filter mode - if (currentSection === "catalog" && catalogFilter === "favorites") { - // Filter catalog to only show favorites + + if (activeTagFilters && activeTagFilters.length > 0) { gamesToFilter = gamesToFilter.filter(function(game) { - let productId = game.productId || game.product_id || game.id; - return favoriteProductIds.indexOf(productId) !== -1; + return game.category && activeTagFilters.indexOf(game.category) !== -1; }); - } else if (currentSection === "library" && libraryFilter === "favorites") { - // Filter library to only show favorites + } + + if (showFavoritesOnly) { gamesToFilter = gamesToFilter.filter(function(game) { - let productId = game.product_id || game.productId || game.id; + let productId = game.productId || game.product_id || game.id; return favoriteProductIds.indexOf(productId) !== -1; }); } - - if (!searchQuery || searchQuery.trim() === "") { - filteredGames = gamesToFilter; - } else { + + if (searchQuery && searchQuery.trim() !== "") { let query = searchQuery.toLowerCase().trim(); - filteredGames = gamesToFilter.filter(function(game) { - let name = ""; - if (game.name) name = game.name.toLowerCase(); - else if (game.game_meta && game.game_meta.name) name = game.game_meta.name.toLowerCase(); - return name.includes(query); + gamesToFilter = gamesToFilter.filter(function(game) { + let name = gameName(game).toLowerCase(); + let pid = (game.productId || game.product_id || "").toLowerCase(); + return name.includes(query) || pid.includes(query); }); } - - // Show all games on one page (no pagination for both catalog and library) + + filteredGames = sortGames(gamesToFilter); currentPageGames = filteredGames.slice(); // If user was typing, restore focus immediately after model update @@ -767,30 +347,6 @@ Pane { } } - function switchSection(section) { - // Clear old cards immediately when switching sections - allGames = []; - filteredGames = []; - currentPageGames = []; - currentSection = section; - searchQuery = ""; - // Save the selected section - Chiaki.settings.lastSelectedCloudSection = section; - // Don't clear auth error here - let the load functions handle it - // Clear search field text using Qt.callLater to ensure it works - Qt.callLater(() => { - if (searchField) { - searchField.text = ""; - } - }); - if (section === "catalog") { - loadPsnowCatalog(); - } else { - authErrorMessage = ""; // Clear auth error when switching to library (it will be set if needed) - loadPs5CloudLibrary(); - } - } - function showShortcutToast(title, message) { shortcutToastTitle.text = title; shortcutToastMessage.text = message; @@ -818,7 +374,7 @@ Pane { left: parent.left right: parent.right } - height: 75 + height: 52 color: Qt.rgba(10/255, 20/255, 38/255, 0.95) @@ -838,21 +394,93 @@ Pane { fill: parent leftMargin: 25 rightMargin: 25 - topMargin: 8 - bottomMargin: 8 + topMargin: 6 + bottomMargin: 6 } - spacing: 16 - - // Search bar - icon that expands when focused (far left) + spacing: 8 + + // Acquisition-tag filter summary (Owned / Streamable / Store) — far left + Item { + id: filterToggle + Layout.preferredWidth: Math.max(filterToggleRow.implicitWidth + 20, 110) + Layout.preferredHeight: 36 + + Rectangle { + anchors.fill: parent + color: filterToggle.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.15) : "transparent" + border.color: filterToggle.activeFocus ? "#00d4ff" : "transparent" + border.width: filterToggle.activeFocus ? 1 : 0 + radius: 4 + } + + Row { + id: filterToggleRow + anchors.centerIn: parent + spacing: 6 + property bool filtersActive: activeTagFilters && activeTagFilters.length > 0 + property color tint: filtersActive ? "#00d4ff" : Qt.rgba(255, 255, 255, 0.6) + + // Funnel / "decrease" filter glyph (matches iOS line.3.horizontal.decrease) + Canvas { + id: filterGlyph + anchors.verticalCenter: parent.verticalCenter + width: 16; height: 16 + property color stroke: filterToggleRow.tint + onStrokeChanged: requestPaint() + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + ctx.strokeStyle = stroke; + ctx.lineWidth = 1.8; ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(2, 4); ctx.lineTo(14, 4); + ctx.moveTo(4, 8); ctx.lineTo(12, 8); + ctx.moveTo(6, 12); ctx.lineTo(10, 12); + ctx.stroke(); + } + } + + Text { + id: filterToggleText + anchors.verticalCenter: parent.verticalCenter + text: tagFilterSummary() + font.pixelSize: 13 + font.weight: Font.Medium + color: filterToggleRow.tint + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: tagFilterPopup.open() + } + + focusPolicy: Qt.StrongFocus + KeyNavigation.left: sortToggle + KeyNavigation.right: searchContainer + KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null + KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null + Keys.onReturnPressed: { tagFilterPopup.open(); event.accepted = true; } + } + + // Flexible gap pushes search + the right-side controls to the right edge. + // It sits to the LEFT of search so the field expands leftward into this gap. + Item { Layout.fillWidth: true } + + // Search bar - icon that expands leftward when focused (right side, left of favorites) Rectangle { id: searchContainer - Layout.preferredHeight: 44 - Layout.preferredWidth: searchContainer.activeFocus || searchField.activeFocus || searchField.text.length > 0 ? 400 : 44 - radius: 22 + Layout.preferredHeight: 36 + Layout.preferredWidth: searchContainer.activeFocus || searchField.activeFocus || searchField.text.length > 0 ? 360 : 36 + radius: 18 color: searchContainer.activeFocus || searchField.activeFocus ? Qt.rgba(255, 255, 255, 0.15) : Qt.rgba(255, 255, 255, 0.1) border.color: searchContainer.activeFocus || searchField.activeFocus ? "#00d4ff" : Qt.rgba(255, 255, 255, 0.2) border.width: searchContainer.activeFocus || searchField.activeFocus ? 2 : 1 focusPolicy: Qt.StrongFocus + // Keep search OUT of the automatic focus chain so it never grabs default + // focus on launch. It's still reachable by click and arrow/controller nav. + activeFocusOnTab: false Behavior on Layout.preferredWidth { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } @@ -880,14 +508,13 @@ Pane { } Keys.onLeftPressed: { - // Wrap to refresh button if at start - refreshButton.forceActiveFocus(); + // Filter toggle sits to the left of search now + filterToggle.forceActiveFocus(); event.accepted = true; } Keys.onRightPressed: { - // Move to catalog button - catalogButton.forceActiveFocus(); + favoritesToggle.forceActiveFocus(); event.accepted = true; } @@ -948,6 +575,8 @@ Pane { color: "white" selectByMouse: true focusPolicy: Qt.StrongFocus + // Not auto-focusable on launch; only via click / explicit navigation. + activeFocusOnTab: false verticalAlignment: TextInput.AlignVCenter topPadding: 0 bottomPadding: 0 @@ -959,14 +588,14 @@ Pane { NumberAnimation { duration: 200 } } - KeyNavigation.right: catalogButton - KeyNavigation.left: refreshButton + KeyNavigation.right: favoritesToggle + KeyNavigation.left: filterToggle KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null Keys.onLeftPressed: (event) => { - refreshButton.forceActiveFocus(); + filterToggle.forceActiveFocus(); event.accepted = true; } @@ -1018,366 +647,295 @@ Pane { } } - // Section switcher - immediately to the right of search + // Right side controls RowLayout { - spacing: 10 + spacing: 0 - // Game Catalog button - Button { - id: catalogButton - Layout.preferredHeight: 44 - Layout.preferredWidth: 150 - focusPolicy: Qt.StrongFocus - checked: currentSection === "catalog" - onClicked: switchSection("catalog") - - KeyNavigation.left: searchContainer - KeyNavigation.right: libraryButton - KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null - - Keys.onLeftPressed: (event) => { - searchContainer.forceActiveFocus(); - event.accepted = true; - } - - KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null - - Keys.onReturnPressed: { - if (currentSection !== "catalog") { - switchSection("catalog"); - } - event.accepted = true; + // Filter dialog: mirrors the proven ConfirmDialog pattern (overlay-parented, + // root-centered, content-sized) so it centers correctly and captures input. + Dialog { + id: tagFilterPopup + parent: Overlay.overlay + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + width: 320 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + title: qsTr("Filter games") + Material.roundedScale: Material.MediumScale + + Component.onCompleted: { + header.horizontalAlignment = Text.AlignHCenter; + // Qt 6.6: workaround dialog header background flashing transparent on close. + header.background = null; } - + background: Rectangle { - radius: 22 - // Checked (active section) - solid bright blue background - // Focused (keyboard navigation) - subtle blue background with animated glow - // Neither - subtle gray - color: parent.checked ? Qt.rgba(0, 212/255, 255/255, 0.35) : (parent.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.18) : Qt.rgba(255, 255, 255, 0.08)) - border.color: parent.checked ? "#00d4ff" : (parent.activeFocus ? "#00d4ff" : Qt.rgba(255, 255, 255, 0.15)) - // When focused, use thicker border (3px) even if also checked - // When checked but not focused, use 2px - // When neither, use 1px - border.width: parent.activeFocus ? 3 : (parent.checked ? 2 : 1) - - // Focus glow effect (only when focused but not checked) - make it very visible - Rectangle { - anchors.fill: parent - radius: parent.radius - color: "transparent" - border.color: "#00d4ff" - border.width: 2 - opacity: parent.parent.activeFocus && !parent.parent.checked ? 0.7 : 0 - visible: opacity > 0 - - layer.enabled: parent.parent.activeFocus && !parent.parent.checked - layer.effect: MultiEffect { - blurEnabled: true - blurMax: 10 - blur: 0.7 - } - - Behavior on opacity { NumberAnimation { duration: 150 } } - } - - // Additional outer glow when focused (even if checked) - thicker border effect - Rectangle { - anchors { - fill: parent - margins: -1 - } - radius: parent.radius + 1 - color: "transparent" - border.color: "#00d4ff" - border.width: 1 - opacity: parent.parent.activeFocus ? 0.5 : 0 - visible: opacity > 0 - - layer.enabled: parent.parent.activeFocus - layer.effect: MultiEffect { - blurEnabled: true - blurMax: 6 - blur: 0.4 - } - - Behavior on opacity { NumberAnimation { duration: 150 } } - } - - // Additional inner glow for checked state - Rectangle { - anchors { - fill: parent - margins: 2 - } - radius: parent.radius - 2 - color: parent.parent.checked ? Qt.rgba(0, 212/255, 255/255, 0.2) : "transparent" - visible: parent.parent.checked - - Behavior on color { ColorAnimation { duration: 150 } } - } - - Behavior on color { ColorAnimation { duration: 150 } } - Behavior on border.color { ColorAnimation { duration: 150 } } - Behavior on border.width { NumberAnimation { duration: 150 } } + color: Qt.rgba(10/255, 20/255, 38/255, 0.98) + radius: 12 + border.color: "#00d4ff" + border.width: 2 } - - contentItem: Text { - text: qsTr("Game Catalog") - font.pixelSize: 14 - font.weight: parent.parent.checked ? Font.Medium : (parent.parent.activeFocus ? Font.Medium : Font.Normal) - // Checked = bright cyan, Focused = bright cyan (but different background), Neither = gray - color: parent.parent.checked ? "#00d4ff" : (parent.parent.activeFocus ? "#00d4ff" : Qt.rgba(255, 255, 255, 0.7)) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideNone - - Behavior on color { ColorAnimation { duration: 150 } } + + // Sync checkbox visuals to current state on open, then capture focus so the + // grid behind never receives our Enter / confirm key. + onOpened: { + ownedCheck.checked = isTagFilterActive(tagFilterCategories[0]); + streamableCheck.checked = isTagFilterActive(tagFilterCategories[1]); + storeCheck.checked = isTagFilterActive(tagFilterCategories[2]); + ownedCheck.forceActiveFocus(Qt.TabFocusReason); } - } - - // Game Library button - Button { - id: libraryButton - Layout.preferredHeight: 44 - Layout.preferredWidth: 160 - focusPolicy: Qt.StrongFocus - checked: currentSection === "library" - onClicked: switchSection("library") - - KeyNavigation.left: catalogButton - KeyNavigation.right: currentSection === "library" ? filterToggle : refreshButton - KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null - - Keys.onReturnPressed: { - if (currentSection !== "library") { - switchSection("library"); + onClosed: filterToggle.forceActiveFocus() + + ColumnLayout { + spacing: 10 + + CheckBox { + id: ownedCheck + text: tagFilterLabels[0] + Layout.fillWidth: true + focusPolicy: Qt.StrongFocus + onClicked: toggleTagFilter(tagFilterCategories[0]) + KeyNavigation.down: streamableCheck + Keys.onReturnPressed: { toggle(); toggleTagFilter(tagFilterCategories[0]); event.accepted = true; } } - event.accepted = true; - } - - KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null - - background: Rectangle { - radius: 22 - // Checked (active section) - brighter blue background - // Focused (keyboard navigation) - subtle blue glow - // Neither - subtle gray - color: parent.checked ? Qt.rgba(0, 212/255, 255/255, 0.3) : (parent.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.12) : Qt.rgba(255, 255, 255, 0.08)) - border.color: parent.checked ? "#00d4ff" : (parent.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.6) : Qt.rgba(255, 255, 255, 0.15)) - // When focused, use thicker border (3px) even if also checked - // When checked but not focused, use 2px - // When neither, use 1px - border.width: parent.activeFocus ? 3 : (parent.checked ? 2 : 1) - - // Focus glow effect (only when focused but not checked) - Rectangle { - anchors.fill: parent - radius: parent.radius - color: "transparent" - border.color: "#00d4ff" - border.width: 2 - opacity: parent.parent.activeFocus && !parent.parent.checked ? 0.4 : 0 - visible: opacity > 0 - - Behavior on opacity { NumberAnimation { duration: 150 } } + CheckBox { + id: streamableCheck + text: tagFilterLabels[1] + Layout.fillWidth: true + focusPolicy: Qt.StrongFocus + onClicked: toggleTagFilter(tagFilterCategories[1]) + KeyNavigation.up: ownedCheck + KeyNavigation.down: storeCheck + Keys.onReturnPressed: { toggle(); toggleTagFilter(tagFilterCategories[1]); event.accepted = true; } } - - // Additional outer glow when focused (even if checked) - thicker border effect - Rectangle { - anchors { - fill: parent - margins: -1 + CheckBox { + id: storeCheck + text: tagFilterLabels[2] + Layout.fillWidth: true + focusPolicy: Qt.StrongFocus + onClicked: toggleTagFilter(tagFilterCategories[2]) + KeyNavigation.up: streamableCheck + KeyNavigation.down: showAllButton + Keys.onReturnPressed: { toggle(); toggleTagFilter(tagFilterCategories[2]); event.accepted = true; } + } + RowLayout { + Layout.alignment: Qt.AlignCenter + Layout.topMargin: 6 + spacing: 12 + Button { + id: showAllButton + text: qsTr("Show all") + focusPolicy: Qt.StrongFocus + Material.roundedScale: Material.SmallScale + onClicked: { + setTagFilters([]); + ownedCheck.checked = true; + streamableCheck.checked = true; + storeCheck.checked = true; + tagFilterPopup.close(); + } + KeyNavigation.up: storeCheck + KeyNavigation.right: closeButton + Keys.onReturnPressed: { clicked(); event.accepted = true; } } - radius: parent.radius + 1 - color: "transparent" - border.color: "#00d4ff" - border.width: 1 - opacity: parent.parent.activeFocus ? 0.5 : 0 - visible: opacity > 0 - - layer.enabled: parent.parent.activeFocus - layer.effect: MultiEffect { - blurEnabled: true - blurMax: 6 - blur: 0.4 + Button { + id: closeButton + text: qsTr("Close") + focusPolicy: Qt.StrongFocus + Material.roundedScale: Material.SmallScale + onClicked: tagFilterPopup.close() + KeyNavigation.up: storeCheck + KeyNavigation.left: showAllButton + Keys.onReturnPressed: { clicked(); event.accepted = true; } } - - Behavior on opacity { NumberAnimation { duration: 150 } } } - - Behavior on color { ColorAnimation { duration: 150 } } - Behavior on border.color { ColorAnimation { duration: 150 } } - Behavior on border.width { NumberAnimation { duration: 150 } } - } - - contentItem: Text { - text: qsTr("Game Library") - font.pixelSize: 14 - font.weight: parent.parent.checked ? Font.Medium : Font.Normal - // Checked = bright cyan, Focused = cyan, Neither = gray - color: parent.parent.checked ? "#00d4ff" : (parent.parent.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.9) : Qt.rgba(255, 255, 255, 0.7)) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideNone - - Behavior on color { ColorAnimation { duration: 150 } } } } - } - - Item { Layout.fillWidth: true } - - // Right side controls - RowLayout { - spacing: 0 - - // Filter toggle (visible for both catalog and library) - // Cycles through filter options + + // Favorites filter toggle Item { - id: filterToggle - visible: true - Layout.preferredWidth: filterToggleText.implicitWidth + 16 + id: favoritesToggle + Layout.preferredWidth: 36 Layout.preferredHeight: 36 - Layout.rightMargin: 16 - + Layout.rightMargin: 8 + Rectangle { anchors.fill: parent - color: filterToggle.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.15) : "transparent" - border.color: filterToggle.activeFocus ? "#00d4ff" : "transparent" - border.width: filterToggle.activeFocus ? 1 : 0 + color: favoritesToggle.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.15) : "transparent" + border.color: favoritesToggle.activeFocus ? "#00d4ff" : "transparent" + border.width: favoritesToggle.activeFocus ? 1 : 0 radius: 4 - - // Underline always visible - Rectangle { - anchors { - left: parent.left - right: parent.right - bottom: parent.bottom - } - height: 3 - color: "#00d4ff" - radius: 1.5 - } } - - Text { - id: filterToggleText + + // Star glyph: filled gold when favorites-only is active, outline otherwise + Canvas { + id: favoritesStar anchors.centerIn: parent - text: { - if (currentSection === "library") { - if (libraryFilter === "owned") return qsTr("Owned"); - if (libraryFilter === "all") return qsTr("All"); - return qsTr("Favorites"); + width: 20; height: 20 + property bool active: showFavoritesOnly + onActiveChanged: requestPaint() + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + var cx = 10, cy = 10.5, spikes = 5, outer = 8.5, inner = 3.6; + var rot = -Math.PI / 2; + var step = Math.PI / spikes; + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer); + for (var i = 0; i < spikes; i++) { + rot += step; + ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner); + rot += step; + ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer); + } + ctx.closePath(); + if (active) { + ctx.fillStyle = "#FFD700"; + ctx.fill(); } else { - return catalogFilter === "all" ? qsTr("All") : qsTr("Favorites"); + ctx.strokeStyle = Qt.rgba(255, 255, 255, 0.7); + ctx.lineWidth = 1.6; + ctx.lineJoin = "round"; + ctx.stroke(); } } - font.pixelSize: 14 - font.weight: Font.Medium - color: filterToggle.activeFocus ? "#00d4ff" : "#00d4ff" } - + MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: { - if (currentSection === "library") { - // Cycle through all -> owned -> favorites - if (libraryFilter === "all") { - libraryFilter = "owned"; - } else if (libraryFilter === "owned") { - libraryFilter = "favorites"; - } else { - libraryFilter = "all"; - } - Chiaki.settings.cloudLibraryFilter = libraryFilter; - loadPs5CloudLibrary(); - } else { - // Toggle between all and favorites for catalog - catalogFilter = catalogFilter === "all" ? "favorites" : "all"; - Chiaki.settings.cloudCatalogFilter = catalogFilter; - applySearchFilter(); - } - } + onClicked: { showFavoritesOnly = !showFavoritesOnly; applySearchFilter(); } } - + focusPolicy: Qt.StrongFocus - KeyNavigation.left: currentSection === "catalog" ? catalogButton : libraryButton + KeyNavigation.left: searchContainer KeyNavigation.right: refreshButton KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null - - Keys.onReturnPressed: { - if (currentSection === "library") { - // Cycle through all -> owned -> favorites - if (libraryFilter === "all") { - libraryFilter = "owned"; - } else if (libraryFilter === "owned") { - libraryFilter = "favorites"; - } else { - libraryFilter = "all"; - } - Chiaki.settings.cloudLibraryFilter = libraryFilter; - loadPs5CloudLibrary(); - } else { - // Toggle between all and favorites for catalog - catalogFilter = catalogFilter === "all" ? "favorites" : "all"; - Chiaki.settings.cloudCatalogFilter = catalogFilter; - applySearchFilter(); - } - } + Keys.onReturnPressed: { showFavoritesOnly = !showFavoritesOnly; applySearchFilter(); event.accepted = true; } } - - // Refresh button - Button { + + // Refresh button (icon-only, matches Android/iOS) — plain Item so the + // bundled Material SVG renders at full size, uniform with the star/sort glyphs. + Item { id: refreshButton - text: qsTr("Refresh") - font.pixelSize: 14 - font.weight: Font.Medium - Layout.preferredHeight: 44 - Layout.preferredWidth: 110 - Layout.rightMargin: 4 + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + Layout.rightMargin: 8 enabled: !isLoading - focusPolicy: Qt.StrongFocus - onClicked: { - // Invalidate cache and reload + + function activate() { + if (!enabled) + return; Chiaki.cloudCatalog.invalidateCache(); - if (currentSection === "catalog") { - loadPsnowCatalog(); - } else { - loadPs5CloudLibrary(); + loadUnifiedCatalog(); + } + + Rectangle { + anchors.fill: parent + color: refreshButton.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.15) : "transparent" + border.color: refreshButton.activeFocus ? "#00d4ff" : "transparent" + border.width: refreshButton.activeFocus ? 1 : 0 + radius: 4 + } + + Image { + anchors.centerIn: parent + source: "qrc:/icons/refresh-24px.svg" + sourceSize: Qt.size(48, 48) + width: 24 + height: 24 + smooth: true + opacity: refreshButton.enabled ? 1.0 : 0.4 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: refreshButton.activate() + } + + focusPolicy: Qt.StrongFocus + KeyNavigation.left: favoritesToggle + KeyNavigation.right: sortToggle + KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null + KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null + Keys.onReturnPressed: { activate(); event.accepted = true; } + } + + // Sort toggle (far right, just before the game count) + Item { + id: sortToggle + Layout.preferredWidth: sortToggleRow.implicitWidth + 16 + Layout.preferredHeight: 36 + Layout.rightMargin: 8 + + Rectangle { + anchors.fill: parent + color: sortToggle.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.15) : "transparent" + border.color: sortToggle.activeFocus ? "#00d4ff" : "transparent" + border.width: sortToggle.activeFocus ? 1 : 0 + radius: 4 + } + + Row { + id: sortToggleRow + anchors.centerIn: parent + spacing: 5 + + // Up/down arrows glyph (matches iOS arrow.up.arrow.down) + Canvas { + anchors.verticalCenter: parent.verticalCenter + width: 18; height: 18 + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + ctx.strokeStyle = "#00d4ff"; + ctx.lineWidth = 1.8; ctx.lineCap = "round"; ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(5, 15); ctx.lineTo(5, 3); + ctx.moveTo(2, 6); ctx.lineTo(5, 3); ctx.lineTo(8, 6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(13, 3); ctx.lineTo(13, 15); + ctx.moveTo(10, 12); ctx.lineTo(13, 15); ctx.lineTo(16, 12); + ctx.stroke(); + } + } + + Text { + id: sortToggleText + anchors.verticalCenter: parent.verticalCenter + text: sortState === 1 ? qsTr("A → Z") : (sortState === 2 ? qsTr("Z → A") : qsTr("Playable")) + font.pixelSize: 13 + font.weight: Font.Medium + color: "#00d4ff" } } - - KeyNavigation.left: currentSection === "library" ? filterToggle : libraryButton + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + sortState = (sortState + 1) % 3; + Chiaki.settings.cloudSortState = sortState; + applySearchFilter(); + } + } + + focusPolicy: Qt.StrongFocus + KeyNavigation.left: refreshButton + KeyNavigation.right: filterToggle KeyNavigation.down: gamesGrid.count > 0 ? gamesGrid : null - + KeyNavigation.up: mainTabBar ? mainTabBar.itemAt(1) : null Keys.onReturnPressed: { - clicked(); + sortState = (sortState + 1) % 3; + Chiaki.settings.cloudSortState = sortState; + applySearchFilter(); event.accepted = true; } - - KeyNavigation.up: settingsButton - - background: Rectangle { - radius: 22 - color: parent.activeFocus ? Qt.rgba(0, 212/255, 255/255, 0.3) : Qt.rgba(255, 255, 255, 0.1) - border.width: parent.activeFocus ? 2 : 1 - border.color: parent.activeFocus ? "#00d4ff" : Qt.rgba(255, 255, 255, 0.25) - - Behavior on color { ColorAnimation { duration: 150 } } - Behavior on border.color { ColorAnimation { duration: 150 } } - } - - contentItem: Text { - text: parent.text - font: parent.font - color: parent.enabled ? (parent.activeFocus ? "#ffffff" : Qt.rgba(255, 255, 255, 0.9)) : Qt.rgba(255, 255, 255, 0.4) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideNone - } } // Game count label @@ -1408,6 +966,39 @@ Pane { anchors.topMargin: 15 spacing: 0 + // Region-group fallback banner (yellow) + Rectangle { + id: fallbackBanner + Layout.fillWidth: true + Layout.preferredHeight: fallbackRegion.length > 0 ? 56 : 0 + visible: fallbackRegion.length > 0 + color: Qt.rgba(255/255, 193/255, 7/255, 0.2) + border.color: "#FFC107" + border.width: 2 + clip: true + + Behavior on Layout.preferredHeight { + NumberAnimation { duration: 300; easing.type: Easing.OutCubic } + } + + Label { + anchors { + fill: parent + leftMargin: 20 + rightMargin: 20 + topMargin: 8 + bottomMargin: 8 + } + text: qsTr("PlayStation cloud isn't offered natively in your region — showing the %1 catalog. Some titles may not stream.").arg(fallbackRegion) + wrapMode: Text.Wrap + color: "#FFFFFF" + font.pixelSize: 13 + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + // Persistent authentication error banner Rectangle { id: authErrorBanner @@ -1537,7 +1128,7 @@ Pane { flickableDirection: Flickable.VerticalFlick boundsBehavior: Flickable.StopAtBounds - KeyNavigation.up: searchField + KeyNavigation.up: filterToggle model: currentPageGames highlightFollowsCurrentItem: true @@ -1560,22 +1151,6 @@ Pane { gameData: modelData focus: false // GridView handles focus, not individual cards activeFocusOnTab: false - // The catalog is normally PS Now; when it falls back to the imagic - // cloud catalog the cards are pscloud (correct streaming path/platform). - // PS3 Classics (appended from the Apollo container) are always PS Now: - // isPsnow=true makes the card read playable_platform -> "ps3" and route - // to the PSNOW/konan streaming path regardless of the catalog source. - isPsnow: (currentSection === "catalog" && !catalogImagicFallback) - || gameIsPs3(modelData) - // Catalog cards: every subscription title is streamable, so use a non-"all" - // value to suppress the "Add Game" state — all of them show "Stream Game". - // Library cards use the real filter ("all" enables Add Game for non-owned). - // PS3 Classics are subscription-streamable (never "owned"), so they always - // show "Stream Game" regardless of section/filter. - libraryFilter: gameIsPs3(modelData) - ? "catalog" - : ((currentSection === "catalog" && catalogImagicFallback) - ? "catalog" : root.libraryFilter) qrCodeDialog: root.qrCodeDialogRef // Bind isFavorite to favoriteProductIds array changes @@ -1699,12 +1274,7 @@ Pane { event.accepted = true; return; } - // If at top row, move focus to the unselected section switcher button - if (currentSection === "catalog") { - libraryButton.forceActiveFocus(); - } else { - catalogButton.forceActiveFocus(); - } + filterToggle.forceActiveFocus(); event.accepted = true; return; } @@ -1781,9 +1351,11 @@ Pane { if (currentIndex < 0) { currentIndex = 0; } - // Ensure focus when model changes + // Ensure focus when model changes, but never steal it from the + // search field or an open modal (e.g. the filter dialog), otherwise + // a live re-filter yanks focus back to the grid mid-interaction. Qt.callLater(() => { - if (count > 0) { + if (count > 0 && !searchField.activeFocus && !tagFilterPopup.opened) { currentIndex = 0; forceActiveFocus(); } @@ -1800,10 +1372,10 @@ Pane { if (currentIndex < 0) { currentIndex = 0; } - // Only auto-focus if search field doesn't have focus - // This prevents stealing focus while user is typing in search + // Only auto-focus if neither the search field nor the filter dialog + // is active; a live re-filter must not pull focus off an open modal. Qt.callLater(() => { - if (count > 0 && !searchField.activeFocus) { + if (count > 0 && !searchField.activeFocus && !tagFilterPopup.opened) { currentIndex = 0; forceActiveFocus(); } diff --git a/gui/src/qml/MainView.qml b/gui/src/qml/MainView.qml index e359117e..bd0d6470 100644 --- a/gui/src/qml/MainView.qml +++ b/gui/src/qml/MainView.qml @@ -1242,14 +1242,11 @@ Pane { item.mainTabBar = mainTabBar item.settingsButton = settingsButton item.showConfirmDialogFunc = root.showConfirmDialog - // Ensure games are loaded when the loader becomes active + // Ensure games are loaded when the loader becomes active. + // Post-unification there is a single combined catalog entry point. if (mainTabBar.currentIndex === 1) { Qt.callLater(() => { - if (item.currentSection === "catalog") { - item.loadPsnowCatalog(); - } else { - item.loadPs5CloudLibrary(); - } + item.loadUnifiedCatalog(); }); } } diff --git a/gui/src/qmlsettings.cpp b/gui/src/qmlsettings.cpp index eb70398c..a5227c2e 100644 --- a/gui/src/qmlsettings.cpp +++ b/gui/src/qmlsettings.cpp @@ -838,6 +838,39 @@ void QmlSettings::setCloudCatalogFilter(const QString &filter) emit cloudCatalogFilterChanged(); } +QString QmlSettings::cloudFallbackRegion() const +{ + return settings->GetCloudFallbackRegion(); +} + +void QmlSettings::setCloudFallbackRegion(const QString ®ion) +{ + settings->SetCloudFallbackRegion(region); + emit cloudFallbackRegionChanged(); +} + +QString QmlSettings::cloudTagFilters() const +{ + return settings->GetCloudTagFilters(); +} + +void QmlSettings::setCloudTagFilters(const QString &filtersJson) +{ + settings->SetCloudTagFilters(filtersJson); + emit cloudTagFiltersChanged(); +} + +int QmlSettings::cloudSortState() const +{ + return settings->GetCloudSortState(); +} + +void QmlSettings::setCloudSortState(int sortState) +{ + settings->SetCloudSortState(sortState); + emit cloudSortStateChanged(); +} + QString QmlSettings::cloudFavorites() const { return settings->GetCloudFavorites(); diff --git a/gui/src/settings.cpp b/gui/src/settings.cpp index 6dc40bb5..857f5d66 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -1024,6 +1024,41 @@ void Settings::SetCloudCatalogFilter(QString filter) settings.setValue("settings/cloud_catalog_filter", filter); } +QString Settings::GetCloudFallbackRegion() const +{ + return settings.value("settings/cloud_fallback_region", "").toString(); +} + +void Settings::SetCloudFallbackRegion(const QString ®ion) +{ + settings.setValue("settings/cloud_fallback_region", region); +} + +bool Settings::IsCloudFallbackMode() const +{ + return !GetCloudFallbackRegion().isEmpty(); +} + +QString Settings::GetCloudTagFilters() const +{ + return settings.value("settings/cloud_tag_filters", "[]").toString(); +} + +void Settings::SetCloudTagFilters(const QString &filtersJson) +{ + settings.setValue("settings/cloud_tag_filters", filtersJson); +} + +int Settings::GetCloudSortState() const +{ + return settings.value("settings/cloud_sort_state", 0).toInt(); +} + +void Settings::SetCloudSortState(int sortState) +{ + settings.setValue("settings/cloud_sort_state", sortState); +} + QString Settings::GetCloudFavorites() const { return settings.value("settings/cloud_favorites", "[]").toString(); diff --git a/ios/Pylux.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index a42c861f..7a67b46c 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ A3000016 /* CloudCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000006 /* CloudCatalogService.swift */; }; A3000018 /* PsCloudOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000008 /* PsCloudOwnership.swift */; }; A3000017 /* CloudPlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000007 /* CloudPlayView.swift */; }; + A3000060 /* CachedAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000050 /* CachedAsyncImage.swift */; }; A4000011 /* DonationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000001 /* DonationStore.swift */; }; A4000012 /* DonationPhrasePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000002 /* DonationPhrasePicker.swift */; }; A4000013 /* DonationPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000003 /* DonationPromptCoordinator.swift */; }; @@ -127,6 +128,7 @@ A3000006 /* CloudCatalogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudCatalogService.swift; sourceTree = ""; }; A3000008 /* PsCloudOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsCloudOwnership.swift; sourceTree = ""; }; A3000007 /* CloudPlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPlayView.swift; sourceTree = ""; }; + A3000050 /* CachedAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedAsyncImage.swift; sourceTree = ""; }; A4000001 /* DonationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationStore.swift; sourceTree = ""; }; A4000002 /* DonationPhrasePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationPhrasePicker.swift; sourceTree = ""; }; A4000003 /* DonationPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationPromptCoordinator.swift; sourceTree = ""; }; @@ -257,6 +259,7 @@ children = ( A1000125 /* AutoRegistrationView.swift */, A3000007 /* CloudPlayView.swift */, + A3000050 /* CachedAsyncImage.swift */, A1000036 /* ConnectInfoEntryView.swift */, A1000105 /* HostCardView.swift */, A1000108 /* ManualHostView.swift */, @@ -410,6 +413,7 @@ A3000016 /* CloudCatalogService.swift in Sources */, A3000018 /* PsCloudOwnership.swift in Sources */, A3000017 /* CloudPlayView.swift in Sources */, + A3000060 /* CachedAsyncImage.swift in Sources */, A4000011 /* DonationStore.swift in Sources */, A4000012 /* DonationPhrasePicker.swift in Sources */, A4000013 /* DonationPromptCoordinator.swift in Sources */, diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 2f523556..51e88147 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -21,12 +21,17 @@ struct CloudGame: Identifiable, Hashable { var storeProductId: String // PSCloud: product_id from entitlements API var plusCatalog: Bool // In the PS Plus subscription catalog (vs the full streamable universe) var featureType: Int // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on + // Unified-page acquisition tag, assigned once at catalog-assembly time: + // "owned" -> entitlement resolves to a streamable row (Stream) + // "streamable" -> not owned, PS Now subscription title (Stream) + // "purchaseable" -> not owned, PS Plus catalog title (Add to Library, then Stream) + var category: String init(productId: String, name: String, imageUrl: String, landscapeImageUrl: String = "", platform: String = "ps4", serviceType: String = "psnow", conceptUrl: String = "", conceptId: String = "", isOwned: Bool = false, entitlementId: String = "", storeProductId: String = "", plusCatalog: Bool = false, - featureType: Int = 0) { + featureType: Int = 0, category: String = "") { self.id = productId self.name = name self.imageUrl = imageUrl @@ -40,35 +45,45 @@ struct CloudGame: Identifiable, Hashable { self.storeProductId = storeProductId self.plusCatalog = plusCatalog self.featureType = featureType + self.category = category } - /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. Stream the owned PRODUCT id - /// (storeProductId), NOT the entitlement id: for cross-gen titles you upgraded, Sony's entitlement - /// id is the stale ORIGINAL SKU (Alan Wake's old CUSA license; Death Stranding's pre-DC SKU) that - /// Gaikai's cloud catalog has no game for -> noGameForEntitlementId. product_id is the current SKU. + /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. PS5/cronos streams the owned + /// PS5 entitlement's OWN id (entitlementId), resolved from the entitlement's platform_id during + /// cross-reference. For a canonical SKU (Red Dead, Alan Wake) that id == product_id == ...PPSA...; + /// for a classic whose product_id is a non-streamable wrapper (Blood Omen) it is the ...PPSA..SLUS + /// license. Never a PS4/CUSA cross-buy id (the platform-disciplined merge guarantees this). var streamingIdentifier: String { if serviceType.lowercased() == "pscloud" { - if !storeProductId.isEmpty { return storeProductId } if !entitlementId.isEmpty { return entitlementId } + if !storeProductId.isEmpty { return storeProductId } } return id } - // A PlayStation title id encodes its platform: CUSAxxxxx = PS4, PPSAxxxxx = PS5. This is - // more reliable than the catalog device list, and decides the streaming path: PS4 goes - // through Kamaji (psnow) to acquire the streaming entitlement, PS5 streams directly (pscloud). + // Platform that drives the streaming path (PS4 = Kamaji, PS5 = cronos). serviceType is the + // canonical signal but with one asymmetry: `psnow` is always PS3/PS4-class (set on PS Now browse + // rows and filled for owned PS3/PS4 cards from platform_id), while `pscloud` is authoritative ONLY + // for OWNED cards (filled from the entitlement's platform_id) -- non-owned imagic browse rows are + // blanket-labeled `pscloud` yet include a few PS4 titles, so for those we use the clean id token + // (PS4 there streams via PS Now/Kamaji, not cronos). Mirrors canonical Qt, whose non-owned imagic + // rows simply carry no serviceType and so fall through to the same token path. var streamPlatform: String { - // Prefer the OWNED product id (storeProductId): for a cross-gen title you upgraded, the catalog - // `id` may be the OTHER generation (Alan Wake's catalog entry is PS4 CUSA, but you own the PS5 - // PPSA), and the owned product is what decides the streaming path. + let st = serviceType.lowercased() + if st == "psnow" { return "ps4" } + // isOwned gate: imagic browse rows are blanket-tagged serviceType="pscloud" (see catalog parse), so + // only treat "pscloud" as PS5/cronos when actually OWNED; non-owned rows fall through to the product-id + // token below, routing non-owned PS4 imagic titles to PS Now (matches Qt, whose imagic rows carry no + // serviceType at all). + if st == "pscloud" && isOwned { return "ps5" } let p = !storeProductId.isEmpty ? storeProductId : (!id.isEmpty ? id : entitlementId) if p.contains("PPSA") { return "ps5" } if p.contains("CUSA") { return "ps4" } return platform.isEmpty ? "ps5" : platform } - /// Service type to stream with: real legacy PS Now games stay psnow; otherwise route by the - /// title-id platform (PS4 catalog titles need the Kamaji acquire-flow, PS5 stays direct). + /// Service type to stream with: route by the (platform_id-disciplined) streaming platform -- + /// PS3/PS4 via Kamaji (psnow), PS5 direct (pscloud). var streamServiceType: String { if serviceType.lowercased() == "psnow" { return "psnow" } return streamPlatform == "ps4" ? "psnow" : "pscloud" @@ -203,6 +218,13 @@ enum ClassicsRegion { ? "STORE-MSF192018-APOLLOPS3GAMES" : "STORE-MSF192014-APOLLOPS3" } + + /// Fully-qualified APOLLOROOT (PS Now: PS3 + PS4) container id for the account's region group. + static func apolloRootContainerId(_ accountCountry: String) -> String { + return isAmericasClassicsRegion(accountCountry) + ? "STORE-MSF192018-APOLLOROOT" + : "STORE-MSF192014-APOLLOROOT" + } } // MARK: - Gaikai Allocation Result diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 4adac4cf..cd2885da 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -20,7 +20,13 @@ final class CloudCatalogService { private static let psnowCacheFile = "psnow_catalog.json" private static let ps5PublicCacheFile = "ps5_cloud_catalog_v4.json" // v4: adds plusCatalog tag + broader supplement private static let pscloudAllCacheFile = "pscloud_catalog_v2.json" - private static let pscloudOwnedCacheFile = "pscloud_owned_v3.json" // v3: ft0 filter + rank dedupe + featureType + private static let pscloudOwnedCacheFile = "pscloud_owned_v4.json" // v4: serviceType from platform_id + private static let unifiedCacheFile = "unified_catalog_v4.json" // v4: Qt-aligned merge (existingClass guard + explicit pscloud/psnow serviceType stamp) + + private static let ownershipSessionWarning = + "Your PlayStation session has expired. Please log in again to see your owned games." + private static let ownershipNetworkWarning = + "Couldn't verify your owned games (network error). Pull to refresh to try again." private static var cacheDir: URL = { let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] @@ -96,7 +102,8 @@ final class CloudCatalogService { "conceptUrl": g.conceptUrl, "conceptId": g.conceptId, "isOwned": g.isOwned, "entitlementId": g.entitlementId, "storeProductId": g.storeProductId, - "plusCatalog": g.plusCatalog, "featureType": g.featureType + "plusCatalog": g.plusCatalog, "featureType": g.featureType, + "category": g.category ] } @@ -115,7 +122,8 @@ final class CloudCatalogService { entitlementId: d["entitlementId"] as? String ?? "", storeProductId: d["storeProductId"] as? String ?? "", plusCatalog: d["plusCatalog"] as? Bool ?? false, - featureType: (d["featureType"] as? NSNumber)?.intValue ?? 0 + featureType: (d["featureType"] as? NSNumber)?.intValue ?? 0, + category: d["category"] as? String ?? "" ) } @@ -407,6 +415,11 @@ final class CloudCatalogService { return CloudGame( productId: productId, name: name, imageUrl: imageUrl, landscapeImageUrl: imageUrl, + // Deliberate Qt<->mobile divergence: Qt leaves imagic browse rows with NO serviceType and derives + // platform from the clean catalog product-id token. Mobile instead blanket-tags imagic rows "pscloud" + // and COMPENSATES with an isOwned gate in streamPlatform (a non-owned "pscloud" row falls back to the + // product-id token, so a non-owned PS4 imagic title still routes to PS Now, not cronos). Both reach the + // same routing -- do NOT naively "fix" one side to match the other. platform: { let p = ps5PlatformToken(productId); return p.isEmpty ? "ps5" : p }(), serviceType: "pscloud", conceptUrl: conceptUrl, conceptId: conceptKey(for: gameObj), isOwned: false, @@ -523,7 +536,8 @@ final class CloudCatalogService { npssoToken: String, publicCatalog: [CloudGame], plusLibrarySupplement: [CloudGame] = [], - productIdAliases: [String: String] = [:] + productIdAliases: [String: String] = [:], + psnowCatalog: [CloudGame] = [] ) -> [CloudGame]? { guard !npssoToken.isEmpty, let oauthToken = fetchOwnedGamesOAuthToken(npssoToken: npssoToken) else { @@ -544,9 +558,10 @@ final class CloudCatalogService { componentIds[ent.productId, default: []].append(ent.id) } + let combinedCatalog = psnowCatalog.isEmpty ? publicCatalog : psnowCatalog + publicCatalog return PsCloudOwnership.crossReferenceOwnedGames( filteredEntitlements: filtered, - publicCatalog: publicCatalog, + publicCatalog: combinedCatalog, plusLibrarySupplement: plusLibrarySupplement, productIdAliases: productIdAliases, componentIdsByProductId: componentIds @@ -599,9 +614,243 @@ final class CloudCatalogService { return all } + // MARK: - Unified Catalog + + /// Unified cloud catalog: ONE merged, deduped, tagged list across PS3/PS4 (PS Now/Kamaji) and + /// PS5 (imagic/Gaikai). Mirrors Android CloudGameRepository.fetchUnifiedCatalog(). + func fetchUnifiedCatalog(npssoToken: String, forceRefresh: Bool = false) -> [CloudGame] { + lastLibraryFetchError = nil + lastLibraryFetchWarning = nil + lastCatalogFetchWarning = nil + CloudLocaleSettings.ensureConfigured(npssoToken: npssoToken) + + if !forceRefresh, let cached = loadCachedGames(Self.unifiedCacheFile) { + os_log(.info, log: catalogLog, "Returning %d unified games from cache", cached.count) + return cached + } + + let accountCountry = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored).country + + // --- 1) PS Now APOLLOROOT (PS3 + PS4): native, else region-group fallback ---------- + let native = fetchNativeCatalog(npssoToken: npssoToken) + var apolloGames: [CloudGame] = [] + var nativeMode = false + var fallbackRegion = "" + if native.storesAvailable && !native.games.isEmpty { + apolloGames = native.games + nativeMode = true + } else if native.authError { + lastCatalogFetchWarning = Self.ownershipSessionWarning + apolloGames = tryApolloRootFallback(accountCountry: accountCountry) + } else { + apolloGames = tryApolloRootFallback(accountCountry: accountCountry) + if !apolloGames.isEmpty { + fallbackRegion = ClassicsRegion.classicsStoreCountry(accountCountry) + } + } + SecureStore.shared.cloudFallbackRegion = fallbackRegion + os_log(.info, log: catalogLog, + "PS Now APOLLOROOT: %d games (nativeMode=%{public}s, fallbackRegion='%{public}s')", + apolloGames.count, nativeMode ? "true" : "false", fallbackRegion) + + // --- 2) imagic PS5 catalog (browse + supplement + aliases) ------------------------- + let imagic: Ps5CloudCatalogResult + do { + imagic = try fetchPs5CatalogV3(stored: CloudLocaleSettings.stored, forceRefresh: forceRefresh) + } catch { + os_log(.error, log: catalogLog, "imagic PS5 catalog fetch failed: %{public}s", error.localizedDescription) + if apolloGames.isEmpty { + lastLibraryFetchError = "Failed to fetch catalog: \(error.localizedDescription)" + return [] + } + imagic = Ps5CloudCatalogResult( + browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], + shouldCacheV3: false + ) + } + + // --- 3) owned cross-reference (skip on expired token) ------------------------------ + var owned: [CloudGame] = [] + if !npssoToken.isEmpty && !native.authError { + if let crossRef = getOwnedPs5CloudGames( + npssoToken: npssoToken, + publicCatalog: imagic.browseGames, + plusLibrarySupplement: imagic.plusLibrarySupplement, + productIdAliases: imagic.productIdAliases, + psnowCatalog: apolloGames + ) { + owned = crossRef + } else { + os_log(.info, log: catalogLog, + "Ownership cross-reference failed; showing as not owned") + lastCatalogFetchWarning = Self.ownershipNetworkWarning + } + } + + // --- 4) assemble the streamable universe (PS Now PS3/PS4 + imagic PS5) ------------- + let ps5Browse = imagic.browseGames.filter { $0.streamPlatform == "ps5" } + let universe = apolloGames + ps5Browse + var games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog( + browseCatalog: universe, ownedCrossRef: owned, addUnmatched: true + ) + + // --- 5) concept-sibling streamability gate (native mode only) ---------------------- + if nativeMode { + let index = PsCloudOwnership.StreamabilityIndex( + apolloCatalog: apolloGames, + imagicBrowse: imagic.browseGames, + imagicConceptRows: imagic.browseGames + imagic.plusLibrarySupplement + ) + games = PsCloudOwnership.applyStreamabilityGate(games, index: index) + } + + // --- 6) tag + cache ---------------------------------------------------------------- + games = games.map { game in + var tagged = game + tagged.category = PsCloudOwnership.categoryFor(game) + return tagged + } + if !games.isEmpty && !isOwnershipVerificationFailure(lastCatalogFetchWarning) { + cacheGames(games, filename: Self.unifiedCacheFile) + } + if let warning = imagic.catalogFetchWarning, lastCatalogFetchWarning == nil { + lastCatalogFetchWarning = warning + } + return games + } + + private func fetchPs5CatalogV3(stored: String, forceRefresh: Bool) throws -> Ps5CloudCatalogResult { + if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { + return cached + } + lastCatalogFetchWarning = nil + for tier in CloudLocaleSettings.fallbackChain() { + guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: tier.imagic) else { continue } + if tier.canonical != stored { + CloudLocaleSettings.setStored(tier.canonical) + } + if fetched.shouldCacheV3, + !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { + cachePs5CatalogV3(fetched, locale: tier.canonical) + } + return fetched + } + throw NSError(domain: "CloudCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "All imagic locales failed to load"]) + } + + private func tryApolloRootFallback(accountCountry: String) -> [CloudGame] { + do { + return try fetchApolloRootCatalog(accountCountry: accountCountry) + } catch { + os_log(.info, log: catalogLog, "APOLLOROOT region-group fallback failed: %{public}s", + error.localizedDescription) + return [] + } + } + + private func isOwnershipVerificationFailure(_ warning: String?) -> Bool { + warning == Self.ownershipSessionWarning || warning == Self.ownershipNetworkWarning + } + // MARK: - PSNow Catalog - /// Fetch PSNow catalog (PS3/PS4 games) + /// Native PS Now catalog fetch (one APOLLOROOT walk: PS3 + PS4) using the account's own + /// /user/stores base_url. Mirrors Android PsnCatalogService.fetchNativeCatalog(). + func fetchNativeCatalog(npssoToken: String) -> (storesAvailable: Bool, authError: Bool, games: [CloudGame]) { + os_log(.info, log: catalogLog, "=== Starting PSNow (APOLLOROOT) native catalog fetch ===") + let duid = generateDuid() + + guard let oauthCode = fetchPsnowOAuthCode(npssoToken: npssoToken, duid: duid) else { + return (false, true, []) + } + guard let sessionId = createPsnowKamajiSession(oauthCode: oauthCode, duid: duid) else { + return (false, true, []) + } + guard let baseUrl = fetchPsnowStores(sessionId: sessionId) else { + return (false, false, []) + } + guard let categoryUrls = fetchPsnowRootContainer(baseUrl: baseUrl, sessionId: sessionId) else { + return (true, false, []) + } + + var allGames: [CloudGame] = [] + for (name, url) in categoryUrls { + os_log(.info, log: catalogLog, "Fetching category: %{public}s", name) + allGames += fetchPsnowCategoryGames(url: url) + } + + os_log(.info, log: catalogLog, "=== PSNow native catalog complete: %d games ===", allGames.count) + return (true, false, allGames) + } + + /// Fallback PS Now catalog fetch: walk the PUBLIC region-group APOLLOROOT container directly + /// (no OAuth/session). Mirrors Android PsnCatalogService.fetchApolloRootCatalog(). + func fetchApolloRootCatalog(accountCountry: String) throws -> [CloudGame] { + let storeCountry = ClassicsRegion.classicsStoreCountry(accountCountry) + let containerId = ClassicsRegion.apolloRootContainerId(accountCountry) + let containerUrl = "\(CloudApiConstants.storeBase)/container/\(storeCountry)/en/19/\(containerId)" + + os_log(.info, log: catalogLog, + "=== Fetching APOLLOROOT catalog (region group %{public}s for account %{public}s) ===", + storeCountry, accountCountry) + + var games: [CloudGame] = [] + var start = 0 + var totalResults = -1 + + while true { + let url = "\(containerUrl)?useOffers=true&gkb=1&gkb2=1&start=\(start)&size=100" + guard let response = CloudHttpClient.get(url: url, headers: [ + "Accept": "application/json", + "User-Agent": CloudApiConstants.kamajiUserAgent + ]) else { + if games.isEmpty { + throw NSError(domain: "CloudCatalog", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch APOLLOROOT catalog"]) + } + break + } + + if response.statusCode != 200 { + os_log(.info, log: catalogLog, "APOLLOROOT page fetch failed (HTTP %d)", response.statusCode) + if games.isEmpty { + throw NSError(domain: "CloudCatalog", code: response.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch APOLLOROOT catalog: HTTP \(response.statusCode)"]) + } + break + } + + guard let data = response.body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { break } + + if totalResults < 0 { + totalResults = (json["total_results"] as? Int) + ?? (json["total_results"] as? NSNumber)?.intValue ?? 0 + } + + let links = json["links"] as? [[String: Any]] ?? [] + var productCount = 0 + for link in links { + guard (link["container_type"] as? String) == "product" else { continue } + guard let game = parsePsnowGameObject(link) else { continue } + games.append(game) + productCount += 1 + } + + os_log(.info, log: catalogLog, + "APOLLOROOT page products: %d, accumulated: %d of %d", + productCount, games.count, totalResults) + + start += 100 + if productCount == 0 || start >= totalResults { break } + } + + os_log(.info, log: catalogLog, "APOLLOROOT catalog complete: %d titles", games.count) + return games + } + + /// Fetch PSNow catalog (PS3/PS4 games) — legacy per-tab path; prefer fetchUnifiedCatalog(). func fetchPsnowCatalog(npssoToken: String, forceRefresh: Bool = false) -> [CloudGame] { if !forceRefresh, let cached = loadCachedGames(Self.psnowCacheFile) { return cached @@ -658,7 +907,8 @@ final class CloudCatalogService { } /// Fetch the streamable PS3 Classics from the public Apollo container for the account's - /// region group. Mirrors Qt CloudCatalogBackend::fetchPs3Catalog. PUBLIC API: no auth. + /// region group. Deprecated: APOLLOROOT already includes PS3; use fetchUnifiedCatalog(). + @available(*, deprecated, message: "Use fetchUnifiedCatalog(); APOLLOROOT includes PS3") func fetchPs3Catalog(forceRefresh: Bool = false) -> [CloudGame] { let country = ps3AccountCountry() let storeCountry = ClassicsRegion.classicsStoreCountry(country) diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 64cf6bcf..ee8ab85f 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -143,24 +143,21 @@ final class PSKamajiSession { let sku: String } - // PS3 / Classics product ids (NPEA/NPEB/BLES/BCES or NPUA/NPUB/BLUS/BCUS — anything that - // isn't a modern CUSA/PPSA id) come from the public Apollo catalog, which we walk in the - // account's region group (Americas -> US store, everything else -> PAL/GB). Resolve them - // against that SAME region's container so the lookup finds the product and returns the PSNW - // entitlement the account is authorized for at Gaikai. The account's own locale country can - // be a region with no pcnow storefront (e.g. Hungary -> "Storefront not found"), so map to - // the region-group store. Must match CloudCatalogService's PS3 catalog source. - private var isLegacyClassicsId: Bool { - return !productId.contains("CUSA") && !productId.contains("PPSA") - } - + // Region-group fallback: when /user/stores has no storefront for the account's region, the + // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container + // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same + // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) var country = storePath.country var language = storePath.language - if isLegacyClassicsId { - country = ClassicsRegion.classicsStoreCountry(country) + let fallbackRegion = SecureStore.shared.cloudFallbackRegion + if !fallbackRegion.isEmpty { + country = fallbackRegion language = "en" + os_log(.info, log: kamajiLog, + "Fallback mode -> region-group container: country=%{public}s, language=%{public}s", + country, language) } let url = "\(storeBase)/container/\(country)/\(language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" os_log(.info, log: kamajiLog, "Store container locale: %{public}s", CloudLocaleSettings.stored) @@ -247,20 +244,15 @@ final class PSKamajiSession { if hasEntitlement == nil { return false } if hasEntitlement == true { return true } - // Entitlement not found (404). For PS3 / Classics (legacy non-CUSA/PPSA ids) the streaming - // entitlement is granted by the PS Plus subscription via a free 100%-off checkout, but that - // checkout requires a pcnow storefront in the account's region — which many regions (e.g. - // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". On a real PS5 - // the subscription alone grants streaming with no purchase, so skip the acquire and let - // Gaikai validate the Premium subscription directly. If Gaikai genuinely needs the - // entitlement, it returns noGameForEntitlementId downstream and we learn the wall is there. - if isLegacyClassicsId { + // Entitlement not found (404). Region-group fallback: skip acquire and proceed to Gaikai. + // Native (supported region): run the normal checkout-acquire for PS3 and PS4 alike. + if SecureStore.shared.isCloudFallbackMode { os_log(.info, log: kamajiLog, - "Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai") + "Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai") return true } - // PS4/PS5 catalog: try to acquire it via checkout. + // Native mode: try to acquire it via checkout. // Step 0.5e.3: Checkout preview guard step0_5e3_CheckoutPreview(sessionId: sessionId) else { return false } diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index 1d7b07ee..b4ffc42b 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -14,6 +14,10 @@ struct PsCloudEntitlement { let name: String let conceptId: String let featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC + // Structured platform from entitlement_attributes[].platform_id ("ps5"/"ps4"/"ps3"). This is the + // authoritative stream-backend signal -- NOT a CUSA/PPSA id prefix, since a cross-buy PS4 license + // can carry a PS5-looking product_id wrapper (Red Dead's PS4 license has product_id ...PPSA30528). + let platformId: String } enum PsCloudOwnership { @@ -78,13 +82,17 @@ enum PsCloudOwnership { // direct match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). func emit(_ meta: CloudGame, _ ent: PsCloudEntitlement) { let displayName = meta.name.isEmpty ? ent.name : meta.name + // The owned card's serviceType comes from the ENTITLEMENT's platform_id (pscloud == PS5, + // psnow == PS3/PS4), not the matched catalog row's: a cross-buy PS4 license can match a + // PS5 catalog row by shared product_id, but it must still route as PS4/Kamaji. + let ownedService = ownedServiceType(ent, meta) let game = CloudGame( productId: meta.id, name: displayName, imageUrl: meta.imageUrl, landscapeImageUrl: meta.landscapeImageUrl, platform: meta.platform, - serviceType: meta.serviceType, + serviceType: ownedService, conceptUrl: meta.conceptUrl, conceptId: meta.conceptId, isOwned: true, @@ -116,6 +124,8 @@ enum PsCloudOwnership { meta = g } else if !ent.id.isEmpty, let g = catalogMap[ent.id] { meta = g + // Inert in practice: PSN entitlements carry no conceptId (see findCatalogIndexForOwned note), so this + // platform-blind concept lookup almost never fires; owned games match by exact id above. } else if !ent.conceptId.isEmpty, let g = browseByConcept[ent.conceptId] { // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). meta = g @@ -228,7 +238,16 @@ enum PsCloudOwnership { // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. private static func ownedDedupeKey(meta: CloudGame, ent: PsCloudEntitlement) -> String { - if !meta.conceptId.isEmpty { return "c:\(meta.conceptId):\(platformToken(ent.productId))" } + // Platform from the ENTITLEMENT's structured platform_id (NOT the matched catalog row's, and NOT + // a product-id prefix): a cross-buy title gives the user up to three entitlements that resolve to + // ONE catalog row -- a clean PS4 (CUSA, platform_id ps4), a real PS5 (PPSA, platform_id ps5), and + // a PS5-wrapper PS4 license (id CUSA, product_id ...PPSA..., platform_id ps4). The real PS5 and + // the PS5-wrapper PS4 must stay in SEPARATE buckets (ps5 vs ps4) so the PS5 entitlement is not + // discarded by a same-key collision; the merge then stamps the PS5 card from the PS5 entitlement + // and DROPS the PS4 wrapper (it can't claim a PS5 card). Collapsing by the catalog row's platform + // instead let the wrapper win and threw away the real PS5 license (the Blood Omen / GTA V PS5 + // streaming failure). + if !meta.conceptId.isEmpty { return "c:\(meta.conceptId):\(entPlatform(ent))" } if !meta.id.isEmpty { return "p:\(meta.id)" } if !ent.id.isEmpty { return "e:\(ent.id)" } return "u:\(meta.id):\(ent.id)" @@ -270,12 +289,44 @@ enum PsCloudOwnership { return rank } - // conceptId + platform for an owned/catalog game; the owned product id (storeProductId) takes - // precedence so the owned edition's platform is used, else the catalog product id. + // conceptId + platform for an owned/catalog game. Platform comes from the canonical serviceType + // (pscloud == ps5, psnow == ps4-class) -- filled for owned cards from the entitlement's + // platform_id -- so an owned cross-buy PS4 license whose product_id is a PS5-looking wrapper + // buckets to the PS4 edition, not the PS5 one. Falls back to the product-id token when serviceType + // is absent (non-owned imagic browse rows, whose ids are clean). private static func conceptPlatformKey(_ game: CloudGame) -> String { guard !game.conceptId.isEmpty else { return "" } - let pid = game.storeProductId.isEmpty ? game.id : game.storeProductId - return "\(game.conceptId)|\(platformToken(pid))" + return "\(game.conceptId)|\(platformClassForCard(game))" + } + + /// Platform CLASS of a catalog/owned card (ps5 or ps4). serviceType is canonical when present; + /// non-owned imagic browse rows may only have a clean product-id token (PPSA/CUSA). Mirrors Qt + /// gamePlatformStructured + ps5CloudPlatformToken fallback in mergeOwnedIntoBrowseCatalog. + private static func platformClassForCard(_ game: CloudGame) -> String { + let st = game.serviceType.lowercased() + if st == "pscloud" { return "ps5" } + if st == "psnow" { return "ps4" } + return platformToken(game.storeProductId.isEmpty ? game.id : game.storeProductId) + } + + /// Rebuild a catalog row after merge stamping (serviceType is let, so we must replace the struct). + private static func stampMergedCard(_ existing: CloudGame, from owned: CloudGame, serviceType: String) -> CloudGame { + CloudGame( + productId: existing.id, + name: existing.name, + imageUrl: existing.imageUrl, + landscapeImageUrl: existing.landscapeImageUrl, + platform: existing.platform, + serviceType: serviceType, + conceptUrl: existing.conceptUrl, + conceptId: existing.conceptId, + isOwned: true, + entitlementId: owned.entitlementId.isEmpty ? existing.entitlementId : owned.entitlementId, + storeProductId: owned.storeProductId.isEmpty ? existing.storeProductId : owned.storeProductId, + plusCatalog: existing.plusCatalog, + featureType: owned.featureType != 0 ? owned.featureType : existing.featureType, + category: existing.category + ) } /// Tokenize on '-' and '_'; identity is all tokens except the last (store SKU). @@ -335,12 +386,26 @@ enum PsCloudOwnership { let isTrialTier = owned.featureType == 1 let catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) if catalogMatch >= 0 { - var existing = games[catalogMatch] - existing.isOwned = true - if !owned.entitlementId.isEmpty { existing.entitlementId = owned.entitlementId } - if !owned.storeProductId.isEmpty { existing.storeProductId = owned.storeProductId } - games[catalogMatch] = existing - continue + let existing = games[catalogMatch] + let ownedService = owned.serviceType.lowercased() + let existingService = existing.serviceType.lowercased() + let existingClass = platformClassForCard(existing) + // The card's stream identity must come from the OWNED entitlement of THIS card's + // platform. Cross-buy editions share one product_id (Red Dead's PS4 license and PS5 + // license both carry ...PPSA30528...), so matching by product_id alone lets a PS4 + // entitlement land on the PS5 card. Rule: a PS5 (pscloud) claim is authoritative; a + // PS4/PS3 (psnow) entitlement must NEVER overwrite a PS5-class card. Mirrors Qt + // mergeOwnedIntoBrowseCatalog exactly (cloudcatalogbackend.cpp). + if ownedService == "pscloud" { + games[catalogMatch] = stampMergedCard(existing, from: owned, serviceType: "pscloud") + continue + } + if ownedService == "psnow" && existingService != "pscloud" && existingClass != "ps5" { + games[catalogMatch] = stampMergedCard(existing, from: owned, serviceType: "psnow") + continue + } + // psnow entitlement whose matched card is PS5-class: not this card's edition -- fall + // through to addUnmatched; streamability gate drops non-viable wrappers (Qt path). } guard addUnmatched else { continue } @@ -364,6 +429,17 @@ enum PsCloudOwnership { ?? conceptIdString(gameMeta["concept_id"]) ?? conceptIdString(obj["conceptId"]) ?? "" + // Structured platform from entitlement_attributes[].platform_id. Sony also returns a numeric + // top-level "serviceType" here that is unrelated to our routing -- we never read it. + var platformId = "" + if let attrs = obj["entitlement_attributes"] as? [[String: Any]] { + // Scan for the first RECOGNIZED platform (ps5/ps4/ps3); skip any unknown value so a junk + // attribute ordered first can't shadow a real one (mirrors Qt ownedEntitlementServiceType). + for a in attrs { + guard let p = (a["platform_id"] as? String)?.lowercased() else { continue } + if p == "ps5" || p == "ps4" || p == "ps3" { platformId = p; break } + } + } return PsCloudEntitlement( id: id, productId: (obj["product_id"] as? String) ?? "", @@ -371,10 +447,49 @@ enum PsCloudOwnership { packageType: (gameMeta["package_type"] as? String) ?? "", name: name, conceptId: conceptId, - featureType: (obj["feature_type"] as? NSNumber)?.intValue ?? 0 + featureType: (obj["feature_type"] as? NSNumber)?.intValue ?? 0, + platformId: platformId ) } + // Canonical stream service for an owned entitlement from its structured platform_id: + // ps5 -> pscloud (cronos), ps4/ps3 -> psnow (Kamaji). Empty if platform_id is absent. + static func entServiceType(_ ent: PsCloudEntitlement) -> String { + switch ent.platformId { + case "ps5": return "pscloud" + case "ps4", "ps3": return "psnow" + default: return "" + } + } + + // Stream backend for an owned entitlement, with Qt's exact fallback (streamServiceTypeForGame): + // 1) the structured platform_id (authoritative -- a cross-buy PS4 wrapper has platform_id "ps4" + // even though its product_id is a PS5-looking PPSA, so it correctly stays psnow/Kamaji); else + // 2) the entitlement's own product-id TOKEN (CUSA = PS4/Kamaji, PPSA = PS5/cronos). PS Plus classics + // (e.g. Blood Omen, product ...PPSA24270...) carry NO platform_id and match a PS Now/Apollo + // (psnow) browse row by concept -- inheriting meta.serviceType would mis-route them to Kamaji and + // fail. The product-id token routes them to cronos like Qt. Only when neither token is present do + // we fall back to the matched row's serviceType. + private static func ownedServiceType(_ ent: PsCloudEntitlement, _ meta: CloudGame) -> String { + let svc = entServiceType(ent) + if !svc.isEmpty { return svc } + let tok = ent.productId + " " + ent.id + if tok.contains("CUSA") { return "psnow" } + if tok.contains("PPSA") { return "pscloud" } + return meta.serviceType + } + + // Platform class (ps5/ps4) for owned dedupe, from platform_id; falls back to the product-id token + // only when platform_id is absent (never relied on for the CUSA/PPSA wrapper-prone cross-buy case, + // which always carries a platform_id). + private static func entPlatform(_ ent: PsCloudEntitlement) -> String { + switch ent.platformId { + case "ps5": return "ps5" + case "ps4", "ps3": return "ps4" + default: return platformToken(ent.productId) + } + } + private static func buildCatalogIndex(_ games: [CloudGame]) -> CatalogIndex { var catalogIndex = CatalogIndex() for i in games.indices { @@ -396,6 +511,17 @@ enum PsCloudOwnership { } } + // IMPORTANT (this cost real debugging time): PSN *owned entitlements* carry NO conceptId in practice + // -- their game_meta is just { name, package_type, icon_url }. So every conceptId-based step below is + // effectively INERT for owned games: an owned entitlement resolves to a catalog row by EXACT ID ONLY + // (product_id -> entitlement id -> store product id). The conceptId machinery's live job is catalog-row + // edition dedup (edition / conceptPlatformKey), NOT owned->catalog matching. + // + // Also: a PS4 CROSS-BUY license can carry a PS5-looking PPSA *product_id* wrapper while its real PS4 + // component is the CUSA *id* (platform_id stays "ps4"). product_id is matched against the catalog FIRST, + // so such a ps4 license can land on a PS5 (PPSA) row. NEVER infer platform from a product-id prefix for + // owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge + // guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. private static func findCatalogIndexForOwned(_ owned: CloudGame, catalogIndex: CatalogIndex) -> Int { if !owned.id.isEmpty, let idx = catalogIndex.byProductId[owned.id] { return idx } if !owned.entitlementId.isEmpty, let idx = catalogIndex.byProductId[owned.entitlementId] { return idx } @@ -406,4 +532,84 @@ enum PsCloudOwnership { if !conceptKey.isEmpty, let idx = catalogIndex.byConceptId[conceptKey] { return idx } return -1 } + + // MARK: - Unified-page assembly: acquisition tag + concept-sibling streamability gate + + static let CATEGORY_OWNED = "owned" + static let CATEGORY_STREAMABLE = "streamable" + static let CATEGORY_PURCHASEABLE = "purchaseable" + + static func categoryFor(_ game: CloudGame) -> String { + if game.isOwned { return CATEGORY_OWNED } + if game.streamServiceType == "psnow" { return CATEGORY_STREAMABLE } + return CATEGORY_PURCHASEABLE + } + + /// Concept-sibling streamability gate index, built from the ACTUAL streamable catalog. + struct StreamabilityIndex { + private let productKeys: Set + private let streamableConceptIds: Set + + init( + apolloCatalog: [CloudGame], + imagicBrowse: [CloudGame], + imagicConceptRows: [CloudGame] + ) { + var keys = Set() + var conceptIds = Set() + + func addProduct(_ productId: String) { + guard !productId.isEmpty else { return } + keys.insert(productId) + if let stable = PsCloudOwnership.productIdStableKey(productId) { + keys.insert(stable) + } + } + + for game in apolloCatalog { addProduct(game.id) } + for game in imagicBrowse { + addProduct(game.id) + if !game.conceptId.isEmpty { conceptIds.insert(game.conceptId) } + } + for row in imagicConceptRows { + guard !row.conceptId.isEmpty else { continue } + let rowKeys = [row.id, PsCloudOwnership.productIdStableKey(row.id)].compactMap { $0 } + if rowKeys.contains(where: { keys.contains($0) }) { + conceptIds.insert(row.conceptId) + } + } + + productKeys = keys + streamableConceptIds = conceptIds + } + + func isStreamable(_ game: CloudGame) -> Bool { + for p in [game.id, game.storeProductId, game.entitlementId] { + guard !p.isEmpty else { continue } + if productKeys.contains(p) { return true } + if let stable = PsCloudOwnership.productIdStableKey(p), productKeys.contains(stable) { return true } + } + return !game.conceptId.isEmpty && streamableConceptIds.contains(game.conceptId) + } + } + + static func applyStreamabilityGate(_ games: [CloudGame], index: StreamabilityIndex) -> [CloudGame] { + var kept: [CloudGame] = [] + var dropped = 0 + for game in games { + if !game.isOwned || index.isStreamable(game) { + kept.append(game) + } else { + dropped += 1 + os_log(.info, log: ownershipLog, + "streamability gate: dropped owned non-streamable '%{public}s' (%{public}s)", + game.name, game.id) + } + } + if dropped > 0 { + os_log(.info, log: ownershipLog, + "streamability gate: dropped %d owned non-streamable titles", dropped) + } + return kept + } } diff --git a/ios/Pylux/Services/SecureStore.swift b/ios/Pylux/Services/SecureStore.swift index a38ac8ec..1b96bb55 100644 --- a/ios/Pylux/Services/SecureStore.swift +++ b/ios/Pylux/Services/SecureStore.swift @@ -156,6 +156,8 @@ final class SecureStore { // Cloud private let kCloudFavorites = "favorite_games" private let kCloudSortState = "cloud_sort_state" + private let kCloudFallbackRegion = "cloud_fallback_region" + private let kCloudTagFilters = "cloud_tag_filters" // Donation / support paywall private let kTotalStreamTimeMs = "pylux.totalStreamTimeMs" @@ -275,6 +277,20 @@ final class SecureStore { set { KC.writeInt(kCloudSortState, newValue) } } + /// PS Now region-group fallback. Empty = native mode; "US" or "GB" = fallback mode. + var cloudFallbackRegion: String { + get { KC.readString(kCloudFallbackRegion) ?? "" } + set { newValue.isEmpty ? KC.delete(kCloudFallbackRegion) : KC.writeString(kCloudFallbackRegion, newValue) } + } + + var isCloudFallbackMode: Bool { !cloudFallbackRegion.isEmpty } + + /// Persisted acquisition-tag filter selection (empty = show all). + var cloudTagFilters: Set { + get { KC.readStringSet(kCloudTagFilters) } + set { KC.writeStringSet(kCloudTagFilters, newValue) } + } + // MARK: - Donation / Support Paywall var totalStreamTimeMs: Int64 { @@ -359,7 +375,7 @@ final class SecureStore { kRegisteredHosts, kManualHosts, kDiscoveryActive, kStreamPrefs, kDcPscloud, kDcPsnow, - kCloudFavorites, kCloudSortState, + kCloudFavorites, kCloudSortState, kCloudFallbackRegion, kCloudTagFilters, kTotalStreamTimeMs, kLastDonationPromptWallMs, kDonationPaywallShowCount, kLastAppReviewPromptTotalStreamMs, kLastHost, kLastRegistKey, kLastMorning, kLastPs5, diff --git a/ios/Pylux/Views/CachedAsyncImage.swift b/ios/Pylux/Views/CachedAsyncImage.swift new file mode 100644 index 00000000..1b7ddff6 --- /dev/null +++ b/ios/Pylux/Views/CachedAsyncImage.swift @@ -0,0 +1,101 @@ +import SwiftUI +import UIKit + +// Drop-in replacement for SwiftUI's AsyncImage that fixes the "cover icons frequently don't load" +// problem on the Cloud Play grid. AsyncImage has two fatal flaws for a LazyVGrid of ~5000 cells: +// 1) NO memory cache -- it refetches the image every single time a cell reappears, and +// 2) it CANCELS the in-flight download when a cell is recycled / the view re-renders. +// Fast CDNs (image.api.playstation.com) finish before the cancel; slower ones +// (vulcan.dl.playstation.net, apollo2.dl.playstation.net) get cancelled mid-download (the flood of +// NSURLErrorDomain -999 "cancelled" in the logs) and AsyncImage never retries -> permanent gray +// placeholder. +// +// This version routes all loads through a SHARED loader whose download Tasks are owned by the loader, +// NOT by the view. So when a cell scrolls away the SwiftUI .task is cancelled but the underlying +// download keeps going, lands in the cache, and the next time that cell appears it renders instantly. +// Results are kept in an NSCache (memory) and the URLSession's URLCache (disk), and concurrent +// requests for the same URL are de-duplicated. + +enum CachedImagePhase { + case empty + case success(Image) + case failure(Error) +} + +actor CloudImageLoader { + static let shared = CloudImageLoader() + + private let memory: NSCache + private let session: URLSession + private var inFlight: [URL: Task] = [:] + + init() { + memory = NSCache() + memory.countLimit = 600 + let cfg = URLSessionConfiguration.default + cfg.urlCache = URLCache(memoryCapacity: 32 * 1024 * 1024, diskCapacity: 256 * 1024 * 1024) + cfg.requestCachePolicy = .returnCacheDataElseLoad + session = URLSession(configuration: cfg) + } + + nonisolated func cachedSync(_ url: URL) -> UIImage? { + // Synchronous memory hit so an already-loaded cover renders with zero flicker on reuse. + memory.object(forKey: url as NSURL) + } + + func image(for url: URL) async -> UIImage? { + if let img = memory.object(forKey: url as NSURL) { return img } + if let existing = inFlight[url] { return await existing.value } + let task = Task { [session, memory] in + do { + var request = URLRequest(url: url) + request.timeoutInterval = 30 + let (data, _) = try await session.data(for: request) + guard let img = UIImage(data: data) else { return nil } + memory.setObject(img, forKey: url as NSURL) + return img + } catch { + return nil + } + } + inFlight[url] = task + let result = await task.value + inFlight[url] = nil + return result + } +} + +struct CachedAsyncImage: View { + let url: URL? + @ViewBuilder let content: (CachedImagePhase) -> Content + + @State private var phase: CachedImagePhase = .empty + + init(url: URL?, @ViewBuilder content: @escaping (CachedImagePhase) -> Content) { + self.url = url + self.content = content + } + + var body: some View { + content(phase) + // .task(id:) re-runs when the URL changes and is cancelled when the cell is recycled -- + // but cancelling THIS only stops us awaiting; the shared loader's download continues and + // caches, so reappearing cells render instantly instead of restarting/cancelling forever. + .task(id: url) { + guard let url else { + phase = .empty + return + } + if let cached = CloudImageLoader.shared.cachedSync(url) { + phase = .success(Image(uiImage: cached)) + return + } + if case .success = phase { phase = .empty } + if let img = await CloudImageLoader.shared.image(for: url) { + phase = .success(Image(uiImage: img)) + } else { + phase = .failure(URLError(.cannotDecodeContentData)) + } + } + } +} diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 5a5b7ec7..fb0d90c4 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -10,21 +10,21 @@ private let cloudUILog = OSLog(subsystem: "com.pylux.stream", category: "CloudPl @MainActor final class CloudPlayViewModel: ObservableObject { - enum Section: String, CaseIterable, Identifiable { - case catalog = "Catalog" // PSNow (PS3/PS4) - case library = "Library" // PS5 Cloud (owned) - var id: String { rawValue } - } + static let tagFilterCategories = [ + PsCloudOwnership.CATEGORY_OWNED, + PsCloudOwnership.CATEGORY_STREAMABLE, + PsCloudOwnership.CATEGORY_PURCHASEABLE + ] + static let tagFilterLabels = ["Owned", "Streamable", "Store"] - // Sort orders matching Android CloudPlayFragment.kt (3 states: 0, 1, 2) enum SortOrder: Int, CaseIterable { - case defaultOrder = 0 // Recent for Catalog, Owned First for Library + case defaultOrder = 0 // Playable First case nameAsc = 1 // Name: A -> Z case nameDesc = 2 // Name: Z -> A - func label(for section: Section) -> String { + var label: String { switch self { - case .defaultOrder: return section == .library ? "Owned First" : "Recent" + case .defaultOrder: return "Playable First" case .nameAsc: return "Name: A \u{2192} Z" case .nameDesc: return "Name: Z \u{2192} A" } @@ -36,11 +36,11 @@ final class CloudPlayViewModel: ObservableObject { @Published var refreshing = false @Published var error: String? @Published var warning: String? - @Published var currentSection: Section = .library + @Published var fallbackRegion: String = SecureStore.shared.cloudFallbackRegion @Published var searchQuery = "" @Published var sortOrder: SortOrder = .defaultOrder @Published var showFavoritesOnly = false - @Published var showOwnedOnly = false // Library: false="All", true="Owned" (matches Android default=false) + @Published var activeTagFilters: Set = SecureStore.shared.cloudTagFilters @Published var favoriteIds: Set = CloudFavoritesManager.getFavorites() // Allocation state @@ -61,31 +61,47 @@ final class CloudPlayViewModel: ObservableObject { SecureStore.shared.cloudSortState = sortOrder.rawValue } + var filterSummary: String { + if activeTagFilters.isEmpty { return "All games" } + return Self.tagFilterCategories + .filter { activeTagFilters.contains($0) } + .map { tag in + Self.tagFilterLabels[Self.tagFilterCategories.firstIndex(of: tag) ?? 0] + } + .joined(separator: " · ") + } + var filteredGames: [CloudGame] { var result = games - // Favorites filter (matches Android CloudPlayFragment lines 772-778) + if !activeTagFilters.isEmpty { + result = result.filter { + let category = $0.category.isEmpty ? PsCloudOwnership.categoryFor($0) : $0.category + return activeTagFilters.contains(category) + } + } + if showFavoritesOnly { result = result.filter { favoriteIds.contains($0.id) } } - // Search if !searchQuery.isEmpty { let q = searchQuery.lowercased() - result = result.filter { $0.name.lowercased().contains(q) } + result = result.filter { + $0.name.lowercased().contains(q) || $0.id.lowercased().contains(q) + } } - // Sort (matches Android CloudPlayFragment lines 509-543) switch sortOrder { case .defaultOrder: - if currentSection == .library { - // Library default: owned first, then alphabetical - result.sort { - if $0.isOwned != $1.isOwned { return $0.isOwned && !$1.isOwned } - return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } + result.sort { + let c0 = $0.category.isEmpty ? PsCloudOwnership.categoryFor($0) : $0.category + let c1 = $1.category.isEmpty ? PsCloudOwnership.categoryFor($1) : $1.category + let p0 = c0 != PsCloudOwnership.CATEGORY_PURCHASEABLE + let p1 = c1 != PsCloudOwnership.CATEGORY_PURCHASEABLE + if p0 != p1 { return p0 && !p1 } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - // Catalog: keep original API order (no sort) case .nameAsc: result.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } case .nameDesc: @@ -94,69 +110,66 @@ final class CloudPlayViewModel: ObservableObject { return result } + func isTagFilterActive(_ tag: String) -> Bool { + activeTagFilters.isEmpty || activeTagFilters.contains(tag) + } + + func toggleTagFilter(_ tag: String) { + var next = activeTagFilters + if activeTagFilters.isEmpty { + next = Set(Self.tagFilterCategories.filter { $0 != tag }) + } else if next.contains(tag) { + next.remove(tag) + } else { + next.insert(tag) + } + normalizeAndPersistTagFilters(next) + } + + func setTagFilters(_ tags: Set) { + normalizeAndPersistTagFilters(tags) + } + + private func normalizeAndPersistTagFilters(_ tags: Set) { + let allTags = Set(Self.tagFilterCategories) + activeTagFilters = (tags.isEmpty || tags == allTags) ? [] : tags + SecureStore.shared.cloudTagFilters = activeTagFilters + } + func toggleFavorite(for game: CloudGame) { - let isFav = CloudFavoritesManager.toggleFavorite(game.id) + _ = CloudFavoritesManager.toggleFavorite(game.id) favoriteIds = CloudFavoritesManager.getFavorites() - // If favorites filter active and game was un-favorited, list auto-updates via filteredGames - _ = isFav // suppress unused warning } func loadGames(npssoToken: String) { loading = true error = nil warning = nil - let section = currentSection - let ownedOnly = showOwnedOnly Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - var loadedGames: [CloudGame] - - switch section { - case .catalog: - let psnow = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken) - // The legacy PS Now (Kamaji) browse store 404s in many regions. Fall back to the - // PS Plus subscription catalog (~630), NOT the full ~4000 universe — the Library - // "all" view is the full-universe browse. - loadedGames = psnow.isEmpty - ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken) - : psnow - // PS3 Classics are subscription-streamable, so they belong in the Game Catalog. - loadedGames += self.catalogService.fetchPs3Catalog() - case .library: - if ownedOnly { - loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken) - } else { - loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken) - // PS3 Classics are part of the streamable "all" universe (never the "owned" view). - loadedGames += self.catalogService.fetchPs3Catalog() - } - } - + let loadedGames = self.catalogService.fetchUnifiedCatalog(npssoToken: npssoToken) await MainActor.run { - self.applyLoadedGames(loadedGames, section: section) + self.applyLoadedGames(loadedGames) } } } - private func applyLoadedGames(_ loadedGames: [CloudGame], section: Section) { + private func applyLoadedGames(_ loadedGames: [CloudGame]) { games = loadedGames loading = false + fallbackRegion = SecureStore.shared.cloudFallbackRegion if let fetchError = catalogService.lastLibraryFetchError { error = fetchError } else if loadedGames.isEmpty { - error = section == .library - ? "No cloud games found. Check your connection." - : "Failed to load catalog. Check your connection." + error = "No cloud games found. Check your connection." } - if section == .library { - if let catalogWarning = catalogService.lastCatalogFetchWarning { - warning = catalogWarning - } else if let libraryWarning = catalogService.lastLibraryFetchWarning { - warning = libraryWarning - } else if !CloudLocaleSettings.isConfigured { - warning = CloudLocaleSettings.unconfiguredWarning() - } + if let catalogWarning = catalogService.lastCatalogFetchWarning { + warning = catalogWarning + } else if let libraryWarning = catalogService.lastLibraryFetchWarning { + warning = libraryWarning + } else if !CloudLocaleSettings.isConfigured { + warning = CloudLocaleSettings.unconfiguredWarning() } } @@ -166,11 +179,8 @@ final class CloudPlayViewModel: ObservableObject { loading = true error = nil warning = nil - let section = currentSection - let ownedOnly = showOwnedOnly Task.detached(priority: .userInitiated) { [weak self] in - var loadedGames: [CloudGame] = [] defer { Task { @MainActor in self?.loading = false @@ -178,29 +188,11 @@ final class CloudPlayViewModel: ObservableObject { } } guard let self = self else { return } - - switch section { - case .catalog: - let psnow = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken, forceRefresh: true) - // Fall back to the PS Plus subscription catalog when the legacy PS Now store is - // unavailable for the region (Library "all" is the full-universe browse). - loadedGames = psnow.isEmpty - ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken, forceRefresh: true) - : psnow - // PS3 Classics are subscription-streamable, so they belong in the Game Catalog. - loadedGames += self.catalogService.fetchPs3Catalog(forceRefresh: true) - case .library: - if ownedOnly { - loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: true) - } else { - loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken, forceRefresh: true) - // PS3 Classics are part of the streamable "all" universe (never the "owned" view). - loadedGames += self.catalogService.fetchPs3Catalog(forceRefresh: true) - } - } - + let loadedGames = self.catalogService.fetchUnifiedCatalog( + npssoToken: npssoToken, forceRefresh: true + ) await MainActor.run { - self.applyLoadedGames(loadedGames, section: section) + self.applyLoadedGames(loadedGames) } } } @@ -301,9 +293,19 @@ struct CloudPlayView: View { signInPrompt } else { VStack(spacing: 0) { - // Sub-tabs: Catalog / Library cloudSubTabs + if !viewModel.fallbackRegion.isEmpty { + Text("Cloud catalog isn't fully available in your region; some titles may not stream.") + .font(.caption) + .foregroundColor(.black.opacity(0.85)) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color(red: 1.0, green: 0.92, blue: 0.23)) + } + if let warning = viewModel.warning { Text(warning) .font(.caption) @@ -345,19 +347,6 @@ struct CloudPlayView: View { viewModel.loadGames(npssoToken: npssoToken) } } - .onChange(of: viewModel.currentSection) { _ in - if !npssoToken.isEmpty { - viewModel.games = [] - viewModel.loadGames(npssoToken: npssoToken) - } - } - .onChange(of: viewModel.showOwnedOnly) { _ in - // Re-fetch when toggling All/Owned (matches Android applyFilterState) - if viewModel.currentSection == .library && !npssoToken.isEmpty { - viewModel.games = [] - viewModel.loadGames(npssoToken: npssoToken) - } - } // Allocation progress overlay .overlay { if viewModel.allocating { @@ -417,52 +406,50 @@ struct CloudPlayView: View { private var cloudSubTabs: some View { HStack(spacing: 0) { - // Section tabs - fixed width, no wrapping - ForEach(CloudPlayViewModel.Section.allCases) { section in - let isSelected = viewModel.currentSection == section - Button { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.currentSection = section + Menu { + ForEach(Array(CloudPlayViewModel.tagFilterCategories.enumerated()), id: \.offset) { index, tag in + Button { + viewModel.toggleTagFilter(tag) + } label: { + HStack { + Text(CloudPlayViewModel.tagFilterLabels[index]) + if viewModel.isTagFilterActive(tag) { + Image(systemName: "checkmark") + } + } } - } label: { - Text(section.rawValue) - .font(.system(size: 13, weight: isSelected ? .bold : .medium)) - .foregroundColor(isSelected ? .white : .white.opacity(0.45)) - .lineLimit(1) - .fixedSize() - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule().fill(isSelected ? Color.white.opacity(0.12) : Color.clear) - ) } - } - - // Library: All / Owned toggle (matches Android applyFilterState) - if viewModel.currentSection == .library { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.showOwnedOnly.toggle() - } - } label: { - Text(viewModel.showOwnedOnly ? "Owned" : "All") - .font(.system(size: 11, weight: .bold)) + Divider() + Button("Show all") { + viewModel.setTagFilters([]) + } + } label: { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 12)) + Text(viewModel.filterSummary) + .font(.system(size: 12, weight: .medium)) .lineLimit(1) - .fixedSize() - .foregroundColor(viewModel.showOwnedOnly ? .green : .white.opacity(0.6)) - .padding(.horizontal, 7) - .padding(.vertical, 5) - .background( - Capsule().fill(viewModel.showOwnedOnly ? Color.green.opacity(0.15) : Color.white.opacity(0.08)) - ) } - .padding(.leading, 4) + .foregroundColor(viewModel.activeTagFilters.isEmpty ? .white.opacity(0.45) : .blue.opacity(0.9)) + .padding(.horizontal, 8) + .padding(.vertical, 6) } Spacer(minLength: 4) - // Icon buttons - compact HStack(spacing: 0) { + // Search toggle (left of favorites, matches Android header order) + Button { + withAnimation(.easeInOut(duration: 0.25)) { showSearch.toggle() } + if !showSearch { viewModel.searchQuery = "" } + } label: { + Image(systemName: showSearch ? "magnifyingglass.circle.fill" : "magnifyingglass") + .font(.system(size: 12)) + .foregroundColor(showSearch ? .white : .white.opacity(0.45)) + .frame(width: 28, height: 28) + } + // Favorites filter Button { withAnimation(.easeInOut(duration: 0.2)) { @@ -483,7 +470,7 @@ struct CloudPlayView: View { viewModel.persistSortOrder() } label: { HStack { - Text(order.label(for: viewModel.currentSection)) + Text(order.label) if viewModel.sortOrder == order { Image(systemName: "checkmark") } @@ -497,17 +484,6 @@ struct CloudPlayView: View { .frame(width: 28, height: 28) } - // Search toggle - Button { - withAnimation(.easeInOut(duration: 0.25)) { showSearch.toggle() } - if !showSearch { viewModel.searchQuery = "" } - } label: { - Image(systemName: showSearch ? "magnifyingglass.circle.fill" : "magnifyingglass") - .font(.system(size: 12)) - .foregroundColor(showSearch ? .white : .white.opacity(0.45)) - .frame(width: 28, height: 28) - } - // Refresh Button { viewModel.refreshGames(npssoToken: npssoToken) @@ -559,12 +535,9 @@ struct CloudPlayView: View { .padding(.vertical, 8) } - /// Any non-owned modern cloud-catalog game (PS4 or PS5) must be added to your library before it - /// can stream — Gaikai rejects an unowned PS5 entitlement, and modern PS-Plus PS4 titles (e.g. - /// Far Cry 5) have no free Kamaji SKU. Owned games stream directly. (Legacy PS Now is psnow.) private func handleGameTap(_ game: CloudGame) { - let isPscloud = game.serviceType.lowercased() == "pscloud" - if isPscloud && !game.isOwned { + let category = game.category.isEmpty ? PsCloudOwnership.categoryFor(game) : game.category + if category == PsCloudOwnership.CATEGORY_PURCHASEABLE { let url = game.conceptUrl.trimmingCharacters(in: .whitespacesAndNewlines) if url.isEmpty { showMissingConceptAlert = true @@ -596,8 +569,17 @@ struct CloudPlayView: View { .background(Capsule().fill(Color.yellow.opacity(0.15))) } + if viewModel.activeTagFilters.count > 0 && viewModel.activeTagFilters.count < CloudPlayViewModel.tagFilterCategories.count { + Text(viewModel.filterSummary) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.blue.opacity(0.9)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.blue.opacity(0.15))) + } + if viewModel.sortOrder != .defaultOrder { - Text(viewModel.sortOrder.label(for: viewModel.currentSection)) + Text(viewModel.sortOrder.label) .font(.system(size: 10, weight: .bold)) .foregroundColor(.white.opacity(0.5)) .padding(.horizontal, 6) @@ -615,7 +597,6 @@ struct CloudPlayView: View { CloudGameCardView( game: game, isFavorite: viewModel.favoriteIds.contains(game.id), - showOwnershipBadge: true, // owned/not-owned shown in Library AND Catalog (pscloud cards) onTap: { handleGameTap(game) }, @@ -823,7 +804,7 @@ struct CloudPlayView: View { @ViewBuilder private func allocationCoverThumbnail(width: CGFloat, height: CGFloat) -> some View { if let game = selectedGame { - AsyncImage(url: URL(string: game.imageUrl), transaction: Transaction(animation: nil)) { phase in + CachedAsyncImage(url: URL(string: game.imageUrl)) { phase in switch phase { case .success(let image): image @@ -848,12 +829,19 @@ struct CloudPlayView: View { struct CloudGameCardView: View { let game: CloudGame let isFavorite: Bool - let showOwnershipBadge: Bool // true only in Library section (matches Android adapter.showOwnershipBadge) let onTap: () -> Void let onFavoriteToggle: () -> Void @State private var starTapped = false // debounce visual + private var displayCategory: String { + if !game.category.isEmpty { return game.category } + // Fall back through the canonical tagger (streamServiceType-based), not raw serviceType: + // a non-owned PS4 cloud-browse row is serviceType="pscloud" but streams via PS Now, so it is + // "streamable", not "purchaseable". + return PsCloudOwnership.categoryFor(game) + } + var body: some View { GeometryReader { geo in ZStack { @@ -866,26 +854,12 @@ struct CloudGameCardView: View { bottomOverlay } - // Layer 3: Top overlays - ownership badge (left) + star (right) + // Layer 3: Top overlays - category badge (left) + star (right) VStack { HStack(alignment: .top, spacing: 0) { - // Top-left: Ownership badge (matches Android item_cloud_game.xml ownershipBadge) - if showOwnershipBadge && game.serviceType == "pscloud" { - Text(game.isOwned ? "Owned" : "Not Owned") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(game.isOwned - ? Color(red: 0.30, green: 0.69, blue: 0.31).opacity(0.85) // #4CAF50 green - : Color(red: 1.0, green: 0.60, blue: 0.0).opacity(0.85)) // #FF9800 orange - ) - .shadow(color: .black.opacity(0.5), radius: 2, y: 1) - .padding(.top, 6) - .padding(.leading, 6) - } + categoryBadge + .padding(.top, 6) + .padding(.leading, 6) Spacer() @@ -922,9 +896,33 @@ struct CloudGameCardView: View { .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } + @ViewBuilder + private var categoryBadge: some View { + let (label, color): (String, Color) = { + switch displayCategory { + case PsCloudOwnership.CATEGORY_OWNED: + return ("Owned", Color(red: 0.30, green: 0.69, blue: 0.31)) // #4CAF50 + case PsCloudOwnership.CATEGORY_STREAMABLE: + return ("Streamable", Color(red: 0.13, green: 0.59, blue: 0.95)) // #2196F3 + default: + return ("Add Game", Color(red: 1.0, green: 0.60, blue: 0.0)) // #FF9800 + } + }() + Text(label) + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(0.85)) + ) + .shadow(color: .black.opacity(0.5), radius: 2, y: 1) + } + @ViewBuilder private func coverImage(width: CGFloat, height: CGFloat) -> some View { - AsyncImage(url: URL(string: game.imageUrl), transaction: Transaction(animation: nil)) { phase in + CachedAsyncImage(url: URL(string: game.imageUrl)) { phase in switch phase { case .success(let image): // Use .fit so the full image is visible (no awkward cropping), @@ -950,7 +948,6 @@ struct CloudGameCardView: View { } } } - .id(game.imageUrl) .allowsHitTesting(false) } From 7d8230dbaf4257ff47d23f9375221df014c3058e Mon Sep 17 00:00:00 2001 From: forward technologies Date: Wed, 24 Jun 2026 15:56:51 -0700 Subject: [PATCH 09/72] Cloud catalog: deterministic owned-game dedupe + trial/cross-buy fixes across Qt, iOS, Android Port the Qt cloud-catalog merge fixes to iOS and Android for parity: - Deterministic owned-entitlement tiebreak (stream rank -> GS package -> sku_id -> product_id -> id) so the catalog is stable regardless of the PSN entitlements response order. - Suppress redundant trial (feature_type 1) cards when the same product is also fully owned (F2P cross-buy wrappers, e.g. Trackmania). - Process pscloud (PS5) owned claims before psnow (PS3/PS4) so a cross-buy PPSA wrapper is dropped cleanly instead of orphaning the browse row. - Drop psnow entitlements that land on a PS5-class card (cross-buy wrapper) rather than appending a bogus duplicate / ghost card. Adds sku_id to the parsed entitlement on iOS/Android for the deterministic tiebreak. Qt also restores serviceType stamping and the QML pscloud routing guard for PS1-classic store wrappers (Worms World Party). Co-authored-by: Cursor --- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 104 ++++++++++--- .../repository/CloudGameRepository.kt | 2 +- gui/src/cloudcatalogbackend.cpp | 139 ++++++++++++++++-- gui/src/qml/CloudGameCard.qml | 5 + ios/Pylux/Services/CloudCatalogService.swift | 2 +- ios/Pylux/Services/PsCloudOwnership.swift | 93 +++++++++--- 6 files changed, 294 insertions(+), 51 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt index bb3c3629..f36fe282 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt @@ -15,6 +15,7 @@ object PsCloudOwnership data class Entitlement( val id: String, val productId: String, + val skuId: String, // PSN sku_id -- stable unique key for deterministic dedupe tie-breaking val activeFlag: Boolean, val packageType: String, val name: String, @@ -81,6 +82,7 @@ object PsCloudOwnership return Entitlement( id = id, productId = obj.optString("product_id", ""), + skuId = obj.optString("sku_id", ""), activeFlag = obj.optBoolean("active_flag", false), packageType = gameMeta.optString("package_type", ""), name = name, @@ -151,7 +153,7 @@ object PsCloudOwnership val browseByConcept = buildConceptIdIndex(publicCatalog) val supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) val byKey = linkedMapOf() - val byKeyRank = mutableMapOf() + val byKeyEnt = mutableMapOf() // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping OUR // convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct @@ -163,27 +165,35 @@ object PsCloudOwnership // psnow == PS3/PS4), not the matched catalog row's: a cross-buy PS4 license can match a PS5 // catalog row by shared product_id, but it must still route as PS4/Kamaji. val ownedService = ownedServiceType(ent, meta) - val game = meta.copy( + // Qt emitOwned: productId = entitlement.product_id (NOT catalog meta.productId). + val streamProductId = ent.productId.ifEmpty { meta.productId } + val game = CloudGame( + productId = streamProductId, name = displayName, + imageUrl = meta.imageUrl, + landscapeImageUrl = meta.landscapeImageUrl, + thumbnailUrl = meta.thumbnailUrl, + platform = meta.platform, serviceType = ownedService, + conceptUrl = meta.conceptUrl, + conceptId = meta.conceptId, isOwned = true, entitlementId = ent.id, storeProductId = ent.productId, - featureType = ent.featureType + plusCatalog = meta.plusCatalog, + featureType = ent.featureType, + category = meta.category ) val key = ownedDedupeKey(meta, ent) - val candidateRank = ownedStreamRank(ent) - if (byKey[key] == null) + // Keep the best streaming candidate via a DETERMINISTIC total order (ownedEntitlementBetter), + // independent of the PSN entitlements response order, so the catalog is stable across + // refreshes (cross-buy titles with equal stream rank routinely tie). Mirrors Qt + // ps5CloudOwnedEntitlementBetter in cloudcatalogbackend.cpp. + val existingEnt = byKeyEnt[key] + if (existingEnt == null || ownedEntitlementBetter(ent, existingEnt)) { byKey[key] = game - byKeyRank[key] = candidateRank - } - // Keep the best streaming candidate: the canonical full-game entitlement (its product_id is - // the real streamable game, not a DLC/bonus product Gaikai rejects). - else if (candidateRank > (byKeyRank[key] ?: -1)) - { - byKey[key] = game - byKeyRank[key] = candidateRank + byKeyEnt[key] = ent } } @@ -303,7 +313,8 @@ object PsCloudOwnership Log.i(TAG, "disc-upgrade rescue: $discName $discPid -> $replacement") } - return byKey.values.toList() + // QMap iteration is sorted by dedupe key; merge depends on :ps4 before :ps5 (cloudcatalogbackend.cpp). + return byKey.keys.sorted().mapNotNull { byKey[it] } } // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen @@ -359,6 +370,31 @@ object PsCloudOwnership return rank } + // Is this a "Game Streaming" (GS) package? PSN labels the cloud-streamable SKU *GS (e.g. PS4GS) and + // the installable download *GD (PS4GD / PSGD). For a cross-buy title the GS entitlement carries the + // clean, streamable product for its platform, while the GD cross-buy SKU can carry a cross-gen + // *wrapper* product. So when two same-platform SKUs collapse to one edition, the GS SKU is the right + // streaming candidate. Uses the structured package_type field -- no product-id prefix guessing. + private fun isStreamingPackage(ent: Entitlement): Boolean = ent.packageType.endsWith("GS") + + // Deterministic total order over owned entitlements that collapse to the same edition (conceptId + + // platform). MUST be independent of the PSN entitlements response order so the assembled catalog is + // stable across refreshes. Returns true if `cand` should replace `cur` as the edition's + // representative. Signals, in priority order, all from structured API fields: (1) higher stream rank + // (canonical full-game product); (2) the cloud-streaming (GS) package over a download (GD) SKU; + // (3) stable unique sku_id, then product_id, then entitlement id, to guarantee one deterministic + // winner. Mirrors Qt ps5CloudOwnedEntitlementBetter (cloudcatalogbackend.cpp). + private fun ownedEntitlementBetter(cand: Entitlement, cur: Entitlement): Boolean + { + val rc = ownedStreamRank(cand); val ru = ownedStreamRank(cur) + if (rc != ru) return rc > ru + val gc = isStreamingPackage(cand); val gu = isStreamingPackage(cur) + if (gc != gu) return gc + if (cand.skuId != cur.skuId) return cand.skuId < cur.skuId + if (cand.productId != cur.productId) return cand.productId < cur.productId + return cand.id < cur.id + } + /** conceptId + platform. Platform comes from the canonical serviceType (pscloud == ps5, psnow == * ps4-class) -- filled for owned cards from the entitlement's platform_id -- so an owned cross-buy * PS4 license whose product_id is a PS5-looking wrapper buckets to the PS4 edition, not the PS5 @@ -441,12 +477,31 @@ object PsCloudOwnership val games = browseCatalog.toMutableList() val catalogIndex = buildCatalogIndex(games) - for (owned in ownedCrossRef) + // Products the user FULLY owns (feature_type != 1). A trial (ft1) is kept as its own card ONLY + // when the full game is NOT owned; when the SAME product is also held as a full license (common + // for F2P cross-buy titles: a PS4 trial whose product_id is the PS5 PPSA wrapper, e.g. Trackmania + // / Super Animal Royale / Fantasy Beauties) the trial card is redundant AND broken -- it routes + // to Kamaji (psnow) while carrying a PS5 product. Suppress those trials. Order-independent + // pre-pass. Mirrors Qt mergeOwnedIntoBrowseCatalog (cloudcatalogbackend.cpp). + val fullyOwnedProductIds = ownedCrossRef + .filter { it.featureType != 1 && it.productId.isNotEmpty() } + .map { it.productId } + .toHashSet() + + // Process pscloud (PS5) owned claims BEFORE psnow (PS3/PS4). A PS5 (pscloud) claim is + // authoritative and stamps the PS5 browse row in place; doing it first means the row is already + // owned by the time any PS4 cross-buy license (whose product_id is the SAME PPSA wrapper) is + // seen, so the wrapper is dropped cleanly instead of appending a duplicate / orphaning the + // browse row as a "ghost". Deterministic, order-independent. Stable partition. (Qt parity.) + val ownedOrdered = ownedCrossRef.filter { it.serviceType.lowercase() == "pscloud" } + + ownedCrossRef.filter { it.serviceType.lowercase() != "pscloud" } + + for (owned in ownedOrdered) { - // Trials / free-to-play (feature_type 1) are kept as their OWN card so the user can Stream - // the trial/free build, while the full version still shows separately as a not-owned - // "Add Game" card -- so a trial must NOT collapse into the full-game catalog entry. - val catalogMatch = if (owned.featureType == 1) -1 else findCatalogIndexForOwned(owned, catalogIndex) + val isTrialTier = owned.featureType == 1 + // A trial whose product is also fully owned is superseded by the full license -- drop it. + if (isTrialTier && fullyOwnedProductIds.contains(owned.productId)) continue + val catalogMatch = if (isTrialTier) -1 else findCatalogIndexForOwned(owned, catalogIndex) if (catalogMatch >= 0) { val existing = games[catalogMatch] @@ -479,8 +534,12 @@ object PsCloudOwnership ) continue } - // psnow entitlement whose matched card is PS5-class: not this card's edition -- fall - // through to addUnmatched; streamability gate drops non-viable wrappers (Qt path). + // psnow entitlement whose matched card is PS5-class: this is a PS4 CROSS-BUY license + // whose product_id is the shared PS5 (PPSA) wrapper. DROP it (Qt parity): the PS5 card + // is claimed by the PS5 (pscloud) license processed first, the real PS4 variant matches + // its own CUSA row independently, and appending here would create a bogus duplicate / + // ghost that can't stream (a PS5 cloud product needs a PS5 entitlement). + if (ownedService == "psnow") continue } if (!addUnmatched) continue @@ -586,7 +645,8 @@ object PsCloudOwnership { if (owned.productId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.productId)) return catalogIndex.byProductId.getValue(owned.productId) - if (owned.entitlementId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.entitlementId)) + if (owned.entitlementId.isNotEmpty() && owned.entitlementId != owned.productId + && catalogIndex.byProductId.containsKey(owned.entitlementId)) return catalogIndex.byProductId.getValue(owned.entitlementId) if (owned.storeProductId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.storeProductId)) return catalogIndex.byProductId.getValue(owned.storeProductId) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index b20855d8..3cf2a6a8 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -47,7 +47,7 @@ class CloudGameRepository( Log.w(TAG, "Error invalidating catalog cache", e) } } - private const val UNIFIED_CACHE_FILE = "unified_catalog_v4.json" // v4: Qt-aligned merge (existingClass guard + explicit pscloud/psnow serviceType stamp) + private const val UNIFIED_CACHE_FILE = "unified_catalog_v5.json" // v5: Qt emitOwned productId + QMap-sorted cross-ref merge order private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 5ee4bff0..545fbac4 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -1482,6 +1482,47 @@ static int ps5CloudOwnedStreamRank(const QJsonObject &ownedGameObj) return rank; } +// Is this a "Game Streaming" (GS) package? PSN labels the cloud-streamable SKU *GS (e.g. PS4GS) and +// the installable download *GD (PS4GD / PSGD). For a cross-buy title the GS entitlement carries the +// clean, streamable product for its platform, while the GD cross-buy entitlement can carry a +// cross-generation *wrapper* product. So when two same-platform SKUs collapse to one edition, the GS +// SKU is the right streaming candidate. Uses the structured game_meta.package_type field (the same +// field ps5CloudIsFullGameEntitlement reads) -- no product-id prefix guessing. +static bool ps5CloudIsStreamingPackage(const QJsonObject &ownedGameObj) +{ + const QString pt = ownedGameObj.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("package_type")).toString(); + return pt.endsWith(QStringLiteral("GS")); +} + +// Deterministic total order over owned entitlements that collapse to the same edition (conceptId + +// platform). MUST be independent of the PSN entitlements response order so the assembled catalog is +// stable across refreshes (cross-buy titles with equal stream rank routinely tie). Returns true if +// `cand` should replace `cur` as the edition's representative. Signals, in priority order, all from +// structured API fields: (1) higher stream rank (canonical full-game product); (2) the cloud- +// streaming (GS) package over a download (GD) SKU; (3) stable unique sku_id, then product_id, then +// entitlement id, purely to guarantee a single deterministic winner. +static bool ps5CloudOwnedEntitlementBetter(const QJsonObject &cand, const QJsonObject &cur) +{ + const int rc = ps5CloudOwnedStreamRank(cand); + const int ru = ps5CloudOwnedStreamRank(cur); + if (rc != ru) + return rc > ru; + const bool gc = ps5CloudIsStreamingPackage(cand); + const bool gu = ps5CloudIsStreamingPackage(cur); + if (gc != gu) + return gc; // prefer the cloud-streaming (GS) SKU + const QString sc = cand.value(QStringLiteral("sku_id")).toString(); + const QString su = cur.value(QStringLiteral("sku_id")).toString(); + if (sc != su) + return sc < su; + const QString pc = cand.value(QStringLiteral("product_id")).toString(); + const QString pu = cur.value(QStringLiteral("product_id")).toString(); + if (pc != pu) + return pc < pu; + return cand.value(QStringLiteral("id")).toString() < cur.value(QStringLiteral("id")).toString(); +} + static QString ps5CloudProductIdStableKey(const QString &productId) { if (productId.isEmpty()) @@ -1735,11 +1776,56 @@ static QJsonArray mergeOwnedIntoBrowseCatalog(const QJsonArray &browseCatalog, QJsonArray games = browseCatalog; CatalogIndexMaps catalogIndex = buildCatalogIndex(games); + // Products the user FULLY owns (feature_type 3/5, i.e. not a trial). A trial (ft1) is normally + // kept as its own card so the free/trial build streams while the full game shows "Add Game" -- + // but only when the full game is NOT owned. When the SAME product is also held as a full license + // (common for F2P cross-buy titles: a PS4 free/trial entitlement whose product_id is the PS5 PPSA + // wrapper, e.g. Trackmania / Super Animal Royale / Fantasy Beauties), the trial card is redundant + // AND broken -- it routes to Kamaji (psnow, from its CUSA id) while carrying a PS5 PPSA product + // id, which Kamaji rejects. Suppress those trials below. Order-independent pre-pass. + QSet fullyOwnedProductIds; for (const QJsonValue &ownedVal : ownedCrossRef) { + if (!ownedVal.isObject()) + continue; + const QJsonObject o = ownedVal.toObject(); + if (o.value(QStringLiteral("feature_type")).toInt() == 1) + continue; + const QString pid = gameProductId(o); + if (!pid.isEmpty()) + fullyOwnedProductIds.insert(pid); + } + + // Process pscloud (PS5) owned claims BEFORE psnow (PS3/PS4). A PS5 (pscloud) claim is + // authoritative and stamps the PS5 browse row in place; doing it first means the row is already + // owned by the time any PS4 cross-buy license (whose product_id is the SAME PPSA wrapper) is + // seen, so the wrapper can be dropped cleanly instead of appending a duplicate / orphaning the + // browse row as a "ghost". Without this, Qt's owned set arrives QMap-sorted (c::ps4 + // before c::ps5), so the wrapper is processed first and shadows the index. Stable + // partition: relative order is otherwise preserved. + QList ownedOrdered; + ownedOrdered.reserve(ownedCrossRef.size()); + for (const QJsonValue &ownedVal : ownedCrossRef) { + if (ownedVal.isObject() + && ownedVal.toObject().value(QStringLiteral("serviceType")).toString().toLower() + == QLatin1String("pscloud")) + ownedOrdered.append(ownedVal); + } + for (const QJsonValue &ownedVal : ownedCrossRef) { + if (ownedVal.isObject() + && ownedVal.toObject().value(QStringLiteral("serviceType")).toString().toLower() + != QLatin1String("pscloud")) + ownedOrdered.append(ownedVal); + } + + for (const QJsonValue &ownedVal : ownedOrdered) { if (!ownedVal.isObject()) continue; QJsonObject ownedGame = ownedVal.toObject(); const bool isTrialTier = ownedGame.value(QStringLiteral("feature_type")).toInt() == 1; + // A trial whose product is also fully owned is superseded by the full license -- drop it + // (it would otherwise append a redundant, unstreamable PS4/Kamaji card for a PS5 product). + if (isTrialTier && fullyOwnedProductIds.contains(gameProductId(ownedGame))) + continue; const int catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(ownedGame, catalogIndex); if (catalogMatch >= 0) { @@ -1791,10 +1877,18 @@ static QJsonArray mergeOwnedIntoBrowseCatalog(const QJsonArray &browseCatalog, continue; } // psnow entitlement whose matched card is PS5-class (serviceType=pscloud, OR an unstamped - // imagic browse row whose product-id token is PPSA): this PS4 cross-buy license is not this - // card's edition. Leave the PS5 card untouched and fall through to add it as its own (PS4) - // card / register it for a later same-platform match. (Without the platform-class check, an - // empty serviceType on an imagic PS5 row would let a CUSA id be stamped onto a cronos card.) + // imagic browse row whose product-id token is PPSA): this is a PS4 CROSS-BUY license whose + // product_id is the shared PS5 (PPSA) wrapper. It is NOT a separate streamable edition -- + // the real PS4 variant (if any) matches its own CUSA catalog row independently, and the PS5 + // card is claimed by the PS5 (pscloud) license (processed first, above). DROP it: appending + // it produces a bogus duplicate PS4 card and, depending on merge order, orphans the browse + // row as a "ghost" purchaseable card. Streaming such a wrapper would fail anyway + // (noGameForEntitlementId), since the PS5 cloud variant needs a PS5 entitlement. + if (ownedService == QLatin1String("psnow")) { + continue; + } + // Any other matched-but-unstamped case (e.g. owned entitlement with no serviceType): fall + // through to add it as its own card / register it for a later same-platform match. } if (!addUnmatched) @@ -2014,16 +2108,36 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, void CloudCatalogBackend::assembleUnifiedCatalog(const QJsonArray &ownedCrossRef) { + // Routing source of truth for browse rows: an imagic PS5 row is streamed via cronos (pscloud) + // UNLESS the same product also appears in the Apollo (PS Now) catalog, in which case it is a + // Kamaji (psnow) title. Apollo membership is the authoritative psnow signal; everything else + // PS5 defaults to pscloud. (A/B testing proved the ghost cards are a separate merge-order bug, + // not caused by this stamping, so it is restored: it is required for correct Worms-style routing + // and PS5 badges.) QJsonArray apolloNormalized; + QSet apolloProductIds; for (const QJsonValue &v : unifiedState.apolloGames) { - if (v.isObject()) - apolloNormalized.append(normalizeApolloGame(v.toObject())); + if (v.isObject()) { + const QJsonObject g = normalizeApolloGame(v.toObject()); + apolloNormalized.append(g); + const QString pid = gameProductId(g); + if (!pid.isEmpty()) + apolloProductIds.insert(pid); + } } QJsonArray ps5Browse; for (const QJsonValue &v : unifiedState.imagicBrowse) { - if (v.isObject() && isPs5PlatformGame(v.toObject())) - ps5Browse.append(v); + if (!v.isObject() || !isPs5PlatformGame(v.toObject())) + continue; + QJsonObject g = v.toObject(); + const QString existing = g.value(QStringLiteral("serviceType")).toString().toLower(); + if (existing != QLatin1String("psnow") && existing != QLatin1String("pscloud")) { + const bool inApollo = apolloProductIds.contains(gameProductId(g)); + g.insert(QStringLiteral("serviceType"), + inApollo ? QStringLiteral("psnow") : QStringLiteral("pscloud")); + } + ps5Browse.append(g); } QJsonArray universe = apolloNormalized; @@ -3505,7 +3619,14 @@ void CloudCatalogBackend::processCrossReferenceComplete() const QJsonObject existing = ownedByKey.value(dedupeKey); // Keep the best streaming candidate: the canonical full-game entitlement (its // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). - if (ps5CloudOwnedStreamRank(entry) > ps5CloudOwnedStreamRank(existing)) + // The choice MUST be deterministic -- the PSN entitlements response order is NOT + // guaranteed, so a plain rank ">" (first-inserted wins on a tie) would let the + // catalog flip between refreshes (cross-buy titles with no platform_id routinely tie). + // Total order: (1) higher stream rank; (2) prefer a product_id whose platform token + // matches the entitlement's own platform (a psnow/PS4 license prefers its CUSA product + // over a cross-buy PPSA wrapper, so the card streams a product the backend accepts); + // (3) lexicographically smallest product_id; (4) smallest entitlement id. + if (ps5CloudOwnedEntitlementBetter(entry, existing)) ownedByKey.insert(dedupeKey, entry); } else { ownedByKey.insert(dedupeKey, entry); diff --git a/gui/src/qml/CloudGameCard.qml b/gui/src/qml/CloudGameCard.qml index f0919aae..608b4903 100644 --- a/gui/src/qml/CloudGameCard.qml +++ b/gui/src/qml/CloudGameCard.qml @@ -47,6 +47,11 @@ Rectangle { function isPsnowGame() { if (!gameData) return false; + // Canonical serviceType is authoritative. A pscloud row streams via Gaikai (PS5/cronos) + // and is NEVER PS Now, even for PS1-classic CUSA store wrappers (e.g. Worms World Party, + // whose owned row is serviceType=pscloud but carries a ...CUSA... productId). The CUSA/PPSA + // token heuristic below is only a fallback for rows that have no serviceType. + if (gameData.serviceType === "pscloud") return false; if (gameData.serviceType === "psnow" || gameData.category === "streamable") return true; let p = String(streamProductId()); diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index cd2885da..e7978020 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -21,7 +21,7 @@ final class CloudCatalogService { private static let ps5PublicCacheFile = "ps5_cloud_catalog_v4.json" // v4: adds plusCatalog tag + broader supplement private static let pscloudAllCacheFile = "pscloud_catalog_v2.json" private static let pscloudOwnedCacheFile = "pscloud_owned_v4.json" // v4: serviceType from platform_id - private static let unifiedCacheFile = "unified_catalog_v4.json" // v4: Qt-aligned merge (existingClass guard + explicit pscloud/psnow serviceType stamp) + private static let unifiedCacheFile = "unified_catalog_v5.json" // v5: Qt emitOwned productId + QMap-sorted cross-ref merge order private static let ownershipSessionWarning = "Your PlayStation session has expired. Please log in again to see your owned games." diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index b4ffc42b..f50c6ffc 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -9,6 +9,7 @@ private let ownershipLog = OSLog(subsystem: "com.pylux.stream", category: "Cloud struct PsCloudEntitlement { let id: String let productId: String + let skuId: String // PSN sku_id -- stable unique key for deterministic dedupe tie-breaking let activeFlag: Bool let packageType: String let name: String @@ -75,7 +76,7 @@ enum PsCloudOwnership { let supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) var byKey: [String: CloudGame] = [:] - var byKeyRank: [String: Int] = [:] + var byKeyEnt: [String: PsCloudEntitlement] = [:] // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping // OUR convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a @@ -86,8 +87,11 @@ enum PsCloudOwnership { // psnow == PS3/PS4), not the matched catalog row's: a cross-buy PS4 license can match a // PS5 catalog row by shared product_id, but it must still route as PS4/Kamaji. let ownedService = ownedServiceType(ent, meta) + // Qt emitOwned field convention: productId = entitlement.product_id (NOT catalog meta.id); + // entitlementId = entitlement.id. Merge + QMap sort rely on these being separate for cross-buy. + let streamProductId = ent.productId.isEmpty ? meta.id : ent.productId let game = CloudGame( - productId: meta.id, + productId: streamProductId, name: displayName, imageUrl: meta.imageUrl, landscapeImageUrl: meta.landscapeImageUrl, @@ -101,17 +105,18 @@ enum PsCloudOwnership { featureType: ent.featureType ) let key = ownedDedupeKey(meta: meta, ent: ent) - let candidateRank = ownedStreamRank(ent) - if byKey[key] != nil { - // Keep the best streaming candidate: the canonical full-game entitlement (its - // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). - if candidateRank > (byKeyRank[key] ?? -1) { + // Keep the best streaming candidate via a DETERMINISTIC total order (ownedEntitlementBetter), + // independent of the PSN entitlements response order, so the catalog is stable across + // refreshes (cross-buy titles with equal stream rank routinely tie). Mirrors Qt + // ps5CloudOwnedEntitlementBetter in cloudcatalogbackend.cpp. + if let existingEnt = byKeyEnt[key] { + if ownedEntitlementBetter(ent, existingEnt) { byKey[key] = game - byKeyRank[key] = candidateRank + byKeyEnt[key] = ent } } else { byKey[key] = game - byKeyRank[key] = candidateRank + byKeyEnt[key] = ent } } @@ -231,7 +236,8 @@ enum PsCloudOwnership { discName, discPid, rep) } - return Array(byKey.values) + // QMap iteration is sorted by dedupe key; merge depends on :ps4 before :ps5 (cloudcatalogbackend.cpp). + return byKey.keys.sorted().compactMap { byKey[$0] } } // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen @@ -289,6 +295,32 @@ enum PsCloudOwnership { return rank } + // Is this a "Game Streaming" (GS) package? PSN labels the cloud-streamable SKU *GS (e.g. PS4GS) and + // the installable download *GD (PS4GD / PSGD). For a cross-buy title the GS entitlement carries the + // clean, streamable product for its platform, while the GD cross-buy SKU can carry a cross-gen + // *wrapper* product. So when two same-platform SKUs collapse to one edition, the GS SKU is the right + // streaming candidate. Uses the structured package_type field -- no product-id prefix guessing. + private static func isStreamingPackage(_ ent: PsCloudEntitlement) -> Bool { + ent.packageType.hasSuffix("GS") + } + + // Deterministic total order over owned entitlements that collapse to the same edition (conceptId + + // platform). MUST be independent of the PSN entitlements response order so the assembled catalog is + // stable across refreshes. Returns true if `cand` should replace `cur` as the edition's + // representative. Signals, in priority order, all from structured API fields: (1) higher stream rank + // (canonical full-game product); (2) the cloud-streaming (GS) package over a download (GD) SKU; + // (3) stable unique sku_id, then product_id, then entitlement id, to guarantee one deterministic + // winner. Mirrors Qt ps5CloudOwnedEntitlementBetter (cloudcatalogbackend.cpp). + private static func ownedEntitlementBetter(_ cand: PsCloudEntitlement, _ cur: PsCloudEntitlement) -> Bool { + let rc = ownedStreamRank(cand), ru = ownedStreamRank(cur) + if rc != ru { return rc > ru } + let gc = isStreamingPackage(cand), gu = isStreamingPackage(cur) + if gc != gu { return gc } + if cand.skuId != cur.skuId { return cand.skuId < cur.skuId } + if cand.productId != cur.productId { return cand.productId < cur.productId } + return cand.id < cur.id + } + // conceptId + platform for an owned/catalog game. Platform comes from the canonical serviceType // (pscloud == ps5, psnow == ps4-class) -- filled for owned cards from the entitlement's // platform_id -- so an owned cross-buy PS4 license whose product_id is a PS5-looking wrapper @@ -379,11 +411,28 @@ enum PsCloudOwnership { var games = browseCatalog var catalogIndex = buildCatalogIndex(games) - for owned in ownedCrossRef { - // Trials / free-to-play (feature_type 1) are kept as their OWN card so the user can Stream - // the trial/free build, while the full version still shows separately as a not-owned - // "Add Game" card -- so a trial must NOT collapse into the full-game catalog entry. + // Products the user FULLY owns (feature_type != 1). A trial (ft1) is kept as its own card ONLY + // when the full game is NOT owned; when the SAME product is also held as a full license (common + // for F2P cross-buy titles: a PS4 trial whose product_id is the PS5 PPSA wrapper, e.g. Trackmania + // / Super Animal Royale / Fantasy Beauties) the trial card is redundant AND broken -- it routes + // to Kamaji (psnow) while carrying a PS5 product. Suppress those trials. Order-independent + // pre-pass. Mirrors Qt mergeOwnedIntoBrowseCatalog (cloudcatalogbackend.cpp). + let fullyOwnedProductIds = Set( + ownedCrossRef.filter { $0.featureType != 1 }.map { $0.id }.filter { !$0.isEmpty } + ) + + // Process pscloud (PS5) owned claims BEFORE psnow (PS3/PS4). A PS5 (pscloud) claim is + // authoritative and stamps the PS5 browse row in place; doing it first means the row is already + // owned by the time any PS4 cross-buy license (whose product_id is the SAME PPSA wrapper) is + // seen, so the wrapper is dropped cleanly instead of appending a duplicate / orphaning the + // browse row as a "ghost". Deterministic, order-independent. Stable partition. (Qt parity.) + let ownedOrdered = ownedCrossRef.filter { $0.serviceType.lowercased() == "pscloud" } + + ownedCrossRef.filter { $0.serviceType.lowercased() != "pscloud" } + + for owned in ownedOrdered { let isTrialTier = owned.featureType == 1 + // A trial whose product is also fully owned is superseded by the full license -- drop it. + if isTrialTier && fullyOwnedProductIds.contains(owned.id) { continue } let catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) if catalogMatch >= 0 { let existing = games[catalogMatch] @@ -404,8 +453,12 @@ enum PsCloudOwnership { games[catalogMatch] = stampMergedCard(existing, from: owned, serviceType: "psnow") continue } - // psnow entitlement whose matched card is PS5-class: not this card's edition -- fall - // through to addUnmatched; streamability gate drops non-viable wrappers (Qt path). + // psnow entitlement whose matched card is PS5-class: this is a PS4 CROSS-BUY license + // whose product_id is the shared PS5 (PPSA) wrapper. DROP it (Qt parity): the PS5 card + // is claimed by the PS5 (pscloud) license processed first, the real PS4 variant matches + // its own CUSA row independently, and appending here would create a bogus duplicate / + // ghost that can't stream (a PS5 cloud product needs a PS5 entitlement). + if ownedService == "psnow" { continue } } guard addUnmatched else { continue } @@ -443,6 +496,7 @@ enum PsCloudOwnership { return PsCloudEntitlement( id: id, productId: (obj["product_id"] as? String) ?? "", + skuId: (obj["sku_id"] as? String) ?? "", activeFlag: (obj["active_flag"] as? Bool) ?? false, packageType: (gameMeta["package_type"] as? String) ?? "", name: name, @@ -523,8 +577,11 @@ enum PsCloudOwnership { // owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge // guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. private static func findCatalogIndexForOwned(_ owned: CloudGame, catalogIndex: CatalogIndex) -> Int { - if !owned.id.isEmpty, let idx = catalogIndex.byProductId[owned.id] { return idx } - if !owned.entitlementId.isEmpty, let idx = catalogIndex.byProductId[owned.entitlementId] { return idx } + // Mirrors Qt findCatalogIndexForOwned: product_id, entitlement id, store product id, concept+platform. + let productId = owned.id + if !productId.isEmpty, let idx = catalogIndex.byProductId[productId] { return idx } + if !owned.entitlementId.isEmpty, owned.entitlementId != productId, + let idx = catalogIndex.byProductId[owned.entitlementId] { return idx } if !owned.storeProductId.isEmpty, let idx = catalogIndex.byProductId[owned.storeProductId] { return idx } // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog // entry (and vice-versa); cross-gen editions stay as separate library cards. From 0063eec2473f940c08aea27b559fb478ffa86f54 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Wed, 24 Jun 2026 17:32:42 -0700 Subject: [PATCH 10/72] Cloud catalog: surface expired-session warning + device-based browse parity across Qt, iOS, Android Expired-NPSSO handling (Qt, iOS, Android): - On native PS Now auth failure, surface the "log in again" warning and STOP: do not fall back to the public APOLLOROOT walk (that path is only for region-unsupported accounts) and do not cache the degraded catalog. - iOS/Android no longer let the PS5 (imagic) fetch clobber the session warning; Qt's warning string is aligned to match mobile. Catalog parity (Qt, iOS, Android now emit identical card sets): - Decide PS5-platform browse membership from the authoritative imagic `device` array (or PPSA id), not the CUSA/PPSA productId token, so cross-gen titles (PS4 SKU with PS5 device support) are no longer dropped on mobile. - Skip imagic browse rows already present in the Apollo (PS Now) catalog so a title in both lists is not emitted twice (Crow Country / Grandia / HUMANITY). - categoryFor now resolves the catalog category from serviceType the same way Qt does (psnow and pscloud both short-circuit), independent of the routing-only streamServiceType isOwned gate, so non-owned pscloud PS4 rows are purchaseable. Verified: Qt, iOS, and Android unified caches converge to identical totals and per-card category/serviceType/ownership (4930 / 97 owned / 780 streamable / 4053 purchaseable), zero duplicates, identical productId sets. Co-authored-by: Cursor --- .../cloudplay/api/PsCloudCatalogService.kt | 18 +++++++- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 16 ++++++- .../chiaki/cloudplay/model/CloudGame.kt | 7 ++- .../repository/CloudGameRepository.kt | 44 +++++++++++++----- gui/src/cloudcatalogbackend.cpp | 32 ++++++++++--- ios/Pylux/Models/CloudModels.swift | 8 +++- ios/Pylux/Services/CloudCatalogService.swift | 45 ++++++++++++++++--- ios/Pylux/Services/PsCloudOwnership.swift | 18 +++++++- 8 files changed, 158 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt index f5876fcb..99cff01d 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt @@ -300,9 +300,25 @@ class PsCloudCatalogService conceptUrl = conceptUrl, conceptId = conceptKey(gameObj), isOwned = false, - plusCatalog = gameObj.optBoolean("plusCatalog", false) + plusCatalog = gameObj.optBoolean("plusCatalog", false), + // Authoritative PS5-platform membership from the imagic `device` array (NOT the CUSA/PPSA + // token). A cross-gen title with a PS4 CUSA SKU but "PS5" in `device` is a PS5 browse row + // and must enter the streamable universe (mirrors Qt isPs5PlatformGame). + isPs5Platform = isPs5PlatformGame(gameObj) ) } + + // PS5-platform membership for an imagic browse object: a PPSA product id OR "PS5" in the + // authoritative `device` array. Mirrors Qt isPs5PlatformGame() (cloudcatalogbackend.cpp). + private fun isPs5PlatformGame(gameObj: JSONObject): Boolean + { + val pid = gameObj.optString("productId", "") + if (pid.contains("PPSA")) return true + val devices = gameObj.optJSONArray("device") ?: return false + for (i in 0 until devices.length()) + if (devices.optString(i) == "PS5") return true + return false + } /** * Fetch Owned PS5 Games (user's personal library) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt index f36fe282..6e4b9651 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt @@ -676,10 +676,24 @@ object PsCloudOwnership fun categoryFor(game: CloudGame): String = when { game.isOwned -> CATEGORY_OWNED - streamServiceType(game) == "psnow" -> CATEGORY_STREAMABLE + catalogServiceType(game) == "psnow" -> CATEGORY_STREAMABLE else -> CATEGORY_PURCHASEABLE } + // Category is a CATALOG classification, mirroring Qt categoryForGame + streamServiceTypeForGame + // EXACTLY: the canonical serviceType wins (BOTH "psnow" and "pscloud" short-circuit); only a row + // with no serviceType derives from the CUSA/PPSA token. Deliberately independent of + // streamServiceType, whose isOwned gate re-routes non-owned pscloud rows to Kamaji for STREAMING + // only -- using it here mis-tags non-owned pscloud PS4 titles (e.g. cross-gen indie bundles) as + // "streamable" instead of "purchaseable". + private fun catalogServiceType(game: CloudGame): String + { + val st = game.serviceType.lowercase() + if (st == "psnow" || st == "pscloud") return st + val p = game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } } + return if (p.contains("CUSA")) "psnow" else "pscloud" + } + /** * Concept-sibling streamability gate index, built from the ACTUAL streamable catalog: * - APOLLOROOT (PS3 + PS4) — streamable via Kamaji diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt index 6e7ae925..446d264a 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt @@ -24,7 +24,12 @@ data class CloudGame( // "owned" -> entitlement resolves to a streamable row (Stream) // "streamable" -> not owned, PS Now subscription title (Stream) // "purchaseable" -> not owned, PS Plus catalog title (Add to Library, then Stream) - val category: String = "" + val category: String = "", + // PS5-platform membership from the imagic catalog's authoritative `device` array (contains + // "PS5") OR a PPSA product id. Mirrors Qt's isPs5PlatformGame() and is used to decide which + // browse rows enter the streamable universe -- NOT the CUSA/PPSA productId token, which + // mis-classifies cross-gen titles (a PS4 CUSA SKU that also lists "PS5" in `device`). + val isPs5Platform: Boolean = false ) /** diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 3cf2a6a8..d7c762f3 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -113,9 +113,11 @@ class CloudGameRepository( } native.authError -> { - // Expired token: can't verify owned games. Still show a public catalog. + // Expired session: surface the re-login prompt. Do NOT fall back to the public + // APOLLOROOT walk -- that path is only for region-unsupported accounts (auth OK, + // /user/stores 404). Falling back here would mask the expired token. apolloGames + // stays empty; the user still sees the PS5 catalog plus the warning. lastCatalogFetchWarning = OWNERSHIP_SESSION_WARNING - apolloGames = tryApolloRootFallback(accountCountry) } else -> { @@ -160,7 +162,17 @@ class CloudGameRepository( } // --- 4) assemble the streamable universe (PS Now PS3/PS4 + imagic PS5) ------------- - val ps5Browse = imagic.browseGames.filter { PsCloudOwnership.streamPlatform(it) == "ps5" } + // Browse rows enter the universe when PS5-platform by the authoritative `device` array + // (isPs5Platform), NOT the CUSA/PPSA productId token -- the token drops cross-gen titles + // that carry a PS4 CUSA SKU but list "PS5" in `device` (e.g. the indie bundles). Skip rows + // already in the Apollo (PS Now) catalog: the apollo row already represents them, so adding + // the imagic browse copy would emit a duplicate streamable row (Crow Country / Grandia / + // HUMANITY appear in BOTH the APOLLOROOT walk and the imagic PS5 list). Mirrors Qt + // assembleUnifiedCatalog (cloudcatalogbackend.cpp). + val apolloProductIds = apolloGames.map { it.productId }.toSet() + val ps5Browse = imagic.browseGames.filter { + it.isPs5Platform && !apolloProductIds.contains(it.productId) + } val universe = apolloGames + ps5Browse var games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog(universe, owned, addUnmatched = true) @@ -177,7 +189,7 @@ class CloudGameRepository( // --- 6) tag + cache ---------------------------------------------------------------- games = games.map { it.copy(category = PsCloudOwnership.categoryFor(it)) } - if (games.isNotEmpty() && !isOwnershipVerificationFailure(lastCatalogFetchWarning)) + if (games.isNotEmpty() && !native.authError && !isOwnershipVerificationFailure(lastCatalogFetchWarning)) cacheGames(games, UNIFIED_CACHE_FILE) PsnResult.Success(games) } @@ -205,7 +217,9 @@ class CloudGameRepository( if (!forceRefresh) loadCachedPs5CatalogV3(stored)?.let { return it } - lastCatalogFetchWarning = null + // NOTE: do not reset lastCatalogFetchWarning here. An ownership/session warning set by the + // unified fetch (e.g. expired-token) must survive this call so the re-login prompt reaches + // the UI. The imagic warning is only applied below when no higher-priority warning is set. var lastError: Exception? = null for ((canonical, imagic) in com.metallic.chiaki.cloudplay.CloudLocale.fallbackChain(stored)) { @@ -219,7 +233,9 @@ class CloudGameRepository( } if (fetched.shouldCacheV3) cachePs5CatalogV3(fetched, canonical) - lastCatalogFetchWarning = fetched.catalogFetchWarning + // Don't overwrite a higher-priority ownership/session warning set by the unified fetch. + if (lastCatalogFetchWarning == null) + lastCatalogFetchWarning = fetched.catalogFetchWarning return fetched } catch (e: Exception) @@ -280,9 +296,10 @@ class CloudGameRepository( val obj = jsonArray.getJSONObject(i) // Handle landscapeImageUrl (may be missing in old cache) val landscapeImageUrl = obj.optString("landscapeImageUrl", obj.getString("imageUrl")) + val productId = obj.getString("productId") games.add(CloudGame( - productId = obj.getString("productId"), + productId = productId, name = obj.getString("name"), imageUrl = obj.getString("imageUrl"), landscapeImageUrl = landscapeImageUrl, @@ -296,7 +313,9 @@ class CloudGameRepository( storeProductId = obj.optString("storeProductId", ""), plusCatalog = obj.optBoolean("plusCatalog", false), featureType = obj.optInt("featureType", 0), - category = obj.optString("category", "") + category = obj.optString("category", ""), + // Back-compat for caches written before this field existed: fall back to the token. + isPs5Platform = obj.optBoolean("isPs5Platform", productId.contains("PPSA")) )) } @@ -337,6 +356,7 @@ class CloudGameRepository( obj.put("plusCatalog", game.plusCatalog) obj.put("featureType", game.featureType) obj.put("category", game.category) + obj.put("isPs5Platform", game.isPs5Platform) jsonArray.put(obj) } @@ -440,9 +460,10 @@ class CloudGameRepository( { val obj = jsonArray.getJSONObject(i) val landscapeImageUrl = obj.optString("landscapeImageUrl", obj.getString("imageUrl")) + val productId = obj.getString("productId") games.add( CloudGame( - productId = obj.getString("productId"), + productId = productId, name = obj.getString("name"), imageUrl = obj.getString("imageUrl"), landscapeImageUrl = landscapeImageUrl, @@ -459,7 +480,9 @@ class CloudGameRepository( entitlementId = obj.optString("entitlementId", ""), storeProductId = obj.optString("storeProductId", ""), plusCatalog = obj.optBoolean("plusCatalog", false), - featureType = obj.optInt("featureType", 0) + featureType = obj.optInt("featureType", 0), + // Back-compat for caches written before this field existed: fall back to the token. + isPs5Platform = obj.optBoolean("isPs5Platform", productId.contains("PPSA")) ) ) } @@ -485,6 +508,7 @@ class CloudGameRepository( obj.put("storeProductId", game.storeProductId) obj.put("plusCatalog", game.plusCatalog) obj.put("featureType", game.featureType) + obj.put("isPs5Platform", game.isPs5Platform) jsonArray.put(obj) } return jsonArray diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 545fbac4..5d80dff4 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -981,12 +981,23 @@ void CloudCatalogBackend::unifiedNativeProbeFailed(bool authError) psnowState.unifiedMode = false; unifiedState.authError = authError; - if (authError) + if (authError) { + // Expired session: surface the re-login prompt. Do NOT walk the public APOLLOROOT + // fallback -- that path is only for region-unsupported accounts (auth OK, /user/stores + // 404). Falling back here would mask the expired token. Continue straight to the PS5 + // catalog with empty apolloGames; continueUnifiedAfterApollo() skips the empty-catalog + // failure because authError is set, so the user still sees PS5 titles plus the warning. unifiedState.warning = QStringLiteral( - "Your PSN session may have expired. Owned-game status could not be verified."); + "Your PlayStation session has expired. Please log in again to see your owned games."); + unifiedState.apolloGames = QJsonArray(); + qInfo() << "[UNIFIED] Native APOLLOROOT probe failed (auth error); skipping public " + "fallback, prompting re-login"; + continueUnifiedAfterApollo(); + return; + } - qInfo() << "[UNIFIED] Native APOLLOROOT probe failed (authError=" << authError - << "), trying region-group fallback"; + qInfo() << "[UNIFIED] Native APOLLOROOT probe failed (region unsupported), trying " + "region-group fallback"; startUnifiedApolloFallback(); } @@ -2131,11 +2142,18 @@ void CloudCatalogBackend::assembleUnifiedCatalog(const QJsonArray &ownedCrossRef if (!v.isObject() || !isPs5PlatformGame(v.toObject())) continue; QJsonObject g = v.toObject(); + // If this product is already in the Apollo (PS Now) catalog, that native row already + // represents it as a psnow/streamable title. Appending the imagic browse copy here emits a + // SECOND identical streamable row -- this happens for cross-gen titles that appear in BOTH + // the APOLLOROOT walk and the imagic PS5 list (e.g. Crow Country, Grandia, HUMANITY). Skip + // it: the Apollo row is authoritative for psnow titles. Non-Apollo PS5 titles (including + // cross-gen browse-only games like the indie bundles) still pass through below. + if (apolloProductIds.contains(gameProductId(g))) + continue; const QString existing = g.value(QStringLiteral("serviceType")).toString().toLower(); if (existing != QLatin1String("psnow") && existing != QLatin1String("pscloud")) { - const bool inApollo = apolloProductIds.contains(gameProductId(g)); - g.insert(QStringLiteral("serviceType"), - inApollo ? QStringLiteral("psnow") : QStringLiteral("pscloud")); + // Not in Apollo (guaranteed by the skip above) -> imagic PS5 rows stream via cronos. + g.insert(QStringLiteral("serviceType"), QStringLiteral("pscloud")); } ps5Browse.append(g); } diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 51e88147..a394d874 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -26,12 +26,17 @@ struct CloudGame: Identifiable, Hashable { // "streamable" -> not owned, PS Now subscription title (Stream) // "purchaseable" -> not owned, PS Plus catalog title (Add to Library, then Stream) var category: String + // PS5-platform membership from the imagic catalog's authoritative `device` array (contains + // "PS5") OR a PPSA product id. Mirrors Qt's isPs5PlatformGame() and is used to decide which + // browse rows enter the streamable universe -- NOT the CUSA/PPSA productId token, which + // mis-classifies cross-gen titles (a PS4 CUSA SKU that also lists "PS5" in `device`). + var isPs5Platform: Bool init(productId: String, name: String, imageUrl: String, landscapeImageUrl: String = "", platform: String = "ps4", serviceType: String = "psnow", conceptUrl: String = "", conceptId: String = "", isOwned: Bool = false, entitlementId: String = "", storeProductId: String = "", plusCatalog: Bool = false, - featureType: Int = 0, category: String = "") { + featureType: Int = 0, category: String = "", isPs5Platform: Bool = false) { self.id = productId self.name = name self.imageUrl = imageUrl @@ -46,6 +51,7 @@ struct CloudGame: Identifiable, Hashable { self.plusCatalog = plusCatalog self.featureType = featureType self.category = category + self.isPs5Platform = isPs5Platform } /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. PS5/cronos streams the owned diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index e7978020..6d62a62d 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -103,7 +103,7 @@ final class CloudCatalogService { "isOwned": g.isOwned, "entitlementId": g.entitlementId, "storeProductId": g.storeProductId, "plusCatalog": g.plusCatalog, "featureType": g.featureType, - "category": g.category + "category": g.category, "isPs5Platform": g.isPs5Platform ] } @@ -123,7 +123,10 @@ final class CloudCatalogService { storeProductId: d["storeProductId"] as? String ?? "", plusCatalog: d["plusCatalog"] as? Bool ?? false, featureType: (d["featureType"] as? NSNumber)?.intValue ?? 0, - category: d["category"] as? String ?? "" + category: d["category"] as? String ?? "", + isPs5Platform: (d["isPs5Platform"] as? Bool) + // Back-compat for caches written before this field existed: fall back to the token. + ?? (pid.contains("PPSA")) ) } @@ -423,10 +426,23 @@ final class CloudCatalogService { platform: { let p = ps5PlatformToken(productId); return p.isEmpty ? "ps5" : p }(), serviceType: "pscloud", conceptUrl: conceptUrl, conceptId: conceptKey(for: gameObj), isOwned: false, - plusCatalog: gameObj["plusCatalog"] as? Bool ?? false + plusCatalog: gameObj["plusCatalog"] as? Bool ?? false, + // Authoritative PS5-platform membership from the imagic `device` array (NOT the CUSA/PPSA + // token). A cross-gen title with a PS4 CUSA SKU but "PS5" in `device` is a PS5 browse row + // and must enter the streamable universe (mirrors Qt isPs5PlatformGame). + isPs5Platform: isPs5PlatformGame(gameObj) ) } + // PS5-platform membership for an imagic browse object: a PPSA product id OR "PS5" in the + // authoritative `device` array. Mirrors Qt isPs5PlatformGame() (cloudcatalogbackend.cpp). + private func isPs5PlatformGame(_ gameObj: [String: Any]) -> Bool { + let pid = (gameObj["productId"] as? String) ?? "" + if pid.contains("PPSA") { return true } + if let devices = gameObj["device"] as? [String], devices.contains("PS5") { return true } + return false + } + // MARK: - PS5 Cloud Library: All Games (matches Android fetchPs5CloudCatalog with ownership) /// Fetch ALL PS5 Cloud games with ownership flags. @@ -640,8 +656,10 @@ final class CloudCatalogService { apolloGames = native.games nativeMode = true } else if native.authError { + // Expired session: surface the re-login prompt. Do NOT fall back to the public + // APOLLOROOT walk -- that path is only for region-unsupported accounts (auth OK, + // /user/stores 404). Falling back here would mask the expired token. lastCatalogFetchWarning = Self.ownershipSessionWarning - apolloGames = tryApolloRootFallback(accountCountry: accountCountry) } else { apolloGames = tryApolloRootFallback(accountCountry: accountCountry) if !apolloGames.isEmpty { @@ -688,7 +706,17 @@ final class CloudCatalogService { } // --- 4) assemble the streamable universe (PS Now PS3/PS4 + imagic PS5) ------------- - let ps5Browse = imagic.browseGames.filter { $0.streamPlatform == "ps5" } + // Browse rows enter the universe when PS5-platform by the authoritative `device` array + // (isPs5Platform), NOT the CUSA/PPSA productId token -- the token drops cross-gen titles + // that carry a PS4 CUSA SKU but list "PS5" in `device` (e.g. the indie bundles). Skip rows + // already in the Apollo (PS Now) catalog: the apollo row already represents them, so adding + // the imagic browse copy would emit a duplicate streamable row (Crow Country / Grandia / + // HUMANITY appear in BOTH the APOLLOROOT walk and the imagic PS5 list). Mirrors Qt + // assembleUnifiedCatalog (cloudcatalogbackend.cpp). + let apolloProductIds = Set(apolloGames.map { $0.id }) + let ps5Browse = imagic.browseGames.filter { + $0.isPs5Platform && !apolloProductIds.contains($0.id) + } let universe = apolloGames + ps5Browse var games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog( browseCatalog: universe, ownedCrossRef: owned, addUnmatched: true @@ -710,7 +738,7 @@ final class CloudCatalogService { tagged.category = PsCloudOwnership.categoryFor(game) return tagged } - if !games.isEmpty && !isOwnershipVerificationFailure(lastCatalogFetchWarning) { + if !games.isEmpty && !native.authError && !isOwnershipVerificationFailure(lastCatalogFetchWarning) { cacheGames(games, filename: Self.unifiedCacheFile) } if let warning = imagic.catalogFetchWarning, lastCatalogFetchWarning == nil { @@ -723,7 +751,10 @@ final class CloudCatalogService { if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { return cached } - lastCatalogFetchWarning = nil + // NOTE: do not reset lastCatalogFetchWarning here. The unified fetch already cleared it + // at the top, and an ownership/session warning set earlier (e.g. expired-token) must + // survive this call so the re-login prompt reaches the UI. The imagic warning is still + // surfaced by the caller via imagic.catalogFetchWarning when no higher-priority warning. for tier in CloudLocaleSettings.fallbackChain() { guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: tier.imagic) else { continue } if tier.canonical != stored { diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index f50c6ffc..00917d7a 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -598,8 +598,22 @@ enum PsCloudOwnership { static func categoryFor(_ game: CloudGame) -> String { if game.isOwned { return CATEGORY_OWNED } - if game.streamServiceType == "psnow" { return CATEGORY_STREAMABLE } - return CATEGORY_PURCHASEABLE + // Category is a CATALOG classification and mirrors Qt categoryForGame + streamServiceTypeForGame + // EXACTLY: the canonical serviceType wins (BOTH "psnow" and "pscloud" short-circuit); only a row + // with no serviceType derives from the CUSA/PPSA token. This is deliberately independent of + // `streamServiceType`, whose isOwned gate re-routes non-owned pscloud rows to Kamaji for STREAMING + // only -- using it here mis-tags non-owned pscloud PS4 titles (e.g. cross-gen indie bundles) as + // "streamable" instead of "purchaseable". + let st = game.serviceType.lowercased() + let svc: String + if st == "psnow" || st == "pscloud" { + svc = st + } else { + let p = !game.storeProductId.isEmpty ? game.storeProductId + : (!game.id.isEmpty ? game.id : game.entitlementId) + svc = p.contains("CUSA") ? "psnow" : "pscloud" + } + return svc == "psnow" ? CATEGORY_STREAMABLE : CATEGORY_PURCHASEABLE } /// Concept-sibling streamability gate index, built from the ACTUAL streamable catalog. From b5a99e316ccaad3965e16f344bbc11476d9c892e Mon Sep 17 00:00:00 2001 From: forward technologies Date: Fri, 26 Jun 2026 15:14:47 -0700 Subject: [PATCH 11/72] Cloud catalog: centralize fetch/merge in libchiaki + cross-platform game-language picker Move the entire cloud catalog fetch/merge/cache/cross-reference pipeline into libchiaki (new cloudcatalog_* sources + curl_http) so Qt, iOS, and Android consume one display-and-stream-ready contract with zero client-side catalog logic. Region detection and Gaikai bare-language conversion also live in the lib and are exposed to all three UIs (Q_INVOKABLE, Obj-C bridge, JNI). Add a "Cloud Settings" game-language picker (Auto + supported locales, each shown with its locale code) above Game Library/Catalog on iOS and Android. The manual language override is stored separately from the auto-detected catalog/region locale so it is never clobbered by settledLocale/Kamaji writes; streaming prefers the override and falls back to the catalog locale. "Auto" clears the override. Datacenter auto-matching removed; a short region/datacenter caveat is surfaced compactly (popup on iOS, dialog header on Android, caption on Qt). Remove now-dead per-platform catalog/ownership code superseded by the lib. Co-authored-by: Cursor --- .gitignore | 1 + android/app/src/main/cpp/chiaki-jni.c | 128 + .../metallic/chiaki/cloudplay/CloudLocale.kt | 29 - .../chiaki/cloudplay/PsnApiConstants.kt | 42 - .../cloudplay/api/CloudStreamingExceptions.kt | 3 - .../chiaki/cloudplay/api/PSGaikaiStreaming.kt | 10 +- .../cloudplay/api/PsCloudCatalogService.kt | 547 --- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 780 ---- .../chiaki/cloudplay/api/PsnCatalogService.kt | 709 ---- .../chiaki/cloudplay/model/CloudError.kt | 11 +- .../chiaki/cloudplay/model/CloudGame.kt | 92 +- .../repository/CloudGameRepository.kt | 540 +-- .../com/metallic/chiaki/common/Preferences.kt | 70 +- .../java/com/metallic/chiaki/lib/Chiaki.kt | 45 + .../metallic/chiaki/main/CloudGameAdapter.kt | 28 +- .../metallic/chiaki/main/CloudPlayFragment.kt | 27 +- .../chiaki/main/CloudPlayViewModel.kt | 17 +- .../chiaki/settings/SettingsFragment.kt | 50 + android/app/src/main/res/values/strings.xml | 5 + android/app/src/main/res/xml/preferences.xml | 12 + gui/include/cloudcatalogbackend.h | 188 +- gui/include/qmlsettings.h | 9 + gui/include/settings.h | 2 + gui/src/cloudcatalogbackend.cpp | 3566 +---------------- gui/src/cloudstreaming/psgaikaistreaming.cpp | 17 +- gui/src/qml/CloudGameCard.qml | 146 +- gui/src/qml/CloudPlayView.qml | 22 - gui/src/qml/SettingsDialog.qml | 64 + gui/src/qmlsettings.cpp | 28 + gui/src/settings.cpp | 13 + ios/Pylux.xcodeproj/project.pbxproj | 10 +- ios/Pylux/Bridge/ChiakiBridge.h | 1 + ios/Pylux/Bridge/CloudCatalogBridge.h | 46 + ios/Pylux/Bridge/CloudCatalogBridge.m | 81 + ios/Pylux/Models/CloudModels.swift | 223 +- ios/Pylux/Services/CloudCatalogService.swift | 1141 +----- ios/Pylux/Services/PSGaikaiStreaming.swift | 10 +- ios/Pylux/Services/PsCloudOwnership.swift | 686 ---- ios/Pylux/Views/CloudPlayView.swift | 38 +- ios/Pylux/Views/SettingsView.swift | 88 +- lib/CMakeLists.txt | 10 + lib/include/chiaki/cloudcatalog.h | 136 + lib/src/cloudcatalog_cache.c | 175 + lib/src/cloudcatalog_consts.c | 193 + lib/src/cloudcatalog_fetch.c | 807 ++++ lib/src/cloudcatalog_internal.h | 224 ++ lib/src/cloudcatalog_merge.c | 1351 +++++++ lib/src/cloudcatalog_unified.c | 347 ++ lib/src/cloudcatalog_util.c | 123 + lib/src/curl_http.c | 221 + lib/src/curl_http.h | 71 + 51 files changed, 4815 insertions(+), 8368 deletions(-) delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt create mode 100644 ios/Pylux/Bridge/CloudCatalogBridge.h create mode 100644 ios/Pylux/Bridge/CloudCatalogBridge.m delete mode 100644 ios/Pylux/Services/PsCloudOwnership.swift create mode 100644 lib/include/chiaki/cloudcatalog.h create mode 100644 lib/src/cloudcatalog_cache.c create mode 100644 lib/src/cloudcatalog_consts.c create mode 100644 lib/src/cloudcatalog_fetch.c create mode 100644 lib/src/cloudcatalog_internal.h create mode 100644 lib/src/cloudcatalog_merge.c create mode 100644 lib/src/cloudcatalog_unified.c create mode 100644 lib/src/cloudcatalog_util.c create mode 100644 lib/src/curl_http.c create mode 100644 lib/src/curl_http.h diff --git a/.gitignore b/.gitignore index e649634a..fb85bcb5 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ macos/fastlane/report.xml # Local Flatpak testing (throwaway, never commit) scripts/flatpak/test-flatpak-local.sh pylux-test.flatpak +lib/test_cloudcatalog/.npsso diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index c494531e..efd08204 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -1494,4 +1495,131 @@ JNIEXPORT jobject JNICALL Java_com_metallic_chiaki_cloudplay_ping_DatacenterPing jobject result = (*env)->NewObject(env, pingResultClass, constructor, result_rtt_us, result_mtu_in, result_mtu_out); return result; +} + +// Unified cloud catalog (chiaki/cloudcatalog.h): one fetch+dedup+ownership+tagging pass shared +// with Qt and iOS. Returns the UTF-8 JSON contract as a byte[] (the payload has non-ASCII names +// that JNI's modified-UTF-8 NewStringUTF can't safely carry; Kotlin decodes the bytes as UTF-8). +// On hard failure returns NULL and, if error_out is a non-empty String[], stores the lib's +// human-readable detail in error_out[0] so the caller can surface it (mirrors iOS). +JNIEXPORT jbyteArray JNICALL JNI_FCN(cloudCatalogFetchUnified)(JNIEnv *env, jobject obj, + jstring npsso_str, jstring locale_str, jstring cache_dir_str, jboolean force_refresh, + jobjectArray error_out) +{ + const char *npsso = npsso_str ? E->GetStringUTFChars(env, npsso_str, NULL) : NULL; + const char *locale = locale_str ? E->GetStringUTFChars(env, locale_str, NULL) : NULL; + const char *cache_dir = cache_dir_str ? E->GetStringUTFChars(env, cache_dir_str, NULL) : NULL; + + // The lib requires a non-null cache dir; bail (releasing whatever succeeded) if a requested + // string failed to materialize (only under OOM). + if((cache_dir_str && !cache_dir) || (npsso_str && !npsso) || (locale_str && !locale)) + { + CHIAKI_LOGE(&global_log, "[CloudCatalog] GetStringUTFChars failed (out of memory?)"); + if(npsso) E->ReleaseStringUTFChars(env, npsso_str, npsso); + if(locale) E->ReleaseStringUTFChars(env, locale_str, locale); + if(cache_dir) E->ReleaseStringUTFChars(env, cache_dir_str, cache_dir); + return NULL; + } + + ChiakiCloudCatalogConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.npsso = (npsso && npsso[0]) ? npsso : NULL; + cfg.locale = (locale && locale[0]) ? locale : NULL; + cfg.cache_dir = cache_dir; + cfg.force_refresh = force_refresh ? true : false; + + ChiakiCloudCatalogResult res; + memset(&res, 0, sizeof(res)); + ChiakiErrorCode err = chiaki_cloudcatalog_fetch_unified(&cfg, &res, &global_log); + + jbyteArray result = NULL; + const char *fail_detail = NULL; // non-NULL => report via error_out[0] + if(res.json) + { + size_t len = strlen(res.json); + result = E->NewByteArray(env, (jsize)len); + if(result) + E->SetByteArrayRegion(env, result, 0, (jsize)len, (const jbyte *)res.json); + else + fail_detail = "Out of memory building cloud catalog payload"; // alloc failed despite valid json + } + else + { + CHIAKI_LOGE(&global_log, "[CloudCatalog] fetch failed (err=%d): %s", + (int)err, res.error_message ? res.error_message : "no detail"); + fail_detail = res.error_message ? res.error_message : chiaki_error_string(err); + } + + if(!result && fail_detail && error_out && E->GetArrayLength(env, error_out) > 0) + { + jstring jdetail = E->NewStringUTF(env, fail_detail); + if(jdetail) + { + E->SetObjectArrayElement(env, error_out, 0, jdetail); + E->DeleteLocalRef(env, jdetail); + } + } + + chiaki_cloudcatalog_result_fini(&res); + if(npsso_str) E->ReleaseStringUTFChars(env, npsso_str, npsso); + if(locale_str) E->ReleaseStringUTFChars(env, locale_str, locale); + if(cache_dir_str) E->ReleaseStringUTFChars(env, cache_dir_str, cache_dir); + return result; +} + +JNIEXPORT void JNICALL JNI_FCN(cloudCatalogInvalidateCache)(JNIEnv *env, jobject obj, jstring cache_dir_str) +{ + const char *cache_dir = cache_dir_str ? E->GetStringUTFChars(env, cache_dir_str, NULL) : NULL; + if(cache_dir) + { + chiaki_cloudcatalog_invalidate_cache(cache_dir); + E->ReleaseStringUTFChars(env, cache_dir_str, cache_dir); + } +} + +// Cloud streaming language helpers (chiaki/cloudcatalog.h): the shared lib table +// is the single source of truth across Qt/iOS/Android. Game language is tied to +// the datacenter region (Gaikai ignores a language whose datacenter is unselected). + +JNIEXPORT jstring JNICALL JNI_FCN(cloudGaikaiLanguage)(JNIEnv *env, jobject obj, jstring locale_str) +{ + (void)obj; + const char *locale = locale_str ? E->GetStringUTFChars(env, locale_str, NULL) : NULL; + char buf[16]; + chiaki_cloud_gaikai_language((locale && locale[0]) ? locale : NULL, buf, sizeof(buf)); + if(locale_str && locale) E->ReleaseStringUTFChars(env, locale_str, locale); + return E->NewStringUTF(env, buf); +} + +JNIEXPORT jobjectArray JNICALL JNI_FCN(cloudSupportedLanguages)(JNIEnv *env, jobject obj) +{ + (void)obj; + size_t n = chiaki_cloud_supported_locale_count(); + jclass str_class = E->FindClass(env, "java/lang/String"); + if(!str_class) + return NULL; + jobjectArray arr = E->NewObjectArray(env, (jsize)n, str_class, NULL); + if(!arr) + return NULL; + for(size_t i = 0; i < n; i++) + { + jstring s = E->NewStringUTF(env, chiaki_cloud_supported_locale(i)); + if(s) + { + E->SetObjectArrayElement(env, arr, (jsize)i, s); + E->DeleteLocalRef(env, s); + } + } + return arr; +} + +JNIEXPORT jboolean JNICALL JNI_FCN(cloudDatacenterServesLanguage)(JNIEnv *env, jobject obj, jstring dc_str, jstring locale_str) +{ + (void)obj; + const char *dc = dc_str ? E->GetStringUTFChars(env, dc_str, NULL) : NULL; + const char *locale = locale_str ? E->GetStringUTFChars(env, locale_str, NULL) : NULL; + bool served = (dc && locale) ? chiaki_cloud_datacenter_serves_locale(dc, locale) : false; + if(dc_str && dc) E->ReleaseStringUTFChars(env, dc_str, dc); + if(locale_str && locale) E->ReleaseStringUTFChars(env, locale_str, locale); + return served ? JNI_TRUE : JNI_FALSE; } \ No newline at end of file diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt index 66f81380..108a8908 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt @@ -4,10 +4,6 @@ package com.metallic.chiaki.cloudplay object CloudLocale { - const val DEFAULT = "en-US" - - fun toImagicLocale(stored: String): String = stored.lowercase() - fun parseStorePath(stored: String): Pair { val parts = stored.split("-", limit = 2) @@ -25,31 +21,6 @@ object CloudLocale return "$lang-${cty.uppercase()}" } - /** - * Ordered store locales to try when fetching the catalog. Sony serves a fixed set of - * language-COUNTRY combinations: the country is always valid but the language may not be - * (a Hungarian-language account yields "hu-HU", which 404s, while "en-HU" works). Fall back - * to English for the same country, then en-US, so the catalog loads in every region. - * Each pair is (canonical "ll-CC" for storage, lowercased "ll-cc" for the imagic URL). - */ - fun fallbackChain(stored: String): List> - { - val (country, language) = parseStorePath(stored) - val seen = LinkedHashSet() - val chain = mutableListOf>() - fun add(lang: String, ctry: String) - { - val canonical = "$lang-$ctry" - val imagic = canonical.lowercase() - if (seen.add(imagic)) - chain.add(canonical to imagic) - } - add(language, country) - add("en", country) - add("en", "US") - return chain - } - /** Non-fatal warning when locale could not be learned from Kamaji (catalog may use en-US). */ fun unconfiguredWarning(): String = "Could not detect your PlayStation region. The catalog may not match your store." diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt index 40215833..7c8b6a39 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt @@ -22,47 +22,5 @@ object PsnApiConstants const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" const val PS4_SCOPES = "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" - - const val ROOT_CONTAINER_ID = "STORE-MSF75508-PSNOWALLGAMES" -} - -/** - * pcnow ("Apollo") PS Now store helpers, by account region group. - * Mirrors KamajiConsts (gui/include/cloudstreaming/pskamajisession.h) exactly. - * - * pcnow (the PS Plus PC "Apollo" backend) has only TWO region-group store families: - * - SCEA / Americas -> store MSF192018, US-region ids (UP/NPUA/BLUS) - * - SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP/NPEA/NPEB/BLES) - * JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to - * PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region - * group, so the catalog must be browsed + resolved in the account's group. Region is keyed - * by the ACCOUNT's region group, NOT by parsing the product-id prefix. - * - * The APOLLOROOT container is the single PS Now catalog root: ONE walk returns both PS3 and - * PS4 (distinguished only by playable_platform). It is browsed natively via /user/stores - * (session base_url) in supported regions, or directly via the public region-group container - * (no OAuth/session) as a fallback in regions where /user/stores has no storefront (e.g. HU). - */ -object KamajiClassics -{ - private val AMERICAS = setOf( - "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", "PY", "UY", - "CR", "GT", "HN", "NI", "PA", "SV", "DO" - ) - - // PS Now catalog root store ids per region group (returns PS3 + PS4 in one walk). - const val APOLLOROOT_AMERICAS = "STORE-MSF192018-APOLLOROOT" - const val APOLLOROOT_PAL = "STORE-MSF192014-APOLLOROOT" - - fun isAmericasClassicsRegion(countryCode: String): Boolean = - AMERICAS.contains(countryCode.uppercase()) - - /** Country path to use for container/conversion calls (US for Americas, GB for PAL). */ - fun classicsStoreCountry(accountCountry: String): String = - if (isAmericasClassicsRegion(accountCountry)) "US" else "GB" - - /** Fully-qualified APOLLOROOT (PS Now: PS3 + PS4) container id for the account's region group. */ - fun apolloRootContainerId(accountCountry: String): String = - if (isAmericasClassicsRegion(accountCountry)) APOLLOROOT_AMERICAS else APOLLOROOT_PAL } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt index 77a5e257..409335dd 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt @@ -22,6 +22,3 @@ class AuthorizationFailedException(message: String) : Exception(message) /** General Gaikai allocation error */ class GaikaiAllocationException(message: String) : Exception(message) -/** Kamaji session error */ -class KamajiSessionException(message: String) : Exception(message) - diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt index 7e82e810..9e4caa75 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt @@ -1380,9 +1380,13 @@ catch (e: Exception) spec.put("entitlementId", entitlementId) spec.put("npEnv", "np") - // Read language from unified settings (Qt lines 153, 161) - // Use unified language setting for both PSCloud and PSNOW - val language = preferences.getCloudLanguage() + // Prefer the user's manual streaming-language pick; fall back to the + // auto-detected catalog locale when the picker is left on default. The manual + // pick lives in its own setting so the catalog locale can never clobber it. + // Gaikai expects the bare language code ("de"), not the stored locale + // ("de-DE"); the lib helper is the single source of truth across platforms. + val chosenLocale = preferences.getStreamLanguage().ifEmpty { preferences.getCloudLanguage() } + val language = com.metallic.chiaki.lib.cloudGaikaiLanguage(chosenLocale) spec.put("language", language) spec.put("cloudEndpoint", "https://cc.prod.gaikai.com") diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt deleted file mode 100644 index 99cff01d..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ /dev/null @@ -1,547 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import android.util.Log -import com.metallic.chiaki.cloudplay.PsnApiConstants -import com.metallic.chiaki.cloudplay.model.CloudGame -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import org.json.JSONArray -import org.json.JSONObject - -data class Ps5CloudCatalogResult( - val browseGames: List, - val plusLibrarySupplement: List, - val productIdAliases: Map = emptyMap(), - val catalogFetchWarning: String? = null, - val shouldCacheV3: Boolean = true, -) - -/** - * PsCloudCatalogService - PS5 cloud catalog fetching (imagic gameslist). - */ -class PsCloudCatalogService -{ - companion object - { - private const val TAG = "PsCloudCatalogService" - private const val ACCOUNT_BASE = "https://ca.account.sony.com/api" - private const val IMAGIC_GAMESLIST_BASE = - "https://www.playstation.com/bin/imagic/gameslist" - - private val IMAGIC_PS5_CLOUD_CATEGORY_LISTS = listOf( - "plus-games-list", - "ubisoft-classics-list", - "plus-classics-list", - "plus-monthly-games-list", - "free-to-play-list", - "all-ps5-list", - ) - } - - suspend fun fetchPs5CloudCatalog(locale: String): Ps5CloudCatalogResult = coroutineScope { - Log.i(TAG, "=== Fetching PS5 Game Catalog (6 imagic lists) ===") - Log.i(TAG, " Locale: $locale") - - val byConceptId = LinkedHashMap() - val plusSupplementByProductId = LinkedHashMap() - val productIdAliases = LinkedHashMap() - var totalGames = 0 - val failedLists = mutableListOf() - var allPs5ListSucceeded = false - - IMAGIC_PS5_CLOUD_CATEGORY_LISTS.map { categoryList -> - async { - try { - categoryList to fetchImagicCategoryList(locale, categoryList) - } catch (e: Exception) { - Log.w(TAG, "Imagic list '$categoryList' failed: ${e.message}") - categoryList to null - } - } - }.awaitAll().forEach { (categoryList, jsonArray) -> - if (jsonArray == null) { - failedLists.add(categoryList) - return@forEach - } - if (categoryList == "all-ps5-list") - allPs5ListSucceeded = true - totalGames += mergeImagicCategoryIntoMap( - categoryList, jsonArray, byConceptId, plusSupplementByProductId, productIdAliases - ) - } - - if (failedLists.size == IMAGIC_PS5_CLOUD_CATEGORY_LISTS.size) - throw Exception("All imagic lists failed to load") - - val browseGames = byConceptId.values.mapNotNull { jsonToCloudGame(it) } - val plusLibrarySupplement = plusSupplementByProductId.values.mapNotNull { jsonToCloudGame(it) } - - val catalogFetchWarning = if (failedLists.isEmpty()) null - else "Some catalog lists failed to load (${failedLists.joinToString()}). Catalog may be incomplete." - - Log.i(TAG, " Imagic rows scanned: $totalGames") - Log.i(TAG, " PS5 streaming games (deduped by conceptId): ${browseGames.size}") - Log.i(TAG, " Plus library-stream supplement (stream=false): ${plusLibrarySupplement.size}") - Log.i(TAG, " Product ID aliases (same conceptId): ${productIdAliases.size}") - if (catalogFetchWarning != null) - Log.w(TAG, " Partial imagic fetch: $catalogFetchWarning") - - Ps5CloudCatalogResult( - browseGames, plusLibrarySupplement, productIdAliases, - catalogFetchWarning, allPs5ListSucceeded - ) - } - - private suspend fun fetchImagicCategoryList(locale: String, categoryList: String): JSONArray - { - val url = "$IMAGIC_GAMESLIST_BASE?locale=$locale&categoryList=$categoryList" - val response = HttpClient.get( - url = url, - headers = mapOf( - "Content-Type" to "application/json", - "Accept" to "application/json", - "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ) - ) - - if (response.statusCode != 200) - { - Log.e(TAG, "Imagic list '$categoryList' fetch error: ${response.statusCode}") - throw Exception("Failed to fetch imagic list $categoryList: HTTP ${response.statusCode}") - } - - return JSONArray(response.body) - } - - private fun mergeImagicCategoryIntoMap( - categoryList: String, - jsonArray: JSONArray, - byConceptId: LinkedHashMap, - plusSupplementByProductId: LinkedHashMap, - productIdAliases: LinkedHashMap, - ): Int - { - val plusCatalog = isPlusCatalogList(categoryList) // subscription catalog vs all-ps5 universe - var rows = 0 - for (i in 0 until jsonArray.length()) - { - val games = jsonArray.getJSONObject(i).optJSONArray("games") ?: continue - rows += games.length() - for (j in 0 until games.length()) - { - val gameObj = games.getJSONObject(j) - // Accept PS4 and PS5; the old PS5-only gate dropped PS4-only PS-Plus-catalog - // titles (e.g. God of War 2018) before they could reach the supplement below. - if (!isCloudDeviceGame(gameObj)) - continue - - // Subscription-catalog titles with streamingSupported=false → library-stream - // supplement, captured from EVERY subscription list (not just plus-games-list). - if (plusCatalog && !gameObj.optBoolean("streamingSupported", false)) - { - val productId = gameObj.optString("productId", "") - if (productId.isNotEmpty()) - { - gameObj.put("plusCatalog", true) - plusSupplementByProductId.putIfAbsent(productId, gameObj) - } - continue - } - - if (!isCloudStreamingGame(gameObj)) - continue - val key = editionKey(gameObj) // per game per platform (cross-gen split) - val productId = gameObj.optString("productId", "") - if (key.isEmpty() || productId.isEmpty()) - continue - - if (byConceptId.containsKey(key)) - { - val existing = byConceptId[key] - val canonicalProductId = existing?.optString("productId", "") ?: "" - if (canonicalProductId.isNotEmpty() && productId != canonicalProductId - && !productIdAliases.containsKey(productId)) - { - productIdAliases[productId] = canonicalProductId - } - // Lists fetch in parallel; upgrade the flag so subscription membership wins - // regardless of arrival order. - if (plusCatalog && existing != null && !existing.optBoolean("plusCatalog", false)) - existing.put("plusCatalog", true) - continue - } - - gameObj.put("plusCatalog", plusCatalog) - byConceptId[key] = gameObj - } - } - return rows - } - - // PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not in these imagic lists). - // A PS4-only title such as God of War (2018) is streamable when owned even though it - // carries device ["PS4"], so the catalog must not discard it. - // The PS Plus subscription catalog = these curated lists (≈ what Sony lists). all-ps5-list is - // the full streamable universe and must NOT count as subscription catalog. - private fun isPlusCatalogList(categoryList: String): Boolean = - categoryList == "plus-games-list" || categoryList == "plus-classics-list" || - categoryList == "ubisoft-classics-list" || categoryList == "plus-monthly-games-list" - - private fun isCloudDeviceGame(gameObj: JSONObject): Boolean - { - val devices = gameObj.optJSONArray("device") ?: return false - for (i in 0 until devices.length()) - { - val d = devices.optString(i) - if (d == "PS5" || d == "PS4") - return true - } - return false - } - - private fun isCloudStreamingGame(gameObj: JSONObject): Boolean - { - if (!gameObj.optBoolean("streamingSupported", false)) - return false - return isCloudDeviceGame(gameObj) - } - - private fun conceptKey(gameObj: JSONObject): String - { - if (gameObj.has("conceptId") && !gameObj.isNull("conceptId")) - { - when (val raw = gameObj.get("conceptId")) - { - is Number -> return raw.toLong().toString() - is String -> if (raw.isNotEmpty()) return raw - } - } - return gameObj.optString("productId", "") - } - - // Platform token from a product id (CUSA = PS4, PPSA = PS5). - private fun ps5PlatformToken(productId: String): String = when - { - productId.contains("PPSA") -> "ps5" - productId.contains("CUSA") -> "ps4" - else -> "" - } - - // Dedupe identity: one entry per game PER PLATFORM, so cross-gen PS4/PS5 editions (e.g. Deliver - // Us The Moon) both appear, while duplicate same-platform SKUs still collapse. - private fun editionKey(gameObj: JSONObject): String - { - val c = conceptKey(gameObj) - if (c.isEmpty()) return "" - return c + "|" + ps5PlatformToken(gameObj.optString("productId", "")) - } - - private fun jsonToCloudGame(gameObj: JSONObject): CloudGame? - { - val productId = gameObj.optString("productId", "") - if (productId.isEmpty()) - return null - - val gameName = gameObj.optString("name", "Unknown") - var imageUrl = gameObj.optString("imageUrl", "") - var conceptUrl = gameObj.optString("conceptUrl", "") - if (conceptUrl.isEmpty()) - conceptUrl = gameObj.optString("concept_url", "") - if (conceptUrl.isEmpty()) - conceptUrl = gameObj.optString("url", "") - if (conceptUrl.isEmpty()) - conceptUrl = gameObj.optString("storeUrl", "") - if (conceptUrl.isEmpty()) - conceptUrl = gameObj.optString("psStoreUrl", "") - if (conceptUrl.isEmpty()) - conceptUrl = gameObj.optString("concept", "") - if (conceptUrl.isEmpty()) - { - val links = gameObj.optJSONObject("links") - if (links != null) - { - conceptUrl = links.optString("conceptUrl", "") - .ifEmpty { links.optString("concept_url", "") } - .ifEmpty { links.optString("url", "") } - } - } - if (conceptUrl.isEmpty()) - { - val concept = gameObj.optJSONObject("concept") - if (concept != null) - { - conceptUrl = concept.optString("url", "") - .ifEmpty { concept.optString("href", "") } - } - } - - val (coverUrl, landscapeUrl) = if (imageUrl.isNotEmpty()) - Pair(imageUrl, imageUrl) - else - extractImageUrls(gameObj) - - var finalCoverUrl = coverUrl - var finalLandscapeUrl = landscapeUrl - if (finalCoverUrl.startsWith("http://")) - finalCoverUrl = finalCoverUrl.replace("http://", "https://") - if (finalLandscapeUrl.startsWith("http://")) - finalLandscapeUrl = finalLandscapeUrl.replace("http://", "https://") - - return CloudGame( - productId = productId, - name = gameName, - imageUrl = finalCoverUrl, - landscapeImageUrl = finalLandscapeUrl, - platform = ps5PlatformToken(productId).ifEmpty { "ps5" }, - serviceType = "pscloud", - conceptUrl = conceptUrl, - conceptId = conceptKey(gameObj), - isOwned = false, - plusCatalog = gameObj.optBoolean("plusCatalog", false), - // Authoritative PS5-platform membership from the imagic `device` array (NOT the CUSA/PPSA - // token). A cross-gen title with a PS4 CUSA SKU but "PS5" in `device` is a PS5 browse row - // and must enter the streamable universe (mirrors Qt isPs5PlatformGame). - isPs5Platform = isPs5PlatformGame(gameObj) - ) - } - - // PS5-platform membership for an imagic browse object: a PPSA product id OR "PS5" in the - // authoritative `device` array. Mirrors Qt isPs5PlatformGame() (cloudcatalogbackend.cpp). - private fun isPs5PlatformGame(gameObj: JSONObject): Boolean - { - val pid = gameObj.optString("productId", "") - if (pid.contains("PPSA")) return true - val devices = gameObj.optJSONArray("device") ?: return false - for (i in 0 until devices.length()) - if (devices.optString(i) == "PS5") return true - return false - } - - /** - * Fetch Owned PS5 Games (user's personal library) - * Mirrors: CloudCatalogBackend::fetchOwnedPs5Games() (Qt lines 976-1010) - * - * @param npssoToken User's NPSSO token - * @param locale Language locale - * @return List of CloudGame objects that user owns - */ - suspend fun fetchOwnedPs5Games(npssoToken: String, locale: String): List - { - if (npssoToken.isEmpty()) - { - throw Exception("NPSSO token is required for cloud play. Please login and enter a valid NPSSO token.") - } - - Log.i(TAG, "=== Fetching Owned PS5 Games ===") - Log.i(TAG, " Locale: $locale") - - val catalog = fetchPs5CloudCatalog(locale) - val ownedGames = getOwnedPs5CloudGames( - npssoToken, - catalog.browseGames, - catalog.plusLibrarySupplement, - catalog.productIdAliases - ) - - Log.i(TAG, " Owned streaming games: ${ownedGames.size}") - return ownedGames - } - - /** - * Mirrors CloudCatalogBackend::getOwnedPs5CloudGames cross-reference (network). - * - * @param psnowCatalog the PS Now APOLLOROOT catalog (PS3 + PS4). Prepended to the imagic - * publicCatalog so owned PS4/PS3 entitlements resolve to their (psnow) catalog row and - * keep the Kamaji streaming route; imagic still provides PS5 rows + conceptIds. - */ - suspend fun getOwnedPs5CloudGames( - npssoToken: String, - publicCatalog: List, - plusLibrarySupplement: List = emptyList(), - productIdAliases: Map = emptyMap(), - psnowCatalog: List = emptyList(), - ): List - { - if (npssoToken.isEmpty()) return emptyList() - - val oauthToken = fetchOwnedGamesOAuthToken(npssoToken) - kotlinx.coroutines.delay(PsCloudOwnership.PAGE_COOLDOWN_MS) - - val rawEntitlements = fetchEntitlementsPaginated(oauthToken) - val filtered = PsCloudOwnership.filterOwnedPs5Games(rawEntitlements) - - // Map each bundle product_id -> the entitlement ids sharing it, so a bundle (e.g. RE7 Gold) - // expands to its component games during cross-reference (upstream PR #15 bundle-sibling match). - val componentIds = mutableMapOf>() - for (ent in rawEntitlements) - if (ent.productId.isNotEmpty() && ent.id.isNotEmpty()) - componentIds.getOrPut(ent.productId) { mutableListOf() }.add(ent.id) - - val combinedCatalog = if (psnowCatalog.isEmpty()) publicCatalog else psnowCatalog + publicCatalog - return PsCloudOwnership.crossReferenceOwnedGames( - filtered, combinedCatalog, plusLibrarySupplement, productIdAliases, componentIds - ) - } - - /** - * Fetch OAuth token for entitlements API - * Mirrors: CloudCatalogBackend::fetchOwnedGamesOAuthToken() (Qt lines 1012-1056) - */ - private suspend fun fetchOwnedGamesOAuthToken(npssoToken: String): String - { - Log.i(TAG, "=== Fetching OAuth token for owned games ===") - - // Build URL with proper query parameters (Qt lines 1032-1042) - // IMPORTANT: Use KamajiConsts::REDIRECT_URI (PSNow redirect), not the generic remoteplay one - val scope = "kamaji:get_internal_entitlements user:account.attributes.validate" - val redirectUri = PsnApiConstants.REDIRECT_URI // This is the PSNow redirect URI - - val url = java.net.URL("$ACCOUNT_BASE/v1/oauth/authorize") - val query = "response_type=token&scope=${java.net.URLEncoder.encode(scope, "UTF-8")}&client_id=dc523cc2-b51b-4190-bff0-3397c06871b3&redirect_uri=${java.net.URLEncoder.encode(redirectUri, "UTF-8")}&service_entity=urn:service-entity:psn&prompt=none" - val fullUrl = "$url?$query" - - Log.d(TAG, "OAuth URL: $fullUrl") - - val response = HttpClient.get( - url = fullUrl, - headers = mapOf( - "Cookie" to "npsso=$npssoToken", - "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ), - followRedirects = false - ) - - // Should get a 302 redirect with token in Location header (Qt lines 1063-1094) - if (response.statusCode != 302) - { - Log.e(TAG, "OAuth token fetch failed: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - throw Exception("Failed to fetch OAuth token: HTTP ${response.statusCode}") - } - - // Headers come as Map>, get first element - val location = (response.headers["Location"]?.firstOrNull() - ?: response.headers["location"]?.firstOrNull() - ?: "") - - Log.d(TAG, "Redirect Location header: $location") - - if (location.isEmpty()) - { - Log.e(TAG, "No Location header in redirect response") - Log.e(TAG, "Available headers: ${response.headers.keys}") - throw Exception("No Location header in OAuth redirect") - } - - // Extract access_token from URL fragment (Qt lines 1076-1094) - val tokenPattern = Regex("[#&]access_token=([^&]+)") - val match = tokenPattern.find(location) - - if (match == null) - { - Log.e(TAG, "Failed to extract access_token from redirect URL: $location") - throw Exception("Failed to extract OAuth token from response") - } - - val token = match.groupValues[1] - Log.i(TAG, "✓ OAuth token obtained: ${token.take(20)}...") - - return token - } - - /** - * Fetch entitlements using OAuth token (paginated). - * Mirrors: CloudCatalogBackend::fetchOwnedGamesPage() - */ - private suspend fun fetchEntitlementsPaginated(oauthToken: String): List - { - Log.i(TAG, "=== Fetching entitlements (paginated) ===") - - val all = mutableListOf() - var start = 0 - - while (true) - { - val url = "https://commerce.api.np.km.playstation.net/commerce/api/v1/users/me/internal_entitlements?fields=game_meta&entitlement_type=5&start=$start&size=${PsCloudOwnership.PAGE_SIZE}" - - val response = HttpClient.get( - url = url, - headers = mapOf( - "Authorization" to "Bearer $oauthToken", - "Accept" to "application/json" - ) - ) - - if (response.statusCode != 200) - { - Log.e(TAG, "Entitlements fetch failed: ${response.statusCode}") - throw Exception("Failed to fetch entitlements: HTTP ${response.statusCode}") - } - - val jsonObj = JSONObject(response.body) - val entitlementsArray = jsonObj.optJSONArray("entitlements") ?: JSONArray() - val pageSize = entitlementsArray.length() - - for (i in 0 until pageSize) - { - PsCloudOwnership.parseEntitlement(entitlementsArray.getJSONObject(i))?.let { all.add(it) } - } - - if (pageSize < PsCloudOwnership.PAGE_SIZE) break - start += pageSize - kotlinx.coroutines.delay(PsCloudOwnership.PAGE_COOLDOWN_MS) - } - - Log.i(TAG, " Entitlements count: ${all.size}") - return all - } - - /** - * Extract both cover and landscape image URLs from game object - * Returns Pair - * Mirrors: CloudCatalogBackend::extractCoverImageFromGameObject() - */ - private fun extractImageUrls(gameObj: JSONObject): Pair - { - val imagesArray = gameObj.optJSONArray("images") ?: return Pair("", "") - - var coverUrl = "" - var landscapeUrl = "" - - // Extract both cover (type 10) and landscape (type 12/13) - for (i in 0 until imagesArray.length()) - { - val image = imagesArray.getJSONObject(i) - val type = image.optInt("type", -1) - val url = image.optString("url", "") - - if (url.isEmpty()) continue - - when (type) - { - 10 -> if (coverUrl.isEmpty()) coverUrl = url - 12 -> if (landscapeUrl.isEmpty()) landscapeUrl = url // Prefer 1080p landscape - 13 -> if (landscapeUrl.isEmpty()) landscapeUrl = url // Fallback to 720p landscape - } - } - - // Fallback: use cover for landscape if no landscape found - if (landscapeUrl.isEmpty() && coverUrl.isNotEmpty()) - { - landscapeUrl = coverUrl - } - - // Fallback: use landscape for cover if no cover found - if (coverUrl.isEmpty() && landscapeUrl.isNotEmpty()) - { - coverUrl = landscapeUrl - } - - return Pair(coverUrl, landscapeUrl) - } -} - - diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt deleted file mode 100644 index 6e4b9651..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ /dev/null @@ -1,780 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import android.util.Log -import com.metallic.chiaki.cloudplay.model.CloudGame -import org.json.JSONObject - -object PsCloudOwnership -{ - private const val TAG = "PsCloudOwnership" - const val PAGE_SIZE = 300 - const val PAGE_COOLDOWN_MS = 100L - - data class Entitlement( - val id: String, - val productId: String, - val skuId: String, // PSN sku_id -- stable unique key for deterministic dedupe tie-breaking - val activeFlag: Boolean, - val packageType: String, - val name: String, - val conceptId: String, - val featureType: Int, // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC - // Structured platform from entitlement_attributes[].platform_id ("ps5"/"ps4"/"ps3"). The - // authoritative stream-backend signal -- NOT a CUSA/PPSA id prefix, since a cross-buy PS4 - // license can carry a PS5-looking product_id wrapper (Red Dead's PS4 license has ...PPSA30528). - val platformId: String = "" - ) - - private data class CatalogIndex( - val byProductId: MutableMap, - val byConceptId: MutableMap - ) - - fun filterOwnedPs5Games(entitlements: List): List - { - return entitlements.filter { ent -> - // Previously required packageType == "PSGD" (PS5 only), which dropped owned PS4 - // titles (e.g. God of War 2018) and PS3 titles. Accept every active game entitlement; - // streamability is enforced downstream by the cross-reference (deduped by conceptId), - // so non-streamable / add-on entitlements are harmlessly dropped there. - ent.activeFlag && - !ent.productId.startsWith("IP") && - !ent.productId.startsWith("SUB") && - // Hide EXTRAS: feature_type==0 is DLC/add-ons/themes/avatars/tracks, never a base game - // (games are ft 1=trial/free or 3/5=full). Safe -- it can never hide a game. - ent.featureType != 0 - } - } - - /** Normalize a conceptId (imagic encodes it as a number) to a non-empty string, else "". */ - private fun conceptIdString(value: Any?): String = when (value) - { - is Number -> value.toLong().let { if (it > 0) it.toString() else "" } - is String -> value - else -> "" - } - - fun parseEntitlement(obj: JSONObject): Entitlement? - { - val id = obj.optString("id", "") - if (id.isEmpty()) return null - val gameMeta = obj.optJSONObject("game_meta") ?: JSONObject() - val name = gameMeta.optString("name", id) - val conceptId = conceptIdString(gameMeta.opt("conceptId")) - .ifEmpty { conceptIdString(gameMeta.opt("concept_id")) } - .ifEmpty { conceptIdString(obj.opt("conceptId")) } - // Structured platform from entitlement_attributes[].platform_id. Sony also returns a numeric - // top-level "serviceType" here that is unrelated to our routing -- we never read it. - var platformId = "" - val attrs = obj.optJSONArray("entitlement_attributes") - if (attrs != null) - { - // Scan for the first RECOGNIZED platform (ps5/ps4/ps3); skip any unknown value so a junk - // attribute ordered first can't shadow a real one (mirrors Qt ownedEntitlementServiceType). - for (i in 0 until attrs.length()) - { - val p = attrs.optJSONObject(i)?.optString("platform_id", "")?.lowercase() ?: "" - if (p == "ps5" || p == "ps4" || p == "ps3") { platformId = p; break } - } - } - return Entitlement( - id = id, - productId = obj.optString("product_id", ""), - skuId = obj.optString("sku_id", ""), - activeFlag = obj.optBoolean("active_flag", false), - packageType = gameMeta.optString("package_type", ""), - name = name, - conceptId = conceptId, - featureType = obj.optInt("feature_type", 0), - platformId = platformId - ) - } - - /** Canonical stream service for an owned entitlement from its structured platform_id: - * ps5 -> pscloud (cronos), ps4/ps3 -> psnow (Kamaji). Empty if platform_id is absent. */ - fun entServiceType(ent: Entitlement): String = when (ent.platformId) - { - "ps5" -> "pscloud" - "ps4", "ps3" -> "psnow" - else -> "" - } - - /** Stream backend for an owned entitlement, with Qt's exact fallback (streamServiceTypeForGame): - * 1) the structured platform_id (authoritative -- a cross-buy PS4 wrapper has platform_id "ps4" - * even though its product_id is a PS5-looking PPSA, so it correctly stays psnow/Kamaji); else - * 2) the entitlement's own product-id TOKEN (CUSA = PS4/Kamaji, PPSA = PS5/cronos). PS Plus classics - * (e.g. Blood Omen, product ...PPSA24270...) carry NO platform_id and match a PS Now/Apollo - * (psnow) browse row by concept -- inheriting meta.serviceType would mis-route them to Kamaji and - * fail. The product-id token routes them to cronos like Qt. Only when neither token is present do - * we fall back to the matched row's serviceType. */ - private fun ownedServiceType(ent: Entitlement, meta: CloudGame): String - { - val svc = entServiceType(ent) - if (svc.isNotEmpty()) return svc - val tok = ent.productId + " " + ent.id - return when - { - tok.contains("CUSA") -> "psnow" - tok.contains("PPSA") -> "pscloud" - else -> meta.serviceType - } - } - - /** Platform class (ps5/ps4) for owned dedupe, from platform_id; falls back to the product-id token - * only when platform_id is absent (never relied on for the CUSA/PPSA wrapper-prone cross-buy case, - * which always carries a platform_id). */ - private fun entPlatform(ent: Entitlement): String = when (ent.platformId) - { - "ps5" -> "ps5" - "ps4", "ps3" -> "ps4" - else -> platformToken(ent.productId) - } - - fun crossReferenceOwnedGames( - filteredEntitlements: List, - publicCatalog: List, - plusLibrarySupplement: List = emptyList(), - productIdAliases: Map = emptyMap(), - componentIdsByProductId: Map> = emptyMap(), - ): List - { - val catalogMap = catalogMapFirstWins(publicCatalog) - for ((alias, canonical) in productIdAliases) - { - if (alias in catalogMap) - continue - catalogMap[canonical]?.let { catalogMap[alias] = it } - } - val supplementMap = catalogMapFirstWins(plusLibrarySupplement) - val browseStableKey = buildStableKeyIndex(publicCatalog) - val supplementStableKey = buildStableKeyIndex(plusLibrarySupplement) - val browseByConcept = buildConceptIdIndex(publicCatalog) - val supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) - val byKey = linkedMapOf() - val byKeyEnt = mutableMapOf() - - // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping OUR - // convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct - // match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). - fun emit(meta: CloudGame, ent: Entitlement) - { - val displayName = meta.name.ifEmpty { ent.name } - // The owned card's serviceType comes from the ENTITLEMENT's platform_id (pscloud == PS5, - // psnow == PS3/PS4), not the matched catalog row's: a cross-buy PS4 license can match a PS5 - // catalog row by shared product_id, but it must still route as PS4/Kamaji. - val ownedService = ownedServiceType(ent, meta) - // Qt emitOwned: productId = entitlement.product_id (NOT catalog meta.productId). - val streamProductId = ent.productId.ifEmpty { meta.productId } - val game = CloudGame( - productId = streamProductId, - name = displayName, - imageUrl = meta.imageUrl, - landscapeImageUrl = meta.landscapeImageUrl, - thumbnailUrl = meta.thumbnailUrl, - platform = meta.platform, - serviceType = ownedService, - conceptUrl = meta.conceptUrl, - conceptId = meta.conceptId, - isOwned = true, - entitlementId = ent.id, - storeProductId = ent.productId, - plusCatalog = meta.plusCatalog, - featureType = ent.featureType, - category = meta.category - ) - val key = ownedDedupeKey(meta, ent) - // Keep the best streaming candidate via a DETERMINISTIC total order (ownedEntitlementBetter), - // independent of the PSN entitlements response order, so the catalog is stable across - // refreshes (cross-buy titles with equal stream rank routinely tie). Mirrors Qt - // ps5CloudOwnedEntitlementBetter in cloudcatalogbackend.cpp. - val existingEnt = byKeyEnt[key] - if (existingEnt == null || ownedEntitlementBetter(ent, existingEnt)) - { - byKey[key] = game - byKeyEnt[key] = ent - } - } - - for (ent in filteredEntitlements) - { - val stable = productIdStableKey(ent.productId) - val entStable = productIdStableKey(ent.id) - val skipStableDemo = ent.name.contains("demo", ignoreCase = true) - val meta = when { - ent.productId.isNotEmpty() && catalogMap.containsKey(ent.productId) -> - catalogMap[ent.productId] - ent.id.isNotEmpty() && catalogMap.containsKey(ent.id) -> - catalogMap[ent.id] - // Inert in practice: PSN entitlements carry no conceptId (see findCatalogIndexForOwned note), so this - // platform-blind concept lookup almost never fires; owned games match by exact id above. - // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). - ent.conceptId.isNotEmpty() && browseByConcept.containsKey(ent.conceptId) -> - browseByConcept[ent.conceptId] - ent.conceptId.isNotEmpty() && supplementByConcept.containsKey(ent.conceptId) -> - supplementByConcept[ent.conceptId] - ent.productId.isNotEmpty() && ent.id == ent.productId - && supplementMap.containsKey(ent.productId) -> - supplementMap[ent.productId] - stable != null && !skipStableDemo && browseStableKey.containsKey(stable) -> - browseStableKey[stable] - stable != null && !skipStableDemo && supplementStableKey.containsKey(stable) -> - supplementStableKey[stable] - // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade - // entitlement ids whose stable key matches a catalog row even when product_id did not. - entStable != null && !skipStableDemo && browseStableKey.containsKey(entStable) -> - browseStableKey[entStable] - entStable != null && !skipStableDemo && supplementStableKey.containsKey(entStable) -> - supplementStableKey[entStable] - else -> null - } - - if (meta != null) - { - emit(meta, ent) - continue - } - - // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no - // direct catalog row, but its component entitlement ids each map to a component game. - val seenPids = mutableSetOf() - for (siblingId in componentIdsByProductId[ent.productId] ?: emptyList()) - { - val siblingMeta = when { - catalogMap.containsKey(siblingId) -> catalogMap[siblingId] - supplementMap.containsKey(siblingId) -> supplementMap[siblingId] - else -> { - val s2 = productIdStableKey(siblingId) - if (s2 != null && !skipStableDemo) browseStableKey[s2] ?: supplementStableKey[s2] else null - } - } ?: continue - if (siblingMeta.productId.isEmpty() || seenPids.contains(siblingMeta.productId)) continue - seenPids.add(siblingMeta.productId) - emit(siblingMeta, ent) - } - } - - // Disc-upgrade rescue (mirrors cloudcatalogbackend.cpp). feature_type 5 is a PS4-disc -> PS5 - // *disc upgrade* license; Gaikai refuses to cloud-stream it ("disc-upgrade-unsupported"). The - // browse catalog often binds the concept to exactly that SKU (e.g. Horizon Forbidden West - // concept 10000886 -> PPSA01521), while the user's streamable full-game entitlement is a - // DIFFERENT title id (e.g. Complete Edition PPSA17903) that is absent from the catalog and - // carries no conceptId -- so it never matches and only the disc-upgrade SKU survives. When a - // concept winner is a disc upgrade, adopt a same-name full-game (feature_type 3) owned SKU's - // product id so the card streams the edition Gaikai accepts. - // - // Entitlements carry no conceptId and the disc-upgrade SKU shares no id/sku with the real - // edition, so the only in-data bridge is the title name. To keep that safe: SAME PLATFORM only - // (a PS5/PPSA disc upgrade must never resolve to a PS4/CUSA SKU), prefer the canonical base game - // (product_id == entitlement id), and BAIL on genuine ambiguity rather than guess. - for (key in byKey.keys.toList()) - { - val game = byKey[key] ?: continue - if (game.featureType != 5) continue - val discPid = game.storeProductId - val discPlatform = platformToken(discPid) - val discEnt = filteredEntitlements.firstOrNull { - it.productId == discPid && it.featureType == 5 - } ?: continue - val discName = normalizeTitle(discEnt.name) - if (discName.isEmpty()) continue - val canonical = mutableListOf() // base-game SKUs (product_id == entitlement id) - val other = mutableListOf() // non-canonical full-game SKUs - for (cand in filteredEntitlements) - { - if (cand.featureType != 3) continue - if (normalizeTitle(cand.name) != discName) continue - val candPid = cand.productId - if (candPid.isEmpty() || candPid == discPid) continue - if (platformToken(candPid) != discPlatform) continue - if (candPid == cand.id) - { - if (candPid !in canonical) canonical.add(candPid) - } - else if (candPid !in other) - { - other.add(candPid) - } - } - val replacement = when - { - canonical.size == 1 -> canonical[0] - canonical.isEmpty() && other.size == 1 -> other[0] - else -> null - } - if (replacement == null) - { - if (canonical.isNotEmpty() || other.isNotEmpty()) - Log.w(TAG, "disc-upgrade rescue: ambiguous candidates for $discName -- leaving disc SKU") - continue - } - byKey[key] = game.copy(storeProductId = replacement) - Log.i(TAG, "disc-upgrade rescue: $discName $discPid -> $replacement") - } - - // QMap iteration is sorted by dedupe key; merge depends on :ps4 before :ps5 (cloudcatalogbackend.cpp). - return byKey.keys.sorted().mapNotNull { byKey[it] } - } - - // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen - // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing - // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. - private fun ownedDedupeKey(meta: CloudGame, ent: Entitlement): String - { - // Platform from the ENTITLEMENT's structured platform_id (NOT the matched catalog row's, and NOT - // a product-id prefix): a cross-buy title gives the user up to three entitlements that resolve to - // ONE catalog row -- a clean PS4 (CUSA, platform_id ps4), a real PS5 (PPSA, platform_id ps5), and - // a PS5-wrapper PS4 license (id CUSA, product_id ...PPSA..., platform_id ps4). The real PS5 and - // the PS5-wrapper PS4 must stay in SEPARATE buckets (ps5 vs ps4) so the PS5 entitlement is not - // discarded by a same-key collision; the merge then stamps the PS5 card from the PS5 entitlement - // and DROPS the PS4 wrapper (it can't claim a PS5 card). Collapsing by the catalog row's platform - // instead let the wrapper win and threw away the real PS5 license (the Blood Omen / GTA V PS5 - // streaming failure). - if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}:${entPlatform(ent)}" - if (meta.productId.isNotEmpty()) return "p:${meta.productId}" - if (ent.id.isNotEmpty()) return "e:${ent.id}" - return "u:${meta.productId}:${ent.id}" - } - - /** Platform token from a product id (CUSA = PS4, PPSA = PS5). */ - private fun platformToken(productId: String): String = when - { - productId.contains("PPSA") -> "ps5" - productId.contains("CUSA") -> "ps4" - else -> "" - } - - /** Lowercase, strip trademark/registered/service-mark glyphs, and collapse whitespace so two owned - * entitlements for the same game compare equal across punctuation/spacing differences. */ - private fun normalizeTitle(raw: String): String = - raw.lowercase() - .replace("™", "").replace("®", "").replace("℠", "") - .trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.joinToString(" ") - - /** A full-game entitlement (vs add-on/avatar): base game has a *GD package_type. */ - private fun isFullGameEntitlement(ent: Entitlement): Boolean = - ent.featureType == 3 || ent.packageType.endsWith("GD") - - // Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). - // Bonus/upgrade SKUs collapse to the same conceptId+platform as the base game; package/feature - // flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3). - // The reliable signal: the base game's entitlement id EQUALS its product_id, while bonus/upgrade - // SKUs carry a different id -- so prefer the canonical full-game entitlement. - private fun ownedStreamRank(ent: Entitlement): Int - { - var rank = 0 - if (ent.productId.isNotEmpty() && ent.productId == ent.id) rank += 4 // canonical base-game SKU - if (isFullGameEntitlement(ent)) rank += 2 - if (ent.id.isNotEmpty()) rank += 1 - return rank - } - - // Is this a "Game Streaming" (GS) package? PSN labels the cloud-streamable SKU *GS (e.g. PS4GS) and - // the installable download *GD (PS4GD / PSGD). For a cross-buy title the GS entitlement carries the - // clean, streamable product for its platform, while the GD cross-buy SKU can carry a cross-gen - // *wrapper* product. So when two same-platform SKUs collapse to one edition, the GS SKU is the right - // streaming candidate. Uses the structured package_type field -- no product-id prefix guessing. - private fun isStreamingPackage(ent: Entitlement): Boolean = ent.packageType.endsWith("GS") - - // Deterministic total order over owned entitlements that collapse to the same edition (conceptId + - // platform). MUST be independent of the PSN entitlements response order so the assembled catalog is - // stable across refreshes. Returns true if `cand` should replace `cur` as the edition's - // representative. Signals, in priority order, all from structured API fields: (1) higher stream rank - // (canonical full-game product); (2) the cloud-streaming (GS) package over a download (GD) SKU; - // (3) stable unique sku_id, then product_id, then entitlement id, to guarantee one deterministic - // winner. Mirrors Qt ps5CloudOwnedEntitlementBetter (cloudcatalogbackend.cpp). - private fun ownedEntitlementBetter(cand: Entitlement, cur: Entitlement): Boolean - { - val rc = ownedStreamRank(cand); val ru = ownedStreamRank(cur) - if (rc != ru) return rc > ru - val gc = isStreamingPackage(cand); val gu = isStreamingPackage(cur) - if (gc != gu) return gc - if (cand.skuId != cur.skuId) return cand.skuId < cur.skuId - if (cand.productId != cur.productId) return cand.productId < cur.productId - return cand.id < cur.id - } - - /** conceptId + platform. Platform comes from the canonical serviceType (pscloud == ps5, psnow == - * ps4-class) -- filled for owned cards from the entitlement's platform_id -- so an owned cross-buy - * PS4 license whose product_id is a PS5-looking wrapper buckets to the PS4 edition, not the PS5 - * one. Falls back to the product-id token when serviceType is absent (non-owned imagic rows). */ - private fun conceptPlatformKey(game: CloudGame): String - { - if (game.conceptId.isEmpty()) return "" - return "${game.conceptId}|${platformClassForCard(game)}" - } - - /** Platform CLASS of a catalog/owned card (ps5 or ps4). Mirrors Qt gamePlatformStructured + - * ps5CloudPlatformToken fallback in mergeOwnedIntoBrowseCatalog. */ - private fun platformClassForCard(game: CloudGame): String - { - val st = game.serviceType.lowercase() - if (st == "pscloud") return "ps5" - if (st == "psnow") return "ps4" - return platformToken(game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } }) - } - - private fun catalogMapFirstWins(games: List): MutableMap - { - val map = linkedMapOf() - for (game in games) - { - if (game.productId.isNotEmpty() && game.productId !in map) - map[game.productId] = game - } - return map - } - - /** Tokenize on '-' and '_'; identity is all tokens except the last (store SKU). */ - private fun productIdStableKey(productId: String): String? - { - if (productId.isEmpty()) - return null - val tokens = mutableListOf() - for (dashPart in productId.split('-')) - { - for (token in dashPart.split('_')) - { - if (token.isNotEmpty()) - tokens.add(token) - } - } - if (tokens.size < 2) - return null - return tokens.dropLast(1).joinToString("|") - } - - private fun buildStableKeyIndex(games: List): Map - { - val index = linkedMapOf() - for (game in games) - { - val key = productIdStableKey(game.productId) ?: continue - if (key !in index) - index[key] = game - } - return index - } - - private fun buildConceptIdIndex(games: List): Map - { - val index = linkedMapOf() - for (game in games) - { - if (game.conceptId.isNotEmpty() && game.conceptId !in index) - index[game.conceptId] = game - } - return index - } - - fun mergeOwnedIntoBrowseCatalog( - browseCatalog: List, - ownedCrossRef: List, - addUnmatched: Boolean = true // false = only mark ownership (Catalog tab), never add - ): List - { - val games = browseCatalog.toMutableList() - val catalogIndex = buildCatalogIndex(games) - - // Products the user FULLY owns (feature_type != 1). A trial (ft1) is kept as its own card ONLY - // when the full game is NOT owned; when the SAME product is also held as a full license (common - // for F2P cross-buy titles: a PS4 trial whose product_id is the PS5 PPSA wrapper, e.g. Trackmania - // / Super Animal Royale / Fantasy Beauties) the trial card is redundant AND broken -- it routes - // to Kamaji (psnow) while carrying a PS5 product. Suppress those trials. Order-independent - // pre-pass. Mirrors Qt mergeOwnedIntoBrowseCatalog (cloudcatalogbackend.cpp). - val fullyOwnedProductIds = ownedCrossRef - .filter { it.featureType != 1 && it.productId.isNotEmpty() } - .map { it.productId } - .toHashSet() - - // Process pscloud (PS5) owned claims BEFORE psnow (PS3/PS4). A PS5 (pscloud) claim is - // authoritative and stamps the PS5 browse row in place; doing it first means the row is already - // owned by the time any PS4 cross-buy license (whose product_id is the SAME PPSA wrapper) is - // seen, so the wrapper is dropped cleanly instead of appending a duplicate / orphaning the - // browse row as a "ghost". Deterministic, order-independent. Stable partition. (Qt parity.) - val ownedOrdered = ownedCrossRef.filter { it.serviceType.lowercase() == "pscloud" } + - ownedCrossRef.filter { it.serviceType.lowercase() != "pscloud" } - - for (owned in ownedOrdered) - { - val isTrialTier = owned.featureType == 1 - // A trial whose product is also fully owned is superseded by the full license -- drop it. - if (isTrialTier && fullyOwnedProductIds.contains(owned.productId)) continue - val catalogMatch = if (isTrialTier) -1 else findCatalogIndexForOwned(owned, catalogIndex) - if (catalogMatch >= 0) - { - val existing = games[catalogMatch] - val ownedService = owned.serviceType.lowercase() - val existingService = existing.serviceType.lowercase() - val existingClass = platformClassForCard(existing) - // The card's stream identity must come from the OWNED entitlement of THIS card's - // platform. Cross-buy editions share one product_id (Red Dead's PS4 license and PS5 - // license both carry ...PPSA30528...), so matching by product_id alone lets a PS4 - // entitlement land on the PS5 card. Rule: a PS5 (pscloud) claim is authoritative; a - // PS4/PS3 (psnow) entitlement must NEVER overwrite a PS5-class card. Mirrors Qt - // mergeOwnedIntoBrowseCatalog exactly (cloudcatalogbackend.cpp). - if (ownedService == "pscloud") - { - games[catalogMatch] = existing.copy( - isOwned = true, - serviceType = "pscloud", - entitlementId = owned.entitlementId.ifEmpty { existing.entitlementId }, - storeProductId = owned.storeProductId.ifEmpty { existing.storeProductId } - ) - continue - } - if (ownedService == "psnow" && existingService != "pscloud" && existingClass != "ps5") - { - games[catalogMatch] = existing.copy( - isOwned = true, - serviceType = "psnow", - entitlementId = owned.entitlementId.ifEmpty { existing.entitlementId }, - storeProductId = owned.storeProductId.ifEmpty { existing.storeProductId } - ) - continue - } - // psnow entitlement whose matched card is PS5-class: this is a PS4 CROSS-BUY license - // whose product_id is the shared PS5 (PPSA) wrapper. DROP it (Qt parity): the PS5 card - // is claimed by the PS5 (pscloud) license processed first, the real PS4 variant matches - // its own CUSA row independently, and appending here would create a bogus duplicate / - // ghost that can't stream (a PS5 cloud product needs a PS5 entitlement). - if (ownedService == "psnow") continue - } - - if (!addUnmatched) continue - val entry = owned.copy(isOwned = true) - registerInCatalogIndex(entry, games.size, catalogIndex) - games.add(entry) - } - - return games.sortedWith( - compareByDescending { it.isOwned } - .thenBy { it.name.lowercase() } - ) - } - - fun streamingIdentifier(game: CloudGame): String - { - if (game.serviceType.equals("pscloud", ignoreCase = true)) - { - // PS5/cronos streams the owned PS5 entitlement's OWN id (entitlementId), resolved from the - // entitlement's platform_id during cross-reference. Canonical SKUs (Red Dead, Alan Wake) - // have id == product_id == ...PPSA...; a classic whose product_id is a non-streamable - // wrapper (Blood Omen) has the ...PPSA..SLUS license id. Never a PS4/CUSA cross-buy id -- - // the platform-disciplined merge guarantees a PS5 card carries only PS5 entitlement data. - if (game.entitlementId.isNotEmpty()) return game.entitlementId - if (game.storeProductId.isNotEmpty()) return game.storeProductId - } - return game.productId - } - - // Platform that drives the streaming path (PS4 = Kamaji, PS5 = cronos). serviceType is the - // canonical signal but with one asymmetry: `psnow` is always PS3/PS4-class (set on PS Now browse - // rows and filled for owned PS3/PS4 cards from platform_id), while `pscloud` is authoritative ONLY - // for OWNED cards (filled from the entitlement's platform_id) -- non-owned imagic browse rows are - // blanket-labeled `pscloud` yet include a few PS4 titles, so for those we use the clean id token - // (PS4 there streams via PS Now/Kamaji, not cronos). Mirrors canonical Qt, whose non-owned imagic - // rows simply carry no serviceType and so fall through to the same token path. - fun streamPlatform(game: CloudGame): String - { - val st = game.serviceType.lowercase() - if (st == "psnow") return "ps4" - // isOwned gate: imagic browse rows are blanket-tagged serviceType="pscloud" (see catalog parse), so - // only treat "pscloud" as PS5/cronos when actually OWNED; non-owned rows fall through to the product-id - // token below, routing non-owned PS4 imagic titles to PS Now (matches Qt, whose imagic rows carry no - // serviceType at all). - if (st == "pscloud" && game.isOwned) return "ps5" - val p = game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } } - return when - { - p.contains("PPSA") -> "ps5" - p.contains("CUSA") -> "ps4" - else -> game.platform.ifEmpty { "ps5" } - } - } - - /** Route by the (platform_id-disciplined) streaming platform: PS3/PS4 via Kamaji (psnow), PS5 - * direct (pscloud). */ - fun streamServiceType(game: CloudGame): String - { - if (game.serviceType.equals("psnow", ignoreCase = true)) return "psnow" - return if (streamPlatform(game) == "ps4") "psnow" else "pscloud" - } - - /** Identifier for startCompleteCloudSession: psnow sends the product id (Kamaji converts it - * and acquires via PS Plus); pscloud sends the owned entitlement id (direct). */ - fun streamIdentifier(game: CloudGame): String - { - return if (streamServiceType(game) == "psnow") game.productId.ifEmpty { streamingIdentifier(game) } - else streamingIdentifier(game) - } - - private fun buildCatalogIndex(games: List): CatalogIndex - { - val byProductId = mutableMapOf() - val byConceptId = mutableMapOf() - for (i in games.indices) - registerInCatalogIndex(games[i], i, CatalogIndex(byProductId, byConceptId)) - return CatalogIndex(byProductId, byConceptId) - } - - private fun registerInCatalogIndex(game: CloudGame, index: Int, catalogIndex: CatalogIndex) - { - if (game.productId.isNotEmpty()) - catalogIndex.byProductId[game.productId] = index - val conceptKey = conceptPlatformKey(game) - if (conceptKey.isNotEmpty()) - catalogIndex.byConceptId[conceptKey] = index - if (game.entitlementId.isNotEmpty() && game.entitlementId != game.productId) - catalogIndex.byProductId[game.entitlementId] = index - } - - // IMPORTANT (this cost real debugging time): PSN *owned entitlements* carry NO conceptId in practice - // -- their game_meta is just { name, package_type, icon_url }. So every conceptId-based step below is - // effectively INERT for owned games: an owned entitlement resolves to a catalog row by EXACT ID ONLY - // (product_id -> entitlement id -> store product id). The conceptId machinery's live job is catalog-row - // edition dedup (edition / conceptPlatformKey), NOT owned->catalog matching. - // - // Also: a PS4 CROSS-BUY license can carry a PS5-looking PPSA *product_id* wrapper while its real PS4 - // component is the CUSA *id* (platform_id stays "ps4"). product_id is matched against the catalog FIRST, - // so such a ps4 license can land on a PS5 (PPSA) row. NEVER infer platform from a product-id prefix for - // owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge - // guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. - private fun findCatalogIndexForOwned(owned: CloudGame, catalogIndex: CatalogIndex): Int - { - if (owned.productId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.productId)) - return catalogIndex.byProductId.getValue(owned.productId) - if (owned.entitlementId.isNotEmpty() && owned.entitlementId != owned.productId - && catalogIndex.byProductId.containsKey(owned.entitlementId)) - return catalogIndex.byProductId.getValue(owned.entitlementId) - if (owned.storeProductId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.storeProductId)) - return catalogIndex.byProductId.getValue(owned.storeProductId) - // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog - // entry (and vice-versa); cross-gen editions stay as separate library cards. - val conceptKey = conceptPlatformKey(owned) - if (conceptKey.isNotEmpty() && catalogIndex.byConceptId.containsKey(conceptKey)) - return catalogIndex.byConceptId.getValue(conceptKey) - return -1 - } - - // --------------------------------------------------------------------------------------------- - // Unified-page assembly: acquisition tag + concept-sibling streamability gate - // --------------------------------------------------------------------------------------------- - - /** Acquisition tag for the single unified list. */ - const val CATEGORY_OWNED = "owned" - const val CATEGORY_STREAMABLE = "streamable" - const val CATEGORY_PURCHASEABLE = "purchaseable" - - /** - * One tag per game, priority Owned > Streamable > Purchaseable: - * - owned -> entitlement resolved to a streamable row (Stream) - * - streamable -> not owned, PS Now subscription title (PS3/PS4 via Kamaji) (Stream) - * - purchaseable -> not owned, PS Plus catalog title (PS5 via Gaikai) (Add to Library) - */ - fun categoryFor(game: CloudGame): String = when - { - game.isOwned -> CATEGORY_OWNED - catalogServiceType(game) == "psnow" -> CATEGORY_STREAMABLE - else -> CATEGORY_PURCHASEABLE - } - - // Category is a CATALOG classification, mirroring Qt categoryForGame + streamServiceTypeForGame - // EXACTLY: the canonical serviceType wins (BOTH "psnow" and "pscloud" short-circuit); only a row - // with no serviceType derives from the CUSA/PPSA token. Deliberately independent of - // streamServiceType, whose isOwned gate re-routes non-owned pscloud rows to Kamaji for STREAMING - // only -- using it here mis-tags non-owned pscloud PS4 titles (e.g. cross-gen indie bundles) as - // "streamable" instead of "purchaseable". - private fun catalogServiceType(game: CloudGame): String - { - val st = game.serviceType.lowercase() - if (st == "psnow" || st == "pscloud") return st - val p = game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } } - return if (p.contains("CUSA")) "psnow" else "pscloud" - } - - /** - * Concept-sibling streamability gate index, built from the ACTUAL streamable catalog: - * - APOLLOROOT (PS3 + PS4) — streamable via Kamaji - * - main imagic browse (streamingSupported=true) — streamable via Gaikai (PS5 + a few PS4) - * - * A title is streamable iff it OR a same-conceptId sibling resolves into that catalog. This is - * deterministic (concept/id membership), so it never "remembers failures" or hides - * intermittently. Keeps cross-gen true positives (e.g. owned PS5 Horizon ZD Remastered via its - * PS4 sibling in APOLLOROOT) and drops no-streamable-path titles (e.g. FOR HONOR). - */ - class StreamabilityIndex( - apolloCatalog: List, // PS Now APOLLOROOT (PS3 + PS4) - imagicBrowse: List, // imagic streamingSupported=true set - imagicConceptRows: List, // browse + supplement: rows carrying conceptId<->productId - ) - { - private val productKeys = HashSet() // raw product ids + stable keys - private val streamableConceptIds = HashSet() - - init - { - fun addProduct(productId: String) - { - if (productId.isEmpty()) return - productKeys.add(productId) - productIdStableKey(productId)?.let { productKeys.add(it) } - } - apolloCatalog.forEach { addProduct(it.productId) } - imagicBrowse.forEach { - addProduct(it.productId) - if (it.conceptId.isNotEmpty()) streamableConceptIds.add(it.conceptId) - } - // Bridge APOLLOROOT membership -> conceptId. APOLLOROOT rows carry no conceptId, so use - // any imagic row (browse OR supplement) whose product id IS in APOLLOROOT to mark its - // concept streamable. A cross-gen sibling sharing that concept (e.g. the PS5 edition) is - // then kept even though it lives only in the supplement. - for (row in imagicConceptRows) - { - if (row.conceptId.isEmpty()) continue - val keys = listOfNotNull( - row.productId.takeIf { it.isNotEmpty() }, - productIdStableKey(row.productId) - ) - if (keys.any { it in productKeys }) - streamableConceptIds.add(row.conceptId) - } - } - - fun isStreamable(game: CloudGame): Boolean - { - for (p in listOf(game.productId, game.storeProductId, game.entitlementId)) - { - if (p.isEmpty()) continue - if (p in productKeys) return true - val stable = productIdStableKey(p) - if (stable != null && stable in productKeys) return true - } - return game.conceptId.isNotEmpty() && game.conceptId in streamableConceptIds - } - } - - /** - * Drop owned titles with no streamable path (native mode only). Non-owned rows already come - * straight from the streamable catalog, so they are never gated. - */ - fun applyStreamabilityGate(games: List, index: StreamabilityIndex): List - { - val kept = mutableListOf() - var dropped = 0 - for (game in games) - { - if (!game.isOwned || index.isStreamable(game)) - kept.add(game) - else - { - dropped++ - Log.i(TAG, "streamability gate: dropped owned non-streamable '${game.name}' (${game.productId})") - } - } - if (dropped > 0) - Log.i(TAG, "streamability gate: dropped $dropped owned non-streamable titles") - return kept - } -} diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt deleted file mode 100644 index f542f802..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt +++ /dev/null @@ -1,709 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import android.util.Log -import com.metallic.chiaki.cloudplay.DuidUtil -import com.metallic.chiaki.cloudplay.PsnApiConstants -import com.metallic.chiaki.cloudplay.model.CloudGame -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.json.JSONObject - -/** - * PSN Catalog Service - * Implements the complete PSNow catalog fetch flow matching Qt implementation - */ -class PsnCatalogService( - private val preferences: com.metallic.chiaki.common.Preferences -) -{ - companion object - { - private const val TAG = "PsnCatalogService" - private val CATEGORY_PATTERNS = listOf( - "A - B", "C - D", "E - G", "H - L", "M - O", "P - R", "S", "T", "U - Z" - ) - private var gameLogCounter = 0 - } - - private var jsessionId: String? = null - private var baseUrl: String? = null - private var country: String? = null - private var language: String? = null - private val duid = DuidUtil.generateDuid() - - /** - * Outcome of the native PS Now (APOLLOROOT) catalog fetch. - * - storesAvailable=false, authError=false -> /user/stores had no storefront for this account's - * region (unsupported region, e.g. HU): caller should fall back to the public region-group walk. - * - authError=true -> OAuth/session failed (expired NPSSO token). - */ - data class NativeCatalogOutcome( - val games: List, - val storesAvailable: Boolean, - val authError: Boolean - ) - - /** - * Native PS Now catalog fetch (one APOLLOROOT walk: PS3 + PS4) using the account's own - * /user/stores base_url. Matches: CloudCatalogBackend::fetchPsnowCatalog(). - */ - suspend fun fetchNativeCatalog(npssoToken: String): NativeCatalogOutcome = withContext(Dispatchers.IO) - { - try - { - gameLogCounter = 0 // Reset counter for new catalog fetch - Log.i(TAG, "=== Starting PSNow (APOLLOROOT) Catalog Fetch ===") - - // Step 1: OAuth authentication (failure here = expired token) - val oauthCode = fetchOAuthCode(npssoToken) - ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = true) - - // Step 2: Create Kamaji session (failure here = expired token) - val sessionId = createKamajiSession(oauthCode) - ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = true) - - jsessionId = sessionId - - // Step 3: Fetch stores to get base URL. No base_url => region not supported (fallback). - val storesBaseUrl = fetchStores() - ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = false) - - baseUrl = storesBaseUrl - - // Step 4: Fetch root container to get category links - val categoryUrls = fetchRootContainer() - ?: return@withContext NativeCatalogOutcome(emptyList(), storesAvailable = true, authError = false) - - // Step 5: Fetch all category pages - val allGames = mutableListOf() - for ((categoryName, categoryUrl) in categoryUrls) - { - Log.i(TAG, "Fetching category: $categoryName") - val games = fetchCategoryGames(categoryUrl) - allGames.addAll(games) - } - - Log.i(TAG, "=== PSNow Catalog Fetch Complete: ${allGames.size} games (native) ===") - NativeCatalogOutcome(allGames, storesAvailable = true, authError = false) - } - catch (e: Exception) - { - Log.e(TAG, "Error fetching native PSNow catalog", e) - NativeCatalogOutcome(emptyList(), storesAvailable = false, authError = false) - } - } - - /** - * Fallback PS Now catalog fetch: walk the PUBLIC region-group APOLLOROOT container directly - * (no OAuth/session), used when /user/stores has no storefront for the account's region. - * Returns the same PS3 + PS4 set as the native walk. Mirrors the public-container technique - * previously used for the dedicated PS3 fetch; APOLLOROOT already includes PS3. - */ - suspend fun fetchApolloRootCatalog(accountCountry: String): List = withContext(Dispatchers.IO) - { - val storeCountry = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) - val containerId = com.metallic.chiaki.cloudplay.KamajiClassics.apolloRootContainerId(accountCountry) - val containerUrl = "${PsnApiConstants.STORE_BASE}/container/$storeCountry/en/19/$containerId" - - Log.i(TAG, "=== Fetching APOLLOROOT catalog (region group $storeCountry for account $accountCountry) ===") - - val games = mutableListOf() - var start = 0 - var totalResults = -1 - - while (true) - { - val url = "$containerUrl?useOffers=true&gkb=1&gkb2=1&start=$start&size=100" - val response = HttpClient.get( - url = url, - headers = mapOf( - "Accept" to "application/json", - "User-Agent" to PsnApiConstants.USER_AGENT - ) - ) - - if (response.statusCode != 200) - { - Log.w(TAG, "APOLLOROOT page fetch failed (HTTP ${response.statusCode})") - if (games.isEmpty()) - throw Exception("Failed to fetch APOLLOROOT catalog: HTTP ${response.statusCode}") - break // Partial data already collected: return what we have. - } - - val obj = JSONObject(response.body) - if (totalResults < 0) - totalResults = obj.optInt("total_results", 0) - - val links = obj.optJSONArray("links") ?: JSONArray() - var productCount = 0 - for (i in 0 until links.length()) - { - val g = links.optJSONObject(i) ?: continue - if (g.optString("container_type") != "product") - continue - parseGameObject(g)?.let { games.add(it); productCount++ } - } - - Log.i(TAG, " APOLLOROOT page products: $productCount, accumulated: ${games.size} of $totalResults") - - start += 100 - if (productCount == 0 || start >= totalResults) - break - } - - Log.i(TAG, " APOLLOROOT catalog complete: ${games.size} titles") - games - } - - /** - * Step 1: OAuth authentication with NPSSO token - * Matches: CloudCatalogBackend::fetchPsnowOAuthToken() - */ - private fun fetchOAuthCode(npssoToken: String): String? - { - try - { - // Build URL with proper encoding using Uri.Builder (matches Qt's QUrlQuery) - val uri = android.net.Uri.parse("${PsnApiConstants.ACCOUNT_BASE}/v1/oauth/authorize") - .buildUpon() - .appendQueryParameter("smcid", "pc:psnow") - .appendQueryParameter("applicationId", "psnow") - .appendQueryParameter("response_type", "code") - .appendQueryParameter("scope", PsnApiConstants.PS4_SCOPES) - .appendQueryParameter("client_id", PsnApiConstants.CLIENT_ID) - .appendQueryParameter("redirect_uri", PsnApiConstants.REDIRECT_URI) - .appendQueryParameter("service_entity", "urn:service-entity:psn") - .appendQueryParameter("prompt", "none") - .appendQueryParameter("renderMode", "mobilePortrait") - .appendQueryParameter("hidePageElements", "forgotPasswordLink") - .appendQueryParameter("displayFooter", "none") - .appendQueryParameter("disableLinks", "qriocityLink") - .appendQueryParameter("mid", "PSNOW") - .appendQueryParameter("duid", duid) - .appendQueryParameter("layout_type", "popup") - .appendQueryParameter("service_logo", "ps") - .appendQueryParameter("tp_psn", "true") - .appendQueryParameter("noEVBlock", "true") - .build() - - val url = uri.toString() - - Log.d(TAG, "OAuth request URL: $url") - - val headers = mapOf( - "Cookie" to "npsso=$npssoToken" - ) - - val response = HttpClient.get(url, headers, followRedirects = false) - - Log.d(TAG, "OAuth response status: ${response.statusCode}") - - if (response.statusCode != 302) - { - Log.e(TAG, "OAuth failed: expected 302, got ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - return null - } - - // Extract code from Location header - val location = HttpClient.extractLocation(response.headers) - if (location == null) - { - Log.e(TAG, "No Location header in OAuth response") - return null - } - - Log.d(TAG, "OAuth redirect location: $location") - - val codeRegex = Regex("[?&]code=([^&]+)") - val match = codeRegex.find(location) - val code = match?.groupValues?.get(1) - - if (code.isNullOrEmpty()) - { - Log.e(TAG, "No OAuth code in redirect location") - return null - } - - Log.i(TAG, "[PSNOW] Got OAuth code, creating session...") - return code - } - catch (e: Exception) - { - Log.e(TAG, "OAuth error", e) - return null - } - } - - /** - * Step 2: Create Kamaji session - * Matches: CloudCatalogBackend::fetchPsnowSession() - */ - private fun createKamajiSession(oauthCode: String): String? - { - try - { - val url = "${PsnApiConstants.KAMAJI_BASE}/user/session" - val body = "code=$oauthCode&client_id=${PsnApiConstants.CLIENT_ID}&duid=$duid" - - Log.i(TAG, "=== Creating Kamaji Session ===") - Log.d(TAG, "POST $url") - Log.d(TAG, "Body: $body") - - val headers = mapOf( - "Content-Type" to "text/plain;charset=UTF-8", - "X-Alt-Referer" to PsnApiConstants.REDIRECT_URI, - "Origin" to PsnApiConstants.ORIGIN, - "Referer" to PsnApiConstants.REFERER, - "Accept" to "*/*" - ) - - val response = HttpClient.post(url, body, headers) - - Log.i(TAG, "=== Session Response ===") - Log.d(TAG, "Status: ${response.statusCode}") - Log.d(TAG, "Body: ${response.body}") - - if (response.statusCode != 200) - { - Log.e(TAG, "Session creation failed: ${response.statusCode}") - return null - } - - // Parse JSON response - val json = JSONObject(response.body) - val header = json.optJSONObject("header") - val data = json.optJSONObject("data") - - if (header?.optString("status_code") != "0x0000") - { - Log.e(TAG, "Session failed with status: ${header?.optString("status_code")}") - return null - } - - // Extract country and language from session data (Qt lines 432-433) - val sessionCountry = data?.optString("country") - val sessionLanguage = data?.optString("language") - - country = sessionCountry - language = sessionLanguage - - // Save country and language to settings as locale (Qt lines 435-440) - if (!sessionCountry.isNullOrEmpty() && !sessionLanguage.isNullOrEmpty()) - { - preferences.setCloudLanguageFromSession(sessionLanguage, sessionCountry) - Log.i(TAG, "[PSNOW] Saved locale from session: ${preferences.getCloudLanguage()}") - } - - Log.i(TAG, "Extracted from session - country: $country, language: $language") - - // Extract JSESSIONID from Set-Cookie - val sessionId = HttpClient.extractCookie(response.headers, "JSESSIONID") - if (sessionId.isNullOrEmpty()) - { - Log.e(TAG, "No JSESSIONID in session response") - return null - } - - Log.i(TAG, "[PSNOW] Session created successfully, JSESSIONID: ${sessionId.take(10)}...") - return sessionId - } - catch (e: Exception) - { - Log.e(TAG, "Session creation error", e) - return null - } - } - - /** - * Step 3: Fetch stores to get base URL - * Matches: CloudCatalogBackend::fetchPsnowStores() - */ - private fun fetchStores(): String? - { - try - { - val url = "${PsnApiConstants.KAMAJI_BASE}/user/stores" - - Log.i(TAG, "=== Fetching Stores ===") - Log.d(TAG, "GET $url") - Log.d(TAG, "Using JSESSIONID: ${jsessionId?.take(10)}...") - - val headers = mapOf( - "Cookie" to "JSESSIONID=$jsessionId", - "Origin" to PsnApiConstants.ORIGIN, - "Referer" to PsnApiConstants.REFERER, - "Accept" to "application/json" - ) - - val response = HttpClient.get(url, headers) - - Log.i(TAG, "=== Stores Response ===") - Log.d(TAG, "Status: ${response.statusCode}") - Log.d(TAG, "Full Body: ${response.body}") - - if (response.statusCode != 200) - { - Log.e(TAG, "Stores fetch failed: ${response.statusCode}") - return null - } - - // Parse JSON - match Qt structure: {header: {...}, data: {base_url: "..."}} - val json = JSONObject(response.body) - val header = json.optJSONObject("header") - val data = json.optJSONObject("data") - - if (header?.optString("status_code") != "0x0000") - { - Log.e(TAG, "Stores failed with status: ${header?.optString("status_code")}") - return null - } - - val baseUrl = data?.optString("base_url") - - if (baseUrl.isNullOrEmpty()) - { - Log.e(TAG, "No base_url in stores response data") - return null - } - - Log.i(TAG, "[PSNOW] Stores fetched successfully") - Log.i(TAG, "Base URL from response: $baseUrl") - return baseUrl - } - catch (e: Exception) - { - Log.e(TAG, "Stores fetch error", e) - return null - } - } - - /** - * Step 4: Fetch root container to get category URLs - * Matches: CloudCatalogBackend::fetchPsnowRootContainer() - */ - private fun fetchRootContainer(): Map? - { - try - { - val url = "$baseUrl?size=100" - - Log.i(TAG, "=== Fetching Root Container ===") - Log.d(TAG, "GET $url") - - val headers = mapOf( - "Cookie" to "JSESSIONID=$jsessionId", - "Origin" to PsnApiConstants.ORIGIN, - "Referer" to PsnApiConstants.REFERER, - "Accept" to "application/json" - ) - - val response = HttpClient.get(url, headers) - - Log.i(TAG, "=== Root Container Response ===") - Log.d(TAG, "Status: ${response.statusCode}") - Log.d(TAG, "Full Body: ${response.body}") - - if (response.statusCode != 200) - { - Log.e(TAG, "Root container fetch failed: ${response.statusCode}") - return null - } - - // Parse JSON - val json = JSONObject(response.body) - val links = json.optJSONArray("links") - - if (links == null) - { - Log.e(TAG, "No 'links' array in root container response") - Log.d(TAG, "Available keys: ${json.keys().asSequence().toList()}") - return null - } - - Log.d(TAG, "Found ${links.length()} total links in response") - - // Extract category URLs matching the patterns - val categoryUrls = mutableMapOf() - - for (i in 0 until links.length()) - { - val link = links.optJSONObject(i) ?: continue - val name = link.optString("name") - val url = link.optString("url") // Field is "url", not "href" - - Log.d(TAG, "Link $i: name='$name', url='$url'") - - if (CATEGORY_PATTERNS.contains(name) && url.isNotEmpty()) - { - categoryUrls[name] = url - Log.i(TAG, "✓ Matched category: $name -> $url") - } - } - - Log.i(TAG, "[PSNOW] Found ${categoryUrls.size} matching categories out of ${links.length()} total links") - return categoryUrls - } - catch (e: Exception) - { - Log.e(TAG, "Root container fetch error", e) - return null - } - } - - /** - * Step 5: Fetch games from a category - * Matches: CloudCatalogBackend::fetchPsnowCategory() - */ - private fun fetchCategoryGames(categoryUrl: String): List - { - val games = mutableListOf() - - try - { - // Add query parameters as Qt does (start=0&size=500) - val url = if (!categoryUrl.contains("?")) { - "$categoryUrl?start=0&size=500" - } else { - "$categoryUrl&start=0&size=500" - } - - Log.i(TAG, "=== Fetching Category ===") - Log.d(TAG, "URL: $url") - - val headers = mapOf( - "Accept" to "application/json", - "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ) - - val response = HttpClient.get(url, headers) - - Log.d(TAG, "Category response status: ${response.statusCode}") - - if (response.statusCode != 200) - { - Log.w(TAG, "Category fetch failed: ${response.statusCode}") - return games - } - - // Parse response - look for "links" array (matches Qt implementation) - val json = JSONObject(response.body) - val linksArray = json.optJSONArray("links") - - if (linksArray != null) - { - Log.d(TAG, "Found links array with ${linksArray.length()} items") - - for (i in 0 until linksArray.length()) - { - val gameObj = linksArray.optJSONObject(i) ?: continue - val game = parseGameObject(gameObj) - if (game != null) - { - games.add(game) - if (i < 3) // Log first 3 games - { - Log.d(TAG, "Parsed game: ${game.name} (${game.productId})") - } - } - } - Log.i(TAG, "Category complete: ${games.size} games") - } - else - { - Log.w(TAG, "No 'links' array in category response") - Log.d(TAG, "Available keys: ${json.keys().asSequence().toList()}") - } - } - catch (e: Exception) - { - Log.e(TAG, "Error fetching category", e) - } - - return games - } - - /** - * Parse game object from API response - * Matches: CloudCatalogBackend::handlePsnowCategoryPageResponse() - */ - private fun parseGameObject(gameObj: JSONObject): CloudGame? - { - try - { - // Qt uses "id" field, not "product_id" - val productId = gameObj.optString("id") - val name = gameObj.optString("name") - - if (productId.isEmpty() || name.isEmpty()) - return null - - // Extract both cover and landscape image URLs - val (coverUrl, landscapeUrl) = extractImageUrls(gameObj) - - // Fix: Replace HTTP with HTTPS to avoid Android cleartext traffic issues - var imageUrl = coverUrl - var landscapeImageUrl = landscapeUrl - if (imageUrl.startsWith("http://")) - { - imageUrl = imageUrl.replace("http://", "https://") - Log.d(TAG, "Converted HTTP to HTTPS for cover: $name") - } - if (landscapeImageUrl.startsWith("http://")) - { - landscapeImageUrl = landscapeImageUrl.replace("http://", "https://") - Log.d(TAG, "Converted HTTP to HTTPS for landscape: $name") - } - - // Determine platform - matches Qt CloudGameCard.qml getPlatform() function exactly - // playable_platform is an array, not a string - val playablePlatformArray = gameObj.optJSONArray("playable_platform") - val platform = if (playablePlatformArray != null && playablePlatformArray.length() > 0) { - // Check each platform in the array (matches Qt: for (let i = 0; i < platformArray.length; i++)) - var foundPlatform = "ps4" // Default to PS4 - for (i in 0 until playablePlatformArray.length()) { - val platformStr = playablePlatformArray.optString(i, "").uppercase() - // Qt checks: platform.indexOf("PS3") !== -1 and platform.indexOf("PS4") !== -1 - if (platformStr.contains("PS3")) { - foundPlatform = "ps3" - break // Qt returns immediately on PS3 match - } - if (platformStr.contains("PS4")) { - foundPlatform = "ps4" - } - } - foundPlatform - } else { - // Default to PS4 if playable_platform is missing or empty (matches Qt) - "ps4" - } - - return CloudGame( - productId = productId, - name = name, - imageUrl = imageUrl, - landscapeImageUrl = landscapeImageUrl, - platform = platform - ) - } - catch (e: Exception) - { - Log.w(TAG, "Error parsing game object", e) - return null - } - } - - /** - * Extract image URL from game object - * Matches: CloudCatalogBackend::extractCoverImageFromGameObject() - */ - /** - * Extract both cover and landscape image URLs from game object - * Returns Pair - */ - private fun extractImageUrls(gameObj: JSONObject): Pair - { - val gameName = gameObj.optString("name", "Unknown") - var coverUrl = "" - var landscapeUrl = "" - - // Check for images array in the game object (matches Qt implementation) - val images = gameObj.optJSONArray("images") - if (images != null && images.length() > 0) - { - // Log available image types for debugging - val availableTypes = mutableListOf() - for (i in 0 until images.length()) - { - val img = images.optJSONObject(i) ?: continue - val type = img.optInt("type", -1) - availableTypes.add(type) - val url = img.optString("url") - - if (url.isEmpty()) continue - - // Type 10 = cover/box art - if (type == 10 && coverUrl.isEmpty()) - { - coverUrl = url - Log.d(TAG, "Found type 10 (cover) image for: $gameName") - } - // Type 12 = landscape 1080p (preferred for landscape) - else if (type == 12 && landscapeUrl.isEmpty()) - { - landscapeUrl = url - Log.d(TAG, "Found type 12 (landscape 1080p) image for: $gameName") - } - // Type 13 = landscape 720p (fallback for landscape) - else if (type == 13 && landscapeUrl.isEmpty()) - { - landscapeUrl = url - Log.d(TAG, "Found type 13 (landscape 720p) image for: $gameName") - } - } - - // If no landscape found, try type 12 again (might have been found after type 13) - if (landscapeUrl.isEmpty()) - { - for (i in 0 until images.length()) - { - val img = images.optJSONObject(i) ?: continue - val type = img.optInt("type", -1) - val url = img.optString("url") - if (type == 12 && url.isNotEmpty()) - { - landscapeUrl = url - Log.d(TAG, "Found type 12 (landscape 1080p) image for: $gameName (second pass)") - break - } - } - } - - // Fallback: use cover for landscape if no landscape found - if (landscapeUrl.isEmpty() && coverUrl.isNotEmpty()) - { - landscapeUrl = coverUrl - Log.d(TAG, "Using cover image as landscape fallback for: $gameName") - } - - // Fallback: use landscape for cover if no cover found - if (coverUrl.isEmpty() && landscapeUrl.isNotEmpty()) - { - coverUrl = landscapeUrl - Log.d(TAG, "Using landscape image as cover fallback for: $gameName") - } - - if (coverUrl.isEmpty() && landscapeUrl.isEmpty()) - { - Log.w(TAG, "No type 10/12/13 images for '$gameName', available types: $availableTypes") - } - } - else - { - Log.w(TAG, "No images array for: $gameName") - } - - // Check for direct imageUrl field as fallback - if (coverUrl.isEmpty() && gameObj.has("imageUrl")) - { - val imageUrl = gameObj.optString("imageUrl") - if (imageUrl.isNotEmpty()) - { - coverUrl = imageUrl - landscapeUrl = imageUrl - Log.d(TAG, "Using direct imageUrl for both cover and landscape: $gameName") - } - } - - if (coverUrl.isEmpty() && landscapeUrl.isEmpty()) - { - Log.w(TAG, "NO IMAGE FOUND for: $gameName") - } - - return Pair(coverUrl, landscapeUrl) - } -} - diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudError.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudError.kt index 721911e0..e4e497f9 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudError.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudError.kt @@ -34,17 +34,22 @@ sealed class CloudError(val message: String, val exception: Exception? = null) { } private fun isAuthenticationError(message: String): Boolean { + // Keep these specific to genuine auth/credential problems. Do NOT include broad words + // like "failed", "token" or "expired": libchiaki's hard-failure detail is literally + // "Failed to fetch cloud catalog" (returned for NON-auth/transient conditions) and + // parse/exception messages also contain "failed" — classifying those as auth would wipe + // a valid NPSSO token and force a needless re-login. A genuinely expired npsso is + // surfaced by the lib as a degraded-but-usable result via the warning banner (which says + // "expired"), NOT through this error path, so "expired" here would only ever be a false + // positive. val authKeywords = listOf( "npsso", - "expired", "authorization", "oauth", "authentication", "login", "unauthorized", "forbidden", - "failed", - "token", "401", "403" ) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt index 446d264a..61114bcd 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt @@ -3,43 +3,77 @@ package com.metallic.chiaki.cloudplay.model /** - * Represents a game in the cloud catalog (PSNow or PSCloud) + * One game from libchiaki's unified cloud catalog contract (chiaki/cloudcatalog.h). + * + * Every field is precomputed by the lib (shared with Qt and iOS); the client renders these + * values verbatim and MUST NOT re-derive category, serviceType, platform, ownership or the + * stream routing. See [CloudGame.fromContract] for the JSON mapping. */ data class CloudGame( val productId: String, val name: String, - val imageUrl: String, // Cover/box art (type 10) - for game cards - val landscapeImageUrl: String = imageUrl, // Landscape (type 12/13) - for loading dialog + val imageUrl: String, // Cover/box art - for game cards + val landscapeImageUrl: String = imageUrl, // Landscape - for loading dialog val thumbnailUrl: String = imageUrl, - val platform: String = "ps4", // "ps4", "ps3", or "ps5" - val serviceType: String = "psnow", // "psnow" or "pscloud" - val conceptUrl: String = "", // URL to add game to library (PS5 games) - val conceptId: String = "", // Imagic conceptId for catalog dedupe (PS5 cloud) - val isOwned: Boolean = false, // Whether user owns this game (PS5 games) - val entitlementId: String = "", // PSCloud: entitlement id for streaming (Qt gameData.id) - val storeProductId: String = "", // PSCloud: product_id from entitlements API - val plusCatalog: Boolean = false, // In the PS Plus subscription catalog (vs full streamable universe) - val featureType: Int = 0, // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on - // Unified-page acquisition tag, assigned once at catalog-assembly time: - // "owned" -> entitlement resolves to a streamable row (Stream) - // "streamable" -> not owned, PS Now subscription title (Stream) - // "purchaseable" -> not owned, PS Plus catalog title (Add to Library, then Stream) + val platform: String = "ps4", // badge: "ps3", "ps4", or "ps5" (derived by lib from device[]) + val serviceType: String = "pscloud", // catalog routing: "psnow" or "pscloud" + val conceptUrl: String = "", // purchase / add-to-library deep link + val conceptId: String = "", // imagic conceptId (catalog dedupe key) + val isOwned: Boolean = false, + val entitlementId: String = "", // owned rows: entitlement id for streaming + val storeProductId: String = "", // owned / purchaseable rows: product_id from entitlements API + val plusCatalog: Boolean = false, // in the PS Plus subscription catalog + // Acquisition tag (lib-assigned): "owned" / "streamable" (both Stream) / "purchaseable" (Add to Library). val category: String = "", - // PS5-platform membership from the imagic catalog's authoritative `device` array (contains - // "PS5") OR a PPSA product id. Mirrors Qt's isPs5PlatformGame() and is used to decide which - // browse rows enter the streamable universe -- NOT the CUSA/PPSA productId token, which - // mis-classifies cross-gen titles (a PS4 CUSA SKU that also lists "PS5" in `device`). - val isPs5Platform: Boolean = false + // Stream routing precomputed by the lib: streamServiceType picks the endpoint (psnow/Kamaji vs + // pscloud/cronos) and streamIdentifier is the exact id handed to the streaming session. + val streamServiceType: String = serviceType, + val streamIdentifier: String = productId ) +{ + companion object + { + /** Build from one element of the lib unified-catalog "games" array, or null if malformed. */ + fun fromContract(obj: org.json.JSONObject): CloudGame? + { + val productId = obj.optString("productId", "") + val name = obj.optString("name", "") + if (productId.isEmpty() || name.isEmpty()) + return null + val imageUrl = obj.optString("imageUrl", "") + val landscape = obj.optString("landscapeImageUrl", "") + val streamSvc = obj.optString("streamServiceType", "") + val streamId = obj.optString("streamIdentifier", "") + val serviceType = obj.optString("serviceType", "pscloud") + return CloudGame( + productId = productId, + name = name, + imageUrl = imageUrl, + landscapeImageUrl = landscape.ifEmpty { imageUrl }, + thumbnailUrl = imageUrl, + platform = obj.optString("platform", "ps4"), + serviceType = serviceType, + conceptUrl = obj.optString("conceptUrl", ""), + conceptId = obj.optString("conceptId", ""), + isOwned = obj.optBoolean("isOwned", false), + entitlementId = obj.optString("entitlementId", ""), + storeProductId = obj.optString("storeProductId", ""), + plusCatalog = obj.optBoolean("plusCatalog", false), + category = obj.optString("category", ""), + streamServiceType = streamSvc.ifEmpty { serviceType }, + streamIdentifier = streamId.ifEmpty { productId } + ) + } + } +} -/** - * Internal session state for PSN authentication - */ -internal data class PsnSession( - val oauthCode: String, - val jsessionId: String, - val baseUrl: String -) +/** Acquisition-tag constants matching the lib contract's "category" field (and iOS CloudCategory). */ +object CloudCategory +{ + const val OWNED = "owned" + const val STREAMABLE = "streamable" + const val PURCHASEABLE = "purchaseable" +} /** * Result wrapper for API operations diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index d7c762f3..9a2c2126 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -4,22 +4,22 @@ package com.metallic.chiaki.cloudplay.repository import android.content.Context import android.util.Log -import com.metallic.chiaki.cloudplay.CloudLocaleBootstrap -import com.metallic.chiaki.cloudplay.api.Ps5CloudCatalogResult -import com.metallic.chiaki.cloudplay.api.PsCloudOwnership -import com.metallic.chiaki.cloudplay.api.PsnCatalogService -import com.metallic.chiaki.cloudplay.api.PsCloudCatalogService import com.metallic.chiaki.cloudplay.model.CloudGame import com.metallic.chiaki.cloudplay.model.PsnResult +import com.metallic.chiaki.lib.cloudCatalogFetchUnified +import com.metallic.chiaki.lib.cloudCatalogInvalidateCache import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.json.JSONArray import org.json.JSONObject import java.io.File /** - * Repository for cloud game catalog data - * Handles caching and data fetching + * Thin wrapper over libchiaki's unified cloud catalog (chiaki/cloudcatalog.h). ALL fetching, + * OAuth/session exchanges, dedup, ownership cross-reference and tagging happen once in the lib + * (shared with Qt and iOS). Android supplies npsso/locale/cache dir and renders the returned + * contract verbatim — no client-side catalog logic. */ class CloudGameRepository( private val context: Context, @@ -29,17 +29,32 @@ class CloudGameRepository( companion object { private const val TAG = "CloudGameRepository" - private const val CACHE_DIR = "cloud_catalog_cache_v2" // v2: catalog games carry plusCatalog tag - + // Dir handed to the lib; the lib owns every file inside it (browse/library/unified caches). + private const val CACHE_DIR = "cloud_catalog_cache" + + // The lib's catalog calls are documented single-threaded and it owns the cache dir; serialize + // every fetch/clear across all repository instances so a cache invalidation can't race a + // concurrent fetch on the same files. Shared (companion) because logout creates its own + // repository instance to clear the cache. + private val catalogLock = Mutex() + + private fun cacheDir(context: Context): File = + File(context.cacheDir, CACHE_DIR).apply { if (!exists()) mkdirs() } + + /** + * Drop the lib-owned caches (e.g. on locale change). Synchronous on purpose: callers (e.g. + * [com.metallic.chiaki.common.Preferences.setCloudLanguage]) need the cache gone before the + * next fetch so it can't serve stale-locale data. It deliberately does NOT take [catalogLock] + * — that lock is held across a full fetch (including network), so a blocking acquire here + * could ANR. A delete racing an in-flight fetch is safe: the lib writes caches atomically + * (temp file + rename) and reads whole files (open fds survive unlink on POSIX), so the worst + * case is a benign cache miss, never a torn read or corruption. + */ fun invalidateCatalogCache(context: Context) { try { - val cacheDir = File(context.cacheDir, CACHE_DIR) - cacheDir.listFiles()?.forEach { file -> - if (file.isFile) - file.delete() - } + cloudCatalogInvalidateCache(cacheDir(context).absolutePath) Log.i(TAG, "Catalog cache invalidated (locale change)") } catch (e: Exception) @@ -47,487 +62,104 @@ class CloudGameRepository( Log.w(TAG, "Error invalidating catalog cache", e) } } - private const val UNIFIED_CACHE_FILE = "unified_catalog_v5.json" // v5: Qt emitOwned productId + QMap-sorted cross-ref merge order - private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" - private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours - - private const val OWNERSHIP_SESSION_WARNING = - "Your PlayStation session has expired. Please log in again to see your owned games." - private const val OWNERSHIP_NETWORK_WARNING = - "Couldn't verify your owned games (network error). Pull to refresh to try again." - } - - private val psnowCatalogService = PsnCatalogService(preferences) - private val pscloudCatalogService = PsCloudCatalogService() - private val cacheDir: File by lazy { - File(context.cacheDir, CACHE_DIR).apply { - if (!exists()) mkdirs() - } } var lastCatalogFetchWarning: String? = null private set - + /** - * Unified cloud catalog: ONE merged, deduped, tagged list across PS3/PS4 (PS Now/Kamaji) and - * PS5 (imagic/Gaikai). Each game carries a `category` tag (owned / streamable / purchaseable). - * - * Sources: - * - PS Now APOLLOROOT walk (PS3 + PS4): native via /user/stores, or public region-group - * fallback when the account's region has no storefront. - * - imagic browse (PS5, streamingSupported=true) = purchaseable universe. - * Owned entitlements (PS4 + PS5) are cross-referenced against both (supplement + aliases + - * conceptId recognition retained). In native mode the concept-sibling streamability gate drops - * owned titles with no streamable path (e.g. FOR HONOR); in fallback mode the gate is skipped - * so nothing is hidden when the catalog isn't authoritative. + * Unified cloud catalog: ONE merged, deduped, tagged list across PS3/PS4 (PS Now) and PS5 + * (cloud). Blocking — runs on [Dispatchers.IO]. The lib serves an on-disk cache hit with no + * network I/O; [forceRefresh] bypasses it. A degraded-but-usable result (e.g. expired npsso) + * still returns games plus a non-empty warning. */ suspend fun fetchUnifiedCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> { return withContext(Dispatchers.IO) { lastCatalogFetchWarning = null - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - if (!forceRefresh) + val fetched = try { - loadCachedGames(UNIFIED_CACHE_FILE)?.let { cached -> - Log.i(TAG, "Returning ${cached.size} unified games from cache") - return@withContext PsnResult.Success(cached) - } - } - - val (accountCountry, _) = - com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudLanguage()) - - // --- 1) PS Now APOLLOROOT (PS3 + PS4): native, else region-group fallback ---------- - val native = psnowCatalogService.fetchNativeCatalog(npssoToken) - var apolloGames: List = emptyList() - var nativeMode = false - var fallbackRegion = "" - when - { - native.storesAvailable && native.games.isNotEmpty() -> - { - apolloGames = native.games - nativeMode = true - } - native.authError -> - { - // Expired session: surface the re-login prompt. Do NOT fall back to the public - // APOLLOROOT walk -- that path is only for region-unsupported accounts (auth OK, - // /user/stores 404). Falling back here would mask the expired token. apolloGames - // stays empty; the user still sees the PS5 catalog plus the warning. - lastCatalogFetchWarning = OWNERSHIP_SESSION_WARNING - } - else -> - { - // /user/stores has no storefront for this region: public region-group walk. - apolloGames = tryApolloRootFallback(accountCountry) - if (apolloGames.isNotEmpty()) - fallbackRegion = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) + catalogLock.withLock { + cloudCatalogFetchUnified( + npsso = npssoToken.ifEmpty { null }, + locale = preferences.getCloudLanguage(), + cacheDir = cacheDir(context).absolutePath, + forceRefresh = forceRefresh + ) } } - preferences.setCloudFallbackRegion(fallbackRegion) - Log.i(TAG, "PS Now APOLLOROOT: ${apolloGames.size} games (nativeMode=$nativeMode, fallbackRegion='$fallbackRegion')") - - // --- 2) imagic PS5 catalog (browse + supplement + aliases) ------------------------- - val imagic = try - { - fetchPs5CatalogV3(preferences.getCloudLanguage(), forceRefresh) - } catch (e: Exception) { - Log.e(TAG, "imagic PS5 catalog fetch failed", e) - if (apolloGames.isEmpty()) - return@withContext PsnResult.Error("Failed to fetch catalog: ${e.message}", e) - Ps5CloudCatalogResult(emptyList(), emptyList(), emptyMap()) - } - - // --- 3) owned cross-reference (skip on expired token) ------------------------------ - var owned: List = emptyList() - if (npssoToken.isNotEmpty() && !native.authError) - { - try - { - owned = pscloudCatalogService.getOwnedPs5CloudGames( - npssoToken, imagic.browseGames, imagic.plusLibrarySupplement, - imagic.productIdAliases, psnowCatalog = apolloGames - ) - } - catch (e: Exception) - { - Log.w(TAG, "Ownership cross-reference failed; showing as not owned", e) - lastCatalogFetchWarning = ownershipFailureWarning(e) - } - } - - // --- 4) assemble the streamable universe (PS Now PS3/PS4 + imagic PS5) ------------- - // Browse rows enter the universe when PS5-platform by the authoritative `device` array - // (isPs5Platform), NOT the CUSA/PPSA productId token -- the token drops cross-gen titles - // that carry a PS4 CUSA SKU but list "PS5" in `device` (e.g. the indie bundles). Skip rows - // already in the Apollo (PS Now) catalog: the apollo row already represents them, so adding - // the imagic browse copy would emit a duplicate streamable row (Crow Country / Grandia / - // HUMANITY appear in BOTH the APOLLOROOT walk and the imagic PS5 list). Mirrors Qt - // assembleUnifiedCatalog (cloudcatalogbackend.cpp). - val apolloProductIds = apolloGames.map { it.productId }.toSet() - val ps5Browse = imagic.browseGames.filter { - it.isPs5Platform && !apolloProductIds.contains(it.productId) + Log.e(TAG, "Unified catalog fetch threw", e) + return@withContext PsnResult.Error("Failed to fetch catalog: ${e.message}", e) } - val universe = apolloGames + ps5Browse - var games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog(universe, owned, addUnmatched = true) - // --- 5) concept-sibling streamability gate (native mode only) ---------------------- - if (nativeMode) + val json = fetched.json + if (json == null) { - val index = PsCloudOwnership.StreamabilityIndex( - apolloCatalog = apolloGames, - imagicBrowse = imagic.browseGames, - imagicConceptRows = imagic.browseGames + imagic.plusLibrarySupplement - ) - games = PsCloudOwnership.applyStreamabilityGate(games, index) + val detail = fetched.errorMessage ?: "Failed to fetch cloud catalog. Check your connection." + Log.e(TAG, "Unified catalog fetch returned null: $detail") + return@withContext PsnResult.Error(detail) } - // --- 6) tag + cache ---------------------------------------------------------------- - games = games.map { it.copy(category = PsCloudOwnership.categoryFor(it)) } - if (games.isNotEmpty() && !native.authError && !isOwnershipVerificationFailure(lastCatalogFetchWarning)) - cacheGames(games, UNIFIED_CACHE_FILE) - PsnResult.Success(games) + parseUnifiedCatalog(json) } } - /** Best-effort public region-group APOLLOROOT walk (no session). Empty list on failure. */ - private suspend fun tryApolloRootFallback(accountCountry: String): List = - try + private fun parseUnifiedCatalog(json: String): PsnResult> + { + val root = try { - psnowCatalogService.fetchApolloRootCatalog(accountCountry) + JSONObject(json) } catch (e: Exception) { - Log.w(TAG, "APOLLOROOT region-group fallback failed", e) - emptyList() + Log.e(TAG, "Failed to parse unified catalog JSON", e) + return PsnResult.Error("Failed to parse cloud catalog.", e) } - /** - * Fetch the PS5 imagic catalog, trying the store-locale fallback chain - * (session locale -> en-COUNTRY -> en-US) since Sony 404s unsupported locales (e.g. hu-HU). - * Persists the locale that works. Returns the cached v3 catalog when available. - */ - private suspend fun fetchPs5CatalogV3(stored: String, forceRefresh: Boolean): Ps5CloudCatalogResult - { - if (!forceRefresh) - loadCachedPs5CatalogV3(stored)?.let { return it } - - // NOTE: do not reset lastCatalogFetchWarning here. An ownership/session warning set by the - // unified fetch (e.g. expired-token) must survive this call so the re-login prompt reaches - // the UI. The imagic warning is only applied below when no higher-priority warning is set. - var lastError: Exception? = null - for ((canonical, imagic) in com.metallic.chiaki.cloudplay.CloudLocale.fallbackChain(stored)) - { - try - { - val fetched = pscloudCatalogService.fetchPs5CloudCatalog(imagic) - if (canonical != stored) - { - Log.i(TAG, "PS5 store locale settled on $canonical (was $stored)") - preferences.setCloudLanguage(canonical) - } - if (fetched.shouldCacheV3) - cachePs5CatalogV3(fetched, canonical) - // Don't overwrite a higher-priority ownership/session warning set by the unified fetch. - if (lastCatalogFetchWarning == null) - lastCatalogFetchWarning = fetched.catalogFetchWarning - return fetched - } - catch (e: Exception) - { - Log.i(TAG, "PS5 imagic locale $imagic failed, trying next tier: ${e.message}") - lastError = e - } + // The lib resolves the working store locale and region group; reflect them back so the + // streaming path (which reads the cloud language) and the region banner agree. Persist the + // settled locale WITHOUT wiping the cache (the lib owns its own invalidation). + root.optString("settledLocale", "").takeIf { it.isNotEmpty() }?.let { + preferences.noteCloudLanguageSettled(it) } - throw (lastError ?: Exception("All imagic locales failed to load")) - } + preferences.setCloudFallbackRegion(root.optString("fallbackRegion", "")) - private fun ownershipFailureWarning(e: Exception): String - { - val msg = e.message?.lowercase() ?: "" - val isAuth = msg.contains("login_required") - || msg.contains("failed to extract oauth token") - || msg.contains("no location header in oauth") - || (msg.contains("oauth") && Regex("http 4\\d\\d").containsMatchIn(msg)) - || (msg.contains("entitlements") && (msg.contains("http 401") || msg.contains("http 403"))) - return if (isAuth) OWNERSHIP_SESSION_WARNING else OWNERSHIP_NETWORK_WARNING - } + root.optString("warning", "").takeIf { it.isNotEmpty() }?.let { + lastCatalogFetchWarning = it + } - private fun isOwnershipVerificationFailure(warning: String?): Boolean = - warning == OWNERSHIP_SESSION_WARNING || warning == OWNERSHIP_NETWORK_WARNING + val gamesArray = root.optJSONArray("games") + val rowCount = gamesArray?.length() ?: 0 + val games = ArrayList(rowCount) + if (gamesArray != null) + for (i in 0 until rowCount) + CloudGame.fromContract(gamesArray.getJSONObject(i))?.let { games.add(it) } - /** - * Load games from cache if valid - */ - private fun loadCachedGames(cacheFileName: String): List? - { - try - { - val cacheFile = File(cacheDir, cacheFileName) - - if (!cacheFile.exists()) - { - Log.d(TAG, "No cache file found: $cacheFileName at ${cacheFile.absolutePath}") - Log.d(TAG, "Cache directory exists: ${cacheDir.exists()}, contents: ${cacheDir.listFiles()?.map { it.name }}") - return null - } - - // Check if cache is still valid - val cacheAge = System.currentTimeMillis() - cacheFile.lastModified() - if (cacheAge > CACHE_DURATION_MS) - { - Log.d(TAG, "Cache expired (age: ${cacheAge / 1000}s, max: ${CACHE_DURATION_MS / 1000}s)") - cacheFile.delete() - return null - } - - // Read and parse cache - val json = cacheFile.readText() - val jsonArray = JSONArray(json) - val games = mutableListOf() - - for (i in 0 until jsonArray.length()) - { - val obj = jsonArray.getJSONObject(i) - // Handle landscapeImageUrl (may be missing in old cache) - val landscapeImageUrl = obj.optString("landscapeImageUrl", obj.getString("imageUrl")) - val productId = obj.getString("productId") - - games.add(CloudGame( - productId = productId, - name = obj.getString("name"), - imageUrl = obj.getString("imageUrl"), - landscapeImageUrl = landscapeImageUrl, - thumbnailUrl = obj.optString("thumbnailUrl", obj.getString("imageUrl")), - platform = obj.optString("platform", "ps4"), - serviceType = obj.optString("serviceType", "psnow"), - conceptUrl = obj.optString("conceptUrl", ""), - conceptId = obj.optString("conceptId", ""), - isOwned = obj.optBoolean("isOwned", false), - entitlementId = obj.optString("entitlementId", ""), - storeProductId = obj.optString("storeProductId", ""), - plusCatalog = obj.optBoolean("plusCatalog", false), - featureType = obj.optInt("featureType", 0), - category = obj.optString("category", ""), - // Back-compat for caches written before this field existed: fall back to the token. - isPs5Platform = obj.optBoolean("isPs5Platform", productId.contains("PPSA")) - )) - } - - Log.i(TAG, "Loaded ${games.size} games from cache: $cacheFileName") - return games - } - catch (e: Exception) - { - Log.w(TAG, "Error loading cache: $cacheFileName", e) - return null - } - } - - /** - * Save games to cache - */ - private fun cacheGames(games: List, cacheFileName: String) - { - try - { - val jsonArray = JSONArray() - - for (game in games) - { - val obj = JSONObject() - obj.put("productId", game.productId) - obj.put("name", game.name) - obj.put("imageUrl", game.imageUrl) - obj.put("landscapeImageUrl", game.landscapeImageUrl) - obj.put("thumbnailUrl", game.thumbnailUrl) - obj.put("platform", game.platform) - obj.put("serviceType", game.serviceType) - obj.put("conceptUrl", game.conceptUrl) - obj.put("conceptId", game.conceptId) - obj.put("isOwned", game.isOwned) - obj.put("entitlementId", game.entitlementId) - obj.put("storeProductId", game.storeProductId) - obj.put("plusCatalog", game.plusCatalog) - obj.put("featureType", game.featureType) - obj.put("category", game.category) - obj.put("isPs5Platform", game.isPs5Platform) - jsonArray.put(obj) - } - - val cacheFile = File(cacheDir, cacheFileName) - cacheFile.writeText(jsonArray.toString()) - - Log.i(TAG, "Cached ${games.size} games to: ${cacheFile.absolutePath}") - Log.d(TAG, "Cache file size: ${cacheFile.length()} bytes, lastModified: ${cacheFile.lastModified()}") - } - catch (e: Exception) - { - Log.e(TAG, "Error caching games to $cacheFileName", e) - } + val dropped = rowCount - games.size + if (dropped > 0) + Log.w(TAG, "Dropped $dropped malformed catalog row(s) (missing productId/name)") + Log.i(TAG, "Unified catalog: ${games.size} games (${games.count { it.isOwned }} owned)") + return PsnResult.Success(games) } - - private fun loadCachedPs5CatalogV3(expectedLocale: String): Ps5CloudCatalogResult? + + /** Clear all lib-owned cached data. Serialized against an in-flight fetch via [catalogLock]. */ + suspend fun clearCache() { - try + withContext(Dispatchers.IO) { - val cacheFile = File(cacheDir, PS5_CATALOG_V3_CACHE_FILE) - if (!cacheFile.exists()) - return null - - val cacheAge = System.currentTimeMillis() - cacheFile.lastModified() - if (cacheAge > CACHE_DURATION_MS) + try { - cacheFile.delete() - return null + catalogLock.withLock { cloudCatalogInvalidateCache(cacheDir(context).absolutePath) } + Log.i(TAG, "Cache cleared") } - - val root = JSONObject(cacheFile.readText()) - val cachedLocale = root.optString("locale", "") - if (cachedLocale.isNotEmpty() && cachedLocale != expectedLocale) + catch (e: Exception) { - Log.i(TAG, "PS5 catalog v3 cache locale mismatch ($cachedLocale != $expectedLocale), refetching") - cacheFile.delete() - return null + Log.w(TAG, "Error clearing cache", e) } - - val browse = parseGameArray(root.optJSONArray("games") ?: JSONArray()) - val supplement = parseGameArray(root.optJSONArray("plusLibrarySupplement") ?: JSONArray()) - val aliases = parseProductIdAliases(root.optJSONObject("productIdAliases")) - Log.i(TAG, "Loaded PS5 catalog v3 from cache: ${browse.size} browse, ${supplement.size} supplement, ${aliases.size} aliases") - return Ps5CloudCatalogResult(browse, supplement, aliases) - } - catch (e: Exception) - { - Log.w(TAG, "Error loading PS5 catalog v3 cache", e) - return null - } - } - - private fun cachePs5CatalogV3(catalog: Ps5CloudCatalogResult, locale: String) - { - try - { - val root = JSONObject() - root.put("locale", locale) - root.put("games", gamesToJsonArray(catalog.browseGames)) - root.put("plusLibrarySupplement", gamesToJsonArray(catalog.plusLibrarySupplement)) - root.put("total", catalog.browseGames.size) - if (catalog.productIdAliases.isNotEmpty()) - root.put("productIdAliases", productIdAliasesToJson(catalog.productIdAliases)) - - val cacheFile = File(cacheDir, PS5_CATALOG_V3_CACHE_FILE) - cacheFile.writeText(root.toString()) - Log.i(TAG, "Cached PS5 catalog v3: ${catalog.browseGames.size} browse, ${catalog.plusLibrarySupplement.size} supplement, ${catalog.productIdAliases.size} aliases") - } - catch (e: Exception) - { - Log.e(TAG, "Error caching PS5 catalog v3", e) - } - } - - private fun parseProductIdAliases(obj: JSONObject?): Map - { - if (obj == null) - return emptyMap() - val aliases = linkedMapOf() - for (key in obj.keys()) - { - val canonical = obj.optString(key, "") - if (canonical.isNotEmpty()) - aliases[key] = canonical - } - return aliases - } - - private fun productIdAliasesToJson(aliases: Map): JSONObject - { - val obj = JSONObject() - for ((alias, canonical) in aliases) - obj.put(alias, canonical) - return obj - } - - private fun parseGameArray(jsonArray: JSONArray): List - { - val games = mutableListOf() - for (i in 0 until jsonArray.length()) - { - val obj = jsonArray.getJSONObject(i) - val landscapeImageUrl = obj.optString("landscapeImageUrl", obj.getString("imageUrl")) - val productId = obj.getString("productId") - games.add( - CloudGame( - productId = productId, - name = obj.getString("name"), - imageUrl = obj.getString("imageUrl"), - landscapeImageUrl = landscapeImageUrl, - platform = obj.optString("platform", "ps5"), - // Deliberate Qt<->mobile divergence: Qt leaves imagic browse rows with NO serviceType and derives - // platform from the clean catalog product-id token. Mobile instead blanket-tags imagic rows "pscloud" - // and COMPENSATES with an isOwned gate in streamPlatform (a non-owned "pscloud" row falls back to the - // product-id token, so a non-owned PS4 imagic title still routes to PS Now, not cronos). Both reach the - // same routing -- do NOT naively "fix" one side to match the other. - serviceType = obj.optString("serviceType", "pscloud"), - conceptUrl = obj.optString("conceptUrl", ""), - conceptId = obj.optString("conceptId", ""), - isOwned = obj.optBoolean("isOwned", false), - entitlementId = obj.optString("entitlementId", ""), - storeProductId = obj.optString("storeProductId", ""), - plusCatalog = obj.optBoolean("plusCatalog", false), - featureType = obj.optInt("featureType", 0), - // Back-compat for caches written before this field existed: fall back to the token. - isPs5Platform = obj.optBoolean("isPs5Platform", productId.contains("PPSA")) - ) - ) - } - return games - } - - private fun gamesToJsonArray(games: List): JSONArray - { - val jsonArray = JSONArray() - for (game in games) - { - val obj = JSONObject() - obj.put("productId", game.productId) - obj.put("name", game.name) - obj.put("imageUrl", game.imageUrl) - obj.put("landscapeImageUrl", game.landscapeImageUrl) - obj.put("platform", game.platform) - obj.put("serviceType", game.serviceType) - obj.put("conceptUrl", game.conceptUrl) - obj.put("conceptId", game.conceptId) - obj.put("isOwned", game.isOwned) - obj.put("entitlementId", game.entitlementId) - obj.put("storeProductId", game.storeProductId) - obj.put("plusCatalog", game.plusCatalog) - obj.put("featureType", game.featureType) - obj.put("isPs5Platform", game.isPs5Platform) - jsonArray.put(obj) - } - return jsonArray - } - - /** - * Clear all cached data - */ - fun clearCache() - { - try - { - cacheDir.listFiles()?.forEach { it.delete() } - Log.i(TAG, "Cache cleared") - } - catch (e: Exception) - { - Log.w(TAG, "Error clearing cache", e) } } } - diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index bb2f329e..f885a3da 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -301,6 +301,23 @@ class Preferences(context: Context) CloudGameRepository.invalidateCatalogCache(appContext) } + /** + * Persist the locale libchiaki actually settled on (unified catalog "settledLocale") WITHOUT + * wiping the cache. The lib owns its own cache invalidation; this only keeps the locale we pass + * next time (and the streaming language) in sync with the lib. Writes when not yet configured + * (even when it equals the en-US default, so the "couldn't detect region" banner clears) or + * when the value changed. + */ + fun noteCloudLanguageSettled(value: String) + { + if (value.isEmpty()) + return + if (isCloudLanguageConfigured() && getCloudLanguage() == value) + return + sharedPreferences.edit().putString("cloud_language_pscloud", value).apply() + Log.i("Preferences", "Cloud locale settled by lib: $value") + } + fun setCloudLanguageFromSession(language: String?, country: String?) { val locale = com.metallic.chiaki.cloudplay.CloudLocale.fromSession(language, country) ?: return @@ -319,7 +336,21 @@ class Preferences(context: Context) } setCloudLanguage(locale) } - + + /** + * Manual streaming-language override chosen in the language picker. Empty means + * "use the catalog locale" ([getCloudLanguage]). Stored separately so the + * auto-detected catalog locale (noteCloudLanguageSettled / setCloudLanguageFromSession) + * can never clobber the user's pick. + */ + fun getStreamLanguage(): String = + sharedPreferences.getString("cloud_stream_language", "") ?: "" + + fun setStreamLanguage(value: String) + { + sharedPreferences.edit().putString("cloud_stream_language", value).apply() + } + // Cloud resolution settings (matching Qt GetCloudResolutionPSNOW/SetCloudResolutionPSNOW) val cloudResolutionPsnowKey get() = resources.getString(R.string.preferences_cloud_resolution_psnow_key) fun getCloudResolutionPsnow(): Int @@ -397,6 +428,10 @@ class Preferences(context: Context) sharedPreferences.edit().putString(cloudDatacentersJsonPsnowKey, json).apply() } + // Cloud streaming game language (shared across PSCloud/PSNOW). Backed by the + // same "cloud_language_pscloud" store as getCloudLanguage/setCloudLanguage. + val cloudLanguageKey get() = resources.getString(R.string.preferences_cloud_language_key) + // PSCloud datacenter settings (matching Qt GetCloudDatacenterPSCloud/SetCloudDatacenterPSCloud) val cloudDatacenterPscloudKey get() = resources.getString(R.string.preferences_cloud_datacenter_pscloud_key) fun getCloudDatacenterPscloud(): String @@ -422,14 +457,11 @@ class Preferences(context: Context) } // Cloud Play UI state - private val LAST_CLOUD_SECTION_KEY = "last_cloud_section" - private val PSCLOUD_FILTER_OWNED_KEY = "pscloud_filter_owned" private val CLOUD_FALLBACK_REGION_KEY = "cloud_fallback_region" private val LAST_MAIN_TAB_KEY = "last_main_tab" private val CLOUD_SORT_STATE_KEY = "cloud_sort_state" private val CLOUD_TAG_FILTERS_KEY = "cloud_tag_filters" private val FAVORITE_GAMES_KEY = "favorite_games" - private val PSNOW_FILTER_FAVORITES_KEY = "psnow_filter_favorites" private val PSCLOUD_FILTER_FAVORITES_KEY = "pscloud_filter_favorites" private val LICENSE_AGREED_KEY = "license_agreed" private val TOTAL_STREAM_TIME_MS_KEY = "total_stream_time_ms" @@ -450,16 +482,6 @@ class Preferences(context: Context) return next } - fun getLastCloudSection(): String - { - return sharedPreferences.getString(LAST_CLOUD_SECTION_KEY, "psnow") ?: "psnow" - } - - fun setLastCloudSection(section: String) - { - sharedPreferences.edit().putString(LAST_CLOUD_SECTION_KEY, section).apply() - } - /** * PS Now region-group fallback. Empty string = native mode (account's own /user/stores * storefront is authoritative). A non-empty value (the region-group store country, "US" @@ -479,16 +501,6 @@ class Preferences(context: Context) fun isCloudFallbackMode(): Boolean = getCloudFallbackRegion().isNotEmpty() - fun getPsCloudFilterOwned(): Boolean - { - return sharedPreferences.getBoolean(PSCLOUD_FILTER_OWNED_KEY, false) - } - - fun setPsCloudFilterOwned(isOwned: Boolean) - { - sharedPreferences.edit().putBoolean(PSCLOUD_FILTER_OWNED_KEY, isOwned).apply() - } - fun getLastMainTab(): Int { return sharedPreferences.getInt(LAST_MAIN_TAB_KEY, 0) // Default to Remote Play (0) @@ -546,16 +558,6 @@ class Preferences(context: Context) } // Filter states for favorites - fun getPsnowFilterFavorites(): Boolean - { - return sharedPreferences.getBoolean(PSNOW_FILTER_FAVORITES_KEY, false) - } - - fun setPsnowFilterFavorites(isFavorites: Boolean) - { - sharedPreferences.edit().putBoolean(PSNOW_FILTER_FAVORITES_KEY, isFavorites).apply() - } - fun getPsCloudFilterFavorites(): Boolean { return sharedPreferences.getBoolean(PSCLOUD_FILTER_FAVORITES_KEY, false) diff --git a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt index db41e02b..03c8277c 100644 --- a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt +++ b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt @@ -144,6 +144,16 @@ private class ChiakiNative @JvmStatic external fun holepunchGetRegistInfoData2(sessionPtr: Long): ByteArray? @JvmStatic external fun holepunchGetRegistInfoCustomData1(sessionPtr: Long): ByteArray? @JvmStatic external fun holepunchGetRegistInfoLocalIp(sessionPtr: Long): String? + + // Unified cloud catalog (chiaki/cloudcatalog.h) — single source of truth shared with Qt/iOS. + // Returns the UTF-8 JSON contract as raw bytes (decoded to String by the wrapper, since the + // payload contains non-ASCII names that JNI's modified-UTF-8 NewStringUTF can't represent). + // errorOut[0] receives the lib's failure detail when the result is null. + @JvmStatic external fun cloudCatalogFetchUnified(npsso: String?, locale: String?, cacheDir: String, forceRefresh: Boolean, errorOut: Array): ByteArray? + @JvmStatic external fun cloudCatalogInvalidateCache(cacheDir: String) + @JvmStatic external fun cloudGaikaiLanguage(locale: String?): String + @JvmStatic external fun cloudSupportedLanguages(): Array + @JvmStatic external fun cloudDatacenterServesLanguage(datacenterName: String, locale: String): Boolean } } @@ -265,6 +275,41 @@ class HolepunchSession(token: String) /** Initialize native SSL CA bundle for curl+mbedTLS on Android. Call once at app startup. */ fun initNativeSsl(cacheDir: String) = ChiakiNative.initNativeSsl(cacheDir) +/** Result of [cloudCatalogFetchUnified]: [json] is non-null on success (including degraded-but- + * usable results such as expired npsso); on hard failure [json] is null and [errorMessage] carries + * the lib's human-readable detail. */ +data class CloudCatalogFetch(val json: String?, val errorMessage: String?) + +/** + * Fetch (or load from the lib-owned on-disk cache) the unified cloud catalog as a JSON string. + * Blocking — call from a background thread. All OAuth/session exchanges, fetch, dedup, ownership + * cross-reference and tagging happen inside libchiaki (shared with Qt and iOS); the caller just + * parses and renders the contract. + */ +fun cloudCatalogFetchUnified(npsso: String?, locale: String?, cacheDir: String, forceRefresh: Boolean): CloudCatalogFetch +{ + val errorOut = arrayOfNulls(1) + val bytes = ChiakiNative.cloudCatalogFetchUnified(npsso, locale, cacheDir, forceRefresh, errorOut) + return CloudCatalogFetch(bytes?.let { String(it, Charsets.UTF_8) }, errorOut[0]) +} + +/** Delete every lib-owned cache file under [cacheDir] (e.g. on locale change). */ +fun cloudCatalogInvalidateCache(cacheDir: String) = ChiakiNative.cloudCatalogInvalidateCache(cacheDir) + +// Cloud streaming language helpers, backed by the shared libchiaki table. Game +// language is tied to the datacenter region (Gaikai ignores a language whose +// datacenter is not selected). + +/** Bare lowercase language code Gaikai expects ("de-DE" -> "de"); "en" default. */ +fun cloudGaikaiLanguage(locale: String?): String = ChiakiNative.cloudGaikaiLanguage(locale) + +/** Locales offered in the language picker (BCP-47, e.g. "en-GB"). */ +fun cloudSupportedLanguages(): List = ChiakiNative.cloudSupportedLanguages().toList() + +/** True if [datacenterName] (4-letter ping name, e.g. "fraa") serves [locale]. */ +fun cloudDatacenterServesLanguage(datacenterName: String, locale: String): Boolean = + ChiakiNative.cloudDatacenterServesLanguage(datacenterName, locale) + class ErrorCode(val value: Int) { override fun toString() = ChiakiNative.errorCodeToString(value) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt index da7b147e..67546cb6 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt @@ -122,28 +122,18 @@ class CloudGameAdapter( card.strokeColor = android.graphics.Color.TRANSPARENT card.strokeWidth = 0 } - // Derive the badge from the title id (PPSA = PS5, CUSA = PS4) like the Qt client does, - // since the catalog parser tags everything "ps5"; fall back to the platform field. - binding.gamePlatformTextView.text = run { - val pid = game.productId.ifEmpty { game.storeProductId } - when { - pid.contains("PPSA") -> "5" - pid.contains("CUSA") -> "4" - else -> when (game.platform.lowercase()) { - "ps3" -> "3" - "ps4" -> "4" - "ps5" -> "5" - else -> game.platform.takeLast(1) - } - } + // Platform badge: the lib derives the authoritative platform from the catalog's device[] + // array (NOT the CUSA/PPSA productId token), so just render it. + binding.gamePlatformTextView.text = when (game.platform.lowercase()) { + "ps3" -> "3" + "ps4" -> "4" + "ps5" -> "5" + else -> game.platform.takeLast(1) } // Acquisition-tag badge (unified page): Owned (green) / Streamable (blue) / - // Purchaseable (orange). Fall back through the canonical tagger (streamServiceType-based), - // not raw serviceType, so a non-owned PS4 cloud-browse row isn't mislabeled "Add Game". - val category = game.category.ifEmpty { - com.metallic.chiaki.cloudplay.api.PsCloudOwnership.categoryFor(game) - } + // Purchaseable (orange). The lib precomputes the category; render it verbatim. + val category = game.category if (showOwnershipBadge) { binding.ownershipBadge.visibility = android.view.View.VISIBLE when (category) { diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index 1f9294ab..c96c7331 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -37,7 +37,7 @@ import com.metallic.chiaki.common.ext.alertDialogBuilder import com.pylux.stream.R import com.metallic.chiaki.cloudplay.PsnLoginActivity import com.metallic.chiaki.cloudplay.api.CloudStreamingBackend -import com.metallic.chiaki.cloudplay.api.PsCloudOwnership +import com.metallic.chiaki.cloudplay.model.CloudCategory import com.metallic.chiaki.cloudplay.model.CloudError import com.metallic.chiaki.cloudplay.model.CloudGame import com.metallic.chiaki.common.Preferences @@ -422,9 +422,9 @@ class CloudPlayFragment : Fragment() // Acquisition-tag filter categories and their display labels (dropdown order). private val tagFilterCategories = listOf( - PsCloudOwnership.CATEGORY_OWNED, - PsCloudOwnership.CATEGORY_STREAMABLE, - PsCloudOwnership.CATEGORY_PURCHASEABLE + CloudCategory.OWNED, + CloudCategory.STREAMABLE, + CloudCategory.PURCHASEABLE ) private val tagFilterLabels = listOf("Owned", "Streamable", "Store") @@ -548,7 +548,7 @@ class CloudPlayFragment : Fragment() /** Games you can play right now (owned + subscription/trial streamable) sort ahead of * store titles that must first be added to your library. */ private fun isPlayableNow(game: CloudGame): Boolean = - game.category != PsCloudOwnership.CATEGORY_PURCHASEABLE + game.category != CloudCategory.PURCHASEABLE private fun sortGames(games: List): List = when (sortState) { 1 -> games.sortedBy { it.name.lowercase() } @@ -848,12 +848,9 @@ class CloudPlayFragment : Fragment() private fun onGameClicked(game: CloudGame) { - // Route on the canonical acquisition tag, not raw serviceType: only a "purchaseable" title - // (not owned, PS Plus catalog / PS5) needs Add-to-Library; "streamable" (PS Now) and owned - // titles stream directly. Raw serviceType=="pscloud" would mis-handle a non-owned PS4 - // cloud-browse row (which streams via PS Now). - val category = game.category.ifEmpty { PsCloudOwnership.categoryFor(game) } - if (category == PsCloudOwnership.CATEGORY_PURCHASEABLE) + // Route on the lib's acquisition tag: only a "purchaseable" title (not owned, PS Plus + // catalog / PS5) needs Add-to-Library; "streamable" (PS Now) and owned titles stream directly. + if (game.category == CloudCategory.PURCHASEABLE) { // Show dialog to add game to library showAddToLibraryDialog(game) @@ -1072,11 +1069,11 @@ class CloudPlayFragment : Fragment() try { val backend = CloudStreamingBackend(requireContext(), viewModel.preferences) - // Route by the title-id platform: PS4 catalog titles go through Kamaji (psnow) to - // acquire the streaming entitlement; PS5 streams directly (pscloud). + // Stream routing is precomputed by libchiaki: streamServiceType picks the endpoint + // (psnow/Kamaji vs pscloud/cronos) and streamIdentifier is the exact id to launch. val result = backend.startCompleteCloudSession( - serviceType = PsCloudOwnership.streamServiceType(game), - gameIdentifier = PsCloudOwnership.streamIdentifier(game), + serviceType = game.streamServiceType, + gameIdentifier = game.streamIdentifier, gameName = game.name, npssoToken = npssoToken, onProgress = { message -> diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt index d071ccd1..8b01fc16 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt @@ -9,7 +9,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metallic.chiaki.cloudplay.CloudLocale -import com.metallic.chiaki.cloudplay.api.PsCloudOwnership import com.metallic.chiaki.cloudplay.model.CloudGame import com.metallic.chiaki.cloudplay.model.PsnResult import com.metallic.chiaki.cloudplay.repository.CloudGameRepository @@ -55,6 +54,10 @@ class CloudPlayViewModel( private var allGames: List = emptyList() + // The lib's catalog fetch is blocking and single-threaded; never run two at once (a double-tap + // on refresh, or a refresh during the initial load, would hit the same cache dir concurrently). + private var fetchInProgress = false + // Active acquisition-tag filters; empty = show all. Restored from prefs, persisted on change. var activeTagFilters: Set = preferences.getCloudTagFilters() private set @@ -72,6 +75,12 @@ class CloudPlayViewModel( */ fun fetchCatalog(forceRefresh: Boolean = false) { + if (fetchInProgress) + { + Log.i(TAG, "Catalog fetch already in progress; ignoring request") + return + } + fetchInProgress = true viewModelScope.launch { try { @@ -89,6 +98,10 @@ class CloudPlayViewModel( allGames = result.data Log.i(TAG, "Loaded ${allGames.size} unified games") repository.lastCatalogFetchWarning?.let { _warning.value = it } + // Match iOS: an empty list with no warning means the fetch effectively + // failed (e.g. network) — tell the user instead of a blank screen. + if (allGames.isEmpty() && _warning.value.isNullOrEmpty()) + _error.value = "No cloud games found. Check your connection." applyFilters() } is PsnResult.Error -> @@ -108,6 +121,7 @@ class CloudPlayViewModel( _fallbackRegion.value = preferences.getCloudFallbackRegion() updateLocaleWarningIfNeeded() _loading.value = false + fetchInProgress = false } } } @@ -158,6 +172,7 @@ class CloudPlayViewModel( fun clearCache() { + // repository.clearCache() runs its file I/O off-main and serializes against an in-flight fetch. viewModelScope.launch { repository.clearCache() Log.i(TAG, "Cache cleared") diff --git a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt index b2c95e0b..6ee12e17 100644 --- a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt @@ -67,6 +67,7 @@ class DataStore(val preferences: Preferences): PreferenceDataStore() preferences.codecKey -> preferences.codec.value preferences.cloudDatacenterPsnowKey -> preferences.getCloudDatacenterPsnow() preferences.cloudDatacenterPscloudKey -> preferences.getCloudDatacenterPscloud() + preferences.cloudLanguageKey -> preferences.getStreamLanguage() preferences.cloudResolutionPscloudKey -> preferences.getCloudResolutionPscloud().toString() preferences.cloudResolutionPsnowKey -> preferences.getCloudResolutionPsnow().toString() preferences.dpadTouchShortcut1Key -> preferences.dpadTouchShortcut1.toString() @@ -98,6 +99,10 @@ class DataStore(val preferences: Preferences): PreferenceDataStore() } preferences.cloudDatacenterPsnowKey -> preferences.setCloudDatacenterPsnow(value ?: "Auto") preferences.cloudDatacenterPscloudKey -> preferences.setCloudDatacenterPscloud(value ?: "Auto") + // Manual streaming-language override. Stored separately from the + // catalog locale and does not touch the datacenter; the user picks a + // matching datacenter themselves. + preferences.cloudLanguageKey -> preferences.setStreamLanguage(value ?: "") preferences.cloudResolutionPscloudKey -> preferences.setCloudResolutionPscloud(value?.toIntOrNull() ?: 720) preferences.cloudResolutionPsnowKey -> preferences.setCloudResolutionPsnow(value?.toIntOrNull() ?: 720) preferences.dpadTouchShortcut1Key -> preferences.dpadTouchShortcut1 = value?.toIntOrNull() ?: Preferences.DPAD_TOUCH_SHORTCUT1_DEFAULT @@ -124,6 +129,7 @@ class DataStore(val preferences: Preferences): PreferenceDataStore() preferences.dpadTouchIncrementKey -> preferences.dpadTouchIncrement = value } } + } class SettingsFragment: PreferenceFragmentCompat(), TitleFragment @@ -209,6 +215,12 @@ class SettingsFragment: PreferenceFragmentCompat(), TitleFragment preferences.getCloudDatacentersJsonPscloud() ) + // Game language list (Auto + all supported languages). + populateCloudLanguagePreference( + preferenceScreen.findPreference(getString(R.string.preferences_cloud_language_key)), + preferences.getCloudLanguage() + ) + bindCloudBitratePreference( preferenceScreen.findPreference(getString(R.string.preferences_cloud_bitrate_pscloud_key)), preferences @@ -488,4 +500,42 @@ class SettingsFragment: PreferenceFragmentCompat(), TitleFragment preference.entryValues = arrayOf("Auto") } } + + // Display names for cloud-language locales. The locale list itself comes from + // libchiaki (chiaki/cloudcatalog.h); only the human-readable names live here. + private val cloudLanguageDisplayNames = mapOf( + "en-US" to "English", + "en-GB" to "English (UK)", + "de-DE" to "Deutsch", + "fr-FR" to "Français", + "fi-FI" to "Suomi", + "it-IT" to "Italiano", + "es-ES" to "Español", + "nl-NL" to "Nederlands", + "pt-BR" to "Português (BR)", + "ja-JP" to "日本語", + "ko-KR" to "한국어" + ) + + /** + * Populate the game-language dropdown with "Auto" + every supported language. + * Datacenter language support can't be reliably enumerated, so we don't filter + * the list. "Auto" (empty value) clears the override so the auto-detected + * catalog/region locale [catalogLocale] is used instead. Locale list comes from + * libchiaki; the manual pick is stored separately and never auto-overwritten. + */ + private fun populateCloudLanguagePreference(preference: ListPreference?, catalogLocale: String) + { + if (preference == null) return + val entries = mutableListOf(getString(R.string.preferences_cloud_language_auto, catalogLocale)) + val values = mutableListOf("") + for (loc in com.metallic.chiaki.lib.cloudSupportedLanguages()) + { + entries.add("${cloudLanguageDisplayNames[loc] ?: loc} ($loc)") + values.add(loc) + } + preference.entries = entries.toTypedArray() + preference.entryValues = values.toTypedArray() + preference.dialogMessage = getString(R.string.preferences_cloud_language_dialog_message) + } } \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index edf99742..3a6e14d8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -151,6 +151,7 @@ Logged out successfully + Cloud Settings Game Library Resolution Streaming resolution for Game Library (up to 4K) @@ -292,6 +293,10 @@ cloud_resolution_pscloud cloud_bitrate_psnow cloud_bitrate_pscloud + cloud_language_pscloud + Game Language + Auto (%1$s) + Not all regions support every language. A language only works on datacenters that offer it — if your chosen language isn\'t applied, pick a datacenter in a matching region. pip_enabled Support Pylux diff --git a/android/app/src/main/res/xml/preferences.xml b/android/app/src/main/res/xml/preferences.xml index 9be41b8b..c12bd449 100644 --- a/android/app/src/main/res/xml/preferences.xml +++ b/android/app/src/main/res/xml/preferences.xml @@ -103,6 +103,18 @@ app:icon="@drawable/ic_codec"/> + + + + + diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index 08014a2b..433cf73f 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -15,21 +15,26 @@ #include #include #include -#include -#include #include #include #include +#include +#include + /** - * CloudCatalogBackend - Fetches and manages cloud gaming catalogs - * - * Provides methods to: - * - Fetch PSNOW catalog (PS4/PS3 subscription games) - * - Fetch PS5 Cloud Streaming catalog (all PS5 games with streaming support) - * - Fetch owned PS5 games (requires PSN authentication) - * - Cross-reference owned games with cloud catalog - * - Fetch detailed game information including images + * CloudCatalogBackend - thin QML bridge over the libchiaki cloud catalog. + * + * The entire catalog fetch / merge / ownership cross-reference / assemble + * pipeline (and every cache file) now lives in libchiaki and is shared verbatim + * with Android and iOS. This class only: + * - forwards fetchUnifiedCatalog() to chiaki_cloudcatalog_fetch_unified() and + * hands the returned display-and-stream-ready JSON straight to QML, and + * - keeps the per-game details fetch + Steam-shortcut / image utilities that + * are GUI-only concerns and not part of the catalog contract. + * + * It performs ZERO catalog derivation (no category/serviceType/platform/owner + * logic) -- see chiaki/cloudcatalog.h for the contract. */ class CloudCatalogBackend : public QObject { @@ -39,190 +44,61 @@ class CloudCatalogBackend : public QObject explicit CloudCatalogBackend(Settings *settings, QObject *parent = nullptr); ~CloudCatalogBackend(); - // Main catalog fetching methods - Q_INVOKABLE void fetchPsnowCatalog(const QJSValue &callback); - // Streamable PS3 Classics catalog. Walks the PUBLIC pcnow (Apollo) container - // STORE-MSF192018-APOLLOPS3GAMES (no OAuth/session needed -- the container API is - // open), returning ~300 PS3 titles that imagic/gameslist never lists. These stream - // via the PSNOW -> Gaikai konan path the streaming code already supports. - Q_INVOKABLE void fetchPs3Catalog(const QJSValue &callback); - Q_INVOKABLE void fetchPs5CloudCatalog(const QJSValue &callback); - Q_INVOKABLE void fetchOwnedPs5Games(const QJSValue &callback); - Q_INVOKABLE void getOwnedPs5CloudGames(const QJSValue &callback); - /** Unified cloud catalog: PS Now APOLLOROOT (PS3+PS4) + imagic PS5, tagged by category. */ + /** Unified cloud catalog (libchiaki single source of truth). */ Q_INVOKABLE void fetchUnifiedCatalog(const QJSValue &callback); Q_INVOKABLE void fetchGameDetails(const QString &productId, const QJSValue &callback); // Steam shortcut creation for cloud games - Q_INVOKABLE void createCloudSteamShortcut(const QString &gameIdentifier, const QString &gameName, - const QString &command, const QJSValue &callback, + Q_INVOKABLE void createCloudSteamShortcut(const QString &gameIdentifier, const QString &gameName, + const QString &command, const QJSValue &callback, const QString &steamDir = QString()); // Utility methods - Q_INVOKABLE void clearCache(); Q_INVOKABLE void invalidateCache(); Q_INVOKABLE void invalidatePs5CatalogCache(); Q_INVOKABLE QString getCachedData(const QString &key, int maxAge); Q_INVOKABLE QString getGameLandscapeImageFromCache(const QString &serviceType, const QString &gameIdentifier); -signals: - void catalogUpdated(); - private slots: - void handlePsnowCategoryResponse(); - void handlePs5ImagicListResponse(); - void finalizePs5CloudCatalogFetch(); - void handleOwnedGamesOAuthResponse(); - void fetchOwnedGamesPage(); - void handleOwnedGamesResponse(); void handleGameDetailsResponse(); - void processCrossReferenceComplete(); - void handleUnifiedApolloPageResponse(); - void finishUnifiedFetch(bool success, const QString &message, const QJsonObject &payload = QJsonObject()); private: Settings *settings; QNetworkAccessManager *networkManager; - + // Cache directory for file-based caching QString cacheDirectory; - + // Cache duration constants - static const int CACHE_DURATION_CATALOG = 24 * 60 * 60 * 1000; // 24 hours static const int CACHE_DURATION_DETAILS = 7 * 24 * 60 * 60 * 1000; // 7 days - - // PSNOW catalog fetching state - struct PsnowFetchState { - QJSValue callback; - QJsonArray allGames; - QStringList categories; - int currentCategoryIndex; - QTimer *rateLimitTimer; - QString oauthCode; - QString jsessionId; - QString baseUrl; - QString duid; - bool authInProgress; - bool unifiedMode = false; - } psnowState; - - // Unified catalog fetch orchestration (mirrors Android CloudGameRepository.fetchUnifiedCatalog). - struct UnifiedFetchState { - bool active = false; - QJSValue callback; - QJsonArray apolloGames; - bool nativeMode = false; - bool authError = false; - QString fallbackRegion; - QString warning; - QString apolloContainerUrl; - int apolloStart = 0; - int apolloTotal = -1; - QJsonArray imagicBrowse; - QJsonArray imagicSupplement; - QMap productIdAliases; - } unifiedState; - - // PS3 Classics catalog fetching state (public Apollo PS3 container, paginated). - // containerUrl is resolved per account region group (Americas vs PAL) at fetch time. - struct Ps3FetchState { - QJSValue callback; - QJsonArray allGames; - QString containerUrl; - int currentStart = 0; - int totalResults = -1; - bool inProgress = false; - } ps3State; - - // PS5 catalog fetching state (six imagic lists, merged like Sony's PS5 cloud finder) - struct Ps5FetchState { - QJSValue callback; - int pendingListFetches = 0; - int succeededListFetches = 0; - bool allPs5ListSucceeded = false; - QStringList failedLists; - QMap gamesByConceptId; - QMap plusLibrarySupplementByProductId; - QMap productIdAliases; // alternate imagic productId -> canonical browse productId - int totalGamesSeen = 0; - // Store-locale fallback: Sony serves a fixed set of language-COUNTRY locales. - // The country is always valid but the language may not be (e.g. hu-HU 404s, - // en-HU works). We try the session locale, then en-COUNTRY, then en-US. - QStringList localeChain; - int localeTierIndex = 0; - QString activeLocale; // canonical "ll-CC" form for the tier currently being fetched - } ps5State; - - // Owned games fetching state - struct OwnedGamesState { - QJSValue callback; - QString oauthToken; - QJsonArray accumulatedEntitlements; // Accumulate results across pages - int currentStart = 0; // Current pagination offset - static const int PAGE_SIZE = 300; // Page size for API requests - } ownedGamesState; - + + // Guards against overlapping unified fetches racing on the shared cache dir. + // A second call while a fetch is in flight is coalesced (not rejected): its + // callback is parked here and invoked with the same result when the running + // fetch completes, so navigating back to the catalog mid-fetch never surfaces + // an error or starts a duplicate racing fetch. Both fields are touched only on + // the GUI/engine thread (Q_INVOKABLE entry + the queued completion handler). + std::atomic unifiedFetchInFlight{false}; + std::vector pendingUnifiedCallbacks; + // Game details fetching state struct GameDetailsState { QJSValue callback; QString productId; - QTimer *cooldownTimer; } gameDetailsState; - - // Cross-reference state for owned PS5 cloud games - struct CrossReferenceState { - QJSValue callback; - QJsonArray cloudCatalogGames; - QJsonArray plusLibrarySupplement; - QJsonArray ownedGames; - QMap productIdAliases; - // Bundle product_id -> its component entitlement ids, for bundle-sibling matching (from - // upstream PR #15): a bundle entitlement (e.g. RE7 Gold) expands to its component games. - QMap componentIdsByProductId; - bool catalogFetched; - bool ownedGamesFetched; - QJsonArray psnowCatalogGames; - bool unifiedMode = false; - } crossReferenceState; - + // Helper methods void setCachedData(const QString &key, const QJsonDocument &data); QString getCachedPs5CatalogV3(int maxAge); QString getCacheFilePath(const QString &key); void ensureCacheDirectory(); - void fetchPsnowCategory(int categoryIndex); - void processPsnowCatalogComplete(); - QString ps3AccountCountry() const; - void fetchPs3CatalogPage(); - void handlePs3CatalogPageResponse(); - void finishPs3Catalog(); - void fetchOwnedGamesOAuthToken(); - void fetchPsnowOAuthToken(); - void fetchPsnowSession(); - void fetchPsnowStores(); - void fetchPsnowRootContainer(); - void handlePsnowOAuthResponse(); - void handlePsnowSessionResponse(); - void handlePsnowStoresResponse(); - void handlePsnowRootContainerResponse(); - void unifiedNativeProbeFailed(bool authError); - void startUnifiedApolloFallback(); - void fetchUnifiedApolloPage(); - void continueUnifiedAfterApollo(); - void startUnifiedOwnedCrossRef(); - void assembleUnifiedCatalog(const QJsonArray &ownedCrossRef); - void startPs5ImagicListFetch(); // fires the six imagic list requests for ps5State.activeLocale void executeGameDetailsFetch(const QString &productId); - QJsonArray filterStreamingSupportedGames(const QJsonArray &games); - QJsonArray filterOwnedPs5Games(const QJsonArray &entitlements); QJsonObject extractGameImages(const QJsonObject &gameData); - QString extractCoverImageFromGameObject(const QJsonObject &gameObj); QString getNpSsoToken(); - + // Helper methods for shortcut creation QPixmap downloadImageFromUrl(const QString &url, int timeoutMs = 10000); QPixmap resizeImageToFit(const QPixmap &source, int targetWidth, int targetHeight); }; #endif // CLOUDCATALOGBACKEND_H - diff --git a/gui/include/qmlsettings.h b/gui/include/qmlsettings.h index 670debb0..a019b480 100644 --- a/gui/include/qmlsettings.h +++ b/gui/include/qmlsettings.h @@ -18,6 +18,7 @@ class QmlSettings : public QObject // PSCloud settings Q_PROPERTY(int cloudResolutionPSCloud READ cloudResolutionPSCloud WRITE setCloudResolutionPSCloud NOTIFY cloudResolutionPSCloudChanged) Q_PROPERTY(QString cloudLanguagePSCloud READ cloudLanguagePSCloud WRITE setCloudLanguagePSCloud NOTIFY cloudLanguagePSCloudChanged) + Q_PROPERTY(QString cloudStreamLanguage READ cloudStreamLanguage WRITE setCloudStreamLanguage NOTIFY cloudStreamLanguageChanged) Q_PROPERTY(QString cloudDatacenterPSCloud READ cloudDatacenterPSCloud WRITE setCloudDatacenterPSCloud NOTIFY cloudDatacenterPSCloudChanged) Q_PROPERTY(QString cloudDatacentersJsonPSCloud READ cloudDatacentersJsonPSCloud NOTIFY cloudDatacentersJsonPSCloudChanged) Q_PROPERTY(int cloudBitratePSCloud READ cloudBitratePSCloud WRITE setCloudBitratePSCloud NOTIFY cloudBitratePSCloudChanged) @@ -230,6 +231,8 @@ class QmlSettings : public QObject void setCloudResolutionPSCloud(int resolution); QString cloudLanguagePSCloud() const; void setCloudLanguagePSCloud(const QString &language); + QString cloudStreamLanguage() const; + void setCloudStreamLanguage(const QString &language); QString cloudDatacenterPSCloud() const; void setCloudDatacenterPSCloud(const QString &datacenter); QString cloudDatacentersJsonPSCloud() const; @@ -655,6 +658,11 @@ class QmlSettings : public QObject Q_INVOKABLE QString stringForStreamMenuShortcut() const; Q_INVOKABLE QString getLicenseText() const; + // Cloud streaming language picker, backed by the shared libchiaki table + // (chiaki/cloudcatalog.h). Game language is tied to the datacenter region. + Q_INVOKABLE QStringList cloudSupportedLanguages() const; + Q_INVOKABLE bool cloudDatacenterServesLanguage(const QString &datacenterName, const QString &locale) const; + signals: void resolutionLocalPS4Changed(); void resolutionRemotePS4Changed(); @@ -662,6 +670,7 @@ class QmlSettings : public QObject void resolutionRemotePS5Changed(); void cloudResolutionPSCloudChanged(); void cloudLanguagePSCloudChanged(); + void cloudStreamLanguageChanged(); void cloudDatacenterPSCloudChanged(); void cloudDatacentersJsonPSCloudChanged(); void cloudBitratePSCloudChanged(); diff --git a/gui/include/settings.h b/gui/include/settings.h index 870533a8..4351bda8 100644 --- a/gui/include/settings.h +++ b/gui/include/settings.h @@ -310,6 +310,8 @@ class Settings : public QObject void SetCloudResolutionPSCloud(int resolution); QString GetCloudLanguagePSCloud() const; void SetCloudLanguagePSCloud(const QString &language); + QString GetCloudStreamLanguage() const; + void SetCloudStreamLanguage(const QString &language); QString GetCloudDatacenterPSCloud() const; void SetCloudDatacenterPSCloud(const QString &datacenter); QString GetCloudDatacentersJsonPSCloud() const; // JSON array of datacenters with ping results diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 5d80dff4..07c2357e 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -1,15 +1,15 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL #include "cloudcatalogbackend.h" -#include "cloudstreamingbackend.h" -#include "cloudstreaming/pskamajisession.h" #ifdef CHIAKI_GUI_ENABLE_STEAM_SHORTCUT #include "steamtools.h" #endif -#include +#include +#include +#include +#include #include #include -#include #include #include #include @@ -26,15 +26,10 @@ #include #include #include -#include -#include #include Q_DECLARE_LOGGING_CATEGORY(chiakiGui) -// PSNOW category IDs (alphabetical categories) -// PSNOW categories are now dynamically fetched from the stores endpoint - CloudCatalogBackend::CloudCatalogBackend(Settings *settings, QObject *parent) : QObject(parent) , settings(settings) @@ -46,32 +41,6 @@ CloudCatalogBackend::CloudCatalogBackend(Settings *settings, QObject *parent) // Initialize cache directory cacheDirectory = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/cloud_catalog"; ensureCacheDirectory(); - - // Initialize state - psnowState.currentCategoryIndex = -1; - psnowState.rateLimitTimer = new QTimer(this); - psnowState.rateLimitTimer->setSingleShot(true); - psnowState.rateLimitTimer->setInterval(100); // 100ms cooldown between API calls - psnowState.oauthCode = QString(); - psnowState.jsessionId = QString(); - psnowState.baseUrl = QString(); - psnowState.duid = QString(); - psnowState.authInProgress = false; - - // Initialize game details cooldown timer - gameDetailsState.cooldownTimer = new QTimer(this); - gameDetailsState.cooldownTimer->setSingleShot(true); - gameDetailsState.cooldownTimer->setInterval(100); // 100ms cooldown between game details calls - - // Initialize cross-reference state - crossReferenceState.callback = QJSValue(); - crossReferenceState.cloudCatalogGames = QJsonArray(); - crossReferenceState.plusLibrarySupplement = QJsonArray(); - crossReferenceState.ownedGames = QJsonArray(); - crossReferenceState.productIdAliases.clear(); - crossReferenceState.componentIdsByProductId.clear(); - crossReferenceState.catalogFetched = false; - crossReferenceState.ownedGamesFetched = false; } CloudCatalogBackend::~CloudCatalogBackend() @@ -91,2935 +60,173 @@ void CloudCatalogBackend::ensureCacheDirectory() QString CloudCatalogBackend::getCacheFilePath(const QString &key) { - // Sanitize key for filename (replace invalid chars) - QString safeKey = key; - safeKey.replace("/", "_"); - safeKey.replace("\\", "_"); - safeKey.replace(":", "_"); - return cacheDirectory + "/" + safeKey + ".json"; -} - -QString CloudCatalogBackend::getCachedData(const QString &key, int maxAge) -{ - QString filePath = getCacheFilePath(key); - QFileInfo fileInfo(filePath); - - if (!fileInfo.exists()) { - qInfo() << "[CACHE MISS] No cache file found for:" << key; - return QString(); - } - - // Check file age - qint64 age = fileInfo.lastModified().msecsTo(QDateTime::currentDateTime()); - if (age > maxAge) { - // Cache expired, delete file - QFile::remove(filePath); - qInfo() << "[CACHE EXPIRED] Cache file expired for:" << key << "(age:" << (age / 1000) << "seconds, max:" << (maxAge / 1000) << "seconds)"; - return QString(); - } - - // Read file - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qWarning() << "[CACHE ERROR] Failed to open cache file:" << filePath; - return QString(); - } - - QByteArray data = file.readAll(); - file.close(); - - qint64 ageSeconds = age / 1000; - qInfo() << "[CACHE HIT] Loaded cached data for:" << key << "(" << (data.size() / 1024) << "KB, age:" << ageSeconds << "seconds)"; - - return QString::fromUtf8(data); -} - -QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) -{ - const QString cached = getCachedData(QStringLiteral("ps5_cloud_catalog_v6"), maxAge); - if (cached.isEmpty()) - return QString(); - - const QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); - if (!doc.isObject()) { - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); - return QString(); - } - - const QString expectedLocale = settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US"); - const QString cachedLocale = doc.object().value(QStringLiteral("locale")).toString(); - if (!cachedLocale.isEmpty() && cachedLocale != expectedLocale) { - qInfo() << "[CACHE LOCALE MISMATCH] PS5 catalog v3 locale" << cachedLocale - << "!=" << expectedLocale << ", refetching"; - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); - return QString(); - } - - return cached; -} - -void CloudCatalogBackend::setCachedData(const QString &key, const QJsonDocument &data) -{ - QString filePath = getCacheFilePath(key); - - QFile file(filePath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - qWarning() << "[CACHE ERROR] Failed to write cache file:" << filePath; - return; - } - - QByteArray jsonData = data.toJson(QJsonDocument::Compact); - file.write(jsonData); - file.close(); - - qInfo() << "[CACHE SAVED] Cached data for:" << key << "(" << (jsonData.size() / 1024) << "KB)"; -} - -QString CloudCatalogBackend::getNpSsoToken() -{ - // Get NPSSO token from settings (saved during login) - return settings->GetNpssoToken(); -} - -void CloudCatalogBackend::fetchPsnowCatalog(const QJSValue &callback) -{ - // Check cache first - QString cached = getCachedData("psnow_catalog", CACHE_DURATION_CATALOG); - if (!cached.isEmpty()) { - qInfo() << "[CACHE] Using cached PSNOW catalog (skipping API calls)"; - QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); - if (callback.isCallable()) { - callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); - } - return; - } - - // Check if already authenticating - if (psnowState.authInProgress) { - qInfo() << "[PSNOW] Authentication already in progress, skipping duplicate request"; - if (callback.isCallable()) { - callback.call({false, "Request already in progress", QJSValue()}); - } - return; - } - - // Check NPSSO token - required for authentication - QString npsso = getNpSsoToken(); - if (npsso.isEmpty()) { - QString errorMsg = "NPSSO token is required for Game Catalog. Please login to PSN and enter a valid NPSSO token."; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (callback.isCallable()) { - callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - qInfo() << "[API CALL] Fetching PSNOW catalog from API (cache miss or expired)"; - - // Initialize fetch state - psnowState.callback = callback; - psnowState.allGames = QJsonArray(); - psnowState.categories = QStringList(); - psnowState.currentCategoryIndex = 0; - psnowState.authInProgress = true; - psnowState.oauthCode.clear(); - psnowState.jsessionId.clear(); - psnowState.baseUrl.clear(); - psnowState.duid.clear(); - - // Start authentication flow: OAuth -> Session -> Stores -> Categories - fetchPsnowOAuthToken(); -} - -void CloudCatalogBackend::fetchPsnowOAuthToken() -{ - QString npsso = getNpSsoToken(); - if (npsso.isEmpty()) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = "NPSSO token is required for Game Catalog. Please login to PSN and enter a valid NPSSO token."; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // Generate DUID dynamically (matching CloudStreamingBackend) - size_t duid_size = CHIAKI_DUID_STR_SIZE; - char duid_arr[duid_size]; - ChiakiErrorCode duid_err = chiaki_holepunch_generate_client_device_uid(duid_arr, &duid_size); - if (duid_err != CHIAKI_ERR_SUCCESS) { - psnowState.authInProgress = false; - QString errorMsg = "Failed to generate device UID for PSNOW OAuth authentication."; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - psnowState.duid = QString(duid_arr); - - QUrl url(CloudConfig::ACCOUNT_BASE + "/v1/oauth/authorize"); - QUrlQuery query; - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("response_type", "code"); - query.addQueryItem("scope", KamajiConsts::PS4_SCOPES); - query.addQueryItem("client_id", KamajiConsts::CLIENT_ID); - query.addQueryItem("redirect_uri", KamajiConsts::REDIRECT_URI); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - query.addQueryItem("renderMode", "mobilePortrait"); - query.addQueryItem("hidePageElements", "forgotPasswordLink"); - query.addQueryItem("displayFooter", "none"); - query.addQueryItem("disableLinks", "qriocityLink"); - query.addQueryItem("mid", "PSNOW"); - query.addQueryItem("duid", psnowState.duid); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("noEVBlock", "true"); - url.setQuery(query); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW OAuth Token Request ==="; - qInfo() << " URL:" << url.toString(); - qInfo() << " Method: GET"; - } - - QNetworkRequest req(url); - req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); - req.setRawHeader("Cookie", QString("npsso=%1").arg(npsso).toUtf8()); - req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - - QNetworkReply *reply = networkManager->get(req); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePsnowOAuthResponse); -} - -void CloudCatalogBackend::handlePsnowOAuthResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW OAuth Response ==="; - qInfo() << " Status:" << statusCode; - } - - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - if (redirectUrl.isEmpty()) { - QByteArray locationHeader = reply->rawHeader("Location"); - if (!locationHeader.isEmpty()) { - redirectUrl = QUrl::fromEncoded(locationHeader); - } - } - - if (redirectUrl.isEmpty() || statusCode != 302) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = "OAuth request failed for PSNOW catalog"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // Extract code from redirect URL - QUrlQuery query(redirectUrl); - QString code = query.queryItemValue("code"); - - if (code.isEmpty()) { - // Try fragment - QString fragment = redirectUrl.fragment(); - QRegularExpression codeRe("code=([^&]+)"); - QRegularExpressionMatch codeMatch = codeRe.match(fragment); - if (codeMatch.hasMatch()) { - code = codeMatch.captured(1); - } - } - - if (code.isEmpty()) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = "No authorization code in OAuth response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - psnowState.oauthCode = code; - qInfo() << "[PSNOW] Got OAuth code, creating session..."; - fetchPsnowSession(); -} - -void CloudCatalogBackend::fetchPsnowSession() -{ - QString url = KamajiConsts::KAMAJI_BASE + "/user/session"; - QString body = QString("code=%1&client_id=%2&duid=%3") - .arg(psnowState.oauthCode) - .arg(KamajiConsts::CLIENT_ID) - .arg(psnowState.duid); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Session Request ==="; - qInfo() << " URL:" << url; - qInfo() << " Method: POST"; - qInfo() << " Body:" << body; - } - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Content-Type", "text/plain;charset=UTF-8"); - req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); - req.setRawHeader("X-Alt-Referer", KamajiConsts::REDIRECT_URI.toUtf8()); - req.setRawHeader("Origin", KamajiConsts::ORIGIN.toUtf8()); - req.setRawHeader("Referer", KamajiConsts::REFERER.toUtf8()); - req.setRawHeader("Accept", "*/*"); - - QNetworkReply *reply = networkManager->post(req, body.toUtf8()); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePsnowSessionResponse); -} - -void CloudCatalogBackend::handlePsnowSessionResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray response = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Session Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Body:" << QString(response); - } - - if (reply->error() != QNetworkReply::NoError || statusCode != 200) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = QString("Session creation failed: %1").arg(reply->errorString()); - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QJsonDocument doc = QJsonDocument::fromJson(response); - if (!doc.isObject()) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = "Invalid JSON in session response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QJsonObject obj = doc.object(); - QJsonObject header = obj["header"].toObject(); - QJsonObject data = obj["data"].toObject(); - - if (header["status_code"].toString() != "0x0000") { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = QString("Session failed with status: %1").arg(header["status_code"].toString()); - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // Extract JSESSIONID from Set-Cookie header - QList headers = reply->rawHeaderPairs(); - for (const auto &headerPair : headers) { - if (headerPair.first.toLower() == "set-cookie") { - QString setCookieValue = QString::fromUtf8(headerPair.second); - QRegularExpression jsessionRegex("JSESSIONID=([^;]+)"); - QRegularExpressionMatch match = jsessionRegex.match(setCookieValue); - if (match.hasMatch()) { - psnowState.jsessionId = match.captured(1); - break; - } - } - } - - if (psnowState.jsessionId.isEmpty()) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(true); - return; - } - QString errorMsg = "No JSESSIONID in session response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // Save country and language from session response to settings - QString country = data["country"].toString(); - QString language = data["language"].toString(); - if (!country.isEmpty() && !language.isEmpty() && settings) { - // Format: language-COUNTRY (e.g., "nl-NL" or "en-US") - const QString sessionLocale = QString("%1-%2").arg(language.toLower(), country.toUpper()); - const QString previousLocale = settings->GetCloudLanguagePSCloud(); - // The country is the real region signal; the language part may get - // auto-corrected later (the imagic fetch settles e.g. hu-HU on en-HU). - // Only re-save when the country actually changes, otherwise we'd clobber - // the validated locale on every visit and thrash the cache. - const QString previousCountry = previousLocale.section(QLatin1Char('-'), 1, 1).toUpper(); - if (previousCountry != country.toUpper()) { - settings->SetCloudLanguagePSCloud(sessionLocale); - qInfo() << "[PSNOW] Region changed, saved locale from session:" << sessionLocale - << "(was" << previousLocale << ") - invalidating cache"; - invalidateCache(); - } else if (settings->GetLogVerbose()) { - qInfo() << "[PSNOW] Session country unchanged (" << country - << "), keeping validated locale" << previousLocale; - } - } - - qInfo() << "[PSNOW] Session created successfully, fetching stores..."; - fetchPsnowStores(); -} - -void CloudCatalogBackend::fetchPsnowStores() -{ - QString url = KamajiConsts::KAMAJI_BASE + "/user/stores"; - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Stores Request ==="; - qInfo() << " URL:" << url; - qInfo() << " Method: GET"; - } - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); - req.setRawHeader("Cookie", QString("JSESSIONID=%1").arg(psnowState.jsessionId).toUtf8()); - req.setRawHeader("Origin", KamajiConsts::ORIGIN.toUtf8()); - req.setRawHeader("Referer", KamajiConsts::REFERER.toUtf8()); - req.setRawHeader("Accept", "application/json"); - - QNetworkReply *reply = networkManager->get(req); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePsnowStoresResponse); -} - -void CloudCatalogBackend::handlePsnowStoresResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray response = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Stores Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Body:" << QString(response); - } - - if (reply->error() != QNetworkReply::NoError || statusCode != 200) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(false); - return; - } - QString errorMsg = QString("Stores request failed: %1").arg(reply->errorString()); - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QJsonDocument doc = QJsonDocument::fromJson(response); - if (!doc.isObject()) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(false); - return; - } - QString errorMsg = "Invalid JSON in stores response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QJsonObject obj = doc.object(); - QJsonObject header = obj["header"].toObject(); - QJsonObject data = obj["data"].toObject(); - - if (header["status_code"].toString() != "0x0000") { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(false); - return; - } - QString errorMsg = QString("Stores request failed with status: %1").arg(header["status_code"].toString()); - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QString baseUrl = data["base_url"].toString(); - if (baseUrl.isEmpty()) { - psnowState.authInProgress = false; - if (psnowState.unifiedMode) { - unifiedNativeProbeFailed(false); - return; - } - QString errorMsg = "No base_url in stores response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - psnowState.baseUrl = baseUrl; - - qInfo() << "[PSNOW] Stores fetched successfully, base URL:" << baseUrl; - - // Fetch the root container to get dynamic category URLs - fetchPsnowRootContainer(); -} - -void CloudCatalogBackend::fetchPsnowRootContainer() -{ - // Fetch root container endpoint with ?size=100 - QString rootUrl = psnowState.baseUrl + "?size=100"; - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Root Container Request ==="; - qInfo() << " URL:" << rootUrl; - qInfo() << " Method: GET"; - } - - QNetworkRequest req{QUrl(rootUrl)}; - req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); - req.setRawHeader("Cookie", QString("JSESSIONID=%1").arg(psnowState.jsessionId).toUtf8()); - req.setRawHeader("Origin", KamajiConsts::ORIGIN.toUtf8()); - req.setRawHeader("Referer", KamajiConsts::REFERER.toUtf8()); - req.setRawHeader("Accept", "application/json"); - - QNetworkReply *reply = networkManager->get(req); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePsnowRootContainerResponse); -} - -void CloudCatalogBackend::handlePsnowRootContainerResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray response = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Root Container Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Body:" << QString(response); - } - - if (reply->error() != QNetworkReply::NoError || statusCode != 200) { - psnowState.authInProgress = false; - QString errorMsg = QString("Root container request failed: %1").arg(reply->errorString()); - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QJsonDocument doc = QJsonDocument::fromJson(response); - if (!doc.isObject()) { - psnowState.authInProgress = false; - QString errorMsg = "Invalid JSON in root container response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - QJsonObject obj = doc.object(); - QJsonArray links = obj["links"].toArray(); - - // Alphabetical category name patterns to match - QStringList categoryPatterns = { - "A - B", - "C - D", - "E - G", - "H - L", - "M - O", - "P - R", - "S", - "T", - "U - Z" - }; - - QStringList categoryUrls; - - // Extract URLs from links that match alphabetical category patterns - for (const QJsonValue &linkValue : links) { - QJsonObject link = linkValue.toObject(); - QString name = link["name"].toString(); - - // Check if this link matches any of our category patterns - if (categoryPatterns.contains(name)) { - QString url = link["url"].toString(); - if (!url.isEmpty()) { - categoryUrls.append(url); - if (settings && settings->GetLogVerbose()) { - qInfo() << "[PSNOW] Found category:" << name << "URL:" << url; - } - } - } - } - - if (categoryUrls.isEmpty()) { - psnowState.authInProgress = false; - QString errorMsg = "No alphabetical category URLs found in root container response"; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - psnowState.categories = categoryUrls; - psnowState.authInProgress = false; - - qInfo() << "[PSNOW] Root container fetched successfully, extracted" << categoryUrls.size() << "alphabetical category URLs"; - - // Now start fetching categories - psnowState.allGames = QJsonArray(); - psnowState.currentCategoryIndex = 0; - fetchPsnowCategory(0); -} - -void CloudCatalogBackend::fetchPsnowCategory(int categoryIndex) -{ - if (categoryIndex >= psnowState.categories.size()) { - // All categories fetched, process and return - processPsnowCatalogComplete(); - return; - } - - // Check if we have categories (from stores endpoint) - if (psnowState.categories.isEmpty()) { - qWarning() << "PSNOW categories not available - authentication may not have completed"; - return; - } - - // Use the URL directly from the root container response - QString url = psnowState.categories[categoryIndex]; - - // Append query parameters if not already present - if (!url.contains("?")) { - url = QString("%1?start=0&size=500").arg(url); - } else { - // URL already has query parameters, append ours - url = QString("%1&start=0&size=500").arg(url); - } - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: Fetching PSNOW category ==="; - qInfo() << " Category Index:" << categoryIndex; - qInfo() << " URL:" << url; - qInfo() << " Method: GET"; - } - - QNetworkRequest request{QUrl(url)}; - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "application/json"); - request.setRawHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); - - QNetworkReply *reply = networkManager->get(request); - reply->setProperty("categoryIndex", categoryIndex); - - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePsnowCategoryResponse); -} - -void CloudCatalogBackend::handlePsnowCategoryResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - int categoryIndex = reply->property("categoryIndex").toInt(); - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PSNOW Category Response ==="; - qInfo() << " Category Index:" << categoryIndex; - qInfo() << " Status:" << statusCode; - } - - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - QString errorMsg = QString("PSNOW category fetch error: %1").arg(reply->errorString()); - qWarning() << errorMsg; - // Report error to callback if this is the last category or if we haven't collected any games - if (psnowState.allGames.isEmpty() && psnowState.currentCategoryIndex >= psnowState.categories.size() - 1) { - if (psnowState.callback.isCallable()) { - psnowState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - // Continue with next category even on error - psnowState.currentCategoryIndex = categoryIndex + 1; - if (psnowState.currentCategoryIndex < psnowState.categories.size()) { - psnowState.rateLimitTimer->start(); - connect(psnowState.rateLimitTimer, &QTimer::timeout, this, [this, categoryIndex]() { - fetchPsnowCategory(categoryIndex + 1); - }, Qt::SingleShotConnection); - } else { - processPsnowCatalogComplete(); - } - return; - } - - QByteArray data = reply->readAll(); - QJsonDocument doc = QJsonDocument::fromJson(data); - - if (doc.isObject()) { - QJsonObject obj = doc.object(); - if (obj.contains("links") && obj["links"].isArray()) { - QJsonArray links = obj["links"].toArray(); - int gameCount = 0; - for (const QJsonValue &link : links) { - if (link.isObject()) { - QJsonObject gameObj = link.toObject(); - - // Extract cover image from catalog response if available - // Check for images in the game object - QString coverImageUrl = extractCoverImageFromGameObject(gameObj); - if (!coverImageUrl.isEmpty()) { - // Add imageUrl field for easy access - gameObj["imageUrl"] = coverImageUrl; - } - - psnowState.allGames.append(gameObj); - gameCount++; - } - } - if (settings && settings->GetLogVerbose()) { - qInfo() << " Games in category:" << gameCount; - } - } - } - - // Move to next category with rate limiting - psnowState.currentCategoryIndex = categoryIndex + 1; - if (psnowState.currentCategoryIndex < psnowState.categories.size()) { - psnowState.rateLimitTimer->start(); - connect(psnowState.rateLimitTimer, &QTimer::timeout, this, [this]() { - fetchPsnowCategory(psnowState.currentCategoryIndex); - }, Qt::SingleShotConnection); - } else { - processPsnowCatalogComplete(); - } -} - -void CloudCatalogBackend::processPsnowCatalogComplete() -{ - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: Processing PSNOW catalog complete ==="; - qInfo() << " Total games before deduplication:" << psnowState.allGames.size(); - } - - // Remove duplicates by product ID - QMap uniqueGames; - for (const QJsonValue &game : psnowState.allGames) { - if (game.isObject()) { - QJsonObject gameObj = game.toObject(); - QString id = gameObj["id"].toString(); - if (!id.isEmpty() && !uniqueGames.contains(id)) { - uniqueGames[id] = gameObj; - } - } - } - - // Convert back to array and ensure images are extracted - QJsonArray finalGames; - for (const QJsonObject &game : uniqueGames.values()) { - QJsonObject gameObj = game; - - // Extract cover image if not already present - if (!gameObj.contains("imageUrl") || gameObj["imageUrl"].toString().isEmpty()) { - QString coverImageUrl = extractCoverImageFromGameObject(gameObj); - if (!coverImageUrl.isEmpty()) { - gameObj["imageUrl"] = coverImageUrl; - if (settings && settings->GetLogVerbose()) { - qInfo() << " Extracted cover image for:" << gameObj["name"].toString(); - } - } - } - - finalGames.append(gameObj); - } - - if (settings && settings->GetLogVerbose()) { - qInfo() << " Unique games after deduplication:" << finalGames.size(); - } - - QJsonObject result; - result["games"] = finalGames; - result["total"] = finalGames.size(); - - QJsonDocument resultDoc(result); - - if (psnowState.unifiedMode) { - psnowState.unifiedMode = false; - psnowState.authInProgress = false; - unifiedState.apolloGames = finalGames; - unifiedState.nativeMode = true; - qInfo() << "[UNIFIED] PS Now APOLLOROOT native:" << finalGames.size() << "games"; - continueUnifiedAfterApollo(); - emit catalogUpdated(); - return; - } - - // Cache the result - setCachedData("psnow_catalog", resultDoc); - - // Call callback - if (psnowState.callback.isCallable()) { - QString jsonStr = QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)); - psnowState.callback.call({true, "Success", QJSValue(jsonStr)}); - } - - emit catalogUpdated(); -} - -// --------------------------------------------------------------------------- -// Unified cloud catalog (mirrors Android CloudGameRepository.fetchUnifiedCatalog) -// --------------------------------------------------------------------------- - -void CloudCatalogBackend::fetchUnifiedCatalog(const QJSValue &callback) -{ - // v2: platform_id-disciplined merge (PS5-class cards never claimed by a PS4 cross-buy license). - // Bumped so users upgrading from a pre-platform_id binary rebuild instead of serving a stale, - // possibly mis-merged catalog until TTL. - const QString cached = getCachedData(QStringLiteral("unified_catalog_v2"), CACHE_DURATION_CATALOG); - if (!cached.isEmpty()) { - qInfo() << "[CACHE] Using cached unified catalog"; - if (callback.isCallable()) - callback.call({true, QStringLiteral("Cached"), QJSValue(cached)}); - return; - } - - if (unifiedState.active) { - if (callback.isCallable()) - callback.call({false, QStringLiteral("Unified catalog fetch already in progress"), QJSValue()}); - return; - } - - unifiedState = UnifiedFetchState{}; - unifiedState.active = true; - unifiedState.callback = callback; - - qInfo() << "[UNIFIED] Starting unified catalog fetch (native APOLLOROOT probe)"; - psnowState.callback = QJSValue(); - psnowState.unifiedMode = true; - psnowState.allGames = QJsonArray(); - psnowState.categories = QStringList(); - psnowState.currentCategoryIndex = 0; - psnowState.authInProgress = true; - psnowState.oauthCode.clear(); - psnowState.jsessionId.clear(); - psnowState.baseUrl.clear(); - psnowState.duid.clear(); - fetchPsnowOAuthToken(); -} - -void CloudCatalogBackend::unifiedNativeProbeFailed(bool authError) -{ - if (!unifiedState.active) - return; - - psnowState.authInProgress = false; - psnowState.unifiedMode = false; - unifiedState.authError = authError; - - if (authError) { - // Expired session: surface the re-login prompt. Do NOT walk the public APOLLOROOT - // fallback -- that path is only for region-unsupported accounts (auth OK, /user/stores - // 404). Falling back here would mask the expired token. Continue straight to the PS5 - // catalog with empty apolloGames; continueUnifiedAfterApollo() skips the empty-catalog - // failure because authError is set, so the user still sees PS5 titles plus the warning. - unifiedState.warning = QStringLiteral( - "Your PlayStation session has expired. Please log in again to see your owned games."); - unifiedState.apolloGames = QJsonArray(); - qInfo() << "[UNIFIED] Native APOLLOROOT probe failed (auth error); skipping public " - "fallback, prompting re-login"; - continueUnifiedAfterApollo(); - return; - } - - qInfo() << "[UNIFIED] Native APOLLOROOT probe failed (region unsupported), trying " - "region-group fallback"; - startUnifiedApolloFallback(); -} - -void CloudCatalogBackend::startUnifiedApolloFallback() -{ - const QString accountCountry = ps3AccountCountry(); - const QString storeCountry = KamajiConsts::classicsStoreCountry(accountCountry); - const QString containerId = KamajiConsts::apolloRootContainerId(accountCountry); - unifiedState.apolloContainerUrl = QStringLiteral( - "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/en/19/%2") - .arg(storeCountry, containerId); - unifiedState.apolloGames = QJsonArray(); - unifiedState.apolloStart = 0; - unifiedState.apolloTotal = -1; - - qInfo() << "[UNIFIED] Fetching APOLLOROOT fallback (region group" << storeCountry - << "for account" << accountCountry << ")"; - fetchUnifiedApolloPage(); -} - -void CloudCatalogBackend::fetchUnifiedApolloPage() -{ - const QString url = QStringLiteral("%1?useOffers=true&gkb=1&gkb2=1&start=%2&size=100") - .arg(unifiedState.apolloContainerUrl) - .arg(unifiedState.apolloStart); - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Accept", "application/json"); - req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); - - QNetworkReply *reply = networkManager->get(req); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handleUnifiedApolloPageResponse); -} - -void CloudCatalogBackend::handleUnifiedApolloPageResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) - return; - reply->deleteLater(); - - const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QByteArray data = reply->readAll(); - - if (reply->error() != QNetworkReply::NoError || statusCode != 200) { - if (unifiedState.apolloGames.isEmpty()) { - finishUnifiedFetch(false, QStringLiteral("Failed to fetch APOLLOROOT catalog: HTTP %1").arg(statusCode)); - return; - } - qWarning() << "[UNIFIED] APOLLOROOT partial fetch ended with HTTP" << statusCode; - continueUnifiedAfterApollo(); - return; - } - - const QJsonObject obj = QJsonDocument::fromJson(data).object(); - if (unifiedState.apolloTotal < 0) - unifiedState.apolloTotal = obj.value(QStringLiteral("total_results")).toInt(); - - int productCount = 0; - for (const QJsonValue &v : obj.value(QStringLiteral("links")).toArray()) { - const QJsonObject g = v.toObject(); - if (g.value(QStringLiteral("container_type")).toString() != QLatin1String("product")) - continue; - QJsonObject game = g; - const QString img = extractCoverImageFromGameObject(game); - if (!img.isEmpty()) - game.insert(QStringLiteral("imageUrl"), img); - unifiedState.apolloGames.append(game); - productCount++; - } - - unifiedState.apolloStart += 100; - if (productCount > 0 && unifiedState.apolloStart < unifiedState.apolloTotal) - fetchUnifiedApolloPage(); - else { - qInfo() << "[UNIFIED] APOLLOROOT fallback complete:" << unifiedState.apolloGames.size() << "titles"; - continueUnifiedAfterApollo(); - } -} - -void CloudCatalogBackend::continueUnifiedAfterApollo() -{ - if (!unifiedState.nativeMode && !unifiedState.apolloGames.isEmpty()) - unifiedState.fallbackRegion = KamajiConsts::classicsStoreCountry(ps3AccountCountry()); - - if (settings) - settings->SetCloudFallbackRegion(unifiedState.nativeMode ? QString() : unifiedState.fallbackRegion); - - qInfo() << "[UNIFIED] PS Now APOLLOROOT:" << unifiedState.apolloGames.size() - << "games (nativeMode=" << unifiedState.nativeMode - << "fallbackRegion='" << unifiedState.fallbackRegion << "')"; - - if (unifiedState.apolloGames.isEmpty() && !unifiedState.authError) { - finishUnifiedFetch(false, QStringLiteral("Failed to fetch cloud catalog")); - return; - } - - ps5State.callback = QJSValue(); - fetchPs5CloudCatalog(QJSValue()); -} - -void CloudCatalogBackend::startUnifiedOwnedCrossRef() -{ - crossReferenceState = CrossReferenceState{}; - crossReferenceState.unifiedMode = true; - crossReferenceState.psnowCatalogGames = unifiedState.apolloGames; - crossReferenceState.cloudCatalogGames = unifiedState.imagicBrowse; - crossReferenceState.plusLibrarySupplement = unifiedState.imagicSupplement; - crossReferenceState.productIdAliases = unifiedState.productIdAliases; - crossReferenceState.catalogFetched = true; - crossReferenceState.ownedGamesFetched = false; - - const QString npsso = getNpSsoToken(); - if (npsso.isEmpty() || unifiedState.authError) { - assembleUnifiedCatalog(QJsonArray()); - return; - } - - QString cachedOwned = getCachedData(QStringLiteral("ps5_cloud_library"), CACHE_DURATION_CATALOG); - if (!cachedOwned.isEmpty()) { - const QJsonDocument doc = QJsonDocument::fromJson(cachedOwned.toUtf8()); - if (doc.isObject() && doc.object().value(QStringLiteral("games")).isArray()) { - crossReferenceState.ownedGames = doc.object().value(QStringLiteral("games")).toArray(); - crossReferenceState.ownedGamesFetched = true; - if (doc.object().contains(QStringLiteral("componentIdsByProductId"))) { - const QJsonObject m = doc.object().value(QStringLiteral("componentIdsByProductId")).toObject(); - for (auto it = m.begin(); it != m.end(); ++it) { - QStringList ids; - for (const QJsonValue &v : it.value().toArray()) - ids.append(v.toString()); - crossReferenceState.componentIdsByProductId.insert(it.key(), ids); - } - } - processCrossReferenceComplete(); - return; - } - } - - fetchOwnedPs5Games(QJSValue()); -} - -// NOTE: assembleUnifiedCatalog() is defined further below, AFTER the anonymous-namespace -// helper block, because it uses normalizeApolloGame / isPs5PlatformGame / -// mergeOwnedIntoBrowseCatalog / StreamabilityIndex / applyStreamabilityGate / categoryForGame, -// which have internal linkage and must be defined before use. - -void CloudCatalogBackend::finishUnifiedFetch(bool success, const QString &message, - const QJsonObject &payload) -{ - const QJSValue cb = unifiedState.callback; - unifiedState = UnifiedFetchState{}; - psnowState.unifiedMode = false; - - if (cb.isCallable()) { - if (success) { - const QString jsonStr = QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact)); - cb.call({true, message, QJSValue(jsonStr)}); - } else { - cb.call({false, message, QJSValue()}); - } - } - if (success) - emit catalogUpdated(); -} - -// --------------------------------------------------------------------------- -// PS3 Classics catalog (public Apollo container walk) -// -// The PS Plus PC ("Apollo") app browses the streamable catalog through the public -// pcnow container API at psnow.playstation.com. There is a dedicated PS3 container, -// STORE-MSF192018-APOLLOPS3GAMES, that lists ~300 streamable PS3 titles with their -// PS3 product ids (NPUA/NPUB/BLUS/BCUS) -- none of which appear in the imagic -// gameslist the rest of the catalog uses. The container API needs no OAuth or -// per-account session (unlike /user/stores, which 404s in regions where the PC app -// is unavailable, e.g. Hungary), so we can walk it directly in any region. The -// resulting titles carry playable_platform ["PS3"] and stream via the existing -// PSNOW -> Gaikai konan path. -// --------------------------------------------------------------------------- -// Resolve the account's region group from its store locale (e.g. "en-HU" -> "HU"). -// pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is -// authorized at Gaikai only for the family of its own region group, so the catalog must -// be browsed in that group. See KamajiConsts::classicsStoreCountry / classicsPs3ContainerId. -QString CloudCatalogBackend::ps3AccountCountry() const -{ - QString locale = settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US"); - QStringList parts = locale.split(QLatin1Char('-')); - QString cc = parts.size() > 1 ? parts[1] : QStringLiteral("US"); - return cc.toUpper(); -} - -void CloudCatalogBackend::fetchPs3Catalog(const QJSValue &callback) -{ - const QString cc = ps3AccountCountry(); - // Region-group-specific cache key so an Americas/PAL switch doesn't serve stale ids. - const QString cacheKey = QStringLiteral("ps3_catalog_") + KamajiConsts::classicsStoreCountry(cc); - QString cached = getCachedData(cacheKey, CACHE_DURATION_CATALOG); - if (!cached.isEmpty()) { - qInfo() << "[CACHE] Using cached PS3 catalog"; - QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); - if (callback.isCallable()) - callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); - return; - } - - if (ps3State.inProgress) { - qInfo() << "[PS3] Catalog fetch already in progress"; - if (callback.isCallable()) - callback.call({false, "Request already in progress", QJSValue()}); - return; - } - - ps3State.containerUrl = QStringLiteral( - "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/en/19/%2") - .arg(KamajiConsts::classicsStoreCountry(cc), KamajiConsts::classicsPs3ContainerId(cc)); - qInfo() << "[API CALL] Fetching PS3 Classics catalog (region group" - << KamajiConsts::classicsStoreCountry(cc) << "for account country" << cc << ")"; - ps3State.callback = callback; - ps3State.allGames = QJsonArray(); - ps3State.currentStart = 0; - ps3State.totalResults = -1; - ps3State.inProgress = true; - fetchPs3CatalogPage(); -} - -void CloudCatalogBackend::fetchPs3CatalogPage() -{ - QString url = QString("%1?useOffers=true&gkb=1&gkb2=1&start=%2&size=100") - .arg(ps3State.containerUrl) - .arg(ps3State.currentStart); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PS3 catalog page ==="; - qInfo() << " URL:" << url; - } - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Accept", "application/json"); - req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); - - QNetworkReply *reply = networkManager->get(req); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePs3CatalogPageResponse); -} - -void CloudCatalogBackend::handlePs3CatalogPageResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray data = reply->readAll(); - - if (reply->error() != QNetworkReply::NoError || statusCode != 200) { - QString errorMsg = QString("PS3 catalog fetch failed (HTTP %1): %2") - .arg(statusCode).arg(reply->errorString()); - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (ps3State.allGames.isEmpty()) { - ps3State.inProgress = false; - if (ps3State.callback.isCallable()) - ps3State.callback.call({false, errorMsg, QJSValue()}); - return; - } - // Partial data already collected: return what we have. - finishPs3Catalog(); - return; - } - - QJsonObject obj = QJsonDocument::fromJson(data).object(); - if (ps3State.totalResults < 0) - ps3State.totalResults = obj.value("total_results").toInt(); - - QJsonArray links = obj.value("links").toArray(); - int productCount = 0; - for (const QJsonValue &v : links) { - QJsonObject g = v.toObject(); - if (g.value("container_type").toString() != QLatin1String("product")) - continue; - QString img = extractCoverImageFromGameObject(g); - if (!img.isEmpty()) - g["imageUrl"] = img; - ps3State.allGames.append(g); - productCount++; - } - - if (settings && settings->GetLogVerbose()) - qInfo() << " PS3 page games:" << productCount << "accumulated:" << ps3State.allGames.size() - << "of" << ps3State.totalResults; - - ps3State.currentStart += 100; - if (productCount > 0 && ps3State.currentStart < ps3State.totalResults) { - fetchPs3CatalogPage(); - } else { - finishPs3Catalog(); - } -} - -void CloudCatalogBackend::finishPs3Catalog() -{ - QJsonObject result; - result["games"] = ps3State.allGames; - result["total"] = ps3State.allGames.size(); - QJsonDocument resultDoc(result); - setCachedData(QStringLiteral("ps3_catalog_") + KamajiConsts::classicsStoreCountry(ps3AccountCountry()), resultDoc); - - qInfo() << "[PS3] Catalog complete:" << ps3State.allGames.size() << "PS3 titles"; - - ps3State.inProgress = false; - if (ps3State.callback.isCallable()) - ps3State.callback.call({true, "Success", QJSValue(QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)))}); -} - -namespace { - -// Canonicalize a "language-COUNTRY" locale to lowercase-language / uppercase-country. -static QString canonicalStoreLocale(const QString &raw) -{ - QString s = raw.trimmed(); - if (s.isEmpty()) - return QStringLiteral("en-US"); - const QStringList parts = s.split(QLatin1Char('-')); - QString lang = parts.value(0).toLower(); - QString country = parts.value(1).toUpper(); - if (lang.isEmpty()) - lang = QStringLiteral("en"); - if (country.isEmpty()) - country = QStringLiteral("US"); - return lang + QLatin1Char('-') + country; -} - -// Build the ordered list of store locales to try. Sony's storefront/imagic endpoints -// only serve a fixed set of language-COUNTRY combinations: the country is always -// served, but the language may not be (e.g. a Hungarian-language account yields -// "hu-HU", which 404s, while "en-HU" works). Fall back to English for the same -// country, then en-US, so the catalog loads in every region. -static QStringList buildStoreLocaleFallbackChain(const QString &stored) -{ - const QString canonical = canonicalStoreLocale(stored); - const QString country = canonical.section(QLatin1Char('-'), 1, 1); - QStringList chain; - auto add = [&chain](const QString &loc) { - if (!loc.isEmpty() && !chain.contains(loc)) - chain.append(loc); - }; - add(canonical); - add(QStringLiteral("en-") + country); - add(QStringLiteral("en-US")); - return chain; -} - -static const QStringList kPs5ImagicCategoryLists = { - QStringLiteral("plus-games-list"), - QStringLiteral("ubisoft-classics-list"), - QStringLiteral("plus-classics-list"), - QStringLiteral("plus-monthly-games-list"), - QStringLiteral("free-to-play-list"), - QStringLiteral("all-ps5-list"), -}; - -// PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not present in these -// imagic lists). A PS4-only title such as God of War (2018) is streamable when -// owned even though it carries device ["PS4"], so the catalog must not discard it. -static bool isCloudDeviceGame(const QJsonObject &gameObj) -{ - const QJsonArray devices = gameObj.value(QStringLiteral("device")).toArray(); - for (const QJsonValue &device : devices) { - const QString d = device.toString(); - if (d == QLatin1String("PS5") || d == QLatin1String("PS4")) - return true; - } - return false; -} - -static bool isCloudStreamingGame(const QJsonObject &gameObj) -{ - if (!gameObj.value(QStringLiteral("streamingSupported")).toBool()) - return false; - return isCloudDeviceGame(gameObj); -} - -static QString ps5CloudConceptKey(const QJsonObject &gameObj) -{ - const QJsonValue conceptIdVal = gameObj.value(QStringLiteral("conceptId")); - if (conceptIdVal.isDouble()) { - const qint64 conceptId = static_cast(conceptIdVal.toDouble()); - if (conceptId > 0) - return QString::number(conceptId); - } else if (conceptIdVal.isString()) { - const QString conceptId = conceptIdVal.toString(); - if (!conceptId.isEmpty()) - return conceptId; - } - return gameObj.value(QStringLiteral("productId")).toString(); -} - -// Platform token from a product id's title id: CUSA = PS4, PPSA = PS5. -static QString ps5CloudPlatformToken(const QString &productId) -{ - if (productId.contains(QLatin1String("PPSA"))) - return QStringLiteral("ps5"); - if (productId.contains(QLatin1String("CUSA"))) - return QStringLiteral("ps4"); - return QString(); -} - -// Platform CLASS (ps5/ps4) from the canonical serviceType axis: pscloud == PS5 (cronos), -// psnow == PS3/PS4 (Kamaji, routed as ps4-class). serviceType is set on PS Now browse rows and is -// filled in for owned entitlements from PSN's structured platform_id, so this never parses CUSA/PPSA -// out of an id (a cross-buy PS4 entitlement can carry a PS5-looking product_id wrapper). Returns -// empty when serviceType is absent (e.g. non-owned imagic browse rows) -- callers fall back to the -// clean catalog product-id token there. -static QString gamePlatformStructured(const QJsonObject &game) -{ - const QString st = game.value(QStringLiteral("serviceType")).toString().toLower(); - if (st == QLatin1String("pscloud")) return QStringLiteral("ps5"); - if (st == QLatin1String("psnow")) return QStringLiteral("ps4"); - return QString(); -} - -// serviceType (pscloud == PS5/cronos, psnow == PS3/PS4/Kamaji) for an owned entitlement, derived -// from PSN's structured platform_id (entitlement_attributes[].platform_id) -- NOT a CUSA/PPSA id -// prefix, since a cross-buy PS4 license can carry a PS5-looking product_id wrapper. Empty if unknown. -static QString ownedEntitlementServiceType(const QJsonObject &ent) -{ - const QJsonArray attrs = ent.value(QStringLiteral("entitlement_attributes")).toArray(); - for (const QJsonValue &a : attrs) { - if (!a.isObject()) - continue; - const QString pid = a.toObject().value(QStringLiteral("platform_id")).toString().toLower(); - if (pid == QLatin1String("ps5")) - return QStringLiteral("pscloud"); - if (pid == QLatin1String("ps4") || pid == QLatin1String("ps3")) - return QStringLiteral("psnow"); - } - return QString(); -} - -// Normalize an owned entitlement's stream-backend tag in place: drop Sony's raw numeric -// `serviceType` (which is unrelated to our routing and collides with our string field), then set -// OUR canonical serviceType ("pscloud"/"psnow") from platform_id. Leaves serviceType absent when -// platform_id is unknown so callers fall back to the clean catalog product-id token. Applied at the -// owned-entitlement ingestion gate (fresh fetch) and when loading owned games from a stale cache. -static void sanitizeOwnedEntitlementServiceType(QJsonObject &ent) -{ - ent.remove(QStringLiteral("serviceType")); - const QString svc = ownedEntitlementServiceType(ent); - if (!svc.isEmpty()) - ent.insert(QStringLiteral("serviceType"), svc); -} - -// Catalog dedupe identity: one entry per game PER PLATFORM, so a cross-gen title that Sony lists -// as separate PS4 and PS5 editions (e.g. Deliver Us The Moon) shows as two cards, while duplicate -// same-platform SKUs still collapse. (conceptId alone collapsed the PS4/PS5 editions into one.) -static QString ps5CloudEditionKey(const QJsonObject &gameObj) -{ - const QString concept = ps5CloudConceptKey(gameObj); - if (concept.isEmpty()) - return QString(); - QString platform = gamePlatformStructured(gameObj); - if (platform.isEmpty()) - platform = ps5CloudPlatformToken(gameObj.value(QStringLiteral("productId")).toString()); - return concept + QLatin1Char('|') + platform; -} - -// A "full game" entitlement (vs an add-on / avatar / theme). PSN marks the base game with -// feature_type 3 and a *GD package_type (PSGD/PS4GD); add-ons use feature_type 0 and -// PS4MISC/PSAL/etc. Used to keep the base game when collapsing same-platform SKUs. -static bool ps5CloudIsFullGameEntitlement(const QJsonObject &ownedGameObj) -{ - if (ownedGameObj.value(QStringLiteral("feature_type")).toInt() == 3) - return true; - const QString pt = ownedGameObj.value(QStringLiteral("game_meta")).toObject() - .value(QStringLiteral("package_type")).toString(); - return pt.endsWith(QStringLiteral("GD")); -} - -// Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). -// Upgrade / bonus / cross-buy SKUs collapse to the same conceptId+platform as the base game, so we -// must pick which one's product_id the card streams. The package_type/feature_type flags are not -// enough: Death Stranding DC's "Bonus Content" SKU is ALSO PSGD + feature_type 3, identical to the -// game. The reliable signal is that the BASE GAME's entitlement id EQUALS its product_id (e.g. -// ...DEATHSTRANDINGEU == ...DEATHSTRANDINGEU), while bonus/upgrade SKUs carry a different id (the -// bonus is product_id ...DEATHSTRADCDDE01 but id ...PPSA02624...). Prefer the canonical full-game -// entitlement so getStreamingIdentifier streams the real game's product_id, not a DLC product that -// Gaikai has no game for (-> noGameForEntitlementId). -static int ps5CloudOwnedStreamRank(const QJsonObject &ownedGameObj) -{ - const QString id = ownedGameObj.value(QStringLiteral("id")).toString(); - const QString pid = ownedGameObj.value(QStringLiteral("product_id")).toString(); - int rank = 0; - if (!pid.isEmpty() && pid == id) rank += 4; // canonical product (the base game SKU) - if (ps5CloudIsFullGameEntitlement(ownedGameObj)) rank += 2; // full game (feature_type 3 / *GD) - if (!id.isEmpty()) rank += 1; // has a real entitlement id - return rank; -} - -// Is this a "Game Streaming" (GS) package? PSN labels the cloud-streamable SKU *GS (e.g. PS4GS) and -// the installable download *GD (PS4GD / PSGD). For a cross-buy title the GS entitlement carries the -// clean, streamable product for its platform, while the GD cross-buy entitlement can carry a -// cross-generation *wrapper* product. So when two same-platform SKUs collapse to one edition, the GS -// SKU is the right streaming candidate. Uses the structured game_meta.package_type field (the same -// field ps5CloudIsFullGameEntitlement reads) -- no product-id prefix guessing. -static bool ps5CloudIsStreamingPackage(const QJsonObject &ownedGameObj) -{ - const QString pt = ownedGameObj.value(QStringLiteral("game_meta")).toObject() - .value(QStringLiteral("package_type")).toString(); - return pt.endsWith(QStringLiteral("GS")); -} - -// Deterministic total order over owned entitlements that collapse to the same edition (conceptId + -// platform). MUST be independent of the PSN entitlements response order so the assembled catalog is -// stable across refreshes (cross-buy titles with equal stream rank routinely tie). Returns true if -// `cand` should replace `cur` as the edition's representative. Signals, in priority order, all from -// structured API fields: (1) higher stream rank (canonical full-game product); (2) the cloud- -// streaming (GS) package over a download (GD) SKU; (3) stable unique sku_id, then product_id, then -// entitlement id, purely to guarantee a single deterministic winner. -static bool ps5CloudOwnedEntitlementBetter(const QJsonObject &cand, const QJsonObject &cur) -{ - const int rc = ps5CloudOwnedStreamRank(cand); - const int ru = ps5CloudOwnedStreamRank(cur); - if (rc != ru) - return rc > ru; - const bool gc = ps5CloudIsStreamingPackage(cand); - const bool gu = ps5CloudIsStreamingPackage(cur); - if (gc != gu) - return gc; // prefer the cloud-streaming (GS) SKU - const QString sc = cand.value(QStringLiteral("sku_id")).toString(); - const QString su = cur.value(QStringLiteral("sku_id")).toString(); - if (sc != su) - return sc < su; - const QString pc = cand.value(QStringLiteral("product_id")).toString(); - const QString pu = cur.value(QStringLiteral("product_id")).toString(); - if (pc != pu) - return pc < pu; - return cand.value(QStringLiteral("id")).toString() < cur.value(QStringLiteral("id")).toString(); -} - -static QString ps5CloudProductIdStableKey(const QString &productId) -{ - if (productId.isEmpty()) - return QString(); - QStringList tokens; - const QStringList dashParts = productId.split(QLatin1Char('-'), Qt::SkipEmptyParts); - for (const QString &dashPart : dashParts) { - const QStringList underscoreParts = dashPart.split(QLatin1Char('_'), Qt::SkipEmptyParts); - for (const QString &token : underscoreParts) - tokens.append(token); - } - if (tokens.size() < 2) - return QString(); - tokens.removeLast(); - return tokens.join(QLatin1Char('|')); -} - -static QMap buildStableKeyIndex(const QJsonArray &games) -{ - QMap index; - for (const QJsonValue &game : games) { - if (!game.isObject()) - continue; - const QJsonObject gameObj = game.toObject(); - const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - const QString key = ps5CloudProductIdStableKey(productId); - if (key.isEmpty() || index.contains(key)) - continue; - index.insert(key, gameObj); - } - return index; -} - -// imagic encodes conceptId as a JSON number; entitlements (if present) may use a -// number or string. Normalize to a non-empty decimal string, else empty. -static QString ps5CloudConceptIdString(const QJsonValue &conceptIdVal) -{ - if (conceptIdVal.isDouble()) { - const qint64 c = static_cast(conceptIdVal.toDouble()); - return c > 0 ? QString::number(c) : QString(); - } - if (conceptIdVal.isString()) - return conceptIdVal.toString(); - return QString(); -} - -// conceptId is region-stable (227770 for God of War 2018) whereas product IDs are -// region-prefixed (EP9000 vs UP9000) and vary by edition, so it is the most reliable -// owned->catalog match when both sides carry one. -static QMap buildConceptIdIndex(const QJsonArray &games) -{ - QMap index; - for (const QJsonValue &game : games) { - if (!game.isObject()) - continue; - const QJsonObject gameObj = game.toObject(); - const QString concept = ps5CloudConceptIdString(gameObj.value(QStringLiteral("conceptId"))); - if (concept.isEmpty() || index.contains(concept)) - continue; - index.insert(concept, gameObj); - } - return index; -} - -// Pull a conceptId out of an owned entitlement, checking the field names the -// commerce API and our merged objects use. Returns empty if none is present. -static QString ownedEntitlementConceptId(const QJsonObject &ownedGameObj) -{ - QString concept = ps5CloudConceptIdString(ownedGameObj.value(QStringLiteral("conceptId"))); - if (concept.isEmpty()) - concept = ps5CloudConceptIdString(ownedGameObj.value(QStringLiteral("concept_id"))); - if (concept.isEmpty()) { - const QJsonObject gameMeta = ownedGameObj.value(QStringLiteral("game_meta")).toObject(); - concept = ps5CloudConceptIdString(gameMeta.value(QStringLiteral("conceptId"))); - if (concept.isEmpty()) - concept = ps5CloudConceptIdString(gameMeta.value(QStringLiteral("concept_id"))); - } - return concept; -} - -static QJsonObject productIdAliasesToJson(const QMap &aliases) -{ - QJsonObject obj; - for (auto it = aliases.cbegin(); it != aliases.cend(); ++it) - obj.insert(it.key(), it.value()); - return obj; -} - -static QMap productIdAliasesFromJson(const QJsonObject &obj) -{ - QMap aliases; - for (auto it = obj.begin(); it != obj.end(); ++it) { - const QString canonical = it.value().toString(); - if (!canonical.isEmpty()) - aliases.insert(it.key(), canonical); - } - return aliases; -} - -// The PS Plus subscription "Game Catalog" (what Sony lists on the PS Plus games page) is the -// union of these curated lists. The other source we fetch, "all-ps5-list", is the entire -// cloud-streamable PS5 universe (~7000 titles) — useful for matching owned games but NOT the -// subscription catalog, so it must not inflate the Catalog tab. -static bool isPlusCatalogList(const QString &categoryList) -{ - return categoryList == QLatin1String("plus-games-list") - || categoryList == QLatin1String("plus-classics-list") - || categoryList == QLatin1String("ubisoft-classics-list") - || categoryList == QLatin1String("plus-monthly-games-list"); -} - -static const QString kCategoryOwned = QStringLiteral("owned"); -static const QString kCategoryStreamable = QStringLiteral("streamable"); -static const QString kCategoryPurchaseable = QStringLiteral("purchaseable"); - -static QString gameProductId(const QJsonObject &game) -{ - const QString pid = game.value(QStringLiteral("productId")).toString(); - if (!pid.isEmpty()) - return pid; - return game.value(QStringLiteral("product_id")).toString(); -} - -static QString gameEntitlementId(const QJsonObject &game) -{ - const QString id = game.value(QStringLiteral("id")).toString(); - const QString pid = gameProductId(game); - if (!id.isEmpty() && id != pid) - return id; - return QString(); -} - -static QString conceptPlatformKey(const QJsonObject &game) -{ - const QString concept = ps5CloudConceptIdString(game.value(QStringLiteral("conceptId"))); - if (concept.isEmpty()) - return QString(); - QString platform = gamePlatformStructured(game); - if (platform.isEmpty()) { - QString pid = game.value(QStringLiteral("storeProductId")).toString(); - if (pid.isEmpty()) - pid = gameProductId(game); - platform = ps5CloudPlatformToken(pid); - } - return concept + QLatin1Char('|') + platform; -} - -struct CatalogIndexMaps { - QMap byProductId; - QMap byConceptId; -}; - -static void registerInCatalogIndex(const QJsonObject &game, int index, CatalogIndexMaps *idx) -{ - const QString productId = gameProductId(game); - if (!productId.isEmpty()) - idx->byProductId.insert(productId, index); - const QString conceptKey = conceptPlatformKey(game); - if (!conceptKey.isEmpty()) - idx->byConceptId.insert(conceptKey, index); - const QString entId = gameEntitlementId(game); - if (!entId.isEmpty()) - idx->byProductId.insert(entId, index); -} - -// IMPORTANT (this cost real debugging time): PSN *owned entitlements* carry NO conceptId in practice -// -- their game_meta is just { name, package_type, icon_url }. So every conceptId-based step below is -// effectively INERT for owned games: an owned entitlement resolves to a catalog row by EXACT ID ONLY -// (product_id -> entitlement id -> store product id). The conceptId machinery's live job is catalog-row -// edition dedup (edition / conceptPlatformKey), NOT owned->catalog matching. -// -// Also: a PS4 CROSS-BUY license can carry a PS5-looking PPSA *product_id* wrapper while its real PS4 -// component is the CUSA *id* (platform_id stays "ps4"). product_id is matched against the catalog FIRST, -// so such a ps4 license can land on a PS5 (PPSA) row. NEVER infer platform from a product-id prefix for -// owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge -// guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. -static int findCatalogIndexForOwned(const QJsonObject &owned, const CatalogIndexMaps &idx) -{ - const QString productId = gameProductId(owned); - if (!productId.isEmpty() && idx.byProductId.contains(productId)) - return idx.byProductId.value(productId); - const QString entId = gameEntitlementId(owned); - if (!entId.isEmpty() && idx.byProductId.contains(entId)) - return idx.byProductId.value(entId); - const QString storePid = owned.value(QStringLiteral("storeProductId")).toString(); - if (!storePid.isEmpty() && idx.byProductId.contains(storePid)) - return idx.byProductId.value(storePid); - const QString conceptKey = conceptPlatformKey(owned); - if (!conceptKey.isEmpty() && idx.byConceptId.contains(conceptKey)) - return idx.byConceptId.value(conceptKey); - return -1; -} - -static CatalogIndexMaps buildCatalogIndex(const QJsonArray &games) -{ - CatalogIndexMaps idx; - for (int i = 0; i < games.size(); ++i) { - if (games.at(i).isObject()) - registerInCatalogIndex(games.at(i).toObject(), i, &idx); - } - return idx; -} - -static QString streamServiceTypeForGame(const QJsonObject &game) -{ - // The canonical serviceType wins: it is set on PS Now browse rows and filled in for owned cards - // from PSN's platform_id (psnow == PS3/PS4 -> Kamaji, pscloud == PS5 -> cronos). - const QString st = game.value(QStringLiteral("serviceType")).toString().toLower(); - if (st == QLatin1String("psnow") || st == QLatin1String("pscloud")) - return st; - // Non-owned imagic browse rows have no serviceType; their product ids are clean (PPSA = PS5, - // CUSA = PS4), so derive from the catalog id token. - QString p = game.value(QStringLiteral("storeProductId")).toString(); - if (p.isEmpty()) - p = gameProductId(game); - if (p.isEmpty()) - p = gameEntitlementId(game); - if (p.contains(QLatin1String("CUSA"))) - return QStringLiteral("psnow"); - return QStringLiteral("pscloud"); -} - -static QString categoryForGame(const QJsonObject &game) -{ - if (game.value(QStringLiteral("isOwned")).toBool()) - return kCategoryOwned; - if (streamServiceTypeForGame(game) == QLatin1String("psnow")) - return kCategoryStreamable; - return kCategoryPurchaseable; -} - -static bool isPs5PlatformGame(const QJsonObject &game) -{ - QString p = gameProductId(game); - if (p.isEmpty()) - p = gameEntitlementId(game); - if (p.contains(QLatin1String("PPSA"))) - return true; - const QJsonArray devices = game.value(QStringLiteral("device")).toArray(); - for (const QJsonValue &d : devices) { - if (d.toString() == QLatin1String("PS5")) - return true; - } - return false; -} - -static QJsonArray mergeOwnedIntoBrowseCatalog(const QJsonArray &browseCatalog, - const QJsonArray &ownedCrossRef, - bool addUnmatched) -{ - QJsonArray games = browseCatalog; - CatalogIndexMaps catalogIndex = buildCatalogIndex(games); - - // Products the user FULLY owns (feature_type 3/5, i.e. not a trial). A trial (ft1) is normally - // kept as its own card so the free/trial build streams while the full game shows "Add Game" -- - // but only when the full game is NOT owned. When the SAME product is also held as a full license - // (common for F2P cross-buy titles: a PS4 free/trial entitlement whose product_id is the PS5 PPSA - // wrapper, e.g. Trackmania / Super Animal Royale / Fantasy Beauties), the trial card is redundant - // AND broken -- it routes to Kamaji (psnow, from its CUSA id) while carrying a PS5 PPSA product - // id, which Kamaji rejects. Suppress those trials below. Order-independent pre-pass. - QSet fullyOwnedProductIds; - for (const QJsonValue &ownedVal : ownedCrossRef) { - if (!ownedVal.isObject()) - continue; - const QJsonObject o = ownedVal.toObject(); - if (o.value(QStringLiteral("feature_type")).toInt() == 1) - continue; - const QString pid = gameProductId(o); - if (!pid.isEmpty()) - fullyOwnedProductIds.insert(pid); - } - - // Process pscloud (PS5) owned claims BEFORE psnow (PS3/PS4). A PS5 (pscloud) claim is - // authoritative and stamps the PS5 browse row in place; doing it first means the row is already - // owned by the time any PS4 cross-buy license (whose product_id is the SAME PPSA wrapper) is - // seen, so the wrapper can be dropped cleanly instead of appending a duplicate / orphaning the - // browse row as a "ghost". Without this, Qt's owned set arrives QMap-sorted (c::ps4 - // before c::ps5), so the wrapper is processed first and shadows the index. Stable - // partition: relative order is otherwise preserved. - QList ownedOrdered; - ownedOrdered.reserve(ownedCrossRef.size()); - for (const QJsonValue &ownedVal : ownedCrossRef) { - if (ownedVal.isObject() - && ownedVal.toObject().value(QStringLiteral("serviceType")).toString().toLower() - == QLatin1String("pscloud")) - ownedOrdered.append(ownedVal); - } - for (const QJsonValue &ownedVal : ownedCrossRef) { - if (ownedVal.isObject() - && ownedVal.toObject().value(QStringLiteral("serviceType")).toString().toLower() - != QLatin1String("pscloud")) - ownedOrdered.append(ownedVal); - } - - for (const QJsonValue &ownedVal : ownedOrdered) { - if (!ownedVal.isObject()) - continue; - QJsonObject ownedGame = ownedVal.toObject(); - const bool isTrialTier = ownedGame.value(QStringLiteral("feature_type")).toInt() == 1; - // A trial whose product is also fully owned is superseded by the full license -- drop it - // (it would otherwise append a redundant, unstreamable PS4/Kamaji card for a PS5 product). - if (isTrialTier && fullyOwnedProductIds.contains(gameProductId(ownedGame))) - continue; - const int catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(ownedGame, catalogIndex); - - if (catalogMatch >= 0) { - QJsonObject existing = games.at(catalogMatch).toObject(); - const QString ownedService = ownedGame.value(QStringLiteral("serviceType")).toString().toLower(); - const QString existingService = existing.value(QStringLiteral("serviceType")).toString().toLower(); - const QString ownedProductId = gameProductId(ownedGame); - // Platform CLASS of the matched card. Non-owned imagic browse rows carry NO serviceType, - // so fall back to the clean catalog product-id token (PPSA == ps5). This is what tells us - // the card is a PS5/cronos edition even before any owned claim stamps serviceType=pscloud. - QString existingClass = gamePlatformStructured(existing); - if (existingClass.isEmpty()) - existingClass = ps5CloudPlatformToken(gameProductId(existing)); - - // The card's stream identity must come from the OWNED entitlement of THIS card's platform. - // Cross-buy editions share one product_id (Red Dead's PS4 license id ...CUSA36842... and - // its PS5 license both carry product_id ...PPSA30528...), so matching by product_id alone - // lets a PS4 entitlement land on the PS5 card. Rule: a PS5 (pscloud) claim is authoritative - // and always stamps the PS5 entitlement's OWN id; a PS4/PS3 (psnow) entitlement must NEVER - // overwrite a card already claimed by PS5. A CUSA id is therefore never sent to cronos. - if (ownedService == QLatin1String("pscloud")) { - existing.insert(QStringLiteral("isOwned"), true); - // PS5/cronos streams the PS5 entitlement's own id (resolved from platform_id), - // ALWAYS -- whether canonical (id == product_id == ...PPSA..., e.g. Red Dead, Alan - // Wake) or a classic whose product_id is a non-streamable wrapper (id ...PPSA..SLUS, - // e.g. Blood Omen). Never the browse row's representative id (often a PS4/CUSA cross-buy). - const QString ownedId = ownedGame.value(QStringLiteral("id")).toString(); - if (!ownedId.isEmpty()) - existing.insert(QStringLiteral("id"), ownedId); - if (!ownedProductId.isEmpty()) { - existing.insert(QStringLiteral("product_id"), ownedProductId); - existing.insert(QStringLiteral("productId"), ownedProductId); - } - existing.insert(QStringLiteral("serviceType"), QStringLiteral("pscloud")); - games[catalogMatch] = existing; - continue; - } - if (ownedService == QLatin1String("psnow") - && existingService != QLatin1String("pscloud") - && existingClass != QLatin1String("ps5")) { - existing.insert(QStringLiteral("isOwned"), true); - // psnow (PS3/PS4 -> Kamaji) streams the catalog product variant (streamProductId), so - // the id is informational; keep stamping a distinct entitlement id when present. - const QString streamId = gameEntitlementId(ownedGame); - if (!streamId.isEmpty()) - existing.insert(QStringLiteral("id"), streamId); - existing.insert(QStringLiteral("serviceType"), QStringLiteral("psnow")); - games[catalogMatch] = existing; - continue; - } - // psnow entitlement whose matched card is PS5-class (serviceType=pscloud, OR an unstamped - // imagic browse row whose product-id token is PPSA): this is a PS4 CROSS-BUY license whose - // product_id is the shared PS5 (PPSA) wrapper. It is NOT a separate streamable edition -- - // the real PS4 variant (if any) matches its own CUSA catalog row independently, and the PS5 - // card is claimed by the PS5 (pscloud) license (processed first, above). DROP it: appending - // it produces a bogus duplicate PS4 card and, depending on merge order, orphans the browse - // row as a "ghost" purchaseable card. Streaming such a wrapper would fail anyway - // (noGameForEntitlementId), since the PS5 cloud variant needs a PS5 entitlement. - if (ownedService == QLatin1String("psnow")) { - continue; - } - // Any other matched-but-unstamped case (e.g. owned entitlement with no serviceType): fall - // through to add it as its own card / register it for a later same-platform match. - } - - if (!addUnmatched) - continue; - - QJsonObject entry = ownedGame; - entry.insert(QStringLiteral("isOwned"), true); - if (!entry.contains(QStringLiteral("productId")) && entry.contains(QStringLiteral("product_id"))) - entry.insert(QStringLiteral("productId"), entry.value(QStringLiteral("product_id")).toString()); - // serviceType (pscloud == PS5/cronos, psnow == PS4/Kamaji) is already set on the owned - // entitlement from its platform_id; nothing extra to stamp here (no id-prefix parsing). - registerInCatalogIndex(entry, games.size(), &catalogIndex); - games.append(entry); - } - - // Owned first, then name (mirrors Android mergeOwnedIntoBrowseCatalog sort). - QList sorted; - for (const QJsonValue &v : games) - sorted.append(v); - std::sort(sorted.begin(), sorted.end(), [](const QJsonValue &a, const QJsonValue &b) { - const QJsonObject ao = a.toObject(); - const QJsonObject bo = b.toObject(); - const bool aOwned = ao.value(QStringLiteral("isOwned")).toBool(); - const bool bOwned = bo.value(QStringLiteral("isOwned")).toBool(); - if (aOwned != bOwned) - return aOwned > bOwned; - QString nameA = ao.value(QStringLiteral("name")).toString(); - if (nameA.isEmpty()) - nameA = ao.value(QStringLiteral("game_meta")).toObject().value(QStringLiteral("name")).toString(); - QString nameB = bo.value(QStringLiteral("name")).toString(); - if (nameB.isEmpty()) - nameB = bo.value(QStringLiteral("game_meta")).toObject().value(QStringLiteral("name")).toString(); - return nameA.compare(nameB, Qt::CaseInsensitive) < 0; - }); - QJsonArray out; - for (const QJsonValue &v : sorted) - out.append(v); - return out; -} - -class StreamabilityIndex { -public: - StreamabilityIndex(const QJsonArray &apolloCatalog, - const QJsonArray &imagicBrowse, - const QJsonArray &imagicConceptRows) - { - auto addProduct = [this](const QString &productId) { - if (productId.isEmpty()) - return; - productKeys.insert(productId); - const QString stable = ps5CloudProductIdStableKey(productId); - if (!stable.isEmpty()) - productKeys.insert(stable); - }; - for (const QJsonValue &v : apolloCatalog) { - if (v.isObject()) - addProduct(gameProductId(v.toObject())); - } - for (const QJsonValue &v : imagicBrowse) { - if (!v.isObject()) - continue; - const QJsonObject g = v.toObject(); - addProduct(gameProductId(g)); - const QString concept = ps5CloudConceptIdString(g.value(QStringLiteral("conceptId"))); - if (!concept.isEmpty()) - streamableConceptIds.insert(concept); - } - for (const QJsonValue &v : imagicConceptRows) { - if (!v.isObject()) - continue; - const QJsonObject row = v.toObject(); - const QString concept = ps5CloudConceptIdString(row.value(QStringLiteral("conceptId"))); - if (concept.isEmpty()) - continue; - const QStringList keys = { - gameProductId(row), - ps5CloudProductIdStableKey(gameProductId(row)) - }; - for (const QString &k : keys) { - if (!k.isEmpty() && productKeys.contains(k)) { - streamableConceptIds.insert(concept); - break; - } - } - } - } - - bool isStreamable(const QJsonObject &game) const - { - const QStringList ids = { - gameProductId(game), - game.value(QStringLiteral("storeProductId")).toString(), - gameEntitlementId(game) - }; - for (const QString &p : ids) { - if (p.isEmpty()) - continue; - if (productKeys.contains(p)) - return true; - const QString stable = ps5CloudProductIdStableKey(p); - if (!stable.isEmpty() && productKeys.contains(stable)) - return true; - } - const QString concept = ps5CloudConceptIdString(game.value(QStringLiteral("conceptId"))); - return !concept.isEmpty() && streamableConceptIds.contains(concept); - } - -private: - QSet productKeys; - QSet streamableConceptIds; -}; - -static QJsonArray applyStreamabilityGate(const QJsonArray &games, const StreamabilityIndex &index) -{ - QJsonArray kept; - int dropped = 0; - for (const QJsonValue &v : games) { - if (!v.isObject()) - continue; - const QJsonObject game = v.toObject(); - if (!game.value(QStringLiteral("isOwned")).toBool() || index.isStreamable(game)) - kept.append(game); - else - dropped++; - } - if (dropped > 0) - qInfo() << "[UNIFIED] streamability gate: dropped" << dropped << "owned non-streamable titles"; - return kept; -} - -static QJsonObject normalizeApolloGame(const QJsonObject &raw) -{ - QJsonObject g = raw; - if (!g.contains(QStringLiteral("productId"))) { - const QString id = g.value(QStringLiteral("id")).toString(); - if (!id.isEmpty()) - g.insert(QStringLiteral("productId"), id); - } - g.insert(QStringLiteral("serviceType"), QStringLiteral("psnow")); - return g; -} - -static void mergeImagicListIntoPs5Catalog(const QString &categoryList, - const QJsonDocument &doc, - QMap &gamesByConceptId, - QMap &plusLibrarySupplementByProductId, - QMap &productIdAliases, - int &totalGamesSeen) -{ - const bool plusCatalog = isPlusCatalogList(categoryList); - if (!doc.isArray()) - return; - - for (const QJsonValue &category : doc.array()) { - if (!category.isObject()) - continue; - const QJsonObject catObj = category.toObject(); - const QJsonArray games = catObj.value(QStringLiteral("games")).toArray(); - totalGamesSeen += games.size(); - for (const QJsonValue &game : games) { - if (!game.isObject()) - continue; - QJsonObject gameObj = game.toObject(); - // Accept both PS4 and PS5 cloud titles. The old PS5-only gate silently - // dropped PS4-only PS-Plus-catalog games (e.g. God of War 2018) before - // they could reach the library-stream supplement below. - if (!isCloudDeviceGame(gameObj)) - continue; - - // Subscription-catalog titles excluded from public cloud browse (library-stream - // candidates): streamingSupported=false but streamable once owned/acquired. Capture - // these from EVERY subscription list (plus-games, classics, ubisoft, monthly) so the - // Game Catalog includes them too — not just plus-games-list. - if (plusCatalog - && !gameObj.value(QStringLiteral("streamingSupported")).toBool()) { - const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - if (!productId.isEmpty()) { - gameObj.insert(QStringLiteral("plusCatalog"), true); - plusLibrarySupplementByProductId.insert(productId, gameObj); - } - continue; - } - - if (!isCloudStreamingGame(gameObj)) - continue; - - // Dedupe per game PER PLATFORM so cross-gen PS4/PS5 editions both appear. - const QString key = ps5CloudEditionKey(gameObj); - const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - if (key.isEmpty() || productId.isEmpty()) - continue; - - if (gamesByConceptId.contains(key)) { - QJsonObject existing = gamesByConceptId.value(key); - const QString canonicalProductId = existing.value(QStringLiteral("productId")).toString(); - if (!canonicalProductId.isEmpty() && productId != canonicalProductId - && !productIdAliases.contains(productId)) { - productIdAliases.insert(productId, canonicalProductId); - } - // Lists are fetched in parallel, so a title may be seen first via all-ps5-list - // (not subscription) and later via a subscription list. Upgrade the flag so the - // subscription membership wins regardless of arrival order. - if (plusCatalog && !existing.value(QStringLiteral("plusCatalog")).toBool()) { - existing.insert(QStringLiteral("plusCatalog"), true); - gamesByConceptId.insert(key, existing); - } - continue; - } - - gameObj.insert(QStringLiteral("plusCatalog"), plusCatalog); - gamesByConceptId.insert(key, gameObj); - } - } -} - -} // namespace - -void CloudCatalogBackend::assembleUnifiedCatalog(const QJsonArray &ownedCrossRef) -{ - // Routing source of truth for browse rows: an imagic PS5 row is streamed via cronos (pscloud) - // UNLESS the same product also appears in the Apollo (PS Now) catalog, in which case it is a - // Kamaji (psnow) title. Apollo membership is the authoritative psnow signal; everything else - // PS5 defaults to pscloud. (A/B testing proved the ghost cards are a separate merge-order bug, - // not caused by this stamping, so it is restored: it is required for correct Worms-style routing - // and PS5 badges.) - QJsonArray apolloNormalized; - QSet apolloProductIds; - for (const QJsonValue &v : unifiedState.apolloGames) { - if (v.isObject()) { - const QJsonObject g = normalizeApolloGame(v.toObject()); - apolloNormalized.append(g); - const QString pid = gameProductId(g); - if (!pid.isEmpty()) - apolloProductIds.insert(pid); - } - } - - QJsonArray ps5Browse; - for (const QJsonValue &v : unifiedState.imagicBrowse) { - if (!v.isObject() || !isPs5PlatformGame(v.toObject())) - continue; - QJsonObject g = v.toObject(); - // If this product is already in the Apollo (PS Now) catalog, that native row already - // represents it as a psnow/streamable title. Appending the imagic browse copy here emits a - // SECOND identical streamable row -- this happens for cross-gen titles that appear in BOTH - // the APOLLOROOT walk and the imagic PS5 list (e.g. Crow Country, Grandia, HUMANITY). Skip - // it: the Apollo row is authoritative for psnow titles. Non-Apollo PS5 titles (including - // cross-gen browse-only games like the indie bundles) still pass through below. - if (apolloProductIds.contains(gameProductId(g))) - continue; - const QString existing = g.value(QStringLiteral("serviceType")).toString().toLower(); - if (existing != QLatin1String("psnow") && existing != QLatin1String("pscloud")) { - // Not in Apollo (guaranteed by the skip above) -> imagic PS5 rows stream via cronos. - g.insert(QStringLiteral("serviceType"), QStringLiteral("pscloud")); - } - ps5Browse.append(g); - } - - QJsonArray universe = apolloNormalized; - for (const QJsonValue &v : ps5Browse) - universe.append(v); - - QJsonArray games = mergeOwnedIntoBrowseCatalog(universe, ownedCrossRef, true); - - if (unifiedState.nativeMode) { - QJsonArray conceptRows = unifiedState.imagicBrowse; - for (const QJsonValue &v : unifiedState.imagicSupplement) - conceptRows.append(v); - const StreamabilityIndex index(apolloNormalized, unifiedState.imagicBrowse, conceptRows); - games = applyStreamabilityGate(games, index); - } - - QJsonArray tagged; - for (const QJsonValue &v : games) { - if (!v.isObject()) - continue; - QJsonObject g = v.toObject(); - g.insert(QStringLiteral("category"), categoryForGame(g)); - tagged.append(g); - } - - QJsonObject payload; - payload.insert(QStringLiteral("games"), tagged); - payload.insert(QStringLiteral("total"), tagged.size()); - payload.insert(QStringLiteral("nativeMode"), unifiedState.nativeMode); - payload.insert(QStringLiteral("fallbackRegion"), unifiedState.fallbackRegion); - if (!unifiedState.warning.isEmpty()) - payload.insert(QStringLiteral("warning"), unifiedState.warning); - - if (!tagged.isEmpty() && !unifiedState.authError) - setCachedData(QStringLiteral("unified_catalog_v2"), QJsonDocument(payload)); - - QString message = QStringLiteral("Success"); - if (!unifiedState.warning.isEmpty()) - message = unifiedState.warning; - - finishUnifiedFetch(true, message, payload); -} - -void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) -{ - // Check cache first - QString cached = getCachedPs5CatalogV3(CACHE_DURATION_CATALOG); - if (!cached.isEmpty()) { - qInfo() << "[CACHE] Using cached PS5 cloud catalog"; - QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); - if (unifiedState.active && doc.isObject()) { - const QJsonObject obj = doc.object(); - unifiedState.imagicBrowse = obj.value(QStringLiteral("games")).toArray(); - unifiedState.imagicSupplement = obj.value(QStringLiteral("plusLibrarySupplement")).toArray(); - if (obj.contains(QStringLiteral("productIdAliases"))) - unifiedState.productIdAliases = - productIdAliasesFromJson(obj.value(QStringLiteral("productIdAliases")).toObject()); - startUnifiedOwnedCrossRef(); - return; - } - if (callback.isCallable()) { - callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); - } - return; - } - - qInfo() << "[API CALL] Fetching PS5 cloud catalog (6 imagic lists, cache miss or expired)"; - ps5State.callback = callback; - - // Build the store-locale fallback chain (session locale -> en-COUNTRY -> en-US) - // and start with the first tier. Tiers escalate only when a whole tier 404s. - ps5State.localeChain = - buildStoreLocaleFallbackChain(settings ? settings->GetCloudLanguagePSCloud() - : QStringLiteral("en-US")); - ps5State.localeTierIndex = 0; - startPs5ImagicListFetch(); -} - -void CloudCatalogBackend::startPs5ImagicListFetch() -{ - ps5State.activeLocale = ps5State.localeChain.value(ps5State.localeTierIndex, - QStringLiteral("en-US")); - const QString locale = ps5State.activeLocale.toLower(); // imagic wants "en-us" - - // Reset per-tier accumulators so a failed tier leaves nothing behind. - ps5State.gamesByConceptId.clear(); - ps5State.plusLibrarySupplementByProductId.clear(); - ps5State.productIdAliases.clear(); - ps5State.totalGamesSeen = 0; - ps5State.succeededListFetches = 0; - ps5State.allPs5ListSucceeded = false; - ps5State.failedLists.clear(); - ps5State.pendingListFetches = kPs5ImagicCategoryLists.size(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "[API CALL] PS5 imagic fetch using locale tier" << ps5State.localeTierIndex - << ":" << ps5State.activeLocale; - } - - for (const QString &categoryList : kPs5ImagicCategoryLists) { - const QString url = QStringLiteral( - "https://www.playstation.com/bin/imagic/gameslist?locale=%1&categoryList=%2") - .arg(locale, categoryList); - - QNetworkRequest request{QUrl(url)}; - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "application/json"); - request.setRawHeader("User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); - - QNetworkReply *reply = networkManager->get(request); - reply->setProperty("imagicCategoryList", categoryList); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePs5ImagicListResponse); - } -} - -void CloudCatalogBackend::handlePs5ImagicListResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) - return; - - const QString categoryList = reply->property("imagicCategoryList").toString(); - const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const bool networkError = reply->error() != QNetworkReply::NoError; - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: PS5 imagic list ==="; - qInfo() << " Category:" << categoryList; - qInfo() << " Status:" << statusCode; - } - - const QString errorString = reply->errorString(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - if (networkError || statusCode != 200) { - qWarning() << "PS5 imagic list fetch failed:" << categoryList - << (networkError ? errorString : QString("HTTP %1").arg(statusCode)); - ps5State.failedLists.append(categoryList); - } else { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isArray()) { - qWarning() << "PS5 imagic list invalid JSON:" << categoryList; - ps5State.failedLists.append(categoryList); - } else { - ps5State.succeededListFetches++; - if (categoryList == QLatin1String("all-ps5-list")) - ps5State.allPs5ListSucceeded = true; - mergeImagicListIntoPs5Catalog(categoryList, doc, ps5State.gamesByConceptId, - ps5State.plusLibrarySupplementByProductId, - ps5State.productIdAliases, - ps5State.totalGamesSeen); - } - } - - ps5State.pendingListFetches--; - if (ps5State.pendingListFetches <= 0) { - if (ps5State.succeededListFetches <= 0) { - // The whole tier failed (typically a 404 for an unsupported store - // locale such as hu-HU). Escalate to the next locale tier before - // giving up, so regions Sony only serves in English still load. - if (ps5State.localeTierIndex + 1 < ps5State.localeChain.size()) { - ps5State.localeTierIndex++; - qWarning() << "[API] All imagic lists failed for locale" - << ps5State.activeLocale << "- retrying with" - << ps5State.localeChain.value(ps5State.localeTierIndex); - startPs5ImagicListFetch(); - return; - } - if (unifiedState.active) { - if (unifiedState.apolloGames.isEmpty()) { - finishUnifiedFetch(false, QStringLiteral("Failed to fetch catalog")); - } else { - qWarning() << "[UNIFIED] imagic PS5 catalog fetch failed; continuing with PS Now only"; - unifiedState.imagicBrowse = QJsonArray(); - unifiedState.imagicSupplement = QJsonArray(); - unifiedState.productIdAliases.clear(); - startUnifiedOwnedCrossRef(); - } - } else if (ps5State.callback.isCallable()) { - ps5State.callback.call({false, - QStringLiteral("All imagic lists failed to load"), - QJSValue()}); - } - } else { - finalizePs5CloudCatalogFetch(); - } - } -} - -void CloudCatalogBackend::finalizePs5CloudCatalogFetch() -{ - QJsonArray allGames; - for (QJsonObject gameObj : ps5State.gamesByConceptId) { - if (!gameObj.contains(QStringLiteral("imageUrl")) - || gameObj.value(QStringLiteral("imageUrl")).toString().isEmpty()) { - const QString coverImageUrl = extractCoverImageFromGameObject(gameObj); - if (!coverImageUrl.isEmpty()) - gameObj.insert(QStringLiteral("imageUrl"), coverImageUrl); - } - allGames.append(gameObj); - } - - QJsonArray plusSupplementGames; - for (QJsonObject gameObj : ps5State.plusLibrarySupplementByProductId) { - if (!gameObj.contains(QStringLiteral("imageUrl")) - || gameObj.value(QStringLiteral("imageUrl")).toString().isEmpty()) { - const QString coverImageUrl = extractCoverImageFromGameObject(gameObj); - if (!coverImageUrl.isEmpty()) - gameObj.insert(QStringLiteral("imageUrl"), coverImageUrl); - } - plusSupplementGames.append(gameObj); - } - - if (settings && settings->GetLogVerbose()) { - qInfo() << " Imagic rows scanned:" << ps5State.totalGamesSeen; - qInfo() << " PS5 streaming games (deduped by conceptId):" << allGames.size(); - qInfo() << " Plus library-stream supplement (stream=false):" << plusSupplementGames.size(); - qInfo() << " Product ID aliases (same conceptId):" << ps5State.productIdAliases.size(); - } - - // Persist the locale that actually worked so game-details fetches and the - // cache locale check all agree on it (e.g. a hu-HU account settles on en-HU). - const QString workingLocale = !ps5State.activeLocale.isEmpty() - ? ps5State.activeLocale - : (settings ? settings->GetCloudLanguagePSCloud() - : QStringLiteral("en-US")); - if (settings && settings->GetCloudLanguagePSCloud() != workingLocale) { - qInfo() << "[PSCLOUD] Store locale settled on" << workingLocale - << "(was" << settings->GetCloudLanguagePSCloud() << ")"; - settings->SetCloudLanguagePSCloud(workingLocale); - } - - QJsonObject result; - result.insert(QStringLiteral("locale"), workingLocale); - result[QStringLiteral("games")] = allGames; - result[QStringLiteral("total")] = allGames.size(); - result[QStringLiteral("plusLibrarySupplement")] = plusSupplementGames; - if (!ps5State.productIdAliases.isEmpty()) - result[QStringLiteral("productIdAliases")] = productIdAliasesToJson(ps5State.productIdAliases); - - const QJsonDocument resultDoc(result); - - if (ps5State.allPs5ListSucceeded) - setCachedData(QStringLiteral("ps5_cloud_catalog_v6"), resultDoc); - - QString callbackMessage = QStringLiteral("Success"); - if (!ps5State.failedLists.isEmpty()) { - callbackMessage = QStringLiteral("Some catalog lists failed to load (%1). Catalog may be incomplete.") - .arg(ps5State.failedLists.join(QStringLiteral(", "))); - qWarning() << "[API]" << callbackMessage; - } - - if (unifiedState.active) { - unifiedState.imagicBrowse = allGames; - unifiedState.imagicSupplement = plusSupplementGames; - unifiedState.productIdAliases = ps5State.productIdAliases; - startUnifiedOwnedCrossRef(); - emit catalogUpdated(); - return; - } - - if (crossReferenceState.callback.isCallable() && !crossReferenceState.catalogFetched) { - crossReferenceState.cloudCatalogGames = allGames; - crossReferenceState.plusLibrarySupplement = plusSupplementGames; - crossReferenceState.productIdAliases = ps5State.productIdAliases; - crossReferenceState.catalogFetched = true; - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Fetched PS5 cloud catalog from API:" << allGames.size() << "games"; - } - if (crossReferenceState.catalogFetched && crossReferenceState.ownedGamesFetched) { - processCrossReferenceComplete(); - } - } - - if (ps5State.callback.isCallable()) { - const QString jsonStr = QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)); - ps5State.callback.call({true, callbackMessage, QJSValue(jsonStr)}); - } - - emit catalogUpdated(); -} - -void CloudCatalogBackend::fetchOwnedPs5Games(const QJSValue &callback) -{ - // Check NPSSO token first - fail immediately if not present - QString npsso = getNpSsoToken(); - if (npsso.isEmpty()) { - QString errorMsg = "NPSSO token is required for PS5 cloud play. Please login to PSN and enter a valid NPSSO token. You also need a valid PS Plus subscription."; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (callback.isCallable()) { - callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // Check cache first - QString cached = getCachedData("ps5_cloud_library", CACHE_DURATION_CATALOG); - if (!cached.isEmpty()) { - qInfo() << "[CACHE] Using cached PS5 cloud library (skipping API calls)"; - QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); - if (callback.isCallable()) { - callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); - } - return; - } - - qInfo() << "[API CALL] Fetching PS5 cloud library from API (cache miss or expired)"; - ownedGamesState.callback = callback; - - // Clear any existing OAuth token to ensure we fetch a fresh one - ownedGamesState.oauthToken.clear(); - - // First, get OAuth token for entitlements API - fetchOwnedGamesOAuthToken(); -} - -void CloudCatalogBackend::fetchOwnedGamesOAuthToken() -{ - // NPSSO token should already be checked in fetchOwnedPs5Games, but double-check here for safety - QString npsso = getNpSsoToken(); - if (npsso.isEmpty()) { - QString errorMsg = "NPSSO token is required for PS5 cloud play. Please login to PSN and enter a valid NPSSO token. You also need a valid PS Plus subscription."; - qWarning() << "CloudCatalogBackend:" << errorMsg; - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: Fetching OAuth token for owned games ==="; - } - - // Get OAuth token for entitlements API - QString url = CloudConfig::ACCOUNT_BASE + "/v1/oauth/authorize"; - QUrlQuery query; - query.addQueryItem("response_type", "token"); - query.addQueryItem("scope", "kamaji:get_internal_entitlements user:account.attributes.validate"); - query.addQueryItem("client_id", "dc523cc2-b51b-4190-bff0-3397c06871b3"); - query.addQueryItem("redirect_uri", KamajiConsts::REDIRECT_URI); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - - QUrl fullUrl(url); - fullUrl.setQuery(query); - - if (settings && settings->GetLogVerbose()) { - qInfo() << " URL:" << fullUrl.toString(); - qInfo() << " Method: GET"; - } - - QNetworkRequest request{fullUrl}; - request.setRawHeader("Cookie", QString("npsso=%1").arg(npsso).toUtf8()); - request.setRawHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - - QNetworkReply *reply = networkManager->get(request); - connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handleOwnedGamesOAuthResponse); -} - -void CloudCatalogBackend::handleOwnedGamesOAuthResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: OAuth Token Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - } - - reply->deleteLater(); - - if (statusCode != 302) { - QString errorMsg = QString("OAuth request failed: Expected 302, got %1").arg(statusCode); - qWarning() << "CloudCatalogBackend:" << errorMsg; - // Clear OAuth token on failure to prevent reuse of invalid token - ownedGamesState.oauthToken.clear(); - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // OAuth flow returns 302 redirect with token in Location header - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - if (redirectUrl.isEmpty()) { - QByteArray locationHeader = reply->rawHeader("Location"); - if (!locationHeader.isEmpty()) { - redirectUrl = QUrl::fromEncoded(locationHeader); - } - } - - if (redirectUrl.isEmpty()) { - qWarning() << "CloudCatalogBackend: No redirect URL in OAuth response"; - // Clear OAuth token on failure to prevent reuse of invalid token - ownedGamesState.oauthToken.clear(); - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, "OAuth redirect not received", QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, "OAuth redirect not received", QJSValue()}); - } - return; - } - - if (settings && settings->GetLogVerbose()) { - qInfo() << " Redirect URL:" << redirectUrl.toString(); - } - - // Check for errors in the redirect URL (both query and fragment) - QUrlQuery query = QUrlQuery(redirectUrl.query()); - QString errorParam = query.queryItemValue("error"); - QString errorDescription = query.queryItemValue("error_description"); - - // Also check fragment for errors - QString fragment = redirectUrl.fragment(); - if (errorParam.isEmpty() && fragment.contains("error=")) { - QRegularExpression errorRe("error=([^&]+)"); - QRegularExpressionMatch errorMatch = errorRe.match(fragment); - if (errorMatch.hasMatch()) { - errorParam = errorMatch.captured(1); - } - } - - // If there's an error, show a user-friendly message - if (!errorParam.isEmpty()) { - QString errorMsg; - if (errorParam == "login_required" || errorParam.contains("login", Qt::CaseInsensitive)) { - errorMsg = "Authentication failed. Please login to PSN and enter a valid NPSSO token. You also need a valid PS Plus subscription to access cloud play."; - } else { - errorMsg = QString("OAuth authentication failed: %1").arg(errorDescription.isEmpty() ? errorParam : errorDescription); - } - qWarning() << "CloudCatalogBackend: OAuth error:" << errorParam << errorDescription; - // Clear OAuth token on failure to prevent reuse of invalid token - ownedGamesState.oauthToken.clear(); - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - // Extract access_token from fragment - QRegularExpression re("access_token=([^&]+)"); - QRegularExpressionMatch match = re.match(fragment); - if (match.hasMatch()) { - ownedGamesState.oauthToken = match.captured(1); - - if (settings && settings->GetLogVerbose()) { - qInfo() << " Extracted access token:" << ownedGamesState.oauthToken.left(20) << "..."; - } - - // Apply 100ms cooldown before fetching owned games (after OAuth) - QTimer::singleShot(100, this, [this]() { - // Reset pagination state - ownedGamesState.accumulatedEntitlements = QJsonArray(); - ownedGamesState.currentStart = 0; - - // Start fetching first page - fetchOwnedGamesPage(); - }); - } else { - // Check if the redirect URL itself indicates an error - QString redirectStr = redirectUrl.toString(); - if (redirectStr.contains("error=", Qt::CaseInsensitive)) { - QString errorMsg = "Authentication failed. Please login to PSN and enter a valid NPSSO token. You also need a valid PS Plus subscription to access cloud play."; - qWarning() << "CloudCatalogBackend: OAuth error in redirect URL"; - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - } else { - qWarning() << "CloudCatalogBackend: Could not extract access token from fragment:" << fragment; - QString errorMsg = "Could not extract access token from OAuth response. Please ensure you have logged in to PSN and entered a valid NPSSO token, and that you have a valid PS Plus subscription."; - // Clear OAuth token on failure to prevent reuse of invalid token - ownedGamesState.oauthToken.clear(); - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - } - } -} - -void CloudCatalogBackend::fetchOwnedGamesPage() -{ - QString url = "https://commerce.api.np.km.playstation.net/commerce/api/v1/users/me/internal_entitlements"; - QUrlQuery query; - query.addQueryItem("fields", "game_meta"); - query.addQueryItem("entitlement_type", "5"); - query.addQueryItem("start", QString::number(ownedGamesState.currentStart)); - query.addQueryItem("size", QString::number(OwnedGamesState::PAGE_SIZE)); - - QUrl fullUrl(url); - fullUrl.setQuery(query); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: Fetching owned games (page) ==="; - qInfo() << " URL:" << fullUrl.toString(); - qInfo() << " Start:" << ownedGamesState.currentStart << "Size:" << OwnedGamesState::PAGE_SIZE; - qInfo() << " Method: GET"; - } - - QNetworkRequest request{fullUrl}; - request.setRawHeader("Authorization", QString("Bearer %1").arg(ownedGamesState.oauthToken).toUtf8()); - request.setRawHeader("Accept", "application/json"); - - QNetworkReply *gamesReply = networkManager->get(request); - connect(gamesReply, &QNetworkReply::finished, this, &CloudCatalogBackend::handleOwnedGamesResponse); -} - -void CloudCatalogBackend::handleOwnedGamesResponse() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) return; - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: Owned Games Response ==="; - qInfo() << " Status:" << statusCode; - } - - reply->deleteLater(); - - // Check for authentication errors (401, 403) - if (statusCode == 401 || statusCode == 403) { - QString errorMsg = "Authentication failed. Please login to PSN and enter a valid NPSSO token. You also need a valid PS Plus subscription to access cloud play."; - qWarning() << "CloudCatalogBackend: Authentication error (HTTP" << statusCode << ")"; - // Clear OAuth token on authentication failure - token is invalid/expired - ownedGamesState.oauthToken.clear(); - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, errorMsg, QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, errorMsg, QJSValue()}); - } - return; - } - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Owned games fetch error:" << reply->errorString(); - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, reply->errorString(), QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, reply->errorString(), QJSValue()}); - } - return; - } - - QByteArray data = reply->readAll(); - QJsonDocument doc = QJsonDocument::fromJson(data); - - if (!doc.isObject()) { - if (ownedGamesState.callback.isCallable()) { - ownedGamesState.callback.call({false, "Invalid response format", QJSValue()}); - } else if (crossReferenceState.callback.isCallable()) { - crossReferenceState.callback.call({false, "Invalid response format", QJSValue()}); - } - return; - } - - QJsonObject obj = doc.object(); + // Sanitize key for filename (replace invalid chars) + QString safeKey = key; + safeKey.replace("/", "_"); + safeKey.replace("\\", "_"); + safeKey.replace(":", "_"); + return cacheDirectory + "/" + safeKey + ".json"; +} + +QString CloudCatalogBackend::getCachedData(const QString &key, int maxAge) +{ + QString filePath = getCacheFilePath(key); + QFileInfo fileInfo(filePath); - // Get entitlements from this page - QJsonArray pageEntitlements; - if (obj.contains("entitlements") && obj["entitlements"].isArray()) { - pageEntitlements = obj["entitlements"].toArray(); + if (!fileInfo.exists()) { + qInfo() << "[CACHE MISS] No cache file found for:" << key; + return QString(); } - if (settings && settings->GetLogVerbose()) { - qInfo() << " Page entitlements:" << pageEntitlements.size(); - qInfo() << " Accumulated so far:" << ownedGamesState.accumulatedEntitlements.size(); + // Check file age + qint64 age = fileInfo.lastModified().msecsTo(QDateTime::currentDateTime()); + if (age > maxAge) { + // Cache expired, delete file + QFile::remove(filePath); + qInfo() << "[CACHE EXPIRED] Cache file expired for:" << key << "(age:" << (age / 1000) << "seconds, max:" << (maxAge / 1000) << "seconds)"; + return QString(); } - // Accumulate entitlements from this page - for (const QJsonValue &ent : pageEntitlements) { - ownedGamesState.accumulatedEntitlements.append(ent); + // Read file + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "[CACHE ERROR] Failed to open cache file:" << filePath; + return QString(); } - // Check if we need to fetch more pages (got a full page means more may exist) - if (pageEntitlements.size() >= OwnedGamesState::PAGE_SIZE) { - ownedGamesState.currentStart += pageEntitlements.size(); - if (settings && settings->GetLogVerbose()) { - qInfo() << " More pages to fetch... scheduling next page"; - } - // Apply 100ms cooldown between page requests to avoid rate limiting - QTimer::singleShot(100, this, &CloudCatalogBackend::fetchOwnedGamesPage); - return; - } + QByteArray data = file.readAll(); + file.close(); - // All pages fetched, process the accumulated results - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== CloudCatalogBackend: All owned games pages fetched ==="; - qInfo() << " Total accumulated entitlements:" << ownedGamesState.accumulatedEntitlements.size(); - } + qint64 ageSeconds = age / 1000; + qInfo() << "[CACHE HIT] Loaded cached data for:" << key << "(" << (data.size() / 1024) << "KB, age:" << ageSeconds << "seconds)"; - // Filter for PS5 games (package_type=PSGD) - QJsonArray ps5Games = filterOwnedPs5Games(ownedGamesState.accumulatedEntitlements); + return QString::fromUtf8(data); +} - // Map each bundle product_id -> the entitlement ids that share it, so a bundle (e.g. RE7 Gold, - // whose components each carry the bundle product_id but a distinct entitlement id) can expand to - // its component games during cross-reference (upstream PR #15's bundle-sibling matching). - QMap componentIds; - for (const QJsonValue &ent : ownedGamesState.accumulatedEntitlements) { - if (!ent.isObject()) - continue; - const QJsonObject o = ent.toObject(); - const QString pid = o.value(QStringLiteral("product_id")).toString(); - const QString eid = o.value(QStringLiteral("id")).toString(); - if (!pid.isEmpty() && !eid.isEmpty()) - componentIds[pid].append(eid); - } +QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) +{ + const QString cached = getCachedData(QStringLiteral("ps5_cloud_catalog_v6"), maxAge); + if (cached.isEmpty()) + return QString(); - if (settings && settings->GetLogVerbose()) { - qInfo() << " PS5 games (PSGD):" << ps5Games.size(); + const QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); + if (!doc.isObject()) { + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); + return QString(); } - QJsonObject result; - result["games"] = ps5Games; - result["total"] = ps5Games.size(); - QJsonObject componentObj; - for (auto it = componentIds.cbegin(); it != componentIds.cend(); ++it) - componentObj.insert(it.key(), QJsonArray::fromStringList(it.value())); - result[QStringLiteral("componentIdsByProductId")] = componentObj; - - QJsonDocument resultDoc(result); - - // Cache the result - setCachedData("ps5_cloud_library", resultDoc); - - // If cross-reference is active, populate its state - if ((crossReferenceState.callback.isCallable() || crossReferenceState.unifiedMode) - && !crossReferenceState.ownedGamesFetched) { - crossReferenceState.ownedGames = ps5Games; - crossReferenceState.componentIdsByProductId = componentIds; - crossReferenceState.ownedGamesFetched = true; - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Fetched owned PS5 games from API:" << ps5Games.size() << "games"; - } - // Check if both are fetched now - if (crossReferenceState.catalogFetched && crossReferenceState.ownedGamesFetched) { - processCrossReferenceComplete(); - } - } - - // Call callback - if (ownedGamesState.callback.isCallable()) { - QString jsonStr = QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)); - ownedGamesState.callback.call({true, "Success", QJSValue(jsonStr)}); + const QString expectedLocale = settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US"); + const QString cachedLocale = doc.object().value(QStringLiteral("locale")).toString(); + if (!cachedLocale.isEmpty() && cachedLocale != expectedLocale) { + qInfo() << "[CACHE LOCALE MISMATCH] PS5 catalog v3 locale" << cachedLocale + << "!=" << expectedLocale << ", refetching"; + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); + return QString(); } + + return cached; } -QJsonArray CloudCatalogBackend::filterOwnedPs5Games(const QJsonArray &entitlements) +void CloudCatalogBackend::setCachedData(const QString &key, const QJsonDocument &data) { - QJsonArray ps5Games; + QString filePath = getCacheFilePath(key); - for (const QJsonValue &ent : entitlements) { - if (!ent.isObject()) - continue; - QJsonObject entObj = ent.toObject(); - - // Must look like a game entitlement (has game_meta). - if (!entObj.contains("game_meta") || !entObj["game_meta"].isObject()) - continue; - QJsonObject gameMeta = entObj["game_meta"].toObject(); - const QString packageType = gameMeta["package_type"].toString(); - - // Skip inactive entitlements (active_flag must be true). - const bool activeFlag = entObj.contains("active_flag") && entObj["active_flag"].toBool(); - if (!activeFlag) - continue; - - // Skip subscriptions/services (Product IDs starting with IP or SUB). - const QString productId = entObj["product_id"].toString(); - if (productId.startsWith("IP") || productId.startsWith("SUB")) - continue; - - // Hide EXTRAS: feature_type==0 is DLC / add-ons / themes / avatars / season passes / cross-buy - // "tracks" (PS4MISC/PSAL/PSTRACK/PS4AC/...), NEVER a base game -- every *GD game package is - // feature_type 1 (trial / free-to-play) or 3/5 (full game). Dropping ft==0 keeps add-ons from - // cluttering the library or marking a game "owned" via DLC, and is safe (it can't hide a game). - // Trials/free (ft1) and full games (ft3/5) are KEPT; the trial-vs-full split is handled when - // merging owned games into the catalog (a trial stays its own card so the full version can - // still show "Add Game"). - if (entObj.value(QStringLiteral("feature_type")).toInt() == 0) - continue; - - // Previously this required package_type == "PSGD" (PS5 only), which dropped - // owned PS4 titles (e.g. God of War 2018) and PS3 titles. We now accept every - // active game entitlement; streamability is enforced downstream by the catalog - // cross-reference (only titles present in the streamable catalog/supplement are - // shown), and matches are deduped by conceptId, so non-streamable or add-on - // entitlements are harmlessly dropped there. - const QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; - if (settings && settings->GetLogVerbose()) { - qInfo() << " Owned entitlement:" << gameName - << "package_type:" << (packageType.isEmpty() ? QStringLiteral("(none)") : packageType) - << "product_id:" << productId; - } - - // Extract cover image from game_meta.icon_url (the primary field for the entitlements API). - QString coverImageUrl; - if (gameMeta.contains("icon_url")) { - coverImageUrl = gameMeta["icon_url"].toString(); - } - if (coverImageUrl.isEmpty()) { - coverImageUrl = extractCoverImageFromGameObject(gameMeta); - } - if (coverImageUrl.isEmpty()) { - coverImageUrl = extractCoverImageFromGameObject(entObj); - } - // Additional fallbacks for other common image field names. - if (coverImageUrl.isEmpty()) { - if (gameMeta.contains("imageUrl")) { - coverImageUrl = gameMeta["imageUrl"].toString(); - } else if (gameMeta.contains("image_url")) { - coverImageUrl = gameMeta["image_url"].toString(); - } else if (gameMeta.contains("thumbnail_url")) { - coverImageUrl = gameMeta["thumbnail_url"].toString(); - } else if (entObj.contains("imageUrl")) { - coverImageUrl = entObj["imageUrl"].toString(); - } else if (entObj.contains("image_url")) { - coverImageUrl = entObj["image_url"].toString(); - } else if (entObj.contains("thumbnail_url")) { - coverImageUrl = entObj["thumbnail_url"].toString(); - } - } - if (!coverImageUrl.isEmpty()) { - entObj["imageUrl"] = coverImageUrl; - } - - // SINGLE INGESTION GATE for an owned entitlement's stream backend. Sony's raw entitlement - // JSON carries its OWN numeric `serviceType` (e.g. 0) that is unrelated to our routing and - // collides with our string field name, so strip it here first. Then set OUR canonical - // serviceType from PSN's structured platform_id (entitlement_attributes[].platform_id): - // ps5 -> pscloud (cronos), ps4/ps3 -> psnow (Kamaji). NOT from a CUSA/PPSA id prefix: a - // cross-buy PS4 license can carry a PS5-looking product_id wrapper (Blood Omen's PS4 license - // UP8489-CUSA49771_00-... has product_id ...PPSA24270...), which the prefix would mis-route. - // If platform_id is absent we leave serviceType unset and let downstream fall back to the - // clean catalog product-id token (non-owned imagic rows only). - sanitizeOwnedEntitlementServiceType(entObj); - - ps5Games.append(entObj); + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + qWarning() << "[CACHE ERROR] Failed to write cache file:" << filePath; + return; } + + QByteArray jsonData = data.toJson(QJsonDocument::Compact); + file.write(jsonData); + file.close(); + + qInfo() << "[CACHE SAVED] Cached data for:" << key << "(" << (jsonData.size() / 1024) << "KB)"; +} - return ps5Games; +QString CloudCatalogBackend::getNpSsoToken() +{ + // Get NPSSO token from settings (saved during login) + return settings->GetNpssoToken(); } -void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) +void CloudCatalogBackend::fetchUnifiedCatalog(const QJSValue &callback) { - // This method cross-references owned PS5 games with the cloud catalog - // First fetch both catalogs (checking cache first), then match by product_id - - // Initialize cross-reference state - crossReferenceState.callback = callback; - crossReferenceState.cloudCatalogGames = QJsonArray(); - crossReferenceState.plusLibrarySupplement = QJsonArray(); - crossReferenceState.ownedGames = QJsonArray(); - crossReferenceState.productIdAliases.clear(); - crossReferenceState.componentIdsByProductId.clear(); - crossReferenceState.catalogFetched = false; - crossReferenceState.ownedGamesFetched = false; - - // Check cache for both catalogs first - QString cachedCatalog = getCachedPs5CatalogV3(CACHE_DURATION_CATALOG); - - QString cachedOwned = getCachedData("ps5_cloud_library", CACHE_DURATION_CATALOG); - - bool catalogFromCache = !cachedCatalog.isEmpty(); - bool ownedFromCache = !cachedOwned.isEmpty(); - - if (catalogFromCache) { - // Parse cached catalog - QJsonDocument doc = QJsonDocument::fromJson(cachedCatalog.toUtf8()); - if (doc.isObject()) { - QJsonObject obj = doc.object(); - if (obj.contains("games") && obj["games"].isArray()) { - crossReferenceState.cloudCatalogGames = obj["games"].toArray(); - crossReferenceState.catalogFetched = true; - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Loaded PS5 cloud catalog from cache:" << crossReferenceState.cloudCatalogGames.size() << "games"; - } - } - if (obj.contains(QStringLiteral("plusLibrarySupplement")) - && obj.value(QStringLiteral("plusLibrarySupplement")).isArray()) { - crossReferenceState.plusLibrarySupplement = - obj.value(QStringLiteral("plusLibrarySupplement")).toArray(); - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Loaded Plus library supplement from cache:" - << crossReferenceState.plusLibrarySupplement.size() << "games"; - } - } - if (obj.contains(QStringLiteral("productIdAliases")) - && obj.value(QStringLiteral("productIdAliases")).isObject()) { - crossReferenceState.productIdAliases = - productIdAliasesFromJson(obj.value(QStringLiteral("productIdAliases")).toObject()); - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Loaded product ID aliases from cache:" - << crossReferenceState.productIdAliases.size(); - } - } - } - } - - if (ownedFromCache) { - // Parse cached owned games - QJsonDocument doc = QJsonDocument::fromJson(cachedOwned.toUtf8()); - if (doc.isObject()) { - QJsonObject obj = doc.object(); - if (obj.contains("games") && obj["games"].isArray()) { - crossReferenceState.ownedGames = obj["games"].toArray(); - crossReferenceState.ownedGamesFetched = true; - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Loaded owned PS5 games from cache:" << crossReferenceState.ownedGames.size() << "games"; - } - } - // Bundle->components map for bundle-sibling matching (upstream PR #15). - if (obj.contains(QStringLiteral("componentIdsByProductId")) - && obj.value(QStringLiteral("componentIdsByProductId")).isObject()) { - const QJsonObject m = obj.value(QStringLiteral("componentIdsByProductId")).toObject(); - crossReferenceState.componentIdsByProductId.clear(); - for (auto it = m.begin(); it != m.end(); ++it) { - QStringList ids; - for (const QJsonValue &v : it.value().toArray()) - ids.append(v.toString()); - crossReferenceState.componentIdsByProductId.insert(it.key(), ids); - } - } - } - } + // Single source of truth: libchiaki owns the entire fetch/merge/cross-reference/ + // assemble pipeline and every cache file under cacheDirectory. This client does ZERO + // catalog derivation -- it forwards npsso/locale/cache_dir and hands the returned + // display-and-stream-ready JSON envelope straight to QML (see chiaki/cloudcatalog.h). + QJSValue cb = callback; - // If we have both from cache, process immediately - if (catalogFromCache && ownedFromCache) { - processCrossReferenceComplete(); + // Serialize: a second concurrent fetch would race the same cache files. Instead + // of rejecting the overlap (which surfaced a spurious "fetch already in progress" + // error when navigating back to the catalog mid-fetch), coalesce it: park this + // caller's callback and resolve it with the SAME result when the running fetch + // finishes. No duplicate fetch, no error toast. (GUI-thread only: this method is + // Q_INVOKABLE from QML and the completion handler is a queued call on this object.) + bool expected = false; + if (!unifiedFetchInFlight.compare_exchange_strong(expected, true)) { + if (cb.isCallable()) + pendingUnifiedCallbacks.push_back(cb); return; } - - // Fetch missing data - use existing methods but they will populate cross-reference state - // via modified response handlers - if (!catalogFromCache) { - // Use empty callback - handler will check cross-reference state - fetchPs5CloudCatalog(QJSValue()); - } - - if (!ownedFromCache) { - // Use empty callback - handler will check cross-reference state - fetchOwnedPs5Games(QJSValue()); - } + + const QByteArray npsso = getNpSsoToken().toUtf8(); + const QByteArray locale = + (settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US")).toUtf8(); + const QByteArray cacheDir = cacheDirectory.toUtf8(); + + std::thread([this, cb, npsso, locale, cacheDir]() mutable { + ChiakiLog log; + chiaki_log_init(&log, CHIAKI_LOG_INFO | CHIAKI_LOG_WARNING | CHIAKI_LOG_ERROR, + chiaki_log_cb_print, nullptr); + + ChiakiCloudCatalogConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.npsso = npsso.constData(); + cfg.locale = locale.constData(); + cfg.cache_dir = cacheDir.constData(); + cfg.force_refresh = false; + + ChiakiCloudCatalogResult res; + ChiakiErrorCode err = chiaki_cloudcatalog_fetch_unified(&cfg, &res, &log); + const bool success = (err == CHIAKI_ERR_SUCCESS && res.json); + const QString json = res.json ? QString::fromUtf8(res.json) : QString(); + const QString message = success + ? QStringLiteral("Success") + : QString::fromUtf8(res.error_message ? res.error_message : "Failed to fetch cloud catalog"); + chiaki_cloudcatalog_result_fini(&res); + + // QJSValue must be invoked on the engine (main) thread; the queued call is + // discarded automatically if `this` is destroyed first. The in-flight flag is + // cleared here (on the GUI thread) AFTER draining any callbacks that were + // coalesced while this fetch ran, so they all receive the same result. + QMetaObject::invokeMethod(this, [this, cb, success, message, json]() mutable { + std::vector parked; + parked.swap(pendingUnifiedCallbacks); + unifiedFetchInFlight.store(false); + + // Persist the locale the lib actually settled on (region detection now lives + // entirely in libchiaki: it re-bases the locale on the account's Kamaji-session + // country and resolves the imagic store-locale chain, returning "settledLocale"). + // Mirrors iOS noteSettledLocale / Android noteCloudLanguageSettled. Uses the core + // Settings setter (NOT QmlSettings), so it does NOT invalidate the cache the lib + // just wrote; otherwise an international account would thrash the catalog. + if (success && settings) { + const QString settled = QJsonDocument::fromJson(json.toUtf8()) + .object().value(QStringLiteral("settledLocale")).toString(); + if (!settled.isEmpty() && settled != settings->GetCloudLanguagePSCloud()) + settings->SetCloudLanguagePSCloud(settled); + } + + const QJSValue payload = success ? QJSValue(json) : QJSValue(); + if (cb.isCallable()) + cb.call({ success, message, payload }); + for (QJSValue &pcb : parked) + if (pcb.isCallable()) + pcb.call({ success, message, payload }); + }, Qt::QueuedConnection); + }).detach(); } void CloudCatalogBackend::fetchGameDetails(const QString &productId, const QJSValue &callback) @@ -3172,52 +379,6 @@ void CloudCatalogBackend::handleGameDetailsResponse() } } -QString CloudCatalogBackend::extractCoverImageFromGameObject(const QJsonObject &gameObj) -{ - // Check for images array in the game object - if (gameObj.contains("images") && gameObj["images"].isArray()) { - QJsonArray imagesArray = gameObj["images"].toArray(); - - // Prefer cover (type 10) over landscape (type 12/13) - for (const QJsonValue &img : imagesArray) { - if (img.isObject()) { - QJsonObject imgObj = img.toObject(); - int type = imgObj["type"].toInt(); - QString url = imgObj["url"].toString(); - - // Type 10 = cover/box art (preferred) - if (type == 10 && !url.isEmpty()) { - return url; - } - } - } - - // Fallback to landscape if no cover - for (const QJsonValue &img : imagesArray) { - if (img.isObject()) { - QJsonObject imgObj = img.toObject(); - int type = imgObj["type"].toInt(); - QString url = imgObj["url"].toString(); - - // Type 12 = landscape 1080p or Type 13 = landscape 720p - if ((type == 12 || type == 13) && !url.isEmpty()) { - return url; - } - } - } - } - - // Check for direct imageUrl field - if (gameObj.contains("imageUrl")) { - QString imageUrl = gameObj["imageUrl"].toString(); - if (!imageUrl.isEmpty()) { - return imageUrl; - } - } - - return QString(); -} - QJsonObject CloudCatalogBackend::extractGameImages(const QJsonObject &gameData) { QJsonObject images; @@ -3263,7 +424,6 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi // Determine cache file based on service type QString cacheKey; - bool isPsCloudLibrary = false; QString productIdForCatalog; // For PSCloud: productId to use in catalog lookup if (serviceType.toLower() == "psnow") { @@ -3340,7 +500,6 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi // Fallback to catalog (may not have landscape images) cacheKey = "ps5_cloud_catalog_v6"; - isPsCloudLibrary = false; } else { qWarning() << "getGameLandscapeImage: Unknown service type:" << serviceType; return QString(); @@ -3475,386 +634,6 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi return QString(); } -void CloudCatalogBackend::clearCache() -{ - // Clear all cache files - QDir dir(cacheDirectory); - if (dir.exists()) { - QStringList filters; - filters << "*.json"; - QFileInfoList files = dir.entryInfoList(filters, QDir::Files); - for (const QFileInfo &fileInfo : files) { - QFile::remove(fileInfo.absoluteFilePath()); - } - if (settings && settings->GetLogVerbose()) { - qInfo() << "Cleared cache directory:" << cacheDirectory; - } - } -} - -void CloudCatalogBackend::processCrossReferenceComplete() -{ - // Cross-reference owned games with browse catalog + Plus library-stream supplement - QMap cloudCatalogMap; - QMap plusSupplementMap; - - // PS Now APOLLOROOT rows are prepended so owned PS3/PS4 entitlements resolve (Android psnowCatalog). - for (const QJsonValue &game : crossReferenceState.psnowCatalogGames) { - if (!game.isObject()) - continue; - QJsonObject gameObj = normalizeApolloGame(game.toObject()); - const QString productId = gameProductId(gameObj); - if (!productId.isEmpty() && !cloudCatalogMap.contains(productId)) - cloudCatalogMap.insert(productId, gameObj); - } - - for (const QJsonValue &game : crossReferenceState.cloudCatalogGames) { - if (game.isObject()) { - QJsonObject gameObj = game.toObject(); - QString productId = gameObj["productId"].toString(); - if (!productId.isEmpty()) { - cloudCatalogMap[productId] = gameObj; - } - } - } - - for (auto it = crossReferenceState.productIdAliases.cbegin(); - it != crossReferenceState.productIdAliases.cend(); ++it) { - if (cloudCatalogMap.contains(it.key())) - continue; - if (cloudCatalogMap.contains(it.value())) - cloudCatalogMap.insert(it.key(), cloudCatalogMap.value(it.value())); - } - - for (const QJsonValue &game : crossReferenceState.plusLibrarySupplement) { - if (game.isObject()) { - QJsonObject gameObj = game.toObject(); - const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - if (!productId.isEmpty()) - plusSupplementMap.insert(productId, gameObj); - } - } - - QJsonArray combinedBrowse = crossReferenceState.psnowCatalogGames; - for (const QJsonValue &v : crossReferenceState.cloudCatalogGames) - combinedBrowse.append(v); - - const QMap browseStableKey = buildStableKeyIndex(combinedBrowse); - const QMap supplementStableKey = - buildStableKeyIndex(crossReferenceState.plusLibrarySupplement); - const QMap browseByConcept = buildConceptIdIndex(combinedBrowse); - const QMap supplementByConcept = - buildConceptIdIndex(crossReferenceState.plusLibrarySupplement); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Cloud catalog map size:" << cloudCatalogMap.size(); - qInfo() << "[CROSS-REF] Product ID aliases:" << crossReferenceState.productIdAliases.size(); - qInfo() << "[CROSS-REF] Plus library supplement map size:" << plusSupplementMap.size(); - qInfo() << "[CROSS-REF] Concept-id index (browse/supplement):" - << browseByConcept.size() << "/" << supplementByConcept.size(); - qInfo() << "[CROSS-REF] Owned games count:" << crossReferenceState.ownedGames.size(); - } - - QJsonArray filteredGames; - int matchedCount = 0; - int productIdMatchCount = 0; - int entitlementIdMatchCount = 0; - int supplementMatchCount = 0; - int conceptIdBrowseMatchCount = 0; - int conceptIdSupplementMatchCount = 0; - int stableKeyBrowseMatchCount = 0; - int stableKeySupplementMatchCount = 0; - QMap ownedByKey; - - for (const QJsonValue &ownedGame : crossReferenceState.ownedGames) { - if (!ownedGame.isObject()) - continue; - - QJsonObject ownedGameObj = ownedGame.toObject(); - // Defensive re-normalization before assembly: owned games reach here either from the fresh - // ingestion gate (already sanitized) OR from a possibly-stale ps5_cloud_library cache written - // by an older binary (which may still carry Sony's numeric serviceType). The same single - // helper guarantees serviceType is OUR routing value (from platform_id) so the per-platform - // dedupe below never falls back to id-prefix parsing. - sanitizeOwnedEntitlementServiceType(ownedGameObj); - const QString productId = ownedGameObj.value(QStringLiteral("product_id")).toString(); - const QString entitlementId = ownedGameObj.value(QStringLiteral("id")).toString(); - const QString entName = ownedGameObj.value(QStringLiteral("game_meta")).toObject() - .value(QStringLiteral("name")).toString(); - const bool skipStableDemo = entName.contains(QStringLiteral("demo"), Qt::CaseInsensitive); - const QString stableKey = ps5CloudProductIdStableKey(productId); - const QString entStableKey = ps5CloudProductIdStableKey(entitlementId); - const QString ownedConceptId = ownedEntitlementConceptId(ownedGameObj); - - // Enrich the owned entitlement with a matched catalog row and dedupe it into ownedByKey, in - // OUR field convention (catalogProductId = streamable catalog variant, productId = owned - // product, conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct - // match, or once per component for a bundle (upstream PR #15 bundle-sibling expansion). - auto emitOwned = [&](const QJsonObject &meta, bool fromSupplement) { - QJsonObject entry = ownedGameObj; - if (meta.contains(QStringLiteral("name"))) { - const QString imagicName = meta.value(QStringLiteral("name")).toString(); - if (!imagicName.isEmpty()) - entry.insert(QStringLiteral("name"), imagicName); - } - if (meta.contains(QStringLiteral("imageUrl")) - && !meta.value(QStringLiteral("imageUrl")).toString().isEmpty()) { - entry.insert(QStringLiteral("imageUrl"), meta.value(QStringLiteral("imageUrl"))); - } - if (meta.contains(QStringLiteral("conceptUrl"))) { - entry.insert(QStringLiteral("conceptUrl"), meta.value(QStringLiteral("conceptUrl"))); - } - // Carry the catalog device list so the UI can tell PS4 from PS5 and route PS4 via Kamaji. - if (meta.contains(QStringLiteral("device"))) { - entry.insert(QStringLiteral("device"), meta.value(QStringLiteral("device"))); - } - // Cloud streaming binds to the catalog product variant (carries the PS Plus streaming - // offer), not the owned download product (e.g. God of War owned ...GODOFWAR vs catalog - // ...GODOFWARN). Keep the catalog productId so PS4 streaming converts the right variant. - const QString metaProductId = meta.value(QStringLiteral("productId")).toString(); - if (!metaProductId.isEmpty()) - entry.insert(QStringLiteral("catalogProductId"), metaProductId); - entry.insert(QStringLiteral("productId"), productId); - entry.insert(QStringLiteral("streamingSupported"), !fromSupplement); - - const QString conceptId = ps5CloudConceptIdString(meta.value(QStringLiteral("conceptId"))); - if (!conceptId.isEmpty()) - entry.insert(QStringLiteral("conceptId"), conceptId); - // Dedupe identity = conceptId + PLATFORM (the catalog edition key): a cross-gen title - // owned on PS4 and PS5 stays as two cards; same-platform SKUs (bonus/avatars) merge. - // Platform comes from the entitlement's structured platform_id (stamped as `platform`), - // NOT the product_id prefix -- a cross-buy PS4 license can carry a PS5-looking product_id - // wrapper, which the prefix would mis-bucket onto the PS5 edition (and could then stamp - // the PS5 card with the PS4/CUSA streaming id). - QString platformToken = gamePlatformStructured(entry); - if (platformToken.isEmpty()) - platformToken = ps5CloudPlatformToken(productId); - const QString dedupeKey = !conceptId.isEmpty() ? QStringLiteral("c:") + conceptId + QLatin1Char(':') + platformToken - : !productId.isEmpty() ? QStringLiteral("p:") + productId - : !entitlementId.isEmpty() ? QStringLiteral("e:") + entitlementId - : QStringLiteral("u:") + productId + QLatin1Char(':') + entitlementId; - if (ownedByKey.contains(dedupeKey)) { - const QJsonObject existing = ownedByKey.value(dedupeKey); - // Keep the best streaming candidate: the canonical full-game entitlement (its - // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). - // The choice MUST be deterministic -- the PSN entitlements response order is NOT - // guaranteed, so a plain rank ">" (first-inserted wins on a tie) would let the - // catalog flip between refreshes (cross-buy titles with no platform_id routinely tie). - // Total order: (1) higher stream rank; (2) prefer a product_id whose platform token - // matches the entitlement's own platform (a psnow/PS4 license prefers its CUSA product - // over a cross-buy PPSA wrapper, so the card streams a product the backend accepts); - // (3) lexicographically smallest product_id; (4) smallest entitlement id. - if (ps5CloudOwnedEntitlementBetter(entry, existing)) - ownedByKey.insert(dedupeKey, entry); - } else { - ownedByKey.insert(dedupeKey, entry); - } - matchedCount++; - }; - - QJsonObject meta; - bool found = false; - bool fromSupplement = false; - - if (!productId.isEmpty() && cloudCatalogMap.contains(productId)) { - meta = cloudCatalogMap.value(productId); - found = true; - productIdMatchCount++; - } else if (!entitlementId.isEmpty() && cloudCatalogMap.contains(entitlementId)) { - meta = cloudCatalogMap.value(entitlementId); - found = true; - entitlementIdMatchCount++; - } else if (!ownedConceptId.isEmpty() && browseByConcept.contains(ownedConceptId)) { - meta = browseByConcept.value(ownedConceptId); - found = true; - conceptIdBrowseMatchCount++; - } else if (!ownedConceptId.isEmpty() && supplementByConcept.contains(ownedConceptId)) { - meta = supplementByConcept.value(ownedConceptId); - found = true; - fromSupplement = true; - conceptIdSupplementMatchCount++; - } else if (!productId.isEmpty() && !entitlementId.isEmpty() - && entitlementId == productId && plusSupplementMap.contains(productId)) { - meta = plusSupplementMap.value(productId); - found = true; - fromSupplement = true; - supplementMatchCount++; - } else if (!stableKey.isEmpty() && !skipStableDemo && browseStableKey.contains(stableKey)) { - meta = browseStableKey.value(stableKey); - found = true; - stableKeyBrowseMatchCount++; - } else if (!stableKey.isEmpty() && !skipStableDemo - && supplementStableKey.contains(stableKey)) { - meta = supplementStableKey.value(stableKey); - found = true; - fromSupplement = true; - stableKeySupplementMatchCount++; - } else if (!entStableKey.isEmpty() && !skipStableDemo && browseStableKey.contains(entStableKey)) { - // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade - // entitlement ids whose stable key matches a catalog row even when product_id did not. - meta = browseStableKey.value(entStableKey); - found = true; - stableKeyBrowseMatchCount++; - } else if (!entStableKey.isEmpty() && !skipStableDemo && supplementStableKey.contains(entStableKey)) { - meta = supplementStableKey.value(entStableKey); - found = true; - fromSupplement = true; - stableKeySupplementMatchCount++; - } - - if (found) { - emitOwned(meta, fromSupplement); - continue; - } - - // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no - // direct catalog row, but its component entitlement ids each map to a component game. Emit - // each distinct component as its own owned entry (via emitOwned, so OUR dedupe/rank apply). - QStringList seenMetaPids; - for (const QString &siblingId : crossReferenceState.componentIdsByProductId.value(productId)) { - QJsonObject siblingMeta; - bool siblingFromSupplement = false; - if (cloudCatalogMap.contains(siblingId)) { - siblingMeta = cloudCatalogMap.value(siblingId); - } else if (plusSupplementMap.contains(siblingId)) { - siblingMeta = plusSupplementMap.value(siblingId); - siblingFromSupplement = true; - } else { - const QString siblingStableKey = ps5CloudProductIdStableKey(siblingId); - if (!siblingStableKey.isEmpty() && !skipStableDemo) { - if (browseStableKey.contains(siblingStableKey)) { - siblingMeta = browseStableKey.value(siblingStableKey); - } else if (supplementStableKey.contains(siblingStableKey)) { - siblingMeta = supplementStableKey.value(siblingStableKey); - siblingFromSupplement = true; - } - } - } - if (siblingMeta.isEmpty()) - continue; - const QString siblingPid = siblingMeta.value(QStringLiteral("productId")).toString(); - if (siblingPid.isEmpty() || seenMetaPids.contains(siblingPid)) - continue; - seenMetaPids.append(siblingPid); - emitOwned(siblingMeta, siblingFromSupplement); - } - } - - // Disc-upgrade rescue. feature_type 5 marks a PS4-disc -> PS5 *disc upgrade* license; Gaikai - // refuses to cloud-stream it ("disc-upgrade-unsupported"). The browse catalog often binds a - // concept to exactly that SKU (e.g. Horizon Forbidden West concept 10000886 -> PPSA01521), while - // the user's streamable full-game entitlement is a DIFFERENT title id (e.g. Complete Edition - // PPSA17903) that is absent from the catalog and carries no conceptId -- so the cross-reference - // never matches it and only the disc-upgrade SKU survives the dedupe. When a concept winner is a - // disc upgrade, adopt the product id of a same-name full-game (feature_type 3) owned SKU so the - // card streams the edition Gaikai accepts. - // - // Entitlements carry no conceptId (the commerce API omits it) and the disc-upgrade SKU shares no - // id/sku with the real edition, so the only in-data bridge is the title name. To keep that safe: - // - SAME PLATFORM only (a PS5/PPSA disc upgrade must never pull in a PS4/CUSA SKU of a - // same-named game), - // - prefer the CANONICAL base game (product_id == entitlement id, i.e. not a bundle/add-on SKU), - // - and BAIL on genuine ambiguity (two distinct base games sharing one name) rather than guess. - auto normalizeTitle = [](const QString &raw) { - return raw.toLower().remove(QChar(0x2122)).remove(QChar(0x00AE)).remove(QChar(0x2120)).simplified(); - }; - for (auto it = ownedByKey.begin(); it != ownedByKey.end(); ++it) { - QJsonObject entry = it.value(); - if (entry.value(QStringLiteral("feature_type")).toInt() != 5) - continue; - const QString discPid = entry.value(QStringLiteral("product_id")).toString(); - const QString discPlatform = ps5CloudPlatformToken(discPid); - const QString discName = normalizeTitle(entry.value(QStringLiteral("game_meta")).toObject() - .value(QStringLiteral("name")).toString()); - if (discName.isEmpty()) - continue; - QStringList canonicalPids; // base-game SKUs (product_id == entitlement id) - QStringList otherPids; // non-canonical full-game SKUs (bundle/edition products) - for (const QJsonValue &candVal : crossReferenceState.ownedGames) { - if (!candVal.isObject()) - continue; - const QJsonObject cand = candVal.toObject(); - // Require a standard digital full game (feature_type 3) -- not another disc upgrade, - // DLC/add-on (ft 0) or trial (ft 1) -- whose name matches the disc-upgrade title. - if (cand.value(QStringLiteral("feature_type")).toInt() != 3) - continue; - const QString candName = normalizeTitle(cand.value(QStringLiteral("game_meta")).toObject() - .value(QStringLiteral("name")).toString()); - if (candName != discName) - continue; - const QString candPid = cand.value(QStringLiteral("product_id")).toString(); - if (candPid.isEmpty() || candPid == discPid) - continue; - if (ps5CloudPlatformToken(candPid) != discPlatform) - continue; // never cross platforms (PS5 disc upgrade must not resolve to a PS4 SKU) - const QString candId = cand.value(QStringLiteral("id")).toString(); - if (candPid == candId) { - if (!canonicalPids.contains(candPid)) canonicalPids.append(candPid); - } else { - if (!otherPids.contains(candPid)) otherPids.append(candPid); - } - } - // A single canonical base game wins; else a single non-canonical full game; else bail. - QString replacementPid; - if (canonicalPids.size() == 1) - replacementPid = canonicalPids.first(); - else if (canonicalPids.isEmpty() && otherPids.size() == 1) - replacementPid = otherPids.first(); - if (replacementPid.isEmpty()) { - if (!canonicalPids.isEmpty() || !otherPids.isEmpty()) - qWarning() << "[CROSS-REF] disc-upgrade rescue: ambiguous full-game candidates for" - << discName << "canonical=" << canonicalPids << "other=" << otherPids - << "-- leaving disc-upgrade SKU in place"; - continue; - } - entry.insert(QStringLiteral("product_id"), replacementPid); - entry.insert(QStringLiteral("productId"), replacementPid); - entry.insert(QStringLiteral("catalogProductId"), replacementPid); - it.value() = entry; - qInfo() << "[CROSS-REF] disc-upgrade rescue:" << discName << ":" << discPid - << "-> streamable" << replacementPid; - } - - for (const QJsonObject &gameObj : ownedByKey) - filteredGames.append(gameObj); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "[CROSS-REF] Matched games (cloud streamable):" << matchedCount; - qInfo() << "[CROSS-REF] By product_id:" << productIdMatchCount; - qInfo() << "[CROSS-REF] By entitlement id (fallback):" << entitlementIdMatchCount; - qInfo() << "[CROSS-REF] By Plus library supplement:" << supplementMatchCount; - qInfo() << "[CROSS-REF] By conceptId (browse):" << conceptIdBrowseMatchCount; - qInfo() << "[CROSS-REF] By conceptId (supplement):" << conceptIdSupplementMatchCount; - qInfo() << "[CROSS-REF] By stable product id key (browse):" << stableKeyBrowseMatchCount; - qInfo() << "[CROSS-REF] By stable product id key (supplement):" << stableKeySupplementMatchCount; - } - - QJsonObject result; - result["games"] = filteredGames; - result["total"] = filteredGames.size(); - - QJsonDocument resultDoc(result); - - const bool unifiedMode = crossReferenceState.unifiedMode; - if (unifiedMode) { - assembleUnifiedCatalog(filteredGames); - } else if (crossReferenceState.callback.isCallable()) { - QString jsonStr = QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)); - crossReferenceState.callback.call({true, "Success", QJSValue(jsonStr)}); - } - - crossReferenceState.callback = QJSValue(); - crossReferenceState.cloudCatalogGames = QJsonArray(); - crossReferenceState.plusLibrarySupplement = QJsonArray(); - crossReferenceState.psnowCatalogGames = QJsonArray(); - crossReferenceState.ownedGames = QJsonArray(); - crossReferenceState.productIdAliases.clear(); - crossReferenceState.componentIdsByProductId.clear(); - crossReferenceState.catalogFetched = false; - crossReferenceState.ownedGamesFetched = false; - crossReferenceState.unifiedMode = false; -} - void CloudCatalogBackend::invalidatePs5CatalogCache() { for (const QString &key : @@ -3870,55 +649,12 @@ void CloudCatalogBackend::invalidatePs5CatalogCache() void CloudCatalogBackend::invalidateCache() { - // Invalidate specific cache files (PSNOW, PS5 cloud catalog, and PS5 cloud library) - QString psnowPath = getCacheFilePath("psnow_catalog"); - QString ps5CatalogPath = getCacheFilePath("ps5_cloud_catalog_v6"); - QString ps5CatalogV2Path = getCacheFilePath("ps5_cloud_catalog_v2"); - QString ps5LibraryPath = getCacheFilePath("ps5_cloud_library"); - QString unifiedPath = getCacheFilePath("unified_catalog_v2"); - // Also clear the superseded v1 unified cache from older binaries. - QFile::remove(getCacheFilePath("unified_catalog_v1")); - - bool invalidated = false; - if (QFile::exists(psnowPath)) { - QFile::remove(psnowPath); - qInfo() << "[CACHE INVALIDATED] Removed PSNOW catalog cache"; - invalidated = true; - } - - if (QFile::exists(ps5CatalogPath)) { - QFile::remove(ps5CatalogPath); - qInfo() << "[CACHE INVALIDATED] Removed PS5 cloud catalog cache"; - invalidated = true; - } - if (QFile::exists(ps5CatalogV2Path)) { - QFile::remove(ps5CatalogV2Path); - qInfo() << "[CACHE INVALIDATED] Removed PS5 cloud catalog v2 cache"; - invalidated = true; - } - // Drop legacy cache from pre-v2 catalog merge / conceptId dedupe fix - const QString legacyPs5CatalogPath = getCacheFilePath("ps5_cloud_catalog"); - if (QFile::exists(legacyPs5CatalogPath)) { - QFile::remove(legacyPs5CatalogPath); - qInfo() << "[CACHE INVALIDATED] Removed legacy PS5 cloud catalog cache"; - invalidated = true; - } - - if (QFile::exists(ps5LibraryPath)) { - QFile::remove(ps5LibraryPath); - qInfo() << "[CACHE INVALIDATED] Removed PS5 cloud library cache"; - invalidated = true; - } - - if (QFile::exists(unifiedPath)) { - QFile::remove(unifiedPath); - qInfo() << "[CACHE INVALIDATED] Removed unified catalog cache"; - invalidated = true; - } - - if (!invalidated) { - qInfo() << "[CACHE INVALIDATED] No cache files found to invalidate"; - } + // libchiaki owns every cache file and its versioned key (current + legacy), so + // delegate to it. This is the single source of truth for cache naming and keeps + // the client from drifting out of sync when the cache schema/version bumps. + const QByteArray cacheDir = cacheDirectory.toUtf8(); + chiaki_cloudcatalog_invalidate_cache(cacheDir.constData()); + qInfo() << "[CACHE INVALIDATED] Delegated cache invalidation to libchiaki for" << cacheDirectory; } QPixmap CloudCatalogBackend::downloadImageFromUrl(const QString &url, int timeoutMs) diff --git a/gui/src/cloudstreaming/psgaikaistreaming.cpp b/gui/src/cloudstreaming/psgaikaistreaming.cpp index d0e569e2..bb2ebb75 100644 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ b/gui/src/cloudstreaming/psgaikaistreaming.cpp @@ -6,6 +6,7 @@ #include "cloudstreaming/nsurlsession_oauth.h" #include "chiaki/remote/holepunch.h" #include "chiaki/common.h" +#include "chiaki/cloudcatalog.h" #include #include @@ -149,9 +150,17 @@ QJsonObject PSGaikaiStreaming::buildRequestGameSpec(QString entitlementId) spec["entitlementId"] = entitlementId; spec["npEnv"] = "np"; - // Read resolution and language from settings fresh each time (not cached) - // Use unified language setting for both PSCloud and PSNOW - QString language = settings->GetCloudLanguagePSCloud(); + // Read resolution and language from settings fresh each time (not cached). + // Prefer the user's manual streaming-language pick; fall back to the + // auto-detected catalog locale when the picker is left on default. The + // manual pick lives in its own setting so the catalog's settledLocale write + // can never clobber it. Gaikai expects the bare language code ("de"), not + // the stored locale ("de-DE"); the lib helper is the single source of truth. + QString locale = settings->GetCloudStreamLanguage(); + if (locale.isEmpty()) + locale = settings->GetCloudLanguagePSCloud(); + char gaikaiLang[16]; + chiaki_cloud_gaikai_language(locale.toUtf8().constData(), gaikaiLang, sizeof(gaikaiLang)); int resolution; if (serviceType == "pscloud") { resolution = settings->GetCloudResolutionPSCloud(); @@ -159,7 +168,7 @@ QJsonObject PSGaikaiStreaming::buildRequestGameSpec(QString entitlementId) // PSNOW resolution = settings->GetCloudResolutionPSNOW(); } - spec["language"] = language; + spec["language"] = QString::fromUtf8(gaikaiLang); // Cloud Infrastructure spec["cloudEndpoint"] = "https://cc.prod.gaikai.com"; diff --git a/gui/src/qml/CloudGameCard.qml b/gui/src/qml/CloudGameCard.qml index 608b4903..b145a3ea 100644 --- a/gui/src/qml/CloudGameCard.qml +++ b/gui/src/qml/CloudGameCard.qml @@ -14,7 +14,6 @@ Rectangle { property bool hasFocus: isCurrentItem && GridView.view.activeFocus property bool isPsnow: isPsnowGame() property string cachedImageUrl: "" - property string libraryFilter: "owned" // "owned" or "all" or "favorites" - filter mode for Game Library property var qrCodeDialog: null // Reference to QR code dialog // In the modern PS Plus catalog (imagic; isPsnow=false) a game you don't own can't be streamed // until it's added to your library: Gaikai rejects an unowned PS5 entitlement, and the legacy @@ -45,24 +44,10 @@ Rectangle { return `image://svg/button-${type}#${buttonName}`; } + // The unified catalog (libchiaki) precomputes serviceType for every row; this is a + // read-only convenience, NOT a re-derivation. function isPsnowGame() { - if (!gameData) return false; - // Canonical serviceType is authoritative. A pscloud row streams via Gaikai (PS5/cronos) - // and is NEVER PS Now, even for PS1-classic CUSA store wrappers (e.g. Worms World Party, - // whose owned row is serviceType=pscloud but carries a ...CUSA... productId). The CUSA/PPSA - // token heuristic below is only a fallback for rows that have no serviceType. - if (gameData.serviceType === "pscloud") return false; - if (gameData.serviceType === "psnow" || gameData.category === "streamable") - return true; - let p = String(streamProductId()); - if (p.indexOf("CUSA") !== -1) return true; - let pp = gameData.playable_platform; - if (pp) { - let arr = Array.isArray(pp) ? pp : (typeof pp === "string" ? [pp] : []); - for (let i = 0; i < arr.length; i++) - if (String(arr[i]).indexOf("PS3") !== -1) return true; - } - return false; + return !!(gameData && gameData.serviceType === "psnow"); } // Extract game information @@ -83,110 +68,35 @@ Rectangle { return ""; } - // Get product ID specifically for API calls (fetchGameDetails) - // For PSCloud: returns product_id (not entitlement id) - // For PSNOW: returns id (which is the product ID) + // productId for the per-game details API (fetchGameDetails). The unified contract + // exposes the canonical catalog productId; no platform guessing needed. function getProductIdForApi() { if (!gameData) return ""; - if (isPsnow) { - // PSNOW: use id as productId - return gameData.id || ""; - } else { - // PSCloud: use product_id for API calls (not the entitlement id) - if (gameData.product_id) { - return gameData.product_id; - } else if (gameData.productId) { - return gameData.productId; - } - return ""; - } + return gameData.productId || gameData.product_id || gameData.id || ""; } - - // Get the identifier to use for streaming (entitlement ID for PSCloud, product ID for PSNOW) + + // Exact id handed to the streaming session. Precomputed by libchiaki + // (chiaki/cloudcatalog.h "streamIdentifier"); read it verbatim. function getStreamingIdentifier() { if (!gameData) return ""; - if (isPsnow) return getProductId(); // legacy PS Now browse catalog - if (streamPlatform() === "ps4") { - // PS4 catalog: send the CUSA product id; Kamaji converts it and acquires the - // streaming entitlement via PS Plus (PS4 store containers expose the entitlement). - let p = streamProductId(); - return p !== "" ? p : getProductId(); - } - // PS5 (cronos): stream the owned PS5 entitlement `id`, which the backend resolved from the - // entitlement's structured platform_id (the catalog merge stamps the platform-matching PS5 - // license here). For a classic like Blood Omen that id is ...PPSA24270_00-SLUS000270000000 - // (NOT the ...-0499... store wrapper product_id, which Gaikai has no game for -> - // noGameForEntitlementId). For a cross-gen upgrade (Alan Wake, Death Stranding) the PS5 - // entitlement id equals its product_id (PPSA01925 / PPSA01968), so it is absent here and we - // fall through to the product id. No id-prefix guessing, no force-to-PS5. - if (gameData.id) return gameData.id; - if (gameData.product_id) return gameData.product_id; - if (gameData.productId) return gameData.productId; - return ""; - } - - function getPlatform() { - if (!gameData) return "ps4"; - if (isPsnow) { - // PSNOW games - check playable_platform - // Note: When passed from C++ to QML, JSON arrays become QVariantList objects, - // not true JavaScript arrays, so we need to handle both cases - let playablePlatform = gameData.playable_platform || gameData["playable_platform"]; - - if (playablePlatform) { - // Convert to array if it's not already (handles QVariantList from C++) - let platformArray = []; - if (Array.isArray(playablePlatform)) { - platformArray = playablePlatform; - } else if (typeof playablePlatform === "object" && playablePlatform.length !== undefined) { - for (let i = 0; i < playablePlatform.length; i++) { - platformArray.push(playablePlatform[i]); - } - } else if (typeof playablePlatform === "string") { - platformArray = [playablePlatform]; - } - - // Check each platform in the array - for (let i = 0; i < platformArray.length; i++) { - let platform = String(platformArray[i]); - if (platform.indexOf("PS3") !== -1) return "ps3"; - if (platform.indexOf("PS4") !== -1) return "ps4"; - } - } - return "ps4"; - } else { - return streamPlatform(); - } + return gameData.streamIdentifier || getProductId(); } - // The product id to stream. Cloud streaming binds to the *catalog* product variant (the - // streamable representative, e.g. God of War's ...GODOFWARN or Alan Wake's PS5 PPSA id), - // not the user's owned download/trial/cross-gen entitlement — so prefer catalogProductId. - function streamProductId() { - if (!gameData) return ""; - return gameData.catalogProductId || gameData.product_id || gameData.productId || gameData.id || ""; + // Platform badge, precomputed (ps3/ps4/ps5). + function getPlatform() { + return (gameData && gameData.platform) ? gameData.platform : "ps4"; } - // Platform that drives the streaming path (PS4 = kratos/Kamaji, PS5 = cronos). The canonical - // signal is serviceType (pscloud == PS5, psnow == PS3/PS4 -> ps4-class), which the backend sets - // on PS Now browse rows and fills in for owned cards from PSN's platform_id -- so a cross-buy PS4 - // entitlement carrying a PS5-looking product_id wrapper is NOT mis-classified. Falls back to the - // clean catalog id token only when serviceType is absent. Defaults to PS5 (the modern catalog). - function streamPlatform() { - if (gameData && gameData.serviceType === "pscloud") return "ps5"; - if (gameData && gameData.serviceType === "psnow") return "ps4"; - let p = String(streamProductId()); - if (p.indexOf("PPSA") !== -1) return "ps5"; - if (p.indexOf("CUSA") !== -1) return "ps4"; - return "ps5"; + // serviceType selects the catalog/shortcut routing (psnow vs pscloud); precomputed. + function getServiceType() { + return (gameData && gameData.serviceType) ? gameData.serviceType : "pscloud"; } - function getServiceType() { - if (gameData && (gameData.serviceType === "psnow" || gameData.serviceType === "pscloud")) - return gameData.serviceType; // canonical value (set on browse rows / from platform_id) - if (isPsnow) return "psnow"; // legacy PS Now browse catalog - // serviceType selects the Gaikai spec/consts/virtType: psnow = PS4/kratos, pscloud = PS5/cronos. - return (streamPlatform() === "ps4") ? "psnow" : "pscloud"; + // The endpoint the stream action targets (may differ from catalog serviceType for + // some owned cross-buy rows). Precomputed by libchiaki. + function getStreamServiceType() { + if (gameData && gameData.streamServiceType) return gameData.streamServiceType; + return getServiceType(); } function getImageUrl() { @@ -239,14 +149,6 @@ Rectangle { return ""; } - function getPlatformBadge() { - let platform = getPlatform(); - if (platform === "ps5") return "PS5"; - if (platform === "ps4") return "PS4"; - if (platform === "ps3") return "PS3"; - return ""; - } - // Note: cachedImageUrl is bound to gameImage.source below, so it will update automatically // Load image URL on component creation - ONLY from catalog/entitlement data, no API calls @@ -483,7 +385,7 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: { - console.log("[CloudGameCard] Button clicked - isPsnow:", isPsnow, "libraryFilter:", libraryFilter, "gameData:", gameData, "isOwned:", gameData ? gameData.isOwned : "N/A"); + console.log("[CloudGameCard] Button clicked - isPsnow:", isPsnow, "gameData:", gameData, "isOwned:", gameData ? gameData.isOwned : "N/A"); console.log("[CloudGameCard] qrCodeDialog:", qrCodeDialog); // Check if this is a non-owned game in "All" filter mode @@ -513,7 +415,7 @@ Rectangle { let platform = getPlatform(); let serviceType = getServiceType(); if (streamingId !== "") { - streamGame(streamingId, platform, serviceType); + streamGame(streamingId, platform, getStreamServiceType()); } } } @@ -615,7 +517,7 @@ Rectangle { let platform = getPlatform(); let serviceType = getServiceType(); if (streamingId !== "") { - streamGame(streamingId, platform, serviceType); + streamGame(streamingId, platform, getStreamServiceType()); event.accepted = true; } } diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index b91715e5..e782650b 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -21,8 +21,6 @@ Pane { readonly property Item searchContainerItem: searchContainer readonly property Item refreshButtonItem: refreshButton - property int currentPage: 0 - property int gamesPerPage: 25 property var allGames: [] property var filteredGames: [] property var currentPageGames: [] @@ -327,26 +325,6 @@ Pane { applySearchFilter(); } - function updateCurrentPage() { - let startIdx = currentPage * gamesPerPage; - let endIdx = Math.min(startIdx + gamesPerPage, filteredGames.length); - currentPageGames = filteredGames.slice(startIdx, endIdx); - } - - function nextPage() { - if ((currentPage + 1) * gamesPerPage < filteredGames.length) { - currentPage++; - updateCurrentPage(); - } - } - - function previousPage() { - if (currentPage > 0) { - currentPage--; - updateCurrentPage(); - } - } - function showShortcutToast(title, message) { shortcutToastTitle.text = title; shortcutToastMessage.text = message; diff --git a/gui/src/qml/SettingsDialog.qml b/gui/src/qml/SettingsDialog.qml index e775491b..76a52638 100644 --- a/gui/src/qml/SettingsDialog.qml +++ b/gui/src/qml/SettingsDialog.qml @@ -2796,6 +2796,70 @@ DialogView { visible: selectedCloudService == SettingsDialog.CloudService.PSNOW } + Label { + Layout.alignment: Qt.AlignRight + text: qsTr("Game Language:") + } + + // Cloud streaming language (manual override, stored separately + // from the auto-detected catalog locale so it's never clobbered). + // Every supported language is listed; game language is tied to + // the datacenter region (Gaikai ignores a language whose + // datacenter isn't selected), so the user must pick a matching + // Datacenter below. Supported-locale list lives in libchiaki. + C.ComboBox { + id: cloudLanguage + Layout.preferredWidth: 400 + property var languageValues: [] + model: { + let displayNames = { + "en-US": "English", "en-GB": "English (UK)", "de-DE": "Deutsch", + "fr-FR": "Français", "fi-FI": "Suomi", "it-IT": "Italiano", + "es-ES": "Español", "nl-NL": "Nederlands", "pt-BR": "Português (BR)", + "ja-JP": "日本語", "ko-KR": "한국어" + }; + // Show every supported language (datacenter language + // support can't be reliably enumerated). "Auto" (empty + // value) clears the override so the auto-detected + // catalog/region locale is used instead. + let supported = Chiaki.settings.cloudSupportedLanguages(); + let catalogLocale = Chiaki.settings.cloudLanguagePSCloud || "en-US"; + let values = [""]; + let labels = [qsTr("Auto") + " (" + catalogLocale + ")"]; + for (let i = 0; i < supported.length; i++) { + let loc = supported[i]; + values.push(loc); + labels.push((displayNames[loc] || loc) + " (" + loc + ")"); + } + languageValues = values; + return labels; + } + currentIndex: { + // Empty override selects "Auto" (index 0). + let sel = Chiaki.settings.cloudStreamLanguage || ""; + let idx = languageValues.indexOf(sel); + return idx >= 0 ? idx : 0; + } + onActivated: index => { + // "" (Auto) clears the override; otherwise store the pick. + Chiaki.settings.cloudStreamLanguage = languageValues[index] || ""; + } + } + + // 3rd-column filler keeps the 3-column grid aligned. + Label { text: "" } + + // Disclaimer row: empty label column + caption under the control. + Label { text: "" } + Label { + Layout.columnSpan: 2 + Layout.maximumWidth: 400 + wrapMode: Text.WordWrap + opacity: 0.6 + font.pixelSize: 12 + text: qsTr("Not all regions support every language. A language only works on datacenters that offer it — if your chosen language isn't applied, pick a matching Datacenter below.") + } + Label { Layout.alignment: Qt.AlignRight text: qsTr("Datacenter:") diff --git a/gui/src/qmlsettings.cpp b/gui/src/qmlsettings.cpp index a5227c2e..d633ad30 100644 --- a/gui/src/qmlsettings.cpp +++ b/gui/src/qmlsettings.cpp @@ -1,6 +1,8 @@ #include "qmlsettings.h" #include "sessionlog.h" +#include + #include #include #include @@ -206,6 +208,32 @@ void QmlSettings::setCloudLanguagePSCloud(const QString &language) emit cloudLanguagePSCloudChanged(); } +QString QmlSettings::cloudStreamLanguage() const +{ + return settings->GetCloudStreamLanguage(); +} + +void QmlSettings::setCloudStreamLanguage(const QString &language) +{ + settings->SetCloudStreamLanguage(language); + emit cloudStreamLanguageChanged(); +} + +QStringList QmlSettings::cloudSupportedLanguages() const +{ + QStringList list; + size_t n = chiaki_cloud_supported_locale_count(); + for(size_t i = 0; i < n; i++) + list.append(QString::fromUtf8(chiaki_cloud_supported_locale(i))); + return list; +} + +bool QmlSettings::cloudDatacenterServesLanguage(const QString &datacenterName, const QString &locale) const +{ + return chiaki_cloud_datacenter_serves_locale(datacenterName.toUtf8().constData(), + locale.toUtf8().constData()); +} + QString QmlSettings::cloudDatacenterPSCloud() const { return settings->GetCloudDatacenterPSCloud(); diff --git a/gui/src/settings.cpp b/gui/src/settings.cpp index 857f5d66..aeaa39bb 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -448,6 +448,19 @@ void Settings::SetCloudLanguagePSCloud(const QString &language) settings.setValue("settings/cloud_language_pscloud", language); } +QString Settings::GetCloudStreamLanguage() const +{ + // Manual streaming-language override chosen in the language picker. Empty + // means "use the catalog locale" (cloud_language_pscloud). Kept separate so + // the auto-detected catalog locale never clobbers the user's pick. + return settings.value("settings/cloud_stream_language", "").toString(); +} + +void Settings::SetCloudStreamLanguage(const QString &language) +{ + settings.setValue("settings/cloud_stream_language", language); +} + QString Settings::GetCloudDatacenterPSCloud() const { // Fallback to legacy cloud_datacenter if not set (for migration) diff --git a/ios/Pylux.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index 7a67b46c..ee12e40d 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ A1000038 /* ConnectInfoEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000036 /* ConnectInfoEntryView.swift */; }; A1000110 /* DiscoveryBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000101 /* DiscoveryBridge.m */; }; A1000111 /* RegistBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000103 /* RegistBridge.m */; }; + A10001C0 /* CloudCatalogBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A10001C2 /* CloudCatalogBridge.m */; }; A1000112 /* HostModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000104 /* HostModels.swift */; }; A1000113 /* HostCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000105 /* HostCardView.swift */; }; A1000114 /* RemotePlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000106 /* RemotePlayView.swift */; }; @@ -53,7 +54,6 @@ A3000014 /* PSKamajiSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000004 /* PSKamajiSession.swift */; }; A3000015 /* CloudStreamingBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000005 /* CloudStreamingBackend.swift */; }; A3000016 /* CloudCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000006 /* CloudCatalogService.swift */; }; - A3000018 /* PsCloudOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000008 /* PsCloudOwnership.swift */; }; A3000017 /* CloudPlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000007 /* CloudPlayView.swift */; }; A3000060 /* CachedAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000050 /* CachedAsyncImage.swift */; }; A4000011 /* DonationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000001 /* DonationStore.swift */; }; @@ -91,6 +91,8 @@ A1000101 /* DiscoveryBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DiscoveryBridge.m; sourceTree = ""; }; A1000102 /* RegistBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegistBridge.h; sourceTree = ""; }; A1000103 /* RegistBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegistBridge.m; sourceTree = ""; }; + A10001C1 /* CloudCatalogBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CloudCatalogBridge.h; sourceTree = ""; }; + A10001C2 /* CloudCatalogBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CloudCatalogBridge.m; sourceTree = ""; }; A1000104 /* HostModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostModels.swift; sourceTree = ""; }; A1000105 /* HostCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostCardView.swift; sourceTree = ""; }; A1000106 /* RemotePlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePlayView.swift; sourceTree = ""; }; @@ -126,7 +128,6 @@ A3000004 /* PSKamajiSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PSKamajiSession.swift; sourceTree = ""; }; A3000005 /* CloudStreamingBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudStreamingBackend.swift; sourceTree = ""; }; A3000006 /* CloudCatalogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudCatalogService.swift; sourceTree = ""; }; - A3000008 /* PsCloudOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsCloudOwnership.swift; sourceTree = ""; }; A3000007 /* CloudPlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPlayView.swift; sourceTree = ""; }; A3000050 /* CachedAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedAsyncImage.swift; sourceTree = ""; }; A4000001 /* DonationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationStore.swift; sourceTree = ""; }; @@ -215,6 +216,8 @@ A1000119 /* HolepunchBridge.m */, A1000102 /* RegistBridge.h */, A1000103 /* RegistBridge.m */, + A10001C1 /* CloudCatalogBridge.h */, + A10001C2 /* CloudCatalogBridge.m */, A1000024 /* SessionEventReceiver.h */, A1000023 /* SessionEventReceiver.m */, A1000022 /* VideoDecoder.h */, @@ -237,7 +240,6 @@ isa = PBXGroup; children = ( A3000006 /* CloudCatalogService.swift */, - A3000008 /* PsCloudOwnership.swift */, A3000002 /* CloudHttpClient.swift */, A3000005 /* CloudStreamingBackend.swift */, A3000003 /* PSGaikaiStreaming.swift */, @@ -392,6 +394,7 @@ A1000038 /* ConnectInfoEntryView.swift in Sources */, A1000110 /* DiscoveryBridge.m in Sources */, A1000111 /* RegistBridge.m in Sources */, + A10001C0 /* CloudCatalogBridge.m in Sources */, A1000112 /* HostModels.swift in Sources */, A1000113 /* HostCardView.swift in Sources */, A1000114 /* RemotePlayView.swift in Sources */, @@ -411,7 +414,6 @@ A3000014 /* PSKamajiSession.swift in Sources */, A3000015 /* CloudStreamingBackend.swift in Sources */, A3000016 /* CloudCatalogService.swift in Sources */, - A3000018 /* PsCloudOwnership.swift in Sources */, A3000017 /* CloudPlayView.swift in Sources */, A3000060 /* CachedAsyncImage.swift in Sources */, A4000011 /* DonationStore.swift in Sources */, diff --git a/ios/Pylux/Bridge/ChiakiBridge.h b/ios/Pylux/Bridge/ChiakiBridge.h index a702fc43..8570aeec 100644 --- a/ios/Pylux/Bridge/ChiakiBridge.h +++ b/ios/Pylux/Bridge/ChiakiBridge.h @@ -12,6 +12,7 @@ #import "RegistBridge.h" #import "HolepunchBridge.h" #import "ChiakiDatacenterPing.h" +#import "CloudCatalogBridge.h" /// Returns a string from the Chiaki library (e.g. "Success" from chiaki_error_string). /// Used to verify the app is correctly linked to the Chiaki library. diff --git a/ios/Pylux/Bridge/CloudCatalogBridge.h b/ios/Pylux/Bridge/CloudCatalogBridge.h new file mode 100644 index 00000000..64d91087 --- /dev/null +++ b/ios/Pylux/Bridge/CloudCatalogBridge.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// Bridge to libchiaki's unified cloud catalog (chiaki/cloudcatalog.h). +// +// The lib is the single source of truth for the cloud catalog across Qt, iOS and +// Android: it performs every OAuth/session exchange, fetch, dedup, ownership +// cross-reference and tagging, then returns ONE display-and-stream-ready JSON +// payload. iOS must not recompute category, serviceType, platform, ownership or +// stream identifiers — it just parses and renders the contract. + +#ifndef CloudCatalogBridge_h +#define CloudCatalogBridge_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PyluxCloudCatalog : NSObject + +/// Blocking; call from a background queue. Returns the unified catalog JSON +/// (UTF-8 string per CHIAKI_CLOUDCATALOG_SCHEMA_VERSION) or nil on hard failure. +/// On a unified-cache hit it performs no network I/O. A degraded-but-usable +/// result (e.g. expired npsso) still returns JSON with a non-empty "warning". ++ (nullable NSString *)fetchUnifiedJSONWithNpsso:(nullable NSString *)npsso + locale:(nullable NSString *)locale + cacheDir:(NSString *)cacheDir + forceRefresh:(BOOL)forceRefresh + errorMessage:(NSString * _Nullable * _Nullable)errorMessage; + +// Cloud streaming language helpers, backed by the shared libchiaki table. Game +// language is tied to the datacenter region (Gaikai ignores a language whose +// datacenter is not selected). + +/// Bare lowercase language code Gaikai expects ("de-DE" -> "de"); "en" default. ++ (NSString *)gaikaiLanguageForLocale:(nullable NSString *)locale; + +/// Locales offered in the language picker (BCP-47, e.g. "en-GB"). ++ (NSArray *)supportedCloudLanguages; + +/// YES if @c datacenterName (4-letter ping name, e.g. "fraa") serves @c locale. ++ (BOOL)datacenter:(NSString *)datacenterName servesLocale:(NSString *)locale; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* CloudCatalogBridge_h */ diff --git a/ios/Pylux/Bridge/CloudCatalogBridge.m b/ios/Pylux/Bridge/CloudCatalogBridge.m new file mode 100644 index 00000000..8eecfb0c --- /dev/null +++ b/ios/Pylux/Bridge/CloudCatalogBridge.m @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL + +#import "CloudCatalogBridge.h" +#import "PyluxChiakiLog.h" +#include +#include +#include + +static os_log_t s_cc_log; + +static void cc_log_cb(ChiakiLogLevel level, const char *msg, void *user) { + (void)user; + os_log_type_t type = (level == CHIAKI_LOG_ERROR) ? OS_LOG_TYPE_ERROR : OS_LOG_TYPE_DEFAULT; + os_log_with_type(s_cc_log, type, "[CloudCatalog] %{public}s", msg ? msg : ""); +} + +@implementation PyluxCloudCatalog + ++ (void)initialize { + if (self == [PyluxCloudCatalog class]) { + s_cc_log = os_log_create("com.pylux.stream", "CloudCatalogLib"); + } +} + ++ (NSString *)fetchUnifiedJSONWithNpsso:(NSString *)npsso + locale:(NSString *)locale + cacheDir:(NSString *)cacheDir + forceRefresh:(BOOL)forceRefresh + errorMessage:(NSString * _Nullable * _Nullable)errorMessage { + ChiakiLog log; + pylux_chiaki_log_init(&log, cc_log_cb, NULL); + + ChiakiCloudCatalogConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.npsso = (npsso.length > 0) ? npsso.UTF8String : NULL; + cfg.locale = (locale.length > 0) ? locale.UTF8String : NULL; + cfg.cache_dir = cacheDir.UTF8String; + cfg.force_refresh = forceRefresh ? true : false; + + ChiakiCloudCatalogResult res; + memset(&res, 0, sizeof(res)); + ChiakiErrorCode err = chiaki_cloudcatalog_fetch_unified(&cfg, &res, &log); + + NSString *json = nil; + if (res.json) { + json = [NSString stringWithUTF8String:res.json]; + } + if (!json && errorMessage) { + *errorMessage = res.error_message + ? [NSString stringWithUTF8String:res.error_message] + : [NSString stringWithFormat:@"Cloud catalog fetch failed (error %d)", (int)err]; + } + + chiaki_cloudcatalog_result_fini(&res); + return json; +} + ++ (NSString *)gaikaiLanguageForLocale:(NSString *)locale { + char buf[16]; + chiaki_cloud_gaikai_language(locale.length > 0 ? locale.UTF8String : NULL, buf, sizeof(buf)); + return [NSString stringWithUTF8String:buf]; +} + ++ (NSArray *)supportedCloudLanguages { + NSMutableArray *out = [NSMutableArray array]; + size_t n = chiaki_cloud_supported_locale_count(); + for (size_t i = 0; i < n; i++) { + const char *l = chiaki_cloud_supported_locale(i); + if (l && *l) + [out addObject:[NSString stringWithUTF8String:l]]; + } + return out; +} + ++ (BOOL)datacenter:(NSString *)datacenterName servesLocale:(NSString *)locale { + if (datacenterName.length == 0 || locale.length == 0) + return NO; + return chiaki_cloud_datacenter_serves_locale(datacenterName.UTF8String, locale.UTF8String) ? YES : NO; +} + +@end diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index a394d874..59056b18 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -6,103 +6,63 @@ import os // MARK: - CloudGame (matches Android CloudGame.kt) -/// Represents a game in the cloud catalog (PSNow or PSCloud) +/// One game from libchiaki's unified cloud catalog. EVERY field is precomputed by +/// the lib (chiaki/cloudcatalog.h) — category, serviceType, platform, ownership and +/// the stream routing values. iOS parses the contract and renders it; it must NOT +/// re-derive any of these (that logic now lives in one place: lib/src/cloudcatalog_*). struct CloudGame: Identifiable, Hashable { - let id: String // catalog productId (PSCloud) or product id (PSNOW) + let id: String // canonical catalog productId + stable dedup key let name: String - let imageUrl: String // Cover/box art (type 10) - let landscapeImageUrl: String // Landscape (type 12/13) - let platform: String // "ps4", "ps3", or "ps5" - let serviceType: String // "psnow" or "pscloud" - let conceptUrl: String // URL to add game to library (PS5) - let conceptId: String // Imagic conceptId for catalog dedupe (PS5 cloud) - var isOwned: Bool // Whether user owns this game (PS5) - var entitlementId: String // PSCloud: entitlement id for streaming (Qt gameData.id) - var storeProductId: String // PSCloud: product_id from entitlements API - var plusCatalog: Bool // In the PS Plus subscription catalog (vs the full streamable universe) - var featureType: Int // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on - // Unified-page acquisition tag, assigned once at catalog-assembly time: - // "owned" -> entitlement resolves to a streamable row (Stream) - // "streamable" -> not owned, PS Now subscription title (Stream) - // "purchaseable" -> not owned, PS Plus catalog title (Add to Library, then Stream) - var category: String - // PS5-platform membership from the imagic catalog's authoritative `device` array (contains - // "PS5") OR a PPSA product id. Mirrors Qt's isPs5PlatformGame() and is used to decide which - // browse rows enter the streamable universe -- NOT the CUSA/PPSA productId token, which - // mis-classifies cross-gen titles (a PS4 CUSA SKU that also lists "PS5" in `device`). - var isPs5Platform: Bool - - init(productId: String, name: String, imageUrl: String, landscapeImageUrl: String = "", - platform: String = "ps4", serviceType: String = "psnow", - conceptUrl: String = "", conceptId: String = "", isOwned: Bool = false, - entitlementId: String = "", storeProductId: String = "", plusCatalog: Bool = false, - featureType: Int = 0, category: String = "", isPs5Platform: Bool = false) { - self.id = productId + let imageUrl: String // portrait / box art + let landscapeImageUrl: String + let platform: String // "ps3" | "ps4" | "ps5" (badge; derived from device[]) + let serviceType: String // "psnow" | "pscloud" (catalog routing) + let conceptUrl: String // purchase / add-to-library deep link + let conceptId: String + let isOwned: Bool + let entitlementId: String + let storeProductId: String + let plusCatalog: Bool + // Acquisition tag: "owned" (Stream) | "streamable" (Stream) | "purchaseable" (Add to Library). + let category: String + // Endpoint + exact id the stream action uses (lib-computed; PS3/PS4 -> Kamaji/psnow, + // PS5 -> cronos/pscloud). The UI hands these straight to the streaming backend. + let streamServiceType: String + let streamIdentifier: String + + /// Build from one element of the lib unified-catalog "games" array. + init?(contract g: [String: Any]) { + guard let pid = g["productId"] as? String, !pid.isEmpty, + let name = g["name"] as? String, !name.isEmpty else { return nil } + self.id = pid self.name = name - self.imageUrl = imageUrl - self.landscapeImageUrl = landscapeImageUrl.isEmpty ? imageUrl : landscapeImageUrl - self.platform = platform - self.serviceType = serviceType - self.conceptUrl = conceptUrl - self.conceptId = conceptId - self.isOwned = isOwned - self.entitlementId = entitlementId - self.storeProductId = storeProductId - self.plusCatalog = plusCatalog - self.featureType = featureType - self.category = category - self.isPs5Platform = isPs5Platform - } - - /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. PS5/cronos streams the owned - /// PS5 entitlement's OWN id (entitlementId), resolved from the entitlement's platform_id during - /// cross-reference. For a canonical SKU (Red Dead, Alan Wake) that id == product_id == ...PPSA...; - /// for a classic whose product_id is a non-streamable wrapper (Blood Omen) it is the ...PPSA..SLUS - /// license. Never a PS4/CUSA cross-buy id (the platform-disciplined merge guarantees this). - var streamingIdentifier: String { - if serviceType.lowercased() == "pscloud" { - if !entitlementId.isEmpty { return entitlementId } - if !storeProductId.isEmpty { return storeProductId } - } - return id + let cover = g["imageUrl"] as? String ?? "" + self.imageUrl = cover + let landscape = g["landscapeImageUrl"] as? String ?? "" + self.landscapeImageUrl = landscape.isEmpty ? cover : landscape + self.platform = g["platform"] as? String ?? "ps4" + // Contract always sets serviceType; default matches Qt's getServiceType() ("pscloud"). + self.serviceType = g["serviceType"] as? String ?? "pscloud" + self.conceptUrl = g["conceptUrl"] as? String ?? "" + self.conceptId = g["conceptId"] as? String ?? "" + self.isOwned = g["isOwned"] as? Bool ?? false + self.entitlementId = g["entitlementId"] as? String ?? "" + self.storeProductId = g["storeProductId"] as? String ?? "" + self.plusCatalog = g["plusCatalog"] as? Bool ?? false + self.category = g["category"] as? String ?? "" + let sst = g["streamServiceType"] as? String ?? "" + self.streamServiceType = sst.isEmpty ? self.serviceType : sst + let sid = g["streamIdentifier"] as? String ?? "" + self.streamIdentifier = sid.isEmpty ? pid : sid } +} - // Platform that drives the streaming path (PS4 = Kamaji, PS5 = cronos). serviceType is the - // canonical signal but with one asymmetry: `psnow` is always PS3/PS4-class (set on PS Now browse - // rows and filled for owned PS3/PS4 cards from platform_id), while `pscloud` is authoritative ONLY - // for OWNED cards (filled from the entitlement's platform_id) -- non-owned imagic browse rows are - // blanket-labeled `pscloud` yet include a few PS4 titles, so for those we use the clean id token - // (PS4 there streams via PS Now/Kamaji, not cronos). Mirrors canonical Qt, whose non-owned imagic - // rows simply carry no serviceType and so fall through to the same token path. - var streamPlatform: String { - let st = serviceType.lowercased() - if st == "psnow" { return "ps4" } - // isOwned gate: imagic browse rows are blanket-tagged serviceType="pscloud" (see catalog parse), so - // only treat "pscloud" as PS5/cronos when actually OWNED; non-owned rows fall through to the product-id - // token below, routing non-owned PS4 imagic titles to PS Now (matches Qt, whose imagic rows carry no - // serviceType at all). - if st == "pscloud" && isOwned { return "ps5" } - let p = !storeProductId.isEmpty ? storeProductId : (!id.isEmpty ? id : entitlementId) - if p.contains("PPSA") { return "ps5" } - if p.contains("CUSA") { return "ps4" } - return platform.isEmpty ? "ps5" : platform - } +// MARK: - Cloud catalog acquisition categories (lib contract "category" values) - /// Service type to stream with: route by the (platform_id-disciplined) streaming platform -- - /// PS3/PS4 via Kamaji (psnow), PS5 direct (pscloud). - var streamServiceType: String { - if serviceType.lowercased() == "psnow" { return "psnow" } - return streamPlatform == "ps4" ? "psnow" : "pscloud" - } - - /// Identifier to send to startCompleteCloudSession: PS4/psnow sends the product id (Kamaji - /// converts it to an entitlement); PS5/pscloud sends the owned entitlement id (direct). - var streamIdentifier: String { - if streamServiceType == "psnow" { - return id.isEmpty ? streamingIdentifier : id - } - return streamingIdentifier - } +enum CloudCategory { + static let owned = "owned" + static let streamable = "streamable" + static let purchaseable = "purchaseable" } // MARK: - CloudStreamSession (matches Android CloudStreamSession.kt) @@ -132,13 +92,6 @@ struct PsPlusSubscriptionError: Error, LocalizedError { var errorDescription: String? { message } } -/// Account privacy settings issue -struct AccountPrivacySettingsError: Error, LocalizedError { - let upgradeUrl: String - let message: String - var errorDescription: String? { message } -} - /// RTT > 80ms on auto datacenter (matches `gui/src/qml/Main.qml` ping dialog copy). struct PingTimeoutError: Error, LocalizedError { static let alertTitle = "Ping Too High" @@ -193,45 +146,8 @@ enum CloudApiConstants { static let accountBase = "https://ca.account.sony.com/api" } -// MARK: - PS3 / Classics region (mirrors KamajiConsts in gui/include/cloudstreaming/pskamajisession.h) - -/// pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: -/// * SCEA / Americas -> store MSF192018, US-region ids (UP*/NPUA*/BLUS*), -/// PS3 child container "APOLLOPS3GAMES" -/// * SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP*/NPEA*/NPEB*/BLES*), -/// PS3 child container "APOLLOPS3" -/// JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to -/// PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region -/// group, so we must browse + resolve in the account's group. -enum ClassicsRegion { - private static let americas: Set = [ - "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", - "PY", "UY", "CR", "GT", "HN", "NI", "PA", "SV", "DO" - ] - - static func isAmericasClassicsRegion(_ countryCode: String) -> Bool { - return americas.contains(countryCode.uppercased()) - } - - /// Country path to use for container/conversion calls (US for Americas, GB for PAL). - static func classicsStoreCountry(_ accountCountry: String) -> String { - return isAmericasClassicsRegion(accountCountry) ? "US" : "GB" - } - - /// Fully-qualified PS3 catalog container id for the account's region group. - static func classicsPs3ContainerId(_ accountCountry: String) -> String { - return isAmericasClassicsRegion(accountCountry) - ? "STORE-MSF192018-APOLLOPS3GAMES" - : "STORE-MSF192014-APOLLOPS3" - } - - /// Fully-qualified APOLLOROOT (PS Now: PS3 + PS4) container id for the account's region group. - static func apolloRootContainerId(_ accountCountry: String) -> String { - return isAmericasClassicsRegion(accountCountry) - ? "STORE-MSF192018-APOLLOROOT" - : "STORE-MSF192014-APOLLOROOT" - } -} +// Region-group / Classics-container logic now lives in libchiaki (lib/src/cloudcatalog_consts.c) +// and is reflected back to the client via the unified catalog's "fallbackRegion" field. // MARK: - Gaikai Allocation Result @@ -274,8 +190,6 @@ enum CloudLocaleSettings { UserDefaults.standard.string(forKey: preferencesKey) ?? defaultStored } - static var imagicLocale: String { stored.lowercased() } - static func unconfiguredWarning() -> String { "Could not detect your PlayStation region. The catalog may not match your store." } @@ -289,26 +203,6 @@ enum CloudLocaleSettings { return (country, lang) } - /// Ordered store locales to try when fetching the catalog. Sony serves a fixed set of - /// language-COUNTRY combinations: the country is always valid but the language may not be - /// (a Hungarian-language account yields "hu-HU", which 404s, while "en-HU" works). Fall - /// back to English for the same country, then en-US, so the catalog loads in every region. - /// Each tuple is (canonical "ll-CC" for storage, lowercased "ll-cc" for the imagic URL). - static func fallbackChain() -> [(canonical: String, imagic: String)] { - let (country, lang) = parseStorePath(stored) - var seen = Set() - var chain: [(String, String)] = [] - func add(_ l: String, _ c: String) { - let canonical = "\(l)-\(c)" - let imagic = canonical.lowercased() - if seen.insert(imagic).inserted { chain.append((canonical, imagic)) } - } - add(lang, country) - add("en", country) - add("en", "US") - return chain - } - static func fromSession(language: String?, country: String?) -> String? { let lang = language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let cty = country?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -338,6 +232,17 @@ enum CloudLocaleSettings { setStored(locale) } + /// Persist the locale the lib actually settled on (unified catalog "settledLocale"), + /// WITHOUT wiping the cache. The lib owns its own cache invalidation; this only keeps + /// the locale we pass next time (and the streaming language) in sync with the lib. + /// Writes when not yet configured (even when it equals the en-US default, so the + /// "couldn't detect region" banner clears) or when the value changed. + static func noteSettledLocale(_ value: String) { + guard !value.isEmpty, !isConfigured || value != stored else { return } + UserDefaults.standard.set(value, forKey: preferencesKey) + os_log(.info, log: cloudLocaleLog, "Cloud locale settled by lib: %{public}s", value) + } + static func setStored(_ value: String) { if isConfigured && stored == value { return } let wasConfigured = isConfigured diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 6d62a62d..28ab0f70 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -1,33 +1,21 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL -// Cloud catalog fetching - mirrors Android CloudGameRepository.kt + PsnCatalogService.kt + PsCloudCatalogService.kt +// +// Thin wrapper over libchiaki's unified cloud catalog. ALL fetching, OAuth/session +// exchanges, dedup, ownership cross-reference and tagging happen once in the lib +// (chiaki/cloudcatalog.h, shared with Qt and Android). iOS supplies npsso/locale/ +// cache dir and renders the returned contract verbatim — no client-side catalog logic. import Foundation import os.log private let catalogLog = OSLog(subsystem: "com.pylux.stream", category: "CloudCatalog") -/// CloudCatalogService - Fetches and caches game catalogs for both PSNow and PS5 Cloud -/// Mirrors: android/.../cloudplay/repository/CloudGameRepository.kt final class CloudCatalogService { private(set) var lastLibraryFetchError: String? - private(set) var lastLibraryFetchWarning: String? private(set) var lastCatalogFetchWarning: String? - // MARK: - Disk Cache (matches Android: context.cacheDir/cloud_catalog_cache/) - - private static let cacheDuration: TimeInterval = 86400 // 24 hours - private static let psnowCacheFile = "psnow_catalog.json" - private static let ps5PublicCacheFile = "ps5_cloud_catalog_v4.json" // v4: adds plusCatalog tag + broader supplement - private static let pscloudAllCacheFile = "pscloud_catalog_v2.json" - private static let pscloudOwnedCacheFile = "pscloud_owned_v4.json" // v4: serviceType from platform_id - private static let unifiedCacheFile = "unified_catalog_v5.json" // v5: Qt emitOwned productId + QMap-sorted cross-ref merge order - - private static let ownershipSessionWarning = - "Your PlayStation session has expired. Please log in again to see your owned games." - private static let ownershipNetworkWarning = - "Couldn't verify your owned games (network error). Pull to refresh to try again." - + /// Dir handed to the lib; the lib owns every file inside it (browse/library/unified caches). private static var cacheDir: URL = { let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent("cloud_catalog_cache", isDirectory: true) @@ -35,1110 +23,57 @@ final class CloudCatalogService { return dir }() - // MARK: - Cache Read/Write - - private func loadCachedGames(_ filename: String) -> [CloudGame]? { - let file = Self.cacheDir.appendingPathComponent(filename) - guard FileManager.default.fileExists(atPath: file.path) else { return nil } - - // Check age - guard let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), - let modified = attrs[.modificationDate] as? Date else { return nil } - let age = Date().timeIntervalSince(modified) - if age > Self.cacheDuration { - os_log(.info, log: catalogLog, "Cache expired for %{public}s (%.0fs old)", filename, age) - try? FileManager.default.removeItem(at: file) - return nil - } - - // Parse - guard let data = try? Data(contentsOf: file), - let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - os_log(.error, log: catalogLog, "Failed to parse cache file: %{public}s", filename) - try? FileManager.default.removeItem(at: file) - return nil - } - - let games = arr.compactMap { deserializeGame($0) } - os_log(.info, log: catalogLog, "Loaded %d games from cache: %{public}s", games.count, filename) - return games - } - - private func cacheGames(_ games: [CloudGame], filename: String) { - let arr = games.map { serializeGame($0) } - guard let data = try? JSONSerialization.data(withJSONObject: arr, options: []) else { return } - let file = Self.cacheDir.appendingPathComponent(filename) - try? data.write(to: file, options: .atomic) - os_log(.info, log: catalogLog, "Cached %d games to: %{public}s", games.count, filename) - } - - private struct Ps5CloudCatalogResult { - let browseGames: [CloudGame] - let plusLibrarySupplement: [CloudGame] - let productIdAliases: [String: String] - let catalogFetchWarning: String? - let shouldCacheV3: Bool - - init( - browseGames: [CloudGame], - plusLibrarySupplement: [CloudGame], - productIdAliases: [String: String], - catalogFetchWarning: String? = nil, - shouldCacheV3: Bool = true - ) { - self.browseGames = browseGames - self.plusLibrarySupplement = plusLibrarySupplement - self.productIdAliases = productIdAliases - self.catalogFetchWarning = catalogFetchWarning - self.shouldCacheV3 = shouldCacheV3 - } - } - - private func serializeGame(_ g: CloudGame) -> [String: Any] { - return [ - "productId": g.id, "name": g.name, - "imageUrl": g.imageUrl, "landscapeImageUrl": g.landscapeImageUrl, - "platform": g.platform, "serviceType": g.serviceType, - "conceptUrl": g.conceptUrl, "conceptId": g.conceptId, - "isOwned": g.isOwned, - "entitlementId": g.entitlementId, "storeProductId": g.storeProductId, - "plusCatalog": g.plusCatalog, "featureType": g.featureType, - "category": g.category, "isPs5Platform": g.isPs5Platform - ] - } - - private func deserializeGame(_ d: [String: Any]) -> CloudGame? { - guard let pid = d["productId"] as? String, !pid.isEmpty, - let name = d["name"] as? String, !name.isEmpty else { return nil } - return CloudGame( - productId: pid, name: name, - imageUrl: d["imageUrl"] as? String ?? "", - landscapeImageUrl: d["landscapeImageUrl"] as? String ?? "", - platform: { let p = ps5PlatformToken(pid); return p.isEmpty ? (d["platform"] as? String ?? "ps4") : p }(), - serviceType: d["serviceType"] as? String ?? "psnow", - conceptUrl: d["conceptUrl"] as? String ?? "", - conceptId: d["conceptId"] as? String ?? "", - isOwned: d["isOwned"] as? Bool ?? false, - entitlementId: d["entitlementId"] as? String ?? "", - storeProductId: d["storeProductId"] as? String ?? "", - plusCatalog: d["plusCatalog"] as? Bool ?? false, - featureType: (d["featureType"] as? NSNumber)?.intValue ?? 0, - category: d["category"] as? String ?? "", - isPs5Platform: (d["isPs5Platform"] as? Bool) - // Back-compat for caches written before this field existed: fall back to the token. - ?? (pid.contains("PPSA")) - ) - } - - // MARK: - PS5 Cloud Catalog (Public) - - func fetchPs5CloudCatalog(forceRefresh: Bool = false) -> [CloudGame] { - loadPs5CloudCatalog(forceRefresh: forceRefresh).browseGames - } - - private func loadPs5CloudCatalog(forceRefresh: Bool) -> Ps5CloudCatalogResult { - let stored = CloudLocaleSettings.stored - os_log(.info, log: catalogLog, - "PS5 catalog stored=%{public}s forceRefresh=%{public}s", - stored, forceRefresh ? "yes" : "no") - if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { - os_log(.info, log: catalogLog, "PS5 catalog: using disk cache") - lastCatalogFetchWarning = nil - return cached - } - - lastCatalogFetchWarning = nil - // Try the store-locale fallback chain (session locale -> en-COUNTRY -> en-US). A whole - // tier returning nil means it 404'd for an unsupported locale; escalate to the next. - for tier in CloudLocaleSettings.fallbackChain() { - guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: tier.imagic) else { - os_log(.info, log: catalogLog, - "PS5 imagic locale %{public}s failed, trying next tier", tier.imagic) - continue - } - // Persist the locale that actually worked so game details and the cache agree on it. - if tier.canonical != stored { - os_log(.info, log: catalogLog, - "PS5 store locale settled on %{public}s (was %{public}s)", tier.canonical, stored) - CloudLocaleSettings.setStored(tier.canonical) - } - if fetched.shouldCacheV3, - !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { - cachePs5CatalogV3(fetched, locale: tier.canonical) - } - if let warning = fetched.catalogFetchWarning { - lastCatalogFetchWarning = warning - } - return fetched - } - return Ps5CloudCatalogResult( - browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], - shouldCacheV3: false - ) - } - - private func loadCachedPs5CatalogV3(expectedLocale: String) -> Ps5CloudCatalogResult? { - let file = Self.cacheDir.appendingPathComponent(Self.ps5PublicCacheFile) - guard FileManager.default.fileExists(atPath: file.path) else { return nil } - - guard let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), - let modified = attrs[.modificationDate] as? Date else { return nil } - if Date().timeIntervalSince(modified) > Self.cacheDuration { - try? FileManager.default.removeItem(at: file) - return nil - } - - guard let data = try? Data(contentsOf: file), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - try? FileManager.default.removeItem(at: file) - return nil - } - - if let cachedLocale = root["locale"] as? String, !cachedLocale.isEmpty, cachedLocale != expectedLocale { - os_log(.info, log: catalogLog, - "PS5 catalog v3 locale mismatch (%{public}s != %{public}s), refetching", - cachedLocale, expectedLocale) - try? FileManager.default.removeItem(at: file) - return nil - } - - let browseArr = root["games"] as? [[String: Any]] ?? [] - let supplementArr = root["plusLibrarySupplement"] as? [[String: Any]] ?? [] - let browse = browseArr.compactMap { deserializeGame($0) } - let supplement = supplementArr.compactMap { deserializeGame($0) } - let aliases = parseProductIdAliases(root["productIdAliases"] as? [String: Any]) - os_log(.info, log: catalogLog, "Loaded PS5 catalog v3: %d browse, %d supplement, %d aliases", - browse.count, supplement.count, aliases.count) - return Ps5CloudCatalogResult(browseGames: browse, plusLibrarySupplement: supplement, productIdAliases: aliases) - } - - private func cachePs5CatalogV3(_ catalog: Ps5CloudCatalogResult, locale: String) { - var root: [String: Any] = [ - "locale": locale, - "games": catalog.browseGames.map { serializeGame($0) }, - "plusLibrarySupplement": catalog.plusLibrarySupplement.map { serializeGame($0) }, - "total": catalog.browseGames.count - ] - if !catalog.productIdAliases.isEmpty { - root["productIdAliases"] = catalog.productIdAliases - } - guard let data = try? JSONSerialization.data(withJSONObject: root, options: []) else { return } - let file = Self.cacheDir.appendingPathComponent(Self.ps5PublicCacheFile) - try? data.write(to: file, options: .atomic) - os_log(.info, log: catalogLog, "Cached PS5 catalog v3: %d browse, %d supplement, %d aliases", - catalog.browseGames.count, catalog.plusLibrarySupplement.count, catalog.productIdAliases.count) - } - - private func parseProductIdAliases(_ raw: [String: Any]?) -> [String: String] { - guard let raw else { return [:] } - var aliases: [String: String] = [:] - for (alias, value) in raw { - if let canonical = value as? String, !canonical.isEmpty { - aliases[alias] = canonical - } - } - return aliases - } - - private static let ps5ImagicCategoryLists = [ - "plus-games-list", - "ubisoft-classics-list", - "plus-classics-list", - "plus-monthly-games-list", - "free-to-play-list", - "all-ps5-list", - ] - - private func fetchPs5CloudCatalogFromNetwork(locale: String) -> Ps5CloudCatalogResult? { - os_log(.info, log: catalogLog, - "=== Fetching PS5 Cloud Catalog (6 imagic lists) locale=%{public}s ===", locale) - - var byConceptId: [String: [String: Any]] = [:] - var order: [String] = [] - var plusSupplementByProductId: [String: [String: Any]] = [:] - var productIdAliases: [String: String] = [:] - var totalRows = 0 - var failedLists: [String] = [] - var succeededListCount = 0 - var allPs5ListSucceeded = false - - let headers = [ - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ] - - for categoryList in Self.ps5ImagicCategoryLists { - let url = "https://www.playstation.com/bin/imagic/gameslist?locale=\(locale)&categoryList=\(categoryList)" - guard let response = CloudHttpClient.get(url: url, headers: headers), - response.statusCode == 200, - let data = response.body.data(using: .utf8), - let categories = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - os_log(.error, log: catalogLog, "PS5 imagic list fetch failed: %{public}s", categoryList) - failedLists.append(categoryList) - continue - } - - succeededListCount += 1 - if categoryList == "all-ps5-list" { - allPs5ListSucceeded = true - } - - let isPlus = isPlusCatalogList(categoryList) // subscription catalog vs the all-ps5 universe - for category in categories { - guard let gameArray = category["games"] as? [[String: Any]] else { continue } - totalRows += gameArray.count - for gameObj in gameArray { - // Accept PS4 and PS5; the old PS5-only gate dropped PS4-only PS-Plus-catalog - // titles (e.g. God of War 2018) before they could reach the supplement below. - guard isCloudDeviceGame(gameObj) else { continue } - - // Subscription-catalog titles with streamingSupported=false → library-stream - // supplement, captured from EVERY subscription list (not just plus-games-list). - if isPlus, (gameObj["streamingSupported"] as? Bool) != true { - let productId = gameObj["productId"] as? String ?? "" - if !productId.isEmpty, plusSupplementByProductId[productId] == nil { - var g = gameObj; g["plusCatalog"] = true - plusSupplementByProductId[productId] = g - } - continue - } - - guard isCloudStreamingGame(gameObj) else { continue } - let key = editionKey(for: gameObj) // per game per platform (cross-gen split) - let productId = gameObj["productId"] as? String ?? "" - guard !key.isEmpty, !productId.isEmpty else { continue } - - if var existing = byConceptId[key] { - let canonicalProductId = existing["productId"] as? String ?? "" - if !canonicalProductId.isEmpty, productId != canonicalProductId, - productIdAliases[productId] == nil { - productIdAliases[productId] = canonicalProductId - } - if isPlus, (existing["plusCatalog"] as? Bool) != true { - existing["plusCatalog"] = true - byConceptId[key] = existing - } - continue - } - - var g = gameObj; g["plusCatalog"] = isPlus - byConceptId[key] = g - order.append(key) - } - } - } - - if succeededListCount == 0 { - os_log(.error, log: catalogLog, "All PS5 imagic lists failed") - return nil - } - - var catalogFetchWarning: String? - if !failedLists.isEmpty { - catalogFetchWarning = "Some catalog lists failed to load (\(failedLists.joined(separator: ", "))). Catalog may be incomplete." - os_log(.info, log: catalogLog, "PS5 imagic partial fetch; failed: %{public}s", - failedLists.joined(separator: ", ")) - } - - var browseGames: [CloudGame] = [] - for key in order { - guard let gameObj = byConceptId[key], - let cloudGame = cloudGameFromImagic(gameObj) else { continue } - browseGames.append(cloudGame) - } - - let plusLibrarySupplement = plusSupplementByProductId.values.compactMap { cloudGameFromImagic($0) } - - os_log(.info, log: catalogLog, - "PS5 Cloud catalog: %d rows scanned, %d streaming, %d supplement, %d aliases", - totalRows, browseGames.count, plusLibrarySupplement.count, productIdAliases.count) - return Ps5CloudCatalogResult( - browseGames: browseGames, - plusLibrarySupplement: plusLibrarySupplement, - productIdAliases: productIdAliases, - catalogFetchWarning: catalogFetchWarning, - shouldCacheV3: allPs5ListSucceeded - ) - } - - // PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not in these imagic lists). - // A PS4-only title such as God of War (2018) is streamable when owned even though it - // carries device ["PS4"], so the catalog must not discard it. - private func isCloudDeviceGame(_ gameObj: [String: Any]) -> Bool { - guard let devices = gameObj["device"] as? [String] else { return false } - return devices.contains("PS5") || devices.contains("PS4") - } - - private func isCloudStreamingGame(_ gameObj: [String: Any]) -> Bool { - guard (gameObj["streamingSupported"] as? Bool) == true else { return false } - return isCloudDeviceGame(gameObj) - } - - // The PS Plus subscription catalog = these curated lists (≈ what Sony lists). all-ps5-list is - // the full streamable universe and must NOT count as subscription catalog. - private func isPlusCatalogList(_ categoryList: String) -> Bool { - return categoryList == "plus-games-list" || categoryList == "plus-classics-list" - || categoryList == "ubisoft-classics-list" || categoryList == "plus-monthly-games-list" - } - - private func conceptKey(for gameObj: [String: Any]) -> String { - if let conceptId = gameObj["conceptId"] as? Int { return String(conceptId) } - if let conceptId = gameObj["conceptId"] as? Double { return String(Int(conceptId)) } - if let conceptId = gameObj["conceptId"] as? String, !conceptId.isEmpty { return conceptId } - return gameObj["productId"] as? String ?? "" - } - - // Platform token from a product id (CUSA = PS4, PPSA = PS5). - private func ps5PlatformToken(_ productId: String) -> String { - if productId.contains("PPSA") { return "ps5" } - if productId.contains("CUSA") { return "ps4" } - return "" - } - - // Dedupe identity: one entry per game PER PLATFORM, so cross-gen PS4/PS5 editions (e.g. Deliver - // Us The Moon) both appear, while duplicate same-platform SKUs still collapse. - private func editionKey(for gameObj: [String: Any]) -> String { - let c = conceptKey(for: gameObj) - if c.isEmpty { return "" } - return c + "|" + ps5PlatformToken(gameObj["productId"] as? String ?? "") - } - - private func cloudGameFromImagic(_ gameObj: [String: Any]) -> CloudGame? { - let productId = gameObj["productId"] as? String ?? "" - guard !productId.isEmpty else { return nil } - let name = gameObj["name"] as? String ?? "Unknown" - var imageUrl = gameObj["imageUrl"] as? String ?? "" - let conceptUrl = gameObj["conceptUrl"] as? String - ?? gameObj["concept_url"] as? String - ?? gameObj["url"] as? String ?? "" - if imageUrl.hasPrefix("http://") { - imageUrl = imageUrl.replacingOccurrences(of: "http://", with: "https://") - } - return CloudGame( - productId: productId, name: name, - imageUrl: imageUrl, landscapeImageUrl: imageUrl, - // Deliberate Qt<->mobile divergence: Qt leaves imagic browse rows with NO serviceType and derives - // platform from the clean catalog product-id token. Mobile instead blanket-tags imagic rows "pscloud" - // and COMPENSATES with an isOwned gate in streamPlatform (a non-owned "pscloud" row falls back to the - // product-id token, so a non-owned PS4 imagic title still routes to PS Now, not cronos). Both reach the - // same routing -- do NOT naively "fix" one side to match the other. - platform: { let p = ps5PlatformToken(productId); return p.isEmpty ? "ps5" : p }(), serviceType: "pscloud", - conceptUrl: conceptUrl, conceptId: conceptKey(for: gameObj), - isOwned: false, - plusCatalog: gameObj["plusCatalog"] as? Bool ?? false, - // Authoritative PS5-platform membership from the imagic `device` array (NOT the CUSA/PPSA - // token). A cross-gen title with a PS4 CUSA SKU but "PS5" in `device` is a PS5 browse row - // and must enter the streamable universe (mirrors Qt isPs5PlatformGame). - isPs5Platform: isPs5PlatformGame(gameObj) - ) - } - - // PS5-platform membership for an imagic browse object: a PPSA product id OR "PS5" in the - // authoritative `device` array. Mirrors Qt isPs5PlatformGame() (cloudcatalogbackend.cpp). - private func isPs5PlatformGame(_ gameObj: [String: Any]) -> Bool { - let pid = (gameObj["productId"] as? String) ?? "" - if pid.contains("PPSA") { return true } - if let devices = gameObj["device"] as? [String], devices.contains("PS5") { return true } - return false - } - - // MARK: - PS5 Cloud Library: All Games (matches Android fetchPs5CloudCatalog with ownership) - - /// Fetch ALL PS5 Cloud games with ownership flags. - /// Mirrors Qt: fetchPs5CloudCatalog + getOwnedPs5CloudGames + CloudPlayView All tab - func fetchAllPs5CloudGames(npssoToken: String, forceRefresh: Bool = false) -> [CloudGame] { - lastLibraryFetchError = nil - lastLibraryFetchWarning = nil - CloudLocaleSettings.ensureConfigured(npssoToken: npssoToken) - - if !forceRefresh, let cached = loadCachedGames(Self.pscloudAllCacheFile) { - os_log(.info, log: catalogLog, "Returning %d PS5 games from cache (ownership included)", cached.count) - return cached - } - - os_log(.info, log: catalogLog, "=== Fetching ALL PS5 Cloud Games (with ownership) ===") - - let catalog = loadPs5CloudCatalog(forceRefresh: forceRefresh) - guard !catalog.browseGames.isEmpty || !catalog.plusLibrarySupplement.isEmpty else { return [] } - - guard let ownedCrossRef = getOwnedPs5CloudGames( - npssoToken: npssoToken, - publicCatalog: catalog.browseGames, - plusLibrarySupplement: catalog.plusLibrarySupplement, - productIdAliases: catalog.productIdAliases - ) else { - os_log(.info, log: catalogLog, - "Entitlements fetch failed; returning browse catalog without ownership") - lastLibraryFetchWarning = - "Failed to verify game ownership. Some games may show as not owned." - let browseGames = catalog.browseGames - if !browseGames.isEmpty { - cacheGames(browseGames, filename: Self.pscloudAllCacheFile) - } - return browseGames - } - let allGames = PsCloudOwnership.mergeOwnedIntoBrowseCatalog( - browseCatalog: catalog.browseGames, - ownedCrossRef: ownedCrossRef - ) - - if !allGames.isEmpty { - cacheGames(allGames, filename: Self.pscloudAllCacheFile) - } - - let ownedCount = allGames.filter { $0.isOwned }.count - os_log(.info, log: catalogLog, "PS5 Library: %d total, %d owned", allGames.count, ownedCount) - return allGames - } - - // MARK: - PS Plus Subscription Catalog (Catalog tab) - - /// The PS Plus subscription catalog: plusCatalog browse titles + the library-stream supplement - /// (the ~630 set Sony lists), NOT the full all-ps5 universe. No ownership fetch — every - /// subscription title is shown as streamable. Mirrors Qt ps5PlusCatalogGames + Catalog tab. - func fetchPlusCatalogGames(npssoToken: String = "", forceRefresh: Bool = false) -> [CloudGame] { - let catalog = loadPs5CloudCatalog(forceRefresh: forceRefresh) - var games = catalog.browseGames.filter { $0.plusCatalog } - games.append(contentsOf: catalog.plusLibrarySupplement) - games.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - // Mark which subscription titles you already own, so owned games show "Stream" and non-owned - // show "Add Game" (they must be added to your library first). addUnmatched:false keeps the - // Catalog the pure subscription set (mark only; never add owned-but-uncatalogued games). - guard !npssoToken.isEmpty else { return games } - let owned = fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: forceRefresh) - return PsCloudOwnership.mergeOwnedIntoBrowseCatalog( - browseCatalog: games, ownedCrossRef: owned, addUnmatched: false) - } - - // MARK: - PS5 Cloud Library: Owned Only - - /// Mirrors CloudCatalogBackend::getOwnedPs5CloudGames (owned tab) - func fetchOwnedPs5Games(npssoToken: String, forceRefresh: Bool = false) -> [CloudGame] { - lastLibraryFetchError = nil - lastLibraryFetchWarning = nil - CloudLocaleSettings.ensureConfigured(npssoToken: npssoToken) - - guard !npssoToken.isEmpty else { return [] } - - if !forceRefresh, let cached = loadCachedGames(Self.pscloudOwnedCacheFile) { - os_log(.info, log: catalogLog, "Returning %d owned PS5 games from cache", cached.count) - return cached - } - - os_log(.info, log: catalogLog, "=== Fetching Owned PS5 Games Only ===") - - let catalog = loadPs5CloudCatalog(forceRefresh: forceRefresh) - guard let owned = getOwnedPs5CloudGames( - npssoToken: npssoToken, - publicCatalog: catalog.browseGames, - plusLibrarySupplement: catalog.plusLibrarySupplement, - productIdAliases: catalog.productIdAliases - ) else { - lastLibraryFetchError = "Failed to fetch owned games. Check your connection." - return [] - } - - if !owned.isEmpty { - cacheGames(owned, filename: Self.pscloudOwnedCacheFile) - } - - os_log(.info, log: catalogLog, "Owned streaming games: %d", owned.count) - return owned - } - - /// Mirrors CloudCatalogBackend::getOwnedPs5CloudGames orchestration (network path). - private func getOwnedPs5CloudGames( - npssoToken: String, - publicCatalog: [CloudGame], - plusLibrarySupplement: [CloudGame] = [], - productIdAliases: [String: String] = [:], - psnowCatalog: [CloudGame] = [] - ) -> [CloudGame]? { - guard !npssoToken.isEmpty, - let oauthToken = fetchOwnedGamesOAuthToken(npssoToken: npssoToken) else { - return nil - } - - Thread.sleep(forTimeInterval: PsCloudOwnership.pageCooldownSeconds) - guard let rawObjects = fetchEntitlementsPaginated(oauthToken: oauthToken) else { - return nil - } - let rawEntitlements = rawObjects.compactMap { PsCloudOwnership.parseEntitlement($0) } - let filtered = PsCloudOwnership.filterOwnedPs5Games(rawEntitlements) - - // Map each bundle product_id -> the entitlement ids sharing it, so a bundle (e.g. RE7 Gold) - // expands to its component games during cross-reference (upstream PR #15 bundle-sibling match). - var componentIds: [String: [String]] = [:] - for ent in rawEntitlements where !ent.productId.isEmpty && !ent.id.isEmpty { - componentIds[ent.productId, default: []].append(ent.id) - } - - let combinedCatalog = psnowCatalog.isEmpty ? publicCatalog : psnowCatalog + publicCatalog - return PsCloudOwnership.crossReferenceOwnedGames( - filteredEntitlements: filtered, - publicCatalog: combinedCatalog, - plusLibrarySupplement: plusLibrarySupplement, - productIdAliases: productIdAliases, - componentIdsByProductId: componentIds - ) - } - - private func fetchOwnedGamesOAuthToken(npssoToken: String) -> String? { - let scope = "kamaji:get_internal_entitlements user:account.attributes.validate" - let redirectUri = CloudApiConstants.kamajiRedirectUri - let clientId = "dc523cc2-b51b-4190-bff0-3397c06871b3" - - let query = "response_type=token&scope=\(scope.cloudUrlEncoded)&client_id=\(clientId)&redirect_uri=\(redirectUri.cloudUrlEncoded)&service_entity=urn%3Aservice-entity%3Apsn&prompt=none" - let url = "\(CloudApiConstants.accountBase)/v1/oauth/authorize?\(query)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "Cookie": "npsso=\(npssoToken)", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ], followRedirects: false), response.statusCode == 302, - let location = CloudHttpClient.extractLocation(from: response), - let range = location.range(of: "access_token=") else { return nil } - - let rest = String(location[range.upperBound...]) - return rest.split(separator: "&").first.map(String.init) - } - - private func fetchEntitlementsPaginated(oauthToken: String) -> [[String: Any]]? { - var all: [[String: Any]] = [] - var start = 0 - - while true { - let url = "https://commerce.api.np.km.playstation.net/commerce/api/v1/users/me/internal_entitlements?fields=game_meta&entitlement_type=5&start=\(start)&size=\(PsCloudOwnership.pageSize)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "Authorization": "Bearer \(oauthToken)", - "Accept": "application/json" - ]), response.statusCode == 200, - let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let page = json["entitlements"] as? [[String: Any]] else { - os_log(.error, log: catalogLog, "Entitlements page failed at start=%d", start) - return nil - } - - all.append(contentsOf: page) - if page.count < PsCloudOwnership.pageSize { break } - start += page.count - Thread.sleep(forTimeInterval: PsCloudOwnership.pageCooldownSeconds) - } - - return all - } - - // MARK: - Unified Catalog - - /// Unified cloud catalog: ONE merged, deduped, tagged list across PS3/PS4 (PS Now/Kamaji) and - /// PS5 (imagic/Gaikai). Mirrors Android CloudGameRepository.fetchUnifiedCatalog(). + /// Unified cloud catalog: ONE merged, deduped, tagged list across PS3/PS4 (PS Now) and + /// PS5 (cloud). Blocking — call from a background queue. The lib serves an on-disk cache + /// hit with no network I/O; `forceRefresh` bypasses it. func fetchUnifiedCatalog(npssoToken: String, forceRefresh: Bool = false) -> [CloudGame] { lastLibraryFetchError = nil - lastLibraryFetchWarning = nil lastCatalogFetchWarning = nil - CloudLocaleSettings.ensureConfigured(npssoToken: npssoToken) - - if !forceRefresh, let cached = loadCachedGames(Self.unifiedCacheFile) { - os_log(.info, log: catalogLog, "Returning %d unified games from cache", cached.count) - return cached - } - - let accountCountry = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored).country - - // --- 1) PS Now APOLLOROOT (PS3 + PS4): native, else region-group fallback ---------- - let native = fetchNativeCatalog(npssoToken: npssoToken) - var apolloGames: [CloudGame] = [] - var nativeMode = false - var fallbackRegion = "" - if native.storesAvailable && !native.games.isEmpty { - apolloGames = native.games - nativeMode = true - } else if native.authError { - // Expired session: surface the re-login prompt. Do NOT fall back to the public - // APOLLOROOT walk -- that path is only for region-unsupported accounts (auth OK, - // /user/stores 404). Falling back here would mask the expired token. - lastCatalogFetchWarning = Self.ownershipSessionWarning - } else { - apolloGames = tryApolloRootFallback(accountCountry: accountCountry) - if !apolloGames.isEmpty { - fallbackRegion = ClassicsRegion.classicsStoreCountry(accountCountry) - } - } - SecureStore.shared.cloudFallbackRegion = fallbackRegion - os_log(.info, log: catalogLog, - "PS Now APOLLOROOT: %d games (nativeMode=%{public}s, fallbackRegion='%{public}s')", - apolloGames.count, nativeMode ? "true" : "false", fallbackRegion) - // --- 2) imagic PS5 catalog (browse + supplement + aliases) ------------------------- - let imagic: Ps5CloudCatalogResult - do { - imagic = try fetchPs5CatalogV3(stored: CloudLocaleSettings.stored, forceRefresh: forceRefresh) - } catch { - os_log(.error, log: catalogLog, "imagic PS5 catalog fetch failed: %{public}s", error.localizedDescription) - if apolloGames.isEmpty { - lastLibraryFetchError = "Failed to fetch catalog: \(error.localizedDescription)" - return [] - } - imagic = Ps5CloudCatalogResult( - browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], - shouldCacheV3: false - ) - } - - // --- 3) owned cross-reference (skip on expired token) ------------------------------ - var owned: [CloudGame] = [] - if !npssoToken.isEmpty && !native.authError { - if let crossRef = getOwnedPs5CloudGames( - npssoToken: npssoToken, - publicCatalog: imagic.browseGames, - plusLibrarySupplement: imagic.plusLibrarySupplement, - productIdAliases: imagic.productIdAliases, - psnowCatalog: apolloGames - ) { - owned = crossRef - } else { - os_log(.info, log: catalogLog, - "Ownership cross-reference failed; showing as not owned") - lastCatalogFetchWarning = Self.ownershipNetworkWarning - } - } - - // --- 4) assemble the streamable universe (PS Now PS3/PS4 + imagic PS5) ------------- - // Browse rows enter the universe when PS5-platform by the authoritative `device` array - // (isPs5Platform), NOT the CUSA/PPSA productId token -- the token drops cross-gen titles - // that carry a PS4 CUSA SKU but list "PS5" in `device` (e.g. the indie bundles). Skip rows - // already in the Apollo (PS Now) catalog: the apollo row already represents them, so adding - // the imagic browse copy would emit a duplicate streamable row (Crow Country / Grandia / - // HUMANITY appear in BOTH the APOLLOROOT walk and the imagic PS5 list). Mirrors Qt - // assembleUnifiedCatalog (cloudcatalogbackend.cpp). - let apolloProductIds = Set(apolloGames.map { $0.id }) - let ps5Browse = imagic.browseGames.filter { - $0.isPs5Platform && !apolloProductIds.contains($0.id) - } - let universe = apolloGames + ps5Browse - var games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog( - browseCatalog: universe, ownedCrossRef: owned, addUnmatched: true + var errorMessage: NSString? + let json = PyluxCloudCatalog.fetchUnifiedJSON( + withNpsso: npssoToken.isEmpty ? nil : npssoToken, + locale: CloudLocaleSettings.stored, + cacheDir: Self.cacheDir.path, + forceRefresh: forceRefresh, + errorMessage: &errorMessage ) - // --- 5) concept-sibling streamability gate (native mode only) ---------------------- - if nativeMode { - let index = PsCloudOwnership.StreamabilityIndex( - apolloCatalog: apolloGames, - imagicBrowse: imagic.browseGames, - imagicConceptRows: imagic.browseGames + imagic.plusLibrarySupplement - ) - games = PsCloudOwnership.applyStreamabilityGate(games, index: index) - } - - // --- 6) tag + cache ---------------------------------------------------------------- - games = games.map { game in - var tagged = game - tagged.category = PsCloudOwnership.categoryFor(game) - return tagged - } - if !games.isEmpty && !native.authError && !isOwnershipVerificationFailure(lastCatalogFetchWarning) { - cacheGames(games, filename: Self.unifiedCacheFile) - } - if let warning = imagic.catalogFetchWarning, lastCatalogFetchWarning == nil { - lastCatalogFetchWarning = warning - } - return games - } - - private func fetchPs5CatalogV3(stored: String, forceRefresh: Bool) throws -> Ps5CloudCatalogResult { - if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { - return cached - } - // NOTE: do not reset lastCatalogFetchWarning here. The unified fetch already cleared it - // at the top, and an ownership/session warning set earlier (e.g. expired-token) must - // survive this call so the re-login prompt reaches the UI. The imagic warning is still - // surfaced by the caller via imagic.catalogFetchWarning when no higher-priority warning. - for tier in CloudLocaleSettings.fallbackChain() { - guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: tier.imagic) else { continue } - if tier.canonical != stored { - CloudLocaleSettings.setStored(tier.canonical) - } - if fetched.shouldCacheV3, - !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { - cachePs5CatalogV3(fetched, locale: tier.canonical) - } - return fetched - } - throw NSError(domain: "CloudCatalog", code: 1, - userInfo: [NSLocalizedDescriptionKey: "All imagic locales failed to load"]) - } - - private func tryApolloRootFallback(accountCountry: String) -> [CloudGame] { - do { - return try fetchApolloRootCatalog(accountCountry: accountCountry) - } catch { - os_log(.info, log: catalogLog, "APOLLOROOT region-group fallback failed: %{public}s", - error.localizedDescription) + guard let json else { + lastLibraryFetchError = (errorMessage as String?) ?? "Failed to fetch cloud catalog. Check your connection." + os_log(.error, log: catalogLog, "Unified catalog fetch failed: %{public}s", lastLibraryFetchError ?? "") return [] } - } - - private func isOwnershipVerificationFailure(_ warning: String?) -> Bool { - warning == Self.ownershipSessionWarning || warning == Self.ownershipNetworkWarning - } - - // MARK: - PSNow Catalog - - /// Native PS Now catalog fetch (one APOLLOROOT walk: PS3 + PS4) using the account's own - /// /user/stores base_url. Mirrors Android PsnCatalogService.fetchNativeCatalog(). - func fetchNativeCatalog(npssoToken: String) -> (storesAvailable: Bool, authError: Bool, games: [CloudGame]) { - os_log(.info, log: catalogLog, "=== Starting PSNow (APOLLOROOT) native catalog fetch ===") - let duid = generateDuid() - - guard let oauthCode = fetchPsnowOAuthCode(npssoToken: npssoToken, duid: duid) else { - return (false, true, []) - } - guard let sessionId = createPsnowKamajiSession(oauthCode: oauthCode, duid: duid) else { - return (false, true, []) - } - guard let baseUrl = fetchPsnowStores(sessionId: sessionId) else { - return (false, false, []) - } - guard let categoryUrls = fetchPsnowRootContainer(baseUrl: baseUrl, sessionId: sessionId) else { - return (true, false, []) - } - - var allGames: [CloudGame] = [] - for (name, url) in categoryUrls { - os_log(.info, log: catalogLog, "Fetching category: %{public}s", name) - allGames += fetchPsnowCategoryGames(url: url) - } - - os_log(.info, log: catalogLog, "=== PSNow native catalog complete: %d games ===", allGames.count) - return (true, false, allGames) - } - - /// Fallback PS Now catalog fetch: walk the PUBLIC region-group APOLLOROOT container directly - /// (no OAuth/session). Mirrors Android PsnCatalogService.fetchApolloRootCatalog(). - func fetchApolloRootCatalog(accountCountry: String) throws -> [CloudGame] { - let storeCountry = ClassicsRegion.classicsStoreCountry(accountCountry) - let containerId = ClassicsRegion.apolloRootContainerId(accountCountry) - let containerUrl = "\(CloudApiConstants.storeBase)/container/\(storeCountry)/en/19/\(containerId)" - - os_log(.info, log: catalogLog, - "=== Fetching APOLLOROOT catalog (region group %{public}s for account %{public}s) ===", - storeCountry, accountCountry) - - var games: [CloudGame] = [] - var start = 0 - var totalResults = -1 - - while true { - let url = "\(containerUrl)?useOffers=true&gkb=1&gkb2=1&start=\(start)&size=100" - guard let response = CloudHttpClient.get(url: url, headers: [ - "Accept": "application/json", - "User-Agent": CloudApiConstants.kamajiUserAgent - ]) else { - if games.isEmpty { - throw NSError(domain: "CloudCatalog", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to fetch APOLLOROOT catalog"]) - } - break - } - - if response.statusCode != 200 { - os_log(.info, log: catalogLog, "APOLLOROOT page fetch failed (HTTP %d)", response.statusCode) - if games.isEmpty { - throw NSError(domain: "CloudCatalog", code: response.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Failed to fetch APOLLOROOT catalog: HTTP \(response.statusCode)"]) - } - break - } - - guard let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { break } - - if totalResults < 0 { - totalResults = (json["total_results"] as? Int) - ?? (json["total_results"] as? NSNumber)?.intValue ?? 0 - } - - let links = json["links"] as? [[String: Any]] ?? [] - var productCount = 0 - for link in links { - guard (link["container_type"] as? String) == "product" else { continue } - guard let game = parsePsnowGameObject(link) else { continue } - games.append(game) - productCount += 1 - } - - os_log(.info, log: catalogLog, - "APOLLOROOT page products: %d, accumulated: %d of %d", - productCount, games.count, totalResults) - - start += 100 - if productCount == 0 || start >= totalResults { break } - } - os_log(.info, log: catalogLog, "APOLLOROOT catalog complete: %d titles", games.count) - return games + return parseUnifiedCatalog(json) } - /// Fetch PSNow catalog (PS3/PS4 games) — legacy per-tab path; prefer fetchUnifiedCatalog(). - func fetchPsnowCatalog(npssoToken: String, forceRefresh: Bool = false) -> [CloudGame] { - if !forceRefresh, let cached = loadCachedGames(Self.psnowCacheFile) { - return cached - } - - os_log(.info, log: catalogLog, "=== Fetching PSNow Catalog ===") - let duid = generateDuid() - - guard let oauthCode = fetchPsnowOAuthCode(npssoToken: npssoToken, duid: duid) else { - os_log(.error, log: catalogLog, "PSNow OAuth failed") - return [] - } - guard let sessionId = createPsnowKamajiSession(oauthCode: oauthCode, duid: duid) else { - os_log(.error, log: catalogLog, "PSNow Kamaji session failed") - return [] - } - guard let baseUrl = fetchPsnowStores(sessionId: sessionId) else { - os_log(.error, log: catalogLog, "PSNow stores fetch failed") - return [] - } - guard let categoryUrls = fetchPsnowRootContainer(baseUrl: baseUrl, sessionId: sessionId) else { - os_log(.error, log: catalogLog, "PSNow root container failed") + private func parseUnifiedCatalog(_ json: String) -> [CloudGame] { + guard let data = json.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + lastLibraryFetchError = "Failed to parse cloud catalog." return [] } - var allGames: [CloudGame] = [] - for (name, url) in categoryUrls { - os_log(.info, log: catalogLog, "Fetching category: %{public}s", name) - allGames += fetchPsnowCategoryGames(url: url) - } - - os_log(.info, log: catalogLog, "PSNow catalog: %d games", allGames.count) - if !allGames.isEmpty { cacheGames(allGames, filename: Self.psnowCacheFile) } - return allGames - } - - // MARK: - PS3 Classics Catalog (public Apollo container walk) - // - // The PS Plus PC ("Apollo") app browses the streamable catalog through the public pcnow - // container API at psnow.playstation.com. There is a dedicated PS3 container (e.g. - // STORE-MSF192018-APOLLOPS3GAMES for Americas) that lists ~300 streamable PS3 titles with - // their PS3 product ids (NPUA/NPUB/BLUS/BCUS) — none of which appear in the imagic gameslist - // the rest of the catalog uses. The container API needs no OAuth or per-account session - // (unlike /user/stores, which 404s in regions where the PC app is unavailable, e.g. Hungary), - // so we can walk it directly in any region. The resulting titles carry playable_platform - // ["PS3"] and stream via the existing PSNOW -> Gaikai konan path. - - /// Resolve the account's region group from its stored store locale (e.g. "en-HU" -> "HU"). - /// pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is - /// authorized at Gaikai only for the family of its own region group, so the catalog must be - /// browsed in that group. See ClassicsRegion.classicsStoreCountry / classicsPs3ContainerId. - private func ps3AccountCountry() -> String { - return CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored).country - } - - /// Fetch the streamable PS3 Classics from the public Apollo container for the account's - /// region group. Deprecated: APOLLOROOT already includes PS3; use fetchUnifiedCatalog(). - @available(*, deprecated, message: "Use fetchUnifiedCatalog(); APOLLOROOT includes PS3") - func fetchPs3Catalog(forceRefresh: Bool = false) -> [CloudGame] { - let country = ps3AccountCountry() - let storeCountry = ClassicsRegion.classicsStoreCountry(country) - // Region-group-specific cache key so an Americas/PAL switch doesn't serve stale ids. - let cacheFile = "ps3_catalog_\(storeCountry).json" - if !forceRefresh, let cached = loadCachedGames(cacheFile) { - os_log(.info, log: catalogLog, "Returning %d PS3 Classics from cache", cached.count) - return cached - } - - let containerId = ClassicsRegion.classicsPs3ContainerId(country) - let containerUrl = "\(CloudApiConstants.storeBase)/container/\(storeCountry)/en/19/\(containerId)" - os_log(.info, log: catalogLog, - "=== Fetching PS3 Classics catalog (region group %{public}s for account country %{public}s) ===", - storeCountry, country) - - var allGames: [CloudGame] = [] - var start = 0 - var totalResults = -1 - - while true { - let url = "\(containerUrl)?useOffers=true&gkb=1&gkb2=1&start=\(start)&size=100" - guard let response = CloudHttpClient.get(url: url, headers: [ - "Accept": "application/json", - "User-Agent": CloudApiConstants.kamajiUserAgent - ]), response.statusCode == 200, - let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - os_log(.error, log: catalogLog, "PS3 catalog page failed at start=%d", start) - break - } - - if totalResults < 0 { - totalResults = (json["total_results"] as? Int) - ?? (json["total_results"] as? NSNumber)?.intValue ?? 0 - } - - let links = json["links"] as? [[String: Any]] ?? [] - var productCount = 0 - for link in links { - guard (link["container_type"] as? String) == "product" else { continue } - guard let game = parsePsnowGameObject(link) else { continue } - allGames.append(game) - productCount += 1 - } - - os_log(.info, log: catalogLog, "PS3 page games: %d accumulated: %d of %d", - productCount, allGames.count, totalResults) - - start += 100 - if productCount == 0 || start >= totalResults { break } - } - - os_log(.info, log: catalogLog, "PS3 Classics catalog: %d titles", allGames.count) - if !allGames.isEmpty { cacheGames(allGames, filename: cacheFile) } - return allGames - } - - // MARK: - PSNow helpers - - private func fetchPsnowOAuthCode(npssoToken: String, duid: String) -> String? { - let params: [(String, String)] = [ - ("smcid", "pc:psnow"), ("applicationId", "psnow"), - ("response_type", "code"), ("scope", CloudApiConstants.ps4Scopes), - ("client_id", CloudApiConstants.kamajiClientId), - ("redirect_uri", CloudApiConstants.kamajiRedirectUri), - ("service_entity", "urn:service-entity:psn"), ("prompt", "none"), - ("renderMode", "mobilePortrait"), ("hidePageElements", "forgotPasswordLink"), - ("displayFooter", "none"), ("disableLinks", "qriocityLink"), - ("mid", "PSNOW"), ("duid", duid), ("layout_type", "popup"), - ("service_logo", "ps"), ("tp_psn", "true"), ("noEVBlock", "true") - ] - let query = params.map { "\($0.0)=\($0.1.cloudUrlEncoded)" }.joined(separator: "&") - let url = "\(CloudApiConstants.accountBase)/v1/oauth/authorize?\(query)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "Cookie": "npsso=\(npssoToken)" - ], followRedirects: false), response.statusCode == 302, - let location = CloudHttpClient.extractLocation(from: response), - let comps = URLComponents(string: location), - let code = comps.queryItems?.first(where: { $0.name == "code" })?.value else { return nil } - return code - } - - private func createPsnowKamajiSession(oauthCode: String, duid: String) -> String? { - let url = "\(CloudApiConstants.kamajiBase)/user/session" - let body = "code=\(oauthCode)&client_id=\(CloudApiConstants.kamajiClientId)&duid=\(duid)" - - guard let response = CloudHttpClient.post(url: url, body: body, headers: [ - "Content-Type": "text/plain;charset=UTF-8", - "X-Alt-Referer": CloudApiConstants.kamajiRedirectUri, - "Origin": CloudApiConstants.kamajiOrigin, - "Referer": CloudApiConstants.kamajiReferer, - "Accept": "*/*" - ]), response.statusCode == 200 else { return nil } - - CloudLocaleSettings.applyLocaleFromKamajiSessionBody(response.body) - return CloudHttpClient.extractCookie(from: response, name: "JSESSIONID") - } - - private func fetchPsnowStores(sessionId: String) -> String? { - let url = "\(CloudApiConstants.kamajiBase)/user/stores" - guard let response = CloudHttpClient.get(url: url, headers: [ - "Cookie": "JSESSIONID=\(sessionId)", - "Origin": CloudApiConstants.kamajiOrigin, - "Referer": CloudApiConstants.kamajiReferer, - "Accept": "application/json" - ]), response.statusCode == 200, - let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let dataObj = json["data"] as? [String: Any], - let baseUrl = dataObj["base_url"] as? String else { return nil } - return baseUrl - } - - private static let categoryPatterns = ["A - B", "C - D", "E - G", "H - L", "M - O", "P - R", "S", "T", "U - Z"] - - private func fetchPsnowRootContainer(baseUrl: String, sessionId: String) -> [(String, String)]? { - let url = "\(baseUrl)?size=100" - guard let response = CloudHttpClient.get(url: url, headers: [ - "Cookie": "JSESSIONID=\(sessionId)", - "Origin": CloudApiConstants.kamajiOrigin, - "Referer": CloudApiConstants.kamajiReferer, - "Accept": "application/json" - ]), response.statusCode == 200, - let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let links = json["links"] as? [[String: Any]] else { return nil } - - var result: [(String, String)] = [] - for link in links { - guard let name = link["name"] as? String, - let url = link["url"] as? String, - Self.categoryPatterns.contains(name) else { continue } - result.append((name, url)) + // The lib resolves the working store locale and region group; reflect them back so the + // streaming path (which reads CloudLocaleSettings.stored) and the region banner agree. + if let settled = root["settledLocale"] as? String { + CloudLocaleSettings.noteSettledLocale(settled) } - return result - } - - private func fetchPsnowCategoryGames(url categoryUrl: String) -> [CloudGame] { - let url = categoryUrl.contains("?") ? "\(categoryUrl)&start=0&size=500" : "\(categoryUrl)?start=0&size=500" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "Accept": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ]), response.statusCode == 200, - let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let links = json["links"] as? [[String: Any]] else { return [] } - - return links.compactMap { parsePsnowGameObject($0) } - } - - private func parsePsnowGameObject(_ obj: [String: Any]) -> CloudGame? { - guard let productId = obj["id"] as? String, !productId.isEmpty, - let name = obj["name"] as? String, !name.isEmpty else { return nil } + SecureStore.shared.cloudFallbackRegion = root["fallbackRegion"] as? String ?? "" - let (coverUrl, landscapeUrl) = extractImageUrls(from: obj) - var cover = coverUrl, landscape = landscapeUrl - if cover.hasPrefix("http://") { cover = cover.replacingOccurrences(of: "http://", with: "https://") } - if landscape.hasPrefix("http://") { landscape = landscape.replacingOccurrences(of: "http://", with: "https://") } - - var platform = "ps4" - if let platforms = obj["playable_platform"] as? [String] { - for p in platforms { - if p.localizedCaseInsensitiveContains("PS3") { platform = "ps3"; break } - if p.localizedCaseInsensitiveContains("PS4") { platform = "ps4" } - } - } - - return CloudGame(productId: productId, name: name, imageUrl: cover, - landscapeImageUrl: landscape, platform: platform) - } - - private func extractImageUrls(from obj: [String: Any]) -> (String, String) { - guard let images = obj["images"] as? [[String: Any]] else { - let fallback = obj["imageUrl"] as? String ?? "" - return (fallback, fallback) + if let warning = root["warning"] as? String, !warning.isEmpty { + lastCatalogFetchWarning = warning } - var cover = "", landscape = "" - for img in images { - let type = img["type"] as? Int ?? -1 - let url = img["url"] as? String ?? "" - if url.isEmpty { continue } - if type == 10 && cover.isEmpty { cover = url } - else if type == 12 && landscape.isEmpty { landscape = url } - else if type == 13 && landscape.isEmpty { landscape = url } + let gamesArr = root["games"] as? [[String: Any]] ?? [] + let games = gamesArr.compactMap { CloudGame(contract: $0) } + os_log(.info, log: catalogLog, "Unified catalog: %d games (%d owned)", + games.count, games.filter { $0.isOwned }.count) + if games.isEmpty && lastLibraryFetchError == nil { + lastLibraryFetchError = "No cloud games found. Check your connection." } - if landscape.isEmpty && !cover.isEmpty { landscape = cover } - if cover.isEmpty && !landscape.isEmpty { cover = landscape } - return (cover, landscape) - } - - private func generateDuid() -> String { - let prefix = "0000000700410080" - var randomBytes = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, 16, &randomBytes) - return prefix + randomBytes.map { String(format: "%02x", $0) }.joined() + return games } } diff --git a/ios/Pylux/Services/PSGaikaiStreaming.swift b/ios/Pylux/Services/PSGaikaiStreaming.swift index edee59e3..82a5402d 100644 --- a/ios/Pylux/Services/PSGaikaiStreaming.swift +++ b/ios/Pylux/Services/PSGaikaiStreaming.swift @@ -753,7 +753,15 @@ final class PSGaikaiStreaming { // Common fields spec["entitlementId"] = entitlementId spec["npEnv"] = "np" - let cloudLanguage = CloudLocaleSettings.stored + // Gaikai expects the bare language code ("de"), not the stored locale + // ("de-DE"); the lib helper is the single source of truth across platforms. + // Use the user's chosen streaming language, falling back to the detected + // catalog locale when unset. + let chosenLocale = { + let l = StreamPreferences.load().cloudLanguage + return l.isEmpty ? CloudLocaleSettings.stored : l + }() + let cloudLanguage = PyluxCloudCatalog.gaikaiLanguage(forLocale: chosenLocale) spec["language"] = cloudLanguage os_log(.info, log: gkLog, "Gaikai request language: %{public}s", cloudLanguage) spec["cloudEndpoint"] = "https://cc.prod.gaikai.com" diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift deleted file mode 100644 index 00917d7a..00000000 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ /dev/null @@ -1,686 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -import Foundation -import os.log - -private let ownershipLog = OSLog(subsystem: "com.pylux.stream", category: "CloudOwnership") - -/// Raw entitlement fields from Sony internal_entitlements API. -struct PsCloudEntitlement { - let id: String - let productId: String - let skuId: String // PSN sku_id -- stable unique key for deterministic dedupe tie-breaking - let activeFlag: Bool - let packageType: String - let name: String - let conceptId: String - let featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC - // Structured platform from entitlement_attributes[].platform_id ("ps5"/"ps4"/"ps3"). This is the - // authoritative stream-backend signal -- NOT a CUSA/PPSA id prefix, since a cross-buy PS4 license - // can carry a PS5-looking product_id wrapper (Red Dead's PS4 license has product_id ...PPSA30528). - let platformId: String -} - -enum PsCloudOwnership { - static let pageSize = 300 - static let pageCooldownSeconds: TimeInterval = 0.1 - - private struct CatalogIndex { - var byProductId: [String: Int] = [:] - var byConceptId: [String: Int] = [:] - } - - static func filterOwnedPs5Games(_ entitlements: [PsCloudEntitlement]) -> [PsCloudEntitlement] { - entitlements.filter { ent in - // Previously required packageType == "PSGD" (PS5 only), which dropped owned - // PS4 titles (e.g. God of War 2018) and PS3 titles. Accept every active game - // entitlement; streamability is enforced downstream by the catalog cross-reference - // (matches are deduped by conceptId), so non-streamable / add-on entitlements are - // harmlessly dropped there. - guard ent.activeFlag else { return false } - let pid = ent.productId - guard !pid.hasPrefix("IP"), !pid.hasPrefix("SUB") else { return false } - // Hide EXTRAS: feature_type==0 is DLC / add-ons / themes / avatars / cross-buy "tracks", - // never a base game (games are feature_type 1=trial/free or 3/5=full). Safe: can't hide a - // game. Trials/free and full games are kept; the trial-vs-full split is handled at merge. - guard ent.featureType != 0 else { return false } - return true - } - } - - static func crossReferenceOwnedGames( - filteredEntitlements: [PsCloudEntitlement], - publicCatalog: [CloudGame], - plusLibrarySupplement: [CloudGame] = [], - productIdAliases: [String: String] = [:], - componentIdsByProductId: [String: [String]] = [:] - ) -> [CloudGame] { - var catalogMap: [String: CloudGame] = [:] - for game in publicCatalog { - catalogMap[game.id] = game - } - for (alias, canonical) in productIdAliases { - if catalogMap[alias] != nil { continue } - if let meta = catalogMap[canonical] { - catalogMap[alias] = meta - } - } - var supplementMap: [String: CloudGame] = [:] - for game in plusLibrarySupplement { - supplementMap[game.id] = game - } - - let browseStableKey = buildStableKeyIndex(publicCatalog) - let supplementStableKey = buildStableKeyIndex(plusLibrarySupplement) - let browseByConcept = buildConceptIdIndex(publicCatalog) - let supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) - - var byKey: [String: CloudGame] = [:] - var byKeyEnt: [String: PsCloudEntitlement] = [:] - - // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping - // OUR convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a - // direct match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). - func emit(_ meta: CloudGame, _ ent: PsCloudEntitlement) { - let displayName = meta.name.isEmpty ? ent.name : meta.name - // The owned card's serviceType comes from the ENTITLEMENT's platform_id (pscloud == PS5, - // psnow == PS3/PS4), not the matched catalog row's: a cross-buy PS4 license can match a - // PS5 catalog row by shared product_id, but it must still route as PS4/Kamaji. - let ownedService = ownedServiceType(ent, meta) - // Qt emitOwned field convention: productId = entitlement.product_id (NOT catalog meta.id); - // entitlementId = entitlement.id. Merge + QMap sort rely on these being separate for cross-buy. - let streamProductId = ent.productId.isEmpty ? meta.id : ent.productId - let game = CloudGame( - productId: streamProductId, - name: displayName, - imageUrl: meta.imageUrl, - landscapeImageUrl: meta.landscapeImageUrl, - platform: meta.platform, - serviceType: ownedService, - conceptUrl: meta.conceptUrl, - conceptId: meta.conceptId, - isOwned: true, - entitlementId: ent.id, - storeProductId: ent.productId, - featureType: ent.featureType - ) - let key = ownedDedupeKey(meta: meta, ent: ent) - // Keep the best streaming candidate via a DETERMINISTIC total order (ownedEntitlementBetter), - // independent of the PSN entitlements response order, so the catalog is stable across - // refreshes (cross-buy titles with equal stream rank routinely tie). Mirrors Qt - // ps5CloudOwnedEntitlementBetter in cloudcatalogbackend.cpp. - if let existingEnt = byKeyEnt[key] { - if ownedEntitlementBetter(ent, existingEnt) { - byKey[key] = game - byKeyEnt[key] = ent - } - } else { - byKey[key] = game - byKeyEnt[key] = ent - } - } - - for ent in filteredEntitlements { - let stable = productIdStableKey(ent.productId) - let entStable = productIdStableKey(ent.id) - let skipStableDemo = ent.name.localizedCaseInsensitiveContains("demo") - let meta: CloudGame? - if !ent.productId.isEmpty, let g = catalogMap[ent.productId] { - meta = g - } else if !ent.id.isEmpty, let g = catalogMap[ent.id] { - meta = g - // Inert in practice: PSN entitlements carry no conceptId (see findCatalogIndexForOwned note), so this - // platform-blind concept lookup almost never fires; owned games match by exact id above. - } else if !ent.conceptId.isEmpty, let g = browseByConcept[ent.conceptId] { - // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). - meta = g - } else if !ent.conceptId.isEmpty, let g = supplementByConcept[ent.conceptId] { - meta = g - } else if !ent.productId.isEmpty, ent.id == ent.productId, - let g = supplementMap[ent.productId] { - meta = g - } else if let stable, !skipStableDemo, let g = browseStableKey[stable] { - meta = g - } else if let stable, !skipStableDemo, let g = supplementStableKey[stable] { - meta = g - } else if let entStable, !skipStableDemo, let g = browseStableKey[entStable] { - // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade - // entitlement ids whose stable key matches a catalog row even when product_id did not. - meta = g - } else if let entStable, !skipStableDemo, let g = supplementStableKey[entStable] { - meta = g - } else { - meta = nil - } - - if let meta { - emit(meta, ent) - continue - } - - // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no - // direct catalog row, but its component entitlement ids each map to a component game. - var seenPids = Set() - for siblingId in componentIdsByProductId[ent.productId] ?? [] { - let siblingMeta: CloudGame? - if let g = catalogMap[siblingId] { - siblingMeta = g - } else if let g = supplementMap[siblingId] { - siblingMeta = g - } else if let sStable = productIdStableKey(siblingId), !skipStableDemo { - siblingMeta = browseStableKey[sStable] ?? supplementStableKey[sStable] - } else { - siblingMeta = nil - } - guard let sMeta = siblingMeta, !sMeta.id.isEmpty, !seenPids.contains(sMeta.id) else { continue } - seenPids.insert(sMeta.id) - emit(sMeta, ent) - } - } - - // Disc-upgrade rescue (mirrors cloudcatalogbackend.cpp). feature_type 5 is a PS4-disc -> PS5 - // *disc upgrade* license; Gaikai refuses to cloud-stream it ("disc-upgrade-unsupported"). The - // browse catalog often binds the concept to exactly that SKU (e.g. Horizon Forbidden West - // concept 10000886 -> PPSA01521), while the user's streamable full-game entitlement is a - // DIFFERENT title id (e.g. Complete Edition PPSA17903) that is absent from the catalog and - // carries no conceptId -- so it never matches and only the disc-upgrade SKU survives. When a - // concept winner is a disc upgrade, adopt a same-name full-game (feature_type 3) owned SKU's - // product id so the card streams the edition Gaikai accepts. - // - // Entitlements carry no conceptId and the disc-upgrade SKU shares no id/sku with the real - // edition, so the only in-data bridge is the title name. To keep that safe: SAME PLATFORM only - // (a PS5/PPSA disc upgrade must never resolve to a PS4/CUSA SKU), prefer the canonical base - // game (product_id == entitlement id), and BAIL on genuine ambiguity rather than guess. - for key in Array(byKey.keys) { - guard let game = byKey[key], game.featureType == 5 else { continue } - let discPid = game.storeProductId - let discPlatform = platformToken(discPid) - guard let discEnt = filteredEntitlements.first(where: { - $0.productId == discPid && $0.featureType == 5 - }) else { continue } - let discName = normalizeTitle(discEnt.name) - guard !discName.isEmpty else { continue } - var canonical: [String] = [] // base-game SKUs (product_id == entitlement id) - var other: [String] = [] // non-canonical full-game SKUs - for cand in filteredEntitlements where cand.featureType == 3 { - guard normalizeTitle(cand.name) == discName else { continue } - let candPid = cand.productId - guard !candPid.isEmpty, candPid != discPid else { continue } - guard platformToken(candPid) == discPlatform else { continue } - if candPid == cand.id { - if !canonical.contains(candPid) { canonical.append(candPid) } - } else if !other.contains(candPid) { - other.append(candPid) - } - } - let replacement: String? - if canonical.count == 1 { - replacement = canonical[0] - } else if canonical.isEmpty, other.count == 1 { - replacement = other[0] - } else { - replacement = nil - } - guard let rep = replacement else { - if !canonical.isEmpty || !other.isEmpty { - os_log(.info, log: ownershipLog, - "disc-upgrade rescue: ambiguous candidates for %{public}s -- leaving disc SKU", - discName) - } - continue - } - var updated = game - updated.storeProductId = rep - byKey[key] = updated - os_log(.info, log: ownershipLog, "disc-upgrade rescue: %{public}s %{public}s -> %{public}s", - discName, discPid, rep) - } - - // QMap iteration is sorted by dedupe key; merge depends on :ps4 before :ps5 (cloudcatalogbackend.cpp). - return byKey.keys.sorted().compactMap { byKey[$0] } - } - - // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen - // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing - // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. - private static func ownedDedupeKey(meta: CloudGame, ent: PsCloudEntitlement) -> String { - // Platform from the ENTITLEMENT's structured platform_id (NOT the matched catalog row's, and NOT - // a product-id prefix): a cross-buy title gives the user up to three entitlements that resolve to - // ONE catalog row -- a clean PS4 (CUSA, platform_id ps4), a real PS5 (PPSA, platform_id ps5), and - // a PS5-wrapper PS4 license (id CUSA, product_id ...PPSA..., platform_id ps4). The real PS5 and - // the PS5-wrapper PS4 must stay in SEPARATE buckets (ps5 vs ps4) so the PS5 entitlement is not - // discarded by a same-key collision; the merge then stamps the PS5 card from the PS5 entitlement - // and DROPS the PS4 wrapper (it can't claim a PS5 card). Collapsing by the catalog row's platform - // instead let the wrapper win and threw away the real PS5 license (the Blood Omen / GTA V PS5 - // streaming failure). - if !meta.conceptId.isEmpty { return "c:\(meta.conceptId):\(entPlatform(ent))" } - if !meta.id.isEmpty { return "p:\(meta.id)" } - if !ent.id.isEmpty { return "e:\(ent.id)" } - return "u:\(meta.id):\(ent.id)" - } - - // Platform token from a product id (CUSA = PS4, PPSA = PS5). - static func platformToken(_ productId: String) -> String { - if productId.contains("PPSA") { return "ps5" } - if productId.contains("CUSA") { return "ps4" } - return "" - } - - // Lowercase, strip trademark/registered/service-mark glyphs, and collapse whitespace so two owned - // entitlements for the same game compare equal across punctuation/spacing differences. - private static func normalizeTitle(_ raw: String) -> String { - let stripped = raw.lowercased() - .replacingOccurrences(of: "\u{2122}", with: "") - .replacingOccurrences(of: "\u{00AE}", with: "") - .replacingOccurrences(of: "\u{2120}", with: "") - return stripped.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") - } - - // A "full game" entitlement (vs add-on/avatar/theme): PSN marks the base game with a *GD - // package_type (PSGD/PS4GD); add-ons use PS4MISC/PSAL/etc. - private static func isFullGameEntitlement(_ ent: PsCloudEntitlement) -> Bool { - ent.featureType == 3 || ent.packageType.hasSuffix("GD") - } - - // Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). - // Bonus/upgrade SKUs collapse to the same conceptId+platform as the base game; package/feature - // flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3). - // The reliable signal: the base game's entitlement id EQUALS its product_id, while bonus/upgrade - // SKUs carry a different id -- so prefer the canonical full-game entitlement. - private static func ownedStreamRank(_ ent: PsCloudEntitlement) -> Int { - var rank = 0 - if !ent.productId.isEmpty && ent.productId == ent.id { rank += 4 } // canonical base-game SKU - if isFullGameEntitlement(ent) { rank += 2 } - if !ent.id.isEmpty { rank += 1 } - return rank - } - - // Is this a "Game Streaming" (GS) package? PSN labels the cloud-streamable SKU *GS (e.g. PS4GS) and - // the installable download *GD (PS4GD / PSGD). For a cross-buy title the GS entitlement carries the - // clean, streamable product for its platform, while the GD cross-buy SKU can carry a cross-gen - // *wrapper* product. So when two same-platform SKUs collapse to one edition, the GS SKU is the right - // streaming candidate. Uses the structured package_type field -- no product-id prefix guessing. - private static func isStreamingPackage(_ ent: PsCloudEntitlement) -> Bool { - ent.packageType.hasSuffix("GS") - } - - // Deterministic total order over owned entitlements that collapse to the same edition (conceptId + - // platform). MUST be independent of the PSN entitlements response order so the assembled catalog is - // stable across refreshes. Returns true if `cand` should replace `cur` as the edition's - // representative. Signals, in priority order, all from structured API fields: (1) higher stream rank - // (canonical full-game product); (2) the cloud-streaming (GS) package over a download (GD) SKU; - // (3) stable unique sku_id, then product_id, then entitlement id, to guarantee one deterministic - // winner. Mirrors Qt ps5CloudOwnedEntitlementBetter (cloudcatalogbackend.cpp). - private static func ownedEntitlementBetter(_ cand: PsCloudEntitlement, _ cur: PsCloudEntitlement) -> Bool { - let rc = ownedStreamRank(cand), ru = ownedStreamRank(cur) - if rc != ru { return rc > ru } - let gc = isStreamingPackage(cand), gu = isStreamingPackage(cur) - if gc != gu { return gc } - if cand.skuId != cur.skuId { return cand.skuId < cur.skuId } - if cand.productId != cur.productId { return cand.productId < cur.productId } - return cand.id < cur.id - } - - // conceptId + platform for an owned/catalog game. Platform comes from the canonical serviceType - // (pscloud == ps5, psnow == ps4-class) -- filled for owned cards from the entitlement's - // platform_id -- so an owned cross-buy PS4 license whose product_id is a PS5-looking wrapper - // buckets to the PS4 edition, not the PS5 one. Falls back to the product-id token when serviceType - // is absent (non-owned imagic browse rows, whose ids are clean). - private static func conceptPlatformKey(_ game: CloudGame) -> String { - guard !game.conceptId.isEmpty else { return "" } - return "\(game.conceptId)|\(platformClassForCard(game))" - } - - /// Platform CLASS of a catalog/owned card (ps5 or ps4). serviceType is canonical when present; - /// non-owned imagic browse rows may only have a clean product-id token (PPSA/CUSA). Mirrors Qt - /// gamePlatformStructured + ps5CloudPlatformToken fallback in mergeOwnedIntoBrowseCatalog. - private static func platformClassForCard(_ game: CloudGame) -> String { - let st = game.serviceType.lowercased() - if st == "pscloud" { return "ps5" } - if st == "psnow" { return "ps4" } - return platformToken(game.storeProductId.isEmpty ? game.id : game.storeProductId) - } - - /// Rebuild a catalog row after merge stamping (serviceType is let, so we must replace the struct). - private static func stampMergedCard(_ existing: CloudGame, from owned: CloudGame, serviceType: String) -> CloudGame { - CloudGame( - productId: existing.id, - name: existing.name, - imageUrl: existing.imageUrl, - landscapeImageUrl: existing.landscapeImageUrl, - platform: existing.platform, - serviceType: serviceType, - conceptUrl: existing.conceptUrl, - conceptId: existing.conceptId, - isOwned: true, - entitlementId: owned.entitlementId.isEmpty ? existing.entitlementId : owned.entitlementId, - storeProductId: owned.storeProductId.isEmpty ? existing.storeProductId : owned.storeProductId, - plusCatalog: existing.plusCatalog, - featureType: owned.featureType != 0 ? owned.featureType : existing.featureType, - category: existing.category - ) - } - - /// Tokenize on '-' and '_'; identity is all tokens except the last (store SKU). - private static func productIdStableKey(_ productId: String) -> String? { - guard !productId.isEmpty else { return nil } - var tokens: [String] = [] - for dashPart in productId.split(separator: "-") { - for token in dashPart.split(separator: "_") where !token.isEmpty { - tokens.append(String(token)) - } - } - guard tokens.count >= 2 else { return nil } - return tokens.dropLast().joined(separator: "|") - } - - private static func buildStableKeyIndex(_ games: [CloudGame]) -> [String: CloudGame] { - var index: [String: CloudGame] = [:] - for game in games { - guard let key = productIdStableKey(game.id) else { continue } - if index[key] == nil { - index[key] = game - } - } - return index - } - - private static func buildConceptIdIndex(_ games: [CloudGame]) -> [String: CloudGame] { - var index: [String: CloudGame] = [:] - for game in games where !game.conceptId.isEmpty { - if index[game.conceptId] == nil { - index[game.conceptId] = game - } - } - return index - } - - /// Normalize a conceptId (imagic encodes it as a number) to a non-empty string, else nil. - static func conceptIdString(_ value: Any?) -> String? { - if let i = value as? Int { return i > 0 ? String(i) : nil } - if let d = value as? Double { return d > 0 ? String(Int(d)) : nil } - if let s = value as? String, !s.isEmpty { return s } - return nil - } - - static func mergeOwnedIntoBrowseCatalog( - browseCatalog: [CloudGame], - ownedCrossRef: [CloudGame], - addUnmatched: Bool = true // false = only mark ownership on catalog entries (Catalog tab) - ) -> [CloudGame] { - var games = browseCatalog - var catalogIndex = buildCatalogIndex(games) - - // Products the user FULLY owns (feature_type != 1). A trial (ft1) is kept as its own card ONLY - // when the full game is NOT owned; when the SAME product is also held as a full license (common - // for F2P cross-buy titles: a PS4 trial whose product_id is the PS5 PPSA wrapper, e.g. Trackmania - // / Super Animal Royale / Fantasy Beauties) the trial card is redundant AND broken -- it routes - // to Kamaji (psnow) while carrying a PS5 product. Suppress those trials. Order-independent - // pre-pass. Mirrors Qt mergeOwnedIntoBrowseCatalog (cloudcatalogbackend.cpp). - let fullyOwnedProductIds = Set( - ownedCrossRef.filter { $0.featureType != 1 }.map { $0.id }.filter { !$0.isEmpty } - ) - - // Process pscloud (PS5) owned claims BEFORE psnow (PS3/PS4). A PS5 (pscloud) claim is - // authoritative and stamps the PS5 browse row in place; doing it first means the row is already - // owned by the time any PS4 cross-buy license (whose product_id is the SAME PPSA wrapper) is - // seen, so the wrapper is dropped cleanly instead of appending a duplicate / orphaning the - // browse row as a "ghost". Deterministic, order-independent. Stable partition. (Qt parity.) - let ownedOrdered = ownedCrossRef.filter { $0.serviceType.lowercased() == "pscloud" } - + ownedCrossRef.filter { $0.serviceType.lowercased() != "pscloud" } - - for owned in ownedOrdered { - let isTrialTier = owned.featureType == 1 - // A trial whose product is also fully owned is superseded by the full license -- drop it. - if isTrialTier && fullyOwnedProductIds.contains(owned.id) { continue } - let catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) - if catalogMatch >= 0 { - let existing = games[catalogMatch] - let ownedService = owned.serviceType.lowercased() - let existingService = existing.serviceType.lowercased() - let existingClass = platformClassForCard(existing) - // The card's stream identity must come from the OWNED entitlement of THIS card's - // platform. Cross-buy editions share one product_id (Red Dead's PS4 license and PS5 - // license both carry ...PPSA30528...), so matching by product_id alone lets a PS4 - // entitlement land on the PS5 card. Rule: a PS5 (pscloud) claim is authoritative; a - // PS4/PS3 (psnow) entitlement must NEVER overwrite a PS5-class card. Mirrors Qt - // mergeOwnedIntoBrowseCatalog exactly (cloudcatalogbackend.cpp). - if ownedService == "pscloud" { - games[catalogMatch] = stampMergedCard(existing, from: owned, serviceType: "pscloud") - continue - } - if ownedService == "psnow" && existingService != "pscloud" && existingClass != "ps5" { - games[catalogMatch] = stampMergedCard(existing, from: owned, serviceType: "psnow") - continue - } - // psnow entitlement whose matched card is PS5-class: this is a PS4 CROSS-BUY license - // whose product_id is the shared PS5 (PPSA) wrapper. DROP it (Qt parity): the PS5 card - // is claimed by the PS5 (pscloud) license processed first, the real PS4 variant matches - // its own CUSA row independently, and appending here would create a bogus duplicate / - // ghost that can't stream (a PS5 cloud product needs a PS5 entitlement). - if ownedService == "psnow" { continue } - } - - guard addUnmatched else { continue } - var entry = owned - entry.isOwned = true - registerInCatalogIndex(entry, index: games.count, catalogIndex: &catalogIndex) - games.append(entry) - } - - return games.sorted { - if $0.isOwned != $1.isOwned { return $0.isOwned && !$1.isOwned } - return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - } - - static func parseEntitlement(_ obj: [String: Any]) -> PsCloudEntitlement? { - guard let id = obj["id"] as? String, !id.isEmpty else { return nil } - let gameMeta = obj["game_meta"] as? [String: Any] ?? [:] - let name = (gameMeta["name"] as? String) ?? id - let conceptId = conceptIdString(gameMeta["conceptId"]) - ?? conceptIdString(gameMeta["concept_id"]) - ?? conceptIdString(obj["conceptId"]) - ?? "" - // Structured platform from entitlement_attributes[].platform_id. Sony also returns a numeric - // top-level "serviceType" here that is unrelated to our routing -- we never read it. - var platformId = "" - if let attrs = obj["entitlement_attributes"] as? [[String: Any]] { - // Scan for the first RECOGNIZED platform (ps5/ps4/ps3); skip any unknown value so a junk - // attribute ordered first can't shadow a real one (mirrors Qt ownedEntitlementServiceType). - for a in attrs { - guard let p = (a["platform_id"] as? String)?.lowercased() else { continue } - if p == "ps5" || p == "ps4" || p == "ps3" { platformId = p; break } - } - } - return PsCloudEntitlement( - id: id, - productId: (obj["product_id"] as? String) ?? "", - skuId: (obj["sku_id"] as? String) ?? "", - activeFlag: (obj["active_flag"] as? Bool) ?? false, - packageType: (gameMeta["package_type"] as? String) ?? "", - name: name, - conceptId: conceptId, - featureType: (obj["feature_type"] as? NSNumber)?.intValue ?? 0, - platformId: platformId - ) - } - - // Canonical stream service for an owned entitlement from its structured platform_id: - // ps5 -> pscloud (cronos), ps4/ps3 -> psnow (Kamaji). Empty if platform_id is absent. - static func entServiceType(_ ent: PsCloudEntitlement) -> String { - switch ent.platformId { - case "ps5": return "pscloud" - case "ps4", "ps3": return "psnow" - default: return "" - } - } - - // Stream backend for an owned entitlement, with Qt's exact fallback (streamServiceTypeForGame): - // 1) the structured platform_id (authoritative -- a cross-buy PS4 wrapper has platform_id "ps4" - // even though its product_id is a PS5-looking PPSA, so it correctly stays psnow/Kamaji); else - // 2) the entitlement's own product-id TOKEN (CUSA = PS4/Kamaji, PPSA = PS5/cronos). PS Plus classics - // (e.g. Blood Omen, product ...PPSA24270...) carry NO platform_id and match a PS Now/Apollo - // (psnow) browse row by concept -- inheriting meta.serviceType would mis-route them to Kamaji and - // fail. The product-id token routes them to cronos like Qt. Only when neither token is present do - // we fall back to the matched row's serviceType. - private static func ownedServiceType(_ ent: PsCloudEntitlement, _ meta: CloudGame) -> String { - let svc = entServiceType(ent) - if !svc.isEmpty { return svc } - let tok = ent.productId + " " + ent.id - if tok.contains("CUSA") { return "psnow" } - if tok.contains("PPSA") { return "pscloud" } - return meta.serviceType - } - - // Platform class (ps5/ps4) for owned dedupe, from platform_id; falls back to the product-id token - // only when platform_id is absent (never relied on for the CUSA/PPSA wrapper-prone cross-buy case, - // which always carries a platform_id). - private static func entPlatform(_ ent: PsCloudEntitlement) -> String { - switch ent.platformId { - case "ps5": return "ps5" - case "ps4", "ps3": return "ps4" - default: return platformToken(ent.productId) - } - } - - private static func buildCatalogIndex(_ games: [CloudGame]) -> CatalogIndex { - var catalogIndex = CatalogIndex() - for i in games.indices { - registerInCatalogIndex(games[i], index: i, catalogIndex: &catalogIndex) - } - return catalogIndex - } - - private static func registerInCatalogIndex( - _ game: CloudGame, - index: Int, - catalogIndex: inout CatalogIndex - ) { - if !game.id.isEmpty { catalogIndex.byProductId[game.id] = index } - let conceptKey = conceptPlatformKey(game) - if !conceptKey.isEmpty { catalogIndex.byConceptId[conceptKey] = index } - if !game.entitlementId.isEmpty, game.entitlementId != game.id { - catalogIndex.byProductId[game.entitlementId] = index - } - } - - // IMPORTANT (this cost real debugging time): PSN *owned entitlements* carry NO conceptId in practice - // -- their game_meta is just { name, package_type, icon_url }. So every conceptId-based step below is - // effectively INERT for owned games: an owned entitlement resolves to a catalog row by EXACT ID ONLY - // (product_id -> entitlement id -> store product id). The conceptId machinery's live job is catalog-row - // edition dedup (edition / conceptPlatformKey), NOT owned->catalog matching. - // - // Also: a PS4 CROSS-BUY license can carry a PS5-looking PPSA *product_id* wrapper while its real PS4 - // component is the CUSA *id* (platform_id stays "ps4"). product_id is matched against the catalog FIRST, - // so such a ps4 license can land on a PS5 (PPSA) row. NEVER infer platform from a product-id prefix for - // owned entitlements -- use the platform_id-derived serviceType (pscloud=PS5, psnow=PS3/PS4). The merge - // guard keys on the matched card's platform CLASS so a ps4 license can never corrupt a PS5 card. - private static func findCatalogIndexForOwned(_ owned: CloudGame, catalogIndex: CatalogIndex) -> Int { - // Mirrors Qt findCatalogIndexForOwned: product_id, entitlement id, store product id, concept+platform. - let productId = owned.id - if !productId.isEmpty, let idx = catalogIndex.byProductId[productId] { return idx } - if !owned.entitlementId.isEmpty, owned.entitlementId != productId, - let idx = catalogIndex.byProductId[owned.entitlementId] { return idx } - if !owned.storeProductId.isEmpty, let idx = catalogIndex.byProductId[owned.storeProductId] { return idx } - // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog - // entry (and vice-versa); cross-gen editions stay as separate library cards. - let conceptKey = conceptPlatformKey(owned) - if !conceptKey.isEmpty, let idx = catalogIndex.byConceptId[conceptKey] { return idx } - return -1 - } - - // MARK: - Unified-page assembly: acquisition tag + concept-sibling streamability gate - - static let CATEGORY_OWNED = "owned" - static let CATEGORY_STREAMABLE = "streamable" - static let CATEGORY_PURCHASEABLE = "purchaseable" - - static func categoryFor(_ game: CloudGame) -> String { - if game.isOwned { return CATEGORY_OWNED } - // Category is a CATALOG classification and mirrors Qt categoryForGame + streamServiceTypeForGame - // EXACTLY: the canonical serviceType wins (BOTH "psnow" and "pscloud" short-circuit); only a row - // with no serviceType derives from the CUSA/PPSA token. This is deliberately independent of - // `streamServiceType`, whose isOwned gate re-routes non-owned pscloud rows to Kamaji for STREAMING - // only -- using it here mis-tags non-owned pscloud PS4 titles (e.g. cross-gen indie bundles) as - // "streamable" instead of "purchaseable". - let st = game.serviceType.lowercased() - let svc: String - if st == "psnow" || st == "pscloud" { - svc = st - } else { - let p = !game.storeProductId.isEmpty ? game.storeProductId - : (!game.id.isEmpty ? game.id : game.entitlementId) - svc = p.contains("CUSA") ? "psnow" : "pscloud" - } - return svc == "psnow" ? CATEGORY_STREAMABLE : CATEGORY_PURCHASEABLE - } - - /// Concept-sibling streamability gate index, built from the ACTUAL streamable catalog. - struct StreamabilityIndex { - private let productKeys: Set - private let streamableConceptIds: Set - - init( - apolloCatalog: [CloudGame], - imagicBrowse: [CloudGame], - imagicConceptRows: [CloudGame] - ) { - var keys = Set() - var conceptIds = Set() - - func addProduct(_ productId: String) { - guard !productId.isEmpty else { return } - keys.insert(productId) - if let stable = PsCloudOwnership.productIdStableKey(productId) { - keys.insert(stable) - } - } - - for game in apolloCatalog { addProduct(game.id) } - for game in imagicBrowse { - addProduct(game.id) - if !game.conceptId.isEmpty { conceptIds.insert(game.conceptId) } - } - for row in imagicConceptRows { - guard !row.conceptId.isEmpty else { continue } - let rowKeys = [row.id, PsCloudOwnership.productIdStableKey(row.id)].compactMap { $0 } - if rowKeys.contains(where: { keys.contains($0) }) { - conceptIds.insert(row.conceptId) - } - } - - productKeys = keys - streamableConceptIds = conceptIds - } - - func isStreamable(_ game: CloudGame) -> Bool { - for p in [game.id, game.storeProductId, game.entitlementId] { - guard !p.isEmpty else { continue } - if productKeys.contains(p) { return true } - if let stable = PsCloudOwnership.productIdStableKey(p), productKeys.contains(stable) { return true } - } - return !game.conceptId.isEmpty && streamableConceptIds.contains(game.conceptId) - } - } - - static func applyStreamabilityGate(_ games: [CloudGame], index: StreamabilityIndex) -> [CloudGame] { - var kept: [CloudGame] = [] - var dropped = 0 - for game in games { - if !game.isOwned || index.isStreamable(game) { - kept.append(game) - } else { - dropped += 1 - os_log(.info, log: ownershipLog, - "streamability gate: dropped owned non-streamable '%{public}s' (%{public}s)", - game.name, game.id) - } - } - if dropped > 0 { - os_log(.info, log: ownershipLog, - "streamability gate: dropped %d owned non-streamable titles", dropped) - } - return kept - } -} diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index fb0d90c4..1bd68547 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -11,9 +11,9 @@ private let cloudUILog = OSLog(subsystem: "com.pylux.stream", category: "CloudPl @MainActor final class CloudPlayViewModel: ObservableObject { static let tagFilterCategories = [ - PsCloudOwnership.CATEGORY_OWNED, - PsCloudOwnership.CATEGORY_STREAMABLE, - PsCloudOwnership.CATEGORY_PURCHASEABLE + CloudCategory.owned, + CloudCategory.streamable, + CloudCategory.purchaseable ] static let tagFilterLabels = ["Owned", "Streamable", "Store"] @@ -75,10 +75,7 @@ final class CloudPlayViewModel: ObservableObject { var result = games if !activeTagFilters.isEmpty { - result = result.filter { - let category = $0.category.isEmpty ? PsCloudOwnership.categoryFor($0) : $0.category - return activeTagFilters.contains(category) - } + result = result.filter { activeTagFilters.contains($0.category) } } if showFavoritesOnly { @@ -95,10 +92,8 @@ final class CloudPlayViewModel: ObservableObject { switch sortOrder { case .defaultOrder: result.sort { - let c0 = $0.category.isEmpty ? PsCloudOwnership.categoryFor($0) : $0.category - let c1 = $1.category.isEmpty ? PsCloudOwnership.categoryFor($1) : $1.category - let p0 = c0 != PsCloudOwnership.CATEGORY_PURCHASEABLE - let p1 = c1 != PsCloudOwnership.CATEGORY_PURCHASEABLE + let p0 = $0.category != CloudCategory.purchaseable + let p1 = $1.category != CloudCategory.purchaseable if p0 != p1 { return p0 && !p1 } return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -166,8 +161,6 @@ final class CloudPlayViewModel: ObservableObject { } if let catalogWarning = catalogService.lastCatalogFetchWarning { warning = catalogWarning - } else if let libraryWarning = catalogService.lastLibraryFetchWarning { - warning = libraryWarning } else if !CloudLocaleSettings.isConfigured { warning = CloudLocaleSettings.unconfiguredWarning() } @@ -206,8 +199,8 @@ final class CloudPlayViewModel: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - // Route by the title-id platform: PS4 catalog titles go through Kamaji (psnow) to - // acquire the streaming entitlement; PS5 streams directly (pscloud). + // Stream routing is precomputed by libchiaki: streamServiceType picks the endpoint + // (psnow/Kamaji vs pscloud/cronos) and streamIdentifier is the exact id to launch. let gameIdentifier = game.streamIdentifier let gameName = game.name let serviceType = game.streamServiceType @@ -536,8 +529,7 @@ struct CloudPlayView: View { } private func handleGameTap(_ game: CloudGame) { - let category = game.category.isEmpty ? PsCloudOwnership.categoryFor(game) : game.category - if category == PsCloudOwnership.CATEGORY_PURCHASEABLE { + if game.category == CloudCategory.purchaseable { let url = game.conceptUrl.trimmingCharacters(in: .whitespacesAndNewlines) if url.isEmpty { showMissingConceptAlert = true @@ -834,13 +826,7 @@ struct CloudGameCardView: View { @State private var starTapped = false // debounce visual - private var displayCategory: String { - if !game.category.isEmpty { return game.category } - // Fall back through the canonical tagger (streamServiceType-based), not raw serviceType: - // a non-owned PS4 cloud-browse row is serviceType="pscloud" but streams via PS Now, so it is - // "streamable", not "purchaseable". - return PsCloudOwnership.categoryFor(game) - } + private var displayCategory: String { game.category } var body: some View { GeometryReader { geo in @@ -900,9 +886,9 @@ struct CloudGameCardView: View { private var categoryBadge: some View { let (label, color): (String, Color) = { switch displayCategory { - case PsCloudOwnership.CATEGORY_OWNED: + case CloudCategory.owned: return ("Owned", Color(red: 0.30, green: 0.69, blue: 0.31)) // #4CAF50 - case PsCloudOwnership.CATEGORY_STREAMABLE: + case CloudCategory.streamable: return ("Streamable", Color(red: 0.13, green: 0.59, blue: 0.95)) // #2196F3 default: return ("Add Game", Color(red: 1.0, green: 0.60, blue: 0.0)) // #FF9800 diff --git a/ios/Pylux/Views/SettingsView.swift b/ios/Pylux/Views/SettingsView.swift index 44a91afd..e872bb91 100644 --- a/ios/Pylux/Views/SettingsView.swift +++ b/ios/Pylux/Views/SettingsView.swift @@ -81,6 +81,10 @@ struct StreamPreferences: Codable { var cloudDatacenterPsnow: String = "Auto" // matches Android default var cloudBitratePsnow: Int = 20000 // kbps, matches Qt/Android default 20 Mbps + /// Cloud streaming game language (BCP-47, e.g. "de-DE"). Empty = follow the + /// detected catalog locale. Game language is tied to the datacenter region. + var cloudLanguage: String = "" + static let cloudBitrateMinKbps = 2000 static let cloudBitrateMaxKbps = 200_000 static let cloudBitrateDefaultKbps = 20000 @@ -91,6 +95,7 @@ struct StreamPreferences: Codable { case onScreenControlsEnabled, touchpadOnlyEnabled case cloudResolutionPscloud, cloudDatacenterPscloud, cloudBitratePscloud case cloudResolutionPsnow, cloudDatacenterPsnow, cloudBitratePsnow + case cloudLanguage } init( @@ -110,7 +115,8 @@ struct StreamPreferences: Codable { cloudBitratePscloud: Int = StreamPreferences.cloudBitrateDefaultKbps, cloudResolutionPsnow: String = "720", cloudDatacenterPsnow: String = "Auto", - cloudBitratePsnow: Int = StreamPreferences.cloudBitrateDefaultKbps + cloudBitratePsnow: Int = StreamPreferences.cloudBitrateDefaultKbps, + cloudLanguage: String = "" ) { self.resolutionIndex = resolutionIndex self.fps = fps @@ -129,6 +135,7 @@ struct StreamPreferences: Codable { self.cloudResolutionPsnow = cloudResolutionPsnow self.cloudDatacenterPsnow = cloudDatacenterPsnow self.cloudBitratePsnow = Self.clampCloudBitrateKbps(cloudBitratePsnow) + self.cloudLanguage = cloudLanguage } init(from decoder: Decoder) throws { @@ -154,6 +161,7 @@ struct StreamPreferences: Codable { cloudBitratePsnow = Self.clampCloudBitrateKbps( try c.decodeIfPresent(Int.self, forKey: .cloudBitratePsnow) ?? Self.cloudBitrateDefaultKbps ) + cloudLanguage = try c.decodeIfPresent(String.self, forKey: .cloudLanguage) ?? "" } func encode(to encoder: Encoder) throws { @@ -175,6 +183,7 @@ struct StreamPreferences: Codable { try c.encode(cloudResolutionPsnow, forKey: .cloudResolutionPsnow) try c.encode(cloudDatacenterPsnow, forKey: .cloudDatacenterPsnow) try c.encode(cloudBitratePsnow, forKey: .cloudBitratePsnow) + try c.encode(cloudLanguage, forKey: .cloudLanguage) } static func clampCloudBitrateKbps(_ kbps: Int) -> Int { @@ -286,6 +295,7 @@ struct SettingsView: View { @State private var prefs = StreamPreferences.load() @State private var bitrateText = "" @State private var showResetAlert = false + @State private var showLanguageInfo = false @State private var psnLoggedIn = PsnTokenStore.shared.hasTokens /// Bumped when cloud ping results are saved so datacenter pickers reload from `SecureStore`. @State private var datacenterStoreRevision = 0 @@ -304,7 +314,10 @@ struct SettingsView: View { // 3. Remote Play Settings remotePlaySection - // 3. Cloud Game Library (PSCloud) + // 4. Cloud Settings (shared across cloud library + catalog) + cloudSettingsSection + + // 5. Cloud Game Library (PSCloud) cloudLibrarySection // 4. Cloud Game Catalog (PSNow) @@ -524,6 +537,28 @@ struct SettingsView: View { } } + // MARK: - Cloud Settings (shared) + + private var cloudSettingsSection: some View { + Section { + // Game language (manual override, stored separately from the + // auto-detected catalog locale). Shared across cloud library + + // catalog, so it lives in its own section above both. + languagePicker() + } header: { + Text("Cloud Settings") + } footer: { + Text("Language availability depends on your datacenter's region.") + } + // Full caveat shown as a popup only when a specific language is chosen, + // keeping the inline section short. + .alert("Game Language", isPresented: $showLanguageInfo) { + Button("OK", role: .cancel) { } + } message: { + Text("Not all regions support every language. A language only works on datacenters that offer it — if your chosen language isn't applied, pick a datacenter in a matching region.") + } + } + // MARK: - 4. Cloud Game Catalog (PSNow) private var cloudCatalogSection: some View { @@ -570,6 +605,55 @@ struct SettingsView: View { .onChange(of: selection.wrappedValue) { _ in prefs.save() } } + // MARK: - Game Language Picker Helper + + /// Human-readable name for a cloud-language locale. Display names are the + /// platform's responsibility; the locale list itself comes from libchiaki. + private static func cloudLanguageName(_ locale: String) -> String { + switch locale { + case "en-US": return "English" + case "en-GB": return "English (UK)" + case "de-DE": return "Deutsch" + case "fr-FR": return "Français" + case "fi-FI": return "Suomi" + case "it-IT": return "Italiano" + case "es-ES": return "Español" + case "nl-NL": return "Nederlands" + case "pt-BR": return "Português (BR)" + case "ja-JP": return "日本語" + case "ko-KR": return "한국어" + default: return locale + } + } + + private func languagePicker() -> some View { + // Show every supported language (datacenter language support can't be + // reliably enumerated). The manual pick is stored separately from the + // auto-detected catalog locale and never auto-changes the datacenter; + // the user picks a matching datacenter themselves. + let supported = PyluxCloudCatalog.supportedCloudLanguages() + let catalogLocale = CloudLocaleSettings.stored.isEmpty ? "en-US" : CloudLocaleSettings.stored + let current = prefs.cloudLanguage + let selection = Binding( + // Empty override selects "Auto"; an unknown value also falls back to Auto. + get: { (current.isEmpty || supported.contains(current)) ? current : "" }, + set: { newValue in + prefs.cloudLanguage = newValue + prefs.save() + // Surface the datacenter caveat only when overriding to a + // specific language (Auto needs no warning). + if !newValue.isEmpty { showLanguageInfo = true } + } + ) + return Picker("Game Language", selection: selection) { + Text("Auto (\(catalogLocale))").tag("") + ForEach(supported, id: \.self) { loc in + Text("\(Self.cloudLanguageName(loc)) (\(loc))").tag(loc) + } + } + .id("lang-\(datacenterStoreRevision)") + } + // MARK: - 5. Reset private var resetSection: some View { diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index daa8bc6c..c9f6a83a 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -40,6 +40,7 @@ set(HEADER_FILES include/chiaki/opusencoder.h include/chiaki/orientation.h include/chiaki/bitstream.h + include/chiaki/cloudcatalog.h include/chiaki/remote/holepunch.h include/chiaki/remote/rudp.h include/chiaki/remote/rudpsendbuffer.h) @@ -86,6 +87,15 @@ set(SOURCE_FILES src/opusencoder.c src/orientation.c src/bitstream.c + src/curl_http.h + src/curl_http.c + src/cloudcatalog_internal.h + src/cloudcatalog_util.c + src/cloudcatalog_consts.c + src/cloudcatalog_cache.c + src/cloudcatalog_merge.c + src/cloudcatalog_fetch.c + src/cloudcatalog_unified.c src/remote/holepunch.c src/remote/rudp.c src/remote/rudpsendbuffer.c) diff --git a/lib/include/chiaki/cloudcatalog.h b/lib/include/chiaki/cloudcatalog.h new file mode 100644 index 00000000..fdd7b4c2 --- /dev/null +++ b/lib/include/chiaki/cloudcatalog.h @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Unified PS Cloud catalog: single source of truth for Qt, Android and iOS. +// +// The platform supplies only npsso/locale/cache_dir and receives one JSON +// payload that is *display-and-stream ready*. Clients MUST NOT recompute +// category, serviceType, platform, ownership or stream identifiers — every +// value the UI renders and every routing value the stream/purchase actions +// need is precomputed here. See the JSON contract below. + +#ifndef CHIAKI_CLOUDCATALOG_H +#define CHIAKI_CLOUDCATALOG_H + +#include +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Current schema version of the JSON payload (top-level "schemaVersion"). + * v2: settledLocale is now the locale the lib resolved AFTER re-basing on the + * account's Kamaji-session country (region detection moved into the lib), so a + * pre-v2 cached payload can hold wrong-region content for international accounts + * and must be refetched. */ +#define CHIAKI_CLOUDCATALOG_SCHEMA_VERSION 2 + +typedef struct chiaki_cloudcatalog_config_t +{ + const char *npsso; /**< cookie value only (no "npsso=" prefix); may be NULL/empty for public fallback */ + const char *locale; /**< BCP-47, e.g. "en-US"; NULL => "en-US" */ + const char *cache_dir; /**< platform-supplied dir; lib owns every file inside */ + bool force_refresh; /**< bypass the on-disk unified cache (and intermediates) */ +} ChiakiCloudCatalogConfig; + +typedef struct chiaki_cloudcatalog_result_t +{ + ChiakiErrorCode err; + char *json; /**< UTF-8 unified payload; NULL on hard failure. Free via _result_fini */ + char *error_message; /**< human-readable detail on failure; may be NULL */ +} ChiakiCloudCatalogResult; + +/** + * Fetch (or load from cache) the unified cloud catalog and return it as JSON. + * + * Blocking / single-threaded: call from a worker thread. Performs all OAuth + * exchanges internally from @c config->npsso; never persists tokens. On a + * unified-cache hit it performs no network I/O. + * + * The JSON envelope (see CHIAKI_CLOUDCATALOG_SCHEMA_VERSION): + * + * { + * "schemaVersion": 2, + * "total": , + * "nativeMode": , // true when the authenticated PS Now walk succeeded + * "fallbackRegion": "US"|"GB"|"", // region-group store country in public fallback; "" when native + * "settledLocale": "en-US", // locale the lib resolved (account region from the Kamaji + * // session re-bases the caller locale, then the imagic store- + * // locale chain settles); clients persist this verbatim + * "warning": "", // non-empty => client shows a banner verbatim (e.g. expired npsso) + * "games": [ { + * "productId": , // canonical catalog id + stable dedup key + * "name": , + * "imageUrl": , // portrait/box art + * "landscapeImageUrl":, + * "conceptId": , + * "category": "owned"|"streamable"|"purchaseable", + * "serviceType": "psnow"|"pscloud", // catalog routing + * "platform": "ps3"|"ps4"|"ps5", // badge; derived from device[] + * "isOwned": , + * "streamServiceType":"psnow"|"pscloud", // endpoint the stream action uses + * "streamIdentifier": , // exact id handed to the streaming session + * "entitlementId": , // owned rows + * "storeProductId": , // owned / purchaseable rows + * "conceptUrl": , // purchase / add-to-library deep link + * "plusCatalog": + * }, ... ] + * } + * + * "games" is pre-sorted in the canonical order (owned first, then by name); + * clients render in array order and must not re-sort. + * + * @return err in @p out; out->json non-NULL on success (and on degraded-but- + * usable results such as expired npsso, where "warning" is set). + */ +CHIAKI_EXPORT ChiakiErrorCode chiaki_cloudcatalog_fetch_unified( + const ChiakiCloudCatalogConfig *config, + ChiakiCloudCatalogResult *out, + ChiakiLog *log); + +/** Release a result populated by chiaki_cloudcatalog_fetch_unified(). */ +CHIAKI_EXPORT void chiaki_cloudcatalog_result_fini(ChiakiCloudCatalogResult *out); + +/** Delete all lib-owned cache files under @p cache_dir (e.g. on locale change). */ +CHIAKI_EXPORT void chiaki_cloudcatalog_invalidate_cache(const char *cache_dir); + +// --------------------------------------------------------------------------- +// Cloud streaming language / datacenter helpers (single source of truth) +// +// Cloud game language is tied to the datacenter region: the streaming spec +// "language" field only takes effect when a datacenter that serves that +// language is selected — Gaikai silently ignores a language with no matching +// datacenter. These helpers let every platform (Qt/iOS/Android) share one +// table to (a) hand Gaikai the bare language code it expects, (b) build a +// language picker limited to the account's reachable datacenters, and (c) +// auto-select the datacenter for a chosen language. +// --------------------------------------------------------------------------- + +/** Convert a BCP-47 locale ("de-DE", "en-US") to the bare, lowercase language + * code Gaikai expects ("de", "en"). Gaikai ignores full locales. Writes a + * NUL-terminated code into @p out (>= 8 bytes recommended); defaults to "en" + * for empty/NULL input. */ +CHIAKI_EXPORT void chiaki_cloud_gaikai_language(const char *locale, char *out, size_t out_sz); + +/** Number of locales offered in the cloud-language picker. */ +CHIAKI_EXPORT size_t chiaki_cloud_supported_locale_count(void); + +/** The @p idx-th supported locale (BCP-47, e.g. "en-GB"), or "" if out of range. + * Human-readable display names are the platform's responsibility (localized in + * its own UI resources keyed off this code). */ +CHIAKI_EXPORT const char *chiaki_cloud_supported_locale(size_t idx); + +/** True if @p datacenter_name (the 4-letter ping-result name, e.g. "fraa", + * "stoa") serves @p locale. Matches on the 3-letter region prefix. Use to + * filter the picker to reachable datacenters and to auto-select the server for + * a chosen language. */ +CHIAKI_EXPORT bool chiaki_cloud_datacenter_serves_locale(const char *datacenter_name, const char *locale); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CLOUDCATALOG_H diff --git a/lib/src/cloudcatalog_cache.c b/lib/src/cloudcatalog_cache.c new file mode 100644 index 00000000..9abbe799 --- /dev/null +++ b/lib/src/cloudcatalog_cache.c @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// On-disk cache I/O. Ported from cloudcatalogbackend.cpp ensureCacheDirectory / +// getCacheFilePath / getCachedData / setCachedData. The lib owns every file +// inside cache_dir; platforms never read/write cache files themselves. + +#include "cloudcatalog_internal.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#define cc_mkdir(p) _mkdir(p) +#define cc_getpid() _getpid() +#else +#include +#define cc_mkdir(p) mkdir((p), 0755) +#define cc_getpid() getpid() +#endif + +static void sanitize_key(const char *key, char *out, size_t out_sz) +{ + size_t i = 0; + for(; key && key[i] && i < out_sz - 1; i++) + { + char c = key[i]; + if(c == '/' || c == '\\' || c == ':') + c = '_'; + out[i] = c; + } + out[i] = 0; +} + +static void cache_file_path(const char *cache_dir, const char *key, char *out, size_t out_sz) +{ + char safe[256]; + sanitize_key(key, safe, sizeof(safe)); + snprintf(out, out_sz, "%s/%s.json", cache_dir, safe); +} + +ChiakiErrorCode cc_cache_ensure_dir(const char *cache_dir) +{ + if(!cache_dir || !*cache_dir) + return CHIAKI_ERR_INVALID_DATA; + + // mkdir -p + char tmp[1024]; + snprintf(tmp, sizeof(tmp), "%s", cache_dir); + size_t len = strlen(tmp); + if(len > 0 && tmp[len - 1] == '/') + tmp[len - 1] = 0; + for(char *p = tmp + 1; *p; p++) + { + if(*p == '/') + { + *p = 0; + if(cc_mkdir(tmp) != 0 && errno != EEXIST) + return CHIAKI_ERR_UNKNOWN; + *p = '/'; + } + } + if(cc_mkdir(tmp) != 0 && errno != EEXIST) + return CHIAKI_ERR_UNKNOWN; + return CHIAKI_ERR_SUCCESS; +} + +struct json_object *cc_cache_read(ChiakiLog *log, const char *cache_dir, const char *key, long max_age_ms) +{ + char path[1024]; + cache_file_path(cache_dir, key, path, sizeof(path)); + + struct stat st; + if(stat(path, &st) != 0) + { + CHIAKI_LOGI(log, "[CACHE MISS] %s", key); + return NULL; + } + + time_t now = time(NULL); + long age_ms = (long)(now - st.st_mtime) * 1000L; + if(age_ms > max_age_ms) + { + remove(path); + CHIAKI_LOGI(log, "[CACHE EXPIRED] %s (age %lds)", key, age_ms / 1000); + return NULL; + } + + FILE *f = fopen(path, "rb"); + if(!f) + return NULL; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if(sz <= 0) + { + fclose(f); + return NULL; + } + char *buf = malloc((size_t)sz + 1); + if(!buf) + { + fclose(f); + return NULL; + } + size_t rd = fread(buf, 1, (size_t)sz, f); + fclose(f); + buf[rd] = 0; + + struct json_object *obj = json_tokener_parse(buf); + free(buf); + if(!obj) + { + CHIAKI_LOGW(log, "[CACHE PARSE FAIL] %s; removing", key); + remove(path); + return NULL; + } + CHIAKI_LOGI(log, "[CACHE HIT] %s (%ldKB, age %lds)", key, sz / 1024, age_ms / 1000); + return obj; +} + +ChiakiErrorCode cc_cache_write(ChiakiLog *log, const char *cache_dir, const char *key, struct json_object *obj) +{ + if(!obj) + return CHIAKI_ERR_INVALID_DATA; + cc_cache_ensure_dir(cache_dir); + + char path[1024]; + cache_file_path(cache_dir, key, path, sizeof(path)); + + const char *json = json_object_to_json_string_ext(obj, JSON_C_TO_STRING_PLAIN); + if(!json) + return CHIAKI_ERR_UNKNOWN; + + // Write to a unique temp file then atomically rename, so a concurrent reader + // (or an overlapping writer on the same cache dir) never sees a torn file. + char tmp[1056]; + snprintf(tmp, sizeof(tmp), "%s.tmp.%ld", path, (long)cc_getpid()); + + FILE *f = fopen(tmp, "wb"); + if(!f) + { + CHIAKI_LOGW(log, "[CACHE ERROR] cannot write %s", tmp); + return CHIAKI_ERR_UNKNOWN; + } + size_t len = strlen(json); + size_t wr = fwrite(json, 1, len, f); + fclose(f); + if(wr != len) + { + remove(tmp); + return CHIAKI_ERR_UNKNOWN; + } + if(rename(tmp, path) != 0) + { + remove(tmp); + CHIAKI_LOGW(log, "[CACHE ERROR] cannot rename %s -> %s", tmp, path); + return CHIAKI_ERR_UNKNOWN; + } + CHIAKI_LOGI(log, "[CACHE SAVED] %s (%zuKB)", key, len / 1024); + return CHIAKI_ERR_SUCCESS; +} + +void cc_cache_remove(const char *cache_dir, const char *key) +{ + char path[1024]; + cache_file_path(cache_dir, key, path, sizeof(path)); + remove(path); +} diff --git a/lib/src/cloudcatalog_consts.c b/lib/src/cloudcatalog_consts.c new file mode 100644 index 00000000..0e2ac996 --- /dev/null +++ b/lib/src/cloudcatalog_consts.c @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Region groups + store-locale fallback chain. Ported from KamajiConsts +// (gui/include/cloudstreaming/pskamajisession.h) and canonicalStoreLocale / +// buildStoreLocaleFallbackChain (gui/src/cloudcatalogbackend.cpp). + +#include "cloudcatalog_internal.h" + +#include + +#include +#include +#include +#include +#include + +static bool is_americas_classics_region(const char *cc) +{ + if(!cc || !*cc) + return false; + static const char *const americas[] = { + "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", + "PY", "UY", "CR", "GT", "HN", "NI", "PA", "SV", "DO", NULL + }; + char up[8] = { 0 }; + for(size_t i = 0; i < sizeof(up) - 1 && cc[i]; i++) + up[i] = (char)toupper((unsigned char)cc[i]); + for(size_t i = 0; americas[i]; i++) + if(strcmp(up, americas[i]) == 0) + return true; + return false; +} + +const char *cc_classics_store_country(const char *account_country) +{ + return is_americas_classics_region(account_country) ? "US" : "GB"; +} + +const char *cc_apollo_root_container_id(const char *account_country) +{ + return is_americas_classics_region(account_country) + ? "STORE-MSF192018-APOLLOROOT" + : "STORE-MSF192014-APOLLOROOT"; +} + +// Canonicalize "language-COUNTRY" to lowercase-lang / uppercase-country. +// Writes into out (size >= 16). Defaults to "en-US". +static void canonical_store_locale(const char *raw, char *out, size_t out_sz) +{ + char lang[8] = { 0 }; + char country[8] = { 0 }; + + // trim leading space + const char *p = raw ? raw : ""; + while(*p && isspace((unsigned char)*p)) + p++; + if(!*p) + { + snprintf(out, out_sz, "en-US"); + return; + } + + const char *dash = strchr(p, '-'); + size_t lang_len = dash ? (size_t)(dash - p) : strlen(p); + if(lang_len >= sizeof(lang)) + lang_len = sizeof(lang) - 1; + for(size_t i = 0; i < lang_len; i++) + lang[i] = (char)tolower((unsigned char)p[i]); + + if(dash) + { + const char *cp = dash + 1; + size_t clen = strlen(cp); + // trim trailing whitespace + while(clen > 0 && isspace((unsigned char)cp[clen - 1])) + clen--; + if(clen >= sizeof(country)) + clen = sizeof(country) - 1; + for(size_t i = 0; i < clen; i++) + country[i] = (char)toupper((unsigned char)cp[i]); + } + + if(!lang[0]) + snprintf(lang, sizeof(lang), "en"); + if(!country[0]) + snprintf(country, sizeof(country), "US"); + snprintf(out, out_sz, "%s-%s", lang, country); +} + +size_t cc_build_store_locale_chain(const char *stored, char **out, size_t max) +{ + if(!out || max == 0) + return 0; + + char canonical[16]; + canonical_store_locale(stored, canonical, sizeof(canonical)); + + const char *dash = strchr(canonical, '-'); + const char *country = dash ? dash + 1 : "US"; + + char en_country[16]; + snprintf(en_country, sizeof(en_country), "en-%s", country); + + const char *candidates[3] = { canonical, en_country, "en-US" }; + size_t count = 0; + for(size_t i = 0; i < 3 && count < max; i++) + { + bool dup = false; + for(size_t j = 0; j < count; j++) + if(strcmp(out[j], candidates[i]) == 0) + { + dup = true; + break; + } + if(!dup) + out[count++] = strdup(candidates[i]); + } + return count; +} + +// --------------------------------------------------------------------------- +// Cloud streaming language / datacenter table +// +// Game language is tied to the datacenter region (Gaikai ignores a language +// whose datacenter is not selected). One (locale -> 3-letter datacenter region +// prefix) table backs the cross-platform language picker. A locale may be +// served by several regions (en-GB: London + Stockholm) and a region may serve +// several locales (Stockholm: en-GB + fi-FI). The picker lists every supported +// locale; chiaki_cloud_datacenter_serves_locale() lets a client best-effort +// auto-select a reachable datacenter that serves the chosen language. +// --------------------------------------------------------------------------- + +static const struct { const char *locale; const char *prefix; } kLocaleDatacenters[] = { + { "en-US", "sjc" }, { "en-US", "iad" }, // US West / US East + { "en-GB", "lon" }, { "en-GB", "sto" }, // London / Stockholm (Nordic) + { "de-DE", "fra" }, // Frankfurt + { "fr-FR", "par" }, // Paris + { "fi-FI", "sto" }, // Stockholm + { "it-IT", "mil" }, // Milan + { "es-ES", "mad" }, // Madrid + { "nl-NL", "ams" }, // Amsterdam + { "pt-BR", "sao" }, // Sao Paulo + { "ja-JP", "tyo" }, { "ja-JP", "osa" }, // Tokyo / Osaka + { "ko-KR", "sel" }, // Seoul +}; + +// Distinct picker locales, in display order. Platforms render localized names. +static const char *const kSupportedLocales[] = { + "en-US", "en-GB", "de-DE", "fr-FR", "fi-FI", + "it-IT", "es-ES", "nl-NL", "pt-BR", "ja-JP", "ko-KR", +}; + +void chiaki_cloud_gaikai_language(const char *locale, char *out, size_t out_sz) +{ + if(!out || out_sz == 0) + return; + out[0] = 0; + const char *p = locale ? locale : ""; + while(*p && isspace((unsigned char)*p)) + p++; + size_t i = 0; + for(; p[i] && p[i] != '-' && p[i] != '_' && i < out_sz - 1; i++) + out[i] = (char)tolower((unsigned char)p[i]); + out[i] = 0; + if(!out[0]) + snprintf(out, out_sz, "en"); +} + +size_t chiaki_cloud_supported_locale_count(void) +{ + return sizeof(kSupportedLocales) / sizeof(kSupportedLocales[0]); +} + +const char *chiaki_cloud_supported_locale(size_t idx) +{ + return idx < chiaki_cloud_supported_locale_count() ? kSupportedLocales[idx] : ""; +} + +bool chiaki_cloud_datacenter_serves_locale(const char *datacenter_name, const char *locale) +{ + if(!datacenter_name || !locale || !*datacenter_name || !*locale) + return false; + char prefix[4] = { 0 }; + for(size_t i = 0; i < 3 && datacenter_name[i]; i++) + prefix[i] = (char)tolower((unsigned char)datacenter_name[i]); + if(!prefix[0]) + return false; + for(size_t i = 0; i < sizeof(kLocaleDatacenters) / sizeof(kLocaleDatacenters[0]); i++) + if(strcmp(kLocaleDatacenters[i].prefix, prefix) == 0 + && strcasecmp(kLocaleDatacenters[i].locale, locale) == 0) + return true; + return false; +} diff --git a/lib/src/cloudcatalog_fetch.c b/lib/src/cloudcatalog_fetch.c new file mode 100644 index 00000000..a5fd7abf --- /dev/null +++ b/lib/src/cloudcatalog_fetch.c @@ -0,0 +1,807 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Blocking network fetch flows for the unified cloud catalog. Faithful port of +// the async QNetworkAccessManager state machines in cloudcatalogbackend.cpp: +// - PS Now OAuth -> session -> stores -> APOLLOROOT root + alphabetical walk +// - public APOLLOROOT fallback pagination (region-unsupported accounts) +// - imagic 6-list fetch with locale fallback chain +// - owned entitlements OAuth(token) -> paginated internal_entitlements -> filter + +#include "cloudcatalog_internal.h" +#include "curl_http.h" + +#ifdef _WIN32 +#include +#else +#include +#include +#endif +#include + +#include + +#include +#include +#include +#include + +// --- Constants (KamajiConsts + CloudConfig) -------------------------------- +#define ACCOUNT_BASE "https://ca.account.sony.com/api" +#define KAMAJI_BASE "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000" +#define PSNOW_CLIENT_ID "bc6b0777-abb5-40da-92ca-e133cf18e989" +#define OWNED_CLIENT_ID "dc523cc2-b51b-4190-bff0-3397c06871b3" +#define PS4_SCOPES "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" +#define OWNED_SCOPES "kamaji:get_internal_entitlements user:account.attributes.validate" +#define KAMAJI_UA "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +#define KAMAJI_ORIGIN "https://psnow.playstation.com" +#define KAMAJI_REFERER "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/" +#define KAMAJI_REDIRECT "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" +#define GENERIC_UA "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +#define OWNED_PAGE_SIZE 300 + +// --- small helpers ---------------------------------------------------------- + +static char *url_encode(const char *s) +{ + CURL *c = curl_easy_init(); + char *e = c ? curl_easy_escape(c, s, 0) : NULL; + char *r = e ? strdup(e) : NULL; + if(e) + curl_free(e); + if(c) + curl_easy_cleanup(c); + return r; +} + +// Extract value of "key=" from a URL/query/fragment up to '&'. Returns true if found. +static bool extract_param(const char *url, const char *key, char *out, size_t out_sz) +{ + out[0] = 0; + if(!url) + return false; + char pat[64]; + snprintf(pat, sizeof(pat), "%s=", key); + const char *p = strstr(url, pat); + if(!p) + return false; + p += strlen(pat); + size_t i = 0; + while(*p && *p != '&' && i < out_sz - 1) + out[i++] = *p++; + out[i] = 0; + return i > 0; +} + +// Extract JSESSIONID=...; from a raw header block. +static bool extract_jsessionid(const char *headers, char *out, size_t out_sz) +{ + out[0] = 0; + if(!headers) + return false; + const char *p = strstr(headers, "JSESSIONID="); + if(!p) + return false; + p += strlen("JSESSIONID="); + size_t i = 0; + while(*p && *p != ';' && *p != '\r' && *p != '\n' && i < out_sz - 1) + out[i++] = *p++; + out[i] = 0; + return i > 0; +} + +static struct json_object *parse_body(const CCHttpResponse *resp) +{ + if(!resp->data || !resp->size) + return NULL; + return json_tokener_parse(resp->data); +} + +// status_code header == "0x0000" check on a Kamaji envelope. +static bool kamaji_ok(struct json_object *obj) +{ + struct json_object *header = cc_json_obj(obj, "header"); + return header && strcmp(cc_json_str(header, "status_code"), "0x0000") == 0; +} + +// =========================================================================== +// PS Now native APOLLOROOT probe +// =========================================================================== + +// Returns OAuth `code` (CC_NATIVE_OK) or an error class. Writes code into out_code. +static CCNativeResult psnow_oauth(ChiakiLog *log, const char *npsso, const char *duid, + char *out_code, size_t code_sz) +{ + char *enc_scope = url_encode(PS4_SCOPES); + char *enc_redirect = url_encode(KAMAJI_REDIRECT); + char *enc_duid = url_encode(duid); + if(!enc_scope || !enc_redirect || !enc_duid) + { + free(enc_scope); free(enc_redirect); free(enc_duid); + return CC_NATIVE_FATAL; + } + + char url[2048]; + snprintf(url, sizeof(url), + ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc%%3Apsnow&applicationId=psnow" + "&response_type=code&scope=%s&client_id=" PSNOW_CLIENT_ID "&redirect_uri=%s" + "&service_entity=urn%%3Aservice-entity%%3Apsn&prompt=none&renderMode=mobilePortrait" + "&hidePageElements=forgotPasswordLink&displayFooter=none&disableLinks=qriocityLink" + "&mid=PSNOW&duid=%s&layout_type=popup&service_logo=ps&tp_psn=true&noEVBlock=true", + enc_scope, enc_redirect, enc_duid); + free(enc_scope); free(enc_redirect); free(enc_duid); + + char *cookie = NULL; + cc_http_make_cookie_header(&cookie, "npsso", npsso); + const char *headers[] = { "User-Agent: " KAMAJI_UA, cookie }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 2; + req.capture_headers = true; + + CCHttpResponse resp; + ChiakiErrorCode e = cc_http_perform(log, &req, &resp); + free(cookie); + if(e != CHIAKI_ERR_SUCCESS) + return CC_NATIVE_FATAL; + + CCNativeResult result = CC_NATIVE_AUTH_ERROR; + if(resp.status_code == 302 && resp.redirect_url) + { + if(extract_param(resp.redirect_url, "code", out_code, code_sz)) + result = CC_NATIVE_OK; + } + cc_http_response_fini(&resp); + return result; +} + +// POST /user/session -> JSESSIONID. Returns CC_NATIVE_OK/AUTH_ERROR. +// Also captures the account region signal from the response body (data.country / +// data.language) into out_country/out_language when present (may be left empty). +static CCNativeResult psnow_session(ChiakiLog *log, const char *code, const char *duid, + char *out_jsession, size_t js_sz, + char *out_country, size_t cc_sz, + char *out_language, size_t lang_sz) +{ + char body[1024]; + snprintf(body, sizeof(body), "code=%s&client_id=" PSNOW_CLIENT_ID "&duid=%s", code, duid); + + const char *headers[] = { + "Content-Type: text/plain;charset=UTF-8", + "User-Agent: " KAMAJI_UA, + "X-Alt-Referer: " KAMAJI_REDIRECT, + "Origin: " KAMAJI_ORIGIN, + "Referer: " KAMAJI_REFERER, + "Accept: */*", + }; + CCHttpRequest req = { 0 }; + req.method = "POST"; + req.url = KAMAJI_BASE "/user/session"; + req.headers = headers; + req.header_count = 6; + req.body = body; + req.capture_headers = true; + + CCHttpResponse resp; + if(cc_http_perform(log, &req, &resp) != CHIAKI_ERR_SUCCESS) + return CC_NATIVE_FATAL; + + CCNativeResult result = CC_NATIVE_AUTH_ERROR; + if(resp.status_code == 200) + { + struct json_object *obj = parse_body(&resp); + if(obj && kamaji_ok(obj)) + { + // Capture the account's region signal (country/language) regardless of + // the JSESSIONID outcome, so the lib can drive locale/region centrally + // even on the region-unsupported path (/user/stores 404 after this). + struct json_object *data = cc_json_obj(obj, "data"); + if(data) + { + if(out_country && cc_sz) + snprintf(out_country, cc_sz, "%s", cc_json_str(data, "country")); + if(out_language && lang_sz) + snprintf(out_language, lang_sz, "%s", cc_json_str(data, "language")); + } + if(extract_jsessionid(resp.headers, out_jsession, js_sz)) + result = CC_NATIVE_OK; + } + if(obj) + json_object_put(obj); + } + cc_http_response_fini(&resp); + return result; +} + +// GET /user/stores -> base_url. Returns CC_NATIVE_OK or CC_NATIVE_REGION_UNSUPPORTED. +static CCNativeResult psnow_stores(ChiakiLog *log, const char *jsession, + char *out_base_url, size_t url_sz) +{ + char *cookie = NULL; + cc_http_make_cookie_header(&cookie, "JSESSIONID", jsession); + const char *headers[] = { + "User-Agent: " KAMAJI_UA, cookie, + "Origin: " KAMAJI_ORIGIN, "Referer: " KAMAJI_REFERER, "Accept: application/json", + }; + CCHttpRequest req = { 0 }; + req.url = KAMAJI_BASE "/user/stores"; + req.headers = headers; + req.header_count = 5; + + CCHttpResponse resp; + ChiakiErrorCode e = cc_http_perform(log, &req, &resp); + free(cookie); + if(e != CHIAKI_ERR_SUCCESS) + return CC_NATIVE_REGION_UNSUPPORTED; + + CCNativeResult result = CC_NATIVE_REGION_UNSUPPORTED; + if(resp.status_code == 200) + { + struct json_object *obj = parse_body(&resp); + if(obj && kamaji_ok(obj)) + { + struct json_object *data = cc_json_obj(obj, "data"); + const char *base = data ? cc_json_str(data, "base_url") : ""; + if(*base) + { + snprintf(out_base_url, url_sz, "%s", base); + result = CC_NATIVE_OK; + } + } + if(obj) + json_object_put(obj); + } + cc_http_response_fini(&resp); + return result; +} + +static bool is_alpha_category(const char *name) +{ + static const char *const pats[] = { + "A - B", "C - D", "E - G", "H - L", "M - O", "P - R", "S", "T", "U - Z", NULL + }; + for(size_t i = 0; pats[i]; i++) + if(strcmp(name, pats[i]) == 0) + return true; + return false; +} + +// GET base_url?size=100 -> list of alphabetical category URLs (appended to out array of strings). +static bool psnow_root_categories(ChiakiLog *log, const char *base_url, const char *jsession, + char cat_urls[][1024], int *cat_count, int max_cats) +{ + char url[1100]; + snprintf(url, sizeof(url), "%s?size=100", base_url); + char *cookie = NULL; + cc_http_make_cookie_header(&cookie, "JSESSIONID", jsession); + const char *headers[] = { + "User-Agent: " KAMAJI_UA, cookie, + "Origin: " KAMAJI_ORIGIN, "Referer: " KAMAJI_REFERER, "Accept: application/json", + }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 5; + + CCHttpResponse resp; + ChiakiErrorCode e = cc_http_perform(log, &req, &resp); + free(cookie); + if(e != CHIAKI_ERR_SUCCESS || resp.status_code != 200) + { + cc_http_response_fini(&resp); + return false; + } + struct json_object *obj = parse_body(&resp); + cc_http_response_fini(&resp); + if(!obj) + return false; + + *cat_count = 0; + struct json_object *links = cc_json_arr(obj, "links"); + if(links) + { + size_t n = json_object_array_length(links); + for(size_t i = 0; i < n && *cat_count < max_cats; i++) + { + struct json_object *link = json_object_array_get_idx(links, i); + const char *name = cc_json_str(link, "name"); + const char *u = cc_json_str(link, "url"); + if(*u && is_alpha_category(name)) + snprintf(cat_urls[(*cat_count)++], 1024, "%s", u); + } + } + json_object_put(obj); + return *cat_count > 0; +} + +// GET one category page (?start=0&size=500), append product rows to all_games. +static void psnow_fetch_category(ChiakiLog *log, const char *cat_url, struct json_object *all_games) +{ + char url[1200]; + snprintf(url, sizeof(url), strchr(cat_url, '?') ? "%s&start=0&size=500" : "%s?start=0&size=500", cat_url); + const char *headers[] = { + "Content-Type: application/json", "Accept: application/json", + "User-Agent: " GENERIC_UA, + }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 3; + + CCHttpResponse resp; + if(cc_http_perform(log, &req, &resp) != CHIAKI_ERR_SUCCESS || resp.status_code != 200) + { + cc_http_response_fini(&resp); + return; + } + struct json_object *obj = parse_body(&resp); + cc_http_response_fini(&resp); + if(!obj) + return; + struct json_object *links = cc_json_arr(obj, "links"); + if(links) + { + size_t n = json_object_array_length(links); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(links, i); + if(!g || json_object_get_type(g) != json_type_object) + continue; + struct json_object *gc = cc_json_clone(g); + char img[1024]; + cc_extract_cover_image(gc, img, sizeof(img)); + if(*img) + cc_json_set_str(gc, "imageUrl", img); + json_object_array_add(all_games, gc); + } + } + json_object_put(obj); +} + +CCNativeResult cc_fetch_psnow_native(ChiakiLog *log, const char *npsso, struct json_object **out_games, + char *out_country, size_t cc_sz, char *out_language, size_t lang_sz) +{ + *out_games = NULL; + if(out_country && cc_sz) + out_country[0] = 0; + if(out_language && lang_sz) + out_language[0] = 0; + if(!npsso || !*npsso) + return CC_NATIVE_AUTH_ERROR; + + size_t duid_size = CHIAKI_DUID_STR_SIZE; + char duid[CHIAKI_DUID_STR_SIZE]; + if(chiaki_holepunch_generate_client_device_uid(duid, &duid_size) != CHIAKI_ERR_SUCCESS) + return CC_NATIVE_FATAL; + + char code[1024]; + CCNativeResult r = psnow_oauth(log, npsso, duid, code, sizeof(code)); + if(r != CC_NATIVE_OK) + return r; + CHIAKI_LOGI(log, "[PSNOW] OAuth code obtained, creating session"); + + char jsession[512]; + r = psnow_session(log, code, duid, jsession, sizeof(jsession), out_country, cc_sz, out_language, lang_sz); + if(r != CC_NATIVE_OK) + return r; + CHIAKI_LOGI(log, "[PSNOW] Session created, fetching stores"); + + char base_url[1024]; + r = psnow_stores(log, jsession, base_url, sizeof(base_url)); + if(r != CC_NATIVE_OK) + return r; // region unsupported -> caller does public fallback + CHIAKI_LOGI(log, "[PSNOW] Stores OK, base_url=%s", base_url); + + char cat_urls[16][1024]; + int cat_count = 0; + if(!psnow_root_categories(log, base_url, jsession, cat_urls, &cat_count, 16)) + return CC_NATIVE_REGION_UNSUPPORTED; + + struct json_object *all = json_object_new_array(); + for(int i = 0; i < cat_count; i++) + psnow_fetch_category(log, cat_urls[i], all); + + // Dedup by id (first-wins). + struct json_object *seen = json_object_new_object(); + struct json_object *final = json_object_new_array(); + size_t n = json_object_array_length(all); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(all, i); + const char *id = cc_json_str(g, "id"); + struct json_object *tmp = NULL; + if(!*id || json_object_object_get_ex(seen, id, &tmp)) + continue; + json_object_object_add(seen, id, json_object_new_int(1)); + json_object_array_add(final, cc_json_clone(g)); + } + json_object_put(seen); + json_object_put(all); + + CHIAKI_LOGI(log, "[PSNOW] APOLLOROOT native: %d games", (int)json_object_array_length(final)); + *out_games = final; + return CC_NATIVE_OK; +} + +// =========================================================================== +// Public APOLLOROOT fallback pagination +// =========================================================================== + +struct json_object *cc_fetch_apollo_fallback(ChiakiLog *log, const char *account_country) +{ + const char *store_country = cc_classics_store_country(account_country); + const char *container = cc_apollo_root_container_id(account_country); + char container_url[512]; + snprintf(container_url, sizeof(container_url), + "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%s/en/19/%s", + store_country, container); + + struct json_object *games = json_object_new_array(); + int start = 0, total = -1; + for(;;) + { + char url[700]; + snprintf(url, sizeof(url), "%s?useOffers=true&gkb=1&gkb2=1&start=%d&size=100", container_url, start); + const char *headers[] = { "Accept: application/json", "User-Agent: " KAMAJI_UA }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 2; + + CCHttpResponse resp; + if(cc_http_perform(log, &req, &resp) != CHIAKI_ERR_SUCCESS || resp.status_code != 200) + { + cc_http_response_fini(&resp); + break; + } + struct json_object *obj = parse_body(&resp); + cc_http_response_fini(&resp); + if(!obj) + break; + if(total < 0) + total = cc_json_int(obj, "total_results"); + int product_count = 0; + struct json_object *links = cc_json_arr(obj, "links"); + if(links) + { + size_t n = json_object_array_length(links); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(links, i); + if(!cc_ieq(cc_json_str(g, "container_type"), "product")) + continue; + struct json_object *gc = cc_json_clone(g); + char img[1024]; + cc_extract_cover_image(gc, img, sizeof(img)); + if(*img) + cc_json_set_str(gc, "imageUrl", img); + json_object_array_add(games, gc); + product_count++; + } + } + json_object_put(obj); + start += 100; + if(product_count <= 0 || (total >= 0 && start >= total)) + break; + } + CHIAKI_LOGI(log, "[UNIFIED] APOLLOROOT fallback: %d titles", (int)json_object_array_length(games)); + return games; +} + +// =========================================================================== +// imagic 6-list +// =========================================================================== + +static const char *const kImagicLists[] = { + "plus-games-list", "ubisoft-classics-list", "plus-classics-list", + "plus-monthly-games-list", "free-to-play-list", "all-ps5-list", +}; +#define IMAGIC_LIST_COUNT 6 + +void cc_imagic_result_fini(CCImagicResult *r) +{ + if(!r) + return; + if(r->browse) json_object_put(r->browse); + if(r->supplement) json_object_put(r->supplement); + if(r->aliases) json_object_put(r->aliases); + memset(r, 0, sizeof(*r)); +} + +bool cc_fetch_imagic(ChiakiLog *log, const char *stored_locale, CCImagicResult *out) +{ + memset(out, 0, sizeof(*out)); + char *chain[3]; + size_t chain_n = cc_build_store_locale_chain(stored_locale, chain, 3); + + for(size_t tier = 0; tier < chain_n; tier++) + { + // lower-case locale for imagic ("en-us") + char locale[16]; + snprintf(locale, sizeof(locale), "%s", chain[tier]); + for(char *p = locale; *p; p++) + *p = (char)tolower((unsigned char)*p); + + struct json_object *games_by_edition = json_object_new_object(); + struct json_object *supplement = json_object_new_object(); + struct json_object *aliases = json_object_new_object(); + int total_seen = 0, succeeded = 0; + bool all_ps5_ok = false; + + for(int i = 0; i < IMAGIC_LIST_COUNT; i++) + { + char url[256]; + snprintf(url, sizeof(url), + "https://www.playstation.com/bin/imagic/gameslist?locale=%s&categoryList=%s", + locale, kImagicLists[i]); + const char *headers[] = { + "Content-Type: application/json", "Accept: application/json", + "User-Agent: " GENERIC_UA, + }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 3; + + CCHttpResponse resp; + if(cc_http_perform(log, &req, &resp) != CHIAKI_ERR_SUCCESS || resp.status_code != 200) + { + cc_http_response_fini(&resp); + continue; + } + struct json_object *doc = parse_body(&resp); + cc_http_response_fini(&resp); + if(!doc || json_object_get_type(doc) != json_type_array) + { + if(doc) + json_object_put(doc); + continue; + } + succeeded++; + if(strcmp(kImagicLists[i], "all-ps5-list") == 0) + all_ps5_ok = true; + cc_merge_imagic_list(kImagicLists[i], doc, games_by_edition, supplement, aliases, &total_seen); + json_object_put(doc); + } + + if(succeeded <= 0) + { + json_object_put(games_by_edition); + json_object_put(supplement); + json_object_put(aliases); + continue; // escalate to next locale tier + } + + // Materialize arrays (with image extraction). + struct json_object *browse = json_object_new_array(); + json_object_object_foreach(games_by_edition, k1, v1) + { + (void)k1; + struct json_object *gc = cc_json_clone(v1); + if(!cc_json_has(gc, "imageUrl") || !*cc_json_str(gc, "imageUrl")) + { + char img[1024]; + cc_extract_cover_image(gc, img, sizeof(img)); + if(*img) + cc_json_set_str(gc, "imageUrl", img); + } + json_object_array_add(browse, gc); + } + struct json_object *supp = json_object_new_array(); + json_object_object_foreach(supplement, k2, v2) + { + (void)k2; + struct json_object *gc = cc_json_clone(v2); + if(!cc_json_has(gc, "imageUrl") || !*cc_json_str(gc, "imageUrl")) + { + char img[1024]; + cc_extract_cover_image(gc, img, sizeof(img)); + if(*img) + cc_json_set_str(gc, "imageUrl", img); + } + json_object_array_add(supp, gc); + } + + json_object_put(games_by_edition); + json_object_put(supplement); + + out->browse = browse; + out->supplement = supp; + out->aliases = aliases; + snprintf(out->settled_locale, sizeof(out->settled_locale), "%s", chain[tier]); + out->all_ps5_list_succeeded = all_ps5_ok; + out->any_succeeded = true; + CHIAKI_LOGI(log, "[PSCLOUD] imagic settled on %s: %d browse, %d supplement (scanned %d)", + out->settled_locale, (int)json_object_array_length(browse), + (int)json_object_array_length(supp), total_seen); + break; + } + + for(size_t i = 0; i < chain_n; i++) + free(chain[i]); + return out->any_succeeded; +} + +// =========================================================================== +// Owned entitlements +// =========================================================================== + +// filterOwnedPs5Games: keep active game entitlements (feature_type != 0), set imageUrl + serviceType. +static struct json_object *filter_owned(ChiakiLog *log, struct json_object *entitlements) +{ + (void)log; + struct json_object *out = json_object_new_array(); + size_t n = json_object_array_length(entitlements); + for(size_t i = 0; i < n; i++) + { + struct json_object *ent = json_object_array_get_idx(entitlements, i); + if(!ent || json_object_get_type(ent) != json_type_object) + continue; + struct json_object *gm = cc_json_obj(ent, "game_meta"); + if(!gm) + continue; + if(!cc_json_bool(ent, "active_flag")) + continue; + const char *pid = cc_json_str(ent, "product_id"); + if(strncmp(pid, "IP", 2) == 0 || strncmp(pid, "SUB", 3) == 0) + continue; + if(cc_json_int(ent, "feature_type") == 0) + continue; + + struct json_object *e = cc_json_clone(ent); + struct json_object *egm = cc_json_obj(e, "game_meta"); + char img[1024]; + const char *icon = cc_json_str(egm, "icon_url"); + if(*icon) + snprintf(img, sizeof(img), "%s", icon); + else + { + cc_extract_cover_image(egm, img, sizeof(img)); + if(!*img) + cc_extract_cover_image(e, img, sizeof(img)); + } + if(*img) + cc_json_set_str(e, "imageUrl", img); + cc_sanitize_owned_service_type(e); + json_object_array_add(out, e); + } + return out; +} + +static CCOwnedResult owned_oauth(ChiakiLog *log, const char *npsso, char *out_token, size_t tok_sz) +{ + char *enc_scope = url_encode(OWNED_SCOPES); + char *enc_redirect = url_encode(KAMAJI_REDIRECT); + if(!enc_scope || !enc_redirect) + { + free(enc_scope); free(enc_redirect); + return CC_OWNED_ERROR; + } + char url[1536]; + snprintf(url, sizeof(url), + ACCOUNT_BASE "/v1/oauth/authorize?response_type=token&scope=%s&client_id=" OWNED_CLIENT_ID + "&redirect_uri=%s&service_entity=urn%%3Aservice-entity%%3Apsn&prompt=none", + enc_scope, enc_redirect); + free(enc_scope); free(enc_redirect); + + char *cookie = NULL; + cc_http_make_cookie_header(&cookie, "npsso", npsso); + const char *headers[] = { cookie, "User-Agent: " GENERIC_UA }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 2; + + CCHttpResponse resp; + ChiakiErrorCode e = cc_http_perform(log, &req, &resp); + free(cookie); + if(e != CHIAKI_ERR_SUCCESS) + return CC_OWNED_ERROR; + + CCOwnedResult result = CC_OWNED_AUTH_ERROR; + if(resp.status_code == 302 && resp.redirect_url) + { + char errbuf[128]; + if(extract_param(resp.redirect_url, "error", errbuf, sizeof(errbuf))) + result = CC_OWNED_AUTH_ERROR; + else if(extract_param(resp.redirect_url, "access_token", out_token, tok_sz)) + result = CC_OWNED_OK; + } + cc_http_response_fini(&resp); + return result; +} + +CCOwnedResult cc_fetch_owned(ChiakiLog *log, const char *npsso, + struct json_object **out_games, struct json_object **out_component_ids) +{ + *out_games = NULL; + *out_component_ids = NULL; + if(!npsso || !*npsso) + return CC_OWNED_AUTH_ERROR; + + char token[2048]; + CCOwnedResult r = owned_oauth(log, npsso, token, sizeof(token)); + if(r != CC_OWNED_OK) + return r; + + char *bearer = NULL; + cc_http_make_bearer_header(&bearer, token); + + struct json_object *accumulated = json_object_new_array(); + int start = 0; + CCOwnedResult result = CC_OWNED_OK; + for(;;) + { + char url[512]; + snprintf(url, sizeof(url), + "https://commerce.api.np.km.playstation.net/commerce/api/v1/users/me/internal_entitlements" + "?fields=game_meta&entitlement_type=5&start=%d&size=%d", start, OWNED_PAGE_SIZE); + const char *headers[] = { bearer, "Accept: application/json" }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = headers; + req.header_count = 2; + + CCHttpResponse resp; + if(cc_http_perform(log, &req, &resp) != CHIAKI_ERR_SUCCESS) + { + cc_http_response_fini(&resp); + result = CC_OWNED_ERROR; + break; + } + if(resp.status_code == 401 || resp.status_code == 403) + { + cc_http_response_fini(&resp); + result = CC_OWNED_AUTH_ERROR; + break; + } + struct json_object *obj = parse_body(&resp); + cc_http_response_fini(&resp); + if(!obj) + { + result = CC_OWNED_ERROR; + break; + } + struct json_object *page = cc_json_arr(obj, "entitlements"); + int page_count = page ? (int)json_object_array_length(page) : 0; + for(int i = 0; i < page_count; i++) + json_object_array_add(accumulated, cc_json_clone(json_object_array_get_idx(page, i))); + json_object_put(obj); + if(page_count < OWNED_PAGE_SIZE) + break; + start += page_count; + } + free(bearer); + + if(result != CC_OWNED_OK) + { + json_object_put(accumulated); + return result; + } + + // componentIdsByProductId + struct json_object *components = json_object_new_object(); + size_t an = json_object_array_length(accumulated); + for(size_t i = 0; i < an; i++) + { + struct json_object *ent = json_object_array_get_idx(accumulated, i); + const char *pid = cc_json_str(ent, "product_id"); + const char *eid = cc_json_str(ent, "id"); + if(!*pid || !*eid) + continue; + struct json_object *arr = NULL; + if(!json_object_object_get_ex(components, pid, &arr)) + { + arr = json_object_new_array(); + json_object_object_add(components, pid, arr); + } + json_object_array_add(arr, json_object_new_string(eid)); + } + + *out_games = filter_owned(log, accumulated); + *out_component_ids = components; + json_object_put(accumulated); + CHIAKI_LOGI(log, "[OWNED] %d entitlements -> %d games", + (int)an, (int)json_object_array_length(*out_games)); + return CC_OWNED_OK; +} diff --git a/lib/src/cloudcatalog_internal.h b/lib/src/cloudcatalog_internal.h new file mode 100644 index 00000000..d51db351 --- /dev/null +++ b/lib/src/cloudcatalog_internal.h @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Internal declarations shared across the cloudcatalog_*.c modules. Not part of +// the public API (see include/chiaki/cloudcatalog.h for that). + +#ifndef CHIAKI_CLOUDCATALOG_INTERNAL_H +#define CHIAKI_CLOUDCATALOG_INTERNAL_H + +#include +#include + +// Use the per-component json-c headers (like remote/holepunch.c) instead of the +// umbrella : on the Android/iOS FetchContent build the generated +// umbrella lands in jsonc-build/json.h (no json-c/ prefix), so +// is unresolvable there while these always resolve from the source include dir. +#include +#include +// json_object_object_foreach() expands to lh_table_head/lh_entry_* calls, declared here. +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// --------------------------------------------------------------------------- +// json-c convenience helpers (NULL-safe). These keep the ported merge logic +// close to the Qt original (QJsonObject::value(...).toString() etc.). +// --------------------------------------------------------------------------- + +/** Return the string at obj[key], or "" if missing/not a string. Never NULL. */ +const char *cc_json_str(struct json_object *obj, const char *key); + +/** Return obj[key] as object, or NULL. */ +struct json_object *cc_json_obj(struct json_object *obj, const char *key); + +/** Return obj[key] as array, or NULL. */ +struct json_object *cc_json_arr(struct json_object *obj, const char *key); + +/** Return obj[key] as bool (false if missing). */ +bool cc_json_bool(struct json_object *obj, const char *key); + +/** Return obj[key] as int (0 if missing). */ +int cc_json_int(struct json_object *obj, const char *key); + +/** True if obj has a (non-null) value at key. */ +bool cc_json_has(struct json_object *obj, const char *key); + +/** strdup that tolerates NULL (returns NULL). */ +char *cc_strdup(const char *s); + +/** Case-insensitive equality (NULL-safe; NULL != non-NULL, NULL == NULL). */ +bool cc_ieq(const char *a, const char *b); + +/** strstr wrapper, NULL-safe. */ +bool cc_contains(const char *haystack, const char *needle); + +/** True if s ends with suffix. */ +bool cc_ends_with(const char *s, const char *suffix); + +/** Set obj[key] = string value (replaces). Copies the string. */ +void cc_json_set_str(struct json_object *obj, const char *key, const char *value); + +/** Set obj[key] = bool. */ +void cc_json_set_bool(struct json_object *obj, const char *key, bool value); + +/** Deep copy a json value (json_object_deep_copy with the standard copy fn). */ +struct json_object *cc_json_clone(struct json_object *src); + +// --------------------------------------------------------------------------- +// Region / locale (cloudcatalog_consts.c) +// --------------------------------------------------------------------------- + +/** "US" for Americas account regions, else "GB". @p account_country may be NULL. */ +const char *cc_classics_store_country(const char *account_country); + +/** Fully-qualified APOLLOROOT container id for the account's region group. */ +const char *cc_apollo_root_container_id(const char *account_country); + +/** + * Build the ordered store-locale fallback chain (canonical, en-COUNTRY, en-US). + * Writes up to @p max NUL-terminated locales into @p out (caller frees each via + * free()); returns the count. + */ +size_t cc_build_store_locale_chain(const char *stored, char **out, size_t max); + +// --------------------------------------------------------------------------- +// Cache I/O (cloudcatalog_cache.c) +// --------------------------------------------------------------------------- + +#define CC_CACHE_TTL_MS (24 * 60 * 60 * 1000) /* 24h */ + +/** mkdir -p the cache dir. */ +ChiakiErrorCode cc_cache_ensure_dir(const char *cache_dir); + +/** + * Read cache_dir/.json if present and younger than max_age_ms. Returns a + * parsed json_object (caller json_object_put) or NULL on miss/expiry/parse-fail. + * Expired files are deleted. + */ +struct json_object *cc_cache_read(ChiakiLog *log, const char *cache_dir, const char *key, long max_age_ms); + +/** Write obj (compact) to cache_dir/.json. */ +ChiakiErrorCode cc_cache_write(ChiakiLog *log, const char *cache_dir, const char *key, struct json_object *obj); + +/** Delete cache_dir/.json. */ +void cc_cache_remove(const char *cache_dir, const char *key); + +// --------------------------------------------------------------------------- +// Merge / assembly (cloudcatalog_merge.c) +// --------------------------------------------------------------------------- + +/** Inputs to the unified assembly (all borrowed; not freed by assemble). */ +typedef struct cc_assemble_input_t +{ + struct json_object *apollo_games; /**< raw PS Now (Apollo) rows array, or NULL */ + struct json_object *imagic_browse; /**< imagic PS5 browse rows array, or NULL */ + struct json_object *imagic_supplement;/**< imagic plus-library supplement rows array, or NULL */ + struct json_object *owned_cross_ref; /**< processed owned entitlements array, or NULL */ + bool native_mode; + const char *fallback_region; /**< "US"|"GB"|"" */ + const char *settled_locale; /**< or NULL */ + const char *warning; /**< or NULL/"" */ +} CCAssembleInput; + +/** + * Pure assembly: mirrors Qt assembleUnifiedCatalog. Returns a newly-allocated + * json_object envelope { schemaVersion, total, nativeMode, fallbackRegion, + * settledLocale, warning, games:[...] } with every contract field populated and + * games pre-sorted (owned first, then name). Caller json_object_put(). + */ +struct json_object *cc_assemble_unified_catalog(ChiakiLog *log, const CCAssembleInput *in); + +/** + * Cross-reference owned entitlements against the browse catalog + supplement. + * Faithful port of processCrossReferenceComplete: produces the deduped owned + * "cross-ref" array (conceptId+platform dedupe, canonical-entitlement rank, + * bundle-sibling expansion, disc-upgrade rescue) that feeds the assemble step. + * + * All params borrowed. @p product_id_aliases is a {alias:canonical} object (or + * NULL); @p component_ids is a {product_id:[entitlement_id,...]} object (or NULL). + * Returns a new array (caller json_object_put()). + */ +struct json_object *cc_build_owned_cross_ref(ChiakiLog *log, + struct json_object *psnow_catalog, struct json_object *imagic_browse, + struct json_object *imagic_supplement, struct json_object *product_id_aliases, + struct json_object *owned_games, struct json_object *component_ids); + +/** + * mergeImagicListIntoPs5Catalog: fold one imagic category-list document into the + * accumulators. @p games_by_edition (concept|platform -> game), @p supplement + * (productId -> game), @p aliases (alt productId -> canonical) are json object + * maps mutated in place. Mirrors the Qt helper exactly. + */ +void cc_merge_imagic_list(const char *category_list, struct json_object *list_doc, + struct json_object *games_by_edition, struct json_object *supplement, + struct json_object *aliases, int *total_seen); + +/** Cover-image extraction (images[type 10]>12>13, then imageUrl). Returns "" if none. */ +const char *cc_extract_cover_image(struct json_object *game_obj, char *out, size_t out_sz); + +/** + * Strip Sony's numeric serviceType and set canonical pscloud/psnow from + * entitlement_attributes[].platform_id. Mirrors sanitizeOwnedEntitlementServiceType. + */ +void cc_sanitize_owned_service_type(struct json_object *ent); + +// --------------------------------------------------------------------------- +// Network fetch (cloudcatalog_fetch.c) — blocking HTTP flows +// --------------------------------------------------------------------------- + +typedef enum cc_native_result_t +{ + CC_NATIVE_OK, /**< authenticated APOLLOROOT walk succeeded */ + CC_NATIVE_AUTH_ERROR, /**< OAuth/session failed (expired token) */ + CC_NATIVE_REGION_UNSUPPORTED,/**< auth OK but /user/stores 404 -> public fallback */ + CC_NATIVE_FATAL /**< setup/transport failure */ +} CCNativeResult; + +/** + * Authenticated PS Now APOLLOROOT probe. On CC_NATIVE_OK, *out_games is a new array. + * Also reports the account region signal from the Kamaji session: out_country / + * out_language receive data.country / data.language (each may be NULL to skip, and + * is set to "" when unavailable). These are populated even on + * CC_NATIVE_REGION_UNSUPPORTED (session succeeded, /user/stores 404'd). + */ +CCNativeResult cc_fetch_psnow_native(ChiakiLog *log, const char *npsso, struct json_object **out_games, + char *out_country, size_t cc_sz, char *out_language, size_t lang_sz); + +/** Public APOLLOROOT fallback pagination for @p account_country. New array or NULL. */ +struct json_object *cc_fetch_apollo_fallback(ChiakiLog *log, const char *account_country); + +typedef struct cc_imagic_result_t +{ + struct json_object *browse; /**< new array of streamable PS5 rows */ + struct json_object *supplement; /**< new array of plus-library rows */ + struct json_object *aliases; /**< new object {altProductId: canonicalProductId} */ + char settled_locale[16]; + bool all_ps5_list_succeeded; + bool any_succeeded; +} CCImagicResult; + +/** imagic 6-list fetch with locale fallback chain. Returns true if any list loaded. */ +bool cc_fetch_imagic(ChiakiLog *log, const char *stored_locale, CCImagicResult *out); +void cc_imagic_result_fini(CCImagicResult *r); + +typedef enum cc_owned_result_t +{ + CC_OWNED_OK, + CC_OWNED_AUTH_ERROR, + CC_OWNED_ERROR +} CCOwnedResult; + +/** Owned entitlements OAuth + pagination + filter. Outputs new games array + componentIds object. */ +CCOwnedResult cc_fetch_owned(ChiakiLog *log, const char *npsso, + struct json_object **out_games, struct json_object **out_component_ids); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CLOUDCATALOG_INTERNAL_H diff --git a/lib/src/cloudcatalog_merge.c b/lib/src/cloudcatalog_merge.c new file mode 100644 index 00000000..b3c87243 --- /dev/null +++ b/lib/src/cloudcatalog_merge.c @@ -0,0 +1,1351 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Unified catalog merge/assembly. Faithful C/json-c port of the anonymous +// namespace + assembleUnifiedCatalog in gui/src/cloudcatalogbackend.cpp +// (HEAD >= commit 0063eec2 — device-based isPs5PlatformGame, apollo-skip browse +// dedup, serviceType-first categoryForGame). Emits every contract field the +// clients used to derive (platform, streamServiceType, streamIdentifier, +// entitlementId, storeProductId) and pre-sorts owned-first then by name. + +#include "cloudcatalog_internal.h" + +#include + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Tiny string-keyed maps backed by json_object (last-write-wins like QMap): +// set: key -> 1 (membership) +// index: key -> int (idx) (catalog index) +// json_object_object_add overwrites an existing key, matching QMap::insert. +// --------------------------------------------------------------------------- + +static void set_add(struct json_object *set, const char *key) +{ + if(key && *key) + json_object_object_add(set, key, json_object_new_int(1)); +} + +static bool set_has(struct json_object *set, const char *key) +{ + struct json_object *v = NULL; + return key && *key && json_object_object_get_ex(set, key, &v); +} + +static void idx_put(struct json_object *map, const char *key, int idx) +{ + if(key && *key) + json_object_object_add(map, key, json_object_new_int(idx)); +} + +static int idx_get(struct json_object *map, const char *key) +{ + struct json_object *v = NULL; + if(key && *key && json_object_object_get_ex(map, key, &v)) + return json_object_get_int(v); + return -1; +} + +// --------------------------------------------------------------------------- +// Field accessors (mirror gameProductId / gameEntitlementId / concept helpers) +// --------------------------------------------------------------------------- + +static const char *game_product_id(struct json_object *g) +{ + const char *pid = cc_json_str(g, "productId"); + if(*pid) + return pid; + return cc_json_str(g, "product_id"); +} + +// Returns id if non-empty and != productId, else "". +static const char *game_entitlement_id(struct json_object *g) +{ + const char *id = cc_json_str(g, "id"); + const char *pid = game_product_id(g); + if(*id && strcmp(id, pid) != 0) + return id; + return ""; +} + +// conceptId may be a JSON number or string; normalize to decimal string. +// Writes into out (>=24). Returns out, empty string if none/<=0. +static const char *concept_id_string(struct json_object *g, const char *key, char *out, size_t out_sz) +{ + out[0] = 0; + struct json_object *v = NULL; + if(!g || !json_object_object_get_ex(g, key, &v) || !v) + return out; + enum json_type t = json_object_get_type(v); + if(t == json_type_int || t == json_type_double) + { + long long c = json_object_get_int64(v); + if(c > 0) + snprintf(out, out_sz, "%lld", c); + } + else if(t == json_type_string) + { + const char *s = json_object_get_string(v); + if(s) + snprintf(out, out_sz, "%s", s); + } + return out; +} + +// pscloud == ps5 (cronos), psnow == ps4 (Kamaji); "" when serviceType absent. +static const char *platform_structured(struct json_object *g) +{ + const char *st = cc_json_str(g, "serviceType"); + if(cc_ieq(st, "pscloud")) + return "ps5"; + if(cc_ieq(st, "psnow")) + return "ps4"; + return ""; +} + +static const char *platform_token(const char *product_id) +{ + if(cc_contains(product_id, "PPSA")) + return "ps5"; + if(cc_contains(product_id, "CUSA")) + return "ps4"; + return ""; +} + +static bool is_cloud_device_game(struct json_object *g) +{ + struct json_object *devs = cc_json_arr(g, "device"); + if(!devs) + return false; + size_t n = json_object_array_length(devs); + for(size_t i = 0; i < n; i++) + { + const char *d = json_object_get_string(json_object_array_get_idx(devs, i)); + if(d && (strcmp(d, "PS5") == 0 || strcmp(d, "PS4") == 0)) + return true; + } + return false; +} + +static bool device_has(struct json_object *g, const char *want) +{ + struct json_object *devs = cc_json_arr(g, "device"); + if(!devs) + return false; + size_t n = json_object_array_length(devs); + for(size_t i = 0; i < n; i++) + { + const char *d = json_object_get_string(json_object_array_get_idx(devs, i)); + if(d && strcmp(d, want) == 0) + return true; + } + return false; +} + +static bool is_cloud_streaming_game(struct json_object *g) +{ + if(!cc_json_bool(g, "streamingSupported")) + return false; + return is_cloud_device_game(g); +} + +// concept|platform edition key. Writes into out (>=64). Returns out (empty if no concept). +static const char *edition_key(struct json_object *g, char *out, size_t out_sz) +{ + out[0] = 0; + char concept[24]; + concept_id_string(g, "conceptId", concept, sizeof(concept)); + if(!*concept) + { + // ps5CloudConceptKey falls back to productId when no conceptId + const char *pid = game_product_id(g); + if(!*pid) + return out; + snprintf(concept, sizeof(concept), "%s", pid); + } + const char *platform = platform_structured(g); + if(!*platform) + platform = platform_token(game_product_id(g)); + snprintf(out, out_sz, "%s|%s", concept, platform); + return out; +} + +// concept|platform key using storeProductId fallback (conceptPlatformKey). +static const char *concept_platform_key(struct json_object *g, char *out, size_t out_sz) +{ + out[0] = 0; + char concept[24]; + concept_id_string(g, "conceptId", concept, sizeof(concept)); + if(!*concept) + return out; + const char *platform = platform_structured(g); + if(!*platform) + { + const char *pid = cc_json_str(g, "storeProductId"); + if(!*pid) + pid = game_product_id(g); + platform = platform_token(pid); + } + snprintf(out, out_sz, "%s|%s", concept, platform); + return out; +} + +static bool is_plus_catalog_list(const char *list) +{ + return list && (strcmp(list, "plus-games-list") == 0 + || strcmp(list, "plus-classics-list") == 0 + || strcmp(list, "ubisoft-classics-list") == 0 + || strcmp(list, "plus-monthly-games-list") == 0); +} + +// productId stable key: drop last token of the dash/underscore split, join with '|'. +// Writes into out (>=128). Returns out (empty if <2 tokens). +static const char *stable_key(const char *product_id, char *out, size_t out_sz) +{ + out[0] = 0; + if(!product_id || !*product_id) + return out; + char tokens[16][64]; + int ntok = 0; + char buf[256]; + snprintf(buf, sizeof(buf), "%s", product_id); + for(char *dash = strtok(buf, "-"); dash && ntok < 16; dash = strtok(NULL, "-")) + { + char sub[128]; + snprintf(sub, sizeof(sub), "%s", dash); + for(char *us = strtok(sub, "_"); us && ntok < 16; us = strtok(NULL, "_")) + snprintf(tokens[ntok++], 64, "%s", us); + } + if(ntok < 2) + return out; + ntok--; // drop last token + size_t off = 0; + for(int i = 0; i < ntok && off < out_sz - 1; i++) + off += (size_t)snprintf(out + off, out_sz - off, i ? "|%s" : "%s", tokens[i]); + return out; +} + +static const char *stream_service_type(struct json_object *g) +{ + const char *st = cc_json_str(g, "serviceType"); + if(cc_ieq(st, "psnow") || cc_ieq(st, "pscloud")) + return cc_ieq(st, "psnow") ? "psnow" : "pscloud"; + const char *p = cc_json_str(g, "storeProductId"); + if(!*p) + p = game_product_id(g); + if(!*p) + p = game_entitlement_id(g); + if(cc_contains(p, "CUSA")) + return "psnow"; + return "pscloud"; +} + +static const char *category_for(struct json_object *g) +{ + if(cc_json_bool(g, "isOwned")) + return "owned"; + if(strcmp(stream_service_type(g), "psnow") == 0) + return "streamable"; + return "purchaseable"; +} + +static bool is_ps5_platform(struct json_object *g) +{ + const char *p = game_product_id(g); + if(!*p) + p = game_entitlement_id(g); + if(cc_contains(p, "PPSA")) + return true; + return device_has(g, "PS5"); +} + +// Badge platform from device list + id token only (NOT serviceType, so PS Now +// PS3 classics — psnow, no PS4/PS5 marker — correctly badge as ps3): +// ps5: PPSA id or device PS5; ps4: CUSA id or device PS4; else ps3. +static const char *platform_badge(struct json_object *g) +{ + if(is_ps5_platform(g)) + return "ps5"; + const char *p = game_product_id(g); + if(!*p) + p = game_entitlement_id(g); + if(device_has(g, "PS4") || cc_contains(p, "CUSA")) + return "ps4"; + return "ps3"; +} + +// --------------------------------------------------------------------------- +// normalizeApolloGame +// --------------------------------------------------------------------------- + +static struct json_object *normalize_apollo_game(struct json_object *raw) +{ + struct json_object *g = cc_json_clone(raw); + if(!g) + return NULL; + if(!cc_json_has(g, "productId")) + { + const char *id = cc_json_str(g, "id"); + if(*id) + cc_json_set_str(g, "productId", id); + } + cc_json_set_str(g, "serviceType", "psnow"); + return g; +} + +// --------------------------------------------------------------------------- +// Catalog index (byProductId / byConceptId), borrowed games array +// --------------------------------------------------------------------------- + +typedef struct +{ + struct json_object *by_product; // key -> int idx + struct json_object *by_concept; // concept|platform -> int idx +} CatalogIndex; + +static void register_in_index(struct json_object *game, int idx, CatalogIndex *ix) +{ + idx_put(ix->by_product, game_product_id(game), idx); + char ck[64]; + concept_platform_key(game, ck, sizeof(ck)); + if(*ck) + idx_put(ix->by_concept, ck, idx); + const char *ent = game_entitlement_id(game); + if(*ent) + idx_put(ix->by_product, ent, idx); +} + +static int find_index_for_owned(struct json_object *owned, CatalogIndex *ix) +{ + const char *pid = game_product_id(owned); + int m = idx_get(ix->by_product, pid); + if(m >= 0) + return m; + const char *ent = game_entitlement_id(owned); + m = idx_get(ix->by_product, ent); + if(m >= 0) + return m; + const char *store = cc_json_str(owned, "storeProductId"); + m = idx_get(ix->by_product, store); + if(m >= 0) + return m; + char ck[64]; + concept_platform_key(owned, ck, sizeof(ck)); + if(*ck) + return idx_get(ix->by_concept, ck); + return -1; +} + +// --------------------------------------------------------------------------- +// mergeOwnedIntoBrowseCatalog +// --------------------------------------------------------------------------- + +static const char *game_name(struct json_object *g) +{ + const char *n = cc_json_str(g, "name"); + if(*n) + return n; + struct json_object *meta = cc_json_obj(g, "game_meta"); + return meta ? cc_json_str(meta, "name") : ""; +} + +static int sort_owned_then_name(const void *a, const void *b) +{ + struct json_object *ao = *(struct json_object *const *)a; + struct json_object *bo = *(struct json_object *const *)b; + bool aown = cc_json_bool(ao, "isOwned"); + bool bown = cc_json_bool(bo, "isOwned"); + if(aown != bown) + return aown ? -1 : 1; + return strcasecmp(game_name(ao), game_name(bo)); +} + +// Returns a NEW array (caller owns). browse and owned are borrowed. +static struct json_object *merge_owned_into_browse(struct json_object *browse, + struct json_object *owned_cross_ref, + bool add_unmatched) +{ + struct json_object *games = json_object_new_array(); + if(browse) + { + size_t n = json_object_array_length(browse); + for(size_t i = 0; i < n; i++) + json_object_array_add(games, cc_json_clone(json_object_array_get_idx(browse, i))); + } + + CatalogIndex ix = { json_object_new_object(), json_object_new_object() }; + { + size_t n = json_object_array_length(games); + for(size_t i = 0; i < n; i++) + register_in_index(json_object_array_get_idx(games, i), (int)i, &ix); + } + + // Pre-pass: products fully owned (feature_type != 1) by productId. + struct json_object *fully_owned = json_object_new_object(); + size_t owned_n = owned_cross_ref ? json_object_array_length(owned_cross_ref) : 0; + for(size_t i = 0; i < owned_n; i++) + { + struct json_object *o = json_object_array_get_idx(owned_cross_ref, i); + if(!o || cc_json_int(o, "feature_type") == 1) + continue; + set_add(fully_owned, game_product_id(o)); + } + + // pscloud-first stable partition. + struct json_object *ordered = json_object_new_array(); + for(size_t i = 0; i < owned_n; i++) + { + struct json_object *o = json_object_array_get_idx(owned_cross_ref, i); + if(o && cc_ieq(cc_json_str(o, "serviceType"), "pscloud")) + json_object_array_add(ordered, json_object_get(o)); + } + for(size_t i = 0; i < owned_n; i++) + { + struct json_object *o = json_object_array_get_idx(owned_cross_ref, i); + if(o && !cc_ieq(cc_json_str(o, "serviceType"), "pscloud")) + json_object_array_add(ordered, json_object_get(o)); + } + + size_t ord_n = json_object_array_length(ordered); + for(size_t i = 0; i < ord_n; i++) + { + struct json_object *owned_game = json_object_array_get_idx(ordered, i); + if(!owned_game) + continue; + bool is_trial = cc_json_int(owned_game, "feature_type") == 1; + if(is_trial && set_has(fully_owned, game_product_id(owned_game))) + continue; + int match = is_trial ? -1 : find_index_for_owned(owned_game, &ix); + + if(match >= 0) + { + struct json_object *existing = json_object_array_get_idx(games, (size_t)match); + const char *owned_service = cc_json_str(owned_game, "serviceType"); + const char *existing_service = cc_json_str(existing, "serviceType"); + const char *owned_pid = game_product_id(owned_game); + const char *existing_class = platform_structured(existing); + if(!*existing_class) + existing_class = platform_token(game_product_id(existing)); + + if(cc_ieq(owned_service, "pscloud")) + { + cc_json_set_bool(existing, "isOwned", true); + const char *owned_id = cc_json_str(owned_game, "id"); + if(*owned_id) + cc_json_set_str(existing, "id", owned_id); + if(*owned_pid) + { + cc_json_set_str(existing, "product_id", owned_pid); + cc_json_set_str(existing, "productId", owned_pid); + } + cc_json_set_str(existing, "serviceType", "pscloud"); + continue; + } + if(cc_ieq(owned_service, "psnow") + && !cc_ieq(existing_service, "pscloud") + && strcmp(existing_class, "ps5") != 0) + { + cc_json_set_bool(existing, "isOwned", true); + const char *stream_id = game_entitlement_id(owned_game); + if(*stream_id) + cc_json_set_str(existing, "id", stream_id); + cc_json_set_str(existing, "serviceType", "psnow"); + continue; + } + if(cc_ieq(owned_service, "psnow")) + continue; // PS4 cross-buy wrapper on a PS5 card: drop + // fall through for unstamped owned + } + + if(!add_unmatched) + continue; + + struct json_object *entry = cc_json_clone(owned_game); + cc_json_set_bool(entry, "isOwned", true); + if(!cc_json_has(entry, "productId") && cc_json_has(entry, "product_id")) + cc_json_set_str(entry, "productId", cc_json_str(entry, "product_id")); + register_in_index(entry, (int)json_object_array_length(games), &ix); + json_object_array_add(games, entry); + } + + // Cross-buy duplicate suppression. The store can list the same concept under + // two SKUs on one platform (e.g. a PS1-emulation classic exposed as both a + // CUSA and a PPSA productId). The imagic edition dedup keys off the productId + // token (CUSA->ps4, PPSA->ps5), so both survive into browse; once serviceType + // is stamped they collapse to the same concept|platform. When an owned + // entitlement claims one SKU, the sibling is left stranded as a purchaseable + // "Add Game" duplicate of a title you already own (Worms World Party cross-buy). + // Drop any non-owned row whose concept|platform matches an owned row. + { + struct json_object *owned_keys = json_object_new_object(); + size_t n = json_object_array_length(games); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(games, i); + if(!cc_json_bool(g, "isOwned")) + continue; + char ck[64]; + concept_platform_key(g, ck, sizeof(ck)); + if(*ck) + set_add(owned_keys, ck); + } + struct json_object *filtered = json_object_new_array(); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(games, i); + if(!cc_json_bool(g, "isOwned")) + { + char ck[64]; + concept_platform_key(g, ck, sizeof(ck)); + if(*ck && set_has(owned_keys, ck)) + continue; // purchaseable duplicate of an owned title + } + json_object_array_add(filtered, json_object_get(g)); + } + json_object_put(owned_keys); + json_object_put(games); + games = filtered; + } + + // Sort owned-first then name. + json_object_array_sort(games, sort_owned_then_name); + + json_object_put(fully_owned); + json_object_put(ordered); + json_object_put(ix.by_product); + json_object_put(ix.by_concept); + return games; +} + +// --------------------------------------------------------------------------- +// StreamabilityIndex / applyStreamabilityGate +// --------------------------------------------------------------------------- + +typedef struct +{ + struct json_object *product_keys; // set + struct json_object *streamable_concepts; // set +} StreamabilityIndex; + +static void streamability_add_product(StreamabilityIndex *ix, const char *pid) +{ + if(!pid || !*pid) + return; + set_add(ix->product_keys, pid); + char sk[128]; + stable_key(pid, sk, sizeof(sk)); + if(*sk) + set_add(ix->product_keys, sk); +} + +static StreamabilityIndex streamability_build(struct json_object *apollo, + struct json_object *imagic_browse, + struct json_object *concept_rows) +{ + StreamabilityIndex ix = { json_object_new_object(), json_object_new_object() }; + if(apollo) + { + size_t n = json_object_array_length(apollo); + for(size_t i = 0; i < n; i++) + streamability_add_product(&ix, game_product_id(json_object_array_get_idx(apollo, i))); + } + if(imagic_browse) + { + size_t n = json_object_array_length(imagic_browse); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(imagic_browse, i); + streamability_add_product(&ix, game_product_id(g)); + char concept[24]; + concept_id_string(g, "conceptId", concept, sizeof(concept)); + if(*concept) + set_add(ix.streamable_concepts, concept); + } + } + if(concept_rows) + { + size_t n = json_object_array_length(concept_rows); + for(size_t i = 0; i < n; i++) + { + struct json_object *row = json_object_array_get_idx(concept_rows, i); + char concept[24]; + concept_id_string(row, "conceptId", concept, sizeof(concept)); + if(!*concept) + continue; + const char *pid = game_product_id(row); + char sk[128]; + stable_key(pid, sk, sizeof(sk)); + if(set_has(ix.product_keys, pid) || (*sk && set_has(ix.product_keys, sk))) + set_add(ix.streamable_concepts, concept); + } + } + return ix; +} + +static bool streamability_is_streamable(StreamabilityIndex *ix, struct json_object *g) +{ + const char *ids[3] = { game_product_id(g), cc_json_str(g, "storeProductId"), game_entitlement_id(g) }; + for(int i = 0; i < 3; i++) + { + if(!*ids[i]) + continue; + if(set_has(ix->product_keys, ids[i])) + return true; + char sk[128]; + stable_key(ids[i], sk, sizeof(sk)); + if(*sk && set_has(ix->product_keys, sk)) + return true; + } + char concept[24]; + concept_id_string(g, "conceptId", concept, sizeof(concept)); + return *concept && set_has(ix->streamable_concepts, concept); +} + +static void streamability_fini(StreamabilityIndex *ix) +{ + json_object_put(ix->product_keys); + json_object_put(ix->streamable_concepts); +} + +// --------------------------------------------------------------------------- +// streamIdentifier (contract field): what the streaming layer is handed. +// pscloud: the entitlement's own id when owned, else the catalog productId. +// psnow: the catalog product variant (catalogProductId or productId). +// --------------------------------------------------------------------------- + +static const char *stream_identifier(struct json_object *g, const char *stream_service) +{ + if(strcmp(stream_service, "pscloud") == 0) + { + const char *id = cc_json_str(g, "id"); + if(cc_json_bool(g, "isOwned") && *id) + return id; + return game_product_id(g); + } + const char *cat = cc_json_str(g, "catalogProductId"); + if(*cat) + return cat; + return game_product_id(g); +} + +// --------------------------------------------------------------------------- +// Object maps (string -> borrowed json_object), used by the cross-reference. +// --------------------------------------------------------------------------- + +static void objmap_put_first(struct json_object *map, const char *key, struct json_object *obj) +{ + struct json_object *v = NULL; + if(key && *key && !json_object_object_get_ex(map, key, &v)) + json_object_object_add(map, key, json_object_get(obj)); +} + +static void objmap_put_last(struct json_object *map, const char *key, struct json_object *obj) +{ + if(key && *key) + json_object_object_add(map, key, json_object_get(obj)); +} + +static struct json_object *objmap_get(struct json_object *map, const char *key) +{ + struct json_object *v = NULL; + if(key && *key && json_object_object_get_ex(map, key, &v)) + return v; + return NULL; +} + +static bool objmap_has(struct json_object *map, const char *key) +{ + return objmap_get(map, key) != NULL; +} + +// --------------------------------------------------------------------------- +// cc_extract_cover_image +// --------------------------------------------------------------------------- + +const char *cc_extract_cover_image(struct json_object *game_obj, char *out, size_t out_sz) +{ + out[0] = 0; + struct json_object *imgs = cc_json_arr(game_obj, "images"); + if(imgs) + { + size_t n = json_object_array_length(imgs); + for(size_t i = 0; i < n; i++) // type 10 preferred + { + struct json_object *im = json_object_array_get_idx(imgs, i); + if(cc_json_int(im, "type") == 10) + { + const char *u = cc_json_str(im, "url"); + if(*u) { snprintf(out, out_sz, "%s", u); return out; } + } + } + for(size_t i = 0; i < n; i++) // landscape 12/13 fallback + { + struct json_object *im = json_object_array_get_idx(imgs, i); + int t = cc_json_int(im, "type"); + if(t == 12 || t == 13) + { + const char *u = cc_json_str(im, "url"); + if(*u) { snprintf(out, out_sz, "%s", u); return out; } + } + } + } + const char *iu = cc_json_str(game_obj, "imageUrl"); + if(*iu) + snprintf(out, out_sz, "%s", iu); + return out; +} + +// --------------------------------------------------------------------------- +// cc_merge_imagic_list +// --------------------------------------------------------------------------- + +void cc_merge_imagic_list(const char *category_list, struct json_object *list_doc, + struct json_object *games_by_edition, struct json_object *supplement, + struct json_object *aliases, int *total_seen) +{ + bool plus_catalog = is_plus_catalog_list(category_list); + if(!list_doc || json_object_get_type(list_doc) != json_type_array) + return; + size_t ncat = json_object_array_length(list_doc); + for(size_t c = 0; c < ncat; c++) + { + struct json_object *cat = json_object_array_get_idx(list_doc, c); + struct json_object *games = cc_json_arr(cat, "games"); + if(!games) + continue; + size_t ng = json_object_array_length(games); + if(total_seen) + *total_seen += (int)ng; + for(size_t i = 0; i < ng; i++) + { + struct json_object *g = json_object_array_get_idx(games, i); + if(!g || json_object_get_type(g) != json_type_object) + continue; + if(!is_cloud_device_game(g)) + continue; + + if(plus_catalog && !cc_json_bool(g, "streamingSupported")) + { + const char *pid = cc_json_str(g, "productId"); + if(*pid) + { + struct json_object *gc = cc_json_clone(g); + cc_json_set_bool(gc, "plusCatalog", true); + json_object_object_add(supplement, pid, gc); + } + continue; + } + + if(!is_cloud_streaming_game(g)) + continue; + + char key[64]; + edition_key(g, key, sizeof(key)); + const char *pid = cc_json_str(g, "productId"); + if(!*key || !*pid) + continue; + + struct json_object *existing = objmap_get(games_by_edition, key); + if(existing) + { + const char *canonical = cc_json_str(existing, "productId"); + if(*canonical && strcmp(pid, canonical) != 0 && !cc_json_has(aliases, pid)) + cc_json_set_str(aliases, pid, canonical); + if(plus_catalog && !cc_json_bool(existing, "plusCatalog")) + cc_json_set_bool(existing, "plusCatalog", true); + continue; + } + + struct json_object *gc = cc_json_clone(g); + cc_json_set_bool(gc, "plusCatalog", plus_catalog); + json_object_object_add(games_by_edition, key, gc); + } + } +} + +// --------------------------------------------------------------------------- +// Cross-reference helpers (owned entitlement ranking / matching) +// --------------------------------------------------------------------------- + +static bool is_full_game_entitlement(struct json_object *o) +{ + if(cc_json_int(o, "feature_type") == 3) + return true; + struct json_object *gm = cc_json_obj(o, "game_meta"); + return cc_ends_with(gm ? cc_json_str(gm, "package_type") : "", "GD"); +} + +static bool is_streaming_package(struct json_object *o) +{ + struct json_object *gm = cc_json_obj(o, "game_meta"); + return cc_ends_with(gm ? cc_json_str(gm, "package_type") : "", "GS"); +} + +static int owned_stream_rank(struct json_object *o) +{ + const char *id = cc_json_str(o, "id"); + const char *pid = cc_json_str(o, "product_id"); + int rank = 0; + if(*pid && strcmp(pid, id) == 0) + rank += 4; + if(is_full_game_entitlement(o)) + rank += 2; + if(*id) + rank += 1; + return rank; +} + +static bool owned_better(struct json_object *cand, struct json_object *cur) +{ + int rc = owned_stream_rank(cand), ru = owned_stream_rank(cur); + if(rc != ru) + return rc > ru; + bool gc = is_streaming_package(cand), gu = is_streaming_package(cur); + if(gc != gu) + return gc; + int c = strcmp(cc_json_str(cand, "sku_id"), cc_json_str(cur, "sku_id")); + if(c != 0) + return c < 0; + c = strcmp(cc_json_str(cand, "product_id"), cc_json_str(cur, "product_id")); + if(c != 0) + return c < 0; + return strcmp(cc_json_str(cand, "id"), cc_json_str(cur, "id")) < 0; +} + +static const char *owned_concept_id(struct json_object *o, char *out, size_t out_sz) +{ + concept_id_string(o, "conceptId", out, out_sz); + if(!*out) + concept_id_string(o, "concept_id", out, out_sz); + if(!*out) + { + struct json_object *gm = cc_json_obj(o, "game_meta"); + if(gm) + { + concept_id_string(gm, "conceptId", out, out_sz); + if(!*out) + concept_id_string(gm, "concept_id", out, out_sz); + } + } + return out; +} + +static void build_stable_index(struct json_object *arr, struct json_object *map) +{ + if(!arr) + return; + size_t n = json_object_array_length(arr); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(arr, i); + char sk[128]; + stable_key(game_product_id(g), sk, sizeof(sk)); + if(*sk) + objmap_put_first(map, sk, g); + } +} + +static void build_concept_index(struct json_object *arr, struct json_object *map) +{ + if(!arr) + return; + size_t n = json_object_array_length(arr); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(arr, i); + char c[24]; + concept_id_string(g, "conceptId", c, sizeof(c)); + if(*c) + objmap_put_first(map, c, g); + } +} + +void cc_sanitize_owned_service_type(struct json_object *ent) +{ + json_object_object_del(ent, "serviceType"); + struct json_object *attrs = cc_json_arr(ent, "entitlement_attributes"); + if(!attrs) + return; + size_t n = json_object_array_length(attrs); + for(size_t i = 0; i < n; i++) + { + struct json_object *a = json_object_array_get_idx(attrs, i); + if(!a || json_object_get_type(a) != json_type_object) + continue; + const char *pid = cc_json_str(a, "platform_id"); + if(cc_ieq(pid, "ps5")) { cc_json_set_str(ent, "serviceType", "pscloud"); return; } + if(cc_ieq(pid, "ps4") || cc_ieq(pid, "ps3")) { cc_json_set_str(ent, "serviceType", "psnow"); return; } + } +} + +// emitOwned: enrich `ow` with `meta`, dedupe into owned_by_key (ranked). +static void emit_owned(struct json_object *ow, struct json_object *meta, bool from_supplement, + const char *product_id, const char *entitlement_id, + struct json_object *owned_by_key) +{ + struct json_object *entry = cc_json_clone(ow); + + const char *mname = cc_json_str(meta, "name"); + if(*mname) + cc_json_set_str(entry, "name", mname); + const char *mimg = cc_json_str(meta, "imageUrl"); + if(*mimg) + cc_json_set_str(entry, "imageUrl", mimg); + struct json_object *mcu = NULL; + if(json_object_object_get_ex(meta, "conceptUrl", &mcu) && mcu) + json_object_object_add(entry, "conceptUrl", cc_json_clone(mcu)); + struct json_object *mdev = cc_json_arr(meta, "device"); + if(mdev) + json_object_object_add(entry, "device", cc_json_clone(mdev)); + const char *meta_pid = cc_json_str(meta, "productId"); + if(*meta_pid) + cc_json_set_str(entry, "catalogProductId", meta_pid); + cc_json_set_str(entry, "productId", product_id); + cc_json_set_bool(entry, "streamingSupported", !from_supplement); + + char concept[24]; + concept_id_string(meta, "conceptId", concept, sizeof(concept)); + if(*concept) + cc_json_set_str(entry, "conceptId", concept); + + const char *platform = platform_structured(entry); + if(!*platform) + platform = platform_token(product_id); + + char key[96]; + if(*concept) + snprintf(key, sizeof(key), "c:%s:%s", concept, platform); + else if(*product_id) + snprintf(key, sizeof(key), "p:%s", product_id); + else if(*entitlement_id) + snprintf(key, sizeof(key), "e:%s", entitlement_id); + else + snprintf(key, sizeof(key), "u:%s:%s", product_id, entitlement_id); + + struct json_object *existing = objmap_get(owned_by_key, key); + if(!existing || owned_better(entry, existing)) + objmap_put_last(owned_by_key, key, entry); + json_object_put(entry); +} + +// normalizeTitle: lowercase, strip TM/R/SM glyphs, collapse whitespace. +static void normalize_title(const char *raw, char *out, size_t out_sz) +{ + out[0] = 0; + if(!raw) + return; + size_t o = 0; + bool prev_space = true; // trims leading + for(size_t i = 0; raw[i] && o < out_sz - 1;) + { + unsigned char ch = (unsigned char)raw[i]; + // strip UTF-8 ™ (E2 84 A2), ℠ (E2 84 A0), ® (C2 AE). Bounds-check each + // continuation byte before reading so a truncated multibyte tail at EOS + // (lone 0xE2/0xC2) never reads past the terminating NUL. + if(ch == 0xE2 && (unsigned char)raw[i + 1] == 0x84 + && ((unsigned char)raw[i + 2] == 0xA2 || (unsigned char)raw[i + 2] == 0xA0)) + { + i += 3; + continue; + } + if(ch == 0xC2 && raw[i + 1] && (unsigned char)raw[i + 1] == 0xAE) + { + i += 2; + continue; + } + if(ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') + { + if(!prev_space) + { + out[o++] = ' '; + prev_space = true; + } + i++; + continue; + } + out[o++] = (char)tolower(ch); + prev_space = false; + i++; + } + while(o > 0 && out[o - 1] == ' ') // trim trailing + o--; + out[o] = 0; +} + +static bool strlist_contains(char list[][128], int n, const char *s) +{ + for(int i = 0; i < n; i++) + if(strcmp(list[i], s) == 0) + return true; + return false; +} + +struct json_object *cc_build_owned_cross_ref(ChiakiLog *log, + struct json_object *psnow_catalog, struct json_object *imagic_browse, + struct json_object *imagic_supplement, struct json_object *product_id_aliases, + struct json_object *owned_games, struct json_object *component_ids) +{ + struct json_object *cloud_map = json_object_new_object(); + struct json_object *supp_map = json_object_new_object(); + struct json_object *browse_stable = json_object_new_object(); + struct json_object *supp_stable = json_object_new_object(); + struct json_object *browse_concept = json_object_new_object(); + struct json_object *supp_concept = json_object_new_object(); + struct json_object *owned_by_key = json_object_new_object(); + + // cloudCatalogMap: normalized psnow (first-wins by productId), then imagic (last-wins). + if(psnow_catalog) + { + size_t n = json_object_array_length(psnow_catalog); + for(size_t i = 0; i < n; i++) + { + struct json_object *norm = normalize_apollo_game(json_object_array_get_idx(psnow_catalog, i)); + if(!norm) + continue; + objmap_put_first(cloud_map, game_product_id(norm), norm); + json_object_put(norm); + } + } + if(imagic_browse) + { + size_t n = json_object_array_length(imagic_browse); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(imagic_browse, i); + objmap_put_last(cloud_map, cc_json_str(g, "productId"), g); + } + } + // aliases: alias -> canonical (only when canonical already mapped and alias not). + if(product_id_aliases) + { + json_object_object_foreach(product_id_aliases, alias, canonical_v) + { + const char *canonical = json_object_get_string(canonical_v); + if(objmap_has(cloud_map, alias)) + continue; + struct json_object *c = objmap_get(cloud_map, canonical); + if(c) + objmap_put_last(cloud_map, alias, c); + } + } + if(imagic_supplement) + { + size_t n = json_object_array_length(imagic_supplement); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(imagic_supplement, i); + objmap_put_last(supp_map, cc_json_str(g, "productId"), g); + } + } + + // combinedBrowse = raw psnow + imagic browse (for stable/concept indexes). + build_stable_index(psnow_catalog, browse_stable); + build_stable_index(imagic_browse, browse_stable); + build_concept_index(psnow_catalog, browse_concept); + build_concept_index(imagic_browse, browse_concept); + build_stable_index(imagic_supplement, supp_stable); + build_concept_index(imagic_supplement, supp_concept); + + size_t owned_n = owned_games ? json_object_array_length(owned_games) : 0; + for(size_t i = 0; i < owned_n; i++) + { + struct json_object *raw = json_object_array_get_idx(owned_games, i); + if(!raw || json_object_get_type(raw) != json_type_object) + continue; + struct json_object *ow = cc_json_clone(raw); + cc_sanitize_owned_service_type(ow); + + const char *product_id = cc_json_str(ow, "product_id"); + const char *entitlement_id = cc_json_str(ow, "id"); + struct json_object *gm = cc_json_obj(ow, "game_meta"); + const char *ent_name = gm ? cc_json_str(gm, "name") : ""; + char ent_name_lc[256]; + normalize_title(ent_name, ent_name_lc, sizeof(ent_name_lc)); + bool skip_demo = cc_contains(ent_name_lc, "demo"); + + char stable_k[128], ent_stable_k[128], owned_concept[24]; + stable_key(product_id, stable_k, sizeof(stable_k)); + stable_key(entitlement_id, ent_stable_k, sizeof(ent_stable_k)); + owned_concept_id(ow, owned_concept, sizeof(owned_concept)); + + struct json_object *meta = NULL; + bool from_supp = false; + + if(*product_id && (meta = objmap_get(cloud_map, product_id))) { } + else if(*entitlement_id && (meta = objmap_get(cloud_map, entitlement_id))) { } + else if(*owned_concept && (meta = objmap_get(browse_concept, owned_concept))) { } + else if(*owned_concept && (meta = objmap_get(supp_concept, owned_concept))) { from_supp = true; } + else if(*product_id && *entitlement_id && strcmp(entitlement_id, product_id) == 0 + && (meta = objmap_get(supp_map, product_id))) { from_supp = true; } + else if(*stable_k && !skip_demo && (meta = objmap_get(browse_stable, stable_k))) { } + else if(*stable_k && !skip_demo && (meta = objmap_get(supp_stable, stable_k))) { from_supp = true; } + else if(*ent_stable_k && !skip_demo && (meta = objmap_get(browse_stable, ent_stable_k))) { } + else if(*ent_stable_k && !skip_demo && (meta = objmap_get(supp_stable, ent_stable_k))) { from_supp = true; } + + if(meta) + { + emit_owned(ow, meta, from_supp, product_id, entitlement_id, owned_by_key); + json_object_put(ow); + continue; + } + + // Bundle-sibling expansion. + struct json_object *siblings = component_ids ? cc_json_arr(component_ids, product_id) : NULL; + if(siblings) + { + char seen[64][128]; + int nseen = 0; + size_t ns = json_object_array_length(siblings); + for(size_t s = 0; s < ns; s++) + { + const char *sibling_id = json_object_get_string(json_object_array_get_idx(siblings, s)); + if(!sibling_id) + continue; + struct json_object *sibling_meta = NULL; + bool sibling_supp = false; + if((sibling_meta = objmap_get(cloud_map, sibling_id))) { } + else if((sibling_meta = objmap_get(supp_map, sibling_id))) { sibling_supp = true; } + else + { + char sk[128]; + stable_key(sibling_id, sk, sizeof(sk)); + if(*sk && !skip_demo) + { + if((sibling_meta = objmap_get(browse_stable, sk))) { } + else if((sibling_meta = objmap_get(supp_stable, sk))) { sibling_supp = true; } + } + } + if(!sibling_meta) + continue; + const char *sibling_pid = cc_json_str(sibling_meta, "productId"); + if(!*sibling_pid || strlist_contains(seen, nseen, sibling_pid)) + continue; + if(nseen < 64) + snprintf(seen[nseen++], 128, "%s", sibling_pid); + emit_owned(ow, sibling_meta, sibling_supp, product_id, entitlement_id, owned_by_key); + } + } + json_object_put(ow); + } + + // Disc-upgrade rescue (feature_type 5). + json_object_object_foreach(owned_by_key, dkey, entry) + { + (void)dkey; + if(cc_json_int(entry, "feature_type") != 5) + continue; + const char *disc_pid = cc_json_str(entry, "product_id"); + const char *disc_platform = platform_token(disc_pid); + struct json_object *egm = cc_json_obj(entry, "game_meta"); + char disc_name[256]; + normalize_title(egm ? cc_json_str(egm, "name") : "", disc_name, sizeof(disc_name)); + if(!*disc_name) + continue; + char canonical[32][128]; + char other[32][128]; + int nc = 0, no = 0; + size_t cn = owned_games ? json_object_array_length(owned_games) : 0; + for(size_t c = 0; c < cn; c++) + { + struct json_object *cand = json_object_array_get_idx(owned_games, c); + if(!cand || cc_json_int(cand, "feature_type") != 3) + continue; + struct json_object *cgm = cc_json_obj(cand, "game_meta"); + char cand_name[256]; + normalize_title(cgm ? cc_json_str(cgm, "name") : "", cand_name, sizeof(cand_name)); + if(strcmp(cand_name, disc_name) != 0) + continue; + const char *cand_pid = cc_json_str(cand, "product_id"); + if(!*cand_pid || strcmp(cand_pid, disc_pid) == 0) + continue; + if(strcmp(platform_token(cand_pid), disc_platform) != 0) + continue; + const char *cand_id = cc_json_str(cand, "id"); + if(strcmp(cand_pid, cand_id) == 0) + { + if(!strlist_contains(canonical, nc, cand_pid) && nc < 32) + snprintf(canonical[nc++], 128, "%s", cand_pid); + } + else + { + if(!strlist_contains(other, no, cand_pid) && no < 32) + snprintf(other[no++], 128, "%s", cand_pid); + } + } + const char *replacement = NULL; + if(nc == 1) + replacement = canonical[0]; + else if(nc == 0 && no == 1) + replacement = other[0]; + if(!replacement) + continue; + cc_json_set_str(entry, "product_id", replacement); + cc_json_set_str(entry, "productId", replacement); + cc_json_set_str(entry, "catalogProductId", replacement); + CHIAKI_LOGI(log, "[CROSS-REF] disc-upgrade rescue: %s -> %s", disc_name, replacement); + } + + // Emit filteredGames (clones; order re-sorted in assemble). + struct json_object *out = json_object_new_array(); + json_object_object_foreach(owned_by_key, k2, v2) + { + (void)k2; + json_object_array_add(out, cc_json_clone(v2)); + } + + json_object_put(cloud_map); + json_object_put(supp_map); + json_object_put(browse_stable); + json_object_put(supp_stable); + json_object_put(browse_concept); + json_object_put(supp_concept); + json_object_put(owned_by_key); + return out; +} + +// --------------------------------------------------------------------------- +// assembleUnifiedCatalog -> contract envelope +// --------------------------------------------------------------------------- + +struct json_object *cc_assemble_unified_catalog(ChiakiLog *log, const CCAssembleInput *in) +{ + // 1. Normalize apollo rows + collect their productIds. + struct json_object *apollo_norm = json_object_new_array(); + struct json_object *apollo_pids = json_object_new_object(); + if(in->apollo_games) + { + size_t n = json_object_array_length(in->apollo_games); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = normalize_apollo_game(json_object_array_get_idx(in->apollo_games, i)); + if(!g) + continue; + json_object_array_add(apollo_norm, g); + set_add(apollo_pids, game_product_id(g)); + } + } + + // 2. PS5 browse rows: device-based filter, skip apollo dups, stamp pscloud. + struct json_object *ps5_browse = json_object_new_array(); + if(in->imagic_browse) + { + size_t n = json_object_array_length(in->imagic_browse); + for(size_t i = 0; i < n; i++) + { + struct json_object *v = json_object_array_get_idx(in->imagic_browse, i); + if(!v || !is_ps5_platform(v)) + continue; + if(set_has(apollo_pids, game_product_id(v))) + continue; + struct json_object *g = cc_json_clone(v); + const char *existing = cc_json_str(g, "serviceType"); + if(!cc_ieq(existing, "psnow") && !cc_ieq(existing, "pscloud")) + cc_json_set_str(g, "serviceType", "pscloud"); + json_object_array_add(ps5_browse, g); + } + } + + // 3. universe = apollo + ps5Browse + struct json_object *universe = json_object_new_array(); + { + size_t n = json_object_array_length(apollo_norm); + for(size_t i = 0; i < n; i++) + json_object_array_add(universe, cc_json_clone(json_object_array_get_idx(apollo_norm, i))); + n = json_object_array_length(ps5_browse); + for(size_t i = 0; i < n; i++) + json_object_array_add(universe, cc_json_clone(json_object_array_get_idx(ps5_browse, i))); + } + + // 4. merge owned + struct json_object *games = merge_owned_into_browse(universe, in->owned_cross_ref, true); + + // 5. streamability gate (native mode only) + if(in->native_mode) + { + struct json_object *concept_rows = json_object_new_array(); + if(in->imagic_browse) + { + size_t n = json_object_array_length(in->imagic_browse); + for(size_t i = 0; i < n; i++) + json_object_array_add(concept_rows, json_object_get(json_object_array_get_idx(in->imagic_browse, i))); + } + if(in->imagic_supplement) + { + size_t n = json_object_array_length(in->imagic_supplement); + for(size_t i = 0; i < n; i++) + json_object_array_add(concept_rows, json_object_get(json_object_array_get_idx(in->imagic_supplement, i))); + } + StreamabilityIndex ix = streamability_build(apollo_norm, in->imagic_browse, concept_rows); + + struct json_object *kept = json_object_new_array(); + size_t n = json_object_array_length(games); + int dropped = 0; + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(games, i); + if(!cc_json_bool(g, "isOwned") || streamability_is_streamable(&ix, g)) + json_object_array_add(kept, json_object_get(g)); + else + dropped++; + } + if(dropped > 0) + CHIAKI_LOGI(log, "[UNIFIED] streamability gate dropped %d owned non-streamable", dropped); + json_object_put(games); + games = kept; + streamability_fini(&ix); + json_object_put(concept_rows); + } + + // 6. tag every game with the full contract. + size_t n = json_object_array_length(games); + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(games, i); + const char *svc = stream_service_type(g); + cc_json_set_str(g, "category", category_for(g)); + cc_json_set_str(g, "streamServiceType", svc); + cc_json_set_str(g, "platform", platform_badge(g)); + cc_json_set_str(g, "streamIdentifier", stream_identifier(g, svc)); + // Always-present contract booleans/strings (clients never branch on absence). + cc_json_set_bool(g, "isOwned", cc_json_bool(g, "isOwned")); + cc_json_set_bool(g, "plusCatalog", cc_json_bool(g, "plusCatalog")); + // conceptId may arrive as a JSON number (imagic browse) or string (owned + // cross-ref). Normalize to a decimal string so it is always present and the + // integer form is never dropped (reading it as a string blanks ints). + char concept_norm[24]; + concept_id_string(g, "conceptId", concept_norm, sizeof(concept_norm)); + cc_json_set_str(g, "conceptId", concept_norm); + // normalize identity fields the clients read + if(!cc_json_has(g, "productId") && cc_json_has(g, "product_id")) + cc_json_set_str(g, "productId", cc_json_str(g, "product_id")); + const char *ent = game_entitlement_id(g); + if(*ent) + cc_json_set_str(g, "entitlementId", ent); + const char *store = cc_json_str(g, "catalogProductId"); + if(!*store) + store = cc_json_str(g, "storeProductId"); + if(*store) + cc_json_set_str(g, "storeProductId", store); + } + + // 7. envelope + struct json_object *out = json_object_new_object(); + json_object_object_add(out, "schemaVersion", json_object_new_int(CHIAKI_CLOUDCATALOG_SCHEMA_VERSION)); + json_object_object_add(out, "total", json_object_new_int((int)n)); + json_object_object_add(out, "nativeMode", json_object_new_boolean(in->native_mode)); + cc_json_set_str(out, "fallbackRegion", in->fallback_region ? in->fallback_region : ""); + if(in->settled_locale && *in->settled_locale) + cc_json_set_str(out, "settledLocale", in->settled_locale); + cc_json_set_str(out, "warning", in->warning ? in->warning : ""); + json_object_object_add(out, "games", games); // transfers ownership + + json_object_put(apollo_norm); + json_object_put(apollo_pids); + json_object_put(ps5_browse); + json_object_put(universe); + return out; +} diff --git a/lib/src/cloudcatalog_unified.c b/lib/src/cloudcatalog_unified.c new file mode 100644 index 00000000..e4ce2c36 --- /dev/null +++ b/lib/src/cloudcatalog_unified.c @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Unified catalog orchestrator + public API. Mirrors the Qt fetchUnifiedCatalog +// chain: native APOLLOROOT probe -> (public fallback | expired-warning) -> +// imagic 6-list -> owned entitlements -> cross-reference -> assemble. Cache keys +// (unified_catalog_v3 [contract schema; was v2 pre-migration], ps5_cloud_catalog_v6, +// ps5_cloud_library) are shared across platforms so files stay byte-comparable, and +// the unified read is guarded by schemaVersion so a stale older payload is never served. + +#include "cloudcatalog_internal.h" + +#include + +#include +#include +#include +#include +#include // strcasecmp + +#define WARNING_EXPIRED \ + "Your PlayStation session has expired. Please log in again to see your owned games." + +static const char *account_country_from_locale(const char *locale, char *out, size_t out_sz) +{ + snprintf(out, out_sz, "US"); + if(!locale) + return out; + const char *dash = strchr(locale, '-'); + if(dash && dash[1]) + { + size_t i = 0; + for(const char *p = dash + 1; *p && i < out_sz - 1; p++) + { + char c = *p; + if(c >= 'a' && c <= 'z') + c = (char)(c - 'a' + 'A'); + out[i++] = c; + } + out[i] = 0; + } + return out; +} + +// Build/write the ps5_cloud_catalog_v6 envelope from imagic outputs. +static void write_v6_cache(ChiakiLog *log, const char *cache_dir, const char *locale, + struct json_object *browse, struct json_object *supplement, + struct json_object *aliases) +{ + struct json_object *v6 = json_object_new_object(); + cc_json_set_str(v6, "locale", locale); + json_object_object_add(v6, "games", cc_json_clone(browse)); + json_object_object_add(v6, "total", json_object_new_int((int)json_object_array_length(browse))); + json_object_object_add(v6, "plusLibrarySupplement", cc_json_clone(supplement)); + if(aliases && json_object_object_length(aliases) > 0) + json_object_object_add(v6, "productIdAliases", cc_json_clone(aliases)); + cc_cache_write(log, cache_dir, "ps5_cloud_catalog_v6", v6); + json_object_put(v6); +} + +static void write_library_cache(ChiakiLog *log, const char *cache_dir, + struct json_object *owned, struct json_object *components) +{ + struct json_object *lib = json_object_new_object(); + json_object_object_add(lib, "games", cc_json_clone(owned)); + json_object_object_add(lib, "total", json_object_new_int((int)json_object_array_length(owned))); + json_object_object_add(lib, "componentIdsByProductId", cc_json_clone(components)); + cc_cache_write(log, cache_dir, "ps5_cloud_library", lib); + json_object_put(lib); +} + +static ChiakiErrorCode finish_ok(ChiakiCloudCatalogResult *out, struct json_object *env) +{ + const char *s = json_object_to_json_string_ext(env, JSON_C_TO_STRING_PLAIN); + out->json = s ? strdup(s) : NULL; + out->err = out->json ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_MEMORY; + return out->err; +} + +CHIAKI_EXPORT ChiakiErrorCode chiaki_cloudcatalog_fetch_unified( + const ChiakiCloudCatalogConfig *config, ChiakiCloudCatalogResult *out, ChiakiLog *log) +{ + if(!config || !out || !config->cache_dir) + return CHIAKI_ERR_INVALID_DATA; + memset(out, 0, sizeof(*out)); + + const char *cache_dir = config->cache_dir; + const char *locale = (config->locale && *config->locale) ? config->locale : "en-US"; + const char *npsso = config->npsso ? config->npsso : ""; + bool force = config->force_refresh; + + cc_cache_ensure_dir(cache_dir); + + // 1. unified cache hit -> no network. The cache key is versioned (v3) AND the + // payload's schemaVersion is validated, so a unified cache written by an older + // build (different contract) is never served as a stale hit. + if(!force) + { + struct json_object *cached = cc_cache_read(log, cache_dir, "unified_catalog_v3", CC_CACHE_TTL_MS); + if(cached) + { + struct json_object *sv = NULL; + int ver = json_object_object_get_ex(cached, "schemaVersion", &sv) + ? json_object_get_int(sv) : 0; + if(ver == CHIAKI_CLOUDCATALOG_SCHEMA_VERSION) + { + ChiakiErrorCode e = finish_ok(out, cached); + json_object_put(cached); + return e; + } + CHIAKI_LOGI(log, "[CACHE] unified schemaVersion %d != %d; refetching", + ver, CHIAKI_CLOUDCATALOG_SCHEMA_VERSION); + cc_cache_remove(cache_dir, "unified_catalog_v3"); + json_object_put(cached); + } + } + + // 2. native APOLLOROOT probe. The probe also reports the account's region + // (country/language) from the Kamaji session so the lib can own region detection + // instead of trusting only the caller-supplied locale. + bool auth_error = false, native = false; + char fallback_region[8] = ""; + const char *warning = ""; + struct json_object *apollo = NULL; + char acct_country[8] = "", acct_language[8] = ""; + + CCNativeResult nr = cc_fetch_psnow_native(log, npsso, &apollo, + acct_country, sizeof(acct_country), acct_language, sizeof(acct_language)); + if(nr == CC_NATIVE_OK) + native = true; + else if(nr == CC_NATIVE_AUTH_ERROR) + { + auth_error = true; + warning = WARNING_EXPIRED; + apollo = json_object_new_array(); + CHIAKI_LOGW(log, "[UNIFIED] native probe auth error; prompting re-login"); + } + else // region unsupported / fatal -> public fallback + { + char cc[8]; + // Prefer the account country from the Kamaji session (captured even when + // /user/stores 404'd); only fall back to the input locale's country. + if(acct_country[0]) + snprintf(cc, sizeof(cc), "%s", acct_country); + else + account_country_from_locale(locale, cc, sizeof(cc)); + apollo = cc_fetch_apollo_fallback(log, cc); + snprintf(fallback_region, sizeof(fallback_region), "%s", cc_classics_store_country(cc)); + } + if(!apollo) + apollo = json_object_new_array(); + + // Effective store locale: the lib owns region detection. When the Kamaji session + // reports a country that differs from the caller-supplied locale's country (e.g. a + // fresh "en-US" install on a Hungarian account), re-base the locale on the real + // account region ("hu-HU") so the imagic store-locale chain and the returned + // settledLocale reflect it. When the country already matches, keep the caller's + // locale so a previously imagic-settled refinement (e.g. "en-HU") is preserved. + // Callers persist settledLocale, so this converges after one fetch. + char effective_locale[16]; + snprintf(effective_locale, sizeof(effective_locale), "%s", locale); + if(acct_country[0]) + { + char input_cc[8]; + account_country_from_locale(locale, input_cc, sizeof(input_cc)); + if(strcasecmp(input_cc, acct_country) != 0) + { + // Compose canonically (lowercase lang subtag, uppercase country) to match + // canonical_store_locale(); a server "language" may arrive as a full locale + // (e.g. "hu-HU"), so keep only the bit before the first '-'. + char lang[8] = "en"; + if(acct_language[0]) + { + size_t i = 0; + for(const char *p = acct_language; *p && *p != '-' && i < sizeof(lang) - 1; p++) + lang[i++] = (char)tolower((unsigned char)*p); + lang[i] = 0; + if(!lang[0]) + snprintf(lang, sizeof(lang), "en"); + } + char cc_up[8]; + size_t j = 0; + for(const char *p = acct_country; *p && j < sizeof(cc_up) - 1; p++) + cc_up[j++] = (char)toupper((unsigned char)*p); + cc_up[j] = 0; + snprintf(effective_locale, sizeof(effective_locale), "%s-%s", lang, cc_up); + CHIAKI_LOGI(log, "[UNIFIED] account region %s differs from locale %s; using %s", + acct_country, locale, effective_locale); + } + } + + // 3. imagic (cache, then network with locale fallback). + struct json_object *browse = NULL, *supplement = NULL, *aliases = NULL; + char settled[16]; + snprintf(settled, sizeof(settled), "%s", effective_locale); + + struct json_object *v6 = force ? NULL : cc_cache_read(log, cache_dir, "ps5_cloud_catalog_v6", CC_CACHE_TTL_MS); + if(v6) + { + const char *cl = cc_json_str(v6, "locale"); + if(*cl && strcmp(cl, effective_locale) != 0) + { + CHIAKI_LOGI(log, "[CACHE] v6 locale %s != %s; refetching", cl, effective_locale); + json_object_put(v6); + v6 = NULL; + } + } + if(v6) + { + struct json_object *g = cc_json_arr(v6, "games"); + struct json_object *s = cc_json_arr(v6, "plusLibrarySupplement"); + struct json_object *a = cc_json_obj(v6, "productIdAliases"); + browse = cc_json_clone(g ? g : json_object_new_array()); + supplement = cc_json_clone(s ? s : json_object_new_array()); + aliases = a ? cc_json_clone(a) : json_object_new_object(); + const char *cl = cc_json_str(v6, "locale"); + if(*cl) + snprintf(settled, sizeof(settled), "%s", cl); + json_object_put(v6); + } + else + { + CCImagicResult ir; + if(cc_fetch_imagic(log, effective_locale, &ir)) + { + browse = ir.browse; ir.browse = NULL; + supplement = ir.supplement; ir.supplement = NULL; + aliases = ir.aliases; ir.aliases = NULL; + snprintf(settled, sizeof(settled), "%s", ir.settled_locale); + if(ir.all_ps5_list_succeeded) + write_v6_cache(log, cache_dir, settled, browse, supplement, aliases); + cc_imagic_result_fini(&ir); + } + else + { + cc_imagic_result_fini(&ir); + } + } + if(!browse) browse = json_object_new_array(); + if(!supplement) supplement = json_object_new_array(); + if(!aliases) aliases = json_object_new_object(); + + // Hard-fail only when BOTH catalog sources came back empty and the session is + // valid. An empty Apollo is fine as long as the imagic PS5 browse universe loaded + // (e.g. a flaky native category walk shouldn't nuke 4000+ PS5 titles); an expired + // session still returns the browse universe plus a re-login warning. + if(json_object_array_length(apollo) == 0 + && json_object_array_length(browse) == 0 && !auth_error) + { + json_object_put(apollo); + json_object_put(browse); + json_object_put(supplement); + json_object_put(aliases); + out->err = CHIAKI_ERR_UNKNOWN; + out->error_message = strdup("Failed to fetch cloud catalog"); + return out->err; + } + + // 4. owned entitlements (skip on missing/expired session). + struct json_object *owned = NULL, *components = NULL; + if(*npsso && !auth_error) + { + struct json_object *lib = force ? NULL : cc_cache_read(log, cache_dir, "ps5_cloud_library", CC_CACHE_TTL_MS); + if(lib) + { + struct json_object *g = cc_json_arr(lib, "games"); + struct json_object *c = cc_json_obj(lib, "componentIdsByProductId"); + owned = cc_json_clone(g ? g : json_object_new_array()); + components = c ? cc_json_clone(c) : json_object_new_object(); + json_object_put(lib); + } + else + { + CCOwnedResult orr = cc_fetch_owned(log, npsso, &owned, &components); + if(orr == CC_OWNED_OK) + write_library_cache(log, cache_dir, owned, components); + else if(orr == CC_OWNED_AUTH_ERROR) + { + auth_error = true; + warning = WARNING_EXPIRED; + } + } + } + if(!owned) owned = json_object_new_array(); + if(!components) components = json_object_new_object(); + + // 5. cross-reference owned -> catalog. + struct json_object *owned_cross_ref = + cc_build_owned_cross_ref(log, apollo, browse, supplement, aliases, owned, components); + + // 6. assemble. + CCAssembleInput in; + memset(&in, 0, sizeof(in)); + in.apollo_games = apollo; + in.imagic_browse = browse; + in.imagic_supplement = supplement; + in.owned_cross_ref = owned_cross_ref; + in.native_mode = native; + in.fallback_region = fallback_region; + in.settled_locale = settled; + in.warning = warning; + struct json_object *env = cc_assemble_unified_catalog(log, &in); + + // 7. cache write guard (non-empty + not auth error). + struct json_object *games = cc_json_arr(env, "games"); + int total = games ? (int)json_object_array_length(games) : 0; + if(total > 0 && !auth_error) + cc_cache_write(log, cache_dir, "unified_catalog_v3", env); + + ChiakiErrorCode e = finish_ok(out, env); + + json_object_put(env); + json_object_put(owned_cross_ref); + json_object_put(apollo); + json_object_put(browse); + json_object_put(supplement); + json_object_put(aliases); + json_object_put(owned); + json_object_put(components); + return e; +} + +CHIAKI_EXPORT void chiaki_cloudcatalog_result_fini(ChiakiCloudCatalogResult *out) +{ + if(!out) + return; + free(out->json); + free(out->error_message); + memset(out, 0, sizeof(*out)); +} + +CHIAKI_EXPORT void chiaki_cloudcatalog_invalidate_cache(const char *cache_dir) +{ + if(!cache_dir) + return; + // Current keys + legacy keys, so invalidation also purges caches written by + // older builds (e.g. the pre-contract unified_catalog_v2). + static const char *const keys[] = { + "unified_catalog_v3", "ps5_cloud_catalog_v6", "ps5_cloud_library", + "psnow_catalog", + "unified_catalog_v2", "unified_catalog_v1", + "ps5_cloud_catalog_v5", "ps5_cloud_catalog_v4", "ps5_cloud_catalog_v3", + "ps5_cloud_catalog_v2", "ps5_cloud_catalog", + NULL + }; + for(size_t i = 0; keys[i]; i++) + cc_cache_remove(cache_dir, keys[i]); +} diff --git a/lib/src/cloudcatalog_util.c b/lib/src/cloudcatalog_util.c new file mode 100644 index 00000000..99cd8811 --- /dev/null +++ b/lib/src/cloudcatalog_util.c @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL + +#include "cloudcatalog_internal.h" + +#include +#include +#include + +const char *cc_json_str(struct json_object *obj, const char *key) +{ + if(!obj || !key) + return ""; + struct json_object *v = NULL; + if(!json_object_object_get_ex(obj, key, &v) || !v) + return ""; + if(json_object_get_type(v) != json_type_string) + return ""; + const char *s = json_object_get_string(v); + return s ? s : ""; +} + +struct json_object *cc_json_obj(struct json_object *obj, const char *key) +{ + if(!obj || !key) + return NULL; + struct json_object *v = NULL; + if(!json_object_object_get_ex(obj, key, &v) || !v) + return NULL; + return json_object_get_type(v) == json_type_object ? v : NULL; +} + +struct json_object *cc_json_arr(struct json_object *obj, const char *key) +{ + if(!obj || !key) + return NULL; + struct json_object *v = NULL; + if(!json_object_object_get_ex(obj, key, &v) || !v) + return NULL; + return json_object_get_type(v) == json_type_array ? v : NULL; +} + +bool cc_json_bool(struct json_object *obj, const char *key) +{ + if(!obj || !key) + return false; + struct json_object *v = NULL; + if(!json_object_object_get_ex(obj, key, &v) || !v) + return false; + return json_object_get_boolean(v); +} + +int cc_json_int(struct json_object *obj, const char *key) +{ + if(!obj || !key) + return 0; + struct json_object *v = NULL; + if(!json_object_object_get_ex(obj, key, &v) || !v) + return 0; + return json_object_get_int(v); +} + +bool cc_json_has(struct json_object *obj, const char *key) +{ + if(!obj || !key) + return false; + struct json_object *v = NULL; + return json_object_object_get_ex(obj, key, &v) && v != NULL; +} + +char *cc_strdup(const char *s) +{ + return s ? strdup(s) : NULL; +} + +bool cc_ieq(const char *a, const char *b) +{ + if(a == b) + return true; + if(!a || !b) + return false; + return strcasecmp(a, b) == 0; +} + +bool cc_contains(const char *haystack, const char *needle) +{ + if(!haystack || !needle) + return false; + return strstr(haystack, needle) != NULL; +} + +bool cc_ends_with(const char *s, const char *suffix) +{ + if(!s || !suffix) + return false; + size_t ls = strlen(s), lsuf = strlen(suffix); + if(lsuf > ls) + return false; + return strcmp(s + (ls - lsuf), suffix) == 0; +} + +void cc_json_set_str(struct json_object *obj, const char *key, const char *value) +{ + if(!obj || !key) + return; + json_object_object_add(obj, key, json_object_new_string(value ? value : "")); +} + +void cc_json_set_bool(struct json_object *obj, const char *key, bool value) +{ + if(!obj || !key) + return; + json_object_object_add(obj, key, json_object_new_boolean(value)); +} + +struct json_object *cc_json_clone(struct json_object *src) +{ + if(!src) + return NULL; + struct json_object *dst = NULL; + if(json_object_deep_copy(src, &dst, NULL) != 0) + return NULL; + return dst; +} diff --git a/lib/src/curl_http.c b/lib/src/curl_http.c new file mode 100644 index 00000000..823a510b --- /dev/null +++ b/lib/src/curl_http.c @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL + +#include "curl_http.h" + +#include + +#include +#include +#include + +#define CHIAKI_HTTP_DEFAULT_TIMEOUT_MS 30000L +#define CHIAKI_HTTP_USER_AGENT "pylux-cloudcatalog/1.0" + +typedef struct grow_buffer_t +{ + char *data; + size_t size; +} GrowBuffer; + +static size_t grow_buffer_write(void *ptr, size_t size, size_t nmemb, void *userdata) +{ + size_t realsize = size * nmemb; + GrowBuffer *buf = (GrowBuffer *)userdata; + char *tmp = realloc(buf->data, buf->size + realsize + 1); + if(!tmp) + { + free(buf->data); + buf->data = NULL; + buf->size = 0; + return 0; + } + buf->data = tmp; + memcpy(&buf->data[buf->size], ptr, realsize); + buf->size += realsize; + buf->data[buf->size] = 0; + return realsize; +} + +static int cc_http_debug_cb(CURL *handle, curl_infotype type, char *data, size_t size, void *userptr) +{ + (void)handle; + ChiakiLog *log = (ChiakiLog *)userptr; + if(!log || !(log->level_mask & CHIAKI_LOG_VERBOSE)) + return 0; + switch(type) + { + case CURLINFO_HEADER_OUT: + CHIAKI_LOGV(log, ">>> HTTP Request Headers:"); + CHIAKI_LOGV(log, "%.*s", (int)size, data); + break; + case CURLINFO_DATA_OUT: + CHIAKI_LOGV(log, ">>> HTTP Request Body:"); + CHIAKI_LOGV(log, "%.*s", (int)size, data); + break; + case CURLINFO_HEADER_IN: + CHIAKI_LOGV(log, "<<< HTTP Response Headers:"); + CHIAKI_LOGV(log, "%.*s", (int)size, data); + break; + case CURLINFO_DATA_IN: + CHIAKI_LOGV(log, "<<< HTTP Response Body:"); + CHIAKI_LOGV(log, "%.*s", (int)size, data); + break; + default: + break; + } + return 0; +} + +static CURL *easy_init_logged(ChiakiLog *log) +{ + CURL *curl = curl_easy_init(); + if(!curl) + return NULL; + + // mbedTLS (Android and other non-system-trust backends) needs the CA bundle + // path explicitly. Harmless when the env var is unset or on Secure Transport. + const char *ca_bundle = getenv("CHIAKI_CA_BUNDLE"); + if(ca_bundle && *ca_bundle) + curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle); + + if(log && (log->level_mask & CHIAKI_LOG_VERBOSE)) + { + curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L); + curl_easy_setopt(curl, CURLOPT_DEBUGFUNCTION, cc_http_debug_cb); + curl_easy_setopt(curl, CURLOPT_DEBUGDATA, log); + } + return curl; +} + +CHIAKI_EXPORT ChiakiErrorCode cc_http_perform( + ChiakiLog *log, + const CCHttpRequest *request, + CCHttpResponse *response) +{ + if(!request || !request->url || !response) + return CHIAKI_ERR_INVALID_DATA; + + memset(response, 0, sizeof(*response)); + + CURL *curl = easy_init_logged(log); + if(!curl) + return CHIAKI_ERR_MEMORY; + + ChiakiErrorCode err = CHIAKI_ERR_SUCCESS; + struct curl_slist *header_list = NULL; + GrowBuffer body_buf = { 0 }; + GrowBuffer header_buf = { 0 }; + + for(size_t i = 0; i < request->header_count; i++) + { + struct curl_slist *next = curl_slist_append(header_list, request->headers[i]); + if(!next) + { + err = CHIAKI_ERR_MEMORY; + goto cleanup; + } + header_list = next; + } + + curl_easy_setopt(curl, CURLOPT_URL, request->url); + curl_easy_setopt(curl, CURLOPT_USERAGENT, CHIAKI_HTTP_USER_AGENT); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, request->follow_redirects ? 1L : 0L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, + request->timeout_ms > 0 ? request->timeout_ms : CHIAKI_HTTP_DEFAULT_TIMEOUT_MS); + if(header_list) + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list); + + if(request->method && strcmp(request->method, "POST") == 0) + { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + const char *body = request->body ? request->body : ""; + curl_off_t len = (curl_off_t)(request->body_len > 0 ? request->body_len : strlen(body)); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, len); + } + else if(request->method && strcmp(request->method, "GET") != 0) + { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, request->method); + } + + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, grow_buffer_write); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body_buf); + if(request->capture_headers) + { + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, grow_buffer_write); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &header_buf); + } + + CURLcode res = curl_easy_perform(curl); + if(res != CURLE_OK) + { + if(log) + CHIAKI_LOGE(log, "cc_http_perform: %s (%s)", curl_easy_strerror(res), request->url); + err = CHIAKI_ERR_NETWORK; + goto cleanup; + } + + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + response->status_code = status; + + char *redirect = NULL; + curl_easy_getinfo(curl, CURLINFO_REDIRECT_URL, &redirect); + if(redirect) + response->redirect_url = strdup(redirect); + + response->data = body_buf.data; + response->size = body_buf.size; + body_buf.data = NULL; + if(request->capture_headers) + { + response->headers = header_buf.data; + response->headers_size = header_buf.size; + header_buf.data = NULL; + } + +cleanup: + free(body_buf.data); + free(header_buf.data); + if(header_list) + curl_slist_free_all(header_list); + curl_easy_cleanup(curl); + return err; +} + +CHIAKI_EXPORT void cc_http_response_fini(CCHttpResponse *response) +{ + if(!response) + return; + free(response->data); + free(response->headers); + free(response->redirect_url); + memset(response, 0, sizeof(*response)); +} + +CHIAKI_EXPORT ChiakiErrorCode cc_http_make_bearer_header(char **out, const char *token) +{ + if(!out || !token) + return CHIAKI_ERR_INVALID_DATA; + static const char fmt[] = "Authorization: Bearer %s"; + size_t len = sizeof(fmt) + strlen(token); + *out = malloc(len); + if(!*out) + return CHIAKI_ERR_MEMORY; + snprintf(*out, len, fmt, token); + return CHIAKI_ERR_SUCCESS; +} + +CHIAKI_EXPORT ChiakiErrorCode cc_http_make_cookie_header( + char **out, const char *name, const char *value) +{ + if(!out || !name || !value) + return CHIAKI_ERR_INVALID_DATA; + static const char fmt[] = "Cookie: %s=%s"; + size_t len = sizeof(fmt) + strlen(name) + strlen(value); + *out = malloc(len); + if(!*out) + return CHIAKI_ERR_MEMORY; + snprintf(*out, len, fmt, name, value); + return CHIAKI_ERR_SUCCESS; +} diff --git a/lib/src/curl_http.h b/lib/src/curl_http.h new file mode 100644 index 00000000..500f9e0a --- /dev/null +++ b/lib/src/curl_http.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Minimal blocking HTTP helper built on libcurl, shared by the cloud catalog +// module. Self-contained: handles mbedTLS CA-bundle (CHIAKI_CA_BUNDLE) and +// verbose request/response logging internally, so it does not depend on the +// holepunch.c curl glue (which is left untouched). + +#ifndef CHIAKI_CC_HTTP_H +#define CHIAKI_CC_HTTP_H + +#include +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Growable response buffer (NUL-terminated body). */ +typedef struct cc_http_response_t +{ + char *data; /**< response body, NUL-terminated (may be NULL on empty) */ + size_t size; /**< body length in bytes (excluding terminating NUL) */ + char *headers; /**< raw response headers if request.capture_headers (else NULL) */ + size_t headers_size; + char *redirect_url; /**< CURLINFO_REDIRECT_URL (the Location target), or NULL */ + long status_code; /**< HTTP status code */ +} CCHttpResponse; + +/** One blocking HTTP request. */ +typedef struct cc_http_request_t +{ + const char *method; /**< "GET", "POST", ... (defaults to GET if NULL) */ + const char *url; + const char *const *headers; /**< array of "Key: Value" strings */ + size_t header_count; + const char *body; /**< request body (POST); NULL for none */ + size_t body_len; /**< if 0 and body != NULL, strlen(body) is used */ + bool follow_redirects; /**< CURLOPT_FOLLOWLOCATION */ + bool capture_headers; /**< capture raw response headers into response */ + long timeout_ms; /**< total timeout; 0 = library default (30s) */ +} CCHttpRequest; + +/** + * Perform one blocking HTTP request. On success the caller owns @p response and + * must release it with cc_http_response_fini(). Returns CHIAKI_ERR_SUCCESS + * even for non-2xx HTTP status codes (inspect response->status_code); only + * transport/setup failures return an error code. + */ +CHIAKI_EXPORT ChiakiErrorCode cc_http_perform( + ChiakiLog *log, + const CCHttpRequest *request, + CCHttpResponse *response); + +/** Release a response populated by cc_http_perform(). Safe on zeroed struct. */ +CHIAKI_EXPORT void cc_http_response_fini(CCHttpResponse *response); + +/** Build "Authorization: Bearer ". Caller frees *out. */ +CHIAKI_EXPORT ChiakiErrorCode cc_http_make_bearer_header(char **out, const char *token); + +/** Build "Cookie: =" (e.g. name="npsso"). Caller frees *out. */ +CHIAKI_EXPORT ChiakiErrorCode cc_http_make_cookie_header( + char **out, const char *name, const char *value); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CC_HTTP_H From 8c338afdf80743ca462b022c81bb37521681d3ef Mon Sep 17 00:00:00 2001 From: forward technologies Date: Fri, 26 Jun 2026 16:24:31 -0700 Subject: [PATCH 12/72] Cloud: RTT safety offset, stream-language separation, and Cloud Play card polish libchiaki: - Apply a cloud-only RTT safety offset (-20ms, clamped to 1ms min) at the single senkusha measurement point so the latency gate, /datacenters/select, /allocate, and the settings display all see the adjusted value. Scoped via service_type so Remote Play is untouched (new CHIAKI_CLOUD_RTT_* constants in common.h). - Add a contributor-guidance block to cloudcatalog_unified.c documenting the shared-merge ground rules (all logic in libchiaki, imagic=owned PS5 / Apollo=PS3-PS4, graceful Apollo region fallback, no title-ID regex matching). Cross-platform (Qt, Android, iOS): - Separate the manual stream-language setting from the auto catalog locale. - Preserve previously-measured datacenter ping RTTs instead of clobbering the picker with a no-RTT list before pinging. Android: - Shorten the inline cloud-language note and show the full caveat in a popup only when a specific language is chosen. iOS: - Redesign the Cloud Play card platform badge as a neon-outlined corner tag (own bottom-right layer) so it no longer competes with the title for space. Tests: - Add cloudcatalog_merge unit test + a desktop fetch harness. Co-authored-by: Cursor --- .../chiaki/cloudplay/api/PSGaikaiStreaming.kt | 29 +- .../chiaki/settings/SettingsFragment.kt | 16 +- android/app/src/main/res/values/strings.xml | 1 + android/app/src/main/res/xml/preferences.xml | 7 + gui/src/cloudstreaming/psgaikaistreaming.cpp | 25 +- ios/Pylux/Services/PSGaikaiStreaming.swift | 18 +- ios/Pylux/Views/CloudPlayView.swift | 59 +++- ios/Pylux/Views/SettingsView.swift | 11 + lib/CMakeLists.txt | 5 + lib/include/chiaki/common.h | 10 + lib/src/cloudcatalog_unified.c | 43 +++ lib/src/senkusha.c | 13 + lib/test_cloudcatalog/cloudcatalog-test.c | 84 +++++ test/CMakeLists.txt | 6 +- test/cloudcatalog_merge.c | 294 ++++++++++++++++++ test/main.c | 8 + 16 files changed, 594 insertions(+), 35 deletions(-) create mode 100644 lib/test_cloudcatalog/cloudcatalog-test.c create mode 100644 test/cloudcatalog_merge.c diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt index 9e4caa75..b1b4390f 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt @@ -1017,16 +1017,27 @@ catch (e: Exception) { if (datacenters.length() == 0) return null - // Save datacenters to settings (Qt lines 1194-1200) - // This saves the raw datacenter list before pinging - val datacentersJsonString = datacenters.toString() - if (serviceType == "pscloud") - { - preferences.setCloudDatacentersJsonPscloud(datacentersJsonString) - } - else // psnow + // Seed the picker with the raw datacenter list ONLY when nothing is saved + // yet. Never overwrite a previously-saved list here: it carries real ping + // RTTs from a prior Auto run, and manual mode below won't re-ping, so + // clobbering it with this no-RTT list would drop the ms from the picker. + val existingDatacentersJson = if (serviceType == "pscloud") + preferences.getCloudDatacentersJsonPscloud() + else + preferences.getCloudDatacentersJsonPsnow() + val hasExistingDatacenters = existingDatacentersJson.isNotEmpty() && + try { org.json.JSONArray(existingDatacentersJson).length() > 0 } catch (e: Exception) { false } + if (!hasExistingDatacenters) { - preferences.setCloudDatacentersJsonPsnow(datacentersJsonString) + val datacentersJsonString = datacenters.toString() + if (serviceType == "pscloud") + { + preferences.setCloudDatacentersJsonPscloud(datacentersJsonString) + } + else // psnow + { + preferences.setCloudDatacentersJsonPsnow(datacentersJsonString) + } } // Check if a specific datacenter is selected (Qt lines 1203-1228) diff --git a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt index 6ee12e17..5fa980a3 100644 --- a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt @@ -536,6 +536,20 @@ class SettingsFragment: PreferenceFragmentCompat(), TitleFragment } preference.entries = entries.toTypedArray() preference.entryValues = values.toTypedArray() - preference.dialogMessage = getString(R.string.preferences_cloud_language_dialog_message) + + // Keep the inline note short; show the full caveat as a popup only when a + // specific language is chosen (matches iOS Cloud Settings behavior). + preference.setOnPreferenceChangeListener { _, newValue -> + val selected = newValue as? String ?: "" + if (selected.isNotEmpty()) + { + context?.alertDialogBuilder() + ?.setTitle(R.string.preferences_cloud_language_title) + ?.setMessage(R.string.preferences_cloud_language_dialog_message) + ?.setPositiveButton(android.R.string.ok, null) + ?.show() + } + true + } } } \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3a6e14d8..532e7283 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -297,6 +297,7 @@ Game Language Auto (%1$s) Not all regions support every language. A language only works on datacenters that offer it — if your chosen language isn\'t applied, pick a datacenter in a matching region. + Language availability depends on your datacenter\'s region. pip_enabled Support Pylux diff --git a/android/app/src/main/res/xml/preferences.xml b/android/app/src/main/res/xml/preferences.xml index c12bd449..48aaa6dc 100644 --- a/android/app/src/main/res/xml/preferences.xml +++ b/android/app/src/main/res/xml/preferences.xml @@ -113,6 +113,13 @@ app:summary="%s" app:defaultValue="" app:icon="@drawable/ic_resolution"/> + + SetCloudDatacentersJsonPSCloud(datacentersDoc.toJson(QJsonDocument::Compact)); - } else { - settings->SetCloudDatacentersJsonPSNOW(datacentersDoc.toJson(QJsonDocument::Compact)); + // Seed the picker with the raw datacenter list ONLY when nothing is saved + // yet. Never overwrite a previously-saved list here: it carries real ping + // RTTs from a prior Auto run, and manual mode below won't re-ping, so + // clobbering it with this no-RTT list would drop the ms from the picker. + QString existingDatacentersJson = (serviceType == "pscloud") + ? settings->GetCloudDatacentersJsonPSCloud() + : settings->GetCloudDatacentersJsonPSNOW(); + bool hasExistingDatacenters = false; + if (!existingDatacentersJson.isEmpty()) { + QJsonDocument existingDoc = QJsonDocument::fromJson(existingDatacentersJson.toUtf8()); + hasExistingDatacenters = existingDoc.isArray() && !existingDoc.array().isEmpty(); + } + if (!hasExistingDatacenters) { + QJsonDocument datacentersDoc(datacenters); + if (serviceType == "pscloud") { + settings->SetCloudDatacentersJsonPSCloud(datacentersDoc.toJson(QJsonDocument::Compact)); + } else { + settings->SetCloudDatacentersJsonPSNOW(datacentersDoc.toJson(QJsonDocument::Compact)); + } } // Check if a specific datacenter is selected (non-auto) diff --git a/ios/Pylux/Services/PSGaikaiStreaming.swift b/ios/Pylux/Services/PSGaikaiStreaming.swift index 82a5402d..8cc88d29 100644 --- a/ios/Pylux/Services/PSGaikaiStreaming.swift +++ b/ios/Pylux/Services/PSGaikaiStreaming.swift @@ -415,8 +415,13 @@ final class PSGaikaiStreaming { dc["dataCenter"] as? String ?? "", dc["publicIp"] as? String ?? "", dc["port"] as? Int ?? 0) } - // Raw list for Settings (matches Android step 11 — before ping) - CloudDatacenterStore.saveDatacenters(arr, for: serviceType) + // Seed the picker with the raw list ONLY when nothing is saved yet. Never + // overwrite a previously-saved list here: it carries real ping RTTs from a + // prior Auto run, and manual mode won't re-ping, so clobbering it with this + // no-RTT list would drop the ms from the picker. + if !CloudDatacenterStore.hasStoredDatacenters(for: serviceType) { + CloudDatacenterStore.saveDatacenters(arr, for: serviceType) + } return arr } @@ -446,8 +451,13 @@ final class PSGaikaiStreaming { "publicIp": selectedDc["publicIp"] as? String ?? "", "maxBandwidth": maxBw ] - let forStore = Self.datacenterRowsForManualStore(datacenters: datacenters, selectedName: userChoice, dummyPing: dummyPing) - CloudDatacenterStore.saveDatacenters(forStore, for: serviceType) + // Only persist the manual/dummy rows when no real measurements exist yet. + // Otherwise keep the previously-measured RTTs so the picker still shows the + // real ms (manual mode uses a dummy 20ms purely for this stream). + if !CloudDatacenterStore.hasStoredDatacenters(for: serviceType) { + let forStore = Self.datacenterRowsForManualStore(datacenters: datacenters, selectedName: userChoice, dummyPing: dummyPing) + CloudDatacenterStore.saveDatacenters(forStore, for: serviceType) + } return try submitDatacenterSelection(pingResult: dummyPing, validatePing: false) } diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 1bd68547..808054df 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -840,7 +840,20 @@ struct CloudGameCardView: View { bottomOverlay } - // Layer 3: Top overlays - category badge (left) + star (right) + // Layer 3: Platform "console coin" pinned to the bottom-right corner, + // mirroring the star in the top-right. Its own layer (not in the name + // row) so it never competes with the title for horizontal space. + VStack { + Spacer() + HStack { + Spacer() + platformCoin + .padding(.trailing, 8) + .padding(.bottom, 8) + } + } + + // Layer 4: Top overlays - category badge (left) + star (right) VStack { HStack(alignment: .top, spacing: 0) { categoryBadge @@ -870,7 +883,7 @@ struct CloudGameCardView: View { Spacer() } - // Layer 4: Full-card invisible tap target for launching (behind star button) + // Layer 5: Full-card invisible tap target for launching (behind star button) Color.clear .contentShape(Rectangle()) .onTapGesture(perform: onTap) @@ -938,25 +951,20 @@ struct CloudGameCardView: View { } private var bottomOverlay: some View { - VStack(alignment: .leading, spacing: 3) { - Text(platformLabel) - .font(.system(size: 9, weight: .heavy)) - .foregroundColor(platformColor) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background( - Capsule().fill(platformColor.opacity(0.2)) - ) - + // Name spans the full width; the platform coin lives in its own bottom-right + // corner layer, so we just reserve a little trailing inset here to keep a long + // 2-line title from running under the coin. + HStack(spacing: 0) { Text(game.name) .font(.system(size: 12, weight: .bold)) .foregroundColor(.white) .lineLimit(2) .multilineTextAlignment(.leading) .shadow(color: .black.opacity(0.8), radius: 2, y: 1) + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) + .padding(.leading, 8) + .padding(.trailing, 34) .padding(.bottom, 8) .padding(.top, 40) .background( @@ -972,6 +980,29 @@ struct CloudGameCardView: View { .allowsHitTesting(false) } + /// Neon platform tag pinned to the bottom-right corner. A flat, translucent + /// rounded-rect with a glowing platform-colored outline + text glow, so it reads + /// as part of the app's electric-blue theme instead of a floating coin. + private var platformCoin: some View { + Text(platformLabel) + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundColor(.white) + .shadow(color: platformColor.opacity(0.95), radius: 3.5) // inner text glow + .frame(minWidth: 10) + .padding(.horizontal, 4.5) + .padding(.vertical, 1.5) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(.black.opacity(0.40)) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(platformColor.opacity(0.95), lineWidth: 1) + ) + ) + .shadow(color: platformColor.opacity(0.75), radius: 5) // outer neon glow + .allowsHitTesting(false) + } + /// Platform label without "PS" prefix to avoid trademark private var platformLabel: String { switch game.platform { diff --git a/ios/Pylux/Views/SettingsView.swift b/ios/Pylux/Views/SettingsView.swift index e872bb91..1f71275e 100644 --- a/ios/Pylux/Views/SettingsView.swift +++ b/ios/Pylux/Views/SettingsView.swift @@ -249,6 +249,17 @@ struct StreamPreferences: Codable { // MARK: - Datacenter list storage (matches Android cloud_datacenters_json_*) enum CloudDatacenterStore { + /// Whether a non-empty datacenter list is already persisted. Used to avoid + /// clobbering previously-measured ping RTTs with a no-RTT/dummy list. + static func hasStoredDatacenters(for serviceType: String) -> Bool { + let data = serviceType == "pscloud" + ? SecureStore.shared.pscloudDatacentersData + : SecureStore.shared.psnowDatacentersData + guard let data, + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return false } + return !arr.isEmpty + } + /// Save datacenter list after allocation (called from PSGaikaiStreaming) static func saveDatacenters(_ datacenters: [[String: Any]], for serviceType: String) { guard let data = try? JSONSerialization.data(withJSONObject: datacenters) else { return } diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index c9f6a83a..39d16b1a 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -230,4 +230,9 @@ endif() if(CHIAKI_ENABLE_TESTS) add_executable(holepunch-test include/chiaki/remote/holepunch.h src/remote/holepunch-test.c) target_link_libraries(holepunch-test chiaki-lib) + + if(NOT ANDROID AND NOT IOS) + add_executable(cloudcatalog-test test_cloudcatalog/cloudcatalog-test.c) + target_link_libraries(cloudcatalog-test chiaki-lib) + endif() endif() \ No newline at end of file diff --git a/lib/include/chiaki/common.h b/lib/include/chiaki/common.h index 7590c611..791a6dff 100644 --- a/lib/include/chiaki/common.h +++ b/lib/include/chiaki/common.h @@ -145,6 +145,16 @@ static inline bool chiaki_service_type_is_cloud(ChiakiServiceType service_type) return chiaki_service_type_normalize(service_type) != CHIAKI_SERVICE_TYPE_REMOTE_PLAY; } +/** + * Fixed safety offset (in milliseconds) subtracted from the senkusha-measured RTT + * for cloud sessions only. Our cloud datacenter ping reads consistently high, which + * trips the client-side latency gate and inflates the RTT reported to Gaikai. The + * offset is applied once at the measurement source (senkusha) and clamped so the + * result never drops below CHIAKI_CLOUD_RTT_MIN_MS. Remote Play is unaffected. + */ +#define CHIAKI_CLOUD_RTT_SAFETY_OFFSET_MS 20 +#define CHIAKI_CLOUD_RTT_MIN_MS 1 + CHIAKI_EXPORT const char *chiaki_service_type_string(ChiakiServiceType service_type); #ifdef __cplusplus diff --git a/lib/src/cloudcatalog_unified.c b/lib/src/cloudcatalog_unified.c index e4ce2c36..69be88ec 100644 --- a/lib/src/cloudcatalog_unified.c +++ b/lib/src/cloudcatalog_unified.c @@ -6,6 +6,49 @@ // (unified_catalog_v3 [contract schema; was v2 pre-migration], ps5_cloud_catalog_v6, // ps5_cloud_library) are shared across platforms so files stay byte-comparable, and // the unified read is guarded by schemaVersion so a stale older payload is never served. +// +// ============================================================================= +// CONTRIBUTOR NOTES — read this before changing how the catalog is built +// ============================================================================= +// This file (and its cloudcatalog_*.c siblings) is THE one place where the cloud +// game library is assembled. Edits here are welcome, but please keep to a few +// ground rules so the three clients (Qt, Android, iOS) stay in lockstep. +// +// 1. ALL catalog logic lives HERE, in libchiaki — never in a client. +// Qt/QML, the Android Kotlin layer, and the iOS Swift layer must stay "dumb": +// they call chiaki_cloudcatalog_fetch_unified() and render the JSON it returns. +// Do NOT re-derive platform, ownership, service type, or identifiers in a +// client. If a client needs a new field, ADD IT TO THE CONTRACT HERE (see +// cloudcatalog_merge.c) and emit it for everyone — don't special-case one OS. +// +// 2. The two sources, and what each is authoritative for: +// - imagic -> owned PS5 cloud games (the PS5 browse universe + your +// entitlements / "plus library" supplement). +// - Apollo -> the PS3/PS4 (PS Now classics) catalog. +// Treat them as the source of truth for their own domain. When in doubt about +// where a game should come from, prefer imagic for PS5-owned and Apollo for +// the PS3/PS4 classics, rather than inventing a heuristic. +// +// 3. Apollo can legitimately be unavailable (region not served, expired session). +// That is NOT a fatal error. The chain already degrades gracefully: native +// APOLLOROOT probe -> public fallback for the account's country -> still serve +// the imagic PS5 universe (+ a re-login warning on auth failure). If you touch +// the fetch/fallback path, KEEP these fallbacks working — losing your owned PS5 +// list because Apollo 404'd in someone's region is the exact bug we avoid here. +// +// 4. DO NOT pattern-match / regex on title IDs to infer platform or anything else. +// Product/title IDs (CUSA####, PPSA####, etc.) vary by region and over time, so +// "starts with CUSA" / "looks like PPSA" style checks are brittle and unsafe. +// When you must parse an identifier, split on its real structural separators +// ('-' and '_') and use the resulting parts — never a regex over the raw ID. +// Platform/ownership decisions should come from the source data (device lists, +// serviceType, entitlements), not from how an ID happens to be spelled. +// +// 5. Keep the cache keys and emitted contract fields stable and shared. The cache +// files are meant to be byte-comparable across platforms; if you change the +// shape, bump the schema/key version (see CHIAKI_CLOUDCATALOG_SCHEMA_VERSION +// and the versioned key names) so stale payloads are never served. +// ============================================================================= #include "cloudcatalog_internal.h" diff --git a/lib/src/senkusha.c b/lib/src/senkusha.c index 7d2721b8..bfc35a56 100644 --- a/lib/src/senkusha.c +++ b/lib/src/senkusha.c @@ -485,6 +485,19 @@ static ChiakiErrorCode senkusha_run_rtt_test(ChiakiSenkusha *senkusha, uint16_t *rtt_us = rtt_us_acc / pings_successful; CHIAKI_LOGI(senkusha->log, "Senkusha determined average RTT = %.3f ms", (float)(*rtt_us) * 0.001f); + // Cloud-only RTT safety offset. Applied at the single measurement source so every + // downstream consumer (latency gate, /datacenters/select, /allocate, settings) sees + // the adjusted value with no per-platform call-site changes. Scoped to cloud via the + // session's service_type (PSNOW/PSCLOUD) so Remote Play RTT is left untouched. + if(senkusha->session && chiaki_service_type_is_cloud(senkusha->session->service_type)) + { + uint64_t offset_us = (uint64_t)CHIAKI_CLOUD_RTT_SAFETY_OFFSET_MS * 1000; + uint64_t floor_us = (uint64_t)CHIAKI_CLOUD_RTT_MIN_MS * 1000; + *rtt_us = (*rtt_us >= offset_us + floor_us) ? (*rtt_us - offset_us) : floor_us; + CHIAKI_LOGI(senkusha->log, "Applied cloud RTT safety offset (-%d ms) -> %.3f ms", + CHIAKI_CLOUD_RTT_SAFETY_OFFSET_MS, (float)(*rtt_us) * 0.001f); + } + return CHIAKI_ERR_SUCCESS; } diff --git a/lib/test_cloudcatalog/cloudcatalog-test.c b/lib/test_cloudcatalog/cloudcatalog-test.c new file mode 100644 index 00000000..f063e4f7 --- /dev/null +++ b/lib/test_cloudcatalog/cloudcatalog-test.c @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Desktop harness for the unified cloud catalog lib. Drives a real fetch with a +// provided NPSSO and writes the unified JSON to disk for baseline comparison. +// +// cloudcatalog-test [cache_dir] [out.json] [locale] [--force] +// +// Defaults: cache_dir=./tmp/cc-lib-cache, out=./tmp/lib-unified.json, locale=en-US + +#include +#include + +#include +#include +#include + +static char *read_token(const char *arg) +{ + // If arg names a readable file, use its first line; else treat arg as the token. + FILE *f = fopen(arg, "rb"); + if(!f) + return strdup(arg); + char buf[512]; + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + buf[n] = 0; + // trim trailing whitespace/newlines + while(n > 0 && (buf[n - 1] == '\n' || buf[n - 1] == '\r' || buf[n - 1] == ' ' || buf[n - 1] == '\t')) + buf[--n] = 0; + return strdup(buf); +} + +int main(int argc, char *argv[]) +{ + if(argc < 2) + { + fprintf(stderr, "usage: %s [cache_dir] [out.json] [locale] [--force]\n", argv[0]); + return 2; + } + const char *cache_dir = argc > 2 ? argv[2] : "./tmp/cc-lib-cache"; + const char *out_path = argc > 3 ? argv[3] : "./tmp/lib-unified.json"; + const char *locale = argc > 4 ? argv[4] : "en-US"; + bool force = false; + for(int i = 1; i < argc; i++) + if(strcmp(argv[i], "--force") == 0) + force = true; + + char *token = read_token(argv[1]); + + ChiakiLog log; + chiaki_log_init(&log, CHIAKI_LOG_INFO | CHIAKI_LOG_WARNING | CHIAKI_LOG_ERROR, chiaki_log_cb_print, NULL); + + ChiakiCloudCatalogConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.npsso = token; + cfg.locale = locale; + cfg.cache_dir = cache_dir; + cfg.force_refresh = force; + + ChiakiCloudCatalogResult res; + ChiakiErrorCode err = chiaki_cloudcatalog_fetch_unified(&cfg, &res, &log); + + printf("\n=== fetch_unified err=%d ===\n", (int)err); + if(res.error_message) + printf("error_message: %s\n", res.error_message); + if(res.json) + { + FILE *o = fopen(out_path, "wb"); + if(o) + { + fwrite(res.json, 1, strlen(res.json), o); + fclose(o); + printf("wrote %zu bytes -> %s\n", strlen(res.json), out_path); + } + } + else + { + printf("no json payload\n"); + } + + chiaki_cloudcatalog_result_fini(&res); + free(token); + return err == CHIAKI_ERR_SUCCESS ? 0 : 1; +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 16c0f73f..6373ccfe 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -15,8 +15,12 @@ add_executable(chiaki-unit test_log.c test_log.h bitstream.c - regist.c) + regist.c + cloudcatalog_merge.c) target_link_libraries(chiaki-unit chiaki-lib munit) +if(TARGET PkgConfig::json-c) + target_link_libraries(chiaki-unit PkgConfig::json-c) +endif() add_test(unit chiaki-unit) diff --git a/test/cloudcatalog_merge.c b/test/cloudcatalog_merge.c new file mode 100644 index 00000000..f57c6af9 --- /dev/null +++ b/test/cloudcatalog_merge.c @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Offline merge/assembly tests for the libchiaki cloud catalog. Synthetic +// inputs exercise the tricky cases that drove the parity work: apollo<->imagic +// browse dedup, device-based PS5 membership, trial suppression, cross-buy +// wrapper drop, category tagging, the contract fields, and sort order. + +#include + +#include "../lib/src/cloudcatalog_internal.h" +#include +#include "test_log.h" + +#include +#include + +static struct json_object *parse(const char *s) +{ + struct json_object *o = json_tokener_parse(s); + munit_assert_not_null(o); + return o; +} + +static struct json_object *games_of(struct json_object *env) +{ + struct json_object *g = NULL; + munit_assert_true(json_object_object_get_ex(env, "games", &g)); + return g; +} + +static struct json_object *find_pid(struct json_object *games, const char *pid) +{ + size_t n = json_object_array_length(games); + struct json_object *found = NULL; + for(size_t i = 0; i < n; i++) + { + struct json_object *g = json_object_array_get_idx(games, i); + if(strcmp(cc_json_str(g, "productId"), pid) == 0) + found = g; // last match (also lets us count) + } + return found; +} + +static int count_pid(struct json_object *games, const char *pid) +{ + size_t n = json_object_array_length(games); + int c = 0; + for(size_t i = 0; i < n; i++) + if(strcmp(cc_json_str(json_object_array_get_idx(games, i), "productId"), pid) == 0) + c++; + return c; +} + +static int count_cat(struct json_object *games, const char *cat) +{ + size_t n = json_object_array_length(games); + int c = 0; + for(size_t i = 0; i < n; i++) + if(strcmp(cc_json_str(json_object_array_get_idx(games, i), "category"), cat) == 0) + c++; + return c; +} + +// Apollo title that also appears in the imagic PS5 browse list (same productId) +// must emit exactly once, as the authoritative psnow/streamable row. +static MunitResult test_apollo_dedup(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *apollo = parse("[{\"id\":\"PPSA-CROW_00\",\"name\":\"Crow Country\",\"conceptId\":111}]"); + struct json_object *browse = parse("[{\"productId\":\"PPSA-CROW_00\",\"name\":\"Crow Country\",\"conceptId\":111,\"device\":[\"PS5\"],\"streamingSupported\":true}]"); + + CCAssembleInput in = { 0 }; + in.apollo_games = apollo; + in.imagic_browse = browse; + in.native_mode = true; + in.fallback_region = ""; + + struct json_object *env = cc_assemble_unified_catalog(get_test_log(), &in); + struct json_object *games = games_of(env); + + munit_assert_int(count_pid(games, "PPSA-CROW_00"), ==, 1); + struct json_object *crow = find_pid(games, "PPSA-CROW_00"); + munit_assert_string_equal(cc_json_str(crow, "serviceType"), "psnow"); + munit_assert_string_equal(cc_json_str(crow, "category"), "streamable"); + munit_assert_string_equal(cc_json_str(crow, "streamServiceType"), "psnow"); + + json_object_put(env); + json_object_put(apollo); + json_object_put(browse); + return MUNIT_OK; +} + +// A CUSA-id browse game whose device[] includes "PS5" is a PS5 title and must be +// kept (the bug we fixed: token-only PPSA check dropped these). A PS4-only device +// game is excluded from the PS5 browse universe. +static MunitResult test_device_based_ps5(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *browse = parse( + "[{\"productId\":\"CUSA-BUNDLE_00\",\"name\":\"Indie Bundle\",\"device\":[\"PS4\",\"PS5\"],\"streamingSupported\":true,\"conceptId\":501}," + " {\"productId\":\"CUSA-PS4ONLY_00\",\"name\":\"PS4 Only\",\"device\":[\"PS4\"],\"streamingSupported\":true,\"conceptId\":502}]"); + + CCAssembleInput in = { 0 }; + in.imagic_browse = browse; + in.native_mode = false; // skip gate so we test pure membership + in.fallback_region = "US"; + + struct json_object *env = cc_assemble_unified_catalog(get_test_log(), &in); + struct json_object *games = games_of(env); + + struct json_object *bundle = find_pid(games, "CUSA-BUNDLE_00"); + munit_assert_not_null(bundle); + munit_assert_string_equal(cc_json_str(bundle, "platform"), "ps5"); + munit_assert_string_equal(cc_json_str(bundle, "serviceType"), "pscloud"); + munit_assert_string_equal(cc_json_str(bundle, "category"), "purchaseable"); + + munit_assert_null(find_pid(games, "CUSA-PS4ONLY_00")); + + json_object_put(env); + json_object_put(browse); + return MUNIT_OK; +} + +// pscloud owned claim stamps the PS5 browse card; a PS4 cross-buy psnow wrapper +// carrying the SAME PPSA productId is dropped (no ghost duplicate). The trial of +// a fully-owned product is suppressed. +static MunitResult test_crossbuy_and_trial(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *browse = parse( + "[{\"productId\":\"PPSA-TRACK_00\",\"name\":\"Trackmania\",\"conceptId\":333,\"device\":[\"PS5\"],\"streamingSupported\":true}]"); + struct json_object *owned = parse( + "[{\"serviceType\":\"pscloud\",\"id\":\"PPSA-TRACK-ENT\",\"product_id\":\"PPSA-TRACK_00\",\"conceptId\":333,\"feature_type\":3,\"game_meta\":{\"name\":\"Trackmania\"}}," + " {\"serviceType\":\"psnow\",\"id\":\"CUSA-TRACK-ENT\",\"product_id\":\"PPSA-TRACK_00\",\"conceptId\":333,\"feature_type\":1,\"game_meta\":{\"name\":\"Trackmania Trial\"}}]"); + + CCAssembleInput in = { 0 }; + in.imagic_browse = browse; + in.owned_cross_ref = owned; + in.native_mode = true; + in.fallback_region = ""; + + struct json_object *env = cc_assemble_unified_catalog(get_test_log(), &in); + struct json_object *games = games_of(env); + + munit_assert_int(count_pid(games, "PPSA-TRACK_00"), ==, 1); + struct json_object *track = find_pid(games, "PPSA-TRACK_00"); + munit_assert_true(cc_json_bool(track, "isOwned")); + munit_assert_string_equal(cc_json_str(track, "category"), "owned"); + munit_assert_string_equal(cc_json_str(track, "serviceType"), "pscloud"); + // pscloud owned streams the entitlement's own id + munit_assert_string_equal(cc_json_str(track, "streamIdentifier"), "PPSA-TRACK-ENT"); + + json_object_put(env); + json_object_put(browse); + json_object_put(owned); + return MUNIT_OK; +} + +// Cross-buy stranded-sibling regression (Worms World Party). The browse lists the +// same concept under two SKUs on the same platform (a CUSA and a PPSA productId, +// both PS5-cloud streamable). The owned PS5 entitlement (product_id = CUSA, id = +// PPSA) claims the CUSA row; the PPSA sibling must NOT remain as a purchaseable +// "Add Game" duplicate of a title you already own. +static MunitResult test_crossbuy_sku_sibling(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *browse = parse( + "[{\"productId\":\"UP-CUSA_00\",\"name\":\"Worms\",\"conceptId\":900,\"device\":[\"PS5\"],\"streamingSupported\":true}," + " {\"productId\":\"UP-PPSA_00\",\"name\":\"Worms\",\"conceptId\":900,\"device\":[\"PS5\"],\"streamingSupported\":true}]"); + struct json_object *owned = parse( + "[{\"serviceType\":\"pscloud\",\"id\":\"UP-PPSA_00\",\"product_id\":\"UP-CUSA_00\",\"conceptId\":900,\"feature_type\":3,\"game_meta\":{\"name\":\"Worms\"}}," + " {\"serviceType\":\"psnow\",\"id\":\"UP-CUSA_00\",\"product_id\":\"UP-CUSA_00\",\"conceptId\":900,\"feature_type\":3,\"game_meta\":{\"name\":\"Worms\"}}]"); + + CCAssembleInput in = { 0 }; + in.imagic_browse = browse; + in.owned_cross_ref = owned; + in.native_mode = true; + in.fallback_region = ""; + + struct json_object *env = cc_assemble_unified_catalog(get_test_log(), &in); + struct json_object *games = games_of(env); + + // Exactly one Worms card, owned; the purchaseable PPSA sibling is gone. + munit_assert_int(count_cat(games, "owned"), ==, 1); + munit_assert_int(count_cat(games, "purchaseable"), ==, 0); + munit_assert_int(count_pid(games, "UP-PPSA_00"), ==, 0); + struct json_object *worms = find_pid(games, "UP-CUSA_00"); + munit_assert_not_null(worms); + munit_assert_true(cc_json_bool(worms, "isOwned")); + // Streams via the PS5 entitlement id (cross-buy rescue). + munit_assert_string_equal(cc_json_str(worms, "streamIdentifier"), "UP-PPSA_00"); + + json_object_put(env); + json_object_put(browse); + json_object_put(owned); + return MUNIT_OK; +} + +// owned rows sort before non-owned; envelope carries schema + counts. +static MunitResult test_sort_and_envelope(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *browse = parse( + "[{\"productId\":\"PPSA-ZEBRA_00\",\"name\":\"Zebra\",\"device\":[\"PS5\"],\"streamingSupported\":true,\"conceptId\":1}," + " {\"productId\":\"PPSA-APPLE_00\",\"name\":\"Apple\",\"device\":[\"PS5\"],\"streamingSupported\":true,\"conceptId\":2}]"); + struct json_object *owned = parse( + "[{\"serviceType\":\"pscloud\",\"id\":\"PPSA-ZEBRA_00\",\"product_id\":\"PPSA-ZEBRA_00\",\"conceptId\":1,\"feature_type\":3,\"game_meta\":{\"name\":\"Zebra\"}}]"); + + CCAssembleInput in = { 0 }; + in.imagic_browse = browse; + in.owned_cross_ref = owned; + in.native_mode = false; + in.fallback_region = ""; + in.settled_locale = "en-US"; + + struct json_object *env = cc_assemble_unified_catalog(get_test_log(), &in); + struct json_object *games = games_of(env); + + munit_assert_int(json_object_get_int(json_object_object_get(env, "schemaVersion")), ==, CHIAKI_CLOUDCATALOG_SCHEMA_VERSION); + munit_assert_int(json_object_get_int(json_object_object_get(env, "total")), ==, (int)json_object_array_length(games)); + munit_assert_string_equal(cc_json_str(env, "settledLocale"), "en-US"); + + // Owned Zebra sorts before unowned Apple despite alphabetical order. + munit_assert_true(cc_json_bool(json_object_array_get_idx(games, 0), "isOwned")); + munit_assert_string_equal(cc_json_str(json_object_array_get_idx(games, 0), "name"), "Zebra"); + + munit_assert_int(count_cat(games, "owned"), ==, 1); + munit_assert_int(count_cat(games, "purchaseable"), ==, 1); + + json_object_put(env); + json_object_put(browse); + json_object_put(owned); + return MUNIT_OK; +} + +// Cloud streaming language / datacenter helpers (cross-platform source of truth). +static MunitResult test_cloud_language_helpers(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + char buf[16]; + + // Gaikai wants the bare lowercase language code, not the full locale. + chiaki_cloud_gaikai_language("de-DE", buf, sizeof(buf)); + munit_assert_string_equal(buf, "de"); + chiaki_cloud_gaikai_language("en-US", buf, sizeof(buf)); + munit_assert_string_equal(buf, "en"); + chiaki_cloud_gaikai_language("pt_BR", buf, sizeof(buf)); + munit_assert_string_equal(buf, "pt"); + chiaki_cloud_gaikai_language("FR", buf, sizeof(buf)); + munit_assert_string_equal(buf, "fr"); + chiaki_cloud_gaikai_language("", buf, sizeof(buf)); + munit_assert_string_equal(buf, "en"); + chiaki_cloud_gaikai_language(NULL, buf, sizeof(buf)); + munit_assert_string_equal(buf, "en"); + + // Datacenter region prefix match (incl. multi-locale Stockholm). + munit_assert_true(chiaki_cloud_datacenter_serves_locale("fraa", "de-DE")); + munit_assert_true(chiaki_cloud_datacenter_serves_locale("frab", "de-DE")); + munit_assert_true(chiaki_cloud_datacenter_serves_locale("stoa", "fi-FI")); + munit_assert_true(chiaki_cloud_datacenter_serves_locale("stoa", "en-GB")); + munit_assert_true(chiaki_cloud_datacenter_serves_locale("FRAA", "de-de")); // case-insensitive + munit_assert_false(chiaki_cloud_datacenter_serves_locale("fraa", "fi-FI")); + munit_assert_false(chiaki_cloud_datacenter_serves_locale("", "de-DE")); + munit_assert_false(chiaki_cloud_datacenter_serves_locale("fraa", "")); + + // Supported locale enumeration. + size_t n = chiaki_cloud_supported_locale_count(); + munit_assert_int((int)n, >=, 5); + bool seen_de = false, seen_en_us = false; + for(size_t i = 0; i < n; i++) + { + const char *l = chiaki_cloud_supported_locale(i); + if(strcmp(l, "de-DE") == 0) + seen_de = true; + if(strcmp(l, "en-US") == 0) + seen_en_us = true; + } + munit_assert_true(seen_de); + munit_assert_true(seen_en_us); + munit_assert_string_equal(chiaki_cloud_supported_locale(n), ""); // out of range + + return MUNIT_OK; +} + +MunitTest tests_cloudcatalog_merge[] = { + { "/apollo_dedup", test_apollo_dedup, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/device_based_ps5", test_device_based_ps5, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/crossbuy_and_trial", test_crossbuy_and_trial, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/crossbuy_sku_sibling", test_crossbuy_sku_sibling, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/sort_and_envelope", test_sort_and_envelope, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/cloud_language_helpers", test_cloud_language_helpers, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL } +}; diff --git a/test/main.c b/test/main.c index 132b4e64..1994c4af 100644 --- a/test/main.c +++ b/test/main.c @@ -12,6 +12,7 @@ extern MunitTest tests_takion[]; extern MunitTest tests_fec[]; extern MunitTest tests_regist[]; extern MunitTest tests_bitstream[]; +extern MunitTest tests_cloudcatalog_merge[]; static MunitSuite suites[] = { { @@ -84,6 +85,13 @@ static MunitSuite suites[] = { 1, MUNIT_SUITE_OPTION_NONE }, + { + "/cloudcatalog_merge", + tests_cloudcatalog_merge, + NULL, + 1, + MUNIT_SUITE_OPTION_NONE + }, { NULL, NULL, NULL, 0, MUNIT_SUITE_OPTION_NONE } }; From 26c3a5564eaeacf390da4399fafeaaa6967460f9 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Fri, 26 Jun 2026 23:08:31 -0700 Subject: [PATCH 13/72] Streaming stats overlay + Android Cloud Play card polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in on-screen streaming stats overlay (bitrate, packet loss, dropped frames/sec, FPS, live RTT, resolution) across Qt, Android, and iOS. All values are computed in libchiaki and read via a single getter (Android JNI sessionGetMetrics, iOS ChiakiSessionBridge metrics helper), so clients only render — no per-frame instrumentation. FPS/RTT use an EMA in libchiaki to keep the readout stable. The overlay is a single top-centered row toggled from the in-stream menu, with a light translucent background matching across platforms. Android Cloud Play polish: per-platform neon badge (ps5 blue / ps4 indigo / ps3 purple) with glow to match iOS; lighter, correctly oriented bottom gradient behind the title; and a landscape card fix so a single tap launches a game (removed stray focusableInTouchMode that forced a focus-then-activate two-tap; TV still works via the programmatic enableFocusableInTouchModeForTv path). Co-authored-by: Cursor --- android/app/src/main/cpp/chiaki-jni.c | 41 +++++ .../com/metallic/chiaki/common/Preferences.kt | 6 + .../java/com/metallic/chiaki/lib/Chiaki.kt | 33 ++++ .../metallic/chiaki/main/CloudGameAdapter.kt | 33 ++++ .../metallic/chiaki/session/StreamSession.kt | 3 + .../metallic/chiaki/stream/StreamActivity.kt | 90 +++++++++ .../main/res/drawable/gradient_overlay.xml | 8 +- .../main/res/layout-land/item_cloud_game.xml | 5 +- .../src/main/res/layout/activity_stream.xml | 35 +++- .../src/main/res/layout/item_cloud_game.xml | 4 +- android/app/src/main/res/values/colors.xml | 2 + android/app/src/main/res/values/strings.xml | 1 + gui/include/streamsession.h | 31 ++++ gui/src/qml/StreamView.qml | 171 +++++++++++------- gui/src/streamsession.cpp | 21 +++ ios/Pylux/Bridge/ChiakiSessionBridge.h | 21 +++ ios/Pylux/Bridge/ChiakiSessionBridge.m | 12 ++ ios/Pylux/Services/StreamSession.swift | 28 +++ ios/Pylux/Views/SettingsView.swift | 2 + ios/Pylux/Views/StreamView.swift | 66 +++++++ lib/include/chiaki/ios_bridge_helpers.h | 8 + lib/include/chiaki/streamconnection.h | 13 ++ lib/include/chiaki/videoreceiver.h | 1 + lib/src/ios_bridge_helpers.c | 40 ++++ lib/src/streamconnection.c | 51 ++++++ lib/src/videoreceiver.c | 2 + 26 files changed, 649 insertions(+), 79 deletions(-) diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index efd08204..fba38196 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -706,6 +706,47 @@ JNIEXPORT void JNICALL JNI_FCN(sessionSetSurface)(JNIEnv *env, jobject obj, jlon android_chiaki_video_decoder_set_surface(&session->video_decoder, env, surface); } +// Live stream metrics for the optional on-screen stats overlay. All values are +// owned/computed by libchiaki (shared with Qt/iOS) so the client just renders +// them. Returns a double[7]: +// [0] bitrate (Mbit/s) [1] packet loss (0..1) [2] dropped frames (cumulative) +// [3] fps [4] rtt (ms) [5] width [6] height +// Cheap best-effort read with no locking (same as Qt's polling timer); only +// called while a session is live and the overlay is toggled on. +JNIEXPORT jdoubleArray JNICALL JNI_FCN(sessionGetMetrics)(JNIEnv *env, jobject obj, jlong ptr) +{ + jdouble vals[7] = { 0 }; + AndroidChiakiSession *session = (AndroidChiakiSession *)ptr; + if(session) + { + ChiakiStreamConnection *sc = &session->session.stream_connection; + vals[0] = sc->measured_bitrate; + vals[1] = sc->congestion_control.packet_loss; + vals[3] = sc->measured_fps; + vals[4] = sc->measured_rtt_ms; + ChiakiVideoReceiver *vr = sc->video_receiver; + if(vr) + { + vals[2] = (jdouble)vr->cumulative_frames_lost; + if(vr->profile_cur >= 0 && (size_t)vr->profile_cur < vr->profiles_count) + { + vals[5] = (jdouble)vr->profiles[vr->profile_cur].width; + vals[6] = (jdouble)vr->profiles[vr->profile_cur].height; + } + } + // Fall back to the requested profile before the first adaptive profile is selected. + if(vals[5] == 0 || vals[6] == 0) + { + vals[5] = (jdouble)session->session.connect_info.video_profile.width; + vals[6] = (jdouble)session->session.connect_info.video_profile.height; + } + } + jdoubleArray arr = E->NewDoubleArray(env, 7); + if(arr) + E->SetDoubleArrayRegion(env, arr, 0, 7, vals); + return arr; +} + JNIEXPORT void JNICALL JNI_FCN(sessionSetControllerState)(JNIEnv *env, jobject obj, jlong ptr, jobject controller_state_java) { AndroidChiakiSession *session = (AndroidChiakiSession *)ptr; diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index f885a3da..eaf37362 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -115,6 +115,12 @@ class Preferences(context: Context) get() = sharedPreferences.getBoolean(pipEnabledKey, true) set(value) { sharedPreferences.edit().putBoolean(pipEnabledKey, value).apply() } + /** Whether the in-stream performance stats overlay is toggled on (per-session UI state). */ + private val STREAM_STATS_OVERLAY_KEY = "stream_stats_overlay_enabled" + var streamStatsOverlayEnabled + get() = sharedPreferences.getBoolean(STREAM_STATS_OVERLAY_KEY, false) + set(value) { sharedPreferences.edit().putBoolean(STREAM_STATS_OVERLAY_KEY, value).apply() } + val swapCrossMoonKey get() = resources.getString(R.string.preferences_swap_cross_moon_key) var swapCrossMoon get() = sharedPreferences.getBoolean(swapCrossMoonKey, false) diff --git a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt index 03c8277c..b586c129 100644 --- a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt +++ b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt @@ -101,6 +101,34 @@ data class PsnDevice( val isPS5: Boolean get() = type == 1 } +/** + * Snapshot of the live stream stats for the on-screen overlay. Every value is + * computed in libchiaki (shared with Qt/iOS) — the client only renders them. + */ +data class StreamMetrics( + val bitrateMbps: Double, + val packetLoss: Double, // 0..1 + val droppedFrames: Long, + val fps: Double, + val rttMs: Double, + val width: Int, + val height: Int +) +{ + companion object + { + fun fromArray(a: DoubleArray): StreamMetrics = StreamMetrics( + bitrateMbps = a.getOrElse(0) { 0.0 }, + packetLoss = a.getOrElse(1) { 0.0 }, + droppedFrames = a.getOrElse(2) { 0.0 }.toLong(), + fps = a.getOrElse(3) { 0.0 }, + rttMs = a.getOrElse(4) { 0.0 }, + width = a.getOrElse(5) { 0.0 }.toInt(), + height = a.getOrElse(6) { 0.0 }.toInt() + ) + } +} + private class ChiakiNative { data class CreateResult(var errorCode: Int, var ptr: Long) @@ -121,6 +149,7 @@ private class ChiakiNative @JvmStatic external fun sessionStop(ptr: Long): Int @JvmStatic external fun sessionJoin(ptr: Long): Int @JvmStatic external fun sessionSetSurface(ptr: Long, surface: Surface?) + @JvmStatic external fun sessionGetMetrics(ptr: Long): DoubleArray @JvmStatic external fun sessionSetControllerState(ptr: Long, controllerState: ControllerState) @JvmStatic external fun sessionSetLoginPin(ptr: Long, pin: String) @JvmStatic external fun discoveryServiceCreate(result: CreateResult, options: DiscoveryServiceOptions, javaService: DiscoveryService) @@ -602,6 +631,10 @@ class Session(connectInfo: ConnectInfo, logFile: String?, logVerbose: Boolean) ChiakiNative.sessionSetSurface(nativePtr, surface) } + /** Latest live stream metrics for the stats overlay, or null if the session is gone. */ + fun getMetrics(): StreamMetrics? = + if(nativePtr == 0L) null else StreamMetrics.fromArray(ChiakiNative.sessionGetMetrics(nativePtr)) + fun setControllerState(controllerState: ControllerState) { ChiakiNative.sessionSetControllerState(nativePtr, controllerState) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt index 67546cb6..54e5a954 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt @@ -101,6 +101,38 @@ class CloudGameAdapter( binding.gameImageView.dispose() } + // Neon platform tag matching iOS: translucent dark fill, a glowing platform-colored + // outline, and a heavy white digit with a strong colored halo, so the badge reads as + // part of the app's electric theme (ps5 blue / ps4 indigo / ps3 purple). Android has + // no view-level outer glow like iOS's neon shadow, so we compensate with a heavier + // font (Roboto Black), a larger text glow radius, and a slightly darker fill + + // brighter/thicker outline to keep the digit just as legible over busy cover art. + private fun stylePlatformBadge(platform: String) + { + val tv = binding.gamePlatformTextView + val color = platformBadgeColor(platform) + val density = tv.resources.displayMetrics.density + val bg = android.graphics.drawable.GradientDrawable().apply { + shape = android.graphics.drawable.GradientDrawable.RECTANGLE + cornerRadius = 6f * density + setColor(0x80000000.toInt()) // black @ ~50% (vs iOS 40%) — Android lacks the outer glow, so a darker chip keeps contrast + setStroke((1.6f * density + 0.5f).toInt(), color) + } + tv.background = bg + tv.setTextColor(0xFFFFFFFF.toInt()) + // Roboto Black ≈ iOS .black weight (900). create(...) is cached by the framework. + tv.typeface = android.graphics.Typeface.create("sans-serif-black", android.graphics.Typeface.BOLD) + // Colored halo around the digit ≈ iOS neon glow (larger radius compensates for no rect glow). + tv.setShadowLayer(6f * density, 0f, 0f, color) + } + + private fun platformBadgeColor(platform: String): Int = when (platform.lowercase()) { + "ps5" -> 0xFF4D8CFF.toInt() // iOS (0.30, 0.55, 1.0) + "ps4" -> 0xFF6673F2.toInt() // iOS (0.40, 0.45, 0.95) + "ps3" -> 0xFFA666E6.toInt() // iOS (0.65, 0.40, 0.90) + else -> 0xFF9E9E9E.toInt() // gray + } + fun reloadImage(game: CloudGame) { if (game.imageUrl.isNotEmpty()) { @@ -130,6 +162,7 @@ class CloudGameAdapter( "ps5" -> "5" else -> game.platform.takeLast(1) } + stylePlatformBadge(game.platform) // Acquisition-tag badge (unified page): Owned (green) / Streamable (blue) / // Purchaseable (orange). The lib precomputes the category; render it verbatim. diff --git a/android/app/src/main/java/com/metallic/chiaki/session/StreamSession.kt b/android/app/src/main/java/com/metallic/chiaki/session/StreamSession.kt index a5bf3770..af7eb4bd 100644 --- a/android/app/src/main/java/com/metallic/chiaki/session/StreamSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/session/StreamSession.kt @@ -330,4 +330,7 @@ class StreamSession(val connectInfo: ConnectInfo, val logManager: LogManager, va { session?.setLoginPin(pin) } + + /** Latest live stream metrics (bitrate/loss/fps/rtt/resolution) for the stats overlay. */ + fun metrics(): StreamMetrics? = session?.getMetrics() } \ No newline at end of file diff --git a/android/app/src/main/java/com/metallic/chiaki/stream/StreamActivity.kt b/android/app/src/main/java/com/metallic/chiaki/stream/StreamActivity.kt index c2cbdde8..c5c1b12a 100644 --- a/android/app/src/main/java/com/metallic/chiaki/stream/StreamActivity.kt +++ b/android/app/src/main/java/com/metallic/chiaki/stream/StreamActivity.kt @@ -28,6 +28,7 @@ import com.metallic.chiaki.common.ext.viewModelFactory import com.pylux.stream.databinding.ActivityStreamBinding import com.metallic.chiaki.lib.ConnectInfo import com.metallic.chiaki.lib.ConnectVideoProfile +import com.metallic.chiaki.lib.StreamMetrics import com.metallic.chiaki.session.StreamStateConnected import com.metallic.chiaki.session.StreamStateConnecting import com.metallic.chiaki.session.StreamStateCreateError @@ -40,6 +41,7 @@ import com.metallic.chiaki.touchcontrols.TouchControlsFragment import com.metallic.chiaki.touchcontrols.TouchpadOnlyFragment import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.addTo +import java.util.Locale import kotlin.math.min private sealed class DialogContents @@ -53,6 +55,9 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe { const val EXTRA_CONNECT_INFO = "connect_info" private const val HIDE_UI_TIMEOUT_MS = 4000L + // libchiaki refreshes all overlay metrics once per second (from the periodic + // CONNECTIONQUALITY message), so polling faster only re-reads stale values. + private const val STATS_POLL_INTERVAL_MS = 1000L } private lateinit var viewModel: StreamViewModel @@ -60,6 +65,21 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe private val uiVisibilityHandler = Handler() + /** Lightweight poll that refreshes the stats overlay only while it is toggled on + * and the session is connected. Reposts itself; no work happens when stopped. */ + private val statsHandler = Handler(Looper.getMainLooper()) + private var statsPolling = false + /** Previous cumulative dropped-frame total, so the overlay can show drops *this tick* + * (per poll = per second) instead of an ever-climbing lifetime total. -1 = uninitialized. */ + private var lastDroppedFrames = -1L + private val statsRunnable = object : Runnable { + override fun run() { + updateStatsOverlay() + if (statsPolling) + statsHandler.postDelayed(this, STATS_POLL_INTERVAL_MS) + } + } + /** Tracks whether the activity is in the stopped state (between onStop and onStart). * Used to detect PiP dismissal: onStop fires while pip=true (so cleanup is skipped), * then onPictureInPictureModeChanged(false) fires — at that point we check this @@ -129,6 +149,14 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe finish() } + // Performance stats overlay toggle (mirrors Qt's in-stream stats overlay). + binding.statsSwitch.isChecked = viewModel.preferences.streamStatsOverlayEnabled + binding.statsSwitch.setOnCheckedChangeListener { _, isChecked -> + viewModel.preferences.streamStatsOverlayEnabled = isChecked + updateStatsVisibility() + showOverlay() + } + // Handle back button — on TV show a disconnect confirmation dialog; on touch show the overlay onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -200,6 +228,7 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe // resume() is safe to call even if session is already running - // it returns immediately when session != null viewModel.session.resume() + updateStatsVisibility() } override fun onPause() @@ -213,6 +242,7 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe viewModel.session.skipNativeSurfaceCleanup = false viewModel.session.pause() } + stopStatsPolling() } override fun onStop() @@ -238,6 +268,7 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe donationCoordinator.onDestroy() controlsDisposable.dispose() uiVisibilityHandler.removeCallbacksAndMessages(null) + stopStatsPolling() } override fun onConfigurationChanged(newConfig: Configuration) @@ -318,6 +349,7 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe viewModel.setOnScreenControlsEnabled(false) viewModel.setTouchpadOnlyEnabled(false) binding.progressBar.isGone = true + updateStatsVisibility() } else { @@ -338,6 +370,7 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe viewModel.setTouchpadOnlyEnabled(savedTouchpadOnlyEnabled) hideOverlay() hideSystemUI() + updateStatsVisibility() } } } @@ -393,6 +426,62 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe }) } + /** Show/hide the stats overlay and start/stop polling based on the toggle, + * connection state and PiP. Safe to call from any state transition. */ + private fun updateStatsVisibility() + { + val show = binding.statsSwitch.isChecked + && viewModel.session.state.value == StreamStateConnected + && !isInPictureInPictureMode + if (show) + { + binding.statsOverlay.isVisible = true + if (!statsPolling) + { + statsPolling = true + lastDroppedFrames = -1L // reset so the first tick reads 0, not the lifetime total + statsHandler.post(statsRunnable) + } + } + else + { + stopStatsPolling() + binding.statsOverlay.isGone = true + } + } + + private fun stopStatsPolling() + { + statsPolling = false + statsHandler.removeCallbacks(statsRunnable) + } + + private fun updateStatsOverlay() + { + val m = viewModel.session.metrics() ?: return + // Drops since the previous tick (≈ per second), not the lifetime total. + val dropsPerTick = if (lastDroppedFrames < 0L) 0L + else (m.droppedFrames - lastDroppedFrames).coerceAtLeast(0L) + lastDroppedFrames = m.droppedFrames + binding.statsOverlay.text = formatStats(m, dropsPerTick) + } + + /** Single compact top row with short labels, e.g. + * "4.7 Mbps • PL 1.1% • DF/s 0 • 60 FPS • 90 ms • 1280×720". */ + private fun formatStats(m: StreamMetrics, dropsPerTick: Long): String + { + val sep = " • " + val parts = mutableListOf() + parts.add(String.format(Locale.US, "%.1f Mbps", m.bitrateMbps)) + parts.add(String.format(Locale.US, "PL %.1f%%", m.packetLoss * 100.0)) + parts.add("DF/s $dropsPerTick") + parts.add(String.format(Locale.US, "%.0f FPS", m.fps)) + if (m.rttMs > 0) + parts.add(String.format(Locale.US, "%.0f ms", m.rttMs)) + parts.add("${m.width}×${m.height}") + return parts.joinToString(sep) + } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) @@ -432,6 +521,7 @@ class StreamActivity : AppCompatActivity(), View.OnSystemUiVisibilityChangeListe { Log.i("StreamActivity", "stateChanged: $state pip=$isInPictureInPictureMode") binding.progressBar.visibility = if(state == StreamStateConnecting) View.VISIBLE else View.GONE + updateStatsVisibility() when(state) { diff --git a/android/app/src/main/res/drawable/gradient_overlay.xml b/android/app/src/main/res/drawable/gradient_overlay.xml index 110ef158..01da2d2b 100644 --- a/android/app/src/main/res/drawable/gradient_overlay.xml +++ b/android/app/src/main/res/drawable/gradient_overlay.xml @@ -1,9 +1,11 @@ + diff --git a/android/app/src/main/res/layout-land/item_cloud_game.xml b/android/app/src/main/res/layout-land/item_cloud_game.xml index dd98cf6e..d218f7e4 100644 --- a/android/app/src/main/res/layout-land/item_cloud_game.xml +++ b/android/app/src/main/res/layout-land/item_cloud_game.xml @@ -9,7 +9,6 @@ app:cardElevation="2dp" android:clickable="true" android:focusable="true" - android:focusableInTouchMode="true" android:foreground="?android:attr/selectableItemBackground"> diff --git a/android/app/src/main/res/layout/activity_stream.xml b/android/app/src/main/res/layout/activity_stream.xml index 82d2a5ef..77a94e93 100644 --- a/android/app/src/main/res/layout/activity_stream.xml +++ b/android/app/src/main/res/layout/activity_stream.xml @@ -75,6 +75,17 @@ app:layout_constraintStart_toStartOf="parent" app:switchPadding="8dp" /> + + - + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/item_cloud_game.xml b/android/app/src/main/res/layout/item_cloud_game.xml index 4835272a..8ae4da66 100644 --- a/android/app/src/main/res/layout/item_cloud_game.xml +++ b/android/app/src/main/res/layout/item_cloud_game.xml @@ -69,7 +69,7 @@ android:background="@drawable/gradient_overlay" android:paddingStart="8dp" android:paddingEnd="8dp" - android:paddingTop="8dp" + android:paddingTop="28dp" android:paddingBottom="8dp"> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 5765c3f7..bc26de05 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -9,6 +9,8 @@ @android:color/white @android:color/black #77000000 + + #66000000 #4A9EFF #ffffff diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 532e7283..0dee0670 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Quit Connect Disconnect + Stats Settings Discover Consoles Automatically Address: %s diff --git a/gui/include/streamsession.h b/gui/include/streamsession.h index 059ee77c..b0388379 100755 --- a/gui/include/streamsession.h +++ b/gui/include/streamsession.h @@ -160,6 +160,9 @@ class StreamSession : public QObject Q_PROPERTY(bool connected READ GetConnected NOTIFY ConnectedChanged) Q_PROPERTY(double measuredBitrate READ GetMeasuredBitrate NOTIFY MeasuredBitrateChanged) Q_PROPERTY(double averagePacketLoss READ GetAveragePacketLoss NOTIFY AveragePacketLossChanged) + Q_PROPERTY(double measuredFps READ GetMeasuredFps NOTIFY MeasuredFpsChanged) + Q_PROPERTY(double measuredRtt READ GetMeasuredRtt NOTIFY MeasuredRttChanged) + Q_PROPERTY(QString resolution READ GetResolution NOTIFY ResolutionChanged) Q_PROPERTY(bool muted READ GetMuted WRITE SetMuted NOTIFY MutedChanged) Q_PROPERTY(bool cantDisplay READ GetCantDisplay NOTIFY CantDisplayChanged) Q_PROPERTY(QString loadingMessage READ GetLoadingMessage WRITE SetLoadingMessage NOTIFY LoadingMessageChanged) @@ -190,6 +193,9 @@ class StreamSession : public QObject int audio_volume; double measured_bitrate = 0; double average_packet_loss = 0; + double measured_fps = 0; + double measured_rtt = 0; + QString resolution_str; QList packet_loss_history; bool cant_display = false; QString loading_message; @@ -336,6 +342,28 @@ class StreamSession : public QObject bool GetConnected() { return connected; } double GetMeasuredBitrate() { return measured_bitrate; } double GetAveragePacketLoss() { return average_packet_loss; } + double GetMeasuredFps() { return measured_fps; } + double GetMeasuredRtt() { return measured_rtt; } + QString GetResolution() { + // Use the resolution the video receiver is actually decoding (the + // negotiated/adaptive profile from the server's stream-info), not the + // requested connect_info profile — the cloud server may encode lower + // than requested. Fall back to the requested profile before the first + // frame selects an adaptive profile. + int w = 0, h = 0; + ChiakiVideoReceiver *vr = session.stream_connection.video_receiver; + if(vr && vr->profile_cur >= 0 && (size_t)vr->profile_cur < vr->profiles_count) + { + w = (int)vr->profiles[vr->profile_cur].width; + h = (int)vr->profiles[vr->profile_cur].height; + } + else + { + w = session.connect_info.video_profile.width; + h = session.connect_info.video_profile.height; + } + return (w > 0 && h > 0) ? QStringLiteral("%1x%2").arg(w).arg(h) : QString(); + } bool GetMuted() { return muted; } void SetMuted(bool enable) { if (enable != muted) ToggleMute(); } Q_INVOKABLE int GetAudioVolume() { return audio_volume; } @@ -387,6 +415,9 @@ class StreamSession : public QObject void ConnectedChanged(); void MeasuredBitrateChanged(); void AveragePacketLossChanged(); + void MeasuredFpsChanged(); + void MeasuredRttChanged(); + void ResolutionChanged(); void MutedChanged(); void CantDisplayChanged(bool cant_display); void LoadingMessageChanged(); diff --git a/gui/src/qml/StreamView.qml b/gui/src/qml/StreamView.qml index 2e570ac5..e6350ec7 100644 --- a/gui/src/qml/StreamView.qml +++ b/gui/src/qml/StreamView.qml @@ -345,90 +345,98 @@ Item { id: streamStats anchors.fill: parent visible: Chiaki.settings.showStreamStats && !menuView.visible && !sessionLoading && !sessionError && !(Chiaki.settings.audioVideoDisabled & 0x02) - Label { + + // Single bottom-right anchored column that grows UPWARD, so adding rows can + // never push the stats off the bottom of the screen. + ColumnLayout { anchors { - right: statsConsoleNameLabel.right - bottom: statsConsoleNameLabel.top - bottomMargin: 5 + right: parent.right + bottom: parent.bottom rightMargin: 5 - + bottomMargin: 30 } - text: "Mbps" - font.pixelSize: 18 - visible: Chiaki.session + spacing: 2 Label { - anchors { - right: parent.left - baseline: parent.baseline - rightMargin: 5 + Layout.alignment: Qt.AlignRight + text: "Mbps" + font.pixelSize: 18 + visible: Chiaki.session + Label { + anchors { right: parent.left; baseline: parent.baseline; rightMargin: 5 } + text: visible ? Chiaki.session.measuredBitrate.toFixed(1) : "" + color: Material.accent + font.bold: true + font.pixelSize: 28 } - text: visible ? Chiaki.session.measuredBitrate.toFixed(1) : "" - color: Material.accent - font.bold: true - font.pixelSize: 28 } - } - Label { - id: statsConsoleNameLabel - anchors { - right: parent.right - bottom: parent.bottom - bottomMargin: 30 - } - ColumnLayout { - anchors { - right: parent.right - top: parent.top - bottom: parent.bottom - rightMargin: 5 + Label { + Layout.alignment: Qt.AlignRight + id: statsPacketLossLabel + text: qsTr("packet loss") + font.pixelSize: 15 + Label { + anchors { right: parent.left; baseline: parent.baseline; rightMargin: 5 } + text: parent.visible ? "%1%".arg((Chiaki.session?.averagePacketLoss * 100).toFixed(1)) : "" + font.bold: true + color: "#ef9a9a" // Material.Red + font.pixelSize: 18 } - RowLayout { - Layout.alignment: Qt.AlignRight - Label { - id: statsPacketLossLabel - text: qsTr("packet loss") - font.pixelSize: 15 - opacity: parent.visible - visible: opacity - - Behavior on opacity { NumberAnimation { duration: 250 } } - - Label { - anchors { - right: parent.left - baseline: parent.baseline - rightMargin: 5 - } - text: visible ? "%1%".arg((Chiaki.session?.averagePacketLoss * 100).toFixed(1)) : "" - font.bold: true - color: "#ef9a9a" // Material.Red - font.pixelSize: 18 - } - } + } + + Label { + Layout.alignment: Qt.AlignRight + text: qsTr("dropped frames") + font.pixelSize: 15 + Label { + id: statsDroppedFramesLabel + anchors { right: parent.left; baseline: parent.baseline; rightMargin: 5 } + text: parent.visible ? Chiaki.window.droppedFrames : "" + color: "#ef9a9a" // Material.Red + font.bold: true + font.pixelSize: 18 } + } + Label { + Layout.alignment: Qt.AlignRight + text: qsTr("fps") + font.pixelSize: 15 Label { - text: qsTr("dropped frames") - font.pixelSize: 15 - opacity: parent.visible - visible: opacity + anchors { right: parent.left; baseline: parent.baseline; rightMargin: 5 } + text: parent.visible ? (Chiaki.session?.measuredFps ?? 0).toFixed(0) : "" + color: Material.accent + font.bold: true + font.pixelSize: 18 + } + } - Behavior on opacity { NumberAnimation { duration: 250 } } + Label { + Layout.alignment: Qt.AlignRight + text: qsTr("ms rtt") + font.pixelSize: 15 + visible: (Chiaki.session?.measuredRtt ?? 0) > 0 + Label { + anchors { right: parent.left; baseline: parent.baseline; rightMargin: 5 } + text: parent.visible ? (Chiaki.session?.measuredRtt ?? 0).toFixed(0) : "" + color: Material.accent + font.bold: true + font.pixelSize: 18 + } + } - Label { - id: statsDroppedFramesLabel - anchors { - right: parent.left - baseline: parent.baseline - rightMargin: 5 - } - text: visible ? Chiaki.window.droppedFrames : "" - color: "#ef9a9a" // Material.Red - font.bold: true - font.pixelSize: 18 - } + Label { + Layout.alignment: Qt.AlignRight + text: qsTr("res") + font.pixelSize: 15 + visible: (Chiaki.session?.resolution ?? "") !== "" + Label { + anchors { right: parent.left; baseline: parent.baseline; rightMargin: 5 } + text: parent.visible ? (Chiaki.session?.resolution ?? "") : "" + color: "white" + font.bold: true + font.pixelSize: 18 } } } @@ -816,6 +824,29 @@ Item { font.pixelSize: 18 } } + + Label { + Layout.leftMargin: fpsMenuLabel.width + 6 + text: qsTr("fps") + font.pixelSize: 15 + opacity: parent.visible ? 1.0 : 0.0 + visible: opacity + + Behavior on opacity { NumberAnimation { duration: 250 } } + + Label { + id: fpsMenuLabel + anchors { + right: parent.left + baseline: parent.baseline + rightMargin: 5 + } + text: visible ? (Chiaki.session?.measuredFps ?? 0).toFixed(0) : "" + color: Material.accent + font.bold: true + font.pixelSize: 18 + } + } } } } diff --git a/gui/src/streamsession.cpp b/gui/src/streamsession.cpp index 1eb3bef4..d429302f 100755 --- a/gui/src/streamsession.cpp +++ b/gui/src/streamsession.cpp @@ -580,6 +580,27 @@ StreamSession::StreamSession(const StreamSessionConnectInfo &connect_info, QObje average_packet_loss = packet_loss; emit AveragePacketLossChanged(); } + + // FPS and live RTT are computed in libchiaki from the periodic + // CONNECTIONQUALITY message; just surface the latest values for the overlay. + double fps = session.stream_connection.measured_fps; + if(fps != measured_fps) + { + measured_fps = fps; + emit MeasuredFpsChanged(); + } + double rtt = session.stream_connection.measured_rtt_ms; + if(rtt != measured_rtt) + { + measured_rtt = rtt; + emit MeasuredRttChanged(); + } + QString res = GetResolution(); + if(res != resolution_str) + { + resolution_str = res; + emit ResolutionChanged(); + } }); // Initialize GameLauncher if game_name is set diff --git a/ios/Pylux/Bridge/ChiakiSessionBridge.h b/ios/Pylux/Bridge/ChiakiSessionBridge.h index 67827c6e..18cb031a 100644 --- a/ios/Pylux/Bridge/ChiakiSessionBridge.h +++ b/ios/Pylux/Bridge/ChiakiSessionBridge.h @@ -150,6 +150,27 @@ void chiaki_session_bridge_set_video_sample_cb(ChiakiSessionRef ref, bool (*cb)(uint8_t *buf, size_t buf_size, int32_t frames_lost, bool frame_recovered, void *user), void *user); +/** + * Live stream metrics for the on-screen stats overlay. All values are computed in + * libchiaki (shared with Qt/Android); Swift just renders them. Defined here (not in + * libchiaki) so the Xcode-compiled app and bridge agree on layout. + */ +typedef struct ChiakiSessionBridgeMetrics { + double bitrate_mbps; + double packet_loss; // 0..1 + uint64_t dropped_frames; // cumulative for the session + double fps; + double rtt_ms; + int width; + int height; +} ChiakiSessionBridgeMetrics; + +/** + * Fill *out with the latest live stream metrics. Cheap best-effort read (no locking), + * safe to call while the session is live. Zeroes *out if ref is NULL. + */ +void chiaki_session_bridge_get_metrics(ChiakiSessionRef ref, ChiakiSessionBridgeMetrics *out); + /** * Helpers for error/quit strings. */ diff --git a/ios/Pylux/Bridge/ChiakiSessionBridge.m b/ios/Pylux/Bridge/ChiakiSessionBridge.m index 2f4ff965..8e140f83 100644 --- a/ios/Pylux/Bridge/ChiakiSessionBridge.m +++ b/ios/Pylux/Bridge/ChiakiSessionBridge.m @@ -303,6 +303,18 @@ int chiaki_session_bridge_set_controller_state(ChiakiSessionRef ref, const void return (int)chiaki_session_set_controller_state(((iOSChiakiSession *)ref)->session, (ChiakiControllerState *)state); } +void chiaki_session_bridge_get_metrics(ChiakiSessionRef ref, ChiakiSessionBridgeMetrics *out) +{ + if (!out) return; + memset(out, 0, sizeof(*out)); + if (!ref) return; + iOSChiakiSession *s = (iOSChiakiSession *)ref; + if (!s->session) return; + chiaki_session_get_stream_metrics_ex(s->session, + &out->bitrate_mbps, &out->packet_loss, &out->dropped_frames, + &out->fps, &out->rtt_ms, &out->width, &out->height); +} + int chiaki_session_bridge_set_login_pin(ChiakiSessionRef ref, const uint8_t *pin, size_t pin_size) { if (!ref) return CHIAKI_ERR_UNINITIALIZED; diff --git a/ios/Pylux/Services/StreamSession.swift b/ios/Pylux/Services/StreamSession.swift index fa762074..6d3d9d1a 100644 --- a/ios/Pylux/Services/StreamSession.swift +++ b/ios/Pylux/Services/StreamSession.swift @@ -48,6 +48,18 @@ struct StreamConnectInfo: Identifiable { } } +/// Snapshot of the live stream stats for the on-screen overlay. Every value is +/// computed in libchiaki (shared with Qt/Android) — the client only renders them. +struct StreamMetrics { + let bitrateMbps: Double + let packetLoss: Double // 0..1 + let droppedFrames: UInt64 + let fps: Double + let rttMs: Double + let width: Int + let height: Int +} + @MainActor final class StreamSession: ObservableObject { @Published private(set) var state: StreamState = .idle @@ -170,6 +182,22 @@ final class StreamSession: ObservableObject { _ = chiaki_session_bridge_set_login_pin(ref, pinBytes, pinBytes.count) } + /// Latest live stream metrics for the stats overlay, or nil if no session is active. + func metrics() -> StreamMetrics? { + guard let ref = sessionRef else { return nil } + var m = ChiakiSessionBridgeMetrics() + chiaki_session_bridge_get_metrics(ref, &m) + return StreamMetrics( + bitrateMbps: m.bitrate_mbps, + packetLoss: m.packet_loss, + droppedFrames: m.dropped_frames, + fps: m.fps, + rttMs: m.rtt_ms, + width: Int(m.width), + height: Int(m.height) + ) + } + /// Attach display layer for video output. Call when view is ready and again when session connects. /// Stores view so we can attach when decoder becomes available (matches Android's stored surface). func attachToView(_ view: StreamVideoUIView) { diff --git a/ios/Pylux/Views/SettingsView.swift b/ios/Pylux/Views/SettingsView.swift index 1f71275e..91d477ce 100644 --- a/ios/Pylux/Views/SettingsView.swift +++ b/ios/Pylux/Views/SettingsView.swift @@ -70,6 +70,8 @@ struct StreamPreferences: Codable { var onScreenControlsEnabled: Bool = true /// Stream overlay: touchpad-only strip (matches Android `touchpadOnlyEnabled`, default false) var touchpadOnlyEnabled: Bool = false + /// In-stream performance stats overlay toggle (matches Android `streamStatsOverlayEnabled`, default false) + var streamStatsOverlayEnabled: Bool = false // Cloud Game Library (PSCloud) var cloudResolutionPscloud: String = "720" // matches Android default diff --git a/ios/Pylux/Views/StreamView.swift b/ios/Pylux/Views/StreamView.swift index 4964ac18..600b1f8a 100644 --- a/ios/Pylux/Views/StreamView.swift +++ b/ios/Pylux/Views/StreamView.swift @@ -37,6 +37,11 @@ struct StreamView: View { @State private var displayMode: DisplayMode = .fit @State private var onScreenControls: Bool @State private var touchpadOnly: Bool + @State private var showStats: Bool + @State private var statsText: String = "" + /// Previous cumulative dropped-frame total so the overlay can show drops *this tick* + /// (per second) instead of a lifetime total. -1 = uninitialized. + @State private var lastDroppedFrames: Int64 = -1 @State private var showQuitAlert = false @State private var showErrorAlert = false @State private var errorMessage = "" @@ -56,6 +61,7 @@ struct StreamView: View { let fullOn = prefs.onScreenControlsEnabled _onScreenControls = State(initialValue: tpOnly ? false : fullOn) _touchpadOnly = State(initialValue: tpOnly) + _showStats = State(initialValue: prefs.streamStatsOverlayEnabled) _session = StateObject(wrappedValue: StreamSession(connectInfo: connectInfo, input: StreamInput())) } @@ -63,9 +69,32 @@ struct StreamView: View { var p = StreamPreferences.load() p.onScreenControlsEnabled = onScreenControls p.touchpadOnlyEnabled = touchpadOnly + p.streamStatsOverlayEnabled = showStats p.save() } + private var isConnected: Bool { + if case .connected = session.state { return true } + return false + } + + /// Single compact top row with short labels, e.g. + /// "4.7 Mbps • PL 1.1% • DF/s 0 • 60 FPS • 90 ms • 1280×720". + private func updateStats() { + // No native read unless the overlay is actually showing — zero cost when off. + guard showStats, isConnected, let m = session.metrics() else { return } + let drops: Int64 = lastDroppedFrames < 0 ? 0 : max(0, Int64(m.droppedFrames) - lastDroppedFrames) + lastDroppedFrames = Int64(m.droppedFrames) + var parts: [String] = [] + parts.append(String(format: "%.1f Mbps", m.bitrateMbps)) + parts.append(String(format: "PL %.1f%%", m.packetLoss * 100.0)) + parts.append("DF/s \(drops)") + parts.append(String(format: "%.0f FPS", m.fps)) + if m.rttMs > 0 { parts.append(String(format: "%.0f ms", m.rttMs)) } + parts.append("\(m.width)×\(m.height)") + statsText = parts.joined(separator: " • ") + } + var body: some View { ZStack { Color.black.ignoresSafeArea() @@ -96,6 +125,26 @@ struct StreamView: View { .ignoresSafeArea() .allowsHitTesting(true) + // Performance stats overlay: single top-centered row. Stays visible while + // toggled on and connected, independent of the auto-hiding control bar. + if showStats, isConnected { + VStack { + Text(statsText) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundColor(.white) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(Color.black.opacity(0.4)) + .cornerRadius(6) + .padding(.top, 1) + .allowsHitTesting(false) + Spacer() + } + .transition(.opacity) + } + // Bottom overlay bar (matches Android's stream overlay) if showOverlay { VStack { @@ -176,6 +225,15 @@ struct StreamView: View { if !on && !onScreenControls { session.input.clearTouchOverlayState() } persistStreamOverlayPreferences() } + .onChange(of: showStats) { on in + if on { lastDroppedFrames = -1 } // first tick reads 0, not the lifetime total + persistStreamOverlayPreferences() + } + // Refresh the overlay once per second to match libchiaki's CONNECTIONQUALITY + // cadence. The closure no-ops (no native read) unless the overlay is showing. + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in + updateStats() + } .onDisappear { session.input.clearTouchOverlayState() AppOrientationLock.unlockAfterStream() @@ -194,6 +252,7 @@ struct StreamView: View { if let view = videoHostView { session.attachToView(view) } + lastDroppedFrames = -1 // reset stats baseline for the new session donationCoordinator.markConnected() donationCoordinator.scheduleOfferIfEligible() case .quit(_, _): @@ -267,6 +326,13 @@ struct StreamView: View { .foregroundColor(.white) .fixedSize() + // Performance stats overlay toggle (matches Android's statsSwitch) + Toggle("Stats", isOn: $showStats) + .toggleStyle(.switch) + .font(.system(size: 13)) + .foregroundColor(.white) + .fixedSize() + Spacer() // Display mode toggle group (matches Android's displayModeToggle) diff --git a/lib/include/chiaki/ios_bridge_helpers.h b/lib/include/chiaki/ios_bridge_helpers.h index 2f03ada3..d729671d 100644 --- a/lib/include/chiaki/ios_bridge_helpers.h +++ b/lib/include/chiaki/ios_bridge_helpers.h @@ -53,6 +53,14 @@ CHIAKI_EXPORT void chiaki_session_set_cloud_port_ex(ChiakiSession *session, uint CHIAKI_EXPORT void chiaki_session_set_cloud_psn_wrapper_type_ex(ChiakiSession *session, uint8_t type); CHIAKI_EXPORT void chiaki_session_set_service_type_ex(ChiakiSession *session, ChiakiServiceType st); +// Live stream metrics for the on-screen stats overlay. All values are owned/computed +// by libchiaki (shared with Qt/Android) so Swift just renders them. Out-params are +// primitives (ABI-safe across the CMake/Xcode boundary); pass NULL for any you don't +// need. Cheap best-effort read with no locking (same as the other clients' polling). +CHIAKI_EXPORT void chiaki_session_get_stream_metrics_ex(ChiakiSession *session, + double *bitrate_mbps, double *packet_loss, uint64_t *dropped_frames, + double *fps, double *rtt_ms, int *width, int *height); + #ifdef __cplusplus } #endif diff --git a/lib/include/chiaki/streamconnection.h b/lib/include/chiaki/streamconnection.h index ba3bcefc..faff1d31 100644 --- a/lib/include/chiaki/streamconnection.h +++ b/lib/include/chiaki/streamconnection.h @@ -78,6 +78,19 @@ typedef struct chiaki_stream_connection_t char *remote_disconnect_reason; double measured_bitrate; + + /** + * Live stream metrics for an optional on-screen stats overlay. These are + * refreshed from the periodic CONNECTIONQUALITY message (same source as + * measured_bitrate) so every platform reads identical, libchiaki-owned values + * with no per-frame instrumentation. measured_fps is real frames/second over + * wall-clock; measured_rtt_ms is the server-reported live RTT (0 until first + * report). measured_loss is the server-reported cumulative lost-packet count. + */ + double measured_fps; + double measured_rtt_ms; + uint64_t measured_loss; + uint64_t connection_quality_last_us; // internal: timestamp of last CONNECTIONQUALITY, for FPS timing } ChiakiStreamConnection; CHIAKI_EXPORT ChiakiErrorCode chiaki_stream_connection_init(ChiakiStreamConnection *stream_connection, ChiakiSession *session, double packet_loss_max); diff --git a/lib/include/chiaki/videoreceiver.h b/lib/include/chiaki/videoreceiver.h index 6eae5b29..7d0a5388 100644 --- a/lib/include/chiaki/videoreceiver.h +++ b/lib/include/chiaki/videoreceiver.h @@ -31,6 +31,7 @@ typedef struct chiaki_video_receiver_t ChiakiPacketStats *packet_stats; int32_t frames_lost; + uint64_t cumulative_frames_lost; // running total for the stats overlay (never reset mid-session) int32_t reference_frames[16]; ChiakiBitstream bitstream; } ChiakiVideoReceiver; diff --git a/lib/src/ios_bridge_helpers.c b/lib/src/ios_bridge_helpers.c index b216bc38..d31e82eb 100644 --- a/lib/src/ios_bridge_helpers.c +++ b/lib/src/ios_bridge_helpers.c @@ -67,3 +67,43 @@ CHIAKI_EXPORT void chiaki_session_set_service_type_ex(ChiakiSession *session, Ch { session->service_type = st; } + +CHIAKI_EXPORT void chiaki_session_get_stream_metrics_ex(ChiakiSession *session, + double *bitrate_mbps, double *packet_loss, uint64_t *dropped_frames, + double *fps, double *rtt_ms, int *width, int *height) +{ + double v_bitrate = 0.0, v_loss = 0.0, v_fps = 0.0, v_rtt = 0.0; + uint64_t v_drops = 0; + int v_w = 0, v_h = 0; + if(session) + { + ChiakiStreamConnection *sc = &session->stream_connection; + v_bitrate = sc->measured_bitrate; + v_loss = sc->congestion_control.packet_loss; + v_fps = sc->measured_fps; + v_rtt = sc->measured_rtt_ms; + ChiakiVideoReceiver *vr = sc->video_receiver; + if(vr) + { + v_drops = vr->cumulative_frames_lost; + if(vr->profile_cur >= 0 && (size_t)vr->profile_cur < vr->profiles_count) + { + v_w = (int)vr->profiles[vr->profile_cur].width; + v_h = (int)vr->profiles[vr->profile_cur].height; + } + } + // Fall back to the requested profile before the first adaptive profile is selected. + if(v_w == 0 || v_h == 0) + { + v_w = (int)session->connect_info.video_profile.width; + v_h = (int)session->connect_info.video_profile.height; + } + } + if(bitrate_mbps) *bitrate_mbps = v_bitrate; + if(packet_loss) *packet_loss = v_loss; + if(dropped_frames) *dropped_frames = v_drops; + if(fps) *fps = v_fps; + if(rtt_ms) *rtt_ms = v_rtt; + if(width) *width = v_w; + if(height) *height = v_h; +} diff --git a/lib/src/streamconnection.c b/lib/src/streamconnection.c index e201e95c..bf5fada0 100644 --- a/lib/src/streamconnection.c +++ b/lib/src/streamconnection.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -34,6 +35,16 @@ #define HEARTBEAT_INTERVAL_MS 1000 +// Smoothing factor for the live stats-overlay metrics (RTT and FPS). The server +// reports CONNECTIONQUALITY roughly once per second, and the raw per-second RTT +// sample is very jittery (seen swinging ~10..256 ms second-to-second), so the +// HUD would flash alarming one-off spikes. We feed each new sample through an +// exponential moving average: value = a*sample + (1-a)*value. a=0.3 keeps a +// memory of ~6 samples (~6 s at 1 Hz) while still reacting to real degradation. +// Cost is a single multiply-add per (periodic) message, so it adds nothing per +// frame and nothing at all when the overlay is toggled off. +#define STREAM_STATS_EMA_ALPHA 0.3 + typedef enum { STATE_IDLE, @@ -67,6 +78,12 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_stream_connection_init(ChiakiStreamConnecti stream_connection->log = session->log; stream_connection->packet_loss_max = packet_loss_max; + stream_connection->measured_bitrate = 0.0; + stream_connection->measured_fps = 0.0; + stream_connection->measured_rtt_ms = 0.0; + stream_connection->measured_loss = 0; + stream_connection->connection_quality_last_us = 0; + stream_connection->ecdh_secret = NULL; stream_connection->gkcrypt_remote = NULL; stream_connection->gkcrypt_local = NULL; @@ -724,6 +741,40 @@ static void stream_connection_takion_data_idle(ChiakiStreamConnection *stream_co q.disable_upstream_audio, q.rtt, q.loss); stream_connection->measured_bitrate = chiaki_stream_stats_bitrate(&stream_connection->video_receiver->frame_processor.stream_stats, stream_connection->session->connect_info.video_profile.max_fps) / 1000000.0; CHIAKI_LOGV(stream_connection->log, "StreamConnection measured bitrate: %.4f MBit/s", stream_connection->measured_bitrate); + + // Real FPS over wall-clock since the previous CONNECTIONQUALITY message. + // frames is the count accumulated since the last reset (i.e. over this same + // window), so frames / elapsed_seconds is the actual delivered framerate. + // The instantaneous value is smoothed with the same EMA as RTT below so the + // overlay does not flicker. Cost is a single subtraction/divide/multiply-add + // per (periodic) message, so the stats overlay adds nothing per-frame. + { + uint64_t now_us = chiaki_time_now_monotonic_us(); + uint64_t frames = stream_connection->video_receiver->frame_processor.stream_stats.frames; + uint64_t last_us = stream_connection->connection_quality_last_us; + if(last_us != 0 && now_us > last_us) + { + double elapsed_s = (double)(now_us - last_us) / 1000000.0; + if(elapsed_s > 0.0) + { + double fps_sample = (double)frames / elapsed_s; + stream_connection->measured_fps = stream_connection->measured_fps > 0.0 + ? STREAM_STATS_EMA_ALPHA * fps_sample + (1.0 - STREAM_STATS_EMA_ALPHA) * stream_connection->measured_fps + : fps_sample; + } + } + stream_connection->connection_quality_last_us = now_us; + } + + // Live RTT/loss reported by the server. The protobuf rtt is already in + // milliseconds. The raw per-second sample is very jittery, so smooth it + // with an EMA (seeding directly on the first non-zero reading) for a stable + // overlay value. measured_loss is the server's cumulative lost-packet count. + stream_connection->measured_rtt_ms = (stream_connection->measured_rtt_ms > 0.0 && q.rtt > 0.0) + ? STREAM_STATS_EMA_ALPHA * q.rtt + (1.0 - STREAM_STATS_EMA_ALPHA) * stream_connection->measured_rtt_ms + : (q.rtt > 0.0 ? q.rtt : stream_connection->measured_rtt_ms); + stream_connection->measured_loss = q.loss; + chiaki_stream_stats_reset(&stream_connection->video_receiver->frame_processor.stream_stats); break; } diff --git a/lib/src/videoreceiver.c b/lib/src/videoreceiver.c index be9d6693..a1c68db7 100644 --- a/lib/src/videoreceiver.c +++ b/lib/src/videoreceiver.c @@ -49,6 +49,7 @@ CHIAKI_EXPORT void chiaki_video_receiver_init(ChiakiVideoReceiver *video_receive video_receiver->packet_stats = packet_stats; video_receiver->frames_lost = 0; + video_receiver->cumulative_frames_lost = 0; memset(video_receiver->reference_frames, -1, sizeof(video_receiver->reference_frames)); chiaki_bitstream_init(&video_receiver->bitstream, video_receiver->log, video_receiver->session->connect_info.video_profile.codec); } @@ -219,6 +220,7 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi if(succ && video_receiver->session->video_sample_cb) { bool cb_succ = video_receiver->session->video_sample_cb(frame, frame_size, video_receiver->frames_lost, recovered, video_receiver->session->video_sample_cb_user); + video_receiver->cumulative_frames_lost += (uint64_t)video_receiver->frames_lost; video_receiver->frames_lost = 0; if(!cb_succ) { From f4a1b835858b09366f9a5c7609db8a7dfb85b68b Mon Sep 17 00:00:00 2001 From: forward technologies Date: Fri, 26 Jun 2026 23:32:24 -0700 Subject: [PATCH 14/72] Android CI: optional sideloadable test APK; bump version to 2.10.22 Add a "build_apk" checkbox to the manual deploy-android workflow that produces an installable APK artifact and skips the Google Play publish, so testers can sideload a build before it ships. Signed release APK when signing secrets are present, debug APK otherwise. Bump CHIAKI_VERSION to 2.10.22 (single source of truth in CMakeLists.txt; propagates to Android versionName/versionCode, iOS, and macOS/Linux). Co-authored-by: Cursor --- .github/workflows/deploy-android.yml | 54 +++++++++++++++++++++++++++- CMakeLists.txt | 2 +- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index f088a2a9..7a462cad 100644 --- a/.github/workflows/deploy-android.yml +++ b/.github/workflows/deploy-android.yml @@ -17,6 +17,11 @@ name: Deploy Android + Android TV (Google Play) on: workflow_dispatch: + inputs: + build_apk: + description: "APK-only: build a sideloadable test APK artifact and SKIP the Google Play publish" + type: boolean + default: false workflow_call: outputs: chiaki_version: @@ -122,8 +127,11 @@ jobs: echo "No signing — unsigned build only" fi + # Skipped on APK-only runs (build_apk checkbox): those just want a sideloadable + # APK and must not touch Google Play. - name: Build release AAB id: bundle_release + if: ${{ !inputs.build_apk }} working-directory: android run: | ./gradlew bundleRelease \ @@ -131,7 +139,43 @@ jobs: --build-cache \ -Dorg.gradle.java.home="$JAVA_HOME" + # Optional sideloadable APK. The AAB above can only go through Google Play, + # so this opt-in step (the "build_apk" checkbox on a manual run) produces an + # installable .apk testers can grab from the run's Artifacts. A signed release + # APK is built when signing is configured; otherwise a debug APK (still + # installable, self-signed) so the step never fails for lack of secrets. + - name: Build sideloadable APK + id: build_apk + if: ${{ inputs.build_apk }} + working-directory: android + run: | + if [ "$SIGNING_CONFIGURED" = "true" ]; then + echo "Signing configured — building signed release APK" + ./gradlew assembleRelease \ + --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" + else + echo "::warning::No signing configured — building debug APK (self-signed, still installable for testing)" + ./gradlew assembleDebug \ + --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" + fi + APK="$(find app/build/outputs/apk -name '*.apk' | sort | tail -1)" + test -n "$APK" || { echo "::error::No APK produced"; exit 1; } + SHORT_SHA="$(git rev-parse --short HEAD)" + DEST="pylux-${{ steps.extract_version.outputs.version }}-${SHORT_SHA}.apk" + cp "$APK" "$GITHUB_WORKSPACE/$DEST" + echo "apk_name=$DEST" >> "$GITHUB_OUTPUT" + echo "Built sideloadable APK: $DEST" + + - name: Upload APK artifact + if: ${{ inputs.build_apk }} + uses: actions/upload-artifact@v4 + with: + name: pylux-android-apk + path: ${{ steps.build_apk.outputs.apk_name }} + if-no-files-found: error + - name: Decode Google Play service account key + if: ${{ !inputs.build_apk }} env: JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} run: | @@ -177,7 +221,15 @@ jobs: { echo "## Pylux Android — v${CHIAKI_VERSION:-?}" echo "" - if [ "${{ steps.bundle_release.outcome }}" = "skipped" ] || [ "${{ steps.bundle_release.outcome }}" != "success" ]; then + if [ "${{ inputs.build_apk }}" = "true" ]; then + echo "APK-only run — Google Play publish was skipped." + echo "" + if [ "${{ steps.build_apk.outcome }}" = "success" ]; then + echo "Sideloadable test APK \`${{ steps.build_apk.outputs.apk_name }}\` is attached to this run (see the **Artifacts** section, named \`pylux-android-apk\`)." + else + echo "APK build did not complete (outcome: **${{ steps.build_apk.outcome }}**). Check the logs above for details." + fi + elif [ "${{ steps.bundle_release.outcome }}" != "success" ]; then echo "Build did not complete (outcome: **${{ steps.bundle_release.outcome }}**). Check the logs above for details." else case "${UPLOAD_ENABLED:-false}" in diff --git a/CMakeLists.txt b/CMakeLists.txt index 16661a1a..ba358a79 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,7 +74,7 @@ tri_option(CHIAKI_USE_SYSTEM_CURL "Use system-provided curl instead of submodule # CI injects real values from the CHIAKI_VERSION_* lines below at archive time (.github/workflows/deploy-ios.yml). set(CHIAKI_VERSION_MAJOR 2) set(CHIAKI_VERSION_MINOR 10) -set(CHIAKI_VERSION_PATCH 21) +set(CHIAKI_VERSION_PATCH 22) set(CHIAKI_VERSION ${CHIAKI_VERSION_MAJOR}.${CHIAKI_VERSION_MINOR}.${CHIAKI_VERSION_PATCH}) configure_file( From 61bbe70a88d116a15e08c40ac7e70e019a1511ba Mon Sep 17 00:00:00 2001 From: forward technologies Date: Fri, 26 Jun 2026 23:55:46 -0700 Subject: [PATCH 15/72] Address PR review: APK ABI pinning, CI publish guards, accurate dropped-frame count - deploy-android.yml: pin the APK-only build to arm64-v8a so the sideload artifact is always an installable arm64 split (was picking a stray ABI via sort|tail). Add explicit !inputs.build_apk guards to the Play-publish steps so an APK-only run can never upload to Google Play. - videoreceiver: increment cumulative_frames_lost at each loss site so the stats overlay's running total stays accurate even on loss paths that return before the next successful flush. Overlay-counter accuracy only; no change to decode/FEC/flush behavior. Co-authored-by: Cursor --- .github/workflows/deploy-android.yml | 17 ++++++++++++----- lib/src/videoreceiver.c | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index 7a462cad..0e4ad074 100644 --- a/.github/workflows/deploy-android.yml +++ b/.github/workflows/deploy-android.yml @@ -149,16 +149,23 @@ jobs: if: ${{ inputs.build_apk }} working-directory: android run: | + # abiFilters produces per-ABI split APKs; pin to arm64-v8a (covers virtually + # all modern phones/tablets/TV) so we never ship a stray x86_64 split to testers. + ABI=arm64-v8a if [ "$SIGNING_CONFIGURED" = "true" ]; then - echo "Signing configured — building signed release APK" + echo "Signing configured — building signed release APK ($ABI)" ./gradlew assembleRelease \ + -Pandroid.injected.build.abi="$ABI" \ --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" else - echo "::warning::No signing configured — building debug APK (self-signed, still installable for testing)" + echo "::warning::No signing configured — building debug APK ($ABI, self-signed, still installable for testing)" ./gradlew assembleDebug \ + -Pandroid.injected.build.abi="$ABI" \ --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" fi - APK="$(find app/build/outputs/apk -name '*.apk' | sort | tail -1)" + # Prefer the arm64-v8a APK; fall back to the newest APK by mtime if naming differs. + APK="$(find app/build -name "*${ABI}*.apk" -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -1)" + [ -n "$APK" ] || APK="$(find app/build -name '*.apk' -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -1)" test -n "$APK" || { echo "::error::No APK produced"; exit 1; } SHORT_SHA="$(git rev-parse --short HEAD)" DEST="pylux-${{ steps.extract_version.outputs.version }}-${SHORT_SHA}.apk" @@ -192,7 +199,7 @@ jobs: fi - name: Determine Google Play track - if: env.UPLOAD_ENABLED == 'true' + if: ${{ env.UPLOAD_ENABLED == 'true' && !inputs.build_apk }} run: | if [ "${{ github.ref_name }}" = "master" ]; then TRACK=production @@ -204,7 +211,7 @@ jobs: - name: Upload AAB to Google Play id: upload_play - if: env.UPLOAD_ENABLED == 'true' + if: ${{ env.UPLOAD_ENABLED == 'true' && !inputs.build_apk }} working-directory: android env: # Same value Gradle uses (extract-version semver_build_id); avoids Fastlane relying on a solo Gradle invoke. diff --git a/lib/src/videoreceiver.c b/lib/src/videoreceiver.c index a1c68db7..2615c977 100644 --- a/lib/src/videoreceiver.c +++ b/lib/src/videoreceiver.c @@ -158,6 +158,19 @@ CHIAKI_EXPORT void chiaki_video_receiver_av_packet(ChiakiVideoReceiver *video_re } } +// Account a frame loss in both counters at the moment it happens: frames_lost is +// consumed by the decoder callback (for concealment) and reset after each delivered +// frame, while cumulative_frames_lost is the never-reset session total shown by the +// stats overlay. Counting here keeps that total accurate even on loss paths that +// return before the next successful flush. No effect on decode/FEC/flush behavior. +static inline void video_receiver_account_lost(ChiakiVideoReceiver *video_receiver, int32_t lost) +{ + if(lost < 0) + lost = 0; + video_receiver->frames_lost += lost; + video_receiver->cumulative_frames_lost += (uint64_t)lost; +} + static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *video_receiver) { uint8_t *frame; @@ -168,7 +181,7 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi { ChiakiSeqNum16 next_frame_expected = (ChiakiSeqNum16)(video_receiver->frame_index_prev_complete + 1); stream_connection_send_corrupt_frame(&video_receiver->session->stream_connection, next_frame_expected, video_receiver->frame_index_cur); - video_receiver->frames_lost += video_receiver->frame_index_cur - next_frame_expected + 1; + video_receiver_account_lost(video_receiver, video_receiver->frame_index_cur - next_frame_expected + 1); video_receiver->frame_index_prev_complete = video_receiver->frame_index_cur; video_receiver->frame_index_prev = video_receiver->frame_index_cur; CHIAKI_LOGW(video_receiver->log, "FEC failed for frame %d, requesting resend", (int)video_receiver->frame_index_cur); @@ -207,7 +220,7 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi } if(!recovered) { - video_receiver->frames_lost++; + video_receiver_account_lost(video_receiver, 1); CHIAKI_LOGW(video_receiver->log, "Missing reference frame %d for decoding frame %d", (int)ref_frame_index, (int)video_receiver->frame_index_cur); video_receiver->frame_index_prev = video_receiver->frame_index_cur; video_receiver->frame_index_prev_complete = video_receiver->frame_index_cur; @@ -220,7 +233,8 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi if(succ && video_receiver->session->video_sample_cb) { bool cb_succ = video_receiver->session->video_sample_cb(frame, frame_size, video_receiver->frames_lost, recovered, video_receiver->session->video_sample_cb_user); - video_receiver->cumulative_frames_lost += (uint64_t)video_receiver->frames_lost; + // cumulative_frames_lost is incremented at each loss site (video_receiver_account_lost), + // so only reset the per-callback counter here to avoid double-counting. video_receiver->frames_lost = 0; if(!cb_succ) { From 59815af6728cff12f507e53bed5fbe9b9ba6ddf4 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Fri, 26 Jun 2026 23:57:59 -0700 Subject: [PATCH 16/72] Revert dropped-frame counter tweak; keep videoreceiver untouched The stats overlay being at most one frame behind is immaterial, and the change touched streaming-adjacent code for no user-visible benefit. Keep only the safe CI fixes (APK ABI pin + Play-publish guards) from the prior commit. Co-authored-by: Cursor --- lib/src/videoreceiver.c | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/src/videoreceiver.c b/lib/src/videoreceiver.c index 2615c977..a1c68db7 100644 --- a/lib/src/videoreceiver.c +++ b/lib/src/videoreceiver.c @@ -158,19 +158,6 @@ CHIAKI_EXPORT void chiaki_video_receiver_av_packet(ChiakiVideoReceiver *video_re } } -// Account a frame loss in both counters at the moment it happens: frames_lost is -// consumed by the decoder callback (for concealment) and reset after each delivered -// frame, while cumulative_frames_lost is the never-reset session total shown by the -// stats overlay. Counting here keeps that total accurate even on loss paths that -// return before the next successful flush. No effect on decode/FEC/flush behavior. -static inline void video_receiver_account_lost(ChiakiVideoReceiver *video_receiver, int32_t lost) -{ - if(lost < 0) - lost = 0; - video_receiver->frames_lost += lost; - video_receiver->cumulative_frames_lost += (uint64_t)lost; -} - static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *video_receiver) { uint8_t *frame; @@ -181,7 +168,7 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi { ChiakiSeqNum16 next_frame_expected = (ChiakiSeqNum16)(video_receiver->frame_index_prev_complete + 1); stream_connection_send_corrupt_frame(&video_receiver->session->stream_connection, next_frame_expected, video_receiver->frame_index_cur); - video_receiver_account_lost(video_receiver, video_receiver->frame_index_cur - next_frame_expected + 1); + video_receiver->frames_lost += video_receiver->frame_index_cur - next_frame_expected + 1; video_receiver->frame_index_prev_complete = video_receiver->frame_index_cur; video_receiver->frame_index_prev = video_receiver->frame_index_cur; CHIAKI_LOGW(video_receiver->log, "FEC failed for frame %d, requesting resend", (int)video_receiver->frame_index_cur); @@ -220,7 +207,7 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi } if(!recovered) { - video_receiver_account_lost(video_receiver, 1); + video_receiver->frames_lost++; CHIAKI_LOGW(video_receiver->log, "Missing reference frame %d for decoding frame %d", (int)ref_frame_index, (int)video_receiver->frame_index_cur); video_receiver->frame_index_prev = video_receiver->frame_index_cur; video_receiver->frame_index_prev_complete = video_receiver->frame_index_cur; @@ -233,8 +220,7 @@ static ChiakiErrorCode chiaki_video_receiver_flush_frame(ChiakiVideoReceiver *vi if(succ && video_receiver->session->video_sample_cb) { bool cb_succ = video_receiver->session->video_sample_cb(frame, frame_size, video_receiver->frames_lost, recovered, video_receiver->session->video_sample_cb_user); - // cumulative_frames_lost is incremented at each loss site (video_receiver_account_lost), - // so only reset the per-callback counter here to avoid double-counting. + video_receiver->cumulative_frames_lost += (uint64_t)video_receiver->frames_lost; video_receiver->frames_lost = 0; if(!cb_succ) { From ef803a10cccbe72aad321be63ab4aa4f8beaa124 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 01:35:37 -0700 Subject: [PATCH 17/72] Invalidate + reload cloud catalog on account/profile/locale change Drop the shared cloud catalog cache whenever the active account or catalog locale changes so one account never sees another's owned games: - Qt/Android/iOS: invalidate the libchiaki-owned catalog cache on NPSSO login/logout/re-entry and on cloud-language change. - Qt: add Settings::NpssoTokenChanged and CloudCatalogBackend::cacheInvalidated; the cloud view re-fetches on invalidation so the visible grid never lingers on the previous account's games. - Qt: fix profile-switch ordering/staleness. CloudCatalogBackend now gets setSettings() and is rebound to the new profile before invalidateCache() runs, so the reload reads the new account's NPSSO instead of the old (deleted) Settings (also removes a latent use-after-free on the next fetch). Co-authored-by: Cursor --- .../repository/CloudGameRepository.kt | 4 +-- .../com/metallic/chiaki/common/Preferences.kt | 2 +- .../chiaki/common/SecureTokenManager.kt | 10 +++++++ gui/include/cloudcatalogbackend.h | 12 ++++++++ gui/include/settings.h | 1 + gui/src/cloudcatalogbackend.cpp | 8 +++++ gui/src/qml/CloudPlayView.qml | 11 ++++++- gui/src/qmlbackend.cpp | 29 ++++++++++++++++++- gui/src/settings.cpp | 4 +++ ios/Pylux/Models/CloudModels.swift | 4 ++- ios/Pylux/Services/SecureStore.swift | 10 ++++++- lib/src/cloudcatalog_unified.c | 2 +- 12 files changed, 89 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 9a2c2126..cc6fe0aa 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -50,12 +50,12 @@ class CloudGameRepository( * (temp file + rename) and reads whole files (open fds survive unlink on POSIX), so the worst * case is a benign cache miss, never a torn read or corruption. */ - fun invalidateCatalogCache(context: Context) + fun invalidateCatalogCache(context: Context, reason: String = "") { try { cloudCatalogInvalidateCache(cacheDir(context).absolutePath) - Log.i(TAG, "Catalog cache invalidated (locale change)") + Log.i(TAG, "Catalog cache invalidated" + if (reason.isNotEmpty()) " ($reason)" else "") } catch (e: Exception) { diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index eaf37362..80da6589 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -304,7 +304,7 @@ class Preferences(context: Context) return sharedPreferences.edit().putString("cloud_language_pscloud", value).apply() Log.i("Preferences", "Cloud locale ${if (configured) "changed" else "configured"}: $previous -> $value") - CloudGameRepository.invalidateCatalogCache(appContext) + CloudGameRepository.invalidateCatalogCache(appContext, "locale change") } /** diff --git a/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt b/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt index 0a7bdcdf..8d9239f8 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt @@ -7,12 +7,16 @@ import android.content.SharedPreferences import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import com.metallic.chiaki.cloudplay.repository.CloudGameRepository /** * Secure storage for PSN tokens using EncryptedSharedPreferences */ class SecureTokenManager(context: Context) { + // Held only to drop the lib-owned cloud catalog cache when the account changes. + private val appContext = context.applicationContext + companion object { private const val TAG = "SecureTokenManager" @@ -60,6 +64,9 @@ class SecureTokenManager(context: Context) .putString(KEY_NPSSO_TOKEN, token) .apply() Log.i(TAG, "NPSSO token saved securely") + // Account changed (login / token re-entry): drop the cached catalog so the next + // fetch re-resolves owned games for this account instead of serving the old one's. + CloudGameRepository.invalidateCatalogCache(appContext, "account login") } catch (e: Exception) { @@ -102,6 +109,9 @@ class SecureTokenManager(context: Context) .remove(KEY_NPSSO_TOKEN) .apply() Log.i(TAG, "NPSSO token cleared") + // Logout: drop the cached catalog so a later login can't briefly show the + // previous account's owned games from a stale cache hit. + CloudGameRepository.invalidateCatalogCache(appContext, "account logout") } catch (e: Exception) { diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index 433cf73f..436121d7 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -53,12 +53,24 @@ class CloudCatalogBackend : public QObject const QString &command, const QJSValue &callback, const QString &steamDir = QString()); + // Rebind to the active profile's Settings after a profile switch. The backend reads the NPSSO + // token + cloud locale from this pointer, and the previous profile's Settings is deleted on + // switch, so failing to update this would read a stale/dangling account (wrong owned games or a + // use-after-free on the next fetch). + void setSettings(Settings *settings); + // Utility methods Q_INVOKABLE void invalidateCache(); Q_INVOKABLE void invalidatePs5CatalogCache(); Q_INVOKABLE QString getCachedData(const QString &key, int maxAge); Q_INVOKABLE QString getGameLandscapeImageFromCache(const QString &serviceType, const QString &gameIdentifier); +signals: + // Emitted after the on-disk catalog cache is wiped (profile/account switch, NPSSO change, + // cloud-language change, or manual refresh). The cloud view listens for this to re-fetch so + // the visible game list never lingers on the previous account's games. + void cacheInvalidated(); + private slots: void handleGameDetailsResponse(); diff --git a/gui/include/settings.h b/gui/include/settings.h index 4351bda8..5f983850 100644 --- a/gui/include/settings.h +++ b/gui/include/settings.h @@ -796,6 +796,7 @@ class Settings : public QObject void PlaceboSettingsUpdated(); void CloudDatacentersJsonPSCloudChanged(); void CloudDatacentersJsonPSNOWChanged(); + void NpssoTokenChanged(); }; #endif // CHIAKI_SETTINGS_H diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 07c2357e..f71f98b9 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -47,6 +47,11 @@ CloudCatalogBackend::~CloudCatalogBackend() { } +void CloudCatalogBackend::setSettings(Settings *new_settings) +{ + settings = new_settings; +} + void CloudCatalogBackend::ensureCacheDirectory() { QDir dir; @@ -655,6 +660,9 @@ void CloudCatalogBackend::invalidateCache() const QByteArray cacheDir = cacheDirectory.toUtf8(); chiaki_cloudcatalog_invalidate_cache(cacheDir.constData()); qInfo() << "[CACHE INVALIDATED] Delegated cache invalidation to libchiaki for" << cacheDirectory; + // Tell the cloud view to drop its stale in-memory list and re-fetch (the cache files are gone, + // so the next fetch is a guaranteed network refresh for the now-current account). + emit cacheInvalidated(); } QPixmap CloudCatalogBackend::downloadImageFromUrl(const QString &url, int timeoutMs) diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index e782650b..b94d89e0 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -93,6 +93,15 @@ Pane { initialFocusTimer.restart(); } + // Account/profile switch, NPSSO change, or cloud-language change wipes the catalog cache in the + // backend; reload here so the visible grid never keeps showing the previous account's games. + Connections { + target: Chiaki.cloudCatalog + function onCacheInvalidated() { + loadUnifiedCatalog(); + } + } + // Pins default focus to the first game card (or the filter toggle if games // haven't loaded yet) after startup focus churn settles, so the search field // never holds focus by default. Runs late enough to override the window's @@ -807,8 +816,8 @@ Pane { function activate() { if (!enabled) return; + // invalidateCache() emits cacheInvalidated, which triggers the reload above. Chiaki.cloudCatalog.invalidateCache(); - loadUnifiedCatalog(); } Rectangle { diff --git a/gui/src/qmlbackend.cpp b/gui/src/qmlbackend.cpp index c1f86927..b801a47d 100644 --- a/gui/src/qmlbackend.cpp +++ b/gui/src/qmlbackend.cpp @@ -147,7 +147,14 @@ QmlBackend::QmlBackend(Settings *settings, QmlMainWindow *window, SteamworksWrap cloud_streaming_backend = new CloudStreamingBackend(settings, this); cloud_catalog_backend = new CloudCatalogBackend(settings, this); connect(settings_qml, &QmlSettings::cloudLanguagePSCloudChanged, this, [this]() { - cloud_catalog_backend->invalidatePs5CatalogCache(); + // Full wipe (not just the v6 PS5 intermediates): the unified catalog is locale-specific, + // so a language change must also drop unified_catalog_v3 or a stale-locale list is served. + cloud_catalog_backend->invalidateCache(); + }); + // Account/profile change (login, logout, token re-entry) must drop the cached catalog so + // one account never sees another account's owned games. + connect(settings, &Settings::NpssoTokenChanged, this, [this]() { + cloud_catalog_backend->invalidateCache(); }); // Connect cloud streaming backend to register sessions @@ -705,12 +712,32 @@ void QmlBackend::profileChanged() connect(settings, &Settings::ManualHostsUpdated, this, &QmlBackend::hostsChanged); connect(settings, &Settings::CurrentProfileChanged, this, &QmlBackend::profileChanged); connect(settings, &Settings::ControllerMappingsUpdated, this, &QmlBackend::updateControllerMappings); + // The npsso-change hook was bound to the previous (now-deleted) Settings object, so rebind + // it to the new profile's Settings. + if(cloud_catalog_backend) + { + connect(settings, &Settings::NpssoTokenChanged, this, [this]() { + cloud_catalog_backend->invalidateCache(); + }); + } settings_qml->setSettings(settings); games_backend->setSettings(settings); // Update games backend settings too discovery_manager.SetSettings(settings); window->setSettings(settings); setDiscoveryEnabled(true); + // Drop the cached cloud catalog and reload AFTER every consumer above points at the new + // profile's Settings. Switching profiles switches the active PSN account and the catalog cache + // is a single shared dir, so the new profile must not see the previous profile's owned games + // (treat it like a re-login). Order matters: invalidateCache() emits cacheInvalidated, which + // makes the cloud view re-fetch immediately -- if we did this before the setSettings() calls, + // the re-fetch would read the OLD account's npsso and repopulate the cache with its owned games. + if(cloud_catalog_backend) + { + cloud_catalog_backend->setSettings(settings); + cloud_catalog_backend->invalidateCache(); + } + auto_connect_mac = settings->GetAutoConnectHost().GetServerMAC(); auto_connect_nickname = settings->GetAutoConnectHost().GetServerNickname(); psn_reconnect_timer->deleteLater(); diff --git a/gui/src/settings.cpp b/gui/src/settings.cpp index aeaa39bb..b3dbbf31 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -985,6 +985,10 @@ QString Settings::GetNpssoToken() const void Settings::SetNpssoToken(QString npsso_token) { settings.setValue("settings/psn_npsso_token", npsso_token); + // Fires on login, logout, and token re-entry (not on periodic auth/refresh-token + // renewals, which don't touch the npsso). Listeners use this to drop the cached + // cloud catalog so one account never sees another's owned games. + emit NpssoTokenChanged(); } bool Settings::GetAccountAttributesCheckPassed() const diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 59056b18..e3d269a2 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -256,7 +256,9 @@ enum CloudLocaleSettings { private static let catalogCacheSubdir = "cloud_catalog_cache" - private static func invalidateCatalogCache() { + static func invalidateCatalogCache(reason: String = "") { + os_log(.info, log: cloudLocaleLog, "Catalog cache invalidated%{public}s", + reason.isEmpty ? "" : " (\(reason))") let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent(catalogCacheSubdir, isDirectory: true) guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { diff --git a/ios/Pylux/Services/SecureStore.swift b/ios/Pylux/Services/SecureStore.swift index 1b96bb55..34e57cf5 100644 --- a/ios/Pylux/Services/SecureStore.swift +++ b/ios/Pylux/Services/SecureStore.swift @@ -180,7 +180,15 @@ final class SecureStore { var npsso: String { get { KC.readString(kNpsso) ?? "" } - set { newValue.isEmpty ? KC.delete(kNpsso) : KC.writeString(kNpsso, newValue) } + set { + let changed = newValue != (KC.readString(kNpsso) ?? "") + newValue.isEmpty ? KC.delete(kNpsso) : KC.writeString(kNpsso, newValue) + // Account/profile change (login, logout, token re-entry) must drop the cached + // cloud catalog so one account never sees another account's owned games. + if changed { + CloudLocaleSettings.invalidateCatalogCache(reason: newValue.isEmpty ? "account logout" : "account login") + } + } } var authToken: String { diff --git a/lib/src/cloudcatalog_unified.c b/lib/src/cloudcatalog_unified.c index 69be88ec..d0b2e7bc 100644 --- a/lib/src/cloudcatalog_unified.c +++ b/lib/src/cloudcatalog_unified.c @@ -61,7 +61,7 @@ #include // strcasecmp #define WARNING_EXPIRED \ - "Your PlayStation session has expired. Please log in again to see your owned games." + "Your session has expired. Please log in again to see your owned games." static const char *account_country_from_locale(const char *locale, char *out, size_t out_sz) { From 202a575e20a9cf3f0c6746d71bcd1e1ce5910045 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 01:41:34 -0700 Subject: [PATCH 18/72] Build scripts: unify non-hanging log capture; add android/build-local.sh Make log viewing crystal-clear and never-hang across platforms: - macOS (scripts/build-macos.sh) and iOS (ios/build.sh): every launch path captures to one fixed log file and returns immediately; `logs` does a bounded one-shot dump. Detach background watchdog subshells from the terminal so a piped `... | tail`/`grep` no longer blocks on the auto-stop window. iOS drops the obsolete PYLUX_DEV_NO_STREAM toggle and dead foreground-stream helper. - Promote the local Android build script out of tmp/ into android/build-local.sh (mirrors deploy-android.yml; --logs-dump for one-shot, non-hanging logcat). Co-authored-by: Cursor --- android/build-local.sh | 481 +++++++++++++++++++++++++++++++++++++++++ ios/build.sh | 186 ++++++++-------- scripts/build-macos.sh | 79 ++++++- 3 files changed, 659 insertions(+), 87 deletions(-) create mode 100755 android/build-local.sh diff --git a/android/build-local.sh b/android/build-local.sh new file mode 100755 index 00000000..018ee612 --- /dev/null +++ b/android/build-local.sh @@ -0,0 +1,481 @@ +#!/usr/bin/env bash +# Local Android build script — mirrors .github/workflows/deploy-android.yml. +# Builds/installs the app locally and views logs without the CI signing/publish secrets. +# +# ============================ HOW TO RUN + READ LOGS (Android) ============================ +# Normal loop: ./android/build-local.sh --quick --install # build arm64 debug APK + install + launch +# View logs: ./android/build-local.sh --logs-dump # one-shot dump of app logcat + exit (NEVER hangs) +# Filter: ./android/build-local.sh --logs-dump | grep 'Catalog cache invalidated' +# +# --logs-dump reads the device's logcat ring buffer directly, so it works any time after an action -- +# no capture process to start or stop. For a LONG stream session (where the ring buffer rotates) add +# --logs to also tee a continuous file, then `tail -n 200` it; stop it with --stop-logs. +# ========================================================================================== +# +# Build modes: +# ./android/build-local.sh # release AAB (CI parity, all ABIs) +# ./android/build-local.sh --quick # debug APK, arm64-v8a only (fast) +# ./android/build-local.sh --full # clean + rebuild all native ABIs (after lib/ changes) +# ./android/build-local.sh --install # install + launch APK on connected device after build +# ./android/build-local.sh --skip-deps # skip SDK/JDK dependency installs +# Logs: +# ./android/build-local.sh --logs-dump # ONE-SHOT dump of app-tagged logcat to stdout + exit (use this) +# ./android/build-local.sh --logs # also start a continuous background capture (pair with --install) +# ./android/build-local.sh --stop-logs # stop the background capture started by --logs + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ANDROID_DIR="$REPO_ROOT/android" +SECRETS_DIR="$REPO_ROOT/secrets/android" + +NDK_VERSION="28.2.13676358" +CMAKE_VERSION="3.30.4" +BUILD_TOOLS_VERSION="35.0.0" +PLATFORM="android-35" + +QUICK=false +FULL_REBUILD=false +INSTALL_APK=false +SKIP_DEPS=false +CAPTURE_LOGS=false +STOP_LOGS=false +LOGS_DUMP=false +LOG_DIR="$REPO_ROOT/tmp/android-debug" +LOG_MINUTES=20 + +while [[ $# -gt 0 ]]; do + case "$1" in + --quick) QUICK=true; shift ;; + --full) FULL_REBUILD=true; QUICK=false; shift ;; + --install) INSTALL_APK=true; shift ;; + --skip-deps) SKIP_DEPS=true; shift ;; + --logs) CAPTURE_LOGS=true; shift ;; + --stop-logs) STOP_LOGS=true; shift ;; + --logs-dump) LOGS_DUMP=true; shift ;; + --log-dir) + LOG_DIR="$2" + shift 2 + ;; + --log-minutes) + LOG_MINUTES="$2" + shift 2 + ;; + -h|--help) + sed -n '2,24p' "$0" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +log() { printf '==> %s\n' "$*"; } +warn() { printf 'warning: %s\n' "$*" >&2; } + +# Logcat tags captured by --logs and dumped by --logs-dump. Shared by both so they never drift. +# NOTE: keep the app-side Kotlin tags (CloudGameRepository / SecureTokenManager / Preferences / +# CloudPlayViewModel) here or catalog cache + login/logout events won't appear. +PYLUX_LOG_TAGS=( + PSGaikaiStreaming:I + PSGaikaiStreaming:D + PSKamajiSession:I + DatacenterPing:I + StreamInput:I + StreamInput:D + Chiaki:I + Chiaki:W + Chiaki:E + Chiaki:V + CloudPlayFragment:I + CloudPlayViewModel:I + CloudGameRepository:I + CloudGameRepository:W + SecureTokenManager:I + Preferences:I + StreamActivity:I + StreamSession:I + AndroidRuntime:E +) + +# One-shot logcat dump (adb logcat -d): prints the current ring buffer for the tags above and +# EXITS. This never streams/hangs — use it to verify events after performing an action in the app. +dump_logs() { + local adb_bin="$1" + "$adb_bin" wait-for-device + "$adb_bin" logcat -d -v threadtime -s "${PYLUX_LOG_TAGS[@]}" +} +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +find_java_home() { + local candidate + for candidate in \ + "${JAVA_HOME:-}" \ + "/opt/homebrew/opt/temurin@21/libexec/openjdk.jdk/Contents/Home" \ + "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" \ + "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home" \ + "/Library/Java/JavaVirtualMachines/temurin-21.jre/Contents/Home" \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home" + do + [[ -n "$candidate" && -x "$candidate/bin/java" ]] || continue + if "$candidate/bin/java" -version 2>&1 | grep -Eq 'version "21'; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +ensure_java_21() { + if JAVA_HOME="$(find_java_home)"; then + export JAVA_HOME + log "Using JDK 21 at $JAVA_HOME" + return 0 + fi + + if ! command -v brew >/dev/null 2>&1; then + die "JDK 21 required. Install openjdk@21 (brew install openjdk@21) or set JAVA_HOME." + fi + + log "JDK 21 not found — installing openjdk@21 via Homebrew (no sudo)" + brew install openjdk@21 + JAVA_HOME="$(find_java_home)" || die "JDK 21 install did not produce a usable JAVA_HOME" + export JAVA_HOME + log "Using JDK 21 at $JAVA_HOME" +} + +find_android_sdk() { + local candidate + for candidate in \ + "${ANDROID_SDK_ROOT:-}" \ + "${ANDROID_HOME:-}" \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk" + do + [[ -n "$candidate" && -d "$candidate" ]] || continue + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + +find_sdkmanager() { + local sdk_root="$1" + local candidate + for candidate in \ + "$sdk_root/cmdline-tools/latest/bin/sdkmanager" \ + "$sdk_root/cmdline-tools/bin/sdkmanager" \ + "$(command -v sdkmanager 2>/dev/null || true)" \ + "/opt/homebrew/bin/sdkmanager" \ + "/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager" + do + [[ -n "$candidate" && -x "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } + done + while IFS= read -r candidate; do + [[ -x "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } + done < <(find "$sdk_root/cmdline-tools" -name sdkmanager -type f 2>/dev/null | sort -r) + return 1 +} + +ensure_cmdline_tools() { + local sdk_root="$1" + if find_sdkmanager "$sdk_root" >/dev/null; then + return 0 + fi + + log "Android cmdline-tools not found — installing via Homebrew" + if command -v brew >/dev/null 2>&1; then + brew install --cask android-commandlinetools + else + die "Install Android cmdline-tools (brew install --cask android-commandlinetools)" + fi + + find_sdkmanager "$sdk_root" >/dev/null || die "sdkmanager still not found after cmdline-tools install" +} + +ensure_sdk_packages() { + local sdk_root="$1" + local sdkmanager + sdkmanager="$(find_sdkmanager "$sdk_root")" + + log "Accepting Android SDK licenses (if needed)" + yes | "$sdkmanager" --sdk_root="$sdk_root" --licenses >/dev/null 2>&1 || true + + local need_install=() + [[ -d "$sdk_root/ndk/$NDK_VERSION" ]] || need_install+=("ndk;$NDK_VERSION") + [[ -d "$sdk_root/cmake/$CMAKE_VERSION" ]] || need_install+=("cmake;$CMAKE_VERSION") + [[ -d "$sdk_root/build-tools/$BUILD_TOOLS_VERSION" ]] || need_install+=("build-tools;$BUILD_TOOLS_VERSION") + [[ -d "$sdk_root/platforms/$PLATFORM" ]] || need_install+=("platforms;$PLATFORM") + + if ((${#need_install[@]} == 0)); then + log "Android SDK packages already present" + return 0 + fi + + log "Installing missing SDK packages: ${need_install[*]}" + yes | "$sdkmanager" --sdk_root="$sdk_root" "${need_install[@]}" +} + +ensure_protobuf_tools() { + if command -v protoc >/dev/null 2>&1; then + log "protoc found: $(command -v protoc)" + elif python3 -c "import grpc_tools.protoc" >/dev/null 2>&1; then + log "Python grpc_tools.protoc available" + else + log "Installing protobuf compiler and Python grpc tools" + if command -v brew >/dev/null 2>&1; then + brew install protobuf + fi + python3 -m pip install --user 'protobuf>=5,<6' 'grpcio-tools>=1.60' + fi +} + +write_local_properties() { + local sdk_root="$1" + local props_file="$ANDROID_DIR/local.properties" + + { + printf 'sdk.dir=%s\n' "$sdk_root" + } > "$props_file" + + local keystore="" + local store_pw="" + local key_alias="" + local key_pw="" + + if [[ -f "$SECRETS_DIR/credentials.env" ]]; then + # shellcheck disable=SC1091 + set -a + source "$SECRETS_DIR/credentials.env" + set +a + store_pw="${ANDROID_KEYSTORE_PASSWORD:-}" + key_alias="${ANDROID_KEY_ALIAS:-}" + key_pw="${ANDROID_KEY_PASSWORD:-}" + fi + + for candidate in \ + "$SECRETS_DIR/chiaki-release.keystore" \ + "$SECRETS_DIR/release.jks" + do + [[ -f "$candidate" ]] && { keystore="$candidate"; break; } + done + + if [[ -n "$keystore" && -n "$store_pw" && -n "$key_alias" && -n "$key_pw" ]]; then + cat >> "$props_file" </, while a stale universal APK can linger in + # build/outputs/apk//. Always installing the most recently built APK prevents + # silently flashing an old binary (which previously masked code fixes during testing). + find "$ANDROID_DIR/app/build" -name '*.apk' -path "*${kind}*" -print0 2>/dev/null \ + | xargs -0 ls -t 2>/dev/null | head -1 +} + +find_aab() { + find "$ANDROID_DIR/app/build/outputs/bundle/release" -name '*.aab' -print -quit 2>/dev/null \ + || find "$ANDROID_DIR/app/build" -name '*.aab' -print -quit 2>/dev/null +} + +run_gradle() { + local gradle_task="$1" + shift + ( + cd "$ANDROID_DIR" + ./gradlew "$gradle_task" \ + --parallel \ + --no-build-cache \ + -Dorg.gradle.java.home="$JAVA_HOME" \ + "$@" + ) +} + +stop_log_capture() { + local pid_file="$LOG_DIR/capture.pid" + [[ -f "$pid_file" ]] || { warn "No log capture pid file at $pid_file"; return 0; } + local pid + pid="$(cat "$pid_file")" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + log "Stopped log capture (pid $pid)" + else + warn "Log capture pid $pid is not running" + fi + rm -f "$pid_file" +} + +start_log_capture() { + local adb_bin="$1" + mkdir -p "$LOG_DIR" + stop_log_capture + + local ts log_file latest_file pid_file meta_file + ts="$(date +%Y%m%d-%H%M%S)" + log_file="$LOG_DIR/pylux-$ts.log" + latest_file="$LOG_DIR/pylux-latest.log" + pid_file="$LOG_DIR/capture.pid" + meta_file="$LOG_DIR/capture.meta" + + "$adb_bin" wait-for-device + "$adb_bin" logcat -c + + local log_tags=("${PYLUX_LOG_TAGS[@]}") + + log "Capturing logcat for ${LOG_MINUTES}m -> $log_file" + log " tags: ${log_tags[*]}" + log " grep hints: bwKbpsSent|target_bitrate|Step 13|service_type|video_profile" + + ( + "$adb_bin" logcat -v threadtime -s "${log_tags[@]}" + ) > "$log_file" 2>&1 & + local pid=$! + disown "$pid" 2>/dev/null || true + printf '%s\n' "$pid" > "$pid_file" + cat > "$meta_file" </dev/null || echo unknown) +grep_bitrate=rg -i 'bwKbps|target_bitrate|Step 13|measured bitrate|video_profile|cloudBitrate' "$log_file" +EOF + ln -sf "$(basename "$log_file")" "$latest_file" + + # Detach the watchdog from the terminal's stdout/stderr/stdin, otherwise a `... | tail`/`| grep` + # on the build command would block until this subshell exits (i.e. the whole --log-minutes window). + ( + sleep $((LOG_MINUTES * 60)) + kill "$pid" 2>/dev/null || true + rm -f "$pid_file" + ) >/dev/null 2>&1 /dev/null || true + + cat <)" + echo " Stop background capture: ./android/build-local.sh --stop-logs" + echo " Session file (device): files/session_logs/chiaki_session_*.log" + echo " Pull session log: adb shell run-as com.pylux.stream cat files/session_logs/\$(adb shell run-as com.pylux.stream ls -t files/session_logs/ | head -1)" + if [[ -f "$LOG_DIR/capture.pid" ]]; then + echo " Capturing: pid $(cat "$LOG_DIR/capture.pid") -> $(readlink "$LOG_DIR/pylux-latest.log" 2>/dev/null || echo pylux-latest.log)" + fi + + log "Done" +} + +main "$@" diff --git a/ios/build.sh b/ios/build.sh index a2c9279f..2aa75095 100755 --- a/ios/build.sh +++ b/ios/build.sh @@ -2,18 +2,31 @@ # iOS build script for Pylux (Chiaki) # Similar to android/build.ps1: dev = simulator + run, release = production archive # -# Usage: -# ./build.sh [dev|release] - dev (default): build and run on device/simulator -# release: build archive for App Store upload -# ./build.sh launch - Skip build, launch app and stream logs only -# ./build.sh iterate - Fast loop: optional full lib, xcodebuild, install, launch, -# background syslog (auto-stops after PYLUX_LOG_MINUTES, default 20) +# ============================ HOW TO RUN + READ LOGS (iOS) ============================ +# Normal loop: ./build.sh dev # build + install + launch (sim or device) + start logs +# View logs: ./build.sh logs # one-shot tail -n 200 of ios/logs/pylux.log (NEVER hangs) +# Stop logs: ./build.sh stop-logs +# +# Every launch path (dev / launch / iterate) starts a BACKGROUND log capture into ONE fixed file +# (ios/logs/pylux.log) and returns immediately -- nothing streams in the foreground, so it never +# hangs. We capture in the background because the simulator's `log show` does NOT retain .info logs. +# To watch live instead of a snapshot: tail -f ios/logs/pylux.log +# ====================================================================================== +# +# Modes: +# ./build.sh dev - (default) build + install + launch on device/sim + background logs +# ./build.sh launch - Skip build; just launch app + background logs +# ./build.sh iterate - Fast loop: optional full lib, xcodebuild, install, launch, background logs +# ./build.sh logs - One-shot dump (tail -n 200) of logs/pylux.log; never streams/hangs +# ./build.sh stop-logs - Stop capture (reads logs/pylux-capture.pid if present) +# ./build.sh release - Build archive for App Store upload +# +# Env knobs: # PYLUX_FULL_BUILD=1 ./build.sh iterate - Rebuild chiaki-lib via CMake first -# PYLUX_XCODE_QUIET=1 - Optional: pass xcodebuild -quiet (default: full build log to terminal) -# PYLUX_XCODE_CONFIGURATION=Release ./build.sh dev|iterate - Release build (matches shipped log masks); default Debug -# PYLUX_DEV_NO_STREAM=1 - After install/launch, skip foreground log streaming (script exits; default dev waits on logs) +# PYLUX_XCODE_QUIET=1 - Pass xcodebuild -quiet (default: full build log to terminal) +# PYLUX_XCODE_CONFIGURATION=Release ./build.sh dev|iterate - Release build; default Debug +# PYLUX_LOG_MINUTES=N - Background capture auto-stops after N minutes (default 20) # PYLUX_SYSLOG_NETWORK=1 - idevicesyslog always uses -n (override auto USB vs Wi‑Fi) -# ./build.sh stop-logs - Stop capture (reads logs/pylux-capture.pid if present) # ./build.sh release xcframework - Also create XCFramework after release build # ./build.sh ship - Archive + export IPA + Fastlane upload (TestFlight). Uses generic/platform=iOS only; # does not install on a device or stream logs. Requires: brew install fastlane + API key env (see below). @@ -62,31 +75,6 @@ _pylux_idevicesyslog_use_network_flag() { return 1 } -# idevicesyslog -p Pylux (PATH includes Homebrew before any run_*). Fallback: pymobiledevice3. -_pylux_stream_phys_device_syslog() { - local udid="$1" - local log_file="$2" - local isys - isys=$(command -v idevicesyslog 2>/dev/null || true) - if [ -n "$isys" ]; then - if _pylux_idevicesyslog_use_network_flag "$udid"; then - echo "device syslog: $isys -n -u $udid -p Pylux" >&2 - exec "$isys" -n -u "$udid" -p Pylux 2>&1 | tee "$log_file" - else - echo "device syslog: $isys -u $udid -p Pylux" >&2 - exec "$isys" -u "$udid" -p Pylux 2>&1 | tee "$log_file" - fi - elif python3 -c "import pymobiledevice3" 2>/dev/null; then - echo "WARN: no idevicesyslog (brew install libimobiledevice); using pymobiledevice3" >&2 - local extra=() - [ -n "$udid" ] && extra+=(--udid "$udid") - exec env PYTHONUNBUFFERED=1 python3 -u -m pymobiledevice3 syslog live "${extra[@]}" 2>&1 | tee "$log_file" - else - echo "ERROR: install libimobiledevice (idevicesyslog) or pymobiledevice3 for device logs." >&2 - exit 1 - fi -} - _pylux_start_phys_device_syslog_bg() { local udid="$1" local log_file="$2" @@ -276,16 +264,9 @@ run_dev() { mkdir -p "$LOGS_DIR" LOG_FILE="$LOGS_DIR/pylux.log" - if [ "${PYLUX_DEV_NO_STREAM:-0}" = "1" ]; then - echo "PYLUX_DEV_NO_STREAM=1: skipping foreground log stream. Tail logs: ./build.sh launch or Console.app" - exit 0 - fi - echo "=== Streaming logs (press Ctrl+C to stop) ===" - echo "Logs also being saved to: $LOG_FILE" - sleep 1 - SYSLOG_UDID="$(_pylux_resolve_syslog_udid "$DEVICE_UDID")" - _pylux_stream_phys_device_syslog "$SYSLOG_UDID" "$LOG_FILE" + start_phys_log_capture_bg "$SYSLOG_UDID" "$LOG_FILE" + exit 0 else echo "No physical device found, falling back to simulator" echo "" @@ -332,20 +313,7 @@ run_dev() { echo "" echo "App launched on simulator." echo "" - if [ "${PYLUX_DEV_NO_STREAM:-0}" = "1" ]; then - echo "PYLUX_DEV_NO_STREAM=1: skipping log stream. Stream: xcrun simctl spawn booted log stream --predicate 'subsystem == \"com.pylux.stream\"'" - exit 0 - fi - echo "=== Streaming logs (press Ctrl+C to stop) ===" - LOGS_DIR="$SCRIPT_DIR/logs" - mkdir -p "$LOGS_DIR" - LOG_FILE="$LOGS_DIR/pylux.log" - echo "Logs also being saved to: $LOG_FILE" - sleep 2 - - xcrun simctl spawn booted log stream \ - --predicate 'subsystem == "com.pylux.stream"' \ - --level info 2>&1 | tee "$LOG_FILE" + start_sim_log_capture_bg fi } @@ -526,16 +494,8 @@ run_launch() { echo "App launched on physical device: $DEVICE_NAME" echo "" - # Create logs directory and file - LOGS_DIR="$SCRIPT_DIR/logs" - mkdir -p "$LOGS_DIR" - LOG_FILE="$LOGS_DIR/pylux.log" - - echo "=== Streaming logs (press Ctrl+C to stop) ===" - echo "Logs also being saved to: $LOG_FILE" - sleep 2 - - _pylux_stream_phys_device_syslog "$SYSLOG_UDID" "$LOG_FILE" + LOG_FILE="$SCRIPT_DIR/logs/pylux.log" + start_phys_log_capture_bg "$SYSLOG_UDID" "$LOG_FILE" else echo "No physical device found, launching on simulator" echo "" @@ -550,20 +510,8 @@ run_launch() { echo "Launching app on simulator: $SIMULATOR_UDID" xcrun simctl launch "$SIMULATOR_UDID" com.pylux.stream - echo "" - - # Create logs directory and file - LOGS_DIR="$SCRIPT_DIR/logs" - mkdir -p "$LOGS_DIR" - LOG_FILE="$LOGS_DIR/pylux.log" - - echo "=== Streaming logs (press Ctrl+C to stop) ===" - echo "Logs also being saved to: $LOG_FILE" - sleep 1 - - # Simulator: use native predicate filter to capture all app logs - exec xcrun simctl spawn "$SIMULATOR_UDID" log stream --predicate 'processImagePath CONTAINS "Pylux"' --level debug | tee "$LOG_FILE" + start_sim_log_capture_bg fi } @@ -674,6 +622,7 @@ run_iterate() { CAPTURE_PID=$PYLUX_SYSLOG_BG_PID echo "$CAPTURE_PID" > "$CAPTURE_PID_FILE" + # Detach the watchdog from the terminal's stdout/stderr/stdin so a piped `iterate | tail` returns. ( sleep $((STOP_MIN * 60)) if kill -0 "$CAPTURE_PID" 2>/dev/null; then @@ -681,7 +630,7 @@ run_iterate() { fi rm -f "$CAPTURE_PID_FILE" printf '\n########## SESSION END (auto %s min) %s ##########\n' "$STOP_MIN" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG_FILE" - ) & + ) >/dev/null 2>&1 /dev/null 2>&1 || true + printf '\n########## SIM SESSION %s ##########\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG_FILE" + nohup xcrun simctl spawn booted log stream \ + --predicate 'subsystem == "com.pylux.stream"' --level info >> "$LOG_FILE" 2>&1 /dev/null || true + echo "$pid" > "$CAPTURE_PID_FILE" + # Detach the watchdog from the terminal's stdout/stderr/stdin, otherwise a `... | tail`/`| grep` + # on the launch command would block until this subshell exits (i.e. the whole auto-stop window). + ( sleep $((STOP_MIN * 60)); kill "$pid" 2>/dev/null || true; rm -f "$CAPTURE_PID_FILE" ) >/dev/null 2>&1 /dev/null || true + echo " Background log capture PID $pid -> $LOG_FILE (auto-stops in ${STOP_MIN}m)" + echo "" + echo " VIEW LOGS (one-shot, never hangs):" + echo " $0 logs # tail -n 200 of $LOG_FILE" + echo " $0 logs | grep 'Catalog cache invalidated'" + echo " Stop capture: $0 stop-logs" +} + +# --- Start a BACKGROUND physical-device syslog capture (mirror of the simulator path) --- +start_phys_log_capture_bg() { + local udid="$1" + local LOG_FILE="$2" + local LOGS_DIR="$SCRIPT_DIR/logs" + mkdir -p "$LOGS_DIR" + local CAPTURE_PID_FILE="$LOGS_DIR/pylux-capture.pid" + local STOP_MIN="${PYLUX_LOG_MINUTES:-20}" + stop_logs >/dev/null 2>&1 || true + printf '\n########## DEVICE SESSION %s ##########\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG_FILE" + _pylux_start_phys_device_syslog_bg "$udid" "$LOG_FILE" + local pid="$PYLUX_SYSLOG_BG_PID" + disown "$pid" 2>/dev/null || true + echo "$pid" > "$CAPTURE_PID_FILE" + # Detach the watchdog from the terminal's stdout/stderr/stdin, otherwise a `... | tail`/`| grep` + # on the launch command would block until this subshell exits (i.e. the whole auto-stop window). + ( sleep $((STOP_MIN * 60)); kill "$pid" 2>/dev/null || true; rm -f "$CAPTURE_PID_FILE" ) >/dev/null 2>&1 /dev/null || true + echo " Background log capture PID $pid -> $LOG_FILE (auto-stops in ${STOP_MIN}m)" + echo "" + echo " VIEW LOGS (one-shot, never hangs):" + echo " $0 logs # tail -n 200 of $LOG_FILE" + echo " $0 logs | grep 'Catalog cache invalidated'" + echo " Stop capture: $0 stop-logs" +} + # --- Stop log streaming (kill orphan processes) --- stop_logs() { local killed=0 @@ -735,18 +738,31 @@ case "$MODE" in stop-logs) stop_logs ;; + logs) + # One-shot bounded dump of the captured log (never streams/hangs). `log show` does NOT + # retain .info logs on the simulator, so this tails the file fed by the background capture + # (started by `dev`/`iterate`). Start one first if the file is empty/missing. + LOG_FILE="$SCRIPT_DIR/logs/pylux.log" + if [ -f "$LOG_FILE" ]; then + tail -n 200 "$LOG_FILE" + else + echo "No log file yet at $LOG_FILE." + echo "Launch the app first: $0 dev (builds + launches + starts background capture)" + fi + ;; iterate) run_iterate ;; *) - echo "Usage: $0 [dev|launch|iterate|release|ship|clean|stop-logs]" - echo " dev - Build and run on device (if connected) or simulator" - echo " launch - Launch app and stream logs (skip rebuild)" + echo "Usage: $0 [dev|launch|iterate|release|ship|clean|logs|stop-logs]" + echo " dev - Build + install + launch (device/sim) + background logs" + echo " launch - Launch app (skip rebuild) + background logs" echo " iterate - Fast xcodebuild + install + launch + background logs (auto-stop, see header in script)" echo " release - Build archive for App Store upload" echo " release xcframework - Also create XCFramework" echo " ship - Build + export IPA + upload to App Store Connect (needs fastlane + API key env vars)" echo " clean - Remove all build directories" + echo " logs - One-shot dump (tail -n 200) of the captured log; never hangs" echo " stop-logs - Stop log capture" exit 1 ;; diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 9b49310d..43d1e8a5 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -19,6 +19,19 @@ # --skip-dmg-notarize - After signing .app, skip DMG creation and notarization (e.g. Mac App Store) # --iterate - Fast rebuild: skip cmake configure, incremental ninja, copy binary into # existing .app bundle, re-sign, skip DMG. Requires a prior full build. +# Cold-launches the app with logs captured (see LOGS below). +# --launch - Skip build: cold-launch the existing .app with logs captured, print LOGS. +# logs - Print the last 200 lines of the captured log and exit (one-shot; never hangs). +# +# LOGS (IMPORTANT - read this instead of guessing every time): +# macOS `open` sends a GUI app's stderr to /dev/null, and Qt logs ONLY to stderr (no message +# handler; the unified log does NOT capture Qt stderr). So this script cold-launches the app with +# `open --stdout/--stderr` redirected to a single fixed file. View it with a BOUNDED dump: +# Log file: /tmp/pylux-macos.log +# View recent: tail -n 200 tmp/pylux-macos.log (one-shot; safe; never hangs) +# Cache events: grep 'CACHE INVALIDATED' tmp/pylux-macos.log +# Follow live: tail -f tmp/pylux-macos.log (BLOCKS until Ctrl-C; do not use in automation) +# Re-dump: ./scripts/build-macos.sh logs # # Optional environment: # PYLUX_ENTITLEMENTS - Path to entitlements plist (default: gui/entitlements.xml) @@ -55,6 +68,41 @@ STEAMWORKS="OFF" SKIP_DMG_NOTARIZE="false" MAC_APPSTORE="OFF" ITERATE="false" +LAUNCH_ONLY="false" +LOGS_ONLY="false" + +# Single fixed log file for the captured app output (see LOGS in the header). +PYLUX_MACOS_LOG="$REPO_ROOT/tmp/pylux-macos.log" + +# Print the one place/way to read logs. Always shown after a launch so it's never ambiguous. +print_logs_help() { + echo "" + echo "=== LOGS ===" + echo " Log file: $PYLUX_MACOS_LOG" + echo " View recent: tail -n 200 \"$PYLUX_MACOS_LOG\" (one-shot; never hangs)" + echo " Cache events: grep 'CACHE INVALIDATED' \"$PYLUX_MACOS_LOG\"" + echo " Follow live: tail -f \"$PYLUX_MACOS_LOG\" (BLOCKS until Ctrl-C)" + echo " Re-dump: $0 logs" + echo "" +} + +# Cold-launch the .app with stdout+stderr redirected to PYLUX_MACOS_LOG. `open` silently ignores +# the redirect if the app is already running, so any existing instance must be fully terminated first. +launch_app_with_logs() { + local app_path="$1" + mkdir -p "$(dirname "$PYLUX_MACOS_LOG")" + pkill -9 -f "$app_path" 2>/dev/null || true + # Wait until no instance remains (open won't redirect into a running app). + local _i + for _i in $(seq 1 12); do + pgrep -f "$app_path/Contents/MacOS/" >/dev/null 2>&1 || break + sleep 0.3 + done + : > "$PYLUX_MACOS_LOG" + open --stdout "$PYLUX_MACOS_LOG" --stderr "$PYLUX_MACOS_LOG" "$app_path" + echo "Launched $app_path (logs -> $PYLUX_MACOS_LOG)" + print_logs_help +} # Parse arguments first so flags work regardless of order (e.g. --no-notarize universal) for arg in "$@"; do @@ -71,6 +119,8 @@ for arg in "$@"; do --appstore) MAC_APPSTORE="ON" ;; --skip-dmg-notarize) SKIP_DMG_NOTARIZE="true" ;; --iterate) ITERATE="true" ;; + --launch) LAUNCH_ONLY="true" ;; + logs) LOGS_ONLY="true" ;; *) if [[ "$arg" == -* ]]; then echo "Unknown option: $arg" @@ -86,6 +136,26 @@ done ARCH="${ARCH:-$(uname -m)}" +# One-shot log dump (never streams/hangs). Handle before any build/credentials work. +if [ "$LOGS_ONLY" = "true" ]; then + if [ -f "$PYLUX_MACOS_LOG" ]; then + tail -n 200 "$PYLUX_MACOS_LOG" + else + echo "No log file yet at $PYLUX_MACOS_LOG — run a build (--iterate) or --launch first." + fi + exit 0 +fi + +# Launch the already-built bundle with logs captured, then exit (no build). +if [ "$LAUNCH_ONLY" = "true" ]; then + if [ ! -d "$BUILD_OUTPUT_DIR/Pylux.app" ]; then + echo "ERROR: $BUILD_OUTPUT_DIR/Pylux.app not found — do a full build first." + exit 1 + fi + launch_app_with_logs "$BUILD_OUTPUT_DIR/Pylux.app" + exit 0 +fi + # Optional secrets/macos/credentials.env — loads MACOS_SIGN_ID, Apple IDs, etc. if [ "$NO_CREDENTIALS_FILE" = "false" ] && [ -f "$SECRETS_FILE" ]; then echo "Loading credentials from secrets/macos/credentials.env..." @@ -451,7 +521,7 @@ if [ "$ITERATE" = "true" ]; then echo "" echo "=== Iterate: launching app ===" - open "$output_path" + launch_app_with_logs "$output_path" elif [ "$BUILD_MODE" = "universal" ]; then echo "=== Building Universal Binary ===" @@ -523,7 +593,12 @@ else echo "DMG file: $BUILD_OUTPUT_DIR/Pylux.dmg" fi echo "" -echo "To run:" +echo "To run with logs captured (recommended):" +echo " $0 --launch # cold-launch this bundle, capture stderr -> $PYLUX_MACOS_LOG" +echo "Then read logs (one-shot, never hangs):" +echo " $0 logs # or: tail -n 200 \"$PYLUX_MACOS_LOG\"" +echo "" +echo "To run without log capture:" echo " open $BUILD_OUTPUT_DIR/Pylux.app" echo "" echo "Distribution (credentials.env + default notarize):" From c669fb3dfe0626697f3f76f6ed2b234ae41a3c63 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 03:21:18 -0700 Subject: [PATCH 19/72] Phase 1: rename cloud locale settings; remove dead datacenter-language table Co-authored-by: Cursor --- android/app/src/main/cpp/chiaki-jni.c | 11 -- .../chiaki/cloudplay/CloudLocaleBootstrap.kt | 10 +- .../chiaki/cloudplay/api/PSGaikaiStreaming.kt | 2 +- .../chiaki/cloudplay/api/PSKamajiSession.kt | 10 +- .../repository/CloudGameRepository.kt | 9 +- .../com/metallic/chiaki/common/Preferences.kt | 128 ++++++++++++------ .../java/com/metallic/chiaki/lib/Chiaki.kt | 5 - .../metallic/chiaki/main/CloudPlayFragment.kt | 23 +++- .../chiaki/main/CloudPlayViewModel.kt | 11 +- .../chiaki/settings/SettingsFragment.kt | 6 +- gui/include/qmlsettings.h | 36 +++-- gui/include/settings.h | 20 +-- gui/src/cloudcatalogbackend.cpp | 18 +-- gui/src/cloudstreaming/psgaikaistreaming.cpp | 4 +- gui/src/cloudstreaming/pskamajisession.cpp | 8 +- gui/src/qml/CloudPlayView.qml | 12 +- gui/src/qml/SettingsDialog.qml | 6 +- gui/src/qmlbackend.cpp | 2 +- gui/src/qmlsettings.cpp | 58 ++++---- gui/src/settings.cpp | 76 +++++++---- ios/Pylux/Bridge/CloudCatalogBridge.h | 7 - ios/Pylux/Bridge/CloudCatalogBridge.m | 6 - ios/Pylux/Models/CloudModels.swift | 11 +- ios/Pylux/Services/CloudCatalogService.swift | 5 +- ios/Pylux/Services/PSGaikaiStreaming.swift | 2 +- ios/Pylux/Services/PSKamajiSession.swift | 4 +- ios/Pylux/Services/SecureStore.swift | 35 ++++- ios/Pylux/Views/CloudPlayView.swift | 8 +- ios/Pylux/Views/SettingsView.swift | 20 +-- lib/include/chiaki/cloudcatalog.h | 6 - lib/src/cloudcatalog_consts.c | 41 +----- test/cloudcatalog_merge.c | 10 -- 32 files changed, 318 insertions(+), 292 deletions(-) diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index fba38196..5d4438ac 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -1652,15 +1652,4 @@ JNIEXPORT jobjectArray JNICALL JNI_FCN(cloudSupportedLanguages)(JNIEnv *env, job } } return arr; -} - -JNIEXPORT jboolean JNICALL JNI_FCN(cloudDatacenterServesLanguage)(JNIEnv *env, jobject obj, jstring dc_str, jstring locale_str) -{ - (void)obj; - const char *dc = dc_str ? E->GetStringUTFChars(env, dc_str, NULL) : NULL; - const char *locale = locale_str ? E->GetStringUTFChars(env, locale_str, NULL) : NULL; - bool served = (dc && locale) ? chiaki_cloud_datacenter_serves_locale(dc, locale) : false; - if(dc_str && dc) E->ReleaseStringUTFChars(env, dc_str, dc); - if(locale_str && locale) E->ReleaseStringUTFChars(env, locale_str, locale); - return served ? JNI_TRUE : JNI_FALSE; } \ No newline at end of file diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt index 2bd4f6e0..03b30966 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt @@ -14,7 +14,7 @@ object CloudLocaleBootstrap fun ensureConfigured(preferences: Preferences, npssoToken: String): Boolean { - if (preferences.isCloudLanguageConfigured()) + if (preferences.isCloudStoreLocaleConfigured()) return true if (npssoToken.isBlank()) { @@ -24,7 +24,7 @@ object CloudLocaleBootstrap synchronized(lock) { - if (preferences.isCloudLanguageConfigured()) + if (preferences.isCloudStoreLocaleConfigured()) return true Log.i(TAG, "Bootstrapping cloud locale via Kamaji session (first time only)") @@ -46,7 +46,7 @@ object CloudLocaleBootstrap Log.w(TAG, "Locale bootstrap failed: Kamaji session") return false } - Log.i(TAG, "Locale bootstrap OK: ${preferences.getCloudLanguage()}") + Log.i(TAG, "Locale bootstrap OK: ${preferences.getCloudStoreLocale()}") true } catch (e: Exception) @@ -114,10 +114,10 @@ object CloudLocaleBootstrap return false val data = json.optJSONObject("data") - preferences.setCloudLanguageFromSession( + preferences.setCloudStoreLocaleFromSession( data?.optString("language"), data?.optString("country") ) - return preferences.isCloudLanguageConfigured() + return preferences.isCloudStoreLocaleConfigured() } } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt index b1b4390f..e4061c31 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt @@ -1396,7 +1396,7 @@ catch (e: Exception) // pick lives in its own setting so the catalog locale can never clobber it. // Gaikai expects the bare language code ("de"), not the stored locale // ("de-DE"); the lib helper is the single source of truth across platforms. - val chosenLocale = preferences.getStreamLanguage().ifEmpty { preferences.getCloudLanguage() } + val chosenLocale = preferences.getCloudGameLanguage().ifEmpty { preferences.getCloudStoreLocale() } val language = com.metallic.chiaki.lib.cloudGaikaiLanguage(chosenLocale) spec.put("language", language) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index 8cad7b12..aa418a52 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -279,8 +279,8 @@ class PSKamajiSession( if (!sessionCountry.isNullOrEmpty() && !sessionLanguage.isNullOrEmpty()) { - preferences.setCloudLanguageFromSession(sessionLanguage, sessionCountry) - Log.i(TAG, "Saved locale from session: ${preferences.getCloudLanguage()}") + preferences.setCloudStoreLocaleFromSession(sessionLanguage, sessionCountry) + Log.i(TAG, "Saved locale from session: ${preferences.getCloudStoreLocale()}") } } } @@ -308,7 +308,7 @@ class PSKamajiSession( { try { - val localeSetting = preferences.getCloudLanguage() + val localeSetting = preferences.getCloudStoreLocale() var (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) Log.i(TAG, "Using locale from settings: $localeSetting -> country=$country, language=$language") @@ -318,7 +318,7 @@ class PSKamajiSession( // region-group store -- the account's own locale country would 404 ("Storefront not found"). // Driven by the account-level fallback flag, so PS3 and PS4 behave identically. In native // mode the account's own locale resolves both. - val fallbackRegion = preferences.getCloudFallbackRegion() + val fallbackRegion = preferences.getCloudResolvedStoreCountry() if (fallbackRegion.isNotEmpty()) { country = fallbackRegion @@ -582,7 +582,7 @@ class PSKamajiSession( // it returns noGameForEntitlementId downstream, surfaced via the region banner). Driven by // the account-level fallback flag, so PS3 and PS4 behave identically. // Native (supported region): run the normal checkout-acquire for both PS3 and PS4. - if (preferences.isCloudFallbackMode()) + if (preferences.isCloudCatalogIsForeign()) { Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai") return true diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index cc6fe0aa..37f04c8c 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -43,7 +43,7 @@ class CloudGameRepository( /** * Drop the lib-owned caches (e.g. on locale change). Synchronous on purpose: callers (e.g. - * [com.metallic.chiaki.common.Preferences.setCloudLanguage]) need the cache gone before the + * [com.metallic.chiaki.common.Preferences.setCloudStoreLocale]) need the cache gone before the * next fetch so it can't serve stale-locale data. It deliberately does NOT take [catalogLock] * — that lock is held across a full fetch (including network), so a blocking acquire here * could ANR. A delete racing an in-flight fetch is safe: the lib writes caches atomically @@ -84,7 +84,7 @@ class CloudGameRepository( catalogLock.withLock { cloudCatalogFetchUnified( npsso = npssoToken.ifEmpty { null }, - locale = preferences.getCloudLanguage(), + locale = preferences.getCloudStoreLocale(), cacheDir = cacheDir(context).absolutePath, forceRefresh = forceRefresh ) @@ -124,9 +124,10 @@ class CloudGameRepository( // streaming path (which reads the cloud language) and the region banner agree. Persist the // settled locale WITHOUT wiping the cache (the lib owns its own invalidation). root.optString("settledLocale", "").takeIf { it.isNotEmpty() }?.let { - preferences.noteCloudLanguageSettled(it) + preferences.noteCloudStoreLocaleSettled(it) } - preferences.setCloudFallbackRegion(root.optString("fallbackRegion", "")) + preferences.setCloudResolvedStoreCountry(root.optString("fallbackRegion", "")) + preferences.setCloudCatalogNativeMode(root.optBoolean("nativeMode", true)) root.optString("warning", "").takeIf { it.isNotEmpty() }?.let { lastCatalogFetchWarning = it diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index 80da6589..3363ee83 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -62,6 +62,14 @@ class Preferences(context: Context) const val DPAD_TOUCH_SHORTCUT2_DEFAULT = 10 const val DPAD_TOUCH_SHORTCUT3_DEFAULT = 7 const val DPAD_TOUCH_SHORTCUT4_DEFAULT = 0 + + private const val CLOUD_STORE_LOCALE_KEY = "cloud_store_locale" + private const val LEGACY_CLOUD_LANGUAGE_PSCLOUD_KEY = "cloud_language_pscloud" + private const val CLOUD_GAME_LANGUAGE_KEY = "cloud_game_language" + private const val LEGACY_CLOUD_STREAM_LANGUAGE_KEY = "cloud_stream_language" + private const val CLOUD_RESOLVED_STORE_COUNTRY_KEY = "cloud_resolved_store_country" + private const val LEGACY_CLOUD_FALLBACK_REGION_KEY = "cloud_fallback_region" + private const val CLOUD_CATALOG_NATIVE_MODE_KEY = "cloud_catalog_native_mode" } private val appContext = context.applicationContext @@ -288,21 +296,28 @@ class Preferences(context: Context) .apply() } - fun isCloudLanguageConfigured(): Boolean = - sharedPreferences.contains("cloud_language_pscloud") + fun isCloudStoreLocaleConfigured(): Boolean = + sharedPreferences.contains(CLOUD_STORE_LOCALE_KEY) + || sharedPreferences.contains(LEGACY_CLOUD_LANGUAGE_PSCLOUD_KEY) - fun getCloudLanguage(): String + private fun migrateCloudStoreLocaleIfNeeded(): String { - return sharedPreferences.getString("cloud_language_pscloud", "en-US") ?: "en-US" + if (sharedPreferences.contains(CLOUD_STORE_LOCALE_KEY)) + return sharedPreferences.getString(CLOUD_STORE_LOCALE_KEY, "en-US") ?: "en-US" + val legacy = sharedPreferences.getString(LEGACY_CLOUD_LANGUAGE_PSCLOUD_KEY, "en-US") ?: "en-US" + sharedPreferences.edit().putString(CLOUD_STORE_LOCALE_KEY, legacy).apply() + return legacy } - fun setCloudLanguage(value: String) + fun getCloudStoreLocale(): String = migrateCloudStoreLocaleIfNeeded() + + fun setCloudStoreLocale(value: String) { - val configured = isCloudLanguageConfigured() - val previous = getCloudLanguage() + val configured = isCloudStoreLocaleConfigured() + val previous = getCloudStoreLocale() if (configured && previous == value) return - sharedPreferences.edit().putString("cloud_language_pscloud", value).apply() + sharedPreferences.edit().putString(CLOUD_STORE_LOCALE_KEY, value).apply() Log.i("Preferences", "Cloud locale ${if (configured) "changed" else "configured"}: $previous -> $value") CloudGameRepository.invalidateCatalogCache(appContext, "locale change") } @@ -314,47 +329,55 @@ class Preferences(context: Context) * (even when it equals the en-US default, so the "couldn't detect region" banner clears) or * when the value changed. */ - fun noteCloudLanguageSettled(value: String) + fun noteCloudStoreLocaleSettled(value: String) { if (value.isEmpty()) return - if (isCloudLanguageConfigured() && getCloudLanguage() == value) + if (isCloudStoreLocaleConfigured() && getCloudStoreLocale() == value) return - sharedPreferences.edit().putString("cloud_language_pscloud", value).apply() + sharedPreferences.edit().putString(CLOUD_STORE_LOCALE_KEY, value).apply() Log.i("Preferences", "Cloud locale settled by lib: $value") } - fun setCloudLanguageFromSession(language: String?, country: String?) + fun setCloudStoreLocaleFromSession(language: String?, country: String?) { val locale = com.metallic.chiaki.cloudplay.CloudLocale.fromSession(language, country) ?: return - if (isCloudLanguageConfigured()) + if (isCloudStoreLocaleConfigured()) { // The country is the real region signal; the language part may get auto-corrected by // the imagic fetch (e.g. hu-HU settles on en-HU). Only re-save when the country changes, // otherwise we'd clobber the validated locale on every Kamaji session and thrash the cache. - val storedCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(getCloudLanguage()).first + val storedCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(getCloudStoreLocale()).first val sessionCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(locale).first if (storedCountry == sessionCountry) { - Log.i("Preferences", "Kamaji session country unchanged ($sessionCountry), keeping validated locale ${getCloudLanguage()}") + Log.i("Preferences", "Kamaji session country unchanged ($sessionCountry), keeping validated locale ${getCloudStoreLocale()}") return } } - setCloudLanguage(locale) + setCloudStoreLocale(locale) } /** * Manual streaming-language override chosen in the language picker. Empty means - * "use the catalog locale" ([getCloudLanguage]). Stored separately so the - * auto-detected catalog locale (noteCloudLanguageSettled / setCloudLanguageFromSession) + * "use the catalog locale" ([getCloudStoreLocale]). Stored separately so the + * auto-detected catalog locale (noteCloudStoreLocaleSettled / setCloudStoreLocaleFromSession) * can never clobber the user's pick. */ - fun getStreamLanguage(): String = - sharedPreferences.getString("cloud_stream_language", "") ?: "" + private fun migrateCloudGameLanguageIfNeeded(): String + { + if (sharedPreferences.contains(CLOUD_GAME_LANGUAGE_KEY)) + return sharedPreferences.getString(CLOUD_GAME_LANGUAGE_KEY, "") ?: "" + val legacy = sharedPreferences.getString(LEGACY_CLOUD_STREAM_LANGUAGE_KEY, "") ?: "" + sharedPreferences.edit().putString(CLOUD_GAME_LANGUAGE_KEY, legacy).apply() + return legacy + } + + fun getCloudGameLanguage(): String = migrateCloudGameLanguageIfNeeded() - fun setStreamLanguage(value: String) + fun setCloudGameLanguage(value: String) { - sharedPreferences.edit().putString("cloud_stream_language", value).apply() + sharedPreferences.edit().putString(CLOUD_GAME_LANGUAGE_KEY, value).apply() } // Cloud resolution settings (matching Qt GetCloudResolutionPSNOW/SetCloudResolutionPSNOW) @@ -434,8 +457,7 @@ class Preferences(context: Context) sharedPreferences.edit().putString(cloudDatacentersJsonPsnowKey, json).apply() } - // Cloud streaming game language (shared across PSCloud/PSNOW). Backed by the - // same "cloud_language_pscloud" store as getCloudLanguage/setCloudLanguage. + // Cloud streaming game language picker key (manual override; separate from store locale). val cloudLanguageKey get() = resources.getString(R.string.preferences_cloud_language_key) // PSCloud datacenter settings (matching Qt GetCloudDatacenterPSCloud/SetCloudDatacenterPSCloud) @@ -462,8 +484,43 @@ class Preferences(context: Context) sharedPreferences.edit().putString(cloudDatacentersJsonPscloudKey, json).apply() } - // Cloud Play UI state - private val CLOUD_FALLBACK_REGION_KEY = "cloud_fallback_region" + /** + * PS Now region-group fallback store country. Empty string = native mode (account's own /user/stores + * storefront is authoritative). A non-empty value (the region-group store country, "US" + * or "GB") = fallback mode: the catalog came from a foreign region group, so the + * concept-sibling streamability gate is skipped and stream-conversion/acquire remap to + * the region-group store. Recomputed on every catalog refresh (self-healing). + */ + fun getCloudResolvedStoreCountry(): String + { + if (sharedPreferences.contains(CLOUD_RESOLVED_STORE_COUNTRY_KEY)) + return sharedPreferences.getString(CLOUD_RESOLVED_STORE_COUNTRY_KEY, "") ?: "" + val legacy = sharedPreferences.getString(LEGACY_CLOUD_FALLBACK_REGION_KEY, "") ?: "" + sharedPreferences.edit().putString(CLOUD_RESOLVED_STORE_COUNTRY_KEY, legacy).apply() + return legacy + } + + fun setCloudResolvedStoreCountry(country: String) + { + sharedPreferences.edit().putString(CLOUD_RESOLVED_STORE_COUNTRY_KEY, country).apply() + } + + fun getCloudCatalogNativeMode(): Boolean + { + if (sharedPreferences.contains(CLOUD_CATALOG_NATIVE_MODE_KEY)) + return sharedPreferences.getBoolean(CLOUD_CATALOG_NATIVE_MODE_KEY, true) + val native = getCloudResolvedStoreCountry().isEmpty() + sharedPreferences.edit().putBoolean(CLOUD_CATALOG_NATIVE_MODE_KEY, native).apply() + return native + } + + fun setCloudCatalogNativeMode(nativeMode: Boolean) + { + sharedPreferences.edit().putBoolean(CLOUD_CATALOG_NATIVE_MODE_KEY, nativeMode).apply() + } + + fun isCloudCatalogIsForeign(): Boolean = !getCloudCatalogNativeMode() + private val LAST_MAIN_TAB_KEY = "last_main_tab" private val CLOUD_SORT_STATE_KEY = "cloud_sort_state" private val CLOUD_TAG_FILTERS_KEY = "cloud_tag_filters" @@ -488,25 +545,6 @@ class Preferences(context: Context) return next } - /** - * PS Now region-group fallback. Empty string = native mode (account's own /user/stores - * storefront is authoritative). A non-empty value (the region-group store country, "US" - * or "GB") = fallback mode: the catalog came from a foreign region group, so the - * concept-sibling streamability gate is skipped and stream-conversion/acquire remap to - * the region-group store. Recomputed on every catalog refresh (self-healing). - */ - fun getCloudFallbackRegion(): String - { - return sharedPreferences.getString(CLOUD_FALLBACK_REGION_KEY, "") ?: "" - } - - fun setCloudFallbackRegion(region: String) - { - sharedPreferences.edit().putString(CLOUD_FALLBACK_REGION_KEY, region).apply() - } - - fun isCloudFallbackMode(): Boolean = getCloudFallbackRegion().isNotEmpty() - fun getLastMainTab(): Int { return sharedPreferences.getInt(LAST_MAIN_TAB_KEY, 0) // Default to Remote Play (0) diff --git a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt index b586c129..4ff2461d 100644 --- a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt +++ b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt @@ -182,7 +182,6 @@ private class ChiakiNative @JvmStatic external fun cloudCatalogInvalidateCache(cacheDir: String) @JvmStatic external fun cloudGaikaiLanguage(locale: String?): String @JvmStatic external fun cloudSupportedLanguages(): Array - @JvmStatic external fun cloudDatacenterServesLanguage(datacenterName: String, locale: String): Boolean } } @@ -335,10 +334,6 @@ fun cloudGaikaiLanguage(locale: String?): String = ChiakiNative.cloudGaikaiLangu /** Locales offered in the language picker (BCP-47, e.g. "en-GB"). */ fun cloudSupportedLanguages(): List = ChiakiNative.cloudSupportedLanguages().toList() -/** True if [datacenterName] (4-letter ping name, e.g. "fraa") serves [locale]. */ -fun cloudDatacenterServesLanguage(datacenterName: String, locale: String): Boolean = - ChiakiNative.cloudDatacenterServesLanguage(datacenterName, locale) - class ErrorCode(val value: Int) { override fun toString() = ChiakiNative.errorCodeToString(value) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index c96c7331..11015cf0 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -758,14 +758,23 @@ class CloudPlayFragment : Fragment() }) viewModel.fallbackRegion.observe(viewLifecycleOwner, Observer { region -> - if (region.isNullOrEmpty()) { - binding.regionBanner.visibility = View.GONE - } else { - binding.regionBanner.text = - "PlayStation cloud isn't offered natively in your region — showing the $region catalog. Some titles may not stream." - binding.regionBanner.visibility = View.VISIBLE - } + updateRegionBanner(region) }) + viewModel.catalogIsForeign.observe(viewLifecycleOwner, Observer { isForeign -> + updateRegionBanner(viewModel.fallbackRegion.value) + }) + } + + private fun updateRegionBanner(region: String?) + { + if (viewModel.catalogIsForeign.value != true) { + binding.regionBanner.visibility = View.GONE + } else { + val label = region?.takeIf { it.isNotEmpty() } ?: "foreign" + binding.regionBanner.text = + "PlayStation cloud isn't offered natively in your region — showing the $label catalog. Some titles may not stream." + binding.regionBanner.visibility = View.VISIBLE + } } private fun updateEmptyState(isEmpty: Boolean) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt index 8b01fc16..c16ad3ab 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt @@ -49,6 +49,9 @@ class CloudPlayViewModel( private val _fallbackRegion = MutableLiveData() val fallbackRegion: LiveData get() = _fallbackRegion + private val _catalogIsForeign = MutableLiveData() + val catalogIsForeign: LiveData get() = _catalogIsForeign + private val _searchQuery = MutableLiveData() val searchQuery: LiveData get() = _searchQuery @@ -67,7 +70,8 @@ class CloudPlayViewModel( _loading.value = false _error.value = null _searchQuery.value = "" - _fallbackRegion.value = preferences.getCloudFallbackRegion() + _fallbackRegion.value = preferences.getCloudResolvedStoreCountry() + _catalogIsForeign.value = preferences.isCloudCatalogIsForeign() } /** @@ -118,7 +122,8 @@ class CloudPlayViewModel( } finally { - _fallbackRegion.value = preferences.getCloudFallbackRegion() + _fallbackRegion.value = preferences.getCloudResolvedStoreCountry() + _catalogIsForeign.value = preferences.isCloudCatalogIsForeign() updateLocaleWarningIfNeeded() _loading.value = false fetchInProgress = false @@ -199,7 +204,7 @@ class CloudPlayViewModel( { if (!_warning.value.isNullOrEmpty()) return - if (!preferences.isCloudLanguageConfigured()) + if (!preferences.isCloudStoreLocaleConfigured()) _warning.value = CloudLocale.unconfiguredWarning() } } diff --git a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt index 5fa980a3..48726f5f 100644 --- a/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt @@ -67,7 +67,7 @@ class DataStore(val preferences: Preferences): PreferenceDataStore() preferences.codecKey -> preferences.codec.value preferences.cloudDatacenterPsnowKey -> preferences.getCloudDatacenterPsnow() preferences.cloudDatacenterPscloudKey -> preferences.getCloudDatacenterPscloud() - preferences.cloudLanguageKey -> preferences.getStreamLanguage() + preferences.cloudLanguageKey -> preferences.getCloudGameLanguage() preferences.cloudResolutionPscloudKey -> preferences.getCloudResolutionPscloud().toString() preferences.cloudResolutionPsnowKey -> preferences.getCloudResolutionPsnow().toString() preferences.dpadTouchShortcut1Key -> preferences.dpadTouchShortcut1.toString() @@ -102,7 +102,7 @@ class DataStore(val preferences: Preferences): PreferenceDataStore() // Manual streaming-language override. Stored separately from the // catalog locale and does not touch the datacenter; the user picks a // matching datacenter themselves. - preferences.cloudLanguageKey -> preferences.setStreamLanguage(value ?: "") + preferences.cloudLanguageKey -> preferences.setCloudGameLanguage(value ?: "") preferences.cloudResolutionPscloudKey -> preferences.setCloudResolutionPscloud(value?.toIntOrNull() ?: 720) preferences.cloudResolutionPsnowKey -> preferences.setCloudResolutionPsnow(value?.toIntOrNull() ?: 720) preferences.dpadTouchShortcut1Key -> preferences.dpadTouchShortcut1 = value?.toIntOrNull() ?: Preferences.DPAD_TOUCH_SHORTCUT1_DEFAULT @@ -218,7 +218,7 @@ class SettingsFragment: PreferenceFragmentCompat(), TitleFragment // Game language list (Auto + all supported languages). populateCloudLanguagePreference( preferenceScreen.findPreference(getString(R.string.preferences_cloud_language_key)), - preferences.getCloudLanguage() + preferences.getCloudStoreLocale() ) bindCloudBitratePreference( diff --git a/gui/include/qmlsettings.h b/gui/include/qmlsettings.h index a019b480..b575f5cf 100644 --- a/gui/include/qmlsettings.h +++ b/gui/include/qmlsettings.h @@ -17,14 +17,13 @@ class QmlSettings : public QObject Q_PROPERTY(int resolutionRemotePS5 READ resolutionRemotePS5 WRITE setResolutionRemotePS5 NOTIFY resolutionRemotePS5Changed) // PSCloud settings Q_PROPERTY(int cloudResolutionPSCloud READ cloudResolutionPSCloud WRITE setCloudResolutionPSCloud NOTIFY cloudResolutionPSCloudChanged) - Q_PROPERTY(QString cloudLanguagePSCloud READ cloudLanguagePSCloud WRITE setCloudLanguagePSCloud NOTIFY cloudLanguagePSCloudChanged) - Q_PROPERTY(QString cloudStreamLanguage READ cloudStreamLanguage WRITE setCloudStreamLanguage NOTIFY cloudStreamLanguageChanged) + Q_PROPERTY(QString cloudStoreLocale READ cloudStoreLocale WRITE setCloudStoreLocale NOTIFY cloudStoreLocaleChanged) + Q_PROPERTY(QString cloudGameLanguage READ cloudGameLanguage WRITE setCloudGameLanguage NOTIFY cloudGameLanguageChanged) Q_PROPERTY(QString cloudDatacenterPSCloud READ cloudDatacenterPSCloud WRITE setCloudDatacenterPSCloud NOTIFY cloudDatacenterPSCloudChanged) Q_PROPERTY(QString cloudDatacentersJsonPSCloud READ cloudDatacentersJsonPSCloud NOTIFY cloudDatacentersJsonPSCloudChanged) Q_PROPERTY(int cloudBitratePSCloud READ cloudBitratePSCloud WRITE setCloudBitratePSCloud NOTIFY cloudBitratePSCloudChanged) // PSNOW settings Q_PROPERTY(int cloudResolutionPSNOW READ cloudResolutionPSNOW WRITE setCloudResolutionPSNOW NOTIFY cloudResolutionPSNOWChanged) - Q_PROPERTY(QString cloudLanguagePSNOW READ cloudLanguagePSNOW WRITE setCloudLanguagePSNOW NOTIFY cloudLanguagePSNOWChanged) Q_PROPERTY(QString cloudDatacenterPSNOW READ cloudDatacenterPSNOW WRITE setCloudDatacenterPSNOW NOTIFY cloudDatacenterPSNOWChanged) Q_PROPERTY(QString cloudDatacentersJsonPSNOW READ cloudDatacentersJsonPSNOW NOTIFY cloudDatacentersJsonPSNOWChanged) Q_PROPERTY(int cloudBitratePSNOW READ cloudBitratePSNOW WRITE setCloudBitratePSNOW NOTIFY cloudBitratePSNOWChanged) @@ -96,7 +95,8 @@ class QmlSettings : public QObject Q_PROPERTY(QString lastSelectedCloudSection READ lastSelectedCloudSection WRITE setLastSelectedCloudSection NOTIFY lastSelectedCloudSectionChanged) Q_PROPERTY(QString cloudLibraryFilter READ cloudLibraryFilter WRITE setCloudLibraryFilter NOTIFY cloudLibraryFilterChanged) Q_PROPERTY(QString cloudCatalogFilter READ cloudCatalogFilter WRITE setCloudCatalogFilter NOTIFY cloudCatalogFilterChanged) - Q_PROPERTY(QString cloudFallbackRegion READ cloudFallbackRegion WRITE setCloudFallbackRegion NOTIFY cloudFallbackRegionChanged) + Q_PROPERTY(QString cloudResolvedStoreCountry READ cloudResolvedStoreCountry WRITE setCloudResolvedStoreCountry NOTIFY cloudResolvedStoreCountryChanged) + Q_PROPERTY(bool cloudCatalogNativeMode READ cloudCatalogNativeMode WRITE setCloudCatalogNativeMode NOTIFY cloudCatalogNativeModeChanged) Q_PROPERTY(QString cloudTagFilters READ cloudTagFilters WRITE setCloudTagFilters NOTIFY cloudTagFiltersChanged) Q_PROPERTY(int cloudSortState READ cloudSortState WRITE setCloudSortState NOTIFY cloudSortStateChanged) Q_PROPERTY(QString cloudFavorites READ cloudFavorites WRITE setCloudFavorites NOTIFY cloudFavoritesChanged) @@ -229,10 +229,10 @@ class QmlSettings : public QObject // PSCloud settings int cloudResolutionPSCloud() const; void setCloudResolutionPSCloud(int resolution); - QString cloudLanguagePSCloud() const; - void setCloudLanguagePSCloud(const QString &language); - QString cloudStreamLanguage() const; - void setCloudStreamLanguage(const QString &language); + QString cloudStoreLocale() const; + void setCloudStoreLocale(const QString &locale); + QString cloudGameLanguage() const; + void setCloudGameLanguage(const QString &language); QString cloudDatacenterPSCloud() const; void setCloudDatacenterPSCloud(const QString &datacenter); QString cloudDatacentersJsonPSCloud() const; @@ -241,8 +241,6 @@ class QmlSettings : public QObject // PSNOW settings int cloudResolutionPSNOW() const; void setCloudResolutionPSNOW(int resolution); - QString cloudLanguagePSNOW() const; - void setCloudLanguagePSNOW(const QString &language); QString cloudDatacenterPSNOW() const; void setCloudDatacenterPSNOW(const QString &datacenter); QString cloudDatacentersJsonPSNOW() const; @@ -575,8 +573,10 @@ class QmlSettings : public QObject QString cloudCatalogFilter() const; void setCloudCatalogFilter(const QString &filter); - QString cloudFallbackRegion() const; - void setCloudFallbackRegion(const QString ®ion); + QString cloudResolvedStoreCountry() const; + void setCloudResolvedStoreCountry(const QString &country); + bool cloudCatalogNativeMode() const; + void setCloudCatalogNativeMode(bool native_mode); QString cloudTagFilters() const; void setCloudTagFilters(const QString &filtersJson); @@ -658,10 +658,8 @@ class QmlSettings : public QObject Q_INVOKABLE QString stringForStreamMenuShortcut() const; Q_INVOKABLE QString getLicenseText() const; - // Cloud streaming language picker, backed by the shared libchiaki table - // (chiaki/cloudcatalog.h). Game language is tied to the datacenter region. + // Cloud streaming language picker, backed by the shared libchiaki table. Q_INVOKABLE QStringList cloudSupportedLanguages() const; - Q_INVOKABLE bool cloudDatacenterServesLanguage(const QString &datacenterName, const QString &locale) const; signals: void resolutionLocalPS4Changed(); @@ -669,13 +667,12 @@ class QmlSettings : public QObject void resolutionLocalPS5Changed(); void resolutionRemotePS5Changed(); void cloudResolutionPSCloudChanged(); - void cloudLanguagePSCloudChanged(); - void cloudStreamLanguageChanged(); + void cloudStoreLocaleChanged(); + void cloudGameLanguageChanged(); void cloudDatacenterPSCloudChanged(); void cloudDatacentersJsonPSCloudChanged(); void cloudBitratePSCloudChanged(); void cloudResolutionPSNOWChanged(); - void cloudLanguagePSNOWChanged(); void cloudDatacenterPSNOWChanged(); void cloudDatacentersJsonPSNOWChanged(); void cloudBitratePSNOWChanged(); @@ -746,7 +743,8 @@ class QmlSettings : public QObject void lastSelectedCloudSectionChanged(); void cloudLibraryFilterChanged(); void cloudCatalogFilterChanged(); - void cloudFallbackRegionChanged(); + void cloudResolvedStoreCountryChanged(); + void cloudCatalogNativeModeChanged(); void cloudTagFiltersChanged(); void cloudSortStateChanged(); void cloudFavoritesChanged(); diff --git a/gui/include/settings.h b/gui/include/settings.h index 5f983850..1fd094b0 100644 --- a/gui/include/settings.h +++ b/gui/include/settings.h @@ -308,10 +308,10 @@ class Settings : public QObject // PSCloud settings int GetCloudResolutionPSCloud() const; void SetCloudResolutionPSCloud(int resolution); - QString GetCloudLanguagePSCloud() const; - void SetCloudLanguagePSCloud(const QString &language); - QString GetCloudStreamLanguage() const; - void SetCloudStreamLanguage(const QString &language); + QString GetCloudStoreLocale() const; + void SetCloudStoreLocale(const QString &locale); + QString GetCloudGameLanguage() const; + void SetCloudGameLanguage(const QString &language); QString GetCloudDatacenterPSCloud() const; void SetCloudDatacenterPSCloud(const QString &datacenter); QString GetCloudDatacentersJsonPSCloud() const; // JSON array of datacenters with ping results @@ -324,8 +324,6 @@ class Settings : public QObject // PSNOW settings int GetCloudResolutionPSNOW() const; void SetCloudResolutionPSNOW(int resolution); - QString GetCloudLanguagePSNOW() const; - void SetCloudLanguagePSNOW(const QString &language); QString GetCloudDatacenterPSNOW() const; void SetCloudDatacenterPSNOW(const QString &datacenter); QString GetCloudDatacentersJsonPSNOW() const; // JSON array of datacenters with ping results @@ -452,10 +450,12 @@ class Settings : public QObject QString GetCloudCatalogFilter() const; void SetCloudCatalogFilter(QString filter); - /** PS Now region-group fallback. Empty = native mode; "US"/"GB" = fallback mode. */ - QString GetCloudFallbackRegion() const; - void SetCloudFallbackRegion(const QString ®ion); - bool IsCloudFallbackMode() const; + /** PS Now region-group fallback store country. Empty = native mode; "US"/"GB" = fallback mode. */ + QString GetCloudResolvedStoreCountry() const; + void SetCloudResolvedStoreCountry(const QString &country); + bool GetCloudCatalogNativeMode() const; + void SetCloudCatalogNativeMode(bool native_mode); + bool IsCloudCatalogIsForeign() const; /** Persisted acquisition-tag filter JSON array; empty/[] = show all. */ QString GetCloudTagFilters() const; diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index f71f98b9..26a68974 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -120,7 +120,7 @@ QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) return QString(); } - const QString expectedLocale = settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US"); + const QString expectedLocale = settings ? settings->GetCloudStoreLocale() : QStringLiteral("en-US"); const QString cachedLocale = doc.object().value(QStringLiteral("locale")).toString(); if (!cachedLocale.isEmpty() && cachedLocale != expectedLocale) { qInfo() << "[CACHE LOCALE MISMATCH] PS5 catalog v3 locale" << cachedLocale @@ -178,7 +178,7 @@ void CloudCatalogBackend::fetchUnifiedCatalog(const QJSValue &callback) const QByteArray npsso = getNpSsoToken().toUtf8(); const QByteArray locale = - (settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US")).toUtf8(); + (settings ? settings->GetCloudStoreLocale() : QStringLiteral("en-US")).toUtf8(); const QByteArray cacheDir = cacheDirectory.toUtf8(); std::thread([this, cb, npsso, locale, cacheDir]() mutable { @@ -214,14 +214,16 @@ void CloudCatalogBackend::fetchUnifiedCatalog(const QJSValue &callback) // Persist the locale the lib actually settled on (region detection now lives // entirely in libchiaki: it re-bases the locale on the account's Kamaji-session // country and resolves the imagic store-locale chain, returning "settledLocale"). - // Mirrors iOS noteSettledLocale / Android noteCloudLanguageSettled. Uses the core + // Mirrors iOS noteSettledLocale / Android noteCloudStoreLocaleSettled. Uses the core // Settings setter (NOT QmlSettings), so it does NOT invalidate the cache the lib // just wrote; otherwise an international account would thrash the catalog. if (success && settings) { - const QString settled = QJsonDocument::fromJson(json.toUtf8()) - .object().value(QStringLiteral("settledLocale")).toString(); - if (!settled.isEmpty() && settled != settings->GetCloudLanguagePSCloud()) - settings->SetCloudLanguagePSCloud(settled); + const QJsonObject root = QJsonDocument::fromJson(json.toUtf8()).object(); + const QString settled = root.value(QStringLiteral("settledLocale")).toString(); + if (!settled.isEmpty() && settled != settings->GetCloudStoreLocale()) + settings->SetCloudStoreLocale(settled); + settings->SetCloudResolvedStoreCountry(root.value(QStringLiteral("fallbackRegion")).toString()); + settings->SetCloudCatalogNativeMode(root.value(QStringLiteral("nativeMode")).toBool(true)); } const QJSValue payload = success ? QJSValue(json) : QJSValue(); @@ -263,7 +265,7 @@ void CloudCatalogBackend::fetchGameDetails(const QString &productId, const QJSVa void CloudCatalogBackend::executeGameDetailsFetch(const QString &productId) { // Get locale from unified language setting - QString localeSetting = settings ? settings->GetCloudLanguagePSCloud() : "en-US"; + QString localeSetting = settings ? settings->GetCloudStoreLocale() : "en-US"; QString locale = localeSetting.toLower(); // Convert "en-US" to "en-us" // Extract country and language from locale (e.g., "en-us" -> "US", "en") diff --git a/gui/src/cloudstreaming/psgaikaistreaming.cpp b/gui/src/cloudstreaming/psgaikaistreaming.cpp index b66f9773..2ef0d276 100644 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ b/gui/src/cloudstreaming/psgaikaistreaming.cpp @@ -156,9 +156,9 @@ QJsonObject PSGaikaiStreaming::buildRequestGameSpec(QString entitlementId) // manual pick lives in its own setting so the catalog's settledLocale write // can never clobber it. Gaikai expects the bare language code ("de"), not // the stored locale ("de-DE"); the lib helper is the single source of truth. - QString locale = settings->GetCloudStreamLanguage(); + QString locale = settings->GetCloudGameLanguage(); if (locale.isEmpty()) - locale = settings->GetCloudLanguagePSCloud(); + locale = settings->GetCloudStoreLocale(); char gaikaiLang[16]; chiaki_cloud_gaikai_language(locale.toUtf8().constData(), gaikaiLang, sizeof(gaikaiLang)); int resolution; diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index b3960d22..9485c367 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -318,7 +318,7 @@ void PSKamajiSession::handleAnonSessionResponse(QNetworkReply *reply) void PSKamajiSession::step0_5d_ConvertProductId() { // Get locale from unified language setting - QString localeSetting = settings ? settings->GetCloudLanguagePSCloud() : "en-US"; + QString localeSetting = settings ? settings->GetCloudStoreLocale() : "en-US"; QString locale = localeSetting.toLower(); // Convert "en-US" to "en-us" // Extract country and language from locale (e.g., "en-us" -> "US", "en") @@ -330,8 +330,8 @@ void PSKamajiSession::step0_5d_ConvertProductId() // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. - if (settings && settings->IsCloudFallbackMode()) { - country = settings->GetCloudFallbackRegion(); + if (settings && settings->IsCloudCatalogIsForeign()) { + country = settings->GetCloudResolvedStoreCountry(); language = QStringLiteral("en"); qInfo() << "Kamaji Step 0.5d: Fallback mode -> region-group container: country=" << country << "language=" << language; @@ -918,7 +918,7 @@ void PSKamajiSession::handleCheckEntitlementResponse(QNetworkReply *reply) // Region-group fallback: unsupported regions have no pcnow storefront, so the // free checkout-acquire fails. Skip it and let Gaikai validate the subscription. // Native (supported region): run the normal checkout-acquire for PS3 + PS4 + PS5 alike. - if (settings && settings->IsCloudFallbackMode()) { + if (settings && settings->IsCloudCatalogIsForeign()) { qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai"; step5_GetAuthCode(); return; diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index b94d89e0..6448c3bf 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -28,6 +28,7 @@ Pane { property string searchQuery: "" property string authErrorMessage: "" property string fallbackRegion: "" + property bool catalogNativeMode: true property var activeTagFilters: [] // empty = show all; values: owned, streamable, purchaseable property bool showFavoritesOnly: false property int sortState: 0 // 0=Playable First, 1=A-Z, 2=Z-A @@ -55,7 +56,8 @@ Pane { } Component.onCompleted: { - fallbackRegion = Chiaki.settings.cloudFallbackRegion || ""; + fallbackRegion = Chiaki.settings.cloudResolvedStoreCountry || ""; + catalogNativeMode = Chiaki.settings.cloudCatalogNativeMode; sortState = Chiaki.settings.cloudSortState || 0; let savedTagFilters = Chiaki.settings.cloudTagFilters; if (savedTagFilters) { @@ -244,7 +246,9 @@ Pane { if (data.games && Array.isArray(data.games)) { allGames = data.games; fallbackRegion = data.fallbackRegion || ""; - Chiaki.settings.cloudFallbackRegion = fallbackRegion; + catalogNativeMode = data.nativeMode !== false; + Chiaki.settings.cloudResolvedStoreCountry = fallbackRegion; + Chiaki.settings.cloudCatalogNativeMode = catalogNativeMode; if (data.warning) authErrorMessage = data.warning; else if (npssoToken && npssoToken.trim().length > 0) @@ -957,8 +961,8 @@ Pane { Rectangle { id: fallbackBanner Layout.fillWidth: true - Layout.preferredHeight: fallbackRegion.length > 0 ? 56 : 0 - visible: fallbackRegion.length > 0 + Layout.preferredHeight: !catalogNativeMode ? 56 : 0 + visible: !catalogNativeMode color: Qt.rgba(255/255, 193/255, 7/255, 0.2) border.color: "#FFC107" border.width: 2 diff --git a/gui/src/qml/SettingsDialog.qml b/gui/src/qml/SettingsDialog.qml index 76a52638..b4621b89 100644 --- a/gui/src/qml/SettingsDialog.qml +++ b/gui/src/qml/SettingsDialog.qml @@ -2823,7 +2823,7 @@ DialogView { // value) clears the override so the auto-detected // catalog/region locale is used instead. let supported = Chiaki.settings.cloudSupportedLanguages(); - let catalogLocale = Chiaki.settings.cloudLanguagePSCloud || "en-US"; + let catalogLocale = Chiaki.settings.cloudStoreLocale || "en-US"; let values = [""]; let labels = [qsTr("Auto") + " (" + catalogLocale + ")"]; for (let i = 0; i < supported.length; i++) { @@ -2836,13 +2836,13 @@ DialogView { } currentIndex: { // Empty override selects "Auto" (index 0). - let sel = Chiaki.settings.cloudStreamLanguage || ""; + let sel = Chiaki.settings.cloudGameLanguage || ""; let idx = languageValues.indexOf(sel); return idx >= 0 ? idx : 0; } onActivated: index => { // "" (Auto) clears the override; otherwise store the pick. - Chiaki.settings.cloudStreamLanguage = languageValues[index] || ""; + Chiaki.settings.cloudGameLanguage = languageValues[index] || ""; } } diff --git a/gui/src/qmlbackend.cpp b/gui/src/qmlbackend.cpp index b801a47d..43e5bf68 100644 --- a/gui/src/qmlbackend.cpp +++ b/gui/src/qmlbackend.cpp @@ -146,7 +146,7 @@ QmlBackend::QmlBackend(Settings *settings, QmlMainWindow *window, SteamworksWrap #endif cloud_streaming_backend = new CloudStreamingBackend(settings, this); cloud_catalog_backend = new CloudCatalogBackend(settings, this); - connect(settings_qml, &QmlSettings::cloudLanguagePSCloudChanged, this, [this]() { + connect(settings_qml, &QmlSettings::cloudStoreLocaleChanged, this, [this]() { // Full wipe (not just the v6 PS5 intermediates): the unified catalog is locale-specific, // so a language change must also drop unified_catalog_v3 or a stale-locale list is served. cloud_catalog_backend->invalidateCache(); diff --git a/gui/src/qmlsettings.cpp b/gui/src/qmlsettings.cpp index d633ad30..40ac8f5c 100644 --- a/gui/src/qmlsettings.cpp +++ b/gui/src/qmlsettings.cpp @@ -197,26 +197,26 @@ void QmlSettings::setCloudResolutionPSCloud(int resolution) emit cloudResolutionPSCloudChanged(); } -QString QmlSettings::cloudLanguagePSCloud() const +QString QmlSettings::cloudStoreLocale() const { - return settings->GetCloudLanguagePSCloud(); + return settings->GetCloudStoreLocale(); } -void QmlSettings::setCloudLanguagePSCloud(const QString &language) +void QmlSettings::setCloudStoreLocale(const QString &locale) { - settings->SetCloudLanguagePSCloud(language); - emit cloudLanguagePSCloudChanged(); + settings->SetCloudStoreLocale(locale); + emit cloudStoreLocaleChanged(); } -QString QmlSettings::cloudStreamLanguage() const +QString QmlSettings::cloudGameLanguage() const { - return settings->GetCloudStreamLanguage(); + return settings->GetCloudGameLanguage(); } -void QmlSettings::setCloudStreamLanguage(const QString &language) +void QmlSettings::setCloudGameLanguage(const QString &language) { - settings->SetCloudStreamLanguage(language); - emit cloudStreamLanguageChanged(); + settings->SetCloudGameLanguage(language); + emit cloudGameLanguageChanged(); } QStringList QmlSettings::cloudSupportedLanguages() const @@ -228,12 +228,6 @@ QStringList QmlSettings::cloudSupportedLanguages() const return list; } -bool QmlSettings::cloudDatacenterServesLanguage(const QString &datacenterName, const QString &locale) const -{ - return chiaki_cloud_datacenter_serves_locale(datacenterName.toUtf8().constData(), - locale.toUtf8().constData()); -} - QString QmlSettings::cloudDatacenterPSCloud() const { return settings->GetCloudDatacenterPSCloud(); @@ -273,17 +267,6 @@ void QmlSettings::setCloudResolutionPSNOW(int resolution) emit cloudResolutionPSNOWChanged(); } -QString QmlSettings::cloudLanguagePSNOW() const -{ - return settings->GetCloudLanguagePSNOW(); -} - -void QmlSettings::setCloudLanguagePSNOW(const QString &language) -{ - settings->SetCloudLanguagePSNOW(language); - emit cloudLanguagePSNOWChanged(); -} - QString QmlSettings::cloudDatacenterPSNOW() const { return settings->GetCloudDatacenterPSNOW(); @@ -866,15 +849,26 @@ void QmlSettings::setCloudCatalogFilter(const QString &filter) emit cloudCatalogFilterChanged(); } -QString QmlSettings::cloudFallbackRegion() const +QString QmlSettings::cloudResolvedStoreCountry() const +{ + return settings->GetCloudResolvedStoreCountry(); +} + +void QmlSettings::setCloudResolvedStoreCountry(const QString &country) +{ + settings->SetCloudResolvedStoreCountry(country); + emit cloudResolvedStoreCountryChanged(); +} + +bool QmlSettings::cloudCatalogNativeMode() const { - return settings->GetCloudFallbackRegion(); + return settings->GetCloudCatalogNativeMode(); } -void QmlSettings::setCloudFallbackRegion(const QString ®ion) +void QmlSettings::setCloudCatalogNativeMode(bool native_mode) { - settings->SetCloudFallbackRegion(region); - emit cloudFallbackRegionChanged(); + settings->SetCloudCatalogNativeMode(native_mode); + emit cloudCatalogNativeModeChanged(); } QString QmlSettings::cloudTagFilters() const diff --git a/gui/src/settings.cpp b/gui/src/settings.cpp index b3dbbf31..2bd05f8e 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -438,27 +438,36 @@ void Settings::SetCloudResolutionPSCloud(int resolution) settings.setValue("settings/cloud_resolution_pscloud", resolution); } -QString Settings::GetCloudLanguagePSCloud() const +QString Settings::GetCloudStoreLocale() const { - return settings.value("settings/cloud_language_pscloud", "en-US").toString(); + const QString key = QStringLiteral("settings/cloud_store_locale"); + QString value = settings.value(key).toString(); + if (value.isEmpty()) { + value = settings.value(QStringLiteral("settings/cloud_language_pscloud"), QStringLiteral("en-US")).toString(); + const_cast(this)->settings.setValue(key, value); + } + return value.isEmpty() ? QStringLiteral("en-US") : value; } -void Settings::SetCloudLanguagePSCloud(const QString &language) +void Settings::SetCloudStoreLocale(const QString &locale) { - settings.setValue("settings/cloud_language_pscloud", language); + settings.setValue(QStringLiteral("settings/cloud_store_locale"), locale); } -QString Settings::GetCloudStreamLanguage() const +QString Settings::GetCloudGameLanguage() const { - // Manual streaming-language override chosen in the language picker. Empty - // means "use the catalog locale" (cloud_language_pscloud). Kept separate so - // the auto-detected catalog locale never clobbers the user's pick. - return settings.value("settings/cloud_stream_language", "").toString(); + const QString key = QStringLiteral("settings/cloud_game_language"); + if (!settings.contains(key)) { + const QString migrated = settings.value(QStringLiteral("settings/cloud_stream_language"), QString()).toString(); + const_cast(this)->settings.setValue(key, migrated); + return migrated; + } + return settings.value(key, QString()).toString(); } -void Settings::SetCloudStreamLanguage(const QString &language) +void Settings::SetCloudGameLanguage(const QString &language) { - settings.setValue("settings/cloud_stream_language", language); + settings.setValue(QStringLiteral("settings/cloud_game_language"), language); } QString Settings::GetCloudDatacenterPSCloud() const @@ -560,17 +569,6 @@ ChiakiConnectVideoProfile Settings::GetCloudVideoProfile(const QString &serviceT return profile; } -QString Settings::GetCloudLanguagePSNOW() const -{ - // Fallback to legacy cloud_language if not set (for migration) - return settings.value("settings/cloud_language_psnow", settings.value("settings/cloud_language", "en-US").toString()).toString(); -} - -void Settings::SetCloudLanguagePSNOW(const QString &language) -{ - settings.setValue("settings/cloud_language_psnow", language); -} - QString Settings::GetCloudDatacenterPSNOW() const { // Fallback to legacy cloud_datacenter if not set (for migration) @@ -1041,19 +1039,41 @@ void Settings::SetCloudCatalogFilter(QString filter) settings.setValue("settings/cloud_catalog_filter", filter); } -QString Settings::GetCloudFallbackRegion() const +QString Settings::GetCloudResolvedStoreCountry() const { - return settings.value("settings/cloud_fallback_region", "").toString(); + const QString key = QStringLiteral("settings/cloud_resolved_store_country"); + if (!settings.contains(key)) { + const QString migrated = settings.value(QStringLiteral("settings/cloud_fallback_region"), QString()).toString(); + const_cast(this)->settings.setValue(key, migrated); + return migrated; + } + return settings.value(key, QString()).toString(); +} + +void Settings::SetCloudResolvedStoreCountry(const QString &country) +{ + settings.setValue(QStringLiteral("settings/cloud_resolved_store_country"), country); +} + +bool Settings::GetCloudCatalogNativeMode() const +{ + const QString key = QStringLiteral("settings/cloud_catalog_native_mode"); + if (!settings.contains(key)) { + const bool native = GetCloudResolvedStoreCountry().isEmpty(); + const_cast(this)->settings.setValue(key, native); + return native; + } + return settings.value(key, true).toBool(); } -void Settings::SetCloudFallbackRegion(const QString ®ion) +void Settings::SetCloudCatalogNativeMode(bool native_mode) { - settings.setValue("settings/cloud_fallback_region", region); + settings.setValue(QStringLiteral("settings/cloud_catalog_native_mode"), native_mode); } -bool Settings::IsCloudFallbackMode() const +bool Settings::IsCloudCatalogIsForeign() const { - return !GetCloudFallbackRegion().isEmpty(); + return !GetCloudCatalogNativeMode(); } QString Settings::GetCloudTagFilters() const diff --git a/ios/Pylux/Bridge/CloudCatalogBridge.h b/ios/Pylux/Bridge/CloudCatalogBridge.h index 64d91087..35c88987 100644 --- a/ios/Pylux/Bridge/CloudCatalogBridge.h +++ b/ios/Pylux/Bridge/CloudCatalogBridge.h @@ -26,19 +26,12 @@ NS_ASSUME_NONNULL_BEGIN forceRefresh:(BOOL)forceRefresh errorMessage:(NSString * _Nullable * _Nullable)errorMessage; -// Cloud streaming language helpers, backed by the shared libchiaki table. Game -// language is tied to the datacenter region (Gaikai ignores a language whose -// datacenter is not selected). - /// Bare lowercase language code Gaikai expects ("de-DE" -> "de"); "en" default. + (NSString *)gaikaiLanguageForLocale:(nullable NSString *)locale; /// Locales offered in the language picker (BCP-47, e.g. "en-GB"). + (NSArray *)supportedCloudLanguages; -/// YES if @c datacenterName (4-letter ping name, e.g. "fraa") serves @c locale. -+ (BOOL)datacenter:(NSString *)datacenterName servesLocale:(NSString *)locale; - @end NS_ASSUME_NONNULL_END diff --git a/ios/Pylux/Bridge/CloudCatalogBridge.m b/ios/Pylux/Bridge/CloudCatalogBridge.m index 8eecfb0c..fb417b1f 100644 --- a/ios/Pylux/Bridge/CloudCatalogBridge.m +++ b/ios/Pylux/Bridge/CloudCatalogBridge.m @@ -72,10 +72,4 @@ + (NSString *)gaikaiLanguageForLocale:(NSString *)locale { return out; } -+ (BOOL)datacenter:(NSString *)datacenterName servesLocale:(NSString *)locale { - if (datacenterName.length == 0 || locale.length == 0) - return NO; - return chiaki_cloud_datacenter_serves_locale(datacenterName.UTF8String, locale.UTF8String) ? YES : NO; -} - @end diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index e3d269a2..d013ddb1 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -179,15 +179,22 @@ struct KamajiSessionResult { private let cloudLocaleLog = OSLog(subsystem: "com.pylux.stream", category: "CloudLocale") enum CloudLocaleSettings { - private static let preferencesKey = "cloud_language_pscloud" + private static let preferencesKey = "cloud_store_locale" + private static let legacyPreferencesKey = "cloud_language_pscloud" static let defaultStored = "en-US" static var isConfigured: Bool { UserDefaults.standard.object(forKey: preferencesKey) != nil + || UserDefaults.standard.object(forKey: legacyPreferencesKey) != nil } static var stored: String { - UserDefaults.standard.string(forKey: preferencesKey) ?? defaultStored + if UserDefaults.standard.object(forKey: preferencesKey) != nil { + return UserDefaults.standard.string(forKey: preferencesKey) ?? defaultStored + } + let legacy = UserDefaults.standard.string(forKey: legacyPreferencesKey) ?? defaultStored + UserDefaults.standard.set(legacy, forKey: preferencesKey) + return legacy } static func unconfiguredWarning() -> String { diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 28ab0f70..4790eaec 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -60,7 +60,10 @@ final class CloudCatalogService { if let settled = root["settledLocale"] as? String { CloudLocaleSettings.noteSettledLocale(settled) } - SecureStore.shared.cloudFallbackRegion = root["fallbackRegion"] as? String ?? "" + SecureStore.shared.cloudResolvedStoreCountry = root["fallbackRegion"] as? String ?? "" + if let nativeMode = root["nativeMode"] as? Bool { + SecureStore.shared.cloudCatalogNativeMode = nativeMode + } if let warning = root["warning"] as? String, !warning.isEmpty { lastCatalogFetchWarning = warning diff --git a/ios/Pylux/Services/PSGaikaiStreaming.swift b/ios/Pylux/Services/PSGaikaiStreaming.swift index 8cc88d29..cbfee4a2 100644 --- a/ios/Pylux/Services/PSGaikaiStreaming.swift +++ b/ios/Pylux/Services/PSGaikaiStreaming.swift @@ -768,7 +768,7 @@ final class PSGaikaiStreaming { // Use the user's chosen streaming language, falling back to the detected // catalog locale when unset. let chosenLocale = { - let l = StreamPreferences.load().cloudLanguage + let l = StreamPreferences.load().cloudGameLanguage return l.isEmpty ? CloudLocaleSettings.stored : l }() let cloudLanguage = PyluxCloudCatalog.gaikaiLanguage(forLocale: chosenLocale) diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index ee8ab85f..0d3ae9b8 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -151,7 +151,7 @@ final class PSKamajiSession { let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) var country = storePath.country var language = storePath.language - let fallbackRegion = SecureStore.shared.cloudFallbackRegion + let fallbackRegion = SecureStore.shared.cloudResolvedStoreCountry if !fallbackRegion.isEmpty { country = fallbackRegion language = "en" @@ -246,7 +246,7 @@ final class PSKamajiSession { // Entitlement not found (404). Region-group fallback: skip acquire and proceed to Gaikai. // Native (supported region): run the normal checkout-acquire for PS3 and PS4 alike. - if SecureStore.shared.isCloudFallbackMode { + if SecureStore.shared.isCloudCatalogIsForeign { os_log(.info, log: kamajiLog, "Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai") return true diff --git a/ios/Pylux/Services/SecureStore.swift b/ios/Pylux/Services/SecureStore.swift index 34e57cf5..fe3565ee 100644 --- a/ios/Pylux/Services/SecureStore.swift +++ b/ios/Pylux/Services/SecureStore.swift @@ -156,7 +156,9 @@ final class SecureStore { // Cloud private let kCloudFavorites = "favorite_games" private let kCloudSortState = "cloud_sort_state" - private let kCloudFallbackRegion = "cloud_fallback_region" + private let kCloudResolvedStoreCountry = "cloud_resolved_store_country" + private let kLegacyCloudFallbackRegion = "cloud_fallback_region" + private let kCloudCatalogNativeMode = "cloud_catalog_native_mode" private let kCloudTagFilters = "cloud_tag_filters" // Donation / support paywall @@ -285,13 +287,34 @@ final class SecureStore { set { KC.writeInt(kCloudSortState, newValue) } } - /// PS Now region-group fallback. Empty = native mode; "US" or "GB" = fallback mode. - var cloudFallbackRegion: String { - get { KC.readString(kCloudFallbackRegion) ?? "" } - set { newValue.isEmpty ? KC.delete(kCloudFallbackRegion) : KC.writeString(kCloudFallbackRegion, newValue) } + /// PS Now region-group fallback store country. Empty = native mode; "US" or "GB" = fallback mode. + var cloudResolvedStoreCountry: String { + get { + if KC.readString(kCloudResolvedStoreCountry) != nil { + return KC.readString(kCloudResolvedStoreCountry) ?? "" + } + let legacy = KC.readString(kLegacyCloudFallbackRegion) ?? "" + KC.writeString(kCloudResolvedStoreCountry, legacy) + return legacy + } + set { + newValue.isEmpty ? KC.delete(kCloudResolvedStoreCountry) : KC.writeString(kCloudResolvedStoreCountry, newValue) + } + } + + var cloudCatalogNativeMode: Bool { + get { + if KC.readString(kCloudCatalogNativeMode) != nil { + return KC.readBool(kCloudCatalogNativeMode, default: true) + } + let native = cloudResolvedStoreCountry.isEmpty + KC.writeBool(kCloudCatalogNativeMode, native) + return native + } + set { KC.writeBool(kCloudCatalogNativeMode, newValue) } } - var isCloudFallbackMode: Bool { !cloudFallbackRegion.isEmpty } + var isCloudCatalogIsForeign: Bool { !cloudCatalogNativeMode } /// Persisted acquisition-tag filter selection (empty = show all). var cloudTagFilters: Set { diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 808054df..7c00d727 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -36,7 +36,8 @@ final class CloudPlayViewModel: ObservableObject { @Published var refreshing = false @Published var error: String? @Published var warning: String? - @Published var fallbackRegion: String = SecureStore.shared.cloudFallbackRegion + @Published var fallbackRegion: String = SecureStore.shared.cloudResolvedStoreCountry + @Published var catalogIsForeign: Bool = SecureStore.shared.isCloudCatalogIsForeign @Published var searchQuery = "" @Published var sortOrder: SortOrder = .defaultOrder @Published var showFavoritesOnly = false @@ -153,7 +154,8 @@ final class CloudPlayViewModel: ObservableObject { private func applyLoadedGames(_ loadedGames: [CloudGame]) { games = loadedGames loading = false - fallbackRegion = SecureStore.shared.cloudFallbackRegion + fallbackRegion = SecureStore.shared.cloudResolvedStoreCountry + catalogIsForeign = SecureStore.shared.isCloudCatalogIsForeign if let fetchError = catalogService.lastLibraryFetchError { error = fetchError } else if loadedGames.isEmpty { @@ -288,7 +290,7 @@ struct CloudPlayView: View { VStack(spacing: 0) { cloudSubTabs - if !viewModel.fallbackRegion.isEmpty { + if viewModel.catalogIsForeign { Text("Cloud catalog isn't fully available in your region; some titles may not stream.") .font(.caption) .foregroundColor(.black.opacity(0.85)) diff --git a/ios/Pylux/Views/SettingsView.swift b/ios/Pylux/Views/SettingsView.swift index 91d477ce..eb9eaf77 100644 --- a/ios/Pylux/Views/SettingsView.swift +++ b/ios/Pylux/Views/SettingsView.swift @@ -84,8 +84,8 @@ struct StreamPreferences: Codable { var cloudBitratePsnow: Int = 20000 // kbps, matches Qt/Android default 20 Mbps /// Cloud streaming game language (BCP-47, e.g. "de-DE"). Empty = follow the - /// detected catalog locale. Game language is tied to the datacenter region. - var cloudLanguage: String = "" + /// detected catalog locale. + var cloudGameLanguage: String = "" static let cloudBitrateMinKbps = 2000 static let cloudBitrateMaxKbps = 200_000 @@ -97,7 +97,8 @@ struct StreamPreferences: Codable { case onScreenControlsEnabled, touchpadOnlyEnabled case cloudResolutionPscloud, cloudDatacenterPscloud, cloudBitratePscloud case cloudResolutionPsnow, cloudDatacenterPsnow, cloudBitratePsnow - case cloudLanguage + case cloudGameLanguage + case cloudLanguage // legacy key } init( @@ -118,7 +119,7 @@ struct StreamPreferences: Codable { cloudResolutionPsnow: String = "720", cloudDatacenterPsnow: String = "Auto", cloudBitratePsnow: Int = StreamPreferences.cloudBitrateDefaultKbps, - cloudLanguage: String = "" + cloudGameLanguage: String = "" ) { self.resolutionIndex = resolutionIndex self.fps = fps @@ -137,7 +138,7 @@ struct StreamPreferences: Codable { self.cloudResolutionPsnow = cloudResolutionPsnow self.cloudDatacenterPsnow = cloudDatacenterPsnow self.cloudBitratePsnow = Self.clampCloudBitrateKbps(cloudBitratePsnow) - self.cloudLanguage = cloudLanguage + self.cloudGameLanguage = cloudGameLanguage } init(from decoder: Decoder) throws { @@ -163,7 +164,8 @@ struct StreamPreferences: Codable { cloudBitratePsnow = Self.clampCloudBitrateKbps( try c.decodeIfPresent(Int.self, forKey: .cloudBitratePsnow) ?? Self.cloudBitrateDefaultKbps ) - cloudLanguage = try c.decodeIfPresent(String.self, forKey: .cloudLanguage) ?? "" + cloudGameLanguage = try c.decodeIfPresent(String.self, forKey: .cloudGameLanguage) + ?? c.decodeIfPresent(String.self, forKey: .cloudLanguage) ?? "" } func encode(to encoder: Encoder) throws { @@ -185,7 +187,7 @@ struct StreamPreferences: Codable { try c.encode(cloudResolutionPsnow, forKey: .cloudResolutionPsnow) try c.encode(cloudDatacenterPsnow, forKey: .cloudDatacenterPsnow) try c.encode(cloudBitratePsnow, forKey: .cloudBitratePsnow) - try c.encode(cloudLanguage, forKey: .cloudLanguage) + try c.encode(cloudGameLanguage, forKey: .cloudGameLanguage) } static func clampCloudBitrateKbps(_ kbps: Int) -> Int { @@ -646,12 +648,12 @@ struct SettingsView: View { // the user picks a matching datacenter themselves. let supported = PyluxCloudCatalog.supportedCloudLanguages() let catalogLocale = CloudLocaleSettings.stored.isEmpty ? "en-US" : CloudLocaleSettings.stored - let current = prefs.cloudLanguage + let current = prefs.cloudGameLanguage let selection = Binding( // Empty override selects "Auto"; an unknown value also falls back to Auto. get: { (current.isEmpty || supported.contains(current)) ? current : "" }, set: { newValue in - prefs.cloudLanguage = newValue + prefs.cloudGameLanguage = newValue prefs.save() // Surface the datacenter caveat only when overriding to a // specific language (Auto needs no warning). diff --git a/lib/include/chiaki/cloudcatalog.h b/lib/include/chiaki/cloudcatalog.h index fdd7b4c2..6293fe2a 100644 --- a/lib/include/chiaki/cloudcatalog.h +++ b/lib/include/chiaki/cloudcatalog.h @@ -123,12 +123,6 @@ CHIAKI_EXPORT size_t chiaki_cloud_supported_locale_count(void); * its own UI resources keyed off this code). */ CHIAKI_EXPORT const char *chiaki_cloud_supported_locale(size_t idx); -/** True if @p datacenter_name (the 4-letter ping-result name, e.g. "fraa", - * "stoa") serves @p locale. Matches on the 3-letter region prefix. Use to - * filter the picker to reachable datacenters and to auto-select the server for - * a chosen language. */ -CHIAKI_EXPORT bool chiaki_cloud_datacenter_serves_locale(const char *datacenter_name, const char *locale); - #ifdef __cplusplus } #endif diff --git a/lib/src/cloudcatalog_consts.c b/lib/src/cloudcatalog_consts.c index 0e2ac996..d4a074f8 100644 --- a/lib/src/cloudcatalog_consts.c +++ b/lib/src/cloudcatalog_consts.c @@ -119,31 +119,10 @@ size_t cc_build_store_locale_chain(const char *stored, char **out, size_t max) } // --------------------------------------------------------------------------- -// Cloud streaming language / datacenter table -// -// Game language is tied to the datacenter region (Gaikai ignores a language -// whose datacenter is not selected). One (locale -> 3-letter datacenter region -// prefix) table backs the cross-platform language picker. A locale may be -// served by several regions (en-GB: London + Stockholm) and a region may serve -// several locales (Stockholm: en-GB + fi-FI). The picker lists every supported -// locale; chiaki_cloud_datacenter_serves_locale() lets a client best-effort -// auto-select a reachable datacenter that serves the chosen language. +// Cloud streaming language picker locales (display order). Platforms render +// localized names; datacenter selection is independent of language choice. // --------------------------------------------------------------------------- -static const struct { const char *locale; const char *prefix; } kLocaleDatacenters[] = { - { "en-US", "sjc" }, { "en-US", "iad" }, // US West / US East - { "en-GB", "lon" }, { "en-GB", "sto" }, // London / Stockholm (Nordic) - { "de-DE", "fra" }, // Frankfurt - { "fr-FR", "par" }, // Paris - { "fi-FI", "sto" }, // Stockholm - { "it-IT", "mil" }, // Milan - { "es-ES", "mad" }, // Madrid - { "nl-NL", "ams" }, // Amsterdam - { "pt-BR", "sao" }, // Sao Paulo - { "ja-JP", "tyo" }, { "ja-JP", "osa" }, // Tokyo / Osaka - { "ko-KR", "sel" }, // Seoul -}; - // Distinct picker locales, in display order. Platforms render localized names. static const char *const kSupportedLocales[] = { "en-US", "en-GB", "de-DE", "fr-FR", "fi-FI", @@ -175,19 +154,3 @@ const char *chiaki_cloud_supported_locale(size_t idx) { return idx < chiaki_cloud_supported_locale_count() ? kSupportedLocales[idx] : ""; } - -bool chiaki_cloud_datacenter_serves_locale(const char *datacenter_name, const char *locale) -{ - if(!datacenter_name || !locale || !*datacenter_name || !*locale) - return false; - char prefix[4] = { 0 }; - for(size_t i = 0; i < 3 && datacenter_name[i]; i++) - prefix[i] = (char)tolower((unsigned char)datacenter_name[i]); - if(!prefix[0]) - return false; - for(size_t i = 0; i < sizeof(kLocaleDatacenters) / sizeof(kLocaleDatacenters[0]); i++) - if(strcmp(kLocaleDatacenters[i].prefix, prefix) == 0 - && strcasecmp(kLocaleDatacenters[i].locale, locale) == 0) - return true; - return false; -} diff --git a/test/cloudcatalog_merge.c b/test/cloudcatalog_merge.c index f57c6af9..3106867a 100644 --- a/test/cloudcatalog_merge.c +++ b/test/cloudcatalog_merge.c @@ -254,16 +254,6 @@ static MunitResult test_cloud_language_helpers(const MunitParameter params[], vo chiaki_cloud_gaikai_language(NULL, buf, sizeof(buf)); munit_assert_string_equal(buf, "en"); - // Datacenter region prefix match (incl. multi-locale Stockholm). - munit_assert_true(chiaki_cloud_datacenter_serves_locale("fraa", "de-DE")); - munit_assert_true(chiaki_cloud_datacenter_serves_locale("frab", "de-DE")); - munit_assert_true(chiaki_cloud_datacenter_serves_locale("stoa", "fi-FI")); - munit_assert_true(chiaki_cloud_datacenter_serves_locale("stoa", "en-GB")); - munit_assert_true(chiaki_cloud_datacenter_serves_locale("FRAA", "de-de")); // case-insensitive - munit_assert_false(chiaki_cloud_datacenter_serves_locale("fraa", "fi-FI")); - munit_assert_false(chiaki_cloud_datacenter_serves_locale("", "de-DE")); - munit_assert_false(chiaki_cloud_datacenter_serves_locale("fraa", "")); - // Supported locale enumeration. size_t n = chiaki_cloud_supported_locale_count(); munit_assert_int((int)n, >=, 5); From 0da713026ef8b9b5cea8e8c3026568e94fcb2ee9 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 03:31:40 -0700 Subject: [PATCH 20/72] Phase 2: server-authoritative store country from base_url; step0_5d uses resolvedStoreCountry Co-authored-by: Cursor --- .../chiaki/cloudplay/api/PSKamajiSession.kt | 23 +++----- gui/src/cloudstreaming/pskamajisession.cpp | 30 +++++------ ios/Pylux/Services/PSKamajiSession.swift | 22 ++++---- lib/include/chiaki/cloudcatalog.h | 2 +- lib/src/cloudcatalog_fetch.c | 53 +++++++++++++++++-- lib/src/cloudcatalog_internal.h | 5 +- lib/src/cloudcatalog_unified.c | 12 ++++- 7 files changed, 98 insertions(+), 49 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index aa418a52..45f5d3ca 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -308,23 +308,14 @@ class PSKamajiSession( { try { - val localeSetting = preferences.getCloudStoreLocale() - var (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) - Log.i(TAG, "Using locale from settings: $localeSetting -> country=$country, language=$language") - - // Region-group fallback: when /user/stores has no storefront for the account's region, the - // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container - // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same - // region-group store -- the account's own locale country would 404 ("Storefront not found"). - // Driven by the account-level fallback flag, so PS3 and PS4 behave identically. In native - // mode the account's own locale resolves both. - val fallbackRegion = preferences.getCloudResolvedStoreCountry() - if (fallbackRegion.isNotEmpty()) - { - country = fallbackRegion - language = "en" - Log.i(TAG, "Fallback mode -> region-group container: country=$country, language=$language") + val resolvedCountry = preferences.getCloudResolvedStoreCountry() + val (country, language) = if (resolvedCountry.isNotEmpty()) { + resolvedCountry to "en" + } else { + val localeSetting = preferences.getCloudStoreLocale() + com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) } + Log.i(TAG, "step0_5d: using resolvedStoreCountry=$country (lang=$language) for container URL") val url = "$storeBase/container/$country/$language/19/$productId?useOffers=true&gkb=1&gkb2=1" Log.d(TAG, "Step 0.5d: Convert Product ID") diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index 9485c367..bf460795 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -317,25 +317,21 @@ void PSKamajiSession::handleAnonSessionResponse(QNetworkReply *reply) // ============================================================================ void PSKamajiSession::step0_5d_ConvertProductId() { - // Get locale from unified language setting - QString localeSetting = settings ? settings->GetCloudStoreLocale() : "en-US"; - QString locale = localeSetting.toLower(); // Convert "en-US" to "en-us" - - // Extract country and language from locale (e.g., "en-us" -> "US", "en") - QStringList localeParts = locale.split("-"); - QString country = localeParts.size() > 1 ? localeParts[1].toUpper() : "US"; - QString language = localeParts[0].toLower(); - - // Region-group fallback: when /user/stores has no storefront for the account's region, the - // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container - // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same - // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. - if (settings && settings->IsCloudCatalogIsForeign()) { - country = settings->GetCloudResolvedStoreCountry(); + // Server-authoritative store country from unified catalog (fallbackRegion). + QString resolvedCountry = settings ? settings->GetCloudResolvedStoreCountry() : QString(); + QString country; + QString language; + if (!resolvedCountry.isEmpty()) { + country = resolvedCountry; language = QStringLiteral("en"); - qInfo() << "Kamaji Step 0.5d: Fallback mode -> region-group container: country=" - << country << "language=" << language; + } else { + QString localeSetting = settings ? settings->GetCloudStoreLocale() : "en-US"; + QString locale = localeSetting.toLower(); + QStringList localeParts = locale.split("-"); + country = localeParts.size() > 1 ? localeParts[1].toUpper() : "US"; + language = localeParts[0].toLower(); } + qInfo() << "Kamaji step0_5d: using resolvedStoreCountry=" << country << "(lang=" << language << ") for container URL"; QString url = QString("https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/%2/19/%3?useOffers=true&gkb=1&gkb2=1") .arg(country, language, productId); diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 0d3ae9b8..4b12a3d6 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -148,19 +148,21 @@ final class PSKamajiSession { // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { - let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) - var country = storePath.country - var language = storePath.language - let fallbackRegion = SecureStore.shared.cloudResolvedStoreCountry - if !fallbackRegion.isEmpty { - country = fallbackRegion + let resolvedCountry = SecureStore.shared.cloudResolvedStoreCountry + let country: String + let language: String + if !resolvedCountry.isEmpty { + country = resolvedCountry language = "en" - os_log(.info, log: kamajiLog, - "Fallback mode -> region-group container: country=%{public}s, language=%{public}s", - country, language) + } else { + let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) + country = storePath.country + language = storePath.language } + os_log(.info, log: kamajiLog, + "step0_5d: using resolvedStoreCountry=%{public}s (lang=%{public}s) for container URL", + country, language) let url = "\(storeBase)/container/\(country)/\(language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" - os_log(.info, log: kamajiLog, "Store container locale: %{public}s", CloudLocaleSettings.stored) guard let response = CloudHttpClient.get(url: url, headers: [ "Accept": "application/json", diff --git a/lib/include/chiaki/cloudcatalog.h b/lib/include/chiaki/cloudcatalog.h index 6293fe2a..0c571d89 100644 --- a/lib/include/chiaki/cloudcatalog.h +++ b/lib/include/chiaki/cloudcatalog.h @@ -56,7 +56,7 @@ typedef struct chiaki_cloudcatalog_result_t * "schemaVersion": 2, * "total": , * "nativeMode": , // true when the authenticated PS Now walk succeeded - * "fallbackRegion": "US"|"GB"|"", // region-group store country in public fallback; "" when native + * "fallbackRegion": "US"|"GB"|..., // server-authoritative store country for container URLs * "settledLocale": "en-US", // locale the lib resolved (account region from the Kamaji * // session re-bases the caller locale, then the imagic store- * // locale chain settles); clients persist this verbatim diff --git a/lib/src/cloudcatalog_fetch.c b/lib/src/cloudcatalog_fetch.c index a5fd7abf..3f3b11e3 100644 --- a/lib/src/cloudcatalog_fetch.c +++ b/lib/src/cloudcatalog_fetch.c @@ -213,9 +213,49 @@ static CCNativeResult psnow_session(ChiakiLog *log, const char *code, const char return result; } +// Parse .../container/{CC}/{lang}/19/... from a store base_url. +static bool cc_parse_container_store_locale(const char *base_url, + char *out_country, size_t cc_sz, char *out_lang, size_t lang_sz) +{ + if(out_country && cc_sz) + out_country[0] = 0; + if(out_lang && lang_sz) + out_lang[0] = 0; + const char *p = strstr(base_url, "/container/"); + if(!p) + return false; + p += strlen("/container/"); + const char *slash = strchr(p, '/'); + if(!slash || slash == p) + return false; + size_t cc_len = (size_t)(slash - p); + if(!cc_len || cc_len >= cc_sz) + return false; + if(out_country && cc_sz) + { + memcpy(out_country, p, cc_len); + out_country[cc_len] = 0; + } + p = slash + 1; + slash = strchr(p, '/'); + if(!slash || slash == p) + return false; + size_t lang_len = (size_t)(slash - p); + if(!lang_len || lang_len >= lang_sz) + return false; + if(out_lang && lang_sz) + { + memcpy(out_lang, p, lang_len); + out_lang[lang_len] = 0; + } + return true; +} + // GET /user/stores -> base_url. Returns CC_NATIVE_OK or CC_NATIVE_REGION_UNSUPPORTED. static CCNativeResult psnow_stores(ChiakiLog *log, const char *jsession, - char *out_base_url, size_t url_sz) + char *out_base_url, size_t url_sz, + char *out_store_country, size_t cc_sz, + char *out_store_lang, size_t lang_sz) { char *cookie = NULL; cc_http_make_cookie_header(&cookie, "JSESSIONID", jsession); @@ -245,6 +285,7 @@ static CCNativeResult psnow_stores(ChiakiLog *log, const char *jsession, if(*base) { snprintf(out_base_url, url_sz, "%s", base); + cc_parse_container_store_locale(base, out_store_country, cc_sz, out_store_lang, lang_sz); result = CC_NATIVE_OK; } } @@ -359,13 +400,18 @@ static void psnow_fetch_category(ChiakiLog *log, const char *cat_url, struct jso } CCNativeResult cc_fetch_psnow_native(ChiakiLog *log, const char *npsso, struct json_object **out_games, - char *out_country, size_t cc_sz, char *out_language, size_t lang_sz) + char *out_country, size_t cc_sz, char *out_language, size_t lang_sz, + char *out_store_country, size_t store_cc_sz, char *out_store_lang, size_t store_lang_sz) { *out_games = NULL; if(out_country && cc_sz) out_country[0] = 0; if(out_language && lang_sz) out_language[0] = 0; + if(out_store_country && store_cc_sz) + out_store_country[0] = 0; + if(out_store_lang && store_lang_sz) + out_store_lang[0] = 0; if(!npsso || !*npsso) return CC_NATIVE_AUTH_ERROR; @@ -387,7 +433,8 @@ CCNativeResult cc_fetch_psnow_native(ChiakiLog *log, const char *npsso, struct j CHIAKI_LOGI(log, "[PSNOW] Session created, fetching stores"); char base_url[1024]; - r = psnow_stores(log, jsession, base_url, sizeof(base_url)); + r = psnow_stores(log, jsession, base_url, sizeof(base_url), + out_store_country, store_cc_sz, out_store_lang, store_lang_sz); if(r != CC_NATIVE_OK) return r; // region unsupported -> caller does public fallback CHIAKI_LOGI(log, "[PSNOW] Stores OK, base_url=%s", base_url); diff --git a/lib/src/cloudcatalog_internal.h b/lib/src/cloudcatalog_internal.h index d51db351..6ef8a6eb 100644 --- a/lib/src/cloudcatalog_internal.h +++ b/lib/src/cloudcatalog_internal.h @@ -185,9 +185,12 @@ typedef enum cc_native_result_t * out_language receive data.country / data.language (each may be NULL to skip, and * is set to "" when unavailable). These are populated even on * CC_NATIVE_REGION_UNSUPPORTED (session succeeded, /user/stores 404'd). + * out_store_country / out_store_lang receive the /container/{CC}/{lang}/ segments + * parsed from the server base_url on CC_NATIVE_OK (empty when unavailable). */ CCNativeResult cc_fetch_psnow_native(ChiakiLog *log, const char *npsso, struct json_object **out_games, - char *out_country, size_t cc_sz, char *out_language, size_t lang_sz); + char *out_country, size_t cc_sz, char *out_language, size_t lang_sz, + char *out_store_country, size_t store_cc_sz, char *out_store_lang, size_t store_lang_sz); /** Public APOLLOROOT fallback pagination for @p account_country. New array or NULL. */ struct json_object *cc_fetch_apollo_fallback(ChiakiLog *log, const char *account_country); diff --git a/lib/src/cloudcatalog_unified.c b/lib/src/cloudcatalog_unified.c index d0b2e7bc..4687e909 100644 --- a/lib/src/cloudcatalog_unified.c +++ b/lib/src/cloudcatalog_unified.c @@ -165,11 +165,20 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloudcatalog_fetch_unified( const char *warning = ""; struct json_object *apollo = NULL; char acct_country[8] = "", acct_language[8] = ""; + char store_country[8] = "", store_lang[8] = ""; CCNativeResult nr = cc_fetch_psnow_native(log, npsso, &apollo, - acct_country, sizeof(acct_country), acct_language, sizeof(acct_language)); + acct_country, sizeof(acct_country), acct_language, sizeof(acct_language), + store_country, sizeof(store_country), store_lang, sizeof(store_lang)); if(nr == CC_NATIVE_OK) + { native = true; + if(store_country[0]) + { + snprintf(fallback_region, sizeof(fallback_region), "%s", store_country); + CHIAKI_LOGI(log, "[UNIFIED] resolvedStoreCountry=%s (native base_url)", store_country); + } + } else if(nr == CC_NATIVE_AUTH_ERROR) { auth_error = true; @@ -188,6 +197,7 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloudcatalog_fetch_unified( account_country_from_locale(locale, cc, sizeof(cc)); apollo = cc_fetch_apollo_fallback(log, cc); snprintf(fallback_region, sizeof(fallback_region), "%s", cc_classics_store_country(cc)); + CHIAKI_LOGI(log, "[UNIFIED] resolvedStoreCountry=%s (fallback cc_classics)", fallback_region); } if(!apollo) apollo = json_object_new_array(); From a43e8af2905522515d5cc2786f5a89d9be9e1c44 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 13:04:22 -0700 Subject: [PATCH 21/72] Fix step0_5d language: use locale lang with resolved store country Phase 2 hardcoded "en" when resolvedStoreCountry was set, regressing non-English native accounts (JP/ja, DE/de, etc.). Keep server-authoritative country from fallbackRegion; take language from cloud_store_locale parse. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/api/PSKamajiSession.kt | 7 ++++--- gui/src/cloudstreaming/pskamajisession.cpp | 14 ++++++++------ ios/Pylux/Services/PSKamajiSession.swift | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index 45f5d3ca..321733b7 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -309,11 +309,12 @@ class PSKamajiSession( try { val resolvedCountry = preferences.getCloudResolvedStoreCountry() + val localeSetting = preferences.getCloudStoreLocale() + val parsedLocale = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) val (country, language) = if (resolvedCountry.isNotEmpty()) { - resolvedCountry to "en" + resolvedCountry to parsedLocale.second } else { - val localeSetting = preferences.getCloudStoreLocale() - com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) + parsedLocale } Log.i(TAG, "step0_5d: using resolvedStoreCountry=$country (lang=$language) for container URL") val url = "$storeBase/container/$country/$language/19/$productId?useOffers=true&gkb=1&gkb2=1" diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index bf460795..88d54122 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -318,18 +318,20 @@ void PSKamajiSession::handleAnonSessionResponse(QNetworkReply *reply) void PSKamajiSession::step0_5d_ConvertProductId() { // Server-authoritative store country from unified catalog (fallbackRegion). + QString localeSetting = settings ? settings->GetCloudStoreLocale() : QStringLiteral("en-US"); + QString locale = localeSetting.toLower(); + QStringList localeParts = locale.split("-"); + const QString localeLang = localeParts.size() > 0 ? localeParts[0].toLower() : QStringLiteral("en"); + const QString localeCountry = localeParts.size() > 1 ? localeParts[1].toUpper() : QStringLiteral("US"); QString resolvedCountry = settings ? settings->GetCloudResolvedStoreCountry() : QString(); QString country; QString language; if (!resolvedCountry.isEmpty()) { country = resolvedCountry; - language = QStringLiteral("en"); + language = localeLang; } else { - QString localeSetting = settings ? settings->GetCloudStoreLocale() : "en-US"; - QString locale = localeSetting.toLower(); - QStringList localeParts = locale.split("-"); - country = localeParts.size() > 1 ? localeParts[1].toUpper() : "US"; - language = localeParts[0].toLower(); + country = localeCountry; + language = localeLang; } qInfo() << "Kamaji step0_5d: using resolvedStoreCountry=" << country << "(lang=" << language << ") for container URL"; diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 4b12a3d6..8e1ff6dd 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -149,13 +149,13 @@ final class PSKamajiSession { // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { let resolvedCountry = SecureStore.shared.cloudResolvedStoreCountry + let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) let country: String let language: String if !resolvedCountry.isEmpty { country = resolvedCountry - language = "en" + language = storePath.language } else { - let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) country = storePath.country language = storePath.language } From b098f4009f5ba18eaddc248ff0a8493ccdc9fce6 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 13:08:18 -0700 Subject: [PATCH 22/72] Cloud catalog cache: only invalidate when the npsso actually changes Qt SetNpssoToken and Android saveNpssoToken dropped the 24h catalog cache unconditionally on every write. Re-auth paths re-save the same npsso (e.g. token re-exchange after an expired access token), which is not an account change, so the cache was needlessly wiped and the next Cloud Play open paid a full multi-second re-fetch. Guard both on a value change, matching the iOS SecureStore behavior. Co-Authored-By: Claude Opus 4.8 --- .../metallic/chiaki/common/SecureTokenManager.kt | 13 ++++++++++--- gui/src/settings.cpp | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt b/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt index 8d9239f8..88650c55 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt @@ -60,13 +60,20 @@ class SecureTokenManager(context: Context) { try { + // Only drop the cached catalog when the token actually changes. Re-auth paths + // can re-save the same npsso (e.g. token re-exchange after an expired access + // token), which is not an account change and must not wipe the 24h cache. + val changed = (encryptedPrefs.getString(KEY_NPSSO_TOKEN, "") ?: "") != token encryptedPrefs.edit() .putString(KEY_NPSSO_TOKEN, token) .apply() Log.i(TAG, "NPSSO token saved securely") - // Account changed (login / token re-entry): drop the cached catalog so the next - // fetch re-resolves owned games for this account instead of serving the old one's. - CloudGameRepository.invalidateCatalogCache(appContext, "account login") + if (changed) + { + // Account changed (login / token re-entry): drop the cached catalog so the next + // fetch re-resolves owned games for this account instead of serving the old one's. + CloudGameRepository.invalidateCatalogCache(appContext, "account login") + } } catch (e: Exception) { diff --git a/gui/src/settings.cpp b/gui/src/settings.cpp index 2bd05f8e..cf6cba00 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -982,6 +982,11 @@ QString Settings::GetNpssoToken() const void Settings::SetNpssoToken(QString npsso_token) { + // No-op when the token is unchanged so we don't needlessly drop the cloud catalog + // cache. Re-auth paths can re-write the same npsso (e.g. token re-exchange after an + // expired access token), and that is not an account change. + if(settings.value("settings/psn_npsso_token").toString() == npsso_token) + return; settings.setValue("settings/psn_npsso_token", npsso_token); // Fires on login, logout, and token re-entry (not on periodic auth/refresh-token // renewals, which don't touch the npsso). Listeners use this to drop the cached From e33d2df94eef93731cc95ed01461a6e526a853ea Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 13:08:29 -0700 Subject: [PATCH 23/72] test: cover cc_parse_container_store_locale (Phase 2 store-country parse) Expose the base_url container-locale parser via cloudcatalog_internal.h (lib-internal, not the public API) and add a munit case. Covers the happy path, a non-English native account (FI/fi), and the fail-closed cases (no /container/ segment, empty country, missing language slash, country longer than its buffer) so a malformed base_url can never feed a broken step0_5d container URL. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudcatalog_fetch.c | 3 ++- lib/src/cloudcatalog_internal.h | 8 ++++++ test/cloudcatalog_merge.c | 48 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/src/cloudcatalog_fetch.c b/lib/src/cloudcatalog_fetch.c index 3f3b11e3..8c86a47a 100644 --- a/lib/src/cloudcatalog_fetch.c +++ b/lib/src/cloudcatalog_fetch.c @@ -214,7 +214,8 @@ static CCNativeResult psnow_session(ChiakiLog *log, const char *code, const char } // Parse .../container/{CC}/{lang}/19/... from a store base_url. -static bool cc_parse_container_store_locale(const char *base_url, +// Declared in cloudcatalog_internal.h (exposed for unit tests). +bool cc_parse_container_store_locale(const char *base_url, char *out_country, size_t cc_sz, char *out_lang, size_t lang_sz) { if(out_country && cc_sz) diff --git a/lib/src/cloudcatalog_internal.h b/lib/src/cloudcatalog_internal.h index 6ef8a6eb..e3cc09e0 100644 --- a/lib/src/cloudcatalog_internal.h +++ b/lib/src/cloudcatalog_internal.h @@ -179,6 +179,14 @@ typedef enum cc_native_result_t CC_NATIVE_FATAL /**< setup/transport failure */ } CCNativeResult; +/** + * Parse the /container/{COUNTRY}/{lang}/ segments out of a Sony store base_url. + * out_country / out_lang are set to "" on failure (each may be NULL to skip). + * Returns true only when both segments were present and fit their buffers. + */ +bool cc_parse_container_store_locale(const char *base_url, + char *out_country, size_t cc_sz, char *out_lang, size_t lang_sz); + /** * Authenticated PS Now APOLLOROOT probe. On CC_NATIVE_OK, *out_games is a new array. * Also reports the account region signal from the Kamaji session: out_country / diff --git a/test/cloudcatalog_merge.c b/test/cloudcatalog_merge.c index 3106867a..f5ea7f25 100644 --- a/test/cloudcatalog_merge.c +++ b/test/cloudcatalog_merge.c @@ -273,6 +273,53 @@ static MunitResult test_cloud_language_helpers(const MunitParameter params[], vo return MUNIT_OK; } +// Phase 2 store-country resolution: parse /container/{COUNTRY}/{lang}/ out of the +// Sony store base_url. The country drives step0_5d's product->entitlement lookup, so +// a wrong/partial parse must fail closed (return false, leave outputs empty) rather +// than feed a malformed container URL. +static MunitResult test_parse_container_store_locale(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + char cc[8], lang[8]; + + // Happy path: US/en. + munit_assert_true(cc_parse_container_store_locale( + "https://store.example/container/US/en/19/PPSA01234_00?x=1", cc, sizeof(cc), lang, sizeof(lang))); + munit_assert_string_equal(cc, "US"); + munit_assert_string_equal(lang, "en"); + + // Non-English native account (the regression the store_lang fix protects): country + // and language are both taken verbatim from the server base_url. + munit_assert_true(cc_parse_container_store_locale( + "https://store.example/container/FI/fi/19/EP9000-NPEA_00", cc, sizeof(cc), lang, sizeof(lang))); + munit_assert_string_equal(cc, "FI"); + munit_assert_string_equal(lang, "fi"); + + // No /container/ segment -> fail closed, outputs empty. + munit_assert_false(cc_parse_container_store_locale( + "https://store.example/foo/bar/baz", cc, sizeof(cc), lang, sizeof(lang))); + munit_assert_string_equal(cc, ""); + munit_assert_string_equal(lang, ""); + + // Empty country segment (//) -> fail. + munit_assert_false(cc_parse_container_store_locale( + "https://store.example/container//en/19/x", cc, sizeof(cc), lang, sizeof(lang))); + + // Country present but the language segment has no closing slash -> fail. + munit_assert_false(cc_parse_container_store_locale( + "https://store.example/container/US/en", cc, sizeof(cc), lang, sizeof(lang))); + + // Country segment longer than its buffer -> fail (bounds guard), not truncate. + { + char tiny[3]; // holds 2 chars + NUL + munit_assert_false(cc_parse_container_store_locale( + "https://store.example/container/USA/en/19/x", tiny, sizeof(tiny), lang, sizeof(lang))); + munit_assert_string_equal(tiny, ""); + } + + return MUNIT_OK; +} + MunitTest tests_cloudcatalog_merge[] = { { "/apollo_dedup", test_apollo_dedup, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, { "/device_based_ps5", test_device_based_ps5, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, @@ -280,5 +327,6 @@ MunitTest tests_cloudcatalog_merge[] = { { "/crossbuy_sku_sibling", test_crossbuy_sku_sibling, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, { "/sort_and_envelope", test_sort_and_envelope, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, { "/cloud_language_helpers", test_cloud_language_helpers, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse_container_store_locale", test_parse_container_store_locale, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL } }; From b7eeb9f480645a194cd5a255c03cc3fd326701f1 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 19:11:25 -0700 Subject: [PATCH 24/72] macOS build: bundle SDL3 for sdl2-compat (fixes launch crash) Homebrew migrated the `sdl2` formula to an alias for `sdl2-compat`, an SDL2 API shim that dlopens SDL3 at runtime via @loader_path/libSDL3.dylib. macdeployqt does not copy SDL3 (it's loaded via dlopen, not linked), so the app aborted on launch with "Failed loading SDL3 library" before any of our code ran -- on any build after the Homebrew migration, local and the App Store CI alike. Bundle SDL3 next to libSDL2 under exactly the name sdl2-compat looks for (libSDL3.dylib, NOT libSDL3.0.dylib -- the latter only the bare-name fallback finds, masking the bug on dev machines that have Homebrew SDL3). Added to both scripts/build-macos.sh (sign_app_bundle, covers --iterate + full build) and the deploy-macos.yml App Store workflow (per-arch, lipo-merged + signed). Proven machine-independent via the Homebrew API: sdl2-compat has aliases: ['sdl2']. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy-macos.yml | 18 ++++++++++++++++++ scripts/build-macos.sh | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/.github/workflows/deploy-macos.yml b/.github/workflows/deploy-macos.yml index 5b287882..51b92a12 100644 --- a/.github/workflows/deploy-macos.yml +++ b/.github/workflows/deploy-macos.yml @@ -154,6 +154,24 @@ jobs: ln -sf libvulkan.1.dylib build/gui/chiaki.app/Contents/Frameworks/vulkan 2>/dev/null || true + # Bundle SDL3 for sdl2-compat. Homebrew's `sdl2` (installed above) is now sdl2-compat -- an + # SDL2 API shim that dlopens SDL3 at runtime via @loader_path/libSDL3.dylib (next to libSDL2 + # in Frameworks). macdeployqt does NOT copy SDL3 (it is loaded via dlopen, not linked), so + # without this the App Store .app aborts on launch with "Failed loading SDL3 library" before + # any of our code runs. It MUST be named libSDL3.dylib (the @loader_path name) -- not + # libSDL3.0.dylib, which only sdl2-compat's bare-name fallback (system search path) finds, + # masking the bug on dev machines. The per-arch copy is lipo-merged into the universal + # bundle in the create-appstore-pkg job and signed there with the rest of the dylibs. + SDL3_SRC="${{ matrix.brew_prefix }}/opt/sdl3/lib/libSDL3.0.dylib" + if [ -f "$SDL3_SRC" ]; then + echo "Bundling SDL3 (required by sdl2-compat)..." + cp -f "$SDL3_SRC" build/gui/chiaki.app/Contents/Frameworks/libSDL3.dylib + chmod u+w build/gui/chiaki.app/Contents/Frameworks/libSDL3.dylib + install_name_tool -id "@rpath/libSDL3.dylib" build/gui/chiaki.app/Contents/Frameworks/libSDL3.dylib + else + echo "::warning::SDL3 not found at $SDL3_SRC -- if libSDL2 is sdl2-compat the app will crash on launch" + fi + - name: Create tarball for PKG job (cache, not workflow artifact) run: | cd build/gui diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 43d1e8a5..e94c4022 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -411,6 +411,30 @@ sign_app_bundle() { exit 1 fi echo " Using entitlements: $ENTITLEMENTS_PLIST" + + # Bundle SDL3 for sdl2-compat. Homebrew's `sdl2` is now sdl2-compat -- an SDL2 API shim that + # dlopen's SDL3 at runtime. macdeployqt does NOT copy SDL3 (it is loaded via dlopen, not a link + # dependency), so without this the app aborts on launch with "Failed loading SDL3 library" + # before any of our own code runs. Copy it next to libSDL2 with an @rpath id; the bundle's + # rpath already includes @executable_path/../Frameworks, so sdl2-compat finds it. The + # standalone-dylib signing step below then signs it. Harmless if SDL3 is absent or the bundled + # SDL2 is ever a real (non-compat) build -- the extra dylib just goes unused. + local sdl3_src="$(brew --prefix)/opt/sdl3/lib/libSDL3.0.dylib" + if [ -f "$sdl3_src" ]; then + echo " Bundling SDL3 (required by sdl2-compat)..." + # CRITICAL: sdl2-compat dlopens SDL3 by the leaf name "libSDL3.dylib" via @loader_path + # (next to libSDL2 in Frameworks). It must be bundled under EXACTLY that name. If it is + # named libSDL3.0.dylib instead, only sdl2-compat's bare-name fallback finds it -- which on + # a dev machine silently resolves to /opt/homebrew/lib (masking the bug), but on any other + # machine / a Finder launch there is no SDL3 in the search path and the app aborts with + # "Failed loading SDL3 library". Bundle it as libSDL3.dylib so @loader_path always resolves. + local sdl3_dst="$app_path/Contents/Frameworks/libSDL3.dylib" + rm -f "$app_path/Contents/Frameworks/libSDL3.0.dylib" # remove any wrongly-named prior copy + cp -f "$sdl3_src" "$sdl3_dst" + chmod u+w "$sdl3_dst" + install_name_tool -id "@rpath/libSDL3.dylib" "$sdl3_dst" 2>/dev/null || true + fi + echo " Signing MoltenVK dylibs..." for dylib in "$app_path/Contents/Resources/vulkan/icd.d"/*.dylib; do [ -f "$dylib" ] && codesign --force "${CODE_SIGN_EXTRA[@]}" --sign "$SIGN_ID" "$dylib" From a90926d7e54c5bca7604f451d012c0db456ce11a Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 19:11:41 -0700 Subject: [PATCH 25/72] Phase 3.1 (Qt): owned-PSNOW fast-path with one-shot fallback For an owned PS Now title the unified catalog already carries the resolved streaming entitlement, so there is nothing to look up or acquire. When the catalog provides an entitlementId for the launched game, PSKamajiSession skips the entire entitlement path (0.5b anonymous session, 0.5d product->entitlement resolve, 0.5e check/acquire) and goes straight to the authenticated session (step5/6). This is the correctness fix for storefront-less regions where 0.5d/ 0.5e 404 and the acquire always fails even though the entitlement is owned. Safety: if Gaikai rejects the fast-path entitlement (noGameForEntitlementId at session start), CloudStreamingBackend retries exactly once with forceFullEntitlementFlow=true -- the normal resolve/acquire path -- which can't loop because the fast-path is disabled on the retry. Unowned titles are unaffected: no catalog entitlementId -> full flow as before. Also fixes a real bug this surfaced: Gaikai's Step 8 (sessions/start) error dropped the response body, so the noGameForEntitlementId marker never reached the fallback check; include the body in the AllocationError. Validated on live streams: owned (Ghost of Tsushima) fast-paths and streams; unowned (RESOGUN) runs the full $0-acquire flow; a forced bad entitlement (Celeste) rejects -> one-shot fallback -> acquires -> streams. Co-Authored-By: Claude Opus 4.8 --- gui/include/cloudcatalogbackend.h | 7 +++ gui/include/cloudstreaming/pskamajisession.h | 19 ++++++- gui/include/cloudstreamingbackend.h | 19 +++++-- gui/src/cloudcatalogbackend.cpp | 48 +++++++++++++++++ gui/src/cloudstreaming/psgaikaistreaming.cpp | 7 ++- gui/src/cloudstreaming/pskamajisession.cpp | 32 +++++++++-- gui/src/cloudstreamingbackend.cpp | 56 +++++++++++++++++--- 7 files changed, 171 insertions(+), 17 deletions(-) diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index 436121d7..7eb853b6 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -65,6 +65,13 @@ class CloudCatalogBackend : public QObject Q_INVOKABLE QString getCachedData(const QString &key, int maxAge); Q_INVOKABLE QString getGameLandscapeImageFromCache(const QString &serviceType, const QString &gameIdentifier); + // Owned-PSNOW launch fast-path: look up a title in the cached unified catalog by its launch + // identifier and, if it is an owned PSNOW row with a pre-resolved streaming entitlement, return + // that entitlementId + platform so PSKamajiSession can skip the resolve/acquire path. Returns + // false (out params untouched) for anything else (non-owned, pscloud, missing entitlementId, or + // no cached catalog). Reads the catalog the lib wrote; account-specific ownership only. + bool getOwnedPsnowEntitlement(const QString &gameIdentifier, QString &outEntitlementId, QString &outPlatform); + signals: // Emitted after the on-disk catalog cache is wiped (profile/account switch, NPSSO change, // cloud-language change, or manual refresh). The cloud view listens for this to re-fetch so diff --git a/gui/include/cloudstreaming/pskamajisession.h b/gui/include/cloudstreaming/pskamajisession.h index 388fc34e..ea604e30 100644 --- a/gui/include/cloudstreaming/pskamajisession.h +++ b/gui/include/cloudstreaming/pskamajisession.h @@ -105,7 +105,21 @@ class PSKamajiSession : public QObject * Start the complete Kamaji session creation flow (Steps 0.5a-0.5d, 5-6) */ void startSessionCreation(); - + + /** + * Owned-PSNOW fast-path: when the unified catalog already knows the user owns this title's + * streaming entitlement, hand it in here. startSessionCreation() then skips the whole + * entitlement path (0.5b anonymous session, 0.5d product->entitlement resolve, 0.5e + * check/acquire) and goes straight to the authenticated session (step5/6). This is the + * correctness win for storefront-less regions where the 0.5d/0.5e calls 404 and the acquire + * always fails even though the entitlement is already owned. Empty entitlementId == take the + * normal full flow. If Gaikai later rejects the id, the orchestrator re-runs us without this. + */ + void setOwnedEntitlementFastPath(const QString &ownedEntitlementId, const QString &ownedPlatform); + + /** True once startSessionCreation() actually took the fast-path (used to gate the one-shot retry). */ + bool usedEntitlementFastPath() const { return entitlementFastPathUsed; } + /** * Get session data (only available after successful authentication) */ @@ -154,6 +168,9 @@ private slots: QString jsessionId; // JSESSIONID from anonymous session QString entitlementId; // Converted from productId QString streamingSku; // SKU from product ID conversion (for entitlement check) + QString fastPathEntitlementId; // Pre-resolved owned entitlement from the unified catalog (fast-path) + QString fastPathPlatform; // Platform that accompanies the fast-path entitlement (ps3/ps4) + bool entitlementFastPathUsed = false; // Set when startSessionCreation() skipped 0.5b-0.5e QString commerceOAuthToken; // OAuth token for Commerce API (Bearer token) // Session data (set after successful authentication) diff --git a/gui/include/cloudstreamingbackend.h b/gui/include/cloudstreamingbackend.h index 8c3d2412..0e65ccb5 100644 --- a/gui/include/cloudstreamingbackend.h +++ b/gui/include/cloudstreamingbackend.h @@ -75,8 +75,10 @@ private slots: // Centralized authorization check (used by both PSNOW and PSCLOUD) void checkAuthorization(QString serviceType, QString npssoToken, QString duid, std::function callback); - // Continue cloud session after successful authorization - void continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid); + // Continue cloud session after successful authorization. + // forceFullEntitlementFlow=true disables the owned-PSNOW fast-path so the one-shot retry (after + // Gaikai rejects a fast-path entitlement) takes the full resolve/acquire path like today. + void continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid, bool forceFullEntitlementFlow = false); Settings *settings; QString allocation_progress; @@ -84,11 +86,20 @@ private slots: QString game_image_url; // Landscape image URL for current cloud game QNetworkAccessManager *authManager; // For authorization check - // Helper method to start Gaikai allocation (shared between PSNOW and PSCLOUD flows) + // Helper method to start Gaikai allocation (shared between PSNOW and PSCLOUD flows). + // usedFastPath + gameIdentifier + npssoToken let the allocation-error handler retry once via the + // full entitlement flow when Gaikai rejects a fast-path (owned) entitlement. void startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, QString duid, QString redirectUri, QString userAgent, QString oauthApiPath, - ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession); + ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession, + bool usedFastPath = false, QString gameIdentifier = QString(), + QString npssoToken = QString()); + + // True when a Gaikai allocation error means "the entitlement we streamed isn't valid/owned" + // (e.g. noGameForEntitlementId) -- the signal that the owned fast-path guessed wrong and we + // should retry with the full resolve/acquire flow. + static bool isEntitlementRejectedError(const QString &error); }; #endif // CLOUDSTREAMINGBACKEND_H diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 26a68974..c1e8a7d4 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -73,6 +73,54 @@ QString CloudCatalogBackend::getCacheFilePath(const QString &key) return cacheDirectory + "/" + safeKey + ".json"; } +bool CloudCatalogBackend::getOwnedPsnowEntitlement(const QString &gameIdentifier, + QString &outEntitlementId, QString &outPlatform) +{ + if (gameIdentifier.isEmpty()) + return false; + + // The lib owns the unified catalog filename and bumps its version suffix, so resolve it by glob + // (newest unified_catalog_v*.json) rather than hard-coding the current version. + QDir dir(cacheDirectory); + QFileInfoList matches = dir.entryInfoList({QStringLiteral("unified_catalog_v*.json")}, + QDir::Files, QDir::Time); + if (matches.isEmpty()) + return false; + + QFile file(matches.first().absoluteFilePath()); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return false; + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (!doc.isObject()) + return false; + + const QJsonArray games = doc.object().value(QStringLiteral("games")).toArray(); + for (const QJsonValue &v : games) { + if (!v.isObject()) + continue; + const QJsonObject g = v.toObject(); + // Match the launch identifier against the row's launch id (and productId as a fallback). + const QString streamId = g.value(QStringLiteral("streamIdentifier")).toString(); + const QString productId = g.value(QStringLiteral("productId")).toString(); + if (gameIdentifier != streamId && gameIdentifier != productId) + continue; + + // Only owned PSNOW rows carry a pre-resolved streaming entitlement we can stream directly. + const QString svcRaw = g.value(QStringLiteral("streamServiceType")).toString(); + const QString svc = svcRaw.isEmpty() ? g.value(QStringLiteral("serviceType")).toString() : svcRaw; + const QString entitlementId = g.value(QStringLiteral("entitlementId")).toString(); + if (svc != QStringLiteral("psnow") || !g.value(QStringLiteral("isOwned")).toBool() + || entitlementId.isEmpty()) + return false; + + outEntitlementId = entitlementId; + outPlatform = g.value(QStringLiteral("platform")).toString(); + return true; + } + return false; +} + QString CloudCatalogBackend::getCachedData(const QString &key, int maxAge) { QString filePath = getCacheFilePath(key); diff --git a/gui/src/cloudstreaming/psgaikaistreaming.cpp b/gui/src/cloudstreaming/psgaikaistreaming.cpp index 2ef0d276..0e5712e1 100644 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ b/gui/src/cloudstreaming/psgaikaistreaming.cpp @@ -623,7 +623,12 @@ void PSGaikaiStreaming::step8_StartSession(QString entitlementId) qWarning() << "Gaikai Step 8 failed:" << reply->errorString(); QByteArray errorData = reply->readAll(); qWarning() << "Server response:" << QString::fromUtf8(errorData); - emit AllocationError(QString("Session start failed: %1").arg(reply->errorString())); + // Include the server response body in the error: this is where Gaikai reports an + // unowned/invalid entitlement (e.g. {"name":"noGameForEntitlementId",...}), and the + // owned fast-path fallback in CloudStreamingBackend keys off that marker to retry via + // the full resolve/acquire flow. reply->errorString() alone is just "Bad Request". + emit AllocationError(QString("Session start failed: %1 %2") + .arg(reply->errorString(), QString::fromUtf8(errorData))); emit Finished(); return; } diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index 88d54122..d0c67bdf 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -95,26 +95,50 @@ PSKamajiSession::PSKamajiSession( manager->setCookieJar(nullptr); // Disable cookie jar - we use manual Cookie headers only } +void PSKamajiSession::setOwnedEntitlementFastPath(const QString &ownedEntitlementId, const QString &ownedPlatform) +{ + fastPathEntitlementId = ownedEntitlementId; + fastPathPlatform = ownedPlatform; +} + void PSKamajiSession::startSessionCreation() { // Get npsso fresh from settings at the start of each session attempt npssoToken = settings->GetNpssoToken(); - + // Clear jsessionId to ensure we start fresh jsessionId.clear(); - + qInfo() << "Kamaji Session: Starting authentication flow (Steps 0.5b-0.5d, 5-6)..."; qInfo() << "Platform:" << platform; qInfo() << "Product ID:" << productId; qInfo() << "Note: Authorization check is now handled centrally by CloudStreamingBackend"; - + if (npssoToken.isEmpty()) { QString error = "NPSSO token is empty"; qWarning() << "Kamaji Session:" << error; emit sessionComplete(false, error, QString()); return; } - + + // Owned-PSNOW fast-path: the unified catalog already resolved this title's streaming + // entitlement from the user's owned cross-reference, so there is nothing to look up or + // acquire. Skip the entire entitlement path (0.5b anonymous session + 0.5d resolve + + // 0.5e check/acquire) -- those calls 404 and the acquire fails outright in storefront-less + // regions -- and go straight to the authenticated session. step5/6 are independent of the + // anonymous session, so this is safe. The orchestrator falls back to the full flow if Gaikai + // rejects the id. + if (!fastPathEntitlementId.isEmpty()) { + entitlementId = fastPathEntitlementId; + platform = fastPathPlatform.isEmpty() ? QStringLiteral("ps4") : fastPathPlatform; + scopesStr = (platform == QStringLiteral("ps3")) ? KamajiConsts::PS3_SCOPES : KamajiConsts::PS4_SCOPES; + entitlementFastPathUsed = true; + qInfo() << "Kamaji fast-path: owned entitlementId=" << entitlementId + << "platform=" << platform << "- skipping 0.5b/0.5d/0.5e"; + step5_GetAuthCode(); + return; + } + // Authorization check is now done centrally by CloudStreamingBackend before creating PSKamajiSession // Start directly with Step 0.5b: Get anonymous session OAuth code step0_5b_GetAnonymousAuthCode(); diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 1bb20856..cb5f2549 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -114,7 +114,7 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri }); } -void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid) +void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid, bool forceFullEntitlementFlow) { // Determine service-specific configuration QString redirectUri; @@ -146,6 +146,23 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q settings, sharedDuid, gameIdentifier, CloudConfig::ACCOUNT_BASE, KamajiConsts::REDIRECT_URI, KamajiConsts::USER_AGENT, this); + // Owned-PSNOW fast-path: if the unified catalog already resolved this title's streaming + // entitlement from the user's owned cross-reference, hand it straight to Kamaji so it + // skips the resolve/acquire path (which 404s + fails in storefront-less regions). Disabled + // on the retry (forceFullEntitlementFlow) so a Gaikai rejection falls back to the full flow. + if (!forceFullEntitlementFlow) { + QmlBackend *qmlBackendLookup = qobject_cast(parent()); + QString ownedEntitlementId, ownedPlatform; + if (qmlBackendLookup && qmlBackendLookup->cloudCatalog() + && qmlBackendLookup->cloudCatalog()->getOwnedPsnowEntitlement(gameIdentifier, ownedEntitlementId, ownedPlatform)) { + qInfo() << "PSNOW owned fast-path: catalog entitlementId=" << ownedEntitlementId + << "platform=" << ownedPlatform; + kamajiSession->setOwnedEntitlementFastPath(ownedEntitlementId, ownedPlatform); + } + } else { + qInfo() << "PSNOW: forcing full entitlement flow (fast-path retry fallback)"; + } + connect(kamajiSession, &PSKamajiSession::psPlusSubscriptionError, this, [this]() { QmlBackend *qmlBackend = qobject_cast(parent()); if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); @@ -158,7 +175,7 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q } }); connect(kamajiSession, &PSKamajiSession::sessionComplete, this, - [this, kamajiSession, callback, sharedDuid, serviceType, target, redirectUri, userAgent, oauthApiPath](bool success, QString message, QString entitlementId) { + [this, kamajiSession, callback, sharedDuid, serviceType, target, redirectUri, userAgent, oauthApiPath, gameIdentifier, npssoToken](bool success, QString message, QString entitlementId) { if (!success) { qWarning() << "Kamaji session creation failed:" << message; setGameImageUrl(QString()); @@ -175,8 +192,10 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q qInfo() << "Converted Entitlement ID:" << entitlementId; QString detectedPlatform = kamajiSession->getPlatform(); // ps4 / ps3 ChiakiTarget platformTarget = CHIAKI_TARGET_PS4_9; // PS4 and PS3 both stream as PS4 + const bool usedFastPath = kamajiSession->usedEntitlementFastPath(); startGaikaiAllocation(serviceType, detectedPlatform, entitlementId, sharedDuid, - redirectUri, userAgent, oauthApiPath, platformTarget, callback, kamajiSession); + redirectUri, userAgent, oauthApiPath, platformTarget, callback, kamajiSession, + usedFastPath, gameIdentifier, npssoToken); }); kamajiSession->startSessionCreation(); } else { @@ -187,10 +206,19 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q } } -void CloudStreamingBackend::startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, +bool CloudStreamingBackend::isEntitlementRejectedError(const QString &error) +{ + // Gaikai's authorize step (step9) surfaces an unowned/invalid entitlement as + // "noGameForEntitlementId" in the errors[].description it bubbles up here. (If live testing + // shows a different marker for the unowned case, add it here -- this is the fast-path retry gate.) + return error.contains(QStringLiteral("noGameForEntitlement"), Qt::CaseInsensitive); +} + +void CloudStreamingBackend::startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, QString duid, QString redirectUri, QString userAgent, QString oauthApiPath, - ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession) + ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession, + bool usedFastPath, QString gameIdentifier, QString npssoToken) { // Step 7-13: Complete Gaikai allocation PSGaikaiStreaming *gaikaiStreaming = new PSGaikaiStreaming( @@ -418,9 +446,23 @@ void CloudStreamingBackend::startGaikaiAllocation(QString serviceType, QString p // When Gaikai allocation fails connect(gaikaiStreaming, &PSGaikaiStreaming::AllocationError, this, - [this, gaikaiStreaming, kamajiSession, callback](QString error) { + [this, gaikaiStreaming, kamajiSession, callback, serviceType, duid, usedFastPath, gameIdentifier, npssoToken](QString error) { qWarning() << "Gaikai allocation failed:" << error; - + + // Owned fast-path fallback: if we streamed a catalog-provided entitlement and Gaikai rejected + // it (the entitlement isn't actually valid/owned), retry exactly once via the full + // resolve/acquire flow -- i.e. behave like today. One shot only (forceFullEntitlementFlow + // disables the fast-path on the retry), so this can never loop. + if (usedFastPath && !gameIdentifier.isEmpty() && isEntitlementRejectedError(error)) { + qWarning() << "Owned fast-path entitlement rejected by Gaikai; retrying once with the full entitlement flow"; + gaikaiStreaming->deleteLater(); + if (kamajiSession) + kamajiSession->deleteLater(); + continueCloudSessionAfterAuth(serviceType, gameIdentifier, callback, npssoToken, duid, + /*forceFullEntitlementFlow=*/true); + return; + } + // Clear game image on error setGameImageUrl(QString()); From 8e145894fd59d6c58741cb1eadc82348098173fb Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 23:09:56 -0700 Subject: [PATCH 26/72] Fix iOS build: SecureStore referenced renamed cloud_fallback_region key Phase 1 (c669fb3d) renamed the iOS constant kCloudFallbackRegion to kCloudResolvedStoreCountry / kLegacyCloudFallbackRegion but left a dangling reference in SecureStore.clearAll(), so the iOS target had not compiled since Phase 1 (Phase 1/2 were never actually built or run on iOS until now). Clear all three current cloud-region keys instead. Co-Authored-By: Claude Opus 4.8 --- ios/Pylux/Services/SecureStore.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Pylux/Services/SecureStore.swift b/ios/Pylux/Services/SecureStore.swift index fe3565ee..e39b81eb 100644 --- a/ios/Pylux/Services/SecureStore.swift +++ b/ios/Pylux/Services/SecureStore.swift @@ -406,7 +406,9 @@ final class SecureStore { kRegisteredHosts, kManualHosts, kDiscoveryActive, kStreamPrefs, kDcPscloud, kDcPsnow, - kCloudFavorites, kCloudSortState, kCloudFallbackRegion, kCloudTagFilters, + kCloudFavorites, kCloudSortState, + kCloudResolvedStoreCountry, kLegacyCloudFallbackRegion, kCloudCatalogNativeMode, + kCloudTagFilters, kTotalStreamTimeMs, kLastDonationPromptWallMs, kDonationPaywallShowCount, kLastAppReviewPromptTotalStreamMs, kLastHost, kLastRegistKey, kLastMorning, kLastPs5, From e37fc1ad61ace88dde14b445e30b7a9bdd257420 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sat, 27 Jun 2026 23:10:15 -0700 Subject: [PATCH 27/72] Phase 3.1 (Android + iOS): owned-PSNOW fast-path with one-shot fallback Port the Qt owned-PSNOW fast-path (a90926d7) to Android and iOS. For an owned PS Now title the unified catalog already carries the resolved streaming entitlement, so PSKamajiSession skips the entire entitlement path (0.5b anonymous session, 0.5d product->entitlement resolve, 0.5e check/acquire) and goes straight to the authenticated session. This is the correctness fix for storefront-less regions where 0.5d/0.5e 404 and the acquire fails even though the entitlement is owned. Safety: if Gaikai rejects the fast-path entitlement (noGameForEntitlementId at session start), the orchestrator retries exactly once with the full resolve/ acquire flow (one-shot; the fast-path is disabled on the retry, so it can't loop). Unowned titles are unaffected (empty catalog entitlementId -> full flow). Also surfaces the Gaikai step8 response body so the noGameForEntitlementId marker reaches the fallback. CloudPlayFragment.kt / CloudPlayView.swift pass the catalog entitlementId + platform from the launched game. Validated on a real device + simulator: owned (Ghost of Tsushima) fast-paths and allocates; unowned (Gitaroo Man / Bomber Crew) runs the full $0-acquire; a forced bad entitlement (Hollow Knight / Tekken 6) rejects -> one-shot fallback -> resolves + acquires the real entitlement -> allocates. Co-Authored-By: Claude Opus 4.8 --- .../cloudplay/api/CloudStreamingBackend.kt | 61 ++++++++++++++++--- .../chiaki/cloudplay/api/PSGaikaiStreaming.kt | 9 ++- .../chiaki/cloudplay/api/PSKamajiSession.kt | 46 +++++++++++++- .../metallic/chiaki/main/CloudPlayFragment.kt | 4 ++ .../Services/CloudStreamingBackend.swift | 55 ++++++++++++++++- ios/Pylux/Services/PSGaikaiStreaming.swift | 10 ++- ios/Pylux/Services/PSKamajiSession.swift | 41 +++++++++++++ ios/Pylux/Views/CloudPlayView.swift | 4 ++ 8 files changed, 215 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 0212a852..4e516a0a 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -62,6 +62,8 @@ class CloudStreamingBackend( gameIdentifier: String, gameName: String, npssoToken: String, + ownedEntitlementId: String = "", // PSNOW owned fast-path: catalog's pre-resolved entitlement + ownedPlatform: String = "", // platform accompanying ownedEntitlementId onProgress: ((String) -> Unit)? = null, // Progress callback isCancelled: () -> Boolean = { false } // Cancellation check ): Result = withContext(Dispatchers.IO) @@ -108,8 +110,10 @@ class CloudStreamingBackend( gameName, npssoToken, sharedDuid, - onProgress, - isCancelled + ownedEntitlementId, + ownedPlatform, + onProgress = onProgress, + isCancelled = isCancelled ) result @@ -131,6 +135,9 @@ class CloudStreamingBackend( gameName: String, npssoToken: String, sharedDuid: String, + ownedEntitlementId: String = "", + ownedPlatform: String = "", + forceFullEntitlementFlow: Boolean = false, // true on the one-shot fallback retry (disables fast-path) onProgress: ((String) -> Unit)? = null, isCancelled: () -> Boolean = { false } ): Result = withContext(Dispatchers.IO) @@ -166,11 +173,12 @@ class CloudStreamingBackend( // For PSCLOUD: Skip Kamaji entirely var finalEntitlementId = gameIdentifier var finalPlatform = initialPlatform - + var usedFastPath = false + if (serviceType == "psnow") { Log.i(TAG, "=== PSNOW Flow: Starting Kamaji Session ===") - + // Create Kamaji session with productId (will be converted to entitlementId) // Platform will be automatically detected from the API response val kamajiSession = PSKamajiSession( @@ -181,19 +189,33 @@ class CloudStreamingBackend( userAgent = userAgent, preferences = preferences ) - + + // Owned-PSNOW fast-path: if the catalog already resolved this title's streaming + // entitlement (owned), hand it to Kamaji so it skips the resolve/acquire path. + // Disabled on the fallback retry (forceFullEntitlementFlow). + if (!forceFullEntitlementFlow && ownedEntitlementId.isNotEmpty()) + { + Log.i(TAG, "PSNOW owned fast-path: catalog entitlementId=$ownedEntitlementId platform=$ownedPlatform") + kamajiSession.setOwnedEntitlementFastPath(ownedEntitlementId, ownedPlatform) + } + else if (forceFullEntitlementFlow) + { + Log.i(TAG, "PSNOW: forcing full entitlement flow (fast-path retry fallback)") + } + // Start Kamaji session creation val kamajiResult = kamajiSession.startSessionCreation(npssoToken) - + usedFastPath = kamajiSession.usedEntitlementFastPath + if (!kamajiResult.success) { Log.e(TAG, "Kamaji session creation failed: ${kamajiResult.message}") return@withContext Result.failure(Exception("Kamaji session failed: ${kamajiResult.message}")) } - + finalEntitlementId = kamajiResult.entitlementId finalPlatform = kamajiResult.platform - + Log.i(TAG, "✓ Kamaji session complete") Log.i(TAG, " Entitlement ID: $finalEntitlementId") Log.i(TAG, " Platform: $finalPlatform") @@ -220,10 +242,23 @@ class CloudStreamingBackend( ) val allocationResult = gaikaiStreaming.startAllocationFlow(finalEntitlementId) - + if (!allocationResult.success) { Log.e(TAG, "Gaikai allocation failed: ${allocationResult.message}") + // Owned fast-path fallback: if we streamed a catalog entitlement and Gaikai rejected it + // (the entitlement isn't actually valid/owned), retry exactly once via the full + // resolve/acquire flow. One shot only -- forceFullEntitlementFlow disables the fast-path + // on the retry, so this can never loop. + if (usedFastPath && !forceFullEntitlementFlow && isEntitlementRejectedError(allocationResult.message)) + { + Log.w(TAG, "Owned fast-path entitlement rejected by Gaikai; retrying once with the full entitlement flow") + return@withContext continueCloudSessionAfterAuth( + serviceType, gameIdentifier, gameName, npssoToken, sharedDuid, + ownedEntitlementId = "", ownedPlatform = "", forceFullEntitlementFlow = true, + onProgress = onProgress, isCancelled = isCancelled + ) + } return@withContext Result.failure(Exception("Gaikai allocation failed: ${allocationResult.message}")) } @@ -258,6 +293,14 @@ class CloudStreamingBackend( } } + /** + * True when a Gaikai allocation error means "the entitlement we streamed isn't valid/owned" + * (Gaikai's session-start reports {"name":"noGameForEntitlementId",...}). That's the signal the + * owned fast-path guessed wrong and we should retry with the full resolve/acquire flow. + */ + private fun isEntitlementRejectedError(error: String): Boolean = + error.contains("noGameForEntitlement", ignoreCase = true) + /** * Centralized Authorization Check (used by both PSNOW and PSCLOUD) * Mirrors: CloudStreamingBackend::checkAuthorization() (Qt lines 543-613) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt index e4061c31..7dfc9421 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt @@ -80,6 +80,9 @@ class PSGaikaiStreaming( private var streamServerAuthCode = "" private var requestGameSpec = JSONObject() private var selectedDatacenter = "" + // Captured server response body from a failed step8 (sessions/start) so the owned fast-path + // fallback can detect noGameForEntitlementId. Empty unless step8 failed. + private var lastStartSessionError = "" private var selectedDatacenterPort = 0 private var selectedDatacenterPingResult = JSONObject() @@ -143,7 +146,7 @@ class PSGaikaiStreaming( if (isCancelled()) { return@withContext AllocationResult(false, "Allocation cancelled") } - step8_StartSession(entitlementId) ?: return@withContext AllocationResult(false, "Failed to start session") + step8_StartSession(entitlementId) ?: return@withContext AllocationResult(false, "Session start failed: $lastStartSessionError") Log.i(TAG, "✓ Step 8: Started session") // Step 8a: Get gkClientId auth code @@ -452,6 +455,10 @@ class PSGaikaiStreaming( { Log.e(TAG, "Step 8 failed: ${response.statusCode}") Log.e(TAG, "Response: ${response.body}") + // Surface the response body: this is where Gaikai reports an unowned/invalid + // entitlement (e.g. {"name":"noGameForEntitlementId",...}), which the owned fast-path + // fallback in CloudStreamingBackend keys off to retry via the full resolve/acquire flow. + lastStartSessionError = response.body return null } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index 321733b7..2852e882 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -40,13 +40,33 @@ class PSKamajiSession( private val kamajiClientId = PsnApiConstants.CLIENT_ID private var platform = "ps4" // Default, will be detected from API response private var scopesStr = PsnApiConstants.PS4_SCOPES // Default to PS4 scopes - + // State tracking private var anonAuthCode: String? = null // OAuth code for anonymous session private var authorizationCode: String? = null // OAuth code for authenticated session private var jsessionId: String? = null // JSESSIONID from anonymous session private var entitlementId: String? = null // Converted from productId private var streamingSku: String? = null // SKU from product ID conversion + + // Owned-PSNOW fast-path: the unified catalog's pre-resolved owned streaming entitlement. + // When set, startSessionCreation skips the entire entitlement path (0.5b/0.5d/0.5e). See + // setOwnedEntitlementFastPath(). usedEntitlementFastPath gates the orchestrator's one-shot retry. + private var fastPathEntitlementId: String = "" + private var fastPathPlatform: String = "" + var usedEntitlementFastPath = false + private set + + /** + * Owned-PSNOW fast-path: hand in the streaming entitlement the unified catalog already resolved + * for an owned title, so startSessionCreation() skips the entitlement path (0.5b anonymous + * session, 0.5d product->entitlement resolve, 0.5e check/acquire) and goes straight to step5/6. + * Empty == normal full flow. The orchestrator falls back to the full flow if Gaikai rejects it. + */ + fun setOwnedEntitlementFastPath(ownedEntitlementId: String, ownedPlatform: String) + { + fastPathEntitlementId = ownedEntitlementId + fastPathPlatform = ownedPlatform + } /** * Data class for session result @@ -74,7 +94,29 @@ class PSKamajiSession( { return@withContext SessionResult(false, "NPSSO token is empty") } - + + // Owned-PSNOW fast-path: the unified catalog already resolved this title's streaming + // entitlement, so there is nothing to look up or acquire. Skip the entire entitlement + // path (0.5b/0.5d/0.5e -- which 404 and fail to acquire in storefront-less regions) and + // go straight to the authenticated session. step5/6 are independent of the anonymous + // session, so this is safe. The orchestrator retries the full flow if Gaikai rejects it. + if (fastPathEntitlementId.isNotEmpty()) + { + entitlementId = fastPathEntitlementId + platform = if (fastPathPlatform.isEmpty()) "ps4" else fastPathPlatform + scopesStr = if (platform == "ps3") "kamaji:commerce_native" else PsnApiConstants.PS4_SCOPES + usedEntitlementFastPath = true + Log.i(TAG, "Kamaji fast-path: owned entitlementId=$entitlementId platform=$platform - skipping 0.5b/0.5d/0.5e") + + val authCode = step5_GetAuthCode(npssoToken) + ?: return@withContext SessionResult(false, "Failed to get auth code") + authorizationCode = authCode + val authSession = step6_CreateAuthSession(authCode) + ?: return@withContext SessionResult(false, "Failed to create authenticated session") + Log.i(TAG, "=== Kamaji Session Complete (fast-path) === Entitlement ID: $entitlementId, Platform: $platform") + return@withContext SessionResult(true, "Success", entitlementId!!, platform) + } + // Step 0.5b: Get Anonymous Auth Code val anonCode = step0_5b_GetAnonymousAuthCode(npssoToken) ?: return@withContext SessionResult(false, "Failed to get anonymous auth code") diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index 11015cf0..b90865f2 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -1085,6 +1085,10 @@ class CloudPlayFragment : Fragment() gameIdentifier = game.streamIdentifier, gameName = game.name, npssoToken = npssoToken, + // Owned-PSNOW fast-path: the catalog's pre-resolved streaming entitlement (empty + // for unowned titles -> normal full flow). Only used by the PSNOW path. + ownedEntitlementId = game.entitlementId, + ownedPlatform = game.platform, onProgress = { message -> requireActivity().runOnUiThread { allocationProgressTextView?.text = message diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index aa6cb627..1368783f 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -24,6 +24,8 @@ final class CloudStreamingBackend { gameIdentifier: String, gameName: String, npssoToken: String, + ownedEntitlementId: String = "", // PSNOW owned fast-path: catalog's pre-resolved entitlement + ownedPlatform: String = "", // platform accompanying ownedEntitlementId onProgress: ((String) -> Void)? = nil, isCancelled: @escaping () -> Bool = { false } ) throws -> CloudStreamSession { @@ -57,11 +59,20 @@ final class CloudStreamingBackend { gameName: gameName, npssoToken: npssoToken, sharedDuid: sharedDuid, + ownedEntitlementId: ownedEntitlementId, + ownedPlatform: ownedPlatform, onProgress: onProgress, isCancelled: isCancelled ) } + /// True when a Gaikai allocation error means "the entitlement we streamed isn't valid/owned" + /// (Gaikai's session-start reports {"name":"noGameForEntitlementId",...}). Signals the owned + /// fast-path guessed wrong and we should retry with the full resolve/acquire flow. + private func isEntitlementRejectedError(_ message: String) -> Bool { + message.range(of: "noGameForEntitlement", options: .caseInsensitive) != nil + } + // MARK: - Continue After Auth private func continueCloudSessionAfterAuth( @@ -70,6 +81,9 @@ final class CloudStreamingBackend { gameName: String, npssoToken: String, sharedDuid: String, + ownedEntitlementId: String = "", + ownedPlatform: String = "", + forceFullEntitlementFlow: Bool = false, // true on the one-shot fallback retry (disables fast-path) onProgress: ((String) -> Void)?, isCancelled: @escaping () -> Bool ) throws -> CloudStreamSession { @@ -87,6 +101,7 @@ final class CloudStreamingBackend { let initialPlatform = serviceType == "pscloud" ? "ps5" : "ps4" var finalEntitlementId = gameIdentifier var finalPlatform = initialPlatform + var usedFastPath = false // For PSNOW: Kamaji session (converts productId -> entitlementId) // For PSCLOUD: Skip Kamaji entirely @@ -99,7 +114,18 @@ final class CloudStreamingBackend { redirectUri: redirectUri, userAgent: userAgent ) + // Owned-PSNOW fast-path: if the catalog already resolved this title's streaming + // entitlement (owned), hand it to Kamaji so it skips the resolve/acquire path. + // Disabled on the fallback retry (forceFullEntitlementFlow). + if !forceFullEntitlementFlow && !ownedEntitlementId.isEmpty { + os_log(.info, log: cloudLog, "PSNOW owned fast-path: catalog entitlementId=%{public}s platform=%{public}s", + ownedEntitlementId, ownedPlatform) + kamajiSession.setOwnedEntitlementFastPath(ownedEntitlementId: ownedEntitlementId, ownedPlatform: ownedPlatform) + } else if forceFullEntitlementFlow { + os_log(.info, log: cloudLog, "PSNOW: forcing full entitlement flow (fast-path retry fallback)") + } let kamajiResult = kamajiSession.startSessionCreation(npssoToken: npssoToken) + usedFastPath = kamajiSession.usedEntitlementFastPath guard kamajiResult.success else { throw KamajiSessionError(message: "Kamaji session failed: \(kamajiResult.message)") } @@ -121,8 +147,35 @@ final class CloudStreamingBackend { onProgress: onProgress, isCancelled: isCancelled ) - let allocationResult = try gaikai.startAllocationFlow(entitlementId: finalEntitlementId) + + // Owned fast-path fallback: if Gaikai rejects a catalog entitlement (it isn't actually + // valid/owned), retry exactly once via the full resolve/acquire flow. One shot only -- + // forceFullEntitlementFlow disables the fast-path on the retry -- so it can never loop. + // Gaikai reports this both by throwing GaikaiAllocationError (session start) and via a + // success=false result, so handle both. + func retryFullFlow() throws -> CloudStreamSession { + os_log(.error, log: cloudLog, "Owned fast-path entitlement rejected by Gaikai; retrying once with the full entitlement flow") + return try continueCloudSessionAfterAuth( + serviceType: serviceType, gameIdentifier: gameIdentifier, gameName: gameName, + npssoToken: npssoToken, sharedDuid: sharedDuid, + ownedEntitlementId: "", ownedPlatform: "", forceFullEntitlementFlow: true, + onProgress: onProgress, isCancelled: isCancelled + ) + } + + let allocationResult: GaikaiAllocationResult + do { + allocationResult = try gaikai.startAllocationFlow(entitlementId: finalEntitlementId) + } catch let error as GaikaiAllocationError { + if usedFastPath && !forceFullEntitlementFlow && isEntitlementRejectedError(error.message) { + return try retryFullFlow() + } + throw error + } guard allocationResult.success else { + if usedFastPath && !forceFullEntitlementFlow && isEntitlementRejectedError(allocationResult.message) { + return try retryFullFlow() + } throw GaikaiAllocationError(message: "Gaikai allocation failed: \(allocationResult.message)") } diff --git a/ios/Pylux/Services/PSGaikaiStreaming.swift b/ios/Pylux/Services/PSGaikaiStreaming.swift index cbfee4a2..ccf8579e 100644 --- a/ios/Pylux/Services/PSGaikaiStreaming.swift +++ b/ios/Pylux/Services/PSGaikaiStreaming.swift @@ -248,8 +248,14 @@ final class PSGaikaiStreaming { guard let response = CloudHttpClient.post(url: url, body: bodyStr, headers: [ "Content-Type": "application/json", "User-Agent": userAgent, "Accept": "application/json", "X-Gaikai-Session": configKey - ]), response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Failed to start session") + ]) else { + throw GaikaiAllocationError(message: "Session start failed: no response") + } + guard response.statusCode == 200 else { + // Include the response body: Gaikai reports an unowned/invalid entitlement here + // (e.g. {"name":"noGameForEntitlementId",...}), which the owned fast-path fallback in + // CloudStreamingBackend keys off to retry via the full resolve/acquire flow. + throw GaikaiAllocationError(message: "Session start failed: \(response.body)") } if let newKey = response.header("x-gaikai-session") ?? response.header("X-Gaikai-Session"), diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 8e1ff6dd..d9df3b18 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -28,6 +28,13 @@ final class PSKamajiSession { private var streamingSku: String? private var commerceOAuthToken: String? + // Owned-PSNOW fast-path: the unified catalog's pre-resolved owned streaming entitlement. When + // set, startSessionCreation skips the entitlement path (0.5b/0.5d/0.5e). See + // setOwnedEntitlementFastPath(). usedEntitlementFastPath gates the orchestrator's one-shot retry. + private var fastPathEntitlementId = "" + private var fastPathPlatform = "" + private(set) var usedEntitlementFastPath = false + init(duid: String, productId: String, accountBaseUrl: String, redirectUri: String, userAgent: String) { self.duid = duid self.productId = productId @@ -36,11 +43,45 @@ final class PSKamajiSession { self.userAgent = userAgent } + /// Owned-PSNOW fast-path: hand in the streaming entitlement the unified catalog already resolved + /// for an owned title, so startSessionCreation() skips the entitlement path (0.5b anonymous + /// session, 0.5d product->entitlement resolve, 0.5e check/acquire) and goes straight to step5/6. + /// Empty == normal full flow. The orchestrator falls back to the full flow if Gaikai rejects it. + func setOwnedEntitlementFastPath(ownedEntitlementId: String, ownedPlatform: String) { + fastPathEntitlementId = ownedEntitlementId + fastPathPlatform = ownedPlatform + } + /// Start the complete Kamaji session creation flow func startSessionCreation(npssoToken: String) -> KamajiSessionResult { os_log(.info, log: kamajiLog, "=== Starting Kamaji Session ===") os_log(.info, log: kamajiLog, "Product ID: %{public}s", productId) + // Owned-PSNOW fast-path: the unified catalog already resolved this title's streaming + // entitlement, so there is nothing to look up or acquire. Skip the entire entitlement path + // (0.5b/0.5d/0.5e -- which 404 and fail to acquire in storefront-less regions) and go + // straight to the authenticated session (step5/6 here reuse 0.5b/0.5c). The orchestrator + // retries the full flow if Gaikai rejects it. + if !fastPathEntitlementId.isEmpty { + entitlementId = fastPathEntitlementId + platform = fastPathPlatform.isEmpty ? "ps4" : fastPathPlatform + scopesStr = platform == "ps3" ? "kamaji:commerce_native" : CloudApiConstants.ps4Scopes + usedEntitlementFastPath = true + os_log(.info, log: kamajiLog, + "Kamaji fast-path: owned entitlementId=%{public}s platform=%{public}s - skipping 0.5b/0.5d/0.5e", + entitlementId ?? "", platform) + + guard let authCode = step0_5b_GetAnonymousAuthCode(npssoToken: npssoToken) else { + return KamajiSessionResult(success: false, message: "Failed to get auth code") + } + guard step0_5c_CreateAnonymousSession(authCode: authCode) != nil else { + return KamajiSessionResult(success: false, message: "Failed to create auth session") + } + os_log(.info, log: kamajiLog, "=== Kamaji Session Complete (fast-path) ===") + return KamajiSessionResult(success: true, message: "Success", + entitlementId: entitlementId ?? "", platform: platform) + } + // Step 0.5b: Get anonymous auth code guard let anonCode = step0_5b_GetAnonymousAuthCode(npssoToken: npssoToken) else { return KamajiSessionResult(success: false, message: "Failed to get anonymous auth code") diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 7c00d727..8e550f8b 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -214,6 +214,10 @@ final class CloudPlayViewModel: ObservableObject { gameIdentifier: gameIdentifier, gameName: gameName, npssoToken: npssoToken, + // Owned-PSNOW fast-path: the catalog's pre-resolved streaming entitlement + // (empty for unowned -> normal full flow). Only used by the PSNOW path. + ownedEntitlementId: game.entitlementId, + ownedPlatform: game.platform, onProgress: { msg in Task { @MainActor in self.allocationProgress = msg From 4ee975becf3aa1083d191b602ce21d49d931369d Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 01:37:45 -0700 Subject: [PATCH 28/72] Cloud streaming: server-authoritative store language (resolvedStoreLang) step0_5d's /container/{CC}/{lang}/ entitlement lookup 404s on the wrong language for a non-English native store (NL needs /NL/nl/, rejects /NL/en/). The imagic catalog can settle its locale to English while the Kamaji store still serves the native language, so the locale-derived proxy step0_5d used was unreliable. Parse the store language from the /user/stores base_url (same source we already use for the store country) and emit it as a new "resolvedStoreLang" field. step0_5d now prefers it over the locale proxy, falling back to the proxy only in fallback/foreign mode where the field is empty. Mirrored across libchiaki plus Qt, Android and iOS (setting + persist + step0_5d). Bump schemaVersion 2 -> 3 so existing caches (24h TTL) refetch and pick up the field immediately rather than after expiry. Co-Authored-By: Claude Opus 4.8 --- .../metallic/chiaki/cloudplay/api/PSKamajiSession.kt | 6 +++++- .../cloudplay/repository/CloudGameRepository.kt | 1 + .../java/com/metallic/chiaki/common/Preferences.kt | 10 ++++++++++ gui/include/settings.h | 3 +++ gui/src/cloudcatalogbackend.cpp | 1 + gui/src/cloudstreaming/pskamajisession.cpp | 6 +++++- gui/src/settings.cpp | 10 ++++++++++ ios/Pylux/Services/CloudCatalogService.swift | 1 + ios/Pylux/Services/PSKamajiSession.swift | 6 +++++- ios/Pylux/Services/SecureStore.swift | 11 ++++++++++- lib/include/chiaki/cloudcatalog.h | 12 +++++++++--- lib/src/cloudcatalog_internal.h | 3 ++- lib/src/cloudcatalog_merge.c | 1 + lib/src/cloudcatalog_unified.c | 4 ++++ 14 files changed, 67 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index 2852e882..e8c460a4 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -351,10 +351,14 @@ class PSKamajiSession( try { val resolvedCountry = preferences.getCloudResolvedStoreCountry() + val resolvedLang = preferences.getCloudResolvedStoreLang() val localeSetting = preferences.getCloudStoreLocale() val parsedLocale = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) val (country, language) = if (resolvedCountry.isNotEmpty()) { - resolvedCountry to parsedLocale.second + // Prefer the server-authoritative store language from the native base_url: a non-English + // native store (e.g. NL) 404s on the wrong language. Fall back to the locale-derived + // proxy when empty (fallback/foreign mode, where the public US/GB store wants en). + resolvedCountry to (resolvedLang.ifEmpty { parsedLocale.second }) } else { parsedLocale } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 37f04c8c..a840a44e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -127,6 +127,7 @@ class CloudGameRepository( preferences.noteCloudStoreLocaleSettled(it) } preferences.setCloudResolvedStoreCountry(root.optString("fallbackRegion", "")) + preferences.setCloudResolvedStoreLang(root.optString("resolvedStoreLang", "")) preferences.setCloudCatalogNativeMode(root.optBoolean("nativeMode", true)) root.optString("warning", "").takeIf { it.isNotEmpty() }?.let { diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index 3363ee83..95ae8778 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -68,6 +68,7 @@ class Preferences(context: Context) private const val CLOUD_GAME_LANGUAGE_KEY = "cloud_game_language" private const val LEGACY_CLOUD_STREAM_LANGUAGE_KEY = "cloud_stream_language" private const val CLOUD_RESOLVED_STORE_COUNTRY_KEY = "cloud_resolved_store_country" + private const val CLOUD_RESOLVED_STORE_LANG_KEY = "cloud_resolved_store_lang" private const val LEGACY_CLOUD_FALLBACK_REGION_KEY = "cloud_fallback_region" private const val CLOUD_CATALOG_NATIVE_MODE_KEY = "cloud_catalog_native_mode" } @@ -505,6 +506,15 @@ class Preferences(context: Context) sharedPreferences.edit().putString(CLOUD_RESOLVED_STORE_COUNTRY_KEY, country).apply() } + /** Server store language parsed from the native base_url (e.g. "nl"); empty in fallback mode. */ + fun getCloudResolvedStoreLang(): String = + sharedPreferences.getString(CLOUD_RESOLVED_STORE_LANG_KEY, "") ?: "" + + fun setCloudResolvedStoreLang(lang: String) + { + sharedPreferences.edit().putString(CLOUD_RESOLVED_STORE_LANG_KEY, lang).apply() + } + fun getCloudCatalogNativeMode(): Boolean { if (sharedPreferences.contains(CLOUD_CATALOG_NATIVE_MODE_KEY)) diff --git a/gui/include/settings.h b/gui/include/settings.h index 1fd094b0..8b33044b 100644 --- a/gui/include/settings.h +++ b/gui/include/settings.h @@ -453,6 +453,9 @@ class Settings : public QObject /** PS Now region-group fallback store country. Empty = native mode; "US"/"GB" = fallback mode. */ QString GetCloudResolvedStoreCountry() const; void SetCloudResolvedStoreCountry(const QString &country); + /** Server store language parsed from the native base_url (e.g. "nl"); empty in fallback mode. */ + QString GetCloudResolvedStoreLang() const; + void SetCloudResolvedStoreLang(const QString &lang); bool GetCloudCatalogNativeMode() const; void SetCloudCatalogNativeMode(bool native_mode); bool IsCloudCatalogIsForeign() const; diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index c1e8a7d4..bf632eca 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -271,6 +271,7 @@ void CloudCatalogBackend::fetchUnifiedCatalog(const QJSValue &callback) if (!settled.isEmpty() && settled != settings->GetCloudStoreLocale()) settings->SetCloudStoreLocale(settled); settings->SetCloudResolvedStoreCountry(root.value(QStringLiteral("fallbackRegion")).toString()); + settings->SetCloudResolvedStoreLang(root.value(QStringLiteral("resolvedStoreLang")).toString()); settings->SetCloudCatalogNativeMode(root.value(QStringLiteral("nativeMode")).toBool(true)); } diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index d0c67bdf..4ab320f6 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -348,11 +348,15 @@ void PSKamajiSession::step0_5d_ConvertProductId() const QString localeLang = localeParts.size() > 0 ? localeParts[0].toLower() : QStringLiteral("en"); const QString localeCountry = localeParts.size() > 1 ? localeParts[1].toUpper() : QStringLiteral("US"); QString resolvedCountry = settings ? settings->GetCloudResolvedStoreCountry() : QString(); + QString resolvedLang = settings ? settings->GetCloudResolvedStoreLang() : QString(); QString country; QString language; if (!resolvedCountry.isEmpty()) { country = resolvedCountry; - language = localeLang; + // Prefer the server-authoritative store language from the native base_url: a non-English + // native store (e.g. NL) 404s on the wrong language. Fall back to the locale-derived + // proxy when empty (fallback/foreign mode, where the public US/GB store wants en). + language = !resolvedLang.isEmpty() ? resolvedLang : localeLang; } else { country = localeCountry; language = localeLang; diff --git a/gui/src/settings.cpp b/gui/src/settings.cpp index cf6cba00..d1452a72 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -1060,6 +1060,16 @@ void Settings::SetCloudResolvedStoreCountry(const QString &country) settings.setValue(QStringLiteral("settings/cloud_resolved_store_country"), country); } +QString Settings::GetCloudResolvedStoreLang() const +{ + return settings.value(QStringLiteral("settings/cloud_resolved_store_lang"), QString()).toString(); +} + +void Settings::SetCloudResolvedStoreLang(const QString &lang) +{ + settings.setValue(QStringLiteral("settings/cloud_resolved_store_lang"), lang); +} + bool Settings::GetCloudCatalogNativeMode() const { const QString key = QStringLiteral("settings/cloud_catalog_native_mode"); diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 4790eaec..4621d720 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -61,6 +61,7 @@ final class CloudCatalogService { CloudLocaleSettings.noteSettledLocale(settled) } SecureStore.shared.cloudResolvedStoreCountry = root["fallbackRegion"] as? String ?? "" + SecureStore.shared.cloudResolvedStoreLang = root["resolvedStoreLang"] as? String ?? "" if let nativeMode = root["nativeMode"] as? Bool { SecureStore.shared.cloudCatalogNativeMode = nativeMode } diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index d9df3b18..ad07c9e4 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -190,12 +190,16 @@ final class PSKamajiSession { // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { let resolvedCountry = SecureStore.shared.cloudResolvedStoreCountry + let resolvedLang = SecureStore.shared.cloudResolvedStoreLang let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) let country: String let language: String if !resolvedCountry.isEmpty { country = resolvedCountry - language = storePath.language + // Prefer the server-authoritative store language from the native base_url: a non-English + // native store (e.g. NL) 404s on the wrong language. Fall back to the locale-derived + // proxy when empty (fallback/foreign mode, where the public US/GB store wants en). + language = resolvedLang.isEmpty ? storePath.language : resolvedLang } else { country = storePath.country language = storePath.language diff --git a/ios/Pylux/Services/SecureStore.swift b/ios/Pylux/Services/SecureStore.swift index e39b81eb..130e08ed 100644 --- a/ios/Pylux/Services/SecureStore.swift +++ b/ios/Pylux/Services/SecureStore.swift @@ -157,6 +157,7 @@ final class SecureStore { private let kCloudFavorites = "favorite_games" private let kCloudSortState = "cloud_sort_state" private let kCloudResolvedStoreCountry = "cloud_resolved_store_country" + private let kCloudResolvedStoreLang = "cloud_resolved_store_lang" private let kLegacyCloudFallbackRegion = "cloud_fallback_region" private let kCloudCatalogNativeMode = "cloud_catalog_native_mode" private let kCloudTagFilters = "cloud_tag_filters" @@ -302,6 +303,14 @@ final class SecureStore { } } + /// Server store language parsed from the native base_url (e.g. "nl"); empty in fallback mode. + var cloudResolvedStoreLang: String { + get { KC.readString(kCloudResolvedStoreLang) ?? "" } + set { + newValue.isEmpty ? KC.delete(kCloudResolvedStoreLang) : KC.writeString(kCloudResolvedStoreLang, newValue) + } + } + var cloudCatalogNativeMode: Bool { get { if KC.readString(kCloudCatalogNativeMode) != nil { @@ -407,7 +416,7 @@ final class SecureStore { kStreamPrefs, kDcPscloud, kDcPsnow, kCloudFavorites, kCloudSortState, - kCloudResolvedStoreCountry, kLegacyCloudFallbackRegion, kCloudCatalogNativeMode, + kCloudResolvedStoreCountry, kCloudResolvedStoreLang, kLegacyCloudFallbackRegion, kCloudCatalogNativeMode, kCloudTagFilters, kTotalStreamTimeMs, kLastDonationPromptWallMs, kDonationPaywallShowCount, kLastAppReviewPromptTotalStreamMs, diff --git a/lib/include/chiaki/cloudcatalog.h b/lib/include/chiaki/cloudcatalog.h index 0c571d89..a1b038ac 100644 --- a/lib/include/chiaki/cloudcatalog.h +++ b/lib/include/chiaki/cloudcatalog.h @@ -25,8 +25,11 @@ extern "C" { * v2: settledLocale is now the locale the lib resolved AFTER re-basing on the * account's Kamaji-session country (region detection moved into the lib), so a * pre-v2 cached payload can hold wrong-region content for international accounts - * and must be refetched. */ -#define CHIAKI_CLOUDCATALOG_SCHEMA_VERSION 2 + * and must be refetched. + * v3: adds "resolvedStoreLang" (server store language for the step0_5d container URL); + * bumped so existing caches refetch and clients get the field immediately rather + * than after the 24h TTL. */ +#define CHIAKI_CLOUDCATALOG_SCHEMA_VERSION 3 typedef struct chiaki_cloudcatalog_config_t { @@ -53,10 +56,13 @@ typedef struct chiaki_cloudcatalog_result_t * The JSON envelope (see CHIAKI_CLOUDCATALOG_SCHEMA_VERSION): * * { - * "schemaVersion": 2, + * "schemaVersion": 3, * "total": , * "nativeMode": , // true when the authenticated PS Now walk succeeded * "fallbackRegion": "US"|"GB"|..., // server-authoritative store country for container URLs + * "resolvedStoreLang": "nl"|"", // server store language parsed from the native base_url; + * // clients use it for the step0_5d container URL (a non-English + * // native store 404s on the wrong language). "" in fallback mode. * "settledLocale": "en-US", // locale the lib resolved (account region from the Kamaji * // session re-bases the caller locale, then the imagic store- * // locale chain settles); clients persist this verbatim diff --git a/lib/src/cloudcatalog_internal.h b/lib/src/cloudcatalog_internal.h index e3cc09e0..cc9feaaf 100644 --- a/lib/src/cloudcatalog_internal.h +++ b/lib/src/cloudcatalog_internal.h @@ -120,7 +120,8 @@ typedef struct cc_assemble_input_t struct json_object *imagic_supplement;/**< imagic plus-library supplement rows array, or NULL */ struct json_object *owned_cross_ref; /**< processed owned entitlements array, or NULL */ bool native_mode; - const char *fallback_region; /**< "US"|"GB"|"" */ + const char *fallback_region; /**< "US"|"GB"|... store country from base_url, or "" */ + const char *resolved_store_lang; /**< store language from native base_url ("nl"), "" in fallback */ const char *settled_locale; /**< or NULL */ const char *warning; /**< or NULL/"" */ } CCAssembleInput; diff --git a/lib/src/cloudcatalog_merge.c b/lib/src/cloudcatalog_merge.c index b3c87243..6825fa7a 100644 --- a/lib/src/cloudcatalog_merge.c +++ b/lib/src/cloudcatalog_merge.c @@ -1338,6 +1338,7 @@ struct json_object *cc_assemble_unified_catalog(ChiakiLog *log, const CCAssemble json_object_object_add(out, "total", json_object_new_int((int)n)); json_object_object_add(out, "nativeMode", json_object_new_boolean(in->native_mode)); cc_json_set_str(out, "fallbackRegion", in->fallback_region ? in->fallback_region : ""); + cc_json_set_str(out, "resolvedStoreLang", in->resolved_store_lang ? in->resolved_store_lang : ""); if(in->settled_locale && *in->settled_locale) cc_json_set_str(out, "settledLocale", in->settled_locale); cc_json_set_str(out, "warning", in->warning ? in->warning : ""); diff --git a/lib/src/cloudcatalog_unified.c b/lib/src/cloudcatalog_unified.c index 4687e909..a2e25b7f 100644 --- a/lib/src/cloudcatalog_unified.c +++ b/lib/src/cloudcatalog_unified.c @@ -349,6 +349,10 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloudcatalog_fetch_unified( in.owned_cross_ref = owned_cross_ref; in.native_mode = native; in.fallback_region = fallback_region; + // Server-authoritative store language parsed from the native base_url (e.g. "nl"); "" in the + // public-fallback path (no base_url). Clients use it for the step0_5d container URL so a + // non-English native store (which 404s on the wrong language) always gets its real language. + in.resolved_store_lang = store_lang; in.settled_locale = settled; in.warning = warning; struct json_object *env = cc_assemble_unified_catalog(log, &in); From 7f31f24b9bdaa959b7f89dc8559c4ffb36c5bce0 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 01:37:55 -0700 Subject: [PATCH 29/72] Cloud Play: hide region banner on auth failure or mid-load The "PlayStation cloud isn't offered natively in your region" banner fired whenever nativeMode=false -- including when the native probe failed for an auth reason (missing/expired npsso). In that case the region was never actually determined, so the banner was misleading; the login/expired banner is the real message. It also flashed during catalog load because the persisted nativeMode held a stale value mid-fetch. Show the region banner only when nativeMode=false AND there is no auth warning AND the catalog is not loading. Applied to Qt, Android and iOS. Co-Authored-By: Claude Opus 4.8 --- .../com/metallic/chiaki/main/CloudPlayFragment.kt | 12 +++++++++++- gui/src/qml/CloudPlayView.qml | 11 ++++++++--- ios/Pylux/Views/CloudPlayView.swift | 7 ++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index b90865f2..46460db1 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -733,6 +733,9 @@ class CloudPlayFragment : Fragment() }) viewModel.loading.observe(viewLifecycleOwner, Observer { loading -> + // Re-evaluate the region banner: catalogIsForeign holds a stale value mid-fetch, so the + // banner must only reflect a COMPLETED fetch (otherwise it flashes while games load). + updateRegionBanner(viewModel.fallbackRegion.value) binding.progressBar.visibility = if(loading && adapter.games.isEmpty()) View.VISIBLE else View.GONE if (loading) { val rotate = RotateAnimation(0f, 360f, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f).apply { @@ -753,6 +756,8 @@ class CloudPlayFragment : Fragment() }) viewModel.warning.observe(viewLifecycleOwner, Observer { warning -> + // An auth error also re-evaluates the region banner (it must hide when login failed). + updateRegionBanner(viewModel.fallbackRegion.value) if (warning.isNullOrEmpty()) return@Observer Toast.makeText(requireContext(), warning, Toast.LENGTH_LONG).show() }) @@ -767,7 +772,12 @@ class CloudPlayFragment : Fragment() private fun updateRegionBanner(region: String?) { - if (viewModel.catalogIsForeign.value != true) { + // Suppress the region banner when an auth error is present: nativeMode=false is then just a + // side-effect of the failed login (region was never determined), so the expired/login + // prompt is the real message -- not "your region has no cloud". + val hasAuthError = !viewModel.warning.value.isNullOrEmpty() + val isLoading = viewModel.loading.value == true + if (viewModel.catalogIsForeign.value != true || hasAuthError || isLoading) { binding.regionBanner.visibility = View.GONE } else { val label = region?.takeIf { it.isNotEmpty() } ?: "foreign" diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 6448c3bf..e33afe19 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -957,12 +957,17 @@ Pane { anchors.topMargin: 15 spacing: 0 - // Region-group fallback banner (yellow) + // Region-group fallback banner (yellow). + // Only a genuine "region has no native cloud" signal: suppressed when an auth error is + // present, because nativeMode=false is then just a side-effect of the failed login (we + // never determined the region) -- the red expired banner below is the real reason. Rectangle { id: fallbackBanner Layout.fillWidth: true - Layout.preferredHeight: !catalogNativeMode ? 56 : 0 - visible: !catalogNativeMode + Layout.preferredHeight: (!catalogNativeMode && authErrorMessage.length === 0 && !isLoading) ? 56 : 0 + // Gate on !isLoading: catalogNativeMode holds a stale persisted value mid-fetch, so the + // banner must only reflect a COMPLETED fetch (otherwise it flashes while games load). + visible: !catalogNativeMode && authErrorMessage.length === 0 && !isLoading color: Qt.rgba(255/255, 193/255, 7/255, 0.2) border.color: "#FFC107" border.width: 2 diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 8e550f8b..3ebc8358 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -294,7 +294,12 @@ struct CloudPlayView: View { VStack(spacing: 0) { cloudSubTabs - if viewModel.catalogIsForeign { + // Suppress the region banner when an auth error is present: nativeMode=false + // is then just a side-effect of the failed login (region was never + // determined), so the warning banner below is the real message. Also gate on + // !loading: catalogIsForeign holds a stale persisted value mid-fetch, so the + // banner must only reflect a COMPLETED fetch (otherwise it flashes on load). + if viewModel.catalogIsForeign && viewModel.warning == nil && !viewModel.loading { Text("Cloud catalog isn't fully available in your region; some titles may not stream.") .font(.caption) .foregroundColor(.black.opacity(0.85)) From 38d4ac42976a4cca08ec18c1a81429e6bd2085d5 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 02:12:13 -0700 Subject: [PATCH 30/72] CI(android): fix sideloadable-APK step failing when find matches nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assembleRelease with -Pandroid.injected.build.abi produces app-release.apk (no ABI token in the name), so `find -name "*arm64-v8a*.apk"` matched nothing. The pipe `find ... | xargs -0 ls -t` then ran `ls` with no args, listing the cwd and returning the `app` directory — a bogus non-empty value that skipped the *.apk fallback and the "No APK produced" guard, so the final `cp "app" ...` failed with "cp: -r not specified; omitting directory". Add `-r` (--no-run-if-empty) to both xargs calls so an empty find yields an empty APK var, letting the fallback find app-release.apk. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy-android.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index 0e4ad074..eb1e36fb 100644 --- a/.github/workflows/deploy-android.yml +++ b/.github/workflows/deploy-android.yml @@ -164,8 +164,10 @@ jobs: --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" fi # Prefer the arm64-v8a APK; fall back to the newest APK by mtime if naming differs. - APK="$(find app/build -name "*${ABI}*.apk" -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -1)" - [ -n "$APK" ] || APK="$(find app/build -name '*.apk' -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -1)" + # xargs -r so an empty find (e.g. assembleRelease names the file app-release.apk with no + # ABI token) doesn't run `ls` on the cwd and yield a bogus dir ("app") that breaks the cp. + APK="$(find app/build -name "*${ABI}*.apk" -print0 2>/dev/null | xargs -0 -r ls -t 2>/dev/null | head -1)" + [ -n "$APK" ] || APK="$(find app/build -name '*.apk' -print0 2>/dev/null | xargs -0 -r ls -t 2>/dev/null | head -1)" test -n "$APK" || { echo "::error::No APK produced"; exit 1; } SHORT_SHA="$(git rev-parse --short HEAD)" DEST="pylux-${{ steps.extract_version.outputs.version }}-${SHORT_SHA}.apk" From 8aaa31bdd51442830f6338d2eff45fea991f0d6a Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 02:38:46 -0700 Subject: [PATCH 31/72] CI(android): make sideloadable APK installable (drop testOnly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build_apk step used `-Pandroid.injected.build.abi`, which is Android Studio's deploy mechanism and marks the APK testOnly=true. Such an APK can only be installed via `adb install -t` and is rejected by the normal package installer when sideloaded ("can't install on this device" / INSTALL_FAILED_TEST_ONLY). Build a plain assembleRelease/Debug instead — abiFilters already limits the native libs to the built ABI (arm64-v8a), so the APK stays effectively arm64 but is now installable from a file manager / Downloads. Also locate the APK in outputs/ only, so we never grab an intermediate unsigned APK. Only touches the sideloadable-APK path; the AAB build and Google Play upload steps are unchanged. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy-android.yml | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index eb1e36fb..e20d356f 100644 --- a/.github/workflows/deploy-android.yml +++ b/.github/workflows/deploy-android.yml @@ -149,25 +149,23 @@ jobs: if: ${{ inputs.build_apk }} working-directory: android run: | - # abiFilters produces per-ABI split APKs; pin to arm64-v8a (covers virtually - # all modern phones/tablets/TV) so we never ship a stray x86_64 split to testers. - ABI=arm64-v8a + # Plain assemble (NO android.injected.build.* properties): those mark the APK + # testOnly=true, which blocks normal sideloading (INSTALL_FAILED_TEST_ONLY / "can't + # install on this device"). abiFilters in build.gradle already limits native libs to the + # ABIs that were built (arm64-v8a), so this stays effectively arm64 while remaining + # installable from a file manager / the Downloads folder. if [ "$SIGNING_CONFIGURED" = "true" ]; then - echo "Signing configured — building signed release APK ($ABI)" + echo "Signing configured — building signed release APK" ./gradlew assembleRelease \ - -Pandroid.injected.build.abi="$ABI" \ --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" else - echo "::warning::No signing configured — building debug APK ($ABI, self-signed, still installable for testing)" + echo "::warning::No signing configured — building debug APK (self-signed, still installable for testing)" ./gradlew assembleDebug \ - -Pandroid.injected.build.abi="$ABI" \ --parallel --build-cache -Dorg.gradle.java.home="$JAVA_HOME" fi - # Prefer the arm64-v8a APK; fall back to the newest APK by mtime if naming differs. - # xargs -r so an empty find (e.g. assembleRelease names the file app-release.apk with no - # ABI token) doesn't run `ls` on the cwd and yield a bogus dir ("app") that breaks the cp. - APK="$(find app/build -name "*${ABI}*.apk" -print0 2>/dev/null | xargs -0 -r ls -t 2>/dev/null | head -1)" - [ -n "$APK" ] || APK="$(find app/build -name '*.apk' -print0 2>/dev/null | xargs -0 -r ls -t 2>/dev/null | head -1)" + # Newest final APK from the outputs dir (outputs/ only, so we never pick an intermediate + # unsigned APK). xargs -r so an empty find doesn't run `ls` on the cwd and yield a bogus path. + APK="$(find app/build/outputs/apk -name '*.apk' -print0 2>/dev/null | xargs -0 -r ls -t 2>/dev/null | head -1)" test -n "$APK" || { echo "::error::No APK produced"; exit 1; } SHORT_SHA="$(git rev-parse --short HEAD)" DEST="pylux-${{ steps.extract_version.outputs.version }}-${SHORT_SHA}.apk" From fdea780ce5e95d3cd65248de6a0b1467b8255138 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 20:34:02 -0700 Subject: [PATCH 32/72] cloudsession (phase 0): public API + module skeleton Introduce the unified cloud session-provisioning module that will replace the triplicated Kamaji+Gaikai flow across Qt/Android/iOS (mirrors the cloudcatalog consolidation). This commit lands only the contract + a compiling skeleton: - chiaki/cloudsession.h: ChiakiCloudProvisionConfig / ChiakiCloudProvisionResult and the three entry points (chiaki_cloud_provision_session, chiaki_cloud_provision_result_fini, chiaki_cloud_ping_datacenters). - cloudsession.c: result lifecycle + stubbed entry points (return UNKNOWN until the ping/gaikai/kamaji phases land). - wired into lib/CMakeLists.txt next to the cloudcatalog_* sources. No behavior change; nothing calls this yet. Co-Authored-By: Claude Opus 4.8 --- lib/CMakeLists.txt | 2 + lib/include/chiaki/cloudsession.h | 106 ++++++++++++++++++++++++++++++ lib/src/cloudsession.c | 67 +++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 lib/include/chiaki/cloudsession.h create mode 100644 lib/src/cloudsession.c diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 39d16b1a..a1cce49c 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -41,6 +41,7 @@ set(HEADER_FILES include/chiaki/orientation.h include/chiaki/bitstream.h include/chiaki/cloudcatalog.h + include/chiaki/cloudsession.h include/chiaki/remote/holepunch.h include/chiaki/remote/rudp.h include/chiaki/remote/rudpsendbuffer.h) @@ -96,6 +97,7 @@ set(SOURCE_FILES src/cloudcatalog_merge.c src/cloudcatalog_fetch.c src/cloudcatalog_unified.c + src/cloudsession.c src/remote/holepunch.c src/remote/rudp.c src/remote/rudpsendbuffer.c) diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h new file mode 100644 index 00000000..283e22a8 --- /dev/null +++ b/lib/include/chiaki/cloudsession.h @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Unified PS Cloud session provisioning: single source of truth for Qt, +// Android and iOS. Mirrors the cloudcatalog module (chiaki_cloudcatalog_*). +// +// Given a chosen title + npsso (+ resolved store locale + datacenter prefs), +// this runs the entire provisioning flow that used to be duplicated per +// platform -- authorization check, the PSNOW Kamaji session (or the direct +// PSCLOUD path), datacenter discovery/ping/select, and the Gaikai allocation -- +// and returns an allocation result that is *ready to stream*: the platform +// only needs to hand {server_ip, server_port, handshake_key, launch_spec, +// session_id} to its existing StreamSession (which already uses libchiaki). +// +// Blocking / single-threaded: call from a worker thread. Performs all OAuth +// exchanges and HTTP internally from @c cfg->npsso; never persists tokens. + +#ifndef CHIAKI_CLOUDSESSION_H +#define CHIAKI_CLOUDSESSION_H + +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Inputs for one provisioning attempt. All strings are borrowed (the caller + * owns them and they must outlive the call). NULL is treated as "". + */ +typedef struct chiaki_cloud_provision_config_t +{ + const char *service_type; /**< "psnow" (PS3/PS4) | "pscloud" (PS5) */ + const char *game_identifier; /**< productId (psnow) or entitlementId (pscloud) */ + const char *game_name; /**< display name (logging / result echo) */ + const char *npsso; /**< cookie value only (no "npsso=" prefix) */ + const char *store_country; /**< resolvedStoreCountry for the step0_5d container URL */ + const char *store_lang; /**< resolvedStoreLang for the step0_5d container URL */ + const char *owned_entitlement_id; /**< owned-PSNOW fast-path entitlement, or "" */ + const char *owned_platform; /**< platform accompanying owned_entitlement_id, or "" */ + const char *forced_datacenter; /**< settings-selected region; non-empty => SKIP pinging */ + const char *cache_dir; /**< lib-owned datacenter-ping cache lives here; may be "" */ + int rtt_safety_offset_ms; /**< cloud-only RTT offset (e.g. -20); Remote Play unaffected */ + + /** Progress callback: @p stage is a UI-ready string shown verbatim. May be NULL. */ + void (*progress)(const char *stage, void *user); + /** Cancellation check, polled between steps. May be NULL. */ + bool (*is_cancelled)(void *user); + void *user; /**< opaque, passed back to the callbacks */ +} ChiakiCloudProvisionConfig; + +/** + * Allocation result. On success the dynamic strings are heap-owned and must be + * released with chiaki_cloud_provision_result_fini(). + */ +typedef struct chiaki_cloud_provision_result_t +{ + ChiakiErrorCode err; + char server_ip[64]; + int server_port; + char *handshake_key; /**< base64; -> ConnectInfo.cloud_handshake_key */ + char *launch_spec; /**< -> ConnectInfo.cloud_launch_spec */ + char *session_id; + char entitlement_id[128]; /**< the entitlement actually streamed */ + char platform[8]; /**< "ps3"|"ps4"|"ps5" */ + uint32_t mtu_in, mtu_out; + uint64_t rtt_us; + char *datacenter_pings; /**< JSON: [{"dataCenter":...,"rtt_ms":...}, ...] for Settings */ + char *error_message; /**< human-readable detail on failure; may be NULL */ +} ChiakiCloudProvisionResult; + +/** + * Run the full provisioning flow. Blocking; call from a worker thread. + * On a fast-path entitlement rejection (noGameForEntitlementId) the full + * resolve/acquire flow is retried exactly once internally. + * + * @return err in @p out; out->err == CHIAKI_ERR_SUCCESS on a stream-ready result. + */ +CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( + const ChiakiCloudProvisionConfig *cfg, + ChiakiCloudProvisionResult *out, + ChiakiLog *log); + +/** Release the heap-owned fields of a result populated by the call above. */ +CHIAKI_EXPORT void chiaki_cloud_provision_result_fini(ChiakiCloudProvisionResult *out); + +/** + * Ping the account's reachable datacenters and return per-region latency for + * the Settings/overlay UI, without starting a stream. @p out_pings_json is a + * heap-owned JSON array [{"dataCenter":...,"rtt_ms":...}, ...]; free() it. + * Uses the same senkusha-based ping as the provisioning flow. + */ +CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_ping_datacenters( + const ChiakiCloudProvisionConfig *cfg, + char **out_pings_json, + ChiakiLog *log); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CLOUDSESSION_H diff --git a/lib/src/cloudsession.c b/lib/src/cloudsession.c new file mode 100644 index 00000000..a84a3fdd --- /dev/null +++ b/lib/src/cloudsession.c @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Cloud session provisioning -- orchestrator + public entry points. +// Mirrors cloudcatalog.c. Phase 0: skeleton (entry points stubbed); the +// Kamaji / Gaikai / ping logic is filled in by cloudsession_{kamaji,gaikai, +// ping}.c across the following phases. + +#include + +#include +#include + +static void result_init(ChiakiCloudProvisionResult *out) +{ + memset(out, 0, sizeof(*out)); + out->err = CHIAKI_ERR_UNKNOWN; + out->server_port = 0; +} + +CHIAKI_EXPORT void chiaki_cloud_provision_result_fini(ChiakiCloudProvisionResult *out) +{ + if(!out) + return; + free(out->handshake_key); + free(out->launch_spec); + free(out->session_id); + free(out->datacenter_pings); + free(out->error_message); + out->handshake_key = NULL; + out->launch_spec = NULL; + out->session_id = NULL; + out->datacenter_pings = NULL; + out->error_message = NULL; +} + +CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( + const ChiakiCloudProvisionConfig *cfg, + ChiakiCloudProvisionResult *out, + ChiakiLog *log) +{ + if(!cfg || !out) + return CHIAKI_ERR_INVALID_DATA; + result_init(out); + if(!cfg->npsso || !*cfg->npsso) + { + out->err = CHIAKI_ERR_INVALID_DATA; + return out->err; + } + // TODO(phase 4): authorization check -> Kamaji (psnow) / direct (pscloud) + // -> Gaikai allocation -> one-shot fallback. + CHIAKI_LOGW(log, "[CLOUDSESSION] provision not implemented yet (skeleton)"); + out->err = CHIAKI_ERR_UNKNOWN; + return out->err; +} + +CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_ping_datacenters( + const ChiakiCloudProvisionConfig *cfg, + char **out_pings_json, + ChiakiLog *log) +{ + if(!cfg || !out_pings_json) + return CHIAKI_ERR_INVALID_DATA; + *out_pings_json = NULL; + // TODO(phase 1): senkusha ping of reachable datacenters. + CHIAKI_LOGW(log, "[CLOUDSESSION] ping not implemented yet (skeleton)"); + return CHIAKI_ERR_UNKNOWN; +} From 0e6ad8e7243a1dc408163c4e952bad5e88eaf904 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 20:36:48 -0700 Subject: [PATCH 33/72] cloudsession (phase 1): port the senkusha datacenter ping to C cc_ping_datacenter() is a straight C port of the Qt datacenterping.cpp performPingHandshake: resolve host:port, build the minimal ChiakiSession senkusha needs (cloud_port, service_type/psn-wrapper, target), run chiaki_senkusha_run (Takion connect -> BIG/BANG -> echo -> averaged RTT), return rtt_us + MTU. The Qt version's QHostAddress/QThread/QTimer wrappers are dropped; the actual ping was already pure libchiaki calls. The multi-datacenter fan-out and the standalone chiaki_cloud_ping_datacenters entry (which needs Gaikai datacenter discovery) land with phase 2. Co-Authored-By: Claude Opus 4.8 --- lib/CMakeLists.txt | 1 + lib/src/cloudsession_internal.h | 39 +++++++++++ lib/src/cloudsession_ping.c | 113 ++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 lib/src/cloudsession_internal.h create mode 100644 lib/src/cloudsession_ping.c diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index a1cce49c..c712c1fc 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -98,6 +98,7 @@ set(SOURCE_FILES src/cloudcatalog_fetch.c src/cloudcatalog_unified.c src/cloudsession.c + src/cloudsession_ping.c src/remote/holepunch.c src/remote/rudp.c src/remote/rudpsendbuffer.c) diff --git a/lib/src/cloudsession_internal.h b/lib/src/cloudsession_internal.h new file mode 100644 index 00000000..c746ed8e --- /dev/null +++ b/lib/src/cloudsession_internal.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Internal declarations shared across the cloudsession_*.c units. +// Not part of the public API (chiaki/cloudsession.h). + +#ifndef CHIAKI_CLOUDSESSION_INTERNAL_H +#define CHIAKI_CLOUDSESSION_INTERNAL_H + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Ping one datacenter using the senkusha echo/ping handshake (Takion connect -> + * BIG/BANG -> echo -> averaged RTT), the same flow Remote Play uses. Blocking; + * senkusha applies its own internal timeout. + * + * @param public_ip datacenter host or IPv4 (resolved here) + * @param port datacenter UDP port (typically 40101) + * @param session_key x-gaikai-session value, used as the BIG message launch_spec + * @param service_type "psnow" (adds the PSN wrapper) or "pscloud" + * @param out_rtt_us averaged RTT in microseconds, or <0 on failure + * @param out_mtu_in/out_mtu_out negotiated MTU (0 on failure) + * @return CHIAKI_ERR_SUCCESS only when a valid RTT was measured. + */ +ChiakiErrorCode cc_ping_datacenter(ChiakiLog *log, const char *public_ip, int port, + const char *session_key, const char *service_type, + int64_t *out_rtt_us, uint32_t *out_mtu_in, uint32_t *out_mtu_out); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CLOUDSESSION_INTERNAL_H diff --git a/lib/src/cloudsession_ping.c b/lib/src/cloudsession_ping.c new file mode 100644 index 00000000..00869cfd --- /dev/null +++ b/lib/src/cloudsession_ping.c @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Datacenter ping for cloud provisioning -- C port of the Qt datacenterping.cpp. +// The heavy lifting is chiaki_senkusha_run (already in libchiaki); this just +// builds the minimal ChiakiSession senkusha needs and resolves the address. + +#include "cloudsession_internal.h" + +#include +#include + +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#endif + +ChiakiErrorCode cc_ping_datacenter(ChiakiLog *log, const char *public_ip, int port, + const char *session_key, const char *service_type, + int64_t *out_rtt_us, uint32_t *out_mtu_in, uint32_t *out_mtu_out) +{ + if(out_rtt_us) *out_rtt_us = -1; + if(out_mtu_in) *out_mtu_in = 0; + if(out_mtu_out) *out_mtu_out = 0; + if(!public_ip || !*public_ip) + return CHIAKI_ERR_INVALID_DATA; + + // Resolve host:port as UDP/IPv4 (getaddrinfo handles both literal IPs and names). + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_DGRAM; + hints.ai_protocol = IPPROTO_UDP; + + char port_str[16]; + snprintf(port_str, sizeof(port_str), "%d", port); + + struct addrinfo *addrinfo_result = NULL; + int gai = getaddrinfo(public_ip, port_str, &hints, &addrinfo_result); + if(gai != 0 || !addrinfo_result) + { + CHIAKI_LOGW(log, "[PING] resolve failed for %s:%d", public_ip, port); + return CHIAKI_ERR_HOST_DOWN; + } + + // senkusha needs a (mostly zeroed) ChiakiSession with a few cloud fields set. + ChiakiSession *session = (ChiakiSession *)calloc(1, sizeof(ChiakiSession)); + if(!session) + { + freeaddrinfo(addrinfo_result); + return CHIAKI_ERR_MEMORY; + } + session->log = log; + session->connect_info.host_addrinfo_selected = addrinfo_result; + session->connect_info.enable_dualsense = false; + session->target = CHIAKI_TARGET_PS5_1; + session->cloud_port = port; + if(service_type && strcmp(service_type, "pscloud") == 0) + { + session->cloud_psn_wrapper_type = 0; // no PSN wrapper for PSCLOUD + session->service_type = CHIAKI_SERVICE_TYPE_PSCLOUD; + } + else + { + session->cloud_psn_wrapper_type = 0x01; // PSN wrapper for PSNOW (and default) + session->service_type = CHIAKI_SERVICE_TYPE_PSNOW; + } + + ChiakiSenkusha senkusha; + ChiakiErrorCode err = chiaki_senkusha_init(&senkusha, session); + if(err != CHIAKI_ERR_SUCCESS) + { + CHIAKI_LOGW(log, "[PING] senkusha init failed for %s: %d", public_ip, err); + freeaddrinfo(addrinfo_result); + free(session); + return err; + } + + senkusha.protocol_version = 9; // cloud ping always v9 + // x-gaikai-session key -> BIG message launch_spec (senkusha owns nothing here; + // it reads the pointer during run, so a stack/heap copy that outlives run() is fine). + char *key_copy = NULL; + if(session_key && *session_key) + { + key_copy = strdup(session_key); + senkusha.cloud_launch_spec = key_copy; + } + + uint32_t mtu_in = 0, mtu_out = 0; + uint64_t rtt_us = 0; + err = chiaki_senkusha_run(&senkusha, &mtu_in, &mtu_out, &rtt_us, NULL); + + senkusha.cloud_launch_spec = NULL; + free(key_copy); + chiaki_senkusha_fini(&senkusha); + freeaddrinfo(addrinfo_result); + free(session); + + if(err != CHIAKI_ERR_SUCCESS || rtt_us == 0) + return (err != CHIAKI_ERR_SUCCESS) ? err : CHIAKI_ERR_UNKNOWN; + + if(out_rtt_us) *out_rtt_us = (int64_t)rtt_us; + if(out_mtu_in) *out_mtu_in = mtu_in; + if(out_mtu_out) *out_mtu_out = mtu_out; + return CHIAKI_ERR_SUCCESS; +} From 8b1098bfbf0fec6b07dad9991915d78ce3ae82ad Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 20:43:58 -0700 Subject: [PATCH 34/72] cloudsession (phase 2a): Gaikai client_ids + config + session threading Start the Gaikai allocation port (psgaikaistreaming.cpp -> C, sequential blocking). This lands the scaffolding + the first two HTTP steps: - GaikaiCtx + cc_gaikai_allocate() entry (virtType/UA derived from service_type+platform). - gk_header_value()/gk_update_session_key(): thread the x-gaikai-session response header (capture_headers) through the flow. - step0 GET /client_ids (gkClientId/ps3GkClientId/streamServerClientId). - step7 POST /config (configKey). Steps 8-13 (start session, OAuth 8a/8b, authorize, lock, datacenters, ping/select via cc_ping_datacenter, allocate+wait) + buildRequestGameSpec land next. cc_gaikai_allocate returns UNKNOWN until then; nothing calls it. Co-Authored-By: Claude Opus 4.8 --- lib/CMakeLists.txt | 1 + lib/src/cloudsession_gaikai.c | 215 ++++++++++++++++++++++++++++++++ lib/src/cloudsession_internal.h | 14 +++ 3 files changed, 230 insertions(+) create mode 100644 lib/src/cloudsession_gaikai.c diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index c712c1fc..a02b2a20 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -99,6 +99,7 @@ set(SOURCE_FILES src/cloudcatalog_unified.c src/cloudsession.c src/cloudsession_ping.c + src/cloudsession_gaikai.c src/remote/holepunch.c src/remote/rudp.c src/remote/rudpsendbuffer.c) diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c new file mode 100644 index 00000000..3172484b --- /dev/null +++ b/lib/src/cloudsession_gaikai.c @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Gaikai allocation flow -- C port of the Qt psgaikaistreaming.cpp async state +// machine, run as a sequential blocking flow. Phase 2 (incremental): +// [done] step0 client_ids, step7 config, x-gaikai-session threading +// [todo] step8 start session, 8a/8b OAuth, step9 authorize, step10 lock, +// step11 datacenters, step12 ping/select, step13 allocate + wait. + +#include "cloudsession_internal.h" +#include "cloudcatalog_internal.h" // cc_json_* helpers +#include "curl_http.h" + +#include + +#include +#include +#include +#include + +#define GK_BASE "https://cc.prod.gaikai.com/v1" +#define GK_CONFIG_BASE "https://config.cc.prod.gaikai.com/v1" +#define GK_UA_PSCLOUD "PlayStation Portal/6.0.0-rel.444+6a9cea6f5" +#define GK_UA_PSNOW "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" + +typedef struct +{ + ChiakiLog *log; + const ChiakiCloudProvisionConfig *cfg; + bool pscloud; + const char *platform; // "ps3"|"ps4"|"ps5" + const char *virt_type; // "konan"|"kratos"|"cronos" + const char *user_agent; + // state filled in by the steps + char *config_key; // x-gaikai-session (updates every response) + char *lock_session_key; + char *gaikai_session_id; + char gk_client_id[128]; + char ps3_gk_client_id[128]; + char stream_server_client_id[128]; +} GaikaiCtx; + +static void gk_progress(GaikaiCtx *c, const char *stage) +{ + if(c->cfg->progress) + c->cfg->progress(stage, c->cfg->user); +} + +static bool gk_cancelled(GaikaiCtx *c) +{ + return c->cfg->is_cancelled && c->cfg->is_cancelled(c->cfg->user); +} + +// Extract a header value (case-insensitive name) from the raw response header +// block. Returns a malloc'd, trimmed value or NULL. +static char *gk_header_value(const char *headers, const char *name) +{ + if(!headers || !name) + return NULL; + size_t nlen = strlen(name); + const char *p = headers; + while(*p) + { + const char *eol = strpbrk(p, "\r\n"); + size_t linelen = eol ? (size_t)(eol - p) : strlen(p); + if(linelen > nlen && strncasecmp(p, name, nlen) == 0 && p[nlen] == ':') + { + const char *v = p + nlen + 1; + while(*v == ' ' || *v == '\t') v++; + size_t vlen = (p + linelen) - v; + while(vlen && (v[vlen-1] == ' ' || v[vlen-1] == '\t')) vlen--; + char *out = (char *)malloc(vlen + 1); + if(!out) return NULL; + memcpy(out, v, vlen); + out[vlen] = '\0'; + return out; + } + if(!eol) break; + p = eol + ((eol[0] == '\r' && eol[1] == '\n') ? 2 : 1); + } + return NULL; +} + +// Update config_key from a response's x-gaikai-session header (if present). +static void gk_update_session_key(GaikaiCtx *c, const CCHttpResponse *resp) +{ + char *k = gk_header_value(resp->headers, "x-gaikai-session"); + if(k && *k) + { + free(c->config_key); + c->config_key = k; + CHIAKI_LOGI(c->log, "[GAIKAI] updated session key (len %zu)", strlen(k)); + } + else + { + free(k); + } +} + +// Step 0: GET /client_ids?virtType=... -> gkClientId / ps3GkClientId / streamServerClientId. +static ChiakiErrorCode gk_step0_client_ids(GaikaiCtx *c) +{ + gk_progress(c, "Getting Client IDs - Step 1 of 10"); + char url[256]; + snprintf(url, sizeof(url), "%s/client_ids?virtType=%s", GK_BASE, c->virt_type); + const char *headers[] = { "Accept: */*", NULL }; + char ua[512]; + snprintf(ua, sizeof(ua), "User-Agent: %s", c->user_agent); + const char *hdrs[] = { headers[0], ua }; + + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = hdrs; + req.header_count = 2; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + if(e != CHIAKI_ERR_SUCCESS || resp.status_code != 200 || !resp.data) + { + CHIAKI_LOGE(c->log, "[GAIKAI] step0 client_ids failed (http %ld)", resp.status_code); + cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; + } + struct json_object *j = json_tokener_parse(resp.data); + if(j) + { + snprintf(c->gk_client_id, sizeof(c->gk_client_id), "%s", cc_json_str(j, "gkClientId")); + snprintf(c->ps3_gk_client_id, sizeof(c->ps3_gk_client_id), "%s", cc_json_str(j, "ps3GkClientId")); + snprintf(c->stream_server_client_id, sizeof(c->stream_server_client_id), "%s", cc_json_str(j, "streamServerClientId")); + json_object_put(j); + } + cc_http_response_fini(&resp); + if(!c->gk_client_id[0]) + return CHIAKI_ERR_UNKNOWN; + CHIAKI_LOGI(c->log, "[GAIKAI] step0: gkClientId=%s", c->gk_client_id); + return CHIAKI_ERR_SUCCESS; +} + +// Step 7: POST /config -> configKey (first x-gaikai-session). +static ChiakiErrorCode gk_step7_config(GaikaiCtx *c) +{ + gk_progress(c, "Getting Configuration - Step 2 of 10"); + char url[256]; + snprintf(url, sizeof(url), "%s/config", GK_CONFIG_BASE); + char body[256]; + snprintf(body, sizeof(body), "{\"product\":\"%s\",\"platform\":\"%s\",\"sessionId\":\"\"}", + c->pscloud ? "qlite" : "psnow", c->pscloud ? "qlite" : "PC"); + char ua[512]; + snprintf(ua, sizeof(ua), "User-Agent: %s", c->user_agent); + const char *hdrs[] = { "Content-Type: application/json", "Accept: */*", ua }; + + CCHttpRequest req = { 0 }; + req.method = "POST"; + req.url = url; + req.headers = hdrs; + req.header_count = 3; + req.body = body; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + if(e != CHIAKI_ERR_SUCCESS || resp.status_code != 200 || !resp.data) + { + CHIAKI_LOGE(c->log, "[GAIKAI] step7 config failed (http %ld)", resp.status_code); + cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; + } + struct json_object *j = json_tokener_parse(resp.data); + if(j) + { + const char *ck = cc_json_str(j, "configKey"); + if(*ck) + { + free(c->config_key); + c->config_key = strdup(ck); + } + json_object_put(j); + } + cc_http_response_fini(&resp); + if(!c->config_key || !*c->config_key) + return CHIAKI_ERR_UNKNOWN; + CHIAKI_LOGI(c->log, "[GAIKAI] step7: got configKey (len %zu)", strlen(c->config_key)); + return CHIAKI_ERR_SUCCESS; +} + +ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, + const ChiakiCloudProvisionConfig *cfg, + const char *platform, const char *entitlement_id, + ChiakiCloudProvisionResult *out) +{ + (void)entitlement_id; (void)out; + GaikaiCtx c; + memset(&c, 0, sizeof(c)); + c.log = log; + c.cfg = cfg; + c.platform = platform ? platform : ""; + c.pscloud = cfg->service_type && strcmp(cfg->service_type, "pscloud") == 0; + c.user_agent = c.pscloud ? GK_UA_PSCLOUD : GK_UA_PSNOW; + if(strcmp(c.platform, "ps3") == 0) c.virt_type = "konan"; + else if(strcmp(c.platform, "ps5") == 0) c.virt_type = "cronos"; + else c.virt_type = "kratos"; // ps4 / default + + ChiakiErrorCode e = gk_step0_client_ids(&c); + if(e == CHIAKI_ERR_SUCCESS && !gk_cancelled(&c)) + e = gk_step7_config(&c); + + // TODO(phase 2 cont.): step8 start session, OAuth 8a/8b, step9 authorize, + // step10 lock, step11 datacenters, step12 ping/select, step13 allocate. + if(e == CHIAKI_ERR_SUCCESS) + { + CHIAKI_LOGW(log, "[GAIKAI] steps 8-13 not implemented yet"); + e = CHIAKI_ERR_UNKNOWN; + } + + free(c.config_key); + free(c.lock_session_key); + free(c.gaikai_session_id); + return e; +} diff --git a/lib/src/cloudsession_internal.h b/lib/src/cloudsession_internal.h index c746ed8e..7bc3cac6 100644 --- a/lib/src/cloudsession_internal.h +++ b/lib/src/cloudsession_internal.h @@ -7,6 +7,7 @@ #define CHIAKI_CLOUDSESSION_INTERNAL_H #include +#include #include #include @@ -15,6 +16,19 @@ extern "C" { #endif +/** + * Gaikai allocation flow (steps 0/7-13): client ids -> config -> start session + * -> OAuth auth codes -> authorize -> lock -> datacenters -> ping/select -> + * allocate slot. @p platform is the resolved "ps3"|"ps4"|"ps5"; @p entitlement_id + * is the entitlement to stream. cfg->service_type selects PSNOW vs PSCLOUD. + * On success fills out->{server_ip,server_port,handshake_key,launch_spec, + * session_id,mtu_*,rtt_us,platform,datacenter_pings}. + */ +ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, + const ChiakiCloudProvisionConfig *cfg, + const char *platform, const char *entitlement_id, + ChiakiCloudProvisionResult *out); + /** * Ping one datacenter using the senkusha echo/ping handshake (Takion connect -> * BIG/BANG -> echo -> averaged RTT), the same flow Remote Play uses. Blocking; From 28a10203a5fe81fe3ca5f2d11737e9cb9764bf53 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 21:02:03 -0700 Subject: [PATCH 35/72] cloudsession (phase 2): full Gaikai allocation flow in C Port the rest of psgaikaistreaming.cpp as a blocking sequence: - step8 start session (captures the noGameForEntitlementId body for the orchestrator's one-shot fallback) - step8a/8b OAuth auth codes via cc_http (no-redirect + npsso cookie -> parse code from the 302 Location; replaces the macOS NSURLSession path), with the shared DUID threaded into the auth-code URLs - buildRequestGameSpec (PSCLOUD + PSNOW bodies) via json-c - step9 authorize (PS+ subscription error via x-gaikai-event 002.2001) - step10 lock (retry up to 12x on pollFrequency) - step11 datacenters: forced-datacenter bypass, else ping every DC via cc_ping_datacenter and sort by RTT; results returned as datacenter_pings - step12 select (auto = lowest RTT with the <80ms gate, or forced) - step13 allocate with the queued/dataMigration wait loop; fills server/handshake/launchSpec/sessionId + psn_wrapper_type from privateIp Config gains game_language/resolution/bitrate_kbps; result gains psn_wrapper_type. cc_gaikai_allocate is the internal entry; the orchestrator (phase 4) will drive it. Nothing calls it yet. Co-Authored-By: Claude Opus 4.8 --- lib/include/chiaki/cloudsession.h | 6 +- lib/src/cloudsession_gaikai.c | 762 +++++++++++++++++++++++++++--- 2 files changed, 700 insertions(+), 68 deletions(-) diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index 283e22a8..fbcfee78 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -42,8 +42,11 @@ typedef struct chiaki_cloud_provision_config_t const char *store_lang; /**< resolvedStoreLang for the step0_5d container URL */ const char *owned_entitlement_id; /**< owned-PSNOW fast-path entitlement, or "" */ const char *owned_platform; /**< platform accompanying owned_entitlement_id, or "" */ - const char *forced_datacenter; /**< settings-selected region; non-empty => SKIP pinging */ + const char *forced_datacenter; /**< settings-selected region; "Auto"/"" => ping & auto-pick */ const char *cache_dir; /**< lib-owned datacenter-ping cache lives here; may be "" */ + const char *game_language; /**< streaming-language locale (e.g. "de-DE"); bare code derived */ + int resolution; /**< 720|1080|1440|2160 (platform picks the per-service value) */ + int bitrate_kbps; /**< cloud stream bitrate (platform picks the per-service value) */ int rtt_safety_offset_ms; /**< cloud-only RTT offset (e.g. -20); Remote Play unaffected */ /** Progress callback: @p stage is a UI-ready string shown verbatim. May be NULL. */ @@ -67,6 +70,7 @@ typedef struct chiaki_cloud_provision_result_t char *session_id; char entitlement_id[128]; /**< the entitlement actually streamed */ char platform[8]; /**< "ps3"|"ps4"|"ps5" */ + uint8_t psn_wrapper_type; /**< from the allocated privateIp last octet -> ConnectInfo */ uint32_t mtu_in, mtu_out; uint64_t rtt_us; char *datacenter_pings; /**< JSON: [{"dataCenter":...,"rtt_ms":...}, ...] for Settings */ diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 3172484b..1e82db4b 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -1,26 +1,45 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL // // Gaikai allocation flow -- C port of the Qt psgaikaistreaming.cpp async state -// machine, run as a sequential blocking flow. Phase 2 (incremental): -// [done] step0 client_ids, step7 config, x-gaikai-session threading -// [todo] step8 start session, 8a/8b OAuth, step9 authorize, step10 lock, -// step11 datacenters, step12 ping/select, step13 allocate + wait. +// machine, run as a single blocking sequence on a worker thread. +// step0 client_ids -> step7 config -> step8 start -> 8a/8b OAuth auth codes +// -> step9 authorize -> step10 lock -> step11 datacenters (ping/forced) -> +// step12 select -> step13 allocate(+wait). Produces the stream-ready result. #include "cloudsession_internal.h" #include "cloudcatalog_internal.h" // cc_json_* helpers #include "curl_http.h" +#include // chiaki_cloud_gaikai_language + +#ifdef _WIN32 +#include +#include +#else +#include // INET6_ADDRSTRLEN (needed by holepunch.h) +#include +#endif +#include // chiaki_holepunch_generate_client_device_uid + #include #include #include #include #include +#include +#include #define GK_BASE "https://cc.prod.gaikai.com/v1" #define GK_CONFIG_BASE "https://config.cc.prod.gaikai.com/v1" +#define ACCOUNT_BASE "https://ca.account.sony.com" #define GK_UA_PSCLOUD "PlayStation Portal/6.0.0-rel.444+6a9cea6f5" #define GK_UA_PSNOW "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +#define GK_REDIR_PSNOW "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" +#define GK_REDIR_PSCLOUD "gaikai://local" +#define MAX_LOCK_RETRIES 12 +#define DEFAULT_ALLOC_WAIT_S 300 +#define MAX_ALLOC_WAIT_S 900 typedef struct { @@ -30,32 +49,47 @@ typedef struct const char *platform; // "ps3"|"ps4"|"ps5" const char *virt_type; // "konan"|"kratos"|"cronos" const char *user_agent; - // state filled in by the steps + const char *oauth_api_path; // "/api/authz/v3" | "/api/v1" + const char *redirect_uri; + char duid[CHIAKI_DUID_STR_SIZE]; // shared client device uid (OAuth + future Kamaji) char *config_key; // x-gaikai-session (updates every response) char *lock_session_key; char *gaikai_session_id; char gk_client_id[128]; char ps3_gk_client_id[128]; char stream_server_client_id[128]; + char *gk_cloud_auth_code; + char *ps3_auth_code; + char *stream_server_auth_code; + struct json_object *spec; // requestGameSpecification (auth codes patched after 8b) + struct json_object *ping_results; // sorted array (also returned to caller) + struct json_object *selected_ping; // borrowed ref into ping_results + char selected_datacenter[128]; + int selected_dc_port; } GaikaiCtx; static void gk_progress(GaikaiCtx *c, const char *stage) { - if(c->cfg->progress) - c->cfg->progress(stage, c->cfg->user); + if(c->cfg->progress) c->cfg->progress(stage, c->cfg->user); } - static bool gk_cancelled(GaikaiCtx *c) { return c->cfg->is_cancelled && c->cfg->is_cancelled(c->cfg->user); } +// Sleep up to seconds, checking cancellation every 100ms. false if cancelled. +static bool gk_sleep_cancellable(GaikaiCtx *c, int seconds) +{ + for(int i = 0; i < seconds * 10; i++) + { + if(gk_cancelled(c)) return false; + usleep(100000); + } + return true; +} -// Extract a header value (case-insensitive name) from the raw response header -// block. Returns a malloc'd, trimmed value or NULL. static char *gk_header_value(const char *headers, const char *name) { - if(!headers || !name) - return NULL; + if(!headers || !name) return NULL; size_t nlen = strlen(name); const char *p = headers; while(*p) @@ -66,12 +100,11 @@ static char *gk_header_value(const char *headers, const char *name) { const char *v = p + nlen + 1; while(*v == ' ' || *v == '\t') v++; - size_t vlen = (p + linelen) - v; + size_t vlen = (size_t)((p + linelen) - v); while(vlen && (v[vlen-1] == ' ' || v[vlen-1] == '\t')) vlen--; char *out = (char *)malloc(vlen + 1); if(!out) return NULL; - memcpy(out, v, vlen); - out[vlen] = '\0'; + memcpy(out, v, vlen); out[vlen] = '\0'; return out; } if(!eol) break; @@ -80,42 +113,275 @@ static char *gk_header_value(const char *headers, const char *name) return NULL; } -// Update config_key from a response's x-gaikai-session header (if present). static void gk_update_session_key(GaikaiCtx *c, const CCHttpResponse *resp) { char *k = gk_header_value(resp->headers, "x-gaikai-session"); - if(k && *k) + if(k && *k) { free(c->config_key); c->config_key = k; } + else free(k); +} + +// Extract a query parameter value (no URL-decoding; Gaikai codes are URL-safe). +static char *gk_query_param(const char *url, const char *key) +{ + if(!url) return NULL; + size_t klen = strlen(key); + const char *p = url; + while((p = strchr(p, key[0])) != NULL) + { + if((p == url || p[-1] == '?' || p[-1] == '&') && + strncmp(p, key, klen) == 0 && p[klen] == '=') + { + const char *v = p + klen + 1; + const char *e = strpbrk(v, "&#"); + size_t vlen = e ? (size_t)(e - v) : strlen(v); + char *out = (char *)malloc(vlen + 1); + if(!out) return NULL; + memcpy(out, v, vlen); out[vlen] = '\0'; + return out; + } + p++; + } + return NULL; +} + +// "Key: Value" -> malloc'd. Caller frees. +static char *gk_hdr(const char *key, const char *value) +{ + size_t n = strlen(key) + 2 + (value ? strlen(value) : 0) + 1; + char *s = (char *)malloc(n); + if(s) snprintf(s, n, "%s: %s", key, value ? value : ""); + return s; +} + +// OAuth /oauth/authorize GET (prompt=none): returns the 302 redirect's ?code=. +static ChiakiErrorCode gk_oauth_code(GaikaiCtx *c, const char *url, char **out_code) +{ + *out_code = NULL; + char *cookie = NULL; + if(cc_http_make_cookie_header(&cookie, "npsso", c->cfg->npsso) != CHIAKI_ERR_SUCCESS) + return CHIAKI_ERR_MEMORY; + char *h_ua = gk_hdr("User-Agent", c->user_agent); + const char *hdrs[] = { h_ua, cookie }; + CCHttpRequest req = { 0 }; + req.url = url; + req.headers = hdrs; + req.header_count = 2; + req.follow_redirects = false; + req.capture_headers = true; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + free(h_ua); free(cookie); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + + const char *loc = resp.redirect_url; + char *loc_hdr = NULL; + if(!loc) { loc_hdr = gk_header_value(resp.headers, "Location"); loc = loc_hdr; } + if(resp.status_code != 302 || !loc) + { + CHIAKI_LOGE(c->log, "[GAIKAI] oauth: expected 302+Location, got %ld", resp.status_code); + free(loc_hdr); cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; + } + char *code = gk_query_param(loc, "code"); + free(loc_hdr); + cc_http_response_fini(&resp); + if(!code) return CHIAKI_ERR_UNKNOWN; + *out_code = code; + return CHIAKI_ERR_SUCCESS; +} + +// POST a {"requestGameSpecification": spec, } body to /sessions/{id}/. +// @p extra (may be NULL) is merged into the body root (ownership transferred). +static ChiakiErrorCode gk_post_session(GaikaiCtx *c, const char *action_with_query, + struct json_object *extra, CCHttpResponse *resp_out) +{ + struct json_object *wrap = json_object_new_object(); + json_object_object_add(wrap, "requestGameSpecification", json_object_get(c->spec)); + if(extra) + { + json_object_object_foreach(extra, k, v) + json_object_object_add(wrap, k, json_object_get(v)); + json_object_put(extra); + } + const char *body = json_object_to_json_string(wrap); + + char url[512]; + snprintf(url, sizeof(url), "%s/sessions/%s%s", GK_BASE, + c->gaikai_session_id ? c->gaikai_session_id : "", action_with_query); + char *h_ua = gk_hdr("User-Agent", c->user_agent); + char *h_sid = gk_hdr("X-Gaikai-SessionId", c->gaikai_session_id ? c->gaikai_session_id : ""); + char *h_skey = gk_hdr("X-Gaikai-Session", c->config_key ? c->config_key : ""); + const char *hdrs[] = { "Content-Type: application/json", "Accept: */*", h_ua, h_sid, h_skey }; + + CCHttpRequest req = { 0 }; + req.method = "POST"; + req.url = url; + req.headers = hdrs; + req.header_count = 5; + req.body = body; + req.capture_headers = true; + ChiakiErrorCode e = cc_http_perform(c->log, &req, resp_out); + free(h_ua); free(h_sid); free(h_skey); + json_object_put(wrap); + return e; +} + +// Build the requestGameSpecification (auth codes empty; patched after step 8b). +static struct json_object *gk_build_spec(GaikaiCtx *c, const char *entitlement_id) +{ + char lang[16]; + chiaki_cloud_gaikai_language(c->cfg->game_language ? c->cfg->game_language : "", lang, sizeof(lang)); + + int res = c->cfg->resolution; + const char *res_set; int cw, ch; + if(res == 720) { res_set = "720"; cw = 1280; ch = 720; } + else if(res == 1440) { res_set = "1440"; cw = 2560; ch = 1440; } + else if(res == 2160) { res_set = "2160"; cw = 3840; ch = 2160; } + else { res_set = "1080"; cw = 1920; ch = 1080; } + + // Timezone "UTC+HH:MM" from the system offset. + char tz[16]; { - free(c->config_key); - c->config_key = k; - CHIAKI_LOGI(c->log, "[GAIKAI] updated session key (len %zu)", strlen(k)); + time_t t = time(NULL); + struct tm lt; + localtime_r(&t, <); + long off = lt.tm_gmtoff; + int oh = (int)(off / 3600); + int om = (int)((off < 0 ? -off : off) % 3600) / 60; + snprintf(tz, sizeof(tz), "UTC%c%02d:%02d", off >= 0 ? '+' : '-', oh < 0 ? -oh : oh, om); + } + + struct json_object *s = json_object_new_object(); + #define S_STR(k,v) json_object_object_add(s, k, json_object_new_string(v)) + #define S_INT(k,v) json_object_object_add(s, k, json_object_new_int(v)) + #define S_BOOL(k,v) json_object_object_add(s, k, json_object_new_boolean(v)) + S_STR("entitlementId", entitlement_id); + S_STR("npEnv", "np"); + S_STR("language", lang); + S_STR("cloudEndpoint", "https://cc.prod.gaikai.com"); + S_STR("redirectUri", c->redirect_uri); + S_STR("resolutionSetting", res_set); + S_INT("clientWidth", cw); + S_INT("clientHeight", ch); + S_STR("adaptiveStreamMode", "resize"); + S_BOOL("useClientBwLadder", true); + S_BOOL("audioUploadEnabled", true); + S_INT("audioUploadNumChannels", 1); + S_INT("audioUploadSamplingFrequency", 48000); + S_STR("acceptButton", "X"); + S_BOOL("encryptionSupported", true); + S_INT("summerTime", 0); + S_STR("timeZone", tz); + S_STR("httpUserAgent", c->user_agent); + S_STR("gkCloudAuthCode", ""); + S_INT("accessibilityMarqueeSpeed", 0); + S_INT("accessibilityLargeText", 0); + S_INT("accessibilityBoldText", 0); + S_INT("accessibilityContrast", 0); + S_INT("accessibilityTtsEnable", 0); + S_INT("accessibilityTtsSpeed", 0); + S_INT("accessibilityTtsVolume", 0); + S_BOOL("partyCapability", false); + S_BOOL("homesharing", false); + S_BOOL("isFirstBoot", false); + S_BOOL("isPlusMember", true); + S_INT("parentalLevel", 0); + S_STR("yuvCoefficient", ""); + + struct json_object *caps = json_object_new_array(); + json_object_array_add(caps, json_object_new_string("cloudDrivenSenkushaTest")); + + if(c->pscloud) + { + S_STR("videoEncoderProfile", "hw5.0"); + struct json_object *ctrls = json_object_new_array(); + json_object_array_add(ctrls, json_object_new_string("ds4")); + json_object_array_add(ctrls, json_object_new_string("ds5")); + json_object_array_add(ctrls, json_object_new_string("xinput")); + json_object_object_add(s, "connectedControllers", json_object_get(ctrls)); + struct json_object *input = json_object_new_object(); + json_object_object_add(input, "controllers", ctrls); + json_object_object_add(s, "input", input); + S_STR("model", "portal"); + S_STR("platform", "qlite"); + S_STR("gaikaiPlayer", "16.4.0"); + S_INT("protocolVersion", 12); + S_STR("ps3AuthCode", ""); + S_STR("streamServerAuthCode", ""); + json_object_array_add(caps, json_object_new_string("cronos")); + struct json_object *vss = json_object_new_object(); + json_object_object_add(vss, "clientHeight", json_object_new_int(ch)); + json_object_object_add(vss, "supportedMaxResolution", json_object_new_int(ch)); + struct json_object *vprof = json_object_new_array(); + json_object_array_add(vprof, json_object_new_string("hevc_hw4")); + json_object_object_add(vss, "supportedVideoEncoderProfiles", vprof); + json_object_object_add(vss, "supportedDynamicRange", json_object_new_string("sdr")); + json_object_object_add(vss, "preferredMaxResolution", json_object_new_int(ch)); + json_object_object_add(vss, "preferredDynamicRange", json_object_new_string("sdr")); + json_object_object_add(vss, "hqMode", json_object_new_int(1)); + json_object_object_add(s, "videoStreamSettings", vss); + S_STR("audioChannels", "2"); + S_STR("audioEncoderProfile", "default"); + struct json_object *ass = json_object_new_object(); + json_object_object_add(ass, "audioEncoderProfile", json_object_new_string("default")); + json_object_object_add(ass, "maxAudioChannels", json_object_new_string("2")); + json_object_object_add(ass, "preferredNumberAudioChannels", json_object_new_string("2")); + json_object_object_add(s, "audioStreamSettings", ass); } else { - free(k); + S_STR("audioChannels", "2.1"); + S_STR("audioEncoderProfile", "default"); + S_STR("videoEncoderProfile", "hw4.1"); + struct json_object *ctrls = json_object_new_array(); + json_object_array_add(ctrls, json_object_new_string("xinput")); + json_object_object_add(s, "connectedControllers", json_object_get(ctrls)); + struct json_object *input = json_object_new_object(); + json_object_object_add(input, "controllers", ctrls); + json_object_object_add(s, "input", input); + S_STR("model", "WINDOWS"); + S_STR("platform", "PC"); + S_STR("gaikaiPlayer", "12.5.0"); + S_INT("protocolVersion", 9); + S_STR("ps3AuthCode", ""); + S_STR("streamServerAuthCode", ""); + json_object_array_add(caps, json_object_new_string("kratos")); } + json_object_object_add(s, "capabilities", caps); + #undef S_STR + #undef S_INT + #undef S_BOOL + (void)c; + return s; } -// Step 0: GET /client_ids?virtType=... -> gkClientId / ps3GkClientId / streamServerClientId. +static void gk_patch_auth_codes(GaikaiCtx *c) +{ + json_object_object_add(c->spec, "gkCloudAuthCode", + json_object_new_string(c->gk_cloud_auth_code ? c->gk_cloud_auth_code : "")); + json_object_object_add(c->spec, "ps3AuthCode", + json_object_new_string(c->ps3_auth_code ? c->ps3_auth_code : "")); + json_object_object_add(c->spec, "streamServerAuthCode", + json_object_new_string(c->stream_server_auth_code ? c->stream_server_auth_code : "")); +} + +// ---- steps ----------------------------------------------------------------- + static ChiakiErrorCode gk_step0_client_ids(GaikaiCtx *c) { gk_progress(c, "Getting Client IDs - Step 1 of 10"); char url[256]; snprintf(url, sizeof(url), "%s/client_ids?virtType=%s", GK_BASE, c->virt_type); - const char *headers[] = { "Accept: */*", NULL }; - char ua[512]; - snprintf(ua, sizeof(ua), "User-Agent: %s", c->user_agent); - const char *hdrs[] = { headers[0], ua }; - + char *h_ua = gk_hdr("User-Agent", c->user_agent); + const char *hdrs[] = { "Accept: */*", h_ua }; CCHttpRequest req = { 0 }; - req.url = url; - req.headers = hdrs; - req.header_count = 2; + req.url = url; req.headers = hdrs; req.header_count = 2; CCHttpResponse resp = { 0 }; ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + free(h_ua); if(e != CHIAKI_ERR_SUCCESS || resp.status_code != 200 || !resp.data) { - CHIAKI_LOGE(c->log, "[GAIKAI] step0 client_ids failed (http %ld)", resp.status_code); + CHIAKI_LOGE(c->log, "[GAIKAI] step0 client_ids http %ld", resp.status_code); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } @@ -128,13 +394,9 @@ static ChiakiErrorCode gk_step0_client_ids(GaikaiCtx *c) json_object_put(j); } cc_http_response_fini(&resp); - if(!c->gk_client_id[0]) - return CHIAKI_ERR_UNKNOWN; - CHIAKI_LOGI(c->log, "[GAIKAI] step0: gkClientId=%s", c->gk_client_id); - return CHIAKI_ERR_SUCCESS; + return c->gk_client_id[0] ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_UNKNOWN; } -// Step 7: POST /config -> configKey (first x-gaikai-session). static ChiakiErrorCode gk_step7_config(GaikaiCtx *c) { gk_progress(c, "Getting Configuration - Step 2 of 10"); @@ -143,73 +405,439 @@ static ChiakiErrorCode gk_step7_config(GaikaiCtx *c) char body[256]; snprintf(body, sizeof(body), "{\"product\":\"%s\",\"platform\":\"%s\",\"sessionId\":\"\"}", c->pscloud ? "qlite" : "psnow", c->pscloud ? "qlite" : "PC"); - char ua[512]; - snprintf(ua, sizeof(ua), "User-Agent: %s", c->user_agent); - const char *hdrs[] = { "Content-Type: application/json", "Accept: */*", ua }; - + char *h_ua = gk_hdr("User-Agent", c->user_agent); + const char *hdrs[] = { "Content-Type: application/json", "Accept: */*", h_ua }; CCHttpRequest req = { 0 }; - req.method = "POST"; - req.url = url; - req.headers = hdrs; - req.header_count = 3; - req.body = body; + req.method = "POST"; req.url = url; req.headers = hdrs; req.header_count = 3; req.body = body; CCHttpResponse resp = { 0 }; ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + free(h_ua); if(e != CHIAKI_ERR_SUCCESS || resp.status_code != 200 || !resp.data) { - CHIAKI_LOGE(c->log, "[GAIKAI] step7 config failed (http %ld)", resp.status_code); + CHIAKI_LOGE(c->log, "[GAIKAI] step7 config http %ld", resp.status_code); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } struct json_object *j = json_tokener_parse(resp.data); - if(j) + if(j) { const char *ck = cc_json_str(j, "configKey"); if(*ck) { free(c->config_key); c->config_key = strdup(ck); } json_object_put(j); } + cc_http_response_fini(&resp); + return (c->config_key && *c->config_key) ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_UNKNOWN; +} + +// step8 start session. On entitlement rejection, the response body carries the +// {"name":"noGameForEntitlementId"} marker -> copied to out->error_message so the +// orchestrator can trigger the one-shot full-flow fallback. +static ChiakiErrorCode gk_step8_start(GaikaiCtx *c, ChiakiCloudProvisionResult *out) +{ + gk_progress(c, "Starting Session - Step 3 of 10"); + char url[256]; + snprintf(url, sizeof(url), "%s/sessions/start?npEnv=np", GK_BASE); + struct json_object *wrap = json_object_new_object(); + json_object_object_add(wrap, "requestGameSpecification", json_object_get(c->spec)); + const char *body = json_object_to_json_string(wrap); + char *h_ua = gk_hdr("User-Agent", c->user_agent); + char *h_skey = gk_hdr("X-Gaikai-Session", c->config_key ? c->config_key : ""); + const char *hdrs[] = { "Content-Type: application/json", "Accept: */*", h_ua, h_skey }; + CCHttpRequest req = { 0 }; + req.method = "POST"; req.url = url; req.headers = hdrs; req.header_count = 4; + req.body = body; req.capture_headers = true; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + free(h_ua); free(h_skey); json_object_put(wrap); + if(e != CHIAKI_ERR_SUCCESS || resp.status_code != 200) + { + CHIAKI_LOGE(c->log, "[GAIKAI] step8 start http %ld: %s", resp.status_code, resp.data ? resp.data : ""); + if(resp.data) { free(out->error_message); out->error_message = strdup(resp.data); } + cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; + } + gk_update_session_key(c, &resp); + struct json_object *j = resp.data ? json_tokener_parse(resp.data) : NULL; + if(j) { const char *sid = cc_json_str(j, "sessionId"); if(*sid) { free(c->gaikai_session_id); c->gaikai_session_id = strdup(sid); } json_object_put(j); } + cc_http_response_fini(&resp); + return (c->gaikai_session_id && *c->gaikai_session_id) ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_UNKNOWN; +} + +static ChiakiErrorCode gk_step8a_gk_authcode(GaikaiCtx *c) +{ + gk_progress(c, "Getting Tokens - Step 4 of 10"); + char url[2048]; + if(c->pscloud) + snprintf(url, sizeof(url), "%s%s/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s" + "&service_entity=urn:service-entity:psn&prompt=none&duid=%s&smcid=qlite&applicationId=qlite&mid=qlite" + "&scope=id_token:psn.basic_claims%%20kamaji:s2s.subscriptionsPremium.get%%20id_token:duid%%20id_token:online_id%%20openid%%20psn:s2s", + ACCOUNT_BASE, c->oauth_api_path, c->gk_client_id, GK_REDIR_PSCLOUD, c->duid); + else + snprintf(url, sizeof(url), "%s%s/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s" + "&service_entity=urn:service-entity:psn&prompt=none&duid=%s&smcid=pc:psnow&applicationId=psnow&mid=PSNOW" + "&scope=kamaji:commerce_native%%20versa:user_update_entitlements_first_play%%20kamaji:lists" + "&renderMode=mobilePortrait&hidePageElements=forgotPasswordLink&displayFooter=none&disableLinks=qriocityLink" + "&layout_type=popup&service_logo=ps&tp_psn=true&noEVBlock=true", + ACCOUNT_BASE, c->oauth_api_path, c->gk_client_id, GK_REDIR_PSNOW, c->duid); + ChiakiErrorCode e = gk_oauth_code(c, url, &c->gk_cloud_auth_code); + if(e == CHIAKI_ERR_SUCCESS) CHIAKI_LOGI(c->log, "[GAIKAI] step8a got gkCloudAuthCode"); + return e; +} + +static ChiakiErrorCode gk_step8b_server_authcode(GaikaiCtx *c) +{ + gk_progress(c, "Getting Server Tokens - Step 5 of 10"); + char url[2048]; + if(c->pscloud) + snprintf(url, sizeof(url), "%s%s/oauth/authorize?response_type=code&redirect_uri=%s" + "&service_entity=urn:service-entity:psn&prompt=none&client_id=%s&smcid=qlite&applicationId=qlite&mid=qlite" + "&scope=id_token:duid%%20id_token:online_id%%20openid%%20oauth:create_authn_ticket_for_cloud_console_signin&duid=%s", + ACCOUNT_BASE, c->oauth_api_path, GK_REDIR_PSCLOUD, c->stream_server_client_id, c->duid); + else { - const char *ck = cc_json_str(j, "configKey"); - if(*ck) + bool ps3 = strcmp(c->platform, "ps3") == 0; + char duid_param[128]; + if(ps3) duid_param[0] = '\0'; // PS3 omits duid + else snprintf(duid_param, sizeof(duid_param), "&duid=%s", c->duid); // PS4 includes it + snprintf(url, sizeof(url), "%s%s/oauth/authorize?response_type=code&redirect_uri=%s" + "&service_entity=urn:service-entity:psn&prompt=none&client_id=%s&smcid=pc:psnow&applicationId=psnow&mid=PSNOW" + "&scope=%s%s&renderMode=mobilePortrait&hidePageElements=forgotPasswordLink&displayFooter=none" + "&disableLinks=qriocityLink&layout_type=popup&service_logo=ps&tp_psn=true&noEVBlock=true", + ACCOUNT_BASE, c->oauth_api_path, GK_REDIR_PSNOW, c->ps3_gk_client_id, + ps3 ? "kamaji:commerce_native" : "sso:none", duid_param); + } + char *code = NULL; + ChiakiErrorCode e = gk_oauth_code(c, url, &code); + if(e != CHIAKI_ERR_SUCCESS) return e; + if(c->pscloud) { c->stream_server_auth_code = code; c->ps3_auth_code = strdup(""); } + else { c->ps3_auth_code = code; c->stream_server_auth_code = strdup(code); } + gk_patch_auth_codes(c); + CHIAKI_LOGI(c->log, "[GAIKAI] step8b got server auth code"); + return CHIAKI_ERR_SUCCESS; +} + +static ChiakiErrorCode gk_step9_authorize(GaikaiCtx *c, bool *out_psplus_err) +{ + gk_progress(c, "Authorizing Session - Step 6 of 10"); + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = gk_post_session(c, "/authorize", NULL, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code != 200) + { + char *ev = gk_header_value(resp.headers, "x-gaikai-event"); + if(ev && strstr(ev, "002.2001")) *out_psplus_err = true; + if(resp.data && strstr(resp.data, "002.2001")) *out_psplus_err = true; + CHIAKI_LOGE(c->log, "[GAIKAI] step9 authorize http %ld: %s", resp.status_code, resp.data ? resp.data : ""); + free(ev); cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; + } + gk_update_session_key(c, &resp); + cc_http_response_fini(&resp); + return CHIAKI_ERR_SUCCESS; +} + +static ChiakiErrorCode gk_step10_lock(GaikaiCtx *c) +{ + gk_progress(c, "Locking Session - Step 7 of 10"); + for(int attempt = 0; attempt <= MAX_LOCK_RETRIES; attempt++) + { + if(gk_cancelled(c)) return CHIAKI_ERR_CANCELED; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = gk_post_session(c, "/lock?forceLogout=true", NULL, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code != 200) { CHIAKI_LOGE(c->log, "[GAIKAI] step10 lock http %ld", resp.status_code); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } + gk_update_session_key(c, &resp); + struct json_object *j = resp.data ? json_tokener_parse(resp.data) : NULL; + bool acquired = j && cc_json_bool(j, "lockAcquired"); + int poll = j ? cc_json_int(j, "pollFrequency") : 10; + if(poll <= 0) poll = 10; + if(j) json_object_put(j); + cc_http_response_fini(&resp); + if(acquired) { - free(c->config_key); - c->config_key = strdup(ck); + free(c->lock_session_key); + c->lock_session_key = c->config_key ? strdup(c->config_key) : NULL; + CHIAKI_LOGI(c->log, "[GAIKAI] step10 lock acquired"); + return CHIAKI_ERR_SUCCESS; } - json_object_put(j); + if(attempt == MAX_LOCK_RETRIES) break; + CHIAKI_LOGI(c->log, "[GAIKAI] lock not acquired; retry in %ds (%d/%d)", poll, attempt + 1, MAX_LOCK_RETRIES); + gk_progress(c, "Closing old session..."); + if(!gk_sleep_cancellable(c, poll)) return CHIAKI_ERR_CANCELED; } + return CHIAKI_ERR_UNKNOWN; +} + +// Build one ping-result json object {dataCenter,rtt,rtts,mtu_in,mtu_out,port,publicIp,maxBandwidth,measured}. +static struct json_object *gk_ping_obj(const char *dc, int rtt_ms, uint32_t mtu_in, uint32_t mtu_out, + int port, const char *ip, int max_bw, bool measured) +{ + struct json_object *o = json_object_new_object(); + json_object_object_add(o, "dataCenter", json_object_new_string(dc)); + json_object_object_add(o, "rtt", json_object_new_int(rtt_ms)); + struct json_object *rtts = json_object_new_array(); + json_object_array_add(rtts, json_object_new_int(rtt_ms)); + json_object_object_add(o, "rtts", rtts); + json_object_object_add(o, "mtu_in", json_object_new_int((int)mtu_in)); + json_object_object_add(o, "mtu_out", json_object_new_int((int)mtu_out)); + json_object_object_add(o, "port", json_object_new_int(port)); + json_object_object_add(o, "publicIp", json_object_new_string(ip ? ip : "")); + json_object_object_add(o, "maxBandwidth", json_object_new_int(max_bw)); + json_object_object_add(o, "measured", json_object_new_boolean(measured)); + return o; +} + +static int gk_cmp_rtt(const void *a, const void *b) +{ + struct json_object *oa = *(struct json_object * const *)a; + struct json_object *ob = *(struct json_object * const *)b; + return cc_json_int(oa, "rtt") - cc_json_int(ob, "rtt"); +} + +// step11 datacenters + ping/select. Fills c->ping_results (sorted) + c->selected_*. +static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) +{ + gk_progress(c, "Getting Datacenters - Step 8 of 10"); + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = gk_post_session(c, "/datacenters", NULL, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code != 200 || !resp.data) { CHIAKI_LOGE(c->log, "[GAIKAI] step11 http %ld", resp.status_code); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } + gk_update_session_key(c, &resp); + struct json_object *dcs = json_tokener_parse(resp.data); cc_http_response_fini(&resp); - if(!c->config_key || !*c->config_key) + if(!dcs || json_object_get_type(dcs) != json_type_array || json_object_array_length(dcs) == 0) + { + if(dcs) json_object_put(dcs); + CHIAKI_LOGE(c->log, "[GAIKAI] step11 no datacenters"); return CHIAKI_ERR_UNKNOWN; - CHIAKI_LOGI(c->log, "[GAIKAI] step7: got configKey (len %zu)", strlen(c->config_key)); + } + size_t n = json_object_array_length(dcs); + const char *forced = c->cfg->forced_datacenter; + bool use_forced = forced && *forced && strcmp(forced, "Auto") != 0; + + c->ping_results = json_object_new_array(); + if(use_forced) + { + struct json_object *match = NULL; + for(size_t i = 0; i < n; i++) + { + struct json_object *dc = json_object_array_get_idx(dcs, i); + if(strcmp(cc_json_str(dc, "dataCenter"), forced) == 0) { match = dc; break; } + } + if(!match) { json_object_put(dcs); CHIAKI_LOGE(c->log, "[GAIKAI] forced datacenter '%s' not available", forced); return CHIAKI_ERR_UNKNOWN; } + // dummy ping (RTT 20, MTU 1454/1254); bypass pinging entirely. + json_object_array_add(c->ping_results, gk_ping_obj(forced, 20, 1454, 1254, + cc_json_int(match, "port"), cc_json_str(match, "publicIp"), cc_json_int(match, "maxBandwidth"), true)); + CHIAKI_LOGI(c->log, "[GAIKAI] forced datacenter %s (ping bypassed)", forced); + } + else + { + gk_progress(c, "Pinging Datacenters - Step 8 of 10"); + for(size_t i = 0; i < n; i++) + { + if(gk_cancelled(c)) { json_object_put(dcs); return CHIAKI_ERR_CANCELED; } + struct json_object *dc = json_object_array_get_idx(dcs, i); + const char *name = cc_json_str(dc, "dataCenter"); + const char *ip = cc_json_str(dc, "publicIp"); + int port = cc_json_int(dc, "port"); + int bw = cc_json_int(dc, "maxBandwidth"); + int64_t rtt_us = -1; uint32_t mi = 0, mo = 0; + cc_ping_datacenter(c->log, ip, port, c->lock_session_key, c->cfg->service_type, &rtt_us, &mi, &mo); + if(rtt_us > 0) + json_object_array_add(c->ping_results, gk_ping_obj(name, (int)(rtt_us / 1000), mi, mo, port, ip, bw, true)); + else + json_object_array_add(c->ping_results, gk_ping_obj(name, 999, 0, 0, port, ip, bw, false)); + } + // sort by RTT + size_t rn = json_object_array_length(c->ping_results); + struct json_object **arr = (struct json_object **)malloc(rn * sizeof(*arr)); + for(size_t i = 0; i < rn; i++) arr[i] = json_object_get(json_object_array_get_idx(c->ping_results, i)); + qsort(arr, rn, sizeof(*arr), gk_cmp_rtt); + struct json_object *sorted = json_object_new_array(); + for(size_t i = 0; i < rn; i++) json_object_array_add(sorted, arr[i]); + free(arr); + json_object_put(c->ping_results); + c->ping_results = sorted; + } + json_object_put(dcs); return CHIAKI_ERR_SUCCESS; } +static ChiakiErrorCode gk_step12_select(GaikaiCtx *c) +{ + const char *forced = c->cfg->forced_datacenter; + bool use_forced = forced && *forced && strcmp(forced, "Auto") != 0; + size_t rn = json_object_array_length(c->ping_results); + if(rn == 0) return CHIAKI_ERR_UNKNOWN; + + if(use_forced) + { + for(size_t i = 0; i < rn; i++) + { + struct json_object *r = json_object_array_get_idx(c->ping_results, i); + if(strcmp(cc_json_str(r, "dataCenter"), forced) == 0) { c->selected_ping = r; break; } + } + if(!c->selected_ping) c->selected_ping = json_object_array_get_idx(c->ping_results, 0); + } + else + { + c->selected_ping = json_object_array_get_idx(c->ping_results, 0); // lowest RTT + bool measured = cc_json_bool(c->selected_ping, "measured"); + int rtt_ms = cc_json_int(c->selected_ping, "rtt"); + if(measured && rtt_ms > 80) + { + CHIAKI_LOGE(c->log, "[GAIKAI] best datacenter RTT %dms > 80ms", rtt_ms); + return CHIAKI_ERR_UNKNOWN; // ping-too-high + } + } + snprintf(c->selected_datacenter, sizeof(c->selected_datacenter), "%s", cc_json_str(c->selected_ping, "dataCenter")); + int port = cc_json_int(c->selected_ping, "port"); + c->selected_dc_port = port > 0 ? port : 2053; + + gk_progress(c, "Selecting Datacenter - Step 9 of 10"); + struct json_object *extra = json_object_new_object(); + json_object_object_add(extra, "pingResults", json_object_get(c->ping_results)); + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = gk_post_session(c, "/datacenters/select", extra, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code != 200) { CHIAKI_LOGE(c->log, "[GAIKAI] step12 select http %ld", resp.status_code); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } + gk_update_session_key(c, &resp); + struct json_object *j = resp.data ? json_tokener_parse(resp.data) : NULL; + if(j) + { + int p = cc_json_int(j, "port"); + if(p <= 0) { struct json_object *net = cc_json_obj(j, "network"); if(net) p = cc_json_int(net, "port"); } + if(p > 0) c->selected_dc_port = p; + json_object_put(j); + } + cc_http_response_fini(&resp); + CHIAKI_LOGI(c->log, "[GAIKAI] step12 selected %s:%d", c->selected_datacenter, c->selected_dc_port); + return CHIAKI_ERR_SUCCESS; +} + +static ChiakiErrorCode gk_step13_allocate(GaikaiCtx *c, ChiakiCloudProvisionResult *out) +{ + gk_progress(c, "Allocating Streaming Slot - Step 10 of 10"); + int max_wait = DEFAULT_ALLOC_WAIT_S, elapsed = 0; + bool wait_started = false; + for(;;) + { + if(gk_cancelled(c)) return CHIAKI_ERR_CANCELED; + int mtu_in = cc_json_int(c->selected_ping, "mtu_in"); if(mtu_in <= 0) mtu_in = 1454; + int mtu_out = cc_json_int(c->selected_ping, "mtu_out"); if(mtu_out <= 0) mtu_out = 1254; + int rtt = cc_json_int(c->selected_ping, "rtt"); if(rtt <= 0) rtt = 25; + + struct json_object *extra = json_object_new_object(); + json_object_object_add(extra, "dataCenter", json_object_new_string(c->selected_datacenter)); + struct json_object *net = json_object_new_object(); + json_object_object_add(net, "bwKbpsSent", json_object_new_int(c->cfg->bitrate_kbps)); + json_object_object_add(net, "bwLoss", json_object_new_double(0.001)); + json_object_object_add(net, "mtu", json_object_new_int(mtu_in)); + json_object_object_add(net, "rtt", json_object_new_int(rtt)); + json_object_object_add(net, "port", json_object_new_int(c->selected_dc_port)); + json_object_object_add(net, "bwKbpsReceived", json_object_new_int(c->cfg->bitrate_kbps)); + json_object_object_add(net, "bwLossUpstream", json_object_new_int(0)); + json_object_object_add(net, "mtuUpstream", json_object_new_int(mtu_out)); + json_object_object_add(extra, "network", net); + json_object_object_add(extra, "stateExecutionTime", json_object_new_double(5974.7632)); + json_object_object_add(extra, "streamTestTime", json_object_new_double(11262.8423)); + + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = gk_post_session(c, "/allocate", extra, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code != 200) { CHIAKI_LOGE(c->log, "[GAIKAI] step13 allocate http %ld: %s", resp.status_code, resp.data ? resp.data : ""); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } + gk_update_session_key(c, &resp); + struct json_object *a = resp.data ? json_tokener_parse(resp.data) : NULL; + cc_http_response_fini(&resp); + if(!a) return CHIAKI_ERR_UNKNOWN; + + bool queued = cc_json_bool(a, "queued"); + bool migrating = cc_json_bool(a, "dataMigration"); + if(queued || migrating) + { + if(!wait_started) + { + wait_started = true; + int est = cc_json_int(a, "waitTimeEstimate"); + max_wait = est > 0 ? (est * 2 > MAX_ALLOC_WAIT_S ? MAX_ALLOC_WAIT_S : est * 2) : DEFAULT_ALLOC_WAIT_S; + } + int poll = cc_json_int(a, "pollFrequency"); if(poll <= 0) poll = 15; + json_object_put(a); + if(elapsed >= max_wait) { CHIAKI_LOGE(c->log, "[GAIKAI] allocation wait timeout (%ds)", max_wait); return CHIAKI_ERR_TIMEOUT; } + if(poll > max_wait - elapsed) poll = max_wait - elapsed; + gk_progress(c, queued ? "Waiting in queue..." : "Migrating data..."); + if(!gk_sleep_cancellable(c, poll)) return CHIAKI_ERR_CANCELED; + elapsed += poll; + continue; + } + + // success + struct json_object *slot = cc_json_obj(a, "launchSlot"); + if(!slot) { json_object_put(a); CHIAKI_LOGE(c->log, "[GAIKAI] allocate: no launchSlot"); return CHIAKI_ERR_UNKNOWN; } + snprintf(out->server_ip, sizeof(out->server_ip), "%s", cc_json_str(slot, "publicIp")); + out->server_port = cc_json_int(slot, "port"); + const char *priv = cc_json_str(slot, "privateIp"); + out->handshake_key = strdup(cc_json_str(a, "handshakeKey")); + out->launch_spec = strdup(cc_json_str(a, "launchSpecification")); + out->session_id = strdup(cc_json_str(a, "sessionId")); + out->mtu_in = (uint32_t)mtu_in; out->mtu_out = (uint32_t)mtu_out; + out->rtt_us = (uint64_t)rtt * 1000; + out->psn_wrapper_type = 0x01; + const char *dot = priv ? strrchr(priv, '.') : NULL; + if(dot) { int oct = atoi(dot + 1); if(oct >= 0 && oct <= 255) out->psn_wrapper_type = (uint8_t)oct; } + json_object_put(a); + CHIAKI_LOGI(c->log, "[GAIKAI] ALLOCATION OK %s:%d", out->server_ip, out->server_port); + return CHIAKI_ERR_SUCCESS; + } +} + ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, const ChiakiCloudProvisionConfig *cfg, const char *platform, const char *entitlement_id, ChiakiCloudProvisionResult *out) { - (void)entitlement_id; (void)out; GaikaiCtx c; memset(&c, 0, sizeof(c)); c.log = log; c.cfg = cfg; - c.platform = platform ? platform : ""; + c.platform = (platform && *platform) ? platform : "ps4"; c.pscloud = cfg->service_type && strcmp(cfg->service_type, "pscloud") == 0; c.user_agent = c.pscloud ? GK_UA_PSCLOUD : GK_UA_PSNOW; + c.oauth_api_path = c.pscloud ? "/api/authz/v3" : "/api/v1"; + c.redirect_uri = c.pscloud ? GK_REDIR_PSCLOUD : GK_REDIR_PSNOW; if(strcmp(c.platform, "ps3") == 0) c.virt_type = "konan"; else if(strcmp(c.platform, "ps5") == 0) c.virt_type = "cronos"; - else c.virt_type = "kratos"; // ps4 / default + else c.virt_type = "kratos"; + + // Generate the client device uid used in the OAuth auth-code URLs. (When the + // orchestrator drives the full flow it will pass the same duid Kamaji used.) + size_t duid_size = sizeof(c.duid); + if(chiaki_holepunch_generate_client_device_uid(c.duid, &duid_size) != CHIAKI_ERR_SUCCESS) + return CHIAKI_ERR_UNKNOWN; + snprintf(out->platform, sizeof(out->platform), "%s", c.platform); + snprintf(out->entitlement_id, sizeof(out->entitlement_id), "%s", entitlement_id ? entitlement_id : ""); + + c.spec = gk_build_spec(&c, entitlement_id ? entitlement_id : ""); + bool psplus_err = false; ChiakiErrorCode e = gk_step0_client_ids(&c); - if(e == CHIAKI_ERR_SUCCESS && !gk_cancelled(&c)) - e = gk_step7_config(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step7_config(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step8_start(&c, out); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step8a_gk_authcode(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step8b_server_authcode(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step9_authorize(&c, &psplus_err); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step10_lock(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step11_datacenters(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step12_select(&c); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step13_allocate(&c, out); - // TODO(phase 2 cont.): step8 start session, OAuth 8a/8b, step9 authorize, - // step10 lock, step11 datacenters, step12 ping/select, step13 allocate. - if(e == CHIAKI_ERR_SUCCESS) + // Return the ping results for the Settings UI (per-region ms). + if(c.ping_results) { - CHIAKI_LOGW(log, "[GAIKAI] steps 8-13 not implemented yet"); - e = CHIAKI_ERR_UNKNOWN; + const char *s = json_object_to_json_string(c.ping_results); + if(s) { free(out->datacenter_pings); out->datacenter_pings = strdup(s); } } + if(psplus_err && !out->error_message) + out->error_message = strdup("PS_PLUS_SUBSCRIPTION_REQUIRED"); - free(c.config_key); - free(c.lock_session_key); - free(c.gaikai_session_id); + free(c.config_key); free(c.lock_session_key); free(c.gaikai_session_id); + free(c.gk_cloud_auth_code); free(c.ps3_auth_code); free(c.stream_server_auth_code); + if(c.spec) json_object_put(c.spec); + if(c.ping_results) json_object_put(c.ping_results); return e; } From 816f4c959bab3667277804490c48aa2c9b26f519 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 21:12:30 -0700 Subject: [PATCH 36/72] cloudsession (phase 3+4): Kamaji resolve + orchestrator cloudsession_kamaji.c -- C port of pskamajisession.cpp: - 0.5b anonymous OAuth code, 0.5c anonymous session (JSESSIONID) - 0.5d productId -> entitlementId (license_type==4, else the PS Plus full-game "*GD" fallback with title-match) + platform from playable_platform; uses cfg->store_country/store_lang for the container URL (resolvedStoreLang) - 0.5e commerce-token OAuth, account-attributes privacy check (skippable), entitlement check (200 owned / 404 -> $0 checkout preview+buynow, or skip acquire in fallback regions), step5/6 authenticated session - owned fast-path: skip 0.5b-0.5e cloudsession.c -- orchestrator: - one shared DUID for both Kamaji + Gaikai OAuth - PSNOW: Kamaji resolve -> Gaikai allocate; PSCLOUD: Gaikai directly on the entitlementId (ps5) - one-shot fallback: an owned entitlement Gaikai rejects (noGameForEntitlementId) re-runs the full Kamaji resolve once Gaikai now takes the shared duid as a parameter (was self-generated). Config gains catalog_is_foreign + skip_account_attr_check. Builds clean, 105/105. Nothing wired to the apps yet (phase 5). Co-Authored-By: Claude Opus 4.8 --- lib/CMakeLists.txt | 1 + lib/include/chiaki/cloudsession.h | 2 + lib/src/cloudsession.c | 91 ++++- lib/src/cloudsession_gaikai.c | 20 +- lib/src/cloudsession_internal.h | 24 +- lib/src/cloudsession_kamaji.c | 570 ++++++++++++++++++++++++++++++ 6 files changed, 678 insertions(+), 30 deletions(-) create mode 100644 lib/src/cloudsession_kamaji.c diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index a02b2a20..4bf3781b 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -100,6 +100,7 @@ set(SOURCE_FILES src/cloudsession.c src/cloudsession_ping.c src/cloudsession_gaikai.c + src/cloudsession_kamaji.c src/remote/holepunch.c src/remote/rudp.c src/remote/rudpsendbuffer.c) diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index fbcfee78..b88e3785 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -42,6 +42,8 @@ typedef struct chiaki_cloud_provision_config_t const char *store_lang; /**< resolvedStoreLang for the step0_5d container URL */ const char *owned_entitlement_id; /**< owned-PSNOW fast-path entitlement, or "" */ const char *owned_platform; /**< platform accompanying owned_entitlement_id, or "" */ + bool catalog_is_foreign; /**< fallback-region account: skip the $0 acquire on 404 */ + bool skip_account_attr_check; /**< platform already passed (or user ignored) the privacy check */ const char *forced_datacenter; /**< settings-selected region; "Auto"/"" => ping & auto-pick */ const char *cache_dir; /**< lib-owned datacenter-ping cache lives here; may be "" */ const char *game_language; /**< streaming-language locale (e.g. "de-DE"); bare code derived */ diff --git a/lib/src/cloudsession.c b/lib/src/cloudsession.c index a84a3fdd..308a7e06 100644 --- a/lib/src/cloudsession.c +++ b/lib/src/cloudsession.c @@ -1,14 +1,28 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL // // Cloud session provisioning -- orchestrator + public entry points. -// Mirrors cloudcatalog.c. Phase 0: skeleton (entry points stubbed); the -// Kamaji / Gaikai / ping logic is filled in by cloudsession_{kamaji,gaikai, -// ping}.c across the following phases. +// Mirrors cloudcatalog.c. Routes PSNOW (Kamaji resolve -> Gaikai allocate) vs +// PSCLOUD (Gaikai allocate directly on the entitlementId), threads one shared +// DUID through both, and performs the one-shot noGameForEntitlementId fallback +// (re-run the full Kamaji resolve when an owned fast-path entitlement is +// rejected by Gaikai). + +#include "cloudsession_internal.h" +#include "curl_http.h" #include +#ifdef _WIN32 +#include +#include +#else +#include // INET6_ADDRSTRLEN (needed by holepunch.h) +#endif +#include // chiaki_holepunch_generate_client_device_uid + #include #include +#include static void result_init(ChiakiCloudProvisionResult *out) { @@ -33,6 +47,36 @@ CHIAKI_EXPORT void chiaki_cloud_provision_result_fini(ChiakiCloudProvisionResult out->error_message = NULL; } +// One provisioning attempt: PSNOW resolves via Kamaji then allocates via Gaikai; +// PSCLOUD allocates directly (game_identifier is already the PS5 entitlementId). +static ChiakiErrorCode provision_once(ChiakiLog *log, + const ChiakiCloudProvisionConfig *cfg, const char *duid, + ChiakiCloudProvisionResult *out) +{ + bool pscloud = cfg->service_type && strcmp(cfg->service_type, "pscloud") == 0; + char entitlement[128] = ""; + char platform[8] = "ps4"; + + if(pscloud) + { + snprintf(entitlement, sizeof(entitlement), "%s", cfg->game_identifier ? cfg->game_identifier : ""); + snprintf(platform, sizeof(platform), "ps5"); + } + else + { + char *kerr = NULL; + ChiakiErrorCode e = cc_kamaji_resolve(log, cfg, duid, entitlement, platform, &kerr); + if(e != CHIAKI_ERR_SUCCESS) + { + if(kerr) { free(out->error_message); out->error_message = kerr; } + return e; + } + free(kerr); + } + + return cc_gaikai_allocate(log, cfg, duid, platform, entitlement, out); +} + CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( const ChiakiCloudProvisionConfig *cfg, ChiakiCloudProvisionResult *out, @@ -46,11 +90,35 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( out->err = CHIAKI_ERR_INVALID_DATA; return out->err; } - // TODO(phase 4): authorization check -> Kamaji (psnow) / direct (pscloud) - // -> Gaikai allocation -> one-shot fallback. - CHIAKI_LOGW(log, "[CLOUDSESSION] provision not implemented yet (skeleton)"); - out->err = CHIAKI_ERR_UNKNOWN; - return out->err; + + // One shared client device uid for the Kamaji + Gaikai OAuth exchanges. + char duid[64]; + size_t duid_size = sizeof(duid); + if(chiaki_holepunch_generate_client_device_uid(duid, &duid_size) != CHIAKI_ERR_SUCCESS) + { + out->err = CHIAKI_ERR_UNKNOWN; + return out->err; + } + + ChiakiErrorCode e = provision_once(log, cfg, duid, out); + + // One-shot fallback: an owned fast-path entitlement that Gaikai rejects + // (noGameForEntitlementId) -> re-run the full Kamaji resolve/acquire once. + bool used_fast_path = cfg->owned_entitlement_id && *cfg->owned_entitlement_id; + if(e != CHIAKI_ERR_SUCCESS && used_fast_path && out->error_message && + strstr(out->error_message, "noGameForEntitlement")) + { + CHIAKI_LOGW(log, "[CLOUDSESSION] owned entitlement rejected by Gaikai; retrying full resolve flow"); + chiaki_cloud_provision_result_fini(out); + result_init(out); + ChiakiCloudProvisionConfig cfg2 = *cfg; + cfg2.owned_entitlement_id = ""; + cfg2.owned_platform = ""; + e = provision_once(log, &cfg2, duid, out); + } + + out->err = e; + return e; } CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_ping_datacenters( @@ -61,7 +129,10 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_ping_datacenters( if(!cfg || !out_pings_json) return CHIAKI_ERR_INVALID_DATA; *out_pings_json = NULL; - // TODO(phase 1): senkusha ping of reachable datacenters. - CHIAKI_LOGW(log, "[CLOUDSESSION] ping not implemented yet (skeleton)"); + // The datacenter list is only available inside an authenticated Gaikai + // session (step11), so per-region latency comes back as result.datacenter_pings + // from chiaki_cloud_provision_session. A standalone ping-only path (auth -> + // step11 -> ping -> stop) can be added when the Settings refresh button needs it. + CHIAKI_LOGW(log, "[CLOUDSESSION] standalone datacenter ping not wired; use provision result.datacenter_pings"); return CHIAKI_ERR_UNKNOWN; } diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 1e82db4b..45bcedff 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -12,15 +12,6 @@ #include // chiaki_cloud_gaikai_language -#ifdef _WIN32 -#include -#include -#else -#include // INET6_ADDRSTRLEN (needed by holepunch.h) -#include -#endif -#include // chiaki_holepunch_generate_client_device_uid - #include #include @@ -51,7 +42,7 @@ typedef struct const char *user_agent; const char *oauth_api_path; // "/api/authz/v3" | "/api/v1" const char *redirect_uri; - char duid[CHIAKI_DUID_STR_SIZE]; // shared client device uid (OAuth + future Kamaji) + char duid[128]; // shared client device uid (OAuth, same one Kamaji uses) char *config_key; // x-gaikai-session (updates every response) char *lock_session_key; char *gaikai_session_id; @@ -787,7 +778,7 @@ static ChiakiErrorCode gk_step13_allocate(GaikaiCtx *c, ChiakiCloudProvisionResu } ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, - const ChiakiCloudProvisionConfig *cfg, + const ChiakiCloudProvisionConfig *cfg, const char *duid, const char *platform, const char *entitlement_id, ChiakiCloudProvisionResult *out) { @@ -804,11 +795,8 @@ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, else if(strcmp(c.platform, "ps5") == 0) c.virt_type = "cronos"; else c.virt_type = "kratos"; - // Generate the client device uid used in the OAuth auth-code URLs. (When the - // orchestrator drives the full flow it will pass the same duid Kamaji used.) - size_t duid_size = sizeof(c.duid); - if(chiaki_holepunch_generate_client_device_uid(c.duid, &duid_size) != CHIAKI_ERR_SUCCESS) - return CHIAKI_ERR_UNKNOWN; + // Shared client device uid (same one Kamaji used), threaded into the OAuth URLs. + snprintf(c.duid, sizeof(c.duid), "%s", duid ? duid : ""); snprintf(out->platform, sizeof(out->platform), "%s", c.platform); snprintf(out->entitlement_id, sizeof(out->entitlement_id), "%s", entitlement_id ? entitlement_id : ""); diff --git a/lib/src/cloudsession_internal.h b/lib/src/cloudsession_internal.h index 7bc3cac6..2527724d 100644 --- a/lib/src/cloudsession_internal.h +++ b/lib/src/cloudsession_internal.h @@ -16,16 +16,32 @@ extern "C" { #endif +/** + * Kamaji session flow (PSNOW): 0.5b anonymous OAuth -> 0.5c anonymous session + * -> 0.5d productId->entitlementId (uses store_country/store_lang) -> 0.5e + * check/acquire ($0 checkout) -> step5/6 authenticated session. With a non-empty + * cfg->owned_entitlement_id it takes the owned fast-path (skips 0.5b-0.5e). + * @p duid is the shared client device uid (also used by Gaikai's OAuth). + * On success writes the resolved entitlement + platform; on failure may set + * *out_error (heap, caller frees) -- "PS_PLUS_SUBSCRIPTION_REQUIRED" / + * "ACCOUNT_PRIVACY_SETTINGS:" are recognised sentinels. + */ +ChiakiErrorCode cc_kamaji_resolve(ChiakiLog *log, + const ChiakiCloudProvisionConfig *cfg, const char *duid, + char out_entitlement_id[128], char out_platform[8], char **out_error); + /** * Gaikai allocation flow (steps 0/7-13): client ids -> config -> start session * -> OAuth auth codes -> authorize -> lock -> datacenters -> ping/select -> * allocate slot. @p platform is the resolved "ps3"|"ps4"|"ps5"; @p entitlement_id - * is the entitlement to stream. cfg->service_type selects PSNOW vs PSCLOUD. - * On success fills out->{server_ip,server_port,handshake_key,launch_spec, - * session_id,mtu_*,rtt_us,platform,datacenter_pings}. + * is the entitlement to stream; @p duid the shared client device uid (Kamaji's). + * cfg->service_type selects PSNOW vs PSCLOUD. On success fills out->{server_ip, + * server_port,handshake_key,launch_spec,session_id,mtu_*,rtt_us,platform, + * datacenter_pings,psn_wrapper_type}. On step8 entitlement rejection sets + * out->error_message to the response body (the noGameForEntitlementId marker). */ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, - const ChiakiCloudProvisionConfig *cfg, + const ChiakiCloudProvisionConfig *cfg, const char *duid, const char *platform, const char *entitlement_id, ChiakiCloudProvisionResult *out); diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c new file mode 100644 index 00000000..3a18c51b --- /dev/null +++ b/lib/src/cloudsession_kamaji.c @@ -0,0 +1,570 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Kamaji session flow -- C port of the Qt pskamajisession.cpp async state +// machine, run as a single blocking sequence. Resolves the chosen PSNOW title's +// streaming entitlement (and acquires it via a $0 checkout when the account does +// not yet own it), then establishes the authenticated Kamaji session. +// +// Full path: 0.5b anonymous OAuth code -> 0.5c anonymous session (JSESSIONID) +// -> 0.5d productId -> entitlementId (+ platform) -> 0.5e check/acquire +// -> step5 authenticated OAuth code -> step6 authenticated session. +// Owned fast-path: skip 0.5b-0.5e, go straight to step5/6. + +#include "cloudsession_internal.h" +#include "cloudcatalog_internal.h" // cc_json_* helpers +#include "curl_http.h" + +#include + +#include +#include +#include +#include + +#define KM_ACCOUNT_BASE "https://ca.account.sony.com/api" +#define KM_KAMAJI_BASE "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000" +#define KM_CLIENT_ID "bc6b0777-abb5-40da-92ca-e133cf18e989" +#define KM_COMMERCE_CLIENT_ID "dc523cc2-b51b-4190-bff0-3397c06871b3" +#define KM_REDIRECT_URI "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" +#define KM_USER_AGENT "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +#define KM_PS3_SCOPES "kamaji:commerce_native" +#define KM_PS4_SCOPES "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" +#define KM_REFERER "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/" +#define KM_ORIGIN "https://psnow.playstation.com" + +typedef struct +{ + ChiakiLog *log; + const ChiakiCloudProvisionConfig *cfg; + const char *duid; + const char *npsso; + char platform[8]; // ps3|ps4|ps5 + const char *scopes; // KM_PS3_SCOPES | KM_PS4_SCOPES + char entitlement_id[128]; + char streaming_sku[160]; + char *jsessionid; + char *commerce_token; +} KamajiCtx; + +static char *km_hdr(const char *key, const char *value) +{ + size_t n = strlen(key) + 2 + (value ? strlen(value) : 0) + 1; + char *s = (char *)malloc(n); + if(s) snprintf(s, n, "%s: %s", key, value ? value : ""); + return s; +} + +static char *km_header_value(const char *headers, const char *name) +{ + if(!headers || !name) return NULL; + size_t nlen = strlen(name); + const char *p = headers; + while(*p) + { + const char *eol = strpbrk(p, "\r\n"); + size_t linelen = eol ? (size_t)(eol - p) : strlen(p); + if(linelen > nlen && strncasecmp(p, name, nlen) == 0 && p[nlen] == ':') + { + const char *v = p + nlen + 1; + while(*v == ' ' || *v == '\t') v++; + size_t vlen = (size_t)((p + linelen) - v); + while(vlen && (v[vlen-1] == ' ' || v[vlen-1] == '\t')) vlen--; + char *out = (char *)malloc(vlen + 1); + if(!out) return NULL; + memcpy(out, v, vlen); out[vlen] = '\0'; + return out; + } + if(!eol) break; + p = eol + ((eol[0] == '\r' && eol[1] == '\n') ? 2 : 1); + } + return NULL; +} + +// Extract key= from a URL's query OR fragment (delimiters ? & #). +static char *km_url_param(const char *url, const char *key) +{ + if(!url) return NULL; + size_t klen = strlen(key); + const char *p = url; + while((p = strstr(p, key)) != NULL) + { + if((p == url || p[-1] == '?' || p[-1] == '&' || p[-1] == '#') && p[klen] == '=') + { + const char *v = p + klen + 1; + size_t vlen = strcspn(v, "&#"); + char *o = (char *)malloc(vlen + 1); + if(!o) return NULL; + memcpy(o, v, vlen); o[vlen] = '\0'; + return o; + } + p++; + } + return NULL; +} + +// Scan the raw header block for JSESSIONID= in any Set-Cookie line. +static char *km_jsessionid(const char *headers) +{ + if(!headers) return NULL; + const char *p = strstr(headers, "JSESSIONID="); + if(!p) return NULL; + p += strlen("JSESSIONID="); + size_t vlen = strcspn(p, ";\r\n"); + char *o = (char *)malloc(vlen + 1); + if(!o) return NULL; + memcpy(o, p, vlen); o[vlen] = '\0'; + return o; +} + +// OAuth GET (prompt=none) -> the 302 redirect's code= (or access_token= when @p want_token). +static ChiakiErrorCode km_oauth(KamajiCtx *c, const char *url, bool want_token, char **out) +{ + *out = NULL; + char *cookie = NULL; + if(cc_http_make_cookie_header(&cookie, "npsso", c->npsso) != CHIAKI_ERR_SUCCESS) + return CHIAKI_ERR_MEMORY; + char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + const char *hdrs[] = { h_ua, cookie }; + CCHttpRequest req = { 0 }; + req.url = url; req.headers = hdrs; req.header_count = 2; + req.follow_redirects = false; req.capture_headers = true; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + free(h_ua); free(cookie); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + // Prefer the raw Location header (keeps the #access_token fragment curl drops from REDIRECT_URL). + char *loc = km_header_value(resp.headers, "Location"); + const char *src = loc ? loc : resp.redirect_url; + char *val = src ? km_url_param(src, want_token ? "access_token" : "code") : NULL; + if(!val) + CHIAKI_LOGE(c->log, "[KAMAJI] oauth: no %s in redirect (status %ld)", want_token ? "token" : "code", resp.status_code); + free(loc); cc_http_response_fini(&resp); + if(!val) return CHIAKI_ERR_UNKNOWN; + *out = val; + return CHIAKI_ERR_SUCCESS; +} + +// POST {KAMAJI_BASE}/user/session with the "code=&client_id=&duid=" body. +// @p capture set when we need Set-Cookie (anonymous session). resp_out owned by caller. +static ChiakiErrorCode km_post_session(KamajiCtx *c, const char *code, bool capture, CCHttpResponse *resp_out) +{ + char body[512]; + snprintf(body, sizeof(body), "code=%s&client_id=%s&duid=%s", code, KM_CLIENT_ID, c->duid); + char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + char *h_alt = km_hdr("X-Alt-Referer", KM_REDIRECT_URI); + const char *hdrs[] = { + "Content-Type: text/plain;charset=UTF-8", "Accept: */*", + "Origin: " KM_ORIGIN, "Referer: " KM_REFERER, h_ua, h_alt + }; + CCHttpRequest req = { 0 }; + req.method = "POST"; req.url = KM_KAMAJI_BASE "/user/session"; + req.headers = hdrs; req.header_count = 6; req.body = body; req.capture_headers = capture; + ChiakiErrorCode e = cc_http_perform(c->log, &req, resp_out); + free(h_ua); free(h_alt); + return e; +} + +// ---- steps ----------------------------------------------------------------- + +static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) +{ + if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); + char url[2048]; + snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" + "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" + "&prompt=none&renderMode=mobilePortrait&hidePageElements=forgotPasswordLink&displayFooter=none" + "&disableLinks=qriocityLink&mid=PSNOW&duid=%s&layout_type=popup&service_logo=ps&tp_psn=true&noEVBlock=true", + c->scopes, KM_CLIENT_ID, KM_REDIRECT_URI, c->duid); + return km_oauth(c, url, false, out_code); +} + +static ChiakiErrorCode km_step0_5c_anon_session(KamajiCtx *c, const char *anon_code) +{ + if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = km_post_session(c, anon_code, true, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + char *jsess = km_jsessionid(resp.headers); + cc_http_response_fini(&resp); + if(!jsess) { CHIAKI_LOGE(c->log, "[KAMAJI] 0.5c no JSESSIONID"); return CHIAKI_ERR_UNKNOWN; } + free(c->jsessionid); c->jsessionid = jsess; + return CHIAKI_ERR_SUCCESS; +} + +// Pick a streaming entitlement (license_type==4) from one sku; returns true if found. +static bool km_pick_streaming(KamajiCtx *c, struct json_object *sku) +{ + struct json_object *ents = cc_json_arr(sku, "entitlements"); + if(!ents) return false; + size_t n = json_object_array_length(ents); + for(size_t i = 0; i < n; i++) + { + struct json_object *ent = json_object_array_get_idx(ents, i); + if(cc_json_int(ent, "license_type") == 4) + { + const char *id = cc_json_str(ent, "id"); + if(id && *id) + { + snprintf(c->entitlement_id, sizeof(c->entitlement_id), "%s", id); + snprintf(c->streaming_sku, sizeof(c->streaming_sku), "%s", cc_json_str(sku, "id")); + return true; + } + } + } + return false; +} + +// PS Plus catalog fallback: a full-game digital entitlement ("*GD"); optionally +// requiring the entitlement id to contain the requested title id (platform-consistent). +static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require_title, const char *title_id) +{ + struct json_object *ents = cc_json_arr(sku, "entitlements"); + if(!ents) return false; + size_t n = json_object_array_length(ents); + for(size_t i = 0; i < n; i++) + { + struct json_object *ent = json_object_array_get_idx(ents, i); + const char *id = cc_json_str(ent, "id"); + const char *pkg = cc_json_str(ent, "packageType"); + size_t plen = strlen(pkg); + if(!id || !*id || plen < 2 || strcmp(pkg + plen - 2, "GD") != 0) + continue; + if(require_title && title_id && *title_id && !strstr(id, title_id)) + continue; + snprintf(c->entitlement_id, sizeof(c->entitlement_id), "%s", id); + snprintf(c->streaming_sku, sizeof(c->streaming_sku), "%s", cc_json_str(sku, "id")); + CHIAKI_LOGI(c->log, "[KAMAJI] full-game entitlement (PS+ fallback): %s pkg=%s", id, pkg); + return true; + } + return false; +} + +static ChiakiErrorCode km_step0_5d_resolve(KamajiCtx *c) +{ + if(c->cfg->progress) c->cfg->progress("Resolving Game - Step 2 of 6", c->cfg->user); + const char *country = (c->cfg->store_country && *c->cfg->store_country) ? c->cfg->store_country : "US"; + const char *lang = (c->cfg->store_lang && *c->cfg->store_lang) ? c->cfg->store_lang : "en"; + char url[512]; + snprintf(url, sizeof(url), "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/" + "%s/%s/19/%s?useOffers=true&gkb=1&gkb2=1", country, lang, c->cfg->game_identifier); + CHIAKI_LOGI(c->log, "[KAMAJI] 0.5d resolve %s (store %s/%s)", c->cfg->game_identifier, country, lang); + + const char *hdrs[] = { "Accept: application/json", + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }; + CCHttpRequest req = { 0 }; + req.url = url; req.headers = hdrs; req.header_count = 2; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code == 404) + { + CHIAKI_LOGE(c->log, "[KAMAJI] 0.5d product not found (404)"); + cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; + } + if(resp.status_code != 200 || !resp.data) { cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } + struct json_object *obj = json_tokener_parse(resp.data); + cc_http_response_fini(&resp); + if(!obj) return CHIAKI_ERR_UNKNOWN; + + struct json_object *default_sku = cc_json_obj(obj, "default_sku"); + if(default_sku) km_pick_streaming(c, default_sku); + if(!c->entitlement_id[0]) + { + struct json_object *skus = cc_json_arr(obj, "skus"); + if(skus) for(size_t i = 0; i < json_object_array_length(skus) && !c->entitlement_id[0]; i++) + km_pick_streaming(c, json_object_array_get_idx(skus, i)); + } + // Full-game fallback (PS Plus catalog titles have no license_type==4): title-match then any. + if(!c->entitlement_id[0]) + { + char title_id[64] = ""; + { + const char *dash = strchr(c->cfg->game_identifier, '-'); + if(dash) + { + const char *t = dash + 1; + size_t tl = strcspn(t, "_"); + if(tl < sizeof(title_id)) { memcpy(title_id, t, tl); title_id[tl] = '\0'; } + } + } + struct json_object *skus = cc_json_arr(obj, "skus"); + for(int pass = 0; pass < 2 && !c->entitlement_id[0]; pass++) + { + bool require_title = (pass == 0); + if(default_sku && km_pick_fullgame(c, default_sku, require_title, title_id)) break; + if(skus) for(size_t i = 0; i < json_object_array_length(skus) && !c->entitlement_id[0]; i++) + km_pick_fullgame(c, json_object_array_get_idx(skus, i), require_title, title_id); + } + } + + // Platform from playable_platform (root array, else metadata.playable_platform.values). + struct json_object *pp = cc_json_arr(obj, "playable_platform"); + if(!pp) + { + struct json_object *meta = cc_json_obj(obj, "metadata"); + struct json_object *ppm = meta ? cc_json_obj(meta, "playable_platform") : NULL; + if(ppm) pp = cc_json_arr(ppm, "values"); + } + bool ps5 = false, ps4 = false, ps3 = false; + if(pp) for(size_t i = 0; i < json_object_array_length(pp); i++) + { + const char *s = json_object_get_string(json_object_array_get_idx(pp, i)); + if(!s) continue; + if(strcasestr(s, "PS5")) ps5 = true; + else if(strcasestr(s, "PS4")) ps4 = true; + else if(strcasestr(s, "PS3")) ps3 = true; + } + snprintf(c->platform, sizeof(c->platform), "%s", ps5 ? "ps5" : (ps4 ? "ps4" : (ps3 ? "ps3" : "ps4"))); + c->scopes = (strcmp(c->platform, "ps3") == 0) ? KM_PS3_SCOPES : KM_PS4_SCOPES; + json_object_put(obj); + + if(!c->entitlement_id[0]) { CHIAKI_LOGE(c->log, "[KAMAJI] 0.5d no entitlement resolved"); return CHIAKI_ERR_UNKNOWN; } + CHIAKI_LOGI(c->log, "[KAMAJI] 0.5d -> entitlement %s platform %s sku %s", c->entitlement_id, c->platform, c->streaming_sku); + return CHIAKI_ERR_SUCCESS; +} + +static ChiakiErrorCode km_get_commerce_token(KamajiCtx *c) +{ + char url[2048]; + snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" + "&response_type=token&scope=kamaji:get_internal_entitlements%%20user:account.attributes.validate" + "%%20kamaji:get_privacy_settings%%20user:account.settings.privacy.get%%20kamaji:s2s.subscriptionsPremium.get" + "&client_id=%s&redirect_uri=%s&grant_type=authorization_code&service_entity=urn:service-entity:psn" + "&prompt=none&renderMode=mobilePortrait&hidePageElements=forgotPasswordLink&displayFooter=none" + "&disableLinks=qriocityLink&mid=PSNOW&duid=%s&layout_type=popup&service_logo=ps&tp_psn=true&noEVBlock=true", + KM_COMMERCE_CLIENT_ID, KM_REDIRECT_URI, c->duid); + char *tok = NULL; + ChiakiErrorCode e = km_oauth(c, url, true, &tok); + if(e != CHIAKI_ERR_SUCCESS) return e; + free(c->commerce_token); c->commerce_token = tok; + return CHIAKI_ERR_SUCCESS; +} + +static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_error) +{ + if(c->cfg->skip_account_attr_check) return CHIAKI_ERR_SUCCESS; + const char *body = "{\"attributes\":[\"ONLINE_ID\",\"BIRTH_DATE\",\"CITY\",\"REAL_NAME\"," + "\"PRIVACY_SETTING_ACTIVITYSTREAM\",\"PRIVACY_SETTING_FRIENDSLIST\",\"PRIVACY_SETTING_FRIENDREQUESTS\"," + "\"PRIVACY_SETTING_MESSAGES\",\"PRIVACY_SETTING_TRUENAME\",\"PRIVACY_SETTING_SEARCH\"," + "\"PRIVACY_SETTING_RECOMMENDUSERS\",\"PRIVACY_SETTING_BROADCAST\"]}"; + char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); + char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + const char *hdrs[] = { h_auth, h_ua, "Accept: application/json", "Content-Type: application/json" }; + CCHttpRequest req = { 0 }; + req.method = "POST"; req.url = "https://accounts.api.playstation.com/api/v2/accounts/me/attributes"; + req.headers = hdrs; req.header_count = 4; req.body = body; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &req, &resp); + free(h_auth); free(h_ua); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + if(resp.status_code == 200 || resp.status_code == 204) { cc_http_response_fini(&resp); return CHIAKI_ERR_SUCCESS; } + // Privacy/account upgrade required: surface a sentinel the platform turns into the upgrade dialog. + CHIAKI_LOGE(c->log, "[KAMAJI] account attributes failed (%ld)", resp.status_code); + if(out_error) *out_error = strdup("ACCOUNT_PRIVACY_SETTINGS"); + cc_http_response_fini(&resp); + return CHIAKI_ERR_UNKNOWN; +} + +static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) +{ + if(c->cfg->progress) c->cfg->progress("Acquiring License - Step 3 of 6", c->cfg->user); + char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); + char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + char *h_cookie = NULL; + if(c->jsessionid) cc_http_make_cookie_header(&h_cookie, "JSESSIONID", c->jsessionid); + + // --- preview: confirm $0 then take the authoritative sku --- + char prev_body[256]; + snprintf(prev_body, sizeof(prev_body), "sku=%s", c->streaming_sku[0] ? c->streaming_sku : c->entitlement_id); + const char *prev_hdrs[] = { + h_auth, h_ua, h_cookie ? h_cookie : "Accept: application/json", + "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", + "Accept: application/json, text/javascript, */*; q=0.01", + "X-Requested-With: XMLHttpRequest", "Origin: " KM_ORIGIN, "Referer: " KM_REFERER + }; + CCHttpRequest preq = { 0 }; + preq.method = "POST"; preq.url = KM_KAMAJI_BASE "/user/checkout/buynow/preview"; + preq.headers = prev_hdrs; preq.header_count = 8; preq.body = prev_body; + CCHttpResponse presp = { 0 }; + ChiakiErrorCode e = cc_http_perform(c->log, &preq, &presp); + if(e != CHIAKI_ERR_SUCCESS) { free(h_auth); free(h_ua); free(h_cookie); cc_http_response_fini(&presp); return e; } + struct json_object *pj = presp.data ? json_tokener_parse(presp.data) : NULL; + struct json_object *phdr = pj ? cc_json_obj(pj, "header") : NULL; + const char *pstatus = phdr ? cc_json_str(phdr, "status_code") : ""; + if(presp.status_code != 200 || (pstatus && *pstatus && strcmp(pstatus, "0x0000") != 0)) + { + CHIAKI_LOGE(c->log, "[KAMAJI] checkout preview failed (%ld / %s)", presp.status_code, pstatus); + if(out_error) *out_error = strdup("PS_PLUS_SUBSCRIPTION_REQUIRED"); + if(pj) json_object_put(pj); + free(h_auth); free(h_ua); free(h_cookie); cc_http_response_fini(&presp); + return CHIAKI_ERR_UNKNOWN; + } + struct json_object *pdata = cc_json_obj(pj, "data"); + struct json_object *cart = pdata ? cc_json_obj(pdata, "cart") : NULL; + int total = cart ? cc_json_int(cart, "total_price_value") : -1; + if(total != 0) + { + CHIAKI_LOGE(c->log, "[KAMAJI] title is not free (price value %d)", total); + if(out_error) *out_error = strdup("GAME_NOT_FREE"); + if(pj) json_object_put(pj); + free(h_auth); free(h_ua); free(h_cookie); cc_http_response_fini(&presp); + return CHIAKI_ERR_UNKNOWN; + } + struct json_object *items = cart ? cc_json_arr(cart, "items") : NULL; + if(items && json_object_array_length(items) > 0) + { + const char *real = cc_json_str(json_object_array_get_idx(items, 0), "sku_id"); + if(real && *real) snprintf(c->streaming_sku, sizeof(c->streaming_sku), "%s", real); + } + char *js2 = km_jsessionid(presp.headers); + if(js2) { free(c->jsessionid); c->jsessionid = js2; free(h_cookie); h_cookie = NULL; cc_http_make_cookie_header(&h_cookie, "JSESSIONID", c->jsessionid); } + if(pj) json_object_put(pj); + cc_http_response_fini(&presp); + + // --- buynow: complete the $0 acquire --- + char buy_body[256]; + snprintf(buy_body, sizeof(buy_body), "sku=%s&skipEmail=true", c->streaming_sku); + const char *buy_hdrs[] = { + h_auth, h_ua, h_cookie ? h_cookie : "Accept: application/json", "Accept: application/json", + "Content-Type: application/x-www-form-urlencoded" + }; + CCHttpRequest breq = { 0 }; + breq.method = "POST"; breq.url = KM_KAMAJI_BASE "/user/checkout/buynow"; + breq.headers = buy_hdrs; breq.header_count = 5; breq.body = buy_body; + CCHttpResponse bresp = { 0 }; + e = cc_http_perform(c->log, &breq, &bresp); + free(h_auth); free(h_ua); free(h_cookie); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&bresp); return e; } + struct json_object *bj = bresp.data ? json_tokener_parse(bresp.data) : NULL; + struct json_object *bhdr = bj ? cc_json_obj(bj, "header") : NULL; + const char *bstatus = bhdr ? cc_json_str(bhdr, "status_code") : ""; + bool ok = (bresp.status_code == 200) && bstatus && strcmp(bstatus, "0x0000") == 0; + if(!ok) CHIAKI_LOGE(c->log, "[KAMAJI] checkout buynow failed (%ld / %s)", bresp.status_code, bstatus); + else CHIAKI_LOGI(c->log, "[KAMAJI] entitlement acquired"); + if(bj) json_object_put(bj); + cc_http_response_fini(&bresp); + return ok ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_UNKNOWN; +} + +static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) +{ + if(c->cfg->progress) c->cfg->progress("Checking License - Step 3 of 6", c->cfg->user); + ChiakiErrorCode e = km_get_commerce_token(c); + if(e != CHIAKI_ERR_SUCCESS) return e; + e = km_check_account_attributes(c, out_error); + if(e != CHIAKI_ERR_SUCCESS) return e; + + char url[256]; + snprintf(url, sizeof(url), "https://commerce.api.np.km.playstation.net/commerce/api/v1/users/me/" + "internal_entitlements/%s?fields=game_meta", c->entitlement_id); + char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); + char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + const char *hdrs[] = { h_auth, h_ua, "Accept: application/json" }; + CCHttpRequest req = { 0 }; + req.url = url; req.headers = hdrs; req.header_count = 3; + CCHttpResponse resp = { 0 }; + e = cc_http_perform(c->log, &req, &resp); + free(h_auth); free(h_ua); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + long status = resp.status_code; + cc_http_response_fini(&resp); + + if(status == 200) { CHIAKI_LOGI(c->log, "[KAMAJI] entitlement already owned"); return CHIAKI_ERR_SUCCESS; } + if(status == 404) + { + if(c->cfg->catalog_is_foreign) + { + CHIAKI_LOGI(c->log, "[KAMAJI] entitlement 404, fallback region -> skip acquire, let Gaikai validate"); + return CHIAKI_ERR_SUCCESS; + } + return km_checkout_acquire(c, out_error); + } + CHIAKI_LOGE(c->log, "[KAMAJI] entitlement check failed (%ld)", status); + return CHIAKI_ERR_UNKNOWN; +} + +static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) +{ + if(c->cfg->progress) c->cfg->progress("Authorizing - Step 5 of 6", c->cfg->user); + char url[2048]; + snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" + "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" + "&prompt=none&mid=PSNOW&duid=%s&layout_type=popup&service_logo=ps&tp_psn=true&noEVBlock=true", + c->scopes, KM_CLIENT_ID, KM_REDIRECT_URI, c->duid); + return km_oauth(c, url, false, out_code); +} + +static ChiakiErrorCode km_step6_auth_session(KamajiCtx *c, const char *auth_code) +{ + if(c->cfg->progress) c->cfg->progress("Creating Session - Step 6 of 6", c->cfg->user); + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = km_post_session(c, auth_code, false, &resp); + if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } + struct json_object *j = resp.data ? json_tokener_parse(resp.data) : NULL; + struct json_object *hdr = j ? cc_json_obj(j, "header") : NULL; + const char *status = hdr ? cc_json_str(hdr, "status_code") : ""; + bool ok = status && strcmp(status, "0x0000") == 0; + if(ok) + { + struct json_object *data = cc_json_obj(j, "data"); + CHIAKI_LOGI(c->log, "[KAMAJI] session created (onlineId %s)", data ? cc_json_str(data, "onlineId") : ""); + } + else CHIAKI_LOGE(c->log, "[KAMAJI] step6 session failed (%ld / %s)", resp.status_code, status); + if(j) json_object_put(j); + cc_http_response_fini(&resp); + return ok ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_UNKNOWN; +} + +ChiakiErrorCode cc_kamaji_resolve(ChiakiLog *log, + const ChiakiCloudProvisionConfig *cfg, const char *duid, + char out_entitlement_id[128], char out_platform[8], char **out_error) +{ + KamajiCtx c; + memset(&c, 0, sizeof(c)); + c.log = log; + c.cfg = cfg; + c.duid = (duid && *duid) ? duid : ""; + c.npsso = cfg->npsso ? cfg->npsso : ""; + snprintf(c.platform, sizeof(c.platform), "ps4"); + c.scopes = KM_PS4_SCOPES; + if(out_error) *out_error = NULL; + + ChiakiErrorCode e; + bool fast_path = cfg->owned_entitlement_id && *cfg->owned_entitlement_id; + if(fast_path) + { + snprintf(c.entitlement_id, sizeof(c.entitlement_id), "%s", cfg->owned_entitlement_id); + snprintf(c.platform, sizeof(c.platform), "%s", + (cfg->owned_platform && *cfg->owned_platform) ? cfg->owned_platform : "ps4"); + c.scopes = (strcmp(c.platform, "ps3") == 0) ? KM_PS3_SCOPES : KM_PS4_SCOPES; + CHIAKI_LOGI(log, "[KAMAJI] fast-path owned entitlement %s (%s) -> skip 0.5b-0.5e", c.entitlement_id, c.platform); + e = CHIAKI_ERR_SUCCESS; + } + else + { + char *anon_code = NULL; + e = km_step0_5b_anon_authcode(&c, &anon_code); + if(e == CHIAKI_ERR_SUCCESS) e = km_step0_5c_anon_session(&c, anon_code); + free(anon_code); + if(e == CHIAKI_ERR_SUCCESS) e = km_step0_5d_resolve(&c); + if(e == CHIAKI_ERR_SUCCESS) e = km_step0_5e_check_acquire(&c, out_error); + } + + if(e == CHIAKI_ERR_SUCCESS) + { + char *auth_code = NULL; + e = km_step5_authcode(&c, &auth_code); + if(e == CHIAKI_ERR_SUCCESS) e = km_step6_auth_session(&c, auth_code); + free(auth_code); + } + + if(e == CHIAKI_ERR_SUCCESS) + { + snprintf(out_entitlement_id, 128, "%s", c.entitlement_id); + snprintf(out_platform, 8, "%s", c.platform); + } + free(c.jsessionid); + free(c.commerce_token); + return e; +} From 2af429e7dfaf9bc1c54a9d6178563231855dc47c Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 21:17:45 -0700 Subject: [PATCH 37/72] cloudsession: fix Kamaji OAuth scope encoding + add live probe harness The PS4 Kamaji scope contained literal spaces, which curl rejects ("bad/illegal format URL"); URL-encode them (%20) since the scope is spliced into the OAuth query verbatim. Add lib/test_cloudsession/cloudsession-probe (CHIAKI_ENABLE_TESTS, desktop only): NPSSO=... cloudsession-probe [resolve|provision]. Used to validate the flow against live Sony servers. Verified live with the US PS+ account on Child of Light: - resolve: 0.5b/0.5c/0.5d/0.5e/step5/6 -> entitlement UP0001-CUSA00339_00- PSRSVD0000000000 ps4, "already owned", authenticated session (matches the Python reference probe exactly) - provision: full Kamaji+Gaikai -> allocation 104.142.146.72:2053 with handshake_key/launch_spec/psn_wrapper_type/mtu, 5 datacenters pinged and sorted (sjca 68ms selected) Co-Authored-By: Claude Opus 4.8 --- lib/CMakeLists.txt | 4 ++ lib/src/cloudsession_kamaji.c | 3 +- lib/test_cloudsession/cloudsession-probe.c | 62 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 lib/test_cloudsession/cloudsession-probe.c diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 4bf3781b..77b0f8b7 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -239,5 +239,9 @@ if(CHIAKI_ENABLE_TESTS) if(NOT ANDROID AND NOT IOS) add_executable(cloudcatalog-test test_cloudcatalog/cloudcatalog-test.c) target_link_libraries(cloudcatalog-test chiaki-lib) + + add_executable(cloudsession-probe test_cloudsession/cloudsession-probe.c) + target_link_libraries(cloudsession-probe chiaki-lib) + target_include_directories(cloudsession-probe PRIVATE src) endif() endif() \ No newline at end of file diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 3a18c51b..37720cfe 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -27,8 +27,9 @@ #define KM_COMMERCE_CLIENT_ID "dc523cc2-b51b-4190-bff0-3397c06871b3" #define KM_REDIRECT_URI "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" #define KM_USER_AGENT "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +// URL-encoded (these are spliced into OAuth query strings via %s, not re-encoded). #define KM_PS3_SCOPES "kamaji:commerce_native" -#define KM_PS4_SCOPES "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" +#define KM_PS4_SCOPES "kamaji:commerce_native%20kamaji:commerce_container%20kamaji:lists%20kamaji:s2s.subscriptionsPremium.get" #define KM_REFERER "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/" #define KM_ORIGIN "https://psnow.playstation.com" diff --git a/lib/test_cloudsession/cloudsession-probe.c b/lib/test_cloudsession/cloudsession-probe.c new file mode 100644 index 00000000..3a0ca19d --- /dev/null +++ b/lib/test_cloudsession/cloudsession-probe.c @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Dev harness for the unified cloud-provisioning flow (not built on device). +// NPSSO=... cloudsession-probe [resolve|provision] +// "resolve" -> cc_kamaji_resolve only (OAuth + 0.5b-0.5e + step5/6); read-only +// for an owned title, no Gaikai allocation. +// "provision" -> the full public chiaki_cloud_provision_session (reserves a slot). + +#include +#include + +#include "cloudsession_internal.h" // cc_kamaji_resolve + +#include +#include +#include + +int main(int argc, char **argv) +{ + ChiakiLog log; + chiaki_log_init(&log, CHIAKI_LOG_ALL & ~CHIAKI_LOG_VERBOSE, chiaki_log_cb_print, NULL); + + const char *npsso = getenv("NPSSO"); + if(!npsso || !*npsso) { fprintf(stderr, "set NPSSO env\n"); return 2; } + const char *product = argc > 1 ? argv[1] : "UP0001-CUSA00339_00-CHILDOFLIGHT0001"; + const char *mode = argc > 2 ? argv[2] : "resolve"; + + ChiakiCloudProvisionConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.service_type = "psnow"; + cfg.game_identifier = product; + cfg.game_name = "probe"; + cfg.npsso = npsso; + cfg.store_country = "US"; + cfg.store_lang = "en"; + cfg.forced_datacenter = ""; + cfg.resolution = 1080; + cfg.bitrate_kbps = 15000; + + if(strcmp(mode, "provision") == 0) + { + ChiakiCloudProvisionResult out; + ChiakiErrorCode e = chiaki_cloud_provision_session(&cfg, &out, &log); + printf("\n== PROVISION err=%d ip=%s:%d ent=%s plat=%s wrap=%u hs=%s spec=%s rtt=%llu mtu=%u/%u\n", + e, out.server_ip, out.server_port, out.entitlement_id, out.platform, out.psn_wrapper_type, + out.handshake_key ? "yes" : "no", out.launch_spec ? "yes" : "no", + (unsigned long long)out.rtt_us, out.mtu_in, out.mtu_out); + printf(" pings=%s\n", out.datacenter_pings ? out.datacenter_pings : "(none)"); + printf(" msg=%s\n", out.error_message ? out.error_message : "(none)"); + chiaki_cloud_provision_result_fini(&out); + return e == CHIAKI_ERR_SUCCESS ? 0 : 1; + } + + char ent[128] = "", plat[8] = ""; + char *err = NULL; + // duid format matching the live flow: "0000000700410080" + 32 hex chars. + char duid[64] = "00000007004100800123456789abcdef0123456789abcdef"; + ChiakiErrorCode e = cc_kamaji_resolve(&log, &cfg, duid, ent, plat, &err); + printf("\n== KAMAJI err=%d ent=%s plat=%s err=%s\n", e, ent, plat, err ? err : "(none)"); + free(err); + return e == CHIAKI_ERR_SUCCESS ? 0 : 1; +} From 624fc92a7840d55878551ebdac3024b6bb1eab32 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 21:26:22 -0700 Subject: [PATCH 38/72] cloudsession (phase 5): wire Qt to the unified C provisioning flow CloudStreamingBackend::continueCloudSessionAfterAuth now runs chiaki_cloud_provision_session on a worker thread (std::thread + QMetaObject::invokeMethod, mirroring CloudCatalogBackend) instead of driving PSKamajiSession + PSGaikaiStreaming via signals/slots: - config built from Settings (resolvedStoreCountry/Lang, game language, forced datacenter, resolution, bitrate, foreign-region + account-attr flags) + the catalog owned-PSNOW fast-path entitlement - progress marshaled to setAllocationProgress via a static C thunk - finishCloudSession builds StreamSessionConnectInfo from the result and starts StreamSession (boundary unchanged) - handleProvisionError maps the C error_message sentinels (PS_PLUS_SUBSCRIPTION_REQUIRED / ACCOUNT_PRIVACY_SETTINGS / PING_TIMEOUT) to the existing dialogs The owned fast-path retry and the one-shot noGameForEntitlementId fallback now live in the C orchestrator, so the Qt startGaikaiAllocation / isEntitlementRejectedError helpers are removed. Gaikai gains a PING_TIMEOUT sentinel for the auto-select >80ms gate. checkAuthorization stays in Qt. gui builds clean (chiaki.app links). The old PSKamajiSession/PSGaikaiStreaming/ datacenterping classes are now unused (kept for KamajiConsts/GaikaiConsts used by checkAuthorization; deletion is a follow-up). Co-Authored-By: Claude Opus 4.8 --- gui/include/cloudstreamingbackend.h | 34 +- gui/src/cloudstreamingbackend.cpp | 649 ++++++++++++---------------- lib/src/cloudsession_gaikai.c | 4 + 3 files changed, 295 insertions(+), 392 deletions(-) diff --git a/gui/include/cloudstreamingbackend.h b/gui/include/cloudstreamingbackend.h index 0e65ccb5..e5cbadd1 100644 --- a/gui/include/cloudstreamingbackend.h +++ b/gui/include/cloudstreamingbackend.h @@ -75,31 +75,27 @@ private slots: // Centralized authorization check (used by both PSNOW and PSCLOUD) void checkAuthorization(QString serviceType, QString npssoToken, QString duid, std::function callback); - // Continue cloud session after successful authorization. - // forceFullEntitlementFlow=true disables the owned-PSNOW fast-path so the one-shot retry (after - // Gaikai rejects a fast-path entitlement) takes the full resolve/acquire path like today. - void continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid, bool forceFullEntitlementFlow = false); + // Continue cloud session after successful authorization: runs the unified C + // provisioning flow (chiaki_cloud_provision_session) on a worker thread and + // hands the stream-ready result to StreamSession. Kamaji+Gaikai, the owned + // fast-path and the one-shot noGameForEntitlementId retry all live in libchiaki. + void continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid); + + // Build StreamSessionConnectInfo from a successful provision result and start the session. + void finishCloudSession(QString serviceType, QString serverIp, int serverPort, + QString handshakeKey, QString launchSpec, QString sessionId, + uint8_t psnWrapperType, uint32_t mtuIn, uint32_t mtuOut, uint64_t rttUs, + const QJSValue &callback); + // Map a provisioning failure (error_message sentinels) to the right UI dialog. + void handleProvisionError(QString serviceType, QString errorMessage, const QJSValue &callback); + // C progress callback (called from the worker thread): marshals to setAllocationProgress. + static void provisionProgressThunk(const char *stage, void *user); Settings *settings; QString allocation_progress; int queue_position = -1; // -1 means not queued or no position available QString game_image_url; // Landscape image URL for current cloud game QNetworkAccessManager *authManager; // For authorization check - - // Helper method to start Gaikai allocation (shared between PSNOW and PSCLOUD flows). - // usedFastPath + gameIdentifier + npssoToken let the allocation-error handler retry once via the - // full entitlement flow when Gaikai rejects a fast-path (owned) entitlement. - void startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, - QString duid, - QString redirectUri, QString userAgent, QString oauthApiPath, - ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession, - bool usedFastPath = false, QString gameIdentifier = QString(), - QString npssoToken = QString()); - - // True when a Gaikai allocation error means "the entitlement we streamed isn't valid/owned" - // (e.g. noGameForEntitlementId) -- the signal that the owned fast-path guessed wrong and we - // should retry with the full resolve/acquire flow. - static bool isEntitlementRejectedError(const QString &error); }; #endif // CLOUDSTREAMINGBACKEND_H diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index cb5f2549..5f63730d 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -1,12 +1,14 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL #include "cloudstreamingbackend.h" -#include "cloudstreaming/pskamajisession.h" -#include "cloudstreaming/psgaikaistreaming.h" +#include "cloudstreaming/pskamajisession.h" // KamajiConsts (auth check) +#include "cloudstreaming/psgaikaistreaming.h" // GaikaiConsts (auth check) #include "streamsession.h" #include "exception.h" #include "chiaki/remote/holepunch.h" #include "chiaki/session.h" +#include "chiaki/cloudsession.h" +#include "chiaki/log.h" #include "qmlbackend.h" #include "cloudcatalogbackend.h" @@ -21,6 +23,8 @@ #include #include #include +#include +#include extern "C" { #include @@ -45,7 +49,7 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri qInfo() << "=== Starting Complete Cloud Streaming Session ==="; qInfo() << "Service Type:" << serviceType; qInfo() << "Game Identifier:" << gameIdentifier; - + // Get NPSSO token from settings QString npssoToken = settings->GetNpssoToken(); if (npssoToken.isEmpty()) { @@ -53,10 +57,10 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri } else { qInfo() << "Using NPSSO:" << npssoToken.left(20) << "..."; } - + // Normalize service type to lowercase serviceType = serviceType.toLower(); - + // Validate parameters if (serviceType != "psnow" && serviceType != "pscloud") { qWarning() << "Invalid serviceType:" << serviceType << "Must be 'psnow' or 'pscloud'"; @@ -65,7 +69,7 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri } return; } - + // Lookup game image from cache before starting session QmlBackend *qmlBackend = qobject_cast(parent()); if (qmlBackend && qmlBackend->cloudCatalog()) { @@ -81,13 +85,13 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri qWarning() << "Could not access CloudCatalogBackend for image lookup"; setGameImageUrl(QString()); // Clear any previous image } - + // Generate DUID once - shared between authorization check and session creation size_t duid_size = CHIAKI_DUID_STR_SIZE; char duid_arr[duid_size]; chiaki_holepunch_generate_client_device_uid(duid_arr, &duid_size); QString sharedDuid = QString(duid_arr); - + // Centralized authorization check for both PSNOW and PSCLOUD checkAuthorization(serviceType, npssoToken, sharedDuid, [this, serviceType, gameIdentifier, callback, npssoToken, sharedDuid](bool success) { if (!success) { @@ -96,401 +100,301 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri if (qmlBackend) { qmlBackend->setShowAuthorizationFailedDialog(true); // Also emit sessionError to trigger StreamView error handling and return to main menu - emit qmlBackend->sessionError(tr("Authentication Required"), + emit qmlBackend->sessionError(tr("Authentication Required"), tr("Your NPSSO token is likely expired. Please re-login to continue using cloud streaming.")); } - + // Clear game image on authorization failure setGameImageUrl(QString()); - + if (callback.isCallable()) { callback.call({false, "Authorization check failed"}); } return; } - + // Authorization successful - continue with cloud session setup continueCloudSessionAfterAuth(serviceType, gameIdentifier, callback, npssoToken, sharedDuid); }); } -void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid, bool forceFullEntitlementFlow) +// Runs the unified C provisioning flow on a worker thread, then hands the +// stream-ready result back to the GUI thread. Kamaji + Gaikai + datacenter +// ping/select + the owned fast-path + the one-shot noGameForEntitlementId retry +// all live in libchiaki (chiaki_cloud_provision_session) now. +void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, QString gameIdentifier, const QJSValue &callback, QString npssoToken, QString sharedDuid) { - // Determine service-specific configuration - QString redirectUri; - QString userAgent; - QString oauthApiPath; - - if (serviceType == "pscloud") { - redirectUri = GaikaiConsts::REDIRECT_URI; - userAgent = GaikaiConsts::USER_AGENT; - oauthApiPath = "/authz/v3"; // ACCOUNT_BASE already includes /api - } else { // psnow - redirectUri = KamajiConsts::REDIRECT_URI; - userAgent = KamajiConsts::USER_AGENT; - oauthApiPath = "/v1"; // ACCOUNT_BASE already includes /api - } - - // ChiakiTarget (console type for the Chiaki core). PSCLOUD = PS5; PSNOW refined after the - // Kamaji platform detection. - ChiakiTarget target = (serviceType == "pscloud") ? CHIAKI_TARGET_PS5_1 : CHIAKI_TARGET_PS4_9; - qInfo() << "Using DUID:" << sharedDuid; - - // PS4 / PS3 (PSNOW) titles go through a Kamaji session: the PS4 store container exposes the - // streaming/full-game entitlement, which Kamaji converts and acquires via PS Plus. - // PS5 (PSCLOUD) titles skip Kamaji: PS5 store containers carry NO entitlements/skus to - // convert, so we stream the owned entitlement id directly via Gaikai (cronos). - if (serviceType == "psnow") { - qInfo() << "=== PSNOW Flow: Starting Kamaji Session ==="; - PSKamajiSession *kamajiSession = new PSKamajiSession( - settings, sharedDuid, gameIdentifier, CloudConfig::ACCOUNT_BASE, - KamajiConsts::REDIRECT_URI, KamajiConsts::USER_AGENT, this); - - // Owned-PSNOW fast-path: if the unified catalog already resolved this title's streaming - // entitlement from the user's owned cross-reference, hand it straight to Kamaji so it - // skips the resolve/acquire path (which 404s + fails in storefront-less regions). Disabled - // on the retry (forceFullEntitlementFlow) so a Gaikai rejection falls back to the full flow. - if (!forceFullEntitlementFlow) { - QmlBackend *qmlBackendLookup = qobject_cast(parent()); - QString ownedEntitlementId, ownedPlatform; - if (qmlBackendLookup && qmlBackendLookup->cloudCatalog() - && qmlBackendLookup->cloudCatalog()->getOwnedPsnowEntitlement(gameIdentifier, ownedEntitlementId, ownedPlatform)) { - qInfo() << "PSNOW owned fast-path: catalog entitlementId=" << ownedEntitlementId - << "platform=" << ownedPlatform; - kamajiSession->setOwnedEntitlementFastPath(ownedEntitlementId, ownedPlatform); - } - } else { - qInfo() << "PSNOW: forcing full entitlement flow (fast-path retry fallback)"; + const bool pscloud = (serviceType == "pscloud"); + + // Snapshot everything the worker needs as owned byte arrays (must outlive the thread). + const QByteArray svc = serviceType.toUtf8(); + const QByteArray gameId = gameIdentifier.toUtf8(); + const QByteArray npsso = npssoToken.toUtf8(); + const QByteArray storeCountry = settings->GetCloudResolvedStoreCountry().toUtf8(); + const QByteArray storeLang = settings->GetCloudResolvedStoreLang().toUtf8(); + const QByteArray gameLang = settings->GetCloudGameLanguage().toUtf8(); + const QByteArray forcedDc = (pscloud ? settings->GetCloudDatacenterPSCloud() + : settings->GetCloudDatacenterPSNOW()).toUtf8(); + const int resolution = pscloud ? settings->GetCloudResolutionPSCloud() + : settings->GetCloudResolutionPSNOW(); + const int bitrate = static_cast(pscloud ? settings->GetCloudBitratePSCloud() + : settings->GetCloudBitratePSNOW()); + const bool isForeign = settings->IsCloudCatalogIsForeign(); + const bool attrPassed = settings->GetAccountAttributesCheckPassed(); + + // Owned-PSNOW fast-path: hand the catalog's resolved owned entitlement straight in so the + // C flow skips the resolve/acquire path. (If Gaikai rejects it, the orchestrator retries + // the full resolve flow once internally.) + QByteArray ownedEnt, ownedPlat; + if (!pscloud) { + QmlBackend *qb = qobject_cast(parent()); + QString e, p; + if (qb && qb->cloudCatalog() && qb->cloudCatalog()->getOwnedPsnowEntitlement(gameIdentifier, e, p)) { + qInfo() << "PSNOW owned fast-path: entitlementId=" << e << "platform=" << p; + ownedEnt = e.toUtf8(); + ownedPlat = p.toUtf8(); } + } + Q_UNUSED(sharedDuid); // the C flow generates its own shared DUID for Kamaji+Gaikai - connect(kamajiSession, &PSKamajiSession::psPlusSubscriptionError, this, [this]() { - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); - }); - connect(kamajiSession, &PSKamajiSession::accountPrivacySettingsError, this, [this](QString upgradeUrl) { - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - qmlBackend->setAccountPrivacyUpgradeUrl(upgradeUrl); - qmlBackend->setShowAccountPrivacySettingsDialog(true); - } - }); - connect(kamajiSession, &PSKamajiSession::sessionComplete, this, - [this, kamajiSession, callback, sharedDuid, serviceType, target, redirectUri, userAgent, oauthApiPath, gameIdentifier, npssoToken](bool success, QString message, QString entitlementId) { - if (!success) { - qWarning() << "Kamaji session creation failed:" << message; - setGameImageUrl(QString()); - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) - emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), - QString("Session creation failed: %1").arg(message)); - if (callback.isCallable()) - callback.call({false, QString("Session creation failed: %1").arg(message)}); - kamajiSession->deleteLater(); - return; + setAllocationProgress(tr("Starting cloud session...")); + + std::thread([this, callback, svc, gameId, npsso, storeCountry, storeLang, gameLang, + forcedDc, resolution, bitrate, isForeign, attrPassed, ownedEnt, ownedPlat]() mutable { + ChiakiLog log; + chiaki_log_init(&log, CHIAKI_LOG_INFO | CHIAKI_LOG_WARNING | CHIAKI_LOG_ERROR, + chiaki_log_cb_print, nullptr); + + ChiakiCloudProvisionConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.service_type = svc.constData(); + cfg.game_identifier = gameId.constData(); + cfg.npsso = npsso.constData(); + cfg.store_country = storeCountry.constData(); + cfg.store_lang = storeLang.constData(); + cfg.game_language = gameLang.constData(); + cfg.owned_entitlement_id = ownedEnt.constData(); + cfg.owned_platform = ownedPlat.constData(); + cfg.catalog_is_foreign = isForeign; + cfg.skip_account_attr_check = attrPassed; + cfg.forced_datacenter = forcedDc.constData(); + cfg.cache_dir = ""; + cfg.resolution = resolution; + cfg.bitrate_kbps = bitrate; + cfg.progress = &CloudStreamingBackend::provisionProgressThunk; + cfg.is_cancelled = nullptr; + cfg.user = this; + + ChiakiCloudProvisionResult res; + ChiakiErrorCode err = chiaki_cloud_provision_session(&cfg, &res, &log); + + const bool success = (err == CHIAKI_ERR_SUCCESS); + const QString serviceTypeStr = QString::fromUtf8(svc); + const QString serverIp = QString::fromUtf8(res.server_ip); + const int serverPort = res.server_port; + const QString handshakeKey = res.handshake_key ? QString::fromUtf8(res.handshake_key) : QString(); + const QString launchSpec = res.launch_spec ? QString::fromUtf8(res.launch_spec) : QString(); + const QString sessionId = res.session_id ? QString::fromUtf8(res.session_id) : QString(); + const uint8_t wrap = res.psn_wrapper_type; + const uint32_t mtuIn = res.mtu_in, mtuOut = res.mtu_out; + const quint64 rttUs = res.rtt_us; + const QString errMsg = res.error_message ? QString::fromUtf8(res.error_message) : QString(); + chiaki_cloud_provision_result_fini(&res); + + QMetaObject::invokeMethod(this, [this, callback, success, serviceTypeStr, serverIp, serverPort, + handshakeKey, launchSpec, sessionId, wrap, mtuIn, mtuOut, rttUs, errMsg]() mutable { + if (success) { + finishCloudSession(serviceTypeStr, serverIp, serverPort, handshakeKey, launchSpec, + sessionId, wrap, mtuIn, mtuOut, rttUs, callback); + } else { + handleProvisionError(serviceTypeStr, errMsg, callback); } - qInfo() << "=== Kamaji Session Created, Starting Allocation ==="; - qInfo() << "Converted Entitlement ID:" << entitlementId; - QString detectedPlatform = kamajiSession->getPlatform(); // ps4 / ps3 - ChiakiTarget platformTarget = CHIAKI_TARGET_PS4_9; // PS4 and PS3 both stream as PS4 - const bool usedFastPath = kamajiSession->usedEntitlementFastPath(); - startGaikaiAllocation(serviceType, detectedPlatform, entitlementId, sharedDuid, - redirectUri, userAgent, oauthApiPath, platformTarget, callback, kamajiSession, - usedFastPath, gameIdentifier, npssoToken); }); - kamajiSession->startSessionCreation(); - } else { - // PSCLOUD: stream the owned entitlement id directly (no Kamaji — PS5 containers have none). - qInfo() << "=== PSCLOUD Flow: Direct Gaikai (PS5), entitlement:" << gameIdentifier << "==="; - startGaikaiAllocation(serviceType, QStringLiteral("ps5"), gameIdentifier, sharedDuid, - redirectUri, userAgent, oauthApiPath, target, callback, nullptr); - } + }).detach(); } -bool CloudStreamingBackend::isEntitlementRejectedError(const QString &error) +// Build StreamSessionConnectInfo from the C result and start the StreamSession. +// This boundary (and everything below it) is unchanged from the previous flow -- +// only the source of the parameters moved from PSGaikaiStreaming to the C result. +void CloudStreamingBackend::finishCloudSession(QString serviceType, QString serverIp, int serverPort, + QString handshakeKey, QString launchSpec, QString sessionId, + uint8_t psnWrapperType, uint32_t mtuIn, uint32_t mtuOut, uint64_t rttUs, + const QJSValue &callback) { - // Gaikai's authorize step (step9) surfaces an unowned/invalid entitlement as - // "noGameForEntitlementId" in the errors[].description it bubbles up here. (If live testing - // shows a different marker for the unowned case, add it here -- this is the fast-path retry gate.) - return error.contains(QStringLiteral("noGameForEntitlement"), Qt::CaseInsensitive); -} + qInfo() << "=== COMPLETE CLOUD SESSION SUCCESS ==="; + qInfo() << " IP:" << serverIp << " Port:" << serverPort << " SessionId len:" << sessionId.length(); + qInfo() << " handshake len:" << handshakeKey.length() << " launchSpec len:" << launchSpec.length(); -void CloudStreamingBackend::startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, - QString duid, - QString redirectUri, QString userAgent, QString oauthApiPath, - ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession, - bool usedFastPath, QString gameIdentifier, QString npssoToken) -{ - // Step 7-13: Complete Gaikai allocation - PSGaikaiStreaming *gaikaiStreaming = new PSGaikaiStreaming( + // PSCLOUD streams as PS5, PSNOW (PS3 + PS4) as PS4. + const ChiakiTarget target = (serviceType == "pscloud") ? CHIAKI_TARGET_PS5_1 : CHIAKI_TARGET_PS4_9; + + // Read window type from settings (same as remote play) + bool fullscreen = false, zoom = false, stretch = false; + switch (settings->GetWindowType()) { + case WindowType::SelectedResolution: + case WindowType::CustomResolution: + case WindowType::AdjustableResolution: + break; + case WindowType::Fullscreen: fullscreen = true; break; + case WindowType::Zoom: zoom = true; break; + case WindowType::Stretch: stretch = true; break; + default: break; + } + + // Pass host as "IP:PORT"; StreamSession extracts the port for cloud mode. + StreamSessionConnectInfo connect_info( settings, - duid, - serviceType, - platform, - this - ); - - // Connect progress updates - update our property which QML can bind to - connect(gaikaiStreaming, &PSGaikaiStreaming::AllocationProgress, this, - &CloudStreamingBackend::onAllocationProgress); - - // When Gaikai completes successfully - connect(gaikaiStreaming, &PSGaikaiStreaming::AllocationComplete, this, - [this, gaikaiStreaming, kamajiSession, callback, target, serviceType](QString serverIp, int serverPort, QString handshakeKey, QString launchSpec, QString sessionId) { - qInfo() << "=== COMPLETE CLOUD SESSION SUCCESS ==="; - qInfo() << "Ready to connect to streaming server:"; - qInfo() << " IP:" << serverIp; - qInfo() << " Port:" << serverPort; - qInfo() << " Session ID:" << sessionId; - - qInfo() << "Creating StreamSessionConnectInfo for cloud streaming"; - qInfo() << " Server IP:" << serverIp; - qInfo() << " Server Port:" << serverPort; - qInfo() << " Session ID length:" << sessionId.length(); - qInfo() << " Handshake key length:" << handshakeKey.length(); - qInfo() << " Launch spec length:" << launchSpec.length(); - - // Read window type from settings (same as remote play) - bool fullscreen = false, zoom = false, stretch = false; - switch (settings->GetWindowType()) { - case WindowType::SelectedResolution: - break; - case WindowType::CustomResolution: - break; - case WindowType::AdjustableResolution: - break; - case WindowType::Fullscreen: - fullscreen = true; - break; - case WindowType::Zoom: - zoom = true; - break; - case WindowType::Stretch: - stretch = true; - break; - default: - break; - } - - // Create StreamSessionConnectInfo with cloud parameters - // Pass host as "IP:PORT" format - StreamSession will extract port for cloud mode - StreamSessionConnectInfo connect_info( - settings, - target, // PSCLOUD->PS5 target, PSNOW->PS4 target - QString("%1:%2").arg(serverIp).arg(serverPort), // host:port (will be split in StreamSession) - QString(), // nickname - QByteArray(), // regist_key (not used for cloud) - QByteArray(), // morning (not used for cloud) - QString(), // initial_login_pin - QString(), // duid (not used for cloud, direct connection) - false, // auto_regist - fullscreen, // fullscreen (from settings) - zoom, // zoom (from settings) - stretch // stretch (from settings) - ); - - // Set service type for cloud streaming BEFORE any validation - connect_info.cloud_launch_spec = launchSpec; - connect_info.cloud_handshake_key = handshakeKey; - connect_info.cloud_session_id = sessionId; - if (serviceType == "pscloud") { - connect_info.service_type = CHIAKI_SERVICE_TYPE_PSCLOUD; - } else if (serviceType == "psnow") { - connect_info.service_type = CHIAKI_SERVICE_TYPE_PSNOW; - } else { - connect_info.service_type = CHIAKI_SERVICE_TYPE_REMOTE_PLAY; - } - connect_info.cloud_psn_wrapper_type = gaikaiStreaming->getPsnWrapperType(); - - // Extract MTU values from ping results - QJsonObject pingResult = gaikaiStreaming->getSelectedDatacenterPingResult(); - if (!pingResult.isEmpty()) { - connect_info.cloud_mtu_in = pingResult["mtu_in"].toInt(0); - connect_info.cloud_mtu_out = pingResult["mtu_out"].toInt(0); - int rtt_ms = pingResult["rtt"].toInt(0); - connect_info.cloud_rtt_us = rtt_ms > 0 ? (uint64_t)rtt_ms * 1000 : 0; - qInfo() << "Cloud mode: Using MTU values from ping results - mtu_in:" << connect_info.cloud_mtu_in - << ", mtu_out:" << connect_info.cloud_mtu_out << ", rtt:" << rtt_ms << "ms"; - } else { - connect_info.cloud_mtu_in = 0; - connect_info.cloud_mtu_out = 0; - connect_info.cloud_rtt_us = 0; - qWarning() << "Cloud mode: No ping results available, will use default MTU values"; - } - - // Override Remote Play default video profile with cloud resolution/codec/bitrate. - connect_info.video_profile = settings->GetCloudVideoProfile(serviceType); - - qInfo() << "Cloud streaming parameters set:"; - qInfo() << " service_type:" << chiaki_service_type_string(connect_info.service_type); - qInfo() << " cloud_session_id set:" << !connect_info.cloud_session_id.isEmpty(); - qInfo() << " cloud_handshake_key set:" << !connect_info.cloud_handshake_key.isEmpty(); - qInfo() << " cloud_launch_spec set:" << !connect_info.cloud_launch_spec.isEmpty(); - qInfo() << " cloud_psn_wrapper_type:" << QString("0x%1").arg(connect_info.cloud_psn_wrapper_type, 2, 16, QChar('0')); - - // Resolve "auto" hardware decoder to actual decoder - if(connect_info.hw_decoder == "auto") - { - connect_info.hw_decoder = QString(); - // Auto-detect available hardware decoder - static QSet allowed = { - "vulkan", + target, + QString("%1:%2").arg(serverIp).arg(serverPort), + QString(), // nickname + QByteArray(), // regist_key (not used for cloud) + QByteArray(), // morning (not used for cloud) + QString(), // initial_login_pin + QString(), // duid (not used for cloud, direct connection) + false, // auto_regist + fullscreen, zoom, stretch); + + connect_info.cloud_launch_spec = launchSpec; + connect_info.cloud_handshake_key = handshakeKey; + connect_info.cloud_session_id = sessionId; + if (serviceType == "pscloud") + connect_info.service_type = CHIAKI_SERVICE_TYPE_PSCLOUD; + else if (serviceType == "psnow") + connect_info.service_type = CHIAKI_SERVICE_TYPE_PSNOW; + else + connect_info.service_type = CHIAKI_SERVICE_TYPE_REMOTE_PLAY; + connect_info.cloud_psn_wrapper_type = psnWrapperType; + connect_info.cloud_mtu_in = mtuIn; + connect_info.cloud_mtu_out = mtuOut; + connect_info.cloud_rtt_us = rttUs; + connect_info.video_profile = settings->GetCloudVideoProfile(serviceType); + + qInfo() << "Cloud streaming parameters set:"; + qInfo() << " service_type:" << chiaki_service_type_string(connect_info.service_type); + qInfo() << " cloud_psn_wrapper_type:" << QString("0x%1").arg(connect_info.cloud_psn_wrapper_type, 2, 16, QChar('0')); + qInfo() << " mtu_in:" << mtuIn << " mtu_out:" << mtuOut << " rtt_us:" << rttUs; + + // Resolve "auto" hardware decoder to an actual decoder. + if (connect_info.hw_decoder == "auto") { + connect_info.hw_decoder = QString(); + static QSet allowed = { + "vulkan", #if defined(Q_OS_LINUX) - "vaapi", + "vaapi", #elif defined(Q_OS_MACOS) - "videotoolbox", + "videotoolbox", #elif defined(Q_OS_WIN) - "d3d11va", + "d3d11va", #endif - }; - - enum AVHWDeviceType hw_dev = AV_HWDEVICE_TYPE_NONE; - QStringList available; - while (true) { - hw_dev = av_hwdevice_iterate_types(hw_dev); - if (hw_dev == AV_HWDEVICE_TYPE_NONE) - break; - const QString name = QString::fromUtf8(av_hwdevice_get_type_name(hw_dev)); - if (allowed.contains(name)) - available.append(name); - } - - // Select decoder based on platform preferences - if (available.contains("vulkan")) { - connect_info.hw_decoder = "vulkan"; - qInfo() << "Auto-selected hardware decoder: vulkan"; - } + }; + enum AVHWDeviceType hw_dev = AV_HWDEVICE_TYPE_NONE; + QStringList available; + while (true) { + hw_dev = av_hwdevice_iterate_types(hw_dev); + if (hw_dev == AV_HWDEVICE_TYPE_NONE) + break; + const QString name = QString::fromUtf8(av_hwdevice_get_type_name(hw_dev)); + if (allowed.contains(name)) + available.append(name); + } + if (available.contains("vulkan")) { + connect_info.hw_decoder = "vulkan"; + qInfo() << "Auto-selected hardware decoder: vulkan"; + } #if defined(Q_OS_LINUX) - else if (available.contains("vaapi")) { - connect_info.hw_decoder = "vaapi"; - qInfo() << "Auto-selected hardware decoder: vaapi"; - } + else if (available.contains("vaapi")) { + connect_info.hw_decoder = "vaapi"; + qInfo() << "Auto-selected hardware decoder: vaapi"; + } #elif defined(Q_OS_WIN) - else if (available.contains("d3d11va")) { - connect_info.hw_decoder = "d3d11va"; - qInfo() << "Auto-selected hardware decoder: d3d11va"; - } + else if (available.contains("d3d11va")) { + connect_info.hw_decoder = "d3d11va"; + qInfo() << "Auto-selected hardware decoder: d3d11va"; + } #elif defined(Q_OS_MACOS) - else if (available.contains("videotoolbox")) { - connect_info.hw_decoder = "videotoolbox"; - qInfo() << "Auto-selected hardware decoder: videotoolbox"; - } + else if (available.contains("videotoolbox")) { + connect_info.hw_decoder = "videotoolbox"; + qInfo() << "Auto-selected hardware decoder: videotoolbox"; + } #endif - else { - qInfo() << "No hardware decoder available, using software decoding"; - } + else { + qInfo() << "No hardware decoder available, using software decoding"; } - - // Create and start StreamSession - qInfo() << "=== Creating StreamSession ==="; - try { - qInfo() << "Instantiating StreamSession with cloud parameters..."; - // Create session with QmlBackend as parent so it can manage it - StreamSession *session = new StreamSession(connect_info, parent()); - qInfo() << "StreamSession created successfully, emitting sessionCreated signal..."; - - // Emit signal so QmlBackend can register the session - emit sessionCreated(session); - - // Clear progress message since allocation is complete - setAllocationProgress(""); + } + + qInfo() << "=== Creating StreamSession ==="; + try { + StreamSession *session = new StreamSession(connect_info, parent()); + emit sessionCreated(session); + + setAllocationProgress(""); if (queue_position != -1) { queue_position = -1; emit queuePositionChanged(); } - - // Start the session - session->Start(); - qInfo() << "StreamSession Start() called (connection is asynchronous)"; - - // Success will be reported when the stream actually connects - // For now, just indicate that we've initiated the connection - if (callback.isCallable()) { - callback.call({ - true, - "Cloud session connection initiated (waiting for server response...)", - serverIp - }); - } - } catch (const Exception &e) { - qWarning() << "Failed to start cloud streaming session:" << e.what(); - setGameImageUrl(QString()); // Clear image on error - if (callback.isCallable()) { - callback.call({ - false, - QString("Failed to start session: %1").arg(e.what()) - }); - } - } - - // Clean up - gaikaiStreaming->deleteLater(); - if (kamajiSession) { - kamajiSession->deleteLater(); - } - }); - - // Connect dialog error signals - connect(gaikaiStreaming, &PSGaikaiStreaming::psPlusSubscriptionError, this, [this]() { - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - qmlBackend->setShowPSPlusSubscriptionDialog(true); - } - }); - - connect(gaikaiStreaming, &PSGaikaiStreaming::pingTimeoutError, this, [this]() { - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - qmlBackend->setShowPingTimeoutDialog(true); - } - }); - - // When Gaikai allocation fails - connect(gaikaiStreaming, &PSGaikaiStreaming::AllocationError, this, - [this, gaikaiStreaming, kamajiSession, callback, serviceType, duid, usedFastPath, gameIdentifier, npssoToken](QString error) { - qWarning() << "Gaikai allocation failed:" << error; - - // Owned fast-path fallback: if we streamed a catalog-provided entitlement and Gaikai rejected - // it (the entitlement isn't actually valid/owned), retry exactly once via the full - // resolve/acquire flow -- i.e. behave like today. One shot only (forceFullEntitlementFlow - // disables the fast-path on the retry), so this can never loop. - if (usedFastPath && !gameIdentifier.isEmpty() && isEntitlementRejectedError(error)) { - qWarning() << "Owned fast-path entitlement rejected by Gaikai; retrying once with the full entitlement flow"; - gaikaiStreaming->deleteLater(); - if (kamajiSession) - kamajiSession->deleteLater(); - continueCloudSessionAfterAuth(serviceType, gameIdentifier, callback, npssoToken, duid, - /*forceFullEntitlementFlow=*/true); - return; - } - // Clear game image on error - setGameImageUrl(QString()); - - // Emit sessionError to dismiss loading screen - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), - QString("Allocation failed: %1").arg(error)); - } - + session->Start(); + qInfo() << "StreamSession Start() called (connection is asynchronous)"; + if (callback.isCallable()) { - callback.call({false, QString("Allocation failed: %1").arg(error)}); + callback.call({ + true, + "Cloud session connection initiated (waiting for server response...)", + serverIp + }); } - gaikaiStreaming->deleteLater(); - if (kamajiSession) { - kamajiSession->deleteLater(); + } catch (const Exception &e) { + qWarning() << "Failed to start cloud streaming session:" << e.what(); + setGameImageUrl(QString()); + if (callback.isCallable()) { + callback.call({false, QString("Failed to start session: %1").arg(e.what())}); } - - // Clear progress message on error - setAllocationProgress(""); - if (queue_position != -1) { - queue_position = -1; - emit queuePositionChanged(); + } +} + +// Map the C error_message sentinels to the same dialogs the old flow raised. +void CloudStreamingBackend::handleProvisionError(QString serviceType, QString errorMessage, const QJSValue &callback) +{ + Q_UNUSED(serviceType); + qWarning() << "Cloud provisioning failed:" << errorMessage; + setGameImageUrl(QString()); + + QmlBackend *qmlBackend = qobject_cast(parent()); + if (errorMessage.contains(QStringLiteral("PS_PLUS_SUBSCRIPTION_REQUIRED"))) { + if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); + } else if (errorMessage.contains(QStringLiteral("ACCOUNT_PRIVACY_SETTINGS"))) { + if (qmlBackend) { + qmlBackend->setAccountPrivacyUpgradeUrl(QString()); + qmlBackend->setShowAccountPrivacySettingsDialog(true); } - }); - - // Start Gaikai allocation with entitlement ID - gaikaiStreaming->StartAllocationFlow(entitlementId, QJSValue()); + } else if (errorMessage.contains(QStringLiteral("PING_TIMEOUT"))) { + if (qmlBackend) qmlBackend->setShowPingTimeoutDialog(true); + } else if (qmlBackend) { + emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), + errorMessage.isEmpty() ? tr("Allocation failed") + : QString("Allocation failed: %1").arg(errorMessage)); + } + + if (callback.isCallable()) { + callback.call({false, errorMessage.isEmpty() ? QString("Allocation failed") + : QString("Allocation failed: %1").arg(errorMessage)}); + } + + setAllocationProgress(""); + if (queue_position != -1) { + queue_position = -1; + emit queuePositionChanged(); + } +} + +// C progress callback -- runs on the worker thread; marshal to the GUI thread. +void CloudStreamingBackend::provisionProgressThunk(const char *stage, void *user) +{ + auto *self = static_cast(user); + if (!self || !stage) + return; + const QString s = QString::fromUtf8(stage); + QMetaObject::invokeMethod(self, [self, s]() { self->setAllocationProgress(s); }); } void CloudStreamingBackend::onAllocationProgress(QString message, int queuePosition) @@ -529,13 +433,13 @@ void CloudStreamingBackend::checkAuthorization(QString serviceType, QString npss callback(false); return; } - + // Determine configuration based on service type QString kamajiClientId; QString scopesStr; QString redirectUri; QString userAgent; - + if (serviceType == "psnow") { // PSNOW configuration (matching PSKamajiSession) kamajiClientId = KamajiConsts::CLIENT_ID; @@ -549,13 +453,13 @@ void CloudStreamingBackend::checkAuthorization(QString serviceType, QString npss redirectUri = GaikaiConsts::REDIRECT_URI; userAgent = GaikaiConsts::USER_AGENT; } - + // Disable cookie jar on auth manager - we use manual Cookie headers only authManager->setCookieJar(nullptr); - + // Create authorization check request (matching PSKamajiSession::step0_5a_AuthorizeCheck) QString url = CloudConfig::ACCOUNT_BASE + "/authz/v3/oauth/authorizeCheck"; - + QJsonObject body; body["client_id"] = kamajiClientId; body["scope"] = scopesStr; @@ -563,7 +467,7 @@ void CloudStreamingBackend::checkAuthorization(QString serviceType, QString npss body["response_type"] = "code"; body["service_entity"] = "urn:service-entity:psn"; body["duid"] = duid; - + QNetworkRequest req{QUrl(url)}; req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json; charset=UTF-8"); req.setRawHeader("User-Agent", userAgent.toUtf8()); @@ -571,16 +475,16 @@ void CloudStreamingBackend::checkAuthorization(QString serviceType, QString npss if (!npssoToken.isEmpty()) { req.setRawHeader("Cookie", QString("npsso=%1").arg(npssoToken).toUtf8()); } - + qInfo() << "=== Centralized Authorization Check ==="; qInfo() << "Service Type:" << serviceType; qInfo() << "URL:" << url; - + QNetworkReply *reply = authManager->post(req, QJsonDocument(body).toJson()); - + connect(reply, &QNetworkReply::finished, this, [reply, callback, serviceType]() { bool success = false; - + // Match PSKamajiSession::handleAuthorizeCheckResponse logic if (reply->error() == QNetworkReply::NoError) { success = true; @@ -588,9 +492,8 @@ void CloudStreamingBackend::checkAuthorization(QString serviceType, QString npss } else { qWarning() << "Authorization check failed for" << serviceType << ":" << reply->errorString(); } - + reply->deleteLater(); callback(success); }); } - diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 45bcedff..12efaa05 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -57,6 +57,7 @@ typedef struct struct json_object *selected_ping; // borrowed ref into ping_results char selected_datacenter[128]; int selected_dc_port; + bool ping_timeout; // best measured RTT exceeded the auto-select gate (>80ms) } GaikaiCtx; static void gk_progress(GaikaiCtx *c, const char *stage) @@ -673,6 +674,7 @@ static ChiakiErrorCode gk_step12_select(GaikaiCtx *c) if(measured && rtt_ms > 80) { CHIAKI_LOGE(c->log, "[GAIKAI] best datacenter RTT %dms > 80ms", rtt_ms); + c->ping_timeout = true; return CHIAKI_ERR_UNKNOWN; // ping-too-high } } @@ -822,6 +824,8 @@ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, } if(psplus_err && !out->error_message) out->error_message = strdup("PS_PLUS_SUBSCRIPTION_REQUIRED"); + if(c.ping_timeout && !out->error_message) + out->error_message = strdup("PING_TIMEOUT"); free(c.config_key); free(c.lock_session_key); free(c.gaikai_session_id); free(c.gk_cloud_auth_code); free(c.ps3_auth_code); free(c.stream_server_auth_code); From 5d944f73c1d8620ecc75171e845cbebe68688ccf Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 21:45:47 -0700 Subject: [PATCH 39/72] cloudsession: parameterize probe harness to exercise every path Env-driven (SERVICE/STORE_CC/STORE_LANG/OWNED_ENT/OWNED_PLAT/FORCED_DC/FOREIGN/ GAME_LANG/RES/BITRATE) + a "ping" mode, so the probe can drive the owned fast-path, the one-shot fallback, forced-datacenter bypass, foreign 404-skip, PSCLOUD, and the PS+/ping error paths -- not just the default PSNOW full flow. Co-Authored-By: Claude Opus 4.8 --- lib/test_cloudsession/cloudsession-probe.c | 49 ++++++++++++++++------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/lib/test_cloudsession/cloudsession-probe.c b/lib/test_cloudsession/cloudsession-probe.c index 3a0ca19d..d85a6be9 100644 --- a/lib/test_cloudsession/cloudsession-probe.c +++ b/lib/test_cloudsession/cloudsession-probe.c @@ -1,10 +1,13 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL // // Dev harness for the unified cloud-provisioning flow (not built on device). -// NPSSO=... cloudsession-probe [resolve|provision] -// "resolve" -> cc_kamaji_resolve only (OAuth + 0.5b-0.5e + step5/6); read-only -// for an owned title, no Gaikai allocation. -// "provision" -> the full public chiaki_cloud_provision_session (reserves a slot). +// NPSSO=... cloudsession-probe [resolve|provision|ping] +// Env overrides (drive every path): +// SERVICE=psnow|pscloud STORE_CC=US STORE_LANG=en +// OWNED_ENT= OWNED_PLAT=ps4 (owned fast-path / one-shot fallback) +// FORCED_DC=sjca (bypass datacenter pinging) +// FOREIGN=1 (fallback region: skip $0 acquire on 404) +// GAME_LANG=en-US RES=1080 BITRATE=15000 #include #include @@ -15,6 +18,12 @@ #include #include +static const char *env_or(const char *k, const char *def) +{ + const char *v = getenv(k); + return (v && *v) ? v : def; +} + int main(int argc, char **argv) { ChiakiLog log; @@ -27,15 +36,33 @@ int main(int argc, char **argv) ChiakiCloudProvisionConfig cfg; memset(&cfg, 0, sizeof(cfg)); - cfg.service_type = "psnow"; + cfg.service_type = env_or("SERVICE", "psnow"); cfg.game_identifier = product; cfg.game_name = "probe"; cfg.npsso = npsso; - cfg.store_country = "US"; - cfg.store_lang = "en"; - cfg.forced_datacenter = ""; - cfg.resolution = 1080; - cfg.bitrate_kbps = 15000; + cfg.store_country = env_or("STORE_CC", "US"); + cfg.store_lang = env_or("STORE_LANG", "en"); + cfg.game_language = env_or("GAME_LANG", "en-US"); + cfg.owned_entitlement_id = env_or("OWNED_ENT", ""); + cfg.owned_platform = env_or("OWNED_PLAT", "ps4"); + cfg.forced_datacenter = env_or("FORCED_DC", ""); + cfg.catalog_is_foreign = getenv("FOREIGN") && *getenv("FOREIGN"); + cfg.resolution = atoi(env_or("RES", "1080")); + cfg.bitrate_kbps = atoi(env_or("BITRATE", "15000")); + + printf("\n>>> mode=%s service=%s product=%s store=%s/%s ownedEnt=%s forcedDC=%s foreign=%d\n", + mode, cfg.service_type, product, cfg.store_country, cfg.store_lang, + cfg.owned_entitlement_id[0] ? cfg.owned_entitlement_id : "(none)", + cfg.forced_datacenter[0] ? cfg.forced_datacenter : "(auto)", cfg.catalog_is_foreign); + + if(strcmp(mode, "ping") == 0) + { + char *pings = NULL; + ChiakiErrorCode e = chiaki_cloud_ping_datacenters(&cfg, &pings, &log); + printf("== PING err=%d pings=%s\n", e, pings ? pings : "(none)"); + free(pings); + return e == CHIAKI_ERR_SUCCESS ? 0 : 1; + } if(strcmp(mode, "provision") == 0) { @@ -45,7 +72,6 @@ int main(int argc, char **argv) e, out.server_ip, out.server_port, out.entitlement_id, out.platform, out.psn_wrapper_type, out.handshake_key ? "yes" : "no", out.launch_spec ? "yes" : "no", (unsigned long long)out.rtt_us, out.mtu_in, out.mtu_out); - printf(" pings=%s\n", out.datacenter_pings ? out.datacenter_pings : "(none)"); printf(" msg=%s\n", out.error_message ? out.error_message : "(none)"); chiaki_cloud_provision_result_fini(&out); return e == CHIAKI_ERR_SUCCESS ? 0 : 1; @@ -53,7 +79,6 @@ int main(int argc, char **argv) char ent[128] = "", plat[8] = ""; char *err = NULL; - // duid format matching the live flow: "0000000700410080" + 32 hex chars. char duid[64] = "00000007004100800123456789abcdef0123456789abcdef"; ChiakiErrorCode e = cc_kamaji_resolve(&log, &cfg, duid, ent, plat, &err); printf("\n== KAMAJI err=%d ent=%s plat=%s err=%s\n", e, ent, plat, err ? err : "(none)"); From a97ba2603c5760a7cb339e27d2ce75813321bb17 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 22:16:34 -0700 Subject: [PATCH 40/72] cloudsession: parallel datacenter ping + richer allocate progress Three loading-screen regressions vs the old Qt flow: - Datacenter pinging was sequential (one senkusha handshake after another), so "Pinging Datacenters" took far too long. Ping every datacenter on its own ChiakiThread and collect/sort after -- a full provision incl. ping is ~16s again (5 DCs pinged concurrently). Each cc_ping_datacenter owns its session/socket, so they are independent. - The step13 allocate wait showed a bare "Migrating data..." so a long data-migration (e.g. a freshly $0-acquired title provisioning a cold VM) looked frozen. Restore the old rich text -- "Migrating data (N%) - Attempt M" and "Allocating streaming slot - Queue position: P - Attempt M" -- and log each attempt at INFO so it is diagnosable. - Kamaji emitted its own "Step X of 6" progress that the old app never showed on the loading screen (Kamaji ran silently; only Gaikai's "of 10" was shown), which made the count look like it jumped 1/6 -> 1/10. Make the Kamaji phases descriptive ("Resolving game...", "Acquiring license...") so only one numbered sequence remains. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession_gaikai.c | 86 ++++++++++++++++++++++++++++++----- lib/src/cloudsession_kamaji.c | 14 +++--- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 12efaa05..ce7dad42 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -11,6 +11,7 @@ #include "curl_http.h" #include // chiaki_cloud_gaikai_language +#include // parallel datacenter ping #include @@ -581,6 +582,28 @@ static int gk_cmp_rtt(const void *a, const void *b) return cc_json_int(oa, "rtt") - cc_json_int(ob, "rtt"); } +// One datacenter's ping, run on its own thread (each cc_ping_datacenter owns its +// session/socket, so they are independent). Mirrors the Qt parallel ping -- +// sequential pinging made "Pinging Datacenters" take far too long. +typedef struct +{ + ChiakiLog *log; + const char *name, *ip; // borrowed from the datacenters json (outlives the join) + int port, bw; + const char *session_key, *service_type; + int64_t rtt_us; uint32_t mtu_in, mtu_out; bool ok; +} GkPingJob; + +static void *gk_ping_thread(void *arg) +{ + GkPingJob *j = (GkPingJob *)arg; + j->rtt_us = -1; j->mtu_in = 0; j->mtu_out = 0; + cc_ping_datacenter(j->log, j->ip, j->port, j->session_key, j->service_type, + &j->rtt_us, &j->mtu_in, &j->mtu_out); + j->ok = (j->rtt_us > 0); + return NULL; +} + // step11 datacenters + ping/select. Fills c->ping_results (sorted) + c->selected_*. static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) { @@ -620,21 +643,38 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) else { gk_progress(c, "Pinging Datacenters - Step 8 of 10"); + if(gk_cancelled(c)) { json_object_put(dcs); return CHIAKI_ERR_CANCELED; } + + // Ping every datacenter in parallel (one thread each), then collect. + GkPingJob *jobs = (GkPingJob *)calloc(n, sizeof(GkPingJob)); + ChiakiThread *threads = (ChiakiThread *)calloc(n, sizeof(ChiakiThread)); for(size_t i = 0; i < n; i++) { - if(gk_cancelled(c)) { json_object_put(dcs); return CHIAKI_ERR_CANCELED; } struct json_object *dc = json_object_array_get_idx(dcs, i); - const char *name = cc_json_str(dc, "dataCenter"); - const char *ip = cc_json_str(dc, "publicIp"); - int port = cc_json_int(dc, "port"); - int bw = cc_json_int(dc, "maxBandwidth"); - int64_t rtt_us = -1; uint32_t mi = 0, mo = 0; - cc_ping_datacenter(c->log, ip, port, c->lock_session_key, c->cfg->service_type, &rtt_us, &mi, &mo); - if(rtt_us > 0) - json_object_array_add(c->ping_results, gk_ping_obj(name, (int)(rtt_us / 1000), mi, mo, port, ip, bw, true)); + jobs[i].log = c->log; + jobs[i].name = cc_json_str(dc, "dataCenter"); + jobs[i].ip = cc_json_str(dc, "publicIp"); + jobs[i].port = cc_json_int(dc, "port"); + jobs[i].bw = cc_json_int(dc, "maxBandwidth"); + jobs[i].session_key = c->lock_session_key; + jobs[i].service_type = c->cfg->service_type; + if(chiaki_thread_create(&threads[i], gk_ping_thread, &jobs[i]) != CHIAKI_ERR_SUCCESS) + gk_ping_thread(&jobs[i]); // fall back to inline if a thread won't start + } + for(size_t i = 0; i < n; i++) + { + chiaki_thread_join(&threads[i], NULL); + if(jobs[i].ok) + json_object_array_add(c->ping_results, gk_ping_obj(jobs[i].name, (int)(jobs[i].rtt_us / 1000), + jobs[i].mtu_in, jobs[i].mtu_out, jobs[i].port, jobs[i].ip, jobs[i].bw, true)); else - json_object_array_add(c->ping_results, gk_ping_obj(name, 999, 0, 0, port, ip, bw, false)); + json_object_array_add(c->ping_results, gk_ping_obj(jobs[i].name, 999, 0, 0, jobs[i].port, jobs[i].ip, jobs[i].bw, false)); + if(jobs[i].ok) + CHIAKI_LOGI(c->log, "[GAIKAI] ping %s = %dms", jobs[i].name, (int)(jobs[i].rtt_us / 1000)); + else + CHIAKI_LOGI(c->log, "[GAIKAI] ping %s = unreachable", jobs[i].name); } + free(jobs); free(threads); // sort by RTT size_t rn = json_object_array_length(c->ping_results); struct json_object **arr = (struct json_object **)malloc(rn * sizeof(*arr)); @@ -706,7 +746,7 @@ static ChiakiErrorCode gk_step12_select(GaikaiCtx *c) static ChiakiErrorCode gk_step13_allocate(GaikaiCtx *c, ChiakiCloudProvisionResult *out) { gk_progress(c, "Allocating Streaming Slot - Step 10 of 10"); - int max_wait = DEFAULT_ALLOC_WAIT_S, elapsed = 0; + int max_wait = DEFAULT_ALLOC_WAIT_S, elapsed = 0, attempt = 0; bool wait_started = false; for(;;) { @@ -743,6 +783,7 @@ static ChiakiErrorCode gk_step13_allocate(GaikaiCtx *c, ChiakiCloudProvisionResu bool migrating = cc_json_bool(a, "dataMigration"); if(queued || migrating) { + attempt++; if(!wait_started) { wait_started = true; @@ -750,10 +791,31 @@ static ChiakiErrorCode gk_step13_allocate(GaikaiCtx *c, ChiakiCloudProvisionResu max_wait = est > 0 ? (est * 2 > MAX_ALLOC_WAIT_S ? MAX_ALLOC_WAIT_S : est * 2) : DEFAULT_ALLOC_WAIT_S; } int poll = cc_json_int(a, "pollFrequency"); if(poll <= 0) poll = 15; + // Rich progress so the user can see it is making progress (% / queue / attempt), + // matching the old Qt loading text instead of a bare "Migrating...". + char prog[160]; + if(migrating) + { + int pct = cc_json_int(a, "dataMigrationPercentageComplete"); + snprintf(prog, sizeof(prog), "Migrating data (%d%%) - Attempt %d", pct, attempt); + CHIAKI_LOGI(c->log, "[GAIKAI] allocate attempt %d: data migration %d%% (elapsed %ds/%ds, poll %ds)", + attempt, pct, elapsed, max_wait, poll); + } + else + { + int qpos = cc_json_has(a, "displayQueuePosition") ? cc_json_int(a, "displayQueuePosition") + : (cc_json_has(a, "queuePosition") ? cc_json_int(a, "queuePosition") : -1); + if(qpos >= 0) + snprintf(prog, sizeof(prog), "Allocating streaming slot - Queue position: %d - Attempt %d", qpos, attempt); + else + snprintf(prog, sizeof(prog), "Allocating streaming slot - Attempt %d", attempt); + CHIAKI_LOGI(c->log, "[GAIKAI] allocate attempt %d: queued (pos %d, elapsed %ds/%ds, poll %ds)", + attempt, qpos, elapsed, max_wait, poll); + } json_object_put(a); if(elapsed >= max_wait) { CHIAKI_LOGE(c->log, "[GAIKAI] allocation wait timeout (%ds)", max_wait); return CHIAKI_ERR_TIMEOUT; } if(poll > max_wait - elapsed) poll = max_wait - elapsed; - gk_progress(c, queued ? "Waiting in queue..." : "Migrating data..."); + gk_progress(c, prog); if(!gk_sleep_cancellable(c, poll)) return CHIAKI_ERR_CANCELED; elapsed += poll; continue; diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 37720cfe..e4ffedbe 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -169,7 +169,7 @@ static ChiakiErrorCode km_post_session(KamajiCtx *c, const char *code, bool capt static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) { - if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Connecting to PlayStation...", c->cfg->user); char url[2048]; snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" @@ -181,7 +181,7 @@ static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) static ChiakiErrorCode km_step0_5c_anon_session(KamajiCtx *c, const char *anon_code) { - if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Connecting to PlayStation...", c->cfg->user); CCHttpResponse resp = { 0 }; ChiakiErrorCode e = km_post_session(c, anon_code, true, &resp); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } @@ -242,7 +242,7 @@ static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require static ChiakiErrorCode km_step0_5d_resolve(KamajiCtx *c) { - if(c->cfg->progress) c->cfg->progress("Resolving Game - Step 2 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Resolving game...", c->cfg->user); const char *country = (c->cfg->store_country && *c->cfg->store_country) ? c->cfg->store_country : "US"; const char *lang = (c->cfg->store_lang && *c->cfg->store_lang) ? c->cfg->store_lang : "en"; char url[512]; @@ -369,7 +369,7 @@ static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_erro static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) { - if(c->cfg->progress) c->cfg->progress("Acquiring License - Step 3 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Acquiring license...", c->cfg->user); char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); char *h_cookie = NULL; @@ -450,7 +450,7 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) { - if(c->cfg->progress) c->cfg->progress("Checking License - Step 3 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Checking license...", c->cfg->user); ChiakiErrorCode e = km_get_commerce_token(c); if(e != CHIAKI_ERR_SUCCESS) return e; e = km_check_account_attributes(c, out_error); @@ -487,7 +487,7 @@ static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) { - if(c->cfg->progress) c->cfg->progress("Authorizing - Step 5 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Authorizing...", c->cfg->user); char url[2048]; snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" @@ -498,7 +498,7 @@ static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) static ChiakiErrorCode km_step6_auth_session(KamajiCtx *c, const char *auth_code) { - if(c->cfg->progress) c->cfg->progress("Creating Session - Step 6 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Creating session...", c->cfg->user); CCHttpResponse resp = { 0 }; ChiakiErrorCode e = km_post_session(c, auth_code, false, &resp); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } From a681c318c99159b08ae20e4eb0e32c2a23436291 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 22:30:48 -0700 Subject: [PATCH 41/72] cloudsession: restore the lock-retry + datacenter loading text verbatim The step10 lock retry showed a bare "Closing old session..." and step12 showed "Selecting Datacenter" without the region, dropping detail the original loading screen had. Restore them to match psgaikaistreaming.cpp exactly: - "Closing old session () - Attempt N" (event name parsed from the x-gaikai-event header JSON, attempt count from the retry counter) - "Selecting Datacenter () - Step 9 of 10" The migration / queue-position text already matched; it only shows once the allocate returns 200 + dataMigration (a 5xx during a cold-VM migration still hard-fails, same as the original). Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession_gaikai.c | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index ce7dad42..88ed8e0c 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -540,6 +540,16 @@ static ChiakiErrorCode gk_step10_lock(GaikaiCtx *c) int poll = j ? cc_json_int(j, "pollFrequency") : 10; if(poll <= 0) poll = 10; if(j) json_object_put(j); + // Event name from the x-gaikai-event header (a JSON object) -- shown in the + // "Closing old session () - Attempt N" loading text, like the original. + char event_name[64] = ""; + char *evhdr = gk_header_value(resp.headers, "x-gaikai-event"); + if(evhdr) + { + struct json_object *ev = json_tokener_parse(evhdr); + if(ev) { snprintf(event_name, sizeof(event_name), "%s", cc_json_str(ev, "name")); json_object_put(ev); } + free(evhdr); + } cc_http_response_fini(&resp); if(acquired) { @@ -549,8 +559,14 @@ static ChiakiErrorCode gk_step10_lock(GaikaiCtx *c) return CHIAKI_ERR_SUCCESS; } if(attempt == MAX_LOCK_RETRIES) break; - CHIAKI_LOGI(c->log, "[GAIKAI] lock not acquired; retry in %ds (%d/%d)", poll, attempt + 1, MAX_LOCK_RETRIES); - gk_progress(c, "Closing old session..."); + char prog[160]; + if(event_name[0]) + snprintf(prog, sizeof(prog), "Closing old session (%s) - Attempt %d", event_name, attempt + 1); + else + snprintf(prog, sizeof(prog), "Closing old session - Attempt %d", attempt + 1); + CHIAKI_LOGI(c->log, "[GAIKAI] lock not acquired (%s); retry in %ds (attempt %d/%d)", + event_name[0] ? event_name : "-", poll, attempt + 1, MAX_LOCK_RETRIES); + gk_progress(c, prog); if(!gk_sleep_cancellable(c, poll)) return CHIAKI_ERR_CANCELED; } return CHIAKI_ERR_UNKNOWN; @@ -722,7 +738,9 @@ static ChiakiErrorCode gk_step12_select(GaikaiCtx *c) int port = cc_json_int(c->selected_ping, "port"); c->selected_dc_port = port > 0 ? port : 2053; - gk_progress(c, "Selecting Datacenter - Step 9 of 10"); + char sel_prog[96]; + snprintf(sel_prog, sizeof(sel_prog), "Selecting Datacenter (%s) - Step 9 of 10", c->selected_datacenter); + gk_progress(c, sel_prog); struct json_object *extra = json_object_new_object(); json_object_object_add(extra, "pingResults", json_object_get(c->ping_results)); CCHttpResponse resp = { 0 }; From 919e25795eb168be6ca4ed4d64eb4e8b59ad3eab Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 22:45:27 -0700 Subject: [PATCH 42/72] cloudsession (qt): always return to menu on a cloud error handleProvisionError set the PS+/privacy/ping-timeout dialog flag but only emitted sessionError in the generic else branch, so those three errors left the stream/loading page up (the dialog just toasted on the streaming page and never exited). The original emitted its special signal AND AllocationError/ sessionComplete(false) -> sessionError on every path. Restore that: set the specific dialog, then always emit sessionError so the page dismisses and the main menu shows the popup. Ping-timeout keeps the original message. Co-Authored-By: Claude Opus 4.8 --- gui/src/cloudstreamingbackend.cpp | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 5f63730d..160ddb0a 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -359,25 +359,36 @@ void CloudStreamingBackend::handleProvisionError(QString serviceType, QString er qWarning() << "Cloud provisioning failed:" << errorMessage; setGameImageUrl(QString()); + // Set the specific dialog (supplementary), then ALWAYS emit sessionError so the + // stream/loading page dismisses and returns to the main menu -- the original + // emitted both its special signal AND AllocationError/sessionComplete(false) + // (which fired sessionError). Without the sessionError the page never exits and + // the dialog just toasts on the streaming page. + QString userMessage; QmlBackend *qmlBackend = qobject_cast(parent()); if (errorMessage.contains(QStringLiteral("PS_PLUS_SUBSCRIPTION_REQUIRED"))) { if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); + userMessage = tr("PlayStation Plus subscription required"); } else if (errorMessage.contains(QStringLiteral("ACCOUNT_PRIVACY_SETTINGS"))) { if (qmlBackend) { qmlBackend->setAccountPrivacyUpgradeUrl(QString()); qmlBackend->setShowAccountPrivacySettingsDialog(true); } + userMessage = tr("Account privacy settings need updating"); } else if (errorMessage.contains(QStringLiteral("PING_TIMEOUT"))) { if (qmlBackend) qmlBackend->setShowPingTimeoutDialog(true); - } else if (qmlBackend) { - emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), - errorMessage.isEmpty() ? tr("Allocation failed") - : QString("Allocation failed: %1").arg(errorMessage)); + userMessage = tr("Ping must be < 80ms to start a cloud session"); + } else { + userMessage = errorMessage.isEmpty() ? tr("Allocation failed") + : QString("Allocation failed: %1").arg(errorMessage); + } + + if (qmlBackend) { + emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), userMessage); } if (callback.isCallable()) { - callback.call({false, errorMessage.isEmpty() ? QString("Allocation failed") - : QString("Allocation failed: %1").arg(errorMessage)}); + callback.call({false, userMessage}); } setAllocationProgress(""); From 0155932d18b29842058790b0d50d938f776d7c3f Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 23:00:25 -0700 Subject: [PATCH 43/72] cloudsession: restore PSNOW Kamaji "Step X of 6" progress; drop "PlayStation" - Bring back the numbered Kamaji loading text for PSNOW (Cloud Auth - Step 1 of 6 ... Creating Session - Step 6 of 6); PSCLOUD still skips Kamaji so only Gaikai's "of 10" shows there. - No user-visible "PlayStation": the PS+ error status is now "PS Plus subscription required" and the Kamaji status strings no longer say it. (The Gaikai PSCLOUD User-Agent "PlayStation Portal" stays -- it's a required protocol header, not a log/status.) Co-Authored-By: Claude Opus 4.8 --- gui/src/cloudstreamingbackend.cpp | 2 +- lib/src/cloudsession_kamaji.c | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 160ddb0a..015a6201 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -368,7 +368,7 @@ void CloudStreamingBackend::handleProvisionError(QString serviceType, QString er QmlBackend *qmlBackend = qobject_cast(parent()); if (errorMessage.contains(QStringLiteral("PS_PLUS_SUBSCRIPTION_REQUIRED"))) { if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); - userMessage = tr("PlayStation Plus subscription required"); + userMessage = tr("PS Plus subscription required"); } else if (errorMessage.contains(QStringLiteral("ACCOUNT_PRIVACY_SETTINGS"))) { if (qmlBackend) { qmlBackend->setAccountPrivacyUpgradeUrl(QString()); diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index e4ffedbe..37720cfe 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -169,7 +169,7 @@ static ChiakiErrorCode km_post_session(KamajiCtx *c, const char *code, bool capt static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) { - if(c->cfg->progress) c->cfg->progress("Connecting to PlayStation...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); char url[2048]; snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" @@ -181,7 +181,7 @@ static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) static ChiakiErrorCode km_step0_5c_anon_session(KamajiCtx *c, const char *anon_code) { - if(c->cfg->progress) c->cfg->progress("Connecting to PlayStation...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); CCHttpResponse resp = { 0 }; ChiakiErrorCode e = km_post_session(c, anon_code, true, &resp); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } @@ -242,7 +242,7 @@ static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require static ChiakiErrorCode km_step0_5d_resolve(KamajiCtx *c) { - if(c->cfg->progress) c->cfg->progress("Resolving game...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Resolving Game - Step 2 of 6", c->cfg->user); const char *country = (c->cfg->store_country && *c->cfg->store_country) ? c->cfg->store_country : "US"; const char *lang = (c->cfg->store_lang && *c->cfg->store_lang) ? c->cfg->store_lang : "en"; char url[512]; @@ -369,7 +369,7 @@ static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_erro static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) { - if(c->cfg->progress) c->cfg->progress("Acquiring license...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Acquiring License - Step 3 of 6", c->cfg->user); char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); char *h_cookie = NULL; @@ -450,7 +450,7 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) { - if(c->cfg->progress) c->cfg->progress("Checking license...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Checking License - Step 3 of 6", c->cfg->user); ChiakiErrorCode e = km_get_commerce_token(c); if(e != CHIAKI_ERR_SUCCESS) return e; e = km_check_account_attributes(c, out_error); @@ -487,7 +487,7 @@ static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) { - if(c->cfg->progress) c->cfg->progress("Authorizing...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Authorizing - Step 5 of 6", c->cfg->user); char url[2048]; snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" @@ -498,7 +498,7 @@ static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) static ChiakiErrorCode km_step6_auth_session(KamajiCtx *c, const char *auth_code) { - if(c->cfg->progress) c->cfg->progress("Creating session...", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Creating Session - Step 6 of 6", c->cfg->user); CCHttpResponse resp = { 0 }; ChiakiErrorCode e = km_post_session(c, auth_code, false, &resp); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } From 6879b9f4e5c320c5e17829e95eee012a414f2f07 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 23:08:55 -0700 Subject: [PATCH 44/72] cloudsession (qt): restore streaming-language catalog-locale fallback Parity fix from the review: the original psgaikaistreaming.cpp fell back to GetCloudStoreLocale() when the manual language picker (GetCloudGameLanguage()) was empty. The port passed only the picker value, so a default-picker user in a non-English store region silently streamed with language "en". Restore the fallback before snapshotting it for the worker. Co-Authored-By: Claude Opus 4.8 --- gui/src/cloudstreamingbackend.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 015a6201..ba33a844 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -132,7 +132,12 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q const QByteArray npsso = npssoToken.toUtf8(); const QByteArray storeCountry = settings->GetCloudResolvedStoreCountry().toUtf8(); const QByteArray storeLang = settings->GetCloudResolvedStoreLang().toUtf8(); - const QByteArray gameLang = settings->GetCloudGameLanguage().toUtf8(); + // Streaming language: manual picker, else fall back to the auto-detected catalog + // locale (matches psgaikaistreaming.cpp) so non-English regions don't silently get "en". + QString gameLangStr = settings->GetCloudGameLanguage(); + if (gameLangStr.isEmpty()) + gameLangStr = settings->GetCloudStoreLocale(); + const QByteArray gameLang = gameLangStr.toUtf8(); const QByteArray forcedDc = (pscloud ? settings->GetCloudDatacenterPSCloud() : settings->GetCloudDatacenterPSNOW()).toUtf8(); const int resolution = pscloud ? settings->GetCloudResolutionPSCloud() From 605641f4e856014850ef46eaa8093cbda8f17166 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 23:29:40 -0700 Subject: [PATCH 45/72] cloudsession: build the Sony account-privacy upgrade URL (parity) Restore the last review gap: the original parsed the missing privacy elements (error.validationErrors[].missingElements[].name) from the failed account-attributes response and built the id.sonyentertainmentnetwork.com upgrade_account_ca URL for the dialog. Port that into km_build_privacy_sentinel (percent-encoding redirect_uri + missing_elements), emit it as "ACCOUNT_PRIVACY_SETTINGS:", and have handleProvisionError extract the URL into setAccountPrivacyUpgradeUrl. Falls back to the bare sentinel (empty URL) when no elements are present, as before. Co-Authored-By: Claude Opus 4.8 --- gui/src/cloudstreamingbackend.cpp | 9 ++++- lib/src/cloudsession_kamaji.c | 63 ++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index ba33a844..d8d61a06 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -375,8 +375,15 @@ void CloudStreamingBackend::handleProvisionError(QString serviceType, QString er if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); userMessage = tr("PS Plus subscription required"); } else if (errorMessage.contains(QStringLiteral("ACCOUNT_PRIVACY_SETTINGS"))) { + // Sentinel is "ACCOUNT_PRIVACY_SETTINGS:" (URL omitted when no + // missing elements were parsed). Extract the URL for the dialog. + const QString prefix = QStringLiteral("ACCOUNT_PRIVACY_SETTINGS:"); + QString upgradeUrl; + int idx = errorMessage.indexOf(prefix); + if (idx >= 0) + upgradeUrl = errorMessage.mid(idx + prefix.length()); if (qmlBackend) { - qmlBackend->setAccountPrivacyUpgradeUrl(QString()); + qmlBackend->setAccountPrivacyUpgradeUrl(upgradeUrl); qmlBackend->setShowAccountPrivacySettingsDialog(true); } userMessage = tr("Account privacy settings need updating"); diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 37720cfe..dfa12149 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -342,6 +342,59 @@ static ChiakiErrorCode km_get_commerce_token(KamajiCtx *c) return CHIAKI_ERR_SUCCESS; } +// Percent-encode per RFC 3986 (unreserved A-Za-z0-9-_.~ left as-is). +static void km_urlencode(const char *in, char *out, size_t out_size) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t o = 0; + for(const unsigned char *p = (const unsigned char *)in; *p && o + 4 < out_size; p++) + { + if((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || + (*p >= '0' && *p <= '9') || *p == '-' || *p == '_' || *p == '.' || *p == '~') + out[o++] = (char)*p; + else { out[o++] = '%'; out[o++] = hex[*p >> 4]; out[o++] = hex[*p & 0xF]; } + } + out[o] = '\0'; +} + +// On an attributes failure, build the Sony "upgrade account" URL from the missing +// privacy elements (error.validationErrors[].missingElements[].name), surfaced as +// the "ACCOUNT_PRIVACY_SETTINGS:" sentinel -- the platform opens it so the user +// can complete the required settings. Mirrors pskamajisession.cpp:830-882. +static char *km_build_privacy_sentinel(struct json_object *body) +{ + char elements[512] = ""; + struct json_object *err = body ? cc_json_obj(body, "error") : NULL; + struct json_object *ve = err ? cc_json_arr(err, "validationErrors") : NULL; + if(ve) for(size_t i = 0; i < json_object_array_length(ve); i++) + { + struct json_object *me = cc_json_arr(json_object_array_get_idx(ve, i), "missingElements"); + if(!me) continue; + for(size_t k = 0; k < json_object_array_length(me); k++) + { + const char *name = cc_json_str(json_object_array_get_idx(me, k), "name"); + if(name && *name) + { + if(elements[0]) strncat(elements, ",", sizeof(elements) - strlen(elements) - 1); + strncat(elements, name, sizeof(elements) - strlen(elements) - 1); + } + } + } + if(!elements[0]) return strdup("ACCOUNT_PRIVACY_SETTINGS"); + char enc_redir[256], enc_elem[768], url[2048]; + km_urlencode(KM_REDIRECT_URI, enc_redir, sizeof(enc_redir)); + km_urlencode(elements, enc_elem, sizeof(enc_elem)); + snprintf(url, sizeof(url), + "ACCOUNT_PRIVACY_SETTINGS:https://id.sonyentertainmentnetwork.com/id/upgrade_account_ca/" + "?entry=upgrade_account&pr_referer=upgrade&redirect_uri=%s&applicationId=psnow&refererPage=websso" + "&service_logo=ps&tp_console=true&disableLinks=SENLink&renderMode=mobilePortrait&noEVBlock=true" + "&displayFooter=none&hidePageElements=SENLogo&layout_type=popup&missing_elements=%s&response_type=code" + "&service_entity=urn:service-entity:psn&smcid=pc:psnow&tp_psn=true&tp_social=true" + "&elements_visibility_upgrade=no_cancel", + enc_redir, enc_elem); + return strdup(url); +} + static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_error) { if(c->cfg->skip_account_attr_check) return CHIAKI_ERR_SUCCESS; @@ -360,9 +413,15 @@ static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_erro free(h_auth); free(h_ua); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } if(resp.status_code == 200 || resp.status_code == 204) { cc_http_response_fini(&resp); return CHIAKI_ERR_SUCCESS; } - // Privacy/account upgrade required: surface a sentinel the platform turns into the upgrade dialog. + // Privacy/account upgrade required: build the upgrade URL from the missing + // elements and surface it as the sentinel the platform turns into the dialog. CHIAKI_LOGE(c->log, "[KAMAJI] account attributes failed (%ld)", resp.status_code); - if(out_error) *out_error = strdup("ACCOUNT_PRIVACY_SETTINGS"); + if(out_error) + { + struct json_object *j = resp.data ? json_tokener_parse(resp.data) : NULL; + *out_error = km_build_privacy_sentinel(j); + if(j) json_object_put(j); + } cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; } From a67c5095f0504988c1968d01218c09537db7dde6 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Sun, 28 Jun 2026 23:57:18 -0700 Subject: [PATCH 46/72] cloudsession (ios): wire iOS to chiaki_cloud_provision_session + json-c fix iOS port mirroring the Qt one: - CloudProvisionBridge.{h,m}: Obj-C bridge over chiaki_cloud_provision_session (mirrors CloudCatalogBridge) -- one blocking call returning a stream-ready result, with progress/isCancelled blocks. Added to ChiakiBridge.h (the umbrella bridging header) and to the Xcode project. - CloudStreamingBackend.swift: continueCloudSessionAfterAuth now calls the bridge instead of the Swift PSKamajiSession/PSGaikaiStreaming; reads settings from StreamPreferences/SecureStore (incl. the gameLanguage->store-locale fallback), maps the C error_message sentinels to PsPlusSubscriptionError/PingTimeoutError/ GaikaiAllocationError. The owned fast-path + one-shot fallback now live in C. Auth check + DUID gen stay in Swift. Lib fix (also unblocks Android): cloudsession_{kamaji,gaikai}.c included the umbrella , which the iOS/Android FetchContent json-c build does not provide (only homebrew does). Drop it -- the specific json-c headers already come via cloudcatalog_internal.h. macOS still 105/105. iOS app builds + launches on the simulator; the old Swift classes are now unused (deleted across platforms in the final cleanup pass). Co-Authored-By: Claude Opus 4.8 --- ios/Pylux.xcodeproj/project.pbxproj | 6 + ios/Pylux/Bridge/ChiakiBridge.h | 1 + ios/Pylux/Bridge/CloudProvisionBridge.h | 51 ++++++ ios/Pylux/Bridge/CloudProvisionBridge.m | 114 ++++++++++++ .../Services/CloudStreamingBackend.swift | 172 ++++++------------ lib/src/cloudsession_gaikai.c | 4 +- lib/src/cloudsession_kamaji.c | 4 +- 7 files changed, 236 insertions(+), 116 deletions(-) create mode 100644 ios/Pylux/Bridge/CloudProvisionBridge.h create mode 100644 ios/Pylux/Bridge/CloudProvisionBridge.m diff --git a/ios/Pylux.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index ee12e40d..7f291251 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A1000110 /* DiscoveryBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000101 /* DiscoveryBridge.m */; }; A1000111 /* RegistBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000103 /* RegistBridge.m */; }; A10001C0 /* CloudCatalogBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A10001C2 /* CloudCatalogBridge.m */; }; + A10001D0 /* CloudProvisionBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A10001D2 /* CloudProvisionBridge.m */; }; A1000112 /* HostModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000104 /* HostModels.swift */; }; A1000113 /* HostCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000105 /* HostCardView.swift */; }; A1000114 /* RemotePlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000106 /* RemotePlayView.swift */; }; @@ -93,6 +94,8 @@ A1000103 /* RegistBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegistBridge.m; sourceTree = ""; }; A10001C1 /* CloudCatalogBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CloudCatalogBridge.h; sourceTree = ""; }; A10001C2 /* CloudCatalogBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CloudCatalogBridge.m; sourceTree = ""; }; + A10001D1 /* CloudProvisionBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CloudProvisionBridge.h; sourceTree = ""; }; + A10001D2 /* CloudProvisionBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CloudProvisionBridge.m; sourceTree = ""; }; A1000104 /* HostModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostModels.swift; sourceTree = ""; }; A1000105 /* HostCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostCardView.swift; sourceTree = ""; }; A1000106 /* RemotePlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePlayView.swift; sourceTree = ""; }; @@ -218,6 +221,8 @@ A1000103 /* RegistBridge.m */, A10001C1 /* CloudCatalogBridge.h */, A10001C2 /* CloudCatalogBridge.m */, + A10001D1 /* CloudProvisionBridge.h */, + A10001D2 /* CloudProvisionBridge.m */, A1000024 /* SessionEventReceiver.h */, A1000023 /* SessionEventReceiver.m */, A1000022 /* VideoDecoder.h */, @@ -395,6 +400,7 @@ A1000110 /* DiscoveryBridge.m in Sources */, A1000111 /* RegistBridge.m in Sources */, A10001C0 /* CloudCatalogBridge.m in Sources */, + A10001D0 /* CloudProvisionBridge.m in Sources */, A1000112 /* HostModels.swift in Sources */, A1000113 /* HostCardView.swift in Sources */, A1000114 /* RemotePlayView.swift in Sources */, diff --git a/ios/Pylux/Bridge/ChiakiBridge.h b/ios/Pylux/Bridge/ChiakiBridge.h index 8570aeec..3bb69e47 100644 --- a/ios/Pylux/Bridge/ChiakiBridge.h +++ b/ios/Pylux/Bridge/ChiakiBridge.h @@ -13,6 +13,7 @@ #import "HolepunchBridge.h" #import "ChiakiDatacenterPing.h" #import "CloudCatalogBridge.h" +#import "CloudProvisionBridge.h" /// Returns a string from the Chiaki library (e.g. "Success" from chiaki_error_string). /// Used to verify the app is correctly linked to the Chiaki library. diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.h b/ios/Pylux/Bridge/CloudProvisionBridge.h new file mode 100644 index 00000000..9670687d --- /dev/null +++ b/ios/Pylux/Bridge/CloudProvisionBridge.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Swift bridge for the unified cloud session-provisioning flow (libchiaki +// chiaki_cloud_provision_session). Mirrors CloudCatalogBridge: one blocking +// class method that runs the whole Kamaji+Gaikai flow in C and returns a +// stream-ready result. Call it off the main thread. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PyluxCloudProvisionResult : NSObject +@property (nonatomic) int err; // 0 == success +@property (nonatomic, copy) NSString *serverIp; +@property (nonatomic) int serverPort; +@property (nonatomic, copy) NSString *handshakeKey; +@property (nonatomic, copy) NSString *launchSpec; +@property (nonatomic, copy) NSString *sessionId; +@property (nonatomic, copy) NSString *entitlementId; // the entitlement actually streamed +@property (nonatomic, copy) NSString *platform; // ps3|ps4|ps5 +@property (nonatomic) int psnWrapperType; +@property (nonatomic) int mtuIn; +@property (nonatomic) int mtuOut; +@property (nonatomic) int rttMs; +@property (nonatomic, copy, nullable) NSString *datacenterPings; // JSON, for Settings +@property (nonatomic, copy, nullable) NSString *errorMessage; // sentinels on failure +@end + +@interface PyluxCloudProvision : NSObject + +/// Run the full provisioning flow (blocking — call from a background queue). +/// @c onProgress / @c isCancelled are invoked on the calling thread. ++ (PyluxCloudProvisionResult *)provisionWithServiceType:(NSString *)serviceType + gameIdentifier:(NSString *)gameIdentifier + gameName:(NSString *)gameName + npsso:(NSString *)npsso + storeCountry:(NSString *)storeCountry + storeLang:(NSString *)storeLang + gameLanguage:(NSString *)gameLanguage + ownedEntitlementId:(NSString *)ownedEntitlementId + ownedPlatform:(NSString *)ownedPlatform + forcedDatacenter:(NSString *)forcedDatacenter + catalogIsForeign:(BOOL)catalogIsForeign + resolution:(int)resolution + bitrateKbps:(int)bitrateKbps + onProgress:(nullable void (^)(NSString *stage))onProgress + isCancelled:(nullable BOOL (^)(void))isCancelled; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.m b/ios/Pylux/Bridge/CloudProvisionBridge.m new file mode 100644 index 00000000..6eba7d4e --- /dev/null +++ b/ios/Pylux/Bridge/CloudProvisionBridge.m @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL + +#import "CloudProvisionBridge.h" +#import "PyluxChiakiLog.h" +#include +#include +#include + +static os_log_t s_cp_log; + +static void cp_log_cb(ChiakiLogLevel level, const char *msg, void *user) { + (void)user; + os_log_type_t type = (level == CHIAKI_LOG_ERROR) ? OS_LOG_TYPE_ERROR : OS_LOG_TYPE_DEFAULT; + os_log_with_type(s_cp_log, type, "[CloudProvision] %{public}s", msg ? msg : ""); +} + +// Callbacks reach the Obj-C blocks via cfg.user. The provision call is synchronous +// and only ever calls these from the calling thread, so unretained refs to blocks +// that outlive the call are safe. +typedef struct { + __unsafe_unretained void (^onProgress)(NSString *); + __unsafe_unretained BOOL (^isCancelled)(void); +} CPCallbacks; + +static void cp_progress(const char *stage, void *user) { + CPCallbacks *cb = (CPCallbacks *)user; + if (cb && cb->onProgress && stage) + cb->onProgress([NSString stringWithUTF8String:stage]); +} + +static bool cp_is_cancelled(void *user) { + CPCallbacks *cb = (CPCallbacks *)user; + return (cb && cb->isCancelled) ? (cb->isCancelled() ? true : false) : false; +} + +@implementation PyluxCloudProvisionResult +@end + +@implementation PyluxCloudProvision + ++ (void)initialize { + if (self == [PyluxCloudProvision class]) { + s_cp_log = os_log_create("com.pylux.stream", "CloudProvisionLib"); + } +} + ++ (PyluxCloudProvisionResult *)provisionWithServiceType:(NSString *)serviceType + gameIdentifier:(NSString *)gameIdentifier + gameName:(NSString *)gameName + npsso:(NSString *)npsso + storeCountry:(NSString *)storeCountry + storeLang:(NSString *)storeLang + gameLanguage:(NSString *)gameLanguage + ownedEntitlementId:(NSString *)ownedEntitlementId + ownedPlatform:(NSString *)ownedPlatform + forcedDatacenter:(NSString *)forcedDatacenter + catalogIsForeign:(BOOL)catalogIsForeign + resolution:(int)resolution + bitrateKbps:(int)bitrateKbps + onProgress:(void (^)(NSString *))onProgress + isCancelled:(BOOL (^)(void))isCancelled { + ChiakiLog log; + pylux_chiaki_log_init(&log, cp_log_cb, NULL); + + CPCallbacks cb; + cb.onProgress = onProgress; + cb.isCancelled = isCancelled; + + ChiakiCloudProvisionConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.service_type = serviceType.UTF8String; + cfg.game_identifier = gameIdentifier.UTF8String; + cfg.game_name = gameName.UTF8String; + cfg.npsso = npsso.UTF8String; + cfg.store_country = storeCountry.UTF8String; + cfg.store_lang = storeLang.UTF8String; + cfg.game_language = gameLanguage.UTF8String; + cfg.owned_entitlement_id = ownedEntitlementId.UTF8String; + cfg.owned_platform = ownedPlatform.UTF8String; + cfg.forced_datacenter = forcedDatacenter.UTF8String; + cfg.cache_dir = ""; + cfg.catalog_is_foreign = catalogIsForeign ? true : false; + cfg.skip_account_attr_check = false; // iOS has no "ignore forever" flag + cfg.resolution = resolution; + cfg.bitrate_kbps = bitrateKbps; + cfg.progress = cp_progress; + cfg.is_cancelled = cp_is_cancelled; + cfg.user = &cb; + + ChiakiCloudProvisionResult res; + memset(&res, 0, sizeof(res)); + ChiakiErrorCode err = chiaki_cloud_provision_session(&cfg, &res, &log); + + PyluxCloudProvisionResult *out = [PyluxCloudProvisionResult new]; + out.err = (int)err; + out.serverIp = res.server_ip[0] ? [NSString stringWithUTF8String:res.server_ip] : @""; + out.serverPort = res.server_port; + out.handshakeKey = res.handshake_key ? [NSString stringWithUTF8String:res.handshake_key] : @""; + out.launchSpec = res.launch_spec ? [NSString stringWithUTF8String:res.launch_spec] : @""; + out.sessionId = res.session_id ? [NSString stringWithUTF8String:res.session_id] : @""; + out.entitlementId = res.entitlement_id[0] ? [NSString stringWithUTF8String:res.entitlement_id] : @""; + out.platform = res.platform[0] ? [NSString stringWithUTF8String:res.platform] : @""; + out.psnWrapperType = res.psn_wrapper_type; + out.mtuIn = (int)res.mtu_in; + out.mtuOut = (int)res.mtu_out; + out.rttMs = (int)(res.rtt_us / 1000); + out.datacenterPings = res.datacenter_pings ? [NSString stringWithUTF8String:res.datacenter_pings] : nil; + out.errorMessage = res.error_message ? [NSString stringWithUTF8String:res.error_message] : nil; + + chiaki_cloud_provision_result_fini(&res); + return out; +} + +@end diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index 1368783f..d7b7a876 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -66,15 +66,11 @@ final class CloudStreamingBackend { ) } - /// True when a Gaikai allocation error means "the entitlement we streamed isn't valid/owned" - /// (Gaikai's session-start reports {"name":"noGameForEntitlementId",...}). Signals the owned - /// fast-path guessed wrong and we should retry with the full resolve/acquire flow. - private func isEntitlementRejectedError(_ message: String) -> Bool { - message.range(of: "noGameForEntitlement", options: .caseInsensitive) != nil - } - - // MARK: - Continue After Auth + // MARK: - Continue After Auth (unified libchiaki provisioning flow) + /// Runs the entire Kamaji+Gaikai flow in libchiaki (chiaki_cloud_provision_session via + /// PyluxCloudProvision). The owned fast-path and the one-shot noGameForEntitlementId + /// fallback now live in C, so this just marshals settings in and the result/errors out. private func continueCloudSessionAfterAuth( serviceType: String, gameIdentifier: String, @@ -83,119 +79,71 @@ final class CloudStreamingBackend { sharedDuid: String, ownedEntitlementId: String = "", ownedPlatform: String = "", - forceFullEntitlementFlow: Bool = false, // true on the one-shot fallback retry (disables fast-path) onProgress: ((String) -> Void)?, isCancelled: @escaping () -> Bool ) throws -> CloudStreamSession { - let redirectUri: String - let userAgent: String - - if serviceType == "pscloud" { - redirectUri = CloudApiConstants.gaikaiRedirectUri - userAgent = CloudApiConstants.gaikaiUserAgent - } else { - redirectUri = CloudApiConstants.kamajiRedirectUri - userAgent = CloudApiConstants.kamajiUserAgent - } - - let initialPlatform = serviceType == "pscloud" ? "ps5" : "ps4" - var finalEntitlementId = gameIdentifier - var finalPlatform = initialPlatform - var usedFastPath = false - - // For PSNOW: Kamaji session (converts productId -> entitlementId) - // For PSCLOUD: Skip Kamaji entirely - if serviceType == "psnow" { - os_log(.info, log: cloudLog, "=== PSNOW Flow: Starting Kamaji Session ===") - let kamajiSession = PSKamajiSession( - duid: sharedDuid, - productId: gameIdentifier, - accountBaseUrl: CloudApiConstants.accountBase, - redirectUri: redirectUri, - userAgent: userAgent - ) - // Owned-PSNOW fast-path: if the catalog already resolved this title's streaming - // entitlement (owned), hand it to Kamaji so it skips the resolve/acquire path. - // Disabled on the fallback retry (forceFullEntitlementFlow). - if !forceFullEntitlementFlow && !ownedEntitlementId.isEmpty { - os_log(.info, log: cloudLog, "PSNOW owned fast-path: catalog entitlementId=%{public}s platform=%{public}s", - ownedEntitlementId, ownedPlatform) - kamajiSession.setOwnedEntitlementFastPath(ownedEntitlementId: ownedEntitlementId, ownedPlatform: ownedPlatform) - } else if forceFullEntitlementFlow { - os_log(.info, log: cloudLog, "PSNOW: forcing full entitlement flow (fast-path retry fallback)") - } - let kamajiResult = kamajiSession.startSessionCreation(npssoToken: npssoToken) - usedFastPath = kamajiSession.usedEntitlementFastPath - guard kamajiResult.success else { - throw KamajiSessionError(message: "Kamaji session failed: \(kamajiResult.message)") - } - finalEntitlementId = kamajiResult.entitlementId - finalPlatform = kamajiResult.platform - os_log(.info, log: cloudLog, "✓ Kamaji: entitlement=%{public}s platform=%{public}s", - finalEntitlementId, finalPlatform) - } else { - os_log(.info, log: cloudLog, "=== PSCLOUD Flow: Skipping Kamaji ===") - } - - // Gaikai allocation (Steps 0-13) - os_log(.info, log: cloudLog, "=== Starting Gaikai Allocation ===") - let gaikai = PSGaikaiStreaming( - duid: sharedDuid, - serviceType: serviceType, - platform: finalPlatform, - npssoToken: npssoToken, - onProgress: onProgress, - isCancelled: isCancelled + let prefs = StreamPreferences.load() + let pscloud = serviceType == "pscloud" + + // Streaming language: manual picker, else the detected catalog locale. + let gameLanguage: String = { + let l = prefs.cloudGameLanguage + return l.isEmpty ? CloudLocaleSettings.stored : l + }() + let forcedDatacenter = pscloud ? prefs.cloudDatacenterPscloud : prefs.cloudDatacenterPsnow + let resolution = Int32(Int(pscloud ? prefs.cloudResolutionPscloud : prefs.cloudResolutionPsnow) ?? 1080) + let bitrate = Int32(StreamPreferences.clampCloudBitrateKbps(pscloud ? prefs.cloudBitratePscloud : prefs.cloudBitratePsnow)) + + // sharedDuid is only the auth-check DUID; the C flow generates its own shared one. + _ = sharedDuid + + let result = PyluxCloudProvision.provision( + withServiceType: serviceType, + gameIdentifier: gameIdentifier, + gameName: gameName, + npsso: npssoToken, + storeCountry: SecureStore.shared.cloudResolvedStoreCountry, + storeLang: SecureStore.shared.cloudResolvedStoreLang, + gameLanguage: gameLanguage, + ownedEntitlementId: ownedEntitlementId, + ownedPlatform: ownedPlatform, + forcedDatacenter: forcedDatacenter, + catalogIsForeign: SecureStore.shared.isCloudCatalogIsForeign, + resolution: resolution, + bitrateKbps: bitrate, + onProgress: { stage in onProgress?(stage) }, + isCancelled: { isCancelled() } ) - // Owned fast-path fallback: if Gaikai rejects a catalog entitlement (it isn't actually - // valid/owned), retry exactly once via the full resolve/acquire flow. One shot only -- - // forceFullEntitlementFlow disables the fast-path on the retry -- so it can never loop. - // Gaikai reports this both by throwing GaikaiAllocationError (session start) and via a - // success=false result, so handle both. - func retryFullFlow() throws -> CloudStreamSession { - os_log(.error, log: cloudLog, "Owned fast-path entitlement rejected by Gaikai; retrying once with the full entitlement flow") - return try continueCloudSessionAfterAuth( - serviceType: serviceType, gameIdentifier: gameIdentifier, gameName: gameName, - npssoToken: npssoToken, sharedDuid: sharedDuid, - ownedEntitlementId: "", ownedPlatform: "", forceFullEntitlementFlow: true, - onProgress: onProgress, isCancelled: isCancelled + if result.err == 0 { + os_log(.info, log: cloudLog, "✓ Cloud provisioning complete - Server: %{public}s", result.serverIp) + return CloudStreamSession( + serverIp: result.serverIp, + serverPort: Int(result.serverPort), + handshakeKey: result.handshakeKey, + launchSpec: result.launchSpec, + sessionId: result.sessionId, + entitlementId: result.entitlementId, + gameName: gameName, + platform: result.platform, + psnWrapperType: Int(result.psnWrapperType), + mtuIn: Int(result.mtuIn), + mtuOut: Int(result.mtuOut), + rttMs: Int(result.rttMs), + serviceType: serviceType ) } - let allocationResult: GaikaiAllocationResult - do { - allocationResult = try gaikai.startAllocationFlow(entitlementId: finalEntitlementId) - } catch let error as GaikaiAllocationError { - if usedFastPath && !forceFullEntitlementFlow && isEntitlementRejectedError(error.message) { - return try retryFullFlow() - } - throw error - } - guard allocationResult.success else { - if usedFastPath && !forceFullEntitlementFlow && isEntitlementRejectedError(allocationResult.message) { - return try retryFullFlow() - } - throw GaikaiAllocationError(message: "Gaikai allocation failed: \(allocationResult.message)") + // Map the C error_message sentinels to the error types CloudPlayView catches. + let msg = result.errorMessage ?? "Allocation failed" + os_log(.error, log: cloudLog, "Cloud provisioning failed: %{public}s", msg) + if msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") { + throw PsPlusSubscriptionError(message: "PS Plus subscription required") + } else if msg.contains("PING_TIMEOUT") { + throw PingTimeoutError() + } else { + throw GaikaiAllocationError(message: msg) } - - os_log(.info, log: cloudLog, "✓ Gaikai allocation complete - Server: %{public}s", allocationResult.serverIp) - - return CloudStreamSession( - serverIp: allocationResult.serverIp, - serverPort: allocationResult.serverPort, - handshakeKey: allocationResult.handshakeKey, - launchSpec: allocationResult.launchSpec, - sessionId: allocationResult.sessionId, - entitlementId: finalEntitlementId, - gameName: gameName, - platform: finalPlatform, - psnWrapperType: allocationResult.psnWrapperType, - mtuIn: allocationResult.mtuIn, - mtuOut: allocationResult.mtuOut, - rttMs: allocationResult.rttMs, - serviceType: serviceType - ) } // MARK: - Authorization Check (matches Qt lines 543-613) diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 88ed8e0c..698da3c9 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -12,8 +12,8 @@ #include // chiaki_cloud_gaikai_language #include // parallel datacenter ping - -#include +// json-c: the specific headers come via cloudcatalog_internal.h (the umbrella +// is not present in the iOS/Android FetchContent build). #include #include diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index dfa12149..2ea005e4 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -13,8 +13,8 @@ #include "cloudsession_internal.h" #include "cloudcatalog_internal.h" // cc_json_* helpers #include "curl_http.h" - -#include +// json-c: the specific headers come via cloudcatalog_internal.h (the umbrella +// is not present in the iOS/Android FetchContent build). #include #include From 3e9956f1167fc47195cc487cc08244003cb4aff2 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 00:20:22 -0700 Subject: [PATCH 47/72] cloudsession: surface "Selected datacenter not available" message When a settings-forced datacenter isn't in the title's returned list, the flow correctly errored (matching the original) but only as a generic "Allocation failed". Set out->error_message to "Selected datacenter '' not available" (as the original psgaikaistreaming did) so the platform shows why -- the user can switch to Auto or an available region. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession_gaikai.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 698da3c9..df114f1a 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -59,6 +59,7 @@ typedef struct char selected_datacenter[128]; int selected_dc_port; bool ping_timeout; // best measured RTT exceeded the auto-select gate (>80ms) + bool forced_dc_unavailable; // settings-forced datacenter not in this title's list } GaikaiCtx; static void gk_progress(GaikaiCtx *c, const char *stage) @@ -650,7 +651,7 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) struct json_object *dc = json_object_array_get_idx(dcs, i); if(strcmp(cc_json_str(dc, "dataCenter"), forced) == 0) { match = dc; break; } } - if(!match) { json_object_put(dcs); CHIAKI_LOGE(c->log, "[GAIKAI] forced datacenter '%s' not available", forced); return CHIAKI_ERR_UNKNOWN; } + if(!match) { c->forced_dc_unavailable = true; json_object_put(dcs); CHIAKI_LOGE(c->log, "[GAIKAI] forced datacenter '%s' not available", forced); return CHIAKI_ERR_UNKNOWN; } // dummy ping (RTT 20, MTU 1454/1254); bypass pinging entirely. json_object_array_add(c->ping_results, gk_ping_obj(forced, 20, 1454, 1254, cc_json_int(match, "port"), cc_json_str(match, "publicIp"), cc_json_int(match, "maxBandwidth"), true)); @@ -906,6 +907,13 @@ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, out->error_message = strdup("PS_PLUS_SUBSCRIPTION_REQUIRED"); if(c.ping_timeout && !out->error_message) out->error_message = strdup("PING_TIMEOUT"); + if(c.forced_dc_unavailable && !out->error_message) + { + char m[128]; + snprintf(m, sizeof(m), "Selected datacenter '%s' not available", + cfg->forced_datacenter ? cfg->forced_datacenter : ""); + out->error_message = strdup(m); + } free(c.config_key); free(c.lock_session_key); free(c.gaikai_session_id); free(c.gk_cloud_auth_code); free(c.ps3_auth_code); free(c.stream_server_auth_code); From ea019128509f20ff78905d2e34ce4aa2de6f0d5f Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 00:57:45 -0700 Subject: [PATCH 48/72] cloudsession: produce the full merged datacenter list for the Settings picker result.datacenter_pings was just this run's measured rows; the platforms need the full datacenter list with previously-measured RTTs preserved (so the Settings picker repopulates after a stream, like the old per-platform code did). Centralize the merge in the lib (DRY): add cfg.prior_datacenters_json (the platform's currently-stored list) and emit a 3-way merge over the API list -- this run's measurement wins, else the prior stored RTT, else a 0 placeholder. Auto mode -> all fresh; forced mode -> the selected gets the dummy and the others keep their prior measured RTTs (mirrors the old mergeDatacentersWithExisting / datacenterRowsForManualStore). Verified live: forced laxa + prior(sjca=11, seaa=22) -> laxa=20(dummy), sjca=11, seaa=22, others=0 placeholder. Each platform now just passes its stored JSON in and saves result.datacenter_pings back out (wiring in following commits). Probe gains PRIOR_DC + pings print. Co-Authored-By: Claude Opus 4.8 --- lib/include/chiaki/cloudsession.h | 3 ++ lib/src/cloudsession_gaikai.c | 53 ++++++++++++++++++++-- lib/test_cloudsession/cloudsession-probe.c | 2 + 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index b88e3785..172da85b 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -45,6 +45,9 @@ typedef struct chiaki_cloud_provision_config_t bool catalog_is_foreign; /**< fallback-region account: skip the $0 acquire on 404 */ bool skip_account_attr_check; /**< platform already passed (or user ignored) the privacy check */ const char *forced_datacenter; /**< settings-selected region; "Auto"/"" => ping & auto-pick */ + const char *prior_datacenters_json; /**< platform's stored datacenters for this service; merged + with this run's pings into result.datacenter_pings (keeps + previously-measured RTTs). May be NULL/"". */ const char *cache_dir; /**< lib-owned datacenter-ping cache lives here; may be "" */ const char *game_language; /**< streaming-language locale (e.g. "de-DE"); bare code derived */ int resolution; /**< 720|1080|1440|2160 (platform picks the per-service value) */ diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index df114f1a..f7aaf5e1 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -54,7 +54,8 @@ typedef struct char *ps3_auth_code; char *stream_server_auth_code; struct json_object *spec; // requestGameSpecification (auth codes patched after 8b) - struct json_object *ping_results; // sorted array (also returned to caller) + struct json_object *ping_results; // sorted array (this run's measurements; used for select/allocate) + struct json_object *dc_picker; // full datacenter list for the Settings picker (merged) struct json_object *selected_ping; // borrowed ref into ping_results char selected_datacenter[128]; int selected_dc_port; @@ -621,6 +622,46 @@ static void *gk_ping_thread(void *arg) return NULL; } +// Find a row by dataCenter name in a json array (borrowed ref), or NULL. +static struct json_object *gk_find_dc(struct json_object *arr, const char *name) +{ + if(!arr || json_object_get_type(arr) != json_type_array) return NULL; + size_t n = json_object_array_length(arr); + for(size_t i = 0; i < n; i++) + { + struct json_object *row = json_object_array_get_idx(arr, i); + if(strcmp(cc_json_str(row, "dataCenter"), name) == 0) return row; + } + return NULL; +} + +// Build the full datacenter list for the Settings picker. Three-way merge over the +// API list (@p dcs_api): this run's measurement wins, else the platform's prior +// stored RTT (cfg->prior_datacenters_json), else a 0 placeholder. Mirrors the old +// per-platform merge (keeps previously-measured RTTs for datacenters not pinged +// this run, e.g. the non-selected ones in forced-datacenter mode). +static struct json_object *gk_build_picker(GaikaiCtx *c, struct json_object *dcs_api) +{ + struct json_object *prior = (c->cfg->prior_datacenters_json && *c->cfg->prior_datacenters_json) + ? json_tokener_parse(c->cfg->prior_datacenters_json) : NULL; + struct json_object *out = json_object_new_array(); + size_t n = json_object_array_length(dcs_api); + for(size_t i = 0; i < n; i++) + { + struct json_object *dc = json_object_array_get_idx(dcs_api, i); + const char *name = cc_json_str(dc, "dataCenter"); + struct json_object *row = gk_find_dc(c->ping_results, name); // this run wins + if(!row) row = gk_find_dc(prior, name); // else prior measured + if(row) + json_object_array_add(out, cc_json_clone(row)); + else // else 0 placeholder + json_object_array_add(out, gk_ping_obj(name, 0, 0, 0, + cc_json_int(dc, "port"), cc_json_str(dc, "publicIp"), cc_json_int(dc, "maxBandwidth"), false)); + } + if(prior) json_object_put(prior); + return out; +} + // step11 datacenters + ping/select. Fills c->ping_results (sorted) + c->selected_*. static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) { @@ -703,6 +744,8 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) json_object_put(c->ping_results); c->ping_results = sorted; } + // Full datacenter list for the Settings picker (merged with prior stored RTTs). + c->dc_picker = gk_build_picker(c, dcs); json_object_put(dcs); return CHIAKI_ERR_SUCCESS; } @@ -897,10 +940,11 @@ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, if(e == CHIAKI_ERR_SUCCESS) e = gk_step12_select(&c); if(e == CHIAKI_ERR_SUCCESS) e = gk_step13_allocate(&c, out); - // Return the ping results for the Settings UI (per-region ms). - if(c.ping_results) + // Return the full datacenter list (merged with prior stored RTTs) for the + // Settings picker -- the platform persists this verbatim, like the old code. + if(c.dc_picker) { - const char *s = json_object_to_json_string(c.ping_results); + const char *s = json_object_to_json_string(c.dc_picker); if(s) { free(out->datacenter_pings); out->datacenter_pings = strdup(s); } } if(psplus_err && !out->error_message) @@ -919,5 +963,6 @@ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, free(c.gk_cloud_auth_code); free(c.ps3_auth_code); free(c.stream_server_auth_code); if(c.spec) json_object_put(c.spec); if(c.ping_results) json_object_put(c.ping_results); + if(c.dc_picker) json_object_put(c.dc_picker); return e; } diff --git a/lib/test_cloudsession/cloudsession-probe.c b/lib/test_cloudsession/cloudsession-probe.c index d85a6be9..32053f4b 100644 --- a/lib/test_cloudsession/cloudsession-probe.c +++ b/lib/test_cloudsession/cloudsession-probe.c @@ -46,6 +46,7 @@ int main(int argc, char **argv) cfg.owned_entitlement_id = env_or("OWNED_ENT", ""); cfg.owned_platform = env_or("OWNED_PLAT", "ps4"); cfg.forced_datacenter = env_or("FORCED_DC", ""); + cfg.prior_datacenters_json = env_or("PRIOR_DC", ""); cfg.catalog_is_foreign = getenv("FOREIGN") && *getenv("FOREIGN"); cfg.resolution = atoi(env_or("RES", "1080")); cfg.bitrate_kbps = atoi(env_or("BITRATE", "15000")); @@ -73,6 +74,7 @@ int main(int argc, char **argv) out.handshake_key ? "yes" : "no", out.launch_spec ? "yes" : "no", (unsigned long long)out.rtt_us, out.mtu_in, out.mtu_out); printf(" msg=%s\n", out.error_message ? out.error_message : "(none)"); + printf(" datacenter_pings=%s\n", out.datacenter_pings ? out.datacenter_pings : "(none)"); chiaki_cloud_provision_result_fini(&out); return e == CHIAKI_ERR_SUCCESS ? 0 : 1; } From 8ccbb49abaea423d3dbc4c6fce8ec3d071bf28ad Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 01:01:35 -0700 Subject: [PATCH 49/72] cloudsession (qt+ios): persist merged datacenter pings into Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire both platforms to the lib's prior/merged datacenter handling so the Settings → Cloud datacenter picker repopulates with measured RTTs after a stream attempt, exactly as the old per-platform code did: - pass the platform's stored datacenters JSON in as cfg.prior_datacenters_json - after the call, persist result.datacenter_pings (the full merged list) back to the per-service store, whether or not allocation succeeded (the old code saved during the ping) Qt: GetCloudDatacentersJsonPS{Cloud,NOW} -> SetCloudDatacentersJsonPS{Cloud,NOW}. iOS: SecureStore.{pscloud,psnow}DatacentersData -> CloudDatacenterStore.saveDatacenters (bridge gains the priorDatacentersJson param). Both build + launch. Android still uses its old Kotlin flow (which already persists datacenters); the same prior/save wiring lands as part of the Android port. Co-Authored-By: Claude Opus 4.8 --- gui/src/cloudstreamingbackend.cpp | 16 ++++++++++++++-- ios/Pylux/Bridge/CloudProvisionBridge.h | 1 + ios/Pylux/Bridge/CloudProvisionBridge.m | 2 ++ ios/Pylux/Services/CloudStreamingBackend.swift | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index d8d61a06..8b8a41db 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -140,6 +140,10 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q const QByteArray gameLang = gameLangStr.toUtf8(); const QByteArray forcedDc = (pscloud ? settings->GetCloudDatacenterPSCloud() : settings->GetCloudDatacenterPSNOW()).toUtf8(); + // Prior stored datacenters for this service -> merged with this run's pings by the lib + // and returned, so the Settings picker keeps previously-measured RTTs (like the old code). + const QByteArray priorDc = (pscloud ? settings->GetCloudDatacentersJsonPSCloud() + : settings->GetCloudDatacentersJsonPSNOW()).toUtf8(); const int resolution = pscloud ? settings->GetCloudResolutionPSCloud() : settings->GetCloudResolutionPSNOW(); const int bitrate = static_cast(pscloud ? settings->GetCloudBitratePSCloud() @@ -165,7 +169,7 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q setAllocationProgress(tr("Starting cloud session...")); std::thread([this, callback, svc, gameId, npsso, storeCountry, storeLang, gameLang, - forcedDc, resolution, bitrate, isForeign, attrPassed, ownedEnt, ownedPlat]() mutable { + forcedDc, priorDc, resolution, bitrate, isForeign, attrPassed, ownedEnt, ownedPlat]() mutable { ChiakiLog log; chiaki_log_init(&log, CHIAKI_LOG_INFO | CHIAKI_LOG_WARNING | CHIAKI_LOG_ERROR, chiaki_log_cb_print, nullptr); @@ -183,6 +187,7 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q cfg.catalog_is_foreign = isForeign; cfg.skip_account_attr_check = attrPassed; cfg.forced_datacenter = forcedDc.constData(); + cfg.prior_datacenters_json = priorDc.constData(); cfg.cache_dir = ""; cfg.resolution = resolution; cfg.bitrate_kbps = bitrate; @@ -204,10 +209,17 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q const uint32_t mtuIn = res.mtu_in, mtuOut = res.mtu_out; const quint64 rttUs = res.rtt_us; const QString errMsg = res.error_message ? QString::fromUtf8(res.error_message) : QString(); + const QString dcPings = res.datacenter_pings ? QString::fromUtf8(res.datacenter_pings) : QString(); chiaki_cloud_provision_result_fini(&res); QMetaObject::invokeMethod(this, [this, callback, success, serviceTypeStr, serverIp, serverPort, - handshakeKey, launchSpec, sessionId, wrap, mtuIn, mtuOut, rttUs, errMsg]() mutable { + handshakeKey, launchSpec, sessionId, wrap, mtuIn, mtuOut, rttUs, errMsg, dcPings]() mutable { + // Persist the merged datacenter list so Settings shows the measured RTTs + // (done whether or not allocation succeeded -- the old code saved during the ping). + if (!dcPings.isEmpty()) { + if (serviceTypeStr == "pscloud") settings->SetCloudDatacentersJsonPSCloud(dcPings); + else settings->SetCloudDatacentersJsonPSNOW(dcPings); + } if (success) { finishCloudSession(serviceTypeStr, serverIp, serverPort, handshakeKey, launchSpec, sessionId, wrap, mtuIn, mtuOut, rttUs, callback); diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.h b/ios/Pylux/Bridge/CloudProvisionBridge.h index 9670687d..43354267 100644 --- a/ios/Pylux/Bridge/CloudProvisionBridge.h +++ b/ios/Pylux/Bridge/CloudProvisionBridge.h @@ -40,6 +40,7 @@ NS_ASSUME_NONNULL_BEGIN ownedEntitlementId:(NSString *)ownedEntitlementId ownedPlatform:(NSString *)ownedPlatform forcedDatacenter:(NSString *)forcedDatacenter + priorDatacentersJson:(NSString *)priorDatacentersJson catalogIsForeign:(BOOL)catalogIsForeign resolution:(int)resolution bitrateKbps:(int)bitrateKbps diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.m b/ios/Pylux/Bridge/CloudProvisionBridge.m index 6eba7d4e..e845eb20 100644 --- a/ios/Pylux/Bridge/CloudProvisionBridge.m +++ b/ios/Pylux/Bridge/CloudProvisionBridge.m @@ -54,6 +54,7 @@ + (PyluxCloudProvisionResult *)provisionWithServiceType:(NSString *)serviceType ownedEntitlementId:(NSString *)ownedEntitlementId ownedPlatform:(NSString *)ownedPlatform forcedDatacenter:(NSString *)forcedDatacenter + priorDatacentersJson:(NSString *)priorDatacentersJson catalogIsForeign:(BOOL)catalogIsForeign resolution:(int)resolution bitrateKbps:(int)bitrateKbps @@ -78,6 +79,7 @@ + (PyluxCloudProvisionResult *)provisionWithServiceType:(NSString *)serviceType cfg.owned_entitlement_id = ownedEntitlementId.UTF8String; cfg.owned_platform = ownedPlatform.UTF8String; cfg.forced_datacenter = forcedDatacenter.UTF8String; + cfg.prior_datacenters_json = priorDatacentersJson.UTF8String; cfg.cache_dir = ""; cfg.catalog_is_foreign = catalogIsForeign ? true : false; cfg.skip_account_attr_check = false; // iOS has no "ignore forever" flag diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index d7b7a876..93065723 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -94,6 +94,11 @@ final class CloudStreamingBackend { let resolution = Int32(Int(pscloud ? prefs.cloudResolutionPscloud : prefs.cloudResolutionPsnow) ?? 1080) let bitrate = Int32(StreamPreferences.clampCloudBitrateKbps(pscloud ? prefs.cloudBitratePscloud : prefs.cloudBitratePsnow)) + // Prior stored datacenters for this service -> the lib merges this run's pings into them + // and returns the full list, so the Settings picker keeps previously-measured RTTs. + let priorData = pscloud ? SecureStore.shared.pscloudDatacentersData : SecureStore.shared.psnowDatacentersData + let priorDatacentersJson = priorData.flatMap { String(data: $0, encoding: .utf8) } ?? "" + // sharedDuid is only the auth-check DUID; the C flow generates its own shared one. _ = sharedDuid @@ -108,6 +113,7 @@ final class CloudStreamingBackend { ownedEntitlementId: ownedEntitlementId, ownedPlatform: ownedPlatform, forcedDatacenter: forcedDatacenter, + priorDatacentersJson: priorDatacentersJson, catalogIsForeign: SecureStore.shared.isCloudCatalogIsForeign, resolution: resolution, bitrateKbps: bitrate, @@ -115,6 +121,14 @@ final class CloudStreamingBackend { isCancelled: { isCancelled() } ) + // Persist the merged datacenter list so Settings shows the measured RTTs + // (whether or not allocation succeeded -- the old code saved during the ping). + if let pings = result.datacenterPings, !pings.isEmpty, + let data = pings.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + CloudDatacenterStore.saveDatacenters(arr, for: serviceType) + } + if result.err == 0 { os_log(.info, log: cloudLog, "✓ Cloud provisioning complete - Server: %{public}s", result.serverIp) return CloudStreamSession( From 76ecb43ec20f16060f4c8013318b18681fbcced9 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 01:22:59 -0700 Subject: [PATCH 50/72] cloudsession (android): wire Android to chiaki_cloud_provision_session via JNI Android port mirroring Qt/iOS: - chiaki-jni.c: new cloudProvisionSession JNI -- runs the whole Kamaji+Gaikai flow in C; progress/cancellation route to a Kotlin CloudProvisionCallbacks object (called on the calling thread, JNIEnv stays valid; the lib's parallel ping threads never touch JNI). Results come back via stringOut[8] + intOut[5] (no JSON build needed), returns the ChiakiErrorCode. - Chiaki.kt: the external fun + CloudProvisionCallbacks interface + a CloudProvisionResult data class + a Kotlin-friendly wrapper. - CloudStreamingBackend.kt: continueCloudSessionAfterAuth now calls the wrapper (settings from Preferences incl. the gameLanguage->store-locale fallback + prior datacenters), maps the C error_message sentinels to PsPlusSubscription/AccountPrivacySettings(url)/PingTimeout/GaikaiAllocation exceptions, and persists result.datacenterPings. The owned fast-path + one-shot fallback now live in C; auth check + DUID gen stay in Kotlin. Builds clean (arm64 debug); the Java_..._cloudProvisionSession symbol links into libchiaki-jni.so. The old PSKamajiSession.kt/PSGaikaiStreaming.kt are now unused (deleted in the cross-platform cleanup pass). Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/cpp/chiaki-jni.c | 138 ++++++++++++ .../cloudplay/api/CloudStreamingBackend.kt | 211 ++++++------------ .../java/com/metallic/chiaki/lib/Chiaki.kt | 59 +++++ 3 files changed, 264 insertions(+), 144 deletions(-) diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index 5d4438ac..f7a38a94 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -1618,6 +1619,143 @@ JNIEXPORT void JNICALL JNI_FCN(cloudCatalogInvalidateCache)(JNIEnv *env, jobject } } +// Unified cloud session provisioning (chiaki/cloudsession.h): the whole Kamaji+Gaikai flow +// in C, shared with Qt/iOS. Blocking -- call from a background thread. Progress + cancellation +// route back to a Kotlin CloudProvisionCallbacks object, called on THIS thread (so this JNIEnv +// stays valid; the lib's parallel ping threads never touch JNI). Result comes back via +// stringOut[8] + intOut[5]; returns the ChiakiErrorCode. All result strings are ASCII +// (ip/keys/launchSpec/json/errorMessage), so NewStringUTF is safe (unlike the catalog payload). +typedef struct +{ + JNIEnv *env; + jobject callbacks; + jmethodID on_progress; // (Ljava/lang/String;)V + jmethodID is_cancelled; // ()Z +} CloudCbCtx; + +static void cloud_jni_progress(const char *stage, void *user) +{ + CloudCbCtx *c = (CloudCbCtx *)user; + if(!c || !c->callbacks || !c->on_progress) return; + JNIEnv *env = c->env; + jstring s = E->NewStringUTF(env, stage ? stage : ""); + if(!s) return; + E->CallVoidMethod(env, c->callbacks, c->on_progress, s); + if(E->ExceptionCheck(env)) E->ExceptionClear(env); + E->DeleteLocalRef(env, s); +} + +static bool cloud_jni_cancelled(void *user) +{ + CloudCbCtx *c = (CloudCbCtx *)user; + if(!c || !c->callbacks || !c->is_cancelled) return false; + JNIEnv *env = c->env; + jboolean b = E->CallBooleanMethod(env, c->callbacks, c->is_cancelled); + if(E->ExceptionCheck(env)) { E->ExceptionClear(env); return false; } + return b ? true : false; +} + +static void cloud_set_str_out(JNIEnv *env, jobjectArray arr, int idx, const char *s) +{ + if(!s || !*s) return; + jstring js = E->NewStringUTF(env, s); + if(!js) return; + E->SetObjectArrayElement(env, arr, (jsize)idx, js); + E->DeleteLocalRef(env, js); +} + +JNIEXPORT jint JNICALL JNI_FCN(cloudProvisionSession)(JNIEnv *env, jobject obj, + jstring service_type_str, jstring game_identifier_str, jstring game_name_str, jstring npsso_str, + jstring store_country_str, jstring store_lang_str, jstring game_language_str, + jstring owned_entitlement_str, jstring owned_platform_str, jstring forced_dc_str, + jstring prior_dc_str, jboolean catalog_is_foreign, jint resolution, jint bitrate_kbps, + jobject callbacks, jobjectArray string_out, jintArray int_out) +{ + (void)obj; + const char *service_type = service_type_str ? E->GetStringUTFChars(env, service_type_str, NULL) : NULL; + const char *game_identifier = game_identifier_str ? E->GetStringUTFChars(env, game_identifier_str, NULL) : NULL; + const char *game_name = game_name_str ? E->GetStringUTFChars(env, game_name_str, NULL) : NULL; + const char *npsso = npsso_str ? E->GetStringUTFChars(env, npsso_str, NULL) : NULL; + const char *store_country = store_country_str ? E->GetStringUTFChars(env, store_country_str, NULL) : NULL; + const char *store_lang = store_lang_str ? E->GetStringUTFChars(env, store_lang_str, NULL) : NULL; + const char *game_language = game_language_str ? E->GetStringUTFChars(env, game_language_str, NULL) : NULL; + const char *owned_entitlement = owned_entitlement_str ? E->GetStringUTFChars(env, owned_entitlement_str, NULL) : NULL; + const char *owned_platform = owned_platform_str ? E->GetStringUTFChars(env, owned_platform_str, NULL) : NULL; + const char *forced_dc = forced_dc_str ? E->GetStringUTFChars(env, forced_dc_str, NULL) : NULL; + const char *prior_dc = prior_dc_str ? E->GetStringUTFChars(env, prior_dc_str, NULL) : NULL; + + CloudCbCtx cb; + memset(&cb, 0, sizeof(cb)); + cb.env = env; + cb.callbacks = callbacks; + if(callbacks) + { + jclass cls = E->GetObjectClass(env, callbacks); + cb.on_progress = E->GetMethodID(env, cls, "onProgress", "(Ljava/lang/String;)V"); + cb.is_cancelled = E->GetMethodID(env, cls, "isCancelled", "()Z"); + } + + ChiakiCloudProvisionConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.service_type = service_type; + cfg.game_identifier = game_identifier; + cfg.game_name = game_name; + cfg.npsso = npsso; + cfg.store_country = store_country; + cfg.store_lang = store_lang; + cfg.game_language = game_language; + cfg.owned_entitlement_id = owned_entitlement; + cfg.owned_platform = owned_platform; + cfg.forced_datacenter = forced_dc; + cfg.prior_datacenters_json = prior_dc; + cfg.cache_dir = ""; + cfg.catalog_is_foreign = catalog_is_foreign ? true : false; + cfg.skip_account_attr_check = false; + cfg.resolution = resolution; + cfg.bitrate_kbps = bitrate_kbps; + cfg.progress = callbacks ? cloud_jni_progress : NULL; + cfg.is_cancelled = callbacks ? cloud_jni_cancelled : NULL; + cfg.user = &cb; + + ChiakiCloudProvisionResult res; + memset(&res, 0, sizeof(res)); + ChiakiErrorCode err = chiaki_cloud_provision_session(&cfg, &res, &global_log); + + // stringOut: [serverIp, handshakeKey, launchSpec, sessionId, entitlementId, platform, datacenterPings, errorMessage] + if(string_out && E->GetArrayLength(env, string_out) >= 8) + { + cloud_set_str_out(env, string_out, 0, res.server_ip); + cloud_set_str_out(env, string_out, 1, res.handshake_key); + cloud_set_str_out(env, string_out, 2, res.launch_spec); + cloud_set_str_out(env, string_out, 3, res.session_id); + cloud_set_str_out(env, string_out, 4, res.entitlement_id); + cloud_set_str_out(env, string_out, 5, res.platform); + cloud_set_str_out(env, string_out, 6, res.datacenter_pings); + cloud_set_str_out(env, string_out, 7, res.error_message); + } + // intOut: [serverPort, psnWrapperType, mtuIn, mtuOut, rttMs] + if(int_out && E->GetArrayLength(env, int_out) >= 5) + { + jint ints[5] = { (jint)res.server_port, (jint)res.psn_wrapper_type, + (jint)res.mtu_in, (jint)res.mtu_out, (jint)(res.rtt_us / 1000) }; + E->SetIntArrayRegion(env, int_out, 0, 5, ints); + } + + chiaki_cloud_provision_result_fini(&res); + if(service_type_str) E->ReleaseStringUTFChars(env, service_type_str, service_type); + if(game_identifier_str) E->ReleaseStringUTFChars(env, game_identifier_str, game_identifier); + if(game_name_str) E->ReleaseStringUTFChars(env, game_name_str, game_name); + if(npsso_str) E->ReleaseStringUTFChars(env, npsso_str, npsso); + if(store_country_str) E->ReleaseStringUTFChars(env, store_country_str, store_country); + if(store_lang_str) E->ReleaseStringUTFChars(env, store_lang_str, store_lang); + if(game_language_str) E->ReleaseStringUTFChars(env, game_language_str, game_language); + if(owned_entitlement_str) E->ReleaseStringUTFChars(env, owned_entitlement_str, owned_entitlement); + if(owned_platform_str) E->ReleaseStringUTFChars(env, owned_platform_str, owned_platform); + if(forced_dc_str) E->ReleaseStringUTFChars(env, forced_dc_str, forced_dc); + if(prior_dc_str) E->ReleaseStringUTFChars(env, prior_dc_str, prior_dc); + return (jint)err; +} + // Cloud streaming language helpers (chiaki/cloudcatalog.h): the shared lib table // is the single source of truth across Qt/iOS/Android. Game language is tied to // the datacenter region (Gaikai ignores a language whose datacenter is unselected). diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 4e516a0a..dc64dcd9 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -109,7 +109,6 @@ class CloudStreamingBackend( gameIdentifier, gameName, npssoToken, - sharedDuid, ownedEntitlementId, ownedPlatform, onProgress = onProgress, @@ -126,165 +125,97 @@ class CloudStreamingBackend( } /** - * Continue cloud session after successful authorization - * Mirrors: CloudStreamingBackend::continueCloudSessionAfterAuth() + * Continue cloud session after successful authorization: run the unified C provisioning + * flow (chiaki_cloud_provision_session via JNI). The whole Kamaji+Gaikai flow, the owned + * fast-path and the one-shot noGameForEntitlementId retry all live in libchiaki now. + * Mirrors: gui/src/cloudstreamingbackend.cpp + ios CloudStreamingBackend.swift */ private suspend fun continueCloudSessionAfterAuth( serviceType: String, gameIdentifier: String, gameName: String, npssoToken: String, - sharedDuid: String, ownedEntitlementId: String = "", ownedPlatform: String = "", - forceFullEntitlementFlow: Boolean = false, // true on the one-shot fallback retry (disables fast-path) onProgress: ((String) -> Unit)? = null, isCancelled: () -> Boolean = { false } ): Result = withContext(Dispatchers.IO) { try { - // Determine service-specific configuration - val redirectUri: String - val userAgent: String - val oauthApiPath: String - - if (serviceType == "pscloud") - { - redirectUri = GaikaiConsts.REDIRECT_URI - userAgent = GaikaiConsts.USER_AGENT - oauthApiPath = "/authz/v3" // ACCOUNT_BASE already includes /api - } - else // psnow - { - redirectUri = PsnApiConstants.REDIRECT_URI - userAgent = PsnApiConstants.USER_AGENT - oauthApiPath = "/v1" // ACCOUNT_BASE already includes /api - } - - // Determine ChiakiTarget (device/console type used by Chiaki core). - // PSCLOUD should be treated as PS5. - // PSNOW target will be determined after platform is detected from API response. - val initialPlatform = if (serviceType == "pscloud") "ps5" else "ps4" - - Log.i(TAG, "Determined initial platform: $initialPlatform") - - // For PSNOW: Create Kamaji session handler (Steps 0.5a-0.5d) - // For PSCLOUD: Skip Kamaji entirely - var finalEntitlementId = gameIdentifier - var finalPlatform = initialPlatform - var usedFastPath = false + val pscloud = serviceType == "pscloud" - if (serviceType == "psnow") - { - Log.i(TAG, "=== PSNOW Flow: Starting Kamaji Session ===") + // Streaming language: manual picker, else the auto-detected catalog locale. + val gameLanguage = preferences.getCloudGameLanguage().ifEmpty { preferences.getCloudStoreLocale() } + val forcedDatacenter = if (pscloud) preferences.getCloudDatacenterPscloud() else preferences.getCloudDatacenterPsnow() + val resolution = if (pscloud) preferences.getCloudResolutionPscloud() else preferences.getCloudResolutionPsnow() + val bitrate = if (pscloud) preferences.getCloudBitratePscloud() else preferences.getCloudBitratePsnow() + // Prior stored datacenters -> merged with this run's pings by the lib so the Settings + // picker keeps previously-measured RTTs. + val priorDatacenters = if (pscloud) preferences.getCloudDatacentersJsonPscloud() else preferences.getCloudDatacentersJsonPsnow() - // Create Kamaji session with productId (will be converted to entitlementId) - // Platform will be automatically detected from the API response - val kamajiSession = PSKamajiSession( - duid = sharedDuid, - productId = gameIdentifier, - accountBaseUrl = CloudConfig.ACCOUNT_BASE, - redirectUri = redirectUri, - userAgent = userAgent, - preferences = preferences + val result = com.metallic.chiaki.lib.cloudProvisionSession( + serviceType = serviceType, + gameIdentifier = gameIdentifier, + gameName = gameName, + npsso = npssoToken, + storeCountry = preferences.getCloudResolvedStoreCountry(), + storeLang = preferences.getCloudResolvedStoreLang(), + gameLanguage = gameLanguage, + ownedEntitlementId = ownedEntitlementId, + ownedPlatform = ownedPlatform, + forcedDatacenter = forcedDatacenter, + priorDatacentersJson = priorDatacenters, + catalogIsForeign = preferences.isCloudCatalogIsForeign(), + resolution = resolution, + bitrateKbps = bitrate, + onProgress = onProgress, + isCancelled = isCancelled ) - // Owned-PSNOW fast-path: if the catalog already resolved this title's streaming - // entitlement (owned), hand it to Kamaji so it skips the resolve/acquire path. - // Disabled on the fallback retry (forceFullEntitlementFlow). - if (!forceFullEntitlementFlow && ownedEntitlementId.isNotEmpty()) - { - Log.i(TAG, "PSNOW owned fast-path: catalog entitlementId=$ownedEntitlementId platform=$ownedPlatform") - kamajiSession.setOwnedEntitlementFastPath(ownedEntitlementId, ownedPlatform) - } - else if (forceFullEntitlementFlow) - { - Log.i(TAG, "PSNOW: forcing full entitlement flow (fast-path retry fallback)") - } - - // Start Kamaji session creation - val kamajiResult = kamajiSession.startSessionCreation(npssoToken) - usedFastPath = kamajiSession.usedEntitlementFastPath - - if (!kamajiResult.success) - { - Log.e(TAG, "Kamaji session creation failed: ${kamajiResult.message}") - return@withContext Result.failure(Exception("Kamaji session failed: ${kamajiResult.message}")) - } - - finalEntitlementId = kamajiResult.entitlementId - finalPlatform = kamajiResult.platform - - Log.i(TAG, "✓ Kamaji session complete") - Log.i(TAG, " Entitlement ID: $finalEntitlementId") - Log.i(TAG, " Platform: $finalPlatform") + // Persist the merged datacenter list so Settings shows the measured RTTs + // (whether or not allocation succeeded -- the old code saved during the ping). + if (result.datacenterPings.isNotEmpty()) + { + if (pscloud) preferences.setCloudDatacentersJsonPscloud(result.datacenterPings) + else preferences.setCloudDatacentersJsonPsnow(result.datacenterPings) } - else + + if (result.err == 0) { - // PSCLOUD: Skip Kamaji, start directly with Gaikai (Qt lines 231-237) - // PSCLOUD always uses PS5 platform, gameIdentifier is already an entitlementId - Log.i(TAG, "=== PSCLOUD Flow: Skipping Kamaji, Starting Gaikai Directly ===") - Log.i(TAG, "Using PS5 platform for PSCLOUD") + Log.i(TAG, "✓ Cloud provisioning complete - Server: ${result.serverIp}") + return@withContext Result.success(CloudStreamSession( + serverIp = result.serverIp, + serverPort = result.serverPort, + handshakeKey = result.handshakeKey, + launchSpec = result.launchSpec, + sessionId = result.sessionId, + entitlementId = result.entitlementId, + gameName = gameName, + platform = result.platform, + psnWrapperType = result.psnWrapperType, + mtuIn = result.mtuIn, + mtuOut = result.mtuOut, + rttMs = result.rttMs, + serviceType = serviceType + )) } - - // Start Gaikai allocation (Steps 7-13) - Log.i(TAG, "=== Starting Gaikai Allocation ===") - - val gaikaiStreaming = PSGaikaiStreaming( - duid = sharedDuid, - serviceType = serviceType, - platform = finalPlatform, - npssoToken = npssoToken, - preferences = preferences, - onProgress = onProgress, - isCancelled = isCancelled - ) - - val allocationResult = gaikaiStreaming.startAllocationFlow(finalEntitlementId) - if (!allocationResult.success) + // Map the C error_message sentinels to the exceptions CloudPlayFragment catches. + val msg = result.errorMessage.ifEmpty { "Allocation failed" } + Log.e(TAG, "Cloud provisioning failed: $msg") + val ex: Exception = when { - Log.e(TAG, "Gaikai allocation failed: ${allocationResult.message}") - // Owned fast-path fallback: if we streamed a catalog entitlement and Gaikai rejected it - // (the entitlement isn't actually valid/owned), retry exactly once via the full - // resolve/acquire flow. One shot only -- forceFullEntitlementFlow disables the fast-path - // on the retry, so this can never loop. - if (usedFastPath && !forceFullEntitlementFlow && isEntitlementRejectedError(allocationResult.message)) - { - Log.w(TAG, "Owned fast-path entitlement rejected by Gaikai; retrying once with the full entitlement flow") - return@withContext continueCloudSessionAfterAuth( - serviceType, gameIdentifier, gameName, npssoToken, sharedDuid, - ownedEntitlementId = "", ownedPlatform = "", forceFullEntitlementFlow = true, - onProgress = onProgress, isCancelled = isCancelled - ) - } - return@withContext Result.failure(Exception("Gaikai allocation failed: ${allocationResult.message}")) + msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") -> + PsPlusSubscriptionException("PS Plus subscription required") + msg.startsWith("ACCOUNT_PRIVACY_SETTINGS") -> + AccountPrivacySettingsException(msg.substringAfter("ACCOUNT_PRIVACY_SETTINGS:", ""), + "Account privacy settings need updating") + msg.contains("PING_TIMEOUT") -> + PingTimeoutException("Ping must be < 80ms to start a cloud session") + else -> GaikaiAllocationException(msg) } - - Log.i(TAG, "✓ Gaikai allocation complete") - Log.i(TAG, " Server IP: ${allocationResult.serverIp}") - Log.i(TAG, " Session ID: ${allocationResult.sessionId}") - - // Create cloud stream session - val streamSession = CloudStreamSession( - serverIp = allocationResult.serverIp, - serverPort = allocationResult.serverPort, - handshakeKey = allocationResult.handshakeKey, - launchSpec = allocationResult.launchSpec, - sessionId = allocationResult.sessionId, - entitlementId = finalEntitlementId, - gameName = gameName, - platform = finalPlatform, - psnWrapperType = allocationResult.psnWrapperType, - mtuIn = allocationResult.mtuIn, - mtuOut = allocationResult.mtuOut, - rttMs = allocationResult.rttMs, - serviceType = serviceType - ) - - Log.i(TAG, "=== Cloud Streaming Session Ready ===") - Result.success(streamSession) + Result.failure(ex) } catch (e: Exception) { @@ -292,14 +223,6 @@ class CloudStreamingBackend( Result.failure(e) } } - - /** - * True when a Gaikai allocation error means "the entitlement we streamed isn't valid/owned" - * (Gaikai's session-start reports {"name":"noGameForEntitlementId",...}). That's the signal the - * owned fast-path guessed wrong and we should retry with the full resolve/acquire flow. - */ - private fun isEntitlementRejectedError(error: String): Boolean = - error.contains("noGameForEntitlement", ignoreCase = true) /** * Centralized Authorization Check (used by both PSNOW and PSCLOUD) diff --git a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt index 4ff2461d..b316b8b0 100644 --- a/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt +++ b/android/app/src/main/java/com/metallic/chiaki/lib/Chiaki.kt @@ -182,9 +182,68 @@ private class ChiakiNative @JvmStatic external fun cloudCatalogInvalidateCache(cacheDir: String) @JvmStatic external fun cloudGaikaiLanguage(locale: String?): String @JvmStatic external fun cloudSupportedLanguages(): Array + + // Unified cloud session provisioning (chiaki/cloudsession.h) — the whole Kamaji+Gaikai + // flow in C, shared with Qt/iOS. Blocking; call off the main thread. Progress/cancellation + // route through [callbacks] (invoked on the calling thread). Results come back via + // stringOut[8] = [serverIp, handshakeKey, launchSpec, sessionId, entitlementId, platform, + // datacenterPings, errorMessage] and intOut[5] = [serverPort, psnWrapperType, mtuIn, mtuOut, + // rttMs]; the return is the ChiakiErrorCode (0 == success). + @JvmStatic external fun cloudProvisionSession( + serviceType: String, gameIdentifier: String, gameName: String, npsso: String, + storeCountry: String, storeLang: String, gameLanguage: String, + ownedEntitlementId: String, ownedPlatform: String, forcedDatacenter: String, + priorDatacentersJson: String, catalogIsForeign: Boolean, resolution: Int, bitrateKbps: Int, + callbacks: CloudProvisionCallbacks?, stringOut: Array, intOut: IntArray): Int } } +/** Progress + cancellation routed from the native cloud provisioning flow (called on the worker thread). */ +interface CloudProvisionCallbacks +{ + fun onProgress(stage: String) + fun isCancelled(): Boolean +} + +/** Result of [cloudProvisionSession]; [err] == 0 on a stream-ready allocation. */ +data class CloudProvisionResult( + val err: Int, + val serverIp: String, val serverPort: Int, + val handshakeKey: String, val launchSpec: String, val sessionId: String, + val entitlementId: String, val platform: String, + val psnWrapperType: Int, val mtuIn: Int, val mtuOut: Int, val rttMs: Int, + val datacenterPings: String, val errorMessage: String +) + +/** Kotlin-friendly wrapper over [ChiakiNative.cloudProvisionSession]. Blocking; call off the main thread. */ +fun cloudProvisionSession( + serviceType: String, gameIdentifier: String, gameName: String, npsso: String, + storeCountry: String, storeLang: String, gameLanguage: String, + ownedEntitlementId: String, ownedPlatform: String, forcedDatacenter: String, + priorDatacentersJson: String, catalogIsForeign: Boolean, resolution: Int, bitrateKbps: Int, + onProgress: ((String) -> Unit)?, isCancelled: () -> Boolean +): CloudProvisionResult +{ + val stringOut = arrayOfNulls(8) + val intOut = IntArray(5) + val cb = object : CloudProvisionCallbacks + { + override fun onProgress(stage: String) { onProgress?.invoke(stage) } + override fun isCancelled(): Boolean = isCancelled() + } + val err = ChiakiNative.cloudProvisionSession( + serviceType, gameIdentifier, gameName, npsso, storeCountry, storeLang, gameLanguage, + ownedEntitlementId, ownedPlatform, forcedDatacenter, priorDatacentersJson, + catalogIsForeign, resolution, bitrateKbps, cb, stringOut, intOut) + return CloudProvisionResult( + err = err, + serverIp = stringOut[0] ?: "", serverPort = intOut[0], + handshakeKey = stringOut[1] ?: "", launchSpec = stringOut[2] ?: "", sessionId = stringOut[3] ?: "", + entitlementId = stringOut[4] ?: "", platform = stringOut[5] ?: "", + psnWrapperType = intOut[1], mtuIn = intOut[2], mtuOut = intOut[3], rttMs = intOut[4], + datacenterPings = stringOut[6] ?: "", errorMessage = stringOut[7] ?: "") +} + /** Holepunch port types */ object HolepunchPortType { From 925a7f8c8d3950067810025bf5369c59692bd15a Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 14:58:09 -0700 Subject: [PATCH 51/72] cloudcatalog: badge PS Now titles "streamable" (not "owned") PS Now (PS3/PS4) is a subscription catalog -- you stream those without owning the game; an "owned" entitlement there only means the streaming license is already in your library (acquired on a prior stream), not that you bought the game. So category_for() now always returns "streamable" for psnow, and reserves "owned"/"purchaseable" for pscloud (PS5), which you must actually own to stream. Display/filtering only -- the owned fast-path keys on the separate isOwned flag (getOwnedPsnowEntitlement), which is unchanged, so acquired PS Now titles still skip resolve/acquire. One lib change; all platforms get the new badge + the tag filter now lines up ("Owned" = PS5, "Streamable" = PS3/PS4). 105/105. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudcatalog_merge.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/cloudcatalog_merge.c b/lib/src/cloudcatalog_merge.c index 6825fa7a..fc73071c 100644 --- a/lib/src/cloudcatalog_merge.c +++ b/lib/src/cloudcatalog_merge.c @@ -246,10 +246,16 @@ static const char *stream_service_type(struct json_object *g) static const char *category_for(struct json_object *g) { - if(cc_json_bool(g, "isOwned")) - return "owned"; + // PS Now (PS3/PS4) is a subscription catalog: you stream these without owning the + // game -- an "owned" entitlement here only means the streaming license is already in + // your library (acquired on a prior stream), not that you bought the game. So always + // badge PS Now titles "streamable". PS5 (pscloud) you must own to stream, so it stays + // "owned" (in library) / "purchaseable" (must add). NB: this is display/filtering only; + // the owned fast-path keys on the separate isOwned flag, which is untouched. if(strcmp(stream_service_type(g), "psnow") == 0) return "streamable"; + if(cc_json_bool(g, "isOwned")) + return "owned"; return "purchaseable"; } From 4f5000a3a0549b041f028a316a4911218c95636a Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 15:05:19 -0700 Subject: [PATCH 52/72] settings: rename cloud sections to "Owned Games (PS5)" / "Streamable Games (PS3/PS4)" The unified catalog removed the separate Library/Catalog browse sections, so the old "Game Library" / "Game Catalog" settings titles no longer mapped to anything the user sees. Rename the per-service streaming-settings sections to match the (now platform-aligned) badges: - "Game Library" -> "Owned Games (PS5)" (pscloud, up to 4K) - "Game Catalog" -> "Streamable Games (PS3/PS4)" (psnow, up to 1080p) Updated on Qt (SettingsDialog.qml), iOS (SettingsView.swift), Android (strings.xml), plus the ping-timeout dialog references (short form "Owned Games / Streamable Games") and the login-dialog blurb. User-facing strings only. Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/res/values/strings.xml | 12 ++++++------ gui/src/qml/Main.qml | 2 +- gui/src/qml/PSNLoginDialog.qml | 2 +- gui/src/qml/SettingsDialog.qml | 2 +- ios/Pylux/Models/CloudModels.swift | 2 +- ios/Pylux/Views/SettingsView.swift | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0dee0670..8b8f3031 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -151,20 +151,20 @@ Logout Logged out successfully - + Cloud Settings - Game Library + Owned Games (PS5) Resolution - Streaming resolution for Game Library (up to 4K) + Streaming resolution for Owned Games (up to 4K) Datacenter Select a specific datacenter or use Auto for best ping Bitrate %1$d Mbps (default 20 Mbps) - - Game Catalog + + Streamable Games (PS3/PS4) Resolution - Streaming resolution for Game Catalog + Streaming resolution for Streamable Games (up to 1080p) Datacenter Select a specific datacenter or use Auto for best ping Bitrate diff --git a/gui/src/qml/Main.qml b/gui/src/qml/Main.qml index 9209d0ff..6499e792 100644 --- a/gui/src/qml/Main.qml +++ b/gui/src/qml/Main.qml @@ -125,7 +125,7 @@ Item { Qt.callLater(() => { root.showMessageDialog( qsTr("Ping Too High"), - qsTr("Ping must be less than 80ms to start a cloud session.\n\nTo continue anyway, go to Settings → Cloud and manually select a datacenter for your service (Game Library or Game Catalog)."), + qsTr("Ping must be less than 80ms to start a cloud session.\n\nTo continue anyway, go to Settings → Cloud and manually select a datacenter for your service (Owned Games or Streamable Games)."), () => { Chiaki.showPingTimeoutDialog = false; } diff --git a/gui/src/qml/PSNLoginDialog.qml b/gui/src/qml/PSNLoginDialog.qml index 2319d45f..e73fd4d8 100644 --- a/gui/src/qml/PSNLoginDialog.qml +++ b/gui/src/qml/PSNLoginDialog.qml @@ -488,7 +488,7 @@ DialogView { Label { Layout.columnSpan: 2 Layout.topMargin: 5 - text: qsTr("Required for Game Catalog and Game Library. Sign in first, then copy the full token from the page.") + text: qsTr("Required for cloud game streaming. Sign in first, then copy the full token from the page.") wrapMode: Text.Wrap font.pixelSize: 11 opacity: 0.8 diff --git a/gui/src/qml/SettingsDialog.qml b/gui/src/qml/SettingsDialog.qml index b4621b89..986e95b6 100644 --- a/gui/src/qml/SettingsDialog.qml +++ b/gui/src/qml/SettingsDialog.qml @@ -2688,7 +2688,7 @@ DialogView { id: cloudServiceSelection Layout.preferredWidth: 400 Layout.alignment: Qt.AlignLeft - model: [qsTr("Game Library"), qsTr("Game Catalog")] + model: [qsTr("Owned Games (PS5)"), qsTr("Streamable Games (PS3/PS4)")] currentIndex: selectedCloudService onActivated: (index) => selectedCloudService = index firstInFocusChain: true diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index d013ddb1..694f3f34 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -98,7 +98,7 @@ struct PingTimeoutError: Error, LocalizedError { static let alertMessage = """ Ping must be less than 80ms to start a cloud session. -To continue anyway, go to Settings → Cloud and manually select a datacenter for your service (Game Library or Game Catalog). +To continue anyway, go to Settings → Cloud and manually select a datacenter for your service (Owned Games or Streamable Games). """ var errorDescription: String? { Self.alertMessage } } diff --git a/ios/Pylux/Views/SettingsView.swift b/ios/Pylux/Views/SettingsView.swift index eb9eaf77..7d56954e 100644 --- a/ios/Pylux/Views/SettingsView.swift +++ b/ios/Pylux/Views/SettingsView.swift @@ -548,7 +548,7 @@ struct SettingsView: View { label: "Bitrate" ) } header: { - Text("Game Library") + Text("Owned Games (PS5)") } } @@ -597,7 +597,7 @@ struct SettingsView: View { label: "Bitrate" ) } header: { - Text("Game Catalog") + Text("Streamable Games (PS3/PS4)") } } From b0b7057fd4f54ee8660ea31f5cca6478d6626119 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 16:15:51 -0700 Subject: [PATCH 53/72] test: cover the Kamaji step 0.5d *GD full-game fallback Extract the full-game ("*GD") match into a pure, non-static km_pick_fullgame_id (declared in cloudsession_internal.h, mirroring cc_parse_container_store_locale) so it's unit-testable; km_pick_fullgame now just records the pick onto the context. Behavior identical -- the [KAMAJI] full-game fallback log line is preserved verbatim. Add test/cloudsession_kamaji.c (munit, sibling of cloudcatalog_merge.c) with synthetic store-container SKUs: picks the packageType-"GD" entitlement, honors require_title (title-match pass then any-*GD pass, matching km_step0_5d_resolve's two-pass loop), and returns false when there's no *GD / no entitlements array. This branch is effectively unreachable live (every sampled PS4 catalog title carries a license_type==4 streaming reservation), so a synthetic test is the only way to pin it. First test/cloudsession_*.c; suite now 108/108. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession_internal.h | 15 ++++++ lib/src/cloudsession_kamaji.c | 18 +++++-- test/CMakeLists.txt | 3 +- test/cloudsession_kamaji.c | 90 +++++++++++++++++++++++++++++++++ test/main.c | 8 +++ 5 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 test/cloudsession_kamaji.c diff --git a/lib/src/cloudsession_internal.h b/lib/src/cloudsession_internal.h index 2527724d..d686484c 100644 --- a/lib/src/cloudsession_internal.h +++ b/lib/src/cloudsession_internal.h @@ -10,12 +10,27 @@ #include #include +#include +#include #include #ifdef __cplusplus extern "C" { #endif +struct json_object; // json-c, forward-declared so this header needs no umbrella include + +/** + * Pure picker for the PS Plus full-game ("*GD") fallback that step 0.5d uses when a + * title exposes no license_type==4 streaming reservation. Scans @p sku's + * "entitlements" for the first whose "packageType" ends in "GD"; when @p require_title + * is set, additionally requires the entitlement id to contain @p title_id. On a match + * copies the id into @p out_id (capacity @p out_sz) and returns true; logs the pick via + * @p log when non-NULL. Exposed (non-static) so the unit suite can exercise it. + */ +bool km_pick_fullgame_id(struct json_object *sku, bool require_title, + const char *title_id, char *out_id, size_t out_sz, ChiakiLog *log); + /** * Kamaji session flow (PSNOW): 0.5b anonymous OAuth -> 0.5c anonymous session * -> 0.5d productId->entitlementId (uses store_country/store_lang) -> 0.5e diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 2ea005e4..ca13bbfb 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -217,7 +217,10 @@ static bool km_pick_streaming(KamajiCtx *c, struct json_object *sku) // PS Plus catalog fallback: a full-game digital entitlement ("*GD"); optionally // requiring the entitlement id to contain the requested title id (platform-consistent). -static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require_title, const char *title_id) +// The match logic lives here (non-static, unit-tested in test/cloudsession_kamaji.c); +// km_pick_fullgame records the chosen entitlement + its sku onto the context. +bool km_pick_fullgame_id(struct json_object *sku, bool require_title, + const char *title_id, char *out_id, size_t out_sz, ChiakiLog *log) { struct json_object *ents = cc_json_arr(sku, "entitlements"); if(!ents) return false; @@ -232,14 +235,21 @@ static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require continue; if(require_title && title_id && *title_id && !strstr(id, title_id)) continue; - snprintf(c->entitlement_id, sizeof(c->entitlement_id), "%s", id); - snprintf(c->streaming_sku, sizeof(c->streaming_sku), "%s", cc_json_str(sku, "id")); - CHIAKI_LOGI(c->log, "[KAMAJI] full-game entitlement (PS+ fallback): %s pkg=%s", id, pkg); + snprintf(out_id, out_sz, "%s", id); + if(log) CHIAKI_LOGI(log, "[KAMAJI] full-game entitlement (PS+ fallback): %s pkg=%s", id, pkg); return true; } return false; } +static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require_title, const char *title_id) +{ + if(!km_pick_fullgame_id(sku, require_title, title_id, c->entitlement_id, sizeof(c->entitlement_id), c->log)) + return false; + snprintf(c->streaming_sku, sizeof(c->streaming_sku), "%s", cc_json_str(sku, "id")); + return true; +} + static ChiakiErrorCode km_step0_5d_resolve(KamajiCtx *c) { if(c->cfg->progress) c->cfg->progress("Resolving Game - Step 2 of 6", c->cfg->user); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6373ccfe..e342ee26 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -16,7 +16,8 @@ add_executable(chiaki-unit test_log.h bitstream.c regist.c - cloudcatalog_merge.c) + cloudcatalog_merge.c + cloudsession_kamaji.c) target_link_libraries(chiaki-unit chiaki-lib munit) if(TARGET PkgConfig::json-c) diff --git a/test/cloudsession_kamaji.c b/test/cloudsession_kamaji.c new file mode 100644 index 00000000..1cb6cc5e --- /dev/null +++ b/test/cloudsession_kamaji.c @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Offline tests for the Kamaji resolve helpers. Covers the step 0.5d full-game +// ("*GD") fallback: when a PS Plus title's store container exposes no +// license_type==4 streaming reservation, km_pick_fullgame_id selects the +// full-game digital entitlement (packageType ending "GD"), title-matched first. +// This branch is effectively unreachable with the live catalog (every sampled +// PS4 title carries a streaming reservation), so a synthetic JSON test is the +// only way to pin its behavior. + +#include + +#include "../lib/src/cloudsession_internal.h" + +#include +#include + +static struct json_object *parse(const char *s) +{ + struct json_object *o = json_tokener_parse(s); + munit_assert_not_null(o); + return o; +} + +// A non-"GD" entitlement is skipped; the *GD one is chosen (packageType-driven). +static MunitResult test_gd_fallback_picks_gd(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *sku = parse( + "{\"id\":\"SKU-1\",\"entitlements\":[" + "{\"id\":\"UP9000-CUSA00001_00-DLC0000000000001\",\"packageType\":\"PS4DL\"}," + "{\"id\":\"UP9000-CUSA12345_00-FULLGAME00000001\",\"packageType\":\"PS4GD\"}" + "]}"); + char out[128] = ""; + munit_assert_true(km_pick_fullgame_id(sku, false, NULL, out, sizeof(out), NULL)); + munit_assert_string_equal(out, "UP9000-CUSA12345_00-FULLGAME00000001"); + json_object_put(sku); + return MUNIT_OK; +} + +// require_title picks the *GD entitlement whose id contains the title id; a +// non-matching title id finds nothing on that pass, but the relaxed pass takes +// the first *GD regardless (mirrors km_step0_5d_resolve's two-pass loop). +static MunitResult test_gd_fallback_title_match(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *sku = parse( + "{\"id\":\"SKU-1\",\"entitlements\":[" + "{\"id\":\"UP9000-CUSA99999_00-OTHERGAME0000001\",\"packageType\":\"PS4GD\"}," + "{\"id\":\"UP9000-CUSA12345_00-FULLGAME00000001\",\"packageType\":\"PS4GD\"}" + "]}"); + char out[128] = ""; + munit_assert_true(km_pick_fullgame_id(sku, true, "CUSA12345", out, sizeof(out), NULL)); + munit_assert_string_equal(out, "UP9000-CUSA12345_00-FULLGAME00000001"); + + out[0] = '\0'; + munit_assert_false(km_pick_fullgame_id(sku, true, "CUSA00000", out, sizeof(out), NULL)); + munit_assert_string_equal(out, ""); + + munit_assert_true(km_pick_fullgame_id(sku, false, "CUSA00000", out, sizeof(out), NULL)); + munit_assert_string_equal(out, "UP9000-CUSA99999_00-OTHERGAME0000001"); + json_object_put(sku); + return MUNIT_OK; +} + +// No *GD entitlement, and a missing entitlements array, both yield no pick (no crash). +static MunitResult test_gd_fallback_none(const MunitParameter p[], void *data) +{ + (void)p; (void)data; + struct json_object *sku = parse( + "{\"id\":\"SKU-1\",\"entitlements\":[" + "{\"id\":\"UP9000-CUSA00001_00-DLC0000000000001\",\"packageType\":\"PS4DL\"}," + "{\"id\":\"UP9000-CUSA00001_00-SEASONPASS000001\",\"packageType\":\"PS4SP\"}" + "]}"); + char out[128] = "unchanged"; + munit_assert_false(km_pick_fullgame_id(sku, false, NULL, out, sizeof(out), NULL)); + json_object_put(sku); + + struct json_object *empty = parse("{\"id\":\"SKU-2\"}"); + munit_assert_false(km_pick_fullgame_id(empty, false, NULL, out, sizeof(out), NULL)); + json_object_put(empty); + return MUNIT_OK; +} + +MunitTest tests_cloudsession_kamaji[] = { + { "/gd_fallback_picks_gd", test_gd_fallback_picks_gd, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/gd_fallback_title_match", test_gd_fallback_title_match, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/gd_fallback_none", test_gd_fallback_none, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL } +}; diff --git a/test/main.c b/test/main.c index 1994c4af..6ef0f6d2 100644 --- a/test/main.c +++ b/test/main.c @@ -13,6 +13,7 @@ extern MunitTest tests_fec[]; extern MunitTest tests_regist[]; extern MunitTest tests_bitstream[]; extern MunitTest tests_cloudcatalog_merge[]; +extern MunitTest tests_cloudsession_kamaji[]; static MunitSuite suites[] = { { @@ -92,6 +93,13 @@ static MunitSuite suites[] = { 1, MUNIT_SUITE_OPTION_NONE }, + { + "/cloudsession_kamaji", + tests_cloudsession_kamaji, + NULL, + 1, + MUNIT_SUITE_OPTION_NONE + }, { NULL, NULL, NULL, 0, MUNIT_SUITE_OPTION_NONE } }; From c168e89ea1d1f4dde04058dae2ff244265e6ef98 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 16:27:57 -0700 Subject: [PATCH 54/72] cloudsession: fix review findings (thread-join UB; live JSESSIONID refresh) Fresh-eyes review of the unified C lib + Android wiring (Qt/iOS were already reviewed). Two fixes: MAJOR - gaikai datacenter ping: chiaki_thread_join was called for every slot, including ones where chiaki_thread_create failed and the ping ran inline. A failed-create slot holds a zeroed pthread_t, so joining it is UB (ESRCH/crash). Track which slots actually started a thread and only join those. MINOR - kamaji $0 acquire: the checkout-preview request never set capture_headers, so the JSESSIONID-refresh-before-buynow block was dead code (presp.headers always NULL). Qt's original refreshes the cookie there; set preq.capture_headers = true so it actually does. Strictly safe: a no-op when the server doesn't rotate the cookie (the live-verified path), matches Qt when it does. Review found no leaks/double-frees; protocol/OAuth/spec details match the originals line-for-line. Suite 108/108. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession_gaikai.c | 10 +++++++--- lib/src/cloudsession_kamaji.c | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index f7aaf5e1..41896f97 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -706,6 +706,7 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) // Ping every datacenter in parallel (one thread each), then collect. GkPingJob *jobs = (GkPingJob *)calloc(n, sizeof(GkPingJob)); ChiakiThread *threads = (ChiakiThread *)calloc(n, sizeof(ChiakiThread)); + bool *threaded = (bool *)calloc(n, sizeof(bool)); // which slots actually started a thread for(size_t i = 0; i < n; i++) { struct json_object *dc = json_object_array_get_idx(dcs, i); @@ -716,12 +717,15 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) jobs[i].bw = cc_json_int(dc, "maxBandwidth"); jobs[i].session_key = c->lock_session_key; jobs[i].service_type = c->cfg->service_type; - if(chiaki_thread_create(&threads[i], gk_ping_thread, &jobs[i]) != CHIAKI_ERR_SUCCESS) + if(chiaki_thread_create(&threads[i], gk_ping_thread, &jobs[i]) == CHIAKI_ERR_SUCCESS) + threaded[i] = true; + else gk_ping_thread(&jobs[i]); // fall back to inline if a thread won't start } for(size_t i = 0; i < n; i++) { - chiaki_thread_join(&threads[i], NULL); + if(threaded[i]) // only join slots whose thread started; a zeroed pthread_t join is UB + chiaki_thread_join(&threads[i], NULL); if(jobs[i].ok) json_object_array_add(c->ping_results, gk_ping_obj(jobs[i].name, (int)(jobs[i].rtt_us / 1000), jobs[i].mtu_in, jobs[i].mtu_out, jobs[i].port, jobs[i].ip, jobs[i].bw, true)); @@ -732,7 +736,7 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) else CHIAKI_LOGI(c->log, "[GAIKAI] ping %s = unreachable", jobs[i].name); } - free(jobs); free(threads); + free(jobs); free(threads); free(threaded); // sort by RTT size_t rn = json_object_array_length(c->ping_results); struct json_object **arr = (struct json_object **)malloc(rn * sizeof(*arr)); diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index ca13bbfb..280aaa49 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -456,6 +456,7 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) CCHttpRequest preq = { 0 }; preq.method = "POST"; preq.url = KM_KAMAJI_BASE "/user/checkout/buynow/preview"; preq.headers = prev_hdrs; preq.header_count = 8; preq.body = prev_body; + preq.capture_headers = true; // refresh JSESSIONID from the preview Set-Cookie before buynow (parity with Qt) CCHttpResponse presp = { 0 }; ChiakiErrorCode e = cc_http_perform(c->log, &preq, &presp); if(e != CHIAKI_ERR_SUCCESS) { free(h_auth); free(h_ua); free(h_cookie); cc_http_response_fini(&presp); return e; } From 05d7fa540e837935ce241991c7529b4191e12fbb Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 17:11:16 -0700 Subject: [PATCH 55/72] cloudsession: do the NPSSO authorizeCheck pre-flight in C Port each platform's checkAuthorization into the shared lib as the first (silent) step of chiaki_cloud_provision_session. cc_authorize_check POSTs the service's {client_id,scope,redirect_uri,response_type,service_entity,duid} to ca.account.sony.com/api/authz/v3/oauth/authorizeCheck with Cookie: npsso=, and treats HTTP 200/204 as valid -- identical request to what the platforms send. On failure it returns AUTHORIZATION_FAILED for the UI to map to "re-login". Running it before any progress emission preserves the old fail-fast UX (no progress UI on an expired token). This lets the per-platform checkAuthorization and its constants be deleted next. Live-verified: invalid NPSSO -> HTTP 400 -> AUTHORIZATION_FAILED; valid NPSSO -> passes and allocates (Child of Light, sjca, 67ms). Suite 108/108. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession.c | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/lib/src/cloudsession.c b/lib/src/cloudsession.c index 308a7e06..76c6a454 100644 --- a/lib/src/cloudsession.c +++ b/lib/src/cloudsession.c @@ -47,6 +47,65 @@ CHIAKI_EXPORT void chiaki_cloud_provision_result_fini(ChiakiCloudProvisionResult out->error_message = NULL; } +// --- Pre-flight authorization check (was each platform's checkAuthorization) --- +// POST the service's authorize parameters to authorizeCheck with the npsso cookie; +// HTTP 200/204 means the NPSSO is still valid. Scopes are SPACE-separated here (they +// go in a JSON body), unlike the %20-encoded OAuth-query scopes in +// cloudsession_kamaji.c. The pscloud client id is the fixed pre-flight id the +// platforms used (distinct from the step0-fetched streaming client id). +#define CA_URL "https://ca.account.sony.com/api/authz/v3/oauth/authorizeCheck" +#define CA_PSNOW_CLIENT "bc6b0777-abb5-40da-92ca-e133cf18e989" +#define CA_PSNOW_SCOPE "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" +#define CA_PSNOW_REDIR "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" +#define CA_PSNOW_UA "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +#define CA_PSCLOUD_CLIENT "19ae39c4-3f88-4d11-a792-94e4f52c996d" +#define CA_PSCLOUD_SCOPE "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s" +#define CA_PSCLOUD_REDIR "gaikai://local" +#define CA_PSCLOUD_UA "PlayStation Portal/6.0.0-rel.444+6a9cea6f5" + +// Validate the NPSSO before any real work (silently, like the old platform pre-flight). +// Returns SUCCESS on HTTP 200/204; any other status / transport error -> failure. +static ChiakiErrorCode cc_authorize_check(ChiakiLog *log, + const ChiakiCloudProvisionConfig *cfg, const char *duid) +{ + bool pscloud = cfg->service_type && strcmp(cfg->service_type, "pscloud") == 0; + const char *client_id = pscloud ? CA_PSCLOUD_CLIENT : CA_PSNOW_CLIENT; + const char *scope = pscloud ? CA_PSCLOUD_SCOPE : CA_PSNOW_SCOPE; + const char *redirect = pscloud ? CA_PSCLOUD_REDIR : CA_PSNOW_REDIR; + const char *ua = pscloud ? CA_PSCLOUD_UA : CA_PSNOW_UA; + + char body[768]; + snprintf(body, sizeof(body), + "{\"client_id\":\"%s\",\"scope\":\"%s\",\"redirect_uri\":\"%s\"," + "\"response_type\":\"code\",\"service_entity\":\"urn:service-entity:psn\",\"duid\":\"%s\"}", + client_id, scope, redirect, duid); + + char *h_cookie = NULL; + if(cc_http_make_cookie_header(&h_cookie, "npsso", cfg->npsso) != CHIAKI_ERR_SUCCESS) + return CHIAKI_ERR_UNKNOWN; + char ua_hdr[512]; + snprintf(ua_hdr, sizeof(ua_hdr), "User-Agent: %s", ua); + const char *hdrs[] = { + "Content-Type: application/json; charset=UTF-8", + ua_hdr, + h_cookie + }; + CCHttpRequest req = { 0 }; + req.method = "POST"; req.url = CA_URL; + req.headers = hdrs; req.header_count = 3; req.body = body; + CCHttpResponse resp = { 0 }; + ChiakiErrorCode e = cc_http_perform(log, &req, &resp); + long status = resp.status_code; + cc_http_response_fini(&resp); + free(h_cookie); + if(e != CHIAKI_ERR_SUCCESS) + return e; + if(status == 200 || status == 204) + return CHIAKI_ERR_SUCCESS; + CHIAKI_LOGE(log, "[CLOUDSESSION] authorizeCheck failed (HTTP %ld); NPSSO likely expired", status); + return CHIAKI_ERR_UNKNOWN; +} + // One provisioning attempt: PSNOW resolves via Kamaji then allocates via Gaikai; // PSCLOUD allocates directly (game_identifier is already the PS5 entitlementId). static ChiakiErrorCode provision_once(ChiakiLog *log, @@ -100,6 +159,16 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( return out->err; } + // Pre-flight: validate the NPSSO before any real work (silently, like the old + // per-platform checkAuthorization) so an expired token fails fast with no progress + // UI. Platforms map AUTHORIZATION_FAILED -> "token expired, please re-login". + if(cc_authorize_check(log, cfg, duid) != CHIAKI_ERR_SUCCESS) + { + out->error_message = strdup("AUTHORIZATION_FAILED"); + out->err = CHIAKI_ERR_UNKNOWN; + return out->err; + } + ChiakiErrorCode e = provision_once(log, cfg, duid, out); // One-shot fallback: an owned fast-path entitlement that Gaikai rejects From 055db9cac1914045b44c3c81e0880a5fd14941bf Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 17:20:03 -0700 Subject: [PATCH 56/72] cloudsession (ios): drop checkAuthorization (now in C) + delete old classes The NPSSO authorizeCheck now runs in libchiaki as the first step of the provision flow, so CloudStreamingBackend no longer does its own pre-flight: removed checkAuthorization + the shared-DUID plumbing + generateDuid, and map the new AUTHORIZATION_FAILED sentinel to AuthorizationFailedError ("token expired, re-login"). Delete the now-dead duplicated provisioning classes PSKamajiSession.swift (398) and PSGaikaiStreaming.swift (943) + their project.pbxproj refs -- the unified C flow (chiaki_cloud_provision_session) fully replaces them. BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- gui/include/cloudstreaming/datacenterping.h | 67 - gui/include/cloudstreaming/pscloudauth.h | 49 - .../cloudstreaming/psgaikaistreaming.h | 164 -- gui/include/cloudstreaming/pskamajisession.h | 197 -- gui/src/cloudstreaming/datacenterping.cpp | 340 ---- gui/src/cloudstreaming/pscloudauth.cpp | 90 - gui/src/cloudstreaming/psgaikaistreaming.cpp | 1714 ----------------- gui/src/cloudstreaming/pskamajisession.cpp | 1404 -------------- ios/Pylux.xcodeproj/project.pbxproj | 8 - .../Services/CloudStreamingBackend.swift | 70 +- ios/Pylux/Services/PSGaikaiStreaming.swift | 943 --------- ios/Pylux/Services/PSKamajiSession.swift | 398 ---- 12 files changed, 5 insertions(+), 5439 deletions(-) delete mode 100644 gui/include/cloudstreaming/datacenterping.h delete mode 100644 gui/include/cloudstreaming/pscloudauth.h delete mode 100644 gui/include/cloudstreaming/psgaikaistreaming.h delete mode 100644 gui/include/cloudstreaming/pskamajisession.h delete mode 100644 gui/src/cloudstreaming/datacenterping.cpp delete mode 100644 gui/src/cloudstreaming/pscloudauth.cpp delete mode 100644 gui/src/cloudstreaming/psgaikaistreaming.cpp delete mode 100644 gui/src/cloudstreaming/pskamajisession.cpp delete mode 100644 ios/Pylux/Services/PSGaikaiStreaming.swift delete mode 100644 ios/Pylux/Services/PSKamajiSession.swift diff --git a/gui/include/cloudstreaming/datacenterping.h b/gui/include/cloudstreaming/datacenterping.h deleted file mode 100644 index 2edcb8a4..00000000 --- a/gui/include/cloudstreaming/datacenterping.h +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#ifndef DATACENTERPING_H -#define DATACENTERPING_H - -#include -#include -#include -#include - -// Forward declaration -class Settings; - -/** - * Ping result structure containing RTT and MTU measurements - */ -struct PingResult { - int64_t rtt_us; // RTT in microseconds, or -1 on failure - uint32_t mtu_in; // Inbound MTU (server to client) - uint32_t mtu_out; // Outbound MTU (client to server) - - PingResult() : rtt_us(-1), mtu_in(0), mtu_out(0) {} -}; - -/** - * DatacenterPing - Uses existing senkusha echo/ping functionality for RTT measurement - * - * This class reuses the existing chiaki_senkusha_run flow which performs: - * 1. Takion connect - * 2. Protocol version exchange (always v9 for cloud ping) - * 3. BIG/BANG handshake - * 4. Echo command enable - * 5. Multiple ping/pong measurements (10 by default) - * 6. Average RTT calculation - */ -class DatacenterPing { -public: - /** - * Ping multiple datacenters using senkusha echo/ping functionality - * - * @param datacenters QJsonArray of datacenter objects with "publicIp", "port", "dataCenter", "maxBandwidth" - * @param sessionKey The session key from x-gaikai-session header (used for BIG message) - * @param serviceType Service type: "pscloud" or "psnow" (used to determine if PSN wrapper should be added) - * @param settings Settings object needed for session - * @param callback Called with QJsonArray of ping results - * Each result has: "dataCenter", "rtt", "rtts", "mtu_in", "mtu_out", "port", "publicIp", "maxBandwidth" - */ - static void pingAllDatacentersWithTimeout(const QJsonArray &datacenters, const QString &sessionKey, - const QString &serviceType, Settings *settings, - std::function callback); - -private: - /** - * Ping a single datacenter using senkusha_run - * - * @param publicIp The datacenter's public IP address - * @param port The datacenter's port (typically 40101) - * @param sessionKey The session key (x-gaikai-session) to use in BIG message launch_spec - * @param serviceType Service type: "pscloud" or "psnow" (used to determine if PSN wrapper should be added) - * @param settings Settings object needed for session - * @return PingResult containing RTT and MTU values, or rtt_us=-1 on failure/timeout - */ - static PingResult performPingHandshake(const QString &publicIp, int port, const QString &sessionKey, - const QString &serviceType, Settings *settings); -}; - -#endif // DATACENTERPING_H diff --git a/gui/include/cloudstreaming/pscloudauth.h b/gui/include/cloudstreaming/pscloudauth.h deleted file mode 100644 index 1b4b0f2e..00000000 --- a/gui/include/cloudstreaming/pscloudauth.h +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#ifndef PSCLOUDAUTH_H -#define PSCLOUDAUTH_H - -#include "settings.h" - -#include -#include -#include -#include - -Q_DECLARE_LOGGING_CATEGORY(chiakiGui) - -namespace PSCloudAuthConsts { - // OAuth credentials for cloud streaming - static const QString CLIENT_ID = "d5df3976-b7fa-4651-bcc9-05ac9f0cad47"; - static const QString CLIENT_SECRET = "VF8B50Lt0aqyAZH4"; - static const QString TOKEN_URL = "https://ca.account.sony.com/api/authz/v3/oauth/token"; - - // Scopes required for cloud gaming access - static const QString SCOPES = "id_token:email id_token:is_child id_token:age openid kamaji:get_privacy_settings user:basicProfile.get user:basicProfile.update"; -} - -class PSCloudAuth : public QObject { - Q_OBJECT - -public: - explicit PSCloudAuth(Settings *settings, QObject *parent = nullptr); - - // Exchange NPSSO token for access token and id token - void ExchangeNPSSO(QString npssoToken); - -signals: - void TokenResponse(QString accessToken, QString idToken, int expiresIn); - void TokenError(QString error); - void Finished(); - -private slots: - void handleAccessTokenResponse(const QString &url, const QJsonDocument &jsonDocument); - void handleErrorResponse(const QString &url, const QString &error, const QNetworkReply::NetworkError &err); - -private: - Settings *settings; - QString basicAuthHeader; -}; - -#endif // PSCLOUDAUTH_H - diff --git a/gui/include/cloudstreaming/psgaikaistreaming.h b/gui/include/cloudstreaming/psgaikaistreaming.h deleted file mode 100644 index 99f0f425..00000000 --- a/gui/include/cloudstreaming/psgaikaistreaming.h +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#ifndef PSGAIKAISTREAMING_H -#define PSGAIKAISTREAMING_H - -#include "settings.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -Q_DECLARE_LOGGING_CATEGORY(chiakiGui) - -// ============================================================================ -// Gaikai-specific constants -// ============================================================================ -namespace GaikaiConsts { - static const QString CONFIG_BASE = "https://config.cc.prod.gaikai.com/v1"; - static const QString GAIKAI_BASE = "https://cc.prod.gaikai.com/v1"; - - // PSCLOUD URIs and headers - static const QString REDIRECT_URI = "gaikai://local"; - static const QString USER_AGENT = "PlayStation Portal/6.0.0-rel.444+6a9cea6f5"; -} - -// Complete Gaikai streaming allocation flow (Steps 7-13) -class PSGaikaiStreaming : public QObject { - Q_OBJECT - -public: - explicit PSGaikaiStreaming(Settings *settings, QString duid, - QString serviceType, QString platform, - QObject *parent = nullptr); - - // Complete allocation flow - calls all steps in sequence - void StartAllocationFlow(QString entitlementId, const QJSValue &callback); - -signals: - void AllocationComplete(QString serverIp, int serverPort, QString handshakeKey, QString launchSpec, QString sessionId); - void AllocationError(QString error); - void AllocationProgress(QString message, int queuePosition = -1); - void psPlusSubscriptionError(); - void pingTimeoutError(); - void Finished(); - -public: - // Accessors for allocation results (available after AllocationComplete signal) - QString getServerIp() const { return allocatedServerIp; } - int getServerPort() const { return allocatedServerPort; } - QString getHandshakeKey() const { return allocatedHandshakeKey; } - QString getLaunchSpec() const { return allocatedLaunchSpec; } - uint8_t getPsnWrapperType() const { return allocatedPsnWrapperType; } - QString getGaikaiSessionId() const { return allocatedSessionId; } - QJsonObject getSelectedDatacenterPingResult() const { return selectedDatacenterPingResult; } - -private: - Settings *settings; - QString npsso; - QNetworkAccessManager *manager; - - // Service/platform configuration - QString serviceType; // "psnow" or "pscloud" - QString platform; // "ps3", "ps4", or "ps5" - QString virtType; // "konan" (PS3), "kratos" (PS4), "cronos" (PS5) - - // Shared config (passed from CloudConfig) - QString accountBaseUrl; - QString redirectUriUrl; - QString userAgentString; - QString oauthApiPath; // "/api/v1" (PSNOW) or "/api/authz/v3" (PSCLOUD) - - // Allocation results (stored as class members) - QString allocatedServerIp; - int allocatedServerPort; - QString allocatedHandshakeKey; - QString allocatedLaunchSpec; - uint8_t allocatedPsnWrapperType; - QString allocatedSessionId; - - // State management - QString configKey; // x-gaikai-session key (updates with each response) - QString lockSessionKey; // x-gaikai-session key from Step 10 (LOCK) - used for ping - QString gaikaiSessionId; - QString gkClientId; - QString ps3GkClientId; - QString streamServerClientId; - QString gkCloudAuthCode; - QString ps3AuthCode; - QString streamServerAuthCode; - QString selectedDatacenter; - int selectedDatacenterPort; // Port from step12 response (dynamic) - QJsonObject selectedDatacenterPingResult; // Store full ping result for selected datacenter (includes MTU values) - QString duid; - QJsonObject requestGameSpec; - QJSValue finalCallback; - - // Helper to build request game specification (service/platform-specific) - QJsonObject buildRequestGameSpec(QString entitlementId); - - // Helper to merge new ping results with existing datacenters in settings - // Updates existing datacenters with new ping data, adds new ones, and keeps old ones that aren't in new results - QJsonArray mergeDatacentersWithExisting(const QJsonArray &newPingResults); - - // Step 0: Get client IDs (MUST happen FIRST before step7) - void step0_GetClientIds(); - - // Step 7: Get config - void step7_GetConfig(); - - // Step 8: Start session - void step8_StartSession(QString entitlementId); - - // OAuth via platform-native HTTP (NSURLSession on macOS, QNetworkAccessManager elsewhere) - void performOAuthNative(const QString &urlString, const QString &stepName, - std::function onSuccess, - std::function onError); - - // Step 8a: Get gkClientId auth code - void step8a_GetGkAuthCode(); - - // Step 8b: Get ps3GkClientId auth code - void step8b_GetPs3AuthCode(); - - // Step 9: Authorize session - void step9_AuthorizeSession(); - - // Step 10: Lock session - void step10_LockSession(); - - // Step 11: Get datacenters - void step11_GetDatacenters(); - - // Step 12: Select datacenter (for now, auto-select first one) - void step12_SelectDatacenter(QJsonArray pingResults); - - // Step 13: Allocate slot - void step13_AllocateSlot(); - - // Allocation polling state - QElapsedTimer allocationWaitTimer; - int allocationMaxWaitSeconds; // Max wait time for current allocation attempt - static const int MAX_ALLOCATION_WAIT_SECONDS = 900; // 15 minutes (max) - static const int DEFAULT_ALLOCATION_WAIT_SECONDS = 300; // 5 minutes (fallback) - - // Retry counters - int lockSessionRetryCount; - int allocationRetryCount; - static const int MAX_LOCK_SESSION_RETRIES = 12; // Max retries for lock session - - // Helper to extract and update session key from response - void updateSessionKey(QNetworkReply *reply); - - // Debug logging helpers - void logDebugRequest(const QString &stepName, const QNetworkRequest &request, const QByteArray &body = QByteArray()); - void logDebugResponse(const QString &stepName, QNetworkReply *reply); -}; - -#endif // PSGAIKAISTREAMING_H - diff --git a/gui/include/cloudstreaming/pskamajisession.h b/gui/include/cloudstreaming/pskamajisession.h deleted file mode 100644 index ea604e30..00000000 --- a/gui/include/cloudstreaming/pskamajisession.h +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#ifndef CHIAKI_PSKAMAJISESSION_H -#define CHIAKI_PSKAMAJISESSION_H - -#include "settings.h" - -#include -#include -#include -#include -#include -#include - -// ============================================================================ -// Kamaji-specific constants -// ============================================================================ -namespace KamajiConsts { - static const QString KAMAJI_BASE = "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000"; - static const QString CLIENT_ID = "bc6b0777-abb5-40da-92ca-e133cf18e989"; - - // PS3 scopes (different from PS4) - static const QString PS3_SCOPES = "kamaji:commerce_native"; - - // PS4 scopes - static const QString PS4_SCOPES = "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get"; - - // PSNOW HTTP headers and URIs - static const QString ORIGIN = "https://psnow.playstation.com"; - static const QString REFERER = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"; - static const QString REDIRECT_URI = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html"; - static const QString USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo"; - - // --- PS3 / Classics pcnow store, by account region group --------------------- - // pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: - // * SCEA / Americas -> store MSF192018, US-region ids (UP*/NPUA*/BLUS*), - // PS3 child container "APOLLOPS3GAMES" - // * SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP*/NPEA*/NPEB*/BLES*), - // PS3 child container "APOLLOPS3" - // JP / Asia have no Apollo store (the PC app isn't offered there), so they fall - // back to PAL. A PS Plus account is authorized at Gaikai only for the id family of - // its own region group, so we must browse + resolve in the account's group. - inline bool isAmericasClassicsRegion(const QString &countryCode) { - static const QSet kAmericas = { - QStringLiteral("US"), QStringLiteral("CA"), QStringLiteral("MX"), - QStringLiteral("BR"), QStringLiteral("AR"), QStringLiteral("CL"), - QStringLiteral("CO"), QStringLiteral("PE"), QStringLiteral("EC"), - QStringLiteral("BO"), QStringLiteral("PY"), QStringLiteral("UY"), - QStringLiteral("CR"), QStringLiteral("GT"), QStringLiteral("HN"), - QStringLiteral("NI"), QStringLiteral("PA"), QStringLiteral("SV"), - QStringLiteral("DO") }; - return kAmericas.contains(countryCode.toUpper()); - } - // Country path to use for container/conversion calls (US for Americas, GB for PAL). - inline QString classicsStoreCountry(const QString &accountCountry) { - return isAmericasClassicsRegion(accountCountry) ? QStringLiteral("US") - : QStringLiteral("GB"); - } - // Fully-qualified PS3 catalog container id for the account's region group. - inline QString classicsPs3ContainerId(const QString &accountCountry) { - return isAmericasClassicsRegion(accountCountry) - ? QStringLiteral("STORE-MSF192018-APOLLOPS3GAMES") - : QStringLiteral("STORE-MSF192014-APOLLOPS3"); - } - - // PS Now catalog root store ids per region group (returns PS3 + PS4 in one walk). - static const QString APOLLOROOT_AMERICAS = QStringLiteral("STORE-MSF192018-APOLLOROOT"); - static const QString APOLLOROOT_PAL = QStringLiteral("STORE-MSF192014-APOLLOROOT"); - - /** Fully-qualified APOLLOROOT (PS Now: PS3 + PS4) container id for the account's region group. */ - inline QString apolloRootContainerId(const QString &accountCountry) { - return isAmericasClassicsRegion(accountCountry) ? APOLLOROOT_AMERICAS : APOLLOROOT_PAL; - } -} - -/** - * PSKamajiSession - Handles PlayStation Cloud Gaming Kamaji Authentication (Steps 1-6) - * - * Kamaji is Sony's authentication layer for cloud gaming. This class: - * - Creates and manages cookie-based sessions - * - Handles OAuth2 authorization flow - * - Integrates with Sony's account system - * - * Usage: - * PSKamajiSession *session = new PSKamajiSession(settings, npsso, kamajiBase, accountBase, ...); - * connect(session, &PSKamajiSession::sessionComplete, ...); - * session->startSessionCreation(); - */ -class PSKamajiSession : public QObject -{ - Q_OBJECT - -public: - explicit PSKamajiSession( - Settings *settings, - QString duid, - QString productId, // Product ID (will be converted to Entitlement ID) - QString accountBaseUrl, - QString redirectUri, - QString userAgent, - QObject *parent = nullptr - ); - - /** - * Start the complete Kamaji session creation flow (Steps 0.5a-0.5d, 5-6) - */ - void startSessionCreation(); - - /** - * Owned-PSNOW fast-path: when the unified catalog already knows the user owns this title's - * streaming entitlement, hand it in here. startSessionCreation() then skips the whole - * entitlement path (0.5b anonymous session, 0.5d product->entitlement resolve, 0.5e - * check/acquire) and goes straight to the authenticated session (step5/6). This is the - * correctness win for storefront-less regions where the 0.5d/0.5e calls 404 and the acquire - * always fails even though the entitlement is already owned. Empty entitlementId == take the - * normal full flow. If Gaikai later rejects the id, the orchestrator re-runs us without this. - */ - void setOwnedEntitlementFastPath(const QString &ownedEntitlementId, const QString &ownedPlatform); - - /** True once startSessionCreation() actually took the fast-path (used to gate the one-shot retry). */ - bool usedEntitlementFastPath() const { return entitlementFastPathUsed; } - - /** - * Get session data (only available after successful authentication) - */ - QString getAccountId() const { return accountId; } - QString getOnlineId() const { return onlineId; } - QString getSessionUrl() const { return sessionUrl; } - QString getEntitlementId() const { return entitlementId; } - QString getPlatform() const { return platform; } - -signals: - void sessionComplete(bool success, QString message, QString entitlementId); - void psPlusSubscriptionError(); - void accountPrivacySettingsError(QString upgradeUrl); - -private slots: - void handleAnonAuthCodeResponse(QNetworkReply *reply); - void handleAnonSessionResponse(QNetworkReply *reply); - void handleProductIdConversionResponse(QNetworkReply *reply); - void handleCommerceOAuthTokenResponse(QNetworkReply *reply); - void handleAccountAttributesResponse(QNetworkReply *reply); - void handleCheckEntitlementResponse(QNetworkReply *reply); - void handleCheckoutPreviewResponse(QNetworkReply *reply); - void handleCheckoutBuynowResponse(QNetworkReply *reply); - void handleAuthCodeResponse(QNetworkReply *reply); - void handleAuthSessionResponse(QNetworkReply *reply); - -private: - Settings *settings; - QNetworkAccessManager *manager; - - // Configuration passed from orchestrator - QString npssoToken; - QString kamajiBase; - QString accountBase; - QString kamajiClientId; - QString duid; - QString platform; - QString productId; - QString redirectUriUrl; - QString scopesStr; - QString userAgentString; - - // State tracking - QString anonAuthCode; // OAuth code for anonymous session - QString authorizationCode; // OAuth code for authenticated session - QString jsessionId; // JSESSIONID from anonymous session - QString entitlementId; // Converted from productId - QString streamingSku; // SKU from product ID conversion (for entitlement check) - QString fastPathEntitlementId; // Pre-resolved owned entitlement from the unified catalog (fast-path) - QString fastPathPlatform; // Platform that accompanies the fast-path entitlement (ps3/ps4) - bool entitlementFastPathUsed = false; // Set when startSessionCreation() skipped 0.5b-0.5e - QString commerceOAuthToken; // OAuth token for Commerce API (Bearer token) - - // Session data (set after successful authentication) - QString accountId; - QString onlineId; - QString sessionUrl; - - // Step functions (simplified PSNOW flow) - // Note: step0_5a_AuthorizeCheck is now handled centrally by CloudStreamingBackend - void step0_5b_GetAnonymousAuthCode(); // GET /oauth/authorize (for anonymous session code) - void step0_5c_CreateAnonymousSession(); // POST /user/session (anonymous, with OAuth code) - void step0_5d_ConvertProductId(); // GET /store/api/pcnow/.../container/.../{PRODUCT_ID} - void step0_5e_CheckEntitlement(); // Check and acquire entitlement if needed (entitlement_check.py flow) - void step0_5e_GetCommerceOAuthToken(); // GET /oauth/authorize (response_type=token for Commerce API) - void step0_5e_CheckAccountAttributes(); // POST /api/v2/accounts/me/attributes (verify account attributes) - void step0_5e_CheckEntitlementExists(); // GET /commerce/api/v1/users/me/internal_entitlements/{entitlementId} - void step0_5e_CheckoutPreview(); // POST /checkout/buynow/preview - void step0_5e_CheckoutBuynow(); // POST /checkout/buynow - void step5_GetAuthCode(); // GET /oauth/authorize (for authenticated session code) - void step6_CreateAuthSession(); // POST /user/session (authenticated, with OAuth code) -}; - -#endif // CHIAKI_PSKAMAJISESSION_H - diff --git a/gui/src/cloudstreaming/datacenterping.cpp b/gui/src/cloudstreaming/datacenterping.cpp deleted file mode 100644 index e07a1a0d..00000000 --- a/gui/src/cloudstreaming/datacenterping.cpp +++ /dev/null @@ -1,340 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#include "cloudstreaming/datacenterping.h" -#include "settings.h" -#include "chiaki/senkusha.h" -#include "chiaki/session.h" -#include "chiaki/log.h" -#include "chiaki/time.h" -#include "chiaki/common.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#ifdef _WIN32 -#include -#include -#ifndef gai_strerror -#define gai_strerror gai_strerrorA -#endif -#else -#include -#include -#include -#include -#endif - -// Helper to set port in sockaddr -static ChiakiErrorCode set_port(struct sockaddr *sa, uint16_t port) -{ - if(sa->sa_family == AF_INET) - ((struct sockaddr_in *)sa)->sin_port = port; - else if(sa->sa_family == AF_INET6) - ((struct sockaddr_in6 *)sa)->sin6_port = port; - else - return CHIAKI_ERR_INVALID_DATA; - return CHIAKI_ERR_SUCCESS; -} - -PingResult DatacenterPing::performPingHandshake(const QString &publicIp, int port, const QString &sessionKey, - const QString &serviceType, Settings *settings) -{ - Q_UNUSED(settings); - - // Create a minimal logger - ChiakiLog log; - chiaki_log_init(&log, CHIAKI_LOG_ALL & ~CHIAKI_LOG_VERBOSE, chiaki_log_cb_print, nullptr); - - // Resolve hostname to IP - QHostAddress addr; - if(!addr.setAddress(publicIp)) { - struct addrinfo hints_resolve; - memset(&hints_resolve, 0, sizeof(hints_resolve)); - hints_resolve.ai_family = AF_INET; - hints_resolve.ai_socktype = SOCK_DGRAM; - - struct addrinfo *result_resolve = nullptr; - int err_resolve = getaddrinfo(publicIp.toUtf8().constData(), nullptr, &hints_resolve, &result_resolve); - if(err_resolve != 0 || !result_resolve) { - qWarning() << "Failed to resolve hostname:" << publicIp << "error:" << gai_strerror(err_resolve); - PingResult failResult; - failResult.rtt_us = -1; - failResult.mtu_in = 0; - failResult.mtu_out = 0; - return failResult; - } - - if(result_resolve->ai_family == AF_INET) { - struct sockaddr_in *sin = (struct sockaddr_in *)result_resolve->ai_addr; - char ip_str[INET_ADDRSTRLEN]; - inet_ntop(AF_INET, &sin->sin_addr, ip_str, INET_ADDRSTRLEN); - addr.setAddress(QString::fromUtf8(ip_str)); - } else { - qWarning() << "No IPv4 address found for:" << publicIp; - freeaddrinfo(result_resolve); - PingResult failResult; - failResult.rtt_us = -1; - failResult.mtu_in = 0; - failResult.mtu_out = 0; - return failResult; - } - freeaddrinfo(result_resolve); - } - - // Create addrinfo structure for the datacenter - struct addrinfo hints; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_DGRAM; - hints.ai_protocol = IPPROTO_UDP; - - char portStr[16]; - snprintf(portStr, sizeof(portStr), "%d", port); - - struct addrinfo *addrinfo_result = nullptr; - int err = getaddrinfo(addr.toString().toUtf8().constData(), portStr, &hints, &addrinfo_result); - if(err != 0 || !addrinfo_result) { - qWarning() << "Failed to create addrinfo for" << publicIp << ":" << port; - PingResult failResult; - failResult.rtt_us = -1; - failResult.mtu_in = 0; - failResult.mtu_out = 0; - return failResult; - } - - // Allocate a buffer large enough for ChiakiSession and zero it - size_t session_size = sizeof(ChiakiSession); - char *session_buffer = (char *)calloc(1, session_size); - if(!session_buffer) { - qWarning() << "Failed to allocate session buffer"; - freeaddrinfo(addrinfo_result); - PingResult failResult; - failResult.rtt_us = -1; - failResult.mtu_in = 0; - failResult.mtu_out = 0; - return failResult; - } - - ChiakiSession *session = (ChiakiSession *)session_buffer; - session->log = &log; - session->connect_info.host_addrinfo_selected = addrinfo_result; - session->connect_info.enable_dualsense = false; - session->target = CHIAKI_TARGET_PS5_1; - - // Set service type for cloud ping - session->cloud_port = port; - if(serviceType == "pscloud") { - session->cloud_psn_wrapper_type = 0; // No PSN wrapper for PSCloud - session->service_type = CHIAKI_SERVICE_TYPE_PSCLOUD; - } else if(serviceType == "psnow") { - session->cloud_psn_wrapper_type = 0x01; // PSN wrapper for PSNOW - session->service_type = CHIAKI_SERVICE_TYPE_PSNOW; - } else { - // Fallback to PSNOW behavior for compatibility - session->cloud_psn_wrapper_type = 0x01; - session->service_type = CHIAKI_SERVICE_TYPE_PSNOW; - } - - // Initialize senkusha - ChiakiSenkusha senkusha; - ChiakiErrorCode chiakiErr = chiaki_senkusha_init(&senkusha, session); - if(chiakiErr != CHIAKI_ERR_SUCCESS) { - qWarning() << "Failed to initialize senkusha:" << chiakiErr; - freeaddrinfo(addrinfo_result); - free(session_buffer); - PingResult failResult; - failResult.rtt_us = -1; - failResult.mtu_in = 0; - failResult.mtu_out = 0; - return failResult; - } - - // Force protocol version to 9 for cloud ping (unified handling) - senkusha.protocol_version = 9; - - // Set session key (x-gaikai-session) for cloud mode BIG message - QByteArray sessionKeyBytes = sessionKey.toUtf8(); - senkusha.cloud_launch_spec = (char *)malloc(sessionKeyBytes.size() + 1); - if(!senkusha.cloud_launch_spec) { - qWarning() << "Failed to allocate session key string"; - chiaki_senkusha_fini(&senkusha); - freeaddrinfo(addrinfo_result); - free(session_buffer); - PingResult failResult; - failResult.rtt_us = -1; - failResult.mtu_in = 0; - failResult.mtu_out = 0; - return failResult; - } - memcpy(senkusha.cloud_launch_spec, sessionKeyBytes.constData(), sessionKeyBytes.size()); - senkusha.cloud_launch_spec[sessionKeyBytes.size()] = '\0'; - - // Run senkusha (this will do the full handshake + echo/ping test) - uint32_t mtu_in = 0; - uint32_t mtu_out = 0; - uint64_t rtt_us = 0; - - chiakiErr = chiaki_senkusha_run(&senkusha, &mtu_in, &mtu_out, &rtt_us, nullptr); - - // Free the session key string we allocated - if(senkusha.cloud_launch_spec) { - free(senkusha.cloud_launch_spec); - senkusha.cloud_launch_spec = NULL; - } - - chiaki_senkusha_fini(&senkusha); - freeaddrinfo(addrinfo_result); - free(session_buffer); - - PingResult pingResult; - - if(chiakiErr != CHIAKI_ERR_SUCCESS) { - pingResult.rtt_us = -1; - pingResult.mtu_in = 0; - pingResult.mtu_out = 0; - return pingResult; - } - - pingResult.rtt_us = rtt_us > 0 ? (int64_t)rtt_us : -1; - pingResult.mtu_in = mtu_in; - pingResult.mtu_out = mtu_out; - return pingResult; -} - -void DatacenterPing::pingAllDatacentersWithTimeout(const QJsonArray &datacenters, const QString &sessionKey, - const QString &serviceType, Settings *settings, - std::function callback) -{ - if(datacenters.isEmpty()) { - callback(QJsonArray()); - return; - } - - // Shared state for ping results - struct PingState { - QJsonArray results; - QJsonArray allDatacenters; - int completed = 0; - int total; - bool timeoutFired = false; - bool callbackInvoked = false; - QTimer *timer = nullptr; - std::function callback; - }; - - QSharedPointer state(new PingState); - state->total = datacenters.size(); - state->allDatacenters = datacenters; - state->callback = callback; - - // Create timeout timer - 15 seconds - state->timer = new QTimer(); - state->timer->setSingleShot(true); - state->timer->setInterval(15000); - - QObject::connect(state->timer, &QTimer::timeout, [state]() { - state->timeoutFired = true; - - if(state->callbackInvoked) { - state->timer->deleteLater(); - return; - } - - state->callbackInvoked = true; - - // Filter to only include successfully completed pings (RTT > 0 and < 999) - QJsonArray successfulResults; - for(const QJsonValue &val : state->results) { - QJsonObject result = val.toObject(); - int rtt = result["rtt"].toInt(); - // Only include successful pings (valid RTT, not dummy 999) - if(rtt > 0 && rtt < 999) { - successfulResults.append(result); - } - } - - qWarning() << "DatacenterPing: Timeout -" << state->completed << "of" << state->total << "pings completed, returning" << successfulResults.size() << "successful results"; - - // Return only successfully completed pings - caller will pick the best one - state->callback(successfulResults); - state->timer->deleteLater(); - }); - - // Start the timeout timer - state->timer->start(); - - // Launch ping threads for each datacenter - for(const QJsonValue &dcValue : datacenters) { - QJsonObject dc = dcValue.toObject(); - QString publicIp = dc["publicIp"].toString(); - int port = dc["port"].toInt(); - QString dataCenter = dc["dataCenter"].toString(); - int maxBandwidth = dc["maxBandwidth"].toInt(); - - // Create a background thread for this ping - QThread *thread = new QThread(); - QObject *worker = new QObject(); - worker->moveToThread(thread); - - QObject::connect(thread, &QThread::started, [=, sessionKey=sessionKey, serviceType=serviceType]() { - PingResult pingResult = performPingHandshake(publicIp, port, sessionKey, serviceType, settings); - int rtt_ms = pingResult.rtt_us > 0 ? (int)(pingResult.rtt_us / 1000) : -1; - - // Build result - QJsonObject result; - result["dataCenter"] = dataCenter; - result["port"] = port; - result["publicIp"] = publicIp; - result["maxBandwidth"] = maxBandwidth; - - if(rtt_ms > 0) { - result["rtt"] = rtt_ms; - result["rtts"] = QJsonArray::fromVariantList({rtt_ms}); - result["mtu_in"] = (int)pingResult.mtu_in; - result["mtu_out"] = (int)pingResult.mtu_out; - } else { - result["rtt"] = 999; - result["rtts"] = QJsonArray::fromVariantList({999}); - result["mtu_in"] = 0; - result["mtu_out"] = 0; - } - - // Post result to main thread - QMetaObject::invokeMethod(qApp, [state, result, dataCenter]() { - if(state->timeoutFired || state->callbackInvoked) { - return; - } - - state->results.append(result); - state->completed++; - - // Check if all pings completed - if(state->completed >= state->total) { - if(state->callbackInvoked) { - return; - } - - state->callbackInvoked = true; - state->timer->stop(); - state->callback(state->results); - state->timer->deleteLater(); - } - }, Qt::QueuedConnection); - - worker->deleteLater(); - thread->quit(); - }); - - QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); - thread->start(); - } -} diff --git a/gui/src/cloudstreaming/pscloudauth.cpp b/gui/src/cloudstreaming/pscloudauth.cpp deleted file mode 100644 index 3acc6206..00000000 --- a/gui/src/cloudstreaming/pscloudauth.cpp +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#include "cloudstreaming/pscloudauth.h" -#include "cloudstreaming/pskamajisession.h" -#include "jsonrequester.h" -#include "chiaki/remote/holepunch.h" - -#include -#include -#include -#include - -PSCloudAuth::PSCloudAuth(Settings *settings, QObject *parent) - : QObject(parent) - , settings(settings) -{ - basicAuthHeader = JsonRequester::generateBasicAuthHeader(PSCloudAuthConsts::CLIENT_ID, PSCloudAuthConsts::CLIENT_SECRET); -} - -void PSCloudAuth::ExchangeNPSSO(QString npssoToken) -{ - qInfo() << "Cloud Auth: Exchanging NPSSO token for access token..."; - - // Generate DUID dynamically - size_t duid_size = CHIAKI_DUID_STR_SIZE; - char duid_arr[duid_size]; - chiaki_holepunch_generate_client_device_uid(duid_arr, &duid_size); - QString duid = QString(duid_arr); - qInfo() << "Cloud Auth: Generated DUID:" << duid; - - // Build the request body - MUST NOT use .arg() because URL-encoded % will be treated as placeholders! - // Exact format from successful capture: - // scope=id_token%3Aemail%20id_token%3Ais_child%20id_token%3Aage%20openid%20kamaji%3Aget_privacy_settings%20user%3AbasicProfile.get%20user%3AbasicProfile.update - QString encodedScope = QString::fromUtf8(QUrl::toPercentEncoding(PSCloudAuthConsts::SCOPES)); - - // Build body by concatenation to avoid QString::arg() interpreting % as placeholders - QString body = "scope=" + encodedScope + - "&npsso=" + npssoToken + - "&client_id=" + PSCloudAuthConsts::CLIENT_ID + - "&client_secret=" + PSCloudAuthConsts::CLIENT_SECRET + - "&grant_type=sso_token" + - "&duid=" + duid; - - qInfo() << "Cloud Auth: Request body (first 100 chars):" << body.left(100); - - // Use PlayStation Now User-Agent (required by API) - QString userAgent = KamajiConsts::USER_AGENT; - - JsonRequester* requester = new JsonRequester(this); - connect(requester, &JsonRequester::requestFinished, this, &PSCloudAuth::handleAccessTokenResponse); - connect(requester, &JsonRequester::requestError, this, &PSCloudAuth::handleErrorResponse); - - // NO Authorization header for this endpoint - credentials are in the body! - requester->makePostRequest(PSCloudAuthConsts::TOKEN_URL, "", "application/x-www-form-urlencoded", body, userAgent); -} - -void PSCloudAuth::handleAccessTokenResponse(const QString &url, const QJsonDocument &jsonDocument) -{ - QJsonObject jsonObject = jsonDocument.object(); - QString accessToken = jsonObject["access_token"].toString(); - QString idToken = jsonObject["id_token"].toString(); - int expiresIn = jsonObject["expires_in"].toInt(); - - if (accessToken.isEmpty()) { - QString errorMsg = "Cloud Auth: Failed to get access token from response"; - qWarning() << errorMsg; - emit TokenError(errorMsg); - emit Finished(); - return; - } - - // Calculate expiry timestamp - QDateTime expiry = QDateTime::currentDateTime().addSecs(expiresIn); - - qInfo() << "Cloud Auth: Successfully obtained access token"; - qInfo() << "Cloud Auth: Token expires in" << expiresIn << "seconds (" << expiry.toString() << ")"; - qInfo() << "Cloud Auth: Tokens emitted via signal (not stored - PSCloudAuth is for future catalog API use)"; - - emit TokenResponse(accessToken, idToken, expiresIn); - emit Finished(); -} - -void PSCloudAuth::handleErrorResponse(const QString &url, const QString &error, const QNetworkReply::NetworkError &err) -{ - QString errorMsg = QString("Cloud Auth: Failed to exchange NPSSO token - %1 (Error code: %2)").arg(error).arg(err); - qWarning() << errorMsg; - emit TokenError(errorMsg); - emit Finished(); -} - diff --git a/gui/src/cloudstreaming/psgaikaistreaming.cpp b/gui/src/cloudstreaming/psgaikaistreaming.cpp deleted file mode 100644 index 0e5712e1..00000000 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ /dev/null @@ -1,1714 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#include "cloudstreaming/psgaikaistreaming.h" -#include "cloudstreaming/pskamajisession.h" -#include "cloudstreaming/datacenterping.h" -#include "cloudstreaming/nsurlsession_oauth.h" -#include "chiaki/remote/holepunch.h" -#include "chiaki/common.h" -#include "chiaki/cloudcatalog.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -PSGaikaiStreaming::PSGaikaiStreaming(Settings *settings, QString deviceUid, - QString serviceTypeParam, QString platformParam, - QObject *parent) - : QObject(parent) - , settings(settings) - , duid(deviceUid) - , serviceType(serviceTypeParam.toLower()) - , platform(platformParam.toLower()) -{ - // Determine virtType from platform - if (platform == "ps3") { - virtType = "konan"; - } else if (platform == "ps4") { - virtType = "kratos"; - } else if (platform == "ps5") { - virtType = "cronos"; - } - - // Set service-specific constants based on serviceType - accountBaseUrl = "https://ca.account.sony.com"; - if (serviceType == "pscloud") { - redirectUriUrl = GaikaiConsts::REDIRECT_URI; - userAgentString = GaikaiConsts::USER_AGENT; - oauthApiPath = "/api/authz/v3"; - } else { - // PSNOW - redirectUriUrl = KamajiConsts::REDIRECT_URI; - userAgentString = KamajiConsts::USER_AGENT; - oauthApiPath = "/api/v1"; - } - - manager = new QNetworkAccessManager(this); - manager->setCookieJar(nullptr); // Disable cookie jar - we use manual Cookie headers only - - // Initialize port to 0 (will be set from step12 response) - selectedDatacenterPort = 0; - - // Initialize allocation wait state - allocationMaxWaitSeconds = DEFAULT_ALLOCATION_WAIT_SECONDS; - - // Initialize retry counters - lockSessionRetryCount = 0; - allocationRetryCount = 0; -} - -// Helper function to merge new ping results with existing datacenters in settings -// Updates existing datacenters with new ping data, adds new ones, and keeps old ones that aren't in new results -QJsonArray PSGaikaiStreaming::mergeDatacentersWithExisting(const QJsonArray &newPingResults) -{ - // Load existing datacenters from settings - QString existingJson; - if (serviceType == "pscloud") { - existingJson = settings->GetCloudDatacentersJsonPSCloud(); - } else { - existingJson = settings->GetCloudDatacentersJsonPSNOW(); - } - - // Parse existing datacenters - QJsonArray existingDatacenters; - if (!existingJson.isEmpty()) { - QJsonParseError parseError; - QJsonDocument existingDoc = QJsonDocument::fromJson(existingJson.toUtf8(), &parseError); - if (parseError.error == QJsonParseError::NoError && existingDoc.isArray()) { - existingDatacenters = existingDoc.array(); - } - } - - // Create a map of existing datacenters by name - QHash existingMap; - for (const QJsonValue &val : existingDatacenters) { - QJsonObject dc = val.toObject(); - QString name = dc["dataCenter"].toString(); - if (!name.isEmpty()) { - existingMap[name] = dc; - } - } - - // Update existing entries with new ping results, or add new ones - for (const QJsonValue &val : newPingResults) { - QJsonObject newResult = val.toObject(); - QString name = newResult["dataCenter"].toString(); - if (!name.isEmpty()) { - // Update existing entry or add new one - existingMap[name] = newResult; - } - } - - // Convert merged map back to array - QJsonArray mergedResults; - for (auto it = existingMap.begin(); it != existingMap.end(); ++it) { - mergedResults.append(it.value()); - } - - return mergedResults; -} - -QJsonObject PSGaikaiStreaming::buildRequestGameSpec(QString entitlementId) -{ - QJsonObject spec; - - // Get system timezone automatically - QTimeZone systemTz = QTimeZone::systemTimeZone(); - QDateTime now = QDateTime::currentDateTime(); - int offsetSeconds = systemTz.offsetFromUtc(now); - - // Format as "UTC+HH:MM" or "UTC-HH:MM" - int offsetHours = offsetSeconds / 3600; - int offsetMinutes = qAbs((offsetSeconds % 3600) / 60); - QString timezoneStr; - if (offsetHours >= 0) { - timezoneStr = QString("UTC+%1:%2").arg(offsetHours, 2, 10, QChar('0')).arg(offsetMinutes, 2, 10, QChar('0')); - } else { - timezoneStr = QString("UTC-%1:%2").arg(qAbs(offsetHours), 2, 10, QChar('0')).arg(offsetMinutes, 2, 10, QChar('0')); - } - - // ============================================================================ - // COMMON FIELDS (apply to both PSCLOUD and PSNOW) - // ============================================================================ - - // Core Game Configuration - spec["entitlementId"] = entitlementId; - spec["npEnv"] = "np"; - - // Read resolution and language from settings fresh each time (not cached). - // Prefer the user's manual streaming-language pick; fall back to the - // auto-detected catalog locale when the picker is left on default. The - // manual pick lives in its own setting so the catalog's settledLocale write - // can never clobber it. Gaikai expects the bare language code ("de"), not - // the stored locale ("de-DE"); the lib helper is the single source of truth. - QString locale = settings->GetCloudGameLanguage(); - if (locale.isEmpty()) - locale = settings->GetCloudStoreLocale(); - char gaikaiLang[16]; - chiaki_cloud_gaikai_language(locale.toUtf8().constData(), gaikaiLang, sizeof(gaikaiLang)); - int resolution; - if (serviceType == "pscloud") { - resolution = settings->GetCloudResolutionPSCloud(); - } else { - // PSNOW - resolution = settings->GetCloudResolutionPSNOW(); - } - spec["language"] = QString::fromUtf8(gaikaiLang); - - // Cloud Infrastructure - spec["cloudEndpoint"] = "https://cc.prod.gaikai.com"; - spec["redirectUri"] = redirectUriUrl; - - // Video Resolution (common calculation) - QString resolutionSetting; - int clientWidth, clientHeight; - if (resolution == 720) { - resolutionSetting = "720"; - clientWidth = 1280; - clientHeight = 720; - } else if (resolution == 1440) { - resolutionSetting = "1440"; - clientWidth = 2560; - clientHeight = 1440; - } else if (resolution == 2160) { - resolutionSetting = "2160"; - clientWidth = 3840; - clientHeight = 2160; - } else { - // Default to 1080 (or if invalid value) - resolutionSetting = "1080"; - clientWidth = 1920; - clientHeight = 1080; - } - spec["resolutionSetting"] = resolutionSetting; - spec["clientWidth"] = clientWidth; - spec["clientHeight"] = clientHeight; - spec["adaptiveStreamMode"] = "resize"; - spec["useClientBwLadder"] = true; - - // Audio Upload (common) - spec["audioUploadEnabled"] = true; - spec["audioUploadNumChannels"] = 1; - spec["audioUploadSamplingFrequency"] = 48000; - - // Input Configuration (common) - spec["acceptButton"] = "X"; - - // Protocol (common) - spec["encryptionSupported"] = true; - - // Timezone (common) - automatically detected from system - spec["summerTime"] = 0; - spec["timeZone"] = timezoneStr; - - // HTTP User Agent (common) - spec["httpUserAgent"] = userAgentString; - - // Auth Codes (common - will be updated later in step 9) - spec["gkCloudAuthCode"] = gkCloudAuthCode; - - // Accessibility Features (common - all disabled) - spec["accessibilityMarqueeSpeed"] = 0; - spec["accessibilityLargeText"] = 0; - spec["accessibilityBoldText"] = 0; - spec["accessibilityContrast"] = 0; - spec["accessibilityTtsEnable"] = 0; - spec["accessibilityTtsSpeed"] = 0; - spec["accessibilityTtsVolume"] = 0; - - // Capability Flags (common) - spec["partyCapability"] = false; - spec["homesharing"] = false; - spec["isFirstBoot"] = false; - spec["isPlusMember"] = true; - spec["parentalLevel"] = 0; - spec["yuvCoefficient"] = ""; - - // Common Capabilities - QJsonArray capabilitiesArray; - capabilitiesArray.append("cloudDrivenSenkushaTest"); - - // ============================================================================ - // PSCLOUD (PS5) SPECIFIC FIELDS - // ============================================================================ - if (serviceType == "pscloud") { - // Video Configuration - spec["videoEncoderProfile"] = "hw5.0"; - - // Input Configuration - QJsonArray controllersArray; - controllersArray.append("ds4"); - controllersArray.append("ds5"); - controllersArray.append("xinput"); - spec["connectedControllers"] = controllersArray; - QJsonObject inputObj; - inputObj["controllers"] = controllersArray; - spec["input"] = inputObj; - - // Device/Platform Info - spec["model"] = "portal"; - spec["platform"] = "qlite"; - - // Protocol Settings - spec["gaikaiPlayer"] = "16.4.0"; - spec["protocolVersion"] = 12; - - // Auth Codes - spec["ps3AuthCode"] = ""; - spec["streamServerAuthCode"] = streamServerAuthCode; - - // Capabilities - capabilitiesArray.append("cronos"); - - // Video Stream Settings (PSCLOUD only) - QJsonObject videoStreamSettings; - videoStreamSettings["clientHeight"] = clientHeight; - videoStreamSettings["supportedMaxResolution"] = clientHeight; - QJsonArray videoProfiles; - videoProfiles.append("hevc_hw4"); - videoStreamSettings["supportedVideoEncoderProfiles"] = videoProfiles; - videoStreamSettings["supportedDynamicRange"] = "sdr"; - videoStreamSettings["preferredMaxResolution"] = clientHeight; - videoStreamSettings["preferredDynamicRange"] = "sdr"; - videoStreamSettings["hqMode"] = 1; - spec["videoStreamSettings"] = videoStreamSettings; - - // Audio Stream Settings (PSCLOUD only) - spec["audioChannels"] = "2"; - // Note: audioEncoderProfile is set inside audioStreamSettings for PSCLOUD - spec["audioEncoderProfile"] = "default"; - QJsonObject audioStreamSettings; - audioStreamSettings["audioEncoderProfile"] = "default"; - audioStreamSettings["maxAudioChannels"] = "2"; - audioStreamSettings["preferredNumberAudioChannels"] = "2"; - - // not sure if these should be here or at root level. Either way, not supporting for now - // audioStreamSettings["enable3D"] = true; - // audioStreamSettings["force3DMode"] = true; - // audioStreamSettings["HRTF"] = true; - - spec["audioStreamSettings"] = audioStreamSettings; - } - - // ============================================================================ - // PSNOW (PS3/PS4) SPECIFIC FIELDS - // ============================================================================ - else { - // Audio Configuration - spec["audioChannels"] = "2.1"; - spec["audioEncoderProfile"] = "default"; - - // Video Configuration - spec["videoEncoderProfile"] = "hw4.1"; - - // Input Configuration - QJsonArray controllersArray = QJsonArray::fromStringList({"xinput"}); - spec["connectedControllers"] = controllersArray; - QJsonObject inputObj; - inputObj["controllers"] = controllersArray; - spec["input"] = inputObj; - - // Device/Platform Info - spec["model"] = "WINDOWS"; - spec["platform"] = "PC"; - - // Protocol Settings - spec["gaikaiPlayer"] = "12.5.0"; - spec["protocolVersion"] = 9; - - // Auth Codes - spec["ps3AuthCode"] = ps3AuthCode; - spec["streamServerAuthCode"] = ps3AuthCode; - - // Capabilities - capabilitiesArray.append("kratos"); - } - - // Set capabilities (common, but content differs by service) - spec["capabilities"] = capabilitiesArray; - - // Log the full JSON for inspection - qInfo() << "=== buildRequestGameSpec - Full JSON ==="; - qInfo() << "Service:" << serviceType << "Platform:" << platform; - QByteArray formattedJson = QJsonDocument(spec).toJson(QJsonDocument::Indented); - QString jsonString = QString::fromUtf8(formattedJson); - // Output each line separately so it's properly formatted in logs - QStringList lines = jsonString.split('\n', Qt::SkipEmptyParts); - for (const QString &line : lines) { - if (!line.trimmed().isEmpty()) { - qInfo().noquote() << line; - } - } - qInfo() << "========================================"; - - return spec; -} - -void PSGaikaiStreaming::updateSessionKey(QNetworkReply *reply) -{ - QString newKey = QString::fromUtf8(reply->rawHeader("x-gaikai-session")); - if (!newKey.isEmpty()) { - configKey = newKey; - qInfo() << "Gaikai: Updated session key (length:" << configKey.length() << "):" << configKey; - } -} - -void PSGaikaiStreaming::logDebugRequest(const QString &stepName, const QNetworkRequest &request, const QByteArray &body) -{ - qInfo() << "=== Gaikai" << stepName << "Request ==="; - qInfo() << "URL:" << request.url().toString(); - qInfo() << "Method:" << (body.isEmpty() ? "GET" : "POST"); - qInfo() << "Request Headers:"; - - // QNetworkRequest doesn't have rawHeaderPairs(), so we need to check for headers individually - // Log common headers we set - QByteArray userAgent = request.rawHeader("User-Agent"); - if (!userAgent.isEmpty()) { - qInfo() << " User-Agent:" << QString::fromUtf8(userAgent); - } - QByteArray accept = request.rawHeader("Accept"); - if (!accept.isEmpty()) { - qInfo() << " Accept:" << QString::fromUtf8(accept); - } - QByteArray contentType = request.rawHeader("Content-Type"); - if (!contentType.isEmpty()) { - qInfo() << " Content-Type:" << QString::fromUtf8(contentType); - } - QByteArray xGaikaiSession = request.rawHeader("X-Gaikai-Session"); - if (!xGaikaiSession.isEmpty()) { - qInfo() << " X-Gaikai-Session:" << QString::fromUtf8(xGaikaiSession).left(30) << "..."; - } - QByteArray xGaikaiSessionId = request.rawHeader("X-Gaikai-SessionId"); - if (!xGaikaiSessionId.isEmpty()) { - qInfo() << " X-Gaikai-SessionId:" << QString::fromUtf8(xGaikaiSessionId); - } - // Log all headers using rawHeaderList (available in Qt 5.15+) - QList headerNames = request.rawHeaderList(); - for (const QByteArray &headerName : headerNames) { - // Skip headers we already logged above - QString headerNameStr = QString::fromUtf8(headerName); - if (headerNameStr.compare("User-Agent", Qt::CaseInsensitive) != 0 && - headerNameStr.compare("Accept", Qt::CaseInsensitive) != 0 && - headerNameStr.compare("Content-Type", Qt::CaseInsensitive) != 0 && - headerNameStr.compare("X-Gaikai-Session", Qt::CaseInsensitive) != 0 && - headerNameStr.compare("X-Gaikai-SessionId", Qt::CaseInsensitive) != 0) { - QByteArray headerValue = request.rawHeader(headerName); - qInfo() << " " << headerNameStr << ":" << QString::fromUtf8(headerValue); - } - } - - if (!body.isEmpty()) { - // Try to parse as JSON and format it nicely - QJsonParseError parseError; - QJsonDocument jsonDoc = QJsonDocument::fromJson(body, &parseError); - if (parseError.error == QJsonParseError::NoError) { - qInfo() << "Request Body:"; - QByteArray formattedJson = jsonDoc.toJson(QJsonDocument::Indented); - QString jsonString = QString::fromUtf8(formattedJson); - // Output each line separately so it's properly formatted in logs - QStringList lines = jsonString.split('\n', Qt::SkipEmptyParts); - for (const QString &line : lines) { - if (!line.trimmed().isEmpty()) { - qInfo().noquote() << line; - } - } - } else { - // If not valid JSON, just output as-is - qInfo() << "Request Body:" << QString::fromUtf8(body); - } - } - qInfo() << "========================================"; -} - -void PSGaikaiStreaming::logDebugResponse(const QString &stepName, QNetworkReply *reply) -{ - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - qDebug() << "=== Gaikai" << stepName << "Response ==="; - qDebug() << "HTTP Status:" << statusCode; - qDebug() << "Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qDebug() << " " << header.first << ":" << header.second; - } - - QByteArray responseBody = reply->peek(reply->bytesAvailable()); - qDebug() << "Response Body:" << QString(responseBody); - - if (reply->error() != QNetworkReply::NoError) { - qDebug() << "Network Error:" << reply->error() << reply->errorString(); - } -} - -void PSGaikaiStreaming::StartAllocationFlow(QString entitlementId, const QJSValue &callback) -{ - // Get npsso fresh from settings at the start of each allocation attempt - npsso = settings->GetNpssoToken(); - - qInfo() << "Gaikai Allocation: Starting complete flow"; - qInfo() << " Service Type:" << serviceType; - qInfo() << " Platform:" << platform; - qInfo() << " virtType:" << virtType; - qInfo() << " Entitlement ID:" << entitlementId; - - if (npsso.isEmpty()) { - QString error = "NPSSO token is empty"; - qWarning() << "Gaikai Allocation:" << error; - emit AllocationError(error); - return; - } - - finalCallback = callback; - - // Reset session keys for new allocation - configKey.clear(); - lockSessionKey.clear(); - - // Store entitlement for later use (will be updated with auth codes in step 8) - requestGameSpec = buildRequestGameSpec(entitlementId); - - // Start with Step 0: Get Client IDs (MUST happen FIRST) - step0_GetClientIds(); -} - -// Step 0: Get Client IDs (MUST happen FIRST before step7) -void PSGaikaiStreaming::step0_GetClientIds() -{ - emit AllocationProgress("Getting Client IDs - Step 1 of 10"); - qInfo() << "Gaikai Step 0: Getting client IDs for virtType:" << virtType; - - QString url = QString("%1/client_ids?virtType=%2").arg(GaikaiConsts::GAIKAI_BASE, virtType); - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("Accept", "*/*"); - - logDebugRequest("Step 0: GetClientIds", req); - - QNetworkReply *reply = manager->get(req); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 0 failed:" << reply->errorString(); - emit AllocationError(QString("Client IDs failed: %1").arg(reply->errorString())); - emit Finished(); - return; - } - - QByteArray data = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(data); - QJsonObject jsonObj = jsonDoc.object(); - - gkClientId = jsonObj["gkClientId"].toString(); - ps3GkClientId = jsonObj["ps3GkClientId"].toString(); // Present for PSNOW (PS3/PS4) - streamServerClientId = jsonObj["streamServerClientId"].toString(); // Present for PSCLOUD (PS5) - - qInfo() << "Gaikai Step 0 complete:"; - qInfo() << " gkClientId:" << gkClientId; - if (!ps3GkClientId.isEmpty()) { - qInfo() << " ps3GkClientId:" << ps3GkClientId; - } - if (!streamServerClientId.isEmpty()) { - qInfo() << " streamServerClientId:" << streamServerClientId; - } - - // Continue to Step 7 - step7_GetConfig(); - }); -} - -// Step 7: Get Gaikai configuration -void PSGaikaiStreaming::step7_GetConfig() -{ - emit AllocationProgress("Getting Configuration - Step 2 of 10"); - qInfo() << "Gaikai Step 7: Getting configuration..."; - - QString url = GaikaiConsts::CONFIG_BASE + "/config"; - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - - QJsonObject body; - // Set product/platform based on service type - if (serviceType == "pscloud") { - body["product"] = "qlite"; - body["platform"] = "qlite"; - } else { - body["product"] = "psnow"; - body["platform"] = "PC"; - } - body["sessionId"] = ""; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - logDebugRequest("Step 7: GetConfig", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - logDebugResponse("Step 7: GetConfig", reply); - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 7 failed:" << reply->errorString(); - emit AllocationError(QString("Config failed: %1").arg(reply->errorString())); - emit Finished(); - return; - } - - QByteArray data = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(data); - QJsonObject jsonObj = jsonDoc.object(); - - qDebug() << "Step 7 parsed JSON keys:" << jsonObj.keys(); - - configKey = jsonObj["configKey"].toString(); - qInfo() << "Gaikai Step 7 complete - Got configKey:" << configKey.left(30) << "..."; - - // Continue to Step 8 - step8_StartSession(""); - }); -} - -// Step 8: Start Gaikai session -void PSGaikaiStreaming::step8_StartSession(QString entitlementId) -{ - emit AllocationProgress("Starting Session - Step 3 of 10"); - qInfo() << "Gaikai Step 8: Starting session..."; - - QUrl url(GaikaiConsts::GAIKAI_BASE + "/sessions/start"); - url.setQuery("npEnv=np"); - - QNetworkRequest req(url); - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Gaikai-Session", configKey.toUtf8()); - - // For initial session start, we don't have auth codes yet - QJsonObject initialSpec = requestGameSpec; - initialSpec["gkCloudAuthCode"] = ""; - initialSpec["ps3AuthCode"] = ""; - initialSpec["streamServerAuthCode"] = ""; - - QJsonObject body; - body["requestGameSpecification"] = initialSpec; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - logDebugRequest("Step 8: StartSession", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 8 failed:" << reply->errorString(); - QByteArray errorData = reply->readAll(); - qWarning() << "Server response:" << QString::fromUtf8(errorData); - // Include the server response body in the error: this is where Gaikai reports an - // unowned/invalid entitlement (e.g. {"name":"noGameForEntitlementId",...}), and the - // owned fast-path fallback in CloudStreamingBackend keys off that marker to retry via - // the full resolve/acquire flow. reply->errorString() alone is just "Bad Request". - emit AllocationError(QString("Session start failed: %1 %2") - .arg(reply->errorString(), QString::fromUtf8(errorData))); - emit Finished(); - return; - } - - updateSessionKey(reply); - - QByteArray data = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(data); - QJsonObject jsonObj = jsonDoc.object(); - - gaikaiSessionId = jsonObj["sessionId"].toString(); - // Client IDs are already set from Step 0, but log them for verification - - qInfo() << "Gaikai Step 8 complete:"; - qInfo() << " sessionId:" << gaikaiSessionId; - qInfo() << " gkClientId:" << gkClientId; - if (!ps3GkClientId.isEmpty()) { - qInfo() << " ps3GkClientId:" << ps3GkClientId; - } - if (!streamServerClientId.isEmpty()) { - qInfo() << " streamServerClientId:" << streamServerClientId; - } - - // Continue to Step 8a - step8a_GetGkAuthCode(); - }); -} - -void PSGaikaiStreaming::performOAuthNative(const QString &urlString, const QString &stepName, - std::function onSuccess, - std::function onError) -{ - qInfo() << "=== Gaikai" << stepName << "(via NSURLSession) ==="; - qInfo() << "URL:" << urlString; - - performNativeOAuthGet(urlString, userAgentString, npsso, - [this, stepName, onSuccess, onError](NativeOAuthResult result) { - QMetaObject::invokeMethod(this, [=]() { - qInfo() << "Gaikai" << stepName << "HTTP" << result.statusCode; - - if (result.statusCode == 0) { - qWarning() << "Gaikai" << stepName << "network error:" << result.errorMessage; - onError(QString("OAuth network error: %1").arg(result.errorMessage)); - return; - } - - if (result.statusCode >= 400) { - qWarning() << "Gaikai" << stepName << "failed: HTTP" << result.statusCode; - onError(QString("OAuth authorization failed: HTTP %1").arg(result.statusCode)); - return; - } - - if (result.statusCode != 302) { - qWarning() << "Gaikai" << stepName << "unexpected status:" << result.statusCode; - onError(QString("OAuth authorization failed: Expected redirect, got HTTP %1").arg(result.statusCode)); - return; - } - - if (result.locationHeader.isEmpty()) { - qWarning() << "Gaikai" << stepName << "no Location header in 302"; - onError("OAuth authorization failed: No Location header in redirect"); - return; - } - - QUrl redirectUrl = QUrl::fromEncoded(result.locationHeader.toUtf8()); - QString code = QUrlQuery(redirectUrl).queryItemValue("code"); - - if (code.isEmpty()) { - qWarning() << "Gaikai" << stepName << "no code in redirect:" << result.locationHeader; - onError("OAuth authorization failed: No authorization code received"); - return; - } - - qInfo() << "Gaikai" << stepName << "complete - Got auth code:" << code.left(20) << "..."; - onSuccess(code); - }); - }); -} - -// Step 8a: Get gkClientId authorization code (cloudAuthCode) -void PSGaikaiStreaming::step8a_GetGkAuthCode() -{ - emit AllocationProgress("Getting Tokens - Step 4 of 10"); - qInfo() << "Gaikai Step 8a: Getting gkClientId auth code (cloudAuthCode)..."; - - QUrl url(accountBaseUrl + oauthApiPath + "/oauth/authorize"); - QUrlQuery query; - query.addQueryItem("response_type", "code"); - query.addQueryItem("client_id", gkClientId); - query.addQueryItem("redirect_uri", redirectUriUrl); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - query.addQueryItem("duid", duid); - - if (serviceType == "pscloud") { - query.addQueryItem("smcid", "qlite"); - query.addQueryItem("applicationId", "qlite"); - query.addQueryItem("mid", "qlite"); - query.addQueryItem("scope", "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s"); - } else { - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("mid", "PSNOW"); - query.addQueryItem("scope", "kamaji:commerce_native versa:user_update_entitlements_first_play kamaji:lists"); - query.addQueryItem("renderMode", "mobilePortrait"); - query.addQueryItem("hidePageElements", "forgotPasswordLink"); - query.addQueryItem("displayFooter", "none"); - query.addQueryItem("disableLinks", "qriocityLink"); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("noEVBlock", "true"); - } - - url.setQuery(query); - - performOAuthNative(url.toString(QUrl::FullyEncoded), "Step 8a: GetGkAuthCode", - [this](QString code) { - gkCloudAuthCode = code; - qInfo() << "Gaikai Step 8a complete - Got gkCloudAuthCode:" << gkCloudAuthCode.left(20) << "..."; - step8b_GetPs3AuthCode(); - }, - [this](QString error) { - emit AllocationError(error); - emit Finished(); - }); -} - -// Step 8b: Get ps3GkClientId/streamServerClientId authorization code (serverAuthCode) -void PSGaikaiStreaming::step8b_GetPs3AuthCode() -{ - emit AllocationProgress("Getting Server Tokens - Step 5 of 10"); - qInfo() << "Gaikai Step 8b: Getting server auth code..."; - - QUrl url(accountBaseUrl + oauthApiPath + "/oauth/authorize"); - QUrlQuery query; - query.addQueryItem("response_type", "code"); - query.addQueryItem("redirect_uri", redirectUriUrl); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - - if (serviceType == "pscloud") { - // PSCLOUD (PS5): Use streamServerClientId - qInfo() << " Using streamServerClientId for PSCLOUD"; - query.addQueryItem("client_id", streamServerClientId); - query.addQueryItem("smcid", "qlite"); - query.addQueryItem("applicationId", "qlite"); - query.addQueryItem("mid", "qlite"); - query.addQueryItem("scope", "id_token:duid id_token:online_id openid oauth:create_authn_ticket_for_cloud_console_signin"); - query.addQueryItem("duid", duid); - } else { - // PSNOW (PS3/PS4): Use ps3GkClientId - qInfo() << " Using ps3GkClientId for PSNOW"; - query.addQueryItem("client_id", ps3GkClientId); - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("mid", "PSNOW"); - - // Platform-specific scope - if (platform == "ps3") { - query.addQueryItem("scope", "kamaji:commerce_native"); - } else { - query.addQueryItem("scope", "sso:none"); // PS4 - } - - // Include DUID for PS4, omit for PS3 - if (platform != "ps3") { - query.addQueryItem("duid", duid); - } - - query.addQueryItem("renderMode", "mobilePortrait"); - query.addQueryItem("hidePageElements", "forgotPasswordLink"); - query.addQueryItem("displayFooter", "none"); - query.addQueryItem("disableLinks", "qriocityLink"); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("noEVBlock", "true"); - } - - url.setQuery(query); - - performOAuthNative(url.toString(QUrl::FullyEncoded), "Step 8b: GetServerAuthCode", - [this](QString code) { - if (serviceType == "pscloud") { - streamServerAuthCode = code; - ps3AuthCode = ""; - qInfo() << "Gaikai Step 8b complete - Got streamServerAuthCode:" << streamServerAuthCode.left(20) << "..."; - } else { - ps3AuthCode = code; - streamServerAuthCode = code; - qInfo() << "Gaikai Step 8b complete - Got ps3AuthCode (used for both):" << ps3AuthCode.left(20) << "..."; - } - - requestGameSpec["gkCloudAuthCode"] = gkCloudAuthCode; - requestGameSpec["ps3AuthCode"] = ps3AuthCode; - requestGameSpec["streamServerAuthCode"] = streamServerAuthCode; - - step9_AuthorizeSession(); - }, - [this](QString error) { - emit AllocationError(error); - emit Finished(); - }); -} - -// Step 9: Authorize Gaikai session -void PSGaikaiStreaming::step9_AuthorizeSession() -{ - emit AllocationProgress("Authorizing Session - Step 6 of 10"); - qInfo() << "Gaikai Step 9: Authorizing session..."; - - QString urlStr = GaikaiConsts::GAIKAI_BASE + "/sessions/" + gaikaiSessionId + "/authorize"; - - QNetworkRequest req{QUrl(urlStr)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Gaikai-SessionId", gaikaiSessionId.toUtf8()); - req.setRawHeader("X-Gaikai-Session", configKey.toUtf8()); - - QJsonObject body; - body["requestGameSpecification"] = requestGameSpec; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - - logDebugRequest("Step 9: AuthorizeSession", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray responseBody = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Gaikai Step 9 Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - if (!responseBody.isEmpty()) { - qInfo() << " Body:" << QString::fromUtf8(responseBody); - } - } - - // Check for HTTP errors (401, 400, etc.) - if (statusCode != 200) { - QString errorMsg = QString("Authorize failed with status %1").arg(statusCode); - - // Check for PS Plus subscription error via event header - QByteArray eventHeader = reply->rawHeader("x-gaikai-event"); - bool isPSPlusError = false; - if (!eventHeader.isEmpty()) { - qWarning() << "Gaikai event:" << QString::fromUtf8(eventHeader); - // Parse event header JSON to check event code - QJsonParseError parseError; - QJsonDocument eventDoc = QJsonDocument::fromJson(eventHeader, &parseError); - if (parseError.error == QJsonParseError::NoError && eventDoc.isObject()) { - QJsonObject eventObj = eventDoc.object(); - QString eventCode = eventObj["eventCode"].toString(); - if (eventCode == "002.2001") { - isPSPlusError = true; - } - } - } - - // Parse JSON error response for detailed error messages - if (!responseBody.isEmpty()) { - QJsonParseError parseError; - QJsonDocument errorDoc = QJsonDocument::fromJson(responseBody, &parseError); - if (parseError.error == QJsonParseError::NoError && errorDoc.isObject()) { - QJsonObject errorObj = errorDoc.object(); - - // Extract errors array - if (errorObj.contains("errors") && errorObj["errors"].isArray()) { - QJsonArray errorsArray = errorObj["errors"].toArray(); - QStringList errorDescriptions; - for (const QJsonValue &errorValue : errorsArray) { - if (errorValue.isObject()) { - QJsonObject error = errorValue.toObject(); - if (error.contains("description")) { - errorDescriptions << error["description"].toString(); - } else if (error.contains("eventCode")) { - QString eventCode = error["eventCode"].toString(); - if (eventCode == "002.2001") { - isPSPlusError = true; - } - errorDescriptions << QString("Event: %1").arg(eventCode); - } - } - } - if (!errorDescriptions.isEmpty()) { - errorMsg += "\n" + errorDescriptions.join("\n"); - } - } else if (errorObj.contains("description")) { - errorMsg += ": " + errorObj["description"].toString(); - } else { - // Fallback to raw body if we can't parse - errorMsg += ": " + QString::fromUtf8(responseBody); - } - } else { - // Not JSON, use raw body - errorMsg += ": " + QString::fromUtf8(responseBody); - } - } - - qWarning() << "Gaikai Step 9 failed:" << errorMsg; - - // Emit PS Plus subscription error if detected - if (isPSPlusError) { - emit psPlusSubscriptionError(); - } - emit AllocationError(errorMsg); - emit Finished(); - return; - } - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 9 failed:" << reply->errorString(); - if (!responseBody.isEmpty()) { - qWarning() << "Response body:" << QString::fromUtf8(responseBody); - } - emit AllocationError(QString("Authorize failed: %1").arg(reply->errorString())); - emit Finished(); - return; - } - - updateSessionKey(reply); - - qInfo() << "Gaikai Step 9 complete - Session authorized"; - - // Continue to Step 10 - step10_LockSession(); - }); -} - -// Helper function to parse x-gaikai-event header -static QString parseGaikaiEventName(QNetworkReply *reply) -{ - QByteArray eventHeader = reply->rawHeader("x-gaikai-event"); - if (eventHeader.isEmpty()) { - return QString(); - } - - QJsonDocument eventDoc = QJsonDocument::fromJson(eventHeader); - if (eventDoc.isNull() || !eventDoc.isObject()) { - return QString(); - } - - QJsonObject eventObj = eventDoc.object(); - return eventObj["name"].toString(); -} - -// Step 10: Lock session -void PSGaikaiStreaming::step10_LockSession() -{ - if (lockSessionRetryCount == 0) { - emit AllocationProgress("Locking Session - Step 7 of 10"); - } - qInfo() << "Gaikai Step 10: Locking session... (attempt" << (lockSessionRetryCount + 1) << ")"; - - QString urlStr = GaikaiConsts::GAIKAI_BASE + "/sessions/" + gaikaiSessionId + "/lock?forceLogout=true"; - - QNetworkRequest req{QUrl(urlStr)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Gaikai-SessionId", gaikaiSessionId.toUtf8()); - req.setRawHeader("X-Gaikai-Session", configKey.toUtf8()); - - QJsonObject body; - body["requestGameSpecification"] = requestGameSpec; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - logDebugRequest("Step 10: LockSession", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 10 failed:" << reply->errorString(); - emit AllocationError(QString("Lock failed: %1").arg(reply->errorString())); - emit Finished(); - return; - } - - updateSessionKey(reply); - - QByteArray data = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(data); - QJsonObject jsonObj = jsonDoc.object(); - - bool lockAcquired = jsonObj["lockAcquired"].toBool(); - int pollFrequency = jsonObj["pollFrequency"].toInt(10); // Default 10 seconds - - qInfo() << "Gaikai Step 10 response - Lock acquired:" << lockAcquired << ", pollFrequency:" << pollFrequency; - - if (!lockAcquired) { - // Extract event name from header if available - QString eventName = parseGaikaiEventName(reply); - lockSessionRetryCount++; - - if (lockSessionRetryCount > MAX_LOCK_SESSION_RETRIES) { - qWarning() << "Lock session max retries exceeded:" << lockSessionRetryCount << "(max:" << MAX_LOCK_SESSION_RETRIES << ")"; - emit AllocationError(QString("Lock session failed: Could not acquire lock after %1 attempts").arg(MAX_LOCK_SESSION_RETRIES)); - emit Finished(); - return; - } - - QString message; - if (!eventName.isEmpty()) { - message = QString("Closing old session (%1) - Attempt %2").arg(eventName).arg(lockSessionRetryCount); - } else { - message = QString("Closing old session - Attempt %1").arg(lockSessionRetryCount); - } - emit AllocationProgress(message); - - qInfo() << "Lock not acquired, retrying in" << pollFrequency << "seconds... (attempt" << lockSessionRetryCount << "of" << MAX_LOCK_SESSION_RETRIES << ")"; - - // Retry after pollFrequency seconds - QTimer::singleShot(pollFrequency * 1000, this, [this]() { - step10_LockSession(); - }); - return; - } - - // Lock acquired successfully - reset retry counter - lockSessionRetryCount = 0; - - // Store the session key from LOCK response for use in ping - lockSessionKey = configKey; - qInfo() << "Gaikai Step 10: Stored LOCK session key for ping (length:" << lockSessionKey.length() << "):" << lockSessionKey.left(50) << "..."; - - // Continue to Step 11 - step11_GetDatacenters(); - }); -} - -// Step 11: Get available datacenters -void PSGaikaiStreaming::step11_GetDatacenters() -{ - emit AllocationProgress("Getting Datacenters - Step 8 of 10"); - qInfo() << "Gaikai Step 11: Getting available datacenters..."; - - QString urlStr = GaikaiConsts::GAIKAI_BASE + "/sessions/" + gaikaiSessionId + "/datacenters"; - - QNetworkRequest req{QUrl(urlStr)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Gaikai-SessionId", gaikaiSessionId.toUtf8()); - req.setRawHeader("X-Gaikai-Session", configKey.toUtf8()); - - QJsonObject body; - body["requestGameSpecification"] = requestGameSpec; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - logDebugRequest("Step 11: GetDatacenters", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 11 failed:" << reply->errorString(); - emit AllocationError(QString("Get datacenters failed: %1").arg(reply->errorString())); - emit Finished(); - return; - } - - updateSessionKey(reply); - - QByteArray data = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(data); - QJsonArray datacenters = jsonDoc.array(); - - qInfo() << "Gaikai Step 11 complete - Available datacenters:" << datacenters.size(); - for (const QJsonValue &dc : datacenters) { - QJsonObject dcObj = dc.toObject(); - qInfo() << " -" << dcObj["dataCenter"].toString() - << dcObj["publicIp"].toString() << ":" << dcObj["port"].toInt() - << "maxBw:" << dcObj["maxBandwidth"].toInt(); - } - - if (datacenters.isEmpty()) { - qWarning() << "Gaikai Step 11: No datacenters available"; - emit AllocationError("No datacenters available"); - emit Finished(); - return; - } - - // Seed the picker with the raw datacenter list ONLY when nothing is saved - // yet. Never overwrite a previously-saved list here: it carries real ping - // RTTs from a prior Auto run, and manual mode below won't re-ping, so - // clobbering it with this no-RTT list would drop the ms from the picker. - QString existingDatacentersJson = (serviceType == "pscloud") - ? settings->GetCloudDatacentersJsonPSCloud() - : settings->GetCloudDatacentersJsonPSNOW(); - bool hasExistingDatacenters = false; - if (!existingDatacentersJson.isEmpty()) { - QJsonDocument existingDoc = QJsonDocument::fromJson(existingDatacentersJson.toUtf8()); - hasExistingDatacenters = existingDoc.isArray() && !existingDoc.array().isEmpty(); - } - if (!hasExistingDatacenters) { - QJsonDocument datacentersDoc(datacenters); - if (serviceType == "pscloud") { - settings->SetCloudDatacentersJsonPSCloud(datacentersDoc.toJson(QJsonDocument::Compact)); - } else { - settings->SetCloudDatacentersJsonPSNOW(datacentersDoc.toJson(QJsonDocument::Compact)); - } - } - - // Check if a specific datacenter is selected (non-auto) - QString selectedDatacenterSetting; - if (serviceType == "pscloud") { - selectedDatacenterSetting = settings->GetCloudDatacenterPSCloud(); - } else { - selectedDatacenterSetting = settings->GetCloudDatacenterPSNOW(); - } - - if (selectedDatacenterSetting != "Auto" && !selectedDatacenterSetting.isEmpty()) { - // Find the selected datacenter in the list - QJsonObject selectedDc; - bool found = false; - for (const QJsonValue &dcValue : datacenters) { - QJsonObject dc = dcValue.toObject(); - if (dc["dataCenter"].toString() == selectedDatacenterSetting) { - selectedDc = dc; - found = true; - break; - } - } - - if (!found) { - qWarning() << "Selected datacenter" << selectedDatacenterSetting << "not found in available datacenters"; - emit AllocationError(QString("Selected datacenter '%1' not available").arg(selectedDatacenterSetting)); - emit Finished(); - return; - } - - // Create dummy ping result with hardcoded values - QJsonObject dummyPingResult; - dummyPingResult["dataCenter"] = selectedDc["dataCenter"].toString(); - // Keep the dummy schema identical to DatacenterPing results, because /datacenters/select - // appears to depend on fields like "rtts" (and may return an empty body otherwise). - int dummyRttMs = 20; // 20ms dummy RTT - dummyPingResult["rtt"] = dummyRttMs; - dummyPingResult["rtts"] = QJsonArray::fromVariantList({dummyRttMs}); - dummyPingResult["mtu_in"] = 1454; // Hardcoded MTU in - dummyPingResult["mtu_out"] = 1254; // Hardcoded MTU out - dummyPingResult["port"] = selectedDc["port"].toInt(); - dummyPingResult["publicIp"] = selectedDc["publicIp"].toString(); - dummyPingResult["maxBandwidth"] = selectedDc["maxBandwidth"].toInt(); - - qInfo() << "Bypassing ping tests - using manually selected datacenter:" << selectedDatacenterSetting; - qInfo() << "Using dummy ping values: RTT=20ms, MTU in=1454, MTU out=1254"; - qInfo() << "Note: Dummy ping values are NOT saved to settings (preserving existing real ping data)"; - - // Create single result array for step12 (don't save dummy values to settings) - QJsonArray singleResult; - singleResult.append(dummyPingResult); - - // Skip ping and go directly to step 12 (using dummy values for this session only) - step12_SelectDatacenter(singleResult); - return; - } - - // Auto mode: Use the session key from Step 10 (LOCK) for ping - QString pingSessionKey = lockSessionKey; - - // Ping all datacenters using senkusha handshake - emit AllocationProgress("Pinging Datacenters - Step 8 of 10"); - DatacenterPing::pingAllDatacentersWithTimeout(datacenters, pingSessionKey, serviceType, settings, - [this, datacenters](QJsonArray pingResults) { - qInfo() << "Gaikai Step 11: Ping callback invoked with" << pingResults.size() << "results"; - - // IMPORTANT: Use the CURRENT session key (configKey) when calling step12, not the one from when ping started - // The session key may have been updated during the ping, so we use the latest value - qInfo() << "Gaikai Step 11: Using current session key for step 12:" << configKey.left(30) << "..."; - - // Create a map of ping results by datacenter name - QHash pingResultsMap; - for (const QJsonValue &val : pingResults) { - QJsonObject result = val.toObject(); - pingResultsMap[result["dataCenter"].toString()] = result; - } - - // Build final results: use ping results where available, dummy data for others - QJsonArray allResults; - for (const QJsonValue &dcValue : datacenters) { - QJsonObject dc = dcValue.toObject(); - QString datacenterName = dc["dataCenter"].toString(); - - if(pingResultsMap.contains(datacenterName)) { - // Use actual ping result - QJsonObject measured = pingResultsMap[datacenterName]; - measured["measured"] = true; // real RTT measurement - allResults.append(measured); - } else { - // Use dummy data (999ms RTT) for datacenters that weren't pinged. - // Mark it unmeasured so the latency gate doesn't treat a failed - // measurement as genuinely-high latency. - QJsonObject dummyResult; - dummyResult["dataCenter"] = datacenterName; - dummyResult["rtt"] = 999; - dummyResult["rtts"] = QJsonArray::fromVariantList({999}); - dummyResult["mtu_in"] = 0; - dummyResult["mtu_out"] = 0; - dummyResult["port"] = dc["port"].toInt(); - dummyResult["publicIp"] = dc["publicIp"].toString(); - dummyResult["maxBandwidth"] = dc["maxBandwidth"].toInt(); - dummyResult["measured"] = false; - allResults.append(dummyResult); - } - } - - // Sort by RTT (lowest first) - std::vector resultsList; - for (const QJsonValue &val : allResults) { - resultsList.push_back(val.toObject()); - } - std::sort(resultsList.begin(), resultsList.end(), [](const QJsonObject &a, const QJsonObject &b) { - return a["rtt"].toInt() < b["rtt"].toInt(); - }); - QJsonArray sortedResults; - for (const QJsonObject &obj : resultsList) { - sortedResults.append(obj); - } - - // Merge with existing datacenters (update existing, add new, keep old ones) - QJsonArray mergedResults = mergeDatacentersWithExisting(sortedResults); - - // Save merged datacenters to settings - use service-specific method - QJsonDocument pingResultsDoc(mergedResults); - if (serviceType == "pscloud") { - settings->SetCloudDatacentersJsonPSCloud(pingResultsDoc.toJson(QJsonDocument::Compact)); - } else { - settings->SetCloudDatacentersJsonPSNOW(pingResultsDoc.toJson(QJsonDocument::Compact)); - } - - qInfo() << "Gaikai Step 11: Ping complete. Results:"; - for (const QJsonValue &val : sortedResults) { - QJsonObject dc = val.toObject(); - qInfo() << " -" << dc["dataCenter"].toString() << ":" << dc["rtt"].toInt() << "ms"; - } - - // Continue to Step 12 (will use current configKey value) - step12_SelectDatacenter(sortedResults); - }); - }); -} - -// Step 12: Select datacenter -void PSGaikaiStreaming::step12_SelectDatacenter(QJsonArray pingResults) -{ - // Determine which datacenter to select - QString selectedDatacenterSetting; - if (serviceType == "pscloud") { - selectedDatacenterSetting = settings->GetCloudDatacenterPSCloud(); - } else { - // PSNOW - selectedDatacenterSetting = settings->GetCloudDatacenterPSNOW(); - } - - if (selectedDatacenterSetting == "Auto" || selectedDatacenterSetting.isEmpty()) { - // Auto-select: choose the datacenter with the lowest RTT - if (!pingResults.isEmpty()) { - QJsonObject bestDc = pingResults[0].toObject(); // Already sorted by RTT - selectedDatacenter = bestDc["dataCenter"].toString(); - selectedDatacenterPingResult = bestDc; // Store full ping result - qInfo() << "Auto-selected datacenter:" << selectedDatacenter << "with RTT:" << bestDc["rtt"].toInt() << "ms"; - } else { - qWarning() << "No ping results available for auto-selection"; - emit AllocationError("No datacenters available"); - emit Finished(); - return; - } - } else { - // Use the manually selected datacenter - selectedDatacenter = selectedDatacenterSetting; - qInfo() << "Using manually selected datacenter:" << selectedDatacenter; - - // Find the ping results for this datacenter - bool found = false; - for (const QJsonValue &val : pingResults) { - QJsonObject pingResult = val.toObject(); - if (pingResult["dataCenter"].toString() == selectedDatacenter) { - found = true; - selectedDatacenterPingResult = pingResult; // Store full ping result - qInfo() << "Found ping results for" << selectedDatacenter << "- RTT:" << pingResult["rtt"].toInt() << "ms"; - break; - } - } - - if (!found) { - qWarning() << "Selected datacenter" << selectedDatacenter << "not found in ping results, falling back to auto-select"; - if (!pingResults.isEmpty()) { - QJsonObject bestDc = pingResults[0].toObject(); - selectedDatacenter = bestDc["dataCenter"].toString(); - selectedDatacenterPingResult = bestDc; // Store full ping result - } else { - emit AllocationError("Selected datacenter not available"); - emit Finished(); - return; - } - } - } - - // Validate ping for auto-selected datacenters (manual selection bypasses this check). - // Only gate on a REAL measurement: when the ping couldn't complete the result is a - // fabricated 999ms placeholder (measured=false), which must not be mistaken for genuine - // high latency — otherwise a transient ping failure blocks an otherwise-fine datacenter. - bool isAutoSelected = (selectedDatacenterSetting == "Auto" || selectedDatacenterSetting.isEmpty()); - if (isAutoSelected) { - const bool measured = selectedDatacenterPingResult.value("measured").toBool(false); - int rtt_ms = selectedDatacenterPingResult["rtt"].toInt(0); - if (measured && rtt_ms > 80) { - qWarning() << "Selected datacenter ping too high:" << selectedDatacenter << "RTT:" << rtt_ms << "ms (max: 80ms)"; - emit pingTimeoutError(); - emit AllocationError("Ping must be < 80ms to start a cloud session"); - emit Finished(); - return; - } - if (!measured) { - qWarning() << "Datacenter latency could not be measured for" << selectedDatacenter - << "- proceeding without the latency gate (ping measurement failed, not necessarily high latency)"; - } - } - - emit AllocationProgress(QString("Selecting Datacenter (%1) - Step 9 of 10").arg(selectedDatacenter)); - qInfo() << "Gaikai Step 12: Selecting datacenter:" << selectedDatacenter; - qInfo() << "Gaikai Step 12: Using session key:" << configKey.left(30) << "..."; - - // IMPORTANT: - // Step 12 responses are sometimes empty (no JSON body), but we already know the correct - // datacenter port from Step 11 (datacenters list / ping results). Preserve it here so - // Step 13 never falls back to a wrong default like 2053 when the real port is e.g. 40101. - int portFromPing = selectedDatacenterPingResult["port"].toInt(0); - if (portFromPing > 0) { - selectedDatacenterPort = portFromPing; - qInfo() << "Gaikai Step 12: Using port from ping results:" << selectedDatacenterPort; - } else if (selectedDatacenterPort > 0) { - qInfo() << "Gaikai Step 12: Using previously known port:" << selectedDatacenterPort; - } else { - selectedDatacenterPort = 2053; // final fallback (primarily PSNOW legacy) - qWarning() << "Gaikai Step 12: No port in ping results; defaulting to" << selectedDatacenterPort; - } - - QString urlStr = GaikaiConsts::GAIKAI_BASE + "/sessions/" + gaikaiSessionId + "/datacenters/select"; - - QNetworkRequest req{QUrl(urlStr)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Gaikai-SessionId", gaikaiSessionId.toUtf8()); - req.setRawHeader("X-Gaikai-Session", configKey.toUtf8()); - - QJsonObject body; - body["requestGameSpecification"] = requestGameSpec; - body["pingResults"] = pingResults; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - - logDebugRequest("Step 12: SelectDatacenter", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - QByteArray errorData = reply->readAll(); - QString errorStr = QString::fromUtf8(errorData); - qWarning() << "Gaikai Step 12 failed:" << reply->errorString(); - qWarning() << "Server response:" << errorStr; - - // Parse error response to get detailed error message - QString detailedError = reply->errorString(); - QJsonParseError parseError; - QJsonDocument errorDoc = QJsonDocument::fromJson(errorData, &parseError); - if (parseError.error == QJsonParseError::NoError && errorDoc.isObject()) { - QJsonObject errorObj = errorDoc.object(); - if (errorObj.contains("errors") && errorObj["errors"].isArray()) { - QJsonArray errors = errorObj["errors"].toArray(); - if (!errors.isEmpty() && errors[0].isObject()) { - QJsonObject firstError = errors[0].toObject(); - if (firstError.contains("description")) { - detailedError = firstError["description"].toString(); - } else if (firstError.contains("eventCode")) { - detailedError = QString("Error %1: %2") - .arg(firstError["eventCode"].toString()) - .arg(firstError.contains("description") ? firstError["description"].toString() : "Unknown error"); - } - } - } - } - - emit AllocationError(QString("Select datacenter failed: %1").arg(detailedError)); - emit Finished(); - return; - } - - updateSessionKey(reply); - - QByteArray data = reply->readAll(); - if (data.trimmed().isEmpty()) { - qWarning() << "Gaikai Step 12 failed: Empty response body from /datacenters/select"; - qWarning() << "This usually indicates pingResults format mismatch (e.g. missing rtts) or an auth/session issue."; - emit AllocationError("Select datacenter failed: empty response body (check pingResults format)"); - emit Finished(); - return; - } - - QJsonParseError parseError; - QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &parseError); - if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) { - qWarning() << "Gaikai Step 12 failed: Invalid JSON response from /datacenters/select:" << parseError.errorString(); - qWarning() << "Raw response:" << QString::fromUtf8(data); - emit AllocationError(QString("Select datacenter failed: invalid JSON response (%1)").arg(parseError.errorString())); - emit Finished(); - return; - } - - QJsonObject selected = jsonDoc.object(); - - // Log full response for debugging - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Step 12: Select Datacenter Response ==="; - qInfo() << "Full response:" << QString::fromUtf8(data); - qInfo() << "==========================================="; - } - - // Extract port from Step 12 response if present; otherwise keep the port we already had. - // The port might be in the root object or in a nested "network" object. - int portFromResponse = selected["port"].toInt(0); - if (portFromResponse <= 0 && selected.contains("network") && selected["network"].isObject()) { - QJsonObject network = selected["network"].toObject(); - portFromResponse = network["port"].toInt(0); - } - - if (portFromResponse > 0) { - selectedDatacenterPort = portFromResponse; - qInfo() << "Gaikai Step 12: Using port from response:" << selectedDatacenterPort; - } else if (selectedDatacenterPort <= 0) { - qWarning() << "Gaikai Step 12: No valid port in response and no previously known port; defaulting to 2053"; - qWarning() << "Response keys:" << selected.keys(); - if (selected.contains("network")) { - qWarning() << "Network object keys:" << selected["network"].toObject().keys(); - } - selectedDatacenterPort = 2053; - } else { - qWarning() << "Gaikai Step 12: No valid port in response; keeping existing port:" << selectedDatacenterPort; - } - - qInfo() << "Gaikai Step 12 complete - Selected:" << selectedDatacenter - << selectedDatacenterPingResult["publicIp"].toString() << ":" << selectedDatacenterPort; - - // Continue to Step 13 (port will be used in network object and also extracted from allocate response) - step13_AllocateSlot(); - }); -} - -// Step 13: Allocate streaming slot -void PSGaikaiStreaming::step13_AllocateSlot() -{ - if (allocationRetryCount == 0) { - emit AllocationProgress("Allocating Streaming Slot - Step 10 of 10"); - } - qInfo() << "Gaikai Step 13: Allocating streaming slot... (attempt" << (allocationRetryCount + 1) << ")"; - qInfo() << "Gaikai Step 13: Using session key:" << configKey.left(30) << "..."; - - QString urlStr = GaikaiConsts::GAIKAI_BASE + "/sessions/" + gaikaiSessionId + "/allocate"; - - QNetworkRequest req{QUrl(urlStr)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Gaikai-SessionId", gaikaiSessionId.toUtf8()); - req.setRawHeader("X-Gaikai-Session", configKey.toUtf8()); - - QJsonObject body; - body["requestGameSpecification"] = requestGameSpec; - body["dataCenter"] = selectedDatacenter; - - // Network info (use real values from ping results, port from step12 response) - QJsonObject network; - const unsigned int cloud_bw_kbps = serviceType == "pscloud" - ? settings->GetCloudBitratePSCloud() - : settings->GetCloudBitratePSNOW(); - network["bwKbpsSent"] = static_cast(cloud_bw_kbps); - network["bwLoss"] = 0.001; - // Use real MTU values from ping results, with fallback to defaults - network["mtu"] = selectedDatacenterPingResult["mtu_in"].toInt(1454); - network["rtt"] = selectedDatacenterPingResult["rtt"].toInt(25); - network["port"] = selectedDatacenterPort; // Use port from step12 (dynamic) - network["bwKbpsReceived"] = static_cast(cloud_bw_kbps); - network["bwLossUpstream"] = 0; - // Use real outbound MTU from ping results, with fallback to default - network["mtuUpstream"] = selectedDatacenterPingResult["mtu_out"].toInt(1254); - body["network"] = network; - - qInfo() << "Gaikai Step 13: Using network values - RTT:" << network["rtt"].toInt() - << "ms, MTU in:" << network["mtu"].toInt() - << ", MTU out:" << network["mtuUpstream"].toInt(); - - body["stateExecutionTime"] = 5974.7632; - body["streamTestTime"] = 11262.8423; - - QJsonDocument doc(body); - QByteArray requestBody = doc.toJson(); - - logDebugRequest("Step 13: AllocateSlot", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "Gaikai Step 13 failed:" << reply->errorString(); - QByteArray errorData = reply->readAll(); - qWarning() << "Server response:" << QString::fromUtf8(errorData); - emit AllocationError(QString("Allocate failed: %1").arg(reply->errorString())); - emit Finished(); - return; - } - - // Ensure every response can rotate the x-gaikai-session key, especially important - // when the server returns queued/dataMigration and we need to poll/retry. - updateSessionKey(reply); - - QByteArray data = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(data); - QJsonObject allocation = jsonDoc.object(); - - // Log the full allocation response for inspection - qInfo() << "=== Step 13: Allocate Response - Full JSON ==="; - qInfo() << jsonDoc.toJson(QJsonDocument::Indented); - qInfo() << "=============================================="; - - // Check if we need to wait and retry (queued or data migration) - bool queued = allocation["queued"].toBool(); - bool dataMigration = allocation["dataMigration"].toBool(); - int pollFrequency = allocation["pollFrequency"].toInt(15); // Default 15 seconds - - if (queued || dataMigration) { - // Initialize timer and calculate max wait time on first wait - if (!allocationWaitTimer.isValid()) { - allocationWaitTimer.start(); - - // Calculate max wait time from waitTimeEstimate (multiply by 2 for safety, cap at 15 min, fallback to 5 min) - int waitTimeEstimate = allocation["waitTimeEstimate"].toInt(-1); - if (waitTimeEstimate > 0) { - allocationMaxWaitSeconds = waitTimeEstimate * 2; // Multiply by 2 for safety - if (allocationMaxWaitSeconds > MAX_ALLOCATION_WAIT_SECONDS) { - allocationMaxWaitSeconds = MAX_ALLOCATION_WAIT_SECONDS; // Cap at 15 minutes - } - qInfo() << "Allocation queued/data migration. Using waitTimeEstimate:" << waitTimeEstimate - << "seconds (doubled to" << allocationMaxWaitSeconds << "seconds for safety, max 15 min)"; - } else { - allocationMaxWaitSeconds = DEFAULT_ALLOCATION_WAIT_SECONDS; // Fallback to 5 minutes - qInfo() << "Allocation queued/data migration. No waitTimeEstimate, using default:" << allocationMaxWaitSeconds << "seconds (5 min)"; - } - } - - int elapsedSeconds = allocationWaitTimer.elapsed() / 1000; - - if (elapsedSeconds >= allocationMaxWaitSeconds) { - qWarning() << "Allocation wait timeout after" << elapsedSeconds << "seconds (max:" << allocationMaxWaitSeconds << "s)"; - emit AllocationError(QString("Allocation timeout: Server did not become ready within %1 seconds").arg(allocationMaxWaitSeconds)); - emit Finished(); - return; - } - - int waitTime = pollFrequency; - int remainingTime = allocationMaxWaitSeconds - elapsedSeconds; - if (waitTime > remainingTime) { - waitTime = remainingTime; - } - - allocationRetryCount++; - QString retryMessage; - int queuePosition = -1; - if (dataMigration) { - int migrationPercent = allocation["dataMigrationPercentageComplete"].toInt(0); - retryMessage = QString("Migrating data (%1%%) - Attempt %2").arg(migrationPercent).arg(allocationRetryCount); - qInfo() << "Data migration progress:" << migrationPercent << "%"; - } else { - // Extract queue position if available (prefer displayQueuePosition, fallback to queuePosition) - if (allocation.contains("displayQueuePosition")) { - queuePosition = allocation["displayQueuePosition"].toInt(-1); - } else if (allocation.contains("queuePosition")) { - queuePosition = allocation["queuePosition"].toInt(-1); - } - - // Build retry message with queue position if available - if (queuePosition >= 0) { - retryMessage = QString("Allocating streaming slot - Queue position: %1 - Attempt %2").arg(queuePosition).arg(allocationRetryCount); - } else { - retryMessage = QString("Allocating streaming slot - Attempt %1").arg(allocationRetryCount); - } - } - emit AllocationProgress(retryMessage, queuePosition); - - qInfo() << "Allocation queued/data migration. Waiting" << waitTime << "seconds before retry (elapsed:" << elapsedSeconds << "s, remaining:" << remainingTime << "s, max:" << allocationMaxWaitSeconds << "s, attempt:" << allocationRetryCount << ")"; - - // Wait and retry - QTimer::singleShot(waitTime * 1000, this, [this]() { - qInfo() << "Retrying allocation request..."; - step13_AllocateSlot(); - }); - return; - } - - // Allocation successful - reset retry counter - allocationRetryCount = 0; - - // Allocation successful - extract connection info - QJsonObject launchSlot = allocation["launchSlot"].toObject(); - if (launchSlot.isEmpty()) { - qWarning() << "Allocation response missing launchSlot"; - emit AllocationError("Allocation response invalid: missing launchSlot"); - emit Finished(); - return; - } - - allocatedServerIp = launchSlot["publicIp"].toString(); - allocatedServerPort = launchSlot["port"].toInt(); - QString privateIp = launchSlot["privateIp"].toString(); - allocatedHandshakeKey = allocation["handshakeKey"].toString(); - allocatedLaunchSpec = allocation["launchSpecification"].toString(); - allocatedSessionId = allocation["sessionId"].toString(); - - // Extract PSN wrapper type from private IP's last octet - allocatedPsnWrapperType = 0x01; // default fallback - if (!privateIp.isEmpty()) { - int lastDotPos = privateIp.lastIndexOf('.'); - if (lastDotPos != -1) { - QString lastOctet = privateIp.mid(lastDotPos + 1); - bool ok; - int octetValue = lastOctet.toInt(&ok); - if (ok && octetValue >= 0 && octetValue <= 255) { - allocatedPsnWrapperType = static_cast(octetValue); - qInfo() << "Private IP:" << privateIp << "-> PSN wrapper type:" << QString("0x%1").arg(allocatedPsnWrapperType, 2, 16, QChar('0')); - } - } - } - - qInfo() << "=== Gaikai Step 13: ALLOCATION SUCCESSFUL ==="; - qInfo() << "Server IP:" << allocatedServerIp; - qInfo() << "Server Port:" << allocatedServerPort; - qInfo() << "Handshake Key:" << allocatedHandshakeKey; - qInfo() << "Session ID:" << allocatedSessionId; - qInfo() << "Launch Spec (FULL):" << allocatedLaunchSpec; - qInfo() << "Launch Spec Length:" << allocatedLaunchSpec.length(); - qInfo() << "[Allocation results stored in class for Takion connection]"; - - // Extract additional info - int timeLimit = allocation["timeLimit"].toInt(); - int startGameTimeout = allocation["startGameTimeout"].toInt(); - - qInfo() << "Time Limit:" << timeLimit << "minutes"; - qInfo() << "Start Timeout:" << startGameTimeout << "seconds"; - - if (finalCallback.isCallable()) { - finalCallback.call({true, QString("Streaming slot allocated: %1:%2").arg(allocatedServerIp).arg(allocatedServerPort), allocatedServerIp}); - } - - emit AllocationComplete(allocatedServerIp, allocatedServerPort, allocatedHandshakeKey, allocatedLaunchSpec, allocatedSessionId); - emit Finished(); - }); -} - diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp deleted file mode 100644 index 4ab320f6..00000000 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ /dev/null @@ -1,1404 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -#include "cloudstreaming/pskamajisession.h" -#include "chiaki/remote/holepunch.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -Q_DECLARE_LOGGING_CATEGORY(chiakiGui) - -// Helper function to log request headers -static void logKamajiRequest(const QString &stepName, const QNetworkRequest &request, const QByteArray &body = QByteArray()) -{ - qInfo() << "=== Kamaji" << stepName << "Request ==="; - qInfo() << "URL:" << request.url().toString(); - qInfo() << "Method:" << (body.isEmpty() ? "GET" : "POST"); - qInfo() << "Request Headers:"; - - // QNetworkRequest doesn't have rawHeaderPairs(), so we use rawHeaderList() and rawHeader() - QList headerNames = request.rawHeaderList(); - for (const QByteArray &headerName : headerNames) { - QByteArray headerValue = request.rawHeader(headerName); - QString headerNameStr = QString::fromUtf8(headerName); - QString headerValueStr = QString::fromUtf8(headerValue); - - // Truncate long values for readability - if (headerNameStr.compare("X-Gaikai-Session", Qt::CaseInsensitive) == 0 || - headerNameStr.compare("Authorization", Qt::CaseInsensitive) == 0) { - headerValueStr = headerValueStr.left(30) + "..."; - } - - qInfo() << " " << headerNameStr << ":" << headerValueStr; - } - - // Also check Content-Type header (might be set via setHeader instead of setRawHeader) - QVariant contentType = request.header(QNetworkRequest::ContentTypeHeader); - if (contentType.isValid() && !contentType.toString().isEmpty()) { - qInfo() << " Content-Type:" << contentType.toString(); - } - - if (!body.isEmpty()) { - // Try to parse as JSON and format it nicely - QJsonParseError parseError; - QJsonDocument jsonDoc = QJsonDocument::fromJson(body, &parseError); - if (parseError.error == QJsonParseError::NoError) { - qInfo() << "Request Body:"; - QByteArray formattedJson = jsonDoc.toJson(QJsonDocument::Indented); - QString jsonString = QString::fromUtf8(formattedJson); - // Output each line separately so it's properly formatted in logs - QStringList lines = jsonString.split('\n', Qt::SkipEmptyParts); - for (const QString &line : lines) { - if (!line.trimmed().isEmpty()) { - qInfo().noquote() << line; - } - } - } else { - // If not valid JSON, just output as-is - qInfo() << "Request Body:" << QString::fromUtf8(body); - } - } - qInfo() << "========================================"; -} - -PSKamajiSession::PSKamajiSession( - Settings *settings, - QString deviceUid, - QString productIdParam, - QString accountBaseUrl, - QString redirectUri, - QString userAgent, - QObject *parent -) - : QObject(parent) - , settings(settings) - , duid(deviceUid) - , platform("ps4") // Default, will be detected from API response - , productId(productIdParam) - , kamajiBase(KamajiConsts::KAMAJI_BASE) - , accountBase(accountBaseUrl) - , kamajiClientId(KamajiConsts::CLIENT_ID) - , redirectUriUrl(redirectUri) - , userAgentString(userAgent) - , scopesStr(KamajiConsts::PS4_SCOPES) // Default to PS4 scopes, will be updated when platform is detected -{ - manager = new QNetworkAccessManager(this); - manager->setCookieJar(nullptr); // Disable cookie jar - we use manual Cookie headers only -} - -void PSKamajiSession::setOwnedEntitlementFastPath(const QString &ownedEntitlementId, const QString &ownedPlatform) -{ - fastPathEntitlementId = ownedEntitlementId; - fastPathPlatform = ownedPlatform; -} - -void PSKamajiSession::startSessionCreation() -{ - // Get npsso fresh from settings at the start of each session attempt - npssoToken = settings->GetNpssoToken(); - - // Clear jsessionId to ensure we start fresh - jsessionId.clear(); - - qInfo() << "Kamaji Session: Starting authentication flow (Steps 0.5b-0.5d, 5-6)..."; - qInfo() << "Platform:" << platform; - qInfo() << "Product ID:" << productId; - qInfo() << "Note: Authorization check is now handled centrally by CloudStreamingBackend"; - - if (npssoToken.isEmpty()) { - QString error = "NPSSO token is empty"; - qWarning() << "Kamaji Session:" << error; - emit sessionComplete(false, error, QString()); - return; - } - - // Owned-PSNOW fast-path: the unified catalog already resolved this title's streaming - // entitlement from the user's owned cross-reference, so there is nothing to look up or - // acquire. Skip the entire entitlement path (0.5b anonymous session + 0.5d resolve + - // 0.5e check/acquire) -- those calls 404 and the acquire fails outright in storefront-less - // regions -- and go straight to the authenticated session. step5/6 are independent of the - // anonymous session, so this is safe. The orchestrator falls back to the full flow if Gaikai - // rejects the id. - if (!fastPathEntitlementId.isEmpty()) { - entitlementId = fastPathEntitlementId; - platform = fastPathPlatform.isEmpty() ? QStringLiteral("ps4") : fastPathPlatform; - scopesStr = (platform == QStringLiteral("ps3")) ? KamajiConsts::PS3_SCOPES : KamajiConsts::PS4_SCOPES; - entitlementFastPathUsed = true; - qInfo() << "Kamaji fast-path: owned entitlementId=" << entitlementId - << "platform=" << platform << "- skipping 0.5b/0.5d/0.5e"; - step5_GetAuthCode(); - return; - } - - // Authorization check is now done centrally by CloudStreamingBackend before creating PSKamajiSession - // Start directly with Step 0.5b: Get anonymous session OAuth code - step0_5b_GetAnonymousAuthCode(); -} - -// ============================================================================ -// Step 0.5b: GET /oauth/authorize (for anonymous session OAuth code) -// Note: Step 0.5a (authorizeCheck) is now handled centrally by CloudStreamingBackend -// ============================================================================ -void PSKamajiSession::step0_5b_GetAnonymousAuthCode() -{ - QUrl url(accountBase + "/v1/oauth/authorize"); - QUrlQuery query; - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("response_type", "code"); - query.addQueryItem("scope", scopesStr); - query.addQueryItem("client_id", kamajiClientId); - query.addQueryItem("redirect_uri", redirectUriUrl); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - query.addQueryItem("renderMode", "mobilePortrait"); - query.addQueryItem("hidePageElements", "forgotPasswordLink"); - query.addQueryItem("displayFooter", "none"); - query.addQueryItem("disableLinks", "qriocityLink"); - query.addQueryItem("mid", "PSNOW"); - query.addQueryItem("duid", duid); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("noEVBlock", "true"); - url.setQuery(query); - - qInfo() << "Kamaji Step 0.5b: GET /oauth/authorize (for anonymous session code)"; - if (settings && settings->GetLogVerbose()) { - qInfo() << " URL:" << url.toString(); - qInfo() << " Method: GET"; - } - - QNetworkRequest req(url); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - - // Add npsso cookie for OAuth authorization (required even for anonymous session) - if (!npssoToken.isEmpty()) { - req.setRawHeader("Cookie", QString("npsso=%1").arg(npssoToken).toUtf8()); - } - - logKamajiRequest("Step 0.5b: GetAnonymousAuthCode", req); - - QNetworkReply *reply = manager->get(req); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleAnonAuthCodeResponse(reply); - }); -} - -void PSKamajiSession::handleAnonAuthCodeResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 0.5b Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - if (!redirectUrl.isEmpty()) { - qInfo() << " Redirect URL:" << redirectUrl.toString(); - } - QByteArray response = reply->readAll(); - if (!response.isEmpty()) { - qInfo() << " Body:" << QString(response); - } - } - - // Handle redirect to get OAuth code - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - if (!redirectUrl.isEmpty()) { - QUrlQuery query(redirectUrl); - QString code = query.queryItemValue("code"); - if (!code.isEmpty()) { - anonAuthCode = code; - qInfo() << "Kamaji Step 0.5b complete - Got anonymous auth code:" << anonAuthCode.left(20) << "..."; - step0_5c_CreateAnonymousSession(); - return; - } else { - QString error = query.queryItemValue("error"); - if (!error.isEmpty()) { - emit sessionComplete(false, QString("OAuth error: %1").arg(error), QString()); - return; - } - } - } - - emit sessionComplete(false, "No authorization code in redirect for anonymous session", QString()); -} - -// ============================================================================ -// Step 0.5c: POST /user/session (anonymous) - with OAuth code body -// ============================================================================ -void PSKamajiSession::step0_5c_CreateAnonymousSession() -{ - QString url = kamajiBase + "/user/session"; - QString body = QString("code=%1&client_id=%2&duid=%3") - .arg(anonAuthCode) - .arg(kamajiClientId) - .arg(duid); - - qInfo() << "Kamaji Step 0.5c: POST /user/session (anonymous) - with OAuth code body"; - if (settings && settings->GetLogVerbose()) { - qInfo() << " URL:" << url; - qInfo() << " Method: POST"; - qInfo() << " Content-Type: text/plain;charset=UTF-8"; - qInfo() << " User-Agent:" << userAgentString; - qInfo() << " X-Alt-Referer:" << redirectUriUrl; - qInfo() << " Origin: https://psnow.playstation.com"; - qInfo() << " Referer: https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"; - qInfo() << " Body:" << body; - qInfo() << " Note: Using empty cookie session"; - } - - // Use a temporary network manager with no cookie jar (no cookies needed for anonymous session) - QNetworkAccessManager *tempManager = new QNetworkAccessManager(this); - tempManager->setCookieJar(nullptr); - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Content-Type", "text/plain;charset=UTF-8"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Alt-Referer", redirectUriUrl.toUtf8()); - req.setRawHeader("Accept", "*/*"); - req.setRawHeader("Origin", "https://psnow.playstation.com"); - req.setRawHeader("Sec-Fetch-Site", "same-origin"); - req.setRawHeader("Sec-Fetch-Mode", "cors"); - req.setRawHeader("Sec-Fetch-Dest", "empty"); - req.setRawHeader("Referer", "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"); - - QByteArray requestBody = body.toUtf8(); - logKamajiRequest("Step 0.5c: CreateAnonymousSession", req, requestBody); - - QNetworkReply *reply = tempManager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply, tempManager]() { - handleAnonSessionResponse(reply); - tempManager->deleteLater(); - }); -} - -void PSKamajiSession::handleAnonSessionResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray response = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 0.5c Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - qInfo() << " Body:" << QString(response); - } - - if (reply->error() != QNetworkReply::NoError) { - emit sessionComplete(false, QString("Anonymous session failed: %1").arg(reply->errorString()), QString()); - return; - } - - // Extract JSESSIONID from Set-Cookie header - QList headers = reply->rawHeaderPairs(); - for (const auto &header : headers) { - if (header.first.toLower() == "set-cookie") { - QString setCookieValue = QString::fromUtf8(header.second); - // Parse JSESSIONID=...; from Set-Cookie header - QRegularExpression jsessionRegex("JSESSIONID=([^;]+)"); - QRegularExpressionMatch match = jsessionRegex.match(setCookieValue); - if (match.hasMatch()) { - jsessionId = match.captured(1); - qInfo() << "Kamaji Step 0.5c complete - Got JSESSIONID:" << jsessionId.left(20) << "..."; - - // Continue to Step 0.5d: Convert Product ID to Entitlement ID - step0_5d_ConvertProductId(); - return; - } - } - } - - emit sessionComplete(false, "No JSESSIONID in Set-Cookie header", QString()); -} - -// ============================================================================ -// Step 0.5d: Convert Product ID → Entitlement ID -// GET /store/api/pcnow/00_09_000/container/{COUNTRY}/{LANGUAGE}/19/{PRODUCT_ID}?useOffers=true&gkb=1&gkb2=1 -// ============================================================================ -void PSKamajiSession::step0_5d_ConvertProductId() -{ - // Server-authoritative store country from unified catalog (fallbackRegion). - QString localeSetting = settings ? settings->GetCloudStoreLocale() : QStringLiteral("en-US"); - QString locale = localeSetting.toLower(); - QStringList localeParts = locale.split("-"); - const QString localeLang = localeParts.size() > 0 ? localeParts[0].toLower() : QStringLiteral("en"); - const QString localeCountry = localeParts.size() > 1 ? localeParts[1].toUpper() : QStringLiteral("US"); - QString resolvedCountry = settings ? settings->GetCloudResolvedStoreCountry() : QString(); - QString resolvedLang = settings ? settings->GetCloudResolvedStoreLang() : QString(); - QString country; - QString language; - if (!resolvedCountry.isEmpty()) { - country = resolvedCountry; - // Prefer the server-authoritative store language from the native base_url: a non-English - // native store (e.g. NL) 404s on the wrong language. Fall back to the locale-derived - // proxy when empty (fallback/foreign mode, where the public US/GB store wants en). - language = !resolvedLang.isEmpty() ? resolvedLang : localeLang; - } else { - country = localeCountry; - language = localeLang; - } - qInfo() << "Kamaji step0_5d: using resolvedStoreCountry=" << country << "(lang=" << language << ") for container URL"; - - QString url = QString("https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/%2/19/%3?useOffers=true&gkb=1&gkb2=1") - .arg(country, language, productId); - - qInfo() << "Kamaji Step 0.5d: Convert Product ID to Entitlement ID"; - if (settings && settings->GetLogVerbose()) { - qInfo() << " URL:" << url; - qInfo() << " Method: GET"; - qInfo() << " Product ID:" << productId; - } - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Accept", "application/json"); - req.setRawHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); - - logKamajiRequest("Step 0.5d: ConvertProductId", req); - - QNetworkReply *reply = manager->get(req); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleProductIdConversionResponse(reply); - }); -} - -void PSKamajiSession::handleProductIdConversionResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - // Handle 404 (Product ID not found) with user-friendly message - // Check status code first, as 404 is a valid HTTP response (not a network error) - if (statusCode == 404) { - QByteArray response = reply->readAll(); - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 0.5d Response ==="; - qInfo() << " Status:" << statusCode; - if (!response.isEmpty()) { - qInfo() << " Body:" << QString(response); - } - } - emit sessionComplete(false, QString("Game not found: Product ID '%1' does not exist or is not available for cloud streaming").arg(productId), QString()); - return; - } - - if (reply->error() != QNetworkReply::NoError) { - QByteArray response = reply->readAll(); - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 0.5d Response ==="; - qInfo() << " Status:" << statusCode; - if (!response.isEmpty()) { - qInfo() << " Body:" << QString(response); - } - } - emit sessionComplete(false, QString("Failed to lookup game: Product ID '%1' - %2").arg(productId).arg(reply->errorString()), QString()); - return; - } - - QByteArray response = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 0.5d Response ==="; - qInfo() << " Status:" << statusCode; - if (!response.isEmpty()) { - qInfo() << " Body:" << QString(response); - } - } - QJsonDocument doc = QJsonDocument::fromJson(response); - - if (doc.isNull() || !doc.isObject()) { - emit sessionComplete(false, "Invalid JSON in product lookup response", QString()); - return; - } - - QJsonObject obj = doc.object(); - QString streamingEntitlementId; - QString sku; - - // Extract platform from playable_platform field (contains strings like "PS3™", "PS4™") - // Pick highest available platform (PS4 > PS3) - QString detectedPlatform = "ps4"; // Default to PS4 - QJsonArray playablePlatformArray; - - // Try to get playable_platform from root level first - if (obj.contains("playable_platform") && obj["playable_platform"].isArray()) { - playablePlatformArray = obj["playable_platform"].toArray(); - } - // Fallback to metadata.playable_platform.values - else if (obj.contains("metadata") && obj["metadata"].isObject()) { - QJsonObject metadata = obj["metadata"].toObject(); - if (metadata.contains("playable_platform") && metadata["playable_platform"].isObject()) { - QJsonObject playablePlatformObj = metadata["playable_platform"].toObject(); - if (playablePlatformObj.contains("values") && playablePlatformObj["values"].isArray()) { - playablePlatformArray = playablePlatformObj["values"].toArray(); - } - } - } - - // Look for streaming entitlement - check default_sku first, then skus array - // Streaming entitlements have license_type == 4 - QJsonObject defaultSku = obj["default_sku"].toObject(); - if (!defaultSku.isEmpty() && defaultSku.contains("entitlements") && defaultSku["entitlements"].isArray()) { - QJsonArray entitlements = defaultSku["entitlements"].toArray(); - for (const QJsonValue &entValue : entitlements) { - QJsonObject ent = entValue.toObject(); - int licenseType = ent["license_type"].toInt(); - - // Streaming entitlements have license_type == 4 - if (licenseType == 4) { - QString entId = ent["id"].toString(); - if (!entId.isEmpty()) { - streamingEntitlementId = entId; - sku = defaultSku["id"].toString(); - streamingSku = sku; - qInfo() << "Found streaming Entitlement ID from default_sku:" << streamingEntitlementId; - qInfo() << "License Type:" << licenseType; - qInfo() << "SKU:" << sku; - break; - } - } - } - } - - // If not found in default_sku, check all SKUs in the skus array - if (streamingEntitlementId.isEmpty() && obj.contains("skus") && obj["skus"].isArray()) { - QJsonArray skus = obj["skus"].toArray(); - for (const QJsonValue &skuValue : skus) { - QJsonObject skuObj = skuValue.toObject(); - - if (skuObj.contains("entitlements") && skuObj["entitlements"].isArray()) { - QJsonArray entitlements = skuObj["entitlements"].toArray(); - for (const QJsonValue &entValue : entitlements) { - QJsonObject ent = entValue.toObject(); - int licenseType = ent["license_type"].toInt(); - - // Streaming entitlements have license_type == 4 - if (licenseType == 4) { - QString entId = ent["id"].toString(); - if (!entId.isEmpty()) { - streamingEntitlementId = entId; - sku = skuObj["id"].toString(); - streamingSku = sku; - qInfo() << "Found streaming Entitlement ID from skus array:" << streamingEntitlementId; - qInfo() << "License Type:" << licenseType; - qInfo() << "SKU:" << sku; - break; - } - } - } - } - if (!streamingEntitlementId.isEmpty()) break; - } - } - - // PS Plus catalog titles (e.g. PS4 games via PS Plus Premium) don't carry a per-game - // streaming license (license_type == 4) like the old PS Now catalog did — their full-game - // entitlement is license_type 0 with packageType "PS4GD"/"PS5GD"/"PSGD", streamable via the - // PS Plus subscription. Fall back to that full-game entitlement so step0_5e can acquire it. - if (streamingEntitlementId.isEmpty()) { - // Title id of the requested product, e.g. "EP1464-CUSA24653_00-..." -> "CUSA24653". - // Cross-gen containers list BOTH the PS4 (CUSA) and PS5 (PPSA) full-game entitlements; - // we must pick the one matching the requested product so the entitlement platform stays - // consistent with the streaming session (a PS5 entitlement on a PS4/kratos session makes - // the senkusha ping server never ack -> 0/5 pings -> allocation fails). - QString requestedTitleId; - { - const QStringList dashParts = productId.split(QLatin1Char('-')); - if (dashParts.size() >= 2) - requestedTitleId = dashParts[1].split(QLatin1Char('_')).value(0); - } - auto pickFullGameEntitlement = [&](const QJsonObject &skuObj, bool requireTitleMatch) -> bool { - if (!skuObj.contains("entitlements") || !skuObj["entitlements"].isArray()) - return false; - const QJsonArray entitlements = skuObj["entitlements"].toArray(); - for (const QJsonValue &entValue : entitlements) { - const QJsonObject ent = entValue.toObject(); - const QString entId = ent["id"].toString(); - const QString pkgType = ent["packageType"].toString(); - // Full game digital ("*GD"); skip add-ons (PS4AL), themes (PS4MISC), etc. - if (entId.isEmpty() || !pkgType.endsWith(QStringLiteral("GD"))) - continue; - if (requireTitleMatch && !requestedTitleId.isEmpty() && !entId.contains(requestedTitleId)) - continue; - streamingEntitlementId = entId; - sku = skuObj["id"].toString(); - streamingSku = sku; - qInfo() << "Found full-game Entitlement ID (PS Plus catalog fallback):" - << streamingEntitlementId << "packageType:" << pkgType << "SKU:" << sku - << "titleMatch:" << requireTitleMatch; - return true; - } - return false; - }; - // Pass 1: prefer the entitlement matching the requested product's title id (platform-consistent). - // Pass 2: fall back to any full-game entitlement. - for (bool requireTitleMatch : {true, false}) { - if (streamingEntitlementId.isEmpty() && pickFullGameEntitlement(defaultSku, requireTitleMatch)) - break; - if (streamingEntitlementId.isEmpty() && obj.contains("skus") && obj["skus"].isArray()) { - const QJsonArray skus = obj["skus"].toArray(); - for (const QJsonValue &skuValue : skus) { - if (pickFullGameEntitlement(skuValue.toObject(), requireTitleMatch)) - break; - } - } - if (!streamingEntitlementId.isEmpty()) - break; - } - } - - // Determine platform from playable_platform strings (pick highest: PS5 > PS4 > PS3) - if (!playablePlatformArray.isEmpty()) { - bool hasPS5 = false; - bool hasPS4 = false; - bool hasPS3 = false; - for (const QJsonValue &platformValue : playablePlatformArray) { - QString platformStr = platformValue.toString(); - // Check PS5 first ("PS5™"/"PS5"); PS4/PS5 cross-gen containers may list both. - if (platformStr.contains("PS5", Qt::CaseInsensitive)) { - hasPS5 = true; - } - // Check for PS4 (handles "PS4™" and "PS4") - else if (platformStr.contains("PS4", Qt::CaseInsensitive)) { - hasPS4 = true; - } - // Check for PS3 (handles "PS3™" and "PS3") - else if (platformStr.contains("PS3", Qt::CaseInsensitive)) { - hasPS3 = true; - } - } - if (hasPS5) { - detectedPlatform = "ps5"; - } else if (hasPS4) { - detectedPlatform = "ps4"; - } else if (hasPS3) { - detectedPlatform = "ps3"; - } - qInfo() << "Detected platform from playable_platform:" << detectedPlatform; - } else { - qWarning() << "No playable_platform found in response, defaulting to PS4"; - } - - platform = detectedPlatform; - - // Update scopes based on detected platform - if (platform == "ps3") { - scopesStr = KamajiConsts::PS3_SCOPES; - } else { - scopesStr = KamajiConsts::PS4_SCOPES; - } - qInfo() << "Updated scopes for platform" << platform << ":" << scopesStr; - - if (streamingEntitlementId.isEmpty()) { - emit sessionComplete(false, QString("Could not determine Entitlement ID from Product ID '%1'. Game may not be available for cloud streaming.").arg(productId), QString()); - return; - } - - entitlementId = streamingEntitlementId; - qInfo() << "Kamaji Step 0.5d complete - Entitlement ID:" << entitlementId; - if (!streamingSku.isEmpty()) { - qInfo() << " Streaming SKU:" << streamingSku; - } - - // Continue to Step 0.5e: Check and acquire entitlement if needed - step0_5e_CheckEntitlement(); -} - -// ============================================================================ -// Step 0.5e: Check and Acquire Entitlement (entitlement_check.py flow) -// ============================================================================ -void PSKamajiSession::step0_5e_CheckEntitlement() -{ - qInfo() << "Kamaji Step 0.5e: Starting entitlement check/acquisition flow"; - qInfo() << " Entitlement ID:" << entitlementId; - if (!streamingSku.isEmpty()) { - qInfo() << " SKU:" << streamingSku; - } - - // First, get OAuth token for Commerce API - step0_5e_GetCommerceOAuthToken(); -} - -void PSKamajiSession::step0_5e_GetCommerceOAuthToken() -{ - qInfo() << "Kamaji Step 0.5e.1: Getting OAuth token for Commerce API..."; - - QUrl url(accountBase + "/v1/oauth/authorize"); - QUrlQuery query; - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("response_type", "token"); // Use token, not code - query.addQueryItem("scope", "kamaji:get_internal_entitlements user:account.attributes.validate kamaji:get_privacy_settings user:account.settings.privacy.get kamaji:s2s.subscriptionsPremium.get"); - query.addQueryItem("client_id", "dc523cc2-b51b-4190-bff0-3397c06871b3"); // Commerce API client ID - query.addQueryItem("redirect_uri", redirectUriUrl); - query.addQueryItem("grant_type", "authorization_code"); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - query.addQueryItem("renderMode", "mobilePortrait"); - query.addQueryItem("hidePageElements", "forgotPasswordLink"); - query.addQueryItem("displayFooter", "none"); - query.addQueryItem("disableLinks", "qriocityLink"); - query.addQueryItem("mid", "PSNOW"); - query.addQueryItem("duid", duid); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("noEVBlock", "true"); - url.setQuery(query); - - QNetworkRequest req(url); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - - logKamajiRequest("Step 0.5e.1: GetCommerceOAuthToken", req); - - // Only use npsso cookie, NOT JSESSIONID - if (!npssoToken.isEmpty()) { - req.setRawHeader("Cookie", QString("npsso=%1").arg(npssoToken).toUtf8()); - } - - QNetworkReply *reply = manager->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleCommerceOAuthTokenResponse(reply); - }); -} - -void PSKamajiSession::handleCommerceOAuthTokenResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Commerce OAuth Token Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - } - - if (statusCode != 302) { - qWarning() << "Commerce OAuth token request failed: Expected 302, got" << statusCode; - emit sessionComplete(false, QString("Failed to get Commerce OAuth token (status %1)").arg(statusCode), entitlementId); - return; - } - - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - if (redirectUrl.isEmpty()) { - QByteArray locationHeader = reply->rawHeader("Location"); - if (!locationHeader.isEmpty()) { - redirectUrl = QUrl::fromEncoded(locationHeader); - } - } - - if (redirectUrl.isEmpty()) { - emit sessionComplete(false, "No redirect URL in Commerce OAuth response", entitlementId); - return; - } - - // Extract access_token from URL fragment (#access_token=...) - QString fragment = redirectUrl.fragment(); - QRegularExpression tokenRegex("#access_token=([^&]+)"); - QRegularExpressionMatch match = tokenRegex.match(fragment); - if (!match.hasMatch()) { - // Try query string as fallback - tokenRegex = QRegularExpression("[?&#]access_token=([^&]+)"); - match = tokenRegex.match(redirectUrl.toString()); - } - - if (!match.hasMatch()) { - qWarning() << "Could not extract access_token from redirect URL"; - qWarning() << "Redirect URL:" << redirectUrl.toString(); - emit sessionComplete(false, "Could not extract access token from Commerce OAuth response", entitlementId); - return; - } - - commerceOAuthToken = match.captured(1); - qInfo() << "Kamaji Step 0.5e.1 complete - Got Commerce OAuth token:" << commerceOAuthToken.left(30) << "..."; - - // Continue to check account attributes - step0_5e_CheckAccountAttributes(); -} - -void PSKamajiSession::step0_5e_CheckAccountAttributes() -{ - // Skip check if it has already passed previously - if (settings && settings->GetAccountAttributesCheckPassed()) { - qInfo() << "Kamaji Step 0.5e.1a: Skipping account attributes check (previously passed)"; - step0_5e_CheckEntitlementExists(); - return; - } - - qInfo() << "Kamaji Step 0.5e.1a: Checking account attributes..."; - - QString url = "https://accounts.api.playstation.com/api/v2/accounts/me/attributes"; - - // Create JSON payload - QJsonObject payload; - QJsonArray attributes; - attributes.append("ONLINE_ID"); - attributes.append("BIRTH_DATE"); - attributes.append("CITY"); - attributes.append("REAL_NAME"); - attributes.append("PRIVACY_SETTING_ACTIVITYSTREAM"); - attributes.append("PRIVACY_SETTING_FRIENDSLIST"); - attributes.append("PRIVACY_SETTING_FRIENDREQUESTS"); - attributes.append("PRIVACY_SETTING_MESSAGES"); - attributes.append("PRIVACY_SETTING_TRUENAME"); - attributes.append("PRIVACY_SETTING_SEARCH"); - attributes.append("PRIVACY_SETTING_RECOMMENDUSERS"); - attributes.append("PRIVACY_SETTING_BROADCAST"); - payload["attributes"] = attributes; - - QJsonDocument doc(payload); - QByteArray postData = doc.toJson(QJsonDocument::Compact); - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Authorization", QString("Bearer %1").arg(commerceOAuthToken).toUtf8()); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("Accept", "application/json"); - req.setRawHeader("Content-Type", "application/json"); - - logKamajiRequest("Step 0.5e.1a: CheckAccountAttributes", req, postData); - - QNetworkReply *reply = manager->post(req, postData); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleAccountAttributesResponse(reply); - }); -} - -void PSKamajiSession::handleAccountAttributesResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray data = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Account Attributes Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Body:" << QString::fromUtf8(data); - } - - // Check for successful response (200 or 204) - if (statusCode == 200 || statusCode == 204) { - qInfo() << "Kamaji Step 0.5e.1a complete - Account attributes check successful"; - - // Mark check as passed so we don't need to do it again - if (settings) { - settings->SetAccountAttributesCheckPassed(true); - } - - // Continue to check entitlement - step0_5e_CheckEntitlementExists(); - return; - } - - // Any other status code is an error - parse missing elements and construct upgrade URL - QString errorMsg = QString("Account attributes check failed with status %1").arg(statusCode); - if (!data.isEmpty()) { - errorMsg += ": " + QString::fromUtf8(data); - } - qWarning() << errorMsg; - - // Parse missing elements from error response - QStringList missingElements; - QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isNull() && doc.isObject()) { - QJsonObject obj = doc.object(); - QJsonObject error = obj["error"].toObject(); - QJsonArray validationErrors = error["validationErrors"].toArray(); - - for (const QJsonValue &validationError : validationErrors) { - QJsonObject validationObj = validationError.toObject(); - QJsonArray missingElementsArray = validationObj["missingElements"].toArray(); - - for (const QJsonValue &missingElement : missingElementsArray) { - QJsonObject elementObj = missingElement.toObject(); - QString elementName = elementObj["name"].toString(); - if (!elementName.isEmpty()) { - missingElements.append(elementName); - } - } - } - } - - // Construct Sony upgrade URL - QString upgradeUrl; - if (!missingElements.isEmpty()) { - QString missingElementsParam = missingElements.join(","); - - QUrl url("https://id.sonyentertainmentnetwork.com/id/upgrade_account_ca/"); - QUrlQuery query; - query.addQueryItem("entry", "upgrade_account"); - query.addQueryItem("pr_referer", "upgrade"); - query.addQueryItem("redirect_uri", redirectUriUrl); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("refererPage", "websso"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_console", "true"); - query.addQueryItem("disableLinks", "SENLink"); - query.addQueryItem("renderMode", "mobilePortrait"); - query.addQueryItem("noEVBlock", "true"); - query.addQueryItem("displayFooter", "none"); - query.addQueryItem("hidePageElements", "SENLogo"); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("missing_elements", missingElementsParam); - query.addQueryItem("response_type", "code"); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("tp_social", "true"); - query.addQueryItem("elements_visibility_upgrade", "no_cancel"); - url.setQuery(query); - - upgradeUrl = url.toString(); - qInfo() << "Sony upgrade URL:" << upgradeUrl; - } - - // Show warning dialog to user - session is STOPPED - // User can click "Ignore Forever" to skip this check in future sessions - emit accountPrivacySettingsError(upgradeUrl); - emit sessionComplete(false, "Account privacy settings check failed. Please complete privacy settings or click 'Ignore Forever' and try again.", entitlementId); -} - -void PSKamajiSession::step0_5e_CheckEntitlementExists() -{ - qInfo() << "Kamaji Step 0.5e.2: Checking if entitlement exists..."; - - QString url = QString("https://commerce.api.np.km.playstation.net/commerce/api/v1/users/me/internal_entitlements/%1?fields=game_meta") - .arg(entitlementId); - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Authorization", QString("Bearer %1").arg(commerceOAuthToken).toUtf8()); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("Accept", "application/json"); - - logKamajiRequest("Step 0.5e.2: CheckEntitlementExists", req); - - QNetworkReply *reply = manager->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleCheckEntitlementResponse(reply); - }); -} - -void PSKamajiSession::handleCheckEntitlementResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray data = reply->readAll(); - - // Note: Qt's QNetworkReply may automatically decompress gzip responses - // If we get invalid JSON, may need to add explicit gzip decompression later - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Check Entitlement Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Body:" << QString::fromUtf8(data); - } - - if (statusCode == 200) { - // User has entitlement - QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isNull() && doc.isObject()) { - QJsonObject obj = doc.object(); - QJsonObject gameMeta = obj["game_meta"].toObject(); - QString gameName = gameMeta["name"].toString(); - qInfo() << "Kamaji Step 0.5e.2 complete - User has entitlement"; - qInfo() << " Game Name:" << gameName; - } else { - qInfo() << "Kamaji Step 0.5e.2 complete - User has entitlement"; - } - - // Continue to Step 5: Get authenticated session OAuth code - step5_GetAuthCode(); - return; - } else if (statusCode == 404) { - // Region-group fallback: unsupported regions have no pcnow storefront, so the - // free checkout-acquire fails. Skip it and let Gaikai validate the subscription. - // Native (supported region): run the normal checkout-acquire for PS3 + PS4 + PS5 alike. - if (settings && settings->IsCloudCatalogIsForeign()) { - qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai"; - step5_GetAuthCode(); - return; - } - qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire"; - step0_5e_CheckoutPreview(); - return; - } else { - // Other error - QString errorMsg = QString("Entitlement check failed with status %1").arg(statusCode); - if (!data.isEmpty()) { - errorMsg += ": " + QString::fromUtf8(data); - } - qWarning() << errorMsg; - emit sessionComplete(false, errorMsg, entitlementId); - return; - } -} - -void PSKamajiSession::step0_5e_CheckoutPreview() -{ - qInfo() << "Kamaji Step 0.5e.3: Checking checkout preview..."; - - if (streamingSku.isEmpty()) { - qWarning() << "No SKU available for checkout preview, using entitlement ID"; - // Can still try with entitlement ID - API may return correct SKU - streamingSku = entitlementId; - } - - QString url = "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000/user/checkout/buynow/preview"; - - QUrlQuery formData; - formData.addQueryItem("sku", streamingSku); - QByteArray postData = formData.query(QUrl::FullyEncoded).toUtf8(); - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Host", "psnow.playstation.com"); - req.setRawHeader("Connection", "keep-alive"); - req.setRawHeader("Content-Length", QByteArray::number(postData.size())); - req.setRawHeader("Accept", "application/json, text/javascript, */*; q=0.01"); - req.setRawHeader("X-Requested-With", "XMLHttpRequest"); - req.setRawHeader("Authorization", QString("Bearer %1").arg(commerceOAuthToken).toUtf8()); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded; charset=UTF-8"); - req.setRawHeader("Origin", "https://psnow.playstation.com"); - req.setRawHeader("Sec-Fetch-Site", "same-origin"); - req.setRawHeader("Sec-Fetch-Mode", "cors"); - req.setRawHeader("Sec-Fetch-Dest", "empty"); - req.setRawHeader("Referer", "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"); - req.setRawHeader("Accept-Encoding", "identity"); - req.setRawHeader("Accept-Language", "en-US"); - - logKamajiRequest("Step 0.5e.3: CheckoutPreview", req, postData); - - // Add JSESSIONID cookie - if (!jsessionId.isEmpty()) { - req.setRawHeader("Cookie", QString("JSESSIONID=%1").arg(jsessionId).toUtf8()); - } - - QNetworkReply *reply = manager->post(req, postData); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleCheckoutPreviewResponse(reply); - }); -} - -void PSKamajiSession::handleCheckoutPreviewResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray data = reply->readAll(); - - // Verbose log errors - if (settings && settings->GetLogVerbose()) { - if (statusCode != 200 || reply->error() != QNetworkReply::NoError) { - qInfo() << "=== Checkout Preview Error Response ==="; - qInfo() << " HTTP Status Code:" << statusCode; - qInfo() << " Network Error:" << reply->error(); - qInfo() << " Error String:" << reply->errorString(); - qInfo() << " Response Body:" << QString::fromUtf8(data); - } - } - - // Immediately check for errors and fail - QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isNull() && doc.isObject()) { - QJsonObject obj = doc.object(); - QJsonObject header = obj["header"].toObject(); - QString statusCodeHex = header["status_code"].toString(); - - // Fail immediately if API error detected - if (statusCodeHex != "0x0000") { - QString message = header["message_key"].toString(); - if (settings && settings->GetLogVerbose()) { - qInfo() << " API Status Code:" << statusCodeHex; - qInfo() << " Message:" << message; - } - // Checkout preview errors indicate PS Plus subscription issue - emit psPlusSubscriptionError(); - emit sessionComplete(false, "Checkout preview failed", entitlementId); - return; - } - } - - // Check for HTTP errors - if (statusCode != 200) { - // Checkout preview HTTP errors indicate PS Plus subscription issue - emit psPlusSubscriptionError(); - emit sessionComplete(false, QString("Checkout preview failed with HTTP status %1").arg(statusCode), entitlementId); - return; - } - - // Check for network errors - if (reply->error() != QNetworkReply::NoError) { - emit psPlusSubscriptionError(); - emit sessionComplete(false, QString("Checkout preview failed: %1").arg(reply->errorString()), entitlementId); - return; - } - - // Update JSESSIONID from Set-Cookie if present - QList cookieHeaders = reply->rawHeaderList(); - for (const QByteArray &headerName : cookieHeaders) { - if (headerName.toLower() == "set-cookie") { - QByteArray cookieValue = reply->rawHeader(headerName); - QRegularExpression jsessionRegex("JSESSIONID=([^;]+)"); - QRegularExpressionMatch match = jsessionRegex.match(QString::fromUtf8(cookieValue)); - if (match.hasMatch()) { - QString newJsessionId = match.captured(1); - if (newJsessionId != jsessionId) { - jsessionId = newJsessionId; - qInfo() << "Updated JSESSIONID from checkout preview response"; - } - } - } - } - - // Parse JSON for successful response - if (doc.isNull() || !doc.isObject()) { - emit sessionComplete(false, "Invalid JSON in checkout preview response", entitlementId); - return; - } - - QJsonObject obj = doc.object(); - - QJsonObject dataObj = obj["data"].toObject(); - QJsonObject cart = dataObj["cart"].toObject(); - int totalPriceValue = cart["total_price_value"].toInt(); - QString totalPrice = cart["total_price"].toString(); - - qInfo() << "Checkout preview - Total Price Value:" << totalPriceValue; - qInfo() << "Checkout preview - Total Price:" << totalPrice; - - if (totalPriceValue != 0) { - qWarning() << "Game is not free (price:" << totalPrice << "), cannot proceed"; - emit sessionComplete(false, QString("Game is not free (price: %1), cannot acquire entitlement").arg(totalPrice), entitlementId); - return; - } - - // Extract actual SKU from response (authoritative source) - QJsonArray items = cart["items"].toArray(); - if (!items.isEmpty()) { - QJsonObject firstItem = items[0].toObject(); - QString actualSku = firstItem["sku_id"].toString(); - if (!actualSku.isEmpty() && actualSku != streamingSku) { - qInfo() << "Using SKU from preview response:" << actualSku; - streamingSku = actualSku; - } - } - - qInfo() << "Kamaji Step 0.5e.3 complete - Game is free, proceeding to checkout"; - - // Continue to checkout buynow - step0_5e_CheckoutBuynow(); -} - -void PSKamajiSession::step0_5e_CheckoutBuynow() -{ - qInfo() << "Kamaji Step 0.5e.4: Completing checkout to acquire entitlement..."; - - QString url = "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000/user/checkout/buynow"; - - QUrlQuery formData; - formData.addQueryItem("sku", streamingSku); - formData.addQueryItem("skipEmail", "true"); - QByteArray postData = formData.query(QUrl::FullyEncoded).toUtf8(); - - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("Accept", "application/json"); - req.setRawHeader("Authorization", QString("Bearer %1").arg(commerceOAuthToken).toUtf8()); - - logKamajiRequest("Step 0.5e.4: CheckoutBuynow", req, postData); - - // Add JSESSIONID cookie - if (!jsessionId.isEmpty()) { - req.setRawHeader("Cookie", QString("JSESSIONID=%1").arg(jsessionId).toUtf8()); - } - - QNetworkReply *reply = manager->post(req, postData); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleCheckoutBuynowResponse(reply); - }); -} - -void PSKamajiSession::handleCheckoutBuynowResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray data = reply->readAll(); - - // Note: Qt's QNetworkReply may automatically decompress gzip responses - // If we get invalid JSON, may need to add explicit gzip decompression later - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Checkout Buynow Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Body:" << QString::fromUtf8(data); - } - - // Update JSESSIONID from Set-Cookie if present - QList cookieHeaders = reply->rawHeaderList(); - for (const QByteArray &headerName : cookieHeaders) { - if (headerName.toLower() == "set-cookie") { - QByteArray cookieValue = reply->rawHeader(headerName); - QRegularExpression jsessionRegex("JSESSIONID=([^;]+)"); - QRegularExpressionMatch match = jsessionRegex.match(QString::fromUtf8(cookieValue)); - if (match.hasMatch()) { - QString newJsessionId = match.captured(1); - if (newJsessionId != jsessionId) { - jsessionId = newJsessionId; - qInfo() << "Updated JSESSIONID from checkout buynow response"; - } - } - } - } - - if (statusCode != 200) { - QString errorMsg = QString("Checkout buynow failed with status %1").arg(statusCode); - if (!data.isEmpty()) { - errorMsg += ": " + QString::fromUtf8(data); - } - qWarning() << errorMsg; - emit sessionComplete(false, errorMsg, entitlementId); - return; - } - - QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isNull() || !doc.isObject()) { - emit sessionComplete(false, "Invalid JSON in checkout buynow response", entitlementId); - return; - } - - QJsonObject obj = doc.object(); - QJsonObject header = obj["header"].toObject(); - QString statusCodeHex = header["status_code"].toString(); - - if (statusCodeHex != "0x0000") { - QString message = header["message_key"].toString(); - qWarning() << "Checkout buynow failed - Status:" << statusCodeHex << "Message:" << message; - emit sessionComplete(false, QString("Checkout failed: %1").arg(message), entitlementId); - return; - } - - QJsonObject dataObj = obj["data"].toObject(); - QString transactionId = dataObj["transaction_id"].toString(); - - qInfo() << "Kamaji Step 0.5e.4 complete - Entitlement successfully acquired!"; - qInfo() << " Transaction ID:" << transactionId; - qInfo() << "Kamaji Step 0.5e complete - Entitlement check/acquisition successful"; - - // Continue to Step 5: Get authenticated session OAuth code - step5_GetAuthCode(); -} - -// ============================================================================ -// Step 5: GET /oauth/authorize (for authenticated session OAuth code) -// ============================================================================ -void PSKamajiSession::step5_GetAuthCode() -{ - QUrl url(accountBase + "/v1/oauth/authorize"); - QUrlQuery query; - query.addQueryItem("smcid", "pc:psnow"); - query.addQueryItem("applicationId", "psnow"); - query.addQueryItem("response_type", "code"); - query.addQueryItem("scope", scopesStr); - query.addQueryItem("client_id", kamajiClientId); - query.addQueryItem("redirect_uri", redirectUriUrl); - query.addQueryItem("service_entity", "urn:service-entity:psn"); - query.addQueryItem("prompt", "none"); - query.addQueryItem("mid", "PSNOW"); - query.addQueryItem("duid", duid); - query.addQueryItem("layout_type", "popup"); - query.addQueryItem("service_logo", "ps"); - query.addQueryItem("tp_psn", "true"); - query.addQueryItem("noEVBlock", "true"); - url.setQuery(query); - - qInfo() << "Kamaji Step 5: GET /oauth/authorize (for authenticated session code)"; - if (settings && settings->GetLogVerbose()) { - qInfo() << " URL:" << url.toString(); - qInfo() << " Method: GET"; - } - - QNetworkRequest req(url); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - - // Add npsso cookie for OAuth authorization - if (!npssoToken.isEmpty()) { - req.setRawHeader("Cookie", QString("npsso=%1").arg(npssoToken).toUtf8()); - } - - logKamajiRequest("Step 5: GetAuthCode", req); - - QNetworkReply *reply = manager->get(req); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleAuthCodeResponse(reply); - }); -} - -void PSKamajiSession::handleAuthCodeResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 5 Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - if (!redirectUrl.isEmpty()) { - qInfo() << " Redirect URL:" << redirectUrl.toString(); - } - QByteArray response = reply->readAll(); - if (!response.isEmpty()) { - qInfo() << " Body:" << QString(response); - } - } - - if (statusCode != 302) { - emit sessionComplete(false, QString("Expected 302 redirect, got: %1").arg(statusCode), QString()); - return; - } - - QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); - QString code = QUrlQuery(redirectUrl).queryItemValue("code"); - - if (code.isEmpty()) { - emit sessionComplete(false, "No authorization code in redirect", QString()); - return; - } - - qInfo() << "Kamaji Step 5 complete - Got authenticated auth code:" << code.left(20) << "..."; - authorizationCode = code; - step6_CreateAuthSession(); -} - -// ============================================================================ -// Step 6: POST authenticated session with auth code -// ============================================================================ -void PSKamajiSession::step6_CreateAuthSession() -{ - QString url = kamajiBase + "/user/session"; - QString body = QString("code=%1&client_id=%2&duid=%3") - .arg(authorizationCode) - .arg(kamajiClientId) - .arg(duid); - - qInfo() << "Kamaji Step 6: POST authenticated session"; - if (settings && settings->GetLogVerbose()) { - qInfo() << " URL:" << url; - qInfo() << " Method: POST"; - qInfo() << " Content-Type: text/plain;charset=UTF-8"; - qInfo() << " User-Agent:" << userAgentString; - qInfo() << " X-Alt-Referer:" << redirectUriUrl; - qInfo() << " Origin: https://psnow.playstation.com"; - qInfo() << " Referer: https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"; - qInfo() << " Body:" << body; - } - - QNetworkRequest req{QUrl(url)}; - req.setRawHeader("Content-Type", "text/plain;charset=UTF-8"); - req.setRawHeader("User-Agent", userAgentString.toUtf8()); - req.setRawHeader("X-Alt-Referer", redirectUriUrl.toUtf8()); - req.setRawHeader("Origin", "https://psnow.playstation.com"); - req.setRawHeader("Referer", "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"); - - QByteArray requestBody = body.toUtf8(); - logKamajiRequest("Step 6: CreateAuthSession", req, requestBody); - - QNetworkReply *reply = manager->post(req, requestBody); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - handleAuthSessionResponse(reply); - }); -} - -void PSKamajiSession::handleAuthSessionResponse(QNetworkReply *reply) -{ - reply->deleteLater(); - - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray response = reply->readAll(); - - if (settings && settings->GetLogVerbose()) { - qInfo() << "=== Kamaji Step 6 Response ==="; - qInfo() << " Status:" << statusCode; - qInfo() << " Headers:"; - for (const auto &header : reply->rawHeaderPairs()) { - qInfo() << " " << header.first << ":" << header.second; - } - qInfo() << " Body:" << QString(response); - } - - if (reply->error() != QNetworkReply::NoError) { - emit sessionComplete(false, QString("Auth session failed: %1").arg(reply->errorString()), entitlementId); - return; - } - - QJsonDocument doc = QJsonDocument::fromJson(response); - - if (doc.isNull() || !doc.isObject()) { - emit sessionComplete(false, "Invalid JSON in session response", entitlementId); - return; - } - - QJsonObject obj = doc.object(); - - // Parse Kamaji response format (has header/data structure) - QJsonObject header = obj["header"].toObject(); - QJsonObject data = obj["data"].toObject(); - - if (header["status_code"].toString() != "0x0000") { - QString statusCode = header["status_code"].toString(); - emit sessionComplete(false, QString("Session failed with status: %1").arg(statusCode), entitlementId); - return; - } - - // Store session data in class members (not persisted to settings) - accountId = data["accountId"].toString(); - onlineId = data["onlineId"].toString(); - sessionUrl = data["sessionUrl"].toString(); - - qInfo() << "=== Kamaji Session Created Successfully ==="; - qInfo() << "Authenticated as:" << onlineId; - qInfo() << "Account ID:" << accountId; - qInfo() << "Entitlement ID:" << entitlementId; - - emit sessionComplete(true, "Kamaji authentication complete", entitlementId); -} diff --git a/ios/Pylux.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index 7f291251..9dffd4b4 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -51,8 +51,6 @@ A1000201 /* PictureInPictureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000200 /* PictureInPictureManager.swift */; }; A3000011 /* CloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000001 /* CloudModels.swift */; }; A3000012 /* CloudHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000002 /* CloudHttpClient.swift */; }; - A3000013 /* PSGaikaiStreaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000003 /* PSGaikaiStreaming.swift */; }; - A3000014 /* PSKamajiSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000004 /* PSKamajiSession.swift */; }; A3000015 /* CloudStreamingBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000005 /* CloudStreamingBackend.swift */; }; A3000016 /* CloudCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000006 /* CloudCatalogService.swift */; }; A3000017 /* CloudPlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000007 /* CloudPlayView.swift */; }; @@ -127,8 +125,6 @@ A1000200 /* PictureInPictureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureManager.swift; sourceTree = ""; }; A3000001 /* CloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudModels.swift; sourceTree = ""; }; A3000002 /* CloudHttpClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudHttpClient.swift; sourceTree = ""; }; - A3000003 /* PSGaikaiStreaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PSGaikaiStreaming.swift; sourceTree = ""; }; - A3000004 /* PSKamajiSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PSKamajiSession.swift; sourceTree = ""; }; A3000005 /* CloudStreamingBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudStreamingBackend.swift; sourceTree = ""; }; A3000006 /* CloudCatalogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudCatalogService.swift; sourceTree = ""; }; A3000007 /* CloudPlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPlayView.swift; sourceTree = ""; }; @@ -247,8 +243,6 @@ A3000006 /* CloudCatalogService.swift */, A3000002 /* CloudHttpClient.swift */, A3000005 /* CloudStreamingBackend.swift */, - A3000003 /* PSGaikaiStreaming.swift */, - A3000004 /* PSKamajiSession.swift */, A1000200 /* PictureInPictureManager.swift */, A1000120 /* PsnTokenManager.swift */, A1000130 /* PyluxLoginService.swift */, @@ -416,8 +410,6 @@ A1000126 /* AutoRegistrationView.swift in Sources */, A3000011 /* CloudModels.swift in Sources */, A3000012 /* CloudHttpClient.swift in Sources */, - A3000013 /* PSGaikaiStreaming.swift in Sources */, - A3000014 /* PSKamajiSession.swift in Sources */, A3000015 /* CloudStreamingBackend.swift in Sources */, A3000016 /* CloudCatalogService.swift in Sources */, A3000017 /* CloudPlayView.swift in Sources */, diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index 93065723..b0dece62 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -38,27 +38,17 @@ final class CloudStreamingBackend { throw GaikaiAllocationError(message: "Invalid serviceType: \(normalizedServiceType)") } - // Generate shared DUID - let sharedDuid = generateDuid() - os_log(.info, log: cloudLog, "Using DUID: %{public}s", String(sharedDuid.prefix(20))) - - // Centralized authorization check (matches Qt lines 91-119) - guard checkAuthorization(serviceType: normalizedServiceType, npssoToken: npssoToken, duid: sharedDuid) else { - throw AuthorizationFailedError(message: "Your NPSSO token is likely expired. Please re-login.") - } - os_log(.info, log: cloudLog, "✓ Authorization check passed") - if normalizedServiceType == "pscloud" { CloudLocaleSettings.ensureConfigured(npssoToken: npssoToken) } - // Continue with session setup + // The C flow runs the NPSSO authorizeCheck itself as its first (silent) step + // and returns AUTHORIZATION_FAILED if the token is expired. return try continueCloudSessionAfterAuth( serviceType: normalizedServiceType, gameIdentifier: gameIdentifier, gameName: gameName, npssoToken: npssoToken, - sharedDuid: sharedDuid, ownedEntitlementId: ownedEntitlementId, ownedPlatform: ownedPlatform, onProgress: onProgress, @@ -76,7 +66,6 @@ final class CloudStreamingBackend { gameIdentifier: String, gameName: String, npssoToken: String, - sharedDuid: String, ownedEntitlementId: String = "", ownedPlatform: String = "", onProgress: ((String) -> Void)?, @@ -99,9 +88,6 @@ final class CloudStreamingBackend { let priorData = pscloud ? SecureStore.shared.pscloudDatacentersData : SecureStore.shared.psnowDatacentersData let priorDatacentersJson = priorData.flatMap { String(data: $0, encoding: .utf8) } ?? "" - // sharedDuid is only the auth-check DUID; the C flow generates its own shared one. - _ = sharedDuid - let result = PyluxCloudProvision.provision( withServiceType: serviceType, gameIdentifier: gameIdentifier, @@ -151,7 +137,9 @@ final class CloudStreamingBackend { // Map the C error_message sentinels to the error types CloudPlayView catches. let msg = result.errorMessage ?? "Allocation failed" os_log(.error, log: cloudLog, "Cloud provisioning failed: %{public}s", msg) - if msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") { + if msg.contains("AUTHORIZATION_FAILED") { + throw AuthorizationFailedError(message: "Your NPSSO token is likely expired. Please re-login.") + } else if msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") { throw PsPlusSubscriptionError(message: "PS Plus subscription required") } else if msg.contains("PING_TIMEOUT") { throw PingTimeoutError() @@ -160,52 +148,4 @@ final class CloudStreamingBackend { } } - // MARK: - Authorization Check (matches Qt lines 543-613) - - private func checkAuthorization(serviceType: String, npssoToken: String, duid: String) -> Bool { - guard !npssoToken.isEmpty else { return false } - - let kamajiClientId: String - let scopesStr: String - let redirectUri: String - let userAgent: String - - if serviceType == "psnow" { - kamajiClientId = CloudApiConstants.kamajiClientId - scopesStr = CloudApiConstants.ps4Scopes - redirectUri = CloudApiConstants.kamajiRedirectUri - userAgent = CloudApiConstants.kamajiUserAgent - } else { - kamajiClientId = "19ae39c4-3f88-4d11-a792-94e4f52c996d" - scopesStr = "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s" - redirectUri = CloudApiConstants.gaikaiRedirectUri - userAgent = CloudApiConstants.gaikaiUserAgent - } - - let url = "\(CloudApiConstants.accountBase)/authz/v3/oauth/authorizeCheck" - let body: [String: Any] = [ - "client_id": kamajiClientId, "scope": scopesStr, - "redirect_uri": redirectUri, "response_type": "code", - "service_entity": "urn:service-entity:psn", "duid": duid - ] - - guard let bodyData = try? JSONSerialization.data(withJSONObject: body), - let bodyStr = String(data: bodyData, encoding: .utf8), - let response = CloudHttpClient.post(url: url, body: bodyStr, headers: [ - "Content-Type": "application/json; charset=UTF-8", - "User-Agent": userAgent, - "Cookie": "npsso=\(npssoToken)" - ]) else { return false } - - return response.statusCode == 200 || response.statusCode == 204 - } - - // MARK: - DUID Generation (matches Android DuidUtil) - - private func generateDuid() -> String { - let prefix = "0000000700410080" - var randomBytes = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, 16, &randomBytes) - return prefix + randomBytes.map { String(format: "%02x", $0) }.joined() - } } diff --git a/ios/Pylux/Services/PSGaikaiStreaming.swift b/ios/Pylux/Services/PSGaikaiStreaming.swift deleted file mode 100644 index ccf8579e..00000000 --- a/ios/Pylux/Services/PSGaikaiStreaming.swift +++ /dev/null @@ -1,943 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL -// Gaikai streaming allocation flow (Steps 0-13) - mirrors Android PSGaikaiStreaming.kt exactly - -import Foundation -import os.log - -private let gkLog = OSLog(subsystem: "com.pylux.stream", category: "Gaikai") - -/// PSGaikaiStreaming - Complete Gaikai streaming allocation flow (Steps 0-13) -/// Mirrors: android/.../cloudplay/api/PSGaikaiStreaming.kt -final class PSGaikaiStreaming { - private let duid: String - private let serviceType: String // "psnow" or "pscloud" - private let platform: String // "ps3", "ps4", or "ps5" - private let npssoToken: String - private let onProgress: ((String) -> Void)? - private let isCancelled: () -> Bool - - // Derived configuration - private let virtType: String - private let redirectUri: String - private let userAgent: String - private let oauthApiPath: String - - // State - private var configKey = "" - private var gaikaiSessionId = "" - private var gkClientId = "" - private var ps3GkClientId = "" - private var streamServerClientId = "" - private var gkCloudAuthCode = "" - private var ps3AuthCode = "" - private var streamServerAuthCode = "" - private var requestGameSpec: [String: Any] = [:] - private var selectedDatacenter = "" - private var selectedDatacenterPort = 0 - private var selectedDatacenterPingResult: [String: Any] = [:] - - // Allocation polling - private static let maxAllocationWaitSeconds = 900 // 15 min - private static let defaultAllocationWaitSeconds = 300 // 5 min - private static let maxLockSessionRetries = 12 - /// Same as Android `DatacenterPing.PING_TIMEOUT_MS` (15s). - private static let datacenterPingTimeoutSeconds: TimeInterval = 15 - // TODO: Re-check datacenter senkusha pings on a physical device. Simulator often hits ping timeouts - // (UDP / network path); treat emulator-only failures as inconclusive for ping correctness. - - private var allocationWaitStartTime: TimeInterval = 0 - private var allocationMaxWaitSeconds = 0 - private var allocationRetryCount = 0 - private var lockSessionRetryCount = 0 - - init(duid: String, serviceType: String, platform: String, npssoToken: String, - onProgress: ((String) -> Void)? = nil, isCancelled: @escaping () -> Bool = { false }) { - self.duid = duid - self.serviceType = serviceType - self.platform = platform - self.npssoToken = npssoToken - self.onProgress = onProgress - self.isCancelled = isCancelled - - switch platform { - case "ps3": self.virtType = "konan" - case "ps5": self.virtType = "cronos" - default: self.virtType = "kratos" - } - - if serviceType == "pscloud" { - redirectUri = CloudApiConstants.gaikaiRedirectUri - userAgent = CloudApiConstants.gaikaiUserAgent - oauthApiPath = "/api/authz/v3" - } else { - redirectUri = CloudApiConstants.kamajiRedirectUri - userAgent = CloudApiConstants.kamajiUserAgent - oauthApiPath = "/api/v1" - } - } - - // MARK: - Main Entry Point - - func startAllocationFlow(entitlementId: String) throws -> GaikaiAllocationResult { - os_log(.info, log: gkLog, "=== Starting Gaikai Allocation Flow ===") - os_log(.info, log: gkLog, "Entitlement ID: %{public}s", entitlementId) - - do { - // Step 0: Get client IDs - onProgress?("Getting Client IDs - Step 1 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step0_GetClientIds() - os_log(.info, log: gkLog, "✓ Step 0: Got client IDs") - - // Step 7: Get config - onProgress?("Getting Configuration - Step 2 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step7_GetConfig() - os_log(.info, log: gkLog, "✓ Step 7: Got config") - - // Step 8: Start session - onProgress?("Starting Session - Step 3 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step8_StartSession(entitlementId: entitlementId) - os_log(.info, log: gkLog, "✓ Step 8: Started session") - - // Step 8a: Get gkClientId auth code - onProgress?("Getting Tokens - Step 4 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step8a_GetAuthCode() - os_log(.info, log: gkLog, "✓ Step 8a: Got gkClientId auth code") - - // Step 8b: Get server auth code - onProgress?("Getting Server Tokens - Step 5 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step8b_GetServerAuthCode() - os_log(.info, log: gkLog, "✓ Step 8b: Got server auth code") - - // Step 9: Authorize session - onProgress?("Authorizing Session - Step 6 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step9_AuthorizeSession() - os_log(.info, log: gkLog, "✓ Step 9: Authorized session") - - // Step 10: Lock session - onProgress?("Locking Session - Step 7 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - try step10_LockSession() - os_log(.info, log: gkLog, "✓ Step 10: Locked session") - - // Step 11: Get datacenters - onProgress?("Getting Datacenters - Step 8 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - let datacenters = try step11_GetDatacenters() - os_log(.info, log: gkLog, "✓ Step 11: Got %d datacenters", datacenters.count) - - // Step 12: Select datacenter - onProgress?("Selecting Datacenter - Step 9 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - let dcName = try step12_SelectDatacenter(datacenters: datacenters) - onProgress?("Selecting Datacenter (\(dcName)) - Step 9 of 10") - os_log(.info, log: gkLog, "✓ Step 12: Selected datacenter: %{public}s", dcName) - - // Step 13: Allocate slot - onProgress?("Allocating Streaming Slot - Step 10 of 10") - guard !isCancelled() else { return .init(success: false, message: "Cancelled") } - let allocation = try step13_AllocateSlot() - - // Parse allocation response - guard let launchSlot = allocation["launchSlot"] as? [String: Any] else { - return .init(success: false, message: "Allocation response missing launchSlot") - } - - let serverIp = launchSlot["publicIp"] as? String ?? "" - let serverPort = launchSlot["port"] as? Int ?? 0 - let privateIp = launchSlot["privateIp"] as? String ?? "" - let handshakeKey = allocation["handshakeKey"] as? String ?? "" - let launchSpec = allocation["launchSpecification"] as? String ?? "" - let sessionId = allocation["sessionId"] as? String ?? "" - - guard !serverIp.isEmpty, serverPort != 0, !launchSpec.isEmpty else { - return .init(success: false, message: "Allocation response incomplete") - } - - // PSN wrapper type from private IP last octet - var psnWrapperType = 0x01 - if !privateIp.isEmpty, let lastOctet = privateIp.split(separator: ".").last, - let octet = Int(lastOctet), (0...255).contains(octet) { - psnWrapperType = octet - } - - os_log(.info, log: gkLog, "=== ALLOCATION SUCCESSFUL ===") - os_log(.info, log: gkLog, "Server: %{public}s:%d", serverIp, serverPort) - os_log(.info, log: gkLog, "Session ID: %{public}s", sessionId) - os_log(.info, log: gkLog, "PSN Wrapper Type: 0x%02x", psnWrapperType) - - return GaikaiAllocationResult( - success: true, message: "Success", - serverIp: serverIp, serverPort: serverPort, - handshakeKey: handshakeKey, launchSpec: launchSpec, - sessionId: sessionId, psnWrapperType: psnWrapperType, - mtuIn: Self.jsonNumberToInt(selectedDatacenterPingResult["mtu_in"]) ?? 1454, - mtuOut: Self.jsonNumberToInt(selectedDatacenterPingResult["mtu_out"]) ?? 1254, - rttMs: Self.jsonNumberToInt(selectedDatacenterPingResult["rtt"]) ?? 20 - ) - } catch let error as PsPlusSubscriptionError { - throw error - } catch let error as PingTimeoutError { - throw error - } catch let error as GaikaiAllocationError { - throw error - } catch { - os_log(.error, log: gkLog, "Gaikai allocation error: %{public}s", error.localizedDescription) - return .init(success: false, message: error.localizedDescription) - } - } - - // MARK: - Step 0: Get Client IDs - - private func step0_GetClientIds() throws { - let url = "\(CloudApiConstants.gaikaiBase)/client_ids?virtType=\(virtType)" - guard let response = CloudHttpClient.get(url: url, headers: [ - "User-Agent": userAgent, "Accept": "*/*" - ]), response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Failed to get client IDs") - } - - guard let json = parseJSON(response.body), - let gk = json["gkClientId"] as? String, !gk.isEmpty else { - throw GaikaiAllocationError(message: "No gkClientId in response") - } - gkClientId = gk - ps3GkClientId = json["ps3GkClientId"] as? String ?? "" - streamServerClientId = json["streamServerClientId"] as? String ?? "" - os_log(.info, log: gkLog, "Step 0: gkClientId=%{public}s", gkClientId) - } - - // MARK: - Step 7: Get Config - - private func step7_GetConfig() throws { - let url = "\(CloudApiConstants.configBase)/config" - var body: [String: Any] = ["sessionId": ""] - if serviceType == "pscloud" { - body["product"] = "qlite"; body["platform"] = "qlite" - } else { - body["product"] = "psnow"; body["platform"] = "PC" - } - let bodyStr = jsonString(body) - - guard let response = CloudHttpClient.post(url: url, body: bodyStr, headers: [ - "Content-Type": "application/json", "User-Agent": userAgent, "Accept": "*/*" - ]), response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Failed to get config") - } - - guard let json = parseJSON(response.body), - let key = json["configKey"] as? String, !key.isEmpty else { - throw GaikaiAllocationError(message: "No configKey in response") - } - configKey = key - } - - // MARK: - Step 8: Start Session - - private func step8_StartSession(entitlementId: String) throws { - let url = "\(CloudApiConstants.gaikaiBase)/sessions/start?npEnv=np" - requestGameSpec = buildRequestGameSpec(entitlementId: entitlementId) - let wrapper: [String: Any] = ["requestGameSpecification": requestGameSpec] - let bodyStr = jsonString(wrapper) - - guard let response = CloudHttpClient.post(url: url, body: bodyStr, headers: [ - "Content-Type": "application/json", "User-Agent": userAgent, - "Accept": "application/json", "X-Gaikai-Session": configKey - ]) else { - throw GaikaiAllocationError(message: "Session start failed: no response") - } - guard response.statusCode == 200 else { - // Include the response body: Gaikai reports an unowned/invalid entitlement here - // (e.g. {"name":"noGameForEntitlementId",...}), which the owned fast-path fallback in - // CloudStreamingBackend keys off to retry via the full resolve/acquire flow. - throw GaikaiAllocationError(message: "Session start failed: \(response.body)") - } - - if let newKey = response.header("x-gaikai-session") ?? response.header("X-Gaikai-Session"), - !newKey.isEmpty { configKey = newKey } - - guard let json = parseJSON(response.body), - let sid = json["sessionId"] as? String, !sid.isEmpty else { - throw GaikaiAllocationError(message: "No sessionId in response") - } - gaikaiSessionId = sid - os_log(.info, log: gkLog, "Step 8: Session ID: %{public}s", gaikaiSessionId) - } - - // MARK: - Step 8a: Get gkClientId Auth Code - - private func step8a_GetAuthCode() throws { - var params: [(String, String)] = [ - ("response_type", "code"), - ("client_id", gkClientId), - ("redirect_uri", redirectUri), - ("service_entity", "urn:service-entity:psn"), - ("prompt", "none"), - ("duid", duid) - ] - if serviceType == "pscloud" { - params += [("smcid", "qlite"), ("applicationId", "qlite"), ("mid", "qlite"), - ("scope", "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s")] - } else { - params += [("smcid", "pc:psnow"), ("applicationId", "psnow"), ("mid", "PSNOW"), - ("scope", "kamaji:commerce_native versa:user_update_entitlements_first_play kamaji:lists"), - ("renderMode", "mobilePortrait"), ("hidePageElements", "forgotPasswordLink"), - ("displayFooter", "none"), ("disableLinks", "qriocityLink"), - ("layout_type", "popup"), ("service_logo", "ps"), ("tp_psn", "true"), ("noEVBlock", "true")] - } - - let code = try getOAuthCode(params: params) - gkCloudAuthCode = code - os_log(.info, log: gkLog, "Step 8a: Got gkCloudAuthCode") - } - - // MARK: - Step 8b: Get Server Auth Code - - private func step8b_GetServerAuthCode() throws { - var params: [(String, String)] = [ - ("response_type", "code"), - ("redirect_uri", redirectUri), - ("service_entity", "urn:service-entity:psn"), - ("prompt", "none") - ] - if serviceType == "pscloud" { - params += [("client_id", streamServerClientId), ("smcid", "qlite"), - ("applicationId", "qlite"), ("mid", "qlite"), - ("scope", "id_token:duid id_token:online_id openid oauth:create_authn_ticket_for_cloud_console_signin"), - ("duid", duid)] - } else { - params.append(("client_id", ps3GkClientId)) - params += [("smcid", "pc:psnow"), ("applicationId", "psnow"), ("mid", "PSNOW")] - if platform == "ps3" { - params.append(("scope", "kamaji:commerce_native")) - } else { - params += [("scope", "sso:none"), ("duid", duid)] - } - params += [("renderMode", "mobilePortrait"), ("hidePageElements", "forgotPasswordLink"), - ("displayFooter", "none"), ("disableLinks", "qriocityLink"), - ("layout_type", "popup"), ("service_logo", "ps"), ("tp_psn", "true"), ("noEVBlock", "true")] - } - - let code = try getOAuthCode(params: params) - if serviceType == "pscloud" { - streamServerAuthCode = code; ps3AuthCode = "" - } else { - ps3AuthCode = code; streamServerAuthCode = code - } - } - - // MARK: - Step 9: Authorize Session - - private func step9_AuthorizeSession() throws { - let url = "\(CloudApiConstants.gaikaiBase)/sessions/\(gaikaiSessionId)/authorize" - requestGameSpec["gkCloudAuthCode"] = gkCloudAuthCode - requestGameSpec["ps3AuthCode"] = ps3AuthCode - requestGameSpec["streamServerAuthCode"] = streamServerAuthCode - - let body = jsonString(["requestGameSpecification": requestGameSpec]) - guard let response = CloudHttpClient.post(url: url, body: body, headers: gaikaiHeaders()) else { - throw GaikaiAllocationError(message: "Authorize session request failed") - } - - if response.statusCode != 200 { - // Check for PS Plus subscription error (eventCode 002.2001) - var isPSPlusError = false - if let bodyJson = parseJSON(response.body), - let errors = bodyJson["errors"] as? [[String: Any]] { - for errorObj in errors { - if (errorObj["eventCode"] as? String) == "002.2001" { isPSPlusError = true } - } - } - if isPSPlusError { - throw PsPlusSubscriptionError(message: "PlayStation Plus Premium subscription is required to stream this game") - } - throw GaikaiAllocationError(message: "Authorize failed: HTTP \(response.statusCode)") - } - - if let newKey = response.header("x-gaikai-session"), !newKey.isEmpty { configKey = newKey } - } - - // MARK: - Step 10: Lock Session (with retry) - - private func step10_LockSession() throws { - os_log(.info, log: gkLog, "Step 10: Locking session (attempt %d)", lockSessionRetryCount + 1) - let url = "\(CloudApiConstants.gaikaiBase)/sessions/\(gaikaiSessionId)/lock?forceLogout=true" - let body = jsonString(["requestGameSpecification": requestGameSpec]) - - guard let response = CloudHttpClient.post(url: url, body: body, headers: gaikaiHeaders()), - response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Lock session failed") - } - - if let newKey = response.header("x-gaikai-session"), !newKey.isEmpty { configKey = newKey } - - guard let json = parseJSON(response.body) else { - throw GaikaiAllocationError(message: "Lock session: invalid response") - } - - let lockAcquired = json["lockAcquired"] as? Bool ?? false - let pollFrequency = json["pollFrequency"] as? Int ?? 10 - - if !lockAcquired { - lockSessionRetryCount += 1 - if lockSessionRetryCount > Self.maxLockSessionRetries { - throw GaikaiAllocationError(message: "Could not acquire lock after \(Self.maxLockSessionRetries) attempts") - } - let msg = "Closing old session - Attempt \(lockSessionRetryCount)" - onProgress?(msg) - os_log(.info, log: gkLog, "%{public}s", msg) - guard !isCancelled() else { return } - Thread.sleep(forTimeInterval: TimeInterval(pollFrequency)) - try step10_LockSession() // Retry - return - } - lockSessionRetryCount = 0 - } - - // MARK: - Step 11: Get Datacenters - - private func step11_GetDatacenters() throws -> [[String: Any]] { - let url = "\(CloudApiConstants.gaikaiBase)/sessions/\(gaikaiSessionId)/datacenters" - let body = jsonString(["requestGameSpecification": requestGameSpec]) - - guard let response = CloudHttpClient.post(url: url, body: body, headers: gaikaiHeaders()), - response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Failed to get datacenters") - } - - if let newKey = response.header("x-gaikai-session"), !newKey.isEmpty { configKey = newKey } - - guard let arr = parseJSONArray(response.body) else { - throw GaikaiAllocationError(message: "Invalid datacenters response") - } - - for dc in arr { - os_log(.info, log: gkLog, " DC: %{public}s %{public}s:%d", - dc["dataCenter"] as? String ?? "", dc["publicIp"] as? String ?? "", dc["port"] as? Int ?? 0) - } - - // Seed the picker with the raw list ONLY when nothing is saved yet. Never - // overwrite a previously-saved list here: it carries real ping RTTs from a - // prior Auto run, and manual mode won't re-ping, so clobbering it with this - // no-RTT list would drop the ms from the picker. - if !CloudDatacenterStore.hasStoredDatacenters(for: serviceType) { - CloudDatacenterStore.saveDatacenters(arr, for: serviceType) - } - - return arr - } - - // MARK: - Step 12: Select Datacenter - - private func step12_SelectDatacenter(datacenters: [[String: Any]]) throws -> String { - guard !datacenters.isEmpty else { throw GaikaiAllocationError(message: "No datacenters available") } - - let prefs = StreamPreferences.load() - let userChoice = serviceType == "pscloud" ? prefs.cloudDatacenterPscloud : prefs.cloudDatacenterPsnow - - // Manual datacenter: dummy ping, no validation (Android PSGaikaiStreaming.kt) - if !userChoice.isEmpty, userChoice != "Auto", - let selectedDc = datacenters.first(where: { ($0["dataCenter"] as? String) == userChoice }) { - os_log(.info, log: gkLog, "Step 12: Manual datacenter %{public}s (skip ping validation)", userChoice) - - let port = Self.jsonNumberToInt(selectedDc["port"]) ?? 0 - let maxBw = Self.jsonNumberToInt(selectedDc["maxBandwidth"]) ?? 0 - let dummyPing: [String: Any] = [ - "dataCenter": selectedDc["dataCenter"] as? String ?? userChoice, - "rtt": 20, - "rtts": [20], - "mtu_in": 1454, - "mtu_out": 1254, - "port": port, - "publicIp": selectedDc["publicIp"] as? String ?? "", - "maxBandwidth": maxBw - ] - // Only persist the manual/dummy rows when no real measurements exist yet. - // Otherwise keep the previously-measured RTTs so the picker still shows the - // real ms (manual mode uses a dummy 20ms purely for this stream). - if !CloudDatacenterStore.hasStoredDatacenters(for: serviceType) { - let forStore = Self.datacenterRowsForManualStore(datacenters: datacenters, selectedName: userChoice, dummyPing: dummyPing) - CloudDatacenterStore.saveDatacenters(forStore, for: serviceType) - } - return try submitDatacenterSelection(pingResult: dummyPing, validatePing: false) - } - - if !userChoice.isEmpty, userChoice != "Auto" { - throw GaikaiAllocationError(message: "Selected datacenter '\(userChoice)' not available") - } - - // Auto: parallel senkusha ping (matches Android DatacenterPing + Qt) - os_log(.info, log: gkLog, "Step 12: Pinging %d datacenters (timeout %d s)...", - datacenters.count, Int(Self.datacenterPingTimeoutSeconds)) - - let pingResults = pingAllDatacentersWithTimeout(datacenters) - let mergedForStore = Self.mergeDatacenterPingRows(full: datacenters, pings: pingResults) - CloudDatacenterStore.saveDatacenters(mergedForStore, for: serviceType) - os_log(.info, log: gkLog, "Saved datacenter list for settings (%d rows, %d ping snapshots)", - mergedForStore.count, pingResults.count) - - let bestPing: [String: Any] - if !pingResults.isEmpty { - var best = pingResults[0] - var bestRtt = Self.jsonNumberToInt(best["rtt"]) ?? 999 - for i in 1.. 0, rtt < bestRtt { - best = row - bestRtt = rtt - } - } - let name = best["dataCenter"] as? String ?? "" - os_log(.info, log: gkLog, "Step 12: Best datacenter %{public}s RTT %d ms", name, bestRtt) - bestPing = best - } else { - os_log(.default, log: gkLog, "Step 12: No ping rows — fallback first DC + dummy ping") - let first = datacenters[0] - let port = Self.jsonNumberToInt(first["port"]) ?? 0 - let maxBw = Self.jsonNumberToInt(first["maxBandwidth"]) ?? 0 - bestPing = [ - "dataCenter": first["dataCenter"] as? String ?? "", - "rtt": 20, - "rtts": [20], - "mtu_in": 1454, - "mtu_out": 1254, - "port": port, - "publicIp": first["publicIp"] as? String ?? "", - "maxBandwidth": maxBw - ] - } - - return try submitDatacenterSelection(pingResult: bestPing, validatePing: true) - } - - /// Parallel senkusha pings; on timeout returns whatever rows finished (may be partial). - private func pingAllDatacentersWithTimeout(_ datacenters: [[String: Any]]) -> [[String: Any]] { - guard !datacenters.isEmpty else { return [] } - - let group = DispatchGroup() - let lock = NSLock() - var rows: [[String: Any]] = [] - let sessionKey = configKey - let svc = serviceType - - for dc in datacenters { - group.enter() - DispatchQueue.global(qos: .userInitiated).async { - defer { group.leave() } - let row = Self.buildPingResultRow(dc: dc, sessionKey: sessionKey, serviceType: svc) - lock.lock() - rows.append(row) - lock.unlock() - } - } - - let deadline = DispatchTime.now() + Self.datacenterPingTimeoutSeconds - let timedOut = group.wait(timeout: deadline) == .timedOut - lock.lock() - let snapshot = rows - lock.unlock() - if timedOut { - os_log(.default, log: gkLog, "Datacenter ping timed out; using %d partial result(s) of %d", - snapshot.count, datacenters.count) - } - return snapshot - } - - /// JSON numbers from `JSONSerialization` are often `Double`/`NSNumber`, not `Int`. - private static func jsonNumberToInt(_ value: Any?) -> Int? { - switch value { - case let i as Int: return i - case let n as NSNumber: return n.intValue - case let d as Double: return Int(d) - default: return nil - } - } - - /// One row per API datacenter; prefer measured ping row when present (handles timeout partials). - private static func mergeDatacenterPingRows(full: [[String: Any]], pings: [[String: Any]]) -> [[String: Any]] { - var byName: [String: [String: Any]] = [:] - for row in pings { - if let n = row["dataCenter"] as? String { byName[n] = row } - } - return full.map { dc -> [String: Any] in - let name = dc["dataCenter"] as? String ?? "" - if let hit = byName[name] { return hit } - var row = dc - row["rtt"] = 0 - row["rtts"] = [Int]() - row["mtu_in"] = 0 - row["mtu_out"] = 0 - return row - } - } - - private static func datacenterRowsForManualStore(datacenters: [[String: Any]], selectedName: String, dummyPing: [String: Any]) -> [[String: Any]] { - datacenters.map { dc in - let name = dc["dataCenter"] as? String ?? "" - if name == selectedName { return dummyPing } - var row = dc - row["rtt"] = 0 - row["rtts"] = [Int]() - row["mtu_in"] = 0 - row["mtu_out"] = 0 - return row - } - } - - private static func buildPingResultRow(dc: [String: Any], sessionKey: String, serviceType: String) -> [String: Any] { - let dataCenter = dc["dataCenter"] as? String ?? "" - let publicIp = dc["publicIp"] as? String ?? "" - let port = jsonNumberToInt(dc["port"]) ?? 0 - let maxBandwidth = jsonNumberToInt(dc["maxBandwidth"]) ?? 0 - - var base: [String: Any] = [ - "dataCenter": dataCenter, - "port": port, - "publicIp": publicIp, - "maxBandwidth": maxBandwidth - ] - - guard !sessionKey.isEmpty, !publicIp.isEmpty, port > 0 else { - base["rtt"] = 999 - base["rtts"] = [999] - base["mtu_in"] = 0 - base["mtu_out"] = 0 - return base - } - - var out = ChiakiDatacenterPingOutput() - out.rtt_us = -1 - let ok = publicIp.withCString { ipPtr in - sessionKey.withCString { skPtr in - serviceType.withCString { stPtr in - chiaki_datacenter_ping(ipPtr, Int32(port), skPtr, stPtr, &out) - } - } - } - - if ok, out.rtt_us > 0 { - let rttMs = Int(out.rtt_us / 1000) - base["rtt"] = rttMs - base["rtts"] = [rttMs] - base["mtu_in"] = Int(out.mtu_in) - base["mtu_out"] = Int(out.mtu_out) - os_log(.info, log: gkLog, "Ping %{public}s: %d ms mtu_in=%u mtu_out=%u", - dataCenter, Int32(rttMs), out.mtu_in, out.mtu_out) - } else { - base["rtt"] = 999 - base["rtts"] = [999] - base["mtu_in"] = 0 - base["mtu_out"] = 0 - os_log(.default, log: gkLog, "Ping failed %{public}s", dataCenter) - } - return base - } - - private func submitDatacenterSelection(pingResult: [String: Any], validatePing: Bool) throws -> String { - let dcName = pingResult["dataCenter"] as? String ?? "" - let rtt = Self.jsonNumberToInt(pingResult["rtt"]) ?? 0 - - if validatePing && rtt > 80 { - os_log(.default, log: gkLog, "Ping validation failed: %{public}s RTT %d ms (max 80)", dcName, rtt) - throw PingTimeoutError() - } - - selectedDatacenterPingResult = pingResult - selectedDatacenter = dcName - selectedDatacenterPort = Self.jsonNumberToInt(pingResult["port"]) ?? 0 - - let url = "\(CloudApiConstants.gaikaiBase)/sessions/\(gaikaiSessionId)/datacenters/select" - let body = jsonString([ - "requestGameSpecification": requestGameSpec, - "pingResults": [pingResult] - ]) - - guard let response = CloudHttpClient.post(url: url, body: body, headers: gaikaiHeaders()), - response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Datacenter selection failed") - } - - if let newKey = response.header("x-gaikai-session"), !newKey.isEmpty { configKey = newKey } - - // Extract port from response if provided - if let json = parseJSON(response.body), let port = json["port"] as? Int, port > 0 { - selectedDatacenterPort = port - } - - os_log(.info, log: gkLog, "Step 12: Selected %{public}s:%d", dcName, selectedDatacenterPort) - return dcName - } - - // MARK: - Step 13: Allocate Slot (with retry) - - private func step13_AllocateSlot() throws -> [String: Any] { - let url = "\(CloudApiConstants.gaikaiBase)/sessions/\(gaikaiSessionId)/allocate" - let cloudPrefs = StreamPreferences.load() - let cloudBwKbps = serviceType == "pscloud" - ? StreamPreferences.clampCloudBitrateKbps(cloudPrefs.cloudBitratePscloud) - : StreamPreferences.clampCloudBitrateKbps(cloudPrefs.cloudBitratePsnow) - let network: [String: Any] = [ - "bwKbpsSent": cloudBwKbps, "bwLoss": 0.001, - "mtu": Self.jsonNumberToInt(selectedDatacenterPingResult["mtu_in"]) ?? 1454, - "rtt": Self.jsonNumberToInt(selectedDatacenterPingResult["rtt"]) ?? 25, - "port": selectedDatacenterPort, - "bwKbpsReceived": cloudBwKbps, "bwLossUpstream": 0, - "mtuUpstream": Self.jsonNumberToInt(selectedDatacenterPingResult["mtu_out"]) ?? 1254 - ] - - let body = jsonString([ - "requestGameSpecification": requestGameSpec, - "dataCenter": selectedDatacenter, - "network": network, - "stateExecutionTime": 5974.7632, - "streamTestTime": 11262.8423 - ]) - - guard let response = CloudHttpClient.post(url: url, body: body, headers: gaikaiHeaders()), - response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Allocation failed") - } - - if let newKey = response.header("x-gaikai-session"), !newKey.isEmpty { configKey = newKey } - - guard let allocation = parseJSON(response.body) else { - throw GaikaiAllocationError(message: "Invalid allocation response") - } - - // Check if queued or data migration - let queued = allocation["queued"] as? Bool ?? false - let dataMigration = allocation["dataMigration"] as? Bool ?? false - let pollFrequency = allocation["pollFrequency"] as? Int ?? 15 - - if queued || dataMigration { - allocationRetryCount += 1 - if allocationWaitStartTime == 0 { - allocationWaitStartTime = Date().timeIntervalSince1970 - let waitEstimate = allocation["waitTimeEstimate"] as? Int ?? -1 - allocationMaxWaitSeconds = waitEstimate > 0 - ? min(waitEstimate * 2, Self.maxAllocationWaitSeconds) - : Self.defaultAllocationWaitSeconds - } - - let elapsed = Int(Date().timeIntervalSince1970 - allocationWaitStartTime) - if elapsed >= allocationMaxWaitSeconds { - throw GaikaiAllocationError(message: "Allocation wait timeout after \(elapsed)s") - } - - let msg: String - if dataMigration { - let pct = allocation["dataMigrationPercentageComplete"] as? Int ?? 0 - msg = "Migrating data (\(pct)%) - Attempt \(allocationRetryCount)" - } else { - let qPos = allocation["displayQueuePosition"] as? Int ?? allocation["queuePosition"] as? Int ?? -1 - msg = qPos >= 0 - ? "Queue position: \(qPos) - Attempt \(allocationRetryCount)" - : "Allocating streaming slot - Attempt \(allocationRetryCount)" - } - onProgress?(msg) - os_log(.info, log: gkLog, "%{public}s", msg) - - guard !isCancelled() else { throw GaikaiAllocationError(message: "Cancelled") } - Thread.sleep(forTimeInterval: TimeInterval(pollFrequency)) - return try step13_AllocateSlot() // Retry - } - - allocationRetryCount = 0 - os_log(.info, log: gkLog, "✓ Slot allocated!") - return allocation - } - - // MARK: - Build Request Game Spec - - private func buildRequestGameSpec(entitlementId: String) -> [String: Any] { - var spec: [String: Any] = [:] - - // Timezone - let tz = TimeZone.current - let offset = tz.secondsFromGMT() - let hours = offset / 3600 - let minutes = abs((offset % 3600) / 60) - let tzStr = hours >= 0 ? String(format: "UTC+%02d:%02d", hours, minutes) - : String(format: "UTC-%02d:%02d", abs(hours), minutes) - - // Common fields - spec["entitlementId"] = entitlementId - spec["npEnv"] = "np" - // Gaikai expects the bare language code ("de"), not the stored locale - // ("de-DE"); the lib helper is the single source of truth across platforms. - // Use the user's chosen streaming language, falling back to the detected - // catalog locale when unset. - let chosenLocale = { - let l = StreamPreferences.load().cloudGameLanguage - return l.isEmpty ? CloudLocaleSettings.stored : l - }() - let cloudLanguage = PyluxCloudCatalog.gaikaiLanguage(forLocale: chosenLocale) - spec["language"] = cloudLanguage - os_log(.info, log: gkLog, "Gaikai request language: %{public}s", cloudLanguage) - spec["cloudEndpoint"] = "https://cc.prod.gaikai.com" - spec["redirectUri"] = redirectUri - - // Resolution from settings (matches Android cloud_resolution_pscloud / cloud_resolution_psnow) - let cloudPrefs = StreamPreferences.load() - let cloudRes: (width: Int, height: Int) - let resSetting: String - if serviceType == "pscloud" { - cloudRes = cloudPrefs.cloudResolutionDimensionsPscloud - resSetting = cloudPrefs.cloudResolutionPscloud - } else { - cloudRes = cloudPrefs.cloudResolutionDimensionsPsnow - resSetting = cloudPrefs.cloudResolutionPsnow - } - spec["resolutionSetting"] = resSetting - spec["clientWidth"] = cloudRes.width - spec["clientHeight"] = cloudRes.height - spec["adaptiveStreamMode"] = "resize" - spec["useClientBwLadder"] = true - - // Audio upload - spec["audioUploadEnabled"] = true - spec["audioUploadNumChannels"] = 1 - spec["audioUploadSamplingFrequency"] = 48000 - - // Input - spec["acceptButton"] = "X" - spec["encryptionSupported"] = true - spec["summerTime"] = 0 - spec["timeZone"] = tzStr - spec["httpUserAgent"] = userAgent - spec["gkCloudAuthCode"] = gkCloudAuthCode - - // Accessibility - spec["accessibilityMarqueeSpeed"] = 0 - spec["accessibilityLargeText"] = 0 - spec["accessibilityBoldText"] = 0 - spec["accessibilityContrast"] = 0 - spec["accessibilityTtsEnable"] = 0 - spec["accessibilityTtsSpeed"] = 0 - spec["accessibilityTtsVolume"] = 0 - - // Capabilities - spec["partyCapability"] = false - spec["homesharing"] = false - spec["isFirstBoot"] = false - spec["isPlusMember"] = true - spec["parentalLevel"] = 0 - spec["yuvCoefficient"] = "" - - var caps = ["cloudDrivenSenkushaTest"] - - if serviceType == "pscloud" { - spec["videoEncoderProfile"] = "hw5.0" - spec["connectedControllers"] = ["ds4", "ds5", "xinput"] - spec["input"] = ["controllers": ["ds4", "ds5", "xinput"]] - spec["model"] = "portal" - spec["platform"] = "qlite" - spec["gaikaiPlayer"] = "16.4.0" - spec["protocolVersion"] = 12 - spec["ps3AuthCode"] = "" - spec["streamServerAuthCode"] = streamServerAuthCode - caps.append("cronos") - - let maxRes = Int(resSetting) ?? 1080 - spec["videoStreamSettings"] = [ - "clientHeight": cloudRes.height, - "supportedMaxResolution": maxRes, - "supportedVideoEncoderProfiles": ["hevc_hw4"], - "supportedDynamicRange": "sdr", - "preferredMaxResolution": maxRes, - "preferredDynamicRange": "sdr", - "hqMode": 1 - ] as [String: Any] - - spec["audioChannels"] = "2" - spec["audioEncoderProfile"] = "default" - spec["audioStreamSettings"] = [ - "audioEncoderProfile": "default", - "maxAudioChannels": "2", - "preferredNumberAudioChannels": "2" - ] - } else { - spec["audioChannels"] = "2.1" - spec["audioEncoderProfile"] = "default" - spec["videoEncoderProfile"] = "hw4.1" - spec["connectedControllers"] = ["xinput"] - spec["input"] = ["controllers": ["xinput"]] - spec["model"] = "WINDOWS" - spec["platform"] = "PC" - spec["gaikaiPlayer"] = "12.5.0" - spec["protocolVersion"] = 9 - spec["ps3AuthCode"] = ps3AuthCode - spec["streamServerAuthCode"] = ps3AuthCode - caps.append("kratos") - } - - spec["capabilities"] = caps - return spec - } - - // MARK: - Helpers - - private func gaikaiHeaders() -> [String: String] { - return [ - "Content-Type": "application/json", - "User-Agent": userAgent, - "Accept": "*/*", - "X-Gaikai-Session": configKey, - "X-Gaikai-SessionId": gaikaiSessionId - ] - } - - private func getOAuthCode(params: [(String, String)]) throws -> String { - let query = params.map { "\($0.0)=\($0.1.cloudUrlEncoded)" }.joined(separator: "&") - let url = "\(CloudApiConstants.gaikaiAccountBase)\(oauthApiPath)/oauth/authorize?\(query)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "User-Agent": userAgent, "Cookie": "npsso=\(npssoToken)" - ], followRedirects: false) else { - throw GaikaiAllocationError(message: "OAuth request failed") - } - - guard response.statusCode == 302 else { - throw GaikaiAllocationError(message: "OAuth: expected 302, got \(response.statusCode) Data: \(response.body)") - } - - guard let location = CloudHttpClient.extractLocation(from: response) else { - throw GaikaiAllocationError(message: "No Location header in OAuth redirect") - } - - guard let code = extractCodeFromURL(location) else { - throw GaikaiAllocationError(message: "No code in OAuth redirect URL") - } - return code - } - - private func extractCodeFromURL(_ urlString: String) -> String? { - guard let comps = URLComponents(string: urlString) else { return nil } - return comps.queryItems?.first(where: { $0.name == "code" })?.value - } - - private func parseJSON(_ str: String) -> [String: Any]? { - guard let data = str.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - return json - } - - private func parseJSONArray(_ str: String) -> [[String: Any]]? { - guard let data = str.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return nil } - return arr - } - - private func jsonString(_ dict: [String: Any]) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []), - let str = String(data: data, encoding: .utf8) else { return "{}" } - return str - } -} - diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift deleted file mode 100644 index ad07c9e4..00000000 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ /dev/null @@ -1,398 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL -// Kamaji authentication (Steps 0.5a-6) - mirrors Android PSKamajiSession.kt exactly - -import Foundation -import os.log - -private let kamajiLog = OSLog(subsystem: "com.pylux.stream", category: "Kamaji") - -/// PSKamajiSession - Handles PlayStation Cloud Gaming Kamaji Authentication (Steps 1-6) -/// Used only for PSNOW games (PS3/PS4). PSCLOUD skips Kamaji entirely. -/// Mirrors: android/.../cloudplay/api/PSKamajiSession.kt -final class PSKamajiSession { - private let duid: String - private let productId: String - private let accountBaseUrl: String - private let redirectUri: String - private let userAgent: String - - private let kamajiBase = CloudApiConstants.kamajiBase - private let storeBase = CloudApiConstants.storeBase - private let commerceBase = CloudApiConstants.commerceBase - private let kamajiClientId = CloudApiConstants.kamajiClientId - - private var platform = "ps4" - private var scopesStr = CloudApiConstants.ps4Scopes - private var jsessionId: String? - private var entitlementId: String? - private var streamingSku: String? - private var commerceOAuthToken: String? - - // Owned-PSNOW fast-path: the unified catalog's pre-resolved owned streaming entitlement. When - // set, startSessionCreation skips the entitlement path (0.5b/0.5d/0.5e). See - // setOwnedEntitlementFastPath(). usedEntitlementFastPath gates the orchestrator's one-shot retry. - private var fastPathEntitlementId = "" - private var fastPathPlatform = "" - private(set) var usedEntitlementFastPath = false - - init(duid: String, productId: String, accountBaseUrl: String, redirectUri: String, userAgent: String) { - self.duid = duid - self.productId = productId - self.accountBaseUrl = accountBaseUrl - self.redirectUri = redirectUri - self.userAgent = userAgent - } - - /// Owned-PSNOW fast-path: hand in the streaming entitlement the unified catalog already resolved - /// for an owned title, so startSessionCreation() skips the entitlement path (0.5b anonymous - /// session, 0.5d product->entitlement resolve, 0.5e check/acquire) and goes straight to step5/6. - /// Empty == normal full flow. The orchestrator falls back to the full flow if Gaikai rejects it. - func setOwnedEntitlementFastPath(ownedEntitlementId: String, ownedPlatform: String) { - fastPathEntitlementId = ownedEntitlementId - fastPathPlatform = ownedPlatform - } - - /// Start the complete Kamaji session creation flow - func startSessionCreation(npssoToken: String) -> KamajiSessionResult { - os_log(.info, log: kamajiLog, "=== Starting Kamaji Session ===") - os_log(.info, log: kamajiLog, "Product ID: %{public}s", productId) - - // Owned-PSNOW fast-path: the unified catalog already resolved this title's streaming - // entitlement, so there is nothing to look up or acquire. Skip the entire entitlement path - // (0.5b/0.5d/0.5e -- which 404 and fail to acquire in storefront-less regions) and go - // straight to the authenticated session (step5/6 here reuse 0.5b/0.5c). The orchestrator - // retries the full flow if Gaikai rejects it. - if !fastPathEntitlementId.isEmpty { - entitlementId = fastPathEntitlementId - platform = fastPathPlatform.isEmpty ? "ps4" : fastPathPlatform - scopesStr = platform == "ps3" ? "kamaji:commerce_native" : CloudApiConstants.ps4Scopes - usedEntitlementFastPath = true - os_log(.info, log: kamajiLog, - "Kamaji fast-path: owned entitlementId=%{public}s platform=%{public}s - skipping 0.5b/0.5d/0.5e", - entitlementId ?? "", platform) - - guard let authCode = step0_5b_GetAnonymousAuthCode(npssoToken: npssoToken) else { - return KamajiSessionResult(success: false, message: "Failed to get auth code") - } - guard step0_5c_CreateAnonymousSession(authCode: authCode) != nil else { - return KamajiSessionResult(success: false, message: "Failed to create auth session") - } - os_log(.info, log: kamajiLog, "=== Kamaji Session Complete (fast-path) ===") - return KamajiSessionResult(success: true, message: "Success", - entitlementId: entitlementId ?? "", platform: platform) - } - - // Step 0.5b: Get anonymous auth code - guard let anonCode = step0_5b_GetAnonymousAuthCode(npssoToken: npssoToken) else { - return KamajiSessionResult(success: false, message: "Failed to get anonymous auth code") - } - os_log(.info, log: kamajiLog, "✓ Step 0.5b: Got anonymous auth code") - - // Step 0.5c: Create anonymous session - guard let sessionId = step0_5c_CreateAnonymousSession(authCode: anonCode) else { - return KamajiSessionResult(success: false, message: "Failed to create anonymous session") - } - jsessionId = sessionId - os_log(.info, log: kamajiLog, "✓ Step 0.5c: Got JSESSIONID") - - // Step 0.5d: Convert product ID to entitlement ID - guard let conversion = step0_5d_ConvertProductId(sessionId: sessionId) else { - return KamajiSessionResult(success: false, message: "Failed to convert product ID") - } - entitlementId = conversion.entitlementId - platform = conversion.platform - streamingSku = conversion.sku - os_log(.info, log: kamajiLog, "✓ Step 0.5d: Entitlement: %{public}s, Platform: %{public}s", - entitlementId ?? "", platform) - - if platform == "ps3" { scopesStr = "kamaji:commerce_native" } - - // Step 0.5e: Check and acquire entitlement - guard step0_5e_CheckAndAcquireEntitlement(npssoToken: npssoToken, sessionId: sessionId) else { - return KamajiSessionResult(success: false, message: "Failed to check/acquire entitlement") - } - os_log(.info, log: kamajiLog, "✓ Step 0.5e: Entitlement check OK") - - // Step 5: Get auth code (same as 0.5b) - guard let authCode = step0_5b_GetAnonymousAuthCode(npssoToken: npssoToken) else { - return KamajiSessionResult(success: false, message: "Failed to get auth code") - } - os_log(.info, log: kamajiLog, "✓ Step 5: Got auth code") - - // Step 6: Create auth session (same as 0.5c) - guard step0_5c_CreateAnonymousSession(authCode: authCode) != nil else { - return KamajiSessionResult(success: false, message: "Failed to create auth session") - } - os_log(.info, log: kamajiLog, "✓ Step 6: Authenticated session created") - os_log(.info, log: kamajiLog, "=== Kamaji Session Complete ===") - - return KamajiSessionResult(success: true, message: "Success", - entitlementId: entitlementId ?? "", platform: platform) - } - - // MARK: - Step 0.5b: Get Anonymous Auth Code - - private func step0_5b_GetAnonymousAuthCode(npssoToken: String) -> String? { - let params: [(String, String)] = [ - ("smcid", "pc:psnow"), ("applicationId", "psnow"), - ("response_type", "code"), ("scope", scopesStr), - ("client_id", kamajiClientId), ("redirect_uri", redirectUri), - ("service_entity", "urn:service-entity:psn"), ("prompt", "none"), - ("renderMode", "mobilePortrait"), ("hidePageElements", "forgotPasswordLink"), - ("displayFooter", "none"), ("disableLinks", "qriocityLink"), - ("mid", "PSNOW"), ("duid", duid), ("layout_type", "popup"), - ("service_logo", "ps"), ("tp_psn", "true"), ("noEVBlock", "true") - ] - let query = params.map { "\($0.0)=\($0.1.cloudUrlEncoded)" }.joined(separator: "&") - let url = "\(accountBaseUrl)/v1/oauth/authorize?\(query)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "User-Agent": userAgent, "Cookie": "npsso=\(npssoToken)" - ], followRedirects: false), response.statusCode == 302 else { return nil } - - guard let location = CloudHttpClient.extractLocation(from: response), - let comps = URLComponents(string: location), - let code = comps.queryItems?.first(where: { $0.name == "code" })?.value else { return nil } - return code - } - - // MARK: - Step 0.5c: Create Anonymous Session - - @discardableResult - private func step0_5c_CreateAnonymousSession(authCode: String) -> String? { - let url = "\(kamajiBase)/user/session" - let body = "code=\(authCode)&client_id=\(kamajiClientId)&duid=\(duid)" - - guard let response = CloudHttpClient.post(url: url, body: body, headers: [ - "Content-Type": "text/plain;charset=UTF-8", - "User-Agent": userAgent, - "X-Alt-Referer": redirectUri, - "Accept": "*/*", - "Origin": CloudApiConstants.kamajiOrigin, - "Referer": CloudApiConstants.kamajiReferer - ]), response.statusCode == 200 else { return nil } - - CloudLocaleSettings.applyLocaleFromKamajiSessionBody(response.body) - return CloudHttpClient.extractCookie(from: response, name: "JSESSIONID") - } - - // MARK: - Step 0.5d: Convert Product ID - - private struct ProductConversion { - let entitlementId: String - let platform: String - let sku: String - } - - // Region-group fallback: when /user/stores has no storefront for the account's region, the - // PS Now catalog (PS3 + PS4) was browsed from the public region-group APOLLOROOT container - // (US for Americas, GB for PAL), so its product ids must be RESOLVED against that same - // region-group store. Driven by the account-level fallback flag; PS3 and PS4 behave identically. - private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { - let resolvedCountry = SecureStore.shared.cloudResolvedStoreCountry - let resolvedLang = SecureStore.shared.cloudResolvedStoreLang - let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) - let country: String - let language: String - if !resolvedCountry.isEmpty { - country = resolvedCountry - // Prefer the server-authoritative store language from the native base_url: a non-English - // native store (e.g. NL) 404s on the wrong language. Fall back to the locale-derived - // proxy when empty (fallback/foreign mode, where the public US/GB store wants en). - language = resolvedLang.isEmpty ? storePath.language : resolvedLang - } else { - country = storePath.country - language = storePath.language - } - os_log(.info, log: kamajiLog, - "step0_5d: using resolvedStoreCountry=%{public}s (lang=%{public}s) for container URL", - country, language) - let url = "\(storeBase)/container/\(country)/\(language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "Accept": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ]), response.statusCode == 200 else { return nil } - - guard let json = try? JSONSerialization.jsonObject(with: Data(response.body.utf8)) as? [String: Any] else { - return nil - } - - var eid = "" - var sku = "" - var detectedPlatform = "ps4" - - // PS Now streaming entitlements have license_type == 4. Check default_sku, then skus. - func pickStreamingEntitlement(_ skuObj: [String: Any]) -> Bool { - guard let ents = skuObj["entitlements"] as? [[String: Any]] else { return false } - for ent in ents { - if (ent["license_type"] as? Int) == 4, let id = ent["id"] as? String, !id.isEmpty { - eid = id; sku = skuObj["id"] as? String ?? ""; return true - } - } - return false - } - if let defaultSku = json["default_sku"] as? [String: Any] { _ = pickStreamingEntitlement(defaultSku) } - if eid.isEmpty, let skus = json["skus"] as? [[String: Any]] { - for skuObj in skus where pickStreamingEntitlement(skuObj) { break } - } - - // Full-game fallback: PS Plus catalog titles have no license_type==4 entitlement; their - // full-game entitlement is license_type 0 with packageType "*GD". Prefer the one whose id - // matches the requested product's title id so cross-gen picks the consistent platform. - if eid.isEmpty { - let requestedTitleId: String = { - let dash = productId.split(separator: "-") - guard dash.count >= 2 else { return "" } - return String(dash[1].split(separator: "_").first ?? "") - }() - func pickFullGameEntitlement(_ skuObj: [String: Any], requireTitleMatch: Bool) -> Bool { - guard let ents = skuObj["entitlements"] as? [[String: Any]] else { return false } - for ent in ents { - guard let id = ent["id"] as? String, !id.isEmpty else { continue } - let pkg = ent["packageType"] as? String ?? "" - guard pkg.hasSuffix("GD") else { continue } - if requireTitleMatch, !requestedTitleId.isEmpty, !id.contains(requestedTitleId) { continue } - eid = id; sku = skuObj["id"] as? String ?? ""; return true - } - return false - } - for requireTitleMatch in [true, false] { - if let defaultSku = json["default_sku"] as? [String: Any], - pickFullGameEntitlement(defaultSku, requireTitleMatch: requireTitleMatch) { break } - if let skus = json["skus"] as? [[String: Any]] { - var found = false - for skuObj in skus where pickFullGameEntitlement(skuObj, requireTitleMatch: requireTitleMatch) { found = true; break } - if found { break } - } - } - } - - // Detect platform (PS5 > PS4 > PS3) - if let platforms = json["playable_platform"] as? [String] { - if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS5") }) { detectedPlatform = "ps5" } - else if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS4") }) { detectedPlatform = "ps4" } - else if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS3") }) { detectedPlatform = "ps3" } - } - - guard !eid.isEmpty else { return nil } - return ProductConversion(entitlementId: eid, platform: detectedPlatform, sku: sku) - } - - // MARK: - Step 0.5e: Check and Acquire Entitlement - - private func step0_5e_CheckAndAcquireEntitlement(npssoToken: String, sessionId: String) -> Bool { - // Step 0.5e.1: Get commerce OAuth token - guard let commerceToken = step0_5e1_GetCommerceOAuthToken(npssoToken: npssoToken) else { return false } - commerceOAuthToken = commerceToken - - // Step 0.5e.2: Check if entitlement exists - let hasEntitlement = step0_5e2_CheckEntitlementExists() - if hasEntitlement == nil { return false } - if hasEntitlement == true { return true } - - // Entitlement not found (404). Region-group fallback: skip acquire and proceed to Gaikai. - // Native (supported region): run the normal checkout-acquire for PS3 and PS4 alike. - if SecureStore.shared.isCloudCatalogIsForeign { - os_log(.info, log: kamajiLog, - "Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai") - return true - } - - // Native mode: try to acquire it via checkout. - // Step 0.5e.3: Checkout preview - guard step0_5e3_CheckoutPreview(sessionId: sessionId) else { return false } - - // Step 0.5e.4: Complete checkout - return step0_5e4_CheckoutBuynow(sessionId: sessionId) - } - - private func step0_5e1_GetCommerceOAuthToken(npssoToken: String) -> String? { - let params: [(String, String)] = [ - ("smcid", "pc:psnow"), ("applicationId", "psnow"), - ("response_type", "token"), - ("scope", "kamaji:get_internal_entitlements user:account.attributes.validate kamaji:get_privacy_settings user:account.settings.privacy.get kamaji:s2s.subscriptionsPremium.get"), - ("client_id", "dc523cc2-b51b-4190-bff0-3397c06871b3"), - ("redirect_uri", redirectUri), ("grant_type", "authorization_code"), - ("service_entity", "urn:service-entity:psn"), ("prompt", "none"), - ("renderMode", "mobilePortrait"), ("hidePageElements", "forgotPasswordLink"), - ("displayFooter", "none"), ("disableLinks", "qriocityLink"), - ("mid", "PSNOW"), ("duid", duid), ("layout_type", "popup"), - ("service_logo", "ps"), ("tp_psn", "true"), ("noEVBlock", "true") - ] - let query = params.map { "\($0.0)=\($0.1.cloudUrlEncoded)" }.joined(separator: "&") - let url = "\(accountBaseUrl)/v1/oauth/authorize?\(query)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "User-Agent": userAgent, "Cookie": "npsso=\(npssoToken)" - ], followRedirects: false), response.statusCode == 302 else { return nil } - - guard let location = CloudHttpClient.extractLocation(from: response) else { return nil } - - // Extract access_token from URL fragment (#access_token=...) or query - if let range = location.range(of: "access_token=") { - let rest = String(location[range.upperBound...]) - return rest.split(separator: "&").first.map(String.init) - } - return nil - } - - private func step0_5e2_CheckEntitlementExists() -> Bool? { - guard let eid = entitlementId else { return nil } - let url = "\(commerceBase)/users/me/internal_entitlements/\(eid)?fields=game_meta" - guard let response = CloudHttpClient.get(url: url, headers: [ - "Authorization": "Bearer \(commerceOAuthToken ?? "")", - "User-Agent": userAgent, "Accept": "application/json" - ]) else { return nil } - - if response.statusCode == 200 { return true } - if response.statusCode == 404 { return false } - return nil - } - - private func step0_5e3_CheckoutPreview(sessionId: String) -> Bool { - let url = "\(kamajiBase)/user/checkout/buynow/preview" - let sku = streamingSku ?? entitlementId ?? "" - - guard let response = CloudHttpClient.post(url: url, body: "sku=\(sku)", headers: [ - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": userAgent, "Accept": "application/json", - "Authorization": "Bearer \(commerceOAuthToken ?? "")", - "Cookie": "JSESSIONID=\(sessionId)" - ]), response.statusCode == 200 else { - // Checkout preview errors indicate PS Plus Premium subscription required - return false - } - - guard let json = try? JSONSerialization.jsonObject(with: Data(response.body.utf8)) as? [String: Any], - let header = json["header"] as? [String: Any], - (header["status_code"] as? String) == "0x0000", - let data = json["data"] as? [String: Any], - let cart = data["cart"] as? [String: Any], - (cart["total_price_value"] as? Int) == 0 else { return false } - - // Extract actual SKU from response - if let items = cart["items"] as? [[String: Any]], - let first = items.first, let actualSku = first["sku_id"] as? String, !actualSku.isEmpty { - streamingSku = actualSku - } - return true - } - - private func step0_5e4_CheckoutBuynow(sessionId: String) -> Bool { - let url = "\(kamajiBase)/user/checkout/buynow" - let sku = streamingSku ?? entitlementId ?? "" - - guard let response = CloudHttpClient.post(url: url, body: "sku=\(sku)", headers: [ - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": userAgent, "Accept": "application/json", - "Authorization": "Bearer \(commerceOAuthToken ?? "")", - "Cookie": "JSESSIONID=\(sessionId)" - ]), response.statusCode == 200 else { return false } - - guard let json = try? JSONSerialization.jsonObject(with: Data(response.body.utf8)) as? [String: Any], - let header = json["header"] as? [String: Any], - (header["status_code"] as? String) == "0x0000" else { return false } - return true - } -} From e20bf928f002c8c83c6e8295dfc3fd1d2844e52c Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 17:24:28 -0700 Subject: [PATCH 57/72] cloudsession (qt): drop checkAuthorization (now in C) + delete old classes The NPSSO authorizeCheck now runs in libchiaki as the first step of the provision flow, so the Qt backend no longer does its own pre-flight: removed checkAuthorization (+ its header decl, the shared-DUID generation, and the KamajiConsts/GaikaiConsts includes), and map the new AUTHORIZATION_FAILED sentinel in handleProvisionError to the existing AuthorizationFailed dialog + sessionError (returns to menu). Delete the now-dead duplicated classes pskamajisession/psgaikaistreaming/ datacenterping/pscloudauth (.h+.cpp, ~4,025 lines) + their gui/CMakeLists.txt entries. The KamajiConsts/GaikaiConsts that the pre-flight used are gone with them -- the unified C flow has its own copies. Builds clean, app launches. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/api/PSGaikaiStreaming.kt | 1686 ----------------- .../chiaki/cloudplay/api/PSKamajiSession.kt | 1054 ----------- .../chiaki/cloudplay/ping/DatacenterPing.kt | 189 -- gui/CMakeLists.txt | 8 - gui/include/cloudstreamingbackend.h | 7 +- gui/src/cloudstreamingbackend.cpp | 115 +- 6 files changed, 10 insertions(+), 3049 deletions(-) delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/ping/DatacenterPing.kt diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt deleted file mode 100644 index 7dfc9421..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt +++ /dev/null @@ -1,1686 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import android.util.Log -import com.metallic.chiaki.cloudplay.PsnApiConstants -import com.metallic.chiaki.cloudplay.ping.DatacenterPing -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.json.JSONObject -import java.net.URLEncoder -import java.util.TimeZone - -/** - * Gaikai-specific constants - * Mirrors: GaikaiConsts in psgaikaistreaming.h - */ -object GaikaiConsts -{ - const val CONFIG_BASE = "https://config.cc.prod.gaikai.com/v1" - const val GAIKAI_BASE = "https://cc.prod.gaikai.com/v1" - const val ACCOUNT_BASE = "https://ca.account.sony.com" - - // PSCLOUD URIs and headers - const val REDIRECT_URI = "gaikai://local" - const val USER_AGENT = "PlayStation Portal/6.0.0-rel.444+6a9cea6f5" -} - -/** - * PSGaikaiStreaming - Complete Gaikai streaming allocation flow (Steps 7-13) - * Mirrors: gui/src/cloudstreaming/psgaikaistreaming.cpp - * - * NOTE: This is a simplified implementation focusing on PSNOW PS4 games. - * Full Qt implementation has extensive PS3/PS5/PSCLOUD support that can be added later. - */ -class PSGaikaiStreaming( - private val duid: String, - private val serviceType: String, // "psnow" or "pscloud" - private var platform: String, // "ps3", "ps4", or "ps5" - private val npssoToken: String, - private val preferences: com.metallic.chiaki.common.Preferences, - private val onProgress: ((String) -> Unit)? = null, // Progress callback (message) - private val isCancelled: () -> Boolean = { false } // Cancellation check -) -{ - companion object - { - private const val TAG = "PSGaikaiStreaming" - // Allocation wait limits (Qt lines 141-142) - private const val MAX_ALLOCATION_WAIT_SECONDS = 900 // 15 minutes (max) - private const val DEFAULT_ALLOCATION_WAIT_SECONDS = 300 // 5 minutes (fallback) - // Lock session retry limit (Qt line 147) - private const val MAX_LOCK_SESSION_RETRIES = 12 // Max retries for lock session - } - - // Configuration - private val virtType = when(platform) - { - "ps3" -> "konan" - "ps4" -> "kratos" - "ps5" -> "cronos" - else -> "kratos" - } - - private val accountBaseUrl = GaikaiConsts.ACCOUNT_BASE - private val redirectUriUrl = if (serviceType == "pscloud") GaikaiConsts.REDIRECT_URI else PsnApiConstants.REDIRECT_URI - private val userAgentString = if (serviceType == "pscloud") GaikaiConsts.USER_AGENT else PsnApiConstants.USER_AGENT - private val oauthApiPath = if (serviceType == "pscloud") "/api/authz/v3" else "/api/v1" - - // State management - private var configKey = "" - private var gaikaiSessionId = "" - private var gkClientId = "" - private var ps3GkClientId = "" - private var streamServerClientId = "" - private var gkCloudAuthCode = "" - private var ps3AuthCode = "" - private var streamServerAuthCode = "" - private var requestGameSpec = JSONObject() - private var selectedDatacenter = "" - // Captured server response body from a failed step8 (sessions/start) so the owned fast-path - // fallback can detect noGameForEntitlementId. Empty unless step8 failed. - private var lastStartSessionError = "" - private var selectedDatacenterPort = 0 - private var selectedDatacenterPingResult = JSONObject() - - // Allocation polling state (Qt lines 139-146) - private var allocationWaitStartTime: Long = 0 // System.currentTimeMillis() - private var allocationMaxWaitSeconds = 0 // Calculated from waitTimeEstimate - private var allocationRetryCount = 0 // Counter for logging - private var lockSessionRetryCount = 0 // Counter for lock session retries (Qt line 145) - - /** - * Result class - */ - data class AllocationResult( - val success: Boolean, - val message: String, - val serverIp: String = "", - val serverPort: Int = 0, - val handshakeKey: String = "", - val launchSpec: String = "", - val sessionId: String = "", - val psnWrapperType: Int = 0, - val mtuIn: Int = 0, - val mtuOut: Int = 0, - val rttMs: Int = 0 - ) - - /** - * Start complete allocation flow - * Mirrors: PSGaikaiStreaming::StartAllocationFlow() - */ - suspend fun startAllocationFlow(entitlementId: String): AllocationResult = withContext(Dispatchers.IO) - { - try - { - Log.i(TAG, "=== Starting Gaikai Allocation Flow ===") - Log.i(TAG, "Entitlement ID: $entitlementId") - - // Check cancellation before starting - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - - // Step 0: Get client IDs - onProgress?.invoke("Getting Client IDs - Step 1 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step0_GetClientIds() ?: return@withContext AllocationResult(false, "Failed to get client IDs") - Log.i(TAG, "✓ Step 0: Got client IDs") - - // Step 7: Get config - onProgress?.invoke("Getting Configuration - Step 2 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step7_GetConfig() ?: return@withContext AllocationResult(false, "Failed to get config") - Log.i(TAG, "✓ Step 7: Got config") - - // Step 8: Start session - onProgress?.invoke("Starting Session - Step 3 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step8_StartSession(entitlementId) ?: return@withContext AllocationResult(false, "Session start failed: $lastStartSessionError") - Log.i(TAG, "✓ Step 8: Started session") - - // Step 8a: Get gkClientId auth code - onProgress?.invoke("Getting Tokens - Step 4 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step8a_GetAuthCode() ?: return@withContext AllocationResult(false, "Failed to get gkClientId auth code") - Log.i(TAG, "✓ Step 8a: Got gkClientId auth code") - - // Step 8b: Get ps3GkClientId/streamServerClientId auth code - onProgress?.invoke("Getting Server Tokens - Step 5 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step8b_GetServerAuthCode() ?: return@withContext AllocationResult(false, "Failed to get server auth code") - Log.i(TAG, "✓ Step 8b: Got server auth code") - - // Step 9: Authorize session - onProgress?.invoke("Authorizing Session - Step 6 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step9_AuthorizeSession() ?: return@withContext AllocationResult(false, "Failed to authorize session") - Log.i(TAG, "✓ Step 9: Authorized session") - - // Step 10: Lock session - onProgress?.invoke("Locking Session - Step 7 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - step10_LockSession() ?: return@withContext AllocationResult(false, "Failed to lock session") - Log.i(TAG, "✓ Step 10: Locked session") - - // Step 11: Get datacenters - onProgress?.invoke("Getting Datacenters - Step 8 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - val datacenters = step11_GetDatacenters() ?: return@withContext AllocationResult(false, "Failed to get datacenters") - Log.i(TAG, "✓ Step 11: Got ${datacenters.length()} datacenters") - - // Step 12: Select datacenter (use first one for now) - onProgress?.invoke("Pinging Datacenters - Step 8 of 10") - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - val datacenter = step12_SelectDatacenter(datacenters) ?: return@withContext AllocationResult(false, "No datacenters available") - onProgress?.invoke("Selecting Datacenter ($datacenter) - Step 9 of 10") - Log.i(TAG, "✓ Step 12: Selected datacenter: $datacenter") - - // Step 13: Allocate slot (with polling) - if (allocationRetryCount == 0) { - onProgress?.invoke("Allocating Streaming Slot - Step 10 of 10") - } - if (isCancelled()) { - return@withContext AllocationResult(false, "Allocation cancelled") - } - val allocation = step13_AllocateSlot() ?: return@withContext AllocationResult(false, "Failed to allocate slot") - Log.i(TAG, "✓ Step 13: Slot allocated!") - - // Parse allocation response - Match Qt exactly (lines 1694-1707) - // Qt line 1609: allocation = jsonDoc.object() - the root JSON object IS the allocation - val launchSlot = allocation.optJSONObject("launchSlot") - if (launchSlot == null || launchSlot.length() == 0) - { - Log.e(TAG, "Allocation response missing launchSlot") - return@withContext AllocationResult(false, "Allocation response invalid: missing launchSlot") - } - - // Qt lines 1702-1707: Extract fields EXACTLY as Qt does - val serverIp = launchSlot.optString("publicIp", "") // Qt: allocatedServerIp = launchSlot["publicIp"].toString() - val serverPort = launchSlot.optInt("port", 0) // Qt: allocatedServerPort = launchSlot["port"].toInt() - val privateIp = launchSlot.optString("privateIp", "") // Qt: QString privateIp = launchSlot["privateIp"].toString() - val handshakeKey = allocation.optString("handshakeKey", "") // Qt: allocatedHandshakeKey = allocation["handshakeKey"].toString() - val launchSpec = allocation.optString("launchSpecification", "") // Qt: allocatedLaunchSpec = allocation["launchSpecification"].toString() - val sessionId = allocation.optString("sessionId", "") // Qt: allocatedSessionId = allocation["sessionId"].toString() - - // Log what was extracted - Log.d(TAG, "Extracted from allocation response:") - Log.d(TAG, " publicIp: '$serverIp'") - Log.d(TAG, " port: $serverPort") - Log.d(TAG, " privateIp: '$privateIp'") - Log.d(TAG, " handshakeKey: ${if(handshakeKey.isEmpty()) "(empty/not present)" else handshakeKey.take(20) + "..."}") - Log.d(TAG, " sessionId: ${if(sessionId.isEmpty()) "(empty/not present)" else sessionId}") - Log.d(TAG, " launchSpecification length: ${launchSpec.length}") - - // Extract additional info (Qt lines 1734-1738) - val timeLimit = allocation.optInt("timeLimit", 0) - val startGameTimeout = allocation.optInt("startGameTimeout", 0) - Log.d(TAG, " timeLimit: $timeLimit minutes") - Log.d(TAG, " startGameTimeout: $startGameTimeout seconds") - - // Validate critical fields - if (serverIp.isEmpty() || serverPort == 0 || launchSpec.isEmpty()) - { - Log.e(TAG, "Allocation response missing critical fields:") - Log.e(TAG, " serverIp: '$serverIp'") - Log.e(TAG, " serverPort: $serverPort") - Log.e(TAG, " launchSpec length: ${launchSpec.length}") - return@withContext AllocationResult(false, "Allocation response incomplete") - } - - // Extract PSN wrapper type from private IP's last octet (Qt lines 1709-1722) - var psnWrapperType = 0x01 // default fallback - if (privateIp.isNotEmpty()) - { - val lastOctet = privateIp.substringAfterLast('.') - val octetValue = lastOctet.toIntOrNull() - if (octetValue != null && octetValue in 0..255) - { - psnWrapperType = octetValue - Log.d(TAG, "Private IP: $privateIp -> PSN wrapper type: 0x${psnWrapperType.toString(16).padStart(2, '0')}") - } - } - - // Match Qt log format exactly (lines 1724-1738) - Log.i(TAG, "=== Gaikai Step 13: ALLOCATION SUCCESSFUL ===") - Log.i(TAG, "Server IP: $serverIp") - Log.i(TAG, "Server Port: $serverPort") - Log.i(TAG, "Handshake Key: $handshakeKey") - Log.i(TAG, "Session ID: $sessionId") - Log.i(TAG, "Launch Spec (FULL): $launchSpec") - Log.i(TAG, "Launch Spec Length: ${launchSpec.length}") - Log.i(TAG, "[Allocation results stored for Takion connection]") - Log.i(TAG, "Time Limit: $timeLimit minutes") - Log.i(TAG, "Start Timeout: $startGameTimeout seconds") - Log.i(TAG, "PSN Wrapper Type: 0x${psnWrapperType.toString(16).padStart(2, '0')}") - - AllocationResult( - success = true, - message = "Success", - serverIp = serverIp, - serverPort = serverPort, - handshakeKey = handshakeKey, - launchSpec = launchSpec, - sessionId = sessionId, - psnWrapperType = psnWrapperType, - mtuIn = selectedDatacenterPingResult.optInt("mtu_in", 1454), - mtuOut = selectedDatacenterPingResult.optInt("mtu_out", 1254), - rttMs = selectedDatacenterPingResult.optInt("rtt", 20) - ) - } - catch (e: PsPlusSubscriptionException) - { - // Re-throw specific exceptions so they bubble up to UI - throw e - } - catch (e: PingTimeoutException) - { - // Re-throw ping timeout exception so it shows proper dialog - throw e - } - catch (e: GaikaiAllocationException) - { - // Re-throw specific exceptions so they bubble up to UI - throw e - } - catch (e: Exception) - { - Log.e(TAG, "Gaikai allocation error", e) - throw GaikaiAllocationException("Unexpected error: ${e.message}") - } - } - - /** - * Step 0: Get client IDs - */ - private fun step0_GetClientIds(): Boolean? - { - try - { - val url = "${GaikaiConsts.GAIKAI_BASE}/client_ids?virtType=$virtType" - - Log.d(TAG, "Step 0: GET $url") - - val headers = mapOf( - "User-Agent" to userAgentString, - "Accept" to "*/*" - ) - - val response = HttpClient.get(url, headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 0 failed: ${response.statusCode}") - return null - } - - val json = JSONObject(response.body) - gkClientId = json.optString("gkClientId", "") - ps3GkClientId = json.optString("ps3GkClientId", "") - streamServerClientId = json.optString("streamServerClientId", "") - - if (gkClientId.isEmpty()) - { - Log.e(TAG, "No gkClientId in response") - return null - } - - Log.d(TAG, "Step 0: Got gkClientId: $gkClientId") - if (ps3GkClientId.isNotEmpty()) Log.d(TAG, " ps3GkClientId: $ps3GkClientId") - if (streamServerClientId.isNotEmpty()) Log.d(TAG, " streamServerClientId: $streamServerClientId") - return true - } - catch (e: Exception) - { - Log.e(TAG, "Step 0 error", e) - return null - } - } - - /** - * Step 7: Get config - */ - private fun step7_GetConfig(): Boolean? - { - try - { - val url = "${GaikaiConsts.CONFIG_BASE}/config" - - // Build request body - val body = JSONObject() - if (serviceType == "pscloud") - { - body.put("product", "qlite") - body.put("platform", "qlite") - } - else - { - body.put("product", "psnow") - body.put("platform", "PC") - } - body.put("sessionId", "") - - val bodyStr = body.toString() - - Log.d(TAG, "Step 7: POST $url") - Log.d(TAG, "Body: $bodyStr") - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "*/*" - ) - - val response = HttpClient.post(url, bodyStr, headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 7 failed: ${response.statusCode}") - Log.e(TAG, "Response: ${response.body}") - return null - } - - // Extract config key from JSON response body (not header!) - val json = JSONObject(response.body) - configKey = json.optString("configKey", "") - - if (configKey.isEmpty()) - { - Log.e(TAG, "No configKey in JSON response") - Log.e(TAG, "Response body: ${response.body}") - return null - } - - Log.d(TAG, "Step 7: Got config key: ${configKey.take(20)}...") - return true - } - catch (e: Exception) - { - Log.e(TAG, "Step 7 error", e) - return null - } - } - - /** - * Step 8: Start session - */ - private fun step8_StartSession(entitlementId: String): Boolean? - { - try - { - // Qt uses /sessions/start?npEnv=np - val url = "${GaikaiConsts.GAIKAI_BASE}/sessions/start?npEnv=np" - - // Build game spec wrapped in requestGameSpecification - requestGameSpec = buildRequestGameSpec(entitlementId) - val wrapper = JSONObject() - wrapper.put("requestGameSpecification", requestGameSpec) - val body = wrapper.toString() - - Log.d(TAG, "Step 8: POST $url") - Log.d(TAG, "Game spec: ${body.take(200)}...") - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "application/json", - "X-Gaikai-Session" to configKey - ) - - val response = HttpClient.post(url, body, headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 8 failed: ${response.statusCode}") - Log.e(TAG, "Response: ${response.body}") - // Surface the response body: this is where Gaikai reports an unowned/invalid - // entitlement (e.g. {"name":"noGameForEntitlementId",...}), which the owned fast-path - // fallback in CloudStreamingBackend keys off to retry via the full resolve/acquire flow. - lastStartSessionError = response.body - return null - } - - // Update session key - val newKey = response.headers["x-gaikai-session"]?.firstOrNull() - if (!newKey.isNullOrEmpty()) configKey = newKey - - // Extract session ID - val json = JSONObject(response.body) - gaikaiSessionId = json.optString("sessionId", "") - - if (gaikaiSessionId.isEmpty()) - { - Log.e(TAG, "No sessionId in response") - return null - } - - Log.d(TAG, "Step 8: Session ID: $gaikaiSessionId") - return true - } - catch (e: Exception) - { - Log.e(TAG, "Step 8 error", e) - return null - } - } - - /** - * Step 8a: Get auth code - */ - private fun step8a_GetAuthCode(): Boolean? - { - try - { - // Build OAuth URL - matches Qt PSGaikaiStreaming::step8a_GetGkAuthCode() - val params = mutableListOf( - "response_type" to "code", - "client_id" to gkClientId, - "redirect_uri" to redirectUriUrl, - "service_entity" to "urn:service-entity:psn", // PSN not GK! - "prompt" to "none", - "duid" to duid - ) - - // Add service-specific parameters - if (serviceType == "pscloud") - { - params.add("smcid" to "qlite") - params.add("applicationId" to "qlite") - params.add("mid" to "qlite") - params.add("scope" to "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s") - } - else // psnow - { - params.add("smcid" to "pc:psnow") - params.add("applicationId" to "psnow") - params.add("mid" to "PSNOW") - params.add("scope" to "kamaji:commerce_native versa:user_update_entitlements_first_play kamaji:lists") - params.add("renderMode" to "mobilePortrait") - params.add("hidePageElements" to "forgotPasswordLink") - params.add("displayFooter" to "none") - params.add("disableLinks" to "qriocityLink") - params.add("layout_type" to "popup") - params.add("service_logo" to "ps") - params.add("tp_psn" to "true") - params.add("noEVBlock" to "true") - } - - val query = params.joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } - - val url = "$accountBaseUrl$oauthApiPath/oauth/authorize?$query" - - Log.d(TAG, "Step 8a: GET $url") - - val headers = mapOf( - "User-Agent" to userAgentString, - "Cookie" to "npsso=$npssoToken" - ) - - val response = HttpClient.get(url, headers, followRedirects = false) - - if (response.statusCode != 302) - { - Log.e(TAG, "Step 8a failed: expected 302, got ${response.statusCode}") - return null - } - - val location = HttpClient.extractLocation(response.headers) - if (location == null) - { - Log.e(TAG, "No Location header") - return null - } - - val codeRegex = Regex("[?&]code=([^&]+)") - val match = codeRegex.find(location) - gkCloudAuthCode = match?.groupValues?.get(1) ?: "" - - if (gkCloudAuthCode.isEmpty()) - { - Log.e(TAG, "No code in redirect") - return null - } - - Log.d(TAG, "Step 8a: Got gkCloudAuthCode: ${gkCloudAuthCode.take(20)}...") - return true - } - catch (e: Exception) - { - Log.e(TAG, "Step 8a error", e) - return null - } - } - - /** - * Step 8b: Get ps3GkClientId/streamServerClientId authorization code (serverAuthCode) - * Mirrors: PSGaikaiStreaming::step8b_GetPs3AuthCode() - */ - private fun step8b_GetServerAuthCode(): Boolean? - { - try - { - // Build OAuth URL - matches Qt PSGaikaiStreaming::step8b_GetPs3AuthCode() - val params = mutableListOf( - "response_type" to "code", - "redirect_uri" to redirectUriUrl, - "service_entity" to "urn:service-entity:psn", - "prompt" to "none" - ) - - if (serviceType == "pscloud") - { - // PSCLOUD (PS5): Use streamServerClientId - Log.d(TAG, "Step 8b: Using streamServerClientId for PSCLOUD") - params.add("client_id" to streamServerClientId) - params.add("smcid" to "qlite") - params.add("applicationId" to "qlite") - params.add("mid" to "qlite") - params.add("scope" to "id_token:duid id_token:online_id openid oauth:create_authn_ticket_for_cloud_console_signin") - params.add("duid" to duid) - } - else - { - // PSNOW (PS3/PS4): Use ps3GkClientId - Log.d(TAG, "Step 8b: Using ps3GkClientId for PSNOW ($platform)") - params.add("client_id" to ps3GkClientId) - params.add("smcid" to "pc:psnow") - params.add("applicationId" to "psnow") - params.add("mid" to "PSNOW") - - // Platform-specific scope - if (platform == "ps3") - { - params.add("scope" to "kamaji:commerce_native") - // PS3: DO NOT include duid - } - else - { - // PS4 - params.add("scope" to "sso:none") - params.add("duid" to duid) - } - - params.add("renderMode" to "mobilePortrait") - params.add("hidePageElements" to "forgotPasswordLink") - params.add("displayFooter" to "none") - params.add("disableLinks" to "qriocityLink") - params.add("layout_type" to "popup") - params.add("service_logo" to "ps") - params.add("tp_psn" to "true") - params.add("noEVBlock" to "true") - } - - val query = params.joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } - - val url = "$accountBaseUrl$oauthApiPath/oauth/authorize?$query" - - Log.d(TAG, "Step 8b: GET $url") - - val headers = mapOf( - "User-Agent" to userAgentString, - "Cookie" to "npsso=$npssoToken" - ) - - val response = HttpClient.get(url, headers, followRedirects = false) - - if (response.statusCode != 302) - { - Log.e(TAG, "Step 8b failed: expected 302, got ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - return null - } - - val location = HttpClient.extractLocation(response.headers) - if (location == null) - { - Log.e(TAG, "No Location header in Step 8b") - return null - } - - val codeRegex = Regex("[?&]code=([^&]+)") - val match = codeRegex.find(location) - val serverAuthCode = match?.groupValues?.get(1) ?: "" - - if (serverAuthCode.isEmpty()) - { - Log.e(TAG, "No code in redirect for Step 8b") - return null - } - - // Set auth codes based on service type - if (serviceType == "pscloud") - { - // PSCLOUD: Use serverAuthCode for streamServer, leave ps3AuthCode empty - streamServerAuthCode = serverAuthCode - ps3AuthCode = "" - Log.d(TAG, "Step 8b: Got streamServerAuthCode: ${streamServerAuthCode.take(20)}...") - } - else - { - // PSNOW: Both ps3AuthCode AND streamServerAuthCode use the same code - ps3AuthCode = serverAuthCode - streamServerAuthCode = serverAuthCode - Log.d(TAG, "Step 8b: Got ps3AuthCode (used for both): ${ps3AuthCode.take(20)}...") - } - - return true - } - catch (e: Exception) - { - Log.e(TAG, "Step 8b error", e) - return null - } - } - - /** - * Step 9: Authorize session - * Mirrors: PSGaikaiStreaming::step9_AuthorizeSession() - */ - private fun step9_AuthorizeSession(): Boolean? - { - try - { - val url = "${GaikaiConsts.GAIKAI_BASE}/sessions/$gaikaiSessionId/authorize" - - // Update requestGameSpec with auth codes (matching Qt line 891-893) - requestGameSpec.put("gkCloudAuthCode", gkCloudAuthCode) - requestGameSpec.put("ps3AuthCode", ps3AuthCode) - requestGameSpec.put("streamServerAuthCode", streamServerAuthCode) - - // Send requestGameSpecification (matching Qt line 916) - val body = JSONObject() - body.put("requestGameSpecification", requestGameSpec) - val bodyStr = body.toString() - - Log.d(TAG, "Step 9: POST $url") - Log.d(TAG, "Auth codes - gkCloud: ${gkCloudAuthCode.take(10)}..., ps3: ${ps3AuthCode.take(10)}..., streamServer: ${streamServerAuthCode.take(10)}...") - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "*/*", - "X-Gaikai-Session" to configKey, - "X-Gaikai-SessionId" to gaikaiSessionId - ) - - val response = HttpClient.post(url, bodyStr, headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 9 failed: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - - // Check for PS Plus subscription error (eventCode 002.2001) - // Mirrors: PSGaikaiStreaming::step9_AuthorizeSession() lines 948-962 - val eventHeader = response.headers["x-gaikai-event"]?.firstOrNull() - var isPSPlusError = false - - if (!eventHeader.isNullOrEmpty()) - { - Log.w(TAG, "Gaikai event header: $eventHeader") - try - { - val eventJson = JSONObject(eventHeader) - val eventCode = eventJson.optString("eventCode") - if (eventCode == "002.2001") - { - isPSPlusError = true - } - } - catch (e: Exception) - { - Log.w(TAG, "Failed to parse event header", e) - } - } - - // Parse error response body for detailed error messages - var errorMsg = "Authorize failed with status ${response.statusCode}" - if (response.body.isNotEmpty()) - { - try - { - val errorJson = JSONObject(response.body) - - // Check errors array - val errorsArray = errorJson.optJSONArray("errors") - if (errorsArray != null && errorsArray.length() > 0) - { - val errorDescriptions = mutableListOf() - for (i in 0 until errorsArray.length()) - { - val errorObj = errorsArray.optJSONObject(i) - if (errorObj != null) - { - val description = errorObj.optString("description") - val eventCode = errorObj.optString("eventCode") - - if (eventCode == "002.2001") - { - isPSPlusError = true - } - - if (description.isNotEmpty()) - { - errorDescriptions.add(description) - } - else if (eventCode.isNotEmpty()) - { - errorDescriptions.add("Event: $eventCode") - } - } - } - if (errorDescriptions.isNotEmpty()) - { - errorMsg += "\n" + errorDescriptions.joinToString("\n") - } - } - else - { - val description = errorJson.optString("description") - if (description.isNotEmpty()) - { - errorMsg += ": $description" - } - } - } - catch (e: Exception) - { - Log.w(TAG, "Failed to parse error JSON", e) - errorMsg += ": ${response.body}" - } - } - - Log.w(TAG, "Gaikai Step 9 failed: $errorMsg") - - // Throw specific exception for PS Plus error - if (isPSPlusError) - { - throw PsPlusSubscriptionException(errorMsg) - } - - throw GaikaiAllocationException(errorMsg) - } - - // Update session key - val newKey = response.headers["x-gaikai-session"]?.firstOrNull() - if (!newKey.isNullOrEmpty()) configKey = newKey - - Log.d(TAG, "Step 9: Session authorized") - return true -} -catch (e: PsPlusSubscriptionException) -{ - // Re-throw custom exceptions so they bubble up to UI - Log.e(TAG, "Step 9 PS Plus error", e) - throw e -} -catch (e: GaikaiAllocationException) -{ - // Re-throw custom exceptions so they bubble up to UI - Log.e(TAG, "Step 9 Gaikai error", e) - throw e -} -catch (e: Exception) -{ - // Unexpected errors return null - Log.e(TAG, "Step 9 unexpected error", e) - return null -} - } - - /** - * Step 10: Lock session (with retry logic for queued sessions) - * Mirrors: PSGaikaiStreaming::step10_LockSession() (Qt lines 1052-1137) - */ - private suspend fun step10_LockSession(): Boolean? - { - try - { - if (lockSessionRetryCount == 0) - { - Log.i(TAG, "Gaikai Step 10: Locking session... (attempt ${lockSessionRetryCount + 1})") - } - else - { - Log.i(TAG, "Gaikai Step 10: Locking session... (attempt ${lockSessionRetryCount + 1})") - } - - // Qt includes ?forceLogout=true query parameter (Qt line 1059) - val url = "${GaikaiConsts.GAIKAI_BASE}/sessions/$gaikaiSessionId/lock?forceLogout=true" - - Log.d(TAG, "Step 10: POST $url") - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "*/*", - "X-Gaikai-Session" to configKey, - "X-Gaikai-SessionId" to gaikaiSessionId - ) - - // Qt sends requestGameSpecification in body (Qt lines 1068-1069) - val body = JSONObject() - body.put("requestGameSpecification", requestGameSpec) - val bodyStr = body.toString() - - val response = HttpClient.post(url, bodyStr, headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 10 failed: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - throw GaikaiAllocationException("Lock failed: HTTP ${response.statusCode}") - } - - // Update session key (Qt line 1087) - val newKey = response.headers["x-gaikai-session"]?.firstOrNull() - if (!newKey.isNullOrEmpty()) configKey = newKey - - // Parse response to check if lock was acquired (Qt lines 1089-1096) - val json = JSONObject(response.body) - val lockAcquired = json.optBoolean("lockAcquired", false) - val pollFrequency = json.optInt("pollFrequency", 10) // Default 10 seconds - - Log.i(TAG, "Gaikai Step 10 response - Lock acquired: $lockAcquired, pollFrequency: $pollFrequency") - - // If lock not acquired, retry with delay (Qt lines 1098-1125) - if (!lockAcquired) - { - lockSessionRetryCount++ - - // Check if max retries exceeded (Qt lines 1103-1108) - if (lockSessionRetryCount > MAX_LOCK_SESSION_RETRIES) - { - Log.e(TAG, "Lock session max retries exceeded: $lockSessionRetryCount (max: $MAX_LOCK_SESSION_RETRIES)") - throw GaikaiAllocationException("Lock session failed: Could not acquire lock after $MAX_LOCK_SESSION_RETRIES attempts") - } - - // Build retry message (Qt lines 1110-1116) - val message = "Closing old session - Attempt $lockSessionRetryCount" - onProgress?.invoke(message) - Log.i(TAG, message) - Log.i(TAG, "Lock not acquired, retrying in $pollFrequency seconds... (attempt $lockSessionRetryCount of $MAX_LOCK_SESSION_RETRIES)") - - // Check cancellation before retry - if (isCancelled()) { - return null - } - - // Wait and retry (Qt lines 1121-1123) - delay(pollFrequency * 1000L) - return step10_LockSession() // Recursive retry - } - - // Lock acquired successfully - reset retry counter (Qt lines 1127-1128) - lockSessionRetryCount = 0 - - Log.d(TAG, "Step 10: Session locked") - return true - } - catch (e: GaikaiAllocationException) - { - throw e // Re-throw custom exceptions - } - catch (e: Exception) - { - Log.e(TAG, "Step 10 error", e) - return null - } - } - - /** - * Step 11: Get datacenters - * Mirrors: PSGaikaiStreaming::step11_GetDatacenters() - */ - private fun step11_GetDatacenters(): JSONArray? - { - try - { - val url = "${GaikaiConsts.GAIKAI_BASE}/sessions/$gaikaiSessionId/datacenters" - - Log.d(TAG, "Step 11: POST $url") - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "*/*", - "X-Gaikai-Session" to configKey, - "X-Gaikai-SessionId" to gaikaiSessionId - ) - - // Qt sends requestGameSpecification in body - val body = JSONObject() - body.put("requestGameSpecification", requestGameSpec) - val bodyStr = body.toString() - - val response = HttpClient.post(url, bodyStr, headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 11 failed: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - return null - } - - // Update session key - val newKey = response.headers["x-gaikai-session"]?.firstOrNull() - if (!newKey.isNullOrEmpty()) configKey = newKey - - // Response is a JSON array directly (not wrapped in an object) - val datacentersArray = JSONArray(response.body) - - Log.d(TAG, "Step 11: Got ${datacentersArray.length()} datacenters") - for (i in 0 until datacentersArray.length()) - { - val dc = datacentersArray.optJSONObject(i) - if (dc != null) - { - Log.d(TAG, " - ${dc.optString("dataCenter")} ${dc.optString("publicIp")}:${dc.optInt("port")} maxBw:${dc.optInt("maxBandwidth")}") - } - } - - return datacentersArray - } - catch (e: Exception) - { - Log.e(TAG, "Step 11 error", e) - return null - } - } - - /** - * Step 12: Select best datacenter (with REAL datacenter ping measurements OR manual selection) - * Mirrors: PSGaikaiStreaming::step12_SelectDatacenter() - */ - private suspend fun step12_SelectDatacenter(datacenters: JSONArray): String? - { - try - { - if (datacenters.length() == 0) return null - - // Seed the picker with the raw datacenter list ONLY when nothing is saved - // yet. Never overwrite a previously-saved list here: it carries real ping - // RTTs from a prior Auto run, and manual mode below won't re-ping, so - // clobbering it with this no-RTT list would drop the ms from the picker. - val existingDatacentersJson = if (serviceType == "pscloud") - preferences.getCloudDatacentersJsonPscloud() - else - preferences.getCloudDatacentersJsonPsnow() - val hasExistingDatacenters = existingDatacentersJson.isNotEmpty() && - try { org.json.JSONArray(existingDatacentersJson).length() > 0 } catch (e: Exception) { false } - if (!hasExistingDatacenters) - { - val datacentersJsonString = datacenters.toString() - if (serviceType == "pscloud") - { - preferences.setCloudDatacentersJsonPscloud(datacentersJsonString) - } - else // psnow - { - preferences.setCloudDatacentersJsonPsnow(datacentersJsonString) - } - } - - // Check if a specific datacenter is selected (Qt lines 1203-1228) - val selectedDatacenterSetting = if (serviceType == "pscloud") - { - preferences.getCloudDatacenterPscloud() - } - else // psnow - { - preferences.getCloudDatacenterPsnow() - } - - // If manual datacenter selected, use it with dummy ping (bypasses validation) (Qt lines 1210-1257) - if (selectedDatacenterSetting != "Auto" && selectedDatacenterSetting.isNotEmpty()) - { - Log.i(TAG, "Step 12: Using manually selected datacenter: $selectedDatacenterSetting") - - // Find the selected datacenter in the list - var found = false - var selectedDc: JSONObject? = null - for (i in 0 until datacenters.length()) - { - val dc = datacenters.getJSONObject(i) - if (dc.getString("dataCenter") == selectedDatacenterSetting) - { - selectedDc = dc - found = true - break - } - } - - if (!found) - { - Log.w(TAG, "Selected datacenter $selectedDatacenterSetting not found in available datacenters") - throw GaikaiAllocationException("Selected datacenter '$selectedDatacenterSetting' not available") - } - - // Create dummy ping result with 20ms RTT (Qt lines 1230-1246) - val dummyPingResult = JSONObject() - dummyPingResult.put("dataCenter", selectedDc!!.getString("dataCenter")) - dummyPingResult.put("rtt", 20) - dummyPingResult.put("rtts", JSONArray().put(20)) - dummyPingResult.put("mtu_in", 1454) - dummyPingResult.put("mtu_out", 1254) - dummyPingResult.put("port", selectedDc.getInt("port")) - dummyPingResult.put("publicIp", selectedDc.getString("publicIp")) - dummyPingResult.put("maxBandwidth", selectedDc.getInt("maxBandwidth")) - - Log.i(TAG, "Bypassing ping tests - using manually selected datacenter: $selectedDatacenterSetting") - Log.i(TAG, "Using dummy ping values: RTT=20ms, MTU in=1454, MTU out=1254") - - // Store for Step 13 - selectedDatacenterPingResult = dummyPingResult - selectedDatacenter = selectedDatacenterSetting - selectedDatacenterPort = selectedDc.getInt("port") - - // Submit to /datacenters/select (skip validation, go straight to submission) - return submitDatacenterSelection(dummyPingResult, false) // false = skip validation - } - - // Auto-select: Ping all datacenters (Qt lines 1259-1308) - Log.i(TAG, "Step 12: Pinging ${datacenters.length()} datacenters to find the best one...") - - val pingResults = DatacenterPing.pingAllDatacentersWithTimeout( - datacenters, - configKey, // x-gaikai-session key used as session key for BIG message - serviceType - ) - - // Save ping results to settings (Qt lines 1314-1322) - if (pingResults.length() > 0) - { - val pingResultsJsonString = pingResults.toString() - if (serviceType == "pscloud") - { - preferences.setCloudDatacentersJsonPscloud(pingResultsJsonString) - } - else // psnow - { - preferences.setCloudDatacentersJsonPsnow(pingResultsJsonString) - } - Log.i(TAG, "Saved ${pingResults.length()} datacenter ping results to settings") - } - - // Select best datacenter based on ping results (Qt lines 1310-1365) - val bestPingResult = if (pingResults.length() > 0) - { - // Find datacenter with lowest RTT (Qt lines 1315-1324) - var bestResult = pingResults.getJSONObject(0) - var bestRtt = bestResult.getInt("rtt") - - for (i in 1 until pingResults.length()) - { - val result = pingResults.getJSONObject(i) - val rtt = result.getInt("rtt") - if (rtt > 0 && rtt < bestRtt) - { - bestResult = result - bestRtt = rtt - } - } - - Log.i(TAG, "Step 12: Best datacenter: ${bestResult.getString("dataCenter")} with ${bestRtt}ms RTT") - bestResult - } - else - { - // Fallback to first datacenter with dummy values (Qt lines 1367-1391) - Log.w(TAG, "Step 12: All pings failed or timed out, using first datacenter with dummy values") - val firstDc = datacenters.getJSONObject(0) - val fallbackResult = JSONObject() - fallbackResult.put("dataCenter", firstDc.optString("dataCenter")) - fallbackResult.put("rtt", 20) - fallbackResult.put("rtts", JSONArray().put(20)) - fallbackResult.put("mtu_in", 1454) - fallbackResult.put("mtu_out", 1254) - fallbackResult.put("port", firstDc.optInt("port")) - fallbackResult.put("publicIp", firstDc.optString("publicIp")) - fallbackResult.put("maxBandwidth", firstDc.optInt("maxBandwidth")) - fallbackResult - } - - // Submit with validation (auto-selected datacenters must have <80ms ping) - return submitDatacenterSelection(bestPingResult, true) // true = validate ping - } - catch (e: PingTimeoutException) - { - // Re-throw PingTimeoutException so it can be caught by CloudPlayFragment and show proper dialog - Log.e(TAG, "Step 12 error: Ping too high", e) - throw e - } - catch (e: Exception) - { - Log.e(TAG, "Step 12 error", e) - return null - } - } - - /** - * Step 13: Allocate slot (with queued/data migration retry logic) - * Mirrors: PSGaikaiStreaming::step13_AllocateSlot() (Qt lines 1546-1688) - */ - private suspend fun step13_AllocateSlot(): JSONObject? = runCatching { - // Use /allocate endpoint, not /slot (Qt line 1549) - val url = "${GaikaiConsts.GAIKAI_BASE}/sessions/$gaikaiSessionId/allocate" - - Log.d(TAG, "Step 13: POST $url") - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "*/*", - "X-Gaikai-Session" to configKey, - "X-Gaikai-SessionId" to gaikaiSessionId - ) - - // Build request body with game spec, datacenter, and network info (Qt lines 1558-1583) - val body = JSONObject() - body.put("requestGameSpecification", requestGameSpec) - body.put("dataCenter", selectedDatacenter) - - // Network info from ping results - val cloudBwKbps = if (serviceType == "pscloud") - preferences.getCloudBitratePscloud() - else - preferences.getCloudBitratePsnow() - val network = JSONObject() - network.put("bwKbpsSent", cloudBwKbps) - network.put("bwLoss", 0.001) // 0.1% packet loss - network.put("mtu", selectedDatacenterPingResult.optInt("mtu_in", 1454)) - network.put("rtt", selectedDatacenterPingResult.optInt("rtt", 25)) - network.put("port", selectedDatacenterPort) - network.put("bwKbpsReceived", cloudBwKbps) - network.put("bwLossUpstream", 0) - network.put("mtuUpstream", selectedDatacenterPingResult.optInt("mtu_out", 1254)) - body.put("network", network) - - body.put("stateExecutionTime", 5974.7632) - body.put("streamTestTime", 11262.8423) - - Log.d(TAG, "Step 13: Using network - RTT: ${network.getInt("rtt")}ms, MTU in: ${network.getInt("mtu")}, out: ${network.getInt("mtuUpstream")}") - - // Don't increment retry count here - only increment when we actually retry (matches Qt) - Log.d(TAG, "Allocation attempt ${allocationRetryCount + 1}") - - val response = HttpClient.post(url, body.toString(), headers) - - // Update session key from response (Qt line 1605) - val newKey = response.headers["x-gaikai-session"]?.firstOrNull() - if (!newKey.isNullOrEmpty()) configKey = newKey - - if (response.statusCode != 200) - { - Log.e(TAG, "Allocation failed: ${response.statusCode}") - Log.e(TAG, "Response: ${response.body}") - return@runCatching null - } - - val allocation = JSONObject(response.body) - - // Log EVERY top-level key in the response to match Qt exactly - Log.d(TAG, "=== Step 13: Allocation Response - All Keys ===") - val keys = allocation.keys() - while (keys.hasNext()) - { - val key = keys.next() - val value = allocation.opt(key) - when (value) - { - is JSONObject -> Log.d(TAG, " $key: {JSONObject with ${value.length()} keys}") - is JSONArray -> Log.d(TAG, " $key: [JSONArray with ${value.length()} items]") - is String -> { - val strValue = value as String - if (strValue.length > 100) - Log.d(TAG, " $key: \"${strValue.take(50)}...\" (length: ${strValue.length})") - else - Log.d(TAG, " $key: \"$strValue\"") - } - else -> Log.d(TAG, " $key: $value") - } - } - Log.d(TAG, "================================================") - - // Check if we need to wait and retry (queued or data migration) - Qt lines 1616-1688 - val queued = allocation.optBoolean("queued", false) - val dataMigration = allocation.optBoolean("dataMigration", false) - val pollFrequency = allocation.optInt("pollFrequency", 15) // Default 15 seconds (Qt line 1619) - - if (queued || dataMigration) - { - // Increment retry count when we actually need to retry (Qt line 1656) - allocationRetryCount++ - - // Initialize timer and calculate max wait time on first wait (Qt lines 1622-1639) - if (allocationWaitStartTime == 0L) - { - allocationWaitStartTime = System.currentTimeMillis() - - // Calculate max wait time from waitTimeEstimate (multiply by 2 for safety, cap at 15 min, fallback to 5 min) - val waitTimeEstimate = allocation.optInt("waitTimeEstimate", -1) - if (waitTimeEstimate > 0) - { - allocationMaxWaitSeconds = waitTimeEstimate * 2 // Multiply by 2 for safety - if (allocationMaxWaitSeconds > MAX_ALLOCATION_WAIT_SECONDS) - { - allocationMaxWaitSeconds = MAX_ALLOCATION_WAIT_SECONDS // Cap at 15 minutes - } - Log.i(TAG, "Allocation queued/data migration. Using waitTimeEstimate: $waitTimeEstimate seconds (doubled to $allocationMaxWaitSeconds seconds for safety, max 15 min)") - } - else - { - allocationMaxWaitSeconds = DEFAULT_ALLOCATION_WAIT_SECONDS // Fallback to 5 minutes - Log.i(TAG, "Allocation queued/data migration. No waitTimeEstimate, using default: $allocationMaxWaitSeconds seconds (5 min)") - } - } - - val elapsedSeconds = (System.currentTimeMillis() - allocationWaitStartTime) / 1000 - - // Check if we've exceeded max wait time (Qt lines 1643-1648) - if (elapsedSeconds >= allocationMaxWaitSeconds) - { - Log.e(TAG, "Allocation wait timeout after $elapsedSeconds seconds (max: $allocationMaxWaitSeconds s)") - return@runCatching null - } - - var waitTime = pollFrequency - val remainingTime = allocationMaxWaitSeconds - elapsedSeconds - if (waitTime > remainingTime) - { - waitTime = remainingTime.toInt() - } - - // Build retry message with queue position or migration percentage (Qt lines 1656-1678) - val retryMessage: String - var queuePosition = -1 - if (dataMigration) - { - val migrationPercent = allocation.optInt("dataMigrationPercentageComplete", 0) - retryMessage = "Migrating data ($migrationPercent%) - Attempt $allocationRetryCount" - Log.i(TAG, "Data migration progress: $migrationPercent%") - } - else - { - // Extract queue position (prefer displayQueuePosition, fallback to queuePosition) - Qt lines 1664-1669 - if (allocation.has("displayQueuePosition")) - { - queuePosition = allocation.optInt("displayQueuePosition", -1) - } - else if (allocation.has("queuePosition")) - { - queuePosition = allocation.optInt("queuePosition", -1) - } - - // Build retry message with queue position if available (Qt lines 1672-1676) - retryMessage = if (queuePosition >= 0) - { - "Allocating streaming slot - Queue position: $queuePosition - Attempt $allocationRetryCount" - } - else - { - "Allocating streaming slot - Attempt $allocationRetryCount" - } - } - - Log.i(TAG, "Allocation queued/data migration. Waiting $waitTime seconds before retry (elapsed: $elapsedSeconds s, remaining: $remainingTime s, max: $allocationMaxWaitSeconds s, attempt: $allocationRetryCount)") - Log.i(TAG, retryMessage) - - // Emit progress message (Qt line 1678) - onProgress?.invoke(retryMessage) - - // Check cancellation before retry - if (isCancelled()) { - return@runCatching null - } - - // Wait and retry (Qt lines 1682-1686) - delay(waitTime * 1000L) - Log.i(TAG, "Retrying allocation request...") - return@runCatching step13_AllocateSlot() // Recursive retry - } - - // Allocation successful - reset retry counter (Qt lines 1690-1691) - allocationRetryCount = 0 - Log.i(TAG, "✓ Slot allocated!") - - return@runCatching allocation - }.getOrNull() - - /** - * Build request game spec - Matches Qt buildRequestGameSpec exactly - */ - private fun buildRequestGameSpec(entitlementId: String): JSONObject - { - val spec = JSONObject() - - // Get system timezone - val tzOffset = TimeZone.getDefault().getOffset(System.currentTimeMillis()) - val offsetHours = tzOffset / 3600000 - val offsetMinutes = kotlin.math.abs((tzOffset % 3600000) / 60000) - val timezoneStr = if (offsetHours >= 0) { - "UTC+%02d:%02d".format(offsetHours, offsetMinutes) - } else { - "UTC-%02d:%02d".format(kotlin.math.abs(offsetHours), offsetMinutes) - } - - // ============================================================================ - // COMMON FIELDS (apply to both PSCLOUD and PSNOW) - // ============================================================================ - - // Core game configuration - spec.put("entitlementId", entitlementId) - spec.put("npEnv", "np") - - // Prefer the user's manual streaming-language pick; fall back to the - // auto-detected catalog locale when the picker is left on default. The manual - // pick lives in its own setting so the catalog locale can never clobber it. - // Gaikai expects the bare language code ("de"), not the stored locale - // ("de-DE"); the lib helper is the single source of truth across platforms. - val chosenLocale = preferences.getCloudGameLanguage().ifEmpty { preferences.getCloudStoreLocale() } - val language = com.metallic.chiaki.lib.cloudGaikaiLanguage(chosenLocale) - spec.put("language", language) - - spec.put("cloudEndpoint", "https://cc.prod.gaikai.com") - spec.put("redirectUri", redirectUriUrl) - - // Video Resolution (read from settings based on service type) - val resolution = if (serviceType == "pscloud") - { - preferences.getCloudResolutionPscloud() // PSCloud supports up to 4K - } - else - { - preferences.getCloudResolutionPsnow() // PSNOW supports up to 1080p - } - - val resolutionSetting: String - val clientWidth: Int - val clientHeight: Int - when (resolution) { - 720 -> { - resolutionSetting = "720" - clientWidth = 1280 - clientHeight = 720 - } - 1440 -> { - resolutionSetting = "1440" - clientWidth = 2560 - clientHeight = 1440 - } - 2160 -> { - resolutionSetting = "2160" - clientWidth = 3840 - clientHeight = 2160 - } - else -> { - resolutionSetting = "1080" - clientWidth = 1920 - clientHeight = 1080 - } - } - spec.put("resolutionSetting", resolutionSetting) - spec.put("clientWidth", clientWidth) - spec.put("clientHeight", clientHeight) - spec.put("adaptiveStreamMode", "resize") - spec.put("useClientBwLadder", true) - - // Audio Upload (common) - spec.put("audioUploadEnabled", true) - spec.put("audioUploadNumChannels", 1) - spec.put("audioUploadSamplingFrequency", 48000) - - // Input Configuration (common) - spec.put("acceptButton", "X") - - // Protocol (common) - spec.put("encryptionSupported", true) - - // Timezone (common) - automatically detected from system - spec.put("summerTime", 0) - spec.put("timeZone", timezoneStr) - - // HTTP User Agent (common) - spec.put("httpUserAgent", userAgentString) - - // Auth Codes (common - updated later in step 9) - spec.put("gkCloudAuthCode", gkCloudAuthCode) - - // Accessibility Features (common - all disabled) - spec.put("accessibilityMarqueeSpeed", 0) - spec.put("accessibilityLargeText", 0) - spec.put("accessibilityBoldText", 0) - spec.put("accessibilityContrast", 0) - spec.put("accessibilityTtsEnable", 0) - spec.put("accessibilityTtsSpeed", 0) - spec.put("accessibilityTtsVolume", 0) - - // Capability Flags (common) - spec.put("partyCapability", false) - spec.put("homesharing", false) - spec.put("isFirstBoot", false) - spec.put("isPlusMember", true) - spec.put("parentalLevel", 0) - spec.put("yuvCoefficient", "") - - // Common Capabilities - val capabilitiesArray = JSONArray() - capabilitiesArray.put("cloudDrivenSenkushaTest") - - // ============================================================================ - // PSCLOUD (PS5) SPECIFIC FIELDS - // ============================================================================ - if (serviceType == "pscloud") { - // Video Configuration - spec.put("videoEncoderProfile", "hw5.0") - - // Input Configuration - val controllers = JSONArray() - controllers.put("ds4") - controllers.put("ds5") - controllers.put("xinput") - spec.put("connectedControllers", controllers) - val inputObj = JSONObject() - inputObj.put("controllers", controllers) - spec.put("input", inputObj) - - // Device/Platform Info - spec.put("model", "portal") - spec.put("platform", "qlite") - - // Protocol Settings - spec.put("gaikaiPlayer", "16.4.0") - spec.put("protocolVersion", 12) // CRITICAL: v12 enables PSCloud audio handling - - // Auth Codes - spec.put("ps3AuthCode", "") - spec.put("streamServerAuthCode", streamServerAuthCode) - - // Capabilities - capabilitiesArray.put("cronos") - - // Video Stream Settings (PSCLOUD only) - val videoStreamSettings = JSONObject() - videoStreamSettings.put("clientHeight", clientHeight) - videoStreamSettings.put("supportedMaxResolution", clientHeight) - val videoProfiles = JSONArray() - videoProfiles.put("hevc_hw4") - videoStreamSettings.put("supportedVideoEncoderProfiles", videoProfiles) - videoStreamSettings.put("supportedDynamicRange", "sdr") - videoStreamSettings.put("preferredMaxResolution", clientHeight) - videoStreamSettings.put("preferredDynamicRange", "sdr") - videoStreamSettings.put("hqMode", 1) - spec.put("videoStreamSettings", videoStreamSettings) - - // Audio Stream Settings (PSCLOUD only) - CRITICAL for PSCloud audio - spec.put("audioChannels", "2") // String "2" not "2.1" - spec.put("audioEncoderProfile", "default") - val audioStreamSettings = JSONObject() - audioStreamSettings.put("audioEncoderProfile", "default") - audioStreamSettings.put("maxAudioChannels", "2") - audioStreamSettings.put("preferredNumberAudioChannels", "2") - spec.put("audioStreamSettings", audioStreamSettings) - } - // ============================================================================ - // PSNOW (PS3/PS4) SPECIFIC FIELDS - // ============================================================================ - else { - // Audio Configuration - spec.put("audioChannels", "2.1") - spec.put("audioEncoderProfile", "default") - - // Video Configuration - spec.put("videoEncoderProfile", "hw4.1") - - // Input Configuration - val controllers = JSONArray().put("xinput") - spec.put("connectedControllers", controllers) - val inputObj = JSONObject() - inputObj.put("controllers", controllers) - spec.put("input", inputObj) - - // Device/Platform Info - spec.put("model", "WINDOWS") - spec.put("platform", "PC") - - // Protocol Settings - spec.put("gaikaiPlayer", "12.5.0") - spec.put("protocolVersion", 9) // v9 for PSNow - - // Auth Codes - spec.put("ps3AuthCode", ps3AuthCode) - spec.put("streamServerAuthCode", ps3AuthCode) - - // Capabilities - capabilitiesArray.put("kratos") - } - - // Set capabilities (common, but content differs by service) - spec.put("capabilities", capabilitiesArray) - - // Log the full JSON for inspection (matching Qt) - Log.i(TAG, "=== buildRequestGameSpec - Full JSON ===") - Log.i(TAG, "Service: $serviceType Platform: $platform") - val formattedJson = spec.toString(2) // Pretty print with indent - formattedJson.lines().forEach { line -> - if (line.isNotBlank()) { - Log.i(TAG, line) - } - } - Log.i(TAG, "========================================") - - return spec - } - - /** - * Helper: Submit datacenter selection to Gaikai API - * (Qt lines 1435-1461) - */ - private fun submitDatacenterSelection(pingResult: JSONObject, validatePing: Boolean): String? - { - try - { - val datacenterName = pingResult.getString("dataCenter") - val rtt = pingResult.getInt("rtt") - val mtuIn = pingResult.getInt("mtu_in") - val mtuOut = pingResult.getInt("mtu_out") - - // Validate ping for auto-selected datacenters (Qt lines 1393-1404) - // Manual selection bypasses this check - if (validatePing && rtt > 80) - { - Log.w(TAG, "Selected datacenter ping too high: $datacenterName RTT: ${rtt}ms (max: 80ms)") - throw PingTimeoutException("Ping must be < 80ms to start a cloud session. Selected datacenter $datacenterName has ${rtt}ms latency.") - } - - // Store for Step 13 - selectedDatacenterPingResult = pingResult - selectedDatacenter = datacenterName - selectedDatacenterPort = pingResult.getInt("port") - - Log.i(TAG, "Step 12: Submitting selection - $datacenterName (RTT: ${rtt}ms, MTU in: $mtuIn, out: $mtuOut)") - - // Submit to /datacenters/select (Qt lines 1435-1461) - val url = "${GaikaiConsts.GAIKAI_BASE}/sessions/$gaikaiSessionId/datacenters/select" - - val headers = mapOf( - "Content-Type" to "application/json", - "User-Agent" to userAgentString, - "Accept" to "*/*", - "X-Gaikai-Session" to configKey, - "X-Gaikai-SessionId" to gaikaiSessionId - ) - - // Body needs BOTH requestGameSpecification AND pingResults (Qt line 1435-1436) - val body = JSONObject() - body.put("requestGameSpecification", requestGameSpec) - body.put("pingResults", JSONArray().put(pingResult)) - - val response = HttpClient.post(url, body.toString(), headers) - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 12 failed: ${response.statusCode}") - Log.e(TAG, "Response: ${response.body}") - return null - } - - // Update session key - val newKey = response.headers["x-gaikai-session"]?.firstOrNull() - if (!newKey.isNullOrEmpty()) configKey = newKey - - // Extract port from response if provided - if (response.body.isNotEmpty()) - { - try - { - val json = JSONObject(response.body) - val portFromResponse = json.optInt("port", 0) - if (portFromResponse > 0) - { - selectedDatacenterPort = portFromResponse - Log.d(TAG, "Step 12: Using port from response: $selectedDatacenterPort") - } - } - catch (e: Exception) - { - Log.w(TAG, "Failed to parse Step 12 response", e) - } - } - - Log.i(TAG, "Step 12: ✓ Selected $datacenterName:$selectedDatacenterPort") - return datacenterName - } - catch (e: Exception) - { - Log.e(TAG, "Step 12 submission error", e) - throw e // Re-throw to be caught by caller - } - } -} diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt deleted file mode 100644 index e8c460a4..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ /dev/null @@ -1,1054 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import android.util.Log -import com.metallic.chiaki.cloudplay.PsnApiConstants -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.net.URL - -/** - * PSKamajiSession - Handles PlayStation Cloud Gaming Kamaji Authentication (Steps 1-6) - * - * Kamaji is Sony's authentication layer for cloud gaming. This class: - * - Creates and manages cookie-based sessions - * - Handles OAuth2 authorization flow - * - Integrates with Sony's account system - * - * Mirrors: gui/src/cloudstreaming/pskamajisession.cpp - */ -class PSKamajiSession( - private val duid: String, - private val productId: String, - private val accountBaseUrl: String, - private val redirectUri: String, - private val userAgent: String, - private val preferences: com.metallic.chiaki.common.Preferences -) -{ - companion object - { - private const val TAG = "PSKamajiSession" - } - - // Configuration - private val kamajiBase = PsnApiConstants.KAMAJI_BASE - private val storeBase = PsnApiConstants.STORE_BASE - private val commerceBase = PsnApiConstants.COMMERCE_BASE - private val kamajiClientId = PsnApiConstants.CLIENT_ID - private var platform = "ps4" // Default, will be detected from API response - private var scopesStr = PsnApiConstants.PS4_SCOPES // Default to PS4 scopes - - // State tracking - private var anonAuthCode: String? = null // OAuth code for anonymous session - private var authorizationCode: String? = null // OAuth code for authenticated session - private var jsessionId: String? = null // JSESSIONID from anonymous session - private var entitlementId: String? = null // Converted from productId - private var streamingSku: String? = null // SKU from product ID conversion - - // Owned-PSNOW fast-path: the unified catalog's pre-resolved owned streaming entitlement. - // When set, startSessionCreation skips the entire entitlement path (0.5b/0.5d/0.5e). See - // setOwnedEntitlementFastPath(). usedEntitlementFastPath gates the orchestrator's one-shot retry. - private var fastPathEntitlementId: String = "" - private var fastPathPlatform: String = "" - var usedEntitlementFastPath = false - private set - - /** - * Owned-PSNOW fast-path: hand in the streaming entitlement the unified catalog already resolved - * for an owned title, so startSessionCreation() skips the entitlement path (0.5b anonymous - * session, 0.5d product->entitlement resolve, 0.5e check/acquire) and goes straight to step5/6. - * Empty == normal full flow. The orchestrator falls back to the full flow if Gaikai rejects it. - */ - fun setOwnedEntitlementFastPath(ownedEntitlementId: String, ownedPlatform: String) - { - fastPathEntitlementId = ownedEntitlementId - fastPathPlatform = ownedPlatform - } - - /** - * Data class for session result - */ - data class SessionResult( - val success: Boolean, - val message: String, - val entitlementId: String = "", - val platform: String = "" - ) - - /** - * Start the complete Kamaji session creation flow (Steps 0.5a-0.5d, 5-6) - * Mirrors: PSKamajiSession::startSessionCreation() - */ - suspend fun startSessionCreation(npssoToken: String): SessionResult = withContext(Dispatchers.IO) - { - try - { - Log.i(TAG, "=== Starting Kamaji Session Creation ===") - Log.i(TAG, "Product ID: $productId") - Log.i(TAG, "DUID: ${duid.take(20)}...") - - if (npssoToken.isEmpty()) - { - return@withContext SessionResult(false, "NPSSO token is empty") - } - - // Owned-PSNOW fast-path: the unified catalog already resolved this title's streaming - // entitlement, so there is nothing to look up or acquire. Skip the entire entitlement - // path (0.5b/0.5d/0.5e -- which 404 and fail to acquire in storefront-less regions) and - // go straight to the authenticated session. step5/6 are independent of the anonymous - // session, so this is safe. The orchestrator retries the full flow if Gaikai rejects it. - if (fastPathEntitlementId.isNotEmpty()) - { - entitlementId = fastPathEntitlementId - platform = if (fastPathPlatform.isEmpty()) "ps4" else fastPathPlatform - scopesStr = if (platform == "ps3") "kamaji:commerce_native" else PsnApiConstants.PS4_SCOPES - usedEntitlementFastPath = true - Log.i(TAG, "Kamaji fast-path: owned entitlementId=$entitlementId platform=$platform - skipping 0.5b/0.5d/0.5e") - - val authCode = step5_GetAuthCode(npssoToken) - ?: return@withContext SessionResult(false, "Failed to get auth code") - authorizationCode = authCode - val authSession = step6_CreateAuthSession(authCode) - ?: return@withContext SessionResult(false, "Failed to create authenticated session") - Log.i(TAG, "=== Kamaji Session Complete (fast-path) === Entitlement ID: $entitlementId, Platform: $platform") - return@withContext SessionResult(true, "Success", entitlementId!!, platform) - } - - // Step 0.5b: Get Anonymous Auth Code - val anonCode = step0_5b_GetAnonymousAuthCode(npssoToken) - ?: return@withContext SessionResult(false, "Failed to get anonymous auth code") - anonAuthCode = anonCode - Log.i(TAG, "✓ Step 0.5b complete - Got anonymous auth code") - - // Step 0.5c: Create Anonymous Session - val sessionId = step0_5c_CreateAnonymousSession(anonCode) - ?: return@withContext SessionResult(false, "Failed to create anonymous session") - jsessionId = sessionId - Log.i(TAG, "✓ Step 0.5c complete - Got JSESSIONID: ${sessionId.take(10)}...") - - // Step 0.5d: Convert Product ID to Entitlement ID - val conversionResult = step0_5d_ConvertProductId(sessionId) - ?: return@withContext SessionResult(false, "Failed to convert product ID") - entitlementId = conversionResult.first - platform = conversionResult.second - streamingSku = conversionResult.third - Log.i(TAG, "✓ Step 0.5d complete - Entitlement ID: $entitlementId, Platform: $platform") - - // Update scopes if PS3 - if (platform == "ps3") - { - scopesStr = "kamaji:commerce_native" // PS3_SCOPES - } - - // Step 0.5e: Check and acquire entitlement if needed - val entitlementCheckResult = step0_5e_CheckAndAcquireEntitlement(npssoToken, sessionId) - if (!entitlementCheckResult) - { - return@withContext SessionResult(false, "Failed to check/acquire entitlement") - } - Log.i(TAG, "✓ Step 0.5e complete - Entitlement check/acquisition successful") - - // Step 5: Get Auth Code - val authCode = step5_GetAuthCode(npssoToken) - ?: return@withContext SessionResult(false, "Failed to get auth code") - authorizationCode = authCode - Log.i(TAG, "✓ Step 5 complete - Got auth code") - - // Step 6: Create Auth Session - val authSession = step6_CreateAuthSession(authCode) - ?: return@withContext SessionResult(false, "Failed to create authenticated session") - Log.i(TAG, "✓ Step 6 complete - Authenticated session created") - - // Session complete - Log.i(TAG, "=== Kamaji Session Complete ===") - Log.i(TAG, "Entitlement ID: $entitlementId") - Log.i(TAG, "Platform: $platform") - - SessionResult(true, "Success", entitlementId!!, platform) - } - catch (e: PsPlusSubscriptionException) - { - // Re-throw subscription exceptions so they bubble up to UI - Log.e(TAG, "Kamaji session PS Plus subscription error", e) - throw e - } - catch (e: Exception) - { - Log.e(TAG, "Kamaji session error", e) - SessionResult(false, "Exception: ${e.message}") - } - } - - /** - * Step 0.5b: Get Anonymous Auth Code - * GET /oauth/authorize (for anonymous session code) - * Mirrors: PSKamajiSession::step0_5b_GetAnonymousAuthCode() - */ - private fun step0_5b_GetAnonymousAuthCode(npssoToken: String): String? - { - try - { - // Build URL with query parameters (manual encoding) - val params = listOf( - "smcid" to "pc:psnow", - "applicationId" to "psnow", - "response_type" to "code", - "scope" to scopesStr, - "client_id" to kamajiClientId, - "redirect_uri" to redirectUri, - "service_entity" to "urn:service-entity:psn", - "prompt" to "none", - "renderMode" to "mobilePortrait", - "hidePageElements" to "forgotPasswordLink", - "displayFooter" to "none", - "disableLinks" to "qriocityLink", - "mid" to "PSNOW", - "duid" to duid, - "layout_type" to "popup", - "service_logo" to "ps", - "tp_psn" to "true", - "noEVBlock" to "true" - ) - - val query = params.joinToString("&") { (key, value) -> - "$key=${java.net.URLEncoder.encode(value, "UTF-8")}" - } - - val url = "$accountBaseUrl/v1/oauth/authorize?$query" - - Log.d(TAG, "Step 0.5b: GET /oauth/authorize (anonymous)") - Log.d(TAG, "URL: $url") - - val headers = mapOf( - "User-Agent" to userAgent, - "Cookie" to "npsso=$npssoToken" - ) - - val response = HttpClient.get(url, headers, followRedirects = false) - - Log.d(TAG, "Step 0.5b Response: ${response.statusCode}") - - if (response.statusCode != 302) - { - Log.e(TAG, "Expected 302 redirect, got ${response.statusCode}") - return null - } - - val location = HttpClient.extractLocation(response.headers) - if (location == null) - { - Log.e(TAG, "No Location header in redirect") - return null - } - - Log.d(TAG, "Redirect location: $location") - - val codeRegex = Regex("[?&]code=([^&]+)") - val match = codeRegex.find(location) - val code = match?.groupValues?.get(1) - - if (code.isNullOrEmpty()) - { - Log.e(TAG, "No code parameter in redirect URL") - return null - } - - return code - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5b error", e) - return null - } - } - - /** - * Step 0.5c: Create Anonymous Session - * POST /user/session (anonymous, with OAuth code) - * Mirrors: PSKamajiSession::step0_5c_CreateAnonymousSession() - */ - private fun step0_5c_CreateAnonymousSession(authCode: String): String? - { - try - { - val url = "$kamajiBase/user/session" - val body = "code=$authCode&client_id=$kamajiClientId&duid=$duid" - - Log.d(TAG, "Step 0.5c: POST /user/session (anonymous)") - Log.d(TAG, "URL: $url") - Log.d(TAG, "Body: $body") - - val headers = mapOf( - "Content-Type" to "text/plain;charset=UTF-8", - "User-Agent" to userAgent, - "X-Alt-Referer" to redirectUri, - "Accept" to "*/*", - "Origin" to PsnApiConstants.ORIGIN, - "Referer" to PsnApiConstants.REFERER - ) - - val response = HttpClient.post(url, body, headers) - - Log.d(TAG, "Step 0.5c Response: ${response.statusCode}") - Log.d(TAG, "Response body: ${response.body.take(200)}") - - if (response.statusCode != 200) - { - Log.e(TAG, "Anonymous session failed: ${response.statusCode}") - return null - } - - // Extract JSESSIONID from Set-Cookie header - val jsessionId = HttpClient.extractCookie(response.headers, "JSESSIONID") - if (jsessionId.isNullOrEmpty()) - { - Log.e(TAG, "No JSESSIONID in response") - return null - } - - // Save country and language from session response to settings (Qt CloudCatalogBackend lines 432-440) - try - { - val json = JSONObject(response.body) - val data = json.optJSONObject("data") - if (data != null) - { - val sessionCountry = data.optString("country") - val sessionLanguage = data.optString("language") - - if (!sessionCountry.isNullOrEmpty() && !sessionLanguage.isNullOrEmpty()) - { - preferences.setCloudStoreLocaleFromSession(sessionLanguage, sessionCountry) - Log.i(TAG, "Saved locale from session: ${preferences.getCloudStoreLocale()}") - } - } - } - catch (e: Exception) - { - Log.w(TAG, "Could not parse/save locale from session response", e) - } - - return jsessionId - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5c error", e) - return null - } - } - - /** - * Step 0.5d: Convert Product ID - * GET /store/api/pcnow/.../container/.../{PRODUCT_ID} - * Mirrors: PSKamajiSession::step0_5d_ConvertProductId() - * Returns: Triple - */ - private fun step0_5d_ConvertProductId(sessionId: String): Triple? - { - try - { - val resolvedCountry = preferences.getCloudResolvedStoreCountry() - val resolvedLang = preferences.getCloudResolvedStoreLang() - val localeSetting = preferences.getCloudStoreLocale() - val parsedLocale = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) - val (country, language) = if (resolvedCountry.isNotEmpty()) { - // Prefer the server-authoritative store language from the native base_url: a non-English - // native store (e.g. NL) 404s on the wrong language. Fall back to the locale-derived - // proxy when empty (fallback/foreign mode, where the public US/GB store wants en). - resolvedCountry to (resolvedLang.ifEmpty { parsedLocale.second }) - } else { - parsedLocale - } - Log.i(TAG, "step0_5d: using resolvedStoreCountry=$country (lang=$language) for container URL") - val url = "$storeBase/container/$country/$language/19/$productId?useOffers=true&gkb=1&gkb2=1" - - Log.d(TAG, "Step 0.5d: Convert Product ID") - Log.d(TAG, "URL: $url") - - val headers = mapOf( - "Accept" to "application/json", - "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - ) - - val response = HttpClient.get(url, headers) - - Log.d(TAG, "Step 0.5d Response: ${response.statusCode}") - - if (response.statusCode == 404) - { - Log.e(TAG, "Product ID not found (404)") - return null - } - - if (response.statusCode != 200) - { - Log.e(TAG, "Product lookup failed: ${response.statusCode}") - return null - } - - val json = JSONObject(response.body) - - Log.d(TAG, "Product response JSON: ${response.body.take(500)}...") - - // Extract entitlement ID and SKU - var streamingEntitlementId = "" - var sku = "" - var detectedPlatform = "ps4" // Default - - // Look for streaming entitlement - check default_sku first, then skus array - // Streaming entitlements have license_type == 4 - if (json.has("default_sku")) - { - val defaultSku = json.getJSONObject("default_sku") - if (defaultSku.has("entitlements")) - { - val entitlements = defaultSku.getJSONArray("entitlements") - for (i in 0 until entitlements.length()) - { - val ent = entitlements.getJSONObject(i) - val licenseType = ent.optInt("license_type", -1) - - // Streaming entitlements have license_type == 4 - if (licenseType == 4) - { - val entId = ent.optString("id", "") - if (entId.isNotEmpty()) - { - streamingEntitlementId = entId - sku = defaultSku.optString("id", "") - Log.i(TAG, "Found streaming Entitlement ID from default_sku: $streamingEntitlementId") - Log.i(TAG, "License Type: $licenseType") - Log.i(TAG, "SKU: $sku") - break - } - } - } - } - } - - // If not found in default_sku, check all SKUs in the skus array - if (streamingEntitlementId.isEmpty() && json.has("skus")) - { - val skus = json.getJSONArray("skus") - for (i in 0 until skus.length()) - { - val skuObj = skus.getJSONObject(i) - if (skuObj.has("entitlements")) - { - val entitlements = skuObj.getJSONArray("entitlements") - for (j in 0 until entitlements.length()) - { - val ent = entitlements.getJSONObject(j) - val licenseType = ent.optInt("license_type", -1) - - // Streaming entitlements have license_type == 4 - if (licenseType == 4) - { - val entId = ent.optString("id", "") - if (entId.isNotEmpty()) - { - streamingEntitlementId = entId - sku = skuObj.optString("id", "") - Log.i(TAG, "Found streaming Entitlement ID from skus array: $streamingEntitlementId") - Log.i(TAG, "License Type: $licenseType") - Log.i(TAG, "SKU: $sku") - break - } - } - } - } - if (streamingEntitlementId.isNotEmpty()) break - } - } - - - // Full-game fallback: PS Plus catalog titles have no license_type==4 entitlement; their - // full-game entitlement is license_type 0 with packageType "*GD". Prefer the one whose id - // matches the requested product's title id so cross-gen picks the consistent platform. - if (streamingEntitlementId.isEmpty()) - { - val requestedTitleId = productId.split("-").getOrNull(1)?.split("_")?.firstOrNull() ?: "" - fun pickFullGameEntitlement(skuObj: JSONObject, requireTitleMatch: Boolean): Boolean - { - val ents = skuObj.optJSONArray("entitlements") ?: return false - for (j in 0 until ents.length()) - { - val ent = ents.getJSONObject(j) - val entId = ent.optString("id", "") - val pkg = ent.optString("packageType", "") - if (entId.isEmpty() || !pkg.endsWith("GD")) continue - if (requireTitleMatch && requestedTitleId.isNotEmpty() && !entId.contains(requestedTitleId)) continue - streamingEntitlementId = entId - sku = skuObj.optString("id", "") - Log.i(TAG, "Found full-game Entitlement ID (PS Plus catalog fallback): $streamingEntitlementId packageType: $pkg titleMatch: $requireTitleMatch") - return true - } - return false - } - for (requireTitleMatch in listOf(true, false)) - { - if (json.has("default_sku") && pickFullGameEntitlement(json.getJSONObject("default_sku"), requireTitleMatch)) break - if (streamingEntitlementId.isEmpty() && json.has("skus")) - { - val skus = json.getJSONArray("skus") - for (i in 0 until skus.length()) - { - if (pickFullGameEntitlement(skus.getJSONObject(i), requireTitleMatch)) break - } - } - if (streamingEntitlementId.isNotEmpty()) break - } - } - // Try to extract platform from playable_platform - if (json.has("playable_platform")) - { - val playablePlatform = json.getJSONArray("playable_platform") - var hasPS4 = false - var hasPS3 = false - for (i in 0 until playablePlatform.length()) - { - val platformStr = playablePlatform.getString(i) - if (platformStr.contains("PS4", ignoreCase = true)) - { - hasPS4 = true - } - else if (platformStr.contains("PS3", ignoreCase = true)) - { - hasPS3 = true - } - } - detectedPlatform = when - { - hasPS4 -> "ps4" - hasPS3 -> "ps3" - else -> "ps4" - } - Log.i(TAG, "Detected platform from playable_platform: $detectedPlatform") - } - else if (json.has("metadata")) - { - val metadata = json.getJSONObject("metadata") - if (metadata.has("playable_platform")) - { - val playablePlatformObj = metadata.getJSONObject("playable_platform") - if (playablePlatformObj.has("values")) - { - val values = playablePlatformObj.getJSONArray("values") - var hasPS4 = false - var hasPS3 = false - for (i in 0 until values.length()) - { - val platformStr = values.getString(i) - if (platformStr.contains("PS4", ignoreCase = true)) hasPS4 = true - else if (platformStr.contains("PS3", ignoreCase = true)) hasPS3 = true - } - detectedPlatform = when - { - hasPS4 -> "ps4" - hasPS3 -> "ps3" - else -> "ps4" - } - } - } - } - - if (streamingEntitlementId.isEmpty()) - { - Log.e(TAG, "Could not determine Entitlement ID from Product ID '$productId'. Game may not be available for cloud streaming.") - return null - } - - Log.i(TAG, "Converted Product ID: $productId -> Entitlement: $streamingEntitlementId, Platform: $detectedPlatform") - - return Triple(streamingEntitlementId, detectedPlatform, sku) - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5d error", e) - return null - } - } - - // ============================================================================ - // Step 0.5e: Check and Acquire Entitlement (entitlement_check.py flow) - // ============================================================================ - - private var commerceOAuthToken: String? = null - - /** - * Step 0.5e: Check and acquire entitlement if needed - * Mirrors: PSKamajiSession::step0_5e_CheckEntitlement() - */ - private fun step0_5e_CheckAndAcquireEntitlement(npssoToken: String, sessionId: String): Boolean - { - try - { - Log.i(TAG, "Kamaji Step 0.5e: Starting entitlement check/acquisition flow") - Log.i(TAG, " Entitlement ID: $entitlementId") - if (!streamingSku.isNullOrEmpty()) - { - Log.i(TAG, " SKU: $streamingSku") - } - - // Step 0.5e.1: Get Commerce OAuth token - val commerceToken = step0_5e1_GetCommerceOAuthToken(npssoToken) - ?: return false - commerceOAuthToken = commerceToken - Log.i(TAG, "✓ Step 0.5e.1 complete - Got Commerce OAuth token") - - // Step 0.5e.2: Check if entitlement exists - val hasEntitlement = step0_5e2_CheckEntitlementExists() - if (hasEntitlement == null) - { - return false // Error occurred - } - else if (hasEntitlement) - { - // User has entitlement, continue - Log.i(TAG, "✓ Step 0.5e.2 complete - User has entitlement") - return true - } - - // User doesn't have the per-game entitlement on the account (404). - // Region-group fallback: the free 100%-off checkout that grants the streaming entitlement - // requires a pcnow storefront in the account's region -- which unsupported regions (e.g. - // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". Skip it and let - // Gaikai validate the Premium subscription directly (if it genuinely needs the entitlement - // it returns noGameForEntitlementId downstream, surfaced via the region banner). Driven by - // the account-level fallback flag, so PS3 and PS4 behave identically. - // Native (supported region): run the normal checkout-acquire for both PS3 and PS4. - if (preferences.isCloudCatalogIsForeign()) - { - Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404); fallback region -> skipping acquire, proceeding to Gaikai") - return true - } - - // Native mode: try to acquire it via checkout (PS3 + PS4 + PS5 alike). - Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire") - - // Step 0.5e.3: Checkout preview - // Throws PsPlusSubscriptionException if user doesn't have required subscription - val previewOk = step0_5e3_CheckoutPreview(sessionId) - if (!previewOk) - { - return false - } - Log.i(TAG, "✓ Step 0.5e.3 complete - Game is free, proceeding to checkout") - - // Step 0.5e.4: Complete checkout - val checkoutOk = step0_5e4_CheckoutBuynow(sessionId) - if (!checkoutOk) - { - return false - } - Log.i(TAG, "✓ Step 0.5e.4 complete - Entitlement successfully acquired!") - - return true - } - catch (e: PsPlusSubscriptionException) - { - // Re-throw subscription exceptions so they bubble up to UI - Log.e(TAG, "Step 0.5e subscription error", e) - throw e - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5e error", e) - return false - } -} - - /** - * Step 0.5e.1: Get Commerce OAuth token - * Mirrors: PSKamajiSession::step0_5e_GetCommerceOAuthToken() - */ - private fun step0_5e1_GetCommerceOAuthToken(npssoToken: String): String? - { - try - { - Log.i(TAG, "Kamaji Step 0.5e.1: Getting OAuth token for Commerce API...") - - // Build URL - Uses Commerce API client ID and scopes (Qt lines 551-572) - val params = listOf( - "smcid" to "pc:psnow", - "applicationId" to "psnow", - "response_type" to "token", // Returns access_token in URL fragment, not code - "scope" to "kamaji:get_internal_entitlements user:account.attributes.validate kamaji:get_privacy_settings user:account.settings.privacy.get kamaji:s2s.subscriptionsPremium.get", - "client_id" to "dc523cc2-b51b-4190-bff0-3397c06871b3", // Commerce API client ID - "redirect_uri" to redirectUri, - "grant_type" to "authorization_code", - "service_entity" to "urn:service-entity:psn", - "prompt" to "none", - "renderMode" to "mobilePortrait", - "hidePageElements" to "forgotPasswordLink", - "displayFooter" to "none", - "disableLinks" to "qriocityLink", - "mid" to "PSNOW", - "duid" to duid, - "layout_type" to "popup", - "service_logo" to "ps", - "tp_psn" to "true", - "noEVBlock" to "true" - ) - - val queryString = params.joinToString("&") { (k, v) -> - "$k=${java.net.URLEncoder.encode(v, "UTF-8")}" - } - // accountBaseUrl already has "/api", just add "/v1/oauth/authorize" - val url = "${accountBaseUrl}/v1/oauth/authorize?$queryString" - - Log.d(TAG, "Step 0.5e.1: GET /oauth/authorize (commerce)") - Log.d(TAG, "URL: $url") - - val response = HttpClient.get( - url, - headers = mapOf( - "User-Agent" to userAgent, - "Cookie" to "npsso=$npssoToken" // Only NPSSO, NOT JSESSIONID - ), - followRedirects = false - ) - - Log.d(TAG, "Step 0.5e.1 Response: ${response.statusCode}") - - if (response.statusCode != 302) - { - Log.e(TAG, "Step 0.5e.1 failed: expected 302, got ${response.statusCode}") - return null - } - - // Extract access_token from redirect URL fragment (#access_token=...) - val location = response.headers["Location"]?.firstOrNull() - ?: response.headers["location"]?.firstOrNull() - - if (location == null) - { - Log.e(TAG, "Step 0.5e.1: No Location header in redirect") - return null - } - - Log.d(TAG, "Redirect location: $location") - - // Extract access_token from URL fragment (Qt lines 625-633) - // Try fragment first (#access_token=...) - var tokenMatch = Regex("#access_token=([^&]+)").find(location) - if (tokenMatch == null) - { - // Fallback to query string - tokenMatch = Regex("[?&#]access_token=([^&]+)").find(location) - } - - if (tokenMatch == null) - { - Log.e(TAG, "Could not extract access_token from redirect URL") - Log.e(TAG, "Redirect URL: $location") - return null - } - - val accessToken = tokenMatch.groupValues[1] - Log.i(TAG, "✓ Step 0.5e.1 complete - Got Commerce OAuth token: ${accessToken.take(30)}...") - - return accessToken - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5e.1 error", e) - return null - } - } - - /** - * Step 0.5e.2: Check if entitlement exists - * Mirrors: PSKamajiSession::step0_5e_CheckEntitlementExists() - * Returns: true if exists, false if doesn't exist (404), null on error - */ - private fun step0_5e2_CheckEntitlementExists(): Boolean? - { - try - { - Log.i(TAG, "Kamaji Step 0.5e.2: Checking if entitlement exists...") - - val url = "$commerceBase/users/me/internal_entitlements/$entitlementId?fields=game_meta" - - val response = HttpClient.get( - url, - headers = mapOf( - "Authorization" to "Bearer $commerceOAuthToken", - "User-Agent" to userAgent, - "Accept" to "application/json" - ) - ) - - Log.d(TAG, "Step 0.5e.2 Response: ${response.statusCode}") - - if (response.statusCode == 200) - { - // User has entitlement - try - { - val json = JSONObject(response.body) - val gameMeta = json.optJSONObject("game_meta") - val gameName = gameMeta?.optString("name") - if (gameName != null) - { - Log.i(TAG, " Game Name: $gameName") - } - } - catch (e: Exception) - { - Log.w(TAG, "Could not parse game meta", e) - } - - return true - } - else if (response.statusCode == 404) - { - // User doesn't have entitlement - return false - } - else - { - Log.e(TAG, "Step 0.5e.2 failed: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - return null - } - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5e.2 error", e) - return null - } - } - - /** - * Step 0.5e.3: Checkout preview (verify game is free/available) - * Mirrors: PSKamajiSession::step0_5e_CheckoutPreview() - */ - private fun step0_5e3_CheckoutPreview(sessionId: String): Boolean - { - try - { - Log.i(TAG, "Kamaji Step 0.5e.3: Checking checkout preview...") - - if (streamingSku.isNullOrEmpty()) - { - Log.w(TAG, "No SKU available for checkout preview, using entitlement ID") - streamingSku = entitlementId - } - - val url = "$kamajiBase/user/checkout/buynow/preview" - - // Build form data - val formData = "sku=$streamingSku" - - val response = HttpClient.post( - url, - body = formData, - headers = mapOf( - "Content-Type" to "application/x-www-form-urlencoded", - "User-Agent" to userAgent, - "Accept" to "application/json", - "Authorization" to "Bearer $commerceOAuthToken", - "Sec-Fetch-Site" to "same-origin", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Dest" to "empty", - "Referer" to "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/", - "Accept-Encoding" to "identity", - "Accept-Language" to "en-US", - "Cookie" to "JSESSIONID=$sessionId" - ) - ) - - Log.d(TAG, "Step 0.5e.3 Response: ${response.statusCode}") - - // Parse response to check for API errors first - try - { - val json = JSONObject(response.body) - val header = json.getJSONObject("header") - val statusCode = header.optString("status_code") - - // Check API status code - non-zero indicates subscription/entitlement issue - // Matches Qt: pskamajisession.cpp lines 934-944 - if (statusCode != "0x0000") - { - val message = header.optString("message_key", "Unknown error") - Log.e(TAG, "Preview failed with API status: $statusCode") - Log.e(TAG, "Message: $message") - // Checkout preview errors indicate PS Plus Premium subscription required - throw PsPlusSubscriptionException("PlayStation Plus Premium subscription is required to stream this game") - } - } - catch (e: PsPlusSubscriptionException) - { - // Re-throw subscription exceptions - throw e - } - catch (e: Exception) - { - Log.e(TAG, "Failed to parse preview response", e) - // If we can't parse, fall through to HTTP status check - } - - // Check HTTP status code - // Matches Qt: pskamajisession.cpp lines 948-953 - if (response.statusCode != 200) - { - Log.e(TAG, "Step 0.5e.3 failed with HTTP status: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - // Checkout preview HTTP errors indicate PS Plus Premium subscription issue - throw PsPlusSubscriptionException("PlayStation Plus Premium subscription is required to stream this game") - } - - // Parse successful response - try - { - val json = JSONObject(response.body) - val header = json.getJSONObject("header") - val statusCode = header.optString("status_code") - - val data = json.getJSONObject("data") - // Qt lines 988-991: Parse cart.total_price_value (integer) - val cart = data.getJSONObject("cart") - val totalPriceValue = cart.optInt("total_price_value") - val totalPrice = cart.optString("total_price") - - Log.i(TAG, " Total Price Value: $totalPriceValue") - Log.i(TAG, " Total Price: $totalPrice") - - if (totalPriceValue != 0) - { - Log.e(TAG, "Game is not free! Price: $totalPrice") - return false - } - - // Extract actual SKU from response (Qt lines 1002-1009: cart.items[0].sku_id) - val items = cart.optJSONArray("items") - if (items != null && items.length() > 0) - { - val firstItem = items.getJSONObject(0) - val actualSku = firstItem.optString("sku_id") - if (!actualSku.isNullOrEmpty() && actualSku != streamingSku) - { - Log.i(TAG, "Using SKU from preview response: $actualSku") - streamingSku = actualSku - } - } - - return true - } - catch (e: Exception) - { - Log.e(TAG, "Failed to parse preview response", e) - return false - } - } - catch (e: PsPlusSubscriptionException) - { - // Re-throw subscription exceptions so they bubble up to UI - Log.e(TAG, "Step 0.5e.3 subscription error", e) - throw e - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5e.3 error", e) - return false - } -} - - /** - * Step 0.5e.4: Complete checkout to acquire entitlement - * Mirrors: PSKamajiSession::step0_5e_CheckoutBuynow() - */ - private fun step0_5e4_CheckoutBuynow(sessionId: String): Boolean - { - try - { - Log.i(TAG, "Kamaji Step 0.5e.4: Completing checkout to acquire entitlement...") - - val url = "$kamajiBase/user/checkout/buynow" - - // Build form data - val formData = "sku=$streamingSku" - - val response = HttpClient.post( - url, - body = formData, - headers = mapOf( - "Content-Type" to "application/x-www-form-urlencoded", - "User-Agent" to userAgent, - "Accept" to "application/json", - "Authorization" to "Bearer $commerceOAuthToken", - "Cookie" to "JSESSIONID=$sessionId" - ) - ) - - Log.d(TAG, "Step 0.5e.4 Response: ${response.statusCode}") - - if (response.statusCode != 200) - { - Log.e(TAG, "Step 0.5e.4 failed: ${response.statusCode}") - Log.e(TAG, "Response body: ${response.body}") - return false - } - - // Parse response - try - { - val json = JSONObject(response.body) - val header = json.getJSONObject("header") - val statusCode = header.optString("status_code") - - if (statusCode != "0x0000") - { - Log.e(TAG, "Checkout failed with status: $statusCode") - val messageKey = header.optString("message_key") - Log.e(TAG, "Message: $messageKey") - return false - } - - val data = json.getJSONObject("data") - val transactionId = data.optString("transaction_id") - - Log.i(TAG, " Transaction ID: $transactionId") - - return true - } - catch (e: Exception) - { - Log.e(TAG, "Failed to parse buynow response", e) - return false - } - } - catch (e: Exception) - { - Log.e(TAG, "Step 0.5e.4 error", e) - return false - } - } - - /** - * Step 5: Get Auth Code - * GET /oauth/authorize (for authenticated session code) - * Mirrors: PSKamajiSession::step5_GetAuthCode() - */ - private fun step5_GetAuthCode(npssoToken: String): String? - { - // Same as step0_5b but for authenticated session - return step0_5b_GetAnonymousAuthCode(npssoToken) - } - - /** - * Step 6: Create Auth Session - * POST /user/session (authenticated, with OAuth code) - * Mirrors: PSKamajiSession::step6_CreateAuthSession() - */ - private fun step6_CreateAuthSession(authCode: String): String? - { - // Same as step0_5c but using the authenticated auth code - return step0_5c_CreateAnonymousSession(authCode) - } -} diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/ping/DatacenterPing.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/ping/DatacenterPing.kt deleted file mode 100644 index 489f9d36..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/ping/DatacenterPing.kt +++ /dev/null @@ -1,189 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.ping - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import org.json.JSONArray -import org.json.JSONObject - -/** - * Ping result structure containing RTT and MTU measurements - * Mirrors: PingResult struct in datacenterping.h - */ -data class PingResult( - val rttUs: Long, // RTT in microseconds, or -1 on failure - val mtuIn: Int, // Inbound MTU (server to client) - val mtuOut: Int // Outbound MTU (client to server) -) - -/** - * DatacenterPing - Uses existing senkusha echo/ping functionality for RTT measurement - * - * This class reuses the existing chiaki_senkusha_run flow which performs: - * 1. Takion connect - * 2. Protocol version exchange (always v9 for cloud ping) - * 3. BIG/BANG handshake - * 4. Echo command enable - * 5. Multiple ping/pong measurements (10 by default) - * 6. Average RTT calculation - * - * Mirrors: DatacenterPing class in datacenterping.h/cpp - */ -object DatacenterPing -{ - private const val TAG = "DatacenterPing" - private const val PING_TIMEOUT_MS = 15000L // 15 seconds (Qt line 242) - - /** - * Ping multiple datacenters using senkusha echo/ping functionality - * - * @param datacenters JSONArray of datacenter objects with "publicIp", "port", "dataCenter", "maxBandwidth" - * @param sessionKey The session key from x-gaikai-session header (used for BIG message) - * @param serviceType Service type: "pscloud" or "psnow" (used to determine if PSN wrapper should be added) - * @return JSONArray of ping results. Each result has: "dataCenter", "rtt", "rtts", "mtu_in", "mtu_out", "port", "publicIp", "maxBandwidth" - * - * Mirrors: DatacenterPing::pingAllDatacentersWithTimeout (Qt lines 213-340) - */ - suspend fun pingAllDatacentersWithTimeout( - datacenters: JSONArray, - sessionKey: String, - serviceType: String - ): JSONArray = withContext(Dispatchers.IO) { - if (datacenters.length() == 0) - { - Log.w(TAG, "No datacenters to ping") - return@withContext JSONArray() - } - - Log.i(TAG, "Starting parallel ping of ${datacenters.length()} datacenters with ${PING_TIMEOUT_MS}ms timeout") - - try - { - // Ping all datacenters in parallel with timeout (Qt lines 239-273) - withTimeout(PING_TIMEOUT_MS) { - coroutineScope { - val pingTasks = (0 until datacenters.length()).map { i -> - async { - try - { - val dc = datacenters.getJSONObject(i) - val publicIp = dc.getString("publicIp") - val port = dc.getInt("port") - val dataCenter = dc.getString("dataCenter") - val maxBandwidth = dc.getInt("maxBandwidth") - - Log.d(TAG, "Pinging datacenter: $dataCenter ($publicIp:$port)") - - // Perform the ping handshake (Qt line 289) - val pingResult = performPingHandshake(publicIp, port, sessionKey, serviceType) - val rttMs = if (pingResult.rttUs > 0) (pingResult.rttUs / 1000).toInt() else -1 - - // Build result object (Qt lines 293-309) - val result = JSONObject() - result.put("dataCenter", dataCenter) - result.put("port", port) - result.put("publicIp", publicIp) - result.put("maxBandwidth", maxBandwidth) - - if (rttMs > 0) - { - result.put("rtt", rttMs) - result.put("rtts", JSONArray().put(rttMs)) - result.put("mtu_in", pingResult.mtuIn) - result.put("mtu_out", pingResult.mtuOut) - Log.i(TAG, "✓ $dataCenter: ${rttMs}ms (MTU in=${pingResult.mtuIn}, out=${pingResult.mtuOut})") - } - else - { - result.put("rtt", 999) - result.put("rtts", JSONArray().put(999)) - result.put("mtu_in", 0) - result.put("mtu_out", 0) - Log.w(TAG, "✗ $dataCenter: Ping failed") - } - - result - } - catch (e: Exception) - { - Log.e(TAG, "Error pinging datacenter ${i}: ${e.message}", e) - null - } - } - } - - // Wait for all pings to complete (Qt lines 320-330) - val results = pingTasks.awaitAll().filterNotNull() - val successCount = results.count { it.getInt("rtt") > 0 && it.getInt("rtt") < 999 } - Log.i(TAG, "Completed ${results.size}/${datacenters.length()} pings, $successCount successful") - - // Convert to JSONArray - val resultArray = JSONArray() - results.forEach { resultArray.put(it) } - resultArray - } - } - } - catch (e: kotlinx.coroutines.TimeoutCancellationException) - { - // Timeout - return whatever results we have so far (Qt lines 244-270) - Log.w(TAG, "DatacenterPing: Timeout after ${PING_TIMEOUT_MS}ms") - JSONArray() // Return empty - caller will use fallback - } - } - - /** - * Ping a single datacenter using senkusha_run - * - * @param publicIp The datacenter's public IP address - * @param port The datacenter's port (typically 2053 for cloud) - * @param sessionKey The session key (x-gaikai-session) to use in BIG message launch_spec - * @param serviceType Service type: "pscloud" or "psnow" (used to determine if PSN wrapper should be added) - * @return PingResult containing RTT and MTU values, or rttUs=-1 on failure/timeout - * - * Mirrors: DatacenterPing::performPingHandshake (Qt lines 48-211) - */ - private fun performPingHandshake( - publicIp: String, - port: Int, - sessionKey: String, - serviceType: String - ): PingResult - { - return try - { - // Call native senkusha ping function - DatacenterPingNative.performPing(publicIp, port, sessionKey, serviceType) - } - catch (e: Exception) - { - Log.e(TAG, "Exception in performPingHandshake: ${e.message}", e) - PingResult(rttUs = -1, mtuIn = 0, mtuOut = 0) - } - } -} - -/** - * Native JNI interface for datacenter pinging - * Calls chiaki_senkusha_run from the C library - */ -private object DatacenterPingNative -{ - /** - * Perform a senkusha ping to a datacenter - * - * @param publicIp Datacenter IP address - * @param port Datacenter port - * @param sessionKey Session key for BIG message - * @param serviceType "pscloud" or "psnow" - * @return PingResult with RTT and MTU measurements - */ - external fun performPing(publicIp: String, port: Int, sessionKey: String, serviceType: String): PingResult -} - diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index 5e32279d..71c37321 100755 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -49,14 +49,6 @@ set(SOURCE_FILES src/cloudstreamingbackend.cpp include/cloudcatalogbackend.h src/cloudcatalogbackend.cpp - include/cloudstreaming/pscloudauth.h - src/cloudstreaming/pscloudauth.cpp - include/cloudstreaming/pskamajisession.h - src/cloudstreaming/pskamajisession.cpp - include/cloudstreaming/psgaikaistreaming.h - src/cloudstreaming/psgaikaistreaming.cpp - include/cloudstreaming/datacenterping.h - src/cloudstreaming/datacenterping.cpp include/jsonrequester.h src/jsonrequester.cpp src/qml/qml.qrc diff --git a/gui/include/cloudstreamingbackend.h b/gui/include/cloudstreamingbackend.h index e5cbadd1..7f738e77 100644 --- a/gui/include/cloudstreamingbackend.h +++ b/gui/include/cloudstreamingbackend.h @@ -71,11 +71,8 @@ private slots: private: void setAllocationProgress(const QString &message); - - // Centralized authorization check (used by both PSNOW and PSCLOUD) - void checkAuthorization(QString serviceType, QString npssoToken, QString duid, std::function callback); - - // Continue cloud session after successful authorization: runs the unified C + + // Continue cloud session: runs the unified C // provisioning flow (chiaki_cloud_provision_session) on a worker thread and // hands the stream-ready result to StreamSession. Kamaji+Gaikai, the owned // fast-path and the one-shot noGameForEntitlementId retry all live in libchiaki. diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 8b8a41db..e32c25d8 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -1,8 +1,6 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL #include "cloudstreamingbackend.h" -#include "cloudstreaming/pskamajisession.h" // KamajiConsts (auth check) -#include "cloudstreaming/psgaikaistreaming.h" // GaikaiConsts (auth check) #include "streamsession.h" #include "exception.h" #include "chiaki/remote/holepunch.h" @@ -86,36 +84,10 @@ void CloudStreamingBackend::startCompleteCloudSession(QString serviceType, QStri setGameImageUrl(QString()); // Clear any previous image } - // Generate DUID once - shared between authorization check and session creation - size_t duid_size = CHIAKI_DUID_STR_SIZE; - char duid_arr[duid_size]; - chiaki_holepunch_generate_client_device_uid(duid_arr, &duid_size); - QString sharedDuid = QString(duid_arr); - - // Centralized authorization check for both PSNOW and PSCLOUD - checkAuthorization(serviceType, npssoToken, sharedDuid, [this, serviceType, gameIdentifier, callback, npssoToken, sharedDuid](bool success) { - if (!success) { - // Authorization failed - set flag to show dialog (following ping timeout pattern) - QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - qmlBackend->setShowAuthorizationFailedDialog(true); - // Also emit sessionError to trigger StreamView error handling and return to main menu - emit qmlBackend->sessionError(tr("Authentication Required"), - tr("Your NPSSO token is likely expired. Please re-login to continue using cloud streaming.")); - } - - // Clear game image on authorization failure - setGameImageUrl(QString()); - - if (callback.isCallable()) { - callback.call({false, "Authorization check failed"}); - } - return; - } - - // Authorization successful - continue with cloud session setup - continueCloudSessionAfterAuth(serviceType, gameIdentifier, callback, npssoToken, sharedDuid); - }); + // The C provisioning flow runs the NPSSO authorizeCheck itself as its first + // (silent) step and surfaces AUTHORIZATION_FAILED (handled in handleProvisionError) + // if the token is expired -- no separate pre-flight pass is needed here anymore. + continueCloudSessionAfterAuth(serviceType, gameIdentifier, callback, npssoToken, QString()); } // Runs the unified C provisioning flow on a worker thread, then hands the @@ -383,7 +355,10 @@ void CloudStreamingBackend::handleProvisionError(QString serviceType, QString er // the dialog just toasts on the streaming page. QString userMessage; QmlBackend *qmlBackend = qobject_cast(parent()); - if (errorMessage.contains(QStringLiteral("PS_PLUS_SUBSCRIPTION_REQUIRED"))) { + if (errorMessage.contains(QStringLiteral("AUTHORIZATION_FAILED"))) { + if (qmlBackend) qmlBackend->setShowAuthorizationFailedDialog(true); + userMessage = tr("Your NPSSO token is likely expired. Please re-login to continue using cloud streaming."); + } else if (errorMessage.contains(QStringLiteral("PS_PLUS_SUBSCRIPTION_REQUIRED"))) { if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); userMessage = tr("PS Plus subscription required"); } else if (errorMessage.contains(QStringLiteral("ACCOUNT_PRIVACY_SETTINGS"))) { @@ -458,77 +433,3 @@ void CloudStreamingBackend::setGameImageUrl(const QString &url) } } -// ============================================================================ -// Centralized Authorization Check (used by both PSNOW and PSCLOUD) -// ============================================================================ -void CloudStreamingBackend::checkAuthorization(QString serviceType, QString npssoToken, QString duid, std::function callback) -{ - if (npssoToken.isEmpty()) { - qWarning() << "Authorization check: NPSSO token is empty"; - callback(false); - return; - } - - // Determine configuration based on service type - QString kamajiClientId; - QString scopesStr; - QString redirectUri; - QString userAgent; - - if (serviceType == "psnow") { - // PSNOW configuration (matching PSKamajiSession) - kamajiClientId = KamajiConsts::CLIENT_ID; - scopesStr = KamajiConsts::PS4_SCOPES; - redirectUri = KamajiConsts::REDIRECT_URI; - userAgent = KamajiConsts::USER_AGENT; - } else { // pscloud - // PSCLOUD configuration - kamajiClientId = "19ae39c4-3f88-4d11-a792-94e4f52c996d"; - scopesStr = "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s"; - redirectUri = GaikaiConsts::REDIRECT_URI; - userAgent = GaikaiConsts::USER_AGENT; - } - - // Disable cookie jar on auth manager - we use manual Cookie headers only - authManager->setCookieJar(nullptr); - - // Create authorization check request (matching PSKamajiSession::step0_5a_AuthorizeCheck) - QString url = CloudConfig::ACCOUNT_BASE + "/authz/v3/oauth/authorizeCheck"; - - QJsonObject body; - body["client_id"] = kamajiClientId; - body["scope"] = scopesStr; - body["redirect_uri"] = redirectUri; - body["response_type"] = "code"; - body["service_entity"] = "urn:service-entity:psn"; - body["duid"] = duid; - - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json; charset=UTF-8"); - req.setRawHeader("User-Agent", userAgent.toUtf8()); - // Set npsso cookie manually - if (!npssoToken.isEmpty()) { - req.setRawHeader("Cookie", QString("npsso=%1").arg(npssoToken).toUtf8()); - } - - qInfo() << "=== Centralized Authorization Check ==="; - qInfo() << "Service Type:" << serviceType; - qInfo() << "URL:" << url; - - QNetworkReply *reply = authManager->post(req, QJsonDocument(body).toJson()); - - connect(reply, &QNetworkReply::finished, this, [reply, callback, serviceType]() { - bool success = false; - - // Match PSKamajiSession::handleAuthorizeCheckResponse logic - if (reply->error() == QNetworkReply::NoError) { - success = true; - qInfo() << "Authorization check: SUCCESS for" << serviceType; - } else { - qWarning() << "Authorization check failed for" << serviceType << ":" << reply->errorString(); - } - - reply->deleteLater(); - callback(success); - }); -} From 8725f8bf05cca603a226673ea5d85f9896992b91 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 17:24:28 -0700 Subject: [PATCH 58/72] cloudsession (android): drop checkAuthorization (now in C) + delete old classes The NPSSO authorizeCheck now runs in libchiaki as the first step of the provision flow, so CloudStreamingBackend no longer does its own pre-flight: removed checkAuthorization + the shared-DUID generation + the now-unused DuidUtil/ PsnApiConstants imports, and map the new AUTHORIZATION_FAILED sentinel to AuthorizationFailedException. Delete the now-dead duplicated classes PSKamajiSession.kt (1054), PSGaikaiStreaming .kt (1686, incl. object GaikaiConsts) and DatacenterPing.kt (189). GaikaiConsts went with PSGaikaiStreaming; PsnApiConstants lives in its own file and survives. BUILD SUCCESSFUL. Co-Authored-By: Claude Opus 4.8 --- .../cloudplay/api/CloudStreamingBackend.kt | 103 +----------------- 1 file changed, 4 insertions(+), 99 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index dc64dcd9..4c88186b 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -5,8 +5,6 @@ package com.metallic.chiaki.cloudplay.api import android.content.Context import android.util.Log import com.metallic.chiaki.cloudplay.CloudLocaleBootstrap -import com.metallic.chiaki.cloudplay.DuidUtil -import com.metallic.chiaki.cloudplay.PsnApiConstants import com.metallic.chiaki.cloudplay.model.CloudStreamSession import com.metallic.chiaki.common.Preferences import kotlinx.coroutines.Dispatchers @@ -85,19 +83,8 @@ class CloudStreamingBackend( return@withContext Result.failure(Exception("Invalid serviceType: $normalizedServiceType")) } - // Generate DUID once - shared between authorization check and session creation - val sharedDuid = DuidUtil.generateDuid() - Log.i(TAG, "Using DUID: ${sharedDuid.take(20)}...") - - // Centralized authorization check for both PSNOW and PSCLOUD (Qt lines 91-119) - val authSuccess = checkAuthorization(normalizedServiceType, npssoToken, sharedDuid) - if (!authSuccess) - { - Log.e(TAG, "Authorization check failed - NPSSO token likely expired") - return@withContext Result.failure(AuthorizationFailedException("Your NPSSO token is likely expired. Please re-login to continue using cloud streaming.")) - } - - Log.i(TAG, "✓ Authorization check passed") + // The C provisioning flow runs the NPSSO authorizeCheck itself as its first + // (silent) step and returns AUTHORIZATION_FAILED if the token is expired. // PSCloud skips Kamaji; bootstrap locale once if PSNow never ran if (normalizedServiceType == "pscloud") @@ -206,6 +193,8 @@ class CloudStreamingBackend( Log.e(TAG, "Cloud provisioning failed: $msg") val ex: Exception = when { + msg.contains("AUTHORIZATION_FAILED") -> + AuthorizationFailedException("Your NPSSO token is likely expired. Please re-login to continue using cloud streaming.") msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") -> PsPlusSubscriptionException("PS Plus subscription required") msg.startsWith("ACCOUNT_PRIVACY_SETTINGS") -> @@ -224,89 +213,5 @@ class CloudStreamingBackend( } } - /** - * Centralized Authorization Check (used by both PSNOW and PSCLOUD) - * Mirrors: CloudStreamingBackend::checkAuthorization() (Qt lines 543-613) - */ - private suspend fun checkAuthorization( - serviceType: String, - npssoToken: String, - duid: String - ): Boolean = withContext(Dispatchers.IO) - { - if (npssoToken.isEmpty()) - { - Log.w(TAG, "Authorization check: NPSSO token is empty") - return@withContext false - } - - // Determine configuration based on service type - val kamajiClientId: String - val scopesStr: String - val redirectUri: String - val userAgent: String - - if (serviceType == "psnow") - { - // PSNOW configuration (matching PSKamajiSession) - kamajiClientId = PsnApiConstants.CLIENT_ID - scopesStr = PsnApiConstants.PS4_SCOPES - redirectUri = PsnApiConstants.REDIRECT_URI - userAgent = PsnApiConstants.USER_AGENT - } - else // pscloud - { - // PSCLOUD configuration (Qt lines 563-569) - kamajiClientId = "19ae39c4-3f88-4d11-a792-94e4f52c996d" - scopesStr = "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s" - redirectUri = GaikaiConsts.REDIRECT_URI - userAgent = GaikaiConsts.USER_AGENT - } - - try - { - Log.i(TAG, "=== Centralized Authorization Check ===") - Log.i(TAG, " Service Type: $serviceType") - Log.i(TAG, " Client ID: $kamajiClientId") - - // Create authorization check request (matching PSKamajiSession::step0_5a_AuthorizeCheck) - val url = "${CloudConfig.ACCOUNT_BASE}/authz/v3/oauth/authorizeCheck" - - val body = org.json.JSONObject() - body.put("client_id", kamajiClientId) - body.put("scope", scopesStr) - body.put("redirect_uri", redirectUri) - body.put("response_type", "code") - body.put("service_entity", "urn:service-entity:psn") - body.put("duid", duid) - - val response = HttpClient.post( - url = url, - headers = mapOf( - "Content-Type" to "application/json; charset=UTF-8", - "User-Agent" to userAgent, - "Cookie" to "npsso=$npssoToken" - ), - body = body.toString() - ) - - if (response.statusCode == 200 || response.statusCode == 204) - { - Log.i(TAG, "✓ Authorization check passed (${response.statusCode})") - return@withContext true - } - else - { - Log.w(TAG, "Authorization check failed: ${response.statusCode}") - Log.w(TAG, "Response: ${response.body}") - return@withContext false - } - } - catch (e: Exception) - { - Log.e(TAG, "Authorization check error", e) - return@withContext false - } - } } From b1be26b2921ca8f4de812e2d7040247c27b5235c Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 18:33:46 -0700 Subject: [PATCH 59/72] cloudsession: remove the obsolete PSCLOUD locale bootstrap (superseded by catalog) The iOS/Android "locale bootstrap" (CloudLocaleSettings.ensureConfigured / CloudLocaleBootstrap) did its own OAuth + Kamaji /user/session call to resolve the store region before a PS5 session. It's dead weight now: - It predates (2026-05-26) the catalog's server-authoritative locale resolution (2026-06-27). The unified catalog now emits settledLocale/fallbackRegion and all three platforms persist it (iOS noteSettledLocale, Android noteCloudStoreLocaleSettled, Qt SetCloudStoreLocale). - PS5 (PSCLOUD) provisioning never uses store_country/store_lang -- Gaikai reads only game_language; the store locale is consumed solely by the PSNOW Kamaji resolve. - The streaming-language fallback (game_language empty -> cloud_store_locale) is fed by the catalog on all three platforms, so the bootstrap's only output is already covered. - Qt never had this bootstrap and works correctly -> the catalog path is sufficient. - The fresh-install/catalog-bypass edge case is unreachable: a CloudGame only exists after a catalog fetch, so there's no path to stream without the locale already seeded. Verified by a fresh-eyes review subagent (all findings confirmed; edge case unreachable). iOS: drop ensureConfigured + its bootstrap cluster + the now-dead enum CloudApiConstants and CloudHttpClient.swift. Android: drop the call + delete CloudLocaleBootstrap.kt and its now-dead HttpClient.kt + PsnApiConstants.kt. Qt unchanged. Net -638 lines; the live locale accessors (stored/setStored/noteSettledLocale/parseStorePath) are kept. Both apps build; suite 108/108. Behavior-identical (no device-locale fallback needed). Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/CloudLocaleBootstrap.kt | 123 ------------ .../chiaki/cloudplay/PsnApiConstants.kt | 26 --- .../cloudplay/api/CloudStreamingBackend.kt | 9 +- .../chiaki/cloudplay/api/HttpClient.kt | 150 -------------- ios/Pylux.xcodeproj/project.pbxproj | 4 - ios/Pylux/Models/CloudModels.swift | 143 -------------- ios/Pylux/Services/CloudHttpClient.swift | 187 ------------------ .../Services/CloudStreamingBackend.swift | 10 +- 8 files changed, 7 insertions(+), 645 deletions(-) delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt delete mode 100644 android/app/src/main/java/com/metallic/chiaki/cloudplay/api/HttpClient.kt delete mode 100644 ios/Pylux/Services/CloudHttpClient.swift diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt deleted file mode 100644 index 03b30966..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocaleBootstrap.kt +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay - -import android.util.Log -import com.metallic.chiaki.cloudplay.api.HttpClient -import com.metallic.chiaki.common.Preferences -import org.json.JSONObject - -object CloudLocaleBootstrap -{ - private const val TAG = "CloudLocaleBootstrap" - private val lock = Any() - - fun ensureConfigured(preferences: Preferences, npssoToken: String): Boolean - { - if (preferences.isCloudStoreLocaleConfigured()) - return true - if (npssoToken.isBlank()) - { - Log.w(TAG, "Cannot bootstrap locale: empty npsso token") - return false - } - - synchronized(lock) - { - if (preferences.isCloudStoreLocaleConfigured()) - return true - - Log.i(TAG, "Bootstrapping cloud locale via Kamaji session (first time only)") - return runBootstrap(preferences, npssoToken) - } - } - - private fun runBootstrap(preferences: Preferences, npssoToken: String): Boolean - { - return try - { - val duid = DuidUtil.generateDuid() - val oauthCode = fetchOAuthCode(npssoToken, duid) ?: run { - Log.w(TAG, "Locale bootstrap failed: OAuth") - return false - } - if (!createKamajiSessionAndSaveLocale(preferences, oauthCode, duid)) - { - Log.w(TAG, "Locale bootstrap failed: Kamaji session") - return false - } - Log.i(TAG, "Locale bootstrap OK: ${preferences.getCloudStoreLocale()}") - true - } - catch (e: Exception) - { - Log.w(TAG, "Locale bootstrap error", e) - false - } - } - - private fun fetchOAuthCode(npssoToken: String, duid: String): String? - { - val uri = android.net.Uri.parse("${PsnApiConstants.ACCOUNT_BASE}/v1/oauth/authorize") - .buildUpon() - .appendQueryParameter("smcid", "pc:psnow") - .appendQueryParameter("applicationId", "psnow") - .appendQueryParameter("response_type", "code") - .appendQueryParameter("scope", PsnApiConstants.PS4_SCOPES) - .appendQueryParameter("client_id", PsnApiConstants.CLIENT_ID) - .appendQueryParameter("redirect_uri", PsnApiConstants.REDIRECT_URI) - .appendQueryParameter("service_entity", "urn:service-entity:psn") - .appendQueryParameter("prompt", "none") - .appendQueryParameter("renderMode", "mobilePortrait") - .appendQueryParameter("hidePageElements", "forgotPasswordLink") - .appendQueryParameter("displayFooter", "none") - .appendQueryParameter("disableLinks", "qriocityLink") - .appendQueryParameter("mid", "PSNOW") - .appendQueryParameter("duid", duid) - .appendQueryParameter("layout_type", "popup") - .appendQueryParameter("service_logo", "ps") - .appendQueryParameter("tp_psn", "true") - .appendQueryParameter("noEVBlock", "true") - .build() - - val response = HttpClient.get(uri.toString(), mapOf("Cookie" to "npsso=$npssoToken"), followRedirects = false) - if (response.statusCode != 302) - return null - - val location = HttpClient.extractLocation(response.headers) ?: return null - val match = Regex("[?&]code=([^&]+)").find(location) ?: return null - return match.groupValues.getOrNull(1)?.takeIf { it.isNotEmpty() } - } - - private fun createKamajiSessionAndSaveLocale( - preferences: Preferences, - oauthCode: String, - duid: String - ): Boolean - { - val url = "${PsnApiConstants.KAMAJI_BASE}/user/session" - val body = "code=$oauthCode&client_id=${PsnApiConstants.CLIENT_ID}&duid=$duid" - val headers = mapOf( - "Content-Type" to "text/plain;charset=UTF-8", - "X-Alt-Referer" to PsnApiConstants.REDIRECT_URI, - "Origin" to PsnApiConstants.ORIGIN, - "Referer" to PsnApiConstants.REFERER, - "Accept" to "*/*" - ) - - val response = HttpClient.post(url, body, headers) - if (response.statusCode != 200) - return false - - val json = JSONObject(response.body) - if (json.optJSONObject("header")?.optString("status_code") != "0x0000") - return false - - val data = json.optJSONObject("data") - preferences.setCloudStoreLocaleFromSession( - data?.optString("language"), - data?.optString("country") - ) - return preferences.isCloudStoreLocaleConfigured() - } -} diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt deleted file mode 100644 index 7c8b6a39..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay - -/** - * PSN Cloud Gaming API Constants - * Matches KamajiConsts from gui/include/cloudstreaming/pskamajisession.h exactly - */ -object PsnApiConstants -{ - // CloudConfig constants - const val ACCOUNT_BASE = "https://ca.account.sony.com/api" - - // KamajiConsts - PSNow specific - const val KAMAJI_BASE = "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000" - const val STORE_BASE = "https://psnow.playstation.com/store/api/pcnow/00_09_000" - const val COMMERCE_BASE = "https://commerce.api.np.km.playstation.net/commerce/api/v1" - const val CLIENT_ID = "bc6b0777-abb5-40da-92ca-e133cf18e989" - const val REDIRECT_URI = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" - const val ORIGIN = "https://psnow.playstation.com" - const val REFERER = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/" - const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" - - const val PS4_SCOPES = "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" -} - diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 4c88186b..4a369e13 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -4,7 +4,6 @@ package com.metallic.chiaki.cloudplay.api import android.content.Context import android.util.Log -import com.metallic.chiaki.cloudplay.CloudLocaleBootstrap import com.metallic.chiaki.cloudplay.model.CloudStreamSession import com.metallic.chiaki.common.Preferences import kotlinx.coroutines.Dispatchers @@ -83,13 +82,11 @@ class CloudStreamingBackend( return@withContext Result.failure(Exception("Invalid serviceType: $normalizedServiceType")) } - // The C provisioning flow runs the NPSSO authorizeCheck itself as its first + // The store locale is resolved + persisted by the unified catalog fetch + // (settledLocale -> cloud_store_locale); the streaming-language fallback reads + // it. The C provisioning flow runs the NPSSO authorizeCheck itself as its first // (silent) step and returns AUTHORIZATION_FAILED if the token is expired. - // PSCloud skips Kamaji; bootstrap locale once if PSNow never ran - if (normalizedServiceType == "pscloud") - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - // Continue with cloud session setup val result = continueCloudSessionAfterAuth( normalizedServiceType, diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/HttpClient.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/HttpClient.kt deleted file mode 100644 index 9b93ecd4..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/HttpClient.kt +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import android.util.Log -import com.metallic.chiaki.cloudplay.PsnApiConstants -import java.io.BufferedReader -import java.io.InputStreamReader -import java.io.OutputStreamWriter -import java.net.HttpURLConnection -import java.net.URL -import javax.net.ssl.HttpsURLConnection - -/** - * Simple HTTP client for PSN API calls - * Uses HttpURLConnection for reliability (SSL works out of box on Android) - */ -internal object HttpClient -{ - private const val TAG = "PsnHttpClient" - private const val TIMEOUT_MS = 10000 - - data class Response( - val statusCode: Int, - val body: String, - val headers: Map> - ) - - /** - * Perform GET request - */ - fun get( - url: String, - headers: Map = emptyMap(), - followRedirects: Boolean = true - ): Response - { - Log.d(TAG, "GET: $url") - - val connection = URL(url).openConnection() as HttpURLConnection - try - { - connection.requestMethod = "GET" - connection.connectTimeout = TIMEOUT_MS - connection.readTimeout = TIMEOUT_MS - connection.instanceFollowRedirects = followRedirects - - // Set headers - connection.setRequestProperty("User-Agent", PsnApiConstants.USER_AGENT) - headers.forEach { (key, value) -> - connection.setRequestProperty(key, value) - } - - val statusCode = connection.responseCode - Log.d(TAG, "Response: $statusCode") - - val body = try { - connection.inputStream.bufferedReader().use { it.readText() } - } catch (e: Exception) { - connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "" - } - - return Response(statusCode, body, connection.headerFields) - } - finally - { - connection.disconnect() - } - } - - /** - * Perform POST request - */ - fun post( - url: String, - body: String, - headers: Map = emptyMap() - ): Response - { - Log.d(TAG, "POST: $url") - - val connection = URL(url).openConnection() as HttpURLConnection - try - { - connection.requestMethod = "POST" - connection.connectTimeout = TIMEOUT_MS - connection.readTimeout = TIMEOUT_MS - connection.doOutput = true - - // Set headers - connection.setRequestProperty("User-Agent", PsnApiConstants.USER_AGENT) - headers.forEach { (key, value) -> - connection.setRequestProperty(key, value) - } - - // Write body - OutputStreamWriter(connection.outputStream).use { writer -> - writer.write(body) - writer.flush() - } - - val statusCode = connection.responseCode - Log.d(TAG, "Response: $statusCode") - - val responseBody = try { - connection.inputStream.bufferedReader().use { it.readText() } - } catch (e: Exception) { - connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "" - } - - return Response(statusCode, responseBody, connection.headerFields) - } - finally - { - connection.disconnect() - } - } - - /** - * Extract cookie value from response headers - */ - fun extractCookie(headers: Map>, cookieName: String): String? - { - val setCookieHeaders = headers["Set-Cookie"] ?: headers["set-cookie"] ?: return null - - for (header in setCookieHeaders) - { - val cookies = header.split(";") - for (cookie in cookies) - { - val parts = cookie.trim().split("=", limit = 2) - if (parts.size == 2 && parts[0] == cookieName) - { - return parts[1] - } - } - } - - return null - } - - /** - * Extract Location header for redirects - */ - fun extractLocation(headers: Map>): String? - { - return headers["Location"]?.firstOrNull() ?: headers["location"]?.firstOrNull() - } -} - diff --git a/ios/Pylux.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index 9dffd4b4..a7d404e7 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -50,7 +50,6 @@ A1000192 /* PyluxChiakiLog.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000191 /* PyluxChiakiLog.m */; }; A1000201 /* PictureInPictureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000200 /* PictureInPictureManager.swift */; }; A3000011 /* CloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000001 /* CloudModels.swift */; }; - A3000012 /* CloudHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000002 /* CloudHttpClient.swift */; }; A3000015 /* CloudStreamingBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000005 /* CloudStreamingBackend.swift */; }; A3000016 /* CloudCatalogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000006 /* CloudCatalogService.swift */; }; A3000017 /* CloudPlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000007 /* CloudPlayView.swift */; }; @@ -124,7 +123,6 @@ A1000191 /* PyluxChiakiLog.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PyluxChiakiLog.m; sourceTree = ""; }; A1000200 /* PictureInPictureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureManager.swift; sourceTree = ""; }; A3000001 /* CloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudModels.swift; sourceTree = ""; }; - A3000002 /* CloudHttpClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudHttpClient.swift; sourceTree = ""; }; A3000005 /* CloudStreamingBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudStreamingBackend.swift; sourceTree = ""; }; A3000006 /* CloudCatalogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudCatalogService.swift; sourceTree = ""; }; A3000007 /* CloudPlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPlayView.swift; sourceTree = ""; }; @@ -241,7 +239,6 @@ isa = PBXGroup; children = ( A3000006 /* CloudCatalogService.swift */, - A3000002 /* CloudHttpClient.swift */, A3000005 /* CloudStreamingBackend.swift */, A1000200 /* PictureInPictureManager.swift */, A1000120 /* PsnTokenManager.swift */, @@ -409,7 +406,6 @@ A1000128 /* SecureStore.swift in Sources */, A1000126 /* AutoRegistrationView.swift in Sources */, A3000011 /* CloudModels.swift in Sources */, - A3000012 /* CloudHttpClient.swift in Sources */, A3000015 /* CloudStreamingBackend.swift in Sources */, A3000016 /* CloudCatalogService.swift in Sources */, A3000017 /* CloudPlayView.swift in Sources */, diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 694f3f34..f13862e0 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -121,30 +121,6 @@ struct KamajiSessionError: Error, LocalizedError { var errorDescription: String? { message } } -// MARK: - Cloud API Constants (matches Android PsnApiConstants.kt + GaikaiConsts) - -enum CloudApiConstants { - // Gaikai constants (matches GaikaiConsts in PSGaikaiStreaming.kt) - static let configBase = "https://config.cc.prod.gaikai.com/v1" - static let gaikaiBase = "https://cc.prod.gaikai.com/v1" - static let gaikaiAccountBase = "https://ca.account.sony.com" - static let gaikaiRedirectUri = "gaikai://local" - static let gaikaiUserAgent = "PlayStation Portal/6.0.0-rel.444+6a9cea6f5" - - // PSNow / Kamaji constants (matches PsnApiConstants.kt) - static let kamajiBase = "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000" - static let storeBase = "https://psnow.playstation.com/store/api/pcnow/00_09_000" - static let commerceBase = "https://commerce.api.np.km.playstation.net/commerce/api/v1" - static let kamajiClientId = "bc6b0777-abb5-40da-92ca-e133cf18e989" - static let kamajiRedirectUri = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" - static let kamajiOrigin = "https://psnow.playstation.com" - static let kamajiReferer = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/" - static let kamajiUserAgent = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" - static let ps4Scopes = "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" - - // Cloud config (matches CloudConfig in CloudStreamingBackend.kt) - static let accountBase = "https://ca.account.sony.com/api" -} // Region-group / Classics-container logic now lives in libchiaki (lib/src/cloudcatalog_consts.c) // and is reflected back to the client via the unified catalog's "fallbackRegion" field. @@ -210,35 +186,6 @@ enum CloudLocaleSettings { return (country, lang) } - static func fromSession(language: String?, country: String?) -> String? { - let lang = language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let cty = country?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !lang.isEmpty, !cty.isEmpty else { return nil } - return "\(lang)-\(cty.uppercased())" - } - - static func setFromSession(language: String?, country: String?) { - guard let locale = fromSession(language: language, country: country) else { - os_log(.info, log: cloudLocaleLog, - "Kamaji session: no language/country in response (stored=%{public}s)", stored) - return - } - if isConfigured { - // The country is the real region signal; the language part may get auto-corrected - // by the imagic fetch (e.g. hu-HU settles on en-HU). Only re-save when the country - // changes, otherwise we'd clobber the validated locale on every Kamaji session. - let storedCountry = parseStorePath(stored).country - let sessionCountry = parseStorePath(locale).country - if storedCountry == sessionCountry { - os_log(.info, log: cloudLocaleLog, - "Kamaji session country unchanged (%{public}s), keeping validated locale %{public}s", - sessionCountry, stored) - return - } - } - setStored(locale) - } - /// Persist the locale the lib actually settled on (unified catalog "settledLocale"), /// WITHOUT wiping the cache. The lib owns its own cache invalidation; this only keeps /// the locale we pass next time (and the streaming language) in sync with the lib. @@ -276,94 +223,4 @@ enum CloudLocaleSettings { } } - static func applyLocaleFromKamajiSessionBody(_ body: String) { - guard let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let dataObj = json["data"] as? [String: Any] else { return } - setFromSession( - language: dataObj["language"] as? String, - country: dataObj["country"] as? String - ) - } - - private static let bootstrapLock = NSLock() - - @discardableResult - static func ensureConfigured(npssoToken: String) -> Bool { - if isConfigured { return true } - guard !npssoToken.isEmpty else { - os_log(.info, log: cloudLocaleLog, "Locale bootstrap skipped: no npsso token") - return false - } - - bootstrapLock.lock() - defer { bootstrapLock.unlock() } - if isConfigured { return true } - - os_log(.info, log: cloudLocaleLog, "Bootstrapping cloud locale via Kamaji session (first time only)") - let duid = generateBootstrapDuid() - guard let code = fetchBootstrapOAuthCode(npssoToken: npssoToken, duid: duid) else { - os_log(.info, log: cloudLocaleLog, "Locale bootstrap failed: OAuth") - return false - } - guard postBootstrapKamajiSession(oauthCode: code, duid: duid) else { - os_log(.info, log: cloudLocaleLog, "Locale bootstrap failed: Kamaji session") - return false - } - os_log(.info, log: cloudLocaleLog, "Locale bootstrap OK: %{public}s", stored) - return isConfigured - } - - private static func fetchBootstrapOAuthCode(npssoToken: String, duid: String) -> String? { - let params: [(String, String)] = [ - ("smcid", "pc:psnow"), ("applicationId", "psnow"), - ("response_type", "code"), ("scope", CloudApiConstants.ps4Scopes), - ("client_id", CloudApiConstants.kamajiClientId), - ("redirect_uri", CloudApiConstants.kamajiRedirectUri), - ("service_entity", "urn:service-entity:psn"), ("prompt", "none"), - ("renderMode", "mobilePortrait"), ("hidePageElements", "forgotPasswordLink"), - ("displayFooter", "none"), ("disableLinks", "qriocityLink"), - ("mid", "PSNOW"), ("duid", duid), ("layout_type", "popup"), - ("service_logo", "ps"), ("tp_psn", "true"), ("noEVBlock", "true") - ] - let query = params.map { "\($0.0)=\($0.1.cloudUrlEncoded)" }.joined(separator: "&") - let url = "\(CloudApiConstants.accountBase)/v1/oauth/authorize?\(query)" - - guard let response = CloudHttpClient.get(url: url, headers: [ - "Cookie": "npsso=\(npssoToken)" - ], followRedirects: false), response.statusCode == 302, - let location = CloudHttpClient.extractLocation(from: response), - let comps = URLComponents(string: location), - let code = comps.queryItems?.first(where: { $0.name == "code" })?.value, - !code.isEmpty else { return nil } - return code - } - - private static func postBootstrapKamajiSession(oauthCode: String, duid: String) -> Bool { - let url = "\(CloudApiConstants.kamajiBase)/user/session" - let body = "code=\(oauthCode)&client_id=\(CloudApiConstants.kamajiClientId)&duid=\(duid)" - - guard let response = CloudHttpClient.post(url: url, body: body, headers: [ - "Content-Type": "text/plain;charset=UTF-8", - "X-Alt-Referer": CloudApiConstants.kamajiRedirectUri, - "Origin": CloudApiConstants.kamajiOrigin, - "Referer": CloudApiConstants.kamajiReferer, - "Accept": "*/*" - ]), response.statusCode == 200 else { return false } - - guard let data = response.body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let header = json["header"] as? [String: Any], - header["status_code"] as? String == "0x0000" else { return false } - - applyLocaleFromKamajiSessionBody(response.body) - return isConfigured - } - - private static func generateBootstrapDuid() -> String { - let prefix = "0000000700410080" - var randomBytes = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, 16, &randomBytes) - return prefix + randomBytes.map { String(format: "%02x", $0) }.joined() - } } diff --git a/ios/Pylux/Services/CloudHttpClient.swift b/ios/Pylux/Services/CloudHttpClient.swift deleted file mode 100644 index 89e8c2a4..00000000 --- a/ios/Pylux/Services/CloudHttpClient.swift +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL -// HTTP client for cloud streaming API calls - mirrors Android HttpClient.kt - -import Foundation -import os.log - -private let httpLog = OSLog(subsystem: "com.pylux.stream", category: "CloudHTTP") - -/// HTTP response matching Android's HttpClient.Response -struct CloudHttpResponse { - let statusCode: Int - let body: String - let headers: [String: String] // case-insensitive header lookup - let allHeaders: [AnyHashable: Any] // raw headers from URLResponse - - func header(_ name: String) -> String? { - // Case-insensitive lookup - let lower = name.lowercased() - for (key, value) in headers { - if key.lowercased() == lower { return value } - } - return nil - } -} - -/// Simple HTTP client for PSN/Gaikai API calls - mirrors Android HttpClient.kt -enum CloudHttpClient { - private static let timeout: TimeInterval = 15 - - // MARK: - GET - - static func get( - url urlString: String, - headers: [String: String] = [:], - followRedirects: Bool = true - ) -> CloudHttpResponse? { - guard let url = URL(string: urlString) else { - os_log(.error, log: httpLog, "GET: invalid URL: %{public}s", urlString) - return nil - } - - var request = URLRequest(url: url, timeoutInterval: timeout) - request.httpMethod = "GET" - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - - let config = URLSessionConfiguration.ephemeral - let delegate = followRedirects ? nil : NoRedirectSessionDelegate() - let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - - let sem = DispatchSemaphore(value: 0) - var result: CloudHttpResponse? - - session.dataTask(with: request) { data, response, error in - defer { sem.signal() } - if let error = error { - os_log(.error, log: httpLog, "GET %{public}s error: %{public}s", urlString, error.localizedDescription) - return - } - result = buildResponse(response: response, data: data, delegate: delegate) - }.resume() - sem.wait() - session.invalidateAndCancel() - return result - } - - // MARK: - POST - - static func post( - url urlString: String, - body: String, - headers: [String: String] = [:] - ) -> CloudHttpResponse? { - guard let url = URL(string: urlString) else { - os_log(.error, log: httpLog, "POST: invalid URL: %{public}s", urlString) - return nil - } - - var request = URLRequest(url: url, timeoutInterval: timeout) - request.httpMethod = "POST" - request.httpBody = Data(body.utf8) - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - - let sem = DispatchSemaphore(value: 0) - var result: CloudHttpResponse? - - let session = URLSession(configuration: .ephemeral) - session.dataTask(with: request) { data, response, error in - defer { sem.signal() } - if let error = error { - os_log(.error, log: httpLog, "POST %{public}s error: %{public}s", urlString, error.localizedDescription) - return - } - result = buildResponse(response: response, data: data, delegate: nil) - }.resume() - sem.wait() - session.invalidateAndCancel() - return result - } - - // MARK: - Cookie / Location Helpers (matching Android HttpClient) - - /// Extract cookie from Set-Cookie headers - static func extractCookie(from response: CloudHttpResponse, name: String) -> String? { - // Check all raw headers for Set-Cookie - if let allHeaders = response.allHeaders as? [String: Any] { - for (key, value) in allHeaders { - if key.lowercased() == "set-cookie" { - let cookies: [String] - if let arr = value as? [String] { cookies = arr } - else if let str = value as? String { cookies = [str] } - else { continue } - for cookieStr in cookies { - let parts = cookieStr.split(separator: ";") - for part in parts { - let kv = part.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1) - if kv.count == 2 && kv[0] == name { - return String(kv[1]) - } - } - } - } - } - } - return nil - } - - /// Extract Location header for redirects - static func extractLocation(from response: CloudHttpResponse) -> String? { - return response.header("Location") - } - - // MARK: - Private - - private static func buildResponse(response: URLResponse?, data: Data?, delegate: NoRedirectSessionDelegate?) -> CloudHttpResponse { - let httpResp = response as? HTTPURLResponse - let statusCode = httpResp?.statusCode ?? 0 - let body = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" - - var flatHeaders: [String: String] = [:] - if let allHeaders = httpResp?.allHeaderFields { - for (key, value) in allHeaders { - flatHeaders["\(key)"] = "\(value)" - } - } - - // If we have a redirect delegate, merge the redirect location - if let redirectURL = delegate?.redirectURL { - flatHeaders["Location"] = redirectURL.absoluteString - } - - return CloudHttpResponse( - statusCode: statusCode, - body: body, - headers: flatHeaders, - allHeaders: httpResp?.allHeaderFields ?? [:] - ) - } -} - -// MARK: - URL Encoding (matches Java URLEncoder.encode behavior) - -extension String { - /// URL-encode matching Java's URLEncoder.encode (space -> "+", etc.) - var cloudUrlEncoded: String { - var allowed = CharacterSet.alphanumerics - allowed.insert(charactersIn: "-._*") - return addingPercentEncoding(withAllowedCharacters: allowed)? - .replacingOccurrences(of: "%20", with: "+") ?? self - } -} - -/// Delegate that captures redirect URL without following it -private class NoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate { - var redirectURL: URL? - - func urlSession(_ session: URLSession, task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void) { - redirectURL = request.url - completionHandler(nil) // Don't follow - } -} diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index b0dece62..e052e021 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -38,12 +38,10 @@ final class CloudStreamingBackend { throw GaikaiAllocationError(message: "Invalid serviceType: \(normalizedServiceType)") } - if normalizedServiceType == "pscloud" { - CloudLocaleSettings.ensureConfigured(npssoToken: npssoToken) - } - - // The C flow runs the NPSSO authorizeCheck itself as its first (silent) step - // and returns AUTHORIZATION_FAILED if the token is expired. + // The store locale is resolved + persisted by the unified catalog fetch + // (settledLocale -> cloud_store_locale); the streaming-language fallback reads + // it. The C flow runs the NPSSO authorizeCheck itself as its first (silent) + // step and returns AUTHORIZATION_FAILED if the token is expired. return try continueCloudSessionAfterAuth( serviceType: normalizedServiceType, gameIdentifier: gameIdentifier, From 738d1727a5a18d9e364e2ab5b90f2fd083c9b072 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 18:41:48 -0700 Subject: [PATCH 60/72] cloudsession: remove dead Qt authManager + refresh stale comments The Qt checkAuthorization moved to C, leaving its QNetworkAccessManager (authManager) member + the QNetwork* includes unused. Remove them. Also refresh two comments that named now-deleted classes: cloudcatalogbackend.h ("PSKamajiSession" -> "the C provisioning flow") and iOS SettingsView.swift ("called from PSGaikaiStreaming" -> "called from the cloud streaming backend"). Qt builds clean (0 source errors, app launches). No behavior change. Co-Authored-By: Claude Opus 4.8 --- gui/include/cloudcatalogbackend.h | 2 +- gui/include/cloudstreamingbackend.h | 2 -- gui/src/cloudstreamingbackend.cpp | 4 ---- ios/Pylux/Views/SettingsView.swift | 2 +- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index 7eb853b6..de9f4fbd 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -67,7 +67,7 @@ class CloudCatalogBackend : public QObject // Owned-PSNOW launch fast-path: look up a title in the cached unified catalog by its launch // identifier and, if it is an owned PSNOW row with a pre-resolved streaming entitlement, return - // that entitlementId + platform so PSKamajiSession can skip the resolve/acquire path. Returns + // that entitlementId + platform so the C provisioning flow can skip the resolve/acquire path. Returns // false (out params untouched) for anything else (non-owned, pscloud, missing entitlementId, or // no cached catalog). Reads the catalog the lib wrote; account-specific ownership only. bool getOwnedPsnowEntitlement(const QString &gameIdentifier, QString &outEntitlementId, QString &outPlatform); diff --git a/gui/include/cloudstreamingbackend.h b/gui/include/cloudstreamingbackend.h index 7f738e77..01f20415 100644 --- a/gui/include/cloudstreamingbackend.h +++ b/gui/include/cloudstreamingbackend.h @@ -8,7 +8,6 @@ #include #include #include -#include // ============================================================================ // CONFIGURATION - Shared settings and values used by multiple classes @@ -92,7 +91,6 @@ private slots: QString allocation_progress; int queue_position = -1; // -1 means not queued or no position available QString game_image_url; // Landscape image URL for current cloud game - QNetworkAccessManager *authManager; // For authorization check }; #endif // CLOUDSTREAMINGBACKEND_H diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index e32c25d8..59d0f15c 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -14,9 +14,6 @@ #include #include #include -#include -#include -#include #include #include #include @@ -34,7 +31,6 @@ CloudStreamingBackend::CloudStreamingBackend(Settings *settings, QObject *parent : QObject(parent) , settings(settings) , allocation_progress("") - , authManager(new QNetworkAccessManager(this)) { } diff --git a/ios/Pylux/Views/SettingsView.swift b/ios/Pylux/Views/SettingsView.swift index 7d56954e..42b51ab5 100644 --- a/ios/Pylux/Views/SettingsView.swift +++ b/ios/Pylux/Views/SettingsView.swift @@ -264,7 +264,7 @@ enum CloudDatacenterStore { return !arr.isEmpty } - /// Save datacenter list after allocation (called from PSGaikaiStreaming) + /// Save datacenter list after allocation (called from the cloud streaming backend) static func saveDatacenters(_ datacenters: [[String: Any]], for serviceType: String) { guard let data = try? JSONSerialization.data(withJSONObject: datacenters) else { return } if serviceType == "pscloud" { From 7fb453d0cb20f449a53af2c34329b56d63896f22 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 19:24:34 -0700 Subject: [PATCH 61/72] cloudsession: fix final-review findings (forced-DC RTT regression + dead code) Holistic pre-merge review of the branch vs release/beta. Fixes: MAJOR - forced-datacenter mode no longer clobbers the saved RTT. In forced mode gk_step11 adds a dummy ping (RTT 20) for the chosen datacenter; gk_build_picker's "this run wins" then let that dummy overwrite the datacenter's previously-measured RTT in the persisted Settings picker (the old Qt code deliberately never wrote dummy values in forced mode). Now the picker prefers prior measured data for the forced DC and seeds the dummy only when there's no prior -- mirrors the old seed-only-when-empty behavior. Allocation is unaffected (it reads the dummy from ping_results directly, not the picker). MINOR - delete orphaned ios/Pylux/Bridge/ChiakiDatacenterPing.{h,m} (+ the ChiakiBridge.h import and pbxproj refs); its only caller was the deleted PSGaikaiStreaming.swift -- pinging lives in libchiaki now. NIT - refresh stale doc-comments that still described the deleted PSKamajiSession/PSGaikaiStreaming delegated-class architecture. All three build (iOS/Android) + lib 108/108. Co-Authored-By: Claude Opus 4.8 --- gui/include/cloudstreamingbackend.h | 12 +-- gui/src/cloudstreamingbackend.cpp | 2 +- ios/Pylux.xcodeproj/project.pbxproj | 6 -- ios/Pylux/Bridge/ChiakiBridge.h | 1 - ios/Pylux/Bridge/ChiakiDatacenterPing.h | 25 ----- ios/Pylux/Bridge/ChiakiDatacenterPing.m | 138 ------------------------ lib/src/cloudsession_gaikai.c | 10 +- 7 files changed, 16 insertions(+), 178 deletions(-) delete mode 100644 ios/Pylux/Bridge/ChiakiDatacenterPing.h delete mode 100644 ios/Pylux/Bridge/ChiakiDatacenterPing.m diff --git a/gui/include/cloudstreamingbackend.h b/gui/include/cloudstreamingbackend.h index 01f20415..181aa1ee 100644 --- a/gui/include/cloudstreamingbackend.h +++ b/gui/include/cloudstreamingbackend.h @@ -22,14 +22,14 @@ namespace CloudConfig { * * This class is the main entry point for cloud gaming. It: * - Holds shared configuration (CloudConfig namespace in header) - * - Orchestrates Kamaji authentication (PSKamajiSession) - * - Orchestrates Gaikai allocation (PSGaikaiStreaming) + * - Runs the whole provisioning flow (auth check, Kamaji resolve, Gaikai + * allocation, datacenter ping/select) in libchiaki via + * chiaki_cloud_provision_session, on a worker thread * - Provides a single unified API for the frontend - * + * * Architecture: - * CloudStreamingBackend (orchestrator) - * └─> PSKamajiSession (Steps 1-6: Kamaji auth) - * └─> PSGaikaiStreaming (Steps 7-13: Gaikai allocation) + * CloudStreamingBackend (thin Qt wrapper) + * └─> libchiaki chiaki_cloud_provision_session (the unified C flow) */ class StreamSession; // Forward declaration diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 59d0f15c..c93deaa1 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -101,7 +101,7 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q const QByteArray storeCountry = settings->GetCloudResolvedStoreCountry().toUtf8(); const QByteArray storeLang = settings->GetCloudResolvedStoreLang().toUtf8(); // Streaming language: manual picker, else fall back to the auto-detected catalog - // locale (matches psgaikaistreaming.cpp) so non-English regions don't silently get "en". + // store locale so non-English regions don't silently get "en". QString gameLangStr = settings->GetCloudGameLanguage(); if (gameLangStr.isEmpty()) gameLangStr = settings->GetCloudStoreLocale(); diff --git a/ios/Pylux.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index a7d404e7..e5773e41 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -46,7 +46,6 @@ A1000169 /* StreamTouchControlsButtonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000170 /* StreamTouchControlsButtonViews.swift */; }; A1000171 /* StreamTouchControlsTouchpadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000172 /* StreamTouchControlsTouchpadView.swift */; }; A1000173 /* StreamTouchControlsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000174 /* StreamTouchControlsContainerView.swift */; }; - A1000180 /* ChiakiDatacenterPing.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000182 /* ChiakiDatacenterPing.m */; }; A1000192 /* PyluxChiakiLog.m in Sources */ = {isa = PBXBuildFile; fileRef = A1000191 /* PyluxChiakiLog.m */; }; A1000201 /* PictureInPictureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000200 /* PictureInPictureManager.swift */; }; A3000011 /* CloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3000001 /* CloudModels.swift */; }; @@ -117,8 +116,6 @@ A1000170 /* StreamTouchControlsButtonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamTouchControlsButtonViews.swift; sourceTree = ""; }; A1000172 /* StreamTouchControlsTouchpadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamTouchControlsTouchpadView.swift; sourceTree = ""; }; A1000174 /* StreamTouchControlsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamTouchControlsContainerView.swift; sourceTree = ""; }; - A1000181 /* ChiakiDatacenterPing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ChiakiDatacenterPing.h; sourceTree = ""; }; - A1000182 /* ChiakiDatacenterPing.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ChiakiDatacenterPing.m; sourceTree = ""; }; A1000190 /* PyluxChiakiLog.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PyluxChiakiLog.h; sourceTree = ""; }; A1000191 /* PyluxChiakiLog.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PyluxChiakiLog.m; sourceTree = ""; }; A1000200 /* PictureInPictureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureManager.swift; sourceTree = ""; }; @@ -203,8 +200,6 @@ A1000017 /* ChiakiSessionBridge.m */, A1000141 /* ChiakiAudioOutputIOS.h */, A1000142 /* ChiakiAudioOutputIOS.m */, - A1000181 /* ChiakiDatacenterPing.h */, - A1000182 /* ChiakiDatacenterPing.m */, A1000190 /* PyluxChiakiLog.h */, A1000191 /* PyluxChiakiLog.m */, A1000100 /* DiscoveryBridge.h */, @@ -371,7 +366,6 @@ A1000192 /* PyluxChiakiLog.m in Sources */, A1000005 /* ChiakiSessionBridge.m in Sources */, A1000140 /* ChiakiAudioOutputIOS.m in Sources */, - A1000180 /* ChiakiDatacenterPing.m in Sources */, A1000007 /* VideoDecoder.m in Sources */, A1000008 /* SessionEventReceiver.m in Sources */, A1000201 /* PictureInPictureManager.swift in Sources */, diff --git a/ios/Pylux/Bridge/ChiakiBridge.h b/ios/Pylux/Bridge/ChiakiBridge.h index 3bb69e47..b4597c7e 100644 --- a/ios/Pylux/Bridge/ChiakiBridge.h +++ b/ios/Pylux/Bridge/ChiakiBridge.h @@ -11,7 +11,6 @@ #import "DiscoveryBridge.h" #import "RegistBridge.h" #import "HolepunchBridge.h" -#import "ChiakiDatacenterPing.h" #import "CloudCatalogBridge.h" #import "CloudProvisionBridge.h" diff --git a/ios/Pylux/Bridge/ChiakiDatacenterPing.h b/ios/Pylux/Bridge/ChiakiDatacenterPing.h deleted file mode 100644 index 4f5c7e2c..00000000 --- a/ios/Pylux/Bridge/ChiakiDatacenterPing.h +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL -// Senkusha datacenter ping for cloud allocation (mirrors android chiaki-jni DatacenterPing) - -#ifndef ChiakiDatacenterPing_h -#define ChiakiDatacenterPing_h - -#include -#include - -typedef struct ChiakiDatacenterPingOutput { - int64_t rtt_us; // microseconds, or -1 on failure - uint32_t mtu_in; - uint32_t mtu_out; -} ChiakiDatacenterPingOutput; - -/// Run chiaki_senkusha_run against a Gaikai datacenter (UDP echo / BIG handshake). -/// @param public_ip Hostname or IPv4 string -/// @param session_key x-gaikai-session (configKey) for cloud BIG -/// @param service_type "pscloud" or "psnow" -/// @return true if senkusha completed successfully and RTT was measured -bool chiaki_datacenter_ping(const char *public_ip, int32_t port, - const char *session_key, const char *service_type, - ChiakiDatacenterPingOutput *out); - -#endif diff --git a/ios/Pylux/Bridge/ChiakiDatacenterPing.m b/ios/Pylux/Bridge/ChiakiDatacenterPing.m deleted file mode 100644 index d7b590d5..00000000 --- a/ios/Pylux/Bridge/ChiakiDatacenterPing.m +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL -// Mirrors: android/app/src/main/cpp/chiaki-jni.c Java_...DatacenterPingNative_performPing -// -// IMPORTANT: This file is compiled by Xcode, not CMake. ChiakiSession field offsets -// can differ from libchiaki. Always use chiaki_session_set_*_ex() bridge helpers — -// see for details. - -#import "ChiakiDatacenterPing.h" -#import "PyluxChiakiLog.h" - -#include -#include -#include - -#include -#include -#include -#import - -static void ping_log_cb(ChiakiLogLevel level, const char *msg, void *user) -{ - (void)user; - if (!msg) - return; - NSLog(@"[SenkushaPing] %s", msg); -} - -bool chiaki_datacenter_ping(const char *public_ip, int32_t port, - const char *session_key, const char *service_type, - ChiakiDatacenterPingOutput *out) -{ - if (!out) - return false; - out->rtt_us = -1; - out->mtu_in = 0; - out->mtu_out = 0; - - if (!public_ip || !session_key || !service_type || port <= 0 || !session_key[0]) - return false; - - ChiakiLog log; - pylux_chiaki_log_init(&log, ping_log_cb, NULL); - - struct addrinfo hints; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_DGRAM; - hints.ai_protocol = IPPROTO_UDP; - - char port_str[16]; - snprintf(port_str, sizeof(port_str), "%d", (int)port); - - struct addrinfo *addrinfo_result = NULL; - int gai_err = getaddrinfo(public_ip, port_str, &hints, &addrinfo_result); - if (gai_err != 0 || !addrinfo_result) - { - CHIAKI_LOGE(&log, "DatacenterPing: resolve failed %s:%d", public_ip, (int)port); - return false; - } - - size_t session_size = chiaki_session_get_sizeof(); - ChiakiSession *session = (ChiakiSession *)calloc(1, session_size); - if (!session) - { - freeaddrinfo(addrinfo_result); - return false; - } - - chiaki_session_set_log_ex(session, &log); - chiaki_session_set_host_addrinfo_selected_ex(session, addrinfo_result); - chiaki_session_set_enable_dualsense_ex(session, false); - chiaki_session_set_target_ex(session, CHIAKI_TARGET_PS5_1); - chiaki_session_set_cloud_port_ex(session, (uint16_t)port); - - if (strcmp(service_type, "pscloud") == 0) - { - chiaki_session_set_cloud_psn_wrapper_type_ex(session, 0); - chiaki_session_set_service_type_ex(session, CHIAKI_SERVICE_TYPE_PSCLOUD); - } - else - { - chiaki_session_set_cloud_psn_wrapper_type_ex(session, 0x01); - chiaki_session_set_service_type_ex(session, CHIAKI_SERVICE_TYPE_PSNOW); - } - - ChiakiSenkusha senkusha; - ChiakiErrorCode chiaki_err = chiaki_senkusha_init(&senkusha, session); - if (chiaki_err != CHIAKI_ERR_SUCCESS) - { - CHIAKI_LOGE(&log, "DatacenterPing: senkusha_init failed %d", chiaki_err); - freeaddrinfo(addrinfo_result); - free(session); - return false; - } - - /* Cloud ping always uses protocol version 9, regardless of pscloud vs psnow. - * Version 12 is for the actual streaming connection only (matches Qt datacenterping.cpp). */ - senkusha.protocol_version = 9; - - size_t session_key_len = strlen(session_key); - senkusha.cloud_launch_spec = (char *)malloc(session_key_len + 1); - if (!senkusha.cloud_launch_spec) - { - chiaki_senkusha_fini(&senkusha); - freeaddrinfo(addrinfo_result); - free(session); - return false; - } - memcpy(senkusha.cloud_launch_spec, session_key, session_key_len); - senkusha.cloud_launch_spec[session_key_len] = '\0'; - - uint32_t mtu_in = 0; - uint32_t mtu_out = 0; - uint64_t rtt_us = 0; - chiaki_err = chiaki_senkusha_run(&senkusha, &mtu_in, &mtu_out, &rtt_us, NULL); - - if (senkusha.cloud_launch_spec) - { - free(senkusha.cloud_launch_spec); - senkusha.cloud_launch_spec = NULL; - } - chiaki_senkusha_fini(&senkusha); - freeaddrinfo(addrinfo_result); - free(session); - - if (chiaki_err == CHIAKI_ERR_SUCCESS) - { - out->rtt_us = (int64_t)rtt_us; - out->mtu_in = mtu_in; - out->mtu_out = mtu_out; - CHIAKI_LOGI(&log, "DatacenterPing: ok rtt=%llu us mtu_in=%u mtu_out=%u", - (unsigned long long)rtt_us, mtu_in, mtu_out); - return true; - } - - CHIAKI_LOGE(&log, "DatacenterPing: senkusha_run failed %d", chiaki_err); - return false; -} diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 41896f97..0bf8f78f 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -645,13 +645,21 @@ static struct json_object *gk_build_picker(GaikaiCtx *c, struct json_object *dcs struct json_object *prior = (c->cfg->prior_datacenters_json && *c->cfg->prior_datacenters_json) ? json_tokener_parse(c->cfg->prior_datacenters_json) : NULL; struct json_object *out = json_object_new_array(); + const char *forced = c->cfg->forced_datacenter; + bool use_forced = forced && *forced && strcmp(forced, "Auto") != 0; size_t n = json_object_array_length(dcs_api); for(size_t i = 0; i < n; i++) { struct json_object *dc = json_object_array_get_idx(dcs_api, i); const char *name = cc_json_str(dc, "dataCenter"); - struct json_object *row = gk_find_dc(c->ping_results, name); // this run wins + // In forced-DC mode the only this-run "ping" is a dummy (RTT 20) for the forced + // datacenter; don't let it clobber a previously-measured RTT in the persisted + // picker. Prefer prior measured data, and seed the dummy only when there's no + // prior (mirrors the old per-platform seed-only-when-empty behavior). + bool is_forced_dummy = use_forced && name && strcmp(name, forced) == 0; + struct json_object *row = is_forced_dummy ? NULL : gk_find_dc(c->ping_results, name); // this run wins if(!row) row = gk_find_dc(prior, name); // else prior measured + if(!row && is_forced_dummy) row = gk_find_dc(c->ping_results, name); // else the forced dummy if(row) json_object_array_add(out, cc_json_clone(row)); else // else 0 placeholder From 3dba3a917644f1287bbaf8b8ed81530aa38e9aec Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 19:29:31 -0700 Subject: [PATCH 62/72] cloudsession (ios): map ACCOUNT_PRIVACY_SETTINGS sentinel (safe, untested path) Closes the cross-platform gap the final review flagged: iOS previously let the C flow's "ACCOUNT_PRIVACY_SETTINGS:" sentinel fall through to a generic "allocation failed". Now it maps to a new AccountPrivacySettingsError with an actionable message (+ the upgrade URL when present). Made deliberately safe since we can't easily trigger it live: - URL parsing is total -- a missing/garbage sentinel degrades to an empty string, and the message drops the URL line gracefully (no force-unwrap, no crash). - The new error is a LocalizedError that surfaces through CloudPlayView's existing generic catch -> "Cloud Streaming Error" alert, exactly like AuthorizationFailed/ GaikaiAllocation errors already do. No new dialog/UI to get wrong. iOS BUILD SUCCEEDED. (Qt/Android already handle this sentinel with a dialog.) Co-Authored-By: Claude Opus 4.8 --- ios/Pylux/Models/CloudModels.swift | 12 ++++++++++++ ios/Pylux/Services/CloudStreamingBackend.swift | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index f13862e0..106013c3 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -109,6 +109,18 @@ struct AuthorizationFailedError: Error, LocalizedError { var errorDescription: String? { message } } +/// PSN account privacy settings need updating before cloud streaming (the C flow's +/// "ACCOUNT_PRIVACY_SETTINGS:" sentinel). iOS has no dedicated dialog for this, +/// so it surfaces through CloudPlayView's generic error alert. `upgradeUrl` may be +/// empty -- the message degrades gracefully when no URL is available. +struct AccountPrivacySettingsError: Error, LocalizedError { + let upgradeUrl: String + var errorDescription: String? { + let base = "Your PlayStation account privacy settings need updating before you can use cloud streaming. Update them in your PSN account settings, then try again." + return upgradeUrl.isEmpty ? base : base + "\n\n" + upgradeUrl + } +} + /// General Gaikai allocation error struct GaikaiAllocationError: Error, LocalizedError { let message: String diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index e052e021..6fba1a5a 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -139,6 +139,17 @@ final class CloudStreamingBackend { throw AuthorizationFailedError(message: "Your NPSSO token is likely expired. Please re-login.") } else if msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") { throw PsPlusSubscriptionError(message: "PS Plus subscription required") + } else if msg.contains("ACCOUNT_PRIVACY_SETTINGS") { + // Sentinel is "ACCOUNT_PRIVACY_SETTINGS:" (URL may be absent). + // Parse defensively -- any missing/garbage URL degrades to an empty string, + // and the error surfaces through CloudPlayView's generic catch -> alert + // (no dedicated dialog needed). This path is untested live; keep it total. + let prefix = "ACCOUNT_PRIVACY_SETTINGS:" + var upgradeUrl = "" + if let r = msg.range(of: prefix) { + upgradeUrl = String(msg[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + throw AccountPrivacySettingsError(upgradeUrl: upgradeUrl) } else if msg.contains("PING_TIMEOUT") { throw PingTimeoutError() } else { From dbde9065cf1620fd4a5f9e2f0aed6f79e1d15745 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 19:44:30 -0700 Subject: [PATCH 63/72] cloudsession: address final-review nits (OOM header guard + dead code/comment) Final fresh-eyes review verdict was GO-WITH-NITS; clearing the actionable ones: MINOR - cloudsession_kamaji.c: the three cc_http_make_bearer_header() call sites ignored the return, so on malloc failure h_auth stayed NULL and was passed to curl_slist_append (UB). Only reachable under OOM (the token is non-NULL after a successful km_get_commerce_token), but now guarded -- free + return CHIAKI_ERR_MEMORY so the path is total. NIT - delete the now-unreferenced iOS KamajiSessionError; refresh the Android CloudStreamingBackend doc-comment that still described the deleted PSKamajiSession/ PSGaikaiStreaming delegated architecture (Qt/iOS were already refreshed). Left as known non-blocking nits: the unused rtt_safety_offset_ms/cache_dir config fields and the strcasestr portability note. All three build; lib 108/108. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/api/CloudStreamingBackend.kt | 14 +++++++------- ios/Pylux/Models/CloudModels.swift | 6 ------ lib/src/cloudsession_kamaji.c | 3 +++ 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 4a369e13..49ecc136 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -14,15 +14,15 @@ import kotlinx.coroutines.withContext * * This class is the main entry point for cloud gaming. It: * - Holds shared configuration (CloudConfig namespace) - * - Orchestrates Kamaji authentication (PSKamajiSession) - * - Orchestrates Gaikai allocation (PSGaikaiStreaming) + * - Runs the whole provisioning flow (auth check, Kamaji resolve, Gaikai + * allocation, datacenter ping/select) in libchiaki via + * ChiakiNative.cloudProvisionSession, off the main thread * - Provides a single unified API for the frontend - * + * * Architecture: - * CloudStreamingBackend (orchestrator) - * └─> PSKamajiSession (Steps 1-6: Kamaji auth) - * └─> PSGaikaiStreaming (Steps 7-13: Gaikai allocation) - * + * CloudStreamingBackend (thin Kotlin wrapper) + * └─> libchiaki chiaki_cloud_provision_session (the unified C flow) + * * Mirrors: gui/src/cloudstreamingbackend.cpp */ class CloudStreamingBackend( diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 106013c3..69804466 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -127,12 +127,6 @@ struct GaikaiAllocationError: Error, LocalizedError { var errorDescription: String? { message } } -/// Kamaji session error -struct KamajiSessionError: Error, LocalizedError { - let message: String - var errorDescription: String? { message } -} - // Region-group / Classics-container logic now lives in libchiaki (lib/src/cloudcatalog_consts.c) // and is reflected back to the client via the unified catalog's "fallbackRegion" field. diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 280aaa49..71ca0a21 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -414,6 +414,7 @@ static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_erro "\"PRIVACY_SETTING_RECOMMENDUSERS\",\"PRIVACY_SETTING_BROADCAST\"]}"; char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + if(!h_auth || !h_ua) { free(h_auth); free(h_ua); return CHIAKI_ERR_MEMORY; } // OOM guard (else NULL header) const char *hdrs[] = { h_auth, h_ua, "Accept: application/json", "Content-Type: application/json" }; CCHttpRequest req = { 0 }; req.method = "POST"; req.url = "https://accounts.api.playstation.com/api/v2/accounts/me/attributes"; @@ -443,6 +444,7 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); char *h_cookie = NULL; if(c->jsessionid) cc_http_make_cookie_header(&h_cookie, "JSESSIONID", c->jsessionid); + if(!h_auth || !h_ua) { free(h_auth); free(h_ua); free(h_cookie); return CHIAKI_ERR_MEMORY; } // OOM guard // --- preview: confirm $0 then take the authoritative sku --- char prev_body[256]; @@ -531,6 +533,7 @@ static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) "internal_entitlements/%s?fields=game_meta", c->entitlement_id); char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); + if(!h_auth || !h_ua) { free(h_auth); free(h_ua); return CHIAKI_ERR_MEMORY; } // OOM guard (else NULL header) const char *hdrs[] = { h_auth, h_ua, "Accept: application/json" }; CCHttpRequest req = { 0 }; req.url = url; req.headers = hdrs; req.header_count = 3; From 0f18eaadfcb2b6ca63212882291d24ff23782946 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 21:31:55 -0700 Subject: [PATCH 64/72] cloudsession: address 3-agent review nits (OOM guards, dead field, clarity) Three independent review passes (parity / bug-hunt / code-quality) all returned merge-worthy with zero blockers or majors. Clearing the clear, low-risk items: - gaikai parallel-ping: guard the calloc(jobs/threads/threaded) + the sort malloc against OOM (was an unchecked-allocation segfault, OOM-only) -- bail to CHIAKI_ERR_MEMORY / skip the sort rather than crash. - Remove the dead rtt_safety_offset_ms config field (set by no platform, read by no C code; the old per-platform code never applied an RTT offset either -- all three reviewers flagged it). - Drop a misleading `(void)c;` in gk_build_spec (c is used right below it). - iOS bridge: `?: @""` on the stringWithUTF8String result fields (nonnull copy props would otherwise store nil on non-UTF-8 bytes; server data is ASCII). - Comment the fixed allocate-body telemetry timings and the GAME_NOT_FREE sentinel. Left intentionally: the Kamaji "1,2,3,5,6" step numbering (faithful to the original, which skipped "Step 4"); the larger constant/helper de-dup is a maintainability fast-follow. All three platforms build + lib 108/108. Co-Authored-By: Claude Opus 4.8 --- ios/Pylux/Bridge/CloudProvisionBridge.m | 14 +++++++------ lib/include/chiaki/cloudsession.h | 1 - lib/src/cloudsession_gaikai.c | 28 +++++++++++++++++-------- lib/src/cloudsession_kamaji.c | 3 +++ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.m b/ios/Pylux/Bridge/CloudProvisionBridge.m index e845eb20..62cd23b5 100644 --- a/ios/Pylux/Bridge/CloudProvisionBridge.m +++ b/ios/Pylux/Bridge/CloudProvisionBridge.m @@ -95,13 +95,15 @@ + (PyluxCloudProvisionResult *)provisionWithServiceType:(NSString *)serviceType PyluxCloudProvisionResult *out = [PyluxCloudProvisionResult new]; out.err = (int)err; - out.serverIp = res.server_ip[0] ? [NSString stringWithUTF8String:res.server_ip] : @""; + // `?: @""` guards the nonnull copy properties: stringWithUTF8String returns nil on + // non-UTF-8 bytes (server data is ASCII, so this is belt-and-suspenders). + out.serverIp = res.server_ip[0] ? ([NSString stringWithUTF8String:res.server_ip] ?: @"") : @""; out.serverPort = res.server_port; - out.handshakeKey = res.handshake_key ? [NSString stringWithUTF8String:res.handshake_key] : @""; - out.launchSpec = res.launch_spec ? [NSString stringWithUTF8String:res.launch_spec] : @""; - out.sessionId = res.session_id ? [NSString stringWithUTF8String:res.session_id] : @""; - out.entitlementId = res.entitlement_id[0] ? [NSString stringWithUTF8String:res.entitlement_id] : @""; - out.platform = res.platform[0] ? [NSString stringWithUTF8String:res.platform] : @""; + out.handshakeKey = res.handshake_key ? ([NSString stringWithUTF8String:res.handshake_key] ?: @"") : @""; + out.launchSpec = res.launch_spec ? ([NSString stringWithUTF8String:res.launch_spec] ?: @"") : @""; + out.sessionId = res.session_id ? ([NSString stringWithUTF8String:res.session_id] ?: @"") : @""; + out.entitlementId = res.entitlement_id[0] ? ([NSString stringWithUTF8String:res.entitlement_id] ?: @"") : @""; + out.platform = res.platform[0] ? ([NSString stringWithUTF8String:res.platform] ?: @"") : @""; out.psnWrapperType = res.psn_wrapper_type; out.mtuIn = (int)res.mtu_in; out.mtuOut = (int)res.mtu_out; diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index 172da85b..e1173f59 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -52,7 +52,6 @@ typedef struct chiaki_cloud_provision_config_t const char *game_language; /**< streaming-language locale (e.g. "de-DE"); bare code derived */ int resolution; /**< 720|1080|1440|2160 (platform picks the per-service value) */ int bitrate_kbps; /**< cloud stream bitrate (platform picks the per-service value) */ - int rtt_safety_offset_ms; /**< cloud-only RTT offset (e.g. -20); Remote Play unaffected */ /** Progress callback: @p stage is a UI-ready string shown verbatim. May be NULL. */ void (*progress)(const char *stage, void *user); diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 0bf8f78f..78da07e3 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -346,7 +346,6 @@ static struct json_object *gk_build_spec(GaikaiCtx *c, const char *entitlement_i #undef S_STR #undef S_INT #undef S_BOOL - (void)c; return s; } @@ -715,6 +714,12 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) GkPingJob *jobs = (GkPingJob *)calloc(n, sizeof(GkPingJob)); ChiakiThread *threads = (ChiakiThread *)calloc(n, sizeof(ChiakiThread)); bool *threaded = (bool *)calloc(n, sizeof(bool)); // which slots actually started a thread + if(!jobs || !threads || !threaded) + { + free(jobs); free(threads); free(threaded); + json_object_put(dcs); + return CHIAKI_ERR_MEMORY; + } for(size_t i = 0; i < n; i++) { struct json_object *dc = json_object_array_get_idx(dcs, i); @@ -745,16 +750,19 @@ static ChiakiErrorCode gk_step11_datacenters(GaikaiCtx *c) CHIAKI_LOGI(c->log, "[GAIKAI] ping %s = unreachable", jobs[i].name); } free(jobs); free(threads); free(threaded); - // sort by RTT + // sort by RTT (skip the sort on OOM rather than crash -- leaves API order) size_t rn = json_object_array_length(c->ping_results); struct json_object **arr = (struct json_object **)malloc(rn * sizeof(*arr)); - for(size_t i = 0; i < rn; i++) arr[i] = json_object_get(json_object_array_get_idx(c->ping_results, i)); - qsort(arr, rn, sizeof(*arr), gk_cmp_rtt); - struct json_object *sorted = json_object_new_array(); - for(size_t i = 0; i < rn; i++) json_object_array_add(sorted, arr[i]); - free(arr); - json_object_put(c->ping_results); - c->ping_results = sorted; + if(arr) + { + for(size_t i = 0; i < rn; i++) arr[i] = json_object_get(json_object_array_get_idx(c->ping_results, i)); + qsort(arr, rn, sizeof(*arr), gk_cmp_rtt); + struct json_object *sorted = json_object_new_array(); + for(size_t i = 0; i < rn; i++) json_object_array_add(sorted, arr[i]); + free(arr); + json_object_put(c->ping_results); + c->ping_results = sorted; + } } // Full datacenter list for the Settings picker (merged with prior stored RTTs). c->dc_picker = gk_build_picker(c, dcs); @@ -841,6 +849,8 @@ static ChiakiErrorCode gk_step13_allocate(GaikaiCtx *c, ChiakiCloudProvisionResu json_object_object_add(net, "bwLossUpstream", json_object_new_int(0)); json_object_object_add(net, "mtuUpstream", json_object_new_int(mtu_out)); json_object_object_add(extra, "network", net); + // Fixed client-telemetry timings the allocate body schema expects (sampled from the + // PS Portal client); the server records but doesn't act on them, so they're constant. json_object_object_add(extra, "stateExecutionTime", json_object_new_double(5974.7632)); json_object_object_add(extra, "streamTestTime", json_object_new_double(11262.8423)); diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 71ca0a21..5b702d14 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -479,6 +479,9 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) if(total != 0) { CHIAKI_LOGE(c->log, "[KAMAJI] title is not free (price value %d)", total); + // Defensive: the catalog only offers $0 PS+ titles for the acquire path, so this + // should be unreachable. No dedicated UI -- platforms surface it via the generic + // allocation-error path, which is fine for an unexpected paid SKU. if(out_error) *out_error = strdup("GAME_NOT_FREE"); if(pj) json_object_put(pj); free(h_auth); free(h_ua); free(h_cookie); cc_http_response_fini(&presp); From 0ab45177c86fcc4b0b46fe0090698dc596e49a47 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 22:50:03 -0700 Subject: [PATCH 65/72] cloudsession: consecutive Kamaji step numbers + de-dup PSNOW constants Renumber the Kamaji progress so the visible sequence is consecutive -- "Step 5/6 of 6" -> "Step 4/5 of 5" (the 3->5 jump from the silent commerce-token step looked like a counting bug). Wording unchanged, numbers only. DRY: the PSNOW client id / redirect URI / User-Agent were defined identically in three cloudsession units (CA_PSNOW_*, KM_*, GK_*). Verified byte-identical and that each macro is used only in its own file, then aliased them to single CS_PSNOW_* definitions in cloudsession_internal.h (which all three include) -- one source of truth, no usage changes. The catalog module keeps its own copy (separate module). Live-verified end-to-end (Child of Light provisions, err=0) so the consolidated constants still drive the real OAuth/authorizeCheck/Gaikai flow; all three platforms build; lib 108/108. Co-Authored-By: Claude Opus 4.8 --- lib/src/cloudsession.c | 6 +++--- lib/src/cloudsession_gaikai.c | 4 ++-- lib/src/cloudsession_internal.h | 7 +++++++ lib/src/cloudsession_kamaji.c | 20 ++++++++++---------- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/src/cloudsession.c b/lib/src/cloudsession.c index 76c6a454..f2b31167 100644 --- a/lib/src/cloudsession.c +++ b/lib/src/cloudsession.c @@ -54,10 +54,10 @@ CHIAKI_EXPORT void chiaki_cloud_provision_result_fini(ChiakiCloudProvisionResult // cloudsession_kamaji.c. The pscloud client id is the fixed pre-flight id the // platforms used (distinct from the step0-fetched streaming client id). #define CA_URL "https://ca.account.sony.com/api/authz/v3/oauth/authorizeCheck" -#define CA_PSNOW_CLIENT "bc6b0777-abb5-40da-92ca-e133cf18e989" +#define CA_PSNOW_CLIENT CS_PSNOW_CLIENT_ID // shared (cloudsession_internal.h) #define CA_PSNOW_SCOPE "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" -#define CA_PSNOW_REDIR "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" -#define CA_PSNOW_UA "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +#define CA_PSNOW_REDIR CS_PSNOW_REDIRECT +#define CA_PSNOW_UA CS_PSNOW_USER_AGENT #define CA_PSCLOUD_CLIENT "19ae39c4-3f88-4d11-a792-94e4f52c996d" #define CA_PSCLOUD_SCOPE "id_token:psn.basic_claims kamaji:s2s.subscriptionsPremium.get id_token:duid id_token:online_id openid psn:s2s" #define CA_PSCLOUD_REDIR "gaikai://local" diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 78da07e3..51a7ac64 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -26,8 +26,8 @@ #define GK_CONFIG_BASE "https://config.cc.prod.gaikai.com/v1" #define ACCOUNT_BASE "https://ca.account.sony.com" #define GK_UA_PSCLOUD "PlayStation Portal/6.0.0-rel.444+6a9cea6f5" -#define GK_UA_PSNOW "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" -#define GK_REDIR_PSNOW "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" +#define GK_UA_PSNOW CS_PSNOW_USER_AGENT // shared (cloudsession_internal.h) +#define GK_REDIR_PSNOW CS_PSNOW_REDIRECT #define GK_REDIR_PSCLOUD "gaikai://local" #define MAX_LOCK_RETRIES 12 #define DEFAULT_ALLOC_WAIT_S 300 diff --git a/lib/src/cloudsession_internal.h b/lib/src/cloudsession_internal.h index d686484c..e518c392 100644 --- a/lib/src/cloudsession_internal.h +++ b/lib/src/cloudsession_internal.h @@ -20,6 +20,13 @@ extern "C" { struct json_object; // json-c, forward-declared so this header needs no umbrella include +// Shared PSNOW/Kamaji OAuth constants -- single source of truth for the cloudsession +// units (each file aliases its local macro to these). Protocol-stable Sony values; the +// catalog module (cloudcatalog_fetch.c) keeps its own copy as it's a separate module. +#define CS_PSNOW_CLIENT_ID "bc6b0777-abb5-40da-92ca-e133cf18e989" +#define CS_PSNOW_REDIRECT "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" +#define CS_PSNOW_USER_AGENT "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" + /** * Pure picker for the PS Plus full-game ("*GD") fallback that step 0.5d uses when a * title exposes no license_type==4 streaming reservation. Scans @p sku's diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 5b702d14..4be4e2b7 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -23,10 +23,10 @@ #define KM_ACCOUNT_BASE "https://ca.account.sony.com/api" #define KM_KAMAJI_BASE "https://psnow.playstation.com/kamaji/api/pcnow/00_09_000" -#define KM_CLIENT_ID "bc6b0777-abb5-40da-92ca-e133cf18e989" +#define KM_CLIENT_ID CS_PSNOW_CLIENT_ID // shared (cloudsession_internal.h) #define KM_COMMERCE_CLIENT_ID "dc523cc2-b51b-4190-bff0-3397c06871b3" -#define KM_REDIRECT_URI "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html" -#define KM_USER_AGENT "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" +#define KM_REDIRECT_URI CS_PSNOW_REDIRECT +#define KM_USER_AGENT CS_PSNOW_USER_AGENT // URL-encoded (these are spliced into OAuth query strings via %s, not re-encoded). #define KM_PS3_SCOPES "kamaji:commerce_native" #define KM_PS4_SCOPES "kamaji:commerce_native%20kamaji:commerce_container%20kamaji:lists%20kamaji:s2s.subscriptionsPremium.get" @@ -169,7 +169,7 @@ static ChiakiErrorCode km_post_session(KamajiCtx *c, const char *code, bool capt static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) { - if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 5", c->cfg->user); char url[2048]; snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" @@ -181,7 +181,7 @@ static ChiakiErrorCode km_step0_5b_anon_authcode(KamajiCtx *c, char **out_code) static ChiakiErrorCode km_step0_5c_anon_session(KamajiCtx *c, const char *anon_code) { - if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Cloud Auth - Step 1 of 5", c->cfg->user); CCHttpResponse resp = { 0 }; ChiakiErrorCode e = km_post_session(c, anon_code, true, &resp); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } @@ -252,7 +252,7 @@ static bool km_pick_fullgame(KamajiCtx *c, struct json_object *sku, bool require static ChiakiErrorCode km_step0_5d_resolve(KamajiCtx *c) { - if(c->cfg->progress) c->cfg->progress("Resolving Game - Step 2 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Resolving Game - Step 2 of 5", c->cfg->user); const char *country = (c->cfg->store_country && *c->cfg->store_country) ? c->cfg->store_country : "US"; const char *lang = (c->cfg->store_lang && *c->cfg->store_lang) ? c->cfg->store_lang : "en"; char url[512]; @@ -439,7 +439,7 @@ static ChiakiErrorCode km_check_account_attributes(KamajiCtx *c, char **out_erro static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) { - if(c->cfg->progress) c->cfg->progress("Acquiring License - Step 3 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Acquiring License - Step 3 of 5", c->cfg->user); char *h_auth = NULL; cc_http_make_bearer_header(&h_auth, c->commerce_token); char *h_ua = km_hdr("User-Agent", KM_USER_AGENT); char *h_cookie = NULL; @@ -525,7 +525,7 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) { - if(c->cfg->progress) c->cfg->progress("Checking License - Step 3 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Checking License - Step 3 of 5", c->cfg->user); ChiakiErrorCode e = km_get_commerce_token(c); if(e != CHIAKI_ERR_SUCCESS) return e; e = km_check_account_attributes(c, out_error); @@ -563,7 +563,7 @@ static ChiakiErrorCode km_step0_5e_check_acquire(KamajiCtx *c, char **out_error) static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) { - if(c->cfg->progress) c->cfg->progress("Authorizing - Step 5 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Authorizing - Step 4 of 5", c->cfg->user); char url[2048]; snprintf(url, sizeof(url), KM_ACCOUNT_BASE "/v1/oauth/authorize?smcid=pc:psnow&applicationId=psnow" "&response_type=code&scope=%s&client_id=%s&redirect_uri=%s&service_entity=urn:service-entity:psn" @@ -574,7 +574,7 @@ static ChiakiErrorCode km_step5_authcode(KamajiCtx *c, char **out_code) static ChiakiErrorCode km_step6_auth_session(KamajiCtx *c, const char *auth_code) { - if(c->cfg->progress) c->cfg->progress("Creating Session - Step 6 of 6", c->cfg->user); + if(c->cfg->progress) c->cfg->progress("Creating Session - Step 5 of 5", c->cfg->user); CCHttpResponse resp = { 0 }; ChiakiErrorCode e = km_post_session(c, auth_code, false, &resp); if(e != CHIAKI_ERR_SUCCESS) { cc_http_response_fini(&resp); return e; } From db56da12ca93b8b9fc13ab0fb3327aeb24f07573 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 23:16:20 -0700 Subject: [PATCH 66/72] cloudsession: clarify game_name is logging-only (doc comment) Document that cfg->game_name is never read by the provisioning flow (logging / result echo only), and that the Rich-Presence/window-title game name is a separate StreamSessionConnectInfo.game_name the cloud path has never wired up (pre-existing gap, not introduced here). Co-Authored-By: Claude Opus 4.8 --- lib/include/chiaki/cloudsession.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index e1173f59..141aae9a 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -36,7 +36,10 @@ typedef struct chiaki_cloud_provision_config_t { const char *service_type; /**< "psnow" (PS3/PS4) | "pscloud" (PS5) */ const char *game_identifier; /**< productId (psnow) or entitlementId (pscloud) */ - const char *game_name; /**< display name (logging / result echo) */ + const char *game_name; /**< display name, logging only -- the provisioning flow + * never reads it. (The Rich-Presence/window-title game + * name is a separate StreamSessionConnectInfo.game_name, + * which the cloud path has never wired up -- pre-existing.) */ const char *npsso; /**< cookie value only (no "npsso=" prefix) */ const char *store_country; /**< resolvedStoreCountry for the step0_5d container URL */ const char *store_lang; /**< resolvedStoreLang for the step0_5d container URL */ From f2a89c198bc48763ea2637638fc9bbfb51ee63dc Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 23:40:16 -0700 Subject: [PATCH 67/72] cloudsession: handle GAME_NOT_FREE properly on all platforms (was generic error) The stale-catalog case is real and reachable: when the cached game list is old and a title that was a free PS+ offer starts costing money, the $0 acquire's checkout preview returns a non-zero price. The old Qt code showed "Game is not free (price: $X)"; the new flow emitted a bare GAME_NOT_FREE that NO platform mapped, so it fell through to a generic "allocation failed". Regression. Fix: carry the display price in the sentinel ("GAME_NOT_FREE:") and map it on all three to a clear message telling the user the title is no longer free and to refresh their game list -- Qt handleProvisionError, iOS GameNotFreeError (generic alert), Android GameNotFreeException (generic showError). All three build; 108/108. Co-Authored-By: Claude Opus 4.8 --- .../cloudplay/api/CloudStreamingBackend.kt | 4 ++++ .../cloudplay/api/CloudStreamingExceptions.kt | 9 +++++++-- gui/src/cloudstreamingbackend.cpp | 10 ++++++++++ ios/Pylux/Models/CloudModels.swift | 11 +++++++++++ ios/Pylux/Services/CloudStreamingBackend.swift | 8 ++++++++ lib/src/cloudsession_kamaji.c | 16 +++++++++++----- 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 49ecc136..139d9af7 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -194,6 +194,10 @@ class CloudStreamingBackend( AuthorizationFailedException("Your NPSSO token is likely expired. Please re-login to continue using cloud streaming.") msg.contains("PS_PLUS_SUBSCRIPTION_REQUIRED") -> PsPlusSubscriptionException("PS Plus subscription required") + msg.startsWith("GAME_NOT_FREE") -> + // Stale catalog: a free PS+ title now costs money. Sentinel is + // "GAME_NOT_FREE:" (price may be empty). + GameNotFreeException(msg.substringAfter("GAME_NOT_FREE:", "")) msg.startsWith("ACCOUNT_PRIVACY_SETTINGS") -> AccountPrivacySettingsException(msg.substringAfter("ACCOUNT_PRIVACY_SETTINGS:", ""), "Account privacy settings need updating") diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt index 409335dd..b68c9b2c 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingExceptions.kt @@ -3,13 +3,18 @@ package com.metallic.chiaki.cloudplay.api /** - * Custom exceptions for cloud streaming errors - * Mirrors error handling in gui/src/cloudstreaming/psgaikaistreaming.cpp + * Custom exceptions for cloud streaming errors -- mapped from the libchiaki + * provisioning error sentinels (chiaki_cloud_provision_session). */ /** PS Plus subscription required error (eventCode 002.2001) */ class PsPlusSubscriptionException(message: String) : Exception(message) +/** A cached free PS+ title now costs money (stale catalog); price may be empty. */ +class GameNotFreeException(val price: String) : Exception( + if (price.isBlank()) "This game is no longer free to stream. Your game list may be out of date — refresh it and try again." + else "This game is no longer free to stream (price: $price). Your game list may be out of date — refresh it and try again.") + /** Account privacy settings need to be updated */ class AccountPrivacySettingsException(val upgradeUrl: String, message: String) : Exception(message) diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index c93deaa1..02807d5c 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -373,6 +373,16 @@ void CloudStreamingBackend::handleProvisionError(QString serviceType, QString er } else if (errorMessage.contains(QStringLiteral("PING_TIMEOUT"))) { if (qmlBackend) qmlBackend->setShowPingTimeoutDialog(true); userMessage = tr("Ping must be < 80ms to start a cloud session"); + } else if (errorMessage.contains(QStringLiteral("GAME_NOT_FREE"))) { + // Stale catalog: a title that was a free PS+ offer now costs money. Sentinel is + // "GAME_NOT_FREE:" (price may be empty). Tell the user to refresh. + const QString prefix = QStringLiteral("GAME_NOT_FREE:"); + QString price; + int idx = errorMessage.indexOf(prefix); + if (idx >= 0) price = errorMessage.mid(idx + prefix.length()).trimmed(); + userMessage = price.isEmpty() + ? tr("This game is no longer free to stream. Your game list may be out of date — refresh it and try again.") + : tr("This game is no longer free to stream (price: %1). Your game list may be out of date — refresh it and try again.").arg(price); } else { userMessage = errorMessage.isEmpty() ? tr("Allocation failed") : QString("Allocation failed: %1").arg(errorMessage); diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 69804466..29ca9415 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -127,6 +127,17 @@ struct GaikaiAllocationError: Error, LocalizedError { var errorDescription: String? { message } } +/// A cached free PS+ title now costs money (stale catalog). `price` may be empty. +/// Surfaces through CloudPlayView's generic error alert. +struct GameNotFreeError: Error, LocalizedError { + let price: String + var errorDescription: String? { + let base = "This game is no longer free to stream. Your game list may be out of date — refresh it and try again." + return price.isEmpty ? base + : "This game is no longer free to stream (price: \(price)). Your game list may be out of date — refresh it and try again." + } +} + // Region-group / Classics-container logic now lives in libchiaki (lib/src/cloudcatalog_consts.c) // and is reflected back to the client via the unified catalog's "fallbackRegion" field. diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index 6fba1a5a..567b3e7f 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -150,6 +150,14 @@ final class CloudStreamingBackend { upgradeUrl = String(msg[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) } throw AccountPrivacySettingsError(upgradeUrl: upgradeUrl) + } else if msg.contains("GAME_NOT_FREE") { + // Stale catalog: a free PS+ title now costs money. Sentinel is + // "GAME_NOT_FREE:" (price may be empty). Parse defensively. + var price = "" + if let r = msg.range(of: "GAME_NOT_FREE:") { + price = String(msg[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + throw GameNotFreeError(price: price) } else if msg.contains("PING_TIMEOUT") { throw PingTimeoutError() } else { diff --git a/lib/src/cloudsession_kamaji.c b/lib/src/cloudsession_kamaji.c index 4be4e2b7..b90c226f 100644 --- a/lib/src/cloudsession_kamaji.c +++ b/lib/src/cloudsession_kamaji.c @@ -478,11 +478,17 @@ static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) int total = cart ? cc_json_int(cart, "total_price_value") : -1; if(total != 0) { - CHIAKI_LOGE(c->log, "[KAMAJI] title is not free (price value %d)", total); - // Defensive: the catalog only offers $0 PS+ titles for the acquire path, so this - // should be unreachable. No dedicated UI -- platforms surface it via the generic - // allocation-error path, which is fine for an unexpected paid SKU. - if(out_error) *out_error = strdup("GAME_NOT_FREE"); + const char *price = cart ? cc_json_str(cart, "total_price") : ""; + CHIAKI_LOGE(c->log, "[KAMAJI] title is not free (price %s / value %d)", price, total); + // Reachable when the cached catalog is stale: a title that was a free PS+ offer + // now costs money. Carry the display price in the sentinel so the UI can tell the + // user the title is no longer free (and to refresh their game list). + if(out_error) + { + char sentinel[160]; + snprintf(sentinel, sizeof(sentinel), "GAME_NOT_FREE:%s", price); + *out_error = strdup(sentinel); + } if(pj) json_object_put(pj); free(h_auth); free(h_ua); free(h_cookie); cc_http_response_fini(&presp); return CHIAKI_ERR_UNKNOWN; From f8bfe8e49f7f595f4a5b57f0a806bfce66d93a44 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 23:40:16 -0700 Subject: [PATCH 68/72] cloudsession: remove the unused chiaki_cloud_ping_datacenters stub It was a planned standalone "ping datacenters for the Settings refresh button" entry point, but it was never implemented (always returned CHIAKI_ERR_UNKNOWN) and no platform ever called it -- per-region latency comes back as result.datacenter_pings from a normal provision. Drop the exported declaration, the stub body, and the probe's "ping" mode (its only caller) rather than ship a permanently-failing public function. Co-Authored-By: Claude Opus 4.8 --- lib/include/chiaki/cloudsession.h | 11 ----------- lib/src/cloudsession.c | 16 ---------------- lib/test_cloudsession/cloudsession-probe.c | 11 +---------- 3 files changed, 1 insertion(+), 37 deletions(-) diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index 141aae9a..0c6c1d6c 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -99,17 +99,6 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( /** Release the heap-owned fields of a result populated by the call above. */ CHIAKI_EXPORT void chiaki_cloud_provision_result_fini(ChiakiCloudProvisionResult *out); -/** - * Ping the account's reachable datacenters and return per-region latency for - * the Settings/overlay UI, without starting a stream. @p out_pings_json is a - * heap-owned JSON array [{"dataCenter":...,"rtt_ms":...}, ...]; free() it. - * Uses the same senkusha-based ping as the provisioning flow. - */ -CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_ping_datacenters( - const ChiakiCloudProvisionConfig *cfg, - char **out_pings_json, - ChiakiLog *log); - #ifdef __cplusplus } #endif diff --git a/lib/src/cloudsession.c b/lib/src/cloudsession.c index f2b31167..30e550b9 100644 --- a/lib/src/cloudsession.c +++ b/lib/src/cloudsession.c @@ -189,19 +189,3 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( out->err = e; return e; } - -CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_ping_datacenters( - const ChiakiCloudProvisionConfig *cfg, - char **out_pings_json, - ChiakiLog *log) -{ - if(!cfg || !out_pings_json) - return CHIAKI_ERR_INVALID_DATA; - *out_pings_json = NULL; - // The datacenter list is only available inside an authenticated Gaikai - // session (step11), so per-region latency comes back as result.datacenter_pings - // from chiaki_cloud_provision_session. A standalone ping-only path (auth -> - // step11 -> ping -> stop) can be added when the Settings refresh button needs it. - CHIAKI_LOGW(log, "[CLOUDSESSION] standalone datacenter ping not wired; use provision result.datacenter_pings"); - return CHIAKI_ERR_UNKNOWN; -} diff --git a/lib/test_cloudsession/cloudsession-probe.c b/lib/test_cloudsession/cloudsession-probe.c index 32053f4b..dca89428 100644 --- a/lib/test_cloudsession/cloudsession-probe.c +++ b/lib/test_cloudsession/cloudsession-probe.c @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL // // Dev harness for the unified cloud-provisioning flow (not built on device). -// NPSSO=... cloudsession-probe [resolve|provision|ping] +// NPSSO=... cloudsession-probe [resolve|provision] // Env overrides (drive every path): // SERVICE=psnow|pscloud STORE_CC=US STORE_LANG=en // OWNED_ENT= OWNED_PLAT=ps4 (owned fast-path / one-shot fallback) @@ -56,15 +56,6 @@ int main(int argc, char **argv) cfg.owned_entitlement_id[0] ? cfg.owned_entitlement_id : "(none)", cfg.forced_datacenter[0] ? cfg.forced_datacenter : "(auto)", cfg.catalog_is_foreign); - if(strcmp(mode, "ping") == 0) - { - char *pings = NULL; - ChiakiErrorCode e = chiaki_cloud_ping_datacenters(&cfg, &pings, &log); - printf("== PING err=%d pings=%s\n", e, pings ? pings : "(none)"); - free(pings); - return e == CHIAKI_ERR_SUCCESS ? 0 : 1; - } - if(strcmp(mode, "provision") == 0) { ChiakiCloudProvisionResult out; From 4ee4c4540ec066e49f5ff2dfc442d96b5b3065ca Mon Sep 17 00:00:00 2001 From: forward technologies Date: Mon, 29 Jun 2026 23:51:25 -0700 Subject: [PATCH 69/72] cloudsession: remove the unused cache_dir config field Like rtt_safety_offset_ms, the provisioning config's cache_dir was never read by any cloudsession*.c code -- all three platforms just set it to "". Drop the field and the three setters. (The catalog config's own cache_dir is a separate struct and is untouched.) All three build; lib 108/108. Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/cpp/chiaki-jni.c | 1 - gui/src/cloudstreamingbackend.cpp | 1 - ios/Pylux/Bridge/CloudProvisionBridge.m | 1 - lib/include/chiaki/cloudsession.h | 1 - 4 files changed, 4 deletions(-) diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index f7a38a94..fce1e180 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -1708,7 +1708,6 @@ JNIEXPORT jint JNICALL JNI_FCN(cloudProvisionSession)(JNIEnv *env, jobject obj, cfg.owned_platform = owned_platform; cfg.forced_datacenter = forced_dc; cfg.prior_datacenters_json = prior_dc; - cfg.cache_dir = ""; cfg.catalog_is_foreign = catalog_is_foreign ? true : false; cfg.skip_account_attr_check = false; cfg.resolution = resolution; diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 02807d5c..97c3ea08 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -156,7 +156,6 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q cfg.skip_account_attr_check = attrPassed; cfg.forced_datacenter = forcedDc.constData(); cfg.prior_datacenters_json = priorDc.constData(); - cfg.cache_dir = ""; cfg.resolution = resolution; cfg.bitrate_kbps = bitrate; cfg.progress = &CloudStreamingBackend::provisionProgressThunk; diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.m b/ios/Pylux/Bridge/CloudProvisionBridge.m index 62cd23b5..ddf3d8e7 100644 --- a/ios/Pylux/Bridge/CloudProvisionBridge.m +++ b/ios/Pylux/Bridge/CloudProvisionBridge.m @@ -80,7 +80,6 @@ + (PyluxCloudProvisionResult *)provisionWithServiceType:(NSString *)serviceType cfg.owned_platform = ownedPlatform.UTF8String; cfg.forced_datacenter = forcedDatacenter.UTF8String; cfg.prior_datacenters_json = priorDatacentersJson.UTF8String; - cfg.cache_dir = ""; cfg.catalog_is_foreign = catalogIsForeign ? true : false; cfg.skip_account_attr_check = false; // iOS has no "ignore forever" flag cfg.resolution = resolution; diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h index 0c6c1d6c..c9338dec 100644 --- a/lib/include/chiaki/cloudsession.h +++ b/lib/include/chiaki/cloudsession.h @@ -51,7 +51,6 @@ typedef struct chiaki_cloud_provision_config_t const char *prior_datacenters_json; /**< platform's stored datacenters for this service; merged with this run's pings into result.datacenter_pings (keeps previously-measured RTTs). May be NULL/"". */ - const char *cache_dir; /**< lib-owned datacenter-ping cache lives here; may be "" */ const char *game_language; /**< streaming-language locale (e.g. "de-DE"); bare code derived */ int resolution; /**< 720|1080|1440|2160 (platform picks the per-service value) */ int bitrate_kbps; /**< cloud stream bitrate (platform picks the per-service value) */ From 2aa0a3467f39e64c8097e54c41bf5113500cba22 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Tue, 30 Jun 2026 00:21:17 -0700 Subject: [PATCH 70/72] cloudsession: fix two behavior-drift regressions found by the parity review R1 - store country/language locale fallback was lost. The originals (Qt/iOS PSKamajiSession step0_5d) derived country/language from the store locale (e.g. "de-DE" -> DE/de) when the server-authoritative resolvedStoreCountry/Lang were empty; the unified flow hardcoded US/en, so a non-English native-store user 404'd on the wrong container URL. Restore the fallback on all three wrappers (parse the store locale, prefer resolved else locale-derived) -- the C keeps US/en only as the absolute last resort. R2 - the noGameForEntitlementId one-shot fallback only fired on Gaikai step8, not step9. The originals captured the reject body from both start (step8) and authorize (step9); the new gk_step9_authorize only scanned for the PS+ marker. Now it forwards the reject body (unless it's the 002.2001 PS+ marker) so the owned-fast-path retry fires on a step9 rejection too. Also: authorizeCheck now accepts any 2xx (was 200/204; originals accepted all NoError), and chiaki_cloud_provision_session inits the result before the cfg check so a caller's result_fini is always safe. All three build; lib 108/108; live provision still err=0. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/api/CloudStreamingBackend.kt | 10 ++++++++-- gui/src/cloudstreamingbackend.cpp | 15 +++++++++++++-- ios/Pylux/Services/CloudStreamingBackend.swift | 12 ++++++++++-- lib/src/cloudsession.c | 8 ++++---- lib/src/cloudsession_gaikai.c | 13 +++++++++---- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 139d9af7..21b009bb 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -138,13 +138,19 @@ class CloudStreamingBackend( // picker keeps previously-measured RTTs. val priorDatacenters = if (pscloud) preferences.getCloudDatacentersJsonPscloud() else preferences.getCloudDatacentersJsonPsnow() + // Store country/language: fall back to the store locale (de-DE -> DE/de) when the + // server-authoritative values are empty, so non-English native stores don't 404 on US/en. + val (localeCountry, localeLang) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudStoreLocale()) + val storeCountry = preferences.getCloudResolvedStoreCountry().ifEmpty { localeCountry } + val storeLang = preferences.getCloudResolvedStoreLang().ifEmpty { localeLang } + val result = com.metallic.chiaki.lib.cloudProvisionSession( serviceType = serviceType, gameIdentifier = gameIdentifier, gameName = gameName, npsso = npssoToken, - storeCountry = preferences.getCloudResolvedStoreCountry(), - storeLang = preferences.getCloudResolvedStoreLang(), + storeCountry = storeCountry, + storeLang = storeLang, gameLanguage = gameLanguage, ownedEntitlementId = ownedEntitlementId, ownedPlatform = ownedPlatform, diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 97c3ea08..776ea5ed 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -98,8 +98,19 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q const QByteArray svc = serviceType.toUtf8(); const QByteArray gameId = gameIdentifier.toUtf8(); const QByteArray npsso = npssoToken.toUtf8(); - const QByteArray storeCountry = settings->GetCloudResolvedStoreCountry().toUtf8(); - const QByteArray storeLang = settings->GetCloudResolvedStoreLang().toUtf8(); + // Store country/language for the resolve container URL. When the server-authoritative + // values are empty, fall back to the store locale (e.g. "de-DE" -> DE/de) so a non-English + // native store doesn't 404 on US/en (matches the old Kamaji step0_5d fallback). + QString cc = settings->GetCloudResolvedStoreCountry(); + QString cl = settings->GetCloudResolvedStoreLang(); + if (cc.isEmpty() || cl.isEmpty()) { + QString loc = settings->GetCloudStoreLocale(); + const QStringList lp = (loc.isEmpty() ? QStringLiteral("en-US") : loc).split('-'); + if (cc.isEmpty()) cc = (lp.size() > 1 && !lp[1].isEmpty()) ? lp[1].toUpper() : QStringLiteral("US"); + if (cl.isEmpty()) cl = (!lp.isEmpty() && !lp[0].isEmpty()) ? lp[0].toLower() : QStringLiteral("en"); + } + const QByteArray storeCountry = cc.toUtf8(); + const QByteArray storeLang = cl.toUtf8(); // Streaming language: manual picker, else fall back to the auto-detected catalog // store locale so non-English regions don't silently get "en". QString gameLangStr = settings->GetCloudGameLanguage(); diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index 567b3e7f..851ff8ba 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -86,13 +86,21 @@ final class CloudStreamingBackend { let priorData = pscloud ? SecureStore.shared.pscloudDatacentersData : SecureStore.shared.psnowDatacentersData let priorDatacentersJson = priorData.flatMap { String(data: $0, encoding: .utf8) } ?? "" + // Store country/language: fall back to the store locale (de-DE -> DE/de) when the + // server-authoritative values are empty, so non-English native stores don't 404 on US/en. + let (localeCountry, localeLang) = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) + let resolvedCountry = SecureStore.shared.cloudResolvedStoreCountry + let resolvedLang = SecureStore.shared.cloudResolvedStoreLang + let storeCountry = resolvedCountry.isEmpty ? localeCountry : resolvedCountry + let storeLang = resolvedLang.isEmpty ? localeLang : resolvedLang + let result = PyluxCloudProvision.provision( withServiceType: serviceType, gameIdentifier: gameIdentifier, gameName: gameName, npsso: npssoToken, - storeCountry: SecureStore.shared.cloudResolvedStoreCountry, - storeLang: SecureStore.shared.cloudResolvedStoreLang, + storeCountry: storeCountry, + storeLang: storeLang, gameLanguage: gameLanguage, ownedEntitlementId: ownedEntitlementId, ownedPlatform: ownedPlatform, diff --git a/lib/src/cloudsession.c b/lib/src/cloudsession.c index 30e550b9..c15a8fd6 100644 --- a/lib/src/cloudsession.c +++ b/lib/src/cloudsession.c @@ -100,7 +100,7 @@ static ChiakiErrorCode cc_authorize_check(ChiakiLog *log, free(h_cookie); if(e != CHIAKI_ERR_SUCCESS) return e; - if(status == 200 || status == 204) + if(status >= 200 && status < 300) // any 2xx (the original accepted all QNetworkReply::NoError) return CHIAKI_ERR_SUCCESS; CHIAKI_LOGE(log, "[CLOUDSESSION] authorizeCheck failed (HTTP %ld); NPSSO likely expired", status); return CHIAKI_ERR_UNKNOWN; @@ -141,10 +141,10 @@ CHIAKI_EXPORT ChiakiErrorCode chiaki_cloud_provision_session( ChiakiCloudProvisionResult *out, ChiakiLog *log) { - if(!cfg || !out) + if(!out) return CHIAKI_ERR_INVALID_DATA; - result_init(out); - if(!cfg->npsso || !*cfg->npsso) + result_init(out); // init before any other check so the caller's result_fini is always safe + if(!cfg || !cfg->npsso || !*cfg->npsso) { out->err = CHIAKI_ERR_INVALID_DATA; return out->err; diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c index 51a7ac64..f742ce44 100644 --- a/lib/src/cloudsession_gaikai.c +++ b/lib/src/cloudsession_gaikai.c @@ -505,7 +505,7 @@ static ChiakiErrorCode gk_step8b_server_authcode(GaikaiCtx *c) return CHIAKI_ERR_SUCCESS; } -static ChiakiErrorCode gk_step9_authorize(GaikaiCtx *c, bool *out_psplus_err) +static ChiakiErrorCode gk_step9_authorize(GaikaiCtx *c, ChiakiCloudProvisionResult *out, bool *out_psplus_err) { gk_progress(c, "Authorizing Session - Step 6 of 10"); CCHttpResponse resp = { 0 }; @@ -514,8 +514,13 @@ static ChiakiErrorCode gk_step9_authorize(GaikaiCtx *c, bool *out_psplus_err) if(resp.status_code != 200) { char *ev = gk_header_value(resp.headers, "x-gaikai-event"); - if(ev && strstr(ev, "002.2001")) *out_psplus_err = true; - if(resp.data && strstr(resp.data, "002.2001")) *out_psplus_err = true; + bool psplus = (ev && strstr(ev, "002.2001")) || (resp.data && strstr(resp.data, "002.2001")); + if(psplus) + *out_psplus_err = true; + // Otherwise forward the reject body so the orchestrator's one-shot + // noGameForEntitlementId fallback fires when Gaikai rejects an owned entitlement + // at authorize (step9), not just at start (step8) -- matches both originals. + else if(resp.data) { free(out->error_message); out->error_message = strdup(resp.data); } CHIAKI_LOGE(c->log, "[GAIKAI] step9 authorize http %ld: %s", resp.status_code, resp.data ? resp.data : ""); free(ev); cc_http_response_fini(&resp); return CHIAKI_ERR_UNKNOWN; @@ -956,7 +961,7 @@ ChiakiErrorCode cc_gaikai_allocate(ChiakiLog *log, if(e == CHIAKI_ERR_SUCCESS) e = gk_step8_start(&c, out); if(e == CHIAKI_ERR_SUCCESS) e = gk_step8a_gk_authcode(&c); if(e == CHIAKI_ERR_SUCCESS) e = gk_step8b_server_authcode(&c); - if(e == CHIAKI_ERR_SUCCESS) e = gk_step9_authorize(&c, &psplus_err); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step9_authorize(&c, out, &psplus_err); if(e == CHIAKI_ERR_SUCCESS) e = gk_step10_lock(&c); if(e == CHIAKI_ERR_SUCCESS) e = gk_step11_datacenters(&c); if(e == CHIAKI_ERR_SUCCESS) e = gk_step12_select(&c); From a67865b275fe3b5a07abab137b6f1d8b450a4514 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Tue, 30 Jun 2026 00:21:17 -0700 Subject: [PATCH 71/72] cloudsession (android): remove orphaned DatacenterPingNative JNI Its only caller, cloudplay/ping/DatacenterPing.kt, was deleted with the old provisioning classes (e20bf928); the native export + the PingResult it built were left behind (datacenter pinging lives in libchiaki now). 187 lines of dead, unreachable JNI removed. Android builds. Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/cpp/chiaki-jni.c | 187 -------------------------- 1 file changed, 187 deletions(-) diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index fce1e180..757a5a20 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -1351,193 +1351,6 @@ JNIEXPORT jstring JNICALL JNI_FCN(holepunchGetRegistInfoLocalIp)(JNIEnv *env, jo return E->NewStringUTF(env, info.regist_local_ip); } -// Datacenter Ping JNI -JNIEXPORT jobject JNICALL Java_com_metallic_chiaki_cloudplay_ping_DatacenterPingNative_performPing( - JNIEnv *env, jobject obj, jstring publicIp, jint port, jstring sessionKey, jstring serviceType) -{ - // Create a minimal logger (Qt line 54-55) - ChiakiLog log; - chiaki_log_init(&log, CHIAKI_LOG_ALL & ~CHIAKI_LOG_VERBOSE, chiaki_log_cb_print, NULL); - - const char *ip_str = (*env)->GetStringUTFChars(env, publicIp, NULL); - const char *session_key_str = (*env)->GetStringUTFChars(env, sessionKey, NULL); - const char *service_type_str = (*env)->GetStringUTFChars(env, serviceType, NULL); - - if(!ip_str || !session_key_str || !service_type_str) - { - CHIAKI_LOGI(&log, "DatacenterPing: Failed to get JNI strings"); - - // Create failure result - jclass pingResultClass = (*env)->FindClass(env, "com/metallic/chiaki/cloudplay/ping/PingResult"); - jmethodID constructor = (*env)->GetMethodID(env, pingResultClass, "", "(JII)V"); - jobject result = (*env)->NewObject(env, pingResultClass, constructor, (jlong)-1, (jint)0, (jint)0); - - if(ip_str) (*env)->ReleaseStringUTFChars(env, publicIp, ip_str); - if(session_key_str) (*env)->ReleaseStringUTFChars(env, sessionKey, session_key_str); - if(service_type_str) (*env)->ReleaseStringUTFChars(env, serviceType, service_type_str); - return result; - } - - CHIAKI_LOGI(&log, "DatacenterPing: Pinging %s:%d (service=%s)", ip_str, port, service_type_str); - - // Resolve hostname to IP - struct addrinfo hints; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_DGRAM; - hints.ai_protocol = IPPROTO_UDP; - - char port_str[16]; - snprintf(port_str, sizeof(port_str), "%d", port); - - struct addrinfo *addrinfo_result = NULL; - int err = getaddrinfo(ip_str, port_str, &hints, &addrinfo_result); - if(err != 0 || !addrinfo_result) - { - CHIAKI_LOGE(&log, "DatacenterPing: Failed to resolve %s:%d - %s", ip_str, port, gai_strerror(err)); - - // Create failure result - jclass pingResultClass = (*env)->FindClass(env, "com/metallic/chiaki/cloudplay/ping/PingResult"); - jmethodID constructor = (*env)->GetMethodID(env, pingResultClass, "", "(JII)V"); - jobject result = (*env)->NewObject(env, pingResultClass, constructor, (jlong)-1, (jint)0, (jint)0); - - (*env)->ReleaseStringUTFChars(env, publicIp, ip_str); - (*env)->ReleaseStringUTFChars(env, sessionKey, session_key_str); - (*env)->ReleaseStringUTFChars(env, serviceType, service_type_str); - return result; - } - - // Allocate and initialize session buffer (Qt lines 115-132) - size_t session_size = sizeof(ChiakiSession); - ChiakiSession *session = (ChiakiSession *)calloc(1, session_size); - if(!session) - { - CHIAKI_LOGE(&log, "DatacenterPing: Failed to allocate session buffer"); - freeaddrinfo(addrinfo_result); - - // Create failure result - jclass pingResultClass = (*env)->FindClass(env, "com/metallic/chiaki/cloudplay/ping/PingResult"); - jmethodID constructor = (*env)->GetMethodID(env, pingResultClass, "", "(JII)V"); - jobject result = (*env)->NewObject(env, pingResultClass, constructor, (jlong)-1, (jint)0, (jint)0); - - (*env)->ReleaseStringUTFChars(env, publicIp, ip_str); - (*env)->ReleaseStringUTFChars(env, sessionKey, session_key_str); - (*env)->ReleaseStringUTFChars(env, serviceType, service_type_str); - return result; - } - - session->log = &log; - session->connect_info.host_addrinfo_selected = addrinfo_result; - session->connect_info.enable_dualsense = false; - session->target = CHIAKI_TARGET_PS5_1; - session->cloud_port = port; - - // Set service type for cloud ping (Qt lines 133-145) - if(strcmp(service_type_str, "pscloud") == 0) - { - session->cloud_psn_wrapper_type = 0; // No PSN wrapper for PSCloud - session->service_type = CHIAKI_SERVICE_TYPE_PSCLOUD; - } - else // "psnow" or fallback - { - session->cloud_psn_wrapper_type = 0x01; // PSN wrapper for PSNOW - session->service_type = CHIAKI_SERVICE_TYPE_PSNOW; - } - - // Initialize senkusha (Qt lines 148-159) - ChiakiSenkusha senkusha; - ChiakiErrorCode chiaki_err = chiaki_senkusha_init(&senkusha, session); - if(chiaki_err != CHIAKI_ERR_SUCCESS) - { - CHIAKI_LOGE(&log, "DatacenterPing: Failed to initialize senkusha: %d", chiaki_err); - freeaddrinfo(addrinfo_result); - free(session); - - // Create failure result - jclass pingResultClass = (*env)->FindClass(env, "com/metallic/chiaki/cloudplay/ping/PingResult"); - jmethodID constructor = (*env)->GetMethodID(env, pingResultClass, "", "(JII)V"); - jobject result = (*env)->NewObject(env, pingResultClass, constructor, (jlong)-1, (jint)0, (jint)0); - - (*env)->ReleaseStringUTFChars(env, publicIp, ip_str); - (*env)->ReleaseStringUTFChars(env, sessionKey, session_key_str); - (*env)->ReleaseStringUTFChars(env, serviceType, service_type_str); - return result; - } - - // Force protocol version to 9 for cloud ping (Qt line 162) - senkusha.protocol_version = 9; - - // Set session key (x-gaikai-session) for cloud mode BIG message (Qt lines 164-179) - size_t session_key_len = strlen(session_key_str); - senkusha.cloud_launch_spec = (char *)malloc(session_key_len + 1); - if(!senkusha.cloud_launch_spec) - { - CHIAKI_LOGE(&log, "DatacenterPing: Failed to allocate session key string"); - chiaki_senkusha_fini(&senkusha); - freeaddrinfo(addrinfo_result); - free(session); - - // Create failure result - jclass pingResultClass = (*env)->FindClass(env, "com/metallic/chiaki/cloudplay/ping/PingResult"); - jmethodID constructor = (*env)->GetMethodID(env, pingResultClass, "", "(JII)V"); - jobject result = (*env)->NewObject(env, pingResultClass, constructor, (jlong)-1, (jint)0, (jint)0); - - (*env)->ReleaseStringUTFChars(env, publicIp, ip_str); - (*env)->ReleaseStringUTFChars(env, sessionKey, session_key_str); - (*env)->ReleaseStringUTFChars(env, serviceType, service_type_str); - return result; - } - memcpy(senkusha.cloud_launch_spec, session_key_str, session_key_len); - senkusha.cloud_launch_spec[session_key_len] = '\0'; - - // Run senkusha (this will do the full handshake + echo/ping test) (Qt line 186) - uint32_t mtu_in = 0; - uint32_t mtu_out = 0; - uint64_t rtt_us = 0; - - chiaki_err = chiaki_senkusha_run(&senkusha, &mtu_in, &mtu_out, &rtt_us, NULL); - - // Free resources (Qt lines 189-196) - if(senkusha.cloud_launch_spec) - { - free(senkusha.cloud_launch_spec); - senkusha.cloud_launch_spec = NULL; - } - - chiaki_senkusha_fini(&senkusha); - freeaddrinfo(addrinfo_result); - free(session); - - // Create result object (Qt lines 198-210) - jlong result_rtt_us = -1; - jint result_mtu_in = 0; - jint result_mtu_out = 0; - - if(chiaki_err == CHIAKI_ERR_SUCCESS) - { - result_rtt_us = (jlong)rtt_us; - result_mtu_in = (jint)mtu_in; - result_mtu_out = (jint)mtu_out; - CHIAKI_LOGI(&log, "DatacenterPing: %s:%d - RTT: %lld us, MTU in: %d, MTU out: %d", - ip_str, port, (long long)rtt_us, mtu_in, mtu_out); - } - else - { - CHIAKI_LOGE(&log, "DatacenterPing: %s:%d - Ping failed with error: %d", ip_str, port, chiaki_err); - } - - // Release JNI strings - (*env)->ReleaseStringUTFChars(env, publicIp, ip_str); - (*env)->ReleaseStringUTFChars(env, sessionKey, session_key_str); - (*env)->ReleaseStringUTFChars(env, serviceType, service_type_str); - - // Create and return PingResult object - jclass pingResultClass = (*env)->FindClass(env, "com/metallic/chiaki/cloudplay/ping/PingResult"); - jmethodID constructor = (*env)->GetMethodID(env, pingResultClass, "", "(JII)V"); - jobject result = (*env)->NewObject(env, pingResultClass, constructor, result_rtt_us, result_mtu_in, result_mtu_out); - - return result; -} // Unified cloud catalog (chiaki/cloudcatalog.h): one fetch+dedup+ownership+tagging pass shared // with Qt and iOS. Returns the UTF-8 JSON contract as a byte[] (the payload has non-ASCII names From 59f58bf2d1ad21cbf34d133e7bce1e4cf8386912 Mon Sep 17 00:00:00 2001 From: forward technologies Date: Tue, 30 Jun 2026 00:30:12 -0700 Subject: [PATCH 72/72] cloudsession: tighten R1 store-locale fallback to byte-match the original The R1 fix used independent country/language fallbacks; equivalent in the common case but not structurally identical to the old Kamaji step0_5d (commit a43e8af2). Replace with that commit's exact branching on all three wrappers: native mode (resolvedStoreCountry empty -- the normal state for a natively-supported non-US region) derives BOTH country and language from the store locale; fallback mode uses the resolved country and the resolved-else-locale language. No behavioral guesswork left. All three build. Co-Authored-By: Claude Opus 4.8 --- .../cloudplay/api/CloudStreamingBackend.kt | 18 ++++++++++--- gui/src/cloudstreamingbackend.cpp | 27 ++++++++++++------- .../Services/CloudStreamingBackend.swift | 16 ++++++++--- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt index 21b009bb..b833f09d 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/CloudStreamingBackend.kt @@ -138,11 +138,21 @@ class CloudStreamingBackend( // picker keeps previously-measured RTTs. val priorDatacenters = if (pscloud) preferences.getCloudDatacentersJsonPscloud() else preferences.getCloudDatacentersJsonPsnow() - // Store country/language: fall back to the store locale (de-DE -> DE/de) when the - // server-authoritative values are empty, so non-English native stores don't 404 on US/en. + // Store country/language for the resolve container URL -- byte-faithful to the old + // Kamaji step0_5d: native mode (resolvedStoreCountry empty) derives BOTH from the store + // locale; fallback mode uses the resolved country and resolved-else-locale language. val (localeCountry, localeLang) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudStoreLocale()) - val storeCountry = preferences.getCloudResolvedStoreCountry().ifEmpty { localeCountry } - val storeLang = preferences.getCloudResolvedStoreLang().ifEmpty { localeLang } + val resolvedCountry = preferences.getCloudResolvedStoreCountry() + val resolvedLang = preferences.getCloudResolvedStoreLang() + val storeCountry: String + val storeLang: String + if (resolvedCountry.isNotEmpty()) { + storeCountry = resolvedCountry + storeLang = resolvedLang.ifEmpty { localeLang } + } else { + storeCountry = localeCountry + storeLang = localeLang + } val result = com.metallic.chiaki.lib.cloudProvisionSession( serviceType = serviceType, diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 776ea5ed..a3886234 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -98,16 +98,23 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q const QByteArray svc = serviceType.toUtf8(); const QByteArray gameId = gameIdentifier.toUtf8(); const QByteArray npsso = npssoToken.toUtf8(); - // Store country/language for the resolve container URL. When the server-authoritative - // values are empty, fall back to the store locale (e.g. "de-DE" -> DE/de) so a non-English - // native store doesn't 404 on US/en (matches the old Kamaji step0_5d fallback). - QString cc = settings->GetCloudResolvedStoreCountry(); - QString cl = settings->GetCloudResolvedStoreLang(); - if (cc.isEmpty() || cl.isEmpty()) { - QString loc = settings->GetCloudStoreLocale(); - const QStringList lp = (loc.isEmpty() ? QStringLiteral("en-US") : loc).split('-'); - if (cc.isEmpty()) cc = (lp.size() > 1 && !lp[1].isEmpty()) ? lp[1].toUpper() : QStringLiteral("US"); - if (cl.isEmpty()) cl = (!lp.isEmpty() && !lp[0].isEmpty()) ? lp[0].toLower() : QStringLiteral("en"); + // Store country/language for the resolve container URL -- byte-faithful to the old + // Kamaji step0_5d (commit a43e8af2): in native mode (resolvedStoreCountry empty) derive + // BOTH from the store locale; in fallback mode use the resolved country and the resolved + // language (else the locale language). Hardcoded US/en would 404 a non-US native store. + QString loc = settings->GetCloudStoreLocale(); + const QStringList lp = (loc.isEmpty() ? QStringLiteral("en-US") : loc).split('-'); + const QString localeLang = (!lp.isEmpty() && !lp[0].isEmpty()) ? lp[0].toLower() : QStringLiteral("en"); + const QString localeCountry = (lp.size() > 1 && !lp[1].isEmpty()) ? lp[1].toUpper() : QStringLiteral("US"); + const QString resolvedCountry = settings->GetCloudResolvedStoreCountry(); + const QString resolvedLang = settings->GetCloudResolvedStoreLang(); + QString cc, cl; + if (!resolvedCountry.isEmpty()) { + cc = resolvedCountry; + cl = !resolvedLang.isEmpty() ? resolvedLang : localeLang; + } else { + cc = localeCountry; + cl = localeLang; } const QByteArray storeCountry = cc.toUtf8(); const QByteArray storeLang = cl.toUtf8(); diff --git a/ios/Pylux/Services/CloudStreamingBackend.swift b/ios/Pylux/Services/CloudStreamingBackend.swift index 851ff8ba..7023bee8 100644 --- a/ios/Pylux/Services/CloudStreamingBackend.swift +++ b/ios/Pylux/Services/CloudStreamingBackend.swift @@ -86,13 +86,21 @@ final class CloudStreamingBackend { let priorData = pscloud ? SecureStore.shared.pscloudDatacentersData : SecureStore.shared.psnowDatacentersData let priorDatacentersJson = priorData.flatMap { String(data: $0, encoding: .utf8) } ?? "" - // Store country/language: fall back to the store locale (de-DE -> DE/de) when the - // server-authoritative values are empty, so non-English native stores don't 404 on US/en. + // Store country/language for the resolve container URL -- byte-faithful to the old + // Kamaji step0_5d: native mode (resolvedStoreCountry empty) derives BOTH from the store + // locale; fallback mode uses the resolved country and resolved-else-locale language. let (localeCountry, localeLang) = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) let resolvedCountry = SecureStore.shared.cloudResolvedStoreCountry let resolvedLang = SecureStore.shared.cloudResolvedStoreLang - let storeCountry = resolvedCountry.isEmpty ? localeCountry : resolvedCountry - let storeLang = resolvedLang.isEmpty ? localeLang : resolvedLang + let storeCountry: String + let storeLang: String + if !resolvedCountry.isEmpty { + storeCountry = resolvedCountry + storeLang = resolvedLang.isEmpty ? localeLang : resolvedLang + } else { + storeCountry = localeCountry + storeLang = localeLang + } let result = PyluxCloudProvision.provision( withServiceType: serviceType,