Add download cancel/delete and harden the patch pipeline

Cancel a running download (the worker aborts mid-piece and the partial
file is removed) or delete a finished package, from the queue or the
title card. The progress callback now returns an abort signal that
reaches libcurl and the manifest merge loop.

Manifest merge: bound the piece scan to the "pieces" array so a later
"url" key (e.g. playgoChunkCrcUrl) can't be appended as a bogus piece,
and require each piece's fileOffset to match the bytes written so far so
an out-of-order manifest fails instead of silently producing a corrupt
package.

Report real free space on the download partition via statvfs; it was a
hardcoded 0.

Fixes found in review:
- scan: bound the SFO entry table to the bytes actually read and require
  the key to be NUL-terminated before strcmp (OOB read on a crafted
  param.sfo from a shadow-mounted dir).
- proc: bound the kinfo_proc walk and the name compare to the record and
  the buffer.
- install: publish the API probe under the lock (data race with the MHD
  worker thread) and initialize rc2.
- verxml: reject a truncated attribute value instead of returning it as
  valid.
- web: keep download/install/downloaded flags across a refresh, stop the
  queue poll only after repeated empty results, coerce the progress
  number, and treat a cancelled download (HTTP 200, ok:false) as
  not-downloaded.
This commit is contained in:
Knutwurst committed 2026-06-23 17:52:29 +02:00
1 parent 510f199b89
commit 9006965a75
13 files changed
+750 -84

No files matched your search

