5 Commits
Author SHA1 Message Date
Knutwurst 3f40dc1d7c Rework the web UI: tile-based progress, view nav, responsive
Replace the single-page layout with a view-based one (Games / Settings /
Logs via the left nav) and fold all download UI into the game tile — the
separate download queue is gone.

Per tile while downloading: an in-tile progress bar with auto-scaled size
(B/KB/MB/GB/TB), live transfer speed and ETA from the poll deltas, and a
green "Downloading" marker. One fixed-width action button that no longer
reflows with its label: a blue Update/Download/Install that morphs into an
amber Cancel while the download runs, plus a ghost Delete for a finished
package.

Settings and Logs moved to their own pages. Mobile-first responsive layout
(rail collapses to a top bar, icon-only nav, single-column tiles, >=44px
touch targets, 16px inputs). Real free space (statvfs) is shown auto-scaled
in the rail and status strip.

New "Home-screen shortcut" toggle (default on, persisted as home_shortcut
in config.json). The actual PS5 tile install is not wired yet: it needs a
prebuilt deeplinkUri stub PKG installed via sceAppInstUtil.

Fixes from an adversarial review pass:
- Reconcile in-flight downloads from /api/downloads onto the cards, so
  progress + Cancel appear after a reload or a download started elsewhere,
  and stop the per-poll full-grid rebuild.
- Clear the downloaded flag on install and once the server reports the
  title up to date, so a patched title no longer shows Install/Delete
  forever.
- Zero a stale speed/ETA if the byte counter goes backwards.
- a11y: nav buttons keep an accessible name when the label is hidden on
  small screens; visible focus ring on the search box; filter group is
  role=group with aria-pressed; drop the noisy grid-level aria-live; fold
  the transient "checking" state into a visible filter bucket.
2026-06-23 21:48:54 +02:00
Knutwurst 5f0d1be078 Harden filesystem safety after a security review
Defense in depth around the only operations that touch the filesystem,
so no request can escape /data/patchdl or leave the process able to
write to a system path:

- Validate the HTTP title_id with path_segment_safe at the top of the
  title-action route, and again inside remove_title_dir and
  cleanup_installed_download. The delete primitives are now self-
  protecting instead of relying only on the "title exists in the scan"
  guard, so a future refactor cannot reintroduce a /data-wiping
  traversal (a title_id of ".." would otherwise resolve the dir to
  /data).
- Only swap the process root vnode when the current root was captured
  and can be restored, in both the scan and the debug dump. Otherwise
  the process could be left rooted at the system root, sending later
  absolute-path writes to the wrong place.

Reviewed and confirmed safe with no change needed: the installer only
delegates to Sony's signed AppInstUtil service (it never writes or
redirects to system paths itself), app.db is opened read-only and
immutable, every write targets /data/patchdl, the patch picker never
selects an update that needs a newer firmware, and the /api/pkg file
server already blocks path traversal.
2026-06-23 19:46:23 +02:00
Knutwurst ea1328de79 Add optional SHA-256 verification of downloaded manifest pieces
Each Sony manifest piece carries a SHA-256 (hashValue). When the new
"Verify downloaded pieces" setting is on, every piece is hashed while it
streams to disk (OpenSSL EVP, already linked) and compared against the
manifest value; a mismatch aborts the download, deletes the partial, and
reports piece_verify_failed instead of handing a corrupt 60 GB package to
the installer.

Off by default: TLS already protects the bytes in transit and the PS5
installer verifies the whole packageDigest before applying, so this is a
fail-fast belt-and-suspenders check. It is also unverified on hardware
yet, so it stays opt-in (persisted in config.json) until confirmed
on-device; the hex compare is case-insensitive since Sony mixes cases.
2026-06-23 18:08:57 +02:00
Knutwurst 9006965a75 Add download cancel/delete and harden the patch pipeline
Cancel a running download (the worker aborts mid-piece and the partial
file is removed) or delete a finished package, from the queue or the
title card. The progress callback now returns an abort signal that
reaches libcurl and the manifest merge loop.

Manifest merge: bound the piece scan to the "pieces" array so a later
"url" key (e.g. playgoChunkCrcUrl) can't be appended as a bogus piece,
and require each piece's fileOffset to match the bytes written so far so
an out-of-order manifest fails instead of silently producing a corrupt
package.

Report real free space on the download partition via statvfs; it was a
hardcoded 0.

Fixes found in review:
- scan: bound the SFO entry table to the bytes actually read and require
  the key to be NUL-terminated before strcmp (OOB read on a crafted
  param.sfo from a shadow-mounted dir).
- proc: bound the kinfo_proc walk and the name compare to the record and
  the buffer.
- install: publish the API probe under the lock (data race with the MHD
  worker thread) and initialize rc2.
- verxml: reject a truncated attribute value instead of returning it as
  valid.
- web: keep download/install/downloaded flags across a refresh, stop the
  queue poll only after repeated empty results, coerce the progress
  number, and treat a cancelled download (HTTP 200, ok:false) as
  not-downloaded.
2026-06-23 17:52:29 +02:00
Knutwurst 510f199b89 Handle target-aware patch installs 2026-06-23 17:03:51 +02:00
17 changed files with 2310 additions and 1139 deletions

No files matched your search

