mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 10:00:22 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e1c377ed | ||
|
|
fb9d06fb5b | ||
|
|
3d2ee430e1 | ||
|
|
986ff00c36 | ||
|
|
6024dbfc5d | ||
|
|
21f6bd61ad | ||
|
|
01eaef79f2 | ||
|
|
66e4912485 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`. */
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.2"
|
||||
#define PATCHDL_VERSION "0.0.3"
|
||||
+963
-149
File diff suppressed because it is too large.
Load diff
+199
-81
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user