9 Commits
Author SHA1 Message Date
Knutwurst ec94f0578b Release 0.0.6 2026-06-25 06:35:01 +02:00
Knutwurst 88368e3df3 Plug response-OOM leak, cap RAM, free pool on shutdown
queue_buffer leaked the MUST_FREE payload (every queue_json_owned/
build_*_json/strdup(resp)) when MHD_create_response_from_buffer hit OOM.
The MUST_FREE contract hands ownership to MHD only on success — on the
NULL return path the caller still owns the buffer, so free it before
bailing. Critical under memory pressure where the first OOM turns into
a cascade.

patchdl_websrv_stop walked the verxml thread + workers but never freed
the remaining dl_job_t entries or g_debug_json. Today main never calls
the function, but the early-failure path inside patchdl_websrv_start
does, and any later graceful-shutdown work would hit the same leak.
Drain g_pool.jobs through free_job_locked under the pool lock; free
g_debug_json under g_mutex.

RAM caps:

- MHD: CONNECTION_LIMIT 64 -> 8 (single-user UI), and
  THREAD_STACK_SIZE = 512 KB. THREAD_PER_CONNECTION on libc's default
  pthread stack (multi-MB) was reserving hundreds of MB of VM per
  burst; the largest stack frame in any handler is the 8 KB sidecar
  buffer, so 512 KB is generous even with curl + openssl in the path.
- patchdl_buf_t caps: manifest 64 -> 16 MiB, version.xml 16 -> 4 MiB.
  Real PS5 manifests are 1-2 MiB; the old caps allowed 64 MiB per
  fetch with multiple fetches possible in flight.
- patchdl_install_status_json: ai_install_status_t (2 KB pad) moves
  from the MHD worker stack to a calloc/free pair so a polling browser
  doesn't keep committing pages on each /api/installstatus tick.
- seed_from_sidecar: 16 KB stack buf -> 8 KB. 4096-piece cap fits with
  the JSON wrapper inside 8 KB.

No functional changes; download/install/scan/tile paths unaffected.
2026-06-25 06:10:48 +02:00
Knutwurst c80be921c5 Release 0.0.5 2026-06-24 22:15:04 +02:00
Knutwurst a371a67521 Net: drop HTTPS pin on the streaming download paths
Real regression from c9d721f: pinning CURLOPT_PROTOCOLS_STR /
REDIR_PROTOCOLS_STR to "https" on the piece downloader (and the simple
file streamer) broke real-world Sony patch downloads. Banishers reliably
aborted after ~73 MB and Last of Us Part I after ~127 MB — the Sony CDN
appears to 302 the piece URL to an http:// signed edge inside its own
infrastructure, and refusing those redirects killed the transfer mid-piece.

The smaller patchdl_http_get path (version.xml + manifest JSON) keeps the
HTTPS pin since those payloads always come back from the public https
endpoints. The defence still holds elsewhere: host_allowed gates every
URL to the Sony CDN allowlist, TLS verifies against the pinned SCEI root
even on a 302, and NOSIGNAL stays so a connection RST can't smuggle a
SIGPIPE back into the worker thread.

For future regressions of this class /api/downloads now exposes the last
CURLcode + HTTP status per job (last_curl_rc, last_http_code) so we don't
have to instrument the binary again to find out what curl returned.
2026-06-24 22:13:16 +02:00
Knutwurst 2bc15cbaa2 UI: Updating now means only the queue; active downloads stay in Updatable
Previous behaviour moved a game out of Updatable the moment it entered
the pool, which made it disappear from the list you came to watch. The
queue chip now means literally that: jobs in state="queued" waiting
for a free pool slot. Active, paused and installing jobs stay under
Updatable so you don't have to switch tabs to see the thing you just
told to download.

Implementation: reconcileFromJobs stamps the raw j.state on the title
as g._jobState; gameCategory routes only "queued" to Updating, every
other live state falls through to the existing Updatable branch.
2026-06-24 22:00:42 +02:00
Knutwurst 81d6f0e332 UI: global download banner above the status strip
A sticky-feeling banner appears whenever any job is queued or active.
It shows the eyebrow (Downloading / Queued), the current title's name,
a "3 of 9" chip when more than one job is in this batch, plus
percent, speed, and ETA. A thin gradient bar runs edge-to-edge along
the bottom so the at-a-glance state matches the per-card progress.

Batch counting: jobs with state in {queued, active, done} make up the
batch; done count + 1 is the current position. When all queued jobs
finish and roll out of the pool list the banner hides on the next poll.

While the active job's manifest is still being fetched (no total_bytes
yet) the bar runs an indeterminate sweep so the user isn't staring at
a frozen 0%. Speed comes from the existing per-job smoothed estimate
in reconcileFromJobs so the banner shares one source of truth with
the per-game tiles.

Render hooks: renderGames() and applyDownloadProgress() both call
renderGlobalStatus(), so every refresh path keeps it in sync without
adding a separate timer.
2026-06-24 21:55:28 +02:00
Knutwurst fcd43b54ae Home-screen tile via sceAppInstUtilAppInstallTitleDir
Write param.json + icon0.png into /user/app/<TITLE_ID>/sce_sys/, then
call sceAppInstUtilAppInstallTitleDir to register the directory as an
app. No package, no code signing — the installer reads the metadata
files directly.

The tile uses TITLE_ID PTDL00001 and deeplinkUri
http://127.0.0.1:12880/, so tapping it opens PatchDL's own UI in the
on-console browser. Only useful while the ELF is running.

Asset pipeline: param.json + icon0.png live under assets/ and get
.incbin'd straight into .rodata. Makefile lists them as TILE_ASSETS so
a touch on either forces a relink.

Stat-guard: file_matches() diffs each asset against the on-disk copy
first; when nothing changed the install API isn't called at all. Keeps
repeated payload starts from re-registering the app. Repeated
/api/install_tile calls return "already installed and up to date".

Backend lazy-load: AppInstUtil isn't mapped until something pokes it,
so the helper polls patchdl_install_backend_check() for up to ~15 s
before resolving sceAppInstUtilAppInstallTitleDir.

