mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 06:00:21 +02:00
Handle target-aware patch installs
This commit is contained in:
1 parent
01464ae88a
commit
510f199b89
14 files changed
+1010
-234
No files matched your search
@@ -25,7 +25,7 @@ by Knutwurst
|
|||||||
## Safety model
|
## Safety model
|
||||||
|
|
||||||
Deny-by-default. A patch is installed only for a genuine install, and only when
|
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 |
|
| Source | Check | Download | Install |
|
||||||
|-----------------------|-------|----------|---------|
|
|-----------------------|-------|----------|---------|
|
||||||
@@ -33,11 +33,13 @@ the package's title id matches the installed game:
|
|||||||
| shadowmount | yes | yes | no |
|
| shadowmount | yes | yes | no |
|
||||||
| preinstall / unknown | yes | no | no |
|
| preinstall / unknown | yes | no | no |
|
||||||
|
|
||||||
Two independent guards stop the wrong package being installed: the patch's title
|
Two independent guards stop the wrong target being installed: the patch target
|
||||||
id (read from its download URL) must match the game, and just before install the
|
id (read from `version.xml` / `manifest_url`) must match the installed game, and
|
||||||
real title id is read back from the package
|
the install call receives the installed game's content id from app.db. Sony may
|
||||||
(`sceAppInstUtilGetTitleIdFromPkg`) and checked again. A cross-region or
|
store the actual patch bytes under a regional/master title id that differs from
|
||||||
cross-title package is refused instead of installed as a phantom title.
|
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.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -69,9 +71,13 @@ http://<console-ip>:12880/
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
0.0.1, early. Title scan, version resolution, firmware-compatibility filtering,
|
0.0.2, early. Title scan, source classification, version resolution,
|
||||||
download, and install work and have been verified on firmware 11.60. Open items:
|
firmware-compatibility filtering, download, and install work and have been
|
||||||
the web UI marks a title "Installing…" but reads progress from the PS5's own
|
verified on firmware 11.60. Patches download internally to `/data/patchdl` and
|
||||||
notifications rather than a percentage; config persistence and a download queue
|
are removed once the install has applied. Each title has a single action button
|
||||||
are not built yet; disc-based games need the disc inserted for their patch to
|
(Download then Install, or Update when "install after download" is on). Open
|
||||||
apply (a normal Sony requirement).
|
items: the web UI marks a title "Installing…" but reads progress from the PS5's
|
||||||
|
own notifications rather than a percentage; a download queue is not built yet;
|
||||||
|
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
|
#!/bin/sh
|
||||||
# Deploy patchdl to the PS5 via the Payload Manager HTTP API (port 8084).
|
# 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
|
# The uploaded filename carries the version so it is identifiable in the
|
||||||
# Payload Manager UI. patchdl self-kills any running instance, so this is
|
# Payload Manager UI. Any running instance is killed first (the payload's own
|
||||||
# an idempotent redeploy.
|
# self-kill is racy when the port is still held), so this is a clean redeploy.
|
||||||
#
|
#
|
||||||
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
|
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
|
||||||
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
|
# 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; }
|
[ -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 ..."
|
echo "Launching $PAYLOAD_PATH ..."
|
||||||
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
|
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
|
||||||
|
|
||||||
|
|||||||
+201
-28
@@ -1,5 +1,8 @@
|
|||||||
#include "patchdl_install.h"
|
#include "patchdl_install.h"
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
#include <ps5/kernel.h>
|
#include <ps5/kernel.h>
|
||||||
|
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
@@ -7,6 +10,7 @@
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
|
||||||
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
|
/* 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 volatile int g_stage;
|
||||||
static int g_err;
|
static int g_err;
|
||||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
|
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
static char g_probe_json[2048]; /* filled by the backend thread */
|
||||||
|
|
||||||
static intptr_t
|
static intptr_t
|
||||||
dynsym(const char *module, const char *sym) {
|
dynsym(const char *module, const char *sym) {
|
||||||
@@ -79,6 +84,92 @@ dynsym(const char *module, const char *sym) {
|
|||||||
return kernel_dynlib_dlsym(-1, h, 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);
|
||||||
|
size_t n = 0, sz = sizeof(g_probe_json);
|
||||||
|
char *out = g_probe_json;
|
||||||
|
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, "}}");
|
||||||
|
}
|
||||||
|
|
||||||
static void *
|
static void *
|
||||||
backend_init_thread(void *arg) {
|
backend_init_thread(void *arg) {
|
||||||
(void)arg;
|
(void)arg;
|
||||||
@@ -107,6 +198,10 @@ backend_init_thread(void *arg) {
|
|||||||
"sceAppInstUtilInstallByPackage");
|
"sceAppInstUtilInstallByPackage");
|
||||||
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
||||||
"sceAppInstUtilGetTitleIdFromPkg");
|
"sceAppInstUtilGetTitleIdFromPkg");
|
||||||
|
|
||||||
|
/* Read-only feasibility probe — module is loaded, safe context. */
|
||||||
|
fill_probe();
|
||||||
|
|
||||||
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
|
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
|
||||||
g_stage = -3;
|
g_stage = -3;
|
||||||
return NULL;
|
return NULL;
|
||||||
@@ -161,12 +256,32 @@ patchdl_install_backend_check(char *msg, size_t msg_sz) {
|
|||||||
return (s == 5) ? 0 : -1;
|
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) {
|
||||||
|
backend_start();
|
||||||
|
if (g_probe_json[0]) {
|
||||||
|
snprintf(out, out_sz, "%s", g_probe_json);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
snprintf(out, out_sz,
|
||||||
|
"{\"pending\":true,\"stage\":\"%s\"}", stage_str(g_stage));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
int
|
int
|
||||||
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
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 *msg, size_t msg_sz) {
|
||||||
char sdk_path[1024];
|
char sdk_path[1024];
|
||||||
|
char pkg_tid[48] = {0};
|
||||||
struct stat st;
|
struct stat st;
|
||||||
int rc;
|
int rc;
|
||||||
|
int pkg_tid_mismatch = 0;
|
||||||
|
const char *last_uri = "";
|
||||||
|
|
||||||
if (!local_path || !local_path[0]) {
|
if (!local_path || !local_path[0]) {
|
||||||
snprintf(msg, msg_sz, "no package path");
|
snprintf(msg, msg_sz, "no package path");
|
||||||
@@ -183,63 +298,121 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The install service runs in its own sandbox that sees the user
|
/* AppInstallPkg runs in a sandbox that sees the user partition as
|
||||||
partition as /user/data, not /data — remap so it can read the file. */
|
/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))
|
if (!strncmp(local_path, "/data/", 6))
|
||||||
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
|
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
|
||||||
else
|
else
|
||||||
snprintf(sdk_path, sizeof(sdk_path), "%s", local_path);
|
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
|
/* Diagnostic guard: Sony sometimes stores one patch byte stream under a
|
||||||
installed game. A cross-title/region package (e.g. a US PPSA03098 patch
|
master title id while the version.xml targets a regional title id. That
|
||||||
on an EU PPSA03099 install) would otherwise be registered as a separate
|
is valid only when the caller supplies target metadata, so do not feed
|
||||||
phantom title instead of patching the game. */
|
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]) {
|
if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) {
|
||||||
char pkg_tid[48] = {0};
|
|
||||||
int is_app = 0;
|
int is_app = 0;
|
||||||
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
|
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
|
||||||
strncmp(pkg_tid, expected_title_id, 9) != 0) {
|
strncmp(pkg_tid, expected_title_id, 9) != 0) {
|
||||||
snprintf(msg, msg_sz,
|
pkg_tid_mismatch = 1;
|
||||||
"refused: package is for %.12s, installed game is %.12s "
|
|
||||||
"(cross-title/region)", pkg_tid, expected_title_id);
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (pkg_tid_mismatch && (!target_content_id || !target_content_id[0])) {
|
||||||
/* Primary: direct package install. */
|
snprintf(msg, msg_sz,
|
||||||
{
|
"refused: package metadata is %.12s, target is %.12s",
|
||||||
ai_pkg_info_t pkg = {0};
|
pkg_tid, expected_title_id);
|
||||||
rc = ai_install_pkg(sdk_path, &pkg);
|
return -1;
|
||||||
if (rc == 0) {
|
|
||||||
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 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_meta_info_t meta = {0};
|
||||||
ai_pkg_info_t pkg = {0};
|
ai_pkg_info_t pkg = {0};
|
||||||
ai_playgo_info_t playgo = {0};
|
ai_playgo_info_t playgo = {0};
|
||||||
int rc2;
|
int rc2;
|
||||||
|
const char *title_dir;
|
||||||
|
const char *file_base;
|
||||||
|
|
||||||
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
|
snprintf(file_uri, sizeof(file_uri), "file://%s", local_path);
|
||||||
meta.uri = file_uri;
|
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.ex_uri = "";
|
||||||
meta.playgo_scenario_id = "";
|
meta.playgo_scenario_id = "";
|
||||||
meta.content_id = "";
|
meta.content_id = target_content_id ? target_content_id : "";
|
||||||
meta.content_name = "PatchDL";
|
meta.content_name = "PatchDL";
|
||||||
meta.icon_url = "";
|
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) {
|
if (rc2 == 0) {
|
||||||
snprintf(msg, msg_sz, "install started (InstallByPackage)");
|
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
snprintf(msg, msg_sz,
|
snprintf(msg, msg_sz,
|
||||||
"install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)",
|
"install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)",
|
||||||
(unsigned)rc, (unsigned)rc2);
|
(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.
|
* (official) titles and obtain explicit user intent before calling.
|
||||||
*/
|
*/
|
||||||
/* `expected_title_id` is the title id of the installed game the patch is for.
|
/* `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
|
`storage_title_id` is the title id embedded in the delta_url storage path.
|
||||||
(prevents a cross-region/cross-title package being installed as a new
|
`target_content_id` is the installed game's content id from app.db; when
|
||||||
phantom title). */
|
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,
|
int patchdl_install_local_pkg(const char *local_path,
|
||||||
const char *expected_title_id,
|
const char *expected_title_id,
|
||||||
|
const char *storage_title_id,
|
||||||
|
const char *target_content_id,
|
||||||
char *msg, size_t msg_sz);
|
char *msg, size_t msg_sz);
|
||||||
|
|
||||||
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
|
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
|
||||||
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
|
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
|
||||||
int patchdl_install_backend_check(char *msg, size_t msg_sz);
|
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);
|
||||||
+49
-23
@@ -229,20 +229,37 @@ is_game_title(const char *title_id) {
|
|||||||
|
|
||||||
/* ---------- directory scanner ------------------------------------------- */
|
/* ---------- 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
|
* Classify each /user/app/<TID> (verified on fw 11.60):
|
||||||
* under /user/app/<TID>/ (verified on fw 11.60):
|
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
|
||||||
* - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount
|
* mount (authoritative; mount.lnk is only a fallback hint)
|
||||||
* - app.pkg (no mount.lnk) -> genuine install; app.json
|
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
|
||||||
* with CDN piece URLs means a not-downloaded preinstall stub, local
|
* URLs means a not-downloaded preinstall stub, local URLs a real install
|
||||||
* URLs mean a real install
|
* - app.json with "fake":true -> homebrew fake (skip)
|
||||||
* - app.json with "fake":true -> homebrew fake (skip)
|
|
||||||
*/
|
*/
|
||||||
static int
|
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 dir[PATH_MAX];
|
||||||
char appjson[4096];
|
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);
|
snprintf(dir, sizeof(dir), "%s/%s", base, name);
|
||||||
memset(t, 0, sizeof(*t));
|
memset(t, 0, sizeof(*t));
|
||||||
@@ -252,6 +269,7 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
|
|||||||
if (!is_game_title(t->title_id))
|
if (!is_game_title(t->title_id))
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
|
is_shadow = mount_is_shadow(t->title_id, mounts, nmounts);
|
||||||
has_mountlnk = path_exists(dir, "mount.lnk") ||
|
has_mountlnk = path_exists(dir, "mount.lnk") ||
|
||||||
path_exists(dir, "mount_img.lnk");
|
path_exists(dir, "mount_img.lnk");
|
||||||
has_app_pkg = path_exists(dir, "app.pkg");
|
has_app_pkg = path_exists(dir, "app.pkg");
|
||||||
@@ -266,19 +284,18 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
|
|||||||
if (is_fake)
|
if (is_fake)
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
if (has_mountlnk) {
|
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
|
||||||
/* metadata lives in the mounted sce_sys (param.json, or param.sfo
|
titles never have it (they have mounted/leftover sce_sys content). So
|
||||||
for PS4 titles) */
|
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
|
||||||
if (try_param_json(dir, t))
|
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);
|
try_param_sfo(dir, t);
|
||||||
t->source_type = PATCHDL_SOURCE_SHADOWMOUNT;
|
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 {
|
} else {
|
||||||
return -1; /* empty / leftover directory */
|
return -1; /* empty / leftover directory */
|
||||||
}
|
}
|
||||||
@@ -300,7 +317,8 @@ already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void
|
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;
|
DIR *d;
|
||||||
struct dirent *de;
|
struct dirent *de;
|
||||||
|
|
||||||
@@ -310,7 +328,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
|
|||||||
while ((de = readdir(d))) {
|
while ((de = readdir(d))) {
|
||||||
if (de->d_name[0] == '.') continue;
|
if (de->d_name[0] == '.') continue;
|
||||||
if (*cnt >= cap) break;
|
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;
|
continue;
|
||||||
if (already_seen(arr, *cnt, arr[*cnt].title_id))
|
if (already_seen(arr, *cnt, arr[*cnt].title_id))
|
||||||
continue; /* dedupe a title already found in an earlier base */
|
continue; /* dedupe a title already found in an earlier base */
|
||||||
@@ -369,6 +387,9 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
|||||||
intptr_t saved_root = 0, root_vnode;
|
intptr_t saved_root = 0, root_vnode;
|
||||||
int using_vswap = 0;
|
int using_vswap = 0;
|
||||||
|
|
||||||
|
struct statfs *mounts = NULL;
|
||||||
|
int nmounts;
|
||||||
|
|
||||||
arr = calloc(MAX_TITLES, sizeof(*arr));
|
arr = calloc(MAX_TITLES, sizeof(*arr));
|
||||||
if (!arr) return -1;
|
if (!arr) return -1;
|
||||||
|
|
||||||
@@ -376,6 +397,11 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
|||||||
readable; same authid ftpsrv uses. No-op without kernel R/W. */
|
readable; same authid ftpsrv uses. No-op without kernel R/W. */
|
||||||
kernel_set_ucred_authid(pid, 0x4801000000000013L);
|
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();
|
root_vnode = kernel_get_root_vnode();
|
||||||
if (root_vnode) {
|
if (root_vnode) {
|
||||||
saved_root = kernel_get_proc_rootdir(pid);
|
saved_root = kernel_get_proc_rootdir(pid);
|
||||||
@@ -384,7 +410,7 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; SCAN_DIRS[i]; i++)
|
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);
|
merge_appdb(arr, cnt);
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -24,8 +24,10 @@ typedef struct {
|
|||||||
char latest_version[16];
|
char latest_version[16];
|
||||||
char latest_required_fw[16];
|
char latest_required_fw[16];
|
||||||
char patch_url[512]; /* delta_url of the compatible patch */
|
char patch_url[512]; /* delta_url of the compatible patch */
|
||||||
char patch_title_id[16]; /* title id embedded in patch_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 verxml_done;
|
||||||
|
int enabled; /* user policy, persisted in config.json */
|
||||||
} patchdl_title_t;
|
} patchdl_title_t;
|
||||||
|
|
||||||
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
|
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#define PATCHDL_VERSION "0.0.1"
|
#define PATCHDL_VERSION "0.0.2"
|
||||||
+38
-4
@@ -57,8 +57,8 @@ ver_gt(const char *a, const char *b) {
|
|||||||
return strcmp(a, b) > 0;
|
return strcmp(a, b) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03098)
|
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03099)
|
||||||
from a string such as a delta_url. Used to detect cross-title patches. */
|
from a string such as nptitleid, manifest_url, or delta_url. */
|
||||||
static void
|
static void
|
||||||
extract_title_id(const char *s, char *out, size_t sz) {
|
extract_title_id(const char *s, char *out, size_t sz) {
|
||||||
out[0] = '\0';
|
out[0] = '\0';
|
||||||
@@ -77,9 +77,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
|
static void
|
||||||
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||||
const char *p = xml;
|
const char *p = xml;
|
||||||
|
char root_title[16] = {0};
|
||||||
|
|
||||||
|
parse_root_title_id(xml, root_title, sizeof(root_title));
|
||||||
|
|
||||||
while ((p = strstr(p, "<package "))) {
|
while ((p = strstr(p, "<package "))) {
|
||||||
const char *tag_end = strchr(p, '>');
|
const char *tag_end = strchr(p, '>');
|
||||||
@@ -89,12 +111,14 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
|||||||
char ver[16] = {0};
|
char ver[16] = {0};
|
||||||
char sver[16] = {0};
|
char sver[16] = {0};
|
||||||
char durl[512] = {0};
|
char durl[512] = {0};
|
||||||
|
char murl[512] = {0};
|
||||||
|
|
||||||
/* PS5 version.xml uses content_ver; PS4 uses version. */
|
/* PS5 version.xml uses content_ver; PS4 uses version. */
|
||||||
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
|
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, "version", ver, sizeof(ver));
|
||||||
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
|
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, "delta_url", durl, sizeof(durl));
|
||||||
|
attr_val(p, tag_end, "manifest_url", murl, sizeof(murl));
|
||||||
|
|
||||||
if (ver[0] && sver[0]) {
|
if (ver[0] && sver[0]) {
|
||||||
uint32_t pkg_sver = parse_hex(sver);
|
uint32_t pkg_sver = parse_hex(sver);
|
||||||
@@ -114,8 +138,18 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
|||||||
sizeof(out->compatible_version) - 1);
|
sizeof(out->compatible_version) - 1);
|
||||||
strncpy(out->compatible_url, durl,
|
strncpy(out->compatible_url, durl,
|
||||||
sizeof(out->compatible_url) - 1);
|
sizeof(out->compatible_url) - 1);
|
||||||
extract_title_id(durl, out->compatible_title,
|
extract_title_id(durl, out->compatible_storage_title,
|
||||||
sizeof(out->compatible_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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ typedef struct {
|
|||||||
char latest_version[16]; /* highest pkg overall, 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 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_url[512]; /* delta_url of the chosen compatible pkg */
|
||||||
char compatible_title[16]; /* title id embedded in that delta_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;
|
} patchdl_verinfo_t;
|
||||||
|
|
||||||
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
|
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
|
||||||
+453
-27
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#include <microhttpd.h>
|
#include <microhttpd.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
#include <fcntl.h>
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -24,13 +25,26 @@ static size_t g_title_count;
|
|||||||
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
|
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||||
static char g_status_json[512];
|
static char g_status_json[512];
|
||||||
static char *g_debug_json; /* built once at startup */
|
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[] =
|
#define PATCHDL_DL_DIR "/data/patchdl"
|
||||||
"{"
|
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
|
||||||
"\"default_policy\":\"deny\","
|
|
||||||
"\"download_dir\":\"/mnt/usb0/patches\","
|
/* Persisted, user-editable settings. Per-title enable/disable lives in the
|
||||||
"\"install_after_download\":false,"
|
title structs (t->enabled) so it travels with the scan; the global fields
|
||||||
"\"delete_pkg_after_install\":false,"
|
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;
|
||||||
|
} g_cfg = { "deny", 0, 1 };
|
||||||
|
|
||||||
|
/* 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\":{"
|
"\"source_policy\":{"
|
||||||
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
||||||
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
||||||
@@ -45,6 +59,43 @@ static const char config_json[] =
|
|||||||
|
|
||||||
static const char downloads_json[] = "[]";
|
static const char downloads_json[] = "[]";
|
||||||
|
|
||||||
|
/* ---------- tiny JSON value lookups (flat objects only) ----------------- */
|
||||||
|
|
||||||
|
/* 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 ------------------------------------------------ */
|
/* ---------- JSON builder ------------------------------------------------ */
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -156,6 +207,139 @@ queue_asset(struct MHD_Connection *conn, const char *url) {
|
|||||||
asset->data, asset->size, MHD_RESPMEM_PERSISTENT);
|
asset->data, asset->size, MHD_RESPMEM_PERSISTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
|
||||||
|
resp = MHD_create_response_from_fd_at_offset64(send_size, fd, start);
|
||||||
|
if (!resp) {
|
||||||
|
close(fd);
|
||||||
|
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); /* closes fd */
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- status JSON ------------------------------------------------- */
|
/* ---------- status JSON ------------------------------------------------- */
|
||||||
|
|
||||||
static void
|
static void
|
||||||
@@ -166,10 +350,95 @@ rebuild_status_json(void) {
|
|||||||
"\"dns_guard\":\"Active\","
|
"\"dns_guard\":\"Active\","
|
||||||
"\"resolver\":\"Internal allowlist\","
|
"\"resolver\":\"Internal allowlist\","
|
||||||
"\"free_space_mb\":0,"
|
"\"free_space_mb\":0,"
|
||||||
"\"download_dir\":\"/mnt/usb0/patches\"}",
|
"\"download_dir\":\"/data/patchdl (internal)\"}",
|
||||||
g_fw.str, g_fw.bin);
|
g_fw.str, g_fw.bin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 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,",
|
||||||
|
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");
|
||||||
|
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\"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");
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* 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) ----------------- */
|
/* ---------- titles JSON (built per request under mutex) ----------------- */
|
||||||
|
|
||||||
static const char *
|
static const char *
|
||||||
@@ -227,13 +496,15 @@ build_titles_json(void) {
|
|||||||
jbuf_append_ver_or_null(&j, t->version_file_uri);
|
jbuf_append_ver_or_null(&j, t->version_file_uri);
|
||||||
jbuf_append(&j, ",\"patch_title_id\":");
|
jbuf_append(&j, ",\"patch_title_id\":");
|
||||||
jbuf_append_ver_or_null(&j, t->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
|
jbuf_append(&j, ",\"patch_storage_title_id\":");
|
||||||
cross-region/title package that must NOT be installed. */
|
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",
|
jbuf_appendf(&j, ",\"patch_title_match\":%s",
|
||||||
(!t->patch_title_id[0] ||
|
(!t->patch_title_id[0] ||
|
||||||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
|
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
|
||||||
jbuf_appendf(&j, ",\"enabled\":%s",
|
jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false");
|
||||||
t->source_type == PATCHDL_SOURCE_UNKNOWN ? "false" : "true");
|
|
||||||
jbuf_append(&j, ",\"mode\":");
|
jbuf_append(&j, ",\"mode\":");
|
||||||
jbuf_append_str(&j, title_mode_str(t->source_type));
|
jbuf_append_str(&j, title_mode_str(t->source_type));
|
||||||
jbuf_append(&j, ",\"queued\":false");
|
jbuf_append(&j, ",\"queued\":false");
|
||||||
@@ -249,6 +520,21 @@ build_titles_json(void) {
|
|||||||
|
|
||||||
/* ---------- verxml background fetch ------------------------------------ */
|
/* ---------- 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, '/');
|
||||||
|
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 *
|
static void *
|
||||||
verxml_fetch_thread(void *arg) {
|
verxml_fetch_thread(void *arg) {
|
||||||
(void)arg;
|
(void)arg;
|
||||||
@@ -278,8 +564,15 @@ verxml_fetch_thread(void *arg) {
|
|||||||
sizeof(t->patch_url) - 1);
|
sizeof(t->patch_url) - 1);
|
||||||
strncpy(t->patch_title_id, info.compatible_title,
|
strncpy(t->patch_title_id, info.compatible_title,
|
||||||
sizeof(t->patch_title_id) - 1);
|
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;
|
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);
|
pthread_mutex_unlock(&g_mutex);
|
||||||
|
|
||||||
|
if (up_to_date && info.compatible_url[0])
|
||||||
|
cleanup_installed_download(t->title_id, info.compatible_url);
|
||||||
}
|
}
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
@@ -290,7 +583,9 @@ verxml_fetch_thread(void *arg) {
|
|||||||
static int
|
static int
|
||||||
get_title_action_info(const char *title_id, patchdl_source_t *src,
|
get_title_action_info(const char *title_id, patchdl_source_t *src,
|
||||||
char *patch_url, size_t url_sz,
|
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, int *enabled) {
|
||||||
int found = 0;
|
int found = 0;
|
||||||
|
|
||||||
pthread_mutex_lock(&g_mutex);
|
pthread_mutex_lock(&g_mutex);
|
||||||
@@ -301,6 +596,11 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
|
|||||||
patch_url[url_sz - 1] = '\0';
|
patch_url[url_sz - 1] = '\0';
|
||||||
strncpy(patch_title_id, g_titles[i].patch_title_id, pt_sz - 1);
|
strncpy(patch_title_id, g_titles[i].patch_title_id, pt_sz - 1);
|
||||||
patch_title_id[pt_sz - 1] = '\0';
|
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';
|
||||||
|
*enabled = g_titles[i].enabled;
|
||||||
found = 1;
|
found = 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -309,7 +609,28 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
|
|||||||
return found;
|
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. */
|
/* Local on-disk path a title's patch downloads to / installs from. */
|
||||||
static void
|
static void
|
||||||
@@ -322,10 +643,14 @@ title_pkg_path(const char *title_id, const char *patch_url,
|
|||||||
|
|
||||||
static enum MHD_Result
|
static enum MHD_Result
|
||||||
do_download(struct MHD_Connection *conn, const char *title_id,
|
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, int enabled) {
|
||||||
char dir[256], dest[320], resp[640];
|
char dir[256], dest[320], resp[640];
|
||||||
long long bytes = 0;
|
long long bytes = 0;
|
||||||
|
|
||||||
|
if (!enabled)
|
||||||
|
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||||
|
"{\"ok\":false,\"reason\":\"title_disabled\"}");
|
||||||
|
|
||||||
if (src == PATCHDL_SOURCE_UNKNOWN)
|
if (src == PATCHDL_SOURCE_UNKNOWN)
|
||||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||||
"{\"ok\":false,\"reason\":\"source_unknown\"}");
|
"{\"ok\":false,\"reason\":\"source_unknown\"}");
|
||||||
@@ -358,10 +683,15 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
|||||||
static enum MHD_Result
|
static enum MHD_Result
|
||||||
do_install(struct MHD_Connection *conn, const char *title_id,
|
do_install(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 *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];
|
char dest[320], msg[256], resp[640];
|
||||||
int rc;
|
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.
|
/* Source policy: only genuine installs may be patched in place.
|
||||||
Shadowmounts are download-only; unknown/preinstall are blocked. */
|
Shadowmounts are download-only; unknown/preinstall are blocked. */
|
||||||
if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL)
|
if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL)
|
||||||
@@ -372,8 +702,9 @@ do_install(struct MHD_Connection *conn, const char *title_id,
|
|||||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||||
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
|
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
|
||||||
|
|
||||||
/* GUARD (app layer): the patch package's title id must match the game.
|
/* GUARD (app layer): the version.xml target title id must match the game.
|
||||||
A cross-title/region patch would install as a phantom title. */
|
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) {
|
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
|
||||||
snprintf(resp, sizeof(resp),
|
snprintf(resp, sizeof(resp),
|
||||||
"{\"ok\":false,\"reason\":\"patch_title_mismatch\","
|
"{\"ok\":false,\"reason\":\"patch_title_mismatch\","
|
||||||
@@ -384,9 +715,8 @@ do_install(struct MHD_Connection *conn, const char *title_id,
|
|||||||
|
|
||||||
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
|
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
|
||||||
|
|
||||||
/* GUARD (authoritative): patchdl_install reads the PKG's real title id and
|
rc = patchdl_install_local_pkg(dest, title_id, patch_storage_title_id,
|
||||||
refuses if it does not match `title_id`. */
|
content_id, msg, sizeof(msg));
|
||||||
rc = patchdl_install_local_pkg(dest, title_id, msg, sizeof(msg));
|
|
||||||
snprintf(resp, sizeof(resp),
|
snprintf(resp, sizeof(resp),
|
||||||
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}",
|
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}",
|
||||||
rc == 0 ? "true" : "false", rc, msg, dest);
|
rc == 0 ? "true" : "false", rc, msg, dest);
|
||||||
@@ -425,6 +755,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
|||||||
char action[24];
|
char action[24];
|
||||||
char patch_url[512] = {0};
|
char patch_url[512] = {0};
|
||||||
char patch_title_id[16] = {0};
|
char patch_title_id[16] = {0};
|
||||||
|
char patch_storage_title_id[16] = {0};
|
||||||
|
char content_id[64] = {0};
|
||||||
|
int enabled = 0;
|
||||||
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
|
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
|
||||||
|
|
||||||
if (parse_title_action(url, title_id, sizeof(title_id),
|
if (parse_title_action(url, title_id, sizeof(title_id),
|
||||||
@@ -432,35 +765,109 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
|||||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||||
|
|
||||||
if (!get_title_action_info(title_id, &src, patch_url, sizeof(patch_url),
|
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), &enabled))
|
||||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
|
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"))
|
if (!strcmp(action, "download"))
|
||||||
return do_download(conn, title_id, src, patch_url);
|
return do_download(conn, title_id, src, patch_url, enabled);
|
||||||
|
|
||||||
if (!strcmp(action, "check"))
|
if (!strcmp(action, "check"))
|
||||||
return queue_json(conn, MHD_HTTP_ACCEPTED,
|
return queue_json(conn, MHD_HTTP_ACCEPTED,
|
||||||
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
|
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
|
||||||
|
|
||||||
if (!strcmp(action, "install"))
|
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");
|
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);
|
||||||
|
pthread_mutex_unlock(&g_mutex);
|
||||||
|
|
||||||
|
save_config();
|
||||||
|
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
|
||||||
|
}
|
||||||
|
|
||||||
static enum MHD_Result
|
static enum MHD_Result
|
||||||
on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||||
const char *method, const char *version, const char *upload_data,
|
const char *method, const char *version, const char *upload_data,
|
||||||
size_t *upload_data_size, void **con_cls) {
|
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))
|
if (!strcmp(method, MHD_HTTP_METHOD_OPTIONS))
|
||||||
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
|
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
|
||||||
|
|
||||||
if (!strcmp(method, MHD_HTTP_METHOD_POST)) {
|
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"))
|
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))
|
if (!strncmp(url, "/api/titles/", 12))
|
||||||
return handle_title_action(conn, url);
|
return handle_title_action(conn, url);
|
||||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||||
@@ -474,7 +881,7 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
|||||||
return queue_json(conn, MHD_HTTP_OK, g_status_json);
|
return queue_json(conn, MHD_HTTP_OK, g_status_json);
|
||||||
|
|
||||||
if (!strcmp(url, "/api/config"))
|
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"))
|
if (!strcmp(url, "/api/titles"))
|
||||||
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
|
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
|
||||||
@@ -494,6 +901,15 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
|||||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
|
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")) {
|
if (!strcmp(url, "/api/netcheck")) {
|
||||||
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
|
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
|
||||||
pthread_mutex_lock(&g_mutex);
|
pthread_mutex_lock(&g_mutex);
|
||||||
@@ -507,6 +923,9 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
|||||||
return queue_json(conn, MHD_HTTP_OK, diag);
|
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);
|
return queue_asset(conn, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +946,12 @@ patchdl_websrv_start(unsigned short port) {
|
|||||||
if (patchdl_scan(&g_titles, &g_title_count))
|
if (patchdl_scan(&g_titles, &g_title_count))
|
||||||
g_title_count = 0;
|
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
|
/* Build the diagnostic dump now, while single-threaded — the root-vnode
|
||||||
swap it performs is unsafe once MHD worker threads are running. */
|
swap it performs is unsafe once MHD worker threads are running. */
|
||||||
g_debug_json = patchdl_scan_debug_json();
|
g_debug_json = patchdl_scan_debug_json();
|
||||||
@@ -534,6 +959,7 @@ patchdl_websrv_start(unsigned short port) {
|
|||||||
web_daemon = MHD_start_daemon(
|
web_daemon = MHD_start_daemon(
|
||||||
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
|
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
|
||||||
port, NULL, NULL, &on_request, NULL,
|
port, NULL, NULL, &on_request, NULL,
|
||||||
|
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
|
||||||
MHD_OPTION_END);
|
MHD_OPTION_END);
|
||||||
|
|
||||||
if (!web_daemon) {
|
if (!web_daemon) {
|
||||||
|
|||||||
+5
-2
@@ -18,6 +18,9 @@ GET /api/titles
|
|||||||
GET /api/downloads
|
GET /api/downloads
|
||||||
POST /api/titles/:title_id/check
|
POST /api/titles/:title_id/check
|
||||||
POST /api/titles/:title_id/download
|
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
|
## Policy Model
|
||||||
@@ -27,9 +30,9 @@ The UI assumes deny-by-default behavior:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"default_policy": "deny",
|
"default_policy": "deny",
|
||||||
"download_dir": "/mnt/usb0/patches",
|
"download_dir": "/data/patchdl (internal)",
|
||||||
"install_after_download": false,
|
"install_after_download": false,
|
||||||
"delete_pkg_after_install": false,
|
"delete_pkg_after_install": true,
|
||||||
"source_policy": {
|
"source_policy": {
|
||||||
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
|
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||||
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
|
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||||
|
|||||||
+133
-128
@@ -40,13 +40,13 @@ const fallback = {
|
|||||||
dns_guard: "Active",
|
dns_guard: "Active",
|
||||||
resolver: "Internal allowlist",
|
resolver: "Internal allowlist",
|
||||||
free_space_mb: 64218,
|
free_space_mb: 64218,
|
||||||
download_dir: "/mnt/usb0/patches",
|
download_dir: "/data/patchdl (internal)",
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
default_policy: "deny",
|
default_policy: "deny",
|
||||||
download_dir: "/mnt/usb0/patches",
|
download_dir: "/data/patchdl (internal)",
|
||||||
install_after_download: false,
|
install_after_download: false,
|
||||||
delete_pkg_after_install: false,
|
delete_pkg_after_install: true,
|
||||||
source_policy: {
|
source_policy: {
|
||||||
official: { allow_check: true, allow_download: true, allow_install: true },
|
official: { allow_check: true, allow_download: true, allow_install: true },
|
||||||
external: { allow_check: true, allow_download: true, allow_install: true },
|
external: { allow_check: true, allow_download: true, allow_install: true },
|
||||||
@@ -293,7 +293,7 @@ function renderGames() {
|
|||||||
function gameCategory(game) {
|
function gameCategory(game) {
|
||||||
if (game.installing) return "updatable"; // keep visible while the patch installs
|
if (game.installing) return "updatable"; // keep visible while the patch installs
|
||||||
if (game.status === "checking") return "checking";
|
if (game.status === "checking") return "checking";
|
||||||
if (game.patch_title_match === false) return "blocked"; // cross-region/title patch
|
if (game.patch_title_match === false) return "blocked"; // target-title mismatch
|
||||||
if (!sourcePolicy(game).allow_install) return "blocked";
|
if (!sourcePolicy(game).allow_install) return "blocked";
|
||||||
if (game.status === "available") return "updatable";
|
if (game.status === "available") return "updatable";
|
||||||
if (game.status === "incompatible_fw") return "needsfw";
|
if (game.status === "incompatible_fw") return "needsfw";
|
||||||
@@ -322,7 +322,9 @@ function createGameCard(game) {
|
|||||||
title.className = "game-title";
|
title.className = "game-title";
|
||||||
const installBlocked = isInstallBlocked(game);
|
const installBlocked = isInstallBlocked(game);
|
||||||
title.innerHTML = `
|
title.innerHTML = `
|
||||||
<div class="cover">${initials(game.name)}</div>
|
<div class="lead">
|
||||||
|
<div class="cover">${initials(game.name)}</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(game.name)}</h3>
|
<h3>${escapeHtml(game.name)}</h3>
|
||||||
<div class="subline">
|
<div class="subline">
|
||||||
@@ -333,12 +335,34 @@ function createGameCard(game) {
|
|||||||
${statusPill(game)}
|
${statusPill(game)}
|
||||||
${sourcePill(game)}
|
${sourcePill(game)}
|
||||||
${installBlocked ? `<span class="pill blocked">Install blocked</span>` : `<span class="pill ok">Install allowed</span>`}
|
${installBlocked ? `<span class="pill blocked">Install blocked</span>` : `<span class="pill ok">Install allowed</span>`}
|
||||||
<span class="pill">${game.enabled ? "Enabled" : "Off"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="source-path">${escapeHtml(sourceDetail(game))}</div>
|
<div class="source-path">${escapeHtml(sourceDetail(game))}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Per-game enable/disable toggle, grouped under the game icon on the left
|
||||||
|
// (only for sources that can actually be patched).
|
||||||
|
if (sourcePolicy(game).allow_install) {
|
||||||
|
const lead = title.querySelector(".lead");
|
||||||
|
const toggle = document.createElement("label");
|
||||||
|
toggle.className = "toggle";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.checked = game.enabled !== false;
|
||||||
|
cb.setAttribute("aria-label", `Enable updates for ${game.name}`);
|
||||||
|
const track = document.createElement("span");
|
||||||
|
track.className = "track";
|
||||||
|
const lbl = document.createElement("span");
|
||||||
|
lbl.className = "label";
|
||||||
|
lbl.textContent = cb.checked ? "On" : "Off";
|
||||||
|
cb.addEventListener("change", () => {
|
||||||
|
lbl.textContent = cb.checked ? "On" : "Off";
|
||||||
|
updateGame(game.title_id, { enabled: cb.checked, mode: cb.checked ? "latest_compatible" : "disabled" });
|
||||||
|
});
|
||||||
|
toggle.append(cb, track, lbl);
|
||||||
|
lead.appendChild(toggle);
|
||||||
|
}
|
||||||
|
|
||||||
const versions = document.createElement("div");
|
const versions = document.createElement("div");
|
||||||
versions.className = "version-grid";
|
versions.className = "version-grid";
|
||||||
versions.innerHTML = `
|
versions.innerHTML = `
|
||||||
@@ -350,56 +374,23 @@ function createGameCard(game) {
|
|||||||
const controls = document.createElement("div");
|
const controls = document.createElement("div");
|
||||||
controls.className = "controls";
|
controls.className = "controls";
|
||||||
|
|
||||||
const controlRow = document.createElement("div");
|
|
||||||
controlRow.className = "control-row";
|
|
||||||
|
|
||||||
const mode = document.createElement("select");
|
|
||||||
mode.title = "Update mode";
|
|
||||||
[
|
|
||||||
["disabled", "Disabled"],
|
|
||||||
["download_only", "Download only"],
|
|
||||||
["latest_compatible", "Latest compatible"],
|
|
||||||
["pin", "Pin version"],
|
|
||||||
["check_only", "Check only"],
|
|
||||||
].forEach(([value, label]) => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = value;
|
|
||||||
option.textContent = label;
|
|
||||||
option.selected = (game.mode || "disabled") === value;
|
|
||||||
mode.appendChild(option);
|
|
||||||
});
|
|
||||||
mode.addEventListener("change", () => updateGame(game.title_id, { mode: mode.value, enabled: mode.value !== "disabled" }));
|
|
||||||
|
|
||||||
const pin = document.createElement("input");
|
|
||||||
pin.title = "Maximum content version";
|
|
||||||
pin.placeholder = "max ver";
|
|
||||||
pin.value = game.max_content_ver || "";
|
|
||||||
pin.addEventListener("change", () => updateGame(game.title_id, { max_content_ver: pin.value.trim() }));
|
|
||||||
|
|
||||||
controlRow.append(mode, pin);
|
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "actions-row";
|
actions.className = "actions-row";
|
||||||
actions.innerHTML = `
|
const act = rowAction(game);
|
||||||
<button class="row-button" data-action="check">
|
if (act) {
|
||||||
<svg><use href="#icon-refresh"></use></svg>
|
const btn = document.createElement("button");
|
||||||
Check
|
btn.className = "row-button is-primary";
|
||||||
</button>
|
btn.innerHTML = `<svg><use href="#icon-download"></use></svg> ${escapeHtml(act.label)}`;
|
||||||
<button class="row-button is-primary" data-action="download" ${!isDownloadAllowed(game) ? "disabled" : ""} title="${escapeHtml(downloadTitle(game))}">
|
btn.title = escapeHtml(act.hint || act.label);
|
||||||
<svg><use href="#icon-download"></use></svg>
|
if (act.disabled || !act.action) {
|
||||||
Download
|
btn.disabled = true;
|
||||||
</button>
|
} else {
|
||||||
<button class="row-button" data-action="install" ${(!isInstallAllowed(game) || game.installing) ? "disabled" : ""} title="${escapeHtml(installTitle(game))}">
|
btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
|
||||||
<svg><use href="#icon-download"></use></svg>
|
}
|
||||||
${game.installing ? "Installing…" : "Install"}
|
actions.appendChild(btn);
|
||||||
</button>
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
actions.querySelectorAll("button").forEach((button) => {
|
controls.append(actions);
|
||||||
button.addEventListener("click", () => runTitleAction(game.title_id, button.dataset.action));
|
|
||||||
});
|
|
||||||
|
|
||||||
controls.append(controlRow, actions);
|
|
||||||
card.append(title, versions, controls);
|
card.append(title, versions, controls);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -413,15 +404,17 @@ function versionBox(label, value) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status pill = update state only (version/firmware/target title). The source type
|
||||||
|
// has its own pill, so this one never repeats "Shadowmount" etc.
|
||||||
function statusPill(game) {
|
function statusPill(game) {
|
||||||
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
||||||
const cat = gameCategory(game);
|
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
|
||||||
if (cat === "checking") return `<span class="pill">Checking…</span>`;
|
|
||||||
if (game.queued || game.status === "queued") return `<span class="pill warn">In queue</span>`;
|
if (game.queued || game.status === "queued") return `<span class="pill warn">In queue</span>`;
|
||||||
if (cat === "blocked") return `<span class="pill blocked">${escapeHtml(blockedLabel(game))}</span>`;
|
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
|
||||||
if (cat === "updatable") return `<span class="pill ok">Update available</span>`;
|
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
|
||||||
if (cat === "needsfw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
|
if (game.status === "incompatible_fw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
|
||||||
return `<span class="pill">Up to date</span>`;
|
if (game.status === "up_to_date") return `<span class="pill">Up to date</span>`;
|
||||||
|
return `<span class="pill">No patch info</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourcePill(game) {
|
function sourcePill(game) {
|
||||||
@@ -445,15 +438,6 @@ function sourceDetail(game) {
|
|||||||
return "Source could not be identified";
|
return "Source could not be identified";
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockedLabel(game) {
|
|
||||||
if (game.patch_title_match === false) return "Region mismatch";
|
|
||||||
const src = sourceType(game);
|
|
||||||
if (src === "shadowmount") return "Shadowmount";
|
|
||||||
if (src === "unknown") return "Preinstall";
|
|
||||||
if (!game.compatible_version) return "FW blocked";
|
|
||||||
return "Blocked";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderQueue() {
|
function renderQueue() {
|
||||||
els.queueList.replaceChildren();
|
els.queueList.replaceChildren();
|
||||||
if (!state.downloads.length) {
|
if (!state.downloads.length) {
|
||||||
@@ -520,66 +504,78 @@ async function saveConfig() {
|
|||||||
renderSettings();
|
renderSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One per-row action button. Returns null when there is nothing to do.
|
||||||
|
// install_after_download ON -> "Update" (download + install in one go)
|
||||||
|
// install_after_download OFF -> "Download", then "Install" once downloaded
|
||||||
|
// download-only source -> "Download" (no install step)
|
||||||
|
function rowAction(game) {
|
||||||
|
if (game.installing) return { label: "Installing…", disabled: true };
|
||||||
|
if (game.downloading) return { label: "Downloading…", disabled: true };
|
||||||
|
if (game.patch_title_match === false) return null; // target-title mismatch
|
||||||
|
if (game.status !== "available") return null; // nothing newer & compatible
|
||||||
|
if (!isInstallAllowed(game)) return null; // not a patchable source (e.g. shadowmount)
|
||||||
|
if (state.config.install_after_download)
|
||||||
|
return { label: "Update", action: "update", hint: "Download and install the update." };
|
||||||
|
return game.downloaded
|
||||||
|
? { label: "Install", action: "install", hint: "Install the downloaded patch (modifies the game)." }
|
||||||
|
: { label: "Download", action: "download", hint: "Download the patch internally." };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDownload(game) {
|
||||||
|
game.downloading = true;
|
||||||
|
renderGames();
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await postJson(API.action(game.title_id, "download"), {});
|
||||||
|
} catch (error) {
|
||||||
|
game.downloading = false;
|
||||||
|
const why = reasonText(error);
|
||||||
|
state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`);
|
||||||
|
showToast(`${game.name}: ${why}`);
|
||||||
|
renderGames();
|
||||||
|
renderLogs();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
game.downloading = false;
|
||||||
|
game.downloaded = true;
|
||||||
|
const mb = r && r.bytes ? (r.bytes / (1024 * 1024)).toFixed(1) : "?";
|
||||||
|
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${mb} MB, internal)`);
|
||||||
|
showToast(`${game.name}: downloaded ${mb} MB.`);
|
||||||
|
renderGames();
|
||||||
|
renderLogs();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doInstall(game) {
|
||||||
|
game.installing = true;
|
||||||
|
renderGames();
|
||||||
|
try {
|
||||||
|
await postJson(API.action(game.title_id, "install"), {});
|
||||||
|
} catch (error) {
|
||||||
|
game.installing = false;
|
||||||
|
const why = reasonText(error);
|
||||||
|
state.logs.push(`[${timeNow()}] install ${game.title_id} blocked: ${why}`);
|
||||||
|
showToast(`${game.name}: ${why}`);
|
||||||
|
renderGames();
|
||||||
|
renderLogs();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// rc=0 = accepted; keep "Installing…" (the PS5 applies it in the background;
|
||||||
|
// it clears on the next refresh once the version updates).
|
||||||
|
state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`);
|
||||||
|
showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`);
|
||||||
|
renderGames();
|
||||||
|
renderLogs();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function runTitleAction(titleId, action) {
|
async function runTitleAction(titleId, action) {
|
||||||
const game = state.titles.find((item) => item.title_id === titleId);
|
const game = state.titles.find((item) => item.title_id === titleId);
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
|
||||||
if (action === "download" && !isDownloadAllowed(game)) {
|
if (action === "download") await doDownload(game);
|
||||||
showToast(downloadTitle(game));
|
else if (action === "install") await doInstall(game);
|
||||||
return;
|
else if (action === "update") { if (await doDownload(game)) await doInstall(game); }
|
||||||
}
|
|
||||||
if (action === "install" && !isInstallAllowed(game)) {
|
|
||||||
showToast(installTitle(game));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "download" || action === "install") {
|
|
||||||
showToast(action === "download"
|
|
||||||
? `Downloading ${game.title_id} ${game.compatible_version}...`
|
|
||||||
: `Installing ${game.title_id} ${game.compatible_version}...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = null;
|
|
||||||
try {
|
|
||||||
result = await postJson(API.action(titleId, action), {});
|
|
||||||
} catch (error) {
|
|
||||||
const why = reasonText(error);
|
|
||||||
state.logs.push(`[${timeNow()}] ${action} ${game.title_id} blocked: ${why}`);
|
|
||||||
showToast(`${game.title_id}: ${why}`);
|
|
||||||
renderGames();
|
|
||||||
renderLogs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "download") {
|
|
||||||
const mb = result && result.bytes ? (result.bytes / (1024 * 1024)).toFixed(1) : "?";
|
|
||||||
game.queued = true;
|
|
||||||
if (!state.downloads.some((item) => item.title_id === titleId)) {
|
|
||||||
state.downloads.push({
|
|
||||||
title_id: game.title_id,
|
|
||||||
name: game.name,
|
|
||||||
version: game.compatible_version,
|
|
||||||
progress: 100,
|
|
||||||
detail: result && result.install_allowed ? "Downloaded - ready to install" : "Downloaded (install blocked)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${mb} MB) -> ${result ? result.path : "?"}`);
|
|
||||||
showToast(`${game.title_id}: downloaded ${mb} MB.`);
|
|
||||||
} else if (action === "install") {
|
|
||||||
// rc=0 means the Sony installer accepted the job; it now runs in the PS5
|
|
||||||
// background. Mark the card so it's clearly visible the patch is installing.
|
|
||||||
game.installing = true;
|
|
||||||
game.status = "installing";
|
|
||||||
state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`);
|
|
||||||
showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`);
|
|
||||||
} else {
|
|
||||||
state.logs.push(`[${timeNow()}] Check requested for ${game.title_id}`);
|
|
||||||
showToast(`${game.title_id} check queued.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderGames();
|
|
||||||
renderQueue();
|
|
||||||
renderLogs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGame(titleId, patch) {
|
function updateGame(titleId, patch) {
|
||||||
@@ -592,9 +588,18 @@ function updateGame(titleId, patch) {
|
|||||||
}
|
}
|
||||||
Object.assign(game, patch);
|
Object.assign(game, patch);
|
||||||
if (patch.mode === "disabled") game.enabled = false;
|
if (patch.mode === "disabled") game.enabled = false;
|
||||||
state.logs.push(`[${timeNow()}] Policy updated for ${titleId}`);
|
|
||||||
renderGames();
|
renderGames();
|
||||||
renderLogs();
|
|
||||||
|
// Persist the toggle on the PS5 (survives refresh and payload restart).
|
||||||
|
const wantEnabled = game.enabled !== false;
|
||||||
|
postJson(API.action(titleId, wantEnabled ? "enable" : "disable"), {})
|
||||||
|
.then(() => {
|
||||||
|
state.logs.push(`[${timeNow()}] Saved: ${titleId} ${wantEnabled ? "enabled" : "disabled"}`);
|
||||||
|
renderLogs();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast(`${titleId}: could not save toggle (API not reachable).`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDownloadAllowed(game) {
|
function isDownloadAllowed(game) {
|
||||||
@@ -614,7 +619,7 @@ function isInstallAllowed(game) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
function installTitle(game) {
|
function installTitle(game) {
|
||||||
if (game.patch_title_match === false) return "Patch is for a different title/region — install blocked.";
|
if (game.patch_title_match === false) return "Patch metadata targets a different title - install blocked.";
|
||||||
if (isInstallBlocked(game)) return "Install blocked for this source.";
|
if (isInstallBlocked(game)) return "Install blocked for this source.";
|
||||||
if (!game.compatible_version) return "No compatible update to install.";
|
if (!game.compatible_version) return "No compatible update to install.";
|
||||||
return "Install the downloaded patch (modifies the game).";
|
return "Install the downloaded patch (modifies the game).";
|
||||||
@@ -655,7 +660,7 @@ async function postJson(url, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REASON_TEXT = {
|
const REASON_TEXT = {
|
||||||
patch_title_mismatch: "Patch is for a different title/region — install blocked.",
|
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
|
||||||
install_not_allowed_for_source: "Install blocked for this source.",
|
install_not_allowed_for_source: "Install blocked for this source.",
|
||||||
source_unknown: "Source unknown — blocked.",
|
source_unknown: "Source unknown — blocked.",
|
||||||
no_compatible_patch: "No compatible patch available.",
|
no_compatible_patch: "No compatible patch available.",
|
||||||
|
|||||||
+2
-2
@@ -53,7 +53,7 @@
|
|||||||
<div class="brand-mark">PD</div>
|
<div class="brand-mark">PD</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>PatchDL</strong>
|
<strong>PatchDL</strong>
|
||||||
<span>by Knutwurst · v0.0.1</span>
|
<span>by Knutwurst · v0.0.2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Download Folder</span>
|
<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>
|
||||||
<label class="switch-row">
|
<label class="switch-row">
|
||||||
<input id="installAfterDownload" type="checkbox" />
|
<input id="installAfterDownload" type="checkbox" />
|
||||||
|
|||||||
@@ -593,6 +593,74 @@ h2 {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle .track {
|
||||||
|
flex: none;
|
||||||
|
width: 38px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle .track::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .track {
|
||||||
|
background: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
|
border-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .track::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leading column of a game card: icon with its enable toggle stacked below. */
|
||||||
|
.lead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead .toggle {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.allowlist {
|
.allowlist {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in new issue
Block a user