mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 04:04:15 +02:00
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:
1 parent
510f199b89
commit
9006965a75
13 files changed
+750
-84
No files matched your search
@@ -19,8 +19,8 @@ by Knutwurst
|
||||
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
|
||||
- Picks the newest patch compatible with the current firmware
|
||||
(`system_ver <= firmware`), so an update never forces a firmware upgrade.
|
||||
- Downloads the patch package and installs it through Sony's AppInstUtil
|
||||
service.
|
||||
- Downloads the installable package from Sony's manifest pieces and installs it
|
||||
through Sony's AppInstUtil service.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
Requires `ps5-payload-dev/sdk`. The network and install features also need the
|
||||
@@ -72,12 +80,16 @@ http://<console-ip>:12880/
|
||||
## Status
|
||||
|
||||
0.0.2, early. Title scan, source classification, version resolution,
|
||||
firmware-compatibility filtering, download, and install work and have been
|
||||
verified on firmware 11.60. Patches download internally to `/data/patchdl` and
|
||||
are removed once the install has applied. Each title has a single action button
|
||||
(Download then Install, or Update when "install after download" is on). Open
|
||||
items: the web UI marks a title "Installing…" but reads progress from the PS5's
|
||||
own notifications rather than a percentage; a download queue is not built yet;
|
||||
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
|
||||
firmware-compatibility filtering, target/storage-id handling, and the local
|
||||
AppInstUtil HTTP stream have been verified on firmware 11.60. PatchDL now
|
||||
downloads PS5 update manifests as merged piece packages under `/data/patchdl`;
|
||||
large retail updates can be tens of GB. The download queue shows live progress,
|
||||
and each download can be cancelled (the partial file is deleted) or a finished
|
||||
package deleted again, from the queue or the title card. Manifest pieces are
|
||||
verified in offset order and against their declared size while merging. Open
|
||||
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.
|
||||
+18
-5
@@ -148,8 +148,9 @@ fill_probe(void) {
|
||||
uint32_t h = 0;
|
||||
int ai_loaded = (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) >= 0);
|
||||
int bgft_loaded = (kernel_dynlib_handle(-1, "libSceBgft.sprx", &h) >= 0);
|
||||
size_t n = 0, sz = sizeof(g_probe_json);
|
||||
char *out = g_probe_json;
|
||||
char tmp[sizeof(g_probe_json)];
|
||||
size_t n = 0, sz = sizeof(tmp);
|
||||
char *out = tmp;
|
||||
int first = 1;
|
||||
|
||||
n += snprintf(out + n, sz - n,
|
||||
@@ -168,6 +169,12 @@ fill_probe(void) {
|
||||
first = 0;
|
||||
}
|
||||
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 *
|
||||
@@ -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. */
|
||||
int
|
||||
patchdl_install_api_probe(char *out, size_t out_sz) {
|
||||
int ready;
|
||||
|
||||
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);
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
if (ready)
|
||||
return 0;
|
||||
}
|
||||
|
||||
snprintf(out, out_sz,
|
||||
"{\"pending\":true,\"stage\":\"%s\"}", stage_str(g_stage));
|
||||
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_pkg_info_t pkg = {0};
|
||||
ai_playgo_info_t playgo = {0};
|
||||
int rc2;
|
||||
int rc2 = -1;
|
||||
const char *title_dir;
|
||||
const char *file_base;
|
||||
|
||||
|
||||
+221
-10
@@ -295,15 +295,37 @@ file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
return fwrite(ptr, size, nmemb, (FILE *)userdata);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_download(const char *url, const char *dest_path,
|
||||
long long *bytes_out) {
|
||||
typedef struct {
|
||||
patchdl_download_progress_cb cb;
|
||||
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;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||
struct curl_slist *rl = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
FILE *fp;
|
||||
curl_off_t dl = 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 (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);
|
||||
rl = curl_slist_append(NULL, rs443);
|
||||
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;
|
||||
|
||||
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_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_TIME, 30L);
|
||||
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);
|
||||
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
|
||||
curl_easy_cleanup(curl);
|
||||
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);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
if (rc) {
|
||||
unlink(dest_path);
|
||||
return -1;
|
||||
}
|
||||
if (bytes_out) *bytes_out = (long long)dl;
|
||||
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
|
||||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
|
||||
@@ -427,6 +612,32 @@ patchdl_http_download(const char *url, const char *dest_path,
|
||||
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
|
||||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
(void)url;
|
||||
|
||||
@@ -13,10 +13,29 @@ void patchdl_buf_free(patchdl_buf_t *b);
|
||||
|
||||
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
|
||||
success and writes the byte count to *bytes_out. */
|
||||
int patchdl_http_download(const char *url, const char *dest_path,
|
||||
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
|
||||
(dns result/ip, curl code, http status, bytes) into `out_json`. */
|
||||
|
||||
+17
-7
@@ -34,16 +34,26 @@ find_pid(const char *name) {
|
||||
if (!(buf = malloc(buf_size))) return -1;
|
||||
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
|
||||
|
||||
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) {
|
||||
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
|
||||
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
|
||||
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
|
||||
/* The loop guard guarantees the structsize/pid/tdname fields are inside the
|
||||
buffer before we read them, and the per-record check below keeps the name
|
||||
compare within the record (and thus the buffer). */
|
||||
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 */
|
||||
ptr += ki_structsize;
|
||||
if (ki_structsize <= KINFO_OFF_TDNAME) break; /* malformed/truncated */
|
||||
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;
|
||||
|
||||
ptr += ki_structsize;
|
||||
}
|
||||
|
||||
free(buf);
|
||||
|
||||
+11
-1
@@ -140,13 +140,23 @@ sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
|
||||
|
||||
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++) {
|
||||
size_t key_off = h->key_table_start + entries[i].key_offset;
|
||||
size_t val_off = h->data_table_start + entries[i].data_offset;
|
||||
|
||||
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 (entries[i].data_fmt != SFO_FMT_STR) return -1;
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ typedef struct {
|
||||
char compatible_version[16];
|
||||
char latest_version[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_storage_title_id[16]; /* title id embedded in delta_url */
|
||||
int verxml_done;
|
||||
|
||||
+12
-2
@@ -44,6 +44,11 @@ attr_val(const char *tag_start, const char *tag_end, const char *attr,
|
||||
p += strlen(needle);
|
||||
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);
|
||||
out[len] = '\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));
|
||||
}
|
||||
|
||||
/* 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 (!out->compatible_version[0] ||
|
||||
ver_gt(ver, out->compatible_version)) {
|
||||
strncpy(out->compatible_version, ver,
|
||||
sizeof(out->compatible_version) - 1);
|
||||
strncpy(out->compatible_url, durl,
|
||||
strncpy(out->compatible_url, murl[0] ? murl : durl,
|
||||
sizeof(out->compatible_url) - 1);
|
||||
extract_title_id(durl, out->compatible_storage_title,
|
||||
sizeof(out->compatible_storage_title));
|
||||
|
||||
@@ -6,7 +6,7 @@ typedef struct {
|
||||
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, 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 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_storage_title[16]; /* title id embedded in delta_url storage path */
|
||||
} patchdl_verinfo_t;
|
||||
|
||||
+259
-23
@@ -9,12 +9,14 @@
|
||||
|
||||
#include <microhttpd.h>
|
||||
#include <pthread.h>
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static struct MHD_Daemon *web_daemon;
|
||||
@@ -23,14 +25,10 @@ static patchdl_fw_t g_fw;
|
||||
static patchdl_title_t *g_titles;
|
||||
static size_t g_title_count;
|
||||
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 unsigned long g_pkg_hits;
|
||||
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
|
||||
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
|
||||
@@ -41,6 +39,20 @@ static struct {
|
||||
int delete_pkg_after_install;
|
||||
} 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
|
||||
stay constant; only the four mutable fields above are user-controlled. */
|
||||
static const char config_tail_json[] =
|
||||
@@ -56,9 +68,6 @@ static const char config_tail_json[] =
|
||||
"\"gst.prod.dl.playstation.net\","
|
||||
"\"gs2.ww.prod.dl.playstation.net\""
|
||||
"]}";
|
||||
|
||||
static const char downloads_json[] = "[]";
|
||||
|
||||
/* ---------- tiny JSON value lookups (flat objects only) ----------------- */
|
||||
|
||||
/* Find `"key"` then the following `true`/`false`; returns dflt if absent. */
|
||||
@@ -220,6 +229,39 @@ path_segment_safe(const char *s) {
|
||||
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
|
||||
record_pkg_diag(const char *url, const char *range, unsigned int status,
|
||||
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;
|
||||
char content_range[96];
|
||||
struct MHD_Response *resp;
|
||||
pkg_reader_t *reader;
|
||||
enum MHD_Result ret;
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
close(fd);
|
||||
free(reader);
|
||||
return MHD_NO;
|
||||
}
|
||||
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);
|
||||
}
|
||||
ret = MHD_queue_response(conn, status, resp);
|
||||
MHD_destroy_response(resp); /* closes fd */
|
||||
MHD_destroy_response(resp); /* pkg_reader_free closes fd */
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* ---------- status JSON ------------------------------------------------- */
|
||||
|
||||
static void
|
||||
rebuild_status_json(void) {
|
||||
snprintf(g_status_json, sizeof(g_status_json),
|
||||
/* Free space on the download partition, in MB. Best-effort: 0 if unavailable. */
|
||||
static long long
|
||||
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_build\":\"0x%08x\","
|
||||
"\"dns_guard\":\"Active\","
|
||||
"\"resolver\":\"Internal allowlist\","
|
||||
"\"free_space_mb\":0,"
|
||||
"\"free_space_mb\":%lld,"
|
||||
"\"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) -------------- */
|
||||
@@ -518,6 +588,45 @@ build_titles_json(void) {
|
||||
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 ------------------------------------ */
|
||||
|
||||
/* 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_title_id, size_t pt_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;
|
||||
|
||||
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';
|
||||
strncpy(content_id, g_titles[i].content_id, ci_sz - 1);
|
||||
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;
|
||||
found = 1;
|
||||
break;
|
||||
@@ -637,13 +753,86 @@ static void
|
||||
title_pkg_path(const char *title_id, const char *patch_url,
|
||||
char *out, size_t out_sz) {
|
||||
const char *base = strrchr(patch_url, '/');
|
||||
char name[192];
|
||||
size_t n;
|
||||
|
||||
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
|
||||
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];
|
||||
long long bytes = 0;
|
||||
|
||||
@@ -665,12 +854,52 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
mkdir(dir, 0777);
|
||||
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),
|
||||
"{\"ok\":false,\"reason\":\"download_failed\"}");
|
||||
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). */
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"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_storage_title_id[16] = {0};
|
||||
char content_id[64] = {0};
|
||||
char name[128] = {0};
|
||||
char version[16] = {0};
|
||||
int enabled = 0;
|
||||
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_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");
|
||||
|
||||
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);
|
||||
|
||||
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"))
|
||||
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");
|
||||
|
||||
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"))
|
||||
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());
|
||||
|
||||
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"))
|
||||
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.
|
||||
The web UI is reachable only after this completes. */
|
||||
patchdl_fw_get(&g_fw);
|
||||
rebuild_status_json();
|
||||
|
||||
if (patchdl_scan(&g_titles, &g_title_count))
|
||||
g_title_count = 0;
|
||||
|
||||
@@ -83,3 +83,8 @@ unknown
|
||||
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
|
||||
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
@@ -126,21 +126,8 @@ const fallback = {
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
downloads: [
|
||||
{
|
||||
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",
|
||||
],
|
||||
downloads: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
let state = {
|
||||
@@ -153,6 +140,7 @@ let state = {
|
||||
query: "",
|
||||
usingFallback: false,
|
||||
};
|
||||
let downloadPollTimer = null;
|
||||
|
||||
const els = {};
|
||||
|
||||
@@ -208,6 +196,7 @@ function bindEvents() {
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
state.usingFallback = false;
|
||||
const [status, config, titles, downloads] = await Promise.all([
|
||||
getJson(API.status, fallback.status),
|
||||
getJson(API.config, fallback.config),
|
||||
@@ -215,6 +204,18 @@ async function loadInitialData() {
|
||||
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,
|
||||
status,
|
||||
@@ -224,6 +225,7 @@ async function loadInitialData() {
|
||||
};
|
||||
|
||||
render();
|
||||
if (state.downloads.length) startDownloadPolling();
|
||||
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
|
||||
}
|
||||
|
||||
@@ -390,6 +392,18 @@ function createGameCard(game) {
|
||||
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);
|
||||
card.append(title, versions, controls);
|
||||
return card;
|
||||
@@ -449,16 +463,29 @@ function renderQueue() {
|
||||
}
|
||||
|
||||
state.downloads.forEach((item) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(item.progress) || 0));
|
||||
const row = document.createElement("div");
|
||||
row.className = "queue-item";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
<span>${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")}</span>
|
||||
<div class="progress"><i style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></i></div>
|
||||
</div>
|
||||
<span>${item.progress || 0}%</span>
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.innerHTML = `
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
<span>${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")}</span>
|
||||
<div class="progress"><i style="width:${pct}%"></i></div>
|
||||
`;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -480,7 +507,63 @@ function renderSourcePolicies() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -523,26 +606,59 @@ function rowAction(game) {
|
||||
|
||||
async function doDownload(game) {
|
||||
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();
|
||||
startDownloadPolling();
|
||||
let r;
|
||||
try {
|
||||
r = await postJson(API.action(game.title_id, "download"), {});
|
||||
} catch (error) {
|
||||
game.downloading = false;
|
||||
state.downloads = state.downloads.filter((item) => item.title_id !== game.title_id);
|
||||
const why = reasonText(error);
|
||||
state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`);
|
||||
showToast(`${game.name}: ${why}`);
|
||||
renderGames();
|
||||
renderQueue();
|
||||
renderLogs();
|
||||
stopDownloadPolling();
|
||||
return 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;
|
||||
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)`);
|
||||
showToast(`${game.name}: downloaded ${mb} MB.`);
|
||||
renderGames();
|
||||
renderQueue();
|
||||
renderLogs();
|
||||
stopDownloadPolling();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -665,6 +781,7 @@ const REASON_TEXT = {
|
||||
source_unknown: "Source unknown — blocked.",
|
||||
no_compatible_patch: "No compatible patch available.",
|
||||
download_failed: "Download failed.",
|
||||
download_in_progress: "Another download is already running.",
|
||||
};
|
||||
function reasonText(error) {
|
||||
const r = error && error.body && error.body.reason;
|
||||
|
||||
+24
-1
@@ -493,7 +493,7 @@ h2 {
|
||||
|
||||
.queue-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 92px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
@@ -502,6 +502,29 @@ h2 {
|
||||
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 {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in new issue
Block a user