diff --git a/README.md b/README.md index 1c2a4eb..20338d8 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,18 @@ Dead Island 2 update (61.6 GB) downloaded and verified byte-perfect across several reboots. Install works for same-region patches, where Sony stores the patch bytes under -the installed game's own title id. +the installed game's own title id. PatchDL now mirrors etaHEN's native +DirectPKGInstaller call shape for that path: it passes an empty +`MetaInfo.content_id`, lets AppInstUtil bind the signed package metadata, keeps +the returned content id, and exposes `/api/installstatus` for installer progress +when `sceAppInstUtilGetInstallStatus` is exported. Cross-region patches are a known limitation. Sony sometimes packages a regional patch under a different (master) storage title and ships it as a debug-magic container. PatchDL downloads such a patch and verifies it against Sony's hashes, -but the on-console installer (`InstallByPackage`) rejects it on 11.60, and the -homebrew alternative (BGFT register) returns "not supported" outside the system -process. Installing that class of patch needs Sony's authenticated updater, which -nanoDNS blocks. The web UI marks a title "Installing…" and reads progress from the -PS5's own notifications. Disc games need the disc inserted for their patch to -apply, which is a normal Sony requirement. +but refuses to install it from the standalone ELF because `InstallByPackage` +does not retarget signed package metadata on 11.60, and the homebrew alternative +(BGFT register) returns "not supported" outside the system process. Installing +that class of patch needs Sony's authenticated updater, which nanoDNS blocks. +Disc games need the disc inserted for their patch to apply, which is a normal +Sony requirement. diff --git a/src/patchdl_install.c b/src/patchdl_install.c index 3cb6f15..b6a7650 100644 --- a/src/patchdl_install.c +++ b/src/patchdl_install.c @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -45,6 +46,34 @@ typedef struct { long unknown[810]; } ai_playgo_info_t; +typedef struct { + int32_t error_code; + int32_t version; + char description[512]; + char type[9]; +} ai_install_error_t; + +typedef struct { + char status[16]; + char src_type[8]; + uint32_t remain_time; + uint64_t downloaded_size; + uint64_t initial_chunk_size; + uint64_t total_size; + uint32_t promote_progress; + ai_install_error_t error_info; + int32_t local_copy_percent; + bool is_copy_only; +} ai_install_status_t; + +#define STATIC_ASSERT(c, n) typedef char static_assert_##n[(c) ? 1 : -1] +STATIC_ASSERT(sizeof(ai_pkg_info_t) == 0x38, pkg_info_size); +STATIC_ASSERT(sizeof(ai_meta_info_t) == (6 * sizeof(void *)), meta_info_size); +STATIC_ASSERT(sizeof(ai_playgo_info_t) == 0x2700, playgo_info_size); +STATIC_ASSERT(offsetof(ai_meta_info_t, uri) == 0, meta_uri_offset); +STATIC_ASSERT(offsetof(ai_meta_info_t, icon_url) == (5 * sizeof(void *)), + meta_icon_offset); + /* Sysmodule IDs (from ps5-payload-dev/sdk crt/rtld_sprx.c). */ #define SYSMOD_IPMI 0x8000001d #define SYSMOD_USERSERVICE 0x80000011 @@ -58,12 +87,14 @@ typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info, ai_playgo_info_t *playgo); typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app); typedef int (*ai_content_from_pkg_fn)(const char *path, char *content_id, int *is_app); +typedef int (*ai_get_status_fn)(const char *content_id, ai_install_status_t *status); static ai_init_fn ai_initialize; static ai_install_pkg_fn ai_install_pkg; static ai_install_by_pkg_fn ai_install_by_package; static ai_title_from_pkg_fn ai_title_from_pkg; static ai_content_from_pkg_fn ai_content_from_pkg; +static ai_get_status_fn ai_get_status; /* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs (that makes the ELF unloadable by the elfldr) and WITHOUT raw @@ -77,6 +108,10 @@ static volatile int g_stage; static int g_err; static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; static char g_probe_json[2048]; /* filled by the backend thread */ +static char g_last_content_id[AI_CONTENTID_SIZE]; +static char g_last_target_title_id[32]; +static char g_last_method[32]; +static int g_last_start_rc; static intptr_t dynsym(const char *module, const char *sym) { @@ -112,6 +147,37 @@ local_ip(char *out, size_t n) { freeifaddrs(ifa); } +static void +copy_bounded(char *dst, size_t dst_sz, const char *src, size_t src_sz) { + size_t n; + if (!dst || !dst_sz) return; + dst[0] = '\0'; + if (!src || !src_sz) return; + for (n = 0; n + 1 < dst_sz && n < src_sz && src[n]; n++) + dst[n] = src[n]; + dst[n] = '\0'; +} + +static void +remember_install(const char *target_title_id, const char *method, + const ai_pkg_info_t *pkg, const char *fallback_content_id, + int rc) { + char cid[AI_CONTENTID_SIZE] = {0}; + + if (pkg) + copy_bounded(cid, sizeof(cid), pkg->content_id, sizeof(pkg->content_id)); + if (!cid[0] && fallback_content_id) + copy_bounded(cid, sizeof(cid), fallback_content_id, strlen(fallback_content_id)); + + pthread_mutex_lock(&g_mtx); + snprintf(g_last_content_id, sizeof(g_last_content_id), "%s", cid); + snprintf(g_last_target_title_id, sizeof(g_last_target_title_id), "%s", + target_title_id ? target_title_id : ""); + snprintf(g_last_method, sizeof(g_last_method), "%s", method ? method : ""); + g_last_start_rc = rc; + pthread_mutex_unlock(&g_mtx); +} + /* Resolve (dlsym, never call) a list of candidate patch-install symbols and record which exist. Runs inside the backend thread, where the AppInstUtil module is already loaded — the same proven-safe context as the normal symbol @@ -126,6 +192,7 @@ fill_probe(void) { "sceAppInstUtilInstallByPackageEx", "sceAppInstUtilGetTitleIdFromPkg", "sceAppInstUtilGetContentIdFromPkg", + "sceAppInstUtilGetInstallStatus", "sceAppInstUtilAppExist", "sceAppInstUtilAppGetInstallStatus", "sceAppInstUtilAppInstallStatus", @@ -209,6 +276,8 @@ backend_init_thread(void *arg) { "sceAppInstUtilGetTitleIdFromPkg"); ai_content_from_pkg = (ai_content_from_pkg_fn)dynsym("libSceAppInstUtil.sprx", "sceAppInstUtilGetContentIdFromPkg"); + ai_get_status = (ai_get_status_fn)dynsym("libSceAppInstUtil.sprx", + "sceAppInstUtilGetInstallStatus"); /* Read-only feasibility probe — module is loaded, safe context. */ fill_probe(); @@ -338,6 +407,78 @@ patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz return ok ? 0 : -1; } +int +patchdl_install_status_json(char *out, size_t out_sz) { + char cid[AI_CONTENTID_SIZE]; + char tid[32]; + char method[32]; + int start_rc; + ai_install_status_t st; + char status[17], src_type[9]; + int rc; + int progress = 0; + int terminal = 0; + + if (!out || !out_sz) + return -1; + + backend_start(); + + pthread_mutex_lock(&g_mtx); + snprintf(cid, sizeof(cid), "%s", g_last_content_id); + snprintf(tid, sizeof(tid), "%s", g_last_target_title_id); + snprintf(method, sizeof(method), "%s", g_last_method); + start_rc = g_last_start_rc; + pthread_mutex_unlock(&g_mtx); + + if (!cid[0]) { + snprintf(out, out_sz, "{\"active\":false}"); + return -1; + } + if (g_stage != 5) { + snprintf(out, out_sz, + "{\"active\":true,\"content_id\":\"%s\",\"target_title_id\":\"%s\"," + "\"method\":\"%s\",\"start_rc\":%d,\"status\":\"backend_not_ready\"," + "\"stage\":\"%s\"}", + cid, tid, method, start_rc, stage_str(g_stage)); + return -1; + } + if (!ai_get_status) { + snprintf(out, out_sz, + "{\"active\":true,\"content_id\":\"%s\",\"target_title_id\":\"%s\"," + "\"method\":\"%s\",\"start_rc\":%d,\"status\":\"unavailable\"," + "\"message\":\"sceAppInstUtilGetInstallStatus not exported\"}", + cid, tid, method, start_rc); + return -1; + } + + memset(&st, 0, sizeof(st)); + rc = ai_get_status(cid, &st); + copy_bounded(status, sizeof(status), st.status, sizeof(st.status)); + copy_bounded(src_type, sizeof(src_type), st.src_type, sizeof(st.src_type)); + if (st.total_size > 0) + progress = (int)((st.downloaded_size * 100) / st.total_size); + if (progress < 0) progress = 0; + if (progress > 100) progress = 100; + terminal = (!strcmp(status, "playable") || + !strcmp(status, "error") || + !strcmp(status, "none")); + + snprintf(out, out_sz, + "{\"active\":true,\"terminal\":%s,\"content_id\":\"%s\"," + "\"target_title_id\":\"%s\",\"method\":\"%s\",\"start_rc\":%d," + "\"rc\":%d,\"status\":\"%s\",\"src_type\":\"%s\"," + "\"progress\":%d,\"downloaded_size\":%llu,\"total_size\":%llu," + "\"promote_progress\":%u,\"error_code\":%d}", + terminal ? "true" : "false", cid, tid, method, start_rc, rc, + status, src_type, progress, + (unsigned long long)st.downloaded_size, + (unsigned long long)st.total_size, + (unsigned)st.promote_progress, + (int)st.error_info.error_code); + return rc; +} + int patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, const char *storage_title_id, @@ -397,10 +538,18 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, pkg_tid, expected_title_id); return -1; } + if (pkg_tid_mismatch) { + snprintf(msg, msg_sz, + "unsupported shared-master package (pkg %.12s, target %.12s): " + "standalone AppInstUtil cannot retarget signed patch metadata", + pkg_tid, expected_title_id ? expected_title_id : ""); + return -1; + } - /* Preferred path: InstallByPackage accepts target metadata. Use it first, - and use it exclusively when the downloaded bytes report a master/storage - title id that differs from the target regional title id. */ + /* Preferred path: etaHEN's DPI uses InstallByPackage with an empty + MetaInfo.content_id and lets AppInstUtil bind to the signed package + metadata. Passing the installed game's content id is NOT a retarget + override for shared-master regional bytes; those are refused above. */ { char file_uri[1100]; char http_loop_uri[1200] = {0}; @@ -439,7 +588,7 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, uris[3] = http_lan_uri[0] ? http_lan_uri : NULL; meta.ex_uri = ""; meta.playgo_scenario_id = ""; - meta.content_id = target_content_id ? target_content_id : ""; + meta.content_id = ""; meta.content_name = "PatchDL"; meta.icon_url = ""; @@ -458,8 +607,11 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, l ? "," : "", labels[i], (unsigned)rc2); } if (rc2 == 0) { - snprintf(msg, msg_sz, "install started (InstallByPackage%s)", - pkg_tid_mismatch ? ", shared master bytes" : ""); + remember_install(expected_title_id, "InstallByPackage", + &pkg, target_content_id, rc2); + snprintf(msg, msg_sz, "install started (InstallByPackage, content %.47s)", + pkg.content_id[0] ? pkg.content_id : + (target_content_id ? target_content_id : "")); return 0; } } @@ -480,7 +632,11 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, ai_pkg_info_t pkg = {0}; int rc2 = ai_install_pkg(sdk_path, &pkg); if (rc2 == 0) { - snprintf(msg, msg_sz, "install started (AppInstallPkg)"); + remember_install(expected_title_id, "AppInstallPkg", + &pkg, target_content_id, rc2); + snprintf(msg, msg_sz, "install started (AppInstallPkg, content %.47s)", + pkg.content_id[0] ? pkg.content_id : + (target_content_id ? target_content_id : "")); return 0; } snprintf(msg, msg_sz, diff --git a/src/patchdl_install.h b/src/patchdl_install.h index cba34e8..0ae095b 100644 --- a/src/patchdl_install.h +++ b/src/patchdl_install.h @@ -14,9 +14,10 @@ */ /* `expected_title_id` is the title id of the installed game the patch is for. `storage_title_id` is the title id embedded in the delta_url storage path. - `target_content_id` is the installed game's content id from app.db; when - present it is passed to InstallByPackage so Sony's installer has the target - metadata even for region-shared/master-storage patch bytes. */ + `target_content_id` is the installed game's content id from app.db; it is + retained for diagnostics and status fallback. Normal same-title installs + deliberately pass an empty MetaInfo.content_id, matching etaHEN's native DPI + path and letting AppInstUtil bind the package to its signed metadata. */ int patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, const char *storage_title_id, @@ -37,3 +38,7 @@ int patchdl_install_api_probe(char *out, size_t out_sz); int patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz, char *title_id, size_t tid_sz, int *is_app, char *msg, size_t msg_sz); + +/* Read-only: report the last AppInstUtil install task PatchDL started, using + sceAppInstUtilGetInstallStatus when present. No install, no mutation. */ +int patchdl_install_status_json(char *out, size_t out_sz); diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index ef1d39f..3549322 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -664,6 +664,9 @@ build_titles_json(void) { jbuf_append_ver_or_null(&j, t->patch_title_id); jbuf_append(&j, ",\"patch_storage_title_id\":"); jbuf_append_ver_or_null(&j, t->patch_storage_title_id); + jbuf_appendf(&j, ",\"patch_storage_match\":%s", + (!t->patch_storage_title_id[0] || + !strncmp(t->patch_storage_title_id, t->title_id, 9)) ? "true" : "false"); /* This is the target title id parsed from version.xml/manifest_url. CDN storage paths may use another regional/master title id; that is not exposed here and must not block a valid target match. */ @@ -1569,6 +1572,16 @@ do_install(struct MHD_Connection *conn, const char *title_id, patch_title_id, title_id); return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp)); } + if (patch_storage_title_id[0] && + strncmp(patch_storage_title_id, title_id, 9) != 0) { + snprintf(resp, sizeof(resp), + "{\"ok\":false,\"reason\":\"cross_region_storage_unsupported\"," + "\"patch_storage_title_id\":\"%.15s\",\"title_id\":\"%.15s\"," + "\"message\":\"Patch bytes are signed for a shared master title; " + "standalone AppInstUtil cannot retarget them on 11.60\"}", + patch_storage_title_id, title_id); + return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp)); + } title_pkg_path(title_id, patch_url, dest, sizeof(dest)); @@ -1804,6 +1817,12 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url, return queue_json_owned(conn, MHD_HTTP_OK, strdup(p)); } + if (!strcmp(url, "/api/installstatus")) { + char p[1024]; + patchdl_install_status_json(p, sizeof(p)); + return queue_json_owned(conn, MHD_HTTP_OK, strdup(p)); + } + if (!strcmp(url, "/api/pkgdiag")) return queue_json(conn, MHD_HTTP_OK, g_pkg_diag_json); @@ -1908,9 +1927,14 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url, snprintf(resp, sizeof resp, "{\"ok\":%s,\"pkg_content_id\":\"%s\",\"pkg_title_id\":\"%s\"," "\"is_app\":%s,\"target_content_id\":\"%s\",\"target_title_id\":\"%s\"," - "\"storage_title_id\":\"%s\",\"msg\":\"%s\"}", + "\"storage_title_id\":\"%s\",\"installable\":%s,\"install_plan\":\"%s\"," + "\"msg\":\"%s\"}", rc == 0 ? "true" : "false", ecid, etid, is_app ? "true" : "false", - cid, tid, psti, msg); + cid, tid, psti, + (psti[0] && strncmp(psti, tid, 9) != 0) ? "false" : "true", + (psti[0] && strncmp(psti, tid, 9) != 0) ? + "cross_region_storage_unsupported" : "installbypackage", + msg); return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp)); } diff --git a/web/README.md b/web/README.md index 5691dd8..f0cfdd9 100644 --- a/web/README.md +++ b/web/README.md @@ -16,6 +16,9 @@ GET /api/config POST /api/config GET /api/titles GET /api/downloads +GET /api/installstatus +GET /api/pkgmeta/:title_id +GET /api/pkgverify/:title_id POST /api/titles/:title_id/check POST /api/titles/:title_id/download POST /api/titles/:title_id/install @@ -88,3 +91,8 @@ For PS5 game updates, the backend may turn a Sony `manifest_url` into a merged local `.pkg` by downloading all manifest pieces. The `delta_url` `*-DP.pkg` is not shown as a separate user action because it can bootstrap the storage/master title instead of the installed regional target. + +If `patch_storage_match` is false, the UI keeps download/verify available but +does not offer install or auto-install. Those shared-master packages are signed +for a different storage title id and cannot be retargeted by standalone +AppInstUtil on firmware 11.60. diff --git a/web/app.js b/web/app.js index 30fc661..0253c21 100644 --- a/web/app.js +++ b/web/app.js @@ -3,6 +3,7 @@ const API = { titles: "/api/titles", config: "/api/config", downloads: "/api/downloads", + installStatus: "/api/installstatus", action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`, }; @@ -49,6 +50,7 @@ const fallback = { installed_version: "01.032.000", compatible_version: "01.041.000", latest_version: "01.041.000", latest_required_fw: "11.60", source_type: "official", source_path: "/system_ex/app/PPSA01628_00", + patch_storage_match: true, mount_from: "/dev/ssd0.system_ex", enabled: true, status: "available", }, { @@ -57,6 +59,7 @@ const fallback = { installed_version: "01.004.000", compatible_version: "01.004.000", latest_version: "01.004.000", latest_required_fw: "10.01", source_type: "external", source_path: "/system_data/priv/appmeta/external/PPSA01284_00", + patch_storage_match: true, mount_from: "/mnt/ext0/user/app/PPSA01284_00", enabled: true, status: "up_to_date", }, { @@ -65,6 +68,7 @@ const fallback = { installed_version: "01.000.000", compatible_version: "01.006.000", latest_version: "01.009.000", latest_required_fw: "12.00", source_type: "shadowmount", source_path: "/system_ex/app/PPSA90001_00", + patch_storage_match: true, mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "available", }, ], @@ -85,6 +89,7 @@ let state = { }; let downloadPollTimer = null; +let installPollTimer = null; let emptyPolls = 0; const dlMeta = {}; // per-title speed tracking: { bytes, t, speed } @@ -201,6 +206,7 @@ async function loadInitialData() { if (downloads.some((j) => j.state === "active" || j.state === "queued") || state.titles.some((g) => g._localDownloading)) startDownloadPolling(); + if (state.titles.some((g) => g.installing)) startInstallPolling(); showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed."); } @@ -307,8 +313,8 @@ function gameCategory(game) { // Updatable rather than letting it fall out of every specific filter. if (game.installing || game.downloading || game.status === "checking") return "updatable"; if (game.patch_title_match === false) return "blocked"; - if (!sourcePolicy(game).allow_install) return "blocked"; - if (game.status === "available") return "updatable"; + if (game.status === "available" && isDownloadAllowed(game)) return "updatable"; + if (!sourcePolicy(game).allow_install && !sourcePolicy(game).allow_download) return "blocked"; if (game.status === "incompatible_fw") return "needsfw"; return "uptodate"; } @@ -356,6 +362,7 @@ function createGameCard(game) { ${game.downloading ? `Downloading` : ""} ${game.resumable && !game.downloading ? `Paused` : ""} ${statusPill(game)} + ${storagePill(game)} ${sourcePill(game)}
@@ -391,6 +398,15 @@ function createGameCard(game) {
${progressMetaHtml(d)}
`; card.appendChild(prog); + } else if (game.installing) { + const pct = Math.max(0, Math.min(100, Number(game.installProgress) || 0)); + const note = document.createElement("div"); + note.className = "card-progress"; + note.innerHTML = ` +
+
${installProgressHtml(game)}
+ `; + card.appendChild(note); } else if (game.resumable && game.partial_bytes > 0) { // ---- paused partial (survived a reboot) ---- const note = document.createElement("div"); @@ -433,13 +449,14 @@ function primaryButton(game) { if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true }; if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." }; if (game.patch_title_match === false) return null; - if (!isInstallAllowed(game)) return null; - if (game.resumable) + if (game.resumable && isDownloadAllowed(game)) return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." }; - if (game.downloaded && game.status === "available") + if (game.downloaded && game.status === "available" && isInstallAllowed(game)) return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." }; + if (game.downloaded && game.status === "available") return null; if (game.status !== "available") return null; - return state.config.install_after_download + if (!isDownloadAllowed(game)) return null; + return state.config.install_after_download && isInstallAllowed(game) ? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." } : { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." }; } @@ -463,6 +480,10 @@ function statusPill(game) { return `No patch info`; } +function storagePill(game) { + return hasSharedStorage(game) ? `Shared master` : ""; +} + function sourcePill(game) { const info = sourceInfo(game); return `${escapeHtml(info.label)}`; @@ -493,6 +514,16 @@ function progressMetaHtml(d) { return parts.join(""); } +function installProgressHtml(game) { + const status = game.installStatus || "waiting"; + const done = Number(game.installDone) || 0; + const total = Number(game.installTotal) || 0; + const parts = [`Status ${escapeHtml(status)}`]; + if (total > 0) parts.push(`${formatBytes(done)} / ${formatBytes(total)}`); + parts.push(`${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%`); + return parts.join(""); +} + function startDownloadPolling() { emptyPolls = 0; if (downloadPollTimer) return; @@ -538,7 +569,8 @@ function reconcileFromJobs(jobs) { g.resumable = false; g.downloaded = true; g._wasActive = false; - if (state.config.install_after_download && !g.installing && !g._autoInstalled) { + if (state.config.install_after_download && isInstallAllowed(g) && + !g.installing && !g._autoInstalled) { g._autoInstalled = true; doInstall(g); } @@ -558,6 +590,53 @@ function reconcileFromJobs(jobs) { }); } +function startInstallPolling() { + if (installPollTimer) return; + installPollTimer = setInterval(refreshInstallStatus, 2000); + refreshInstallStatus(); +} + +function stopInstallPolling() { + if (!installPollTimer) return; + clearInterval(installPollTimer); + installPollTimer = null; +} + +async function refreshInstallStatus() { + let s; + try { + const response = await fetch(API.installStatus, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + s = await response.json(); + } catch (error) { + return; + } + + if (!s || !s.active) { + if (!state.titles.some((g) => g.installing)) stopInstallPolling(); + return; + } + + const titleId = s.target_title_id || ""; + const game = state.titles.find((g) => g.title_id === titleId || g.title_id.slice(0, 9) === titleId.slice(0, 9)); + if (!game) return; + + game.installing = !s.terminal; + game.installStatus = s.status || "running"; + game.installProgress = Number(s.progress) || 0; + game.installDone = Number(s.downloaded_size) || 0; + game.installTotal = Number(s.total_size) || 0; + renderGames(); + + if (s.terminal) { + const ok = s.status === "playable"; + state.logs.push(`[${timeNow()}] Install ${ok ? "completed" : "stopped"} for ${game.title_id}: ${s.status || "unknown"}${s.error_code ? ` (0x${Number(s.error_code >>> 0).toString(16)})` : ""}`); + renderLogs(); + stopInstallPolling(); + if (ok) loadInitialData(); + } +} + async function refreshDownloads() { let jobs; try { @@ -709,6 +788,8 @@ async function doDownload(game) { async function doInstall(game) { game.installing = true; + game.installStatus = "starting"; + game.installProgress = 0; game.downloaded = false; // the package is being consumed by the install renderGames(); try { @@ -722,7 +803,8 @@ async function doInstall(game) { return false; } state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`); - showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`); + showToast(`${game.name}: installing update.`); + startInstallPolling(); renderGames(); renderLogs(); return true; } @@ -787,11 +869,26 @@ function updateGame(titleId, patch) { /* ---------------- policy helpers ---------------- */ function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; } +function hasSharedStorage(game) { + if (game.patch_storage_match === false) return true; + const storage = (game.patch_storage_title_id || "").slice(0, 9); + const target = (game.title_id || "").slice(0, 9); + return Boolean(storage && target && storage !== target); +} +function isDownloadAllowed(game) { + return Boolean( + game.enabled !== false && + game.compatible_version && + game.patch_title_match !== false && + sourcePolicy(game).allow_download + ); +} function isInstallAllowed(game) { return Boolean( game.enabled !== false && game.compatible_version && game.patch_title_match !== false && + !hasSharedStorage(game) && sourcePolicy(game).allow_install ); } @@ -820,6 +917,7 @@ async function postJson(url, body) { const REASON_TEXT = { patch_title_mismatch: "Patch metadata targets a different title - install blocked.", + cross_region_storage_unsupported: "Patch bytes are signed for a shared master title; this standalone installer cannot retarget them.", install_not_allowed_for_source: "Install blocked for this source.", source_unknown: "Source unknown — blocked.", no_compatible_patch: "No compatible patch available.",