7 Commits
Author SHA1 Message Date
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
15 changed files with 571 additions and 41 deletions

No files matched your search

+8 -3
View File
@@ -23,7 +23,12 @@ SRCS := src/main.c \
src/patchdl_resolve.c \
src/patchdl_verxml.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
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
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
test: $(BIN)
$(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
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
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
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.
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF
(`patchdl_<version>.elf`, so Payload Manager picks the version out of the
filename) and launches it; the payload replaces any running instance.
```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
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.
download pool, reboot-safe resume, and on-device SHA-256 verification.
Install works for same-region patches, where Sony stores the patch bytes under
the installed game's own title id. PatchDL now mirrors etaHEN's native
DirectPKGInstaller call shape for that path: it passes an empty
`MetaInfo.content_id`, lets AppInstUtil bind the signed package metadata, keeps
the returned content id, and exposes `/api/installstatus` for installer progress
when `sceAppInstUtilGetInstallStatus` is exported.
Install works through Sony's AppInstUtil. `sceAppInstUtilInstallByPackage` is
unavailable from the homebrew payload context (rejected with `0x80B21163`
outside the system process), so PatchDL routes through
`sceAppInstUtilAppInstallPkg` — the simpler API that reads the PKG's embedded
`content_id` and binds the install to the right title slot. Verified
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
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 refuses to install it from the standalone ELF because `InstallByPackage`
does not retarget signed package metadata 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.
Disc games need the disc inserted for their patch to apply, which is a normal
Sony requirement.
`/api/installstatus` reports installer progress once
`sceAppInstUtilGetInstallStatus` finishes the queued task.
Disc games still need the disc inserted for their patch to apply, which is a
normal Sony requirement; that's why shadowmounts are download-only by policy.
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"
[ -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"
cp "$SRC_ELF" "$TMP_ELF"
+16 -5
View File
@@ -452,8 +452,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_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
/* HTTPS pin removed on the streaming download path: the Sony CDN sometimes
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_CONNECTTIMEOUT, 20L);
/* No total timeout (patches can be large); abort only on a long stall. */
@@ -822,7 +826,8 @@ 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) {
patchdl_piece_ctx_t *ctx,
int *curl_rc_out, long *http_code_out) {
CURL *curl;
CURLcode res;
long http_code = 0;
@@ -832,6 +837,9 @@ patchdl_http_download_piece(const char *url, int fd,
piece_sink_t sink;
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 (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
@@ -871,8 +879,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_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
/* HTTPS pin removed on the streaming piece path — see the same change in
http_download_to_file_progress for the why. */
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_CONNECTTIMEOUT, 20L);
@@ -890,6 +898,9 @@ patchdl_http_download_piece(const char *url, int fd,
curl_easy_cleanup(curl);
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 (sink.md) EVP_MD_CTX_free(sink.md);
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
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,
long long file_offset, long long file_size,
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
(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
#define PATCHDL_VERSION "0.0.4"
#define PATCHDL_VERSION "0.0.5"
+53 -5
View File
@@ -5,7 +5,9 @@
#include "patchdl_install.h"
#include "patchdl_net.h"
#include "patchdl_resolve.h"
#include "patchdl_tile.h"
#include "patchdl_verxml.h"
#include "patchdl_version.h"
#include <microhttpd.h>
#include <pthread.h>
@@ -94,6 +96,8 @@ typedef struct dl_job {
int inflight;
int fd; /* O_RDWR dest fd, -1 until admit */
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;
} dl_job_t;
@@ -585,8 +589,9 @@ build_status_json(void) {
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\"}",
g_fw.str, g_fw.bin, data_free_mb());
"\"download_dir\":\"/data/patchdl (internal)\","
"\"version\":\"%s\"}",
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
return out;
}
@@ -824,6 +829,8 @@ build_downloads_json(void) {
jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, job_state_str(job->state));
jbuf_appendf(&j, ",\"progress\":%d", progress);
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, "]");
@@ -1337,6 +1344,7 @@ admit_next(void) {
free(q->ps); q->ps = NULL;
free(q->bitmap); q->bitmap = NULL;
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;
for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1;
@@ -1444,15 +1452,22 @@ dl_worker(void *arg) {
pthread_mutex_unlock(&g_pool.mtx);
/* ---- 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 };
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);
g_pool.inflight_bytes[slot] = 0;
job->inflight--;
if (rc != 0) {
job->last_curl_rc = last_curl_rc;
job->last_http_code = last_http_code;
}
job->ps[pidx].slot = -1;
if (my_seq != job->seq) {
/* job cancelled/torn down under us: discard result (do not touch
@@ -1845,6 +1860,7 @@ static enum MHD_Result
handle_config_post(struct MHD_Connection *conn, const char *body) {
char pol[8];
int mc;
int tile_just_enabled = 0;
json_get_str(body, "default_policy", pol, sizeof(pol));
/* Only the two real values are accepted; anything else is silently
@@ -1864,8 +1880,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);
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 =
json_get_int(body, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
@@ -1885,6 +1905,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
pthread_mutex_unlock(&g_pool.mtx);
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());
}
@@ -1996,6 +2026,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,
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");
}
@@ -2249,6 +2288,15 @@ patchdl_websrv_start(unsigned short port) {
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
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
Resume after a reboot. */
detect_resumable_partials();
+73 -3
View File
@@ -127,6 +127,15 @@ function bindElements() {
connPlus: document.getElementById("connPlus"),
refreshBtn: document.getElementById("refreshBtn"),
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"),
clearLogBtn: document.getElementById("clearLogBtn"),
toast: document.getElementById("toast"),
@@ -240,6 +249,8 @@ function renderStatus() {
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.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;
@@ -296,6 +307,7 @@ function renderSettings() {
/* ---------------- games ---------------- */
function renderGames() {
renderGlobalStatus();
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
els.gameGrid.replaceChildren();
@@ -311,9 +323,11 @@ function renderGames() {
// Mutually-exclusive bucket per game for the filter chips.
function gameCategory(game) {
// In-flight work (queued, active, paused, installing) lives in its own
// bucket so Updatable shows only what the user could still trigger.
if (game.installing || game.downloading || game.resumable) return "updating";
// Updating is the queue — jobs waiting for a pool slot. An actively
// downloading game stays under Updatable so you don't lose sight of it
// 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
// Updatable rather than letting it fall out of every specific filter.
if (game.status === "checking") return "updatable";
@@ -557,9 +571,11 @@ function reconcileFromJobs(jobs) {
g._localDownloading = false;
}
if (g.downloading && !g._localDownloading) g.downloading = false;
g._jobState = null;
return;
}
g._localDownloading = false; // the pool now tracks it
g._jobState = j.state;
if (j.state === "active" || j.state === "queued") {
g.downloading = true;
g.resumable = false;
@@ -689,8 +705,62 @@ function downloadingIds() {
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.
function applyDownloadProgress() {
renderGlobalStatus();
let needRender = false;
state.downloads.forEach((d) => {
// 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>
<strong>PatchDL</strong>
<span>by Knutwurst · v0.0.3</span>
<span>by Knutwurst · <span id="brandVersion">--</span></span>
</div>
</div>
@@ -80,6 +80,29 @@
</div>
</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">
<article class="metric">
<span>Firmware</span>
+99
View File
@@ -173,6 +173,105 @@ h2 { font-size: 18px; line-height: 1.2; }
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 {
min-width: 0;
padding: 14px 16px;