+32 -14
View File
@@ -19,13 +19,13 @@ by Knutwurst
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 patch package and installs it through Sony's AppInstUtil
service.
- Downloads the installable package from Sony's manifest pieces and installs it
through Sony's AppInstUtil service.
## Safety model
Deny-by-default. A patch is installed only for a genuine install, and only when
the package's title id matches the installed game:
the patch metadata targets the installed game:
| Source | Check | Download | Install |
|-----------------------|-------|----------|---------|
@@ -33,11 +33,21 @@ the package's title id matches the installed game:
| shadowmount | yes | yes | no |
| preinstall / unknown | yes | no | no |
Two independent guards stop the wrong package being installed: the patch's title
id (read from its download URL) must match the game, and just before install the
real title id is read back from the package
(`sceAppInstUtilGetTitleIdFromPkg`) and checked again. A cross-region or
cross-title package is refused instead of installed as a phantom title.
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.
For PS5 titles, `delta_url` often points to a small `*-DP.pkg` helper package.
That bootstrap can make the system fetch the full patch, but it follows the
package's storage/master title id and can create a duplicate/ghost title for
cross-region updates. PatchDL therefore prefers the Sony `manifest_url`,
downloads every listed `pieces[]` entry in order, and concatenates them into one
local `.pkg` before handing it to AppInstUtil. The `delta_url` title id is kept
only as the storage/master-id diagnostic.
## Build
@@ -69,9 +79,17 @@ http://<console-ip>:12880/
## Status
0.0.1, early. Title scan, version resolution, firmware-compatibility filtering,
download, and install work and have been verified on firmware 11.60. Open items:
the web UI marks a title "Installing…" but reads progress from the PS5's own
notifications rather than a percentage; config persistence and a download queue
are not built yet; disc-based games need the disc inserted for their patch to
apply (a normal Sony requirement).
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.
+26 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
# Deploy patchdl to the PS5 via the Payload Manager HTTP API (port 8084).
# The uploaded filename carries the version so it is identifiable in the
# Payload Manager UI. patchdl self-kills any running instance, so this is
# an idempotent redeploy.
# Payload Manager UI. Any running instance is killed first (the payload's own
# self-kill is racy when the port is still held), so this is a clean redeploy.
#
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
@@ -40,6 +40,30 @@ for p in paths:
')
[ -n "$PAYLOAD_PATH" ] || { echo "uploaded payload not found in list_payloads" >&2; exit 1; }
# Kill any running patchdl first so the new instance can bind the port
# deterministically (the in-payload self-kill races on the held socket).
echo "Stopping any running patchdl ..."
curl -fsS -m15 "http://$HOST:$PM_PORT/processes_list" 2>/dev/null | \
HOST="$HOST" PM_PORT="$PM_PORT" python3 -c '
import os, sys, json, urllib.request
obj = json.load(sys.stdin)
procs = obj if isinstance(obj, list) else obj.get("processes", [])
host, port = os.environ["HOST"], os.environ["PM_PORT"]
for p in procs:
if "patchdl" in str(p.get("name", "")).lower():
try:
urllib.request.urlopen("http://%s:%s/process_kill?pid=%d" % (host, port, int(p["pid"])), timeout=10).read()
print(" killed pid %d" % int(p["pid"]))
except Exception as e:
print(" kill pid %s failed: %s" % (p.get("pid"), e))
' || true
i=0
while [ "$i" -lt 8 ]; do
curl -fsS -m4 "http://$HOST:$HTTP_PORT/api/status" >/dev/null 2>&1 || break
sleep 1
i=$((i + 1))
done
echo "Launching $PAYLOAD_PATH ..."
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
+215 -29
View File
@@ -1,5 +1,8 @@
#include "patchdl_install.h"
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <ps5/kernel.h>
#include <pthread.h>
@@ -7,6 +10,7 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
@@ -70,6 +74,7 @@ static ai_title_from_pkg_fn ai_title_from_pkg;
static volatile int g_stage;
static int g_err;
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static char g_probe_json[2048]; /* filled by the backend thread */
static intptr_t
dynsym(const char *module, const char *sym) {
@@ -79,6 +84,99 @@ dynsym(const char *module, const char *sym) {
return kernel_dynlib_dlsym(-1, h, sym);
}
static void
local_ip(char *out, size_t n) {
struct ifaddrs *ifa = NULL, *p;
out[0] = '\0';
if (getifaddrs(&ifa))
return;
for (p = ifa; p; p = p->ifa_next) {
char ip[INET_ADDRSTRLEN];
struct sockaddr_in *s;
if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET)
continue;
s = (struct sockaddr_in *)p->ifa_addr;
if (!inet_ntop(AF_INET, &s->sin_addr, ip, sizeof(ip)))
continue;
if (strcmp(ip, "127.0.0.1") && strncmp(ip, "0.", 2)) {
strncpy(out, ip, n - 1);
out[n - 1] = '\0';
break;
}
}
freeifaddrs(ifa);
}
/* Resolve (dlsym, never call) a list of candidate patch-install symbols and
record which exist. Runs inside the backend thread, where the AppInstUtil
module is already loaded — the same proven-safe context as the normal symbol
resolution. No sysmod_load and no calls, so it is side-effect free. */
static void
fill_probe(void) {
static const char *ai_syms[] = {
"sceAppInstUtilInitialize",
"sceAppInstUtilAppInstallPkg",
"sceAppInstUtilAppInstallTitleDir", /* takes an explicit title id */
"sceAppInstUtilInstallByPackage",
"sceAppInstUtilInstallByPackageEx",
"sceAppInstUtilGetTitleIdFromPkg",
"sceAppInstUtilGetContentIdFromPkg",
"sceAppInstUtilAppExist",
"sceAppInstUtilAppGetInstallStatus",
"sceAppInstUtilAppInstallStatus",
"sceAppInstUtilAppUnInstall",
"sceAppInstUtilGetMetaInfoFromPkg",
"sceAppInstUtilUpdateTitleByTitleId",
"sceAppInstUtilInstallByChunk",
NULL
};
static const char *bgft_syms[] = {
"sceBgftInitialize",
"sceBgftServiceIntInit",
"sceBgftServiceDownloadRegisterTask",
"sceBgftServiceDownloadRegisterTaskByStorage",
"sceBgftServiceDownloadRegisterTaskByStorageEx",
"sceBgftServiceIntDownloadRegisterTaskByStorageEx",
"sceBgftServiceDownloadStartTask",
"sceBgftServiceDownloadGetProgress",
"sceBgftServiceInstallPackage",
NULL
};
uint32_t h = 0;
int ai_loaded = (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) >= 0);
int bgft_loaded = (kernel_dynlib_handle(-1, "libSceBgft.sprx", &h) >= 0);
char tmp[sizeof(g_probe_json)];
size_t n = 0, sz = sizeof(tmp);
char *out = tmp;
int first = 1;
n += snprintf(out + n, sz - n,
"{\"appinstutil_loaded\":%s,\"bgft_loaded\":%s,\"symbols\":{",
ai_loaded ? "true" : "false", bgft_loaded ? "true" : "false");
for (int i = 0; ai_syms[i] && n < sz - 80; i++) {
intptr_t a = dynsym("libSceAppInstUtil.sprx", ai_syms[i]);
n += snprintf(out + n, sz - n, "%s\"%s\":%s",
first ? "" : ",", ai_syms[i], a ? "true" : "false");
first = 0;
}
for (int i = 0; bgft_syms[i] && n < sz - 80; i++) {
intptr_t a = bgft_loaded ? dynsym("libSceBgft.sprx", bgft_syms[i]) : 0;
n += snprintf(out + n, sz - n, "%s\"%s\":%s",
first ? "" : ",", bgft_syms[i], a ? "true" : "false");
first = 0;
}
snprintf(out + n, sz - n, "}}");
/* Publish atomically: the getter runs on an MHD worker thread and reads
g_probe_json under the same lock, so it never sees a half-built buffer. */
pthread_mutex_lock(&g_mtx);
memcpy(g_probe_json, tmp, sizeof(g_probe_json));
pthread_mutex_unlock(&g_mtx);
}
static void *
backend_init_thread(void *arg) {
(void)arg;
@@ -107,6 +205,10 @@ backend_init_thread(void *arg) {
"sceAppInstUtilInstallByPackage");
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilGetTitleIdFromPkg");
/* Read-only feasibility probe — module is loaded, safe context. */
fill_probe();
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
g_stage = -3;
return NULL;
@@ -161,12 +263,38 @@ patchdl_install_backend_check(char *msg, size_t msg_sz) {
return (s == 5) ? 0 : -1;
}
/* Public getter: trigger the backend (which fills the probe in its own thread)
and return the cached result. Runs from the MHD worker thread, so it only
reads the cached string — it never loads modules or resolves symbols here. */
int
patchdl_install_api_probe(char *out, size_t out_sz) {
int ready;
backend_start();
pthread_mutex_lock(&g_mtx);
ready = (g_probe_json[0] != '\0');
if (ready)
snprintf(out, out_sz, "%s", g_probe_json);
pthread_mutex_unlock(&g_mtx);
if (ready)
return 0;
snprintf(out, out_sz,
"{\"pending\":true,\"stage\":\"%s\"}", stage_str(g_stage));
return -1;
}
int
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
const char *storage_title_id,
const char *target_content_id,
char *msg, size_t msg_sz) {
char sdk_path[1024];
char pkg_tid[48] = {0};
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");
@@ -183,63 +311,121 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
return -1;
}
/* The install service runs in its own sandbox that sees the user
partition as /user/data, not /data — remap so it can read the file. */
/* 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. */
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);
/* GUARD: read the PKG's own title id and refuse if it does not match the
installed game. A cross-title/region package (e.g. a US PPSA03098 patch
on an EU PPSA03099 install) would otherwise be registered as a separate
phantom title instead of patching the game. */
/* Diagnostic guard: Sony sometimes stores one patch byte stream under a
master title id while the version.xml targets a regional title id. That
is valid only when the caller supplies target metadata, so do not feed
such packages to the raw AppInstallPkg path. */
if (storage_title_id && storage_title_id[0] &&
expected_title_id && expected_title_id[0] &&
strncmp(storage_title_id, expected_title_id, 9) != 0) {
pkg_tid_mismatch = 1;
strncpy(pkg_tid, storage_title_id, sizeof(pkg_tid) - 1);
}
if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) {
char pkg_tid[48] = {0};
int is_app = 0;
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
strncmp(pkg_tid, expected_title_id, 9) != 0) {
snprintf(msg, msg_sz,
"refused: package is for %.12s, installed game is %.12s "
"(cross-title/region)", pkg_tid, expected_title_id);
return -1;
pkg_tid_mismatch = 1;
}
}
/* Primary: direct package install. */
{
ai_pkg_info_t pkg = {0};
rc = ai_install_pkg(sdk_path, &pkg);
if (rc == 0) {
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
return 0;
}
if (pkg_tid_mismatch && (!target_content_id || !target_content_id[0])) {
snprintf(msg, msg_sz,
"refused: package metadata is %.12s, target is %.12s",
pkg_tid, expected_title_id);
return -1;
}
/* Fallback: InstallByPackage with a file:// URI. */
/* Preferred path: InstallByPackage accepts target metadata. Use it first,
and use it exclusively when the downloaded bytes report a master/storage
title id that differs from the target regional title id. */
{
char file_uri[1100];
char http_loop_uri[1200] = {0};
char http_lan_uri[1200] = {0};
const char *uris[4];
ai_meta_info_t meta = {0};
ai_pkg_info_t pkg = {0};
ai_playgo_info_t playgo = {0};
int rc2;
int rc2 = -1;
const char *title_dir;
const char *file_base;
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
meta.uri = file_uri;
snprintf(file_uri, sizeof(file_uri), "file://%s", local_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/")) {
char title_id[32] = {0};
const char *t = title_dir + strlen("/data/patchdl/");
size_t tlen = (size_t)(file_base - t);
if (tlen > 0 && tlen < sizeof(title_id)) {
char ip[INET_ADDRSTRLEN] = {0};
memcpy(title_id, t, tlen);
snprintf(http_loop_uri, sizeof(http_loop_uri),
"http://127.0.0.1:%d/api/pkg/%s/%s",
PATCHDL_HTTP_PORT, title_id, file_base + 1);
local_ip(ip, sizeof(ip));
if (ip[0])
snprintf(http_lan_uri, sizeof(http_lan_uri),
"http://%s:%d/api/pkg/%s/%s",
ip, PATCHDL_HTTP_PORT, title_id, file_base + 1);
}
}
uris[0] = local_path;
uris[1] = file_uri;
uris[2] = http_loop_uri[0] ? http_loop_uri : NULL;
uris[3] = http_lan_uri[0] ? http_lan_uri : NULL;
meta.ex_uri = "";
meta.playgo_scenario_id = "";
meta.content_id = "";
meta.content_id = target_content_id ? target_content_id : "";
meta.content_name = "PatchDL";
meta.icon_url = "";
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
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;
}
}
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
metadata parameter, so it is intentionally skipped for shared-master
region bytes. */
{
ai_pkg_info_t pkg = {0};
int rc2 = ai_install_pkg(sdk_path, &pkg);
if (rc2 == 0) {
snprintf(msg, msg_sz, "install started (InstallByPackage)");
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
return 0;
}
snprintf(msg, msg_sz,
"install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)",
"install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)",
(unsigned)rc, (unsigned)rc2);
return rc2;
return rc2 ? rc2 : (rc ? rc : -1);
}
}
+11 -3
View File
@@ -13,13 +13,21 @@
* (official) titles and obtain explicit user intent before calling.
*/
/* `expected_title_id` is the title id of the installed game the patch is for.
The PKG's own title id is read and must match, else the install is refused
(prevents a cross-region/cross-title package being installed as a new
phantom title). */
`storage_title_id` is the title id embedded in the delta_url storage path.
`target_content_id` is the installed game's content id from app.db; when
present it is passed to InstallByPackage so Sony's installer has the target
metadata even for region-shared/master-storage patch bytes. */
int patchdl_install_local_pkg(const char *local_path,
const char *expected_title_id,
const char *storage_title_id,
const char *target_content_id,
char *msg, size_t msg_sz);
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
int patchdl_install_backend_check(char *msg, size_t msg_sz);
/* Read-only feasibility probe: resolve (dlsym, never call) a list of candidate
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);
+285 -11
View File
@@ -7,12 +7,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#ifdef PATCHDL_HAVE_CURL
#include <curl/curl.h>
#include <openssl/evp.h>
#include "patchdl_ca.h"
#endif
@@ -290,29 +292,82 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
return 0;
}
/* Write sink: tees the body to the file and, when verifying, into a running
SHA-256. curl always calls with size==1, so nmemb is the byte count. */
typedef struct {
FILE *fp;
EVP_MD_CTX *md; /* NULL when not verifying */
} write_sink_t;
static size_t
file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
return fwrite(ptr, size, nmemb, (FILE *)userdata);
write_sink_t *s = (write_sink_t *)userdata;
size_t written = fwrite(ptr, size, nmemb, s->fp);
if (s->md && written)
EVP_DigestUpdate(s->md, ptr, written * size);
return written;
}
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
/* Hex-encode a digest, lowercase. */
static void
hex_encode(const unsigned char *d, unsigned int len, char *out, size_t out_sz) {
static const char hexd[] = "0123456789abcdef";
unsigned int i;
for (i = 0; i < len && (2u * i + 2u) < out_sz; i++) {
out[2 * i] = hexd[(d[i] >> 4) & 0xf];
out[2 * i + 1] = hexd[d[i] & 0xf];
}
out[2 * i] = '\0';
}
typedef struct {
patchdl_download_progress_cb cb;
void *ctx;
long long base;
long long total;
} progress_state_t;
static int
curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
curl_off_t ultotal, curl_off_t ulnow) {
progress_state_t *p = (progress_state_t *)clientp;
long long total;
(void)ultotal;
(void)ulnow;
if (!p || !p->cb) return 0;
total = p->total > 0 ? p->total : (long long)dltotal;
/* A non-zero return aborts the transfer (CURLE_ABORTED_BY_CALLBACK),
which is how a cancel request stops a piece mid-flight. */
return p->cb(p->ctx, p->base + (long long)dlnow, total);
}
/* 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. */
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) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
struct curl_slist *rl = NULL;
struct curl_blob ca_blob;
FILE *fp;
curl_off_t dl = 0;
write_sink_t sink = { fp, NULL };
int verify = (expected_sha256_hex && expected_sha256_hex[0]);
if (bytes_out) *bytes_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;
fp = fopen(dest_path, "wb");
if (!fp) return -1;
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);
@@ -324,12 +379,16 @@ patchdl_http_download(const char *url, const char *dest_path,
ca_blob.flags = CURL_BLOB_COPY;
curl = curl_easy_init();
if (!curl) { fclose(fp); curl_slist_free_all(rl); return -1; }
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, file_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
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);
@@ -341,21 +400,210 @@ patchdl_http_download(const char *url, const char *dest_path,
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
if (progress && progress->cb) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, progress);
}
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
fclose(fp);
if (res != CURLE_OK) {
if (sink.md) EVP_MD_CTX_free(sink.md);
return -1;
}
if (sink.md) {
unsigned char dig[EVP_MAX_MD_SIZE];
unsigned int dlen = 0;
char hex[2 * EVP_MAX_MD_SIZE + 1];
EVP_DigestFinal_ex(sink.md, dig, &dlen);
EVP_MD_CTX_free(sink.md);
hex_encode(dig, dlen, hex, sizeof(hex));
if (strcasecmp(hex, expected_sha256_hex) != 0)
return -2; /* integrity mismatch */
}
if (bytes_out) *bytes_out = (long long)dl;
return 0;
}
int
patchdl_http_download_progress(const char *url, const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb, void *ctx) {
FILE *fp = fopen(dest_path, "wb");
progress_state_t progress = { cb, ctx, 0, 0 };
int rc;
if (!fp) return -1;
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL);
fclose(fp);
if (rc) {
unlink(dest_path);
return -1;
}
if (bytes_out) *bytes_out = (long long)dl;
return 0;
}
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
return patchdl_http_download_progress(url, dest_path, bytes_out, NULL, NULL);
}
static int
json_string_after(const char *p, const char *key, char *out, size_t out_sz) {
char needle[48];
const char *q;
size_t n = 0;
if (!p || !out || out_sz == 0) return -1;
out[0] = '\0';
snprintf(needle, sizeof(needle), "\"%s\"", key);
q = strstr(p, needle);
if (!q) return -1;
q += strlen(needle);
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != ':') return -1;
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != '"') return -1;
while (*q && *q != '"' && n + 1 < out_sz) {
if (*q == '\\' && q[1]) q++;
out[n++] = *q++;
}
out[n] = '\0';
return n ? 0 : -1;
}
static int
json_u64_after(const char *p, const char *key, unsigned long long *out) {
char needle[48];
const char *q;
if (!p || !out) return -1;
snprintf(needle, sizeof(needle), "\"%s\"", key);
q = strstr(p, needle);
if (!q) return -1;
q += strlen(needle);
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != ':') return -1;
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q < '0' || *q > '9') return -1;
*out = strtoull(q, NULL, 10);
return 0;
}
int
patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx, int verify) {
patchdl_buf_t manifest;
const char *pieces;
const char *p;
FILE *fp;
long long total = 0;
unsigned long long manifest_total = 0;
int count = 0;
int rc = -1;
if (bytes_out) *bytes_out = 0;
if (patchdl_http_get(manifest_url, &manifest))
return -1;
if (!manifest.data || !manifest.size) {
free(manifest.data);
return -1;
}
pieces = strstr(manifest.data, "\"pieces\"");
if (!pieces || !(pieces = strchr(pieces, '['))) {
free(manifest.data);
return -1;
}
/* Bound the scan to the pieces array; otherwise a later "url" key in the
manifest (e.g. playgoChunkCrcUrl) could be appended as a bogus piece. */
const char *pieces_end = strchr(pieces, ']');
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
fp = fopen(dest_path, "wb");
if (!fp) {
free(manifest.data);
return -1;
}
p = pieces;
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
char url[768];
char hash[80] = {0};
long long got = 0;
unsigned long long expected = 0;
unsigned long long offset = 0;
int have_offset, drc;
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)
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);
if (drc) {
if (drc == -2) rc = -2;
goto done;
}
if (expected && (unsigned long long)got != expected)
goto done;
total += got;
/* A non-zero callback return between pieces means cancel requested. */
if (cb && cb(ctx, total, manifest_total ? (long long)manifest_total : total))
goto done;
count++;
p = obj_end ? obj_end + 1 : p + 5;
}
if (count > 0) {
rc = 0;
if (bytes_out) *bytes_out = total;
}
done:
fclose(fp);
free(manifest.data);
if (rc) unlink(dest_path);
return rc;
}
int
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
long long *bytes_out) {
return patchdl_http_download_manifest_progress(manifest_url, dest_path,
bytes_out, NULL, NULL, 0);
}
void
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
@@ -427,6 +675,32 @@ patchdl_http_download(const char *url, const char *dest_path,
return -1;
}
int
patchdl_http_download_progress(const char *url, const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb, void *ctx) {
(void)cb; (void)ctx;
return patchdl_http_download(url, dest_path, bytes_out);
}
int
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
long long *bytes_out) {
(void)manifest_url; (void)dest_path;
if (bytes_out) *bytes_out = 0;
return -1;
}
int
patchdl_http_download_manifest_progress(const char *manifest_url,
const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb,
void *ctx, int verify) {
(void)cb; (void)ctx; (void)verify;
return patchdl_http_download_manifest(manifest_url, dest_path, bytes_out);
}
void
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
(void)url;
+20
View File
@@ -13,10 +13,30 @@ void patchdl_buf_free(patchdl_buf_t *b);
int patchdl_http_get(const char *url, patchdl_buf_t *out);
/* Progress callback. Return non-zero to ABORT the in-flight download (used to
cancel large patch downloads); return 0 to continue. */
typedef int (*patchdl_download_progress_cb)(void *ctx,
long long downloaded,
long long total);
/* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on
success and writes the byte count to *bytes_out. */
int patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out);
int patchdl_http_download_progress(const char *url, const char *dest_path,
long long *bytes_out,
patchdl_download_progress_cb cb, void *ctx);
/* Download a Sony JSON package manifest by concatenating every entry in
"pieces" into one installable PKG. When `verify` is non-zero each piece is
checked against its manifest SHA-256 (a mismatch returns -2). */
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);
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
(dns result/ip, curl code, http status, bytes) into `out_json`. */
+17 -7
View File
@@ -34,16 +34,26 @@ find_pid(const char *name) {
if (!(buf = malloc(buf_size))) return -1;
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) {
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
/* The loop guard guarantees the structsize/pid/tdname fields are inside the
buffer before we read them, and the per-record check below keeps the name
compare within the record (and thus the buffer). */
for (uint8_t *ptr = buf; ptr + KINFO_OFF_TDNAME < buf + buf_size; ) {
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
pid_t ki_pid;
char *ki_tdname;
size_t name_max;
if (ki_structsize <= 0) break; /* guard against malformed entries */
ptr += ki_structsize;
if (ki_structsize <= KINFO_OFF_TDNAME) break; /* malformed/truncated */
if (ptr + ki_structsize > buf + buf_size) break; /* record past buffer */
if (!strcmp(name, ki_tdname) && ki_pid != mypid)
ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
name_max = (size_t)(ptr + ki_structsize - (uint8_t *)ki_tdname);
if (!strncmp(name, ki_tdname, name_max) && ki_pid != mypid)
pid = ki_pid;
ptr += ki_structsize;
}
free(buf);
+72 -28
View File
@@ -140,13 +140,23 @@ sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
entries = (const sfo_entry_t *)(buf + sizeof(*h));
/* The entry table itself must fit in the bytes we actually read; a crafted
param.sfo (from a shadow-mounted game dir) could otherwise drive
entries[i] past the buffer. */
if (sizeof(*h) + (size_t)h->num_entries * sizeof(sfo_entry_t) > bufsz)
return -1;
for (i = 0; i < h->num_entries; i++) {
size_t key_off = h->key_table_start + entries[i].key_offset;
size_t val_off = h->data_table_start + entries[i].data_offset;
if (key_off >= bufsz || val_off >= bufsz) continue;
const char *k = (const char *)(buf + key_off);
const char *k = (const char *)(buf + key_off);
size_t kmax = bufsz - key_off;
/* Require the key to be NUL-terminated within the buffer before the
strcmp, otherwise it would read past the end. */
if (strnlen(k, kmax) == kmax) continue;
if (strcmp(k, key)) continue;
if (entries[i].data_fmt != SFO_FMT_STR) return -1;
@@ -229,20 +239,37 @@ is_game_title(const char *title_id) {
/* ---------- directory scanner ------------------------------------------- */
/* Authoritative shadowmount test: ShadowMountPlus routes its images through
/mnt/shadowmnt (a pfs from /dev/lvdN there, then a nullfs onto the app dir),
so a title whose mount table references /mnt/shadowmnt is a shadowmount. The
on-disk mount.lnk marker is unreliable across reboots/remounts; the live
mount table is not. */
static int
mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts) {
for (int i = 0; i < nmounts; i++) {
const char *from = mounts[i].f_mntfromname;
const char *on = mounts[i].f_mntonname;
if ((strstr(from, "/mnt/shadowmnt") || strstr(on, "/mnt/shadowmnt")) &&
(strstr(from, title_id) || strstr(on, title_id)))
return 1;
}
return 0;
}
/*
* Distinguish genuine installs from ShadowMountPlus mounts by on-disk layout
* under /user/app/<TID>/ (verified on fw 11.60):
* - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount
* - app.pkg (no mount.lnk) -> genuine install; app.json
* with CDN piece URLs means a not-downloaded preinstall stub, local
* URLs mean a real install
* - app.json with "fake":true -> homebrew fake (skip)
* Classify each /user/app/<TID> (verified on fw 11.60):
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
* mount (authoritative; mount.lnk is only a fallback hint)
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
* URLs means a not-downloaded preinstall stub, local URLs a real install
* - app.json with "fake":true -> homebrew fake (skip)
*/
static int
scan_one(const char *base, const char *name, patchdl_title_t *t) {
scan_one(const char *base, const char *name, patchdl_title_t *t,
const struct statfs *mounts, int nmounts) {
char dir[PATH_MAX];
char appjson[4096];
int has_mountlnk, has_app_pkg, has_paramjson, is_fake = 0, is_cdn = 0;
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0, is_cdn = 0;
snprintf(dir, sizeof(dir), "%s/%s", base, name);
memset(t, 0, sizeof(*t));
@@ -252,6 +279,7 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
if (!is_game_title(t->title_id))
return -1;
is_shadow = mount_is_shadow(t->title_id, mounts, nmounts);
has_mountlnk = path_exists(dir, "mount.lnk") ||
path_exists(dir, "mount_img.lnk");
has_app_pkg = path_exists(dir, "app.pkg");
@@ -266,19 +294,18 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
if (is_fake)
return -1;
if (has_mountlnk) {
/* metadata lives in the mounted sce_sys (param.json, or param.sfo
for PS4 titles) */
if (try_param_json(dir, t))
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
titles never have it (they have mounted/leftover sce_sys content). So
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
whether the shadow image is currently mounted. */
if (has_app_pkg) {
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN /* CDN pkg = preinstall */
: PATCHDL_SOURCE_OFFICIAL;
} else if (is_shadow || has_mountlnk || has_paramjson ||
path_exists(dir, "sce_sys/param.sfo")) {
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
try_param_sfo(dir, t);
t->source_type = PATCHDL_SOURCE_SHADOWMOUNT;
} else if (has_app_pkg) {
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN
: PATCHDL_SOURCE_OFFICIAL;
} else if (has_paramjson || path_exists(dir, "sce_sys/param.sfo")) {
if (try_param_json(dir, t)) /* genuine game currently mounted */
try_param_sfo(dir, t);
t->source_type = PATCHDL_SOURCE_OFFICIAL;
} else {
return -1; /* empty / leftover directory */
}
@@ -300,7 +327,8 @@ already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
}
static void
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap,
const struct statfs *mounts, int nmounts) {
DIR *d;
struct dirent *de;
@@ -310,7 +338,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
while ((de = readdir(d))) {
if (de->d_name[0] == '.') continue;
if (*cnt >= cap) break;
if (scan_one(base, de->d_name, &arr[*cnt]) != 0)
if (scan_one(base, de->d_name, &arr[*cnt], mounts, nmounts) != 0)
continue;
if (already_seen(arr, *cnt, arr[*cnt].title_id))
continue; /* dedupe a title already found in an earlier base */
@@ -369,6 +397,9 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
struct statfs *mounts = NULL;
int nmounts;
arr = calloc(MAX_TITLES, sizeof(*arr));
if (!arr) return -1;
@@ -376,15 +407,25 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
readable; same authid ftpsrv uses. No-op without kernel R/W. */
kernel_set_ucred_authid(pid, 0x4801000000000013L);
/* Global mount table for shadowmount detection. getmntinfo's buffer is
libc-managed — must NOT be freed. */
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
/* Only swap if we captured the current root, so we can always restore
it. Leaving the process rooted at the system root would make later
absolute-path writes land in the wrong place. */
if (saved_root) {
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
}
for (int i = 0; SCAN_DIRS[i]; i++)
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES);
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
merge_appdb(arr, cnt);
@@ -450,8 +491,11 @@ patchdl_scan_debug_json(void) {
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
/* Only swap if we can restore it afterwards (see patchdl_scan). */
if (saved_root) {
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
}
for (int i = 0; SCAN_DIRS[i]; i++) {
+4 -2
View File
@@ -23,9 +23,11 @@ typedef struct {
char compatible_version[16];
char latest_version[16];
char latest_required_fw[16];
char patch_url[512]; /* delta_url of the compatible patch */
char patch_title_id[16]; /* title id embedded in patch_url */
char patch_url[512]; /* manifest_url if present, otherwise pkg URL */
char patch_title_id[16]; /* target title id from version.xml */
char patch_storage_title_id[16]; /* title id embedded in delta_url */
int verxml_done;
int enabled; /* user policy, persisted in config.json */
} patchdl_title_t;
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once
#define PATCHDL_VERSION "0.0.1"
#define PATCHDL_VERSION "0.0.2"
+50 -6
View File
@@ -44,6 +44,11 @@ attr_val(const char *tag_start, const char *tag_end, const char *attr,
p += strlen(needle);
for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++)
;
/* The value must actually end on its closing quote inside the tag —
otherwise we hit tag_end or the buffer limit and would return a
silently truncated URL/title as if it were valid. */
if (p + len >= tag_end || p[len] != '"')
return -1;
memcpy(out, p, len);
out[len] = '\0';
return 0;
@@ -57,8 +62,8 @@ ver_gt(const char *a, const char *b) {
return strcmp(a, b) > 0;
}
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03098)
from a string such as a delta_url. Used to detect cross-title patches. */
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03099)
from a string such as nptitleid, manifest_url, or delta_url. */
static void
extract_title_id(const char *s, char *out, size_t sz) {
out[0] = '\0';
@@ -77,9 +82,31 @@ extract_title_id(const char *s, char *out, size_t sz) {
}
}
static void
parse_root_title_id(const char *xml, char *out, size_t sz) {
const char *p;
const char *tag_end;
char nptitleid[64] = {0};
out[0] = '\0';
if (!xml || sz < 10) return;
p = strstr(xml, "<title_patch");
if (!p) return;
tag_end = strchr(p, '>');
if (!tag_end) return;
tag_end++;
if (!attr_val(p, tag_end, "nptitleid", nptitleid, sizeof(nptitleid)))
extract_title_id(nptitleid, out, sz);
}
static void
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
const char *p = xml;
char root_title[16] = {0};
parse_root_title_id(xml, root_title, sizeof(root_title));
while ((p = strstr(p, "<package "))) {
const char *tag_end = strchr(p, '>');
@@ -89,12 +116,14 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
char ver[16] = {0};
char sver[16] = {0};
char durl[512] = {0};
char murl[512] = {0};
/* PS5 version.xml uses content_ver; PS4 uses version. */
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
attr_val(p, tag_end, "version", ver, sizeof(ver));
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
attr_val(p, tag_end, "manifest_url", murl, sizeof(murl));
if (ver[0] && sver[0]) {
uint32_t pkg_sver = parse_hex(sver);
@@ -106,16 +135,31 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
sizeof(out->latest_required_fw));
}
/* Track latest compatible + its patch URL */
/* Track latest compatible + its installable patch source. PS5
updates expose a small delta_url (DP.pkg) plus a manifest_url
containing the actual split package pieces. Feeding the DP
bootstrap directly can make the system download the full patch
under the storage/master title id, so prefer the target-title
manifest whenever present. */
if (pkg_sver <= fw_bin) {
if (!out->compatible_version[0] ||
ver_gt(ver, out->compatible_version)) {
strncpy(out->compatible_version, ver,
sizeof(out->compatible_version) - 1);
strncpy(out->compatible_url, durl,
strncpy(out->compatible_url, murl[0] ? murl : durl,
sizeof(out->compatible_url) - 1);
extract_title_id(durl, out->compatible_title,
sizeof(out->compatible_title));
extract_title_id(durl, out->compatible_storage_title,
sizeof(out->compatible_storage_title));
if (root_title[0]) {
strncpy(out->compatible_title, root_title,
sizeof(out->compatible_title) - 1);
} else {
extract_title_id(murl, out->compatible_title,
sizeof(out->compatible_title));
if (!out->compatible_title[0])
extract_title_id(durl, out->compatible_title,
sizeof(out->compatible_title));
}
}
}
}
+3 -2
View File
@@ -6,8 +6,9 @@ typedef struct {
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */
char latest_version[16]; /* highest pkg overall, or "" */
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
char compatible_url[512]; /* delta_url of the chosen compatible pkg */
char compatible_title[16]; /* title id embedded in that delta_url */
char compatible_url[512]; /* manifest_url if present, otherwise pkg URL */
char compatible_title[16]; /* target title id from version.xml/manifest_url */
char compatible_storage_title[16]; /* title id embedded in delta_url storage path */
} patchdl_verinfo_t;
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
+741 -41
View File
@@ -9,11 +9,14 @@
#include <microhttpd.h>
#include <pthread.h>
#include <dirent.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h>
static struct MHD_Daemon *web_daemon;
@@ -22,15 +25,40 @@ static patchdl_fw_t g_fw;
static patchdl_title_t *g_titles;
static size_t g_title_count;
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
static char g_status_json[512];
static char *g_debug_json; /* built once at startup */
static unsigned long g_pkg_hits;
static char g_pkg_diag_json[768] = "{\"hits\":0}";
static const char config_json[] =
"{"
"\"default_policy\":\"deny\","
"\"download_dir\":\"/mnt/usb0/patches\","
"\"install_after_download\":false,"
"\"delete_pkg_after_install\":false,"
/* Persisted, user-editable settings. Per-title enable/disable lives in the
title structs (t->enabled) so it travels with the scan; the global fields
live here. Both are saved to PATCHDL_CFG_PATH (homebrew data dir, never a
system file) and reloaded on the next start. Guarded by g_mutex. */
static struct {
char default_policy[8]; /* "deny" | "allow" */
int install_after_download;
int delete_pkg_after_install;
int verify_downloads; /* SHA-256 each manifest piece (default off) */
int home_shortcut; /* user wants a home-screen browser tile */
} g_cfg = { "deny", 0, 1, 0, 1 };
static struct {
int active;
int cancel; /* set by a cancel request; the worker aborts */
char title_id[32];
char name[128];
char version[16];
char path[320];
long long downloaded;
long long total;
} g_dl;
#define PATCHDL_DL_DIR "/data/patchdl"
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
/* The source policy and CDN allowlist are fixed (the safety model), so they
stay constant; only the four mutable fields above are user-controlled. */
static const char config_tail_json[] =
"\"download_dir\":\"/data/patchdl (internal)\","
"\"source_policy\":{"
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
@@ -42,8 +70,42 @@ static const char config_json[] =
"\"gst.prod.dl.playstation.net\","
"\"gs2.ww.prod.dl.playstation.net\""
"]}";
/* ---------- tiny JSON value lookups (flat objects only) ----------------- */
static const char downloads_json[] = "[]";
/* Find `"key"` then the following `true`/`false`; returns dflt if absent. */
static int
json_get_bool(const char *s, const char *key, int dflt) {
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(s, pat);
if (!p) return dflt;
p = strchr(p + strlen(pat), ':');
if (!p) return dflt;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (!strncmp(p, "true", 4)) return 1;
if (!strncmp(p, "false", 5)) return 0;
return dflt;
}
/* Find `"key":"value"` and copy value into out. */
static void
json_get_str(const char *s, const char *key, char *out, size_t sz) {
out[0] = '\0';
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(s, pat);
if (!p) return;
p = strchr(p + strlen(pat), ':');
if (!p) return;
p++;
while (*p == ' ' || *p == '\t') p++;
if (*p != '"') return;
p++;
size_t i = 0;
while (*p && *p != '"' && i + 1 < sz) out[i++] = *p++;
out[i] = '\0';
}
/* ---------- JSON builder ------------------------------------------------ */
@@ -156,18 +218,309 @@ queue_asset(struct MHD_Connection *conn, const char *url) {
asset->data, asset->size, MHD_RESPMEM_PERSISTENT);
}
/* ---------- status JSON ------------------------------------------------- */
static int
path_segment_safe(const char *s) {
if (!s || !s[0] || strstr(s, "..")) return 0;
for (const char *p = s; *p; p++) {
int ok = (*p >= 'A' && *p <= 'Z') ||
(*p >= 'a' && *p <= 'z') ||
(*p >= '0' && *p <= '9') ||
*p == '_' || *p == '-' || *p == '.';
if (!ok) return 0;
}
return 1;
}
typedef struct {
int fd;
uint64_t start;
uint64_t size;
} pkg_reader_t;
static ssize_t
pkg_reader_cb(void *cls, uint64_t pos, char *buf, size_t max) {
pkg_reader_t *r = (pkg_reader_t *)cls;
uint64_t rem;
ssize_t n;
if (!r || pos >= r->size)
return MHD_CONTENT_READER_END_WITH_ERROR;
rem = r->size - pos;
if ((uint64_t)max > rem)
max = (size_t)rem;
n = pread(r->fd, buf, max, (off_t)(r->start + pos));
if (n <= 0)
return MHD_CONTENT_READER_END_WITH_ERROR;
return n;
}
static void
rebuild_status_json(void) {
snprintf(g_status_json, sizeof(g_status_json),
pkg_reader_free(void *cls) {
pkg_reader_t *r = (pkg_reader_t *)cls;
if (!r) return;
close(r->fd);
free(r);
}
static void
record_pkg_diag(const char *url, const char *range, unsigned int status,
uint64_t start, uint64_t end, uint64_t total) {
pthread_mutex_lock(&g_mutex);
g_pkg_hits++;
snprintf(g_pkg_diag_json, sizeof(g_pkg_diag_json),
"{\"hits\":%lu,\"url\":\"%.220s\",\"range\":\"%.160s\","
"\"status\":%u,\"start\":%llu,\"end\":%llu,\"total\":%llu}",
g_pkg_hits, url ? url : "", range ? range : "", status,
(unsigned long long)start,
(unsigned long long)end,
(unsigned long long)total);
pthread_mutex_unlock(&g_mutex);
}
/* Serve a downloaded package back to Sony's installer over localhost. This is
only for files PatchDL already placed under /data/patchdl/<title>/<pkg>. */
static enum MHD_Result
queue_pkg_file(struct MHD_Connection *conn, const char *url) {
const char *prefix = "/api/pkg/";
const char *p, *slash;
char title_id[32], file[160], path[360];
size_t len;
struct stat st;
int fd;
const char *range;
uint64_t total, start = 0, end = 0, send_size = 0;
unsigned int status = MHD_HTTP_OK;
char content_range[96];
struct MHD_Response *resp;
pkg_reader_t *reader;
enum MHD_Result ret;
if (strncmp(url, prefix, strlen(prefix)))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
p = url + strlen(prefix);
slash = strchr(p, '/');
if (!slash || slash == p || !slash[1])
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
len = (size_t)(slash - p);
if (len >= sizeof(title_id))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
memcpy(title_id, p, len);
title_id[len] = '\0';
len = strlen(slash + 1);
if (len >= sizeof(file))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
memcpy(file, slash + 1, len + 1);
if (!path_segment_safe(title_id) || !path_segment_safe(file))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
snprintf(path, sizeof(path), "%s/%s/%s", PATCHDL_DL_DIR, title_id, file);
fd = open(path, O_RDONLY);
if (fd < 0)
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
if (fstat(fd, &st) || st.st_size <= 0) {
close(fd);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
total = (uint64_t)st.st_size;
end = total - 1;
send_size = total;
range = MHD_lookup_connection_value(conn, MHD_HEADER_KIND,
MHD_HTTP_HEADER_RANGE);
if (range && !strncmp(range, "bytes=", 6)) {
const char *spec = range + 6;
char *dash = strchr(spec, '-');
if (!dash || strchr(dash + 1, ',')) {
close(fd);
return queue_text(conn, MHD_HTTP_RANGE_NOT_SATISFIABLE, "invalid range");
}
if (dash == spec) {
uint64_t suffix = strtoull(dash + 1, NULL, 10);
if (!suffix) {
close(fd);
return queue_text(conn, MHD_HTTP_RANGE_NOT_SATISFIABLE, "invalid range");
}
start = suffix >= total ? 0 : total - suffix;
} else {
start = strtoull(spec, NULL, 10);
if (dash[1])
end = strtoull(dash + 1, NULL, 10);
}
if (start >= total || end < start) {
close(fd);
return queue_text(conn, MHD_HTTP_RANGE_NOT_SATISFIABLE, "range not satisfiable");
}
if (end >= total) end = total - 1;
send_size = end - start + 1;
status = 206;
}
record_pkg_diag(url, range, status, start, end, total);
reader = calloc(1, sizeof(*reader));
if (!reader) {
close(fd);
return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
}
reader->fd = fd;
reader->start = start;
reader->size = send_size;
resp = MHD_create_response_from_callback(send_size, 65536,
pkg_reader_cb, reader,
pkg_reader_free);
if (!resp) {
close(fd);
free(reader);
return MHD_NO;
}
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_CONTENT_TYPE, "application/octet-stream");
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCEPT_RANGES, "bytes");
if (status == 206) {
snprintf(content_range, sizeof(content_range), "bytes %llu-%llu/%llu",
(unsigned long long)start,
(unsigned long long)end,
(unsigned long long)total);
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_RANGE, content_range);
}
ret = MHD_queue_response(conn, status, resp);
MHD_destroy_response(resp); /* pkg_reader_free closes fd */
return ret;
}
/* ---------- status JSON ------------------------------------------------- */
/* Free space on the download partition, in MB. Best-effort: 0 if unavailable. */
static long long
data_free_mb(void) {
struct statvfs vfs;
if (statvfs(PATCHDL_DL_DIR, &vfs) == 0 || statvfs("/data", &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
return 0;
}
/* Built fresh per request so free space stays live (a 60+ GB download moves
it a lot). g_fw is read-only after startup, so this is thread-safe. */
static char *
build_status_json(void) {
char *out = malloc(512);
if (!out) return NULL;
snprintf(out, 512,
"{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\","
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":0,"
"\"download_dir\":\"/mnt/usb0/patches\"}",
g_fw.str, g_fw.bin);
"\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\"}",
g_fw.str, g_fw.bin, data_free_mb());
return out;
}
/* ---------- config persistence (/data/patchdl/config.json) -------------- */
/* Serialize the current global settings; the fixed source policy + allowlist
are appended from config_tail_json. Caller owns the result (queue_json_owned). */
static char *
build_config_json(void) {
char head[192];
pthread_mutex_lock(&g_mutex);
snprintf(head, sizeof(head),
"{\"default_policy\":\"%s\","
"\"install_after_download\":%s,"
"\"delete_pkg_after_install\":%s,"
"\"verify_downloads\":%s,"
"\"home_shortcut\":%s,",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false");
pthread_mutex_unlock(&g_mutex);
char *out = malloc(strlen(head) + sizeof(config_tail_json));
if (!out) return NULL;
strcpy(out, head);
strcat(out, config_tail_json);
return out;
}
/* Write global settings + per-title enabled flags. Must NOT be called while
holding g_mutex (it takes the lock itself). */
static void
save_config(void) {
FILE *f;
mkdir(PATCHDL_DL_DIR, 0777);
f = fopen(PATCHDL_CFG_PATH, "w");
if (!f) return;
pthread_mutex_lock(&g_mutex);
fprintf(f,
"{\n\"default_policy\":\"%s\",\n"
"\"install_after_download\":%s,\n"
"\"delete_pkg_after_install\":%s,\n"
"\"verify_downloads\":%s,\n"
"\"home_shortcut\":%s,\n\"titles\":{",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false");
for (size_t i = 0; i < g_title_count; i++)
fprintf(f, "%s\"%s\":%s", i ? "," : "",
g_titles[i].title_id, g_titles[i].enabled ? "true" : "false");
fprintf(f, "}\n}\n");
pthread_mutex_unlock(&g_mutex);
fclose(f);
}
/* Load persisted settings over the defaults. Called once at startup, after the
scan, while still single-threaded. */
static void
load_config(void) {
FILE *f;
char buf[16384];
size_t n;
char pol[8];
f = fopen(PATCHDL_CFG_PATH, "r");
if (!f) return;
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
json_get_str(buf, "default_policy", pol, sizeof(pol));
if (pol[0]) {
strncpy(g_cfg.default_policy, pol, sizeof(g_cfg.default_policy) - 1);
g_cfg.default_policy[sizeof(g_cfg.default_policy) - 1] = '\0';
}
g_cfg.install_after_download =
json_get_bool(buf, "install_after_download", g_cfg.install_after_download);
g_cfg.delete_pkg_after_install =
json_get_bool(buf, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
g_cfg.verify_downloads =
json_get_bool(buf, "verify_downloads", g_cfg.verify_downloads);
g_cfg.home_shortcut =
json_get_bool(buf, "home_shortcut", g_cfg.home_shortcut);
/* per-title overrides live under "titles": { "<id>": true|false, ... } */
const char *titles = strstr(buf, "\"titles\"");
if (titles)
for (size_t i = 0; i < g_title_count; i++)
g_titles[i].enabled =
json_get_bool(titles, g_titles[i].title_id, g_titles[i].enabled);
}
/* ---------- titles JSON (built per request under mutex) ----------------- */
@@ -227,13 +580,15 @@ build_titles_json(void) {
jbuf_append_ver_or_null(&j, t->version_file_uri);
jbuf_append(&j, ",\"patch_title_id\":");
jbuf_append_ver_or_null(&j, t->patch_title_id);
/* The patch package's title must match the game; a mismatch means a
cross-region/title package that must NOT be installed. */
jbuf_append(&j, ",\"patch_storage_title_id\":");
jbuf_append_ver_or_null(&j, t->patch_storage_title_id);
/* This is the target title id parsed from version.xml/manifest_url.
CDN storage paths may use another regional/master title id; that is
not exposed here and must not block a valid target match. */
jbuf_appendf(&j, ",\"patch_title_match\":%s",
(!t->patch_title_id[0] ||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
jbuf_appendf(&j, ",\"enabled\":%s",
t->source_type == PATCHDL_SOURCE_UNKNOWN ? "false" : "true");
jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false");
jbuf_append(&j, ",\"mode\":");
jbuf_append_str(&j, title_mode_str(t->source_type));
jbuf_append(&j, ",\"queued\":false");
@@ -247,8 +602,64 @@ build_titles_json(void) {
return j.buf; /* caller owns; use queue_json_owned */
}
static char *
build_downloads_json(void) {
jbuf_t j = {0};
long long downloaded, total;
int progress = 0;
char detail[128];
pthread_mutex_lock(&g_mutex);
jbuf_append(&j, "[");
if (g_dl.active) {
downloaded = g_dl.downloaded;
total = g_dl.total;
if (total > 0 && downloaded >= 0)
progress = (int)((downloaded * 100) / total);
if (progress < 0) progress = 0;
if (progress > 100) progress = 100;
if (total > 0)
snprintf(detail, sizeof(detail), "%.1f GB of %.1f GB",
downloaded / 1073741824.0, total / 1073741824.0);
else
snprintf(detail, sizeof(detail), "%.1f MB downloaded",
downloaded / 1048576.0);
jbuf_append(&j, "{");
jbuf_append(&j, "\"title_id\":"); jbuf_append_str(&j, g_dl.title_id);
jbuf_append(&j, ",\"name\":"); jbuf_append_str(&j, g_dl.name);
jbuf_append(&j, ",\"version\":"); jbuf_append_str(&j, g_dl.version);
jbuf_appendf(&j, ",\"progress\":%d", progress);
jbuf_append(&j, ",\"detail\":"); jbuf_append_str(&j, detail);
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld",
downloaded, total);
jbuf_append(&j, ",\"path\":"); jbuf_append_str(&j, g_dl.path);
jbuf_append(&j, "}");
}
jbuf_append(&j, "]");
pthread_mutex_unlock(&g_mutex);
return j.buf;
}
/* ---------- verxml background fetch ------------------------------------ */
/* Remove a downloaded patch once the game is at/past that version, i.e. the
install completed. Patches stay internal (/data/patchdl) and are cleaned up
here at the next scan — not during the async install, which still reads the
file. */
static void
cleanup_installed_download(const char *title_id, const char *patch_url) {
char dir[256], path[320];
const char *base = strrchr(patch_url, '/');
if (!path_segment_safe(title_id))
return;
base = base ? base + 1 : "patch.pkg";
snprintf(dir, sizeof(dir), "/data/patchdl/%s", title_id);
snprintf(path, sizeof(path), "%s/%s", dir, base);
unlink(path);
rmdir(dir);
}
static void *
verxml_fetch_thread(void *arg) {
(void)arg;
@@ -278,8 +689,15 @@ verxml_fetch_thread(void *arg) {
sizeof(t->patch_url) - 1);
strncpy(t->patch_title_id, info.compatible_title,
sizeof(t->patch_title_id) - 1);
strncpy(t->patch_storage_title_id, info.compatible_storage_title,
sizeof(t->patch_storage_title_id) - 1);
t->verxml_done = 1;
int up_to_date = (t->installed_version[0] && info.compatible_version[0] &&
strcmp(t->installed_version, info.compatible_version) >= 0);
pthread_mutex_unlock(&g_mutex);
if (up_to_date && info.compatible_url[0])
cleanup_installed_download(t->title_id, info.compatible_url);
}
return NULL;
}
@@ -290,7 +708,12 @@ verxml_fetch_thread(void *arg) {
static int
get_title_action_info(const char *title_id, patchdl_source_t *src,
char *patch_url, size_t url_sz,
char *patch_title_id, size_t pt_sz) {
char *patch_title_id, size_t pt_sz,
char *patch_storage_title_id, size_t pst_sz,
char *content_id, size_t ci_sz,
char *name, size_t name_sz,
char *version, size_t ver_sz,
int *enabled) {
int found = 0;
pthread_mutex_lock(&g_mutex);
@@ -301,6 +724,15 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
patch_url[url_sz - 1] = '\0';
strncpy(patch_title_id, g_titles[i].patch_title_id, pt_sz - 1);
patch_title_id[pt_sz - 1] = '\0';
strncpy(patch_storage_title_id, g_titles[i].patch_storage_title_id, pst_sz - 1);
patch_storage_title_id[pst_sz - 1] = '\0';
strncpy(content_id, g_titles[i].content_id, ci_sz - 1);
content_id[ci_sz - 1] = '\0';
strncpy(name, g_titles[i].name, name_sz - 1);
name[name_sz - 1] = '\0';
strncpy(version, g_titles[i].compatible_version, ver_sz - 1);
version[ver_sz - 1] = '\0';
*enabled = g_titles[i].enabled;
found = 1;
break;
}
@@ -309,22 +741,127 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
return found;
}
#define PATCHDL_DL_DIR "/data/patchdl"
/* Persist a per-title enable/disable toggle. */
static enum MHD_Result
set_title_enabled(struct MHD_Connection *conn, const char *title_id, int en) {
int found = 0;
char r[64];
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (!strcmp(g_titles[i].title_id, title_id)) {
g_titles[i].enabled = en;
found = 1;
break;
}
}
pthread_mutex_unlock(&g_mutex);
if (!found) return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
save_config();
snprintf(r, sizeof(r), "{\"ok\":true,\"enabled\":%s}", en ? "true" : "false");
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
}
/* Local on-disk path a title's patch downloads to / installs from. */
static void
title_pkg_path(const char *title_id, const char *patch_url,
char *out, size_t out_sz) {
const char *base = strrchr(patch_url, '/');
char name[192];
size_t n;
base = base ? base + 1 : "patch.pkg";
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, base);
snprintf(name, sizeof(name), "%s", base);
n = strlen(name);
if (n > 5 && !strcmp(name + n - 5, ".json"))
snprintf(name + n - 5, sizeof(name) - (n - 5), ".pkg");
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, name);
}
static int
url_is_manifest(const char *url) {
size_t n = url ? strlen(url) : 0;
return n > 5 && !strcmp(url + n - 5, ".json");
}
/* Returns non-zero to abort the download when a cancel has been requested. */
static int
download_progress_cb(void *ctx, long long downloaded, long long total) {
int cancel;
(void)ctx;
pthread_mutex_lock(&g_mutex);
if (g_dl.active) {
g_dl.downloaded = downloaded;
g_dl.total = total;
}
cancel = g_dl.cancel;
pthread_mutex_unlock(&g_mutex);
return cancel;
}
/* Delete a title's internal download directory and its contents (a partial or
a finished package). Best-effort; only ever touches /data/patchdl. */
static void
remove_title_dir(const char *title_id) {
char dir[288], path[560];
DIR *d;
struct dirent *e;
/* Self-protecting: never build a delete path from an unsafe segment, even
if a future caller forgets the upstream check. A title_id of ".." would
otherwise resolve dir to /data and wipe it. */
if (!path_segment_safe(title_id))
return;
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
if ((d = opendir(dir))) {
while ((e = readdir(d))) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
continue;
snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
unlink(path);
}
closedir(d);
}
rmdir(dir);
}
/* Cancel an in-progress download for this title (the worker aborts and removes
the partial), or delete an already-downloaded package if nothing is running. */
static enum MHD_Result
do_cancel(struct MHD_Connection *conn, const char *title_id) {
char resp[96];
int was_active = 0;
pthread_mutex_lock(&g_mutex);
if (g_dl.active && !strcmp(g_dl.title_id, title_id)) {
g_dl.cancel = 1;
was_active = 1;
}
pthread_mutex_unlock(&g_mutex);
if (!was_active)
remove_title_dir(title_id);
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"cancelled\":%s,\"deleted\":true}",
was_active ? "true" : "false");
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
static enum MHD_Result
do_download(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url) {
patchdl_source_t src, const char *patch_url,
const char *name, const char *version, int enabled) {
char dir[256], dest[320], resp[640];
long long bytes = 0;
int verify = 0, dlrc;
if (!enabled)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"title_disabled\"}");
if (src == PATCHDL_SOURCE_UNKNOWN)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
@@ -340,12 +877,55 @@ do_download(struct MHD_Connection *conn, const char *title_id,
mkdir(dir, 0777);
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
if (patchdl_http_download(patch_url, dest, &bytes)) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"download_failed\"}");
pthread_mutex_lock(&g_mutex);
if (g_dl.active) {
pthread_mutex_unlock(&g_mutex);
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"download_in_progress\"}");
}
memset(&g_dl, 0, sizeof(g_dl));
g_dl.active = 1;
strncpy(g_dl.title_id, title_id, sizeof(g_dl.title_id) - 1);
strncpy(g_dl.name, name && name[0] ? name : title_id, sizeof(g_dl.name) - 1);
strncpy(g_dl.version, version ? version : "", sizeof(g_dl.version) - 1);
strncpy(g_dl.path, dest, sizeof(g_dl.path) - 1);
verify = g_cfg.verify_downloads;
pthread_mutex_unlock(&g_mutex);
dlrc = url_is_manifest(patch_url)
? patchdl_http_download_manifest_progress(patch_url, dest, &bytes,
download_progress_cb, NULL, verify)
: patchdl_http_download_progress(patch_url, dest, &bytes,
download_progress_cb, NULL);
if (dlrc) {
int was_cancel;
pthread_mutex_lock(&g_mutex);
was_cancel = g_dl.cancel;
g_dl.active = 0;
g_dl.cancel = 0;
pthread_mutex_unlock(&g_mutex);
/* The download function already unlinked the partial file on failure;
drop the now-empty title dir too. */
unlink(dest);
rmdir(dir);
if (was_cancel)
return queue_json(conn, MHD_HTTP_OK,
"{\"ok\":false,\"cancelled\":true,"
"\"reason\":\"download_cancelled\"}");
/* -2 = a piece failed its SHA-256 (only possible when verify is on). */
snprintf(resp, sizeof(resp), "{\"ok\":false,\"reason\":\"%s\"}",
dlrc == -2 ? "piece_verify_failed" : "download_failed");
return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp));
}
pthread_mutex_lock(&g_mutex);
g_dl.downloaded = bytes;
g_dl.total = bytes;
g_dl.active = 0;
pthread_mutex_unlock(&g_mutex);
/* shadowmount: download allowed, install is not (per source policy). */
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"downloaded\":true,\"bytes\":%lld,\"path\":\"%s\","
@@ -358,10 +938,15 @@ do_download(struct MHD_Connection *conn, const char *title_id,
static enum MHD_Result
do_install(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url,
const char *patch_title_id) {
const char *patch_title_id, const char *patch_storage_title_id,
const char *content_id, int enabled) {
char dest[320], msg[256], resp[640];
int rc;
if (!enabled)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"title_disabled\"}");
/* Source policy: only genuine installs may be patched in place.
Shadowmounts are download-only; unknown/preinstall are blocked. */
if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL)
@@ -372,8 +957,9 @@ do_install(struct MHD_Connection *conn, const char *title_id,
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
/* GUARD (app layer): the patch package's title id must match the game.
A cross-title/region patch would install as a phantom title. */
/* GUARD (app layer): the version.xml target title id must match the game.
CDN storage under a different regional/master id is valid and has
already been normalized by patchdl_verxml. */
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"patch_title_mismatch\","
@@ -384,9 +970,8 @@ do_install(struct MHD_Connection *conn, const char *title_id,
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
/* GUARD (authoritative): patchdl_install reads the PKG's real title id and
refuses if it does not match `title_id`. */
rc = patchdl_install_local_pkg(dest, title_id, msg, sizeof(msg));
rc = patchdl_install_local_pkg(dest, title_id, patch_storage_title_id,
content_id, msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}",
rc == 0 ? "true" : "false", rc, msg, dest);
@@ -425,42 +1010,139 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
char action[24];
char patch_url[512] = {0};
char patch_title_id[16] = {0};
char patch_storage_title_id[16] = {0};
char content_id[64] = {0};
char name[128] = {0};
char version[16] = {0};
int enabled = 0;
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
if (parse_title_action(url, title_id, sizeof(title_id),
action, sizeof(action)))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
/* Defense in depth: title_id becomes part of /data/patchdl/<id> paths that
get created, written, and recursively deleted. Reject anything that
isn't a plain id BEFORE it can reach mkdir/unlink/rmdir, so no request
can ever escape the download directory (a title_id of ".." would resolve
the dir to /data). Real PS5 title ids only use [A-Za-z0-9_-.]. */
if (!path_segment_safe(title_id))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
if (!get_title_action_info(title_id, &src, patch_url, sizeof(patch_url),
patch_title_id, sizeof(patch_title_id)))
patch_title_id, sizeof(patch_title_id),
patch_storage_title_id,
sizeof(patch_storage_title_id),
content_id, sizeof(content_id),
name, sizeof(name),
version, sizeof(version),
&enabled))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
if (!strcmp(action, "enable"))
return set_title_enabled(conn, title_id, 1);
if (!strcmp(action, "disable"))
return set_title_enabled(conn, title_id, 0);
if (!strcmp(action, "download"))
return do_download(conn, title_id, src, patch_url);
return do_download(conn, title_id, src, patch_url, name, version, enabled);
if (!strcmp(action, "cancel"))
return do_cancel(conn, title_id);
if (!strcmp(action, "check"))
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
if (!strcmp(action, "install"))
return do_install(conn, title_id, src, patch_url, patch_title_id);
return do_install(conn, title_id, src, patch_url, patch_title_id,
patch_storage_title_id, content_id, enabled);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
/* ---------- POST body accumulation ------------------------------------- */
/* MHD delivers a POST body across several callback invocations. We stash a
growing buffer in con_cls and dispatch once the body is complete. */
typedef struct {
char *data;
size_t len;
} post_body_t;
static void
request_completed(void *cls, struct MHD_Connection *conn, void **con_cls,
enum MHD_RequestTerminationCode toe) {
(void)cls; (void)conn; (void)toe;
post_body_t *pb = *con_cls;
if (pb) {
free(pb->data);
free(pb);
*con_cls = NULL;
}
}
static enum MHD_Result
handle_config_post(struct MHD_Connection *conn, const char *body) {
char pol[8];
json_get_str(body, "default_policy", pol, sizeof(pol));
pthread_mutex_lock(&g_mutex);
if (pol[0]) {
strncpy(g_cfg.default_policy, pol, sizeof(g_cfg.default_policy) - 1);
g_cfg.default_policy[sizeof(g_cfg.default_policy) - 1] = '\0';
}
g_cfg.install_after_download =
json_get_bool(body, "install_after_download", g_cfg.install_after_download);
g_cfg.delete_pkg_after_install =
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);
pthread_mutex_unlock(&g_mutex);
save_config();
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
}
static enum MHD_Result
on_request(void *cls, struct MHD_Connection *conn, const char *url,
const char *method, const char *version, const char *upload_data,
size_t *upload_data_size, void **con_cls) {
(void)cls; (void)version; (void)upload_data; (void)con_cls;
(void)cls; (void)version;
if (!strcmp(method, MHD_HTTP_METHOD_OPTIONS))
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
if (!strcmp(method, MHD_HTTP_METHOD_POST)) {
if (*upload_data_size) { *upload_data_size = 0; return MHD_YES; }
post_body_t *pb = *con_cls;
if (!pb) { /* first call: set up the buffer */
pb = calloc(1, sizeof(*pb));
if (!pb) return MHD_NO;
*con_cls = pb;
return MHD_YES;
}
if (*upload_data_size) { /* a body chunk: append it */
char *n = realloc(pb->data, pb->len + *upload_data_size + 1);
if (n) {
memcpy(n + pb->len, upload_data, *upload_data_size);
pb->len += *upload_data_size;
n[pb->len] = '\0';
pb->data = n;
}
*upload_data_size = 0;
return MHD_YES;
}
/* final call: the full body (if any) is in pb->data */
const char *body = pb->data ? pb->data : "";
if (!strcmp(url, "/api/config"))
return queue_json(conn, MHD_HTTP_OK, config_json);
return handle_config_post(conn, body);
if (!strncmp(url, "/api/titles/", 12))
return handle_title_action(conn, url);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
@@ -471,16 +1153,16 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
if (!strcmp(url, "/api/status"))
return queue_json(conn, MHD_HTTP_OK, g_status_json);
return queue_json_owned(conn, MHD_HTTP_OK, build_status_json());
if (!strcmp(url, "/api/config"))
return queue_json(conn, MHD_HTTP_OK, config_json);
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
if (!strcmp(url, "/api/titles"))
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
if (!strcmp(url, "/api/downloads"))
return queue_json(conn, MHD_HTTP_OK, downloads_json);
return queue_json_owned(conn, MHD_HTTP_OK, build_downloads_json());
if (!strcmp(url, "/api/debug"))
return queue_json(conn, MHD_HTTP_OK,
@@ -494,6 +1176,15 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
}
if (!strcmp(url, "/api/apiprobe")) {
char p[2048];
patchdl_install_api_probe(p, sizeof(p));
return queue_json_owned(conn, MHD_HTTP_OK, strdup(p));
}
if (!strcmp(url, "/api/pkgdiag"))
return queue_json(conn, MHD_HTTP_OK, g_pkg_diag_json);
if (!strcmp(url, "/api/netcheck")) {
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
pthread_mutex_lock(&g_mutex);
@@ -507,6 +1198,9 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json(conn, MHD_HTTP_OK, diag);
}
if (!strncmp(url, "/api/pkg/", 9))
return queue_pkg_file(conn, url);
return queue_asset(conn, url);
}
@@ -522,11 +1216,16 @@ patchdl_websrv_start(unsigned short port) {
/* Collect real FW version and installed titles synchronously.
The web UI is reachable only after this completes. */
patchdl_fw_get(&g_fw);
rebuild_status_json();
if (patchdl_scan(&g_titles, &g_title_count))
g_title_count = 0;
/* Default per-title policy (unknown sources off), then overlay anything the
user previously saved to /data/patchdl/config.json. */
for (size_t i = 0; i < g_title_count; i++)
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
load_config();
/* Build the diagnostic dump now, while single-threaded — the root-vnode
swap it performs is unsafe once MHD worker threads are running. */
g_debug_json = patchdl_scan_debug_json();
@@ -534,6 +1233,7 @@ patchdl_websrv_start(unsigned short port) {
web_daemon = MHD_start_daemon(
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
port, NULL, NULL, &on_request, NULL,
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
MHD_OPTION_END);
if (!web_daemon) {
+10 -2
View File
@@ -18,6 +18,9 @@ GET /api/titles
GET /api/downloads
POST /api/titles/:title_id/check
POST /api/titles/:title_id/download
POST /api/titles/:title_id/install
POST /api/titles/:title_id/enable
POST /api/titles/:title_id/disable
```
## Policy Model
@@ -27,9 +30,9 @@ The UI assumes deny-by-default behavior:
```json
{
"default_policy": "deny",
"download_dir": "/mnt/usb0/patches",
"download_dir": "/data/patchdl (internal)",
"install_after_download": false,
"delete_pkg_after_install": false,
"delete_pkg_after_install": true,
"source_policy": {
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
@@ -80,3 +83,8 @@ unknown
The frontend treats `shadowmount` as download-only and `unknown` as blocked for
downloads and installs. Backend code should enforce the same policy even if a
client sends a forged request.
For PS5 game updates, the backend may turn a Sony `manifest_url` into a merged
local `.pkg` by downloading all manifest pieces. The `delta_url` `*-DP.pkg` is
not shown as a separate user action because it can bootstrap the storage/master
title instead of the installed regional target.
+439 -375
View File
File diff suppressed because it is too large. Load diff
+132 -124
View File
@@ -2,48 +2,34 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>PatchDL</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<svg class="icon-sprite" aria-hidden="true">
<symbol id="icon-shield" viewBox="0 0 24 24">
<path d="M12 3l7 3v5c0 5-3 8-7 10-4-2-7-5-7-10V6l7-3z" />
<path d="M9 12l2 2 4-5" />
</symbol>
<symbol id="icon-download" viewBox="0 0 24 24">
<path d="M12 3v11" />
<path d="M7 10l5 5 5-5" />
<path d="M5 20h14" />
<path d="M12 3v11" /><path d="M7 10l5 5 5-5" /><path d="M5 20h14" />
</symbol>
<symbol id="icon-refresh" viewBox="0 0 24 24">
<path d="M20 6v6h-6" />
<path d="M4 18v-6h6" />
<path d="M19 12A7 7 0 0 0 7 7" />
<path d="M5 12a7 7 0 0 0 12 5" />
<path d="M20 6v6h-6" /><path d="M4 18v-6h6" />
<path d="M19 12A7 7 0 0 0 7 7" /><path d="M5 12a7 7 0 0 0 12 5" />
</symbol>
<symbol id="icon-settings" viewBox="0 0 24 24">
<path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M4 12h2M18 12h2M12 4v2M12 18v2M6.5 6.5 8 8M16 16l1.5 1.5M17.5 6.5 16 8M8 16l-1.5 1.5" />
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<path d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" />
<path d="M16 16l5 5" />
<path d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" /><path d="M16 16l5 5" />
</symbol>
<symbol id="icon-save" viewBox="0 0 24 24">
<path d="M5 4h12l2 2v14H5V4z" />
<path d="M8 4v6h8V4" />
<path d="M8 20v-6h8v6" />
</symbol>
<symbol id="icon-pause" viewBox="0 0 24 24">
<path d="M8 5v14" />
<path d="M16 5v14" />
<path d="M5 4h12l2 2v14H5V4z" /><path d="M8 4v6h8V4" /><path d="M8 20v-6h8v6" />
</symbol>
<symbol id="icon-log" viewBox="0 0 24 24">
<path d="M7 4h10l3 3v13H7V4z" />
<path d="M17 4v4h4" />
<path d="M10 12h7M10 16h5" />
<path d="M7 4h10l3 3v13H7V4z" /><path d="M17 4v4h4" /><path d="M10 12h7M10 16h5" />
</symbol>
<symbol id="icon-grid" viewBox="0 0 24 24">
<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" />
</symbol>
</svg>
@@ -53,111 +39,99 @@
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>by Knutwurst · v0.0.1</span>
<span>by Knutwurst · v0.0.2</span>
</div>
</div>
<nav class="nav-list">
<a class="nav-link is-active" href="#games">
<svg><use href="#icon-download"></use></svg>
Games
</a>
<a class="nav-link" href="#downloads">
<svg><use href="#icon-refresh"></use></svg>
Queue
</a>
<a class="nav-link" href="#settings">
<svg><use href="#icon-settings"></use></svg>
Settings
</a>
<a class="nav-link" href="#logs">
<svg><use href="#icon-log"></use></svg>
Logs
</a>
<nav class="nav-list" aria-label="Sections">
<button class="nav-link is-active" data-view="games" aria-label="Games" aria-current="page">
<svg><use href="#icon-grid"></use></svg><span>Games</span>
</button>
<button class="nav-link" data-view="settings" aria-label="Settings">
<svg><use href="#icon-settings"></use></svg><span>Settings</span>
</button>
<button class="nav-link" data-view="logs" aria-label="Logs">
<svg><use href="#icon-log"></use></svg><span>Logs</span>
</button>
</nav>
<div class="rail-foot">
<span id="railFw">FW --</span>
<span id="railSpace">-- free</span>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow">Standalone ELF Web UI</p>
<h1>Controlled game patch downloads</h1>
</div>
<div class="topbar-actions">
<button class="icon-button" id="refreshBtn" title="Refresh status and games">
<svg><use href="#icon-refresh"></use></svg>
</button>
<button class="primary-button" id="saveBtn">
<svg><use href="#icon-save"></use></svg>
Save
</button>
</div>
</header>
<section class="status-strip" aria-label="System status">
<article class="metric">
<span>Firmware</span>
<strong id="firmwareValue">--</strong>
<em id="firmwareBuild">System version</em>
</article>
<article class="metric">
<span>DNS Guard</span>
<strong id="dnsValue">--</strong>
<em>Sony blocked by nanoDNS</em>
</article>
<article class="metric">
<span>CDN Access</span>
<strong id="resolverValue">--</strong>
<em>PatchDL resolver only</em>
</article>
<article class="metric">
<span>Storage</span>
<strong id="spaceValue">--</strong>
<em id="downloadDirValue">Download target</em>
</article>
</section>
<section class="toolbar" id="games">
<div class="search-box">
<svg><use href="#icon-search"></use></svg>
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
</div>
<div class="segmented" role="tablist" aria-label="Filter">
<button class="is-selected" data-filter="all">All</button>
<button data-filter="updatable">Updatable</button>
<button data-filter="uptodate">Up to date</button>
<button data-filter="needsfw">Needs FW</button>
<button data-filter="blocked">Can't update</button>
<button data-filter="queued">Queue</button>
</div>
</section>
<section class="game-grid" id="gameGrid" aria-live="polite"></section>
<section class="split-band">
<div class="panel" id="downloads">
<div class="panel-heading">
<div>
<p class="eyebrow">Downloads</p>
<h2>Active Queue</h2>
</div>
<button class="ghost-button" id="pauseAllBtn">
<svg><use href="#icon-pause"></use></svg>
Pause
<!-- ============ GAMES ============ -->
<section class="view is-active" data-view="games">
<header class="topbar">
<div>
<p class="eyebrow">Standalone ELF Web UI</p>
<h1>Games</h1>
</div>
<div class="topbar-actions">
<button class="icon-button" id="refreshBtn" title="Refresh status and games" aria-label="Refresh">
<svg><use href="#icon-refresh"></use></svg>
</button>
</div>
<div class="queue-list" id="queueList"></div>
</header>
<section class="status-strip" aria-label="System status">
<article class="metric">
<span>Firmware</span>
<strong id="firmwareValue">--</strong>
<em id="firmwareBuild">System version</em>
</article>
<article class="metric">
<span>DNS Guard</span>
<strong id="dnsValue">--</strong>
<em>Sony blocked by nanoDNS</em>
</article>
<article class="metric">
<span>CDN Access</span>
<strong id="resolverValue">--</strong>
<em>PatchDL resolver only</em>
</article>
<article class="metric">
<span>Storage</span>
<strong id="spaceValue">--</strong>
<em id="downloadDirValue">Download target</em>
</article>
</section>
<div class="toolbar">
<div class="search-box">
<svg><use href="#icon-search"></use></svg>
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
</div>
<div class="segmented" role="group" aria-label="Filter">
<button class="is-selected" data-filter="all" aria-pressed="true">All</button>
<button data-filter="updatable" aria-pressed="false">Updatable</button>
<button data-filter="uptodate" aria-pressed="false">Up to date</button>
<button data-filter="needsfw" aria-pressed="false">Needs FW</button>
<button data-filter="blocked" aria-pressed="false">Can't update</button>
</div>
</div>
<div class="panel" id="settings">
<div class="panel-heading">
<div>
<p class="eyebrow">Policy</p>
<h2>Update Rules</h2>
</div>
</div>
<section class="game-grid" id="gameGrid"></section>
</section>
<!-- ============ SETTINGS ============ -->
<section class="view" data-view="settings">
<header class="topbar">
<div>
<p class="eyebrow">Policy</p>
<h1>Settings</h1>
</div>
<div class="topbar-actions">
<button class="primary-button" id="saveBtn">
<svg><use href="#icon-save"></use></svg>
Save
</button>
</div>
</header>
<div class="panel">
<div class="settings-grid">
<label class="field">
<span>Default Policy</span>
@@ -168,21 +142,49 @@
</label>
<label class="field">
<span>Download Folder</span>
<input id="downloadDir" type="text" spellcheck="false" />
<input id="downloadDir" type="text" spellcheck="false" readonly
title="Patches always download internally and are removed after install" />
</label>
<label class="switch-row">
<input id="installAfterDownload" type="checkbox" />
<span>
<strong>Install after download</strong>
<em>Global default, overridable per game</em>
</span>
<span class="toggle">
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<input id="deleteAfterInstall" type="checkbox" />
<span>
<strong>Delete PKG after install</strong>
<em>Only after a successful install</em>
</span>
<span class="toggle">
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<span>
<strong>Verify downloaded pieces (SHA-256)</strong>
<em>Checks each manifest piece; off by default, verify on-device first</em>
</span>
<span class="toggle">
<input id="verifyDownloads" type="checkbox" aria-label="Verify downloaded pieces" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<span>
<strong>Home-screen shortcut</strong>
<em id="shortcutHint">Add a PS5 home-screen icon that opens this UI in the browser</em>
</span>
<span class="toggle">
<input id="homeShortcut" type="checkbox" aria-label="Install home-screen shortcut" />
<span class="track"></span>
</span>
</label>
</div>
@@ -198,13 +200,19 @@
</div>
</section>
<section class="log-panel" id="logs">
<div class="panel-heading">
<!-- ============ LOGS ============ -->
<section class="view" data-view="logs">
<header class="topbar">
<div>
<p class="eyebrow">Runtime</p>
<h2>Recent Events</h2>
<h1>Logs</h1>
</div>
</div>
<div class="topbar-actions">
<button class="icon-button" id="clearLogBtn" title="Clear log" aria-label="Clear log">
<svg><use href="#icon-log"></use></svg>
</button>
</div>
</header>
<pre id="logOutput" tabindex="0"></pre>
</section>
</main>
+252 -492
View File
File diff suppressed because it is too large. Load diff