7 Commits
Author SHA1 Message Date
Knutwurst f6b0bba49a Show game icons and label the firmware a patch needs
- The scan opens each game's icon0.png (app.db icon0Info first, then
  /user/appmeta and the title's sce_sys) while it can still see the
  whole filesystem, and keeps the fd. GET /api/icon/<title_id> serves
  it with a one-day cache. Cards show the icon and fall back to the
  initials; loaded icons are reused across re-renders.
- "Latest x · FW y" read like the console firmware. It now says
  "needs FW y".
2026-10-15 11:08:49 +02:00
Knutwurst 0efa0312d5 Bump version to 0.0.8 2026-10-15 11:00:31 +02:00
Knutwurst ca5977f872 Show the latest official version for fake packages
The update check only reads Sony's version.xml, so it is safe for fake
packages too. Their cards now show the latest version and the firmware
it needs. Download and install stay blocked.
2026-10-15 10:58:42 +02:00
Knutwurst 04d27a9fd5 List only games, keep fake packages visible, sort by name
- Scan only PPSA and CUSA title ids. Homebrew with made-up ids (the
  PatchDL tile, loaders, file managers) no longer shows up as a game.
- Installed fake packages carry "fake": true in app.json, the same
  marker homebrew uses, so they vanished from the list. Titles with an
  app.pkg now stay listed and count as fake when either the marker or
  the package header says so.
- Sort the game list by name.
- "Updatable" only holds titles PatchDL can install. Download-only
  titles (shadowmounts, shared-master patches) with an update move to
  "Can't update" and show "Update · download only".
2026-10-15 10:53:29 +02:00
Knutwurst 9253235a0d Classify installs by package signature, block fake packages
The scanner treated every title with an app.pkg as official unless its
app.json mentioned a URL. That let fake packages through as official, so
PatchDL offered and tried to install Sony patches on them, while disc and
store installs whose app.json lists CDN pieces ended up as unknown.

Read the app.pkg header instead. A PS5 finalized image (\x7FFIH) is
retail when byte 0x05 has bit 0x80 set; a PS4 container (\x7FCNT) is
retail when bit 0 of byte 4 is set. Retail installs count as official,
fake-signed ones as the new "fake" source, unreadable headers stay
unknown.

Fake packages are listed with a "Not supported" label. PatchDL skips
their update check, and the server refuses download, install and the
enable toggle for them. The titles JSON reports pkg_sign for diagnostics.
2026-10-15 10:21:36 +02:00
Knutwurst 664ad3d4fd Show "Needs FW" when a newer patch exceeds the console firmware
A title whose installed version matched the newest compatible patch was
reported as up to date even when Sony offered a newer patch that needs a
higher firmware. It now reports incompatible_fw, so the UI shows
"Needs FW x.xx". The UI drops stale job flags for that state too.

Fixes the Final Fantasy VII Rebirth case in #4.
2026-10-15 10:12:20 +02:00
Knutwurst 9209408c40 README: list firmware 1.00 to 13.60 as supported
SDK 0.44 ships kernel offsets for every firmware from 1.00 to 13.60,
and the AppInstUtil calls and app.db table PatchDL uses exist across
that range. Tested on 11.60 and 13.60.
2026-10-14 19:42:58 +02:00
9 changed files with 258 additions and 28 deletions

No files matched your search

+3 -3
View File
@@ -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:
+19
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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);
}
+11
View File
@@ -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];
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.0.7"
#define PATCHDL_VERSION "0.0.8"
+79 -5
View File
@@ -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
View File
@@ -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.",
+2
View File
@@ -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; }