mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-24 16:01:38 +02:00
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:
1 parent
0efa0312d5
commit
f6b0bba49a
7 files changed
+140
-2
No files matched your search
@@ -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.
|
||||
|
||||
+54
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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