mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-17 21:00:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fbdd4681 | ||
|
|
8053f5395b | ||
|
|
bde585eab7 | ||
|
|
eaab113d8a | ||
|
|
c3630000b6 | ||
|
|
72b9136ff5 | ||
|
|
c6f001f857 | ||
|
|
e72c25a56e |
No files matched your search
@@ -0,0 +1 @@
|
||||
custom: ["https://paypal.me/oliverkoester"]
|
||||
@@ -5,14 +5,15 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Official game updates for your PS5, from any browser.</b><br>
|
||||
PatchDL finds the newest update for every game on your console, downloads it at full speed and installs it.<br>
|
||||
<b>Official updates for your PS5 and PS4 games, from any browser.</b><br>
|
||||
PatchDL finds the newest update for every PS5 and PS4 game on your console, downloads it at full speed and installs it.<br>
|
||||
Run it from your phone, your laptop or the PS5 itself.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/v/release/knutwurst/ps5-patchdl?style=for-the-badge&label=release&color=22c55e" alt="Latest release">
|
||||
<img src="https://img.shields.io/github/downloads/knutwurst/ps5-patchdl/total?style=for-the-badge&color=22c55e" alt="Downloads">
|
||||
<img src="https://img.shields.io/badge/games-PS5%20%7C%20PS4-22c55e?style=for-the-badge" alt="PS5 and PS4 games">
|
||||
<img src="https://img.shields.io/badge/FW-1.00%E2%80%9313.60-22c55e?style=for-the-badge" alt="Firmware 1.00 to 13.60">
|
||||
<img src="https://img.shields.io/badge/UI-any%20browser-22c55e?style=for-the-badge" alt="Web UI in any browser">
|
||||
<img src="https://img.shields.io/github/license/knutwurst/ps5-patchdl?style=for-the-badge&color=3a3a3a" alt="License">
|
||||
@@ -24,10 +25,17 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If you like what I do, you can buy me a coffee*:
|
||||
<a href="https://paypal.me/oliverkoester"><img src="https://img.shields.io/badge/Donate-PayPal-22c55e?style=flat-square&logo=paypal&logoColor=white" alt="Donate via PayPal"></a><br>
|
||||
<sub>*It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **PS5 and PS4 games.** PatchDL updates both, straight from Sony's servers. PS4 updates land where the console keeps them, next to the game.
|
||||
- **Your whole library at a glance.** Every installed game shows up with its icon, its version and the newest update Sony offers for it, sorted A to Z. Install or remove a game and the list follows within seconds, no restart needed.
|
||||
- **Update all.** One click queues every game that has an update. PatchDL works through the queue and installs each update as soon as it lands.
|
||||
- **Fast downloads.** Up to 16 parallel connections per update. Change the number in Settings while a download runs.
|
||||
@@ -79,7 +87,7 @@ Start an update from the couch with your phone, check on it from your laptop, or
|
||||
No. It fetches updates for games that are already installed on your console. Nothing else.
|
||||
|
||||
**Which games does PatchDL update?**
|
||||
PS5 games you installed from disc or from the PlayStation Store. PatchDL reads each game's package before it offers anything, and it leaves every other title alone. PS4 games are not supported.
|
||||
PS5 and PS4 games you installed from disc or from the PlayStation Store. PatchDL reads each game's package before it offers anything, and it leaves every other title alone.
|
||||
|
||||
**Where do the updates come from?**
|
||||
From Sony's official update servers, the same files your console downloads on its own. PatchDL downloads only from a short list of PlayStation CDN hosts. To find older patches it also asks [Prospero Patches](https://prosperopatches.com/) which versions exist for a game. That lookup sends the game's title ID and nothing else.
|
||||
@@ -131,6 +139,12 @@ PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||
|
||||
PatchDL is written in C and builds on [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/), [libcurl](https://curl.se/), [OpenSSL](https://www.openssl.org/), [SQLite](https://sqlite.org/) and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk). The version history behind older patches comes from [Prospero Patches](https://prosperopatches.com/). Thanks to everyone behind them.
|
||||
|
||||
## Support
|
||||
|
||||
If you like what I do, you can buy me a coffee*: [](https://paypal.me/oliverkoester)
|
||||
|
||||
<sub>*It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</sub>
|
||||
|
||||
## License
|
||||
|
||||
PatchDL is free software under the **GNU General Public License v3.0 or later**. See [LICENSE](LICENSE). Use it, study it, change it, share it. Forks stay under the same license.
|
||||
|
||||
@@ -164,6 +164,11 @@ load_db(const char *uri, patchdl_appinfo_t **out, size_t *count) {
|
||||
if (json) {
|
||||
json_str(json, "CONTENT_VERSION", e->content_version,
|
||||
sizeof(e->content_version));
|
||||
/* PS4 titles carry no CONTENT_VERSION; APP_VER is theirs, and
|
||||
it includes an installed patch. */
|
||||
if (!e->content_version[0])
|
||||
json_str(json, "APP_VER", e->content_version,
|
||||
sizeof(e->content_version));
|
||||
json_str(json, "VERSION_FILE_URI", e->version_file_uri,
|
||||
sizeof(e->version_file_uri));
|
||||
}
|
||||
|
||||
+93
-13
@@ -67,9 +67,16 @@ static const char *ALLOWED_HOSTS[] = {
|
||||
"sgst.prod.dl.playstation.net",
|
||||
"gst.prod.dl.playstation.net",
|
||||
"gs2.ww.prod.dl.playstation.net",
|
||||
"gs-sec.ww.np.dl.playstation.net", /* PS4 update lists */
|
||||
NULL,
|
||||
};
|
||||
|
||||
/* PS4 package manifests are published on gs2 over plain http only (https
|
||||
there is a CloudFront edge without Sony's certificate). The console's own
|
||||
installer checks the package signature, so a forged manifest can't get an
|
||||
unsigned package installed. Everything else stays https-only. */
|
||||
#define PLAIN_HTTP_HOST "gs2.ww.prod.dl.playstation.net"
|
||||
|
||||
static int
|
||||
host_allowed(const char *host) {
|
||||
size_t hlen = strlen(host);
|
||||
@@ -88,6 +95,43 @@ 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 *
|
||||
digest_for(const char *hex) {
|
||||
return (hex && strlen(hex) == 40) ? EVP_sha1() : EVP_sha256();
|
||||
}
|
||||
|
||||
static int
|
||||
url_host(const char *url, char *out, size_t out_sz) {
|
||||
const char *p = strstr(url, "://");
|
||||
@@ -291,9 +335,14 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
struct curl_slist *resolve_list = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
int plain = !strncmp(url, "http://", 7);
|
||||
long http_code = 0;
|
||||
|
||||
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);
|
||||
@@ -334,22 +383,30 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
/* Redirects must stay on HTTPS — host_allowed gates the initial URL, but
|
||||
once libcurl follows a 302 we want the protocol pinned too. The _STR
|
||||
variants replaced the bitfield options in libcurl 7.85. */
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
variants replaced the bitfield options in libcurl 7.85. Plain http only
|
||||
for the PS4 manifest host (see PLAIN_HTTP_HOST). */
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, plain ? "http" : "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -517,7 +574,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
if (verify) {
|
||||
sink.md = EVP_MD_CTX_new();
|
||||
if (sink.md)
|
||||
EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
|
||||
EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_hex), NULL);
|
||||
}
|
||||
|
||||
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||
@@ -944,7 +1001,7 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
|
||||
if (verify) {
|
||||
sink.md = EVP_MD_CTX_new();
|
||||
if (sink.md) EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
|
||||
if (sink.md) EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_or_null), NULL);
|
||||
}
|
||||
|
||||
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||
@@ -1018,10 +1075,12 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex (>=65
|
||||
bytes). Uses pread so it doesn't disturb the fd offset. 0 on success. */
|
||||
/* Read-only: hash a [offset, offset+size) region of fd into out_hex (>=65
|
||||
bytes), with the algorithm `like_hex` is written in (see digest_for). Uses
|
||||
pread so it doesn't disturb the fd offset. 0 on success. */
|
||||
int
|
||||
patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex) {
|
||||
patchdl_hash_fd_region(int fd, long long offset, long long size,
|
||||
const char *like_hex, char *out_hex) {
|
||||
EVP_MD_CTX *md;
|
||||
unsigned char *buf;
|
||||
long long pos = offset, remaining = size;
|
||||
@@ -1033,7 +1092,7 @@ patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex
|
||||
if (!md) return -1;
|
||||
buf = malloc(CHUNK);
|
||||
if (!buf) { EVP_MD_CTX_free(md); return -1; }
|
||||
EVP_DigestInit_ex(md, EVP_sha256(), NULL);
|
||||
EVP_DigestInit_ex(md, digest_for(like_hex), NULL);
|
||||
while (remaining > 0) {
|
||||
size_t want = remaining > (long long)CHUNK ? CHUNK : (size_t)remaining;
|
||||
ssize_t got = pread(fd, buf, want, (off_t)pos);
|
||||
@@ -1074,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)
|
||||
@@ -1206,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) {
|
||||
|
||||
+14
-5
@@ -43,7 +43,7 @@ typedef struct {
|
||||
char *url;
|
||||
long long offset; /* byte offset of this piece in the assembled file */
|
||||
long long size; /* exact length of this piece */
|
||||
char hash[80]; /* manifest SHA-256 hex, or "" */
|
||||
char hash[80]; /* manifest hash hex (SHA-256, or SHA-1 for PS4), or "" */
|
||||
} patchdl_piece_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -79,10 +87,11 @@ int patchdl_http_download_piece(const char *url, int fd,
|
||||
patchdl_piece_ctx_t *ctx,
|
||||
int *curl_rc_out, long *http_code_out);
|
||||
|
||||
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
|
||||
(caller provides >= 65 bytes). Returns 0 on success. */
|
||||
int patchdl_sha256_fd_region(int fd, long long offset, long long size,
|
||||
char *out_hex);
|
||||
/* Read-only: hash a [offset, offset+size) region of fd into out_hex
|
||||
(caller provides >= 65 bytes), SHA-1 if like_hex has 40 hex digits (PS4
|
||||
manifests), else SHA-256. Returns 0 on success. */
|
||||
int patchdl_hash_fd_region(int fd, long long offset, long long size,
|
||||
const char *like_hex, char *out_hex);
|
||||
|
||||
/* Progress callback. Return non-zero to ABORT the in-flight download (used to
|
||||
cancel large patch downloads); return 0 to continue. */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include "patchdl_resolve.h"
|
||||
|
||||
#include <openssl/hmac.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -52,10 +53,43 @@ lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* PS4 titles: Sony's update list lives at a path derived from the title id
|
||||
with HMAC-SHA256 under a key that is part of the PS4 system software and
|
||||
widely published (it's how every PS4 update checker finds patches). */
|
||||
static const unsigned char PS4_UPDATE_KEY[32] = {
|
||||
0xAD, 0x62, 0xE3, 0x7F, 0x90, 0x5E, 0x06, 0xBC, 0x19, 0x59, 0x31, 0x42,
|
||||
0x28, 0x1C, 0x11, 0x2C, 0xEC, 0x0E, 0x7E, 0xC3, 0xE9, 0x7E, 0xFD, 0xCA,
|
||||
0xEF, 0xCD, 0xBA, 0xAF, 0xA6, 0x37, 0x8D, 0x84,
|
||||
};
|
||||
|
||||
static int
|
||||
ps4_update_url(const char *title_id, char *url_out, size_t url_sz) {
|
||||
unsigned char mac[32];
|
||||
unsigned int mac_len = 0;
|
||||
char msg[24], hex[65];
|
||||
int n;
|
||||
|
||||
if (strlen(title_id) != 9) return -1;
|
||||
n = snprintf(msg, sizeof msg, "np_%s", title_id);
|
||||
if (!HMAC(EVP_sha256(), PS4_UPDATE_KEY, sizeof PS4_UPDATE_KEY,
|
||||
(const unsigned char *)msg, (size_t)n, mac, &mac_len) ||
|
||||
mac_len != sizeof mac)
|
||||
return -1;
|
||||
for (unsigned i = 0; i < mac_len; i++)
|
||||
snprintf(hex + 2 * i, 3, "%02x", mac[i]);
|
||||
n = snprintf(url_out, url_sz,
|
||||
"https://gs-sec.ww.np.dl.playstation.net/plo/np/%s/%s/%s-ver.xml",
|
||||
title_id, hex, title_id);
|
||||
return (n > 0 && (size_t)n < url_sz) ? 0 : -1;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz) {
|
||||
if (!title_id) return -1;
|
||||
|
||||
if (!strncmp(title_id, "CUSA", 4))
|
||||
return ps4_update_url(title_id, url_out, url_sz);
|
||||
|
||||
/* Only PS5 native title IDs (PPSA/PPSE/PPSH/PPSJ/PPSC) */
|
||||
if (strncmp(title_id, "PPSA", 4) &&
|
||||
strncmp(title_id, "PPSE", 4) &&
|
||||
|
||||
+42
-5
@@ -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 */
|
||||
@@ -638,15 +656,22 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
static const char *PATCH_DIRS[] = {
|
||||
"/user/patch",
|
||||
"/mnt/ext0/user/patch",
|
||||
"/mnt/ext1/user/patch",
|
||||
NULL,
|
||||
};
|
||||
|
||||
static int
|
||||
read_title_file(const char *const *bases, const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
char path[PATH_MAX];
|
||||
if (g_sysroot_fd < 0 || !sz) return -1;
|
||||
for (int i = 0; SCAN_DIRS[i]; i++) {
|
||||
for (int i = 0; bases[i]; i++) {
|
||||
int fd;
|
||||
ssize_t n;
|
||||
snprintf(path, sizeof(path), "%s/%s/%s", SCAN_DIRS[i] + 1, title_id, name);
|
||||
snprintf(path, sizeof(path), "%s/%s/%s", bases[i] + 1, title_id, name);
|
||||
fd = openat(g_sysroot_fd, path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) continue;
|
||||
n = read(fd, buf, sz - 1);
|
||||
@@ -658,6 +683,18 @@ patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
return -1;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
return read_title_file(SCAN_DIRS, title_id, name, buf, sz);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan_read_patch_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
return read_title_file(PATCH_DIRS, title_id, name, buf, sz);
|
||||
}
|
||||
|
||||
/* Fold one stat result into an FNV-1a hash. */
|
||||
static unsigned long long
|
||||
sig_add(unsigned long long h, const char *rel) {
|
||||
|
||||
@@ -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];
|
||||
@@ -84,6 +86,9 @@ const char *patchdl_scan_step(void);
|
||||
0 on success with a NUL-terminated buf. */
|
||||
int patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz);
|
||||
/* Same for an installed PS4 patch: /user/patch/<id>/<name>. */
|
||||
int patchdl_scan_read_patch_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz);
|
||||
|
||||
/* Mark scan/debug as no longer safe to call (must be set after MHD worker
|
||||
threads come up — patchdl_scan performs a process-wide vnode swap that
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.1.0"
|
||||
#define PATCHDL_VERSION "0.2.1"
|
||||
+11
-5
@@ -134,20 +134,26 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||
char murl[512] = {0};
|
||||
|
||||
/* PS5 version.xml uses content_ver; PS4 uses version. */
|
||||
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
|
||||
int is_ps4 = attr_val(p, tag_end, "content_ver", ver, sizeof(ver)) != 0;
|
||||
if (is_ps4)
|
||||
attr_val(p, tag_end, "version", ver, sizeof(ver));
|
||||
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
|
||||
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
|
||||
attr_val(p, tag_end, "manifest_url", murl, sizeof(murl));
|
||||
|
||||
if (ver[0] && sver[0]) {
|
||||
uint32_t pkg_sver = parse_hex(sver);
|
||||
if (ver[0] && (sver[0] || is_ps4)) {
|
||||
/* A PS4 patch's system_ver is a PS4 firmware (in decimal); the
|
||||
PS5 runs PS4 games regardless, so it never blocks one. */
|
||||
uint32_t pkg_sver = is_ps4 ? 0 : parse_hex(sver);
|
||||
|
||||
/* Track latest overall */
|
||||
if (!out->latest_version[0] || ver_gt(ver, out->latest_version)) {
|
||||
strncpy(out->latest_version, ver, sizeof(out->latest_version) - 1);
|
||||
fw_bin_to_str(pkg_sver, out->latest_required_fw,
|
||||
sizeof(out->latest_required_fw));
|
||||
if (is_ps4)
|
||||
out->latest_required_fw[0] = '\0';
|
||||
else
|
||||
fw_bin_to_str(pkg_sver, out->latest_required_fw,
|
||||
sizeof(out->latest_required_fw));
|
||||
}
|
||||
|
||||
/* Track latest compatible + its installable patch source. PS5
|
||||
|
||||
+176
-39
@@ -145,8 +145,11 @@ stream_find(const char *title_id) {
|
||||
g_mutex held. */
|
||||
static int
|
||||
stream_read_all(const install_stream_t *st) {
|
||||
int quiet = time(NULL) - st->last >= INSTALL_QUIET_SECS;
|
||||
return (st->total > 0 && st->read >= st->total) ||
|
||||
(st->crc && time(NULL) - st->last >= INSTALL_QUIET_SECS);
|
||||
(st->crc && quiet) ||
|
||||
/* PS4 patches come without a .crc */
|
||||
(!strncmp(st->title_id, "CUSA", 4) && st->read > 0 && quiet);
|
||||
}
|
||||
|
||||
#define INSTALL_STALL_SECS 300
|
||||
@@ -229,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;
|
||||
@@ -265,7 +269,8 @@ static const char config_tail_json[] =
|
||||
"\"cdn_allowlist\":["
|
||||
"\"sgst.prod.dl.playstation.net\","
|
||||
"\"gst.prod.dl.playstation.net\","
|
||||
"\"gs2.ww.prod.dl.playstation.net\""
|
||||
"\"gs2.ww.prod.dl.playstation.net\","
|
||||
"\"gs-sec.ww.np.dl.playstation.net\""
|
||||
"]}";
|
||||
/* ---------- tiny JSON value lookups (flat objects only) ----------------- */
|
||||
|
||||
@@ -1014,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);
|
||||
|
||||
@@ -1022,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]) {
|
||||
@@ -1063,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;
|
||||
@@ -1104,9 +1130,6 @@ title_status_str(const patchdl_title_t *t) {
|
||||
if (t->source_type == PATCHDL_SOURCE_UNKNOWN ||
|
||||
t->source_type == PATCHDL_SOURCE_FAKE ||
|
||||
t->source_type == PATCHDL_SOURCE_SHADOWMOUNT) return "blocked";
|
||||
/* PS4 games: their updates live on other Sony servers and install
|
||||
another way. PatchDL only handles PS5 titles. */
|
||||
if (!strncmp(t->title_id, "CUSA", 4)) return "blocked";
|
||||
if (t->compatible_version[0]) {
|
||||
/* Versions are zero-padded (01.000.011), so strcmp orders them.
|
||||
A compatible patch newer than what's installed = update available. */
|
||||
@@ -1201,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");
|
||||
@@ -1266,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, "]");
|
||||
@@ -1879,8 +1907,8 @@ verify_existing(dl_job_t *q, int fd, const patchdl_manifest_t *mf,
|
||||
pthread_mutex_unlock(&g_pool.mtx);
|
||||
if (stop) break;
|
||||
if (mf->pieces[i].hash[0] &&
|
||||
patchdl_sha256_fd_region(fd, mf->pieces[i].offset, mf->pieces[i].size,
|
||||
hex) == 0 &&
|
||||
patchdl_hash_fd_region(fd, mf->pieces[i].offset, mf->pieces[i].size,
|
||||
mf->pieces[i].hash, hex) == 0 &&
|
||||
!strcasecmp(hex, mf->pieces[i].hash))
|
||||
done[i / 8] |= (unsigned char)(1 << (i % 8));
|
||||
hashed += mf->pieces[i].size;
|
||||
@@ -1900,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;
|
||||
@@ -1924,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);
|
||||
@@ -2002,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;
|
||||
@@ -2016,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;
|
||||
@@ -2206,9 +2246,6 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
if (src == PATCHDL_SOURCE_SHADOWMOUNT)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_shadowmount\"}");
|
||||
if (!strncmp(title_id, "CUSA", 4))
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"ps4_not_supported\"}");
|
||||
if (!patch_url[0])
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
|
||||
@@ -2528,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,
|
||||
@@ -2546,9 +2671,7 @@ do_install(struct MHD_Connection *conn, const char *title_id,
|
||||
if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"install_not_allowed_for_source\"}");
|
||||
if (!strncmp(title_id, "CUSA", 4))
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"ps4_not_supported\"}");
|
||||
|
||||
|
||||
if (!patch_url[0] && !delta_url[0])
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
@@ -3325,8 +3448,8 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
char got[80] = {0};
|
||||
int ok = 0;
|
||||
if (mf.pieces[i].hash[0] &&
|
||||
patchdl_sha256_fd_region(fd, mf.pieces[i].offset,
|
||||
mf.pieces[i].size, got) == 0)
|
||||
patchdl_hash_fd_region(fd, mf.pieces[i].offset,
|
||||
mf.pieces[i].size, mf.pieces[i].hash, got) == 0)
|
||||
ok = (strcasecmp(got, mf.pieces[i].hash) == 0);
|
||||
if (ok) okc++; else { badc++; if (first_bad < 0) first_bad = i; }
|
||||
if (i) jbuf_append(&j, ",");
|
||||
@@ -3379,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] = "";
|
||||
@@ -3452,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;
|
||||
@@ -3556,8 +3688,12 @@ check_one_install(int idx) {
|
||||
idle = (long long)(time(NULL) - last);
|
||||
snprintf(size_key, sizeof size_key, "\"originalFileSize\":%lld", total);
|
||||
aj = malloc(256 * 1024);
|
||||
/* A PS5 update replaces the game's app.pkg (app.json); a PS4 patch
|
||||
lands next to it under /user/patch/<id>/ (patch.json). */
|
||||
ok = aj && version_installed(tid, ver) && file[0] &&
|
||||
patchdl_scan_read_app_file(tid, "app.json", aj, 256 * 1024) == 0 &&
|
||||
(strncmp(tid, "CUSA", 4)
|
||||
? patchdl_scan_read_app_file(tid, "app.json", aj, 256 * 1024)
|
||||
: patchdl_scan_read_patch_file(tid, "patch.json", aj, 256 * 1024)) == 0 &&
|
||||
strstr(aj, file) && strstr(aj, size_key);
|
||||
free(aj);
|
||||
/* install_reading() would say busy: this install is the one being
|
||||
@@ -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++)
|
||||
|
||||
+204
-5
@@ -51,6 +51,7 @@ const fallback = {
|
||||
"sgst.prod.dl.playstation.net",
|
||||
"gst.prod.dl.playstation.net",
|
||||
"gs2.ww.prod.dl.playstation.net",
|
||||
"gs-sec.ww.np.dl.playstation.net",
|
||||
],
|
||||
},
|
||||
titles: [
|
||||
@@ -118,11 +119,176 @@ 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
|
||||
// the phone (hidden on phones, where the button opens PayPal directly).
|
||||
function openDonate() {
|
||||
const back = document.activeElement;
|
||||
const matrix = qrEncode(DONATE_URL);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "guide-backdrop";
|
||||
wrap.innerHTML = `
|
||||
<section class="guide" role="dialog" aria-modal="true" aria-labelledby="donateTitle">
|
||||
<header class="guide-head">
|
||||
<div>
|
||||
<p class="eyebrow">Support PatchDL</p>
|
||||
<h2 id="donateTitle">Buy me a coffee</h2>
|
||||
</div>
|
||||
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<p class="guide-why">If you like what I do, you can buy me a coffee.</p>
|
||||
${matrix ? `<div class="guide-qr">${qrSvg(matrix)}<div>
|
||||
<span>Scan with your phone to open PayPal:</span>
|
||||
<code>${escapeHtml(DONATE_URL)}</code></div></div>` : ""}
|
||||
<a class="row-button is-update donate-open" href="${DONATE_URL}" target="_blank" rel="noopener">Open PayPal</a>
|
||||
<p class="guide-why"><small>It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</small></p>
|
||||
</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();
|
||||
}
|
||||
|
||||
// The server rescans on its own when games are added or removed and bumps
|
||||
// titles_gen whenever the list or an update check changes; reload quietly then.
|
||||
let watchBusy = false;
|
||||
@@ -252,9 +418,17 @@ async function loadInitialData({ quiet = false } = {}) {
|
||||
titles.forEach((g) => {
|
||||
const old = prev.get(g.title_id);
|
||||
// Neither state has a patch left to fetch, so drop stale job flags.
|
||||
// An install ends when the install poll says so, not when the list
|
||||
// already reports the new version (the console writes it mid-install).
|
||||
if (old && old.installing) {
|
||||
g.installing = true;
|
||||
g.installStatus = old.installStatus;
|
||||
g.installProgress = old.installProgress;
|
||||
g.installDone = old.installDone;
|
||||
g.installTotal = old.installTotal;
|
||||
}
|
||||
if (old && g.status !== "up_to_date" &&
|
||||
(g.status !== "incompatible_fw" || g.manual_version)) {
|
||||
if (old.installing) g.installing = true;
|
||||
if (old._autoInstalled) g._autoInstalled = true;
|
||||
if (old._localDownloading) g._localDownloading = true;
|
||||
if (old._wasActive) g._wasActive = true;
|
||||
@@ -476,6 +650,8 @@ function createGameCard(game) {
|
||||
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
|
||||
${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>
|
||||
`;
|
||||
@@ -606,17 +782,20 @@ 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("");
|
||||
}
|
||||
|
||||
function statusPill(game) {
|
||||
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
||||
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
|
||||
if (isPs4(game)) return `<span class="pill">PS4 · not supported</span>`;
|
||||
if (game.status === "blocked" || !sourcePolicy(game).allow_install)
|
||||
return `<span class="pill">Not supported</span>`;
|
||||
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
|
||||
@@ -760,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;
|
||||
@@ -770,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);
|
||||
@@ -1303,7 +1503,6 @@ const REASON_TEXT = {
|
||||
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
|
||||
install_running: "The console is installing from this package right now. Try again when it is done.",
|
||||
already_installed: "This version is already installed.",
|
||||
ps4_not_supported: "PS4 games are not supported.",
|
||||
too_many_installs: "Too many installs running at once. Try again when one has finished.",
|
||||
download_incomplete: "The download is not complete yet.",
|
||||
download_unverified: "The package on disk hasn't been checked yet. Press Update: PatchDL verifies it and downloads only what's missing.",
|
||||
|
||||
+7
-1
@@ -58,6 +58,7 @@
|
||||
<div class="rail-foot">
|
||||
<span id="railFw">FW --</span>
|
||||
<span id="railSpace">-- free</span>
|
||||
<button type="button" class="link-button rail-donate" data-donate>Buy me a coffee</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -66,7 +67,7 @@
|
||||
<section class="view is-active" data-view="games">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">PS5 update manager</p>
|
||||
<p class="eyebrow">PS5 & PS4 game updates</p>
|
||||
<h1>Games</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
@@ -239,6 +240,11 @@
|
||||
<span>Downloads on console <em id="storageTotal"></em></span>
|
||||
<div id="storageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="donate-row">
|
||||
<span>Like PatchDL? You can buy me a coffee.</span>
|
||||
<button type="button" class="row-button is-ghost" data-donate>Buy me a coffee</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -116,6 +116,42 @@ svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
.rail-foot span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-foot .rail-donate { justify-self: start; margin-top: 4px; }
|
||||
|
||||
.donate-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
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