mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-17 21:00:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fbdd4681 | ||
|
|
8053f5395b |
No files matched your search
+66
-6
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.2.0"
|
||||
#define PATCHDL_VERSION "0.2.1"
|
||||
+160
-23
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 ---------------- */
|
||||
|
||||
Reference in new issue
Block a user