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.
- 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
View File
@@ -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
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);
}
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;
+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);
/* 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
View File
@@ -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
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));
/* 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
View File
@@ -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
View File
@@ -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));
+1 -1
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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;