diff --git a/README.md b/README.md index e32a56b..1901545 100644 --- a/README.md +++ b/README.md @@ -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://: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. diff --git a/src/patchdl_install.c b/src/patchdl_install.c index 11d76bf..093d15c 100644 --- a/src/patchdl_install.c +++ b/src/patchdl_install.c @@ -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; diff --git a/src/patchdl_net.c b/src/patchdl_net.c index a2591d0..5662d52 100644 --- a/src/patchdl_net.c +++ b/src/patchdl_net.c @@ -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; diff --git a/src/patchdl_net.h b/src/patchdl_net.h index eff444b..3edae0a 100644 --- a/src/patchdl_net.h +++ b/src/patchdl_net.h @@ -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`. */ diff --git a/src/patchdl_proc.c b/src/patchdl_proc.c index b5c083c..ae8d7ee 100644 --- a/src/patchdl_proc.c +++ b/src/patchdl_proc.c @@ -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); diff --git a/src/patchdl_scan.c b/src/patchdl_scan.c index 46d6e5d..0d1e40b 100644 --- a/src/patchdl_scan.c +++ b/src/patchdl_scan.c @@ -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; diff --git a/src/patchdl_scan.h b/src/patchdl_scan.h index 6578e26..de9de97 100644 --- a/src/patchdl_scan.h +++ b/src/patchdl_scan.h @@ -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; diff --git a/src/patchdl_verxml.c b/src/patchdl_verxml.c index c38f6e7..333db4b 100644 --- a/src/patchdl_verxml.c +++ b/src/patchdl_verxml.c @@ -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)); diff --git a/src/patchdl_verxml.h b/src/patchdl_verxml.h index b4c7306..9ec1091 100644 --- a/src/patchdl_verxml.h +++ b/src/patchdl_verxml.h @@ -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; diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index c8a0abd..9b3447b 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -9,12 +9,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include 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; diff --git a/web/README.md b/web/README.md index 9589545..5691dd8 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/app.js b/web/app.js index 6c1183f..63391ea 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = ` -
- ${escapeHtml(item.name)} - ${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")} -
-
- ${item.progress || 0}% + + const info = document.createElement("div"); + info.innerHTML = ` + ${escapeHtml(item.name)} + ${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")} +
`; + + 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; diff --git a/web/styles.css b/web/styles.css index 19b9000..0e6f821 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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;