8 Commits
Author SHA1 Message Date
Knutwurst 79e1c377ed Update README for 0.0.3: parallel pool, resume, verify, honest install status
Document the connection-pool download (configurable 1–16, applied live), the
download queue, reboot-safe per-piece resume, Pause/Resume/Cancel, optional
SHA-256 verification, and on-device package verification. Add a Settings section.
Rewrite Status: download + verify is proven on 11.60 (a 61.6 GB update verified
byte-perfect across reboots); same-region install works; cross-region debug-magic
patches download and verify but cannot be installed via homebrew on 11.60.
2026-06-24 15:53:45 +02:00
Knutwurst fb9d06fb5b Install: pass the /user/data path Sony allowlists; report per-URI rc
Sony path-allowlists the URI given to sceAppInstUtilInstallByPackage —
/user/data/ and /mnt/usb are accepted, a bare /data/... path is rejected with
0x80B2116F (confirmed by the ps5upload project). PatchDL stored the pkg under
/data/patchdl and passed that /data path, so every install was rejected at the
path stage. Pass the /user/data view of the same file instead (the code already
computed it as sdk_path; it was only used for AppInstallPkg before).

Also report each URI's individual rc instead of only the last attempt's, which
revealed the real wall: via file:// the installer reaches header parsing and
rejects with 0x80B21106 — the assembled file is a valid but DEBUG-magic PKG
(\x7FFIH, not retail \x7FCNT), the format Sony's system updater consumes rather
than the retail-pkg format InstallByPackage expects.
2026-06-24 14:25:50 +02:00
Knutwurst 3d2ee430e1 Add read-only pkg diagnostics: manifest dump, integrity verify, embedded ids
Three read-only endpoints (no install, no writes) to inspect a downloaded
package on-device:

- GET /api/manifest/<title_id> — re-fetch the patch manifest (PatchDL bypasses
  the DNS block) and dump each piece's offset/size/SHA-256.
- GET /api/pkgverify/<title_id> — SHA-256 every piece of the assembled .pkg
  against the manifest hashes, on-device (SSD, no multi-GB transfer), and report
  per-piece pass/fail. Proves whether the file is byte-correct.
- GET /api/pkgmeta/<title_id> — read the pkg's embedded content id + title id
  (GetContentIdFromPkg) vs the target ids, to expose cross-region linkage.

Supporting code: patchdl_sha256_fd_region() (pread + OpenSSL EVP) in the net
layer, and bind sceAppInstUtilGetContentIdFromPkg in the install backend.

Used to diagnose the Dead Island 2 install: the 61.6 GB package verifies
byte-perfect (17/17 pieces) and its content id matches the target, so the
0x80B2116F install rejection is a Sony install-method limitation, not the data.
2026-06-24 14:10:39 +02:00
Knutwurst 986ff00c36 Persist resume state every piece; replace conn field with a stepper
Resume: write the sidecar after every completed piece instead of batching
every 8. Each piece's bytes are already fdatasync'd and the sidecar write is
a tiny atomic tmp+rename, so an unclean kill now re-downloads only the pieces
still in flight, not a batch of up-to-8 already-finished ones. Drops the now
-unused 'unpersisted' counter.

UI: the "parallel download connections" control is now a stepper — two large
54px -/+ buttons around a tabular value, in a row beside its label, instead of
a full-width number field for a 1-16 value. Big targets and a clear green focus
ring suit controller navigation (the UI is driven by the PS5 pad via the home
tile). Tapping -/+ updates and auto-saves that field alone (debounced), applying
live on the server.
2026-06-24 12:10:29 +02:00
Knutwurst 6024dbfc5d Apply the connection-count setting live, without a payload restart
The pool now spawns the full worker set at startup and gates each worker by
its slot against a live active_conns limit, instead of spawning exactly
max_connections threads once. Saving a new value in Settings updates the
limit and broadcasts: idle workers wake to pull pieces, and a lowered limit
parks the extra workers after they finish their current piece. No restart,
and no thread creation/teardown at runtime.

Verified on device: max_connections changed 4 -> 8 -> 16 -> 4 through the API
while a download stayed active throughout. (Throughput did not scale with
connections on this CDN, which caps aggregate bandwidth per source IP; 4 is a
sensible default.)
2026-06-24 11:36:48 +02:00
Knutwurst 21f6bd61ad Download patches over a connection pool with a queue and resume
Replace the single sequential transfer with a pool of N worker threads
that pull pieces of one manifest in parallel, lifting the per-connection
~7 MB/s ceiling. One job runs at a time; the rest queue. The connection
count is configurable (1-16, default 4) and applies on the next start.

Resume is tracked per piece in a sidecar bitmap that survives a reboot,
and a one-time migration recognises a partial written by the old
sequential build (a piece-aligned contiguous prefix on disk) and marks
those pieces done so an in-progress download is not restarted from zero.

Pause keeps the partial; Cancel deletes it. Both, plus Resume, are
available at any point in a download's life.

Concurrency review fixes folded in:
- a job is published as the active (claimable) job only after its
  manifest/state/fd are attached, so a half-built job can no longer be
  settled to "done" before any bytes are fetched
- cancel/pause during the admit I/O window only flag the job; admit_next
  is the sole finalizer, closing a use-after-free and a lost-pause race
- resuming a paused job frees the stale per-job buffers and zeroes the
  committed counters before re-seeding, fixing a leak and a double-count
- the background version.xml thread is joined on shutdown before the
  title list is freed
- verify_downloads is snapshotted under its own lock before the pool lock
- the web UI keeps Resume/Cancel after a failed transfer and bounds the
  local "downloading" bridge flag so a card cannot wedge
2026-06-24 11:12:25 +02:00
Knutwurst 01eaef79f2 Add pause/resume, within-part byte-range resume; bump to 0.0.3
Split the single morphing button into a green/amber play-pause (Update →
Pause → Resume) and a red stop (Cancel). Pause aborts the download but
keeps the partial (resumable); Cancel aborts and deletes. Backend gets a
separate pause flag distinct from cancel.

Resume now continues WITHIN a part: the partially-written piece is fetched
from its last byte via an HTTP byte range (with a safe fall back to
re-fetching the whole piece if the CDN ignores the range), instead of
re-downloading the whole part. A title is resumable as soon as any bytes
are on disk.

Version bumped to 0.0.3 (no release tagged).
2026-06-24 09:47:44 +02:00
Knutwurst 66e4912485 Resume interrupted downloads across a reboot
An interrupted download (cancel excepted) now keeps its partial package on
disk instead of deleting it, and records the manifest it belongs to in a
sidecar (state.json). On the next start the title is flagged resumable and
the UI shows a "Paused — X downloaded" note with a Resume button.

Resume refetches the manifest, skips every piece already fully on disk, and
re-fetches only the one partially-written piece (piece-granular, no HTTP
range needed), appending the rest. The fresh-download path is unchanged. A
partial belonging to a different/older manifest is dropped and the download
starts clean; a corrupt (failed SHA-256) download is not kept.

Survives a reboot: a killed payload runs no cleanup, so the partial and its
sidecar persist under /data/patchdl until resumed, completed, or deleted.
2026-06-24 08:58:32 +02:00
11 changed files with 1790 additions and 361 deletions

No files matched your search

