mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 10:00:22 +02:00
PatchDL 0.0.1: scan, resolve, download and install PS5 game patches
Standalone PS5 payload with an embedded web UI on :12880, no etaHEN. - Scans installed titles and classifies the source (genuine install, ShadowMountPlus mount, preinstall, unknown) from the on-disk layout. - Reads name, installed version and the Sony version.xml URL from the PS5 app database (vendored SQLite). - Resolves version.xml past nanoDNS via a raw DNS query, TLS pinned to the SCEI DNAS root. - Filters patches to the newest one compatible with the current firmware. - Downloads the patch and installs it through AppInstUtil (sysmodule loaded at runtime), gated to genuine installs with a package title-id match guard. - Action-based UI filters and an on-screen startup notification with the URL.
This commit is contained in:
1 parent
4a14c689e5
commit
4718657174
31 files changed
+273765
-365
No files matched your search
@@ -5,6 +5,10 @@ build/
|
||||
node_modules/
|
||||
gen/
|
||||
*.elf
|
||||
*.o
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.toolchains/
|
||||
|
||||
# AI process artifacts — keep local, never commit/push (specs, plans, design docs)
|
||||
docs/superpowers/
|
||||
@@ -12,13 +12,38 @@ endif
|
||||
PYTHON ?= python3
|
||||
BIN := patchdl-ps5.elf
|
||||
|
||||
SRCS := src/main.c src/patchdl_assets.c src/patchdl_websrv.c
|
||||
SRCS := src/main.c \
|
||||
src/patchdl_assets.c \
|
||||
src/patchdl_websrv.c \
|
||||
src/patchdl_proc.c \
|
||||
src/patchdl_fw.c \
|
||||
src/patchdl_scan.c \
|
||||
src/patchdl_appdb.c \
|
||||
src/patchdl_net.c \
|
||||
src/patchdl_resolve.c \
|
||||
src/patchdl_verxml.c \
|
||||
src/patchdl_install.c \
|
||||
src/patchdl_notify.c
|
||||
|
||||
WEB_ASSETS := web/index.html web/styles.css web/app.js
|
||||
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
|
||||
|
||||
CFLAGS := -g -O2 -Wall -Werror -Isrc -I"$(ETAHEN_DEPS_DIR)/include" -DPATCHDL_HTTP_PORT=$(PATCHDL_HTTP_PORT)
|
||||
LDADD := -L"$(ETAHEN_DEPS_DIR)/lib" -lmicrohttpd -lkernel_sys
|
||||
SQLITE_DIR := vendor/sqlite
|
||||
SQLITE_OBJ := $(SQLITE_DIR)/sqlite3.o
|
||||
SQLITE_CFLAGS := -O2 -w \
|
||||
-DSQLITE_THREADSAFE=0 \
|
||||
-DSQLITE_OMIT_LOAD_EXTENSION \
|
||||
-DSQLITE_OMIT_WAL \
|
||||
-DSQLITE_DEFAULT_MEMSTATUS=0 \
|
||||
-DSQLITE_OMIT_DEPRECATED
|
||||
|
||||
CFLAGS := -g -O2 -Wall -Werror -Isrc -I"$(ETAHEN_DEPS_DIR)/include" -I"$(SQLITE_DIR)" -DPATCHDL_HTTP_PORT=$(PATCHDL_HTTP_PORT)
|
||||
LDADD := -L"$(ETAHEN_DEPS_DIR)/lib" -lmicrohttpd -lkernel_sys -lpthread
|
||||
|
||||
ifdef CURL_DIR
|
||||
CFLAGS += -DPATCHDL_HAVE_CURL -I"$(CURL_DIR)/include"
|
||||
LDADD += -L"$(CURL_DIR)/lib" -lcurl -lpsl -lssl -lcrypto -lzstd -lz
|
||||
endif
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
@@ -28,13 +53,16 @@ gen/web:
|
||||
gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
|
||||
$(PYTHON) scripts/gen_asset_module.py --path $* $< > $@
|
||||
|
||||
$(BIN): $(SRCS) $(GEN_SRCS)
|
||||
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
|
||||
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
|
||||
|
||||
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
||||
|
||||
test: $(BIN)
|
||||
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
|
||||
|
||||
clean:
|
||||
rm -rf $(BIN) gen
|
||||
rm -rf $(BIN) gen $(SQLITE_OBJ)
|
||||
|
||||
.PHONY: all clean test
|
||||
@@ -1,111 +1,77 @@
|
||||
# PatchDL
|
||||
|
||||
PatchDL is a planned standalone PS5 ELF payload for controlled official game
|
||||
patch downloads.
|
||||
A standalone PlayStation 5 ELF payload that downloads and installs official game
|
||||
patches on your terms. It serves its own web UI and runs without etaHEN.
|
||||
|
||||
The target design is deny-by-default:
|
||||
PatchDL is built for setups where nanoDNS blocks Sony's servers for the whole
|
||||
console. It resolves the Sony patch CDN on its own path, so the rest of the
|
||||
system stays offline and only the patches you pick get fetched.
|
||||
|
||||
- Sony domains stay blocked by nanoDNS for the normal PS5 system.
|
||||
- PatchDL uses its own internal allowlist resolver for Sony CDN hosts.
|
||||
- Games must be explicitly enabled before PatchDL checks or downloads updates.
|
||||
- Patch selection is capped to the highest update compatible with the current
|
||||
firmware.
|
||||
- Install actions are source-aware: shadowmounted and unknown titles are never
|
||||
installed by PatchDL.
|
||||
by Knutwurst
|
||||
|
||||
## Source Classification
|
||||
## What it does
|
||||
|
||||
PatchDL treats the title source as part of the safety policy:
|
||||
- Scans installed titles and classifies each one: genuine install,
|
||||
ShadowMountPlus mount, preinstall, or unknown.
|
||||
- Reads the title name, installed version, and the Sony `version.xml` URL from
|
||||
the PS5 app database.
|
||||
- Fetches each title's `version.xml` from Sony's CDN past nanoDNS (a raw DNS
|
||||
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
|
||||
- Picks the newest patch compatible with the current firmware
|
||||
(`system_ver <= firmware`), so an update never forces a firmware upgrade.
|
||||
- Downloads the patch package and installs it through Sony's AppInstUtil
|
||||
service.
|
||||
|
||||
## 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:
|
||||
|
||||
| Source | Check | Download | Install |
|
||||
|-----------------------|-------|----------|---------|
|
||||
| official | yes | yes | yes |
|
||||
| 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.
|
||||
|
||||
## Build
|
||||
|
||||
Requires `ps5-payload-dev/sdk`. The network and install features also need the
|
||||
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` placed in the SDK
|
||||
sysroot (`target/user/homebrew`); `scripts/build_ps5.sh` enables them
|
||||
automatically when present. libmicrohttpd is vendored under `vendor/etahen`, and
|
||||
SQLite is vendored under `vendor/sqlite`.
|
||||
|
||||
```sh
|
||||
scripts/build_ps5.sh # produces patchdl-ps5.elf
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port
|
||||
8084 (not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the ELF named with its
|
||||
version and launches it; the payload replaces any running instance itself.
|
||||
|
||||
```sh
|
||||
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||
```
|
||||
|
||||
On start it shows an on-screen notification with the URL. Open the web UI at:
|
||||
|
||||
```text
|
||||
official check/download/install allowed
|
||||
external check/download/install allowed when detected as a real install
|
||||
shadowmount check/download allowed, install blocked
|
||||
unknown check allowed, download/install blocked
|
||||
http://<console-ip>:12880/
|
||||
```
|
||||
|
||||
The future ELF scanner should classify titles by app metadata plus mount table
|
||||
inspection. Shadowmounts should be detected through `statfs()` / `getfsstat()`
|
||||
and `nullfs` mount origins instead of trusting title IDs alone.
|
||||
## Status
|
||||
|
||||
## Current Contents
|
||||
|
||||
```text
|
||||
Makefile
|
||||
PS5 payload build using ps5-payload-sdk and libmicrohttpd.
|
||||
|
||||
src/
|
||||
Embedded web server entrypoint and API stubs.
|
||||
|
||||
vendor/etahen/
|
||||
Vendored libmicrohttpd header and static library required for standalone
|
||||
builds.
|
||||
|
||||
web/
|
||||
Static web UI prototype for the future embedded PatchDL web server.
|
||||
```
|
||||
|
||||
Open `web/index.html` directly for the mock UI, or serve `web/` from a local
|
||||
HTTP server.
|
||||
|
||||
## Embedded Web Server
|
||||
|
||||
PatchDL is intended to run one self-contained ELF. It does not need a second
|
||||
`websrv.elf` process. The payload starts its own libmicrohttpd server and serves
|
||||
the UI from compiled-in assets.
|
||||
|
||||
Default port:
|
||||
|
||||
```text
|
||||
http://PS5_IP:12880/
|
||||
```
|
||||
|
||||
The port can be changed at build time:
|
||||
|
||||
```sh
|
||||
make PATCHDL_HTTP_PORT=12881
|
||||
```
|
||||
|
||||
or at runtime by passing a port as the first argument, if the loader supports
|
||||
argv:
|
||||
|
||||
```sh
|
||||
patchdl-ps5.elf 12881
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
PatchDL builds against `ps5-payload-dev/sdk` and reuses etaHEN's checked-in
|
||||
`libmicrohttpd.a` plus header files. The required etaHEN build dependency is
|
||||
vendored in this repository:
|
||||
|
||||
```text
|
||||
vendor/etahen/include/microhttpd.h
|
||||
vendor/etahen/lib/libmicrohttpd.a
|
||||
```
|
||||
|
||||
If the SDK is unpacked into `.toolchains/ps5-payload-sdk/ps5-payload-sdk`, the
|
||||
helper script sets the required environment automatically:
|
||||
|
||||
```sh
|
||||
scripts/build_ps5.sh clean all
|
||||
```
|
||||
|
||||
For a system-wide SDK install, set `PS5_PAYLOAD_SDK` yourself:
|
||||
|
||||
```sh
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
|
||||
make clean all
|
||||
```
|
||||
|
||||
Override paths and ports as needed:
|
||||
|
||||
```sh
|
||||
make ETAHEN_DEPS_DIR="vendor/etahen" PATCHDL_HTTP_PORT=12881
|
||||
```
|
||||
|
||||
Deploy with an ELF loader listening on the PS5:
|
||||
|
||||
```sh
|
||||
PS5_HOST=192.168.178.90 PS5_PORT=9021 scripts/build_ps5.sh test
|
||||
```
|
||||
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).
|
||||
@@ -27,4 +27,14 @@ else
|
||||
fi
|
||||
export PATH
|
||||
|
||||
# Enable the libcurl/OpenSSL network path automatically when those static
|
||||
# libs have been installed into the SDK sysroot (the pacbrew homebrew prefix).
|
||||
if [ -z "${CURL_DIR:-}" ]; then
|
||||
HBREW="$PS5_PAYLOAD_SDK/target/user/homebrew"
|
||||
if [ -f "$HBREW/include/curl/curl.h" ] && [ -f "$HBREW/lib/libcurl.a" ]; then
|
||||
CURL_DIR="$HBREW"
|
||||
export CURL_DIR
|
||||
fi
|
||||
fi
|
||||
|
||||
exec make -C "$ROOT_DIR" "$@"
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/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.
|
||||
#
|
||||
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
|
||||
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
|
||||
set -eu
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
HOST=${1:-${PS5_HOST:-ps5-slim.fritz.box}}
|
||||
PM_PORT=${PM_PORT:-8084}
|
||||
HTTP_PORT=${PATCHDL_HTTP_PORT:-12880}
|
||||
|
||||
VERSION=$(sed -n 's/.*PATCHDL_VERSION[^"]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/src/patchdl_version.h")
|
||||
[ -n "$VERSION" ] || { echo "could not read PATCHDL_VERSION" >&2; exit 1; }
|
||||
|
||||
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
|
||||
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
|
||||
|
||||
UP_NAME="patchdl-ps5-v${VERSION}.elf"
|
||||
TMP_ELF="$ROOT_DIR/$UP_NAME"
|
||||
cp "$SRC_ELF" "$TMP_ELF"
|
||||
|
||||
echo "Uploading $UP_NAME to $HOST:$PM_PORT ..."
|
||||
curl -fsS -m120 -X POST "http://$HOST:$PM_PORT/manage:upload?filename=$UP_NAME" \
|
||||
-H 'Content-Type: application/octet-stream' --data-binary @"$TMP_ELF" >/dev/null
|
||||
rm -f "$TMP_ELF"
|
||||
|
||||
PAYLOAD_PATH=$(curl -fsS -m15 "http://$HOST:$PM_PORT/list_payloads" | \
|
||||
UP_NAME="$UP_NAME" python3 -c '
|
||||
import os, sys, json
|
||||
want = os.environ["UP_NAME"]
|
||||
obj = json.load(sys.stdin)
|
||||
paths = obj.get("payloads", obj) if isinstance(obj, dict) else obj
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.endswith(want):
|
||||
print(p); break
|
||||
')
|
||||
[ -n "$PAYLOAD_PATH" ] || { echo "uploaded payload not found in list_payloads" >&2; exit 1; }
|
||||
|
||||
echo "Launching $PAYLOAD_PATH ..."
|
||||
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
|
||||
|
||||
printf "Waiting for web server on :%s " "$HTTP_PORT"
|
||||
i=0
|
||||
while [ "$i" -lt 20 ]; do
|
||||
if curl -fsS -m4 "http://$HOST:$HTTP_PORT/api/status" >/dev/null 2>&1; then
|
||||
echo " up."
|
||||
curl -fsS -m6 "http://$HOST:$HTTP_PORT/api/status"; echo
|
||||
echo "UI: http://$HOST:$HTTP_PORT/"
|
||||
exit 0
|
||||
fi
|
||||
printf "."
|
||||
sleep 2
|
||||
i=$((i + 1))
|
||||
done
|
||||
echo " timeout — server did not answer." >&2
|
||||
exit 1
|
||||
+14
-1
@@ -3,12 +3,17 @@
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "patchdl_notify.h"
|
||||
#include "patchdl_proc.h"
|
||||
#include "patchdl_version.h"
|
||||
#include "patchdl_websrv.h"
|
||||
|
||||
#ifndef PATCHDL_HTTP_PORT
|
||||
#define PATCHDL_HTTP_PORT 12880
|
||||
#endif
|
||||
|
||||
#define PATCHDL_PROC_NAME "patchdl.elf"
|
||||
|
||||
int
|
||||
main(int argc, char** argv) {
|
||||
unsigned short port = PATCHDL_HTTP_PORT;
|
||||
@@ -22,13 +27,21 @@ main(int argc, char** argv) {
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
printf("PatchDL starting web UI on port %u\n", port);
|
||||
/* Identify ourselves in the PS5 process list and replace any stale
|
||||
instance so re-deploys are idempotent and the HTTP port is free. */
|
||||
patchdl_proc_set_name(PATCHDL_PROC_NAME);
|
||||
patchdl_proc_kill_others(PATCHDL_PROC_NAME);
|
||||
|
||||
printf("PatchDL v%s starting web UI on port %u\n", PATCHDL_VERSION, port);
|
||||
|
||||
if(patchdl_websrv_start(port)) {
|
||||
puts("PatchDL web server failed to start");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* On-screen debug notification with the URL to open. */
|
||||
patchdl_notify_startup(port);
|
||||
|
||||
while(1) {
|
||||
sleep(60);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#include "patchdl_appdb.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define APP_DB_URI \
|
||||
"file:/system_data/priv/mms/app.db?immutable=1"
|
||||
|
||||
#define APPDB_MAX 1024
|
||||
|
||||
/* Extract a string value for `key` from a flat PS5 AppInfoJson blob.
|
||||
Trailing whitespace (Sony pads VERSION_FILE_URI) is stripped. */
|
||||
static void
|
||||
json_str(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char needle[48];
|
||||
const char *p;
|
||||
size_t len;
|
||||
|
||||
out[0] = '\0';
|
||||
if ((size_t)snprintf(needle, sizeof(needle), "\"%s\"", key) >= sizeof(needle))
|
||||
return;
|
||||
|
||||
p = strstr(json, needle);
|
||||
if (!p) return;
|
||||
p += strlen(needle);
|
||||
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')
|
||||
p++;
|
||||
if (*p != '"') return;
|
||||
p++;
|
||||
/* Copy with minimal JSON unescaping — AppInfoJson escapes URL slashes
|
||||
as "\/", so VERSION_FILE_URI arrives as "https:\/\/..." otherwise. */
|
||||
len = 0;
|
||||
while (*p && *p != '"' && len < out_sz - 1) {
|
||||
if (*p == '\\' && p[1]) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case 'n': out[len++] = '\n'; break;
|
||||
case 't': out[len++] = '\t'; break;
|
||||
case 'r': out[len++] = '\r'; break;
|
||||
default: out[len++] = *p; break; /* \/ \\ \" -> literal */
|
||||
}
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
out[len++] = *p++;
|
||||
}
|
||||
out[len] = '\0';
|
||||
while (len > 0 && (out[len - 1] == ' ' || out[len - 1] == '\t'))
|
||||
out[--len] = '\0';
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
sqlite3 *db = NULL;
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
patchdl_appinfo_t *arr;
|
||||
size_t n = 0;
|
||||
|
||||
*out = NULL;
|
||||
*count = 0;
|
||||
|
||||
if (sqlite3_open_v2(APP_DB_URI, &db,
|
||||
SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, NULL) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT titleId, titleName, contentId, AppInfoJson "
|
||||
"FROM tbl_contentinfo;", -1, &stmt, NULL) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
arr = calloc(APPDB_MAX, sizeof(*arr));
|
||||
if (!arr) {
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && n < APPDB_MAX) {
|
||||
patchdl_appinfo_t *e = &arr[n];
|
||||
const char *tid = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *name = (const char *)sqlite3_column_text(stmt, 1);
|
||||
const char *cid = (const char *)sqlite3_column_text(stmt, 2);
|
||||
const char *json = (const char *)sqlite3_column_text(stmt, 3);
|
||||
|
||||
if (!tid) continue;
|
||||
strncpy(e->title_id, tid, sizeof(e->title_id) - 1);
|
||||
if (name) strncpy(e->title_name, name, sizeof(e->title_name) - 1);
|
||||
if (cid) strncpy(e->content_id, cid, sizeof(e->content_id) - 1);
|
||||
if (json) {
|
||||
json_str(json, "CONTENT_VERSION", e->content_version,
|
||||
sizeof(e->content_version));
|
||||
json_str(json, "VERSION_FILE_URI", e->version_file_uri,
|
||||
sizeof(e->version_file_uri));
|
||||
}
|
||||
n++;
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
*out = arr;
|
||||
*count = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_appdb_free(patchdl_appinfo_t *arr) {
|
||||
free(arr);
|
||||
}
|
||||
|
||||
const patchdl_appinfo_t *
|
||||
patchdl_appdb_find(const patchdl_appinfo_t *arr, size_t count,
|
||||
const char *title_id) {
|
||||
for (size_t i = 0; i < count; i++)
|
||||
if (!strcmp(arr[i].title_id, title_id))
|
||||
return &arr[i];
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct {
|
||||
char title_id[16];
|
||||
char title_name[128];
|
||||
char content_id[64];
|
||||
char content_version[16];
|
||||
char version_file_uri[256]; /* Sony version.xml URL (UUID assigned) */
|
||||
} patchdl_appinfo_t;
|
||||
|
||||
/* Read the PS5 app database (tbl_contentinfo) for installed-title metadata.
|
||||
Returns 0 on success with a malloc'd array (caller frees with
|
||||
patchdl_appdb_free). Requires the root-vnode swap + elevated authid to be
|
||||
in effect (the caller arranges this). */
|
||||
int patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count);
|
||||
void patchdl_appdb_free(patchdl_appinfo_t *arr);
|
||||
|
||||
const patchdl_appinfo_t *patchdl_appdb_find(const patchdl_appinfo_t *arr,
|
||||
size_t count, const char *title_id);
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* SCEI DNAS Root 05 — the private root CA that signs Sony's PS5 download CDN
|
||||
* (sgst/gst/gs2.prod.dl.playstation.net). Their version.xml is served over TLS
|
||||
* with this root, which is NOT in any public CA bundle, so it is embedded here
|
||||
* and passed to libcurl via CURLOPT_CAINFO_BLOB (no file written to disk).
|
||||
* Public, non-secret certificate (valid 2004-2037).
|
||||
*/
|
||||
static const char PATCHDL_SCEI_DNAS_ROOT_PEM[] =
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIID0jCCArqgAwIBAgIBADANBgkqhkiG9w0BAQUFADBUMQswCQYDVQQGEwJKUDEp\n"
|
||||
"MCcGA1UEChMgU29ueSBDb21wdXRlciBFbnRlcnRhaW5tZW50IEluYy4xGjAYBgNV\n"
|
||||
"BAMTEVNDRUkgRE5BUyBSb290IDA1MB4XDTA0MDcxMjA5MDExOVoXDTM3MTIwNjA5\n"
|
||||
"MDExOVowVDELMAkGA1UEBhMCSlAxKTAnBgNVBAoTIFNvbnkgQ29tcHV0ZXIgRW50\n"
|
||||
"ZXJ0YWlubWVudCBJbmMuMRowGAYDVQQDExFTQ0VJIEROQVMgUm9vdCAwNTCCASIw\n"
|
||||
"DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANmPeza8PwCqlI7esOGIkoSESnIN\n"
|
||||
"g72ZD3Ut63jy7SdothPIvGBqVZWYkIpqJYJd1I4Nh//IpXQCQL0PnJLrh9BBeowq\n"
|
||||
"Muf5NNq3Us80Ihiu9CvNEAEO18g3OFV1TYdSwQ5zUsk33OUeI7h4aBPDVcZXYeHt\n"
|
||||
"dbPLqe4K8igian5prrAD5S6h28t8aAm+qMWRo+bW25B/841XwDGBP7/IxZv8Yoio\n"
|
||||
"rCo80CVYe6lGoU08eeqQiaHI5zAF281DWZSoVfLjJUEWmEnxqr8aOhszRGePi+Ei\n"
|
||||
"7UQjHDuZX9rLhDI1zAND+BA259tn/iwOqVXe20OccJllHJcG4Ecmd98f5qMCAwEA\n"
|
||||
"AaOBrjCBqzAdBgNVHQ4EFgQUxlahM1tPzoN3YgVEhm0gV7Wv2twwfAYDVR0jBHUw\n"
|
||||
"c4AUxlahM1tPzoN3YgVEhm0gV7Wv2tyhWKRWMFQxCzAJBgNVBAYTAkpQMSkwJwYD\n"
|
||||
"VQQKEyBTb255IENvbXB1dGVyIEVudGVydGFpbm1lbnQgSW5jLjEaMBgGA1UEAxMR\n"
|
||||
"U0NFSSBETkFTIFJvb3QgMDWCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUF\n"
|
||||
"AAOCAQEACZPihjwXA27wJ03tEKcHAeFLi8aBw2ysH4GwuH1dWb3UpuznWOB0iQT1\n"
|
||||
"wQocnEFYCJx5XFEnj4aLWpSHLEq/sSO+my+aPoTEsy20ajF+YLYZm0bZxH50CJYh\n"
|
||||
"rkET4C2aC0XvhGp9k1JQ1o0W6+cFT5LTlXapsq8Btt31t+XDPX7RqGV4WGekt3hM\n"
|
||||
"T7xRc7JWXdAQijIrbYi8mtbM07KEGnPU6IT8C47+0mSurpwLOoWL1tPgo6ePpLNi\n"
|
||||
"c4quUMgh9RXVjeTyXOMmyYdeUm2gt7qErvQONli+6Epmhm0A2khpIMHSpQjTE8gV\n"
|
||||
"rZp42a6+zg1iYy2vFBOmiQ17GRUl0A==\n"
|
||||
"-----END CERTIFICATE-----\n";
|
||||
@@ -0,0 +1,46 @@
|
||||
#include "patchdl_fw.h"
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Layout per ps5-payload-dev/shsrv (verified on fw 11.60). The leading
|
||||
`unknown1` field is why a naive {str,bin} struct reads garbage. */
|
||||
typedef struct {
|
||||
unsigned long unknown1;
|
||||
char str_version[0x1c];
|
||||
unsigned int bin_version;
|
||||
unsigned long unknown2;
|
||||
} sce_version_t;
|
||||
|
||||
/* Resolved from libkernel_sys at runtime on PS5 */
|
||||
extern int sceKernelGetProsperoSystemSwVersion(sce_version_t *ver);
|
||||
|
||||
/* bin_version packs the firmware as hex digits read as decimal:
|
||||
0x11600000 -> "11.60". */
|
||||
static void
|
||||
bin_to_str(uint32_t bin, char *out, size_t sz) {
|
||||
snprintf(out, sz, "%x.%02x", (bin >> 24) & 0xff, (bin >> 16) & 0xff);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_fw_get(patchdl_fw_t *out) {
|
||||
sce_version_t v;
|
||||
|
||||
if (!out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
memset(&v, 0, sizeof(v));
|
||||
|
||||
if (sceKernelGetProsperoSystemSwVersion(&v) == 0 && v.bin_version) {
|
||||
out->bin = v.bin_version;
|
||||
bin_to_str(out->bin, out->str, sizeof(out->str));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Fallback: kernel_get_fw_version reads the live FW build from the
|
||||
kernel when the SCE call is unavailable. */
|
||||
out->bin = kernel_get_fw_version();
|
||||
if (out->bin)
|
||||
bin_to_str(out->bin, out->str, sizeof(out->str));
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
char str[0x1c]; /* e.g. "11.60" */
|
||||
uint32_t bin; /* e.g. 0x11600000 */
|
||||
} patchdl_fw_t;
|
||||
|
||||
int patchdl_fw_get(patchdl_fw_t *out);
|
||||
@@ -0,0 +1,245 @@
|
||||
#include "patchdl_install.h"
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
|
||||
scene (notmaj0r/CheatRunner, ps5-payload-dev/websrv). Sizes per CheatRunner:
|
||||
CONTENTID=0x30, PLAYGOSCENARIOID=3, LANGUAGE=8, NUM_LANGUAGES=30, NUM_IDS=64.
|
||||
The trailing `unknown` padding makes the playgo struct safely large. */
|
||||
#define AI_CONTENTID_SIZE 0x30
|
||||
#define AI_PLAYGOSCENARIOID_SIZE 3
|
||||
#define AI_LANGUAGE_SIZE 8
|
||||
#define AI_NUM_LANGUAGES 30
|
||||
#define AI_NUM_IDS 64
|
||||
|
||||
typedef struct {
|
||||
char content_id[AI_CONTENTID_SIZE];
|
||||
int content_type;
|
||||
int content_platform;
|
||||
} ai_pkg_info_t;
|
||||
|
||||
typedef struct {
|
||||
const char *uri;
|
||||
const char *ex_uri;
|
||||
const char *playgo_scenario_id;
|
||||
const char *content_id;
|
||||
const char *content_name;
|
||||
const char *icon_url;
|
||||
} ai_meta_info_t;
|
||||
|
||||
typedef struct {
|
||||
char languages[AI_NUM_LANGUAGES][AI_LANGUAGE_SIZE];
|
||||
char playgo_scenario_ids[AI_NUM_IDS][AI_PLAYGOSCENARIOID_SIZE];
|
||||
char content_ids[AI_NUM_IDS][AI_CONTENTID_SIZE];
|
||||
long unknown[810];
|
||||
} ai_playgo_info_t;
|
||||
|
||||
/* Sysmodule IDs (from ps5-payload-dev/sdk crt/rtld_sprx.c). */
|
||||
#define SYSMOD_IPMI 0x8000001d
|
||||
#define SYSMOD_USERSERVICE 0x80000011
|
||||
#define SYSMOD_SYSTEMSERVICE 0x80000010
|
||||
#define SYSMOD_APPINSTUTIL 0x80000014
|
||||
|
||||
typedef int (*sysmod_load_fn)(unsigned int id);
|
||||
typedef int (*ai_init_fn)(void);
|
||||
typedef int (*ai_install_pkg_fn)(const char *path, ai_pkg_info_t *info);
|
||||
typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info,
|
||||
ai_playgo_info_t *playgo);
|
||||
typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app);
|
||||
|
||||
static ai_init_fn ai_initialize;
|
||||
static ai_install_pkg_fn ai_install_pkg;
|
||||
static ai_install_by_pkg_fn ai_install_by_package;
|
||||
static ai_title_from_pkg_fn ai_title_from_pkg;
|
||||
|
||||
/* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs
|
||||
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
|
||||
sceKernelLoadStartModule (that wedged the process). Instead use the proper
|
||||
sysmodule loader — the same path the SDK's rtld_sprx takes — then resolve
|
||||
symbols via the kernel dynlib helpers. Runs in a detached thread; the HTTP
|
||||
handler reports the stage and never blocks.
|
||||
stage: 0 idle, 1 resolve loader, 2 load modules, 3 resolve symbols,
|
||||
4 initialize, 5 ready, negative = failure at that step. */
|
||||
static volatile int g_stage;
|
||||
static int g_err;
|
||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static intptr_t
|
||||
dynsym(const char *module, const char *sym) {
|
||||
uint32_t h = 0;
|
||||
if (kernel_dynlib_handle(-1, module, &h) < 0)
|
||||
return 0;
|
||||
return kernel_dynlib_dlsym(-1, h, sym);
|
||||
}
|
||||
|
||||
static void *
|
||||
backend_init_thread(void *arg) {
|
||||
(void)arg;
|
||||
sysmod_load_fn sysmod_load;
|
||||
|
||||
g_stage = 1;
|
||||
sysmod_load = (sysmod_load_fn)dynsym("libSceSysmodule.sprx",
|
||||
"sceSysmoduleLoadModuleInternal");
|
||||
if (!sysmod_load) { g_stage = -1; return NULL; }
|
||||
|
||||
g_stage = 2;
|
||||
/* Dependencies first, then AppInstUtil. The sysmodule loader pulls in
|
||||
further deps and is the non-wedging path. */
|
||||
sysmod_load(SYSMOD_IPMI);
|
||||
sysmod_load(SYSMOD_USERSERVICE);
|
||||
sysmod_load(SYSMOD_SYSTEMSERVICE);
|
||||
g_err = sysmod_load(SYSMOD_APPINSTUTIL);
|
||||
if (g_err) { g_stage = -2; return NULL; }
|
||||
|
||||
g_stage = 3;
|
||||
ai_initialize = (ai_init_fn)dynsym("libSceAppInstUtil.sprx",
|
||||
"sceAppInstUtilInitialize");
|
||||
ai_install_pkg = (ai_install_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
||||
"sceAppInstUtilAppInstallPkg");
|
||||
ai_install_by_package = (ai_install_by_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
||||
"sceAppInstUtilInstallByPackage");
|
||||
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
||||
"sceAppInstUtilGetTitleIdFromPkg");
|
||||
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
|
||||
g_stage = -3;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_stage = 4;
|
||||
g_err = ai_initialize();
|
||||
if (g_err) { g_stage = -4; return NULL; }
|
||||
|
||||
g_stage = 5;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void
|
||||
backend_start(void) {
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
if (g_stage == 0) {
|
||||
pthread_t tid;
|
||||
pthread_attr_t attr;
|
||||
g_stage = 1;
|
||||
pthread_attr_init(&attr);
|
||||
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
|
||||
pthread_create(&tid, &attr, backend_init_thread, NULL);
|
||||
pthread_attr_destroy(&attr);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
}
|
||||
|
||||
static const char *
|
||||
stage_str(int s) {
|
||||
switch (s) {
|
||||
case 0: return "idle";
|
||||
case 1: return "resolving sysmodule loader";
|
||||
case 2: return "loading AppInstUtil module";
|
||||
case 3: return "resolving AppInstUtil symbols";
|
||||
case 4: return "initializing service";
|
||||
case 5: return "ready";
|
||||
case -1: return "sysmodule loader unavailable";
|
||||
case -2: return "AppInstUtil module load failed";
|
||||
case -3: return "AppInstUtil symbol resolution failed";
|
||||
case -4: return "AppInstUtil init failed";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_install_backend_check(char *msg, size_t msg_sz) {
|
||||
int s;
|
||||
backend_start();
|
||||
s = g_stage;
|
||||
snprintf(msg, msg_sz, "%s (rc=0x%08x)", stage_str(s), (unsigned)g_err);
|
||||
return (s == 5) ? 0 : -1;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
char *msg, size_t msg_sz) {
|
||||
char sdk_path[1024];
|
||||
struct stat st;
|
||||
int rc;
|
||||
|
||||
if (!local_path || !local_path[0]) {
|
||||
snprintf(msg, msg_sz, "no package path");
|
||||
return -1;
|
||||
}
|
||||
if (stat(local_path, &st) != 0) {
|
||||
snprintf(msg, msg_sz, "package not downloaded");
|
||||
return -1;
|
||||
}
|
||||
|
||||
backend_start();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
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. */
|
||||
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. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback: InstallByPackage with a file:// URI. */
|
||||
{
|
||||
char file_uri[1100];
|
||||
ai_meta_info_t meta = {0};
|
||||
ai_pkg_info_t pkg = {0};
|
||||
ai_playgo_info_t playgo = {0};
|
||||
int rc2;
|
||||
|
||||
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
|
||||
meta.uri = file_uri;
|
||||
meta.ex_uri = "";
|
||||
meta.playgo_scenario_id = "";
|
||||
meta.content_id = "";
|
||||
meta.content_name = "PatchDL";
|
||||
meta.icon_url = "";
|
||||
|
||||
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
|
||||
if (rc2 == 0) {
|
||||
snprintf(msg, msg_sz, "install started (InstallByPackage)");
|
||||
return 0;
|
||||
}
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)",
|
||||
(unsigned)rc, (unsigned)rc2);
|
||||
return rc2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/*
|
||||
* Install a local PKG (a downloaded patch) via Sony's AppInstUtil service.
|
||||
* `local_path` is the on-disk path under /data/patchdl/... The install runs
|
||||
* asynchronously in the system installer queue once started; this returns 0
|
||||
* when the install was accepted, non-zero (the SCE error code) otherwise, and
|
||||
* writes a short human-readable status into `msg`.
|
||||
*
|
||||
* This MODIFIES the installed game. The caller must gate it to genuine
|
||||
* (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). */
|
||||
int patchdl_install_local_pkg(const char *local_path,
|
||||
const char *expected_title_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);
|
||||
@@ -0,0 +1,448 @@
|
||||
#include "patchdl_net.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
#include <curl/curl.h>
|
||||
#include "patchdl_ca.h"
|
||||
#endif
|
||||
|
||||
#define DNS_SERVER "1.1.1.1"
|
||||
#define DNS_PORT 53
|
||||
#define DNS_TIMEOUT_MS 3000
|
||||
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
|
||||
static const char *ALLOWED_HOSTS[] = {
|
||||
"sgst.prod.dl.playstation.net",
|
||||
"gst.prod.dl.playstation.net",
|
||||
"gs2.ww.prod.dl.playstation.net",
|
||||
"prosperopatches.com",
|
||||
NULL,
|
||||
};
|
||||
|
||||
static int
|
||||
host_allowed(const char *host) {
|
||||
size_t hlen = strlen(host);
|
||||
for (int i = 0; ALLOWED_HOSTS[i]; i++) {
|
||||
if (!strcmp(host, ALLOWED_HOSTS[i]))
|
||||
return 1;
|
||||
size_t alen = strlen(ALLOWED_HOSTS[i]);
|
||||
if (hlen > alen + 1 &&
|
||||
host[hlen - alen - 1] == '.' &&
|
||||
!strcmp(host + hlen - alen, ALLOWED_HOSTS[i]))
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Extract hostname from https://host/path or http://host/path */
|
||||
static int
|
||||
url_host(const char *url, char *out, size_t out_sz) {
|
||||
const char *p = strstr(url, "://");
|
||||
const char *end;
|
||||
size_t len;
|
||||
|
||||
if (!p) return -1;
|
||||
p += 3;
|
||||
end = strpbrk(p, "/:?");
|
||||
len = end ? (size_t)(end - p) : strlen(p);
|
||||
if (len == 0 || len >= out_sz) return -1;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- raw UDP DNS resolver ---------------------------------------- */
|
||||
|
||||
/* Diagnostics from the last dns_resolve() call (read by patchdl_net_diag).
|
||||
step: 0=ok 1=socket 2=sendto 3=recv<12 4=no-answer. */
|
||||
static int g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount;
|
||||
|
||||
static int
|
||||
dns_encode_name(const char *host, uint8_t *buf, size_t bufsz) {
|
||||
size_t pos = 0;
|
||||
const char *p = host;
|
||||
|
||||
while (*p) {
|
||||
const char *dot = strchr(p, '.');
|
||||
size_t lablen = dot ? (size_t)(dot - p) : strlen(p);
|
||||
if (lablen > 63 || pos + 1 + lablen + 1 >= bufsz) return -1;
|
||||
buf[pos++] = (uint8_t)lablen;
|
||||
memcpy(buf + pos, p, lablen);
|
||||
pos += lablen;
|
||||
p += lablen;
|
||||
if (*p == '.') p++;
|
||||
}
|
||||
buf[pos++] = 0;
|
||||
return (int)pos;
|
||||
}
|
||||
|
||||
static int
|
||||
dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
|
||||
int fd;
|
||||
struct sockaddr_in srv;
|
||||
uint8_t pkt[512], resp[512];
|
||||
ssize_t n;
|
||||
int name_len;
|
||||
size_t pos;
|
||||
struct timeval tv;
|
||||
|
||||
g_dns_step = 0; g_dns_errno = 0; g_dns_n = 0; g_dns_ancount = 0;
|
||||
|
||||
fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (fd < 0) { g_dns_step = 1; g_dns_errno = errno; return -1; }
|
||||
|
||||
tv.tv_sec = DNS_TIMEOUT_MS / 1000;
|
||||
tv.tv_usec = (DNS_TIMEOUT_MS % 1000) * 1000;
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
|
||||
memset(&srv, 0, sizeof(srv));
|
||||
srv.sin_family = AF_INET;
|
||||
srv.sin_port = htons(DNS_PORT);
|
||||
srv.sin_addr.s_addr = inet_addr(DNS_SERVER);
|
||||
|
||||
memset(pkt, 0, sizeof(pkt));
|
||||
pkt[0] = 0xDE; pkt[1] = 0xAD; /* ID */
|
||||
pkt[2] = 0x01; pkt[3] = 0x00; /* standard query, RD */
|
||||
pkt[4] = 0x00; pkt[5] = 0x01; /* QDCOUNT = 1 */
|
||||
pos = 12;
|
||||
|
||||
name_len = dns_encode_name(host, pkt + pos, sizeof(pkt) - pos - 4);
|
||||
if (name_len < 0) { close(fd); return -1; }
|
||||
pos += (size_t)name_len;
|
||||
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QTYPE = A */
|
||||
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QCLASS = IN */
|
||||
|
||||
if (sendto(fd, pkt, pos, 0,
|
||||
(struct sockaddr *)&srv, sizeof(srv)) < 0) {
|
||||
g_dns_step = 2; g_dns_errno = errno; close(fd); return -1;
|
||||
}
|
||||
|
||||
n = recv(fd, resp, sizeof(resp), 0);
|
||||
g_dns_n = (int)n;
|
||||
if (n < 0) g_dns_errno = errno;
|
||||
close(fd);
|
||||
if (n < 12) { g_dns_step = 3; return -1; }
|
||||
|
||||
uint16_t ancount = (uint16_t)((resp[6] << 8) | resp[7]);
|
||||
g_dns_ancount = ancount;
|
||||
if (!ancount) { g_dns_step = 4; return -1; }
|
||||
|
||||
/* Skip header and question section */
|
||||
pos = 12;
|
||||
while (pos < (size_t)n) {
|
||||
if (!resp[pos]) { pos++; break; }
|
||||
if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; break; }
|
||||
pos += 1 + resp[pos];
|
||||
}
|
||||
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
|
||||
pos += 4; /* QTYPE + QCLASS */
|
||||
|
||||
/* Walk every answer RR; the CDN returns a CNAME chain, so skip
|
||||
non-A records and return the first A (type 1, rdlen 4). */
|
||||
for (int a = 0; a < ancount && pos < (size_t)n; a++) {
|
||||
if ((resp[pos] & 0xC0) == 0xC0) {
|
||||
pos += 2;
|
||||
} else {
|
||||
while (pos < (size_t)n && resp[pos])
|
||||
pos += 1 + resp[pos];
|
||||
pos++;
|
||||
}
|
||||
if (pos + 10 > (size_t)n) break;
|
||||
|
||||
uint16_t type = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
|
||||
pos += 6; /* CLASS + TTL */
|
||||
uint16_t rdlen = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
|
||||
|
||||
if (type == 1 && rdlen == 4 && pos + 4 <= (size_t)n) {
|
||||
snprintf(ip_out, ip_sz, "%u.%u.%u.%u",
|
||||
resp[pos], resp[pos + 1], resp[pos + 2], resp[pos + 3]);
|
||||
return 0;
|
||||
}
|
||||
pos += rdlen; /* skip this RR's data (e.g. a CNAME target) */
|
||||
}
|
||||
|
||||
g_dns_step = 6; /* no A record among the answers */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Cache resolved IPs by host. The batch fetch hits the same CDN host dozens
|
||||
of times; a burst of identical raw-UDP queries gets dropped/rate-limited,
|
||||
so resolve once (with retries) and reuse. */
|
||||
static pthread_mutex_t dns_cache_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct { char host[256]; char ip[INET_ADDRSTRLEN]; } dns_cache[64];
|
||||
static int dns_cache_n;
|
||||
|
||||
static int
|
||||
dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
|
||||
int rc = -1;
|
||||
|
||||
pthread_mutex_lock(&dns_cache_mtx);
|
||||
for (int i = 0; i < dns_cache_n; i++) {
|
||||
if (!strcmp(dns_cache[i].host, host)) {
|
||||
strncpy(ip_out, dns_cache[i].ip, ip_sz - 1);
|
||||
ip_out[ip_sz - 1] = '\0';
|
||||
pthread_mutex_unlock(&dns_cache_mtx);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&dns_cache_mtx);
|
||||
|
||||
for (int attempt = 0; attempt < 4 && rc; attempt++)
|
||||
rc = dns_resolve(host, ip_out, ip_sz);
|
||||
if (rc) return -1;
|
||||
|
||||
pthread_mutex_lock(&dns_cache_mtx);
|
||||
if (dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
|
||||
strncpy(dns_cache[dns_cache_n].host, host,
|
||||
sizeof(dns_cache[0].host) - 1);
|
||||
strncpy(dns_cache[dns_cache_n].ip, ip_out,
|
||||
sizeof(dns_cache[0].ip) - 1);
|
||||
dns_cache_n++;
|
||||
}
|
||||
pthread_mutex_unlock(&dns_cache_mtx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- HTTP GET via curl ------------------------------------------- */
|
||||
|
||||
static size_t
|
||||
write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
patchdl_buf_t *b = userdata;
|
||||
size_t total = size * nmemb;
|
||||
char *newp = realloc(b->data, b->size + total + 1);
|
||||
if (!newp) return 0;
|
||||
b->data = newp;
|
||||
memcpy(b->data + b->size, ptr, total);
|
||||
b->size += total;
|
||||
b->data[b->size] = '\0';
|
||||
return total;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN];
|
||||
char resolve_str[512];
|
||||
struct curl_slist *resolve_list = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
|
||||
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
|
||||
resolve_list = curl_slist_append(NULL, resolve_str);
|
||||
|
||||
/* Also cover port 80 in case a redirect lands there */
|
||||
char resolve_80[512];
|
||||
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
|
||||
resolve_list = curl_slist_append(resolve_list, resolve_80);
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
curl = curl_easy_init();
|
||||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||||
|
||||
/* Trust only the embedded SCEI DNAS Root (Sony's private CDN CA).
|
||||
CAINFO_BLOB keeps the cert in memory — no file written anywhere. */
|
||||
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||
ca_blob.flags = CURL_BLOB_COPY;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve_list);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
/* Some Sony CDN edges still serve SHA-1-signed leaf certs, which
|
||||
OpenSSL 3.x rejects at the default security level. Lower it so the
|
||||
chain still verifies against the pinned SCEI root. */
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(resolve_list);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
free(out->data);
|
||||
out->data = NULL;
|
||||
out->size = 0;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static size_t
|
||||
file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
return fwrite(ptr, size, nmemb, (FILE *)userdata);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_download(const char *url, const char *dest_path,
|
||||
long long *bytes_out) {
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||
struct curl_slist *rl = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
FILE *fp;
|
||||
curl_off_t dl = 0;
|
||||
|
||||
if (bytes_out) *bytes_out = 0;
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
|
||||
fp = fopen(dest_path, "wb");
|
||||
if (!fp) return -1;
|
||||
|
||||
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||
rl = curl_slist_append(NULL, rs443);
|
||||
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
|
||||
rl = curl_slist_append(rl, rs80);
|
||||
|
||||
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||
ca_blob.flags = CURL_BLOB_COPY;
|
||||
|
||||
curl = curl_easy_init();
|
||||
if (!curl) { fclose(fp); curl_slist_free_all(rl); return -1; }
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, file_write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||
/* No total timeout (patches can be large); abort only on a long stall. */
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
fclose(fp);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
unlink(dest_path);
|
||||
return -1;
|
||||
}
|
||||
if (bytes_out) *bytes_out = (long long)dl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
|
||||
int hostrc, allowrc, dnsrc;
|
||||
long http_code = 0;
|
||||
CURLcode res = CURLE_OK;
|
||||
int did_curl = 0;
|
||||
|
||||
hostrc = url_host(url, host, sizeof(host));
|
||||
allowrc = (hostrc == 0) ? host_allowed(host) : 0;
|
||||
dnsrc = (hostrc == 0) ? dns_resolve(host, ip, sizeof(ip)) : -1;
|
||||
|
||||
if (hostrc == 0 && allowrc && dnsrc == 0) {
|
||||
patchdl_buf_t buf;
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
CURL *curl = curl_easy_init();
|
||||
if (curl) {
|
||||
char rs[512];
|
||||
struct curl_slist *rl;
|
||||
struct curl_blob ca_blob;
|
||||
snprintf(rs, sizeof(rs), "%s:443:%s", host, ip);
|
||||
rl = curl_slist_append(NULL, rs);
|
||||
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||
ca_blob.flags = CURL_BLOB_COPY;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
free(buf.data);
|
||||
did_curl = 1;
|
||||
}
|
||||
}
|
||||
|
||||
snprintf(out_json, sz,
|
||||
"{\"host\":\"%s\",\"host_ok\":%d,\"allowed\":%d,"
|
||||
"\"dns_rc\":%d,\"ip\":\"%s\","
|
||||
"\"dns_step\":%d,\"dns_errno\":%d,\"dns_recv\":%d,\"dns_ancount\":%d,"
|
||||
"\"curl_ran\":%d,\"curl_rc\":%d,\"curl_err\":\"%s\",\"http_code\":%ld}",
|
||||
host, hostrc == 0, allowrc, dnsrc, ip,
|
||||
g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount,
|
||||
did_curl, (int)res,
|
||||
did_curl ? curl_easy_strerror(res) : "n/a", http_code);
|
||||
}
|
||||
|
||||
#else /* !PATCHDL_HAVE_CURL */
|
||||
|
||||
int
|
||||
patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
(void)url;
|
||||
(void)out;
|
||||
return -1; /* curl not available in this build; set CURL_DIR= to enable */
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_download(const char *url, const char *dest_path,
|
||||
long long *bytes_out) {
|
||||
(void)url; (void)dest_path;
|
||||
if (bytes_out) *bytes_out = 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
(void)url;
|
||||
snprintf(out_json, sz, "{\"error\":\"curl not built\"}");
|
||||
}
|
||||
|
||||
#endif /* PATCHDL_HAVE_CURL */
|
||||
|
||||
patchdl_buf_t *
|
||||
patchdl_buf_new(void) {
|
||||
return calloc(1, sizeof(patchdl_buf_t));
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_buf_free(patchdl_buf_t *b) {
|
||||
if (!b) return;
|
||||
free(b->data);
|
||||
free(b);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct {
|
||||
char *data;
|
||||
size_t size;
|
||||
size_t cap;
|
||||
} patchdl_buf_t;
|
||||
|
||||
patchdl_buf_t *patchdl_buf_new(void);
|
||||
void patchdl_buf_free(patchdl_buf_t *b);
|
||||
|
||||
int patchdl_http_get(const char *url, patchdl_buf_t *out);
|
||||
|
||||
/* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on
|
||||
success and writes the byte count to *bytes_out. */
|
||||
int patchdl_http_download(const char *url, const char *dest_path,
|
||||
long long *bytes_out);
|
||||
|
||||
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
|
||||
(dns result/ip, curl code, http status, bytes) into `out_json`. */
|
||||
void patchdl_net_diag(const char *url, char *out_json, size_t sz);
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "patchdl_notify.h"
|
||||
#include "patchdl_version.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
/* Standard PS5/PS4 on-screen notification (same layout ftpsrv uses). */
|
||||
typedef struct {
|
||||
char useless1[45];
|
||||
char message[3075];
|
||||
} notify_request_t;
|
||||
|
||||
extern int sceKernelSendNotificationRequest(int, notify_request_t *,
|
||||
unsigned long, int);
|
||||
|
||||
static void
|
||||
notify(const char *fmt, ...) {
|
||||
notify_request_t req;
|
||||
va_list ap;
|
||||
|
||||
memset(&req, 0, sizeof(req));
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(req.message, sizeof(req.message), fmt, ap);
|
||||
va_end(ap);
|
||||
|
||||
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
|
||||
puts(req.message);
|
||||
}
|
||||
|
||||
/* First non-loopback IPv4 address of the console. */
|
||||
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);
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_notify_startup(unsigned short port) {
|
||||
char ip[INET_ADDRSTRLEN] = {0};
|
||||
|
||||
local_ip(ip, sizeof(ip));
|
||||
if (ip[0])
|
||||
notify("PatchDL %s by Knutwurst\nhttp://%s:%u/", PATCHDL_VERSION, ip, port);
|
||||
else
|
||||
notify("PatchDL %s by Knutwurst\nWeb UI on port %u", PATCHDL_VERSION, port);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
/* Send an on-screen PS5 notification at startup showing the web UI URL
|
||||
(with the console's LAN IP when resolvable). */
|
||||
void patchdl_notify_startup(unsigned short port);
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "patchdl_proc.h"
|
||||
|
||||
#include <signal.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysctl.h>
|
||||
|
||||
/* kinfo_proc field offsets for the FreeBSD 9-based PS5 kernel.
|
||||
Same layout used by ps5-payload-dev/ftpsrv (verified on fw 11.60):
|
||||
ki_pid at +72, ki_tdname (thread name) at +447, ki_structsize at +0. */
|
||||
#define KINFO_OFF_STRUCTSIZE 0
|
||||
#define KINFO_OFF_PID 72
|
||||
#define KINFO_OFF_TDNAME 447
|
||||
|
||||
void
|
||||
patchdl_proc_set_name(const char *name) {
|
||||
/* tid -1 = current thread */
|
||||
syscall(SYS_thr_set_name, -1, name);
|
||||
}
|
||||
|
||||
static pid_t
|
||||
find_pid(const char *name) {
|
||||
int mib[4] = {1, 14, 8, 0}; /* CTL_KERN, KERN_PROC, KERN_PROC_ALL */
|
||||
pid_t mypid = getpid();
|
||||
pid_t pid = -1;
|
||||
size_t buf_size;
|
||||
uint8_t *buf;
|
||||
|
||||
if (sysctl(mib, 4, 0, &buf_size, 0, 0)) return -1;
|
||||
if (!(buf = malloc(buf_size))) return -1;
|
||||
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
|
||||
|
||||
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) {
|
||||
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
|
||||
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
|
||||
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
|
||||
|
||||
if (ki_structsize <= 0) break; /* guard against malformed entries */
|
||||
ptr += ki_structsize;
|
||||
|
||||
if (!strcmp(name, ki_tdname) && ki_pid != mypid)
|
||||
pid = ki_pid;
|
||||
}
|
||||
|
||||
free(buf);
|
||||
return pid;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_proc_kill_others(const char *name) {
|
||||
int killed = 0;
|
||||
pid_t pid;
|
||||
|
||||
while ((pid = find_pid(name)) > 0) {
|
||||
if (kill(pid, SIGKILL))
|
||||
break;
|
||||
killed++;
|
||||
sleep(1); /* let the port + process slot free up */
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
/* Set this process's (main thread) name as shown in the PS5 process list. */
|
||||
void patchdl_proc_set_name(const char *name);
|
||||
|
||||
/* SIGKILL any other process whose name matches `name` (excluding self),
|
||||
so a freshly launched payload replaces a stale instance cleanly.
|
||||
Returns the number of processes killed. */
|
||||
int patchdl_proc_kill_others(const char *name);
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "patchdl_resolve.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define DB_PATH "/data/patchdl/db/ps5_xml.tsv"
|
||||
|
||||
static int
|
||||
lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
|
||||
FILE *fp;
|
||||
char line[1024];
|
||||
|
||||
fp = fopen(DB_PATH, "r");
|
||||
if (!fp) return -1;
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
char *tab = strchr(line, '\t');
|
||||
if (!tab) continue;
|
||||
*tab = '\0';
|
||||
if (strcmp(line, title_id)) continue;
|
||||
|
||||
char *url = tab + 1;
|
||||
size_t len = strlen(url);
|
||||
while (len > 0 && (url[len - 1] == '\n' || url[len - 1] == '\r'))
|
||||
url[--len] = '\0';
|
||||
if (len == 0 || len >= url_sz) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
memcpy(url_out, url, len + 1);
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
lookup_prosperopatches(const char *title_id, char *url_out, size_t url_sz) {
|
||||
/* TODO: implement live lookup once API endpoint is confirmed on-device.
|
||||
prosperopatches.com serves Sony CDN index links for PS5 titles. */
|
||||
(void)title_id;
|
||||
(void)url_out;
|
||||
(void)url_sz;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz) {
|
||||
if (!title_id) return -1;
|
||||
|
||||
/* Only PS5 native title IDs (PPSA/PPSE/PPSH/PPSJ/PPSC) */
|
||||
if (strncmp(title_id, "PPSA", 4) &&
|
||||
strncmp(title_id, "PPSE", 4) &&
|
||||
strncmp(title_id, "PPSH", 4) &&
|
||||
strncmp(title_id, "PPSJ", 4) &&
|
||||
strncmp(title_id, "PPSC", 4))
|
||||
return -1;
|
||||
|
||||
if (!lookup_tsv(title_id, url_out, url_sz))
|
||||
return 0;
|
||||
|
||||
return lookup_prosperopatches(title_id, url_out, url_sz);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
int patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz);
|
||||
@@ -0,0 +1,489 @@
|
||||
#include "patchdl_scan.h"
|
||||
#include "patchdl_appdb.h"
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include <dirent.h>
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAX_TITLES 512
|
||||
|
||||
static const char *SCAN_DIRS[] = {
|
||||
"/user/app",
|
||||
"/mnt/ext0/user/app",
|
||||
"/mnt/ext1/user/app",
|
||||
"/system_ex/app",
|
||||
NULL,
|
||||
};
|
||||
|
||||
/* ---------- param.json -------------------------------------------------- */
|
||||
|
||||
static int
|
||||
json_str(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char needle[80];
|
||||
const char *p;
|
||||
size_t len;
|
||||
|
||||
if ((size_t)snprintf(needle, sizeof(needle), "\"%s\"", key) >= sizeof(needle))
|
||||
return -1;
|
||||
|
||||
p = strstr(json, needle);
|
||||
if (!p) return -1;
|
||||
p += strlen(needle);
|
||||
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')
|
||||
p++;
|
||||
if (*p != '"') return -1;
|
||||
p++;
|
||||
for (len = 0; p[len] && p[len] != '"' && len < out_sz - 1; len++)
|
||||
;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Extract the title name for the default language out of the nested
|
||||
localizedParameters block. PS5 param.json has no flat titleName key. */
|
||||
static void
|
||||
json_title_name(const char *json, char *out, size_t out_sz) {
|
||||
char lang[16] = {0};
|
||||
const char *p;
|
||||
|
||||
if (!json_str(json, "defaultLanguage", lang, sizeof(lang)) && lang[0]) {
|
||||
char anchor[24];
|
||||
if ((size_t)snprintf(anchor, sizeof(anchor), "\"%s\"", lang) <
|
||||
sizeof(anchor)) {
|
||||
p = strstr(json, anchor);
|
||||
if (p) {
|
||||
const char *tn = strstr(p, "\"titleName\"");
|
||||
if (tn && !json_str(tn, "titleName", out, out_sz))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback: first titleName anywhere in the file */
|
||||
json_str(json, "titleName", out, out_sz);
|
||||
}
|
||||
|
||||
static int
|
||||
try_param_json(const char *dir, patchdl_title_t *t) {
|
||||
char path[PATH_MAX];
|
||||
FILE *fp;
|
||||
char buf[8192];
|
||||
size_t n;
|
||||
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.json", dir);
|
||||
fp = fopen(path, "r");
|
||||
if (!fp) return -1;
|
||||
n = fread(buf, 1, sizeof(buf) - 1, fp);
|
||||
fclose(fp);
|
||||
if (!n) return -1;
|
||||
buf[n] = '\0';
|
||||
|
||||
/* PS5 keys first, then PS4 fallbacks */
|
||||
if (json_str(buf, "titleId", t->title_id, sizeof(t->title_id)) &&
|
||||
json_str(buf, "TITLE_ID", t->title_id, sizeof(t->title_id)))
|
||||
return -1;
|
||||
|
||||
json_str(buf, "contentId", t->content_id, sizeof(t->content_id));
|
||||
json_str(buf, "contentVersion", t->installed_version, sizeof(t->installed_version));
|
||||
if (!t->content_id[0])
|
||||
json_str(buf, "CONTENT_ID", t->content_id, sizeof(t->content_id));
|
||||
if (!t->installed_version[0])
|
||||
json_str(buf, "APP_VER", t->installed_version, sizeof(t->installed_version));
|
||||
|
||||
json_title_name(buf, t->name, sizeof(t->name));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- param.sfo --------------------------------------------------- */
|
||||
|
||||
#pragma pack(push, 1)
|
||||
typedef struct {
|
||||
uint32_t magic; /* 0x46535000 = "\x00PSF" */
|
||||
uint32_t version;
|
||||
uint32_t key_table_start;
|
||||
uint32_t data_table_start;
|
||||
uint32_t num_entries;
|
||||
} sfo_hdr_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t key_offset;
|
||||
uint16_t data_fmt;
|
||||
uint32_t data_len;
|
||||
uint32_t data_max_len;
|
||||
uint32_t data_offset;
|
||||
} sfo_entry_t;
|
||||
#pragma pack(pop)
|
||||
|
||||
#define SFO_MAGIC 0x46535000u
|
||||
#define SFO_FMT_STR 0x0204u
|
||||
|
||||
static int
|
||||
sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
|
||||
char *out, size_t out_sz) {
|
||||
const sfo_hdr_t *h;
|
||||
const sfo_entry_t *entries;
|
||||
uint32_t i;
|
||||
|
||||
if (bufsz < sizeof(*h)) return -1;
|
||||
h = (const sfo_hdr_t *)buf;
|
||||
if (h->magic != SFO_MAGIC || h->num_entries > 256) return -1;
|
||||
|
||||
entries = (const sfo_entry_t *)(buf + sizeof(*h));
|
||||
|
||||
for (i = 0; i < h->num_entries; i++) {
|
||||
size_t key_off = h->key_table_start + entries[i].key_offset;
|
||||
size_t val_off = h->data_table_start + entries[i].data_offset;
|
||||
|
||||
if (key_off >= bufsz || val_off >= bufsz) continue;
|
||||
|
||||
const char *k = (const char *)(buf + key_off);
|
||||
if (strcmp(k, key)) continue;
|
||||
if (entries[i].data_fmt != SFO_FMT_STR) return -1;
|
||||
|
||||
size_t len = entries[i].data_len;
|
||||
if (val_off + len > bufsz) return -1;
|
||||
if (len >= out_sz) len = out_sz - 1;
|
||||
memcpy(out, buf + val_off, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
try_param_sfo(const char *dir, patchdl_title_t *t) {
|
||||
char path[PATH_MAX];
|
||||
FILE *fp;
|
||||
uint8_t buf[8192];
|
||||
size_t n;
|
||||
struct stat st;
|
||||
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", dir);
|
||||
fp = fopen(path, "rb");
|
||||
if (!fp) return -1;
|
||||
|
||||
if (fstat(fileno(fp), &st) || (size_t)st.st_size > sizeof(buf)) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
n = fread(buf, 1, (size_t)st.st_size, fp);
|
||||
fclose(fp);
|
||||
if (n != (size_t)st.st_size) return -1;
|
||||
|
||||
if (sfo_get(buf, n, "TITLE_ID", t->title_id, sizeof(t->title_id)))
|
||||
return -1;
|
||||
|
||||
sfo_get(buf, n, "CONTENT_ID", t->content_id, sizeof(t->content_id));
|
||||
sfo_get(buf, n, "APP_VER", t->installed_version, sizeof(t->installed_version));
|
||||
sfo_get(buf, n, "TITLE", t->name, sizeof(t->name));
|
||||
if (!t->name[0])
|
||||
sfo_get(buf, n, "TITLE_00", t->name, sizeof(t->name));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- install-type classification (file-based) -------------------- */
|
||||
|
||||
static int
|
||||
path_exists(const char *dir, const char *rel) {
|
||||
char p[PATH_MAX];
|
||||
struct stat st;
|
||||
snprintf(p, sizeof(p), "%s/%s", dir, rel);
|
||||
return stat(p, &st) == 0;
|
||||
}
|
||||
|
||||
/* Read up to sz-1 bytes of dir/rel into buf (NUL-terminated). 0 on success. */
|
||||
static int
|
||||
read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
|
||||
char p[PATH_MAX];
|
||||
FILE *fp;
|
||||
size_t n;
|
||||
|
||||
snprintf(p, sizeof(p), "%s/%s", dir, rel);
|
||||
fp = fopen(p, "rb");
|
||||
if (!fp) return -1;
|
||||
n = fread(buf, 1, sz - 1, fp);
|
||||
fclose(fp);
|
||||
buf[n] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Keep only actual games. Skip PS5 system apps (NPXS), the Payload Manager
|
||||
fake app (PLDM) and ELF-loader homebrew (PSPS). */
|
||||
static int
|
||||
is_game_title(const char *title_id) {
|
||||
if (!strncmp(title_id, "NPXS", 4)) return 0;
|
||||
if (!strncmp(title_id, "PLDM", 4)) return 0;
|
||||
if (!strncmp(title_id, "PSPS", 4)) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------- directory scanner ------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Distinguish genuine installs from ShadowMountPlus mounts by on-disk layout
|
||||
* under /user/app/<TID>/ (verified on fw 11.60):
|
||||
* - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount
|
||||
* - app.pkg (no mount.lnk) -> genuine install; app.json
|
||||
* with CDN piece URLs means a not-downloaded preinstall stub, local
|
||||
* URLs mean a real install
|
||||
* - app.json with "fake":true -> homebrew fake (skip)
|
||||
*/
|
||||
static int
|
||||
scan_one(const char *base, const char *name, patchdl_title_t *t) {
|
||||
char dir[PATH_MAX];
|
||||
char appjson[4096];
|
||||
int has_mountlnk, has_app_pkg, has_paramjson, is_fake = 0, is_cdn = 0;
|
||||
|
||||
snprintf(dir, sizeof(dir), "%s/%s", base, name);
|
||||
memset(t, 0, sizeof(*t));
|
||||
strncpy(t->source_path, dir, sizeof(t->source_path) - 1);
|
||||
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
|
||||
|
||||
if (!is_game_title(t->title_id))
|
||||
return -1;
|
||||
|
||||
has_mountlnk = path_exists(dir, "mount.lnk") ||
|
||||
path_exists(dir, "mount_img.lnk");
|
||||
has_app_pkg = path_exists(dir, "app.pkg");
|
||||
has_paramjson = path_exists(dir, "sce_sys/param.json");
|
||||
|
||||
if (!read_small_file(dir, "app.json", appjson, sizeof(appjson))) {
|
||||
if (strstr(appjson, "\"fake\"") && strstr(appjson, "true"))
|
||||
is_fake = 1;
|
||||
if (strstr(appjson, "http"))
|
||||
is_cdn = 1; /* piece URLs point at the Sony CDN -> preinstall */
|
||||
}
|
||||
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))
|
||||
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 */
|
||||
}
|
||||
|
||||
if (!t->title_id[0])
|
||||
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
|
||||
if (!t->name[0])
|
||||
strncpy(t->name, t->title_id, sizeof(t->name) - 1);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
|
||||
for (size_t i = 0; i < cnt; i++)
|
||||
if (!strcmp(arr[i].title_id, title_id))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void
|
||||
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
|
||||
DIR *d;
|
||||
struct dirent *de;
|
||||
|
||||
d = opendir(base);
|
||||
if (!d) return;
|
||||
|
||||
while ((de = readdir(d))) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
if (*cnt >= cap) break;
|
||||
if (scan_one(base, de->d_name, &arr[*cnt]) != 0)
|
||||
continue;
|
||||
if (already_seen(arr, *cnt, arr[*cnt].title_id))
|
||||
continue; /* dedupe a title already found in an earlier base */
|
||||
(*cnt)++;
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
/* ---------- public API -------------------------------------------------- */
|
||||
|
||||
const char *
|
||||
patchdl_source_str(patchdl_source_t src) {
|
||||
switch (src) {
|
||||
case PATCHDL_SOURCE_OFFICIAL: return "official";
|
||||
case PATCHDL_SOURCE_EXTERNAL: return "external";
|
||||
case PATCHDL_SOURCE_SHADOWMOUNT: return "shadowmount";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay app.db metadata (real title name, installed version, version.xml
|
||||
URL) onto the scanned titles. Genuine installs have no readable param.json,
|
||||
so this is the only source of their name/version. */
|
||||
static void
|
||||
merge_appdb(patchdl_title_t *arr, size_t cnt) {
|
||||
patchdl_appinfo_t *info = NULL;
|
||||
size_t n = 0;
|
||||
|
||||
if (patchdl_appdb_load(&info, &n))
|
||||
return;
|
||||
|
||||
for (size_t i = 0; i < cnt; i++) {
|
||||
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
|
||||
if (!a) continue;
|
||||
if (a->title_name[0])
|
||||
strncpy(arr[i].name, a->title_name, sizeof(arr[i].name) - 1);
|
||||
if (a->content_version[0])
|
||||
strncpy(arr[i].installed_version, a->content_version,
|
||||
sizeof(arr[i].installed_version) - 1);
|
||||
if (a->content_id[0] && !arr[i].content_id[0])
|
||||
strncpy(arr[i].content_id, a->content_id,
|
||||
sizeof(arr[i].content_id) - 1);
|
||||
if (a->version_file_uri[0])
|
||||
strncpy(arr[i].version_file_uri, a->version_file_uri,
|
||||
sizeof(arr[i].version_file_uri) - 1);
|
||||
}
|
||||
|
||||
patchdl_appdb_free(info);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
patchdl_title_t *arr;
|
||||
size_t cnt = 0;
|
||||
pid_t pid = getpid();
|
||||
intptr_t saved_root = 0, root_vnode;
|
||||
int using_vswap = 0;
|
||||
|
||||
arr = calloc(MAX_TITLES, sizeof(*arr));
|
||||
if (!arr) return -1;
|
||||
|
||||
/* Elevate so privileged paths (the app.db under /system_data) are
|
||||
readable; same authid ftpsrv uses. No-op without kernel R/W. */
|
||||
kernel_set_ucred_authid(pid, 0x4801000000000013L);
|
||||
|
||||
root_vnode = kernel_get_root_vnode();
|
||||
if (root_vnode) {
|
||||
saved_root = kernel_get_proc_rootdir(pid);
|
||||
kernel_set_proc_rootdir(pid, root_vnode);
|
||||
using_vswap = 1;
|
||||
}
|
||||
|
||||
for (int i = 0; SCAN_DIRS[i]; i++)
|
||||
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES);
|
||||
|
||||
merge_appdb(arr, cnt);
|
||||
|
||||
if (using_vswap)
|
||||
kernel_set_proc_rootdir(pid, saved_root);
|
||||
|
||||
*titles_out = arr;
|
||||
*count_out = cnt;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_scan_free(patchdl_title_t *titles, size_t count) {
|
||||
(void)count;
|
||||
free(titles);
|
||||
}
|
||||
|
||||
/* ---------- diagnostic dump --------------------------------------------- */
|
||||
|
||||
static char *
|
||||
dbg_append(char *buf, size_t *len, size_t *cap, const char *s) {
|
||||
size_t sl = strlen(s);
|
||||
if (*len + sl + 1 > *cap) {
|
||||
size_t nc = (*cap + sl + 1) * 2;
|
||||
char *p = realloc(buf, nc);
|
||||
if (!p) return buf;
|
||||
buf = p;
|
||||
*cap = nc;
|
||||
}
|
||||
memcpy(buf + *len, s, sl + 1);
|
||||
*len += sl;
|
||||
return buf;
|
||||
}
|
||||
|
||||
char *
|
||||
patchdl_scan_debug_json(void) {
|
||||
char *buf = NULL;
|
||||
size_t len = 0, cap = 0;
|
||||
char tmp[2048];
|
||||
pid_t pid = getpid();
|
||||
intptr_t saved_root = 0, root_vnode;
|
||||
int using_vswap = 0;
|
||||
struct statfs *mounts = NULL;
|
||||
int nmounts;
|
||||
|
||||
/* getmntinfo returns the global mount table — no root-vnode swap needed.
|
||||
Must NOT free the returned buffer (libc-managed). */
|
||||
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
|
||||
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
|
||||
|
||||
buf = dbg_append(buf, &len, &cap, "{\"mounts\":[");
|
||||
for (int i = 0; i < nmounts; i++) {
|
||||
snprintf(tmp, sizeof(tmp),
|
||||
"%s{\"from\":\"%s\",\"on\":\"%s\",\"type\":\"%s\",\"flags\":\"0x%lx\"}",
|
||||
i ? "," : "",
|
||||
mounts[i].f_mntfromname, mounts[i].f_mntonname,
|
||||
mounts[i].f_fstypename, (unsigned long)mounts[i].f_flags);
|
||||
buf = dbg_append(buf, &len, &cap, tmp);
|
||||
}
|
||||
|
||||
buf = dbg_append(buf, &len, &cap, "],\"bases\":[");
|
||||
|
||||
root_vnode = kernel_get_root_vnode();
|
||||
if (root_vnode) {
|
||||
saved_root = kernel_get_proc_rootdir(pid);
|
||||
kernel_set_proc_rootdir(pid, root_vnode);
|
||||
using_vswap = 1;
|
||||
}
|
||||
|
||||
for (int i = 0; SCAN_DIRS[i]; i++) {
|
||||
DIR *d = opendir(SCAN_DIRS[i]);
|
||||
snprintf(tmp, sizeof(tmp), "%s{\"base\":\"%s\",\"exists\":%s,\"entries\":[",
|
||||
i ? "," : "", SCAN_DIRS[i], d ? "true" : "false");
|
||||
buf = dbg_append(buf, &len, &cap, tmp);
|
||||
if (d) {
|
||||
struct dirent *de;
|
||||
int first = 1;
|
||||
while ((de = readdir(d))) {
|
||||
char dir[PATH_MAX];
|
||||
if (de->d_name[0] == '.') continue;
|
||||
snprintf(dir, sizeof(dir), "%s/%s", SCAN_DIRS[i], de->d_name);
|
||||
snprintf(tmp, sizeof(tmp),
|
||||
"%s{\"name\":\"%s\",\"pj\":%s,\"pkg\":%s,\"lnk\":%s}",
|
||||
first ? "" : ",", de->d_name,
|
||||
path_exists(dir, "sce_sys/param.json") ? "true" : "false",
|
||||
path_exists(dir, "app.pkg") ? "true" : "false",
|
||||
(path_exists(dir, "mount.lnk") ||
|
||||
path_exists(dir, "mount_img.lnk")) ? "true" : "false");
|
||||
buf = dbg_append(buf, &len, &cap, tmp);
|
||||
first = 0;
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
buf = dbg_append(buf, &len, &cap, "]}");
|
||||
}
|
||||
|
||||
if (using_vswap)
|
||||
kernel_set_proc_rootdir(pid, saved_root);
|
||||
|
||||
buf = dbg_append(buf, &len, &cap, "]}");
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum {
|
||||
PATCHDL_SOURCE_OFFICIAL = 0,
|
||||
PATCHDL_SOURCE_EXTERNAL = 1,
|
||||
PATCHDL_SOURCE_SHADOWMOUNT = 2,
|
||||
PATCHDL_SOURCE_UNKNOWN = 3,
|
||||
} patchdl_source_t;
|
||||
|
||||
typedef struct {
|
||||
char title_id[32];
|
||||
char content_id[64];
|
||||
char name[128];
|
||||
char installed_version[16];
|
||||
char source_path[256];
|
||||
char mount_from[256];
|
||||
char version_file_uri[256]; /* from app.db, for version.xml */
|
||||
patchdl_source_t source_type;
|
||||
/* populated by background verxml fetch — guarded by websrv mutex */
|
||||
char compatible_version[16];
|
||||
char latest_version[16];
|
||||
char latest_required_fw[16];
|
||||
char patch_url[512]; /* delta_url of the compatible patch */
|
||||
char patch_title_id[16]; /* title id embedded in patch_url */
|
||||
int verxml_done;
|
||||
} patchdl_title_t;
|
||||
|
||||
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
|
||||
void patchdl_scan_free(patchdl_title_t *titles, size_t count);
|
||||
const char *patchdl_source_str(patchdl_source_t src);
|
||||
|
||||
/* Diagnostic: malloc'd JSON dump of the mount table + scan-base directory
|
||||
listings. Caller frees. */
|
||||
char *patchdl_scan_debug_json(void);
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.1"
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "patchdl_verxml.h"
|
||||
#include "patchdl_net.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Convert packed fw bin to "11.60" string */
|
||||
static void
|
||||
fw_bin_to_str(uint32_t bin, char *out, size_t sz) {
|
||||
/* Packed firmware: hex digits read as decimal (0x10200006 -> "10.20"),
|
||||
same scheme as patchdl_fw. */
|
||||
snprintf(out, sz, "%x.%02x",
|
||||
(bin >> 24) & 0xff,
|
||||
(bin >> 16) & 0xff);
|
||||
}
|
||||
|
||||
static uint32_t
|
||||
parse_hex(const char *s) {
|
||||
uint32_t v = 0;
|
||||
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X'))
|
||||
sscanf(s + 2, "%x", &v);
|
||||
else
|
||||
sscanf(s, "%u", &v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* Extract attribute value from within a <package ...> tag span.
|
||||
tag_start points to '<', tag_end points past '>'. */
|
||||
static int
|
||||
attr_val(const char *tag_start, const char *tag_end, const char *attr,
|
||||
char *out, size_t out_sz) {
|
||||
char needle[64];
|
||||
const char *p;
|
||||
size_t len;
|
||||
|
||||
if ((size_t)snprintf(needle, sizeof(needle), " %s=\"", attr) >= sizeof(needle))
|
||||
return -1;
|
||||
|
||||
p = tag_start;
|
||||
while (p < tag_end) {
|
||||
p = strstr(p, needle);
|
||||
if (!p || p >= tag_end) return -1;
|
||||
p += strlen(needle);
|
||||
for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++)
|
||||
;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* version strings are zero-padded (e.g. "01.041.000") — strcmp orders correctly */
|
||||
static int
|
||||
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. */
|
||||
static void
|
||||
extract_title_id(const char *s, char *out, size_t sz) {
|
||||
out[0] = '\0';
|
||||
if (sz < 10 || !s) return;
|
||||
for (const char *p = s; p[0] && p[8]; p++) {
|
||||
int ok = 1;
|
||||
for (int i = 0; i < 4 && ok; i++)
|
||||
if (p[i] < 'A' || p[i] > 'Z') ok = 0;
|
||||
for (int i = 4; i < 9 && ok; i++)
|
||||
if (p[i] < '0' || p[i] > '9') ok = 0;
|
||||
if (ok) {
|
||||
memcpy(out, p, 9);
|
||||
out[9] = '\0';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||
const char *p = xml;
|
||||
|
||||
while ((p = strstr(p, "<package "))) {
|
||||
const char *tag_end = strchr(p, '>');
|
||||
if (!tag_end) break;
|
||||
tag_end++;
|
||||
|
||||
char ver[16] = {0};
|
||||
char sver[16] = {0};
|
||||
char durl[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));
|
||||
|
||||
if (ver[0] && sver[0]) {
|
||||
uint32_t pkg_sver = parse_hex(sver);
|
||||
|
||||
/* Track latest overall */
|
||||
if (!out->latest_version[0] || ver_gt(ver, out->latest_version)) {
|
||||
strncpy(out->latest_version, ver, sizeof(out->latest_version) - 1);
|
||||
fw_bin_to_str(pkg_sver, out->latest_required_fw,
|
||||
sizeof(out->latest_required_fw));
|
||||
}
|
||||
|
||||
/* Track latest compatible + its patch URL */
|
||||
if (pkg_sver <= fw_bin) {
|
||||
if (!out->compatible_version[0] ||
|
||||
ver_gt(ver, out->compatible_version)) {
|
||||
strncpy(out->compatible_version, ver,
|
||||
sizeof(out->compatible_version) - 1);
|
||||
strncpy(out->compatible_url, durl,
|
||||
sizeof(out->compatible_url) - 1);
|
||||
extract_title_id(durl, out->compatible_title,
|
||||
sizeof(out->compatible_title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p = tag_end;
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||
patchdl_buf_t buf;
|
||||
|
||||
if (!url || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
if (patchdl_http_get(url, &buf)) return -1;
|
||||
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
||||
|
||||
parse_packages(buf.data, fw_bin, out);
|
||||
free(buf.data);
|
||||
|
||||
return (out->latest_version[0]) ? 0 : -1;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */
|
||||
char latest_version[16]; /* highest pkg overall, or "" */
|
||||
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
|
||||
char compatible_url[512]; /* delta_url of the chosen compatible pkg */
|
||||
char compatible_title[16]; /* title id embedded in that delta_url */
|
||||
} patchdl_verinfo_t;
|
||||
|
||||
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
|
||||
+489
-227
@@ -1,23 +1,29 @@
|
||||
#include "patchdl_websrv.h"
|
||||
|
||||
#include "patchdl_assets.h"
|
||||
#include "patchdl_fw.h"
|
||||
#include "patchdl_scan.h"
|
||||
#include "patchdl_install.h"
|
||||
#include "patchdl_net.h"
|
||||
#include "patchdl_resolve.h"
|
||||
#include "patchdl_verxml.h"
|
||||
|
||||
#include <microhttpd.h>
|
||||
#include <pthread.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static struct MHD_Daemon* web_daemon;
|
||||
static struct MHD_Daemon *web_daemon;
|
||||
|
||||
static const char status_json[] =
|
||||
"{"
|
||||
"\"firmware\":\"11.60\","
|
||||
"\"firmware_build\":\"0x11600000\","
|
||||
"\"dns_guard\":\"Active\","
|
||||
"\"resolver\":\"Internal allowlist\","
|
||||
"\"free_space_mb\":64218,"
|
||||
"\"download_dir\":\"/mnt/usb0/patches\""
|
||||
"}";
|
||||
static patchdl_fw_t g_fw;
|
||||
static patchdl_title_t *g_titles;
|
||||
static size_t g_title_count;
|
||||
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static char g_status_json[512];
|
||||
static char *g_debug_json; /* built once at startup */
|
||||
|
||||
static const char config_json[] =
|
||||
"{"
|
||||
@@ -35,270 +41,526 @@ static const char config_json[] =
|
||||
"\"sgst.prod.dl.playstation.net\","
|
||||
"\"gst.prod.dl.playstation.net\","
|
||||
"\"gs2.ww.prod.dl.playstation.net\""
|
||||
"]"
|
||||
"}";
|
||||
"]}";
|
||||
|
||||
static const char titles_json[] =
|
||||
"["
|
||||
"{"
|
||||
"\"title_id\":\"PPSA01628_00\","
|
||||
"\"name\":\"Call of Duty Black Ops Cold War\","
|
||||
"\"content_id\":\"UP0002-PPSA01628_00-CODCWTHEGAME0001\","
|
||||
"\"installed_version\":\"01.032.000\","
|
||||
"\"compatible_version\":\"01.041.000\","
|
||||
"\"latest_version\":\"01.041.000\","
|
||||
"\"latest_required_fw\":\"11.60\","
|
||||
"\"source_type\":\"official\","
|
||||
"\"source_path\":\"/system_ex/app/PPSA01628_00\","
|
||||
"\"mount_from\":\"/dev/ssd0.system_ex\","
|
||||
"\"enabled\":true,"
|
||||
"\"mode\":\"latest_compatible\","
|
||||
"\"queued\":false,"
|
||||
"\"status\":\"available\""
|
||||
"},"
|
||||
"{"
|
||||
"\"title_id\":\"PPSA90001_00\","
|
||||
"\"name\":\"Shadowmounted Test Title\","
|
||||
"\"content_id\":\"UP0000-PPSA90001_00-SHADOWMOUNT0001\","
|
||||
"\"installed_version\":\"01.000.000\","
|
||||
"\"compatible_version\":\"01.006.000\","
|
||||
"\"latest_version\":\"01.009.000\","
|
||||
"\"latest_required_fw\":\"12.00\","
|
||||
"\"source_type\":\"shadowmount\","
|
||||
"\"source_path\":\"/system_ex/app/PPSA90001_00\","
|
||||
"\"mount_from\":\"/mnt/usb0/itemzflow/Shadowmounted Test Title\","
|
||||
"\"enabled\":true,"
|
||||
"\"mode\":\"download_only\","
|
||||
"\"queued\":false,"
|
||||
"\"status\":\"available\""
|
||||
"},"
|
||||
"{"
|
||||
"\"title_id\":\"PPSA08329_00\","
|
||||
"\"name\":\"Example Future Game\","
|
||||
"\"content_id\":\"EP0000-PPSA08329_00-FUTUREPATCH00001\","
|
||||
"\"installed_version\":\"01.000.000\","
|
||||
"\"compatible_version\":null,"
|
||||
"\"latest_version\":\"01.012.000\","
|
||||
"\"latest_required_fw\":\"12.50\","
|
||||
"\"source_type\":\"unknown\","
|
||||
"\"source_path\":\"/system_ex/app/PPSA08329_00\","
|
||||
"\"mount_from\":\"\","
|
||||
"\"enabled\":false,"
|
||||
"\"mode\":\"disabled\","
|
||||
"\"queued\":false,"
|
||||
"\"status\":\"blocked\""
|
||||
"}"
|
||||
"]";
|
||||
static const char downloads_json[] = "[]";
|
||||
|
||||
static const char downloads_json[] =
|
||||
"[]";
|
||||
/* ---------- JSON builder ------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
char *buf;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
} jbuf_t;
|
||||
|
||||
static void
|
||||
jbuf_ensure(jbuf_t *j, size_t extra) {
|
||||
if (!j->buf || j->len + extra + 1 > j->cap) {
|
||||
size_t newcap = (j->cap + extra + 1) * 2;
|
||||
if (newcap < 1024) newcap = 1024;
|
||||
char *p = realloc(j->buf, newcap);
|
||||
if (!p) return;
|
||||
j->buf = p;
|
||||
j->cap = newcap;
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
jbuf_append(jbuf_t *j, const char *s) {
|
||||
size_t slen = strlen(s);
|
||||
jbuf_ensure(j, slen);
|
||||
if (!j->buf) return;
|
||||
memcpy(j->buf + j->len, s, slen + 1);
|
||||
j->len += slen;
|
||||
}
|
||||
|
||||
static void
|
||||
jbuf_appendf(jbuf_t *j, const char *fmt, ...) {
|
||||
char tmp[256];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(tmp, sizeof(tmp), fmt, ap);
|
||||
va_end(ap);
|
||||
jbuf_append(j, tmp);
|
||||
}
|
||||
|
||||
static void
|
||||
jbuf_append_str(jbuf_t *j, const char *s) {
|
||||
jbuf_append(j, "\"");
|
||||
for (const char *p = s; *p; p++) {
|
||||
if (*p == '"') jbuf_append(j, "\\\"");
|
||||
else if (*p == '\\') jbuf_append(j, "\\\\");
|
||||
else if (*p == '\n') jbuf_append(j, "\\n");
|
||||
else if (*p == '\r') jbuf_append(j, "\\r");
|
||||
else if (*p == '\t') jbuf_append(j, "\\t");
|
||||
else { char c[2] = {*p, '\0'}; jbuf_append(j, c); }
|
||||
}
|
||||
jbuf_append(j, "\"");
|
||||
}
|
||||
|
||||
static void
|
||||
jbuf_append_ver_or_null(jbuf_t *j, const char *ver) {
|
||||
if (ver[0])
|
||||
jbuf_append_str(j, ver);
|
||||
else
|
||||
jbuf_append(j, "null");
|
||||
}
|
||||
|
||||
/* ---------- HTTP helpers ------------------------------------------------ */
|
||||
|
||||
static enum MHD_Result
|
||||
queue_buffer(struct MHD_Connection* conn, unsigned int status,
|
||||
const char* mime, const void* data, size_t size) {
|
||||
struct MHD_Response* response;
|
||||
enum MHD_Result result;
|
||||
queue_buffer(struct MHD_Connection *conn, unsigned int status,
|
||||
const char *mime, const void *data, size_t size,
|
||||
enum MHD_ResponseMemoryMode mm) {
|
||||
struct MHD_Response *resp;
|
||||
enum MHD_Result ret;
|
||||
|
||||
response = MHD_create_response_from_buffer(size, (void*)data,
|
||||
MHD_RESPMEM_PERSISTENT);
|
||||
if(!response) {
|
||||
return MHD_NO;
|
||||
}
|
||||
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
|
||||
if (!resp) return MHD_NO;
|
||||
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
"*");
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
|
||||
"no-store");
|
||||
if(mime) {
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, mime);
|
||||
}
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
|
||||
if (mime)
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, mime);
|
||||
|
||||
result = MHD_queue_response(conn, status, response);
|
||||
MHD_destroy_response(response);
|
||||
return result;
|
||||
ret = MHD_queue_response(conn, status, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
queue_text(struct MHD_Connection* conn, unsigned int status, const char* text) {
|
||||
return queue_buffer(conn, status, "text/plain; charset=utf-8", text,
|
||||
strlen(text));
|
||||
queue_text(struct MHD_Connection *conn, unsigned int status, const char *text) {
|
||||
return queue_buffer(conn, status, "text/plain; charset=utf-8",
|
||||
text, strlen(text), MHD_RESPMEM_PERSISTENT);
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
queue_json(struct MHD_Connection* conn, unsigned int status, const char* json) {
|
||||
return queue_buffer(conn, status, "application/json", json, strlen(json));
|
||||
queue_json(struct MHD_Connection *conn, unsigned int status, const char *json) {
|
||||
return queue_buffer(conn, status, "application/json",
|
||||
json, strlen(json), MHD_RESPMEM_PERSISTENT);
|
||||
}
|
||||
|
||||
/* Takes ownership of json — MHD will free() it */
|
||||
static enum MHD_Result
|
||||
queue_json_owned(struct MHD_Connection *conn, unsigned int status, char *json) {
|
||||
if (!json) return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
|
||||
return queue_buffer(conn, status, "application/json",
|
||||
json, strlen(json), MHD_RESPMEM_MUST_FREE);
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
queue_asset(struct MHD_Connection* conn, const char* url) {
|
||||
const patchdl_asset_t* asset = patchdl_asset_find(url);
|
||||
|
||||
if(!asset) {
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
}
|
||||
|
||||
return queue_buffer(conn, MHD_HTTP_OK, asset->mime, asset->data,
|
||||
asset->size);
|
||||
queue_asset(struct MHD_Connection *conn, const char *url) {
|
||||
const patchdl_asset_t *asset = patchdl_asset_find(url);
|
||||
if (!asset) return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
return queue_buffer(conn, MHD_HTTP_OK, asset->mime,
|
||||
asset->data, asset->size, MHD_RESPMEM_PERSISTENT);
|
||||
}
|
||||
|
||||
static const char*
|
||||
source_type_for_title(const char* title_id) {
|
||||
if(!title_id) {
|
||||
return "unknown";
|
||||
}
|
||||
/* ---------- status JSON ------------------------------------------------- */
|
||||
|
||||
if(!strcmp(title_id, "PPSA90001_00")) {
|
||||
return "shadowmount";
|
||||
}
|
||||
|
||||
if(!strcmp(title_id, "PPSA08329_00")) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return "official";
|
||||
static void
|
||||
rebuild_status_json(void) {
|
||||
snprintf(g_status_json, sizeof(g_status_json),
|
||||
"{\"firmware\":\"%s\","
|
||||
"\"firmware_build\":\"0x%08x\","
|
||||
"\"dns_guard\":\"Active\","
|
||||
"\"resolver\":\"Internal allowlist\","
|
||||
"\"free_space_mb\":0,"
|
||||
"\"download_dir\":\"/mnt/usb0/patches\"}",
|
||||
g_fw.str, g_fw.bin);
|
||||
}
|
||||
|
||||
/* ---------- titles JSON (built per request under mutex) ----------------- */
|
||||
|
||||
static const char *
|
||||
title_status_str(const patchdl_title_t *t) {
|
||||
if (!t->verxml_done) return "checking";
|
||||
if (t->source_type == PATCHDL_SOURCE_UNKNOWN) return "blocked";
|
||||
if (t->compatible_version[0]) {
|
||||
/* Versions are zero-padded (01.000.011), so strcmp orders them.
|
||||
A compatible patch newer than what's installed = update available. */
|
||||
if (t->installed_version[0] &&
|
||||
strcmp(t->compatible_version, t->installed_version) <= 0)
|
||||
return "up_to_date";
|
||||
return "available";
|
||||
}
|
||||
if (t->latest_version[0]) return "incompatible_fw";
|
||||
return "no_update";
|
||||
}
|
||||
|
||||
static const char *
|
||||
title_mode_str(patchdl_source_t src) {
|
||||
switch (src) {
|
||||
case PATCHDL_SOURCE_SHADOWMOUNT: return "download_only";
|
||||
case PATCHDL_SOURCE_UNKNOWN: return "disabled";
|
||||
default: return "latest_compatible";
|
||||
}
|
||||
}
|
||||
|
||||
static char *
|
||||
build_titles_json(void) {
|
||||
jbuf_t j = {0};
|
||||
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
|
||||
jbuf_append(&j, "[");
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
const patchdl_title_t *t = &g_titles[i];
|
||||
if (i > 0) jbuf_append(&j, ",");
|
||||
jbuf_append(&j, "{");
|
||||
jbuf_append(&j, "\"title_id\":"); jbuf_append_str(&j, t->title_id);
|
||||
jbuf_append(&j, ",\"name\":"); jbuf_append_str(&j, t->name);
|
||||
jbuf_append(&j, ",\"content_id\":"); jbuf_append_str(&j, t->content_id);
|
||||
jbuf_append(&j, ",\"installed_version\":");
|
||||
jbuf_append_ver_or_null(&j, t->installed_version);
|
||||
jbuf_append(&j, ",\"compatible_version\":");
|
||||
jbuf_append_ver_or_null(&j, t->compatible_version);
|
||||
jbuf_append(&j, ",\"latest_version\":");
|
||||
jbuf_append_ver_or_null(&j, t->latest_version);
|
||||
jbuf_append(&j, ",\"latest_required_fw\":");
|
||||
jbuf_append_ver_or_null(&j, t->latest_required_fw);
|
||||
jbuf_append(&j, ",\"source_type\":");
|
||||
jbuf_append_str(&j, patchdl_source_str(t->source_type));
|
||||
jbuf_append(&j, ",\"source_path\":"); jbuf_append_str(&j, t->source_path);
|
||||
jbuf_append(&j, ",\"mount_from\":"); jbuf_append_str(&j, t->mount_from);
|
||||
jbuf_append(&j, ",\"version_file_uri\":");
|
||||
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_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_append(&j, ",\"mode\":");
|
||||
jbuf_append_str(&j, title_mode_str(t->source_type));
|
||||
jbuf_append(&j, ",\"queued\":false");
|
||||
jbuf_append(&j, ",\"status\":");
|
||||
jbuf_append_str(&j, title_status_str(t));
|
||||
jbuf_append(&j, "}");
|
||||
}
|
||||
jbuf_append(&j, "]");
|
||||
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
return j.buf; /* caller owns; use queue_json_owned */
|
||||
}
|
||||
|
||||
/* ---------- verxml background fetch ------------------------------------ */
|
||||
|
||||
static void *
|
||||
verxml_fetch_thread(void *arg) {
|
||||
(void)arg;
|
||||
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
patchdl_title_t *t = &g_titles[i];
|
||||
char url[512] = {0};
|
||||
patchdl_verinfo_t info;
|
||||
|
||||
memset(&info, 0, sizeof(info));
|
||||
|
||||
/* The version.xml URL comes straight from the app.db (UUID included);
|
||||
fall back to the resolver only if the app.db had no entry. */
|
||||
if (t->version_file_uri[0])
|
||||
patchdl_verxml_query(t->version_file_uri, g_fw.bin, &info);
|
||||
else if (!patchdl_resolve_url(t->title_id, url, sizeof(url)))
|
||||
patchdl_verxml_query(url, g_fw.bin, &info);
|
||||
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
strncpy(t->compatible_version, info.compatible_version,
|
||||
sizeof(t->compatible_version) - 1);
|
||||
strncpy(t->latest_version, info.latest_version,
|
||||
sizeof(t->latest_version) - 1);
|
||||
strncpy(t->latest_required_fw, info.latest_required_fw,
|
||||
sizeof(t->latest_required_fw) - 1);
|
||||
strncpy(t->patch_url, info.compatible_url,
|
||||
sizeof(t->patch_url) - 1);
|
||||
strncpy(t->patch_title_id, info.compatible_title,
|
||||
sizeof(t->patch_title_id) - 1);
|
||||
t->verxml_done = 1;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---------- title action helper ---------------------------------------- */
|
||||
|
||||
/* Snapshot the fields an action needs, under the lock. Returns 0 if found. */
|
||||
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) {
|
||||
int found = 0;
|
||||
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
if (!strcmp(g_titles[i].title_id, title_id)) {
|
||||
*src = g_titles[i].source_type;
|
||||
strncpy(patch_url, g_titles[i].patch_url, url_sz - 1);
|
||||
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';
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
return found;
|
||||
}
|
||||
|
||||
#define PATCHDL_DL_DIR "/data/patchdl"
|
||||
|
||||
/* Local on-disk path a title's patch downloads to / installs from. */
|
||||
static void
|
||||
title_pkg_path(const char *title_id, const char *patch_url,
|
||||
char *out, size_t out_sz) {
|
||||
const char *base = strrchr(patch_url, '/');
|
||||
base = base ? base + 1 : "patch.pkg";
|
||||
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, base);
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
patchdl_source_t src, const char *patch_url) {
|
||||
char dir[256], dest[320], resp[640];
|
||||
long long bytes = 0;
|
||||
|
||||
if (src == PATCHDL_SOURCE_UNKNOWN)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_unknown\"}");
|
||||
|
||||
if (!patch_url[0])
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
|
||||
|
||||
/* /data/patchdl/<title_id>/<pkg-basename> — homebrew data dir, not system */
|
||||
mkdir(PATCHDL_DL_DIR, 0777);
|
||||
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
|
||||
mkdir(dir, 0777);
|
||||
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
|
||||
|
||||
if (patchdl_http_download(patch_url, dest, &bytes)) {
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":false,\"reason\":\"download_failed\"}");
|
||||
return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp));
|
||||
}
|
||||
|
||||
/* shadowmount: download allowed, install is not (per source policy). */
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":true,\"downloaded\":true,\"bytes\":%lld,\"path\":\"%s\","
|
||||
"\"install_allowed\":%s}",
|
||||
bytes, dest,
|
||||
src == PATCHDL_SOURCE_SHADOWMOUNT ? "false" : "true");
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
|
||||
}
|
||||
|
||||
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) {
|
||||
char dest[320], msg[256], resp[640];
|
||||
int rc;
|
||||
|
||||
/* 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)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"install_not_allowed_for_source\"}");
|
||||
|
||||
if (!patch_url[0])
|
||||
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. */
|
||||
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":false,\"reason\":\"patch_title_mismatch\","
|
||||
"\"patch_title_id\":\"%.15s\",\"title_id\":\"%.15s\"}",
|
||||
patch_title_id, title_id);
|
||||
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
|
||||
}
|
||||
|
||||
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));
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}",
|
||||
rc == 0 ? "true" : "false", rc, msg, dest);
|
||||
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
|
||||
strdup(resp));
|
||||
}
|
||||
|
||||
/* ---------- request routing -------------------------------------------- */
|
||||
|
||||
static int
|
||||
parse_title_action(const char* url, char* title_id, size_t title_id_size,
|
||||
char* action, size_t action_size) {
|
||||
const char* prefix = "/api/titles/";
|
||||
const char* start;
|
||||
const char* slash;
|
||||
size_t len;
|
||||
parse_title_action(const char *url, char *title_id, size_t tid_sz,
|
||||
char *action, size_t act_sz) {
|
||||
const char *prefix = "/api/titles/";
|
||||
const char *start, *slash;
|
||||
size_t len;
|
||||
|
||||
if(strncmp(url, prefix, strlen(prefix))) {
|
||||
return -1;
|
||||
}
|
||||
if (strncmp(url, prefix, strlen(prefix))) return -1;
|
||||
start = url + strlen(prefix);
|
||||
slash = strchr(start, '/');
|
||||
if (!slash || slash == start || !slash[1]) return -1;
|
||||
|
||||
start = url + strlen(prefix);
|
||||
slash = strchr(start, '/');
|
||||
if(!slash || slash == start || !slash[1]) {
|
||||
return -1;
|
||||
}
|
||||
len = (size_t)(slash - start);
|
||||
if (len >= tid_sz) return -1;
|
||||
memcpy(title_id, start, len);
|
||||
title_id[len] = '\0';
|
||||
|
||||
len = slash - start;
|
||||
if(len >= title_id_size) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(title_id, start, len);
|
||||
title_id[len] = 0;
|
||||
|
||||
len = strlen(slash + 1);
|
||||
if(len >= action_size) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(action, slash + 1, len + 1);
|
||||
return 0;
|
||||
len = strlen(slash + 1);
|
||||
if (len >= act_sz) return -1;
|
||||
memcpy(action, slash + 1, len + 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
handle_title_action(struct MHD_Connection* conn, const char* url) {
|
||||
char title_id[32];
|
||||
char action[24];
|
||||
const char* source_type;
|
||||
handle_title_action(struct MHD_Connection *conn, const char *url) {
|
||||
char title_id[32];
|
||||
char action[24];
|
||||
char patch_url[512] = {0};
|
||||
char patch_title_id[16] = {0};
|
||||
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
|
||||
|
||||
if (parse_title_action(url, title_id, sizeof(title_id),
|
||||
action, sizeof(action)))
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
|
||||
if (!get_title_action_info(title_id, &src, patch_url, sizeof(patch_url),
|
||||
patch_title_id, sizeof(patch_title_id)))
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
|
||||
|
||||
if (!strcmp(action, "download"))
|
||||
return do_download(conn, title_id, src, patch_url);
|
||||
|
||||
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);
|
||||
|
||||
if(parse_title_action(url, title_id, sizeof(title_id), action,
|
||||
sizeof(action))) {
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
}
|
||||
|
||||
source_type = source_type_for_title(title_id);
|
||||
|
||||
if(!strcmp(action, "download")) {
|
||||
if(!strcmp(source_type, "unknown")) {
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_unknown\"}");
|
||||
}
|
||||
|
||||
if(!strcmp(source_type, "shadowmount")) {
|
||||
return queue_json(conn, MHD_HTTP_ACCEPTED,
|
||||
"{\"ok\":true,\"queued\":true,"
|
||||
"\"source_type\":\"shadowmount\","
|
||||
"\"install_allowed\":false}");
|
||||
}
|
||||
|
||||
return queue_json(conn, MHD_HTTP_ACCEPTED,
|
||||
"{\"ok\":true,\"queued\":true,"
|
||||
"\"install_allowed\":true}");
|
||||
}
|
||||
|
||||
if(!strcmp(action, "check")) {
|
||||
return queue_json(conn, MHD_HTTP_ACCEPTED,
|
||||
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
|
||||
}
|
||||
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
if(!strcmp(method, MHD_HTTP_METHOD_OPTIONS)) {
|
||||
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
|
||||
}
|
||||
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;
|
||||
if (!strcmp(method, MHD_HTTP_METHOD_POST)) {
|
||||
if (*upload_data_size) { *upload_data_size = 0; return MHD_YES; }
|
||||
if (!strcmp(url, "/api/config"))
|
||||
return queue_json(conn, MHD_HTTP_OK, config_json);
|
||||
if (!strncmp(url, "/api/titles/", 12))
|
||||
return handle_title_action(conn, url);
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
}
|
||||
|
||||
if(!strcmp(url, "/api/config")) {
|
||||
return queue_json(conn, MHD_HTTP_OK, config_json);
|
||||
if (strcmp(method, MHD_HTTP_METHOD_GET) &&
|
||||
strcmp(method, MHD_HTTP_METHOD_HEAD))
|
||||
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
|
||||
|
||||
if (!strcmp(url, "/api/status"))
|
||||
return queue_json(conn, MHD_HTTP_OK, g_status_json);
|
||||
|
||||
if (!strcmp(url, "/api/config"))
|
||||
return queue_json(conn, MHD_HTTP_OK, config_json);
|
||||
|
||||
if (!strcmp(url, "/api/titles"))
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
|
||||
|
||||
if (!strcmp(url, "/api/downloads"))
|
||||
return queue_json(conn, MHD_HTTP_OK, downloads_json);
|
||||
|
||||
if (!strcmp(url, "/api/debug"))
|
||||
return queue_json(conn, MHD_HTTP_OK,
|
||||
g_debug_json ? g_debug_json : "{}");
|
||||
|
||||
if (!strcmp(url, "/api/installcheck")) {
|
||||
char m[256], r[320];
|
||||
int rc = patchdl_install_backend_check(m, sizeof(m));
|
||||
snprintf(r, sizeof(r), "{\"ready\":%s,\"message\":\"%s\"}",
|
||||
rc == 0 ? "true" : "false", m);
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
|
||||
}
|
||||
|
||||
if(!strncmp(url, "/api/titles/", 12)) {
|
||||
return handle_title_action(conn, url);
|
||||
if (!strcmp(url, "/api/netcheck")) {
|
||||
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
if (g_titles[i].version_file_uri[0]) {
|
||||
patchdl_net_diag(g_titles[i].version_file_uri, diag, sizeof(diag));
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
return queue_json(conn, MHD_HTTP_OK, diag);
|
||||
}
|
||||
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||
}
|
||||
|
||||
if(strcmp(method, MHD_HTTP_METHOD_GET) &&
|
||||
strcmp(method, MHD_HTTP_METHOD_HEAD)) {
|
||||
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
|
||||
}
|
||||
|
||||
if(!strcmp(url, "/api/status")) {
|
||||
return queue_json(conn, MHD_HTTP_OK, status_json);
|
||||
}
|
||||
if(!strcmp(url, "/api/config")) {
|
||||
return queue_json(conn, MHD_HTTP_OK, config_json);
|
||||
}
|
||||
if(!strcmp(url, "/api/titles")) {
|
||||
return queue_json(conn, MHD_HTTP_OK, titles_json);
|
||||
}
|
||||
if(!strcmp(url, "/api/downloads")) {
|
||||
return queue_json(conn, MHD_HTTP_OK, downloads_json);
|
||||
}
|
||||
|
||||
return queue_asset(conn, url);
|
||||
return queue_asset(conn, url);
|
||||
}
|
||||
|
||||
/* ---------- lifecycle -------------------------------------------------- */
|
||||
|
||||
int
|
||||
patchdl_websrv_start(unsigned short port) {
|
||||
if(web_daemon) {
|
||||
pthread_t tid;
|
||||
pthread_attr_t attr;
|
||||
|
||||
if (web_daemon) return 0;
|
||||
|
||||
/* Collect real FW version and installed titles synchronously.
|
||||
The web UI is reachable only after this completes. */
|
||||
patchdl_fw_get(&g_fw);
|
||||
rebuild_status_json();
|
||||
|
||||
if (patchdl_scan(&g_titles, &g_title_count))
|
||||
g_title_count = 0;
|
||||
|
||||
/* 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();
|
||||
|
||||
web_daemon = MHD_start_daemon(
|
||||
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
|
||||
port, NULL, NULL, &on_request, NULL,
|
||||
MHD_OPTION_END);
|
||||
|
||||
if (!web_daemon) {
|
||||
patchdl_scan_free(g_titles, g_title_count);
|
||||
g_titles = NULL;
|
||||
g_title_count = 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Start background verxml fetch — detached, runs until complete */
|
||||
pthread_attr_init(&attr);
|
||||
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
|
||||
pthread_create(&tid, &attr, verxml_fetch_thread, NULL);
|
||||
pthread_attr_destroy(&attr);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
web_daemon = MHD_start_daemon(MHD_USE_INTERNAL_POLLING_THREAD |
|
||||
MHD_USE_THREAD_PER_CONNECTION,
|
||||
port, NULL, NULL, &on_request, NULL,
|
||||
MHD_OPTION_END);
|
||||
|
||||
return web_daemon ? 0 : -1;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_websrv_stop(void) {
|
||||
if(web_daemon) {
|
||||
MHD_stop_daemon(web_daemon);
|
||||
web_daemon = 0;
|
||||
}
|
||||
if (web_daemon) {
|
||||
MHD_stop_daemon(web_daemon);
|
||||
web_daemon = NULL;
|
||||
}
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
patchdl_scan_free(g_titles, g_title_count);
|
||||
g_titles = NULL;
|
||||
g_title_count = 0;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
Vendored
+257679
File diff suppressed because it is too large.
Load diff
Vendored
+13425
File diff suppressed because it is too large.
Load diff
+103
-27
@@ -284,13 +284,26 @@ function renderGames() {
|
||||
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
|
||||
}
|
||||
|
||||
// One mutually-exclusive "what can I do with this title" bucket per game.
|
||||
// updatable : a newer compatible patch exists AND this source may be installed
|
||||
// uptodate : already on the newest compatible patch
|
||||
// needsfw : a newer patch exists but needs a newer firmware than installed
|
||||
// blocked : source can't be safely patched (shadowmount / preinstall / unknown)
|
||||
// checking : version lookup still running
|
||||
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 (!sourcePolicy(game).allow_install) return "blocked";
|
||||
if (game.status === "available") return "updatable";
|
||||
if (game.status === "incompatible_fw") return "needsfw";
|
||||
return "uptodate";
|
||||
}
|
||||
|
||||
function matchesFilter(game) {
|
||||
if (state.filter === "enabled") return game.enabled;
|
||||
if (state.filter === "available") return game.status === "available";
|
||||
if (state.filter === "shadowmount") return sourceType(game) === "shadowmount";
|
||||
if (state.filter === "blocked") return game.status === "blocked";
|
||||
if (state.filter === "queued") return game.queued || game.status === "queued";
|
||||
return true;
|
||||
if (state.filter === "all") return true;
|
||||
if (state.filter === "queued") return Boolean(game.queued) || game.status === "queued";
|
||||
return gameCategory(game) === state.filter;
|
||||
}
|
||||
|
||||
function matchesQuery(game) {
|
||||
@@ -376,6 +389,10 @@ function createGameCard(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>
|
||||
`;
|
||||
|
||||
actions.querySelectorAll("button").forEach((button) => {
|
||||
@@ -397,10 +414,14 @@ function versionBox(label, value) {
|
||||
}
|
||||
|
||||
function statusPill(game) {
|
||||
if (game.status === "blocked") return `<span class="pill blocked">${escapeHtml(blockedLabel(game))}</span>`;
|
||||
if (game.status === "queued") return `<span class="pill warn">In Queue</span>`;
|
||||
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
|
||||
return `<span class="pill">Current</span>`;
|
||||
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.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>`;
|
||||
}
|
||||
|
||||
function sourcePill(game) {
|
||||
@@ -425,7 +446,10 @@ function sourceDetail(game) {
|
||||
}
|
||||
|
||||
function blockedLabel(game) {
|
||||
if (sourceType(game) === "unknown") return "Unknown source";
|
||||
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";
|
||||
}
|
||||
@@ -504,30 +528,50 @@ async function runTitleAction(titleId, action) {
|
||||
showToast(downloadTitle(game));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await postJson(API.action(titleId, action), {});
|
||||
} catch (error) {
|
||||
// Demo mode keeps the UI interactive before the ELF API exists.
|
||||
if (action === "install" && !isInstallAllowed(game)) {
|
||||
showToast(installTitle(game));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "download" && game.compatible_version) {
|
||||
const detail = isInstallBlocked(game)
|
||||
? "Download only - install blocked by source policy"
|
||||
: "Waiting to start";
|
||||
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;
|
||||
game.status = "queued";
|
||||
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: 0,
|
||||
detail,
|
||||
progress: 100,
|
||||
detail: result && result.install_allowed ? "Downloaded - ready to install" : "Downloaded (install blocked)",
|
||||
});
|
||||
}
|
||||
state.logs.push(`[${timeNow()}] Queued ${game.title_id} ${game.compatible_version} (${sourceType(game)}, install=${isInstallBlocked(game) ? "blocked" : "allowed"})`);
|
||||
showToast(`${game.title_id} was added to the queue.`);
|
||||
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.`);
|
||||
@@ -561,6 +605,20 @@ function isDownloadAllowed(game) {
|
||||
function isInstallBlocked(game) {
|
||||
return !sourcePolicy(game).allow_install;
|
||||
}
|
||||
function isInstallAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled &&
|
||||
game.compatible_version &&
|
||||
game.patch_title_match !== false &&
|
||||
sourcePolicy(game).allow_install
|
||||
);
|
||||
}
|
||||
function installTitle(game) {
|
||||
if (game.patch_title_match === false) return "Patch is for a different title/region — 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).";
|
||||
}
|
||||
|
||||
function sourcePolicy(game) {
|
||||
return sourcePolicyForType(sourceType(game));
|
||||
@@ -586,8 +644,26 @@ async function postJson(url, body) {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json().catch(() => ({}));
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.reason || data.message || `HTTP ${response.status}`);
|
||||
err.body = data;
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const REASON_TEXT = {
|
||||
patch_title_mismatch: "Patch is for a different title/region — install blocked.",
|
||||
install_not_allowed_for_source: "Install blocked for this source.",
|
||||
source_unknown: "Source unknown — blocked.",
|
||||
no_compatible_patch: "No compatible patch available.",
|
||||
download_failed: "Download failed.",
|
||||
};
|
||||
function reasonText(error) {
|
||||
const r = error && error.body && error.body.reason;
|
||||
return (r && REASON_TEXT[r]) || (error && error.message) || "failed";
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
|
||||
+5
-5
@@ -53,7 +53,7 @@
|
||||
<div class="brand-mark">PD</div>
|
||||
<div>
|
||||
<strong>PatchDL</strong>
|
||||
<span>PS5 Patch Control</span>
|
||||
<span>by Knutwurst · v0.0.1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,10 +125,10 @@
|
||||
|
||||
<div class="segmented" role="tablist" aria-label="Filter">
|
||||
<button class="is-selected" data-filter="all">All</button>
|
||||
<button data-filter="enabled">Enabled</button>
|
||||
<button data-filter="available">Updates</button>
|
||||
<button data-filter="shadowmount">Shadow</button>
|
||||
<button data-filter="blocked">Blocked</button>
|
||||
<button data-filter="updatable">Updatable</button>
|
||||
<button data-filter="uptodate">Up to date</button>
|
||||
<button data-filter="needsfw">Needs FW</button>
|
||||
<button data-filter="blocked">Can't update</button>
|
||||
<button data-filter="queued">Queue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user