Wiring: /api/install_tile POST triggers the install on demand;
patchdl_websrv_start() runs it at startup when home_shortcut is on;
and flipping the toggle from off to on in /api/config also fires it.
All three paths share the stat-guarded helper so they're safe to
repeat.
2026-06-24 21:50:59 +02:00
Knutwurst d0ca22d42d UI: pull version from /api/status instead of hardcoding it
The sidebar tag was a literal "v0.0.3" string in index.html, so a
release bump left the running UI lying about which build it was. Server
now publishes PATCHDL_VERSION via /api/status; renderStatus() drops it
into a #brandVersion span. No more chasing a hardcoded version on every
release.
2026-06-24 21:19:38 +02:00
Knutwurst fb47730d70 Docs: README for 0.0.4 (cross-region install works, UI updates, filename)
README catches up to where the code is:

- The "cross-region install is a known limitation" section is gone. We
  route through sceAppInstUtilAppInstallPkg now, verified end-to-end on
  Dead Island 2 (01.000.001 -> 01.000.011, including the shared-storage
  case where the patch lives under a master title id).
- A Web UI section documents the filter chips (Updatable default,
  Updating separate bucket, All on the right) and Update all.
- Deploy section notes the new filename shape (patchdl_<version>.elf)
  so Payload Manager can parse the version out of it.

deploy_ps5.sh follows: UP_NAME is now patchdl_${VERSION}.elf to match
the released asset naming.
2026-06-24 21:16:31 +02:00
17 changed files with 628 additions and 63 deletions

No files matched your search