+76 -48
View File
@@ -1,31 +1,54 @@
# PatchDL
A standalone PlayStation 5 ELF payload that downloads and installs official game
patches on your terms. It serves its own web UI and runs without etaHEN.
patches on your terms. It serves its own dark-mode web UI and runs without
etaHEN.
PatchDL is built for setups where nanoDNS blocks Sony's servers for the whole
console. It resolves the Sony patch CDN on its own path, so the rest of the
console. It resolves the Sony patch CDN on its own DNS path, so the rest of the
system stays offline and only the patches you pick get fetched.
by Knutwurst
## What it does
- Scans installed titles and classifies each one: genuine install,
ShadowMountPlus mount, preinstall, or unknown.
- Reads the title name, installed version, and the Sony `version.xml` URL from
the PS5 app database.
- Fetches each title's `version.xml` from Sony's CDN past nanoDNS (a raw DNS
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
- Scans installed titles and classifies each: genuine install, ShadowMountPlus
mount, preinstall, or unknown.
- Reads the title name, installed version, and Sony `version.xml` URL from the
PS5 app database.
- Fetches each title's `version.xml` past nanoDNS (a raw DNS 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 installable package from Sony's manifest pieces and installs it
through Sony's AppInstUtil service.
- Downloads the patch from Sony's manifest pieces into one local `.pkg`, then
installs it through Sony's AppInstUtil service.
## Downloading
PatchDL pulls each patch over a pool of connections instead of one stream, which
lifts the ~7 MB/s per-connection ceiling on the Sony CDN. Set the connection
count (1 to 16) in Settings with the stepper; the change applies live, with no
payload restart. One patch downloads at a time and further requests queue.
Downloads survive interruptions:
- **Resume across a reboot.** PatchDL records progress per manifest piece in a
sidecar beside the `.pkg`, written after every completed piece, so a reboot or
relaunch continues where it stopped.
- **Pause, Resume, Cancel.** Pause keeps the partial file, Resume continues it,
Cancel deletes it. All three work at any point in a download.
- **Verification.** Turn on "Verify downloaded pieces (SHA-256)" to check each
piece against its manifest hash while downloading. After a download you can
also verify the assembled package on-device against Sony's per-piece hashes
(`GET /api/pkgverify/<title_id>`).
Patches download to `/data/patchdl` on the internal SSD. Large retail updates
run tens of GB.
## Safety model
Deny-by-default. A patch is installed only for a genuine install, and only when
the patch metadata targets the installed game:
Deny-by-default. A patch installs only for a genuine install, and only when the
patch metadata targets the installed game:
| Source | Check | Download | Install |
|-----------------------|-------|----------|---------|
@@ -33,29 +56,30 @@ the patch metadata targets the installed game:
| shadowmount | yes | yes | no |
| preinstall / unknown | yes | no | no |
Two independent guards stop the wrong target being installed: the patch target
id (read from `version.xml` / `manifest_url`) must match the installed game, and
the install call receives the installed game's content id from app.db. Sony may
store the actual patch bytes under a regional/master title id that differs from
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.
Two guards stop the wrong target being installed: the patch target id (from
`version.xml` / `manifest_url`) must match the installed game, and the install
call receives the installed game's content id from app.db. PatchDL refuses a
true target-title mismatch rather than installing 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.
All writes stay under `/data/patchdl`. PatchDL never writes to the system
partition and never touches firmware.
## Settings
Settings persist to `/data/patchdl/config.json` and survive a restart:
- Default policy (allow or deny) and a per-game enable toggle.
- Install after download, as a global default with a per-game override.
- Delete the PKG after a successful install.
- Verify downloaded pieces (SHA-256).
- Parallel download connections (1 to 16), applied live.
## Build
Requires `ps5-payload-dev/sdk`. The network and install features also need the
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` placed in the SDK
sysroot (`target/user/homebrew`); `scripts/build_ps5.sh` enables them
automatically when present. libmicrohttpd is vendored under `vendor/etahen`, and
SQLite is vendored under `vendor/sqlite`.
Requires `ps5-payload-dev/sdk`. The network and install features need the
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` in the SDK sysroot
(`target/user/homebrew`); `scripts/build_ps5.sh` enables them when present.
libmicrohttpd is vendored under `vendor/etahen`, SQLite under `vendor/sqlite`.
```sh
scripts/build_ps5.sh # produces patchdl-ps5.elf
@@ -63,9 +87,9 @@ scripts/build_ps5.sh # produces patchdl-ps5.elf
## Deploy
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port
8084 (not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the ELF named with its
version and launches it; the payload replaces any running instance itself.
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF and
launches it; the payload replaces any running instance.
```sh
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
@@ -79,17 +103,21 @@ http://<console-ip>:12880/
## Status
0.0.2, early. Title scan, source classification, version resolution,
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.
Verified on firmware 11.60: title scan, source classification, version
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
download pool, reboot-safe resume, and on-device SHA-256 verification. A full
Dead Island 2 update (61.6 GB) downloaded and verified byte-perfect across
several reboots.
Install works for same-region patches, where Sony stores the patch bytes under
the installed game's own title id.
Cross-region patches are a known limitation. Sony sometimes packages a regional
patch under a different (master) storage title and ships it as a debug-magic
container. PatchDL downloads such a patch and verifies it against Sony's hashes,
but the on-console installer (`InstallByPackage`) rejects it on 11.60, and the
homebrew alternative (BGFT register) returns "not supported" outside the system
process. Installing that class of patch needs Sony's authenticated updater, which
nanoDNS blocks. The web UI marks a title "Installing…" and reads progress from the
PS5's own notifications. Disc games need the disc inserted for their patch to
apply, which is a normal Sony requirement.
+92 -32
View File
@@ -57,11 +57,13 @@ typedef int (*ai_install_pkg_fn)(const char *path, ai_pkg_info_t *info);
typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info,
ai_playgo_info_t *playgo);
typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app);
typedef int (*ai_content_from_pkg_fn)(const char *path, char *content_id, int *is_app);
static ai_init_fn ai_initialize;
static ai_install_pkg_fn ai_install_pkg;
static ai_install_by_pkg_fn ai_install_by_package;
static ai_title_from_pkg_fn ai_title_from_pkg;
static ai_init_fn ai_initialize;
static ai_install_pkg_fn ai_install_pkg;
static ai_install_by_pkg_fn ai_install_by_package;
static ai_title_from_pkg_fn ai_title_from_pkg;
static ai_content_from_pkg_fn ai_content_from_pkg;
/* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
@@ -205,6 +207,8 @@ backend_init_thread(void *arg) {
"sceAppInstUtilInstallByPackage");
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilGetTitleIdFromPkg");
ai_content_from_pkg = (ai_content_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilGetContentIdFromPkg");
/* Read-only feasibility probe — module is loaded, safe context. */
fill_probe();
@@ -284,6 +288,56 @@ patchdl_install_api_probe(char *out, size_t out_sz) {
return -1;
}
/* Read-only: report the .pkg's embedded content id + title id (and whether it
is a full app vs a patch). No install, no side effects. 0 if anything read. */
int
patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz,
char *title_id, size_t tid_sz, int *is_app,
char *msg, size_t msg_sz) {
char sdk_path[1024];
char cid[64] = {0}, tid[48] = {0};
int app_c = 0, app_t = 0, ok = 0;
struct stat st;
if (content_id && cid_sz) content_id[0] = '\0';
if (title_id && tid_sz) title_id[0] = '\0';
if (is_app) *is_app = 0;
if (!local_path || !local_path[0] || stat(local_path, &st) != 0) {
snprintf(msg, msg_sz, "package not on disk");
return -1;
}
backend_start();
if (g_stage != 5) {
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
return -1;
}
if (!strncmp(local_path, "/data/", 6))
snprintf(sdk_path, sizeof sdk_path, "/user%s", local_path);
else
snprintf(sdk_path, sizeof sdk_path, "%s", local_path);
if (ai_content_from_pkg &&
ai_content_from_pkg(sdk_path, cid, &app_c) == 0 && cid[0]) {
if (content_id && cid_sz) {
strncpy(content_id, cid, cid_sz - 1);
content_id[cid_sz - 1] = '\0';
}
if (is_app) *is_app = app_c;
ok = 1;
}
if (ai_title_from_pkg &&
ai_title_from_pkg(sdk_path, tid, &app_t) == 0 && tid[0]) {
if (title_id && tid_sz) {
strncpy(title_id, tid, tid_sz - 1);
title_id[tid_sz - 1] = '\0';
}
ok = 1;
}
snprintf(msg, msg_sz, ok ? "ok" : "could not read pkg metadata");
return ok ? 0 : -1;
}
int
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
const char *storage_title_id,
@@ -294,7 +348,6 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
struct stat st;
int rc;
int pkg_tid_mismatch = 0;
const char *last_uri = "";
if (!local_path || !local_path[0]) {
snprintf(msg, msg_sz, "no package path");
@@ -311,10 +364,11 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
return -1;
}
/* AppInstallPkg runs in a sandbox that sees the user partition as
/user/data, not /data. InstallByPackage is different: the shell/debug
installer path takes the normal /data/... URI, so keep `local_path` for
that API and use `sdk_path` only for AppInstallPkg / metadata probes. */
/* Sony's installer sees the user partition as /user/data, not /data, and
PATH-ALLOWLISTS the URI passed to InstallByPackage: /user/data/ and
/mnt/usb are accepted, but a bare /data/... path is REJECTED with
0x80B2116F (empirically confirmed by the ps5upload project). So feed the
/user/data view of the file to both InstallByPackage and AppInstallPkg. */
if (!strncmp(local_path, "/data/", 6))
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
else
@@ -359,7 +413,7 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
const char *title_dir;
const char *file_base;
snprintf(file_uri, sizeof(file_uri), "file://%s", local_path);
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
title_dir = strstr(local_path, "/data/patchdl/");
file_base = strrchr(local_path, '/');
if (title_dir && file_base && file_base > title_dir + strlen("/data/patchdl/")) {
@@ -379,8 +433,8 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
ip, PATCHDL_HTTP_PORT, title_id, file_base + 1);
}
}
uris[0] = local_path;
uris[1] = file_uri;
uris[0] = sdk_path; /* /user/data/... — the allowlisted path */
uris[1] = file_uri; /* file:///user/data/... */
uris[2] = http_loop_uri[0] ? http_loop_uri : NULL;
uris[3] = http_lan_uri[0] ? http_lan_uri : NULL;
meta.ex_uri = "";
@@ -389,28 +443,34 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
meta.content_name = "PatchDL";
meta.icon_url = "";
for (int i = 0; i < 4; i++) {
if (!uris[i]) continue;
memset(&pkg, 0, sizeof(pkg));
memset(&playgo, 0, sizeof(playgo));
meta.uri = uris[i];
last_uri = uris[i];
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
if (rc2 == 0) {
snprintf(msg, msg_sz, "install started (InstallByPackage%s)",
pkg_tid_mismatch ? ", shared master bytes" : "");
return 0;
{
char tries[260] = {0};
const char *labels[4] = { "userdata", "file", "loop", "lan" };
for (int i = 0; i < 4; i++) {
if (!uris[i]) continue;
memset(&pkg, 0, sizeof(pkg));
memset(&playgo, 0, sizeof(playgo));
meta.uri = uris[i];
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
{
size_t l = strlen(tries);
snprintf(tries + l, sizeof(tries) - l, "%s%s=0x%08x",
l ? "," : "", labels[i], (unsigned)rc2);
}
if (rc2 == 0) {
snprintf(msg, msg_sz, "install started (InstallByPackage%s)",
pkg_tid_mismatch ? ", shared master bytes" : "");
return 0;
}
}
rc = rc2;
if (pkg_tid_mismatch) {
snprintf(msg, msg_sz,
"install rejected (pkg %.12s -> %.12s; tries: %s)",
pkg_tid, expected_title_id ? expected_title_id : "", tries);
return rc ? rc : -1;
}
}
rc = rc2;
}
if (pkg_tid_mismatch) {
snprintf(msg, msg_sz,
"install rejected (InstallByPackage=0x%08x, pkg %.12s, target %.12s, uri %.96s)",
(unsigned)rc, pkg_tid, expected_title_id ? expected_title_id : "",
last_uri);
return rc ? rc : -1;
}
/* Last resort for normal same-title packages only. This path has no target
+6
View File
@@ -31,3 +31,9 @@ int patchdl_install_backend_check(char *msg, size_t msg_sz);
AppInstUtil/Bgft patch-install symbols and report which exist on this
firmware. Writes a JSON object into `out`. No install, no side effects. */
int patchdl_install_api_probe(char *out, size_t out_sz);
/* Read-only: report the .pkg's embedded content id + title id (and whether it
is a full app vs a patch, via *is_app). No install. 0 if anything was read. */
int patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz,
char *title_id, size_t tid_sz, int *is_app,
char *msg, size_t msg_sz);
+350 -43
View File
@@ -198,14 +198,14 @@ dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
return 0;
}
}
pthread_mutex_unlock(&dns_cache_mtx);
/* Cold miss: resolve while HOLDING the cache lock (single-flight). N pool
workers needing the same CDN host would otherwise each blast Sony's
rate-limited resolver; this way one resolves and the rest get the cache.
It also serializes dns_resolve so its diagnostic globals can't be raced.
(This is the DNS lock, independent of the pool lock.) */
for (int attempt = 0; attempt < 4 && rc; attempt++)
rc = dns_resolve(host, ip_out, ip_sz);
if (rc) return -1;
pthread_mutex_lock(&dns_cache_mtx);
if (dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
if (!rc && dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
strncpy(dns_cache[dns_cache_n].host, host,
sizeof(dns_cache[0].host) - 1);
strncpy(dns_cache[dns_cache_n].ip, ip_out,
@@ -213,7 +213,7 @@ dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
dns_cache_n++;
}
pthread_mutex_unlock(&dns_cache_mtx);
return 0;
return rc;
}
/* ---------- HTTP GET via curl ------------------------------------------- */
@@ -344,14 +344,20 @@ curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
}
/* Returns 0 on success, -1 on download/network failure, -2 when an expected
SHA-256 was given and the downloaded bytes did not match it. */
SHA-256 was given and the downloaded bytes did not match it, -3 when a byte
range was requested (range_start>0) but the server ignored it (no HTTP 206).
When range_start>0 the body is appended at the file's current position, so
the caller must have it positioned at range_start and must not verify. */
static int
http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
progress_state_t *progress,
const char *expected_sha256_hex) {
const char *expected_sha256_hex,
long long range_start) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
char range_hdr[48];
long http_code = 0;
struct curl_slist *rl = NULL;
struct curl_blob ca_blob;
curl_off_t dl = 0;
@@ -400,6 +406,10 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
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 (range_start > 0) {
snprintf(range_hdr, sizeof(range_hdr), "%lld-", range_start);
curl_easy_setopt(curl, CURLOPT_RANGE, range_hdr);
}
if (progress && progress->cb) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb);
@@ -407,6 +417,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
}
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
@@ -415,6 +426,12 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
if (sink.md) EVP_MD_CTX_free(sink.md);
return -1;
}
/* Asked for a byte range but the server sent the whole file (no 206): the
caller must drop the piece and re-fetch it whole. */
if (range_start > 0 && http_code != 206) {
if (sink.md) EVP_MD_CTX_free(sink.md);
return -3;
}
if (sink.md) {
unsigned char dig[EVP_MAX_MD_SIZE];
@@ -440,7 +457,7 @@ patchdl_http_download_progress(const char *url, const char *dest_path,
int rc;
if (!fp) return -1;
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL);
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL, 0);
fclose(fp);
if (rc) {
@@ -504,15 +521,13 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx, int verify) {
void *ctx, int verify, int resume) {
patchdl_buf_t manifest;
const char *pieces;
const char *p;
FILE *fp;
long long total = 0;
const char *pieces, *pieces_end, *p;
FILE *fp = NULL;
long long total = 0, have = 0;
unsigned long long manifest_total = 0;
int count = 0;
int rc = -1;
int count = 0, started, rc = -1;
if (bytes_out) *bytes_out = 0;
if (patchdl_http_get(manifest_url, &manifest))
@@ -529,52 +544,110 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
}
/* 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, ']');
pieces_end = strchr(pieces, ']');
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
fp = fopen(dest_path, "wb");
if (!fp) {
free(manifest.data);
return -1;
/* Resume: reopen the existing partial and keep its bytes; else start clean.
Fully-downloaded pieces are skipped; the one piece that was only partially
written continues mid-piece via an HTTP byte range (with a fall back to
re-fetching it whole if the CDN ignores the range). */
if (resume) {
fp = fopen(dest_path, "r+b");
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
}
if (!fp) { fp = fopen(dest_path, "wb"); have = 0; }
if (!fp) { free(manifest.data); return -1; }
started = (have <= 0);
p = pieces;
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
char url[768];
char hash[80] = {0};
long long got = 0;
long long got = 0, range_start = 0;
unsigned long long expected = 0;
unsigned long long offset = 0;
int have_offset, drc;
const char *want_hash;
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);
if (verify)
json_string_after(p, "hashValue", hash, sizeof(hash));
/* 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)
/* Piece already fully present from a previous run: skip the download. */
if (!started && have_offset && expected &&
have >= (long long)(offset + expected)) {
total = (long long)(offset + expected);
count++;
if (cb && cb(ctx, total, manifest_total ? (long long)manifest_total : total))
goto done;
p = obj_end ? obj_end + 1 : p + 5;
continue;
}
/* First piece to (re)download while resuming. If part of it is already
on disk, resume WITHIN it with a byte range; otherwise drop any stray
bytes and fetch it whole. After this, every piece is fetched whole. */
if (!started) {
if (have_offset && expected && have > (long long)offset &&
have < (long long)(offset + expected)) {
range_start = have - (long long)offset; /* this piece's bytes on disk */
fseek(fp, 0, SEEK_END); /* append at `have` */
total = have;
} else {
long long start_at = have_offset ? (long long)offset : 0;
fflush(fp);
if (ftruncate(fileno(fp), (off_t)start_at) != 0)
goto done; /* can't resume cleanly; keep partial */
fseek(fp, 0, SEEK_END);
total = start_at;
}
started = 1;
}
/* Whole pieces are concatenated in array order; a ranged (partial) piece
starts mid-piece, so the contiguity guard applies only to whole ones. */
if (have_offset && range_start == 0 && offset != (unsigned long long)total)
goto done;
/* drc: 0 ok, -1 network/cancel, -2 SHA-256 mismatch (propagated out). */
drc = http_download_to_file_progress(url, fp, &got, &progress,
hash[0] ? hash : NULL);
/* A ranged piece can't be hashed (only its tail is fetched). */
want_hash = NULL;
if (range_start == 0 && verify) {
json_string_after(p, "hashValue", hash, sizeof(hash));
want_hash = hash[0] ? hash : NULL;
}
{
progress_state_t progress = {
cb, ctx, total, manifest_total ? (long long)manifest_total : 0
};
/* drc: 0 ok, -1 network/cancel, -2 SHA-256, -3 range ignored. */
drc = http_download_to_file_progress(url, fp, &got, &progress,
want_hash, range_start);
if (drc == -3) {
/* Server ignored the range: drop the piece and fetch it whole. */
fflush(fp);
if (ftruncate(fileno(fp), (off_t)offset) != 0)
goto done;
fseek(fp, 0, SEEK_END);
total = (long long)offset;
range_start = 0;
if (verify) {
json_string_after(p, "hashValue", hash, sizeof(hash));
want_hash = hash[0] ? hash : NULL;
}
progress.base = total;
drc = http_download_to_file_progress(url, fp, &got, &progress,
want_hash, 0);
}
}
if (drc) {
if (drc == -2) rc = -2;
goto done;
}
if (expected && (unsigned long long)got != expected)
/* range_start + got = this piece's bytes now on disk. */
if (expected && (unsigned long long)(range_start + got) != expected)
goto done;
total += got;
@@ -593,7 +666,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
done:
fclose(fp);
free(manifest.data);
if (rc) unlink(dest_path);
/* Keep the partial on failure so it can be resumed; the caller deletes it
on cancel or on a corrupt-verify (-2). */
return rc;
}
@@ -601,7 +675,240 @@ 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, 0);
bytes_out, NULL, NULL, 0, 0);
}
/* ---- global init + parallel piece download (connection pool) ----------- */
void patchdl_net_global_init(void) { curl_global_init(CURL_GLOBAL_ALL); }
void patchdl_net_global_cleanup(void) { curl_global_cleanup(); }
/* Write sink for one piece: pwrite at a fixed base offset (concurrent
non-overlapping pieces of the same fd are safe), tee into SHA-256 if asked,
and publish bytes-so-far for live progress. */
typedef struct {
int fd;
long long base;
long long written;
EVP_MD_CTX *md;
volatile long long *bytes_slot;
} piece_sink_t;
static size_t
piece_write_cb(void *ptr, size_t size, size_t nmemb, void *ud) {
piece_sink_t *s = (piece_sink_t *)ud;
size_t n = size * nmemb;
ssize_t w;
if (n == 0) return 0;
w = pwrite(s->fd, ptr, n, (off_t)(s->base + s->written));
if (w < 0 || (size_t)w != n) return 0; /* short write -> curl errors out */
if (s->md) EVP_DigestUpdate(s->md, ptr, n);
s->written += (long long)n;
if (s->bytes_slot) *s->bytes_slot = s->written;
return n;
}
static int
piece_xfer_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
curl_off_t ultotal, curl_off_t ulnow) {
volatile int *abort_flag = (volatile int *)clientp;
(void)dltotal; (void)dlnow; (void)ultotal; (void)ulnow;
return (abort_flag && *abort_flag) ? 1 : 0; /* non-zero aborts the transfer */
}
int
patchdl_http_download_piece(const char *url, int fd,
long long file_offset, long long file_size,
const char *expected_sha256_or_null,
patchdl_piece_ctx_t *ctx) {
CURL *curl;
CURLcode res;
long http_code = 0;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
struct curl_slist *rl = NULL;
struct curl_blob ca_blob;
piece_sink_t sink;
int verify = (expected_sha256_or_null && expected_sha256_or_null[0]);
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
memset(&sink, 0, sizeof(sink));
sink.fd = fd;
sink.base = file_offset;
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
if (verify) {
sink.md = EVP_MD_CTX_new();
if (sink.md) EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
}
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs443);
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
rl = curl_slist_append(rl, rs80);
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
curl = curl_easy_init();
if (!curl) {
curl_slist_free_all(rl);
if (sink.md) EVP_MD_CTX_free(sink.md);
return -1;
}
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, piece_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); /* 4xx/5xx -> error, no body written */
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
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 (ctx && ctx->abort) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, piece_xfer_cb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, (void *)ctx->abort);
}
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
if (res != CURLE_OK) {
if (sink.md) EVP_MD_CTX_free(sink.md);
return -1; /* network error / abort */
}
if (file_size > 0 && sink.written != file_size) {
if (sink.md) EVP_MD_CTX_free(sink.md);
return -1; /* short or over-long -> failed */
}
if (sink.md) {
unsigned char dig[EVP_MAX_MD_SIZE];
unsigned int dl = 0;
char hex[2 * EVP_MAX_MD_SIZE + 1];
EVP_DigestFinal_ex(sink.md, dig, &dl);
EVP_MD_CTX_free(sink.md);
hex_encode(dig, dl, hex, sizeof(hex));
if (strcasecmp(hex, expected_sha256_or_null) != 0)
return -2; /* integrity mismatch */
}
fdatasync(fd); /* durable before the caller sets the done bit */
return 0;
}
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex (>=65
bytes). Uses pread so it doesn't disturb the fd offset. 0 on success. */
int
patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex) {
EVP_MD_CTX *md;
unsigned char *buf;
long long pos = offset, remaining = size;
const size_t CHUNK = 1u << 20;
out_hex[0] = '\0';
if (fd < 0 || size < 0) return -1;
md = EVP_MD_CTX_new();
if (!md) return -1;
buf = malloc(CHUNK);
if (!buf) { EVP_MD_CTX_free(md); return -1; }
EVP_DigestInit_ex(md, EVP_sha256(), NULL);
while (remaining > 0) {
size_t want = remaining > (long long)CHUNK ? CHUNK : (size_t)remaining;
ssize_t got = pread(fd, buf, want, (off_t)pos);
if (got <= 0) { free(buf); EVP_MD_CTX_free(md); return -1; }
EVP_DigestUpdate(md, buf, (size_t)got);
pos += got; remaining -= got;
}
{
unsigned char dig[EVP_MAX_MD_SIZE];
unsigned int dl = 0, i;
EVP_DigestFinal_ex(md, dig, &dl);
for (i = 0; i < dl; i++) sprintf(out_hex + 2 * i, "%02x", dig[i]);
out_hex[2 * dl] = '\0';
}
free(buf);
EVP_MD_CTX_free(md);
return 0;
}
void
patchdl_manifest_free(patchdl_manifest_t *m) {
if (!m || !m->pieces) return;
for (int i = 0; i < m->count; i++) free(m->pieces[i].url);
free(m->pieces);
m->pieces = NULL;
m->count = 0;
}
int
patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
patchdl_buf_t buf;
const char *pieces, *pieces_end, *p;
int cap = 0, n = 0;
long long running = 0;
memset(out, 0, sizeof(*out));
if (patchdl_http_get(manifest_url, &buf)) return -1;
if (!buf.data || !buf.size) { free(buf.data); return -1; }
pieces = strstr(buf.data, "\"pieces\"");
if (!pieces || !(pieces = strchr(pieces, '['))) { free(buf.data); return -1; }
pieces_end = strchr(pieces, ']');
for (p = pieces; (p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end); p += 5)
cap++;
if (cap <= 0) { free(buf.data); return -1; }
out->pieces = calloc((size_t)cap, sizeof(patchdl_piece_t));
if (!out->pieces) { free(buf.data); return -1; }
p = pieces;
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end) && n < cap) {
char url[768] = {0};
unsigned long long sz = 0, off = 0;
const char *obj_end = strchr(p, '}');
if (json_string_after(p, "url", url, sizeof(url)))
break;
json_u64_after(p, "fileSize", &sz);
if (json_u64_after(p, "fileOffset", &off) != 0)
off = (unsigned long long)running; /* no offset -> assume contiguous */
/* Validate tiling: pieces must be in order, contiguous, non-empty. */
if ((long long)off != running || sz == 0) {
patchdl_manifest_free(out);
free(buf.data);
return -1;
}
out->pieces[n].url = strdup(url);
out->pieces[n].offset = (long long)off;
out->pieces[n].size = (long long)sz;
json_string_after(p, "hashValue", out->pieces[n].hash,
sizeof(out->pieces[n].hash));
if (!out->pieces[n].url) {
patchdl_manifest_free(out);
free(buf.data);
return -1;
}
running += (long long)sz;
n++;
out->count = n; /* keep current so manifest_free frees exactly n */
p = obj_end ? obj_end + 1 : p + 5;
}
free(buf.data);
if (n == 0) { patchdl_manifest_free(out); return -1; }
out->total = running; /* authoritative assembled size */
return 0;
}
void
@@ -696,8 +1003,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx, int verify) {
(void)cb; (void)ctx; (void)verify;
void *ctx, int verify, int resume) {
(void)cb; (void)ctx; (void)verify; (void)resume;
return patchdl_http_download_manifest(manifest_url, dest_path, bytes_out);
}
+54 -2
View File
@@ -11,8 +11,57 @@ typedef struct {
patchdl_buf_t *patchdl_buf_new(void);
void patchdl_buf_free(patchdl_buf_t *b);
/* Call once, single-threaded, before any concurrent download worker starts /
after they have all joined. curl's global/OpenSSL init is otherwise lazy and
races across threads. */
void patchdl_net_global_init(void);
void patchdl_net_global_cleanup(void);
int patchdl_http_get(const char *url, patchdl_buf_t *out);
/* ---- parallel piece download (used by the connection pool) ------------- */
/* One piece of a split manifest package. `url` is heap-allocated. */
typedef struct {
char *url;
long long offset; /* byte offset of this piece in the assembled file */
long long size; /* exact length of this piece */
char hash[80]; /* manifest SHA-256 hex, or "" */
} patchdl_piece_t;
typedef struct {
patchdl_piece_t *pieces;
int count;
long long total; /* assembled file size = sum of piece sizes */
} patchdl_manifest_t;
/* Fetch + parse a Sony JSON manifest into a validated, contiguously-tiled
piece list. Returns 0 on success (caller frees with patchdl_manifest_free),
-1 on fetch/parse/tiling failure. */
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
void patchdl_manifest_free(patchdl_manifest_t *m);
/* Live state shared with one in-flight piece download. The worker owns these;
the curl callbacks read `abort` (set elsewhere) and publish progress into
`bytes_slot` (single-writer per worker slot). */
typedef struct {
volatile long long *bytes_slot; /* bytes written so far for this piece */
volatile int *abort; /* non-zero -> stop this transfer */
} patchdl_piece_ctx_t;
/* Download one whole piece and pwrite it into `fd` at `file_offset`. Concurrent
non-overlapping pieces of the same fd are safe. Returns 0 on success (and
fdatasyncs fd), -1 on network/IO/abort, -2 on a SHA-256 mismatch. */
int patchdl_http_download_piece(const char *url, int fd,
long long file_offset, long long file_size,
const char *expected_sha256_or_null,
patchdl_piece_ctx_t *ctx);
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
(caller provides >= 65 bytes). Returns 0 on success. */
int patchdl_sha256_fd_region(int fd, long long offset, long long size,
char *out_hex);
/* 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,
@@ -29,14 +78,17 @@ int patchdl_http_download_progress(const char *url, const char *dest_path,
/* Download a Sony JSON package manifest by concatenating every entry in
"pieces" into one installable PKG. When `verify` is non-zero each piece is
checked against its manifest SHA-256 (a mismatch returns -2). */
checked against its manifest SHA-256 (a mismatch returns -2). When `resume`
is non-zero an existing partial at dest_path is kept: fully-downloaded pieces
are skipped and only the remainder is fetched (survives a reboot). On any
failure the partial is left in place for a later resume. */
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, int verify);
void *ctx, int verify, int resume);
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
(dns result/ip, curl code, http status, bytes) into `out_json`. */
+2
View File
@@ -28,6 +28,8 @@ typedef struct {
char patch_storage_title_id[16]; /* title id embedded in delta_url */
int verxml_done;
int enabled; /* user policy, persisted in config.json */
int resumable; /* a partial download is on disk */
long long partial_bytes; /* size of that partial, for the UI */
} patchdl_title_t;
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once
#define PATCHDL_VERSION "0.0.2"
#define PATCHDL_VERSION "0.0.3"
+963 -149
View File
File diff suppressed because it is too large. Load diff
+199 -81
View File
@@ -29,6 +29,7 @@ const fallback = {
delete_pkg_after_install: true,
verify_downloads: false,
home_shortcut: true,
max_connections: 4,
source_policy: {
official: { allow_check: true, allow_download: true, allow_install: true },
external: { allow_check: true, allow_download: true, allow_install: true },
@@ -116,6 +117,9 @@ function bindElements() {
deleteAfterInstall: document.getElementById("deleteAfterInstall"),
verifyDownloads: document.getElementById("verifyDownloads"),
homeShortcut: document.getElementById("homeShortcut"),
connValue: document.getElementById("connValue"),
connMinus: document.getElementById("connMinus"),
connPlus: document.getElementById("connPlus"),
refreshBtn: document.getElementById("refreshBtn"),
saveBtn: document.getElementById("saveBtn"),
clearLogBtn: document.getElementById("clearLogBtn"),
@@ -148,6 +152,10 @@ function bindEvents() {
els.refreshBtn.addEventListener("click", loadInitialData);
els.saveBtn.addEventListener("click", saveConfig);
els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); });
if (els.connMinus)
els.connMinus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) - 1));
if (els.connPlus)
els.connPlus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) + 1));
}
function setView(view) {
@@ -173,27 +181,26 @@ async function loadInitialData() {
// /api/titles carries no client-only progress flags, so preserve them across a
// refresh — otherwise an in-flight download/install flips back to a clickable
// button mid-operation.
// Carry client-only flags across a refresh; the pool's job list is the source
// of truth for download state and is reconciled right after.
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
titles.forEach((g) => {
const old = prev.get(g.title_id);
// Carry client-only progress flags across a refresh — unless the server now
// reports the title up to date (the patch applied), in which case drop them.
if (old && g.status !== "up_to_date") {
if (old.downloading) g.downloading = true;
if (old.downloaded) g.downloaded = true;
if (old.installing) g.installing = true;
if (old._autoInstalled) g._autoInstalled = true;
if (old._localDownloading) g._localDownloading = true;
}
});
// Reconcile active downloads the server reports, so progress + Cancel show even
// after a hard reload or a download started from another session/device.
const activeDl = new Set(downloads.map((d) => d.title_id));
titles.forEach((g) => { if (activeDl.has(g.title_id)) g.downloading = true; });
state = { ...state, status, config, titles, downloads };
reconcileFromJobs(downloads);
render();
if (state.downloads.length) startDownloadPolling();
if (downloads.some((j) => j.state === "active" || j.state === "queued") ||
state.titles.some((g) => g._localDownloading))
startDownloadPolling();
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
}
@@ -227,6 +234,40 @@ function renderStatus() {
if (els.railSpace) els.railSpace.textContent = `${space} free`;
}
const CONN_MIN = 1, CONN_MAX = 16;
function clampConn(n) {
n = parseInt(n, 10);
if (!Number.isFinite(n)) n = 4;
return Math.max(CONN_MIN, Math.min(CONN_MAX, n));
}
function renderConnStepper() {
const n = clampConn(state.config.max_connections);
if (els.connValue) els.connValue.textContent = String(n);
if (els.connMinus) els.connMinus.disabled = n <= CONN_MIN;
if (els.connPlus) els.connPlus.disabled = n >= CONN_MAX;
}
let connSaveTimer = null;
// Stepper +/-: update + re-render immediately, then persist just this field
// (debounced) so rapid taps collapse into one POST and other unsaved form
// fields stay untouched. The server applies the new count live (no restart).
function setConnections(n) {
const v = clampConn(n);
if (v === clampConn(state.config.max_connections)) { renderConnStepper(); return; }
state.config.max_connections = v;
renderConnStepper();
clearTimeout(connSaveTimer);
connSaveTimer = setTimeout(async () => {
try {
await postJson(API.config, { max_connections: v });
showToast(`Parallel connections: ${v}`);
} catch (e) {
showToast("Could not save connections — API not reachable.");
}
}, 450);
}
function renderSettings() {
els.defaultPolicy.value = state.config.default_policy || "deny";
els.downloadDir.value = state.config.download_dir || "";
@@ -234,6 +275,7 @@ function renderSettings() {
els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
if (els.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads);
if (els.homeShortcut) els.homeShortcut.checked = state.config.home_shortcut !== false;
renderConnStepper();
els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => {
const chip = document.createElement("span");
chip.className = "host-chip";
@@ -312,6 +354,7 @@ function createGameCard(game) {
</div>
<div class="pills">
${game.downloading ? `<span class="pill live">Downloading</span>` : ""}
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
${statusPill(game)}
${sourcePill(game)}
</div>
@@ -324,8 +367,8 @@ function createGameCard(game) {
const actions = document.createElement("div");
actions.className = "card-actions";
const act = tileButton(game);
if (act) {
[primaryButton(game), stopButton(game)].forEach((act) => {
if (!act) return;
const btn = document.createElement("button");
btn.className = `row-button is-${act.variant}`;
btn.textContent = act.label;
@@ -333,16 +376,7 @@ function createGameCard(game) {
if (act.disabled) btn.disabled = true;
else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
actions.appendChild(btn);
}
// Delete a finished (not-yet-installed) download.
if (game.downloaded && !game.installing && !game.downloading) {
const del = document.createElement("button");
del.className = "row-button is-ghost";
del.textContent = "Delete";
del.title = "Delete the downloaded package";
del.addEventListener("click", () => cancelDownload(game.title_id));
actions.appendChild(del);
}
});
row.append(lead, body, actions);
card.appendChild(row);
@@ -357,6 +391,17 @@ function createGameCard(game) {
<div class="progress-meta">${progressMetaHtml(d)}</div>
`;
card.appendChild(prog);
} else if (game.resumable && game.partial_bytes > 0) {
// ---- paused partial (survived a reboot) ----
const note = document.createElement("div");
note.className = "card-progress";
note.innerHTML = `
<div class="progress-meta">
<span>Paused — <b>${formatBytes(game.partial_bytes)}</b> downloaded</span>
<span>Resume to continue</span>
</div>
`;
card.appendChild(note);
}
return card;
@@ -382,13 +427,15 @@ function buildToggle(game) {
return toggle;
}
// The single morphing action button: blue Update/Download/Install, or amber
// Cancel while a download runs. Fixed width (CSS) so the label never reflows.
function tileButton(game) {
// Primary play/pause button (green to go, amber while downloading). Fixed width
// (CSS) so the label never reflows. Returns null when there is nothing to do.
function primaryButton(game) {
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
if (game.downloading) return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop the download and delete the partial file" };
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
if (game.patch_title_match === false) return null;
if (!isInstallAllowed(game)) return null;
if (game.resumable)
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
if (game.downloaded && game.status === "available")
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
if (game.status !== "available") return null;
@@ -397,6 +444,15 @@ function tileButton(game) {
: { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." };
}
// Red stop button: present whenever there is a download to stop or discard.
// Cancel stops AND deletes (unlike Pause, which keeps the partial).
function stopButton(game) {
if (game.installing) return null;
if (game.downloading || game.resumable || game.downloaded)
return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop and delete the download." };
return null;
}
function statusPill(game) {
if (game.installing) return `<span class="pill warn">Installing…</span>`;
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
@@ -427,6 +483,7 @@ function progressMetaHtml(d) {
const done = Number(d.bytes) || 0;
const total = Number(d.total_bytes) || 0;
const speed = Number(d._speed) || 0;
if (d.state === "queued") return `<span>Queued — waiting for a free slot</span>`;
const parts = [];
parts.push(`<span><b>${formatBytes(done)}</b>${total > 0 ? ` / ${formatBytes(total)}` : ""}</span>`);
if (speed > 0) parts.push(`<span><b>${formatBytes(speed)}/s</b></span>`);
@@ -449,55 +506,99 @@ function stopDownloadPolling() {
downloadPollTimer = null;
}
// Map the pool's job list onto per-title flags, and auto-install once a job
// finishes if "install after download" is on.
function reconcileFromJobs(jobs) {
const byId = new Map((jobs || []).map((j) => [j.title_id, j]));
state.titles.forEach((g) => {
const j = byId.get(g.title_id);
if (!j) {
// The pool never produced a job for our local intent: time it out so the
// card can't wedge in "Downloading" forever (server restart between POST
// and poll, or an unexpected response shape).
if (g._localDownloading && g._localSince &&
Date.now() - g._localSince > 12000) {
g._localDownloading = false;
}
if (g.downloading && !g._localDownloading) g.downloading = false;
return;
}
g._localDownloading = false; // the pool now tracks it
if (j.state === "active" || j.state === "queued") {
g.downloading = true;
g.resumable = false;
g._wasActive = true;
} else if (j.state === "paused") {
g.downloading = false;
g.resumable = true;
g.partial_bytes = Number(j.bytes) || g.partial_bytes || 0;
g._wasActive = false;
} else if (j.state === "done") {
g.downloading = false;
g.resumable = false;
g.downloaded = true;
g._wasActive = false;
if (state.config.install_after_download && !g.installing && !g._autoInstalled) {
g._autoInstalled = true;
doInstall(g);
}
} else if (j.state === "error") {
// The server keeps the partial + sidecar (resumable) on a post-retry
// network failure. Reflect that so primaryButton shows "Resume" and the
// red Cancel stays available to delete the kept partial.
const bytes = Number(j.bytes) || 0;
if (g._wasActive) {
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`);
}
g._wasActive = false;
g.downloading = false;
g.resumable = bytes > 0;
g.partial_bytes = bytes || g.partial_bytes || 0;
}
});
}
async function refreshDownloads() {
let downloads;
let jobs;
try {
const response = await fetch(API.downloads, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
downloads = await response.json();
jobs = await response.json();
} catch (error) {
return; // keep last state on a transient failure
}
const now = Date.now();
const activeIds = new Set();
downloads.forEach((d) => {
activeIds.add(d.title_id);
const done = Number(d.bytes) || 0;
const prev = dlMeta[d.title_id];
const ids = new Set();
jobs.forEach((j) => {
ids.add(j.title_id);
const done = Number(j.bytes) || 0;
const prev = dlMeta[j.title_id];
if (prev && now > prev.t) {
if (done >= prev.bytes) {
const inst = ((done - prev.bytes) * 1000) / (now - prev.t); // bytes/s
prev.speed = prev.speed ? prev.speed * 0.5 + inst * 0.5 : inst; // smoothed
} else {
prev.speed = 0; // counter went backwards -> re-baseline, no stale speed
prev.speed = 0; // counter went backwards -> re-baseline
}
}
const meta = prev || (dlMeta[d.title_id] = { speed: 0 });
const meta = prev || (dlMeta[j.title_id] = { speed: 0 });
meta.bytes = done;
meta.t = now;
d._speed = meta.speed || 0;
j._speed = meta.speed || 0;
});
Object.keys(dlMeta).forEach((id) => { if (!activeIds.has(id)) delete dlMeta[id]; });
Object.keys(dlMeta).forEach((id) => { if (!ids.has(id)) delete dlMeta[id]; });
state.downloads = downloads;
// Reconcile downloading flags with the server. A structural change (a download
// appeared or finished) needs a full re-render to add/remove the progress block
// and morph the button; otherwise update the bar in place.
state.downloads = jobs;
const before = downloadingIds();
state.titles.forEach((g) => {
if (activeIds.has(g.title_id)) g.downloading = true;
else if (g.downloading && !g._localDownloading) g.downloading = false;
});
reconcileFromJobs(jobs);
if (downloadingIds() !== before) renderGames();
else applyDownloadProgress();
if (!downloads.length && !state.titles.some((g) => g.downloading)) {
if (++emptyPolls >= 3) stopDownloadPolling();
} else {
emptyPolls = 0;
}
const busy = jobs.some((j) => j.state === "active" || j.state === "queued") ||
state.titles.some((g) => g._localDownloading);
if (!busy) { if (++emptyPolls >= 3) stopDownloadPolling(); }
else emptyPolls = 0;
}
function downloadingIds() {
@@ -508,6 +609,11 @@ function downloadingIds() {
function applyDownloadProgress() {
let needRender = false;
state.downloads.forEach((d) => {
// Only titles currently downloading render a progress tile; paused/done/error
// jobs linger in the pool list but have no .progress bar — skip them so a
// missing tile for a non-downloading title doesn't force a full rebuild.
const g = state.titles.find((t) => t.title_id === d.title_id);
if (!g || !g.downloading) return;
const card = els.gameGrid.querySelector(`[data-title-id="${d.title_id}"]`);
const bar = card && card.querySelector(".card-progress .progress > i");
const meta = card && card.querySelector(".card-progress .progress-meta");
@@ -549,6 +655,7 @@ async function saveConfig() {
delete_pkg_after_install: els.deleteAfterInstall.checked,
verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads),
home_shortcut: els.homeShortcut ? els.homeShortcut.checked : state.config.home_shortcut !== false,
max_connections: clampConn(state.config.max_connections),
};
try {
await postJson(API.config, config);
@@ -564,19 +671,31 @@ async function saveConfig() {
/* ---------------- actions (data layer) ---------------- */
// Enqueue a download. The pool returns immediately (202); progress, completion
// and (if configured) auto-install are driven by reconcileFromJobs() on poll.
async function doDownload(game) {
game.downloading = true;
game._localDownloading = true; // this client owns it; don't let a poll clear it
game._localDownloading = true; // until the pool reports a job for this title
game._localSince = Date.now(); // bounded in reconcileFromJobs if no job appears
game._autoInstalled = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
state.downloads.push({ title_id: game.title_id, name: game.name, version: game.compatible_version || "", progress: 0, bytes: 0, total_bytes: 0 });
state.logs.push(`[${timeNow()}] Download started: ${game.title_id} ${game.compatible_version}`);
state.downloads.push({ title_id: game.title_id, name: game.name,
version: game.compatible_version || "",
state: "queued", progress: 0, bytes: 0, total_bytes: 0 });
renderGames();
renderLogs();
startDownloadPolling();
let r;
try {
r = await postJson(API.action(game.title_id, "download"), {});
const r = await postJson(API.action(game.title_id, "download"), {});
if (r && r.downloaded && r.already) {
game.downloading = false;
game._localDownloading = false;
game.downloaded = true;
renderGames();
} else {
state.logs.push(`[${timeNow()}] Download queued: ${game.title_id} ${game.compatible_version || ""}`);
renderLogs();
}
} catch (error) {
game.downloading = false;
game._localDownloading = false;
@@ -584,30 +703,8 @@ async function doDownload(game) {
const why = reasonText(error);
state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`);
showToast(`${game.name}: ${why}`);
renderGames(); renderLogs(); stopDownloadPolling();
return false;
renderGames(); renderLogs();
}
game.downloading = false;
game._localDownloading = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
// Cancel / soft failure returns HTTP 200 with ok:false (not thrown).
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(); renderLogs(); stopDownloadPolling();
return false;
}
game.downloaded = true;
const sz = r && r.bytes ? formatBytes(r.bytes) : "?";
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${sz}, internal)`);
showToast(`${game.name}: downloaded ${sz}.`);
renderGames(); renderLogs(); stopDownloadPolling();
return true;
}
async function doInstall(game) {
@@ -640,7 +737,13 @@ async function cancelDownload(titleId) {
} catch (error) {
showToast(`${game ? game.name : titleId}: ${reasonText(error)}`);
}
if (game) { game.downloading = false; game._localDownloading = false; game.downloaded = false; }
if (game) {
game.downloading = false;
game._localDownloading = false;
game.downloaded = false;
game.resumable = false;
game.partial_bytes = 0;
}
state.downloads = state.downloads.filter((i) => i.title_id !== titleId);
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`);
renderGames(); renderLogs();
@@ -650,10 +753,23 @@ async function cancelDownload(titleId) {
async function runTitleAction(titleId, action) {
const game = state.titles.find((i) => i.title_id === titleId);
if (!game) return;
if (action === "download") await doDownload(game);
// "update" and "download" both just enqueue; for "update" (install-after-
// download on) the reconciler auto-installs once the pool reports it done.
if (action === "download" || action === "update") await doDownload(game);
else if (action === "install") await doInstall(game);
else if (action === "pause") await doPause(game);
else if (action === "cancel") await cancelDownload(titleId);
else if (action === "update") { if (await doDownload(game)) await doInstall(game); }
}
// Pause only sends the signal; the in-flight doDownload() request returns its
// "paused" result and updates the card (resumable + partial bytes).
async function doPause(game) {
try {
await postJson(API.action(game.title_id, "pause"), {});
showToast(`${game.name}: pausing…`);
} catch (error) {
showToast(`${game.name}: ${reasonText(error)}`);
}
}
function updateGame(titleId, patch) {
@@ -711,6 +827,8 @@ const REASON_TEXT = {
download_in_progress: "Another download is already running.",
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
title_disabled: "This title is disabled.",
download_paused: "Download paused.",
not_downloading: "Nothing is downloading for this title.",
};
function reasonText(error) {
const r = error && error.body && error.body.reason;
+12 -1
View File
@@ -39,7 +39,7 @@
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>by Knutwurst · v0.0.2</span>
<span>by Knutwurst · v0.0.3</span>
</div>
</div>
@@ -186,6 +186,17 @@
<span class="track"></span>
</span>
</label>
<div class="switch-row">
<span>
<strong>Parallel download connections</strong>
<em>1–16 · applies live, no payload restart</em>
</span>
<div class="stepper" role="group" aria-label="Parallel download connections">
<button type="button" class="stepper-btn" id="connMinus" aria-label="Fewer connections">−</button>
<output class="stepper-value" id="connValue" aria-live="polite">4</output>
<button type="button" class="stepper-btn" id="connPlus" aria-label="More connections">+</button>
</div>
</div>
</div>
<div class="allowlist">
+35 -4
View File
@@ -331,10 +331,13 @@ h2 { font-size: 18px; line-height: 1.2; }
width: 100%; /* fills the fixed-width actions column -> never reflows */
padding: 0 12px;
}
.row-button.is-update { border-color: var(--blue); color: #04111f; background: var(--blue); }
.row-button.is-update:hover { background: var(--blue-dark); }
.row-button.is-cancel { border-color: var(--amber); color: #1c1402; background: var(--amber); }
.row-button.is-cancel:hover { background: var(--amber-dark); }
/* go = green (Update / Resume / Install), pause = amber, cancel/stop = red */
.row-button.is-update { border-color: var(--green); color: #04150c; background: var(--green); }
.row-button.is-update:hover { background: var(--green-dark); }
.row-button.is-pause { border-color: var(--amber); color: #1c1402; background: var(--amber); }
.row-button.is-pause:hover { background: var(--amber-dark); }
.row-button.is-cancel { border-color: var(--red); color: #1a0606; background: var(--red); }
.row-button.is-cancel:hover { background: #ff8a8a; }
.row-button.is-ghost { background: transparent; color: var(--muted); }
.row-button.is-ghost:hover { color: var(--ink); border-color: var(--muted); }
.row-button:disabled { opacity: 0.6; cursor: default; }
@@ -392,6 +395,34 @@ h2 { font-size: 18px; line-height: 1.2; }
background: var(--surface-2);
border-radius: 8px;
}
/* number stepper — big, controller-friendly targets with a clear focus ring
(the UI is operated by the PS5 controller via the home tile). */
.stepper { display: inline-flex; align-items: center; gap: 12px; flex: none; }
.stepper-btn {
width: 54px; height: 54px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 30px; line-height: 1; font-weight: 600;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background .12s ease, border-color .12s ease, transform .07s ease;
}
.stepper-btn:hover { background: var(--surface); border-color: var(--muted); }
.stepper-btn:active { transform: scale(0.93); background: var(--green-soft); border-color: var(--green); }
/* :focus (not only :focus-visible) so the controller's focus is always obvious */
.stepper-btn:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px var(--green-soft); }
.stepper-btn:disabled { opacity: 0.32; cursor: default; transform: none; }
.stepper-value {
min-width: 52px;
text-align: center;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.switch-row > span:first-child strong { display: block; font-size: 14px; }
.switch-row > span:first-child em { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; font-style: normal; }