2 Commits
Author SHA1 Message Date
Knutwurst b4fbdd4681 Bump version to 0.2.1 2026-10-17 10:45:52 +02:00
Knutwurst 8053f5395b Mark sideloaded apps, explain failed downloads, diagnose from the UI
Sideloaded apps
- A Sony-signed package installed with another tool ("fake": true in
  app.json, but no PatchDL loopback URL) stays Official but is marked
  Sideloaded, and its Updates switch starts off, so Update all skips it.
  Switching it on is saved separately, so a switch saved earlier can't
  turn it on. The card says an update replaces this exact version.

Failed downloads
- When the package list can't be fetched, the download job records why
  (DNS, connection, timeout, TLS, redirect, HTTP status, not a manifest)
  with the curl and HTTP codes; the card says so with a Details link.
- Details runs /api/netcheck?title=<id>, which walks the game's update
  list and manifest the way a download does and reports PatchDL's own
  DNS answer for each host. The dialog shows a checklist and a report to
  copy into an issue; on the PS5 a QR code opens it on a phone.
2026-10-17 10:39:49 +02:00
8 changed files with 428 additions and 32 deletions

No files matched your search

+66 -6
View File
@@ -95,6 +95,36 @@ host_allowed(const char *host) {
}
/* Extract hostname from https://host/path or http://host/path */
/* Why the calling thread's last patchdl_http_get failed (or what status it
got), for error reports. */
static _Thread_local struct {
char step[24];
int curl_rc;
long http_code;
} g_last;
static void
set_last(const char *step, int curl_rc, long http_code) {
snprintf(g_last.step, sizeof g_last.step, "%s", step);
g_last.curl_rc = curl_rc;
g_last.http_code = http_code;
}
/* PatchDL's own DNS answer for a host (first A record), for diagnostics. */
static int dns_lookup(const char *host, char *ip_out, size_t ip_sz);
int
patchdl_resolve_host(const char *host, char *ip_out, size_t ip_sz) {
return dns_lookup(host, ip_out, ip_sz);
}
void
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
if (step && step_sz) snprintf(step, step_sz, "%s", g_last.step);
if (curl_rc) *curl_rc = g_last.curl_rc;
if (http_code) *http_code = g_last.http_code;
}
/* Manifest piece hashes: PS5 manifests carry SHA-256 (64 hex digits), PS4
manifests SHA-1 (40). */
static const EVP_MD *
@@ -306,11 +336,13 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
struct curl_blob ca_blob;
int plain = !strncmp(url, "http://", 7);
long http_code = 0;
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (plain && strcasecmp(host, PLAIN_HTTP_HOST)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
set_last("", 0, 0);
if (url_host(url, host, sizeof(host))) { set_last("bad_url", 0, 0); return -1; }
if (!host_allowed(host)) { set_last("host_not_allowed", 0, 0); return -1; }
if (plain && strcasecmp(host, PLAIN_HTTP_HOST)) { set_last("http_not_allowed", 0, 0); return -1; }
if (dns_lookup(host, ip, sizeof(ip))) { set_last("dns", 0, 0); return -1; }
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
resolve_list = curl_slist_append(NULL, resolve_str);
@@ -359,15 +391,22 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
curl_slist_free_all(resolve_list);
if (res != CURLE_OK) {
set_last(res == CURLE_PEER_FAILED_VERIFICATION ||
res == CURLE_SSL_CONNECT_ERROR ? "tls"
: res == CURLE_UNSUPPORTED_PROTOCOL ? "redirect"
: res == CURLE_OPERATION_TIMEDOUT ? "timeout" : "network",
(int)res, http_code);
free(out->data);
out->data = NULL;
out->size = 0;
return -1;
}
set_last(http_code >= 400 ? "http_status" : "", 0, http_code);
return 0;
}
@@ -1094,10 +1133,18 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
memset(&buf, 0, sizeof(buf));
buf.max = PATCHDL_BUF_MAX_MANIFEST;
if (patchdl_http_get(manifest_url, &buf)) return -1;
if (!buf.data || !buf.size) { free(buf.data); return -1; }
if (!buf.data || !buf.size) {
free(buf.data);
if (!g_last.step[0]) set_last("empty_reply", 0, g_last.http_code);
return -1;
}
pieces = strstr(buf.data, "\"pieces\"");
if (!pieces || !(pieces = strchr(pieces, '['))) { free(buf.data); return -1; }
if (!pieces || !(pieces = strchr(pieces, '['))) {
free(buf.data);
if (!g_last.step[0]) set_last("bad_manifest", 0, g_last.http_code);
return -1;
}
pieces_end = strchr(pieces, ']');
for (p = pieces; (p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end); p += 5)
@@ -1226,6 +1273,19 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
return -1; /* curl not available in this build; set CURL_DIR= to enable */
}
int
patchdl_resolve_host(const char *host, char *ip_out, size_t ip_sz) {
(void)host; (void)ip_out; (void)ip_sz;
return -1;
}
void
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
if (step && step_sz) snprintf(step, step_sz, "no_curl");
if (curl_rc) *curl_rc = 0;
if (http_code) *http_code = 0;
}
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
+8
View File
@@ -57,6 +57,14 @@ typedef struct {
piece list. Returns 0 on success (caller frees with patchdl_manifest_free),
-1 on fetch/parse/tiling failure. */
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
/* Why this thread's last patchdl_http_get / patchdl_fetch_manifest failed:
step is "" on success, else e.g. "dns", "tls", "network", "timeout",
"redirect", "http_status", "bad_manifest". curl_rc and http_code as seen. */
void patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code);
/* PatchDL's own DNS answer for host (first IPv4 address). 0 on success. */
int patchdl_resolve_host(const char *host, char *ip_out, size_t ip_sz);
void patchdl_manifest_free(patchdl_manifest_t *m);
+18
View File
@@ -26,6 +26,10 @@
#define MAX_TITLES 512
#ifndef PATCHDL_HTTP_PORT
#define PATCHDL_HTTP_PORT 12880
#endif
static const char *SCAN_DIRS[] = {
"/user/app",
"/mnt/ext0/user/app",
@@ -373,6 +377,20 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
: (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
? PATCHDL_SOURCE_FAKE
: PATCHDL_SOURCE_UNKNOWN;
/* A Sony-signed package someone installed with another tool, often
to keep one exact app version. PatchDL's own installs name its
loopback server in app.json. An update would replace that
version. */
{
/* PatchDL installs from its server (loopback or LAN address,
both on its port) or, as a fallback, from its download folder
(/user/data/patchdl/...). app.json escapes slashes, so match
pieces without them. */
char port[16];
snprintf(port, sizeof port, ":%d", PATCHDL_HTTP_PORT);
t->sideloaded = is_fake && t->source_type == PATCHDL_SOURCE_OFFICIAL &&
!strstr(appjson, port) && !strstr(appjson, "patchdl");
}
} 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 */
+2
View File
@@ -38,6 +38,8 @@ 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 sideloaded; /* Sony-signed, but installed by another
tool (not Store, disc or PatchDL) */
int icon_fd; /* open icon0.png, or -1; see patchdl_scan */
/* populated by background verxml fetch — guarded by websrv mutex */
char compatible_version[16];
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.2.0"
#define PATCHDL_VERSION "0.2.1"
+160 -23
View File
@@ -232,6 +232,7 @@ typedef struct dl_job {
long long verify_bytes; /* bytes hashed so far */
long long free_bytes; /* rc -3: bytes free on /data at the time */
int last_curl_rc; /* CURLcode from the most recent piece */
char fail_step[24]; /* why admit failed (manifest fetch), or "" */
long last_http_code; /* HTTP status from the most recent piece */
struct dl_job *next;
} dl_job_t;
@@ -1018,6 +1019,12 @@ save_config(void) {
for (size_t i = 0; i < g_title_count; i++)
fprintf(f, "%s\"%s\":%s", i ? "," : "",
g_titles[i].title_id, g_titles[i].enabled ? "true" : "false");
/* Sideloaded titles the user switched on, kept apart so a switch saved
before sideloaded titles started off can't turn one on. */
fprintf(f, "},\n\"sideload_updates\":{");
for (size_t i = 0, n = 0; i < g_title_count; i++)
if (g_titles[i].sideloaded && g_titles[i].enabled)
fprintf(f, "%s\"%s\":true", n++ ? "," : "", g_titles[i].title_id);
fprintf(f, "}\n}\n");
pthread_mutex_unlock(&g_mutex);
@@ -1026,27 +1033,46 @@ save_config(void) {
/* Load persisted settings over the defaults. Called once at startup, after the
scan, while still single-threaded. */
/* The whole config file, malloc'd and NUL-terminated, or NULL. It holds a
switch per title (twice for sideloaded ones), so no fixed buffer. */
static char *
read_config_file(void) {
FILE *f = fopen(PATCHDL_CFG_PATH, "r");
long len;
char *buf;
size_t n;
if (!f) return NULL;
if (fseek(f, 0, SEEK_END) || (len = ftell(f)) < 0 || len > 1024 * 1024 ||
fseek(f, 0, SEEK_SET) || !(buf = malloc((size_t)len + 1))) {
fclose(f);
return NULL;
}
n = fread(buf, 1, (size_t)len, f);
fclose(f);
buf[n] = '\0';
return buf;
}
/* Per-title overrides live under "titles": { "<id>": true|false, ... }. */
static void
overlay_title_policy(const char *cfg, patchdl_title_t *arr, size_t cnt) {
const char *titles = strstr(cfg, "\"titles\"");
if (titles)
for (size_t i = 0; i < cnt; i++)
const char *side = strstr(cfg, "\"sideload_updates\"");
for (size_t i = 0; i < cnt; i++) {
if (arr[i].sideloaded) /* off unless switched on as sideloaded */
arr[i].enabled = side ? json_get_bool(side, arr[i].title_id, 0) : 0;
else if (titles)
arr[i].enabled = json_get_bool(titles, arr[i].title_id, arr[i].enabled);
}
}
static void
load_config(void) {
FILE *f;
char buf[16384];
size_t n;
char *buf = read_config_file();
char pol[8];
f = fopen(PATCHDL_CFG_PATH, "r");
if (!f) return;
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
if (!buf) return;
json_get_str(buf, "default_policy", pol, sizeof(pol));
if (pol[0]) {
@@ -1067,24 +1093,20 @@ load_config(void) {
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
overlay_title_policy(buf, g_titles, g_title_count);
free(buf);
}
/* Per-title switches for a freshly scanned list: unknown sources start off,
a saved choice from config.json wins, and a fake package stays off. */
static void
apply_title_policy(patchdl_title_t *arr, size_t cnt) {
FILE *f;
char buf[16384];
size_t n = 0;
char *buf = read_config_file();
for (size_t i = 0; i < cnt; i++)
arr[i].enabled = (arr[i].source_type != PATCHDL_SOURCE_UNKNOWN);
if ((f = fopen(PATCHDL_CFG_PATH, "r"))) {
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
}
buf[n] = '\0';
overlay_title_policy(buf, arr, cnt);
arr[i].enabled = (arr[i].source_type != PATCHDL_SOURCE_UNKNOWN) &&
!arr[i].sideloaded;
overlay_title_policy(buf ? buf : "", arr, cnt);
free(buf);
for (size_t i = 0; i < cnt; i++)
if (arr[i].source_type == PATCHDL_SOURCE_FAKE)
arr[i].enabled = 0;
@@ -1202,6 +1224,7 @@ build_titles_json(void) {
jbuf_appendf(&j, ",\"resumable\":%s", t->resumable ? "true" : "false");
jbuf_appendf(&j, ",\"partial_bytes\":%lld", t->partial_bytes);
jbuf_appendf(&j, ",\"downloaded\":%s", t->downloaded ? "true" : "false");
jbuf_appendf(&j, ",\"sideloaded\":%s", t->sideloaded ? "true" : "false");
jbuf_append(&j, ",\"mode\":");
jbuf_append_str(&j, title_mode_str(t->source_type));
jbuf_append(&j, ",\"queued\":false");
@@ -1267,6 +1290,10 @@ build_downloads_json(void) {
if (job->rc == -3)
jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld",
job->need_bytes, job->free_bytes);
if (job->fail_step[0]) {
jbuf_append(&j, ",\"fail_step\":");
jbuf_append_str(&j, job->fail_step);
}
jbuf_append(&j, "}");
}
jbuf_append(&j, "]");
@@ -1901,6 +1928,9 @@ admit_next(void) {
char murl[768], dest[320], dir[288];
int is_manifest, fd = -1, ok = 0, no_space = 0;
unsigned char *pre_done = NULL; /* pieces proven good by hash */
char fstep[24] = "";
int fcurl = 0;
long fhttp = 0;
int adopted = 0;
long long total = 0, old_size = 0, need = 0, free_b = 0;
dl_pstate_t *ps = NULL;
@@ -1925,7 +1955,10 @@ admit_next(void) {
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
if (is_manifest) {
if (patchdl_fetch_manifest(murl, &mf) == 0 && mf.count > 0) {
if (patchdl_fetch_manifest(murl, &mf) != 0 || mf.count <= 0) {
patchdl_http_last_error(fstep, sizeof fstep, &fcurl, &fhttp);
if (!fstep[0]) snprintf(fstep, sizeof fstep, "manifest");
} else {
total = mf.total;
adopted = adopt_by_size(dir, dest, total);
need = space_needed(dest, total);
@@ -2003,6 +2036,11 @@ admit_next(void) {
q->rc = no_space ? -3 : -1;
q->need_bytes = need;
q->free_bytes = free_b;
/* the manifest fetch failed: say where, so a report can tell a
blocked host from a TLS or HTTP problem */
snprintf(q->fail_step, sizeof q->fail_step, "%s", fstep);
q->last_curl_rc = fcurl;
q->last_http_code = fhttp;
}
pthread_cond_broadcast(&g_pool.cv);
return;
@@ -2017,6 +2055,7 @@ admit_next(void) {
free(q->bitmap); q->bitmap = NULL;
q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0;
q->last_curl_rc = 0; q->last_http_code = 0;
q->fail_step[0] = '\0';
q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total;
for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1;
@@ -2526,6 +2565,94 @@ build_storage_json(void) {
return j.buf;
}
/* Host part of a URL into out ("" if none). */
static void
url_host_of(const char *url, char *out, size_t sz) {
const char *p = strstr(url, "://");
size_t n;
out[0] = '\0';
if (!p) return;
p += 3;
n = strcspn(p, "/:?");
if (n < sz) { memcpy(out, p, n); out[n] = '\0'; }
}
/* GET /api/netcheck?title=<id>: walk one title's update chain the way a
download does (update list, then the package manifest) and report each
step with PatchDL's own DNS answer, so a report shows where it breaks. */
static enum MHD_Result
queue_netcheck_title(struct MHD_Connection *conn, const char *tid) {
char vurl[512] = "", host[128], ip[48], step[24], tid9[16];
char murl[768] = "", durl[512], ptid[16], pstid[16], cid[64];
char nm[128], ver[16];
patchdl_source_t src;
int en;
char *out;
int curl_rc = 0, vrc, mrc = -1, pieces = 0;
long http = 0;
patchdl_verinfo_t info;
patchdl_manifest_t mf;
jbuf_t j = {0};
/* Same ids as the title actions; Sony's URLs use the 9-character id. */
if (!path_segment_safe(tid) || strlen(tid) < 9 || strlen(tid) >= 32)
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
snprintf(tid9, sizeof tid9, "%.9s", tid);
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strncmp(g_titles[i].title_id, tid, 9)) {
snprintf(vurl, sizeof vurl, "%s", g_titles[i].version_file_uri);
break;
}
pthread_mutex_unlock(&g_mutex);
if (!vurl[0]) patchdl_resolve_url(tid9, vurl, sizeof vurl);
jbuf_append(&j, "{\"title_id\":"); jbuf_append_str(&j, tid);
jbuf_append(&j, ",\"update_list\":{\"url\":"); jbuf_append_str(&j, vurl);
url_host_of(vurl, host, sizeof host);
ip[0] = '\0';
if (host[0]) patchdl_resolve_host(host, ip, sizeof ip);
jbuf_append(&j, ",\"ip\":"); jbuf_append_str(&j, ip);
memset(&info, 0, sizeof info);
vrc = vurl[0] ? patchdl_verxml_query(vurl, g_fw.bin, &info) : -1;
patchdl_http_last_error(step, sizeof step, &curl_rc, &http);
jbuf_appendf(&j, ",\"ok\":%s,\"curl\":%d,\"http\":%ld,\"step\":",
vrc == 0 ? "true" : "false", curl_rc, http);
jbuf_append_str(&j, step);
jbuf_append(&j, ",\"latest\":"); jbuf_append_str(&j, info.latest_version);
/* The package a download would use: a pasted Prospero link wins over
Sony's current list, exactly as for the download itself. */
if (!get_title_action_info(tid, &src, murl, sizeof murl, durl, sizeof durl,
ptid, sizeof ptid, pstid, sizeof pstid,
cid, sizeof cid, nm, sizeof nm, ver, sizeof ver, &en) ||
!murl[0])
snprintf(murl, sizeof murl, "%s", info.compatible_url);
jbuf_append(&j, "},\"manifest\":{\"url\":"); jbuf_append_str(&j, murl);
url_host_of(murl, host, sizeof host);
ip[0] = '\0';
if (host[0]) patchdl_resolve_host(host, ip, sizeof ip);
jbuf_append(&j, ",\"ip\":"); jbuf_append_str(&j, ip);
memset(&mf, 0, sizeof mf);
step[0] = '\0'; curl_rc = 0; http = 0;
if (url_is_manifest(murl)) {
mrc = patchdl_fetch_manifest(murl, &mf);
patchdl_http_last_error(step, sizeof step, &curl_rc, &http);
pieces = mf.count;
patchdl_manifest_free(&mf);
} else if (murl[0]) {
/* a single package without a piece list: nothing to fetch here */
mrc = 0;
pieces = 1;
snprintf(step, sizeof step, "direct");
}
jbuf_appendf(&j, ",\"ok\":%s,\"pieces\":%d,\"curl\":%d,\"http\":%ld,\"step\":",
mrc == 0 ? "true" : "false", pieces, curl_rc, http);
jbuf_append_str(&j, step);
jbuf_append(&j, "}}");
out = j.buf;
return queue_json_owned(conn, MHD_HTTP_OK, out);
}
static enum MHD_Result
do_install(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url, const char *delta_url,
@@ -3375,6 +3502,11 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
if (!strcmp(url, "/api/netcheck")) {
const char *want = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "title");
if (want && want[0])
return queue_netcheck_title(conn, want);
}
if (!strcmp(url, "/api/netcheck")) {
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
char uri[256] = "";
@@ -3448,7 +3580,11 @@ stop_verxml(void) {
and the rescan isn't an explicit refresh; otherwise it runs again. */
static void
carry_title_state(patchdl_title_t *dst, const patchdl_title_t *src, int recheck) {
dst->enabled = src->enabled;
/* A title that turned sideloaded (or back) keeps the switch its new
state starts with, so a sideloaded copy is never updated without the
user switching it on. */
if (dst->sideloaded == src->sideloaded)
dst->enabled = src->enabled;
dst->resumable = src->resumable;
dst->partial_bytes = src->partial_bytes;
dst->downloaded = src->downloaded;
@@ -3662,7 +3798,8 @@ patchdl_websrv_start(unsigned short port) {
/* Default per-title policy (unknown sources off), then overlay anything the
user previously saved to /data/patchdl/config.json. */
for (size_t i = 0; i < g_title_count; i++)
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN) &&
!g_titles[i].sideloaded;
load_config();
/* A saved config must not switch a fake package back on. */
for (size_t i = 0; i < g_title_count; i++)
+154 -2
View File
@@ -119,12 +119,138 @@ const els = {};
document.addEventListener("DOMContentLoaded", () => {
bindElements();
bindEvents();
loadInitialData().then(openGuideFromHash);
loadInitialData().then(() => { openGuideFromHash(); openDiagFromHash(); });
window.addEventListener("hashchange", openGuideFromHash);
setInterval(watchTitles, 5000);
document.querySelectorAll("[data-donate]").forEach((b) => b.addEventListener("click", openDonate));
// Cards are re-rendered often; listen once on the document.
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-diag]");
if (b) openDiag(b.dataset.diag);
});
window.addEventListener("hashchange", openDiagFromHash);
});
/* ---------------- download diagnosis ---------------- */
function diagLink(titleId) {
const h = location.hostname;
let base = location.origin;
if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") {
if (!state.status.lan_ip) return null;
base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`;
}
return `${base}/#diag/${encodeURIComponent(titleId)}`;
}
function openDiagFromHash() {
const m = location.hash.match(/^#diag\/([A-Za-z0-9_.-]+)$/);
if (!m) return;
history.replaceState(null, "", location.pathname + location.search);
openDiag(m[1]);
}
async function fetchJson(url) {
try {
const r = await fetch(url, { cache: "no-store" });
return r.ok ? await r.json() : null;
} catch (_) { return null; }
}
function diagRow(ok, label, detail) {
return `<li class="diag-row ${ok ? "is-ok" : "is-bad"}"><b>${ok ? "✓" : "✗"}</b>
<span><strong>${escapeHtml(label)}</strong>${detail ? `<br><em>${escapeHtml(detail)}</em>` : ""}</span></li>`;
}
// Runs the network check for one game and shows it as a checklist plus a
// report to paste into an issue. On the PS5 (no clipboard) a QR code opens
// the same dialog on a phone.
async function openDiag(titleId) {
const game = state.titles.find((g) => g.title_id === titleId);
const name = game ? game.name : titleId;
const back = document.activeElement;
const wrap = document.createElement("div");
wrap.className = "guide-backdrop";
wrap.innerHTML = `
<section class="guide" role="dialog" aria-modal="true" aria-labelledby="diagTitle">
<header class="guide-head">
<div>
<p class="eyebrow">Download problem</p>
<h2 id="diagTitle">${escapeHtml(name)}</h2>
<p class="guide-game">${escapeHtml(titleId)}</p>
</div>
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
</header>
<div class="diag-body"><p class="guide-why">Checking the connection to Sony's servers…</p></div>
</section>`;
const close = () => {
document.removeEventListener("keydown", onKey);
wrap.remove();
if (back && document.contains(back)) back.focus();
};
const onKey = (e) => { if (e.key === "Escape") close(); };
wrap.querySelector(".guide-close").addEventListener("click", close);
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); });
document.addEventListener("keydown", onKey);
document.body.appendChild(wrap);
wrap.querySelector(".guide-close").focus();
const [check, status, downloads, pkg, inst] = await Promise.all([
fetchJson(`/api/netcheck?title=${encodeURIComponent(titleId)}`),
fetchJson(API.status),
fetchJson(API.downloads),
fetchJson("/api/pkgdiag"),
fetchJson(API.installStatus),
]);
if (!document.contains(wrap)) return;
const job = (downloads || []).find((d) => d.title_id === titleId) || null;
const ul = check && check.update_list, mf = check && check.manifest;
const mfDetail = !mf ? "" : mf.ok && mf.step === "direct" ? "Single package, no list needed." :
mf.ok ? `${mf.pieces} piece(s)` :
mf.url ? failStepText({ fail_step: mf.step || "manifest", last_http_code: mf.http }) :
"No package for this firmware.";
const rows = [
diagRow(Boolean(ul && ul.ok), "Update list from Sony",
!ul ? "No answer from PatchDL."
: ul.ok ? `Newest: ${ul.latest || "?"}`
: !ul.url ? "PatchDL knows no update address for this game."
: failStepText({ fail_step: ul.step || "network", last_http_code: ul.http })),
diagRow(Boolean(mf && mf.ok), "Package list (manifest)", mfDetail),
];
if (job) rows.push(diagRow(job.state !== "error", "Last download",
job.state === "error" ? (job.fail_step ? failStepText(job) : "Failed while downloading the pieces.") : job.state));
const report = [
"PatchDL diagnostic report",
`PatchDL ${status ? status.version : "?"} · FW ${status ? status.firmware : "?"}`,
`Game: ${name} (${titleId})${game ? ` · installed ${game.installed_version || "?"} · ${game.source_type}${game.sideloaded ? ", sideloaded" : ""}` : ""}`,
`Download: ${job ? JSON.stringify(job) : "none"}`,
`Network check: ${check ? JSON.stringify(check) : "unavailable"}`,
`Install: ${inst ? JSON.stringify(inst.streams || inst) : "unavailable"}`,
`Console requests: ${pkg && pkg.log ? pkg.log.slice(-8).join(" | ") : "none"}`,
...(state.logs.length ? ["Log:", ...state.logs.slice(-15)] : []),
].join("\n");
const onConsole = isConsoleBrowser();
const link = onConsole ? diagLink(titleId) : null;
const matrix = link ? qrEncode(link) : null;
wrap.querySelector(".diag-body").innerHTML = `
<ul class="diag-list">${rows.join("")}</ul>
${onConsole
? (matrix ? `<div class="guide-qr">${qrSvg(matrix)}<div>
<span>To report this, scan with your phone. The same report opens there, ready to copy.</span></div></div>` : "")
: `<p class="guide-why">To report this, copy the report and paste it into a GitHub issue.</p>`}
<textarea class="diag-report" readonly rows="8" aria-label="Diagnostic report">${escapeHtml(report)}</textarea>
${onConsole ? "" : `<button type="button" class="row-button is-update diag-copy">Copy report</button>`}`;
const copy = wrap.querySelector(".diag-copy");
if (copy) copy.addEventListener("click", async () => {
const ta = wrap.querySelector(".diag-report");
try { await navigator.clipboard.writeText(report); }
catch (_) { ta.select(); document.execCommand("copy"); }
showToast("Report copied.");
});
}
const DONATE_URL = "https://paypal.me/oliverkoester";
// Donation dialog. On the TV a link is no use, so it shows a QR code for
@@ -525,6 +651,7 @@ function createGameCard(game) {
${statusPill(game)}
${sourcePill(game)}
${isPs4(game) ? `<span class="pill">PS4</span>` : ""}
${game.sideloaded ? `<span class="pill warn" title="Installed outside the Store, disc or PatchDL">Sideloaded</span>` : ""}
</div>
<div class="versions">${versionsHtml(game)}</div>
`;
@@ -655,10 +782,14 @@ function versionsHtml(game) {
const job = state.downloads.find((d) => d.title_id === game.title_id);
if (job && job.state === "error" && Number(job.rc) === -3)
parts.push(`<span class="card-note is-warn">${noSpaceText(job.need_bytes, job.free_bytes)}</span>`);
else if (job && job.state === "error")
parts.push(`<span class="card-note is-warn">Download failed. ${job.fail_step ? escapeHtml(failStepText(job)) + " " : ""}<button type="button" class="link-button" data-diag="${escapeHtml(game.title_id)}">Details</button></span>`);
if (game.latest_version && game.latest_version !== offered) {
const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : "";
parts.push(`<span>Newest ${v(game.latest_version)}${fw}</span>`);
}
if (game.sideloaded)
parts.push(`<span class="card-note is-warn">Installed outside the Store. An update replaces this exact version, which some tools depend on. Switch on Updates only if you want that.</span>`);
return parts.join("");
}
@@ -808,7 +939,12 @@ function reconcileFromJobs(jobs) {
if (!g._spaceWarned) showToast(`${g.name}: ${noSpaceText(j.need_bytes, j.free_bytes)}`);
g._spaceWarned = true;
} else if (g._wasActive) {
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`);
const why = j.fail_step ? ` ${failStepText(j)}` : "";
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}${why}`);
if (j.fail_step) {
state.logs.push(`[${timeNow()}] Download ${g.title_id} failed while fetching the package list: ${j.fail_step} (curl ${j.last_curl_rc}, HTTP ${j.last_http_code})`);
renderLogs();
}
}
g._wasActive = false;
g.downloading = false;
@@ -818,6 +954,22 @@ function reconcileFromJobs(jobs) {
});
}
// Why the package list (manifest) couldn't be fetched, in words.
function failStepText(j) {
const http = Number(j.last_http_code) || 0;
return ({
dns: "Sony's download server couldn't be found (DNS). Is it blocked on your network?",
network: "Couldn't connect to Sony's download server. Is it blocked on your network?",
timeout: "Sony's download server didn't answer in time.",
tls: "The secure connection to Sony's server failed.",
redirect: "Sony's server sent PatchDL somewhere it doesn't follow.",
http_status: `Sony's server answered HTTP ${http}.`,
bad_manifest: "Sony's server sent something that isn't a package list.",
empty_reply: "Sony's server sent an empty reply.",
host_not_allowed: "The package is on a server PatchDL doesn't use.",
})[j.fail_step] || `Package list unavailable (${j.fail_step}).`;
}
function startInstallPolling() {
if (installPollTimer) return;
installPollTimer = setInterval(refreshInstallStatus, 2000);
+19
View File
@@ -132,6 +132,25 @@ svg {
color: var(--muted);
}
.donate-row .row-button { width: auto; padding: 0 16px; }
.diag-body { display: grid; gap: 14px; }
.diag-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.diag-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; }
.diag-row > b { font-size: 16px; }
.diag-row.is-ok > b { color: var(--green); }
.diag-row.is-bad > b { color: var(--red); }
.diag-row em { color: var(--muted); font-style: normal; font-size: 13px; }
.diag-report {
width: 100%;
box-sizing: border-box;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-2);
color: var(--ink);
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
resize: vertical;
}
.diag-copy { justify-self: start; width: auto; padding: 0 18px; }
.donate-open { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; justify-self: start; padding: 0 18px; }
/* ---------------- views + topbar ---------------- */