mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 23:00:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f40dc1d7c | ||
|
|
5f0d1be078 | ||
|
|
ea1328de79 | ||
|
|
9006965a75 | ||
|
|
510f199b89 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,3 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.1"
|
||||
#define PATCHDL_VERSION "0.0.2"
|
||||
+50
-6
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+132
-124
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user