diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index f088a2a9..e20d356f 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,50 @@ 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: | + # 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" + ./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 + # 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" + 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: | @@ -148,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 @@ -160,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. @@ -177,7 +228,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/.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/.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/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( 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"> diff --git a/android/app/src/main/cpp/chiaki-jni.c b/android/app/src/main/cpp/chiaki-jni.c index c494531e..757a5a20 100644 --- a/android/app/src/main/cpp/chiaki-jni.c +++ b/android/app/src/main/cpp/chiaki-jni.c @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include @@ -705,6 +707,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; @@ -1308,190 +1351,255 @@ 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) + +// 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) { - // 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) + 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(&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; + 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; } - - // Allocate and initialize session buffer (Qt lines 115-132) - size_t session_size = sizeof(ChiakiSession); - ChiakiSession *session = (ChiakiSession *)calloc(1, session_size); - if(!session) + + 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) { - 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; + 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 } - - 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) + else { - session->cloud_psn_wrapper_type = 0; // No PSN wrapper for PSCloud - session->service_type = CHIAKI_SERVICE_TYPE_PSCLOUD; + 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); } - else // "psnow" or fallback + + if(!result && fail_detail && error_out && E->GetArrayLength(env, error_out) > 0) { - session->cloud_psn_wrapper_type = 0x01; // PSN wrapper for PSNOW - session->service_type = CHIAKI_SERVICE_TYPE_PSNOW; + jstring jdetail = E->NewStringUTF(env, fail_detail); + if(jdetail) + { + E->SetObjectArrayElement(env, error_out, 0, jdetail); + E->DeleteLocalRef(env, jdetail); + } } - - // Initialize senkusha (Qt lines 148-159) - ChiakiSenkusha senkusha; - ChiakiErrorCode chiaki_err = chiaki_senkusha_init(&senkusha, session); - if(chiaki_err != CHIAKI_ERR_SUCCESS) + + 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_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; + chiaki_cloudcatalog_invalidate_cache(cache_dir); + E->ReleaseStringUTFChars(env, cache_dir_str, cache_dir); } - - // 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) +} + +// 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) { - 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; + 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"); } - 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) + + 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.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) { - free(senkusha.cloud_launch_spec); - senkusha.cloud_launch_spec = NULL; + 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); } - - 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) + // intOut: [serverPort, psnWrapperType, mtuIn, mtuOut, rttMs] + if(int_out && E->GetArrayLength(env, int_out) >= 5) { - 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); + 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); } - else + + 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). + +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++) { - CHIAKI_LOGE(&log, "DatacenterPing: %s:%d - Ping failed with error: %d", ip_str, port, chiaki_err); + jstring s = E->NewStringUTF(env, chiaki_cloud_supported_locale(i)); + if(s) + { + E->SetObjectArrayElement(env, arr, (jsize)i, s); + E->DeleteLocalRef(env, s); + } } - - // 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; + return arr; } \ 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 a3d572e3..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) 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 2bd4f6e0..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.isCloudLanguageConfigured()) - return true - if (npssoToken.isBlank()) - { - Log.w(TAG, "Cannot bootstrap locale: empty npsso token") - return false - } - - synchronized(lock) - { - if (preferences.isCloudLanguageConfigured()) - 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.getCloudLanguage()}") - 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.setCloudLanguageFromSession( - data?.optString("language"), - data?.optString("country") - ) - return preferences.isCloudLanguageConfigured() - } -} 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 3decfc13..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt +++ /dev/null @@ -1,28 +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" - - const val ROOT_CONTAINER_ID = "STORE-MSF75508-PSNOWALLGAMES" -} - 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..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 @@ -4,9 +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.DuidUtil -import com.metallic.chiaki.cloudplay.PsnApiConstants import com.metallic.chiaki.cloudplay.model.CloudStreamSession import com.metallic.chiaki.common.Preferences import kotlinx.coroutines.Dispatchers @@ -17,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( @@ -62,6 +59,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) @@ -83,33 +82,21 @@ 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 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, gameIdentifier, gameName, npssoToken, - sharedDuid, - onProgress, - isCancelled + ownedEntitlementId, + ownedPlatform, + onProgress = onProgress, + isCancelled = isCancelled ) result @@ -122,225 +109,126 @@ 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 = "", 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 - - 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( - duid = sharedDuid, - productId = gameIdentifier, - accountBaseUrl = CloudConfig.ACCOUNT_BASE, - redirectUri = redirectUri, - userAgent = userAgent, - preferences = preferences - ) - - // Start Kamaji session creation - val kamajiResult = kamajiSession.startSessionCreation(npssoToken) - - 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") - } - else - { - // 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") + val pscloud = serviceType == "pscloud" + + // 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() + + // 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 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 } - - // Start Gaikai allocation (Steps 7-13) - Log.i(TAG, "=== Starting Gaikai Allocation ===") - - val gaikaiStreaming = PSGaikaiStreaming( - duid = sharedDuid, + + val result = com.metallic.chiaki.lib.cloudProvisionSession( serviceType = serviceType, - platform = finalPlatform, - npssoToken = npssoToken, - preferences = preferences, + gameIdentifier = gameIdentifier, + gameName = gameName, + npsso = npssoToken, + storeCountry = storeCountry, + storeLang = storeLang, + gameLanguage = gameLanguage, + ownedEntitlementId = ownedEntitlementId, + ownedPlatform = ownedPlatform, + forcedDatacenter = forcedDatacenter, + priorDatacentersJson = priorDatacenters, + catalogIsForeign = preferences.isCloudCatalogIsForeign(), + resolution = resolution, + bitrateKbps = bitrate, onProgress = onProgress, isCancelled = isCancelled ) - - val allocationResult = gaikaiStreaming.startAllocationFlow(finalEntitlementId) - - if (!allocationResult.success) + + // 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()) { - Log.e(TAG, "Gaikai allocation failed: ${allocationResult.message}") - return@withContext Result.failure(Exception("Gaikai allocation failed: ${allocationResult.message}")) + if (pscloud) preferences.setCloudDatacentersJsonPscloud(result.datacenterPings) + else preferences.setCloudDatacentersJsonPsnow(result.datacenterPings) } - - 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) - } - catch (e: Exception) - { - Log.e(TAG, "Cloud session continuation error", e) - Result.failure(e) - } - } - - /** - * 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) + + if (result.err == 0) { - Log.i(TAG, "✓ Authorization check passed (${response.statusCode})") - return@withContext true + 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 + )) } - else + + // 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.w(TAG, "Authorization check failed: ${response.statusCode}") - Log.w(TAG, "Response: ${response.body}") - return@withContext false + 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("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") + msg.contains("PING_TIMEOUT") -> + PingTimeoutException("Ping must be < 80ms to start a cloud session") + else -> GaikaiAllocationException(msg) } + Result.failure(ex) } catch (e: Exception) { - Log.e(TAG, "Authorization check error", e) - return@withContext false + Log.e(TAG, "Cloud session continuation error", e) + Result.failure(e) } } + } 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..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) @@ -22,6 +27,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/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/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 7e82e810..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSGaikaiStreaming.kt +++ /dev/null @@ -1,1664 +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 = "" - 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, "Failed to start session") - 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}") - 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 - - // 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 - { - 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") - - // Read language from unified settings (Qt lines 153, 161) - // Use unified language setting for both PSCloud and PSNOW - val language = preferences.getCloudLanguage() - 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 b995de55..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ /dev/null @@ -1,950 +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 - - /** - * 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") - } - - // 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.setCloudLanguageFromSession(sessionLanguage, sessionCountry) - Log.i(TAG, "Saved locale from session: ${preferences.getCloudLanguage()}") - } - } - } - 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 localeSetting = preferences.getCloudLanguage() - val (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) - Log.i(TAG, "Using locale from settings: $localeSetting -> 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") - 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 - } - } - - // 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 entitlement (404), try to acquire it - 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/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt deleted file mode 100644 index e80a54b0..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ /dev/null @@ -1,489 +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 - { - 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) - if (!isPs5Game(gameObj)) - continue - - if (categoryList == "plus-games-list" - && !gameObj.optBoolean("streamingSupported", false)) - { - val productId = gameObj.optString("productId", "") - if (productId.isNotEmpty()) - plusSupplementByProductId.putIfAbsent(productId, gameObj) - continue - } - - if (!isPs5StreamingGame(gameObj)) - continue - val key = conceptKey(gameObj) - val productId = gameObj.optString("productId", "") - if (key.isEmpty() || productId.isEmpty()) - continue - - if (byConceptId.containsKey(key)) - { - val canonicalProductId = byConceptId[key]?.optString("productId", "") ?: "" - if (canonicalProductId.isNotEmpty() && productId != canonicalProductId - && !productIdAliases.containsKey(productId)) - { - productIdAliases[productId] = canonicalProductId - } - continue - } - - byConceptId[key] = gameObj - } - } - return rows - } - - private fun isPs5Game(gameObj: JSONObject): Boolean - { - val devices = gameObj.optJSONArray("device") ?: return false - for (i in 0 until devices.length()) - { - if (devices.optString(i) == "PS5") - return true - } - return false - } - - private fun isPs5StreamingGame(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 - } - - 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", "") - } - - 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 = "ps5", - serviceType = "pscloud", - conceptUrl = conceptUrl, - conceptId = conceptKey(gameObj), - isOwned = 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). - */ - suspend fun getOwnedPs5CloudGames( - npssoToken: String, - publicCatalog: List, - plusLibrarySupplement: List = emptyList(), - productIdAliases: Map = emptyMap(), - ): List - { - if (npssoToken.isEmpty()) return emptyList() - - val oauthToken = fetchOwnedGamesOAuthToken(npssoToken) - 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) - - return PsCloudOwnership.crossReferenceOwnedGames( - filtered, publicCatalog, plusLibrarySupplement, productIdAliases, componentIdsByProductId - ) - } - - /** - * 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 6ae7e5eb..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ /dev/null @@ -1,287 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -package com.metallic.chiaki.cloudplay.api - -import com.metallic.chiaki.cloudplay.model.CloudGame -import org.json.JSONObject - -object PsCloudOwnership -{ - const val PAGE_SIZE = 300 - const val PAGE_COOLDOWN_MS = 100L - - data class Entitlement( - val id: String, - val productId: String, - val activeFlag: Boolean, - val packageType: String, - val name: String - ) - - private data class CatalogIndex( - val byProductId: MutableMap, - val byConceptId: MutableMap - ) - - fun filterOwnedPs5Games(entitlements: List): List - { - return entitlements.filter { ent -> - ent.packageType == "PSGD" && - ent.activeFlag && - !ent.productId.startsWith("IP") && - !ent.productId.startsWith("SUB") - } - } - - 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) - return Entitlement( - id = id, - productId = obj.optString("product_id", ""), - activeFlag = obj.optBoolean("active_flag", false), - packageType = gameMeta.optString("package_type", ""), - name = name - ) - } - - 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 byKey = linkedMapOf() - - fun emitMatch(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 - ) - val key = ownedDedupeKey(meta, ent) - val existing = byKey[key] - byKey[key] = if (existing == null) game else preferOwnedEntry(existing, game) - } - - for (ent in filteredEntitlements) - { - 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)) - } - else - { - 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)) - } - } - - if (matches.isEmpty()) - { - 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) - } - } - - if (matches.isEmpty()) - continue - - for (meta in matches) - emitMatch(meta, ent) - } - - return byKey.values.toList() - } - - private fun ownedDedupeKey(meta: CloudGame, ent: Entitlement): String - { - if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}" - 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 - { - return when - { - existing.entitlementId.isEmpty() && candidate.entitlementId.isNotEmpty() -> candidate - else -> existing - } - } - - 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 - } - - fun mergeOwnedIntoBrowseCatalog( - browseCatalog: List, - ownedCrossRef: List - ): List - { - val games = browseCatalog.toMutableList() - val catalogIndex = buildCatalogIndex(games) - - for (owned in ownedCrossRef) - { - val catalogMatch = findCatalogIndexForOwned(owned, catalogIndex) - 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 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)) - { - if (game.entitlementId.isNotEmpty()) return game.entitlementId - if (game.storeProductId.isNotEmpty()) return game.storeProductId - } - return game.productId - } - - 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 - if (game.conceptId.isNotEmpty()) - catalogIndex.byConceptId[game.conceptId] = index - if (game.entitlementId.isNotEmpty() && game.entitlementId != game.productId) - catalogIndex.byProductId[game.entitlementId] = index - } - - 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() && 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) - if (owned.conceptId.isNotEmpty() && catalogIndex.byConceptId.containsKey(owned.conceptId)) - return catalogIndex.byConceptId.getValue(owned.conceptId) - return -1 - } -} 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 0853d3ef..00000000 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsnCatalogService.kt +++ /dev/null @@ -1,636 +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 com.metallic.chiaki.cloudplay.model.PsnResult -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() - - /** - * Fetch PSNow catalog with complete authentication flow - * Matches: CloudCatalogBackend::fetchPsnowCatalog() - */ - suspend fun fetchPsnowCatalog(npssoToken: String): PsnResult> = withContext(Dispatchers.IO) - { - try - { - gameLogCounter = 0 // Reset counter for new catalog fetch - Log.i(TAG, "=== Starting PSNow Catalog Fetch ===") - - // Step 1: OAuth authentication - val oauthCode = fetchOAuthCode(npssoToken) - ?: return@withContext PsnResult.Error("OAuth authentication failed") - - // Step 2: Create Kamaji session - val sessionId = createKamajiSession(oauthCode) - ?: return@withContext PsnResult.Error("Failed to create Kamaji session") - - jsessionId = sessionId - - // Step 3: Fetch stores to get base URL - val storesBaseUrl = fetchStores() - ?: return@withContext PsnResult.Error("Failed to fetch stores") - - baseUrl = storesBaseUrl - - // Step 4: Fetch root container to get category links - val categoryUrls = fetchRootContainer() - ?: return@withContext PsnResult.Error("Failed to fetch root container") - - // 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 ===") - PsnResult.Success(allGames) - } - catch (e: Exception) - { - Log.e(TAG, "Error fetching PSNow catalog", e) - PsnResult.Error("Failed to fetch catalog: ${e.message}", e) - } - } - - /** - * 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 fb8d75bd..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,31 +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 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 = "", + // 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/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/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..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 @@ -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,440 +29,139 @@ class CloudGameRepository( companion object { private const val TAG = "CloudGameRepository" + // Dir handed to the lib; the lib owns every file inside it (browse/library/unified caches). private const val CACHE_DIR = "cloud_catalog_cache" - fun invalidateCatalogCache(context: Context) + // 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.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 + * (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, reason: String = "") { try { - val cacheDir = File(context.cacheDir, CACHE_DIR) - cacheDir.listFiles()?.forEach { file -> - if (file.isFile) - file.delete() - } - Log.i(TAG, "Catalog cache invalidated (locale change)") + cloudCatalogInvalidateCache(cacheDir(context).absolutePath) + Log.i(TAG, "Catalog cache invalidated" + if (reason.isNotEmpty()) " ($reason)" else "") } catch (e: Exception) { 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.json" - 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 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 - - /** - * Fetch PSNow catalog with caching - */ - suspend fun fetchPsnowCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> - { - return withContext(Dispatchers.IO) - { - // 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) - } - } - - // 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) - { - cacheGames(result.data, PSNOW_CACHE_FILE) - } - - result - } - } - - /** - * Fetch PS5 Cloud catalog with caching - */ - suspend fun fetchPs5CloudCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> - { - return withContext(Dispatchers.IO) - { - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) - - if (!forceRefresh) - { - loadCachedGames(PSCLOUD_ALL_CACHE_FILE)?.let { cached -> - Log.i(TAG, "Returning ${cached.size} PS5 games from cache (ownership included)") - return@withContext PsnResult.Success(cached) - } - } - 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 - } - - val gamesWithOwnership = crossReferenceOwnership(catalog, npssoToken) - if (gamesWithOwnership.isNotEmpty()) - cacheGames(gamesWithOwnership, PSCLOUD_ALL_CACHE_FILE) - PsnResult.Success(gamesWithOwnership) - } - catch (e: Exception) - { - Log.e(TAG, "Failed to fetch PS5 catalog", e) - PsnResult.Error("Failed to fetch PS5 catalog: ${e.message}", e) - } - } - } - - /** - * 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) } - - 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) } - } - } - /** - * Fetch owned PS5 games (user's library) + * 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 fetchOwnedPs5Games(npssoToken: String, forceRefresh: Boolean = false): PsnResult> + suspend fun fetchUnifiedCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> { return withContext(Dispatchers.IO) { - CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) + lastCatalogFetchWarning = null - if (!forceRefresh) + val fetched = try { - loadCachedGames(PSCLOUD_OWNED_CACHE_FILE)?.let { cached -> - Log.i(TAG, "Returning ${cached.size} owned PS5 games from cache") - return@withContext PsnResult.Success(cached) + catalogLock.withLock { + cloudCatalogFetchUnified( + npsso = npssoToken.ifEmpty { null }, + locale = preferences.getCloudStoreLocale(), + cacheDir = cacheDir(context).absolutePath, + forceRefresh = forceRefresh + ) } } - - Log.i(TAG, "Fetching owned PS5 games from network (forceRefresh=$forceRefresh)") - 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 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) + Log.e(TAG, "Unified catalog fetch threw", e) + return@withContext PsnResult.Error("Failed to fetch catalog: ${e.message}", e) } - } - } - - /** - * 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")) - - games.add(CloudGame( - productId = obj.getString("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", "") - )) - } - - 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) - 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) - } - } - - private fun loadCachedPs5CatalogV3(expectedLocale: String): Ps5CloudCatalogResult? - { - try - { - 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) + val json = fetched.json + if (json == null) { - cacheFile.delete() - return null + 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) } - val root = JSONObject(cacheFile.readText()) - val cachedLocale = root.optString("locale", "") - if (cachedLocale.isNotEmpty() && cachedLocale != expectedLocale) - { - Log.i(TAG, "PS5 catalog v3 cache locale mismatch ($cachedLocale != $expectedLocale), refetching") - cacheFile.delete() - return null - } - - 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 + parseUnifiedCatalog(json) } } - private fun cachePs5CatalogV3(catalog: Ps5CloudCatalogResult, locale: String) + private fun parseUnifiedCatalog(json: String): PsnResult> { - try + val root = 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") + JSONObject(json) } catch (e: Exception) { - Log.e(TAG, "Error caching PS5 catalog v3", e) + Log.e(TAG, "Failed to parse unified catalog JSON", e) + return PsnResult.Error("Failed to parse cloud catalog.", 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 + // 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.noteCloudStoreLocaleSettled(it) } - return aliases - } - - private fun productIdAliasesToJson(aliases: Map): JSONObject - { - val obj = JSONObject() - for ((alias, canonical) in aliases) - obj.put(alias, canonical) - return obj - } + preferences.setCloudResolvedStoreCountry(root.optString("fallbackRegion", "")) + preferences.setCloudResolvedStoreLang(root.optString("resolvedStoreLang", "")) + preferences.setCloudCatalogNativeMode(root.optBoolean("nativeMode", true)) - 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")) - games.add( - CloudGame( - productId = obj.getString("productId"), - name = obj.getString("name"), - imageUrl = obj.getString("imageUrl"), - landscapeImageUrl = landscapeImageUrl, - platform = obj.optString("platform", "ps5"), - 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", "") - ) - ) + root.optString("warning", "").takeIf { it.isNotEmpty() }?.let { + lastCatalogFetchWarning = it } - 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) - jsonArray.put(obj) - } - return jsonArray + 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) } + + 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) } - /** - * Clear all cached data - */ - fun clearCache() + /** Clear all lib-owned cached data. Serialized against an in-flight fetch via [catalogLock]. */ + suspend fun clearCache() { - try - { - cacheDir.listFiles()?.forEach { it.delete() } - Log.i(TAG, "Cache cleared") - } - catch (e: Exception) + withContext(Dispatchers.IO) { - Log.w(TAG, "Error clearing cache", e) + try + { + catalogLock.withLock { cloudCatalogInvalidateCache(cacheDir(context).absolutePath) } + 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 cb45f18a..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 @@ -62,6 +62,15 @@ 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 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" } private val appContext = context.applicationContext @@ -115,6 +124,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) @@ -282,31 +297,90 @@ 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) + CloudGameRepository.invalidateCatalogCache(appContext, "locale change") + } + + /** + * 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 noteCloudStoreLocaleSettled(value: String) + { + if (value.isEmpty()) + return + if (isCloudStoreLocaleConfigured() && getCloudStoreLocale() == value) + return + 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 - setCloudLanguage(locale) + 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(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 ${getCloudStoreLocale()}") + return + } + } + setCloudStoreLocale(locale) } - + + /** + * Manual streaming-language override chosen in the language picker. Empty means + * "use the catalog locale" ([getCloudStoreLocale]). Stored separately so the + * auto-detected catalog locale (noteCloudStoreLocaleSettled / setCloudStoreLocaleFromSession) + * can never clobber the user's pick. + */ + 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 setCloudGameLanguage(value: String) + { + sharedPreferences.edit().putString(CLOUD_GAME_LANGUAGE_KEY, value).apply() + } + // Cloud resolution settings (matching Qt GetCloudResolutionPSNOW/SetCloudResolutionPSNOW) val cloudResolutionPsnowKey get() = resources.getString(R.string.preferences_cloud_resolution_psnow_key) fun getCloudResolutionPsnow(): Int @@ -384,6 +458,9 @@ class Preferences(context: Context) sharedPreferences.edit().putString(cloudDatacentersJsonPsnowKey, json).apply() } + // 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) val cloudDatacenterPscloudKey get() = resources.getString(R.string.preferences_cloud_datacenter_pscloud_key) fun getCloudDatacenterPscloud(): String @@ -408,13 +485,56 @@ class Preferences(context: Context) sharedPreferences.edit().putString(cloudDatacentersJsonPscloudKey, json).apply() } - // Cloud Play UI state - private val LAST_CLOUD_SECTION_KEY = "last_cloud_section" - private val PSCLOUD_FILTER_OWNED_KEY = "pscloud_filter_owned" + /** + * 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() + } + + /** 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)) + 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" 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" @@ -435,26 +555,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() - } - - 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) @@ -474,6 +574,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 @@ -501,16 +612,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/common/SecureTokenManager.kt b/android/app/src/main/java/com/metallic/chiaki/common/SecureTokenManager.kt index 0a7bdcdf..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 @@ -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" @@ -56,10 +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") + 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) { @@ -102,6 +116,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/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..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 @@ -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) @@ -144,9 +173,77 @@ 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 + + // 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 { @@ -265,6 +362,37 @@ 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() + class ErrorCode(val value: Int) { override fun toString() = ChiakiNative.errorCodeToString(value) @@ -557,6 +685,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 eab0686c..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()) { @@ -122,21 +154,34 @@ class CloudGameAdapter( card.strokeColor = android.graphics.Color.TRANSPARENT card.strokeWidth = 0 } + // 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) } + stylePlatformBadge(game.platform) - if (showOwnershipBadge && game.serviceType == "pscloud") { + // Acquisition-tag badge (unified page): Owned (green) / Streamable (blue) / + // Purchaseable (orange). The lib precomputes the category; render it verbatim. + val category = game.category + 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 49c1859f..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 @@ -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 @@ -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,78 +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( + CloudCategory.OWNED, + CloudCategory.STREAMABLE, + CloudCategory.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 - viewModel.fetchPs5CloudCatalog(showOnlyOwned = !currentlyOwned) - } - - // 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) @@ -508,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 ) @@ -527,143 +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 = false - binding.sortOptionLayout.visibility = android.view.View.VISIBLE - binding.filterOptionLayout.visibility = android.view.View.VISIBLE - updateSortButtonText() - updateFilterButtonText() - - // Update favorites icon to match new section - updateFavoritesIcon() - - viewModel.fetchPsnowCatalog() - } - - 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() - - if (isFavoritesFilter) { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false) - } else { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = 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() - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true) - } else { - viewModel.fetchPsnowCatalog(forceRefresh = 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 != CloudCategory.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 -> @@ -672,245 +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() - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true) - } - else - { - viewModel.fetchPsnowCatalog(forceRefresh = 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 - viewModel.fetchPsnowCatalog(forceRefresh = false) - } - } - 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 - preferences.setPsCloudFilterFavorites(false) - preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false) - } - 1 -> { - // Owned Games - preferences.setPsCloudFilterFavorites(false) - preferences.setPsCloudFilterOwned(true) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = true, forceRefresh = false) - } - 2 -> { - // Favorites - preferences.setPsCloudFilterFavorites(true) - preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false) - } - } - } else { - // Game Catalog - when (selectedItem) { - 0 -> { - // All Games - preferences.setPsnowFilterFavorites(false) - viewModel.fetchPsnowCatalog(forceRefresh = false) - } - 1 -> { - // Favorites - preferences.setPsnowFilterFavorites(true) - viewModel.fetchPsnowCatalog(forceRefresh = false) - } - } - } - - 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()) } @@ -952,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() @@ -988,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()) @@ -1032,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 { @@ -1052,9 +756,35 @@ 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() }) + + viewModel.fallbackRegion.observe(viewLifecycleOwner, Observer { region -> + updateRegionBanner(region) + }) + viewModel.catalogIsForeign.observe(viewLifecycleOwner, Observer { isForeign -> + updateRegionBanner(viewModel.fallbackRegion.value) + }) + } + + private fun updateRegionBanner(region: String?) + { + // 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" + 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) @@ -1137,10 +867,9 @@ class CloudPlayFragment : Fragment() private fun onGameClicked(game: CloudGame) { - val isPscloud = game.serviceType == "pscloud" - val isAllGamesFilter = !viewModel.preferences.getPsCloudFilterOwned() - - if (isPscloud && isAllGamesFilter && !game.isOwned) + // 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) @@ -1359,11 +1088,17 @@ class CloudPlayFragment : Fragment() try { val backend = CloudStreamingBackend(requireContext(), viewModel.preferences) + // 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 = game.serviceType, - gameIdentifier = PsCloudOwnership.streamingIdentifier(game), + serviceType = game.streamServiceType, + 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/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..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 @@ -16,72 +16,97 @@ 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 _catalogIsForeign = MutableLiveData() + val catalogIsForeign: LiveData get() = _catalogIsForeign + private val _searchQuery = MutableLiveData() val searchQuery: LiveData get() = _searchQuery - + private var allGames: List = emptyList() - private var currentSection: String = "psnow" // "psnow" or "pscloud" - + + // 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 + init { _loading.value = false _error.value = null _searchQuery.value = "" - - // Load last selected section from preferences - currentSection = preferences.getLastCloudSection() + _fallbackRegion.value = preferences.getCloudResolvedStoreCountry() + _catalogIsForeign.value = preferences.isCloudCatalogIsForeign() } - + /** - * 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) + fun fetchCatalog(forceRefresh: Boolean = false) { + if (fetchInProgress) + { + Log.i(TAG, "Catalog fetch already in progress; ignoring request") + return + } + fetchInProgress = true viewModelScope.launch { try { _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)) + Log.i(TAG, "Fetching unified cloud catalog (forceRefresh=$forceRefresh)") + + when (val result = repository.fetchUnifiedCatalog(npssoToken, forceRefresh)) { is PsnResult.Success -> { allGames = result.data - Log.i(TAG, "Successfully loaded ${allGames.size} games") - applySearchFilter() + 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 -> { @@ -97,180 +122,89 @@ 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) - { - 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() - } - 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.getCloudResolvedStoreCountry() + _catalogIsForeign.value = preferences.isCloudCatalogIsForeign() updateLocaleWarningIfNeeded() _loading.value = false + fetchInProgress = false } } } - - /** - * 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() { + // 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") } } - - /** - * 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()) return - if (!preferences.isCloudLanguageConfigured()) + if (!preferences.isCloudStoreLocaleConfigured()) _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/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/settings/SettingsFragment.kt b/android/app/src/main/java/com/metallic/chiaki/settings/SettingsFragment.kt index b2c95e0b..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,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.getCloudGameLanguage() 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.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 @@ -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.getCloudStoreLocale() + ) + bindCloudBitratePreference( preferenceScreen.findPreference(getString(R.string.preferences_cloud_bitrate_pscloud_key)), preferences @@ -488,4 +500,56 @@ 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() + + // 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/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/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-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/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" /> - + + + - + - + + + + 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 edf99742..8b8f3031 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 @@ -150,19 +151,20 @@ Logout Logged out successfully - - Game Library + + Cloud Settings + 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 @@ -292,6 +294,11 @@ 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. + 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 9be41b8b..48aaa6dc 100644 --- a/android/app/src/main/res/xml/preferences.xml +++ b/android/app/src/main/res/xml/preferences.xml @@ -103,6 +103,25 @@ app:icon="@drawable/ic_codec"/> + + + + + + + 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/android/gradle.properties b/android/gradle.properties index 50523eea..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/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/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index f750b97e..de9f4fbd 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,131 +44,80 @@ class CloudCatalogBackend : public QObject explicit CloudCatalogBackend(Settings *settings, QObject *parent = nullptr); ~CloudCatalogBackend(); - // Main catalog fetching methods - Q_INVOKABLE void fetchPsnowCatalog(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 (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()); + // 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 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); + // 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 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); + signals: - void catalogUpdated(); + // 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 handlePsnowCategoryResponse(); - void handlePs5ImagicListResponse(); - void finalizePs5CloudCatalogFetch(); - void handleOwnedGamesOAuthResponse(); - void fetchOwnedGamesPage(); - void handleOwnedGamesResponse(); void handleGameDetailsResponse(); - void processCrossReferenceComplete(); 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; - } psnowState; - - // 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; - } 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; - QMap componentIdsByProductId; // product_id -> all sibling entitlement ids (full list) - bool catalogFetched; - bool ownedGamesFetched; - } 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(); - void fetchOwnedGamesOAuthToken(); - void fetchPsnowOAuthToken(); - void fetchPsnowSession(); - void fetchPsnowStores(); - void fetchPsnowRootContainer(); - void handlePsnowOAuthResponse(); - void handlePsnowSessionResponse(); - void handlePsnowStoresResponse(); - void handlePsnowRootContainerResponse(); 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/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 0750ea8d..00000000 --- a/gui/include/cloudstreaming/pskamajisession.h +++ /dev/null @@ -1,138 +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 - -// ============================================================================ -// 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"; -} - -/** - * 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(); - - /** - * 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 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/include/cloudstreamingbackend.h b/gui/include/cloudstreamingbackend.h index 8c3d2412..181aa1ee 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 @@ -23,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 @@ -71,24 +70,27 @@ 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 + + // 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. 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) - void startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, - QString duid, - QString redirectUri, QString userAgent, QString oauthApiPath, - ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession); }; #endif // CLOUDSTREAMINGBACKEND_H diff --git a/gui/include/qmlsettings.h b/gui/include/qmlsettings.h index 272560b6..b575f5cf 100644 --- a/gui/include/qmlsettings.h +++ b/gui/include/qmlsettings.h @@ -17,13 +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 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) @@ -95,6 +95,10 @@ 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 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) Q_PROPERTY(bool mouseTouchEnabled READ mouseTouchEnabled WRITE setMouseTouchEnabled NOTIFY mouseTouchEnabledChanged) Q_PROPERTY(bool keyboardEnabled READ keyboardEnabled WRITE setKeyboardEnabled NOTIFY keyboardEnabledChanged) @@ -225,8 +229,10 @@ class QmlSettings : public QObject // PSCloud settings int cloudResolutionPSCloud() const; void setCloudResolutionPSCloud(int resolution); - QString cloudLanguagePSCloud() const; - void setCloudLanguagePSCloud(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; @@ -235,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; @@ -569,6 +573,17 @@ class QmlSettings : public QObject QString cloudCatalogFilter() const; void setCloudCatalogFilter(const QString &filter); + QString cloudResolvedStoreCountry() const; + void setCloudResolvedStoreCountry(const QString &country); + bool cloudCatalogNativeMode() const; + void setCloudCatalogNativeMode(bool native_mode); + + QString cloudTagFilters() const; + void setCloudTagFilters(const QString &filtersJson); + + int cloudSortState() const; + void setCloudSortState(int sortState); + QString cloudFavorites() const; void setCloudFavorites(const QString &favorites); @@ -643,18 +658,21 @@ class QmlSettings : public QObject Q_INVOKABLE QString stringForStreamMenuShortcut() const; Q_INVOKABLE QString getLicenseText() const; + // Cloud streaming language picker, backed by the shared libchiaki table. + Q_INVOKABLE QStringList cloudSupportedLanguages() const; + signals: void resolutionLocalPS4Changed(); void resolutionRemotePS4Changed(); void resolutionLocalPS5Changed(); void resolutionRemotePS5Changed(); void cloudResolutionPSCloudChanged(); - void cloudLanguagePSCloudChanged(); + void cloudStoreLocaleChanged(); + void cloudGameLanguageChanged(); void cloudDatacenterPSCloudChanged(); void cloudDatacentersJsonPSCloudChanged(); void cloudBitratePSCloudChanged(); void cloudResolutionPSNOWChanged(); - void cloudLanguagePSNOWChanged(); void cloudDatacenterPSNOWChanged(); void cloudDatacentersJsonPSNOWChanged(); void cloudBitratePSNOWChanged(); @@ -725,6 +743,10 @@ class QmlSettings : public QObject void lastSelectedCloudSectionChanged(); void cloudLibraryFilterChanged(); void cloudCatalogFilterChanged(); + void cloudResolvedStoreCountryChanged(); + void cloudCatalogNativeModeChanged(); + void cloudTagFiltersChanged(); + void cloudSortStateChanged(); void cloudFavoritesChanged(); void mouseTouchEnabledChanged(); void keyboardEnabledChanged(); diff --git a/gui/include/settings.h b/gui/include/settings.h index 3bec518f..8b33044b 100644 --- a/gui/include/settings.h +++ b/gui/include/settings.h @@ -308,8 +308,10 @@ class Settings : public QObject // PSCloud settings int GetCloudResolutionPSCloud() const; void SetCloudResolutionPSCloud(int resolution); - QString GetCloudLanguagePSCloud() const; - void SetCloudLanguagePSCloud(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 @@ -322,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 @@ -450,6 +450,23 @@ class Settings : public QObject QString GetCloudCatalogFilter() const; void SetCloudCatalogFilter(QString filter); + /** 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; + + /** 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); @@ -782,6 +799,7 @@ class Settings : public QObject void PlaceboSettingsUpdated(); void CloudDatacentersJsonPSCloudChanged(); void CloudDatacentersJsonPSNOWChanged(); + void NpssoTokenChanged(); }; #endif // CHIAKI_SETTINGS_H 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/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 993211f3..bf632eca 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 @@ -30,9 +30,6 @@ 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) @@ -44,1715 +41,248 @@ 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() { } -void CloudCatalogBackend::ensureCacheDirectory() -{ - QDir dir; - if (!dir.exists(cacheDirectory)) { - dir.mkpath(cacheDirectory); - if (settings && settings->GetLogVerbose()) { - qInfo() << "Created cache directory:" << cacheDirectory; - } - } -} - -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_v3"), maxAge); - if (cached.isEmpty()) - return QString(); - - const QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); - if (!doc.isObject()) { - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v3"))); - 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_v3"))); - 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; - 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; - 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; - 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; - 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; - 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; - 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; - 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()) { - // 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(); - } - } - } - - qInfo() << "[PSNOW] Session created successfully, fetching stores..."; - fetchPsnowStores(); -} - -void CloudCatalogBackend::fetchPsnowStores() +void CloudCatalogBackend::setSettings(Settings *new_settings) { - 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); + settings = new_settings; } -void CloudCatalogBackend::handlePsnowStoresResponse() +void CloudCatalogBackend::ensureCacheDirectory() { - 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; - 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; - 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; - 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; - 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); - - // 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(); -} - -namespace { - -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"), -}; - -static bool isPs5Game(const QJsonObject &gameObj) -{ - const QJsonArray devices = gameObj.value(QStringLiteral("device")).toArray(); - for (const QJsonValue &device : devices) { - if (device.toString() == QLatin1String("PS5")) - return true; - } - return false; -} - -static bool isPs5StreamingGame(const QJsonObject &gameObj) -{ - if (!gameObj.value(QStringLiteral("streamingSupported")).toBool()) - return false; - return isPs5Game(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(); -} - -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; -} - -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; -} - -static void mergeImagicListIntoPs5Catalog(const QString &categoryList, - const QJsonDocument &doc, - QMap &gamesByConceptId, - QMap &plusLibrarySupplementByProductId, - QMap &productIdAliases, - int &totalGamesSeen) -{ - 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(); - if (!isPs5Game(gameObj)) - continue; - - // Plus catalog titles excluded from public cloud browse (library-stream candidates) - if (categoryList == QLatin1String("plus-games-list") - && !gameObj.value(QStringLiteral("streamingSupported")).toBool()) { - const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - if (!productId.isEmpty()) - plusLibrarySupplementByProductId.insert(productId, gameObj); - continue; - } - - if (!isPs5StreamingGame(gameObj)) - continue; - - const QString key = ps5CloudConceptKey(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(); - if (!canonicalProductId.isEmpty() && productId != canonicalProductId - && !productIdAliases.contains(productId)) { - productIdAliases.insert(productId, canonicalProductId); - } - continue; - } - - gamesByConceptId.insert(key, gameObj); - } - } -} - -} // namespace - -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()) { - qInfo() << "[CACHE] Using cached PS5 cloud catalog"; - 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 catalog (6 imagic lists, cache miss or expired)"; - ps5State.callback = callback; - 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(); - - 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) { - 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(); - } - - QJsonObject result; - result.insert(QStringLiteral("locale"), - settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US")); - 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_v3"), 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 (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(); - - // Get entitlements from this page - QJsonArray pageEntitlements; - if (obj.contains("entitlements") && obj["entitlements"].isArray()) { - pageEntitlements = obj["entitlements"].toArray(); - } - - if (settings && settings->GetLogVerbose()) { - qInfo() << " Page entitlements:" << pageEntitlements.size(); - qInfo() << " Accumulated so far:" << ownedGamesState.accumulatedEntitlements.size(); - } - - // Accumulate entitlements from this page - for (const QJsonValue &ent : pageEntitlements) { - ownedGamesState.accumulatedEntitlements.append(ent); - } - - // 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(); + QDir dir; + if (!dir.exists(cacheDirectory)) { + dir.mkpath(cacheDirectory); if (settings && settings->GetLogVerbose()) { - qInfo() << " More pages to fetch... scheduling next page"; + qInfo() << "Created cache directory:" << cacheDirectory; } - // Apply 100ms cooldown between page requests to avoid rate limiting - QTimer::singleShot(100, this, &CloudCatalogBackend::fetchOwnedGamesPage); - return; } - - // 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(); - } - - // Filter for PS5 games (package_type=PSGD) - QJsonArray ps5Games = filterOwnedPs5Games(ownedGamesState.accumulatedEntitlements); +} + +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"; +} + +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; - QMap componentIds; - for (const QJsonValue &ent : ownedGamesState.accumulatedEntitlements) { - if (!ent.isObject()) + 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; - 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); + + // 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); + QFileInfo fileInfo(filePath); - if (settings && settings->GetLogVerbose()) { - qInfo() << " PS5 games (PSGD):" << ps5Games.size(); + if (!fileInfo.exists()) { + qInfo() << "[CACHE MISS] No cache file found for:" << key; + 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; + // 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(); + } - QJsonDocument resultDoc(result); + // Read file + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "[CACHE ERROR] Failed to open cache file:" << filePath; + return QString(); + } - // Cache the result - setCachedData("ps5_cloud_library", resultDoc); + QByteArray data = file.readAll(); + file.close(); - // If cross-reference is active, populate its state - if (crossReferenceState.callback.isCallable() && !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(); - } - } + qint64 ageSeconds = age / 1000; + qInfo() << "[CACHE HIT] Loaded cached data for:" << key << "(" << (data.size() / 1024) << "KB, age:" << ageSeconds << "seconds)"; - // Call callback - if (ownedGamesState.callback.isCallable()) { - QString jsonStr = QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)); - ownedGamesState.callback.call({true, "Success", QJSValue(jsonStr)}); - } + return QString::fromUtf8(data); } -QJsonArray CloudCatalogBackend::filterOwnedPs5Games(const QJsonArray &entitlements) +QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) { - 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); - } - } - } - } + 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(); } - - return ps5Games; + + 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 + << "!=" << expectedLocale << ", refetching"; + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); + return QString(); + } + + return cached; } -void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) +void CloudCatalogBackend::setCachedData(const QString &key, const QJsonDocument &data) { - // 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"; - } - } - 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); - } - } - } - } + QString filePath = getCacheFilePath(key); - // If we have both from cache, process immediately - if (catalogFromCache && ownedFromCache) { - processCrossReferenceComplete(); + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + qWarning() << "[CACHE ERROR] Failed to write cache file:" << filePath; 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()); - } + QByteArray jsonData = data.toJson(QJsonDocument::Compact); + file.write(jsonData); + file.close(); - if (!ownedFromCache) { - // Use empty callback - handler will check cross-reference state - fetchOwnedPs5Games(QJSValue()); + 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::fetchUnifiedCatalog(const QJSValue &callback) +{ + // 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; + + // 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; } + + const QByteArray npsso = getNpSsoToken().toUtf8(); + const QByteArray locale = + (settings ? settings->GetCloudStoreLocale() : 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 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 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->SetCloudResolvedStoreLang(root.value(QStringLiteral("resolvedStoreLang")).toString()); + settings->SetCloudCatalogNativeMode(root.value(QStringLiteral("nativeMode")).toBool(true)); + } + + 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) @@ -1784,7 +314,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") @@ -1905,52 +435,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; @@ -1996,7 +480,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") { @@ -2072,15 +555,14 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi } // Fallback to catalog (may not have landscape images) - cacheKey = "ps5_cloud_catalog_v3"; - isPsCloudLibrary = false; + cacheKey = "ps5_cloud_catalog_v6"; } else { qWarning() << "getGameLandscapeImage: Unknown service type:" << serviceType; return QString(); } // 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()) { @@ -2208,257 +690,11 @@ 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; - - 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); - } - } - - const QMap browseStableKey = - buildStableKeyIndex(crossReferenceState.cloudCatalogGames); - const QMap supplementStableKey = - buildStableKeyIndex(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] Owned games count:" << crossReferenceState.ownedGames.size(); - } - - QJsonArray filteredGames; - int matchedCount = 0; - int t1Count = 0; - int t2Count = 0; - int t3Count = 0; - int t4Count = 0; - QMap ownedByKey; - - for (const QJsonValue &ownedGame : crossReferenceState.ownedGames) { - if (!ownedGame.isObject()) - continue; - - QJsonObject ownedGameObj = ownedGame.toObject(); - 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); - - 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; - 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"))); - } - // 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); - 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 - : !entitlementId.isEmpty() ? QStringLiteral("e:") + entitlementId - : QStringLiteral("u:") + catalogProductId + 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()) - ownedByKey.insert(dedupeKey, entry); - } else { - ownedByKey.insert(dedupeKey, entry); - } - matchedCount++; - } - } - - for (const QJsonObject &gameObj : ownedByKey) - filteredGames.append(gameObj); - - 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; - } - - QJsonObject result; - result["games"] = filteredGames; - result["total"] = filteredGames.size(); - - QJsonDocument resultDoc(result); - - 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.ownedGames = QJsonArray(); - crossReferenceState.productIdAliases.clear(); - crossReferenceState.componentIdsByProductId.clear(); - crossReferenceState.catalogFetched = false; - crossReferenceState.ownedGamesFetched = false; -} - 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); @@ -2469,46 +705,15 @@ 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_v3"); - QString ps5CatalogV2Path = getCacheFilePath("ps5_cloud_catalog_v2"); - QString ps5LibraryPath = getCacheFilePath("ps5_cloud_library"); - - 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 (!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; + // 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/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 9cc4d09e..00000000 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ /dev/null @@ -1,1674 +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 -#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) - // Use unified language setting for both PSCloud and PSNOW - QString language = settings->GetCloudLanguagePSCloud(); - int resolution; - if (serviceType == "pscloud") { - resolution = settings->GetCloudResolutionPSCloud(); - } else { - // PSNOW - resolution = settings->GetCloudResolutionPSNOW(); - } - spec["language"] = language; - - // 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); - emit AllocationError(QString("Session start failed: %1").arg(reply->errorString())); - 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; - } - - // Save datacenters to settings (without ping results yet) - use service-specific method - 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 - allResults.append(pingResultsMap[datacenterName]); - } else { - // Use dummy data (999ms RTT) for datacenters that weren't pinged - 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(); - 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) - bool isAutoSelected = (selectedDatacenterSetting == "Auto" || selectedDatacenterSetting.isEmpty()); - if (isAutoSelected) { - int rtt_ms = selectedDatacenterPingResult["rtt"].toInt(0); - if (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; - } - } - - 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 71366781..00000000 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ /dev/null @@ -1,1299 +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::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; - } - - // 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() -{ - // Get locale from unified language setting - QString localeSetting = settings ? settings->GetCloudLanguagePSCloud() : "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(); - - 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; - } - } - - // Determine platform from playable_platform strings (pick highest: PS4 > PS3) - if (!playablePlatformArray.isEmpty()) { - bool hasPS4 = false; - bool hasPS3 = false; - for (const QJsonValue &platformValue : playablePlatformArray) { - QString platformStr = platformValue.toString(); - // Check for PS4 (handles "PS4™" and "PS4") - 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 (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) { - // User doesn't have entitlement - try to acquire it - qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire"; - - // Continue to checkout preview - 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/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 66161249..a3886234 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -1,12 +1,12 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL #include "cloudstreamingbackend.h" -#include "cloudstreaming/pskamajisession.h" -#include "cloudstreaming/psgaikaistreaming.h" #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" @@ -14,13 +14,12 @@ #include #include #include -#include -#include -#include #include #include #include #include +#include +#include extern "C" { #include @@ -32,7 +31,6 @@ CloudStreamingBackend::CloudStreamingBackend(Settings *settings, QObject *parent : QObject(parent) , settings(settings) , allocation_progress("") - , authManager(new QNetworkAccessManager(this)) { } @@ -45,7 +43,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 +51,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 +63,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,425 +79,355 @@ 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) { - // 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 +// 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 + 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(); + // 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; } - - // 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 + 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(); + if (gameLangStr.isEmpty()) + gameLangStr = settings->GetCloudStoreLocale(); + 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() + : 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(); + } } - - 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; - - 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 - connect(kamajiSession, &PSKamajiSession::psPlusSubscriptionError, this, [this]() { - QmlBackend *qmlBackend = qobject_cast(parent()); - 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) { - 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"), - QString("Session creation failed: %1").arg(message)); - } - - if (callback.isCallable()) { - callback.call({false, QString("Session creation failed: %1").arg(message)}); - } - kamajiSession->deleteLater(); - return; + Q_UNUSED(sharedDuid); // the C flow generates its own shared DUID for Kamaji+Gaikai + + setAllocationProgress(tr("Starting cloud session...")); + + std::thread([this, callback, svc, gameId, npsso, storeCountry, storeLang, gameLang, + 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); + + 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.prior_datacenters_json = priorDc.constData(); + 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(); + 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, 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); } - - 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 + if (success) { + finishCloudSession(serviceTypeStr, serverIp, serverPort, handshakeKey, launchSpec, + sessionId, wrap, mtuIn, mtuOut, rttUs, callback); } else { - platformTarget = CHIAKI_TARGET_PS4_9; // PS4 games use PS4 target + handleProvisionError(serviceTypeStr, errMsg, callback); } - - // Continue to Gaikai allocation with converted entitlementId - 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, - redirectUri, userAgent, oauthApiPath, target, callback, nullptr); - } + }).detach(); } -void CloudStreamingBackend::startGaikaiAllocation(QString serviceType, QString platform, QString entitlementId, - QString duid, - QString redirectUri, QString userAgent, QString oauthApiPath, - ChiakiTarget target, const QJSValue &callback, QObject *kamajiSession) +// 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) { - // Step 7-13: Complete Gaikai allocation - PSGaikaiStreaming *gaikaiStreaming = new PSGaikaiStreaming( + qInfo() << "=== COMPLETE CLOUD SESSION SUCCESS ==="; + qInfo() << " IP:" << serverIp << " Port:" << serverPort << " SessionId len:" << sessionId.length(); + qInfo() << " handshake len:" << handshakeKey.length() << " launchSpec len:" << launchSpec.length(); + + // 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); + + session->Start(); + qInfo() << "StreamSession Start() called (connection is asynchronous)"; + + if (callback.isCallable()) { + callback.call({ + true, + "Cloud session connection initiated (waiting for server response...)", + serverIp + }); } - }); - - // When Gaikai allocation fails - connect(gaikaiStreaming, &PSGaikaiStreaming::AllocationError, this, - [this, gaikaiStreaming, kamajiSession, callback](QString error) { - qWarning() << "Gaikai allocation failed:" << error; - - // Clear game image on error + } catch (const Exception &e) { + qWarning() << "Failed to start cloud streaming session:" << e.what(); 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)); - } - if (callback.isCallable()) { - callback.call({false, QString("Allocation failed: %1").arg(error)}); - } - gaikaiStreaming->deleteLater(); - if (kamajiSession) { - kamajiSession->deleteLater(); + 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()); + + // 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("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"))) { + // 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(upgradeUrl); + qmlBackend->setShowAccountPrivacySettingsDialog(true); } - }); - - // Start Gaikai allocation with entitlement ID - gaikaiStreaming->StartAllocationFlow(entitlementId, QJSValue()); + userMessage = tr("Account privacy settings need updating"); + } 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); + } + + if (qmlBackend) { + emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), userMessage); + } + + if (callback.isCallable()) { + callback.call({false, userMessage}); + } + + 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) @@ -528,78 +456,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); - }); -} - diff --git a/gui/src/qml/CloudGameCard.qml b/gui/src/qml/CloudGameCard.qml index a1648531..b145a3ea 100644 --- a/gui/src/qml/CloudGameCard.qml +++ b/gui/src/qml/CloudGameCard.qml @@ -12,15 +12,21 @@ 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 + // 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: 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) 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) @@ -38,6 +44,12 @@ 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() { + return !!(gameData && gameData.serviceType === "psnow"); + } + // Extract game information function getGameName() { if (!gameData) return qsTr("Unknown Game"); @@ -56,76 +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) { - // 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 ""; - } + return gameData.streamIdentifier || getProductId(); } - + + // Platform badge, precomputed (ps3/ps4/ps5). 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 "ps5"; - } + return (gameData && gameData.platform) ? gameData.platform : "ps4"; } - + + // serviceType selects the catalog/shortcut routing (psnow vs pscloud); precomputed. function getServiceType() { - return isPsnow ? "psnow" : "pscloud"; + return (gameData && gameData.serviceType) ? gameData.serviceType : "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() { @@ -178,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 @@ -298,22 +261,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" - + 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" @@ -412,11 +385,11 @@ 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 - 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; @@ -442,7 +415,7 @@ Rectangle { let platform = getPlatform(); let serviceType = getServiceType(); if (streamingId !== "") { - streamGame(streamingId, platform, serviceType); + streamGame(streamingId, platform, getStreamServiceType()); } } } @@ -451,7 +424,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 +504,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) { @@ -544,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 1ffc2fc4..e33afe19 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -17,24 +17,26 @@ 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 - property int currentPage: 0 - property int gamesPerPage: 25 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 - 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 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 + 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 { @@ -54,21 +56,19 @@ 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.cloudResolvedStoreCountry || ""; + catalogNativeMode = Chiaki.settings.cloudCatalogNativeMode; + 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 { @@ -78,37 +78,48 @@ 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(); + } + + // 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 + // 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 @@ -132,395 +143,165 @@ 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 + + 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]); } - - // Clear old cards immediately when starting to load - allGames = []; - filteredGames = []; - currentPageGames = []; - isLoading = true; - 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(); - // 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 { - console.error("Failed to fetch PSNOW catalog:", message); - allGames = []; - filteredGames = []; - currentPageGames = []; - showErrorToast(qsTr("API Error"), message || qsTr("Failed to fetch game catalog")); - } - }); - } - - 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; } - function ps5CloudStreamingId(game) { - if (!game) - return ""; - return game.id || ""; + function setTagFilters(tags) { + activeTagFilters = tags; + Chiaki.settings.cloudTagFilters = JSON.stringify(tags); + applySearchFilter(); } - 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 conceptId = ps5CloudConceptId(game); - if (conceptId) - byConceptId[conceptId] = i; - let streamId = ps5CloudStreamingId(game); - if (streamId && streamId !== productId) - byProductId[streamId] = i; - } - return { byProductId: byProductId, byConceptId: byConceptId }; + 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 registerPs5CloudGameInCatalogIndex(game, index, catalogIndex) { - let productId = ps5CloudProductId(game); - if (productId) - catalogIndex.byProductId[productId] = index; - let conceptId = ps5CloudConceptId(game); - if (conceptId) - catalogIndex.byConceptId[conceptId] = index; - let streamId = ps5CloudStreamingId(game); - if (streamId && streamId !== productId) - catalogIndex.byProductId[streamId] = index; + function isPlayableNow(game) { + return game && game.category !== "purchaseable"; } - 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]; - let conceptId = ps5CloudConceptId(ownedGame); - if (conceptId && catalogIndex.byConceptId.hasOwnProperty(conceptId)) - return catalogIndex.byConceptId[conceptId]; - return -1; + 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 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 ""; } - function mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames) { - 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); - - let catalogMatch = 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); - if (ownedProductId) { - if (!existing.product_id) - existing.product_id = ownedProductId; - if (!existing.productId) - existing.productId = ownedProductId; - } - games[catalogMatch] = existing; - continue; - } - - 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; - - if (libraryFilter === "all") { - // Fetch all streamable games from game catalog - 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"); - } - let merged = mergeOwnedPs5CloudIntoBrowseCatalog(data.games, ownedGames); - ownedProductIds = Array.from(merged.ownedIds); - allGames = merged.games; - isLoading = false; - - // 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 || ""; + 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) + 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 @@ -557,50 +338,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 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; @@ -628,7 +365,7 @@ Pane { left: parent.left right: parent.right } - height: 75 + height: 52 color: Qt.rgba(10/255, 20/255, 38/255, 0.95) @@ -648,21 +385,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 } @@ -690,14 +499,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; } @@ -758,6 +566,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 @@ -769,14 +579,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; } @@ -828,366 +638,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; + // invalidateCache() emits cacheInvalidated, which triggers the reload above. Chiaki.cloudCatalog.invalidateCache(); - if (currentSection === "catalog") { - loadPsnowCatalog(); - } else { - loadPs5CloudLibrary(); + } + + 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 @@ -1218,6 +957,44 @@ Pane { anchors.topMargin: 15 spacing: 0 + // 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 && 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 + 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 @@ -1347,7 +1124,7 @@ Pane { flickableDirection: Flickable.VerticalFlick boundsBehavior: Flickable.StopAtBounds - KeyNavigation.up: searchField + KeyNavigation.up: filterToggle model: currentPageGames highlightFollowsCurrentItem: true @@ -1370,8 +1147,6 @@ Pane { gameData: modelData focus: false // GridView handles focus, not individual cards activeFocusOnTab: false - isPsnow: currentSection === "catalog" - libraryFilter: root.libraryFilter qrCodeDialog: root.qrCodeDialogRef // Bind isFavorite to favoriteProductIds array changes @@ -1495,12 +1270,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; } @@ -1577,9 +1347,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(); } @@ -1596,10 +1368,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/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/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/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 e775491b..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 @@ -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.cloudStoreLocale || "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.cloudGameLanguage || ""; + let idx = languageValues.indexOf(sel); + return idx >= 0 ? idx : 0; + } + onActivated: index => { + // "" (Auto) clears the override; otherwise store the pick. + Chiaki.settings.cloudGameLanguage = 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/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/qmlbackend.cpp b/gui/src/qmlbackend.cpp index c1f86927..43e5bf68 100644 --- a/gui/src/qmlbackend.cpp +++ b/gui/src/qmlbackend.cpp @@ -146,8 +146,15 @@ 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]() { - cloud_catalog_backend->invalidatePs5CatalogCache(); + 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(); + }); + // 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/qmlsettings.cpp b/gui/src/qmlsettings.cpp index eb70398c..40ac8f5c 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 @@ -195,15 +197,35 @@ void QmlSettings::setCloudResolutionPSCloud(int resolution) emit cloudResolutionPSCloudChanged(); } -QString QmlSettings::cloudLanguagePSCloud() const +QString QmlSettings::cloudStoreLocale() const +{ + return settings->GetCloudStoreLocale(); +} + +void QmlSettings::setCloudStoreLocale(const QString &locale) +{ + settings->SetCloudStoreLocale(locale); + emit cloudStoreLocaleChanged(); +} + +QString QmlSettings::cloudGameLanguage() const +{ + return settings->GetCloudGameLanguage(); +} + +void QmlSettings::setCloudGameLanguage(const QString &language) { - return settings->GetCloudLanguagePSCloud(); + settings->SetCloudGameLanguage(language); + emit cloudGameLanguageChanged(); } -void QmlSettings::setCloudLanguagePSCloud(const QString &language) +QStringList QmlSettings::cloudSupportedLanguages() const { - settings->SetCloudLanguagePSCloud(language); - emit cloudLanguagePSCloudChanged(); + 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; } QString QmlSettings::cloudDatacenterPSCloud() const @@ -245,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(); @@ -838,6 +849,50 @@ void QmlSettings::setCloudCatalogFilter(const QString &filter) emit cloudCatalogFilterChanged(); } +QString QmlSettings::cloudResolvedStoreCountry() const +{ + return settings->GetCloudResolvedStoreCountry(); +} + +void QmlSettings::setCloudResolvedStoreCountry(const QString &country) +{ + settings->SetCloudResolvedStoreCountry(country); + emit cloudResolvedStoreCountryChanged(); +} + +bool QmlSettings::cloudCatalogNativeMode() const +{ + return settings->GetCloudCatalogNativeMode(); +} + +void QmlSettings::setCloudCatalogNativeMode(bool native_mode) +{ + settings->SetCloudCatalogNativeMode(native_mode); + emit cloudCatalogNativeModeChanged(); +} + +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..d1452a72 100644 --- a/gui/src/settings.cpp +++ b/gui/src/settings.cpp @@ -438,14 +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::GetCloudGameLanguage() const +{ + 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::SetCloudGameLanguage(const QString &language) +{ + settings.setValue(QStringLiteral("settings/cloud_game_language"), language); } QString Settings::GetCloudDatacenterPSCloud() const @@ -547,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) @@ -971,7 +982,16 @@ 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 + // cloud catalog so one account never sees another's owned games. + emit NpssoTokenChanged(); } bool Settings::GetAccountAttributesCheckPassed() const @@ -1024,6 +1044,73 @@ void Settings::SetCloudCatalogFilter(QString filter) settings.setValue("settings/cloud_catalog_filter", filter); } +QString Settings::GetCloudResolvedStoreCountry() const +{ + 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); +} + +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"); + 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::SetCloudCatalogNativeMode(bool native_mode) +{ + settings.setValue(QStringLiteral("settings/cloud_catalog_native_mode"), native_mode); +} + +bool Settings::IsCloudCatalogIsForeign() const +{ + return !GetCloudCatalogNativeMode(); +} + +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/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.xcodeproj/project.pbxproj b/ios/Pylux.xcodeproj/project.pbxproj index a42c861f..e5773e41 100644 --- a/ios/Pylux.xcodeproj/project.pbxproj +++ b/ios/Pylux.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 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 */; }; + 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 */; }; @@ -44,17 +46,13 @@ 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 */; }; - 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 */; }; - 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 */; }; @@ -90,6 +88,10 @@ 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 = ""; }; + 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 = ""; }; @@ -114,19 +116,14 @@ 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 = ""; }; 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 = ""; }; - 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 = ""; }; @@ -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 */, @@ -213,6 +208,10 @@ A1000119 /* HolepunchBridge.m */, A1000102 /* RegistBridge.h */, A1000103 /* RegistBridge.m */, + A10001C1 /* CloudCatalogBridge.h */, + A10001C2 /* CloudCatalogBridge.m */, + A10001D1 /* CloudProvisionBridge.h */, + A10001D2 /* CloudProvisionBridge.m */, A1000024 /* SessionEventReceiver.h */, A1000023 /* SessionEventReceiver.m */, A1000022 /* VideoDecoder.h */, @@ -235,11 +234,7 @@ isa = PBXGroup; children = ( A3000006 /* CloudCatalogService.swift */, - A3000008 /* PsCloudOwnership.swift */, - A3000002 /* CloudHttpClient.swift */, A3000005 /* CloudStreamingBackend.swift */, - A3000003 /* PSGaikaiStreaming.swift */, - A3000004 /* PSKamajiSession.swift */, A1000200 /* PictureInPictureManager.swift */, A1000120 /* PsnTokenManager.swift */, A1000130 /* PyluxLoginService.swift */, @@ -257,6 +252,7 @@ children = ( A1000125 /* AutoRegistrationView.swift */, A3000007 /* CloudPlayView.swift */, + A3000050 /* CachedAsyncImage.swift */, A1000036 /* ConnectInfoEntryView.swift */, A1000105 /* HostCardView.swift */, A1000108 /* ManualHostView.swift */, @@ -370,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 */, @@ -389,6 +384,8 @@ A1000038 /* ConnectInfoEntryView.swift in Sources */, 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 */, @@ -403,13 +400,10 @@ A1000128 /* SecureStore.swift in Sources */, 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 */, - 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/Bridge/ChiakiBridge.h b/ios/Pylux/Bridge/ChiakiBridge.h index a702fc43..b4597c7e 100644 --- a/ios/Pylux/Bridge/ChiakiBridge.h +++ b/ios/Pylux/Bridge/ChiakiBridge.h @@ -11,7 +11,8 @@ #import "DiscoveryBridge.h" #import "RegistBridge.h" #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/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/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/Bridge/CloudCatalogBridge.h b/ios/Pylux/Bridge/CloudCatalogBridge.h new file mode 100644 index 00000000..35c88987 --- /dev/null +++ b/ios/Pylux/Bridge/CloudCatalogBridge.h @@ -0,0 +1,39 @@ +// 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; + +/// 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; + +@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..fb417b1f --- /dev/null +++ b/ios/Pylux/Bridge/CloudCatalogBridge.m @@ -0,0 +1,75 @@ +// 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; +} + +@end diff --git a/ios/Pylux/Bridge/CloudProvisionBridge.h b/ios/Pylux/Bridge/CloudProvisionBridge.h new file mode 100644 index 00000000..43354267 --- /dev/null +++ b/ios/Pylux/Bridge/CloudProvisionBridge.h @@ -0,0 +1,52 @@ +// 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 + priorDatacentersJson:(NSString *)priorDatacentersJson + 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..ddf3d8e7 --- /dev/null +++ b/ios/Pylux/Bridge/CloudProvisionBridge.m @@ -0,0 +1,117 @@ +// 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 + priorDatacentersJson:(NSString *)priorDatacentersJson + 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.prior_datacenters_json = priorDatacentersJson.UTF8String; + 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; + // `?: @""` 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.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/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 83048d0b..29ca9415 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -6,45 +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 - - 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 = "") { - 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 + 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 } +} - /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. - var streamingIdentifier: String { - if serviceType.lowercased() == "pscloud" { - if !entitlementId.isEmpty { return entitlementId } - if !storeProductId.isEmpty { return storeProductId } - } - return id - } +// MARK: - Cloud catalog acquisition categories (lib contract "category" values) + +enum CloudCategory { + static let owned = "owned" + static let streamable = "streamable" + static let purchaseable = "purchaseable" } // MARK: - CloudStreamSession (matches Android CloudStreamSession.kt) @@ -74,20 +92,13 @@ 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" 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 } } @@ -98,42 +109,38 @@ 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 var errorDescription: String? { message } } -/// Kamaji session error -struct KamajiSessionError: Error, LocalizedError { - let message: String - 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." + } } -// 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. // MARK: - Gaikai Allocation Result @@ -165,19 +172,24 @@ 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 var imagicLocale: String { stored.lowercased() } - static func unconfiguredWarning() -> String { "Could not detect your PlayStation region. The catalog may not match your store." } @@ -191,25 +203,15 @@ 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 && locale == stored { - os_log(.info, log: cloudLocaleLog, - "Kamaji session locale unchanged: %{public}s", locale) - 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. + /// 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) { @@ -225,7 +227,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 { @@ -236,94 +240,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/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 9538b55a..4621d720 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -1,27 +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_v3.json" - private static let pscloudAllCacheFile = "pscloud_catalog.json" - private static let pscloudOwnedCacheFile = "pscloud_owned.json" - + /// 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) @@ -29,684 +23,61 @@ 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 - ] - } - - 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: d["platform"] as? String ?? "ps4", - 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 ?? "" - ) - } - - // 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 - 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") - if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { - os_log(.info, log: catalogLog, "PS5 catalog: using disk cache") - lastCatalogFetchWarning = nil - return cached - } - - 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 - } - return fetched - } - - 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 - } - - 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 } - - if categoryList == "plus-games-list", - (gameObj["streamingSupported"] as? Bool) != true { - let productId = gameObj["productId"] as? String ?? "" - if !productId.isEmpty, plusSupplementByProductId[productId] == nil { - plusSupplementByProductId[productId] = gameObj - } - continue - } - - guard isPs5StreamingGame(gameObj) else { continue } - let key = conceptKey(for: gameObj) - let productId = gameObj["productId"] as? String ?? "" - guard !key.isEmpty, !productId.isEmpty else { continue } - - if let existing = byConceptId[key] { - let canonicalProductId = existing["productId"] as? String ?? "" - if !canonicalProductId.isEmpty, productId != canonicalProductId, - productIdAliases[productId] == nil { - productIdAliases[productId] = canonicalProductId - } - continue - } - - byConceptId[key] = gameObj - 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 - ) - } - - private func isPs5Game(_ gameObj: [String: Any]) -> Bool { - guard let devices = gameObj["device"] as? [String] else { return false } - return devices.contains("PS5") - } - - private func isPs5StreamingGame(_ 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") - } - - 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 ?? "" - } - - 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, - platform: "ps5", serviceType: "pscloud", - conceptUrl: conceptUrl, conceptId: conceptKey(for: gameObj), - isOwned: 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] { + /// 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 - 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 [] } + lastCatalogFetchWarning = nil - 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 + var errorMessage: NSString? + let json = PyluxCloudCatalog.fetchUnifiedJSON( + withNpsso: npssoToken.isEmpty ? nil : npssoToken, + locale: CloudLocaleSettings.stored, + cacheDir: Self.cacheDir.path, + forceRefresh: forceRefresh, + errorMessage: &errorMessage ) - 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: - 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." + 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 [] } - if !owned.isEmpty { - cacheGames(owned, filename: Self.pscloudOwnedCacheFile) - } - - os_log(.info, log: catalogLog, "Owned streaming games: %d", owned.count) - return owned + return parseUnifiedCatalog(json) } - /// Mirrors CloudCatalogBackend::getOwnedPs5CloudGames orchestration (network path). - private func getOwnedPs5CloudGames( - npssoToken: String, - publicCatalog: [CloudGame], - plusLibrarySupplement: [CloudGame] = [], - productIdAliases: [String: String] = [:] - ) -> [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) } - 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) - - return PsCloudOwnership.crossReferenceOwnedGames( - filteredEntitlements: filtered, - publicCatalog: publicCatalog, - plusLibrarySupplement: plusLibrarySupplement, - productIdAliases: productIdAliases, - componentIdsByProductId: componentIdsByProductId - ) - } - - 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: - PSNow Catalog - - /// Fetch PSNow catalog (PS3/PS4 games) - 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: - 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 } - - 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" } - } + 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 } - 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/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 aa6cb627..7023bee8 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 { @@ -36,161 +38,147 @@ 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 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, gameName: gameName, npssoToken: npssoToken, - sharedDuid: sharedDuid, + ownedEntitlementId: ownedEntitlementId, + ownedPlatform: ownedPlatform, onProgress: onProgress, isCancelled: isCancelled ) } - // 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, gameName: String, npssoToken: String, - sharedDuid: String, + ownedEntitlementId: String = "", + ownedPlatform: String = "", 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 - - // 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 - ) - let kamajiResult = kamajiSession.startSessionCreation(npssoToken: npssoToken) - 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) + 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)) + + // 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) } ?? "" + + // 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: String + let storeLang: String + if !resolvedCountry.isEmpty { + storeCountry = resolvedCountry + storeLang = resolvedLang.isEmpty ? localeLang : resolvedLang } else { - os_log(.info, log: cloudLog, "=== PSCLOUD Flow: Skipping Kamaji ===") + storeCountry = localeCountry + storeLang = localeLang } - // 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 allocationResult = try gaikai.startAllocationFlow(entitlementId: finalEntitlementId) - guard allocationResult.success else { - throw GaikaiAllocationError(message: "Gaikai allocation failed: \(allocationResult.message)") - } - - 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, + let result = PyluxCloudProvision.provision( + withServiceType: serviceType, + gameIdentifier: gameIdentifier, gameName: gameName, - platform: finalPlatform, - psnWrapperType: allocationResult.psnWrapperType, - mtuIn: allocationResult.mtuIn, - mtuOut: allocationResult.mtuOut, - rttMs: allocationResult.rttMs, - serviceType: serviceType + npsso: npssoToken, + storeCountry: storeCountry, + storeLang: storeLang, + gameLanguage: gameLanguage, + ownedEntitlementId: ownedEntitlementId, + ownedPlatform: ownedPlatform, + forcedDatacenter: forcedDatacenter, + priorDatacentersJson: priorDatacentersJson, + catalogIsForeign: SecureStore.shared.isCloudCatalogIsForeign, + resolution: resolution, + bitrateKbps: bitrate, + onProgress: { stage in onProgress?(stage) }, + isCancelled: { isCancelled() } ) - } - - // MARK: - Authorization Check (matches Qt lines 543-613) - private func checkAuthorization(serviceType: String, npssoToken: String, duid: String) -> Bool { - guard !npssoToken.isEmpty else { return false } + // 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) + } - let kamajiClientId: String - let scopesStr: String - let redirectUri: String - let userAgent: String + 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 + ) + } - if serviceType == "psnow" { - kamajiClientId = CloudApiConstants.kamajiClientId - scopesStr = CloudApiConstants.ps4Scopes - redirectUri = CloudApiConstants.kamajiRedirectUri - userAgent = CloudApiConstants.kamajiUserAgent + // 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("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("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("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 { - 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 + throw GaikaiAllocationError(message: msg) } - - 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 edee59e3..00000000 --- a/ios/Pylux/Services/PSGaikaiStreaming.swift +++ /dev/null @@ -1,919 +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 - ]), response.statusCode == 200 else { - throw GaikaiAllocationError(message: "Failed to start session") - } - - 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) - } - - // Raw list for Settings (matches Android step 11 — before ping) - 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 - ] - 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" - let cloudLanguage = CloudLocaleSettings.stored - 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 6ce3325c..00000000 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ /dev/null @@ -1,305 +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? - - 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 - } - - /// 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) - - // 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 - } - - 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" - os_log(.info, log: kamajiLog, "Store container locale: %{public}s", CloudLocaleSettings.stored) - - 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" - - // 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]] { - 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 - } - } - } - - // Fallback to skus array - 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 - } - } - } - if !eid.isEmpty { break } - } - } - - // Detect platform - if let platforms = json["playable_platform"] as? [String] { - 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 } - - // 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 - } -} diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift deleted file mode 100644 index d6778e95..00000000 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ /dev/null @@ -1,246 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL - -import Foundation - -/// Raw entitlement fields from Sony internal_entitlements API. -struct PsCloudEntitlement { - let id: String - let productId: String - let activeFlag: Bool - let packageType: String - let name: 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 - guard ent.packageType == "PSGD" else { return false } - guard ent.activeFlag else { return false } - let pid = ent.productId - guard !pid.hasPrefix("IP"), !pid.hasPrefix("SUB") 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) - - var byKey: [String: CloudGame] = [:] - - func emitMatch(meta: CloudGame, ent: PsCloudEntitlement) { - let displayName = meta.name.isEmpty ? ent.name : meta.name - let game = CloudGame( - productId: meta.id, - name: displayName, - imageUrl: meta.imageUrl, - landscapeImageUrl: meta.landscapeImageUrl, - platform: meta.platform, - serviceType: meta.serviceType, - conceptUrl: meta.conceptUrl, - conceptId: meta.conceptId, - isOwned: true, - entitlementId: ent.id, - storeProductId: ent.productId - ) - - let key = ownedDedupeKey(meta: meta, ent: ent) - if let existing = byKey[key] { - byKey[key] = preferOwnedEntry(existing: existing, candidate: game) - } else { - byKey[key] = game - } - } - - for ent in filteredEntitlements { - let skipStableDemo = ent.name.localizedCaseInsensitiveContains("demo") - var matches: [CloudGame] = [] - - if !ent.productId.isEmpty, let g = catalogMap[ent.productId] { - matches.append(g) - } else if !ent.id.isEmpty, let g = catalogMap[ent.id] { - matches.append(g) - } else if !ent.productId.isEmpty, ent.id == ent.productId, - let g = supplementMap[ent.productId] { - matches.append(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) - } - } - - 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 matches.isEmpty { continue } - - for meta in matches { - emitMatch(meta: meta, ent: ent) - } - } - - return Array(byKey.values) - } - - private static func ownedDedupeKey(meta: CloudGame, ent: PsCloudEntitlement) -> String { - if !meta.conceptId.isEmpty { return "c:\(meta.conceptId)" } - 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 - } - - /// 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 - } - - static func mergeOwnedIntoBrowseCatalog( - browseCatalog: [CloudGame], - ownedCrossRef: [CloudGame] - ) -> [CloudGame] { - var games = browseCatalog - var catalogIndex = buildCatalogIndex(games) - - for owned in ownedCrossRef { - let catalogMatch = 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 - } - - 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 - 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 - ) - } - - 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 } - if !game.conceptId.isEmpty { catalogIndex.byConceptId[game.conceptId] = index } - if !game.entitlementId.isEmpty, game.entitlementId != game.id { - catalogIndex.byProductId[game.entitlementId] = index - } - } - - 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 } - if !owned.storeProductId.isEmpty, let idx = catalogIndex.byProductId[owned.storeProductId] { return idx } - if !owned.conceptId.isEmpty, let idx = catalogIndex.byConceptId[owned.conceptId] { return idx } - return -1 - } -} diff --git a/ios/Pylux/Services/SecureStore.swift b/ios/Pylux/Services/SecureStore.swift index a38ac8ec..130e08ed 100644 --- a/ios/Pylux/Services/SecureStore.swift +++ b/ios/Pylux/Services/SecureStore.swift @@ -156,6 +156,11 @@ final class SecureStore { // Cloud 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" // Donation / support paywall private let kTotalStreamTimeMs = "pylux.totalStreamTimeMs" @@ -178,7 +183,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 { @@ -275,6 +288,49 @@ final class SecureStore { set { KC.writeInt(kCloudSortState, 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) + } + } + + /// 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 { + return KC.readBool(kCloudCatalogNativeMode, default: true) + } + let native = cloudResolvedStoreCountry.isEmpty + KC.writeBool(kCloudCatalogNativeMode, native) + return native + } + set { KC.writeBool(kCloudCatalogNativeMode, newValue) } + } + + var isCloudCatalogIsForeign: Bool { !cloudCatalogNativeMode } + + /// 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 { @@ -360,6 +416,8 @@ final class SecureStore { kStreamPrefs, kDcPscloud, kDcPsnow, kCloudFavorites, kCloudSortState, + kCloudResolvedStoreCountry, kCloudResolvedStoreLang, kLegacyCloudFallbackRegion, kCloudCatalogNativeMode, + kCloudTagFilters, kTotalStreamTimeMs, kLastDonationPromptWallMs, kDonationPaywallShowCount, kLastAppReviewPromptTotalStreamMs, kLastHost, kLastRegistKey, kLastMorning, kLastPs5, 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/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 bd3bea81..3ebc8358 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 = [ + CloudCategory.owned, + CloudCategory.streamable, + CloudCategory.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,12 @@ 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.cloudResolvedStoreCountry + @Published var catalogIsForeign: Bool = SecureStore.shared.isCloudCatalogIsForeign @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 +62,42 @@ 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 { activeTagFilters.contains($0.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 p0 = $0.category != CloudCategory.purchaseable + let p1 = $1.category != CloudCategory.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,59 +106,65 @@ 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 } - let loadedGames: [CloudGame] - - switch section { - case .catalog: - loadedGames = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken) - case .library: - if ownedOnly { - loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken) - } else { - loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken) - } - } - + 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.cloudResolvedStoreCountry + catalogIsForeign = SecureStore.shared.isCloudCatalogIsForeign 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 !CloudLocaleSettings.isConfigured { + warning = CloudLocaleSettings.unconfiguredWarning() } } @@ -156,11 +174,8 @@ final class CloudPlayViewModel: ObservableObject { loading = true error = nil warning = nil - let section = currentSection - let ownedOnly = showOwnedOnly Task.detached(priority: .userInitiated) { [weak self] in - let loadedGames: [CloudGame] defer { Task { @MainActor in self?.loading = false @@ -168,20 +183,11 @@ final class CloudPlayViewModel: ObservableObject { } } guard let self = self else { return } - - switch section { - case .catalog: - loadedGames = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken, forceRefresh: true) - case .library: - if ownedOnly { - loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: true) - } else { - loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken, forceRefresh: true) - } - } - + let loadedGames = self.catalogService.fetchUnifiedCatalog( + npssoToken: npssoToken, forceRefresh: true + ) await MainActor.run { - self.applyLoadedGames(loadedGames, section: section) + self.applyLoadedGames(loadedGames) } } } @@ -195,9 +201,11 @@ final class CloudPlayViewModel: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let gameIdentifier = game.streamingIdentifier + // 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.serviceType + let serviceType = game.streamServiceType var cancelled = false do { @@ -206,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 @@ -280,9 +292,24 @@ struct CloudPlayView: View { signInPrompt } else { VStack(spacing: 0) { - // Sub-tabs: Catalog / Library cloudSubTabs + // 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)) + .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) @@ -324,19 +351,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 { @@ -396,52 +410,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)) { @@ -462,7 +474,7 @@ struct CloudPlayView: View { viewModel.persistSortOrder() } label: { HStack { - Text(order.label(for: viewModel.currentSection)) + Text(order.label) if viewModel.sortOrder == order { Image(systemName: "checkmark") } @@ -476,17 +488,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) @@ -538,11 +539,8 @@ struct CloudPlayView: View { .padding(.vertical, 8) } - /// Matches Android `CloudPlayFragment.onGameClicked`: PS Cloud + All filter + not owned → add-to-library, else stream. private func handleGameTap(_ game: CloudGame) { - let isPscloud = game.serviceType.lowercased() == "pscloud" - let isAllGamesFilter = !viewModel.showOwnedOnly - if viewModel.currentSection == .library && isPscloud && isAllGamesFilter && !game.isOwned { + if game.category == CloudCategory.purchaseable { let url = game.conceptUrl.trimmingCharacters(in: .whitespacesAndNewlines) if url.isEmpty { showMissingConceptAlert = true @@ -574,8 +572,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) @@ -593,7 +600,6 @@ struct CloudPlayView: View { CloudGameCardView( game: game, isFavorite: viewModel.favoriteIds.contains(game.id), - showOwnershipBadge: viewModel.currentSection == .library, onTap: { handleGameTap(game) }, @@ -801,7 +807,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 @@ -826,12 +832,13 @@ 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 { game.category } + var body: some View { GeometryReader { geo in ZStack { @@ -844,26 +851,25 @@ struct CloudGameCardView: View { bottomOverlay } - // Layer 3: Top overlays - ownership 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) { - // 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() @@ -888,7 +894,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) @@ -900,9 +906,33 @@ struct CloudGameCardView: View { .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } + @ViewBuilder + private var categoryBadge: some View { + let (label, color): (String, Color) = { + switch displayCategory { + case CloudCategory.owned: + return ("Owned", Color(red: 0.30, green: 0.69, blue: 0.31)) // #4CAF50 + 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 + } + }() + 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), @@ -928,30 +958,24 @@ struct CloudGameCardView: View { } } } - .id(game.imageUrl) .allowsHitTesting(false) } 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( @@ -967,6 +991,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 44a91afd..42b51ab5 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 @@ -81,6 +83,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. + var cloudGameLanguage: String = "" + static let cloudBitrateMinKbps = 2000 static let cloudBitrateMaxKbps = 200_000 static let cloudBitrateDefaultKbps = 20000 @@ -91,6 +97,8 @@ struct StreamPreferences: Codable { case onScreenControlsEnabled, touchpadOnlyEnabled case cloudResolutionPscloud, cloudDatacenterPscloud, cloudBitratePscloud case cloudResolutionPsnow, cloudDatacenterPsnow, cloudBitratePsnow + case cloudGameLanguage + case cloudLanguage // legacy key } init( @@ -110,7 +118,8 @@ struct StreamPreferences: Codable { cloudBitratePscloud: Int = StreamPreferences.cloudBitrateDefaultKbps, cloudResolutionPsnow: String = "720", cloudDatacenterPsnow: String = "Auto", - cloudBitratePsnow: Int = StreamPreferences.cloudBitrateDefaultKbps + cloudBitratePsnow: Int = StreamPreferences.cloudBitrateDefaultKbps, + cloudGameLanguage: String = "" ) { self.resolutionIndex = resolutionIndex self.fps = fps @@ -129,6 +138,7 @@ struct StreamPreferences: Codable { self.cloudResolutionPsnow = cloudResolutionPsnow self.cloudDatacenterPsnow = cloudDatacenterPsnow self.cloudBitratePsnow = Self.clampCloudBitrateKbps(cloudBitratePsnow) + self.cloudGameLanguage = cloudGameLanguage } init(from decoder: Decoder) throws { @@ -154,6 +164,8 @@ struct StreamPreferences: Codable { cloudBitratePsnow = Self.clampCloudBitrateKbps( try c.decodeIfPresent(Int.self, forKey: .cloudBitratePsnow) ?? Self.cloudBitrateDefaultKbps ) + cloudGameLanguage = try c.decodeIfPresent(String.self, forKey: .cloudGameLanguage) + ?? c.decodeIfPresent(String.self, forKey: .cloudLanguage) ?? "" } func encode(to encoder: Encoder) throws { @@ -175,6 +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(cloudGameLanguage, forKey: .cloudGameLanguage) } static func clampCloudBitrateKbps(_ kbps: Int) -> Int { @@ -240,7 +253,18 @@ struct StreamPreferences: Codable { // MARK: - Datacenter list storage (matches Android cloud_datacenters_json_*) enum CloudDatacenterStore { - /// Save datacenter list after allocation (called from PSGaikaiStreaming) + /// 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 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" { @@ -286,6 +310,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 +329,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) @@ -520,7 +548,29 @@ struct SettingsView: View { label: "Bitrate" ) } header: { - Text("Game Library") + Text("Owned Games (PS5)") + } + } + + // 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.") } } @@ -547,7 +597,7 @@ struct SettingsView: View { label: "Bitrate" ) } header: { - Text("Game Catalog") + Text("Streamable Games (PS3/PS4)") } } @@ -570,6 +620,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.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.cloudGameLanguage = 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/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/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/lib/CMakeLists.txt b/lib/CMakeLists.txt index daa8bc6c..77b0f8b7 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -40,6 +40,8 @@ set(HEADER_FILES include/chiaki/opusencoder.h 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) @@ -86,6 +88,19 @@ 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/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) @@ -220,4 +235,13 @@ 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) + + 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/include/chiaki/cloudcatalog.h b/lib/include/chiaki/cloudcatalog.h new file mode 100644 index 00000000..a1b038ac --- /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. + * 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 +{ + 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": 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 + * "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); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CLOUDCATALOG_H diff --git a/lib/include/chiaki/cloudsession.h b/lib/include/chiaki/cloudsession.h new file mode 100644 index 00000000..c9338dec --- /dev/null +++ b/lib/include/chiaki/cloudsession.h @@ -0,0 +1,105 @@ +// 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 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 */ + 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 *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 *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) */ + + /** 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" */ + 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 */ + 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); + +#ifdef __cplusplus +} +#endif + +#endif // CHIAKI_CLOUDSESSION_H 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/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/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..d4a074f8 --- /dev/null +++ b/lib/src/cloudcatalog_consts.c @@ -0,0 +1,156 @@ +// 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 picker locales (display order). Platforms render +// localized names; datacenter selection is independent of language choice. +// --------------------------------------------------------------------------- + +// 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] : ""; +} diff --git a/lib/src/cloudcatalog_fetch.c b/lib/src/cloudcatalog_fetch.c new file mode 100644 index 00000000..8c86a47a --- /dev/null +++ b/lib/src/cloudcatalog_fetch.c @@ -0,0 +1,855 @@ +// 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; +} + +// Parse .../container/{CC}/{lang}/19/... from a store 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) + 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_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); + 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); + cc_parse_container_store_locale(base, out_store_country, cc_sz, out_store_lang, lang_sz); + 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, + 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; + + 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), + 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); + + 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..cc9feaaf --- /dev/null +++ b/lib/src/cloudcatalog_internal.h @@ -0,0 +1,236 @@ +// 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"|... 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; + +/** + * 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; + +/** + * 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 / + * 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_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); + +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..fc73071c --- /dev/null +++ b/lib/src/cloudcatalog_merge.c @@ -0,0 +1,1358 @@ +// 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) +{ + // 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"; +} + +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 : ""); + 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 : ""); + 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..a2e25b7f --- /dev/null +++ b/lib/src/cloudcatalog_unified.c @@ -0,0 +1,404 @@ +// 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. +// +// ============================================================================= +// 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" + +#include + +#include +#include +#include +#include +#include // strcasecmp + +#define WARNING_EXPIRED \ + "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) +{ + 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] = ""; + 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), + 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; + 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)); + CHIAKI_LOGI(log, "[UNIFIED] resolvedStoreCountry=%s (fallback cc_classics)", fallback_region); + } + 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; + // 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); + + // 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/cloudsession.c b/lib/src/cloudsession.c new file mode 100644 index 00000000..c15a8fd6 --- /dev/null +++ b/lib/src/cloudsession.c @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL +// +// Cloud session provisioning -- orchestrator + public entry points. +// 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) +{ + 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; +} + +// --- 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 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 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" +#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 < 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; +} + +// 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, + ChiakiLog *log) +{ + if(!out) + return CHIAKI_ERR_INVALID_DATA; + 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; + } + + // 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; + } + + // 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 + // (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; +} diff --git a/lib/src/cloudsession_gaikai.c b/lib/src/cloudsession_gaikai.c new file mode 100644 index 00000000..f742ce44 --- /dev/null +++ b/lib/src/cloudsession_gaikai.c @@ -0,0 +1,995 @@ +// 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 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 +#include // parallel datacenter ping +// json-c: the specific headers come via cloudcatalog_internal.h (the umbrella +// is not present in the iOS/Android FetchContent build). + +#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 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 +#define MAX_ALLOC_WAIT_S 900 + +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; + const char *oauth_api_path; // "/api/authz/v3" | "/api/v1" + const char *redirect_uri; + 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; + 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 (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; + 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) +{ + 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; +} + +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 = (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; +} + +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; } + 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]; + { + 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 + { + 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 + return s; +} + +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); + 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; + 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 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); + return c->gk_client_id[0] ? CHIAKI_ERR_SUCCESS : CHIAKI_ERR_UNKNOWN; +} + +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 *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; + 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 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); + 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 + { + 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, ChiakiCloudProvisionResult *out, 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"); + 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; + } + 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); + // 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) + { + 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; + } + if(attempt == MAX_LOCK_RETRIES) break; + 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; +} + +// 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"); +} + +// 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; +} + +// 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(); + 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"); + // 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 + 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) +{ + 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(!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; + } + 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) { 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)); + CHIAKI_LOGI(c->log, "[GAIKAI] forced datacenter %s (ping bypassed)", forced); + } + 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)); + 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); + 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) + 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++) + { + 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)); + else + 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); free(threaded); + // 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)); + 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); + 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); + c->ping_timeout = true; + 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; + + 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 }; + 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, attempt = 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); + // 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)); + + 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) + { + attempt++; + 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; + // 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, prog); + 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 *duid, + const char *platform, const char *entitlement_id, + ChiakiCloudProvisionResult *out) +{ + GaikaiCtx c; + memset(&c, 0, sizeof(c)); + c.log = log; + c.cfg = cfg; + 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"; + + // 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 : ""); + + 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) 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, 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); + if(e == CHIAKI_ERR_SUCCESS) e = gk_step13_allocate(&c, out); + + // 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.dc_picker); + 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"); + 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); + 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/src/cloudsession_internal.h b/lib/src/cloudsession_internal.h new file mode 100644 index 00000000..e518c392 --- /dev/null +++ b/lib/src/cloudsession_internal.h @@ -0,0 +1,91 @@ +// 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 + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +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 + * "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 + * 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; @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 char *duid, + 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; + * 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_kamaji.c b/lib/src/cloudsession_kamaji.c new file mode 100644 index 00000000..b90c226f --- /dev/null +++ b/lib/src/cloudsession_kamaji.c @@ -0,0 +1,653 @@ +// 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" +// json-c: the specific headers come via cloudcatalog_internal.h (the umbrella +// is not present in the iOS/Android FetchContent build). + +#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 CS_PSNOW_CLIENT_ID // shared (cloudsession_internal.h) +#define KM_COMMERCE_CLIENT_ID "dc523cc2-b51b-4190-bff0-3397c06871b3" +#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" +#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 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" + "&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 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; } + 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). +// 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; + 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(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 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]; + 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; +} + +// 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; + 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); + 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"; + 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: 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) + { + 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; +} + +static ChiakiErrorCode km_checkout_acquire(KamajiCtx *c, char **out_error) +{ + 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; + 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]; + 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; + 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; } + 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) + { + 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; + } + 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 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); + 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); + 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; + 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 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" + "&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 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; } + 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; +} 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; +} 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 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/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/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) { 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/lib/test_cloudsession/cloudsession-probe.c b/lib/test_cloudsession/cloudsession-probe.c new file mode 100644 index 00000000..dca89428 --- /dev/null +++ b/lib/test_cloudsession/cloudsession-probe.c @@ -0,0 +1,80 @@ +// 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] +// 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 + +#include "cloudsession_internal.h" // cc_kamaji_resolve + +#include +#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; + 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 = env_or("SERVICE", "psnow"); + cfg.game_identifier = product; + cfg.game_name = "probe"; + cfg.npsso = npsso; + 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.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")); + + 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, "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(" 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; + } + + char ent[128] = "", plat[8] = ""; + char *err = NULL; + 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; +} diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 9b49310d..e94c4022 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..." @@ -341,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" @@ -451,7 +545,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 +617,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):" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 16c0f73f..e342ee26 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -15,8 +15,13 @@ add_executable(chiaki-unit test_log.c test_log.h bitstream.c - regist.c) + regist.c + cloudcatalog_merge.c + cloudsession_kamaji.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..f5ea7f25 --- /dev/null +++ b/test/cloudcatalog_merge.c @@ -0,0 +1,332 @@ +// 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"); + + // 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; +} + +// 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 }, + { "/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 }, + { "/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 } +}; 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 132b4e64..6ef0f6d2 100644 --- a/test/main.c +++ b/test/main.c @@ -12,6 +12,8 @@ extern MunitTest tests_takion[]; 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[] = { { @@ -84,6 +86,20 @@ static MunitSuite suites[] = { 1, MUNIT_SUITE_OPTION_NONE }, + { + "/cloudcatalog_merge", + tests_cloudcatalog_merge, + NULL, + 1, + MUNIT_SUITE_OPTION_NONE + }, + { + "/cloudsession_kamaji", + tests_cloudsession_kamaji, + NULL, + 1, + MUNIT_SUITE_OPTION_NONE + }, { NULL, NULL, NULL, 0, MUNIT_SUITE_OPTION_NONE } };