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