mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 04:04:15 +02:00
Harden AppInstUtil install path and status tracking
This commit is contained in:
1 parent
79e1c377ed
commit
983f39fa89
6 files changed
+322
-27
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user