diff --git a/README.md b/README.md index e4ff82b..e32a56b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ by Knutwurst ## Safety model Deny-by-default. A patch is installed only for a genuine install, and only when -the package's title id matches the installed game: +the patch metadata targets the installed game: | Source | Check | Download | Install | |-----------------------|-------|----------|---------| @@ -33,11 +33,13 @@ the package's title id matches the installed game: | shadowmount | yes | yes | no | | preinstall / unknown | yes | no | no | -Two independent guards stop the wrong package being installed: the patch's title -id (read from its download URL) must match the game, and just before install the -real title id is read back from the package -(`sceAppInstUtilGetTitleIdFromPkg`) and checked again. A cross-region or -cross-title package is refused instead of installed as a phantom title. +Two independent guards stop the wrong target being installed: the patch target +id (read from `version.xml` / `manifest_url`) must match the installed game, and +the install call receives the installed game's content id from app.db. Sony may +store the actual patch bytes under a regional/master title id that differs from +the target; that storage id is accepted only when `version.xml` targets the +installed title. A true target-title mismatch is refused instead of installed as +a phantom title. ## Build @@ -69,9 +71,13 @@ http://:12880/ ## Status -0.0.1, early. Title scan, version resolution, firmware-compatibility filtering, -download, and install work and have been verified on firmware 11.60. Open items: -the web UI marks a title "Installing…" but reads progress from the PS5's own -notifications rather than a percentage; config persistence and a download queue -are not built yet; disc-based games need the disc inserted for their patch to -apply (a normal Sony requirement). +0.0.2, early. Title scan, source classification, version resolution, +firmware-compatibility filtering, download, and install work and have been +verified on firmware 11.60. Patches download internally to `/data/patchdl` and +are removed once the install has applied. Each title has a single action button +(Download then Install, or Update when "install after download" is on). Open +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. diff --git a/scripts/deploy_ps5.sh b/scripts/deploy_ps5.sh index a2d6c33..25e3e63 100755 --- a/scripts/deploy_ps5.sh +++ b/scripts/deploy_ps5.sh @@ -1,8 +1,8 @@ #!/bin/sh # Deploy patchdl to the PS5 via the Payload Manager HTTP API (port 8084). # The uploaded filename carries the version so it is identifiable in the -# Payload Manager UI. patchdl self-kills any running instance, so this is -# an idempotent redeploy. +# Payload Manager UI. Any running instance is killed first (the payload's own +# self-kill is racy when the port is still held), so this is a clean redeploy. # # Usage: scripts/deploy_ps5.sh [PS5_HOST] # PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box @@ -40,6 +40,30 @@ for p in paths: ') [ -n "$PAYLOAD_PATH" ] || { echo "uploaded payload not found in list_payloads" >&2; exit 1; } +# Kill any running patchdl first so the new instance can bind the port +# deterministically (the in-payload self-kill races on the held socket). +echo "Stopping any running patchdl ..." +curl -fsS -m15 "http://$HOST:$PM_PORT/processes_list" 2>/dev/null | \ + HOST="$HOST" PM_PORT="$PM_PORT" python3 -c ' +import os, sys, json, urllib.request +obj = json.load(sys.stdin) +procs = obj if isinstance(obj, list) else obj.get("processes", []) +host, port = os.environ["HOST"], os.environ["PM_PORT"] +for p in procs: + if "patchdl" in str(p.get("name", "")).lower(): + try: + urllib.request.urlopen("http://%s:%s/process_kill?pid=%d" % (host, port, int(p["pid"])), timeout=10).read() + print(" killed pid %d" % int(p["pid"])) + except Exception as e: + print(" kill pid %s failed: %s" % (p.get("pid"), e)) +' || true +i=0 +while [ "$i" -lt 8 ]; do + curl -fsS -m4 "http://$HOST:$HTTP_PORT/api/status" >/dev/null 2>&1 || break + sleep 1 + i=$((i + 1)) +done + echo "Launching $PAYLOAD_PATH ..." curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null diff --git a/src/patchdl_install.c b/src/patchdl_install.c index e59f603..11d76bf 100644 --- a/src/patchdl_install.c +++ b/src/patchdl_install.c @@ -1,5 +1,8 @@ #include "patchdl_install.h" +#include +#include +#include #include #include @@ -7,6 +10,7 @@ #include #include #include +#include #include /* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew @@ -70,6 +74,7 @@ static ai_title_from_pkg_fn ai_title_from_pkg; static volatile int g_stage; static int g_err; static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; +static char g_probe_json[2048]; /* filled by the backend thread */ static intptr_t dynsym(const char *module, const char *sym) { @@ -79,6 +84,92 @@ dynsym(const char *module, const char *sym) { return kernel_dynlib_dlsym(-1, h, sym); } +static void +local_ip(char *out, size_t n) { + struct ifaddrs *ifa = NULL, *p; + + out[0] = '\0'; + if (getifaddrs(&ifa)) + return; + + for (p = ifa; p; p = p->ifa_next) { + char ip[INET_ADDRSTRLEN]; + struct sockaddr_in *s; + + if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET) + continue; + s = (struct sockaddr_in *)p->ifa_addr; + if (!inet_ntop(AF_INET, &s->sin_addr, ip, sizeof(ip))) + continue; + if (strcmp(ip, "127.0.0.1") && strncmp(ip, "0.", 2)) { + strncpy(out, ip, n - 1); + out[n - 1] = '\0'; + break; + } + } + freeifaddrs(ifa); +} + +/* Resolve (dlsym, never call) a list of candidate patch-install symbols and + record which exist. Runs inside the backend thread, where the AppInstUtil + module is already loaded — the same proven-safe context as the normal symbol + resolution. No sysmod_load and no calls, so it is side-effect free. */ +static void +fill_probe(void) { + static const char *ai_syms[] = { + "sceAppInstUtilInitialize", + "sceAppInstUtilAppInstallPkg", + "sceAppInstUtilAppInstallTitleDir", /* takes an explicit title id */ + "sceAppInstUtilInstallByPackage", + "sceAppInstUtilInstallByPackageEx", + "sceAppInstUtilGetTitleIdFromPkg", + "sceAppInstUtilGetContentIdFromPkg", + "sceAppInstUtilAppExist", + "sceAppInstUtilAppGetInstallStatus", + "sceAppInstUtilAppInstallStatus", + "sceAppInstUtilAppUnInstall", + "sceAppInstUtilGetMetaInfoFromPkg", + "sceAppInstUtilUpdateTitleByTitleId", + "sceAppInstUtilInstallByChunk", + NULL + }; + static const char *bgft_syms[] = { + "sceBgftInitialize", + "sceBgftServiceIntInit", + "sceBgftServiceDownloadRegisterTask", + "sceBgftServiceDownloadRegisterTaskByStorage", + "sceBgftServiceDownloadRegisterTaskByStorageEx", + "sceBgftServiceIntDownloadRegisterTaskByStorageEx", + "sceBgftServiceDownloadStartTask", + "sceBgftServiceDownloadGetProgress", + "sceBgftServiceInstallPackage", + NULL + }; + uint32_t h = 0; + int ai_loaded = (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) >= 0); + int bgft_loaded = (kernel_dynlib_handle(-1, "libSceBgft.sprx", &h) >= 0); + 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 * backend_init_thread(void *arg) { (void)arg; @@ -107,6 +198,10 @@ backend_init_thread(void *arg) { "sceAppInstUtilInstallByPackage"); ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx", "sceAppInstUtilGetTitleIdFromPkg"); + + /* Read-only feasibility probe — module is loaded, safe context. */ + fill_probe(); + if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) { g_stage = -3; return NULL; @@ -161,12 +256,32 @@ patchdl_install_backend_check(char *msg, size_t msg_sz) { return (s == 5) ? 0 : -1; } +/* Public getter: trigger the backend (which fills the probe in its own thread) + and return the cached result. Runs from the MHD worker thread, so it only + reads the cached string — it never loads modules or resolves symbols here. */ +int +patchdl_install_api_probe(char *out, size_t out_sz) { + 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 patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, + const char *storage_title_id, + const char *target_content_id, char *msg, size_t msg_sz) { char sdk_path[1024]; + char pkg_tid[48] = {0}; struct stat st; int rc; + int pkg_tid_mismatch = 0; + const char *last_uri = ""; if (!local_path || !local_path[0]) { snprintf(msg, msg_sz, "no package path"); @@ -183,63 +298,121 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, return -1; } - /* The install service runs in its own sandbox that sees the user - partition as /user/data, not /data — remap so it can read the file. */ + /* AppInstallPkg runs in a sandbox that sees the user partition as + /user/data, not /data. InstallByPackage is different: the shell/debug + installer path takes the normal /data/... URI, so keep `local_path` for + that API and use `sdk_path` only for AppInstallPkg / metadata probes. */ if (!strncmp(local_path, "/data/", 6)) snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path); else snprintf(sdk_path, sizeof(sdk_path), "%s", local_path); - /* GUARD: read the PKG's own title id and refuse if it does not match the - installed game. A cross-title/region package (e.g. a US PPSA03098 patch - on an EU PPSA03099 install) would otherwise be registered as a separate - phantom title instead of patching the game. */ + /* Diagnostic guard: Sony sometimes stores one patch byte stream under a + master title id while the version.xml targets a regional title id. That + is valid only when the caller supplies target metadata, so do not feed + such packages to the raw AppInstallPkg path. */ + if (storage_title_id && storage_title_id[0] && + expected_title_id && expected_title_id[0] && + strncmp(storage_title_id, expected_title_id, 9) != 0) { + pkg_tid_mismatch = 1; + strncpy(pkg_tid, storage_title_id, sizeof(pkg_tid) - 1); + } if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) { - char pkg_tid[48] = {0}; int is_app = 0; if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] && strncmp(pkg_tid, expected_title_id, 9) != 0) { - snprintf(msg, msg_sz, - "refused: package is for %.12s, installed game is %.12s " - "(cross-title/region)", pkg_tid, expected_title_id); - return -1; + pkg_tid_mismatch = 1; } } - - /* Primary: direct package install. */ - { - ai_pkg_info_t pkg = {0}; - rc = ai_install_pkg(sdk_path, &pkg); - if (rc == 0) { - snprintf(msg, msg_sz, "install started (AppInstallPkg)"); - return 0; - } + if (pkg_tid_mismatch && (!target_content_id || !target_content_id[0])) { + snprintf(msg, msg_sz, + "refused: package metadata is %.12s, target is %.12s", + pkg_tid, expected_title_id); + return -1; } - /* Fallback: InstallByPackage with a file:// URI. */ + /* Preferred path: InstallByPackage accepts target metadata. Use it first, + and use it exclusively when the downloaded bytes report a master/storage + title id that differs from the target regional title id. */ { char file_uri[1100]; + char http_loop_uri[1200] = {0}; + char http_lan_uri[1200] = {0}; + const char *uris[4]; ai_meta_info_t meta = {0}; ai_pkg_info_t pkg = {0}; ai_playgo_info_t playgo = {0}; int rc2; + const char *title_dir; + const char *file_base; - snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path); - meta.uri = file_uri; + snprintf(file_uri, sizeof(file_uri), "file://%s", local_path); + title_dir = strstr(local_path, "/data/patchdl/"); + file_base = strrchr(local_path, '/'); + if (title_dir && file_base && file_base > title_dir + strlen("/data/patchdl/")) { + char title_id[32] = {0}; + const char *t = title_dir + strlen("/data/patchdl/"); + size_t tlen = (size_t)(file_base - t); + if (tlen > 0 && tlen < sizeof(title_id)) { + char ip[INET_ADDRSTRLEN] = {0}; + memcpy(title_id, t, tlen); + snprintf(http_loop_uri, sizeof(http_loop_uri), + "http://127.0.0.1:%d/api/pkg/%s/%s", + PATCHDL_HTTP_PORT, title_id, file_base + 1); + local_ip(ip, sizeof(ip)); + if (ip[0]) + snprintf(http_lan_uri, sizeof(http_lan_uri), + "http://%s:%d/api/pkg/%s/%s", + ip, PATCHDL_HTTP_PORT, title_id, file_base + 1); + } + } + uris[0] = local_path; + uris[1] = file_uri; + uris[2] = http_loop_uri[0] ? http_loop_uri : NULL; + uris[3] = http_lan_uri[0] ? http_lan_uri : NULL; meta.ex_uri = ""; meta.playgo_scenario_id = ""; - meta.content_id = ""; + meta.content_id = target_content_id ? target_content_id : ""; meta.content_name = "PatchDL"; meta.icon_url = ""; - rc2 = ai_install_by_package(&meta, &pkg, &playgo); + for (int i = 0; i < 4; i++) { + if (!uris[i]) continue; + memset(&pkg, 0, sizeof(pkg)); + memset(&playgo, 0, sizeof(playgo)); + meta.uri = uris[i]; + last_uri = uris[i]; + rc2 = ai_install_by_package(&meta, &pkg, &playgo); + if (rc2 == 0) { + snprintf(msg, msg_sz, "install started (InstallByPackage%s)", + pkg_tid_mismatch ? ", shared master bytes" : ""); + return 0; + } + } + rc = rc2; + } + + if (pkg_tid_mismatch) { + snprintf(msg, msg_sz, + "install rejected (InstallByPackage=0x%08x, pkg %.12s, target %.12s, uri %.96s)", + (unsigned)rc, pkg_tid, expected_title_id ? expected_title_id : "", + last_uri); + return rc ? rc : -1; + } + + /* Last resort for normal same-title packages only. This path has no target + metadata parameter, so it is intentionally skipped for shared-master + region bytes. */ + { + ai_pkg_info_t pkg = {0}; + int rc2 = ai_install_pkg(sdk_path, &pkg); if (rc2 == 0) { - snprintf(msg, msg_sz, "install started (InstallByPackage)"); + snprintf(msg, msg_sz, "install started (AppInstallPkg)"); return 0; } snprintf(msg, msg_sz, - "install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)", + "install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)", (unsigned)rc, (unsigned)rc2); - return rc2; + return rc2 ? rc2 : (rc ? rc : -1); } } diff --git a/src/patchdl_install.h b/src/patchdl_install.h index 4e71cef..bfb12af 100644 --- a/src/patchdl_install.h +++ b/src/patchdl_install.h @@ -13,13 +13,21 @@ * (official) titles and obtain explicit user intent before calling. */ /* `expected_title_id` is the title id of the installed game the patch is for. - The PKG's own title id is read and must match, else the install is refused - (prevents a cross-region/cross-title package being installed as a new - phantom title). */ + `storage_title_id` is the title id embedded in the delta_url storage path. + `target_content_id` is the installed game's content id from app.db; when + present it is passed to InstallByPackage so Sony's installer has the target + metadata even for region-shared/master-storage patch bytes. */ int patchdl_install_local_pkg(const char *local_path, const char *expected_title_id, + const char *storage_title_id, + const char *target_content_id, char *msg, size_t msg_sz); /* Verify the AppInstUtil backend can be loaded + resolved + initialized, WITHOUT performing any install. Returns 0 if ready. Safe to call. */ int patchdl_install_backend_check(char *msg, size_t msg_sz); + +/* Read-only feasibility probe: resolve (dlsym, never call) a list of candidate + AppInstUtil/Bgft patch-install symbols and report which exist on this + firmware. Writes a JSON object into `out`. No install, no side effects. */ +int patchdl_install_api_probe(char *out, size_t out_sz); diff --git a/src/patchdl_scan.c b/src/patchdl_scan.c index d03ffc8..46d6e5d 100644 --- a/src/patchdl_scan.c +++ b/src/patchdl_scan.c @@ -229,20 +229,37 @@ is_game_title(const char *title_id) { /* ---------- directory scanner ------------------------------------------- */ +/* Authoritative shadowmount test: ShadowMountPlus routes its images through + /mnt/shadowmnt (a pfs from /dev/lvdN there, then a nullfs onto the app dir), + so a title whose mount table references /mnt/shadowmnt is a shadowmount. The + on-disk mount.lnk marker is unreliable across reboots/remounts; the live + mount table is not. */ +static int +mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts) { + for (int i = 0; i < nmounts; i++) { + const char *from = mounts[i].f_mntfromname; + const char *on = mounts[i].f_mntonname; + if ((strstr(from, "/mnt/shadowmnt") || strstr(on, "/mnt/shadowmnt")) && + (strstr(from, title_id) || strstr(on, title_id))) + return 1; + } + return 0; +} + /* - * Distinguish genuine installs from ShadowMountPlus mounts by on-disk layout - * under /user/app// (verified on fw 11.60): - * - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount - * - app.pkg (no mount.lnk) -> genuine install; app.json - * with CDN piece URLs means a not-downloaded preinstall stub, local - * URLs mean a real install - * - app.json with "fake":true -> homebrew fake (skip) + * Classify each /user/app/ (verified on fw 11.60): + * - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus + * mount (authoritative; mount.lnk is only a fallback hint) + * - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece + * URLs means a not-downloaded preinstall stub, local URLs a real install + * - app.json with "fake":true -> homebrew fake (skip) */ static int -scan_one(const char *base, const char *name, patchdl_title_t *t) { +scan_one(const char *base, const char *name, patchdl_title_t *t, + const struct statfs *mounts, int nmounts) { char dir[PATH_MAX]; char appjson[4096]; - int has_mountlnk, has_app_pkg, has_paramjson, is_fake = 0, is_cdn = 0; + int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0, is_cdn = 0; snprintf(dir, sizeof(dir), "%s/%s", base, name); memset(t, 0, sizeof(*t)); @@ -252,6 +269,7 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) { if (!is_game_title(t->title_id)) return -1; + is_shadow = mount_is_shadow(t->title_id, mounts, nmounts); has_mountlnk = path_exists(dir, "mount.lnk") || path_exists(dir, "mount_img.lnk"); has_app_pkg = path_exists(dir, "app.pkg"); @@ -266,19 +284,18 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) { if (is_fake) return -1; - if (has_mountlnk) { - /* metadata lives in the mounted sce_sys (param.json, or param.sfo - for PS4 titles) */ - if (try_param_json(dir, t)) + /* app.pkg is the on-disk package of a genuine install; ShadowMountPlus + titles never have it (they have mounted/leftover sce_sys content). So + app.pkg is the reliable genuine-vs-shadow discriminator — independent of + whether the shadow image is currently mounted. */ + if (has_app_pkg) { + t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN /* CDN pkg = preinstall */ + : PATCHDL_SOURCE_OFFICIAL; + } else if (is_shadow || has_mountlnk || has_paramjson || + path_exists(dir, "sce_sys/param.sfo")) { + if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */ try_param_sfo(dir, t); t->source_type = PATCHDL_SOURCE_SHADOWMOUNT; - } else if (has_app_pkg) { - t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN - : PATCHDL_SOURCE_OFFICIAL; - } else if (has_paramjson || path_exists(dir, "sce_sys/param.sfo")) { - if (try_param_json(dir, t)) /* genuine game currently mounted */ - try_param_sfo(dir, t); - t->source_type = PATCHDL_SOURCE_OFFICIAL; } else { return -1; /* empty / leftover directory */ } @@ -300,7 +317,8 @@ already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) { } static void -scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) { +scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap, + const struct statfs *mounts, int nmounts) { DIR *d; struct dirent *de; @@ -310,7 +328,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) { while ((de = readdir(d))) { if (de->d_name[0] == '.') continue; if (*cnt >= cap) break; - if (scan_one(base, de->d_name, &arr[*cnt]) != 0) + if (scan_one(base, de->d_name, &arr[*cnt], mounts, nmounts) != 0) continue; if (already_seen(arr, *cnt, arr[*cnt].title_id)) continue; /* dedupe a title already found in an earlier base */ @@ -369,6 +387,9 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) { intptr_t saved_root = 0, root_vnode; int using_vswap = 0; + struct statfs *mounts = NULL; + int nmounts; + arr = calloc(MAX_TITLES, sizeof(*arr)); if (!arr) return -1; @@ -376,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. */ kernel_set_ucred_authid(pid, 0x4801000000000013L); + /* Global mount table for shadowmount detection. getmntinfo's buffer is + libc-managed — must NOT be freed. */ + nmounts = getmntinfo(&mounts, MNT_NOWAIT); + if (nmounts < 0) { nmounts = 0; mounts = NULL; } + root_vnode = kernel_get_root_vnode(); if (root_vnode) { saved_root = kernel_get_proc_rootdir(pid); @@ -384,7 +410,7 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) { } 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); diff --git a/src/patchdl_scan.h b/src/patchdl_scan.h index 5c31b9f..6578e26 100644 --- a/src/patchdl_scan.h +++ b/src/patchdl_scan.h @@ -24,8 +24,10 @@ typedef struct { char latest_version[16]; char latest_required_fw[16]; char patch_url[512]; /* delta_url of the compatible patch */ - char patch_title_id[16]; /* title id embedded in patch_url */ + char patch_title_id[16]; /* target title id from version.xml */ + char patch_storage_title_id[16]; /* title id embedded in delta_url */ int verxml_done; + int enabled; /* user policy, persisted in config.json */ } patchdl_title_t; int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out); diff --git a/src/patchdl_version.h b/src/patchdl_version.h index 50c392f..2907afc 100644 --- a/src/patchdl_version.h +++ b/src/patchdl_version.h @@ -1,3 +1,3 @@ #pragma once -#define PATCHDL_VERSION "0.0.1" +#define PATCHDL_VERSION "0.0.2" diff --git a/src/patchdl_verxml.c b/src/patchdl_verxml.c index a172900..c38f6e7 100644 --- a/src/patchdl_verxml.c +++ b/src/patchdl_verxml.c @@ -57,8 +57,8 @@ ver_gt(const char *a, const char *b) { return strcmp(a, b) > 0; } -/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03098) - from a string such as a delta_url. Used to detect cross-title patches. */ +/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03099) + from a string such as nptitleid, manifest_url, or delta_url. */ static void extract_title_id(const char *s, char *out, size_t sz) { out[0] = '\0'; @@ -77,9 +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, "'); + if (!tag_end) return; + tag_end++; + + if (!attr_val(p, tag_end, "nptitleid", nptitleid, sizeof(nptitleid))) + extract_title_id(nptitleid, out, sz); +} + static void parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) { const char *p = xml; + char root_title[16] = {0}; + + parse_root_title_id(xml, root_title, sizeof(root_title)); while ((p = strstr(p, "'); @@ -89,12 +111,14 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) { char ver[16] = {0}; char sver[16] = {0}; char durl[512] = {0}; + char murl[512] = {0}; /* PS5 version.xml uses content_ver; PS4 uses version. */ if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver))) attr_val(p, tag_end, "version", ver, sizeof(ver)); attr_val(p, tag_end, "system_ver", sver, sizeof(sver)); attr_val(p, tag_end, "delta_url", durl, sizeof(durl)); + attr_val(p, tag_end, "manifest_url", murl, sizeof(murl)); if (ver[0] && sver[0]) { uint32_t pkg_sver = parse_hex(sver); @@ -114,8 +138,18 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) { sizeof(out->compatible_version) - 1); strncpy(out->compatible_url, durl, sizeof(out->compatible_url) - 1); - extract_title_id(durl, out->compatible_title, - sizeof(out->compatible_title)); + extract_title_id(durl, out->compatible_storage_title, + sizeof(out->compatible_storage_title)); + if (root_title[0]) { + strncpy(out->compatible_title, root_title, + sizeof(out->compatible_title) - 1); + } else { + extract_title_id(murl, out->compatible_title, + sizeof(out->compatible_title)); + if (!out->compatible_title[0]) + extract_title_id(durl, out->compatible_title, + sizeof(out->compatible_title)); + } } } } diff --git a/src/patchdl_verxml.h b/src/patchdl_verxml.h index e598879..b4c7306 100644 --- a/src/patchdl_verxml.h +++ b/src/patchdl_verxml.h @@ -7,7 +7,8 @@ typedef struct { char latest_version[16]; /* highest pkg overall, or "" */ char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */ char compatible_url[512]; /* delta_url of the chosen compatible pkg */ - char compatible_title[16]; /* title id embedded in that delta_url */ + char compatible_title[16]; /* target title id from version.xml/manifest_url */ + char compatible_storage_title[16]; /* title id embedded in delta_url storage path */ } patchdl_verinfo_t; int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out); diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 0da125b..c8a0abd 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -24,13 +25,26 @@ static size_t g_title_count; static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; static char g_status_json[512]; static char *g_debug_json; /* built once at startup */ +static unsigned long g_pkg_hits; +static char g_pkg_diag_json[768] = "{\"hits\":0}"; -static const char config_json[] = - "{" - "\"default_policy\":\"deny\"," - "\"download_dir\":\"/mnt/usb0/patches\"," - "\"install_after_download\":false," - "\"delete_pkg_after_install\":false," +#define PATCHDL_DL_DIR "/data/patchdl" +#define PATCHDL_CFG_PATH "/data/patchdl/config.json" + +/* Persisted, user-editable settings. Per-title enable/disable lives in the + title structs (t->enabled) so it travels with the scan; the global fields + live here. Both are saved to PATCHDL_CFG_PATH (homebrew data dir, never a + system file) and reloaded on the next start. Guarded by g_mutex. */ +static struct { + char default_policy[8]; /* "deny" | "allow" */ + int install_after_download; + int delete_pkg_after_install; +} 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\":{" "\"official\":{\"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[] = "[]"; +/* ---------- 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 ------------------------------------------------ */ typedef struct { @@ -156,6 +207,139 @@ queue_asset(struct MHD_Connection *conn, const char *url) { 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//<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 ------------------------------------------------- */ static void @@ -166,10 +350,95 @@ rebuild_status_json(void) { "\"dns_guard\":\"Active\"," "\"resolver\":\"Internal allowlist\"," "\"free_space_mb\":0," - "\"download_dir\":\"/mnt/usb0/patches\"}", + "\"download_dir\":\"/data/patchdl (internal)\"}", 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) ----------------- */ static const char * @@ -227,13 +496,15 @@ build_titles_json(void) { jbuf_append_ver_or_null(&j, t->version_file_uri); jbuf_append(&j, ",\"patch_title_id\":"); jbuf_append_ver_or_null(&j, t->patch_title_id); - /* The patch package's title must match the game; a mismatch means a - cross-region/title package that must NOT be installed. */ + jbuf_append(&j, ",\"patch_storage_title_id\":"); + jbuf_append_ver_or_null(&j, t->patch_storage_title_id); + /* This is the target title id parsed from version.xml/manifest_url. + CDN storage paths may use another regional/master title id; that is + not exposed here and must not block a valid target match. */ jbuf_appendf(&j, ",\"patch_title_match\":%s", (!t->patch_title_id[0] || !strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false"); - jbuf_appendf(&j, ",\"enabled\":%s", - t->source_type == PATCHDL_SOURCE_UNKNOWN ? "false" : "true"); + jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false"); jbuf_append(&j, ",\"mode\":"); jbuf_append_str(&j, title_mode_str(t->source_type)); jbuf_append(&j, ",\"queued\":false"); @@ -249,6 +520,21 @@ build_titles_json(void) { /* ---------- 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 * verxml_fetch_thread(void *arg) { (void)arg; @@ -278,8 +564,15 @@ verxml_fetch_thread(void *arg) { sizeof(t->patch_url) - 1); strncpy(t->patch_title_id, info.compatible_title, sizeof(t->patch_title_id) - 1); + strncpy(t->patch_storage_title_id, info.compatible_storage_title, + sizeof(t->patch_storage_title_id) - 1); t->verxml_done = 1; + int up_to_date = (t->installed_version[0] && info.compatible_version[0] && + strcmp(t->installed_version, info.compatible_version) >= 0); pthread_mutex_unlock(&g_mutex); + + if (up_to_date && info.compatible_url[0]) + cleanup_installed_download(t->title_id, info.compatible_url); } return NULL; } @@ -290,7 +583,9 @@ verxml_fetch_thread(void *arg) { static int get_title_action_info(const char *title_id, patchdl_source_t *src, char *patch_url, size_t url_sz, - char *patch_title_id, size_t pt_sz) { + char *patch_title_id, size_t pt_sz, + char *patch_storage_title_id, size_t pst_sz, + char *content_id, size_t ci_sz, int *enabled) { int found = 0; 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'; strncpy(patch_title_id, g_titles[i].patch_title_id, pt_sz - 1); patch_title_id[pt_sz - 1] = '\0'; + strncpy(patch_storage_title_id, g_titles[i].patch_storage_title_id, pst_sz - 1); + patch_storage_title_id[pst_sz - 1] = '\0'; + strncpy(content_id, g_titles[i].content_id, ci_sz - 1); + content_id[ci_sz - 1] = '\0'; + *enabled = g_titles[i].enabled; found = 1; break; } @@ -309,7 +609,28 @@ get_title_action_info(const char *title_id, patchdl_source_t *src, return found; } -#define PATCHDL_DL_DIR "/data/patchdl" +/* Persist a per-title enable/disable toggle. */ +static enum MHD_Result +set_title_enabled(struct MHD_Connection *conn, const char *title_id, int en) { + int found = 0; + char r[64]; + + pthread_mutex_lock(&g_mutex); + for (size_t i = 0; i < g_title_count; i++) { + if (!strcmp(g_titles[i].title_id, title_id)) { + g_titles[i].enabled = en; + found = 1; + break; + } + } + pthread_mutex_unlock(&g_mutex); + + if (!found) return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title"); + + save_config(); + snprintf(r, sizeof(r), "{\"ok\":true,\"enabled\":%s}", en ? "true" : "false"); + return queue_json_owned(conn, MHD_HTTP_OK, strdup(r)); +} /* Local on-disk path a title's patch downloads to / installs from. */ static void @@ -322,10 +643,14 @@ title_pkg_path(const char *title_id, const char *patch_url, static enum MHD_Result do_download(struct MHD_Connection *conn, const char *title_id, - patchdl_source_t src, const char *patch_url) { + patchdl_source_t src, const char *patch_url, int enabled) { char dir[256], dest[320], resp[640]; long long bytes = 0; + if (!enabled) + return queue_json(conn, MHD_HTTP_FORBIDDEN, + "{\"ok\":false,\"reason\":\"title_disabled\"}"); + if (src == PATCHDL_SOURCE_UNKNOWN) return queue_json(conn, MHD_HTTP_FORBIDDEN, "{\"ok\":false,\"reason\":\"source_unknown\"}"); @@ -358,10 +683,15 @@ do_download(struct MHD_Connection *conn, const char *title_id, static enum MHD_Result do_install(struct MHD_Connection *conn, const char *title_id, patchdl_source_t src, const char *patch_url, - const char *patch_title_id) { + const char *patch_title_id, const char *patch_storage_title_id, + const char *content_id, int enabled) { char dest[320], msg[256], resp[640]; int rc; + if (!enabled) + return queue_json(conn, MHD_HTTP_FORBIDDEN, + "{\"ok\":false,\"reason\":\"title_disabled\"}"); + /* Source policy: only genuine installs may be patched in place. Shadowmounts are download-only; unknown/preinstall are blocked. */ if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL) @@ -372,8 +702,9 @@ do_install(struct MHD_Connection *conn, const char *title_id, return queue_json(conn, MHD_HTTP_CONFLICT, "{\"ok\":false,\"reason\":\"no_compatible_patch\"}"); - /* GUARD (app layer): the patch package's title id must match the game. - A cross-title/region patch would install as a phantom title. */ + /* GUARD (app layer): the version.xml target title id must match the game. + CDN storage under a different regional/master id is valid and has + already been normalized by patchdl_verxml. */ if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) { snprintf(resp, sizeof(resp), "{\"ok\":false,\"reason\":\"patch_title_mismatch\"," @@ -384,9 +715,8 @@ do_install(struct MHD_Connection *conn, const char *title_id, title_pkg_path(title_id, patch_url, dest, sizeof(dest)); - /* GUARD (authoritative): patchdl_install reads the PKG's real title id and - refuses if it does not match `title_id`. */ - rc = patchdl_install_local_pkg(dest, title_id, msg, sizeof(msg)); + rc = patchdl_install_local_pkg(dest, title_id, patch_storage_title_id, + content_id, msg, sizeof(msg)); snprintf(resp, sizeof(resp), "{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}", rc == 0 ? "true" : "false", rc, msg, dest); @@ -425,6 +755,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) { char action[24]; char patch_url[512] = {0}; char patch_title_id[16] = {0}; + char patch_storage_title_id[16] = {0}; + char content_id[64] = {0}; + int enabled = 0; patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN; 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"); 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"); + if (!strcmp(action, "enable")) + return set_title_enabled(conn, title_id, 1); + + if (!strcmp(action, "disable")) + return set_title_enabled(conn, title_id, 0); + if (!strcmp(action, "download")) - return do_download(conn, title_id, src, patch_url); + return do_download(conn, title_id, src, patch_url, enabled); if (!strcmp(action, "check")) return queue_json(conn, MHD_HTTP_ACCEPTED, "{\"ok\":true,\"queued\":true,\"action\":\"check\"}"); if (!strcmp(action, "install")) - return do_install(conn, title_id, src, patch_url, patch_title_id); + return do_install(conn, title_id, src, patch_url, patch_title_id, + patch_storage_title_id, content_id, enabled); return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); } +/* ---------- POST body accumulation ------------------------------------- */ + +/* MHD delivers a POST body across several callback invocations. We stash a + growing buffer in con_cls and dispatch once the body is complete. */ +typedef struct { + char *data; + size_t len; +} post_body_t; + +static void +request_completed(void *cls, struct MHD_Connection *conn, void **con_cls, + enum MHD_RequestTerminationCode toe) { + (void)cls; (void)conn; (void)toe; + post_body_t *pb = *con_cls; + if (pb) { + free(pb->data); + free(pb); + *con_cls = NULL; + } +} + +static enum MHD_Result +handle_config_post(struct MHD_Connection *conn, const char *body) { + char pol[8]; + + json_get_str(body, "default_policy", pol, sizeof(pol)); + + pthread_mutex_lock(&g_mutex); + if (pol[0]) { + strncpy(g_cfg.default_policy, pol, sizeof(g_cfg.default_policy) - 1); + g_cfg.default_policy[sizeof(g_cfg.default_policy) - 1] = '\0'; + } + g_cfg.install_after_download = + json_get_bool(body, "install_after_download", g_cfg.install_after_download); + g_cfg.delete_pkg_after_install = + json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install); + pthread_mutex_unlock(&g_mutex); + + save_config(); + return queue_json_owned(conn, MHD_HTTP_OK, build_config_json()); +} + static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn, const char *url, const char *method, const char *version, const char *upload_data, size_t *upload_data_size, void **con_cls) { - (void)cls; (void)version; (void)upload_data; (void)con_cls; + (void)cls; (void)version; if (!strcmp(method, MHD_HTTP_METHOD_OPTIONS)) return queue_text(conn, MHD_HTTP_NO_CONTENT, ""); if (!strcmp(method, MHD_HTTP_METHOD_POST)) { - if (*upload_data_size) { *upload_data_size = 0; return MHD_YES; } + post_body_t *pb = *con_cls; + + if (!pb) { /* first call: set up the buffer */ + pb = calloc(1, sizeof(*pb)); + if (!pb) return MHD_NO; + *con_cls = pb; + return MHD_YES; + } + + if (*upload_data_size) { /* a body chunk: append it */ + char *n = realloc(pb->data, pb->len + *upload_data_size + 1); + if (n) { + memcpy(n + pb->len, upload_data, *upload_data_size); + pb->len += *upload_data_size; + n[pb->len] = '\0'; + pb->data = n; + } + *upload_data_size = 0; + return MHD_YES; + } + + /* final call: the full body (if any) is in pb->data */ + const char *body = pb->data ? pb->data : ""; if (!strcmp(url, "/api/config")) - return queue_json(conn, MHD_HTTP_OK, config_json); + return handle_config_post(conn, body); if (!strncmp(url, "/api/titles/", 12)) return handle_title_action(conn, url); return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); @@ -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); if (!strcmp(url, "/api/config")) - return queue_json(conn, MHD_HTTP_OK, config_json); + return queue_json_owned(conn, MHD_HTTP_OK, build_config_json()); if (!strcmp(url, "/api/titles")) return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json()); @@ -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)); } + if (!strcmp(url, "/api/apiprobe")) { + char p[2048]; + patchdl_install_api_probe(p, sizeof(p)); + return queue_json_owned(conn, MHD_HTTP_OK, strdup(p)); + } + + if (!strcmp(url, "/api/pkgdiag")) + return queue_json(conn, MHD_HTTP_OK, g_pkg_diag_json); + if (!strcmp(url, "/api/netcheck")) { char diag[1024] = "{\"error\":\"no title with version_file_uri\"}"; pthread_mutex_lock(&g_mutex); @@ -507,6 +923,9 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url, return queue_json(conn, MHD_HTTP_OK, diag); } + if (!strncmp(url, "/api/pkg/", 9)) + return queue_pkg_file(conn, url); + return queue_asset(conn, url); } @@ -527,6 +946,12 @@ patchdl_websrv_start(unsigned short port) { if (patchdl_scan(&g_titles, &g_title_count)) g_title_count = 0; + /* Default per-title policy (unknown sources off), then overlay anything the + user previously saved to /data/patchdl/config.json. */ + for (size_t i = 0; i < g_title_count; i++) + g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN); + load_config(); + /* Build the diagnostic dump now, while single-threaded — the root-vnode swap it performs is unsafe once MHD worker threads are running. */ g_debug_json = patchdl_scan_debug_json(); @@ -534,6 +959,7 @@ patchdl_websrv_start(unsigned short port) { web_daemon = MHD_start_daemon( MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION, port, NULL, NULL, &on_request, NULL, + MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL, MHD_OPTION_END); if (!web_daemon) { diff --git a/web/README.md b/web/README.md index c958a7a..9589545 100644 --- a/web/README.md +++ b/web/README.md @@ -18,6 +18,9 @@ GET /api/titles GET /api/downloads POST /api/titles/:title_id/check POST /api/titles/:title_id/download +POST /api/titles/:title_id/install +POST /api/titles/:title_id/enable +POST /api/titles/:title_id/disable ``` ## Policy Model @@ -27,9 +30,9 @@ The UI assumes deny-by-default behavior: ```json { "default_policy": "deny", - "download_dir": "/mnt/usb0/patches", + "download_dir": "/data/patchdl (internal)", "install_after_download": false, - "delete_pkg_after_install": false, + "delete_pkg_after_install": true, "source_policy": { "official": { "allow_check": true, "allow_download": true, "allow_install": true }, "external": { "allow_check": true, "allow_download": true, "allow_install": true }, diff --git a/web/app.js b/web/app.js index c5088e4..6c1183f 100644 --- a/web/app.js +++ b/web/app.js @@ -40,13 +40,13 @@ const fallback = { dns_guard: "Active", resolver: "Internal allowlist", free_space_mb: 64218, - download_dir: "/mnt/usb0/patches", + download_dir: "/data/patchdl (internal)", }, config: { default_policy: "deny", - download_dir: "/mnt/usb0/patches", + download_dir: "/data/patchdl (internal)", install_after_download: false, - delete_pkg_after_install: false, + delete_pkg_after_install: true, source_policy: { official: { allow_check: true, allow_download: true, allow_install: true }, external: { allow_check: true, allow_download: true, allow_install: true }, @@ -293,7 +293,7 @@ function renderGames() { function gameCategory(game) { if (game.installing) return "updatable"; // keep visible while the patch installs 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 (game.status === "available") return "updatable"; if (game.status === "incompatible_fw") return "needsfw"; @@ -322,7 +322,9 @@ function createGameCard(game) { title.className = "game-title"; const installBlocked = isInstallBlocked(game); title.innerHTML = ` - <div class="cover">${initials(game.name)}</div> + <div class="lead"> + <div class="cover">${initials(game.name)}</div> + </div> <div> <h3>${escapeHtml(game.name)}</h3> <div class="subline"> @@ -333,12 +335,34 @@ function createGameCard(game) { ${statusPill(game)} ${sourcePill(game)} ${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 class="source-path">${escapeHtml(sourceDetail(game))}</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"); versions.className = "version-grid"; versions.innerHTML = ` @@ -350,56 +374,23 @@ function createGameCard(game) { const controls = document.createElement("div"); 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"); actions.className = "actions-row"; - actions.innerHTML = ` - <button class="row-button" data-action="check"> - <svg><use href="#icon-refresh"></use></svg> - Check - </button> - <button class="row-button is-primary" data-action="download" ${!isDownloadAllowed(game) ? "disabled" : ""} title="${escapeHtml(downloadTitle(game))}"> - <svg><use href="#icon-download"></use></svg> - Download - </button> - <button class="row-button" data-action="install" ${(!isInstallAllowed(game) || game.installing) ? "disabled" : ""} title="${escapeHtml(installTitle(game))}"> - <svg><use href="#icon-download"></use></svg> - ${game.installing ? "Installing…" : "Install"} - </button> - `; + const act = rowAction(game); + if (act) { + const btn = document.createElement("button"); + btn.className = "row-button is-primary"; + btn.innerHTML = `<svg><use href="#icon-download"></use></svg> ${escapeHtml(act.label)}`; + btn.title = escapeHtml(act.hint || act.label); + if (act.disabled || !act.action) { + btn.disabled = true; + } else { + btn.addEventListener("click", () => runTitleAction(game.title_id, act.action)); + } + actions.appendChild(btn); + } - actions.querySelectorAll("button").forEach((button) => { - button.addEventListener("click", () => runTitleAction(game.title_id, button.dataset.action)); - }); - - controls.append(controlRow, actions); + controls.append(actions); card.append(title, versions, controls); 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) { if (game.installing) return `<span class="pill warn">Installing…</span>`; - const cat = gameCategory(game); - if (cat === "checking") return `<span class="pill">Checking…</span>`; + if (game.status === "checking") return `<span class="pill">Checking…</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 (cat === "updatable") return `<span class="pill ok">Update available</span>`; - if (cat === "needsfw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`; - return `<span class="pill">Up to date</span>`; + if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`; + if (game.status === "available") return `<span class="pill ok">Update available</span>`; + if (game.status === "incompatible_fw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</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) { @@ -445,15 +438,6 @@ function sourceDetail(game) { 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() { els.queueList.replaceChildren(); if (!state.downloads.length) { @@ -520,66 +504,78 @@ async function saveConfig() { 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) { const game = state.titles.find((item) => item.title_id === titleId); if (!game) return; - if (action === "download" && !isDownloadAllowed(game)) { - showToast(downloadTitle(game)); - return; - } - 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(); + if (action === "download") await doDownload(game); + else if (action === "install") await doInstall(game); + else if (action === "update") { if (await doDownload(game)) await doInstall(game); } } function updateGame(titleId, patch) { @@ -592,9 +588,18 @@ function updateGame(titleId, patch) { } Object.assign(game, patch); if (patch.mode === "disabled") game.enabled = false; - state.logs.push(`[${timeNow()}] Policy updated for ${titleId}`); 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) { @@ -614,7 +619,7 @@ function isInstallAllowed(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 (!game.compatible_version) return "No compatible update to install."; return "Install the downloaded patch (modifies the game)."; @@ -655,7 +660,7 @@ async function postJson(url, body) { } 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.", source_unknown: "Source unknown — blocked.", no_compatible_patch: "No compatible patch available.", diff --git a/web/index.html b/web/index.html index 3fc9bb9..924203b 100644 --- a/web/index.html +++ b/web/index.html @@ -53,7 +53,7 @@ <div class="brand-mark">PD</div> <div> <strong>PatchDL</strong> - <span>by Knutwurst · v0.0.1</span> + <span>by Knutwurst · v0.0.2</span> </div> </div> @@ -168,7 +168,7 @@ </label> <label class="field"> <span>Download Folder</span> - <input id="downloadDir" type="text" spellcheck="false" /> + <input id="downloadDir" type="text" spellcheck="false" readonly title="Patches always download internally and are removed after install" /> </label> <label class="switch-row"> <input id="installAfterDownload" type="checkbox" /> diff --git a/web/styles.css b/web/styles.css index 67a8cfe..19b9000 100644 --- a/web/styles.css +++ b/web/styles.css @@ -593,6 +593,74 @@ h2 { 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 { display: grid; gap: 8px;