+22 -10
View File
@@ -19,8 +19,8 @@ by Knutwurst
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root. query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
- Picks the newest patch compatible with the current firmware - Picks the newest patch compatible with the current firmware
(`system_ver <= firmware`), so an update never forces a firmware upgrade. (`system_ver <= firmware`), so an update never forces a firmware upgrade.
- Downloads the patch package and installs it through Sony's AppInstUtil - Downloads the installable package from Sony's manifest pieces and installs it
service. through Sony's AppInstUtil service.
## Safety model ## Safety model
@@ -41,6 +41,14 @@ the target; that storage id is accepted only when `version.xml` targets the
installed title. A true target-title mismatch is refused instead of installed as installed title. A true target-title mismatch is refused instead of installed as
a phantom title. a phantom title.
For PS5 titles, `delta_url` often points to a small `*-DP.pkg` helper package.
That bootstrap can make the system fetch the full patch, but it follows the
package's storage/master title id and can create a duplicate/ghost title for
cross-region updates. PatchDL therefore prefers the Sony `manifest_url`,
downloads every listed `pieces[]` entry in order, and concatenates them into one
local `.pkg` before handing it to AppInstUtil. The `delta_url` title id is kept
only as the storage/master-id diagnostic.
## Build ## Build
Requires `ps5-payload-dev/sdk`. The network and install features also need the Requires `ps5-payload-dev/sdk`. The network and install features also need the
@@ -72,12 +80,16 @@ http://<console-ip>:12880/
## Status ## Status
0.0.2, early. Title scan, source classification, version resolution, 0.0.2, early. Title scan, source classification, version resolution,
firmware-compatibility filtering, download, and install work and have been firmware-compatibility filtering, target/storage-id handling, and the local
verified on firmware 11.60. Patches download internally to `/data/patchdl` and AppInstUtil HTTP stream have been verified on firmware 11.60. PatchDL now
are removed once the install has applied. Each title has a single action button downloads PS5 update manifests as merged piece packages under `/data/patchdl`;
(Download then Install, or Update when "install after download" is on). Open large retail updates can be tens of GB. The download queue shows live progress,
items: the web UI marks a title "Installing…" but reads progress from the PS5's and each download can be cancelled (the partial file is deleted) or a finished
own notifications rather than a percentage; a download queue is not built yet; package deleted again, from the queue or the title card. Manifest pieces are
disc-based games need the disc inserted for their patch to apply (a normal Sony verified in offset order and against their declared size while merging. Open
requirement). Settings (global policy and the per-game toggle) persist to items: a full large-title manifest download/install still needs an end-to-end
run, the web UI marks a title "Installing…" but reads progress from the PS5's
own notifications rather than a percentage, and disc-based games need the disc
inserted for their patch to apply (a normal Sony requirement).
Settings (global policy and the per-game toggle) persist to
`/data/patchdl/config.json` and survive a restart. `/data/patchdl/config.json` and survive a restart.
+18 -5
View File
@@ -148,8 +148,9 @@ fill_probe(void) {
uint32_t h = 0; uint32_t h = 0;
int ai_loaded = (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) >= 0); int ai_loaded = (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) >= 0);
int bgft_loaded = (kernel_dynlib_handle(-1, "libSceBgft.sprx", &h) >= 0); int bgft_loaded = (kernel_dynlib_handle(-1, "libSceBgft.sprx", &h) >= 0);
size_t n = 0, sz = sizeof(g_probe_json); char tmp[sizeof(g_probe_json)];
char *out = g_probe_json; size_t n = 0, sz = sizeof(tmp);
char *out = tmp;
int first = 1; int first = 1;
n += snprintf(out + n, sz - n, n += snprintf(out + n, sz - n,
@@ -168,6 +169,12 @@ fill_probe(void) {
first = 0; first = 0;
} }
snprintf(out + n, sz - n, "}}"); snprintf(out + n, sz - n, "}}");
/* Publish atomically: the getter runs on an MHD worker thread and reads
g_probe_json under the same lock, so it never sees a half-built buffer. */
pthread_mutex_lock(&g_mtx);
memcpy(g_probe_json, tmp, sizeof(g_probe_json));
pthread_mutex_unlock(&g_mtx);
} }
static void * static void *
@@ -261,11 +268,17 @@ patchdl_install_backend_check(char *msg, size_t msg_sz) {
reads the cached string — it never loads modules or resolves symbols here. */ reads the cached string — it never loads modules or resolves symbols here. */
int int
patchdl_install_api_probe(char *out, size_t out_sz) { patchdl_install_api_probe(char *out, size_t out_sz) {
int ready;
backend_start(); backend_start();
if (g_probe_json[0]) { pthread_mutex_lock(&g_mtx);
ready = (g_probe_json[0] != '\0');
if (ready)
snprintf(out, out_sz, "%s", g_probe_json); snprintf(out, out_sz, "%s", g_probe_json);
pthread_mutex_unlock(&g_mtx);
if (ready)
return 0; return 0;
}
snprintf(out, out_sz, snprintf(out, out_sz,
"{\"pending\":true,\"stage\":\"%s\"}", stage_str(g_stage)); "{\"pending\":true,\"stage\":\"%s\"}", stage_str(g_stage));
return -1; return -1;
@@ -342,7 +355,7 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
ai_meta_info_t meta = {0}; ai_meta_info_t meta = {0};
ai_pkg_info_t pkg = {0}; ai_pkg_info_t pkg = {0};
ai_playgo_info_t playgo = {0}; ai_playgo_info_t playgo = {0};
int rc2; int rc2 = -1;
const char *title_dir; const char *title_dir;
const char *file_base; const char *file_base;
+221 -10
View File
@@ -295,15 +295,37 @@ file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
return fwrite(ptr, size, nmemb, (FILE *)userdata); return fwrite(ptr, size, nmemb, (FILE *)userdata);
} }
int typedef struct {
patchdl_http_download(const char *url, const char *dest_path, patchdl_download_progress_cb cb;
long long *bytes_out) { void *ctx;
long long base;
long long total;
} progress_state_t;
static int
curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
curl_off_t ultotal, curl_off_t ulnow) {
progress_state_t *p = (progress_state_t *)clientp;
long long total;
(void)ultotal;
(void)ulnow;
if (!p || !p->cb) return 0;
total = p->total > 0 ? p->total : (long long)dltotal;
/* A non-zero return aborts the transfer (CURLE_ABORTED_BY_CALLBACK),
which is how a cancel request stops a piece mid-flight. */
return p->cb(p->ctx, p->base + (long long)dlnow, total);
}
static int
http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
progress_state_t *progress) {
CURL *curl; CURL *curl;
CURLcode res; CURLcode res;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512]; char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
struct curl_slist *rl = NULL; struct curl_slist *rl = NULL;
struct curl_blob ca_blob; struct curl_blob ca_blob;
FILE *fp;
curl_off_t dl = 0; curl_off_t dl = 0;
if (bytes_out) *bytes_out = 0; if (bytes_out) *bytes_out = 0;
@@ -311,9 +333,6 @@ patchdl_http_download(const char *url, const char *dest_path,
if (!host_allowed(host)) return -1; if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1; if (dns_lookup(host, ip, sizeof(ip))) return -1;
fp = fopen(dest_path, "wb");
if (!fp) return -1;
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip); snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs443); rl = curl_slist_append(NULL, rs443);
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip); snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
@@ -324,7 +343,7 @@ patchdl_http_download(const char *url, const char *dest_path,
ca_blob.flags = CURL_BLOB_COPY; ca_blob.flags = CURL_BLOB_COPY;
curl = curl_easy_init(); curl = curl_easy_init();
if (!curl) { fclose(fp); curl_slist_free_all(rl); return -1; } if (!curl) { curl_slist_free_all(rl); return -1; }
curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl); curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
@@ -341,21 +360,187 @@ patchdl_http_download(const char *url, const char *dest_path,
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0"); curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
if (progress && progress->cb) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, progress);
}
res = curl_easy_perform(curl); res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl); curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
curl_easy_cleanup(curl); curl_easy_cleanup(curl);
curl_slist_free_all(rl); curl_slist_free_all(rl);
if (res != CURLE_OK) return -1;
if (bytes_out) *bytes_out = (long long)dl;
return 0;
}
int
patchdl_http_download_progress(const char *url, const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb, void *ctx) {
FILE *fp = fopen(dest_path, "wb");
progress_state_t progress = { cb, ctx, 0, 0 };
int rc;
if (!fp) return -1;
rc = http_download_to_file_progress(url, fp, bytes_out, &progress);
fclose(fp); fclose(fp);
if (res != CURLE_OK) { if (rc) {
unlink(dest_path); unlink(dest_path);
return -1; return -1;
} }
if (bytes_out) *bytes_out = (long long)dl;
return 0; return 0;
} }
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
return patchdl_http_download_progress(url, dest_path, bytes_out, NULL, NULL);
}
static int
json_string_after(const char *p, const char *key, char *out, size_t out_sz) {
char needle[48];
const char *q;
size_t n = 0;
if (!p || !out || out_sz == 0) return -1;
out[0] = '\0';
snprintf(needle, sizeof(needle), "\"%s\"", key);
q = strstr(p, needle);
if (!q) return -1;
q += strlen(needle);
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != ':') return -1;
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != '"') return -1;
while (*q && *q != '"' && n + 1 < out_sz) {
if (*q == '\\' && q[1]) q++;
out[n++] = *q++;
}
out[n] = '\0';
return n ? 0 : -1;
}
static int
json_u64_after(const char *p, const char *key, unsigned long long *out) {
char needle[48];
const char *q;
if (!p || !out) return -1;
snprintf(needle, sizeof(needle), "\"%s\"", key);
q = strstr(p, needle);
if (!q) return -1;
q += strlen(needle);
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != ':') return -1;
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q < '0' || *q > '9') return -1;
*out = strtoull(q, NULL, 10);
return 0;
}
int
patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx) {
patchdl_buf_t manifest;
const char *pieces;
const char *p;
FILE *fp;
long long total = 0;
unsigned long long manifest_total = 0;
int count = 0;
int rc = -1;
if (bytes_out) *bytes_out = 0;
if (patchdl_http_get(manifest_url, &manifest))
return -1;
if (!manifest.data || !manifest.size) {
free(manifest.data);
return -1;
}
pieces = strstr(manifest.data, "\"pieces\"");
if (!pieces || !(pieces = strchr(pieces, '['))) {
free(manifest.data);
return -1;
}
/* Bound the scan to the pieces array; otherwise a later "url" key in the
manifest (e.g. playgoChunkCrcUrl) could be appended as a bogus piece. */
const char *pieces_end = strchr(pieces, ']');
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
fp = fopen(dest_path, "wb");
if (!fp) {
free(manifest.data);
return -1;
}
p = pieces;
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
char url[768];
long long got = 0;
unsigned long long expected = 0;
unsigned long long offset = 0;
int have_offset;
const char *obj_end = strchr(p, '}');
progress_state_t progress = {
cb,
ctx,
total,
manifest_total ? (long long)manifest_total : 0
};
if (json_string_after(p, "url", url, sizeof(url)))
break;
json_u64_after(p, "fileSize", &expected);
have_offset = (json_u64_after(p, "fileOffset", &offset) == 0);
/* Pieces are concatenated in array order; each one's fileOffset must
equal the bytes written so far. A manifest that lists them out of
order would otherwise silently produce a corrupt package. */
if (have_offset && offset != (unsigned long long)total)
goto done;
if (http_download_to_file_progress(url, fp, &got, &progress))
goto done; /* network error or cancel mid-piece */
if (expected && (unsigned long long)got != expected)
goto done;
total += got;
/* A non-zero callback return between pieces means cancel requested. */
if (cb && cb(ctx, total, manifest_total ? (long long)manifest_total : total))
goto done;
count++;
p = obj_end ? obj_end + 1 : p + 5;
}
if (count > 0) {
rc = 0;
if (bytes_out) *bytes_out = total;
}
done:
fclose(fp);
free(manifest.data);
if (rc) unlink(dest_path);
return rc;
}
int
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
long long *bytes_out) {
return patchdl_http_download_manifest_progress(manifest_url, dest_path,
bytes_out, NULL, NULL);
}
void void
patchdl_net_diag(const char *url, char *out_json, size_t sz) { patchdl_net_diag(const char *url, char *out_json, size_t sz) {
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0}; char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
@@ -427,6 +612,32 @@ patchdl_http_download(const char *url, const char *dest_path,
return -1; return -1;
} }
int
patchdl_http_download_progress(const char *url, const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb, void *ctx) {
(void)cb; (void)ctx;
return patchdl_http_download(url, dest_path, bytes_out);
}
int
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
long long *bytes_out) {
(void)manifest_url; (void)dest_path;
if (bytes_out) *bytes_out = 0;
return -1;
}
int
patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx) {
(void)cb; (void)ctx;
return patchdl_http_download_manifest(manifest_url, dest_path, bytes_out);
}
void void
patchdl_net_diag(const char *url, char *out_json, size_t sz) { patchdl_net_diag(const char *url, char *out_json, size_t sz) {
(void)url; (void)url;
+19
View File
@@ -13,10 +13,29 @@ void patchdl_buf_free(patchdl_buf_t *b);
int patchdl_http_get(const char *url, patchdl_buf_t *out); int patchdl_http_get(const char *url, patchdl_buf_t *out);
/* Progress callback. Return non-zero to ABORT the in-flight download (used to
cancel large patch downloads); return 0 to continue. */
typedef int (*patchdl_download_progress_cb)(void *ctx,
long long downloaded,
long long total);
/* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on /* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on
success and writes the byte count to *bytes_out. */ success and writes the byte count to *bytes_out. */
int patchdl_http_download(const char *url, const char *dest_path, int patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out); long long *bytes_out);
int patchdl_http_download_progress(const char *url, const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb, void *ctx);
/* Download a Sony JSON package manifest by concatenating every entry in
"pieces" into one installable PKG. */
int patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
long long *bytes_out);
int patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx);
/* Diagnostic: run the GET pipeline for `url` and write a JSON report /* Diagnostic: run the GET pipeline for `url` and write a JSON report
(dns result/ip, curl code, http status, bytes) into `out_json`. */ (dns result/ip, curl code, http status, bytes) into `out_json`. */
+17 -7
View File
@@ -34,16 +34,26 @@ find_pid(const char *name) {
if (!(buf = malloc(buf_size))) return -1; if (!(buf = malloc(buf_size))) return -1;
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; } if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) { /* The loop guard guarantees the structsize/pid/tdname fields are inside the
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE); buffer before we read them, and the per-record check below keeps the name
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID); compare within the record (and thus the buffer). */
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME); for (uint8_t *ptr = buf; ptr + KINFO_OFF_TDNAME < buf + buf_size; ) {
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
pid_t ki_pid;
char *ki_tdname;
size_t name_max;
if (ki_structsize <= 0) break; /* guard against malformed entries */ if (ki_structsize <= KINFO_OFF_TDNAME) break; /* malformed/truncated */
ptr += ki_structsize; if (ptr + ki_structsize > buf + buf_size) break; /* record past buffer */
if (!strcmp(name, ki_tdname) && ki_pid != mypid) ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
name_max = (size_t)(ptr + ki_structsize - (uint8_t *)ki_tdname);
if (!strncmp(name, ki_tdname, name_max) && ki_pid != mypid)
pid = ki_pid; pid = ki_pid;
ptr += ki_structsize;
} }
free(buf); free(buf);
+11 -1
View File
@@ -140,13 +140,23 @@ sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
entries = (const sfo_entry_t *)(buf + sizeof(*h)); entries = (const sfo_entry_t *)(buf + sizeof(*h));
/* The entry table itself must fit in the bytes we actually read; a crafted
param.sfo (from a shadow-mounted game dir) could otherwise drive
entries[i] past the buffer. */
if (sizeof(*h) + (size_t)h->num_entries * sizeof(sfo_entry_t) > bufsz)
return -1;
for (i = 0; i < h->num_entries; i++) { for (i = 0; i < h->num_entries; i++) {
size_t key_off = h->key_table_start + entries[i].key_offset; size_t key_off = h->key_table_start + entries[i].key_offset;
size_t val_off = h->data_table_start + entries[i].data_offset; size_t val_off = h->data_table_start + entries[i].data_offset;
if (key_off >= bufsz || val_off >= bufsz) continue; if (key_off >= bufsz || val_off >= bufsz) continue;
const char *k = (const char *)(buf + key_off); const char *k = (const char *)(buf + key_off);
size_t kmax = bufsz - key_off;
/* Require the key to be NUL-terminated within the buffer before the
strcmp, otherwise it would read past the end. */
if (strnlen(k, kmax) == kmax) continue;
if (strcmp(k, key)) continue; if (strcmp(k, key)) continue;
if (entries[i].data_fmt != SFO_FMT_STR) return -1; if (entries[i].data_fmt != SFO_FMT_STR) return -1;
+1 -1
View File
@@ -23,7 +23,7 @@ typedef struct {
char compatible_version[16]; char compatible_version[16];
char latest_version[16]; char latest_version[16];
char latest_required_fw[16]; char latest_required_fw[16];
char patch_url[512]; /* delta_url of the compatible patch */ char patch_url[512]; /* manifest_url if present, otherwise pkg URL */
char patch_title_id[16]; /* target title id from version.xml */ char patch_title_id[16]; /* target title id from version.xml */
char patch_storage_title_id[16]; /* title id embedded in delta_url */ char patch_storage_title_id[16]; /* title id embedded in delta_url */
int verxml_done; int verxml_done;
+12 -2
View File
@@ -44,6 +44,11 @@ attr_val(const char *tag_start, const char *tag_end, const char *attr,
p += strlen(needle); p += strlen(needle);
for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++) for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++)
; ;
/* The value must actually end on its closing quote inside the tag —
otherwise we hit tag_end or the buffer limit and would return a
silently truncated URL/title as if it were valid. */
if (p + len >= tag_end || p[len] != '"')
return -1;
memcpy(out, p, len); memcpy(out, p, len);
out[len] = '\0'; out[len] = '\0';
return 0; return 0;
@@ -130,13 +135,18 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
sizeof(out->latest_required_fw)); sizeof(out->latest_required_fw));
} }
/* Track latest compatible + its patch URL */ /* Track latest compatible + its installable patch source. PS5
updates expose a small delta_url (DP.pkg) plus a manifest_url
containing the actual split package pieces. Feeding the DP
bootstrap directly can make the system download the full patch
under the storage/master title id, so prefer the target-title
manifest whenever present. */
if (pkg_sver <= fw_bin) { if (pkg_sver <= fw_bin) {
if (!out->compatible_version[0] || if (!out->compatible_version[0] ||
ver_gt(ver, out->compatible_version)) { ver_gt(ver, out->compatible_version)) {
strncpy(out->compatible_version, ver, strncpy(out->compatible_version, ver,
sizeof(out->compatible_version) - 1); sizeof(out->compatible_version) - 1);
strncpy(out->compatible_url, durl, strncpy(out->compatible_url, murl[0] ? murl : durl,
sizeof(out->compatible_url) - 1); sizeof(out->compatible_url) - 1);
extract_title_id(durl, out->compatible_storage_title, extract_title_id(durl, out->compatible_storage_title,
sizeof(out->compatible_storage_title)); sizeof(out->compatible_storage_title));
+1 -1
View File
@@ -6,7 +6,7 @@ typedef struct {
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */ char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */
char latest_version[16]; /* highest pkg overall, or "" */ char latest_version[16]; /* highest pkg overall, or "" */
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */ char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
char compatible_url[512]; /* delta_url of the chosen compatible pkg */ char compatible_url[512]; /* manifest_url if present, otherwise pkg URL */
char compatible_title[16]; /* target title id from version.xml/manifest_url */ char compatible_title[16]; /* target title id from version.xml/manifest_url */
char compatible_storage_title[16]; /* title id embedded in delta_url storage path */ char compatible_storage_title[16]; /* title id embedded in delta_url storage path */
} patchdl_verinfo_t; } patchdl_verinfo_t;
+259 -23
View File
@@ -9,12 +9,14 @@
#include <microhttpd.h> #include <microhttpd.h>
#include <pthread.h> #include <pthread.h>
#include <dirent.h>
#include <fcntl.h> #include <fcntl.h>
#include <stdarg.h> #include <stdarg.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h> #include <unistd.h>
static struct MHD_Daemon *web_daemon; static struct MHD_Daemon *web_daemon;
@@ -23,14 +25,10 @@ static patchdl_fw_t g_fw;
static patchdl_title_t *g_titles; static patchdl_title_t *g_titles;
static size_t g_title_count; static size_t g_title_count;
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
static char g_status_json[512];
static char *g_debug_json; /* built once at startup */ static char *g_debug_json; /* built once at startup */
static unsigned long g_pkg_hits; static unsigned long g_pkg_hits;
static char g_pkg_diag_json[768] = "{\"hits\":0}"; static char g_pkg_diag_json[768] = "{\"hits\":0}";
#define PATCHDL_DL_DIR "/data/patchdl"
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
/* Persisted, user-editable settings. Per-title enable/disable lives in the /* Persisted, user-editable settings. Per-title enable/disable lives in the
title structs (t->enabled) so it travels with the scan; the global fields title structs (t->enabled) so it travels with the scan; the global fields
live here. Both are saved to PATCHDL_CFG_PATH (homebrew data dir, never a live here. Both are saved to PATCHDL_CFG_PATH (homebrew data dir, never a
@@ -41,6 +39,20 @@ static struct {
int delete_pkg_after_install; int delete_pkg_after_install;
} g_cfg = { "deny", 0, 1 }; } g_cfg = { "deny", 0, 1 };
static struct {
int active;
int cancel; /* set by a cancel request; the worker aborts */
char title_id[32];
char name[128];
char version[16];
char path[320];
long long downloaded;
long long total;
} g_dl;
#define PATCHDL_DL_DIR "/data/patchdl"
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
/* The source policy and CDN allowlist are fixed (the safety model), so they /* The source policy and CDN allowlist are fixed (the safety model), so they
stay constant; only the four mutable fields above are user-controlled. */ stay constant; only the four mutable fields above are user-controlled. */
static const char config_tail_json[] = static const char config_tail_json[] =
@@ -56,9 +68,6 @@ static const char config_tail_json[] =
"\"gst.prod.dl.playstation.net\"," "\"gst.prod.dl.playstation.net\","
"\"gs2.ww.prod.dl.playstation.net\"" "\"gs2.ww.prod.dl.playstation.net\""
"]}"; "]}";
static const char downloads_json[] = "[]";
/* ---------- tiny JSON value lookups (flat objects only) ----------------- */ /* ---------- tiny JSON value lookups (flat objects only) ----------------- */
/* Find `"key"` then the following `true`/`false`; returns dflt if absent. */ /* Find `"key"` then the following `true`/`false`; returns dflt if absent. */
@@ -220,6 +229,39 @@ path_segment_safe(const char *s) {
return 1; return 1;
} }
typedef struct {
int fd;
uint64_t start;
uint64_t size;
} pkg_reader_t;
static ssize_t
pkg_reader_cb(void *cls, uint64_t pos, char *buf, size_t max) {
pkg_reader_t *r = (pkg_reader_t *)cls;
uint64_t rem;
ssize_t n;
if (!r || pos >= r->size)
return MHD_CONTENT_READER_END_WITH_ERROR;
rem = r->size - pos;
if ((uint64_t)max > rem)
max = (size_t)rem;
n = pread(r->fd, buf, max, (off_t)(r->start + pos));
if (n <= 0)
return MHD_CONTENT_READER_END_WITH_ERROR;
return n;
}
static void
pkg_reader_free(void *cls) {
pkg_reader_t *r = (pkg_reader_t *)cls;
if (!r) return;
close(r->fd);
free(r);
}
static void static void
record_pkg_diag(const char *url, const char *range, unsigned int status, record_pkg_diag(const char *url, const char *range, unsigned int status,
uint64_t start, uint64_t end, uint64_t total) { uint64_t start, uint64_t end, uint64_t total) {
@@ -250,6 +292,7 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
unsigned int status = MHD_HTTP_OK; unsigned int status = MHD_HTTP_OK;
char content_range[96]; char content_range[96];
struct MHD_Response *resp; struct MHD_Response *resp;
pkg_reader_t *reader;
enum MHD_Result ret; enum MHD_Result ret;
if (strncmp(url, prefix, strlen(prefix))) if (strncmp(url, prefix, strlen(prefix)))
@@ -319,9 +362,21 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
} }
record_pkg_diag(url, range, status, start, end, total); record_pkg_diag(url, range, status, start, end, total);
resp = MHD_create_response_from_fd_at_offset64(send_size, fd, start); reader = calloc(1, sizeof(*reader));
if (!reader) {
close(fd);
return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
}
reader->fd = fd;
reader->start = start;
reader->size = send_size;
resp = MHD_create_response_from_callback(send_size, 65536,
pkg_reader_cb, reader,
pkg_reader_free);
if (!resp) { if (!resp) {
close(fd); close(fd);
free(reader);
return MHD_NO; return MHD_NO;
} }
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*"); MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
@@ -336,22 +391,37 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_RANGE, content_range); MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_RANGE, content_range);
} }
ret = MHD_queue_response(conn, status, resp); ret = MHD_queue_response(conn, status, resp);
MHD_destroy_response(resp); /* closes fd */ MHD_destroy_response(resp); /* pkg_reader_free closes fd */
return ret; return ret;
} }
/* ---------- status JSON ------------------------------------------------- */ /* ---------- status JSON ------------------------------------------------- */
static void /* Free space on the download partition, in MB. Best-effort: 0 if unavailable. */
rebuild_status_json(void) { static long long
snprintf(g_status_json, sizeof(g_status_json), data_free_mb(void) {
struct statvfs vfs;
if (statvfs(PATCHDL_DL_DIR, &vfs) == 0 || statvfs("/data", &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
return 0;
}
/* Built fresh per request so free space stays live (a 60+ GB download moves
it a lot). g_fw is read-only after startup, so this is thread-safe. */
static char *
build_status_json(void) {
char *out = malloc(512);
if (!out) return NULL;
snprintf(out, 512,
"{\"firmware\":\"%s\"," "{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\"," "\"firmware_build\":\"0x%08x\","
"\"dns_guard\":\"Active\"," "\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\"," "\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":0," "\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\"}", "\"download_dir\":\"/data/patchdl (internal)\"}",
g_fw.str, g_fw.bin); g_fw.str, g_fw.bin, data_free_mb());
return out;
} }
/* ---------- config persistence (/data/patchdl/config.json) -------------- */ /* ---------- config persistence (/data/patchdl/config.json) -------------- */
@@ -518,6 +588,45 @@ build_titles_json(void) {
return j.buf; /* caller owns; use queue_json_owned */ return j.buf; /* caller owns; use queue_json_owned */
} }
static char *
build_downloads_json(void) {
jbuf_t j = {0};
long long downloaded, total;
int progress = 0;
char detail[128];
pthread_mutex_lock(&g_mutex);
jbuf_append(&j, "[");
if (g_dl.active) {
downloaded = g_dl.downloaded;
total = g_dl.total;
if (total > 0 && downloaded >= 0)
progress = (int)((downloaded * 100) / total);
if (progress < 0) progress = 0;
if (progress > 100) progress = 100;
if (total > 0)
snprintf(detail, sizeof(detail), "%.1f GB of %.1f GB",
downloaded / 1073741824.0, total / 1073741824.0);
else
snprintf(detail, sizeof(detail), "%.1f MB downloaded",
downloaded / 1048576.0);
jbuf_append(&j, "{");
jbuf_append(&j, "\"title_id\":"); jbuf_append_str(&j, g_dl.title_id);
jbuf_append(&j, ",\"name\":"); jbuf_append_str(&j, g_dl.name);
jbuf_append(&j, ",\"version\":"); jbuf_append_str(&j, g_dl.version);
jbuf_appendf(&j, ",\"progress\":%d", progress);
jbuf_append(&j, ",\"detail\":"); jbuf_append_str(&j, detail);
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld",
downloaded, total);
jbuf_append(&j, ",\"path\":"); jbuf_append_str(&j, g_dl.path);
jbuf_append(&j, "}");
}
jbuf_append(&j, "]");
pthread_mutex_unlock(&g_mutex);
return j.buf;
}
/* ---------- verxml background fetch ------------------------------------ */ /* ---------- verxml background fetch ------------------------------------ */
/* Remove a downloaded patch once the game is at/past that version, i.e. the /* Remove a downloaded patch once the game is at/past that version, i.e. the
@@ -585,7 +694,10 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
char *patch_url, size_t url_sz, char *patch_url, size_t url_sz,
char *patch_title_id, size_t pt_sz, char *patch_title_id, size_t pt_sz,
char *patch_storage_title_id, size_t pst_sz, char *patch_storage_title_id, size_t pst_sz,
char *content_id, size_t ci_sz, int *enabled) { char *content_id, size_t ci_sz,
char *name, size_t name_sz,
char *version, size_t ver_sz,
int *enabled) {
int found = 0; int found = 0;
pthread_mutex_lock(&g_mutex); pthread_mutex_lock(&g_mutex);
@@ -600,6 +712,10 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
patch_storage_title_id[pst_sz - 1] = '\0'; patch_storage_title_id[pst_sz - 1] = '\0';
strncpy(content_id, g_titles[i].content_id, ci_sz - 1); strncpy(content_id, g_titles[i].content_id, ci_sz - 1);
content_id[ci_sz - 1] = '\0'; content_id[ci_sz - 1] = '\0';
strncpy(name, g_titles[i].name, name_sz - 1);
name[name_sz - 1] = '\0';
strncpy(version, g_titles[i].compatible_version, ver_sz - 1);
version[ver_sz - 1] = '\0';
*enabled = g_titles[i].enabled; *enabled = g_titles[i].enabled;
found = 1; found = 1;
break; break;
@@ -637,13 +753,86 @@ static void
title_pkg_path(const char *title_id, const char *patch_url, title_pkg_path(const char *title_id, const char *patch_url,
char *out, size_t out_sz) { char *out, size_t out_sz) {
const char *base = strrchr(patch_url, '/'); const char *base = strrchr(patch_url, '/');
char name[192];
size_t n;
base = base ? base + 1 : "patch.pkg"; base = base ? base + 1 : "patch.pkg";
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, base); snprintf(name, sizeof(name), "%s", base);
n = strlen(name);
if (n > 5 && !strcmp(name + n - 5, ".json"))
snprintf(name + n - 5, sizeof(name) - (n - 5), ".pkg");
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, name);
}
static int
url_is_manifest(const char *url) {
size_t n = url ? strlen(url) : 0;
return n > 5 && !strcmp(url + n - 5, ".json");
}
/* Returns non-zero to abort the download when a cancel has been requested. */
static int
download_progress_cb(void *ctx, long long downloaded, long long total) {
int cancel;
(void)ctx;
pthread_mutex_lock(&g_mutex);
if (g_dl.active) {
g_dl.downloaded = downloaded;
g_dl.total = total;
}
cancel = g_dl.cancel;
pthread_mutex_unlock(&g_mutex);
return cancel;
}
/* Delete a title's internal download directory and its contents (a partial or
a finished package). Best-effort; only ever touches /data/patchdl. */
static void
remove_title_dir(const char *title_id) {
char dir[288], path[560];
DIR *d;
struct dirent *e;
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
if ((d = opendir(dir))) {
while ((e = readdir(d))) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
continue;
snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
unlink(path);
}
closedir(d);
}
rmdir(dir);
}
/* Cancel an in-progress download for this title (the worker aborts and removes
the partial), or delete an already-downloaded package if nothing is running. */
static enum MHD_Result
do_cancel(struct MHD_Connection *conn, const char *title_id) {
char resp[96];
int was_active = 0;
pthread_mutex_lock(&g_mutex);
if (g_dl.active && !strcmp(g_dl.title_id, title_id)) {
g_dl.cancel = 1;
was_active = 1;
}
pthread_mutex_unlock(&g_mutex);
if (!was_active)
remove_title_dir(title_id);
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"cancelled\":%s,\"deleted\":true}",
was_active ? "true" : "false");
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
} }
static enum MHD_Result static enum MHD_Result
do_download(struct MHD_Connection *conn, const char *title_id, do_download(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url, int enabled) { patchdl_source_t src, const char *patch_url,
const char *name, const char *version, int enabled) {
char dir[256], dest[320], resp[640]; char dir[256], dest[320], resp[640];
long long bytes = 0; long long bytes = 0;
@@ -665,12 +854,52 @@ do_download(struct MHD_Connection *conn, const char *title_id,
mkdir(dir, 0777); mkdir(dir, 0777);
title_pkg_path(title_id, patch_url, dest, sizeof(dest)); title_pkg_path(title_id, patch_url, dest, sizeof(dest));
if (patchdl_http_download(patch_url, dest, &bytes)) { pthread_mutex_lock(&g_mutex);
if (g_dl.active) {
pthread_mutex_unlock(&g_mutex);
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"download_in_progress\"}");
}
memset(&g_dl, 0, sizeof(g_dl));
g_dl.active = 1;
strncpy(g_dl.title_id, title_id, sizeof(g_dl.title_id) - 1);
strncpy(g_dl.name, name && name[0] ? name : title_id, sizeof(g_dl.name) - 1);
strncpy(g_dl.version, version ? version : "", sizeof(g_dl.version) - 1);
strncpy(g_dl.path, dest, sizeof(g_dl.path) - 1);
pthread_mutex_unlock(&g_mutex);
if ((url_is_manifest(patch_url)
? patchdl_http_download_manifest_progress(patch_url, dest, &bytes,
download_progress_cb, NULL)
: patchdl_http_download_progress(patch_url, dest, &bytes,
download_progress_cb, NULL))) {
int was_cancel;
pthread_mutex_lock(&g_mutex);
was_cancel = g_dl.cancel;
g_dl.active = 0;
g_dl.cancel = 0;
pthread_mutex_unlock(&g_mutex);
/* The download function already unlinked the partial file on failure;
drop the now-empty title dir too. */
unlink(dest);
rmdir(dir);
if (was_cancel)
return queue_json(conn, MHD_HTTP_OK,
"{\"ok\":false,\"cancelled\":true,"
"\"reason\":\"download_cancelled\"}");
snprintf(resp, sizeof(resp), snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"download_failed\"}"); "{\"ok\":false,\"reason\":\"download_failed\"}");
return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp)); return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp));
} }
pthread_mutex_lock(&g_mutex);
g_dl.downloaded = bytes;
g_dl.total = bytes;
g_dl.active = 0;
pthread_mutex_unlock(&g_mutex);
/* shadowmount: download allowed, install is not (per source policy). */ /* shadowmount: download allowed, install is not (per source policy). */
snprintf(resp, sizeof(resp), snprintf(resp, sizeof(resp),
"{\"ok\":true,\"downloaded\":true,\"bytes\":%lld,\"path\":\"%s\"," "{\"ok\":true,\"downloaded\":true,\"bytes\":%lld,\"path\":\"%s\","
@@ -757,6 +986,8 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
char patch_title_id[16] = {0}; char patch_title_id[16] = {0};
char patch_storage_title_id[16] = {0}; char patch_storage_title_id[16] = {0};
char content_id[64] = {0}; char content_id[64] = {0};
char name[128] = {0};
char version[16] = {0};
int enabled = 0; int enabled = 0;
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN; patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
@@ -768,7 +999,10 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
patch_title_id, sizeof(patch_title_id), patch_title_id, sizeof(patch_title_id),
patch_storage_title_id, patch_storage_title_id,
sizeof(patch_storage_title_id), sizeof(patch_storage_title_id),
content_id, sizeof(content_id), &enabled)) content_id, sizeof(content_id),
name, sizeof(name),
version, sizeof(version),
&enabled))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title"); return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
if (!strcmp(action, "enable")) if (!strcmp(action, "enable"))
@@ -778,7 +1012,10 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
return set_title_enabled(conn, title_id, 0); return set_title_enabled(conn, title_id, 0);
if (!strcmp(action, "download")) if (!strcmp(action, "download"))
return do_download(conn, title_id, src, patch_url, enabled); return do_download(conn, title_id, src, patch_url, name, version, enabled);
if (!strcmp(action, "cancel"))
return do_cancel(conn, title_id);
if (!strcmp(action, "check")) if (!strcmp(action, "check"))
return queue_json(conn, MHD_HTTP_ACCEPTED, return queue_json(conn, MHD_HTTP_ACCEPTED,
@@ -878,7 +1115,7 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed"); return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
if (!strcmp(url, "/api/status")) if (!strcmp(url, "/api/status"))
return queue_json(conn, MHD_HTTP_OK, g_status_json); return queue_json_owned(conn, MHD_HTTP_OK, build_status_json());
if (!strcmp(url, "/api/config")) if (!strcmp(url, "/api/config"))
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json()); return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
@@ -887,7 +1124,7 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json()); return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
if (!strcmp(url, "/api/downloads")) if (!strcmp(url, "/api/downloads"))
return queue_json(conn, MHD_HTTP_OK, downloads_json); return queue_json_owned(conn, MHD_HTTP_OK, build_downloads_json());
if (!strcmp(url, "/api/debug")) if (!strcmp(url, "/api/debug"))
return queue_json(conn, MHD_HTTP_OK, return queue_json(conn, MHD_HTTP_OK,
@@ -941,7 +1178,6 @@ patchdl_websrv_start(unsigned short port) {
/* Collect real FW version and installed titles synchronously. /* Collect real FW version and installed titles synchronously.
The web UI is reachable only after this completes. */ The web UI is reachable only after this completes. */
patchdl_fw_get(&g_fw); patchdl_fw_get(&g_fw);
rebuild_status_json();
if (patchdl_scan(&g_titles, &g_title_count)) if (patchdl_scan(&g_titles, &g_title_count))
g_title_count = 0; g_title_count = 0;
+5
View File
@@ -83,3 +83,8 @@ unknown
The frontend treats `shadowmount` as download-only and `unknown` as blocked for The frontend treats `shadowmount` as download-only and `unknown` as blocked for
downloads and installs. Backend code should enforce the same policy even if a downloads and installs. Backend code should enforce the same policy even if a
client sends a forged request. client sends a forged request.
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.
+140 -23
View File
@@ -126,21 +126,8 @@ const fallback = {
status: "available", status: "available",
}, },
], ],
downloads: [ downloads: [],
{ logs: [],
title_id: "PPSA01284_00",
name: "Demon's Souls",
version: "01.004.000",
progress: 64,
detail: "4.8 GB of 7.5 GB",
},
],
logs: [
"[12:41:02] nanoDNS guard detected: Sony domains blocked",
"[12:41:04] Internal resolver ready for 3 CDN hosts",
"[12:41:07] PPSA01284_00 selected: pin 01.004.000",
"[12:41:09] Download started: PPSA01284_00 01.004.000",
],
}; };
let state = { let state = {
@@ -153,6 +140,7 @@ let state = {
query: "", query: "",
usingFallback: false, usingFallback: false,
}; };
let downloadPollTimer = null;
const els = {}; const els = {};
@@ -208,6 +196,7 @@ function bindEvents() {
} }
async function loadInitialData() { async function loadInitialData() {
state.usingFallback = false;
const [status, config, titles, downloads] = await Promise.all([ const [status, config, titles, downloads] = await Promise.all([
getJson(API.status, fallback.status), getJson(API.status, fallback.status),
getJson(API.config, fallback.config), getJson(API.config, fallback.config),
@@ -215,6 +204,18 @@ async function loadInitialData() {
getJson(API.downloads, fallback.downloads), getJson(API.downloads, fallback.downloads),
]); ]);
// /api/titles has no client-only progress fields, so carry them across a
// refresh — otherwise an in-flight download/install would flip the card back
// to a clickable "Download/Install" mid-operation.
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
titles.forEach((g) => {
const old = prev.get(g.title_id);
if (!old) return;
if (old.downloading) g.downloading = true;
if (old.downloaded) g.downloaded = true;
if (old.installing) g.installing = true;
});
state = { state = {
...state, ...state,
status, status,
@@ -224,6 +225,7 @@ async function loadInitialData() {
}; };
render(); render();
if (state.downloads.length) startDownloadPolling();
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed."); showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
} }
@@ -390,6 +392,18 @@ function createGameCard(game) {
actions.appendChild(btn); actions.appendChild(btn);
} }
// Cancel an in-progress download, or delete a finished/leftover one.
if (game.downloading || game.downloaded) {
const del = document.createElement("button");
del.className = "row-button is-danger";
del.textContent = game.downloading ? "Cancel" : "Delete";
del.title = game.downloading
? "Stop the download and delete the partial file"
: "Delete the downloaded package";
del.addEventListener("click", () => cancelDownload(game.title_id));
actions.appendChild(del);
}
controls.append(actions); controls.append(actions);
card.append(title, versions, controls); card.append(title, versions, controls);
return card; return card;
@@ -449,16 +463,29 @@ function renderQueue() {
} }
state.downloads.forEach((item) => { state.downloads.forEach((item) => {
const pct = Math.max(0, Math.min(100, Number(item.progress) || 0));
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "queue-item"; row.className = "queue-item";
row.innerHTML = `
<div> const info = document.createElement("div");
<strong>${escapeHtml(item.name)}</strong> info.innerHTML = `
<span>${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")}</span> <strong>${escapeHtml(item.name)}</strong>
<div class="progress"><i style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></i></div> <span>${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")}</span>
</div> <div class="progress"><i style="width:${pct}%"></i></div>
<span>${item.progress || 0}%</span>
`; `;
const right = document.createElement("div");
right.className = "queue-actions";
const span = document.createElement("span");
span.textContent = `${pct}%`;
const cancel = document.createElement("button");
cancel.className = "row-button is-danger";
cancel.textContent = "Cancel";
cancel.title = "Stop the download and delete the partial file";
cancel.addEventListener("click", () => cancelDownload(item.title_id));
right.append(span, cancel);
row.append(info, right);
els.queueList.appendChild(row); els.queueList.appendChild(row);
}); });
} }
@@ -480,7 +507,63 @@ function renderSourcePolicies() {
} }
function renderLogs() { function renderLogs() {
els.logOutput.textContent = state.logs.join("\n"); els.logOutput.textContent = state.logs.length ? state.logs.join("\n") : "No events yet.";
}
let emptyPolls = 0;
function startDownloadPolling() {
emptyPolls = 0;
if (downloadPollTimer) return;
downloadPollTimer = setInterval(refreshDownloads, 2000);
refreshDownloads();
}
function stopDownloadPolling() {
if (!downloadPollTimer) return;
clearInterval(downloadPollTimer);
downloadPollTimer = null;
}
async function refreshDownloads() {
try {
const response = await fetch(API.downloads, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const downloads = await response.json();
state.downloads = downloads;
renderQueue();
// A single empty poll can happen between manifest fetch and first piece;
// only give up after a few in a row so a slow multi-GB job isn't dropped.
if (!downloads.length && !state.titles.some((game) => game.downloading)) {
if (++emptyPolls >= 3) stopDownloadPolling();
} else {
emptyPolls = 0;
}
} catch (error) {
/* Keep the last local queue row visible if polling fails transiently. */
}
}
// Cancel a running download (server aborts and deletes the partial) or delete
// an already-downloaded package. Same endpoint handles both.
async function cancelDownload(titleId) {
const game = state.titles.find((g) => g.title_id === titleId);
try {
await postJson(API.action(titleId, "cancel"), {});
showToast(`${game ? game.name : titleId}: download cancelled / deleted.`);
} catch (error) {
showToast(`${game ? game.name : titleId}: ${reasonText(error)}`);
}
if (game) {
game.downloading = false;
game.downloaded = false;
}
state.downloads = state.downloads.filter((item) => item.title_id !== titleId);
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`);
renderGames();
renderQueue();
renderLogs();
refreshDownloads();
} }
async function saveConfig() { async function saveConfig() {
@@ -523,26 +606,59 @@ function rowAction(game) {
async function doDownload(game) { async function doDownload(game) {
game.downloading = true; game.downloading = true;
state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id);
state.downloads.push({
title_id: game.title_id,
name: game.name,
version: game.compatible_version || "",
progress: 0,
detail: "Downloading manifest package internally. Large PS5 patches can take a long time.",
});
state.logs.push(`[${timeNow()}] Download started: ${game.title_id} ${game.compatible_version}`);
renderQueue();
renderLogs();
renderGames(); renderGames();
startDownloadPolling();
let r; let r;
try { try {
r = await postJson(API.action(game.title_id, "download"), {}); r = await postJson(API.action(game.title_id, "download"), {});
} catch (error) { } catch (error) {
game.downloading = false; game.downloading = false;
state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id);
const why = reasonText(error); const why = reasonText(error);
state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`); state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`);
showToast(`${game.name}: ${why}`); showToast(`${game.name}: ${why}`);
renderGames(); renderGames();
renderQueue();
renderLogs(); renderLogs();
stopDownloadPolling();
return false; return false;
} }
game.downloading = false; game.downloading = false;
state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id);
// A cancel (or soft failure) comes back as HTTP 200 with ok:false, so it
// isn't thrown — handle it here instead of marking the patch downloaded.
if (!r || r.ok === false) {
game.downloaded = false;
const what = r && r.cancelled ? "cancelled" : "failed";
state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`);
showToast(`${game.name}: download ${what}.`);
renderGames();
renderQueue();
renderLogs();
stopDownloadPolling();
return false;
}
game.downloaded = true; game.downloaded = true;
const mb = r && r.bytes ? (r.bytes / (1024 * 1024)).toFixed(1) : "?"; const mb = r && r.bytes ? (r.bytes / (1024 * 1024)).toFixed(1) : "?";
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${mb} MB, internal)`); state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${mb} MB, internal)`);
showToast(`${game.name}: downloaded ${mb} MB.`); showToast(`${game.name}: downloaded ${mb} MB.`);
renderGames(); renderGames();
renderQueue();
renderLogs(); renderLogs();
stopDownloadPolling();
return true; return true;
} }
@@ -665,6 +781,7 @@ const REASON_TEXT = {
source_unknown: "Source unknown — blocked.", source_unknown: "Source unknown — blocked.",
no_compatible_patch: "No compatible patch available.", no_compatible_patch: "No compatible patch available.",
download_failed: "Download failed.", download_failed: "Download failed.",
download_in_progress: "Another download is already running.",
}; };
function reasonText(error) { function reasonText(error) {
const r = error && error.body && error.body.reason; const r = error && error.body && error.body.reason;
+24 -1
View File
@@ -493,7 +493,7 @@ h2 {
.queue-item { .queue-item {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 92px; grid-template-columns: minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
@@ -502,6 +502,29 @@ h2 {
border-radius: 8px; border-radius: 8px;
} }
.queue-actions {
display: flex;
align-items: center;
gap: 10px;
}
.queue-item .queue-actions span {
margin: 0;
color: var(--ink);
font-size: 13px;
white-space: nowrap;
}
.row-button.is-danger {
border-color: var(--red);
color: var(--red);
background: var(--red-soft);
}
.row-button.is-danger:hover {
background: rgba(255, 107, 107, 0.26);
}
.queue-item strong { .queue-item strong {
display: block; display: block;
overflow: hidden; overflow: hidden;