mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-18 08:00:58 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b0bba49a | ||
|
|
0efa0312d5 | ||
|
|
ca5977f872 | ||
|
|
04d27a9fd5 | ||
|
|
9253235a0d | ||
|
|
664ad3d4fd | ||
|
|
9209408c40 |
No files matched your search
@@ -13,7 +13,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/v/release/knutwurst/ps5-patchdl?style=for-the-badge&label=release&color=22c55e" alt="Latest release">
|
||||
<img src="https://img.shields.io/github/downloads/knutwurst/ps5-patchdl/total?style=for-the-badge&color=22c55e" alt="Downloads">
|
||||
<img src="https://img.shields.io/badge/FW-11.60%20%C2%B7%2013.60-22c55e?style=for-the-badge" alt="Firmware 11.60 and 13.60">
|
||||
<img src="https://img.shields.io/badge/FW-1.00%E2%80%9313.60-22c55e?style=for-the-badge" alt="Firmware 1.00 to 13.60">
|
||||
<img src="https://img.shields.io/badge/UI-any%20browser-22c55e?style=for-the-badge" alt="Web UI in any browser">
|
||||
<img src="https://img.shields.io/github/license/knutwurst/ps5-patchdl?style=for-the-badge&color=3a3a3a" alt="License">
|
||||
</p>
|
||||
@@ -65,7 +65,7 @@ Start an update from the couch with your phone, check on it from your laptop, or
|
||||
3. A notification on the TV shows the address, for example `http://192.168.0.42:12880/`. Open it in any browser on your network.
|
||||
4. Optional: switch on **Home-screen shortcut** in Settings to get the PatchDL tile.
|
||||
|
||||
**You need** a PS5 that can load ELF payloads. PatchDL runs on firmware 11.60 and 13.60.
|
||||
**You need** a PS5 that can load ELF payloads. PatchDL runs on firmware 1.00 to 13.60 (tested on 11.60 and 13.60).
|
||||
|
||||
## Good to know
|
||||
|
||||
@@ -102,7 +102,7 @@ PatchDL downloads official, unmodified game updates that Sony publishes for game
|
||||
scripts/build_ps5.sh # produces patchdl-ps5.elf
|
||||
```
|
||||
|
||||
You need the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk) with `libcurl` and `OpenSSL` from its [pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo). `libmicrohttpd` and SQLite are vendored. GitHub Actions builds every push; tagged builds become releases.
|
||||
You need the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk), version 0.44 or newer for firmware 1.00 to 13.60, with `libcurl` and `OpenSSL` from its [pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo). `libmicrohttpd` and SQLite are vendored. GitHub Actions builds every push; tagged builds become releases.
|
||||
|
||||
To try a build on your own console:
|
||||
|
||||
|
||||
@@ -115,6 +115,25 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
/* Icon paths in a separate query: if a firmware lacks the icon0Info
|
||||
column, only the icons go missing, not the whole title list. */
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT titleId, icon0Info FROM tbl_contentinfo "
|
||||
"WHERE icon0Info IS NOT NULL AND icon0Info != '';",
|
||||
-1, &stmt, NULL) == SQLITE_OK) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *tid = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *icon = (const char *)sqlite3_column_text(stmt, 1);
|
||||
patchdl_appinfo_t *e;
|
||||
if (!tid || !icon) continue;
|
||||
e = (patchdl_appinfo_t *)patchdl_appdb_find(arr, n, tid);
|
||||
if (!e || e->icon_path[0]) continue;
|
||||
strncpy(e->icon_path, icon, sizeof(e->icon_path) - 1);
|
||||
e->icon_path[strcspn(e->icon_path, "?")] = '\0';
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
sqlite3_close(db);
|
||||
|
||||
*out = arr;
|
||||
|
||||
@@ -17,6 +17,7 @@ typedef struct {
|
||||
char content_id[64];
|
||||
char content_version[16];
|
||||
char version_file_uri[256]; /* Sony version.xml URL (UUID assigned) */
|
||||
char icon_path[256]; /* icon0Info without its "?ts=" suffix, or "" */
|
||||
} patchdl_appinfo_t;
|
||||
|
||||
/* Read the PS5 app database (tbl_contentinfo) for installed-title metadata.
|
||||
|
||||
+107
-15
@@ -13,6 +13,7 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
@@ -237,13 +238,42 @@ read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Keep only actual games. Skip PS5 system apps (NPXS), the Payload Manager
|
||||
fake app (PLDM) and ELF-loader homebrew (PSPS). */
|
||||
/* Tell a Sony-signed package from a debug/fake-signed one by its header.
|
||||
PS5 finalized image "\x7FFIH": byte 0x05 is 0x80 for retail, 0x00 for
|
||||
debug (matches Sony's CDN patches, which start 7F 46 49 48 01 80).
|
||||
PS4 container "\x7FCNT": bit 0 of byte 4 is set for retail, clear for a
|
||||
fake package (same test Itemzflow uses). */
|
||||
static patchdl_pkg_sign_t
|
||||
pkg_sign_state(const char *dir) {
|
||||
char p[PATH_MAX];
|
||||
unsigned char h[8];
|
||||
FILE *fp;
|
||||
size_t n;
|
||||
|
||||
snprintf(p, sizeof(p), "%s/app.pkg", dir);
|
||||
fp = fopen(p, "rb");
|
||||
if (!fp) return PATCHDL_PKG_UNREAD;
|
||||
n = fread(h, 1, sizeof(h), fp);
|
||||
fclose(fp);
|
||||
if (n < sizeof(h)) return PATCHDL_PKG_UNREAD;
|
||||
|
||||
if (!memcmp(h, "\x7f" "FIH", 4))
|
||||
return (h[5] & 0x80) ? PATCHDL_PKG_RETAIL : PATCHDL_PKG_FAKE;
|
||||
if (!memcmp(h, "\x7f" "CNT", 4))
|
||||
return (h[4] & 0x01) ? PATCHDL_PKG_RETAIL : PATCHDL_PKG_FAKE;
|
||||
return PATCHDL_PKG_UNREAD;
|
||||
}
|
||||
|
||||
/* Keep only actual games: PS5 (PPSA) and PS4 (CUSA) title ids followed by
|
||||
five digits. System apps (NPXS) and homebrew with made-up ids (PatchDL's
|
||||
own tile PTDL00001, loaders, file managers) have no Sony updates. */
|
||||
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;
|
||||
if (strncmp(title_id, "PPSA", 4) && strncmp(title_id, "CUSA", 4))
|
||||
return 0;
|
||||
for (int i = 4; i < 9; i++)
|
||||
if (title_id[i] < '0' || title_id[i] > '9')
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -270,19 +300,22 @@ mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts)
|
||||
* Classify each /user/app/<TID> (verified on fw 11.60):
|
||||
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
|
||||
* mount (authoritative; mount.lnk is only a fallback hint)
|
||||
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
|
||||
* URLs means a not-downloaded preinstall stub, local URLs a real install
|
||||
* - app.json with "fake":true -> homebrew fake (skip)
|
||||
* - app.pkg (no shadow mount) -> its header decides: Sony-signed is a
|
||||
* genuine install (store or disc), debug/fake-signed is a fake package
|
||||
* (listed, but never checked, downloaded or patched), unreadable is unknown
|
||||
* - app.json with "fake":true -> fake package if it has an app.pkg,
|
||||
* otherwise homebrew (skip)
|
||||
*/
|
||||
static int
|
||||
scan_one(const char *base, const char *name, patchdl_title_t *t,
|
||||
const struct statfs *mounts, int nmounts) {
|
||||
char dir[PATH_MAX];
|
||||
char appjson[4096];
|
||||
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0, is_cdn = 0;
|
||||
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0;
|
||||
|
||||
snprintf(dir, sizeof(dir), "%s/%s", base, name);
|
||||
memset(t, 0, sizeof(*t));
|
||||
t->icon_fd = -1;
|
||||
strncpy(t->source_path, dir, sizeof(t->source_path) - 1);
|
||||
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
|
||||
|
||||
@@ -298,10 +331,10 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
|
||||
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)
|
||||
/* "fake": true marks both homebrew and installed fake packages. Only
|
||||
homebrew (no app.pkg) drops out; a fake package stays in the list. */
|
||||
if (is_fake && !has_app_pkg)
|
||||
return -1;
|
||||
|
||||
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
|
||||
@@ -309,8 +342,15 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
|
||||
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
|
||||
whether the shadow image is currently mounted. */
|
||||
if (has_app_pkg) {
|
||||
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN /* CDN pkg = preinstall */
|
||||
: PATCHDL_SOURCE_OFFICIAL;
|
||||
/* app.json piece URLs are no signal: disc and store installs carry
|
||||
CDN URLs there too. The package header is. */
|
||||
t->pkg_sign = pkg_sign_state(dir);
|
||||
/* The installer's own fake marker wins over the header: block when
|
||||
either says fake. */
|
||||
t->source_type = (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
|
||||
? PATCHDL_SOURCE_FAKE
|
||||
: t->pkg_sign == PATCHDL_PKG_RETAIL ? PATCHDL_SOURCE_OFFICIAL
|
||||
: PATCHDL_SOURCE_UNKNOWN;
|
||||
} else if (is_shadow || has_mountlnk || has_paramjson ||
|
||||
path_exists(dir, "sce_sys/param.sfo")) {
|
||||
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
|
||||
@@ -365,10 +405,55 @@ patchdl_source_str(patchdl_source_t src) {
|
||||
case PATCHDL_SOURCE_OFFICIAL: return "official";
|
||||
case PATCHDL_SOURCE_EXTERNAL: return "external";
|
||||
case PATCHDL_SOURCE_SHADOWMOUNT: return "shadowmount";
|
||||
case PATCHDL_SOURCE_FAKE: return "fake";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* Open a title's icon0.png while the scan still sees the whole filesystem.
|
||||
The web server can't reach these paths later, but an fd opened now stays
|
||||
readable. Tries the app.db path first, then the usual install locations.
|
||||
Returns an fd to a non-empty PNG of sane size, or -1. */
|
||||
#define ICON_MAX_BYTES (4 * 1024 * 1024)
|
||||
|
||||
static int
|
||||
open_png(const char *path) {
|
||||
unsigned char magic[8];
|
||||
struct stat st;
|
||||
int fd;
|
||||
|
||||
if (!path || path[0] != '/') return -1;
|
||||
fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
if (fstat(fd, &st) || !S_ISREG(st.st_mode) ||
|
||||
st.st_size < 8 || st.st_size > ICON_MAX_BYTES ||
|
||||
pread(fd, magic, 8, 0) != 8 ||
|
||||
memcmp(magic, "\x89PNG\r\n\x1a\n", 8)) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int
|
||||
open_icon(const patchdl_title_t *t, const char *db_path) {
|
||||
static const char *const candidates[] = {
|
||||
"/user/appmeta/%s/icon0.png",
|
||||
"/user/appmeta/external/%s/icon0.png",
|
||||
"%s/sce_sys/icon0.png", /* source_path, e.g. a shadowmount */
|
||||
NULL,
|
||||
};
|
||||
char path[PATH_MAX];
|
||||
int fd = open_png(db_path);
|
||||
|
||||
for (int i = 0; fd < 0 && candidates[i]; i++) {
|
||||
snprintf(path, sizeof(path), candidates[i],
|
||||
i < 2 ? t->title_id : t->source_path);
|
||||
fd = open_png(path);
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -396,6 +481,11 @@ merge_appdb(patchdl_title_t *arr, size_t cnt) {
|
||||
sizeof(arr[i].version_file_uri) - 1);
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < cnt; i++) {
|
||||
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
|
||||
arr[i].icon_fd = open_icon(&arr[i], a ? a->icon_path : NULL);
|
||||
}
|
||||
|
||||
patchdl_appdb_free(info);
|
||||
}
|
||||
|
||||
@@ -460,7 +550,9 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
|
||||
void
|
||||
patchdl_scan_free(patchdl_title_t *titles, size_t count) {
|
||||
(void)count;
|
||||
for (size_t i = 0; titles && i < count; i++)
|
||||
if (titles[i].icon_fd >= 0)
|
||||
close(titles[i].icon_fd);
|
||||
free(titles);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,17 @@ typedef enum {
|
||||
PATCHDL_SOURCE_EXTERNAL = 1,
|
||||
PATCHDL_SOURCE_SHADOWMOUNT = 2,
|
||||
PATCHDL_SOURCE_UNKNOWN = 3,
|
||||
PATCHDL_SOURCE_FAKE = 4, /* debug/fake-signed package: not supported */
|
||||
} patchdl_source_t;
|
||||
|
||||
/* Signing state read from the installed app.pkg header. */
|
||||
typedef enum {
|
||||
PATCHDL_PKG_NONE = 0, /* no app.pkg */
|
||||
PATCHDL_PKG_RETAIL = 1,
|
||||
PATCHDL_PKG_FAKE = 2,
|
||||
PATCHDL_PKG_UNREAD = 3, /* app.pkg present but header unreadable/unknown */
|
||||
} patchdl_pkg_sign_t;
|
||||
|
||||
typedef struct {
|
||||
char title_id[32];
|
||||
char content_id[64];
|
||||
@@ -28,6 +37,8 @@ typedef struct {
|
||||
char mount_from[256];
|
||||
char version_file_uri[256]; /* from app.db, for version.xml */
|
||||
patchdl_source_t source_type;
|
||||
patchdl_pkg_sign_t pkg_sign; /* app.pkg header verdict, for diagnostics */
|
||||
int icon_fd; /* open icon0.png, or -1; see patchdl_scan */
|
||||
/* populated by background verxml fetch — guarded by websrv mutex */
|
||||
char compatible_version[16];
|
||||
char latest_version[16];
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.7"
|
||||
#define PATCHDL_VERSION "0.0.8"
|
||||
+79
-5
@@ -136,7 +136,8 @@ static const char config_tail_json[] =
|
||||
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
||||
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
||||
"\"shadowmount\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":false},"
|
||||
"\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}"
|
||||
"\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false},"
|
||||
"\"fake\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}"
|
||||
"},"
|
||||
"\"cdn_allowlist\":["
|
||||
"\"sgst.prod.dl.playstation.net\","
|
||||
@@ -331,6 +332,44 @@ queue_json_owned(struct MHD_Connection *conn, unsigned int status, char *json) {
|
||||
json, strlen(json), MHD_RESPMEM_MUST_FREE);
|
||||
}
|
||||
|
||||
/* Serve a title's icon from the fd the scan opened. Icons don't change while
|
||||
PatchDL runs, so the browser may cache them; the card list re-renders on
|
||||
every poll and would otherwise refetch all of them. */
|
||||
static enum MHD_Result
|
||||
queue_icon(struct MHD_Connection *conn, const char *title_id) {
|
||||
struct MHD_Response *resp;
|
||||
enum MHD_Result ret;
|
||||
struct stat st;
|
||||
unsigned char *buf;
|
||||
int fd = -1;
|
||||
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (!strcmp(g_titles[i].title_id, title_id)) {
|
||||
fd = g_titles[i].icon_fd;
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
if (fd < 0 || fstat(fd, &st) || st.st_size <= 0)
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "no icon");
|
||||
buf = malloc((size_t)st.st_size);
|
||||
if (!buf) return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
|
||||
if (pread(fd, buf, (size_t)st.st_size, 0) != st.st_size) {
|
||||
free(buf);
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "no icon");
|
||||
}
|
||||
|
||||
resp = MHD_create_response_from_buffer((size_t)st.st_size, buf,
|
||||
MHD_RESPMEM_MUST_FREE);
|
||||
if (!resp) { free(buf); return MHD_NO; }
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, "image/png");
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "max-age=86400");
|
||||
ret = MHD_queue_response(conn, MHD_HTTP_OK, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
queue_asset(struct MHD_Connection *conn, const char *url) {
|
||||
const patchdl_asset_t *asset = patchdl_asset_find(url);
|
||||
@@ -721,16 +760,28 @@ load_config(void) {
|
||||
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->source_type == PATCHDL_SOURCE_UNKNOWN ||
|
||||
t->source_type == PATCHDL_SOURCE_FAKE) 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)
|
||||
strcmp(t->compatible_version, t->installed_version) <= 0) {
|
||||
/* Nothing installable, but a newer patch exists that needs a
|
||||
newer firmware: say so instead of claiming up to date. */
|
||||
if (t->latest_version[0] &&
|
||||
strcmp(t->latest_version, t->installed_version) > 0)
|
||||
return "incompatible_fw";
|
||||
return "up_to_date";
|
||||
}
|
||||
return "available";
|
||||
}
|
||||
if (t->latest_version[0]) return "incompatible_fw";
|
||||
if (t->latest_version[0]) {
|
||||
if (t->installed_version[0] &&
|
||||
strcmp(t->latest_version, t->installed_version) <= 0)
|
||||
return "up_to_date";
|
||||
return "incompatible_fw";
|
||||
}
|
||||
return "no_update";
|
||||
}
|
||||
|
||||
@@ -739,6 +790,7 @@ title_mode_str(patchdl_source_t src) {
|
||||
switch (src) {
|
||||
case PATCHDL_SOURCE_SHADOWMOUNT: return "download_only";
|
||||
case PATCHDL_SOURCE_UNKNOWN: return "disabled";
|
||||
case PATCHDL_SOURCE_FAKE: return "disabled";
|
||||
default: return "latest_compatible";
|
||||
}
|
||||
}
|
||||
@@ -767,6 +819,12 @@ build_titles_json(void) {
|
||||
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, t->icon_fd >= 0 ? ",\"has_icon\":true" : ",\"has_icon\":false");
|
||||
jbuf_append(&j, ",\"pkg_sign\":");
|
||||
jbuf_append_str(&j, t->pkg_sign == PATCHDL_PKG_RETAIL ? "retail"
|
||||
: t->pkg_sign == PATCHDL_PKG_FAKE ? "fake"
|
||||
: t->pkg_sign == PATCHDL_PKG_UNREAD ? "unreadable"
|
||||
: "none");
|
||||
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\":");
|
||||
@@ -899,6 +957,7 @@ verxml_fetch_thread(void *arg) {
|
||||
if (g_verxml_stop) break; /* shutdown: stop before the next blocking query */
|
||||
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])
|
||||
@@ -983,6 +1042,8 @@ set_title_enabled(struct MHD_Connection *conn, const char *title_id, int en) {
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
if (!strcmp(g_titles[i].title_id, title_id)) {
|
||||
/* Fake packages stay off; the toggle can't switch them on. */
|
||||
if (g_titles[i].source_type == PATCHDL_SOURCE_FAKE) en = 0;
|
||||
g_titles[i].enabled = en;
|
||||
found = 1;
|
||||
break;
|
||||
@@ -1537,6 +1598,9 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
if (src == PATCHDL_SOURCE_UNKNOWN)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_unknown\"}");
|
||||
if (src == PATCHDL_SOURCE_FAKE)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_fake\"}");
|
||||
if (!patch_url[0])
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
|
||||
@@ -1702,7 +1766,7 @@ do_install(struct MHD_Connection *conn, const char *title_id,
|
||||
"{\"ok\":false,\"reason\":\"title_disabled\"}");
|
||||
|
||||
/* Source policy: only genuine installs may be patched in place.
|
||||
Shadowmounts are download-only; unknown/preinstall are blocked. */
|
||||
Shadowmounts are download-only; unknown and fake packages 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\"}");
|
||||
@@ -2071,6 +2135,12 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
if (!strcmp(url, "/api/titles"))
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
|
||||
|
||||
if (!strncmp(url, "/api/icon/", 10)) {
|
||||
if (!path_segment_safe(url + 10))
|
||||
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
|
||||
return queue_icon(conn, url + 10);
|
||||
}
|
||||
|
||||
if (!strcmp(url, "/api/downloads"))
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, build_downloads_json());
|
||||
|
||||
@@ -2307,6 +2377,10 @@ patchdl_websrv_start(unsigned short port) {
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
|
||||
load_config();
|
||||
/* A saved config must not switch a fake package back on. */
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (g_titles[i].source_type == PATCHDL_SOURCE_FAKE)
|
||||
g_titles[i].enabled = 0;
|
||||
|
||||
/* If the user opted into a home-screen tile, refresh it now. The helper
|
||||
is stat-guarded — when nothing changed on disk it's a no-op, so the
|
||||
|
||||
+35
-4
@@ -7,11 +7,15 @@ const API = {
|
||||
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
|
||||
};
|
||||
|
||||
// title_id -> loaded <img>, so re-rendered cards show icons instantly.
|
||||
const iconCache = new Map();
|
||||
|
||||
const SOURCE_TYPES = {
|
||||
official: { label: "Official", className: "ok", installAllowed: true, description: "Native console installation" },
|
||||
external: { label: "External", className: "external", installAllowed: true, description: "Real external installation" },
|
||||
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Download only" },
|
||||
unknown: { label: "Unknown", className: "blocked", installAllowed: false, description: "Blocked" },
|
||||
fake: { label: "Fake package", className: "blocked", installAllowed: false, description: "Not supported" },
|
||||
};
|
||||
|
||||
const fallback = {
|
||||
@@ -36,6 +40,7 @@ const fallback = {
|
||||
external: { allow_check: true, allow_download: true, allow_install: true },
|
||||
shadowmount: { allow_check: true, allow_download: true, allow_install: false },
|
||||
unknown: { allow_check: true, allow_download: false, allow_install: false },
|
||||
fake: { allow_check: true, allow_download: false, allow_install: false },
|
||||
},
|
||||
cdn_allowlist: [
|
||||
"sgst.prod.dl.playstation.net",
|
||||
@@ -202,7 +207,8 @@ async function loadInitialData() {
|
||||
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
|
||||
titles.forEach((g) => {
|
||||
const old = prev.get(g.title_id);
|
||||
if (old && g.status !== "up_to_date") {
|
||||
// Neither state has a patch left to fetch, so drop stale job flags.
|
||||
if (old && g.status !== "up_to_date" && g.status !== "incompatible_fw") {
|
||||
if (old.downloaded) g.downloaded = true;
|
||||
if (old.installing) g.installing = true;
|
||||
if (old._autoInstalled) g._autoInstalled = true;
|
||||
@@ -308,7 +314,8 @@ function renderSettings() {
|
||||
|
||||
function renderGames() {
|
||||
renderGlobalStatus();
|
||||
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
|
||||
const visible = state.titles.filter(matchesFilter).filter(matchesQuery)
|
||||
.sort((a, b) => (a.name || a.title_id).localeCompare(b.name || b.title_id, undefined, { sensitivity: "base", numeric: true }));
|
||||
els.gameGrid.replaceChildren();
|
||||
|
||||
if (!visible.length) {
|
||||
@@ -332,7 +339,13 @@ function gameCategory(game) {
|
||||
// Updatable rather than letting it fall out of every specific filter.
|
||||
if (game.status === "checking") return "updatable";
|
||||
if (game.patch_title_match === false) return "blocked";
|
||||
if (game.status === "available" && isDownloadAllowed(game)) return "updatable";
|
||||
// Updatable means PatchDL can install it. Download-only titles (shadowmounts,
|
||||
// shared-master patches) count as "can't update" unless a download of
|
||||
// theirs is already running or paused.
|
||||
if (game.status === "available" && isInstallAllowed(game)) return "updatable";
|
||||
if (game.status === "available" && isDownloadAllowed(game) &&
|
||||
(game.downloading || game.resumable)) return "updatable";
|
||||
if (game.status === "available") return "blocked";
|
||||
if (!sourcePolicy(game).allow_install && !sourcePolicy(game).allow_download) return "blocked";
|
||||
if (game.status === "incompatible_fw") return "needsfw";
|
||||
return "uptodate";
|
||||
@@ -366,6 +379,20 @@ function createGameCard(game) {
|
||||
const cover = document.createElement("div");
|
||||
cover.className = "cover";
|
||||
cover.textContent = initials(game.name);
|
||||
if (game.has_icon) {
|
||||
// Cards re-render on every poll. Reuse an icon that already loaded so
|
||||
// it doesn't flash back to the initials; a failed icon keeps them.
|
||||
const loaded = iconCache.get(game.title_id);
|
||||
if (loaded) {
|
||||
cover.replaceChildren(loaded.cloneNode());
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.alt = "";
|
||||
img.decoding = "async";
|
||||
img.onload = () => { iconCache.set(game.title_id, img); cover.replaceChildren(img.cloneNode()); };
|
||||
img.src = `/api/icon/${encodeURIComponent(game.title_id)}`;
|
||||
}
|
||||
}
|
||||
lead.appendChild(cover);
|
||||
if (sourcePolicy(game).allow_install) lead.appendChild(buildToggle(game));
|
||||
|
||||
@@ -388,7 +415,7 @@ function createGameCard(game) {
|
||||
<div class="versions">
|
||||
<span>Installed <b>${escapeHtml(game.installed_version || "--")}</b></span>
|
||||
<span>Compatible <b>${escapeHtml(game.compatible_version || "None")}</b></span>
|
||||
<span>Latest <b>${escapeHtml(game.latest_version || "--")}</b>${game.latest_required_fw ? ` · FW ${escapeHtml(game.latest_required_fw)}` : ""}</span>
|
||||
<span>Latest <b>${escapeHtml(game.latest_version || "--")}</b>${game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : ""}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -493,7 +520,10 @@ function stopButton(game) {
|
||||
function statusPill(game) {
|
||||
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
||||
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
|
||||
if (sourceType(game) === "fake") return `<span class="pill">Not supported</span>`;
|
||||
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
|
||||
if (game.status === "available" && !isInstallAllowed(game))
|
||||
return `<span class="pill warn">Update · download only</span>`;
|
||||
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
|
||||
if (game.status === "incompatible_fw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
|
||||
if (game.status === "up_to_date") return `<span class="pill">Up to date</span>`;
|
||||
@@ -1020,6 +1050,7 @@ const REASON_TEXT = {
|
||||
cross_region_storage_unsupported: "Patch bytes are signed for a shared master title; this standalone installer cannot retarget them.",
|
||||
install_not_allowed_for_source: "Install blocked for this source.",
|
||||
source_unknown: "Source unknown — blocked.",
|
||||
source_fake: "Fake packages are not supported.",
|
||||
no_compatible_patch: "No compatible patch available.",
|
||||
download_failed: "Download failed.",
|
||||
download_in_progress: "Another download is already running.",
|
||||
|
||||
@@ -374,7 +374,9 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.card-body { min-width: 0; }
|
||||
.card-body h3 { margin: 0 0 6px; overflow-wrap: anywhere; font-size: 16px; }
|
||||
|
||||
Reference in new issue
Block a user