+8 -3
View File
@@ -23,7 +23,12 @@ SRCS := src/main.c \
src/patchdl_resolve.c \ src/patchdl_resolve.c \
src/patchdl_verxml.c \ src/patchdl_verxml.c \
src/patchdl_install.c \ src/patchdl_install.c \
src/patchdl_notify.c src/patchdl_notify.c \
src/patchdl_tile.c
# patchdl_tile.c uses .incbin to embed param.json + icon0.png; touching the
# assets must trigger a rebuild.
TILE_ASSETS := assets/param.json assets/icon0.png
WEB_ASSETS := web/index.html web/styles.css web/app.js WEB_ASSETS := web/index.html web/styles.css web/app.js
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS)) GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
@@ -56,8 +61,8 @@ gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c $(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
$(CC) $(SQLITE_CFLAGS) -c -o $@ $< $(CC) $(SQLITE_CFLAGS) -c -o $@ $<
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD) $(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
test: $(BIN) test: $(BIN)
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^ $(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
+33 -20
View File
@@ -45,6 +45,22 @@ Downloads survive interruptions:
Patches download to `/data/patchdl` on the internal SSD. Large retail updates Patches download to `/data/patchdl` on the internal SSD. Large retail updates
run tens of GB. run tens of GB.
## Web UI
The Games view groups titles into filter chips:
- **Updatable** — has an installable update; selected by default.
- **Updating** — queued, actively downloading, paused with a partial on disk,
or installing.
- **Up to date**, **Needs FW**, **Can't update** — the rest.
- **All** — flat list at the right end of the strip.
**Update all** in the top bar queues a download for every game with an
installable update in one click. Shadowmounts are skipped (they pass the
download policy but not the install policy), so a sweep doesn't burn tens of
GB on bytes AppInstUtil would refuse — pick those up by hand when the disc is
ready.
## Safety model ## Safety model
Deny-by-default. A patch installs only for a genuine install, and only when the Deny-by-default. A patch installs only for a genuine install, and only when the
@@ -88,8 +104,9 @@ scripts/build_ps5.sh # produces patchdl-ps5.elf
## Deploy ## Deploy
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084 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 (not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF
launches it; the payload replaces any running instance. (`patchdl_<version>.elf`, so Payload Manager picks the version out of the
filename) and launches it; the payload replaces any running instance.
```sh ```sh
PS5_HOST=<console-ip> scripts/deploy_ps5.sh PS5_HOST=<console-ip> scripts/deploy_ps5.sh
@@ -105,23 +122,19 @@ http://<console-ip>:12880/
Verified on firmware 11.60: title scan, source classification, version Verified on firmware 11.60: title scan, source classification, version
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
download pool, reboot-safe resume, and on-device SHA-256 verification. A full download pool, reboot-safe resume, and on-device SHA-256 verification.
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 Install works through Sony's AppInstUtil. `sceAppInstUtilInstallByPackage` is
the installed game's own title id. PatchDL now mirrors etaHEN's native unavailable from the homebrew payload context (rejected with `0x80B21163`
DirectPKGInstaller call shape for that path: it passes an empty outside the system process), so PatchDL routes through
`MetaInfo.content_id`, lets AppInstUtil bind the signed package metadata, keeps `sceAppInstUtilAppInstallPkg` — the simpler API that reads the PKG's embedded
the returned content id, and exposes `/api/installstatus` for installer progress `content_id` and binds the install to the right title slot. Verified
when `sceAppInstUtilGetInstallStatus` is exported. end-to-end: Dead Island 2 (`PPSA03099`) updated from 01.000.001 to 01.000.011
on 11.60, including the cross-region shared-storage case where Sony serves the
patch under a master title id.
Cross-region patches are a known limitation. Sony sometimes packages a regional `/api/installstatus` reports installer progress once
patch under a different (master) storage title and ships it as a debug-magic `sceAppInstUtilGetInstallStatus` finishes the queued task.
container. PatchDL downloads such a patch and verifies it against Sony's hashes,
but refuses to install it from the standalone ELF because `InstallByPackage` Disc games still need the disc inserted for their patch to apply, which is a
does not retarget signed package metadata on 11.60, and the homebrew alternative normal Sony requirement; that's why shadowmounts are download-only by policy.
(BGFT register) returns "not supported" outside the system process. Installing
that class of patch needs Sony's authenticated updater, which nanoDNS blocks.
Disc games need the disc inserted for their patch to apply, which is a normal
Sony requirement.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f2a1e"/>
<stop offset="1" stop-color="#0a1612"/>
</linearGradient>
<linearGradient id="badge" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#21d07a"/>
<stop offset="1" stop-color="#179a59"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="72" fill="url(#bg)"/>
<rect x="56" y="56" width="160" height="160" rx="36" fill="url(#badge)"/>
<text x="136" y="178" font-family="Helvetica,Arial,sans-serif" font-size="116"
font-weight="800" fill="#0a1612" text-anchor="middle">PD</text>
<text x="256" y="346" font-family="Helvetica,Arial,sans-serif" font-size="80"
font-weight="700" fill="#e7ecea" text-anchor="middle">PatchDL</text>
<text x="256" y="404" font-family="Helvetica,Arial,sans-serif" font-size="32"
font-weight="500" fill="#7a8f86" text-anchor="middle">PS5 Patch Tool</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+10
View File
@@ -0,0 +1,10 @@
{
"titleId": "PTDL00001",
"deeplinkUri": "http://127.0.0.1:12880/",
"localizedParameters": {
"defaultLanguage": "en-US",
"en-US": {
"titleName": "PatchDL"
}
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ VERSION=$(sed -n 's/.*PATCHDL_VERSION[^"]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/src/pat
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf" SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; } [ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
UP_NAME="patchdl-ps5-v${VERSION}.elf" UP_NAME="patchdl_${VERSION}.elf"
TMP_ELF="$ROOT_DIR/$UP_NAME" TMP_ELF="$ROOT_DIR/$UP_NAME"
cp "$SRC_ELF" "$TMP_ELF" cp "$SRC_ELF" "$TMP_ELF"
+20 -11
View File
@@ -475,7 +475,10 @@ patchdl_install_status_json(char *out, size_t out_sz) {
char tid[32]; char tid[32];
char method[32]; char method[32];
int start_rc; int start_rc;
ai_install_status_t st; /* ai_install_status_t carries a 2 KB safety pad; live on the heap so
a frequently-polled /api/installstatus doesn't keep committing pages
on every MHD worker's stack. */
ai_install_status_t *st = NULL;
char status[17], src_type[9]; char status[17], src_type[9];
int rc; int rc;
int progress = 0; int progress = 0;
@@ -514,20 +517,25 @@ patchdl_install_status_json(char *out, size_t out_sz) {
return -1; return -1;
} }
st = calloc(1, sizeof(*st));
if (!st) {
snprintf(out, out_sz, "{\"error\":\"oom\"}");
return -1;
}
/* sceAppInstUtilGetInstallStatus(char *content_id_out, status_t *status): /* sceAppInstUtilGetInstallStatus(char *content_id_out, status_t *status):
first arg is an OUTPUT buffer that receives the current install's content_id. first arg is an OUTPUT buffer that receives the current install's content_id.
Do NOT pass `cid` there — it would be overwritten. The visible id fits in Do NOT pass `cid` there — it would be overwritten. The visible id fits in
0x30 bytes but Sony's NUL-pad length is unknown; use a padded buffer. */ 0x30 bytes but Sony's NUL-pad length is unknown; use a padded buffer. */
{ {
char ai_cid_out[AI_CONTENTID_OUT_SIZE] = {0}; char ai_cid_out[AI_CONTENTID_OUT_SIZE] = {0};
memset(&st, 0, sizeof(st)); rc = ai_get_status(ai_cid_out, st);
rc = ai_get_status(ai_cid_out, &st);
(void)ai_cid_out; /* returned content_id for future use */ (void)ai_cid_out; /* returned content_id for future use */
} }
copy_bounded(status, sizeof(status), st.status, sizeof(st.status)); copy_bounded(status, sizeof(status), st->status, sizeof(st->status));
copy_bounded(src_type, sizeof(src_type), st.src_type, sizeof(st.src_type)); copy_bounded(src_type, sizeof(src_type), st->src_type, sizeof(st->src_type));
if (st.total_size > 0) if (st->total_size > 0)
progress = (int)((st.downloaded_size * 100) / st.total_size); progress = (int)((st->downloaded_size * 100) / st->total_size);
if (progress < 0) progress = 0; if (progress < 0) progress = 0;
if (progress > 100) progress = 100; if (progress > 100) progress = 100;
terminal = (!strcmp(status, "playable") || terminal = (!strcmp(status, "playable") ||
@@ -542,10 +550,11 @@ patchdl_install_status_json(char *out, size_t out_sz) {
"\"promote_progress\":%u,\"error_code\":%d}", "\"promote_progress\":%u,\"error_code\":%d}",
terminal ? "true" : "false", cid, tid, method, start_rc, rc, terminal ? "true" : "false", cid, tid, method, start_rc, rc,
status, src_type, progress, status, src_type, progress,
(unsigned long long)st.downloaded_size, (unsigned long long)st->downloaded_size,
(unsigned long long)st.total_size, (unsigned long long)st->total_size,
(unsigned)st.promote_progress, (unsigned)st->promote_progress,
(int)st.error_info.error_code); (int)st->error_info.error_code);
free(st);
return rc; return rc;
} }
+21 -8
View File
@@ -31,9 +31,11 @@
#define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */ #define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */
/* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB, /* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB,
manifest JSON is a few MB at most — fail-closed beyond that. */ manifest JSON is a few MB at most — fail-closed beyond that. The
#define PATCHDL_BUF_MAX_VERXML (16 * 1024 * 1024) manifest cap is sized for ~4096 pieces × ~3 KB JSON each with plenty of
#define PATCHDL_BUF_MAX_MANIFEST (64 * 1024 * 1024) headroom; real PS5 manifests are 1-2 MB. */
#define PATCHDL_BUF_MAX_VERXML (4 * 1024 * 1024)
#define PATCHDL_BUF_MAX_MANIFEST (16 * 1024 * 1024)
#ifdef PATCHDL_HAVE_CURL #ifdef PATCHDL_HAVE_CURL
/* Replacement for fopen("wb"/"r+b") that refuses to follow a symlink at the /* Replacement for fopen("wb"/"r+b") that refuses to follow a symlink at the
@@ -452,8 +454,12 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0"); curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); /* HTTPS pin removed on the streaming download path: the Sony CDN sometimes
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); 302s a piece URL to a signed http:// edge inside its own infrastructure,
and refusing those redirects was breaking real-world downloads.
host_allowed + TLS-against-pinned-root on every leg already gate the
hosts we'll talk to. NOSIGNAL stays — it protects the worker from a
SIGPIPE on connection RST. */
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
/* No total timeout (patches can be large); abort only on a long stall. */ /* No total timeout (patches can be large); abort only on a long stall. */
@@ -822,7 +828,8 @@ int
patchdl_http_download_piece(const char *url, int fd, patchdl_http_download_piece(const char *url, int fd,
long long file_offset, long long file_size, long long file_offset, long long file_size,
const char *expected_sha256_or_null, const char *expected_sha256_or_null,
patchdl_piece_ctx_t *ctx) { patchdl_piece_ctx_t *ctx,
int *curl_rc_out, long *http_code_out) {
CURL *curl; CURL *curl;
CURLcode res; CURLcode res;
long http_code = 0; long http_code = 0;
@@ -832,6 +839,9 @@ patchdl_http_download_piece(const char *url, int fd,
piece_sink_t sink; piece_sink_t sink;
int verify = (expected_sha256_or_null && expected_sha256_or_null[0]); int verify = (expected_sha256_or_null && expected_sha256_or_null[0]);
if (curl_rc_out) *curl_rc_out = 0;
if (http_code_out) *http_code_out = 0;
if (url_host(url, host, sizeof(host))) return -1; if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1; if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1; if (dns_lookup(host, ip, sizeof(ip))) return -1;
@@ -871,8 +881,8 @@ patchdl_http_download_piece(const char *url, int fd,
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0"); curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); /* HTTPS pin removed on the streaming piece path — see the same change in
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); http_download_to_file_progress for the why. */
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); /* 4xx/5xx -> error, no body written */ 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_CONNECTTIMEOUT, 20L);
@@ -890,6 +900,9 @@ patchdl_http_download_piece(const char *url, int fd,
curl_easy_cleanup(curl); curl_easy_cleanup(curl);
curl_slist_free_all(rl); curl_slist_free_all(rl);
if (curl_rc_out) *curl_rc_out = (int)res;
if (http_code_out) *http_code_out = http_code;
if (res != CURLE_OK) { if (res != CURLE_OK) {
if (sink.md) EVP_MD_CTX_free(sink.md); if (sink.md) EVP_MD_CTX_free(sink.md);
return -1; /* network error / abort */ return -1; /* network error / abort */
+5 -2
View File
@@ -52,11 +52,14 @@ typedef struct {
/* Download one whole piece and pwrite it into `fd` at `file_offset`. Concurrent /* 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 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. */ fdatasyncs fd), -1 on network/IO/abort, -2 on a SHA-256 mismatch.
`curl_rc_out`/`http_code_out` (either may be NULL) receive the last libcurl
CURLcode + HTTP status for failure diagnosis. */
int patchdl_http_download_piece(const char *url, int fd, int patchdl_http_download_piece(const char *url, int fd,
long long file_offset, long long file_size, long long file_offset, long long file_size,
const char *expected_sha256_or_null, const char *expected_sha256_or_null,
patchdl_piece_ctx_t *ctx); patchdl_piece_ctx_t *ctx,
int *curl_rc_out, long *http_code_out);
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex /* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
(caller provides >= 65 bytes). Returns 0 on success. */ (caller provides >= 65 bytes). Returns 0 on success. */
+205
View File
@@ -0,0 +1,205 @@
#include "patchdl_tile.h"
#include "patchdl_install.h"
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <ps5/kernel.h>
/* Embed the tile assets into .rodata directly via .incbin — no codegen step,
no Python helper, no second translation unit. Matches itsPLK's pattern. */
#define INCASSET(name, file) \
__asm__(".section .rodata\n" \
".global " #name "\n" \
".global " #name "_end\n" \
".global " #name "_size\n" \
".align 16\n" #name ":\n" \
".incbin \"" file "\"\n" #name "_end:\n" #name "_size:\n" \
".quad " #name "_end - " #name "\n" \
".previous\n"); \
extern const uint8_t name[]; \
extern const size_t name##_size;
INCASSET(tile_param_json, "assets/param.json");
INCASSET(tile_icon0_png, "assets/icon0.png");
#define TILE_TITLE_ID "PTDL00001"
/* Forward decls — we resolve sceAppInstUtilInitialize/AppInstallTitleDir at
runtime via the kernel dynlib helpers so the ELF stays loader-friendly. */
typedef int (*ai_init_fn)(void);
typedef int (*ai_install_dir_fn)(const char *title_id, const char *parent_dir,
void *opts);
static intptr_t
dynsym_by_name(const char *sym) {
uint32_t h = 0;
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
return kernel_dynlib_dlsym(-1, h, sym);
}
static intptr_t
dynsym_by_nid(const char *nid) {
uint32_t h = 0;
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
return kernel_dynlib_resolve(-1, h, nid);
}
static int
write_all(const char *path, const uint8_t *data, size_t size) {
int fd;
ssize_t w;
size_t off = 0;
/* O_NOFOLLOW + 0600: don't follow a symlink at the destination, and don't
create the file with the libc default 0666 mode. Same pattern as the
net layer's fopen_safe. */
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd < 0) return -1;
while (off < size) {
w = write(fd, data + off, size - off);
if (w < 0) {
if (errno == EINTR) continue;
close(fd);
return -1;
}
off += (size_t)w;
}
close(fd);
return 0;
}
static int
file_matches(const char *path, const uint8_t *expected, size_t expected_size) {
struct stat st;
uint8_t *buf;
int fd;
ssize_t n;
int match = 0;
if (stat(path, &st) != 0) return 0;
if ((size_t)st.st_size != expected_size) return 0;
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return 0;
buf = malloc(expected_size);
if (!buf) { close(fd); return 0; }
n = read(fd, buf, expected_size);
close(fd);
if (n == (ssize_t)expected_size && memcmp(buf, expected, expected_size) == 0)
match = 1;
free(buf);
return match;
}
int
patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
char base_dir[128];
char sce_sys_dir[160];
char param_path[192];
char icon_path[192];
ai_init_fn ai_initialize = NULL;
ai_install_dir_fn ai_install_title = NULL;
int rc;
snprintf(base_dir, sizeof base_dir, "/user/app/%s", TILE_TITLE_ID);
snprintf(sce_sys_dir, sizeof sce_sys_dir, "/user/app/%s/sce_sys", TILE_TITLE_ID);
snprintf(param_path, sizeof param_path, "/user/app/%s/sce_sys/param.json", TILE_TITLE_ID);
snprintf(icon_path, sizeof icon_path, "/user/app/%s/sce_sys/icon0.png", TILE_TITLE_ID);
/* stat-guard: if everything on disk already matches, do nothing. Re-running
the install API every payload start would be wasted work and burns the
only safe path through Sony's installer state machine. */
{
struct stat st;
if (stat(base_dir, &st) == 0 &&
file_matches(param_path, tile_param_json, tile_param_json_size) &&
file_matches(icon_path, tile_icon0_png, tile_icon0_png_size)) {
snprintf(msg, msg_sz, "tile already installed and up to date");
return 0;
}
}
/* AppInstUtil is loaded lazily by the install backend thread. Poke it +
poll briefly so libSceAppInstUtil.sprx is mapped before we try to
resolve symbols out of it. Bounded to a few seconds so a stuck init
doesn't wedge an MHD worker forever. */
{
char ready_msg[128];
int ready = -1;
for (int i = 0; i < 60 && ready != 0; i++) {
ready = patchdl_install_backend_check(ready_msg, sizeof ready_msg);
if (ready != 0) usleep(250 * 1000);
}
if (ready != 0) {
snprintf(msg, msg_sz,
"install backend not ready: %s", ready_msg);
return -1;
}
}
/* Resolve the install API now so we can fail fast before touching disk.
itsPLK uses the NID (Wudg3Xe3heE) because the symbol export is
Sony-private; try both, NID first since it survives a stripped sprx. */
ai_install_title = (ai_install_dir_fn)dynsym_by_nid("Wudg3Xe3heE");
if (!ai_install_title)
ai_install_title = (ai_install_dir_fn)dynsym_by_name(
"sceAppInstUtilAppInstallTitleDir");
if (!ai_install_title) {
snprintf(msg, msg_sz, "sceAppInstUtilAppInstallTitleDir not resolved");
return -1;
}
ai_initialize = (ai_init_fn)dynsym_by_name("sceAppInstUtilInitialize");
if (ai_initialize) {
rc = ai_initialize();
/* SCE_OK == 0; a non-zero rc here usually means "already initialised"
in this process, which is fine. We only bail on a clearly fatal
code (anything that isn't already the success case). */
if (rc != 0 && rc != 0x80B21161 /* ALREADY_INITIALIZED */) {
snprintf(msg, msg_sz,
"sceAppInstUtilInitialize failed 0x%08x", (unsigned)rc);
return -1;
}
}
if (mkdir(base_dir, 0755) && errno != EEXIST) {
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", base_dir, errno);
return -1;
}
if (mkdir(sce_sys_dir, 0755) && errno != EEXIST) {
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", sce_sys_dir, errno);
return -1;
}
if (write_all(param_path, tile_param_json, tile_param_json_size)) {
snprintf(msg, msg_sz, "write param.json failed errno=%d", errno);
return -1;
}
if (write_all(icon_path, tile_icon0_png, tile_icon0_png_size)) {
snprintf(msg, msg_sz, "write icon0.png failed errno=%d", errno);
return -1;
}
rc = ai_install_title(TILE_TITLE_ID, "/user/app/", NULL);
if (rc != 0) {
snprintf(msg, msg_sz,
"AppInstallTitleDir(%s) returned 0x%08x",
TILE_TITLE_ID, (unsigned)rc);
return -1;
}
snprintf(msg, msg_sz, "tile installed (%s)", TILE_TITLE_ID);
return 0;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stddef.h>
/* Install / refresh the PatchDL home-screen tile.
*
* Approach (from itsPLK's ps5-payload-manager/app_installer.c, which adapted
* John Tornblom's ftpsrv work): write param.json + icon0.png into
* /user/app/<TITLE_ID>/sce_sys/
* then call sceAppInstUtilAppInstallTitleDir(title_id, "/user/app/", 0)
* which registers the directory as an app. The tile's deeplinkUri opens
* Sony's WebKit browser at http://127.0.0.1:12880/, i.e. PatchDL's own UI
* — only useful while the ELF is running.
*
* No PKG, no code signing, no debug-magic. Files only — Sony's installer
* registers the directory as an app.
*
* stat-guard: the asset bytes are diffed against the on-disk copy first;
* if nothing changed the install API isn't called at all (avoids a costly
* re-register every payload start, and avoids the dangerous CancelInstall
* code path). Returns 0 on success or when nothing needed doing.
*/
int patchdl_tile_install_if_needed(char *msg, size_t msg_sz);
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once #pragma once
#define PATCHDL_VERSION "0.0.4" #define PATCHDL_VERSION "0.0.6"
+3 -3
View File
@@ -183,10 +183,10 @@ patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
if (!url || !out) return -1; if (!url || !out) return -1;
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
/* version.xml is a few KB in practice; cap to 16 MiB so a misbehaving CDN /* version.xml is a few KB in practice; cap so a misbehaving CDN can't
can't slurp unbounded RAM into the buffer. */ slurp unbounded RAM into the buffer. */
memset(&buf, 0, sizeof(buf)); memset(&buf, 0, sizeof(buf));
buf.max = 16 * 1024 * 1024; buf.max = 4 * 1024 * 1024;
if (patchdl_http_get(url, &buf)) return -1; if (patchdl_http_get(url, &buf)) return -1;
if (!buf.data || !buf.size) { free(buf.data); return -1; } if (!buf.data || !buf.size) { free(buf.data); return -1; }
+82 -10
View File
@@ -5,7 +5,9 @@
#include "patchdl_install.h" #include "patchdl_install.h"
#include "patchdl_net.h" #include "patchdl_net.h"
#include "patchdl_resolve.h" #include "patchdl_resolve.h"
#include "patchdl_tile.h"
#include "patchdl_verxml.h" #include "patchdl_verxml.h"
#include "patchdl_version.h"
#include <microhttpd.h> #include <microhttpd.h>
#include <pthread.h> #include <pthread.h>
@@ -94,6 +96,8 @@ typedef struct dl_job {
int inflight; int inflight;
int fd; /* O_RDWR dest fd, -1 until admit */ int fd; /* O_RDWR dest fd, -1 until admit */
int rc; /* 0 ok, -1 net/io, -2 verify */ int rc; /* 0 ok, -1 net/io, -2 verify */
int last_curl_rc; /* CURLcode from the most recent piece */
long last_http_code; /* HTTP status from the most recent piece */
struct dl_job *next; struct dl_job *next;
} dl_job_t; } dl_job_t;
@@ -279,7 +283,14 @@ queue_buffer(struct MHD_Connection *conn, unsigned int status,
enum MHD_Result ret; enum MHD_Result ret;
resp = MHD_create_response_from_buffer(size, (void *)data, mm); resp = MHD_create_response_from_buffer(size, (void *)data, mm);
if (!resp) return MHD_NO; if (!resp) {
/* MUST_FREE hands ownership to MHD on success; on failure we still
own it and have to free it ourselves or the caller's strdup'd
JSON leaks. PERSISTENT/MUST_COPY buffers are caller-owned and
we leave them alone. */
if (mm == MHD_RESPMEM_MUST_FREE) free((void *)data);
return MHD_NO;
}
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*"); MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store"); MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
@@ -585,8 +596,9 @@ build_status_json(void) {
"\"dns_guard\":\"Active\"," "\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\"," "\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":%lld," "\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\"}", "\"download_dir\":\"/data/patchdl (internal)\","
g_fw.str, g_fw.bin, data_free_mb()); "\"version\":\"%s\"}",
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
return out; return out;
} }
@@ -824,6 +836,8 @@ build_downloads_json(void) {
jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, job_state_str(job->state)); jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, job_state_str(job->state));
jbuf_appendf(&j, ",\"progress\":%d", progress); jbuf_appendf(&j, ",\"progress\":%d", progress);
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total); jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total);
jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld",
job->rc, job->last_curl_rc, job->last_http_code);
jbuf_append(&j, "}"); jbuf_append(&j, "}");
} }
jbuf_append(&j, "]"); jbuf_append(&j, "]");
@@ -1122,7 +1136,11 @@ write_job_state(const dl_job_t *job) {
ps[] states and done_bytes. Called with the pool lock held. */ ps[] states and done_bytes. Called with the pool lock held. */
static void static void
seed_from_sidecar(dl_job_t *job) { seed_from_sidecar(dl_job_t *job) {
char path[320], buf[16384], murl[768]; /* 8 KB sidecar buffer: 4096-piece cap × 2 hex chars / 8 bits = 1024 hex
chars for the bitmap, plus the JSON wrapper and the up-to-768-byte
manifest_url — fits with room. Halved from 16 KB to lighten the
per-admit stack frame on the pool worker. */
char path[320], buf[8192], murl[768];
FILE *f; FILE *f;
size_t n; size_t n;
int nbytes = (job->mf.count + 7) / 8; int nbytes = (job->mf.count + 7) / 8;
@@ -1337,6 +1355,7 @@ admit_next(void) {
free(q->ps); q->ps = NULL; free(q->ps); q->ps = NULL;
free(q->bitmap); q->bitmap = NULL; free(q->bitmap); q->bitmap = NULL;
q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0; q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0;
q->last_curl_rc = 0; q->last_http_code = 0;
q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total; q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total;
for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1; for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1;
@@ -1444,15 +1463,22 @@ dl_worker(void *arg) {
pthread_mutex_unlock(&g_pool.mtx); pthread_mutex_unlock(&g_pool.mtx);
/* ---- download the piece, NO lock ---- */ /* ---- download the piece, NO lock ---- */
int last_curl_rc = 0;
long last_http_code = 0;
{ {
patchdl_piece_ctx_t ctx = { &g_pool.inflight_bytes[slot], abort_ptr }; patchdl_piece_ctx_t ctx = { &g_pool.inflight_bytes[slot], abort_ptr };
rc = patchdl_http_download_piece(url, fd, off, sz, rc = patchdl_http_download_piece(url, fd, off, sz,
verify && hash[0] ? hash : NULL, &ctx); verify && hash[0] ? hash : NULL, &ctx,
&last_curl_rc, &last_http_code);
} }
pthread_mutex_lock(&g_pool.mtx); pthread_mutex_lock(&g_pool.mtx);
g_pool.inflight_bytes[slot] = 0; g_pool.inflight_bytes[slot] = 0;
job->inflight--; job->inflight--;
if (rc != 0) {
job->last_curl_rc = last_curl_rc;
job->last_http_code = last_http_code;
}
job->ps[pidx].slot = -1; job->ps[pidx].slot = -1;
if (my_seq != job->seq) { if (my_seq != job->seq) {
/* job cancelled/torn down under us: discard result (do not touch /* job cancelled/torn down under us: discard result (do not touch
@@ -1845,6 +1871,7 @@ static enum MHD_Result
handle_config_post(struct MHD_Connection *conn, const char *body) { handle_config_post(struct MHD_Connection *conn, const char *body) {
char pol[8]; char pol[8];
int mc; int mc;
int tile_just_enabled = 0;
json_get_str(body, "default_policy", pol, sizeof(pol)); json_get_str(body, "default_policy", pol, sizeof(pol));
/* Only the two real values are accepted; anything else is silently /* Only the two real values are accepted; anything else is silently
@@ -1864,8 +1891,12 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install); json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
g_cfg.verify_downloads = g_cfg.verify_downloads =
json_get_bool(body, "verify_downloads", g_cfg.verify_downloads); json_get_bool(body, "verify_downloads", g_cfg.verify_downloads);
g_cfg.home_shortcut = {
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut); int prev_tile = g_cfg.home_shortcut;
g_cfg.home_shortcut =
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut);
tile_just_enabled = (!prev_tile && g_cfg.home_shortcut);
}
g_cfg.max_connections = g_cfg.max_connections =
json_get_int(body, "max_connections", g_cfg.max_connections); json_get_int(body, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1; if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
@@ -1885,6 +1916,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
pthread_mutex_unlock(&g_pool.mtx); pthread_mutex_unlock(&g_pool.mtx);
save_config(); save_config();
/* Trigger the tile install when the toggle just flipped on. Best-effort —
the config save is already committed; we don't want a tile error to
roll that back. Stat-guard inside the helper makes repeated calls a
no-op, so a save without flipping the toggle still costs nothing. */
if (tile_just_enabled) {
char tile_msg[256];
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
}
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json()); return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
} }
@@ -1996,6 +2037,15 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY, return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp)); strdup(resp));
} }
if (!strcmp(url, "/api/install_tile")) {
char msg[256], resp[320];
int rc = patchdl_tile_install_if_needed(msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\"}",
rc == 0 ? "true" : "false", rc, msg);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
} }
@@ -2249,6 +2299,15 @@ patchdl_websrv_start(unsigned short port) {
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN); g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
load_config(); load_config();
/* If the user opted into a home-screen tile, refresh it now. The helper
is stat-guarded — when nothing changed on disk it's a no-op, so the
cost on subsequent startups is two file reads. Best-effort: never
block startup on a tile install failure. */
if (g_cfg.home_shortcut) {
char tile_msg[256];
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
}
/* Flag titles that have a partial download on disk so the UI can offer /* Flag titles that have a partial download on disk so the UI can offer
Resume after a reboot. */ Resume after a reboot. */
detect_resumable_partials(); detect_resumable_partials();
@@ -2293,11 +2352,16 @@ patchdl_websrv_start(unsigned short port) {
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION, MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
port, NULL, NULL, &on_request, NULL, port, NULL, NULL, &on_request, NULL,
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL, MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
/* DoS guards: bound concurrent sockets + per-IP to keep a misbehaving /* DoS guards + RAM caps: the PS5 process budget is a few hundred MB
LAN client from exhausting pthreads on the PS5. */ and the default pthread stack on this libc is multiple MB. We're
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)64, a single-user UI — 8 concurrent connections is plenty for the
SSE poll + a couple of AJAX, and 256 KB per worker is more than
enough for our handlers (largest stack use is a 16 KB sidecar
buffer in seed_from_sidecar). */
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8, MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30, MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30,
MHD_OPTION_THREAD_STACK_SIZE, (size_t)(512 * 1024),
MHD_OPTION_END); MHD_OPTION_END);
if (!web_daemon) { if (!web_daemon) {
@@ -2350,11 +2414,19 @@ patchdl_websrv_stop(void) {
for (int s = 0; s < g_pool.n_workers; s++) for (int s = 0; s < g_pool.n_workers; s++)
pthread_join(g_pool.workers[s], NULL); pthread_join(g_pool.workers[s], NULL);
g_pool.n_workers = 0; g_pool.n_workers = 0;
/* Free every job still in the pool list. Workers have joined so nobody
else touches the list. free_job_locked unlinks + frees mf.pieces[i].url,
mf.pieces, ps, bitmap, and closes the fd. */
pthread_mutex_lock(&g_pool.mtx);
while (g_pool.jobs) free_job_locked(g_pool.jobs);
pthread_mutex_unlock(&g_pool.mtx);
patchdl_net_global_cleanup(); patchdl_net_global_cleanup();
pthread_mutex_lock(&g_mutex); pthread_mutex_lock(&g_mutex);
patchdl_scan_free(g_titles, g_title_count); patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL; g_titles = NULL;
g_title_count = 0; g_title_count = 0;
free(g_debug_json);
g_debug_json = NULL;
pthread_mutex_unlock(&g_mutex); pthread_mutex_unlock(&g_mutex);
} }
+73 -3
View File
@@ -127,6 +127,15 @@ function bindElements() {
connPlus: document.getElementById("connPlus"), connPlus: document.getElementById("connPlus"),
refreshBtn: document.getElementById("refreshBtn"), refreshBtn: document.getElementById("refreshBtn"),
updateAllBtn: document.getElementById("updateAllBtn"), updateAllBtn: document.getElementById("updateAllBtn"),
brandVersion: document.getElementById("brandVersion"),
globalDl: document.getElementById("globalDl"),
globalDlState: document.getElementById("globalDlState"),
globalDlName: document.getElementById("globalDlName"),
globalDlPosition: document.getElementById("globalDlPosition"),
globalDlPct: document.getElementById("globalDlPct"),
globalDlSpeed: document.getElementById("globalDlSpeed"),
globalDlEta: document.getElementById("globalDlEta"),
globalDlBar: document.getElementById("globalDlBar"),
saveBtn: document.getElementById("saveBtn"), saveBtn: document.getElementById("saveBtn"),
clearLogBtn: document.getElementById("clearLogBtn"), clearLogBtn: document.getElementById("clearLogBtn"),
toast: document.getElementById("toast"), toast: document.getElementById("toast"),
@@ -240,6 +249,8 @@ function renderStatus() {
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target"; els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`; if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
if (els.railSpace) els.railSpace.textContent = `${space} free`; if (els.railSpace) els.railSpace.textContent = `${space} free`;
if (els.brandVersion)
els.brandVersion.textContent = state.status.version ? `v${state.status.version}` : "v--";
} }
const CONN_MIN = 1, CONN_MAX = 16; const CONN_MIN = 1, CONN_MAX = 16;
@@ -296,6 +307,7 @@ function renderSettings() {
/* ---------------- games ---------------- */ /* ---------------- games ---------------- */
function renderGames() { function renderGames() {
renderGlobalStatus();
const visible = state.titles.filter(matchesFilter).filter(matchesQuery); const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
els.gameGrid.replaceChildren(); els.gameGrid.replaceChildren();
@@ -311,9 +323,11 @@ function renderGames() {
// Mutually-exclusive bucket per game for the filter chips. // Mutually-exclusive bucket per game for the filter chips.
function gameCategory(game) { function gameCategory(game) {
// In-flight work (queued, active, paused, installing) lives in its own // Updating is the queue — jobs waiting for a pool slot. An actively
// bucket so Updatable shows only what the user could still trigger. // downloading game stays under Updatable so you don't lose sight of it
if (game.installing || game.downloading || game.resumable) return "updating"; // while drilling into the queue; same for paused (the user knows where
// it is and can resume from the card) and installing.
if (game._jobState === "queued") return "updating";
// checking is transient (version lookup still running); keep it visible under // checking is transient (version lookup still running); keep it visible under
// Updatable rather than letting it fall out of every specific filter. // Updatable rather than letting it fall out of every specific filter.
if (game.status === "checking") return "updatable"; if (game.status === "checking") return "updatable";
@@ -557,9 +571,11 @@ function reconcileFromJobs(jobs) {
g._localDownloading = false; g._localDownloading = false;
} }
if (g.downloading && !g._localDownloading) g.downloading = false; if (g.downloading && !g._localDownloading) g.downloading = false;
g._jobState = null;
return; return;
} }
g._localDownloading = false; // the pool now tracks it g._localDownloading = false; // the pool now tracks it
g._jobState = j.state;
if (j.state === "active" || j.state === "queued") { if (j.state === "active" || j.state === "queued") {
g.downloading = true; g.downloading = true;
g.resumable = false; g.resumable = false;
@@ -689,8 +705,62 @@ function downloadingIds() {
return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(","); return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(",");
} }
// Top-of-page status banner. Visible while any job in this batch is queued,
// active, or has just finished (done jobs linger one or two polls before the
// pool reaps them, which is what gives us the "5 of 9" position).
function renderGlobalStatus() {
const el = els.globalDl;
if (!el) return;
const jobs = state.downloads || [];
const batch = jobs.filter((j) => ["queued", "active", "done"].includes(j.state));
const flying = batch.filter((j) => j.state === "queued" || j.state === "active");
if (flying.length === 0) { el.hidden = true; return; }
el.hidden = false;
const active = batch.find((j) => j.state === "active") || flying[0];
const total = batch.length;
const done = batch.filter((j) => j.state === "done").length;
const pos = Math.min(total, done + 1);
const isActive = active && active.state === "active";
// Eyebrow + name
els.globalDlState.textContent = isActive ? "Downloading" : "Queued";
els.globalDlName.textContent = active.name || active.title_id || "—";
// Position chip ("3 of 9") only when there's more than one game in this run
if (total > 1) {
els.globalDlPosition.hidden = false;
els.globalDlPosition.textContent = `${pos} of ${total}`;
} else {
els.globalDlPosition.hidden = true;
}
// Progress line
const pct = pctOf(active);
const speed = Number(active._speed) || 0;
const done_ = Number(active.bytes) || 0;
const total_ = Number(active.total_bytes) || 0;
els.globalDlPct.innerHTML = isActive ? `<b>${pct}%</b>` : `<b>—</b> waiting`;
els.globalDlSpeed.innerHTML = isActive && speed > 0
? `<b>${formatBytes(speed)}/s</b>`
: (isActive && !total_ ? "fetching manifest…" : "—");
els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_
? `ETA <b>${formatEta((total_ - done_) / speed)}</b>`
: "";
// Bar: animated indeterminate while fetching manifest, otherwise width=pct
if (isActive && total_ > 0) {
els.globalDlBar.classList.remove("is-indeterminate");
els.globalDlBar.style.width = pct + "%";
} else {
els.globalDlBar.classList.add("is-indeterminate");
}
}
// Update the progress bar/meta in place to avoid rebuilding every card each tick. // Update the progress bar/meta in place to avoid rebuilding every card each tick.
function applyDownloadProgress() { function applyDownloadProgress() {
renderGlobalStatus();
let needRender = false; let needRender = false;
state.downloads.forEach((d) => { state.downloads.forEach((d) => {
// Only titles currently downloading render a progress tile; paused/done/error // Only titles currently downloading render a progress tile; paused/done/error
+24 -1
View File
@@ -39,7 +39,7 @@
<div class="brand-mark">PD</div> <div class="brand-mark">PD</div>
<div> <div>
<strong>PatchDL</strong> <strong>PatchDL</strong>
<span>by Knutwurst · v0.0.3</span> <span>by Knutwurst · <span id="brandVersion">--</span></span>
</div> </div>
</div> </div>
@@ -80,6 +80,29 @@
</div> </div>
</header> </header>
<aside id="globalDl" class="global-dl" hidden aria-live="polite">
<div class="global-dl-row">
<span class="global-dl-pulse" aria-hidden="true"></span>
<div class="global-dl-text">
<div class="global-dl-line1">
<span class="global-dl-eyebrow" id="globalDlState">Downloading</span>
<strong class="global-dl-name" id="globalDlName">—</strong>
<span class="global-dl-position" id="globalDlPosition" hidden></span>
</div>
<div class="global-dl-line2">
<span id="globalDlPct">0%</span>
<span class="global-dl-sep" aria-hidden="true">·</span>
<span id="globalDlSpeed">—</span>
<span class="global-dl-sep" aria-hidden="true">·</span>
<span id="globalDlEta">—</span>
</div>
</div>
</div>
<div class="global-dl-track">
<i class="global-dl-bar" id="globalDlBar" style="width:0%"></i>
</div>
</aside>
<section class="status-strip" aria-label="System status"> <section class="status-strip" aria-label="System status">
<article class="metric"> <article class="metric">
<span>Firmware</span> <span>Firmware</span>
+99
View File
@@ -173,6 +173,105 @@ h2 { font-size: 18px; line-height: 1.2; }
margin-bottom: 18px; margin-bottom: 18px;
} }
/* ---------------- global download banner ---------------- */
/* Sticky banner above the status strip, visible only while at least one
download is queued/active. The pulse dot and gradient bar at the bottom
echo the per-game card progress styling so the two read as one system. */
.global-dl {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 18px;
padding: 16px 18px 0;
border: 1px solid var(--border);
background:
radial-gradient(120% 80% at 0% 0%, var(--green-soft), transparent 60%),
linear-gradient(180deg, rgba(56, 193, 114, 0.06), transparent 60%),
var(--surface);
border-radius: var(--radius);
overflow: hidden;
}
.global-dl[hidden] { display: none; }
.global-dl-row {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.global-dl-pulse {
flex: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 0 var(--green);
animation: globalDlPulse 1.6s ease-out infinite;
}
@keyframes globalDlPulse {
0% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0.55); }
70% { box-shadow: 0 0 0 14px rgba(56, 193, 114, 0); }
100% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0); }
}
.global-dl-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.global-dl-line1 {
display: flex; align-items: baseline; gap: 10px;
min-width: 0; flex-wrap: wrap;
}
.global-dl-eyebrow {
text-transform: uppercase; letter-spacing: 0.08em;
font-size: 11px; font-weight: 600; color: var(--green-dark);
}
.global-dl-name {
font-size: 16px; font-weight: 600; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
min-width: 0; flex: 1;
}
.global-dl-position {
font-size: 12px; color: var(--muted);
padding: 2px 8px; border-radius: 999px;
background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border);
}
.global-dl-position[hidden] { display: none; }
.global-dl-line2 {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--muted);
font-variant-numeric: tabular-nums;
}
.global-dl-line2 b { color: #d6dde4; font-weight: 600; }
.global-dl-sep { opacity: 0.5; }
.global-dl-track {
position: relative;
height: 3px;
margin: 0 -18px; /* extend bar edge-to-edge */
background: rgba(56, 193, 114, 0.08);
overflow: hidden;
}
.global-dl-bar {
display: block;
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--green) 0%, var(--green-dark) 100%);
box-shadow: 0 0 12px rgba(56, 193, 114, 0.4);
transition: width 400ms ease-out;
}
.global-dl-bar.is-indeterminate {
width: 32% !important;
animation: globalDlIndet 1.6s ease-in-out infinite;
}
@keyframes globalDlIndet {
0% { transform: translateX(-100%); }
100% { transform: translateX(320%); }
}
.metric { .metric {
min-width: 0; min-width: 0;
padding: 14px 16px; padding: 14px 16px;