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".
This commit is contained in:
Knutwurst committed 2026-10-15 11:08:49 +02:00
1 parent 0efa0312d5
commit f6b0bba49a
7 files changed
+140 -2

No files matched your search

+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.
+54 -1
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>
@@ -314,6 +315,7 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
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);
@@ -408,6 +410,50 @@ patchdl_source_str(patchdl_source_t src) {
}
}
/* 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. */
@@ -435,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);
}
@@ -499,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);
}
+1
View File
@@ -38,6 +38,7 @@ typedef struct {
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];
+45
View File
@@ -332,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);
@@ -781,6 +819,7 @@ 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"
@@ -2096,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());
+18 -1
View File
@@ -7,6 +7,9 @@ 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" },
@@ -376,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));
@@ -398,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>
`;
+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; }