Harden AppInstUtil install path and status tracking

This commit is contained in:
Knutwurst committed 2026-06-24 17:19:34 +02:00
1 parent 79e1c377ed
commit 983f39fa89
6 files changed
+322 -27

No files matched your search

+11 -7
View File
@@ -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.
+163 -7
View File
@@ -7,6 +7,7 @@
#include <pthread.h>
#include <stddef.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
@@ -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,
+8 -3
View File
@@ -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);
+26 -2
View File
@@ -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));
}
+8
View File
@@ -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.
+106 -8
View File
@@ -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 ? `<span class="pill live">Downloading</span>` : ""}
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
${statusPill(game)}
${storagePill(game)}
${sourcePill(game)}
</div>
<div class="versions">
@@ -391,6 +398,15 @@ function createGameCard(game) {
<div class="progress-meta">${progressMetaHtml(d)}</div>
`;
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 = `
<div class="progress"><i style="width:${pct}%"></i></div>
<div class="progress-meta">${installProgressHtml(game)}</div>
`;
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 `<span class="pill">No patch info</span>`;
}
function storagePill(game) {
return hasSharedStorage(game) ? `<span class="pill warn">Shared master</span>` : "";
}
function sourcePill(game) {
const info = sourceInfo(game);
return `<span class="pill ${info.className}">${escapeHtml(info.label)}</span>`;
@@ -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 = [`<span>Status <b>${escapeHtml(status)}</b></span>`];
if (total > 0) parts.push(`<span><b>${formatBytes(done)}</b> / ${formatBytes(total)}</span>`);
parts.push(`<span><b>${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%</b></span>`);
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.",