From f6b0bba49aa8fb1b075f5decd990592ccdd69d2f Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:08:49 +0200 Subject: [PATCH] Show game icons and label the firmware a patch needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/ 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". --- src/patchdl_appdb.c | 19 +++++++++++++++ src/patchdl_appdb.h | 1 + src/patchdl_scan.c | 55 +++++++++++++++++++++++++++++++++++++++++++- src/patchdl_scan.h | 1 + src/patchdl_websrv.c | 45 ++++++++++++++++++++++++++++++++++++ web/app.js | 19 ++++++++++++++- web/styles.css | 2 ++ 7 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/patchdl_appdb.c b/src/patchdl_appdb.c index ed4a31c..af169d0 100644 --- a/src/patchdl_appdb.c +++ b/src/patchdl_appdb.c @@ -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; diff --git a/src/patchdl_appdb.h b/src/patchdl_appdb.h index 6aaabff..34b1eee 100644 --- a/src/patchdl_appdb.h +++ b/src/patchdl_appdb.h @@ -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. diff --git a/src/patchdl_scan.c b/src/patchdl_scan.c index 1ef16cf..d87c375 100644 --- a/src/patchdl_scan.c +++ b/src/patchdl_scan.c @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -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); } diff --git a/src/patchdl_scan.h b/src/patchdl_scan.h index a949ff7..15711e2 100644 --- a/src/patchdl_scan.h +++ b/src/patchdl_scan.h @@ -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]; diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index f8f57d3..8aec394 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -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()); diff --git a/web/app.js b/web/app.js index 4857e94..92197cb 100644 --- a/web/app.js +++ b/web/app.js @@ -7,6 +7,9 @@ const API = { action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`, }; +// title_id -> loaded , 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) {
Installed ${escapeHtml(game.installed_version || "--")} Compatible ${escapeHtml(game.compatible_version || "None")} - Latest ${escapeHtml(game.latest_version || "--")}${game.latest_required_fw ? ` · FW ${escapeHtml(game.latest_required_fw)}` : ""} + Latest ${escapeHtml(game.latest_version || "--")}${game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : ""}
`; diff --git a/web/styles.css b/web/styles.css index f14371b..5a154ad 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; }