mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-17 19:00:54 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b58a77a70 | ||
|
|
23e4dda1e2 | ||
|
|
b8899c6b02 | ||
|
|
4816a0fbd7 | ||
|
|
1cc12e2640 | ||
|
|
4dd254c20d | ||
|
|
76d40a5327 | ||
|
|
a8aace1e55 | ||
|
|
b4fbdd4681 | ||
|
|
8053f5395b | ||
|
|
bde585eab7 | ||
|
|
eaab113d8a | ||
|
|
c3630000b6 | ||
|
|
72b9136ff5 | ||
|
|
c6f001f857 | ||
|
|
e72c25a56e |
No files matched your search
@@ -0,0 +1 @@
|
||||
custom: ["https://paypal.me/oliverkoester"]
|
||||
@@ -20,6 +20,7 @@ SRCS := src/main.c \
|
||||
src/patchdl_scan.c \
|
||||
src/patchdl_appdb.c \
|
||||
src/patchdl_net.c \
|
||||
src/patchdl_dns.c \
|
||||
src/patchdl_prospero.c \
|
||||
src/patchdl_resolve.c \
|
||||
src/patchdl_verxml.c \
|
||||
@@ -31,7 +32,7 @@ SRCS := src/main.c \
|
||||
# assets must trigger a rebuild.
|
||||
TILE_ASSETS := assets/param.json assets/icon0.png
|
||||
|
||||
WEB_ASSETS := web/index.html web/styles.css web/app.js
|
||||
WEB_ASSETS := web/index.html web/styles.css web/i18n.js web/app.js
|
||||
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
|
||||
|
||||
SQLITE_DIR := vendor/sqlite
|
||||
|
||||
@@ -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,10 +87,10 @@ 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.
|
||||
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. It finds them through public DNS (Cloudflare and Google, encrypted if your network blocks plain DNS) and never touches the console's own DNS settings, so a DNS block that keeps the system from updating stays in place. 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.
|
||||
|
||||
**Can I get an older patch that runs on my firmware?**
|
||||
Yes. Sony still hosts older patches, but its servers only tell your console about the newest one. When that one needs a newer firmware, PatchDL looks up the version history on [Prospero Patches](https://prosperopatches.com/) and lists the game under *Updates* with an **Old FW patch** button. Tap it and scan the QR code with your phone. Open the game on Prospero, pick the version PatchDL names (confirm the human check if one shows up) and copy the **SC** link. Paste it on your phone, and the PS5 downloads the patch from Sony and handles it like any other update.
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Copyright (C) 2026 Knutwurst
|
||||
*
|
||||
* PatchDL is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version. See the LICENSE file in the project root for details.
|
||||
*/
|
||||
|
||||
#include "patchdl_dns.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
#include <curl/curl.h>
|
||||
#include "patchdl_doh_ca.h"
|
||||
#endif
|
||||
|
||||
#ifndef PATCHDL_DNS_SAVE_FILE
|
||||
#define PATCHDL_DNS_SAVE_FILE "/data/patchdl/dns_cache.txt"
|
||||
#endif
|
||||
|
||||
/* Worst case with everything blocked: 4 x 1.2 s UDP, 2 x 2 s TCP, 3 x 4 s
|
||||
DoH; about 21 s, once per host, then the failure is cached. */
|
||||
#define UDP_TIMEOUT_MS 1200
|
||||
#define TCP_TIMEOUT_MS 2000
|
||||
#define DOH_TIMEOUT_S 4L
|
||||
#define POSITIVE_TTL_S 600 /* CDN edges rotate; ask again after 10 min */
|
||||
#define NEGATIVE_TTL_S 20 /* don't run the whole chain for every piece */
|
||||
#define CACHE_SLOTS 32
|
||||
#define MAX_SAVED 32
|
||||
|
||||
static const char *PLAIN_SERVERS[] = { "1.1.1.1", "8.8.8.8", "9.9.9.9", "1.0.0.1", NULL };
|
||||
static const char *TCP_SERVERS[] = { "1.1.1.1", "8.8.8.8", NULL };
|
||||
|
||||
/* DoH endpoints by name, pinned to an address so no DNS is needed to reach
|
||||
them. The certificate is checked against the name. Only servers that
|
||||
answer over HTTP/1.1: the SDK's libcurl has no HTTP/2 (Quad9 needs it). */
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
static const struct { const char *name, *ip; } DOH_SERVERS[] = {
|
||||
{ "cloudflare-dns.com", "1.1.1.1" },
|
||||
{ "dns.google", "8.8.8.8" },
|
||||
{ "cloudflare-dns.com", "1.0.0.1" },
|
||||
{ NULL, NULL },
|
||||
};
|
||||
#endif
|
||||
|
||||
/* ---------- wire format ------------------------------------------------- */
|
||||
|
||||
enum {
|
||||
ANS_OK = 0,
|
||||
ANS_MALFORMED = -1,
|
||||
ANS_NXDOMAIN = -2,
|
||||
ANS_RCODE = -6, /* REFUSED, SERVFAIL ... */
|
||||
ANS_NO_A = -3,
|
||||
ANS_SINKHOLE = -4, /* only private/loopback/zero addresses */
|
||||
ANS_TRUNCATED = -5,
|
||||
};
|
||||
|
||||
static long long
|
||||
now_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
static uint16_t
|
||||
next_query_id(void) {
|
||||
static unsigned counter;
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, NULL);
|
||||
return (uint16_t)((unsigned)tv.tv_usec * 2654435761u +
|
||||
__atomic_fetch_add(&counter, 1, __ATOMIC_RELAXED) * 40503u +
|
||||
(unsigned)getpid());
|
||||
}
|
||||
|
||||
/* Query for host's A record. Returns the length, or -1. */
|
||||
static int
|
||||
build_query(const char *host, uint16_t id, uint8_t *buf, size_t sz) {
|
||||
size_t pos = 12;
|
||||
const char *p = host;
|
||||
|
||||
if (sz < 12 + 2 + 4) return -1;
|
||||
memset(buf, 0, 12);
|
||||
buf[0] = (uint8_t)(id >> 8); buf[1] = (uint8_t)id;
|
||||
buf[2] = 0x01; /* RD */
|
||||
buf[5] = 0x01; /* QDCOUNT = 1 */
|
||||
while (*p) {
|
||||
const char *dot = strchr(p, '.');
|
||||
size_t len = dot ? (size_t)(dot - p) : strlen(p);
|
||||
if (!len || len > 63 || pos + 1 + len + 1 + 4 > sz) return -1;
|
||||
buf[pos++] = (uint8_t)len;
|
||||
memcpy(buf + pos, p, len);
|
||||
pos += len;
|
||||
p += len;
|
||||
if (*p == '.') p++;
|
||||
}
|
||||
buf[pos++] = 0;
|
||||
buf[pos++] = 0; buf[pos++] = 1; /* QTYPE A */
|
||||
buf[pos++] = 0; buf[pos++] = 1; /* QCLASS IN */
|
||||
return (int)pos;
|
||||
}
|
||||
|
||||
/* Past a (possibly compressed) name; 0 on success. */
|
||||
static int
|
||||
skip_name(const uint8_t *r, size_t n, size_t *pos) {
|
||||
while (*pos < n) {
|
||||
uint8_t len = r[*pos];
|
||||
if (!len) { (*pos)++; return 0; }
|
||||
if ((len & 0xC0) == 0xC0) { *pos += 2; return *pos <= n ? 0 : -1; }
|
||||
if (len & 0xC0) return -1;
|
||||
*pos += 1 + (size_t)len;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Addresses a filtering resolver answers with instead of the real one. */
|
||||
static int
|
||||
is_sinkhole(const uint8_t a[4]) {
|
||||
return a[0] == 0 || a[0] == 127 || a[0] == 10 || a[0] >= 224 ||
|
||||
(a[0] == 172 && (a[1] & 0xF0) == 16) ||
|
||||
(a[0] == 192 && a[1] == 168) ||
|
||||
(a[0] == 169 && a[1] == 254) ||
|
||||
(a[0] == 100 && (a[1] & 0xC0) == 64);
|
||||
}
|
||||
|
||||
static int
|
||||
parse_answer(const uint8_t *r, size_t n, uint16_t id, patchdl_dns_answer_t *out,
|
||||
char *sinkhole_ip, size_t sinkhole_sz) {
|
||||
size_t pos = 12;
|
||||
uint16_t qd, an;
|
||||
|
||||
out->n = 0;
|
||||
if (n < 12) return ANS_MALFORMED;
|
||||
if (((r[0] << 8) | r[1]) != id || !(r[2] & 0x80)) return ANS_MALFORMED;
|
||||
if (r[2] & 0x02) return ANS_TRUNCATED;
|
||||
if ((r[3] & 0x0F) == 3) return ANS_NXDOMAIN;
|
||||
if (r[3] & 0x0F) return ANS_RCODE;
|
||||
qd = (uint16_t)((r[4] << 8) | r[5]);
|
||||
an = (uint16_t)((r[6] << 8) | r[7]);
|
||||
for (int q = 0; q < qd; q++) {
|
||||
if (skip_name(r, n, &pos) || pos + 4 > n) return ANS_MALFORMED;
|
||||
pos += 4;
|
||||
}
|
||||
for (int a = 0; a < an; a++) {
|
||||
uint16_t type, rdlen;
|
||||
if (skip_name(r, n, &pos) || pos + 10 > n) return ANS_MALFORMED;
|
||||
type = (uint16_t)((r[pos] << 8) | r[pos + 1]);
|
||||
rdlen = (uint16_t)((r[pos + 8] << 8) | r[pos + 9]);
|
||||
pos += 10;
|
||||
if (pos + rdlen > n) return ANS_MALFORMED;
|
||||
if (type == 1 && rdlen == 4) {
|
||||
if (is_sinkhole(r + pos)) {
|
||||
if (sinkhole_ip && !sinkhole_ip[0])
|
||||
snprintf(sinkhole_ip, sinkhole_sz, "%u.%u.%u.%u",
|
||||
r[pos], r[pos + 1], r[pos + 2], r[pos + 3]);
|
||||
} else if (out->n < PATCHDL_DNS_MAX_IPS) {
|
||||
snprintf(out->ip[out->n++], sizeof out->ip[0], "%u.%u.%u.%u",
|
||||
r[pos], r[pos + 1], r[pos + 2], r[pos + 3]);
|
||||
}
|
||||
}
|
||||
pos += rdlen; /* CNAMEs and the like */
|
||||
}
|
||||
if (out->n) return ANS_OK;
|
||||
return sinkhole_ip && sinkhole_ip[0] ? ANS_SINKHOLE : ANS_NO_A;
|
||||
}
|
||||
|
||||
static const char *
|
||||
answer_text(int rc) {
|
||||
switch (rc) {
|
||||
case ANS_NXDOMAIN: return "no such name";
|
||||
case ANS_RCODE: return "refused";
|
||||
case ANS_NO_A: return "no address";
|
||||
case ANS_TRUNCATED: return "truncated";
|
||||
default: return "bad reply";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- transports -------------------------------------------------- */
|
||||
|
||||
static void
|
||||
trail_add(char *trail, size_t sz, const char *fmt, const char *a, const char *b) {
|
||||
size_t len = strlen(trail);
|
||||
if (len + 3 >= sz) return;
|
||||
if (len) { memcpy(trail + len, "; ", 3); len += 2; }
|
||||
snprintf(trail + len, sz - len, fmt, a, b);
|
||||
}
|
||||
|
||||
/* Shared tail of every transport: parse, log the outcome, 0 on a usable
|
||||
answer. */
|
||||
static int
|
||||
finish(const uint8_t *resp, size_t n, uint16_t id, patchdl_dns_answer_t *out,
|
||||
const char *label, char *trail, size_t trail_sz, int *truncated) {
|
||||
char sink[16] = "";
|
||||
int rc = parse_answer(resp, n, id, out, sink, sizeof sink);
|
||||
if (truncated) *truncated = (rc == ANS_TRUNCATED);
|
||||
if (rc == ANS_OK) return 0;
|
||||
if (rc == ANS_SINKHOLE) trail_add(trail, trail_sz, "%s: blocked (%s)", label, sink);
|
||||
else trail_add(trail, trail_sz, "%s: %s", label, answer_text(rc));
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
query_udp(const char *server, const char *host, patchdl_dns_answer_t *out,
|
||||
char *trail, size_t trail_sz, int *truncated) {
|
||||
uint8_t q[300], resp[1500];
|
||||
struct sockaddr_in sa;
|
||||
uint16_t id = next_query_id();
|
||||
char label[32];
|
||||
int qlen, fd;
|
||||
ssize_t n = -1;
|
||||
|
||||
snprintf(label, sizeof label, "udp %s", server);
|
||||
if ((qlen = build_query(host, id, q, sizeof q)) < 0) return -1;
|
||||
if ((fd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
|
||||
trail_add(trail, trail_sz, "%s: socket %s", label, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
memset(&sa, 0, sizeof sa);
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_port = htons(53);
|
||||
sa.sin_addr.s_addr = inet_addr(server);
|
||||
/* Connected socket: replies from anyone but this server are dropped. */
|
||||
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) || send(fd, q, (size_t)qlen, 0) != qlen) {
|
||||
trail_add(trail, trail_sz, "%s: %s", label, strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
/* Skip stray datagrams (an older query's late answer) until ours or
|
||||
the deadline. */
|
||||
errno = EAGAIN;
|
||||
for (long long deadline = now_ms() + UDP_TIMEOUT_MS, left;
|
||||
(left = deadline - now_ms()) > 0;) {
|
||||
struct pollfd pfd = { fd, POLLIN, 0 };
|
||||
if (poll(&pfd, 1, (int)left) != 1) { errno = EAGAIN; break; }
|
||||
n = recv(fd, resp, sizeof resp, 0);
|
||||
if (n < 0) break;
|
||||
if (n >= 2 && ((resp[0] << 8) | resp[1]) == id) {
|
||||
close(fd);
|
||||
if (!finish(resp, (size_t)n, id, out, label, trail, trail_sz, truncated)) {
|
||||
snprintf(out->source, sizeof out->source, "%s", label);
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
trail_add(trail, trail_sz, "%s: %s", label, errno == EAGAIN || errno == EWOULDBLOCK
|
||||
? "no answer" : strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Send or receive exactly len bytes on a non-blocking socket before the
|
||||
deadline. A server trickling bytes can't stretch the exchange. */
|
||||
static int
|
||||
io_until(int fd, uint8_t *buf, size_t len, int sending, long long deadline) {
|
||||
size_t done = 0;
|
||||
while (done < len) {
|
||||
struct pollfd pfd = { fd, (short)(sending ? POLLOUT : POLLIN), 0 };
|
||||
long long left = deadline - now_ms();
|
||||
ssize_t k;
|
||||
if (left <= 0 || poll(&pfd, 1, (int)left) != 1) return -1;
|
||||
k = sending ? send(fd, buf + done, len - done, 0)
|
||||
: recv(fd, buf + done, len - done, 0);
|
||||
if (k < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) continue;
|
||||
if (k <= 0) return -1;
|
||||
done += (size_t)k;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* One DNS exchange over TCP, connect included, within TCP_TIMEOUT_MS. */
|
||||
static int
|
||||
query_tcp(const char *server, const char *host, patchdl_dns_answer_t *out,
|
||||
char *trail, size_t trail_sz) {
|
||||
uint8_t q[302], resp[4096], lenb[2];
|
||||
struct sockaddr_in sa;
|
||||
long long deadline = now_ms() + TCP_TIMEOUT_MS;
|
||||
uint16_t id = next_query_id();
|
||||
char label[32];
|
||||
int qlen, fd, err = 0;
|
||||
socklen_t elen = sizeof err;
|
||||
size_t rlen;
|
||||
|
||||
snprintf(label, sizeof label, "tcp %s", server);
|
||||
if ((qlen = build_query(host, id, q + 2, sizeof q - 2)) < 0) return -1;
|
||||
q[0] = (uint8_t)(qlen >> 8); q[1] = (uint8_t)qlen;
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
trail_add(trail, trail_sz, "%s: socket %s", label, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
memset(&sa, 0, sizeof sa);
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_port = htons(53);
|
||||
sa.sin_addr.s_addr = inet_addr(server);
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) && errno != EINPROGRESS) {
|
||||
trail_add(trail, trail_sz, "%s: %s", label, strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
struct pollfd pfd = { fd, POLLOUT, 0 };
|
||||
if (poll(&pfd, 1, TCP_TIMEOUT_MS) != 1 ||
|
||||
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen) || err) {
|
||||
trail_add(trail, trail_sz, "%s: %s", label, err ? strerror(err) : "no connection");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (io_until(fd, q, (size_t)qlen + 2, 1, deadline) || io_until(fd, lenb, 2, 0, deadline) ||
|
||||
(rlen = (size_t)((lenb[0] << 8) | lenb[1])) > sizeof resp ||
|
||||
io_until(fd, resp, rlen, 0, deadline)) {
|
||||
trail_add(trail, trail_sz, "%s: %s", label, "no answer");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
close(fd);
|
||||
if (finish(resp, rlen, id, out, label, trail, trail_sz, NULL)) return -1;
|
||||
snprintf(out->source, sizeof out->source, "%s", label);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
typedef struct { uint8_t data[8192]; size_t len; } doh_buf_t;
|
||||
|
||||
static size_t
|
||||
doh_write(void *p, size_t size, size_t nmemb, void *ud) {
|
||||
doh_buf_t *b = ud;
|
||||
size_t total = size * nmemb;
|
||||
if (total > sizeof b->data - b->len) return 0;
|
||||
memcpy(b->data + b->len, p, total);
|
||||
b->len += total;
|
||||
return total;
|
||||
}
|
||||
|
||||
static int
|
||||
query_doh(const char *name, const char *ip, const char *host,
|
||||
patchdl_dns_answer_t *out, char *trail, size_t trail_sz) {
|
||||
uint8_t q[300];
|
||||
char url[96], pin[96], label[48];
|
||||
doh_buf_t *buf;
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
long http = 0;
|
||||
struct curl_slist *hdr = NULL, *rl = NULL;
|
||||
struct curl_blob ca;
|
||||
uint16_t id = next_query_id();
|
||||
int qlen, rc = -1;
|
||||
|
||||
snprintf(label, sizeof label, "doh %s", name);
|
||||
if ((qlen = build_query(host, id, q, sizeof q)) < 0) return -1;
|
||||
if (!(buf = calloc(1, sizeof *buf))) return -1;
|
||||
if (!(curl = curl_easy_init())) { free(buf); return -1; }
|
||||
snprintf(url, sizeof url, "https://%s/dns-query", name);
|
||||
snprintf(pin, sizeof pin, "%s:443:%s", name, ip);
|
||||
rl = curl_slist_append(NULL, pin);
|
||||
hdr = curl_slist_append(NULL, "Content-Type: application/dns-message");
|
||||
hdr = curl_slist_append(hdr, "Accept: application/dns-message");
|
||||
ca.data = (void *)PATCHDL_DOH_ROOTS_PEM;
|
||||
ca.len = strlen(PATCHDL_DOH_ROOTS_PEM);
|
||||
ca.flags = CURL_BLOB_COPY;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdr);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, (const char *)q);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)qlen);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, doh_write);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, buf);
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, (long)CURL_HTTP_VERSION_1_1);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, DOH_TIMEOUT_S);
|
||||
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);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
curl_slist_free_all(hdr);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
trail_add(trail, trail_sz, "%s: %s", label,
|
||||
res == CURLE_PEER_FAILED_VERIFICATION ? "certificate rejected"
|
||||
: curl_easy_strerror(res));
|
||||
} else if (http != 200) {
|
||||
char code[16];
|
||||
snprintf(code, sizeof code, "http %ld", http);
|
||||
trail_add(trail, trail_sz, "%s: %s", label, code);
|
||||
} else if (!finish(buf->data, buf->len, id, out, label, trail, trail_sz, NULL)) {
|
||||
snprintf(out->source, sizeof out->source, "%s", label);
|
||||
rc = 0;
|
||||
}
|
||||
free(buf);
|
||||
return rc;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ---------- last-known-good addresses on disk --------------------------- */
|
||||
|
||||
static pthread_mutex_t g_save_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
/* One line per host: "host ip,ip,...". */
|
||||
static int
|
||||
saved_get(const char *host, patchdl_dns_answer_t *out) {
|
||||
char line[512];
|
||||
FILE *f;
|
||||
int found = -1;
|
||||
|
||||
pthread_mutex_lock(&g_save_mtx);
|
||||
if ((f = fopen(PATCHDL_DNS_SAVE_FILE, "r"))) {
|
||||
while (found && fgets(line, sizeof line, f)) {
|
||||
char *sp = strchr(line, ' ');
|
||||
if (!sp) continue;
|
||||
*sp = '\0';
|
||||
if (strcmp(line, host)) continue;
|
||||
out->n = 0;
|
||||
for (char *save = NULL, *tok = strtok_r(sp + 1, ",\r\n", &save);
|
||||
tok && out->n < PATCHDL_DNS_MAX_IPS; tok = strtok_r(NULL, ",\r\n", &save)) {
|
||||
struct in_addr a;
|
||||
if (inet_pton(AF_INET, tok, &a) == 1 && !is_sinkhole((uint8_t *)&a))
|
||||
snprintf(out->ip[out->n++], sizeof out->ip[0], "%s", tok);
|
||||
}
|
||||
if (out->n) found = 0;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
pthread_mutex_unlock(&g_save_mtx);
|
||||
return found;
|
||||
}
|
||||
|
||||
static void
|
||||
saved_put(const char *host, const patchdl_dns_answer_t *a) {
|
||||
char lines[MAX_SAVED][512], line[512], tmp[sizeof PATCHDL_DNS_SAVE_FILE + 8];
|
||||
int n = 0, fd;
|
||||
FILE *f;
|
||||
|
||||
pthread_mutex_lock(&g_save_mtx);
|
||||
/* This host first, then the others it already knew. */
|
||||
snprintf(lines[n], sizeof lines[0], "%s ", host);
|
||||
for (int i = 0; i < a->n; i++) {
|
||||
size_t len = strlen(lines[n]);
|
||||
snprintf(lines[n] + len, sizeof lines[0] - len, "%s%s", i ? "," : "", a->ip[i]);
|
||||
}
|
||||
n++;
|
||||
if ((f = fopen(PATCHDL_DNS_SAVE_FILE, "r"))) {
|
||||
size_t hl = strlen(host);
|
||||
while (n < MAX_SAVED && fgets(line, sizeof line, f)) {
|
||||
line[strcspn(line, "\r\n")] = '\0';
|
||||
if (!line[0] || (!strncmp(line, host, hl) && line[hl] == ' ')) continue;
|
||||
snprintf(lines[n++], sizeof lines[0], "%s", line);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
snprintf(tmp, sizeof tmp, "%s.tmp", PATCHDL_DNS_SAVE_FILE);
|
||||
fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||
if (fd >= 0 && (f = fdopen(fd, "w"))) {
|
||||
for (int i = 0; i < n; i++) fprintf(f, "%s\n", lines[i]);
|
||||
if (!fclose(f)) rename(tmp, PATCHDL_DNS_SAVE_FILE);
|
||||
else unlink(tmp);
|
||||
} else if (fd >= 0) {
|
||||
close(fd);
|
||||
unlink(tmp);
|
||||
}
|
||||
pthread_mutex_unlock(&g_save_mtx);
|
||||
}
|
||||
|
||||
/* ---------- the chain --------------------------------------------------- */
|
||||
|
||||
static int
|
||||
resolve_fresh(const char *host, patchdl_dns_answer_t *out) {
|
||||
char *trail = out->trail;
|
||||
size_t tsz = sizeof out->trail;
|
||||
int truncated = 0;
|
||||
|
||||
trail[0] = '\0';
|
||||
for (int i = 0; PLAIN_SERVERS[i]; i++) {
|
||||
truncated = 0;
|
||||
if (!query_udp(PLAIN_SERVERS[i], host, out, trail, tsz, &truncated)) return 0;
|
||||
if (truncated && !query_tcp(PLAIN_SERVERS[i], host, out, trail, tsz)) return 0;
|
||||
}
|
||||
for (int i = 0; TCP_SERVERS[i]; i++)
|
||||
if (!query_tcp(TCP_SERVERS[i], host, out, trail, tsz)) return 0;
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
for (int i = 0; DOH_SERVERS[i].name; i++)
|
||||
if (!query_doh(DOH_SERVERS[i].name, DOH_SERVERS[i].ip, host, out, trail, tsz)) return 0;
|
||||
#endif
|
||||
if (!saved_get(host, out)) {
|
||||
snprintf(out->source, sizeof out->source, "saved");
|
||||
trail_add(trail, tsz, "%s%s", "using saved addresses", "");
|
||||
return 0;
|
||||
}
|
||||
out->n = 0;
|
||||
out->source[0] = '\0';
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ---------- cache ------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
char host[256];
|
||||
patchdl_dns_answer_t ans;
|
||||
int ok;
|
||||
int inflight;
|
||||
time_t expires;
|
||||
time_t used;
|
||||
} cache_slot_t;
|
||||
|
||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER;
|
||||
static cache_slot_t g_cache[CACHE_SLOTS];
|
||||
|
||||
static time_t
|
||||
now_s(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return ts.tv_sec;
|
||||
}
|
||||
|
||||
static cache_slot_t *
|
||||
slot_find(const char *host) {
|
||||
for (int i = 0; i < CACHE_SLOTS; i++)
|
||||
if (g_cache[i].host[0] && !strcmp(g_cache[i].host, host)) return &g_cache[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A free slot, else the least recently used one nobody is resolving. */
|
||||
static cache_slot_t *
|
||||
slot_claim(const char *host) {
|
||||
cache_slot_t *pick = NULL;
|
||||
for (int i = 0; i < CACHE_SLOTS; i++) {
|
||||
cache_slot_t *s = &g_cache[i];
|
||||
if (s->inflight) continue;
|
||||
if (!s->host[0]) { pick = s; break; }
|
||||
if (!pick || s->used < pick->used) pick = s;
|
||||
}
|
||||
if (pick) {
|
||||
memset(pick, 0, sizeof *pick);
|
||||
snprintf(pick->host, sizeof pick->host, "%s", host);
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_dns_lookup(const char *host, patchdl_dns_answer_t *out) {
|
||||
patchdl_dns_answer_t fresh;
|
||||
cache_slot_t *s;
|
||||
int rc;
|
||||
|
||||
memset(out, 0, sizeof *out);
|
||||
if (!host || !host[0] || strlen(host) >= sizeof g_cache[0].host) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
/* One lookup per host at a time: the download workers all want the same
|
||||
CDN host at once, and the others wait for its answer. */
|
||||
for (;;) {
|
||||
s = slot_find(host);
|
||||
if (!s || !s->inflight) break;
|
||||
pthread_cond_wait(&g_cv, &g_mtx);
|
||||
}
|
||||
if (s && s->expires > now_s()) {
|
||||
s->used = now_s();
|
||||
*out = s->ans;
|
||||
rc = s->ok ? 0 : -1;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
return rc;
|
||||
}
|
||||
if (!s) s = slot_claim(host);
|
||||
if (s) s->inflight = 1;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
|
||||
memset(&fresh, 0, sizeof fresh);
|
||||
rc = resolve_fresh(host, &fresh);
|
||||
if (!rc && strcmp(fresh.source, "saved")) saved_put(host, &fresh);
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
if (s) {
|
||||
s->ans = fresh;
|
||||
s->ok = !rc;
|
||||
s->expires = now_s() + (rc ? NEGATIVE_TTL_S : POSITIVE_TTL_S);
|
||||
s->used = now_s();
|
||||
s->inflight = 0;
|
||||
pthread_cond_broadcast(&g_cv);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
*out = fresh;
|
||||
return rc;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_dns_forget(const char *host) {
|
||||
cache_slot_t *s;
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
if ((s = slot_find(host)) && !s->inflight) s->expires = 0;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_dns_resolve_entry(const patchdl_dns_answer_t *a, const char *host,
|
||||
int port, char *out, size_t sz) {
|
||||
size_t len;
|
||||
int k = snprintf(out, sz, "%s:%d:", host, port);
|
||||
if (k < 0 || (size_t)k >= sz) return -1;
|
||||
for (int i = 0; i < a->n; i++) {
|
||||
len = strlen(out);
|
||||
k = snprintf(out + len, sz - len, "%s%s", i ? "," : "", a->ip[i]);
|
||||
if (k < 0 || (size_t)k >= sz - len) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Copyright (C) 2026 Knutwurst
|
||||
*
|
||||
* PatchDL is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version. See the LICENSE file in the project root for details.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/*
|
||||
* PatchDL's own resolver. The console's DNS is often set up to block Sony's
|
||||
* hosts (that is how people keep the system from updating), so PatchDL never
|
||||
* asks it. A lookup tries, in this order, until one gives a usable answer:
|
||||
*
|
||||
* 1. plain DNS over UDP to 1.1.1.1, 8.8.8.8, 9.9.9.9, 1.0.0.1
|
||||
* 2. DNS over TCP to 1.1.1.1 and 8.8.8.8 (UDP/53 blocked or truncated)
|
||||
* 3. DNS over HTTPS to Cloudflare and Google (port 53 blocked or
|
||||
* redirected to a filtering resolver)
|
||||
* 4. the addresses the last successful lookup saved to disk
|
||||
*
|
||||
* Answers in private, loopback or "this network" ranges count as blocked:
|
||||
* that is what filtering resolvers send back for Sony's hosts.
|
||||
*/
|
||||
|
||||
#define PATCHDL_DNS_MAX_IPS 8
|
||||
|
||||
typedef struct {
|
||||
int n;
|
||||
char ip[PATCHDL_DNS_MAX_IPS][16];
|
||||
char source[40]; /* e.g. "udp 1.1.1.1", "doh dns.google", "saved" */
|
||||
char trail[384]; /* what each step answered on the last fresh lookup */
|
||||
} patchdl_dns_answer_t;
|
||||
|
||||
/* Resolve host (IPv4). Answers are cached for a few minutes and failures for
|
||||
a short while. 0 on success, -1 if every source failed. */
|
||||
int patchdl_dns_lookup(const char *host, patchdl_dns_answer_t *out);
|
||||
|
||||
/* Drop host's cached answer, e.g. after none of its addresses accepted a
|
||||
connection, so the next lookup asks again. */
|
||||
void patchdl_dns_forget(const char *host);
|
||||
|
||||
/* Room for any entry: a 255-character host, port and every address. */
|
||||
#define PATCHDL_DNS_ENTRY_MAX (256 + 8 + PATCHDL_DNS_MAX_IPS * 16)
|
||||
|
||||
/* "host:port:ip1,ip2,..." for CURLOPT_RESOLVE. curl moves on to the next
|
||||
address when one doesn't connect. -1 if it doesn't fit in sz. */
|
||||
int patchdl_dns_resolve_entry(const patchdl_dns_answer_t *a, const char *host,
|
||||
int port, char *out, size_t sz);
|
||||
@@ -0,0 +1,142 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Copyright (C) 2026 Knutwurst
|
||||
*
|
||||
* PatchDL is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version. See the LICENSE file in the project root for details.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* Public root CAs of the DNS-over-HTTPS resolvers PatchDL falls back to when
|
||||
* plain DNS is blocked or answers with a sinkhole address. Only the DoH
|
||||
* requests use these; Sony's hosts stay pinned to the SCEI root
|
||||
* (patchdl_ca.h). Public, non-secret certificates.
|
||||
*/
|
||||
static const char PATCHDL_DOH_ROOTS_PEM[] =
|
||||
/* SSL.com Root Certification Authority ECC (Cloudflare 1.1.1.1 / 1.0.0.1) */
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMC\n"
|
||||
"VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T\n"
|
||||
"U0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0\n"
|
||||
"aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNDAzWhcNNDEwMjEyMTgxNDAz\n"
|
||||
"WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0\n"
|
||||
"b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBS\n"
|
||||
"b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB\n"
|
||||
"BAAiA2IABEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI\n"
|
||||
"7Z4INcgn64mMU1jrYor+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPg\n"
|
||||
"CemB+vNH06NjMGEwHQYDVR0OBBYEFILRhXMw5zUE044CkvvlpNHEIejNMA8GA1Ud\n"
|
||||
"EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTTjgKS++Wk0cQh6M0wDgYD\n"
|
||||
"VR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCWe+0F+S8T\n"
|
||||
"kdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+\n"
|
||||
"gA0z5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
/* GTS Root R1 (Google 8.8.8.8) */
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw\n"
|
||||
"CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"
|
||||
"MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"
|
||||
"MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"
|
||||
"Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA\n"
|
||||
"A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo\n"
|
||||
"27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w\n"
|
||||
"Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw\n"
|
||||
"TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl\n"
|
||||
"qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH\n"
|
||||
"szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8\n"
|
||||
"Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk\n"
|
||||
"MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92\n"
|
||||
"wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p\n"
|
||||
"aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN\n"
|
||||
"VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID\n"
|
||||
"AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n"
|
||||
"FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb\n"
|
||||
"C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n"
|
||||
"QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy\n"
|
||||
"h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4\n"
|
||||
"7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J\n"
|
||||
"ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef\n"
|
||||
"MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/\n"
|
||||
"Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT\n"
|
||||
"6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ\n"
|
||||
"0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm\n"
|
||||
"2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb\n"
|
||||
"bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
/* GTS Root R4 (Google 8.8.8.8) */
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n"
|
||||
"VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n"
|
||||
"A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n"
|
||||
"WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n"
|
||||
"IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n"
|
||||
"AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n"
|
||||
"QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n"
|
||||
"HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n"
|
||||
"BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n"
|
||||
"9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n"
|
||||
"p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
/* GlobalSign Root CA (cross-signs the GTS roots) */
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n"
|
||||
"A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n"
|
||||
"b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n"
|
||||
"MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n"
|
||||
"YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n"
|
||||
"aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n"
|
||||
"jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n"
|
||||
"xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n"
|
||||
"1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n"
|
||||
"snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n"
|
||||
"U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n"
|
||||
"9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n"
|
||||
"BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n"
|
||||
"AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n"
|
||||
"yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n"
|
||||
"38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n"
|
||||
"AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n"
|
||||
"DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n"
|
||||
"HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
/* DigiCert Global Root G2 */
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n"
|
||||
"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"
|
||||
"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n"
|
||||
"MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n"
|
||||
"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n"
|
||||
"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n"
|
||||
"9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n"
|
||||
"2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n"
|
||||
"1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n"
|
||||
"q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n"
|
||||
"tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n"
|
||||
"vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n"
|
||||
"BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n"
|
||||
"5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n"
|
||||
"1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n"
|
||||
"NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n"
|
||||
"Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n"
|
||||
"8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n"
|
||||
"pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n"
|
||||
"MrY=\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
/* DigiCert Global Root G3 */
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQsw\n"
|
||||
"CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu\n"
|
||||
"ZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAe\n"
|
||||
"Fw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUw\n"
|
||||
"EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x\n"
|
||||
"IDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0CAQYF\n"
|
||||
"K4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FG\n"
|
||||
"fp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPO\n"
|
||||
"Z9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd\n"
|
||||
"BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIx\n"
|
||||
"AK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/\n"
|
||||
"oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8\n"
|
||||
"sycX\n"
|
||||
"-----END CERTIFICATE-----\n";
|
||||
+144
-217
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
#include "patchdl_net.h"
|
||||
#include "patchdl_dns.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
@@ -29,10 +30,6 @@
|
||||
#include "patchdl_ca.h"
|
||||
#endif
|
||||
|
||||
#define DNS_SERVER "1.1.1.1"
|
||||
#define DNS_PORT 53
|
||||
#define DNS_TIMEOUT_MS 3000
|
||||
|
||||
/* Manifest sanity caps — reject anything bigger than a real PS5 patch. The
|
||||
largest title we've seen tops out around 70 GB / 18 pieces. */
|
||||
#define PATCHDL_MAX_PIECES 4096
|
||||
@@ -67,9 +64,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 +92,70 @@ 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;
|
||||
}
|
||||
|
||||
/* CURLOPT_RESOLVE entries for host on 443 and 80 from PatchDL's own
|
||||
resolver (patchdl_dns.c), with every address it found. NULL when the host
|
||||
can't be resolved. */
|
||||
static struct curl_slist *
|
||||
resolve_list_for(const char *host) {
|
||||
patchdl_dns_answer_t a;
|
||||
char entry[PATCHDL_DNS_ENTRY_MAX];
|
||||
struct curl_slist *rl, *both;
|
||||
|
||||
if (patchdl_dns_lookup(host, &a) ||
|
||||
patchdl_dns_resolve_entry(&a, host, 443, entry, sizeof entry) ||
|
||||
!(rl = curl_slist_append(NULL, entry)))
|
||||
return NULL;
|
||||
if (patchdl_dns_resolve_entry(&a, host, 80, entry, sizeof entry) ||
|
||||
!(both = curl_slist_append(rl, entry))) {
|
||||
curl_slist_free_all(rl);
|
||||
return NULL;
|
||||
}
|
||||
return both;
|
||||
}
|
||||
|
||||
/* None of the host's addresses took the connection (refused, or silently
|
||||
dropped until the timeout): the next request asks DNS again instead of
|
||||
reusing the cached answer. A timeout on an established transfer says
|
||||
nothing about the addresses. Call before curl_easy_cleanup. */
|
||||
static void
|
||||
note_curl_result(CURL *curl, const char *host, CURLcode res) {
|
||||
curl_off_t connect_us = 0;
|
||||
if (res == CURLE_OPERATION_TIMEDOUT)
|
||||
curl_easy_getinfo(curl, CURLINFO_CONNECT_TIME_T, &connect_us);
|
||||
if (res == CURLE_COULDNT_CONNECT || res == CURLE_COULDNT_RESOLVE_HOST ||
|
||||
(res == CURLE_OPERATION_TIMEDOUT && connect_us == 0))
|
||||
patchdl_dns_forget(host);
|
||||
}
|
||||
|
||||
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, "://");
|
||||
@@ -104,164 +172,6 @@ url_host(const char *url, char *out, size_t out_sz) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- raw UDP DNS resolver ---------------------------------------- */
|
||||
|
||||
/* Diagnostics from the last dns_resolve() call (read by patchdl_net_diag).
|
||||
step: 0=ok 1=socket 2=sendto 3=recv<12 4=no-answer. */
|
||||
static int g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount;
|
||||
|
||||
static int
|
||||
dns_encode_name(const char *host, uint8_t *buf, size_t bufsz) {
|
||||
size_t pos = 0;
|
||||
const char *p = host;
|
||||
|
||||
while (*p) {
|
||||
const char *dot = strchr(p, '.');
|
||||
size_t lablen = dot ? (size_t)(dot - p) : strlen(p);
|
||||
if (lablen > 63 || pos + 1 + lablen + 1 >= bufsz) return -1;
|
||||
buf[pos++] = (uint8_t)lablen;
|
||||
memcpy(buf + pos, p, lablen);
|
||||
pos += lablen;
|
||||
p += lablen;
|
||||
if (*p == '.') p++;
|
||||
}
|
||||
buf[pos++] = 0;
|
||||
return (int)pos;
|
||||
}
|
||||
|
||||
static int
|
||||
dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
|
||||
int fd;
|
||||
struct sockaddr_in srv;
|
||||
uint8_t pkt[512], resp[512];
|
||||
ssize_t n;
|
||||
int name_len;
|
||||
size_t pos;
|
||||
struct timeval tv;
|
||||
|
||||
g_dns_step = 0; g_dns_errno = 0; g_dns_n = 0; g_dns_ancount = 0;
|
||||
|
||||
fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (fd < 0) { g_dns_step = 1; g_dns_errno = errno; return -1; }
|
||||
|
||||
tv.tv_sec = DNS_TIMEOUT_MS / 1000;
|
||||
tv.tv_usec = (DNS_TIMEOUT_MS % 1000) * 1000;
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
|
||||
memset(&srv, 0, sizeof(srv));
|
||||
srv.sin_family = AF_INET;
|
||||
srv.sin_port = htons(DNS_PORT);
|
||||
srv.sin_addr.s_addr = inet_addr(DNS_SERVER);
|
||||
|
||||
memset(pkt, 0, sizeof(pkt));
|
||||
pkt[0] = 0xDE; pkt[1] = 0xAD; /* ID */
|
||||
pkt[2] = 0x01; pkt[3] = 0x00; /* standard query, RD */
|
||||
pkt[4] = 0x00; pkt[5] = 0x01; /* QDCOUNT = 1 */
|
||||
pos = 12;
|
||||
|
||||
name_len = dns_encode_name(host, pkt + pos, sizeof(pkt) - pos - 4);
|
||||
if (name_len < 0) { close(fd); return -1; }
|
||||
pos += (size_t)name_len;
|
||||
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QTYPE = A */
|
||||
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QCLASS = IN */
|
||||
|
||||
if (sendto(fd, pkt, pos, 0,
|
||||
(struct sockaddr *)&srv, sizeof(srv)) < 0) {
|
||||
g_dns_step = 2; g_dns_errno = errno; close(fd); return -1;
|
||||
}
|
||||
|
||||
n = recv(fd, resp, sizeof(resp), 0);
|
||||
g_dns_n = (int)n;
|
||||
if (n < 0) g_dns_errno = errno;
|
||||
close(fd);
|
||||
if (n < 12) { g_dns_step = 3; return -1; }
|
||||
|
||||
uint16_t ancount = (uint16_t)((resp[6] << 8) | resp[7]);
|
||||
g_dns_ancount = ancount;
|
||||
if (!ancount) { g_dns_step = 4; return -1; }
|
||||
|
||||
/* Skip header and question section */
|
||||
pos = 12;
|
||||
while (pos < (size_t)n) {
|
||||
if (!resp[pos]) { pos++; break; }
|
||||
if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; break; }
|
||||
/* Bounds-check the label length BEFORE the increment — a malformed
|
||||
response with a 0xFF label byte near the end would otherwise walk
|
||||
past `n`. */
|
||||
if (pos + 1 + (size_t)resp[pos] >= (size_t)n) { g_dns_step = 5; return -1; }
|
||||
pos += 1 + resp[pos];
|
||||
}
|
||||
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
|
||||
pos += 4; /* QTYPE + QCLASS */
|
||||
|
||||
/* Walk every answer RR; the CDN returns a CNAME chain, so skip
|
||||
non-A records and return the first A (type 1, rdlen 4). */
|
||||
for (int a = 0; a < ancount && pos < (size_t)n; a++) {
|
||||
if ((resp[pos] & 0xC0) == 0xC0) {
|
||||
pos += 2;
|
||||
} else {
|
||||
while (pos < (size_t)n && resp[pos]) {
|
||||
if (pos + 1 + (size_t)resp[pos] >= (size_t)n) break;
|
||||
pos += 1 + resp[pos];
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
if (pos + 10 > (size_t)n) break;
|
||||
|
||||
uint16_t type = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
|
||||
pos += 6; /* CLASS + TTL */
|
||||
uint16_t rdlen = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
|
||||
|
||||
if (type == 1 && rdlen == 4 && pos + 4 <= (size_t)n) {
|
||||
snprintf(ip_out, ip_sz, "%u.%u.%u.%u",
|
||||
resp[pos], resp[pos + 1], resp[pos + 2], resp[pos + 3]);
|
||||
return 0;
|
||||
}
|
||||
pos += rdlen; /* skip this RR's data (e.g. a CNAME target) */
|
||||
}
|
||||
|
||||
g_dns_step = 6; /* no A record among the answers */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Cache resolved IPs by host. The batch fetch hits the same CDN host dozens
|
||||
of times; a burst of identical raw-UDP queries gets dropped/rate-limited,
|
||||
so resolve once (with retries) and reuse. */
|
||||
static pthread_mutex_t dns_cache_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct { char host[256]; char ip[INET_ADDRSTRLEN]; } dns_cache[64];
|
||||
static int dns_cache_n;
|
||||
|
||||
static int
|
||||
dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
|
||||
int rc = -1;
|
||||
|
||||
pthread_mutex_lock(&dns_cache_mtx);
|
||||
for (int i = 0; i < dns_cache_n; i++) {
|
||||
if (!strcmp(dns_cache[i].host, host)) {
|
||||
strncpy(ip_out, dns_cache[i].ip, ip_sz - 1);
|
||||
ip_out[ip_sz - 1] = '\0';
|
||||
pthread_mutex_unlock(&dns_cache_mtx);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* Cold miss: resolve while HOLDING the cache lock (single-flight). N pool
|
||||
workers needing the same CDN host would otherwise each blast Sony's
|
||||
rate-limited resolver; this way one resolves and the rest get the cache.
|
||||
It also serializes dns_resolve so its diagnostic globals can't be raced.
|
||||
(This is the DNS lock, independent of the pool lock.) */
|
||||
for (int attempt = 0; attempt < 4 && rc; attempt++)
|
||||
rc = dns_resolve(host, ip_out, ip_sz);
|
||||
if (!rc && dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
|
||||
strncpy(dns_cache[dns_cache_n].host, host,
|
||||
sizeof(dns_cache[0].host) - 1);
|
||||
strncpy(dns_cache[dns_cache_n].ip, ip_out,
|
||||
sizeof(dns_cache[0].ip) - 1);
|
||||
dns_cache_n++;
|
||||
}
|
||||
pthread_mutex_unlock(&dns_cache_mtx);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* ---------- HTTP GET via curl ------------------------------------------- */
|
||||
|
||||
static size_t
|
||||
@@ -286,22 +196,18 @@ int
|
||||
patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN];
|
||||
char resolve_str[512];
|
||||
char host[256];
|
||||
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;
|
||||
|
||||
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
|
||||
resolve_list = curl_slist_append(NULL, resolve_str);
|
||||
|
||||
/* Also cover port 80 in case a redirect lands there */
|
||||
char resolve_80[512];
|
||||
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
|
||||
resolve_list = curl_slist_append(resolve_list, resolve_80);
|
||||
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 (!(resolve_list = resolve_list_for(host))) { set_last("dns", 0, 0); return -1; }
|
||||
|
||||
{
|
||||
size_t caller_max = out->max;
|
||||
@@ -334,22 +240,32 @@ 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);
|
||||
note_curl_result(curl, host, res);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(resolve_list);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
set_last(res == CURLE_COULDNT_CONNECT ? "connect" :
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -365,16 +281,12 @@ http_external(const char *url, const char *method,
|
||||
#else
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN];
|
||||
char resolve_str[512];
|
||||
char host[256];
|
||||
struct curl_slist *resolve_list = NULL;
|
||||
struct curl_slist *headers = NULL;
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
|
||||
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
|
||||
resolve_list = curl_slist_append(NULL, resolve_str);
|
||||
if (!(resolve_list = resolve_list_for(host))) return -1;
|
||||
|
||||
{
|
||||
size_t caller_max = out->max;
|
||||
@@ -412,6 +324,7 @@ http_external(const char *url, const char *method,
|
||||
}
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
note_curl_result(curl, host, res);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(resolve_list);
|
||||
curl_slist_free_all(headers);
|
||||
@@ -500,7 +413,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
long long range_start) {
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||
char host[256];
|
||||
char range_hdr[48];
|
||||
long http_code = 0;
|
||||
struct curl_slist *rl = NULL;
|
||||
@@ -512,19 +425,14 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
if (bytes_out) *bytes_out = 0;
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
if (!(rl = resolve_list_for(host))) return -1;
|
||||
|
||||
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);
|
||||
rl = curl_slist_append(NULL, rs443);
|
||||
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
|
||||
rl = curl_slist_append(rl, rs80);
|
||||
|
||||
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||
ca_blob.flags = CURL_BLOB_COPY;
|
||||
@@ -571,6 +479,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
|
||||
note_curl_result(curl, host, res);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
|
||||
@@ -925,7 +834,7 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
long http_code = 0;
|
||||
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||
char host[256];
|
||||
struct curl_slist *rl = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
piece_sink_t sink;
|
||||
@@ -936,7 +845,7 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
if (!(rl = resolve_list_for(host))) return -1;
|
||||
|
||||
memset(&sink, 0, sizeof(sink));
|
||||
sink.fd = fd;
|
||||
@@ -944,14 +853,9 @@ 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);
|
||||
rl = curl_slist_append(NULL, rs443);
|
||||
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
|
||||
rl = curl_slist_append(rl, rs80);
|
||||
|
||||
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||
ca_blob.flags = CURL_BLOB_COPY;
|
||||
@@ -989,6 +893,7 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
note_curl_result(curl, host, res);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
|
||||
@@ -1018,10 +923,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 +940,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 +981,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)
|
||||
@@ -1142,26 +1057,31 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||
|
||||
void
|
||||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
|
||||
int hostrc, allowrc, dnsrc;
|
||||
long http_code = 0;
|
||||
CURLcode res = CURLE_OK;
|
||||
int did_curl = 0;
|
||||
char host[256] = {0};
|
||||
int hostrc, allowrc, dnsrc = -1;
|
||||
long http_code = 0;
|
||||
CURLcode res = CURLE_OK;
|
||||
int did_curl = 0;
|
||||
patchdl_dns_answer_t a;
|
||||
|
||||
memset(&a, 0, sizeof a);
|
||||
hostrc = url_host(url, host, sizeof(host));
|
||||
allowrc = (hostrc == 0) ? host_allowed(host) : 0;
|
||||
dnsrc = (hostrc == 0) ? dns_resolve(host, ip, sizeof(ip)) : -1;
|
||||
if (hostrc == 0) {
|
||||
patchdl_dns_forget(host);
|
||||
dnsrc = patchdl_dns_lookup(host, &a);
|
||||
}
|
||||
|
||||
if (hostrc == 0 && allowrc && dnsrc == 0) {
|
||||
patchdl_buf_t buf;
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
CURL *curl = curl_easy_init();
|
||||
if (curl) {
|
||||
char rs[512];
|
||||
char rs[PATCHDL_DNS_ENTRY_MAX];
|
||||
struct curl_slist *rl;
|
||||
struct curl_blob ca_blob;
|
||||
snprintf(rs, sizeof(rs), "%s:443:%s", host, ip);
|
||||
rl = curl_slist_append(NULL, rs);
|
||||
rl = patchdl_dns_resolve_entry(&a, host, 443, rs, sizeof rs)
|
||||
? NULL : curl_slist_append(NULL, rs);
|
||||
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||
ca_blob.flags = CURL_BLOB_COPY;
|
||||
@@ -1186,13 +1106,13 @@ patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
}
|
||||
}
|
||||
|
||||
/* host, source and trail are plain ASCII from our own resolver; quotes
|
||||
can't occur in them. */
|
||||
snprintf(out_json, sz,
|
||||
"{\"host\":\"%s\",\"host_ok\":%d,\"allowed\":%d,"
|
||||
"\"dns_rc\":%d,\"ip\":\"%s\","
|
||||
"\"dns_step\":%d,\"dns_errno\":%d,\"dns_recv\":%d,\"dns_ancount\":%d,"
|
||||
"\"dns_rc\":%d,\"ip\":\"%s\",\"dns_source\":\"%s\",\"dns_trail\":\"%s\","
|
||||
"\"curl_ran\":%d,\"curl_rc\":%d,\"curl_err\":\"%s\",\"http_code\":%ld}",
|
||||
host, hostrc == 0, allowrc, dnsrc, ip,
|
||||
g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount,
|
||||
host, hostrc == 0, allowrc, dnsrc, a.n ? a.ip[0] : "", a.source, a.trail,
|
||||
did_curl, (int)res,
|
||||
did_curl ? curl_easy_strerror(res) : "n/a", http_code);
|
||||
}
|
||||
@@ -1206,6 +1126,13 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
return -1; /* curl not available in this build; set CURL_DIR= to enable */
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+13
-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,13 @@ 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", "connect", "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);
|
||||
|
||||
void patchdl_manifest_free(patchdl_manifest_t *m);
|
||||
|
||||
|
||||
@@ -79,10 +86,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. */
|
||||
|
||||
+94
-38
@@ -10,6 +10,7 @@
|
||||
#include "patchdl_prospero.h"
|
||||
#include "patchdl_net.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -79,25 +80,50 @@ json_success(const char *json) {
|
||||
return !strncmp(p, "true", 4);
|
||||
}
|
||||
|
||||
/* PS5 titles live on Prospero Patches, PS4 titles on its sister site Orbis
|
||||
Patches; both serve the same page and API. */
|
||||
const char *
|
||||
patchdl_patchsite_base(const char *title_id) {
|
||||
return (title_id && !strncmp(title_id, "CUSA", 4))
|
||||
? "https://orbispatches.com" : PROSPERO_BASE;
|
||||
}
|
||||
|
||||
/* The API key on the title page: Prospero puts it in data-key="<hex>",
|
||||
Orbis in data-loadparams="{ 'titleid': ..., 'key': '<hex>' }". */
|
||||
static int
|
||||
extract_key(const char *html, char *out, size_t out_sz) {
|
||||
const char *p;
|
||||
size_t len = 0;
|
||||
|
||||
if (!extract_data_key(html, out, out_sz)) return 0;
|
||||
if (!(p = strstr(html, "'key': '"))) return -1;
|
||||
p += strlen("'key': '");
|
||||
while (isxdigit((unsigned char)p[len]) && len < out_sz - 1) len++;
|
||||
if (!len || p[len] != '\'') return -1;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out) {
|
||||
patchdl_patchsite_versions(const char *title_id, patchdl_patch_version_t *out,
|
||||
int max, int *count) {
|
||||
patchdl_buf_t buf;
|
||||
char url[256], key[128], body[256];
|
||||
char best_ver[16] = {0}, best_size[16] = {0};
|
||||
uint32_t best_fw = 0;
|
||||
char url[256], key[128], body[256];
|
||||
const char *base = patchdl_patchsite_base(title_id);
|
||||
const char *cur, *next;
|
||||
int n = 0;
|
||||
|
||||
if (!title_id || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
*count = 0;
|
||||
if (!title_id || !out || max <= 0) return -1;
|
||||
|
||||
/* Step 1: fetch the title page to get the data-key. */
|
||||
/* Step 1: fetch the title page for the API key. */
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = BUF_MAX;
|
||||
snprintf(url, sizeof(url), "%s/%.16s", PROSPERO_BASE, title_id);
|
||||
snprintf(url, sizeof(url), "%s/%.16s", base, title_id);
|
||||
if (patchdl_http_get_external(url, &buf)) return -1;
|
||||
if (!buf.data) return -1;
|
||||
|
||||
if (extract_data_key(buf.data, key, sizeof(key))) {
|
||||
if (extract_key(buf.data, key, sizeof(key))) {
|
||||
free(buf.data);
|
||||
return -1;
|
||||
}
|
||||
@@ -108,50 +134,80 @@ patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
the website and every known third-party client calls it. */
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"titleid\":\"%.16s\",\"key\":\"%s\"}", title_id, key);
|
||||
|
||||
snprintf(url, sizeof(url), "%s/api/internal/loadpatches", base);
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = BUF_MAX;
|
||||
if (patchdl_http_post_external(
|
||||
PROSPERO_API "/loadpatches",
|
||||
if (patchdl_http_post_external(url,
|
||||
"application/x-www-form-urlencoded; charset=UTF-8",
|
||||
body, &buf)) return -1;
|
||||
if (!buf.data) return -1;
|
||||
|
||||
if (!json_success(buf.data)) {
|
||||
free(buf.data);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Walk the patches array. Each entry looks like:
|
||||
{"content_ver":"01.003.000","required_firmware":"07.01.01.00",...}
|
||||
Find the newest version whose firmware requirement is met. */
|
||||
const char *cursor = buf.data;
|
||||
while ((cursor = strstr(cursor, "\"content_ver\""))) {
|
||||
char ver[16] = {0}, fw[32] = {0}, size[16] = {0};
|
||||
/* Each entry starts with "is_latest" and carries content_ver (Prospero)
|
||||
or version (Orbis), filesize and required_firmware. Bound each lookup
|
||||
to its own entry. */
|
||||
for (cur = strstr(buf.data, "\"is_latest\""); cur && n < max; cur = next) {
|
||||
char entry[2048], fw[32] = {0};
|
||||
size_t len;
|
||||
patchdl_patch_version_t *v = &out[n];
|
||||
|
||||
json_str_val(cursor, "content_ver", ver, sizeof(ver));
|
||||
json_str_val(cursor, "filesize", size, sizeof(size));
|
||||
json_str_val(cursor, "required_firmware", fw, sizeof(fw));
|
||||
next = strstr(cur + 1, "\"is_latest\"");
|
||||
len = next ? (size_t)(next - cur) : strlen(cur);
|
||||
if (len >= sizeof entry) len = sizeof entry - 1;
|
||||
memcpy(entry, cur, len);
|
||||
entry[len] = '\0';
|
||||
|
||||
if (ver[0] && fw[0]) {
|
||||
uint32_t pkg_fw = fw_str_to_bin(fw);
|
||||
if (pkg_fw <= fw_bin) {
|
||||
if (!best_ver[0] || strcmp(ver, best_ver) > 0) {
|
||||
strncpy(best_ver, ver, sizeof(best_ver) - 1);
|
||||
memcpy(best_size, size, sizeof(best_size));
|
||||
best_fw = pkg_fw;
|
||||
}
|
||||
}
|
||||
memset(v, 0, sizeof *v);
|
||||
if (!json_str_val(entry, "content_ver", v->version, sizeof v->version))
|
||||
json_str_val(entry, "version", v->version, sizeof v->version);
|
||||
json_str_val(entry, "filesize", v->size, sizeof v->size);
|
||||
json_str_val(entry, "required_firmware", fw, sizeof fw);
|
||||
if (!v->version[0]) continue;
|
||||
if (strchr(fw, '.') && strchr(strchr(fw, '.') + 1, '.')) {
|
||||
/* PS5 style "07.01.01.00": packed like Sony's system_ver */
|
||||
v->fw_bin = fw_str_to_bin(fw);
|
||||
fw_bin_to_display(v->fw_bin, v->required_fw, sizeof v->required_fw);
|
||||
} else {
|
||||
/* PS4 style "3.00": a PS4 firmware, never a limit on the PS5 */
|
||||
snprintf(v->required_fw, sizeof v->required_fw, "%s", fw);
|
||||
v->fw_bin = 0;
|
||||
}
|
||||
cursor++;
|
||||
n++;
|
||||
}
|
||||
free(buf.data);
|
||||
*count = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (best_ver[0]) {
|
||||
strncpy(out->version, best_ver, sizeof(out->version) - 1);
|
||||
fw_bin_to_display(best_fw, out->required_fw, sizeof(out->required_fw));
|
||||
memcpy(out->size, best_size, sizeof(out->size));
|
||||
int
|
||||
patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out) {
|
||||
patchdl_patch_version_t *list;
|
||||
int n = 0, best = -1;
|
||||
|
||||
if (!title_id || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
/* The whole history: the newest versions may all need newer firmware. */
|
||||
if (!(list = calloc(PATCHSITE_MAX_VERSIONS, sizeof *list))) return -1;
|
||||
if (patchdl_patchsite_versions(title_id, list, PATCHSITE_MAX_VERSIONS, &n)) {
|
||||
free(list);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* the newest version whose firmware requirement is met */
|
||||
for (int i = 0; i < n; i++)
|
||||
if (list[i].fw_bin <= fw_bin &&
|
||||
(best < 0 || strcmp(list[i].version, list[best].version) > 0))
|
||||
best = i;
|
||||
if (best >= 0) {
|
||||
snprintf(out->version, sizeof out->version, "%s", list[best].version);
|
||||
snprintf(out->required_fw, sizeof out->required_fw, "%s", list[best].required_fw);
|
||||
snprintf(out->size, sizeof out->size, "%s", list[best].size);
|
||||
out->found = 1;
|
||||
}
|
||||
free(list);
|
||||
return 0;
|
||||
}
|
||||
@@ -25,3 +25,22 @@ typedef struct {
|
||||
data, nothing breaks. */
|
||||
int patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out);
|
||||
|
||||
/* One patch version as Prospero Patches (PS5) or Orbis Patches (PS4) lists it. */
|
||||
typedef struct {
|
||||
char version[16]; /* "01.003.000" (PS5) or "01.07" (PS4) */
|
||||
char required_fw[16]; /* display form; for PS4 a PS4 firmware */
|
||||
char size[16]; /* the site's label, e.g. "8.7GB" */
|
||||
uint32_t fw_bin; /* PS5 firmware it needs, 0 for PS4 */
|
||||
} patchdl_patch_version_t;
|
||||
|
||||
/* More versions than any game has had (the response is capped at 512 KB). */
|
||||
#define PATCHSITE_MAX_VERSIONS 512
|
||||
|
||||
/* Base URL of the site that lists this title's patches. */
|
||||
const char *patchdl_patchsite_base(const char *title_id);
|
||||
|
||||
/* Every version the site lists for title_id, newest first as the site
|
||||
orders them. 0 on success with *count entries (up to max). */
|
||||
int patchdl_patchsite_versions(const char *title_id, patchdl_patch_version_t *out,
|
||||
int max, int *count);
|
||||
@@ -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.3.0"
|
||||
+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
|
||||
|
||||
+289
-53
@@ -13,6 +13,7 @@
|
||||
#include "patchdl_scan.h"
|
||||
#include "patchdl_install.h"
|
||||
#include "patchdl_net.h"
|
||||
#include "patchdl_dns.h"
|
||||
#include "patchdl_resolve.h"
|
||||
#include "patchdl_tile.h"
|
||||
#include "patchdl_verxml.h"
|
||||
@@ -145,8 +146,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 +233,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 +270,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 +1020,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 +1034,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 +1094,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 +1131,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 +1225,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 +1291,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 +1908,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 +1929,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 +1956,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 +2037,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 +2056,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 +2247,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 +2566,167 @@ 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/versions?title=<id>: every patch version Prospero Patches (PS5)
|
||||
or Orbis Patches (PS4) lists for the game, each marked whether it can be
|
||||
picked here: newer than the installed version and, on PS5, runnable on
|
||||
this firmware. Older versions are not offered (no downgrades). */
|
||||
static enum MHD_Result
|
||||
queue_versions(struct MHD_Connection *conn, const char *tid) {
|
||||
patchdl_patch_version_t *list;
|
||||
char inst[16] = "", tid9[16];
|
||||
int n = 0, ps4;
|
||||
jbuf_t j = {0};
|
||||
|
||||
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);
|
||||
ps4 = !strncmp(tid9, "CUSA", 4);
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (!strcmp(g_titles[i].title_id, tid)) {
|
||||
snprintf(inst, sizeof inst, "%s", g_titles[i].installed_version);
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
if (!(list = calloc(PATCHSITE_MAX_VERSIONS, sizeof *list)))
|
||||
return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
|
||||
if (patchdl_patchsite_versions(tid9, list, PATCHSITE_MAX_VERSIONS, &n)) {
|
||||
free(list);
|
||||
return queue_json(conn, MHD_HTTP_BAD_GATEWAY,
|
||||
"{\"ok\":false,\"reason\":\"versions_unavailable\"}");
|
||||
}
|
||||
jbuf_append(&j, "{\"ok\":true,\"site\":");
|
||||
jbuf_append_str(&j, ps4 ? "Orbis Patches" : "Prospero Patches");
|
||||
jbuf_appendf(&j, ",\"page\":\"%s/%s\",\"ps4\":%s,\"installed\":",
|
||||
patchdl_patchsite_base(tid9), tid9, ps4 ? "true" : "false");
|
||||
jbuf_append_str(&j, inst);
|
||||
jbuf_append(&j, ",\"versions\":[");
|
||||
for (int i = 0; i < n; i++) {
|
||||
const char *state =
|
||||
inst[0] && !strcmp(list[i].version, inst) ? "installed"
|
||||
: inst[0] && strcmp(list[i].version, inst) < 0 ? "older"
|
||||
: !ps4 && list[i].fw_bin > g_fw.bin ? "needs_fw"
|
||||
: "ok";
|
||||
jbuf_append(&j, i ? ",{\"version\":" : "{\"version\":");
|
||||
jbuf_append_str(&j, list[i].version);
|
||||
jbuf_append(&j, ",\"fw\":"); jbuf_append_str(&j, list[i].required_fw);
|
||||
jbuf_append(&j, ",\"size\":"); jbuf_append_str(&j, list[i].size);
|
||||
jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, state);
|
||||
jbuf_append(&j, "}");
|
||||
}
|
||||
jbuf_append(&j, "]}");
|
||||
free(list);
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, j.buf);
|
||||
}
|
||||
|
||||
/* A fresh DNS answer for host as "ip", "ips", "dns" (where it came from)
|
||||
and "dns_trail" (what failed before), so a report tells a blocked DNS
|
||||
apart from a blocked connection. */
|
||||
static void
|
||||
append_dns(jbuf_t *j, const char *host) {
|
||||
patchdl_dns_answer_t a;
|
||||
char ips[PATCHDL_DNS_MAX_IPS * 16 + 8] = "";
|
||||
|
||||
memset(&a, 0, sizeof a);
|
||||
if (host[0]) {
|
||||
patchdl_dns_forget(host);
|
||||
patchdl_dns_lookup(host, &a);
|
||||
}
|
||||
for (int i = 0; i < a.n; i++) {
|
||||
size_t len = strlen(ips);
|
||||
snprintf(ips + len, sizeof ips - len, "%s%s", i ? "," : "", a.ip[i]);
|
||||
}
|
||||
jbuf_append(j, ",\"ip\":"); jbuf_append_str(j, a.n ? a.ip[0] : "");
|
||||
jbuf_append(j, ",\"ips\":"); jbuf_append_str(j, ips);
|
||||
jbuf_append(j, ",\"dns\":"); jbuf_append_str(j, a.source);
|
||||
jbuf_append(j, ",\"dns_trail\":"); jbuf_append_str(j, a.trail);
|
||||
}
|
||||
|
||||
/* 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], 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);
|
||||
append_dns(&j, host);
|
||||
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);
|
||||
append_dns(&j, host);
|
||||
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 +2745,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,
|
||||
@@ -2659,6 +2856,7 @@ manifest_from_link(const char *tid, const char *in, char *canon, size_t sz) {
|
||||
char tag[16];
|
||||
const char *p, *rest, *base, *stem_end = NULL;
|
||||
size_t n = strlen(in);
|
||||
int ps4_piece = 0;
|
||||
|
||||
/* The URL ends up unescaped in JSON sidecars and responses, and dot
|
||||
segments could walk the path into another title's folder. */
|
||||
@@ -2675,6 +2873,16 @@ manifest_from_link(const char *tid, const char *in, char *canon, size_t sz) {
|
||||
size_t el = strlen(ends[i]);
|
||||
if (n > el && !strcmp(in + n - el, ends[i])) { stem_end = in + n - el; break; }
|
||||
}
|
||||
/* PS4: Orbis Patches lists the pieces, which sit in the same gs2 folder
|
||||
as the manifest "<name>.json": "<name>_0.pkg", "_1.pkg", ... for a
|
||||
split package, or just "<name>.pkg" when it is a single piece. */
|
||||
if (!stem_end && !strncmp(rest, "gs2.ww.prod.dl.playstation.net/", 31) &&
|
||||
n > 5 && !strcmp(in + n - 4, ".pkg")) {
|
||||
const char *d = in + n - 4;
|
||||
while (d > in && isdigit((unsigned char)d[-1])) d--;
|
||||
stem_end = (d < in + n - 4 && d > in && d[-1] == '_') ? d - 1 : in + n - 4;
|
||||
ps4_piece = 1;
|
||||
}
|
||||
if (!stem_end) return "not_an_sc_link";
|
||||
base = strrchr(in, '/') + 1; /* the scheme guarantees a '/' */
|
||||
if (stem_end <= base) return "not_an_sc_link";
|
||||
@@ -2686,10 +2894,12 @@ manifest_from_link(const char *tid, const char *in, char *canon, size_t sz) {
|
||||
tag[0] = '-';
|
||||
if (!strstr(base, tag)) return "url_title_mismatch";
|
||||
|
||||
/* Manifests are fetched over https with the pinned Sony root. */
|
||||
/* PS5 manifests are fetched over https with the pinned Sony root; PS4
|
||||
manifests only exist over plain http on gs2 (see PLAIN_HTTP_HOST). */
|
||||
if (sizeof("https://") - 1 + (size_t)(stem_end - rest) + sizeof(".json") > sz)
|
||||
return "url_too_long";
|
||||
snprintf(canon, sz, "https://%.*s.json", (int)(stem_end - rest), rest);
|
||||
snprintf(canon, sz, "%s%.*s.json", ps4_piece ? "http://" : "https://",
|
||||
(int)(stem_end - rest), rest);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -2698,23 +2908,30 @@ manifest_from_link(const char *tid, const char *in, char *canon, size_t sz) {
|
||||
for `ver`, i.e. the user most likely copied a link of another version.
|
||||
Versions of (nearly) equal size can't be told apart this way. */
|
||||
static int
|
||||
size_mismatch(const char *tid, const char *ver, long long found,
|
||||
char *label, size_t lsz) {
|
||||
size_mismatch(const char *tid, const char *ver, const char *given_size,
|
||||
long long found, char *label, size_t lsz) {
|
||||
char lbl[16] = {0}, pver[16] = {0}, unit[8] = {0};
|
||||
double v, unit_bytes, expect, diff, tol;
|
||||
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (!strcmp(g_titles[i].title_id, tid)) {
|
||||
memcpy(lbl, g_titles[i].prospero_size, sizeof lbl - 1);
|
||||
memcpy(pver, g_titles[i].prospero_version, sizeof pver - 1);
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
if (given_size && given_size[0]) {
|
||||
/* the size the version list showed for the version picked */
|
||||
snprintf(lbl, sizeof lbl, "%s", given_size);
|
||||
snprintf(pver, sizeof pver, "%s", ver);
|
||||
} else {
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (!strcmp(g_titles[i].title_id, tid)) {
|
||||
memcpy(lbl, g_titles[i].prospero_size, sizeof lbl - 1);
|
||||
memcpy(pver, g_titles[i].prospero_version, sizeof pver - 1);
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
for (char *c = lbl; *c; c++) /* external text, echoed into JSON */
|
||||
if (!isalnum((unsigned char)*c) && *c != '.' && *c != ' ') *c = '?';
|
||||
snprintf(label, lsz, "%s", lbl);
|
||||
|
||||
/* "8.7GB" (Orbis) and "71.7 GB" (Prospero) */
|
||||
if (strcmp(pver, ver) || sscanf(lbl, "%lf %7s", &v, unit) != 2) return 0;
|
||||
if (!strcmp(unit, "TB")) unit_bytes = 1099511627776.0;
|
||||
else if (!strcmp(unit, "GB")) unit_bytes = 1073741824.0;
|
||||
@@ -2734,7 +2951,7 @@ size_mismatch(const char *tid, const char *ver, long long found,
|
||||
static enum MHD_Result
|
||||
handle_download_url(struct MHD_Connection *conn, const char *body) {
|
||||
char tid[32], in[768], ver[16], canon[512], state[320], resp[192];
|
||||
char expect[16];
|
||||
char expect[16], given[16] = "";
|
||||
patchdl_manifest_t mf;
|
||||
char purl[512], durl[512], ptid[16], pstid[16];
|
||||
char cid[64], tname[128], tv[16];
|
||||
@@ -2806,7 +3023,8 @@ handle_download_url(struct MHD_Connection *conn, const char *body) {
|
||||
return queue_json_owned(conn, 507, strdup(resp));
|
||||
}
|
||||
}
|
||||
if (size_mismatch(tid, ver, found, expect, sizeof expect) &&
|
||||
json_get_str(body, "size", given, sizeof given);
|
||||
if (size_mismatch(tid, ver, given, found, expect, sizeof expect) &&
|
||||
!json_get_bool(body, "force", 0)) {
|
||||
snprintf(resp, sizeof resp,
|
||||
"{\"ok\":false,\"reason\":\"size_mismatch\","
|
||||
@@ -3325,8 +3543,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 +3597,15 @@ 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/versions")) {
|
||||
const char *want = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "title");
|
||||
return queue_versions(conn, want ? want : "");
|
||||
}
|
||||
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 +3679,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 +3787,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 +3897,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++)
|
||||
|
||||
+586
-216
File diff suppressed because it is too large.
Load diff
+3344
File diff suppressed because it is too large.
Load diff
+98
-62
@@ -31,33 +31,58 @@
|
||||
<symbol id="icon-grid" viewBox="0 0 24 24">
|
||||
<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" />
|
||||
</symbol>
|
||||
<symbol id="icon-close" viewBox="0 0 24 24">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</symbol>
|
||||
<symbol id="icon-arrow" viewBox="0 0 24 24">
|
||||
<path d="M5 12h14" /><path d="M13 6l6 6-6 6" />
|
||||
</symbol>
|
||||
<symbol id="icon-layers" viewBox="0 0 24 24">
|
||||
<path d="M12 3l9 5-9 5-9-5 9-5z" /><path d="M3 13l9 5 9-5" />
|
||||
</symbol>
|
||||
<symbol id="icon-alert" viewBox="0 0 24 24">
|
||||
<path d="M12 3l10 18H2L12 3z" /><path d="M12 10v5" /><path d="M12 18h.01" />
|
||||
</symbol>
|
||||
<symbol id="icon-check" viewBox="0 0 24 24">
|
||||
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||
</symbol>
|
||||
<symbol id="icon-lock" viewBox="0 0 24 24">
|
||||
<path d="M6 11h12v10H6z" /><path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
|
||||
</symbol>
|
||||
<symbol id="icon-external" viewBox="0 0 24 24">
|
||||
<path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M18 14v6H4V6h6" />
|
||||
</symbol>
|
||||
<symbol id="icon-chevron" viewBox="0 0 24 24">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="rail" aria-label="Navigation">
|
||||
<aside class="rail" aria-label="Navigation" data-i18n-aria-label="nav.label">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">PD</div>
|
||||
<div>
|
||||
<strong>PatchDL</strong>
|
||||
<span>by Knutwurst · <span id="brandVersion">--</span></span>
|
||||
<span><span class="brand-by" data-i18n="brand.by">by Knutwurst</span> · <span id="brandVersion">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list" aria-label="Sections">
|
||||
<button class="nav-link is-active" data-view="games" aria-label="Games" aria-current="page">
|
||||
<svg><use href="#icon-grid"></use></svg><span>Games</span>
|
||||
<nav class="nav-list" aria-label="Sections" data-i18n-aria-label="nav.sections">
|
||||
<button class="nav-link is-active" data-view="games" aria-label="Games" data-i18n-aria-label="nav.games" aria-current="page">
|
||||
<svg><use href="#icon-grid"></use></svg><span data-i18n="nav.games">Games</span>
|
||||
</button>
|
||||
<button class="nav-link" data-view="settings" aria-label="Settings">
|
||||
<svg><use href="#icon-settings"></use></svg><span>Settings</span>
|
||||
<button class="nav-link" data-view="settings" aria-label="Settings" data-i18n-aria-label="nav.settings">
|
||||
<svg><use href="#icon-settings"></use></svg><span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<button class="nav-link" data-view="logs" aria-label="Logs">
|
||||
<svg><use href="#icon-log"></use></svg><span>Logs</span>
|
||||
<button class="nav-link" data-view="logs" aria-label="Logs" data-i18n-aria-label="nav.logs">
|
||||
<svg><use href="#icon-log"></use></svg><span data-i18n="nav.logs">Logs</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="rail-foot">
|
||||
<span id="railFw">FW --</span>
|
||||
<span id="railSpace">-- free</span>
|
||||
<span id="railSpace" data-i18n="rail.freeUnknown">-- free</span>
|
||||
<button type="button" class="link-button rail-donate" data-donate data-i18n="donate.button">Buy me a coffee</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -66,15 +91,15 @@
|
||||
<section class="view is-active" data-view="games">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">PS5 update manager</p>
|
||||
<h1>Games</h1>
|
||||
<p class="eyebrow" data-i18n="games.eyebrow">PS5 & PS4 game updates</p>
|
||||
<h1 data-i18n="games.title">Games</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="primary-button" id="updateAllBtn" title="Download (and optionally install) every game that has an available, allowed update">
|
||||
<button class="primary-button" id="updateAllBtn" title="Download (and optionally install) every game that has an available, allowed update" data-i18n-title="games.updateAllTitle">
|
||||
<svg><use href="#icon-download"></use></svg>
|
||||
Update all
|
||||
<span data-i18n="games.updateAll">Update all</span>
|
||||
</button>
|
||||
<button class="icon-button" id="refreshBtn" title="Refresh status and games" aria-label="Refresh">
|
||||
<button class="icon-button" id="refreshBtn" title="Refresh status and games" data-i18n-title="games.refreshTitle" aria-label="Refresh" data-i18n-aria-label="games.refresh">
|
||||
<svg><use href="#icon-refresh"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,7 +110,7 @@
|
||||
<span class="global-dl-pulse" aria-hidden="true"></span>
|
||||
<div class="global-dl-text">
|
||||
<div class="global-dl-line1">
|
||||
<span class="global-dl-eyebrow" id="globalDlState">Downloading</span>
|
||||
<span class="global-dl-eyebrow" id="globalDlState" data-i18n="dl.downloading">Downloading</span>
|
||||
<strong class="global-dl-name" id="globalDlName">—</strong>
|
||||
<span class="global-dl-position" id="globalDlPosition" hidden></span>
|
||||
</div>
|
||||
@@ -103,39 +128,39 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="status-strip" aria-label="System status">
|
||||
<section class="status-strip" aria-label="System status" data-i18n-aria-label="status.label">
|
||||
<article class="metric">
|
||||
<span>Firmware</span>
|
||||
<span data-i18n="metric.firmware">Firmware</span>
|
||||
<strong id="firmwareValue">--</strong>
|
||||
<em id="firmwareBuild">System version</em>
|
||||
<em id="firmwareBuild" data-i18n="metric.firmwareBuild">System version</em>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>DNS Guard</span>
|
||||
<span data-i18n="metric.dns">DNS Guard</span>
|
||||
<strong id="dnsValue">--</strong>
|
||||
<em>System DNS left untouched</em>
|
||||
<em data-i18n="metric.dnsNote">System DNS left untouched</em>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>CDN Access</span>
|
||||
<span data-i18n="metric.cdn">CDN Access</span>
|
||||
<strong id="resolverValue">--</strong>
|
||||
<em>PatchDL resolver only</em>
|
||||
<em data-i18n="metric.cdnNote">PatchDL resolver only</em>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>Storage</span>
|
||||
<span data-i18n="metric.storage">Storage</span>
|
||||
<strong id="spaceValue">--</strong>
|
||||
<em id="downloadDirValue">Download target</em>
|
||||
<em id="downloadDirValue" data-i18n="metric.downloadTarget">Download target</em>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<svg><use href="#icon-search"></use></svg>
|
||||
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
|
||||
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" data-i18n-placeholder="search.placeholder" />
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Filter">
|
||||
<button class="is-selected" data-filter="updates" aria-pressed="true">Updates <span class="chip-count"></span></button>
|
||||
<button data-filter="uptodate" aria-pressed="false">Up to date <span class="chip-count"></span></button>
|
||||
<button data-filter="blocked" aria-pressed="false">Not supported <span class="chip-count"></span></button>
|
||||
<button data-filter="all" aria-pressed="false">All <span class="chip-count"></span></button>
|
||||
<div class="segmented" role="group" aria-label="Filter" data-i18n-aria-label="filter.label">
|
||||
<button class="is-selected" data-filter="updates" aria-pressed="true"><span data-i18n="filter.updates">Updates</span> <span class="chip-count"></span></button>
|
||||
<button data-filter="uptodate" aria-pressed="false"><span data-i18n="filter.uptodate">Up to date</span> <span class="chip-count"></span></button>
|
||||
<button data-filter="blocked" aria-pressed="false"><span data-i18n="filter.blocked">Not supported</span> <span class="chip-count"></span></button>
|
||||
<button data-filter="all" aria-pressed="false"><span data-i18n="filter.all">All</span> <span class="chip-count"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,13 +171,13 @@
|
||||
<section class="view" data-view="settings">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Policy</p>
|
||||
<h1>Settings</h1>
|
||||
<p class="eyebrow" data-i18n="settings.eyebrow">Policy</p>
|
||||
<h1 data-i18n="settings.title">Settings</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="primary-button" id="saveBtn">
|
||||
<svg><use href="#icon-save"></use></svg>
|
||||
Save
|
||||
<span data-i18n="settings.save">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -160,85 +185,95 @@
|
||||
<div class="panel">
|
||||
<div class="settings-grid">
|
||||
<label class="field">
|
||||
<span>Default Policy</span>
|
||||
<span data-i18n="settings.defaultPolicy">Default Policy</span>
|
||||
<select id="defaultPolicy">
|
||||
<option value="deny">Deny by default</option>
|
||||
<option value="allow">Allow by default</option>
|
||||
<option value="deny" data-i18n="settings.policyDeny">Deny by default</option>
|
||||
<option value="allow" data-i18n="settings.policyAllow">Allow by default</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Download Folder</span>
|
||||
<span data-i18n="settings.downloadFolder">Download Folder</span>
|
||||
<input id="downloadDir" type="text" spellcheck="false" readonly
|
||||
title="Patches always download to the console's internal storage" />
|
||||
title="Patches always download to the console's internal storage"
|
||||
data-i18n-title="settings.downloadFolderTitle" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span data-i18n="settings.language">Language</span>
|
||||
<select id="language"></select>
|
||||
</label>
|
||||
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Install after download</strong>
|
||||
<em>Global default, overridable per game</em>
|
||||
<strong data-i18n="settings.installAfter">Install after download</strong>
|
||||
<em data-i18n="settings.installAfterHint">Global default, overridable per game</em>
|
||||
</span>
|
||||
<span class="toggle">
|
||||
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" />
|
||||
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" data-i18n-aria-label="settings.installAfter" />
|
||||
<span class="track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Delete PKG after install</strong>
|
||||
<em>Only after a successful install</em>
|
||||
<strong data-i18n="settings.deleteAfter">Delete PKG after install</strong>
|
||||
<em data-i18n="settings.deleteAfterHint">Only after a successful install</em>
|
||||
</span>
|
||||
<span class="toggle">
|
||||
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" />
|
||||
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" data-i18n-aria-label="settings.deleteAfterAria" />
|
||||
<span class="track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Verify downloaded pieces (SHA-256)</strong>
|
||||
<em>Checks each manifest piece; off by default, verify on-device first</em>
|
||||
<strong data-i18n="settings.verify">Verify downloaded pieces (SHA-256)</strong>
|
||||
<em data-i18n="settings.verifyHint">Checks each manifest piece; off by default, verify on-device first</em>
|
||||
</span>
|
||||
<span class="toggle">
|
||||
<input id="verifyDownloads" type="checkbox" aria-label="Verify downloaded pieces" />
|
||||
<input id="verifyDownloads" type="checkbox" aria-label="Verify downloaded pieces" data-i18n-aria-label="settings.verifyAria" />
|
||||
<span class="track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Home-screen shortcut</strong>
|
||||
<em id="shortcutHint">Add a PS5 home-screen icon that opens this UI in the browser</em>
|
||||
<strong data-i18n="settings.shortcut">Home-screen shortcut</strong>
|
||||
<em id="shortcutHint" data-i18n="settings.shortcutHint">Add a PS5 home-screen icon that opens this UI in the browser</em>
|
||||
</span>
|
||||
<span class="toggle">
|
||||
<input id="homeShortcut" type="checkbox" aria-label="Install home-screen shortcut" />
|
||||
<input id="homeShortcut" type="checkbox" aria-label="Install home-screen shortcut" data-i18n-aria-label="settings.shortcutAria" />
|
||||
<span class="track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="switch-row">
|
||||
<span>
|
||||
<strong>Parallel download connections</strong>
|
||||
<em>1–16 · applies live, no payload restart</em>
|
||||
<strong data-i18n="settings.connections">Parallel download connections</strong>
|
||||
<em data-i18n="settings.connectionsHint">1–16 · applies live, no payload restart</em>
|
||||
</span>
|
||||
<div class="stepper" role="group" aria-label="Parallel download connections">
|
||||
<button type="button" class="stepper-btn" id="connMinus" aria-label="Fewer connections">−</button>
|
||||
<div class="stepper" role="group" aria-label="Parallel download connections" data-i18n-aria-label="settings.connections">
|
||||
<button type="button" class="stepper-btn" id="connMinus" aria-label="Fewer connections" data-i18n-aria-label="settings.connFewer">−</button>
|
||||
<output class="stepper-value" id="connValue" aria-live="polite">4</output>
|
||||
<button type="button" class="stepper-btn" id="connPlus" aria-label="More connections">+</button>
|
||||
<button type="button" class="stepper-btn" id="connPlus" aria-label="More connections" data-i18n-aria-label="settings.connMore">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="allowlist">
|
||||
<span>Internal CDN Allowlist</span>
|
||||
<span data-i18n="settings.allowlist">Internal CDN Allowlist</span>
|
||||
<div id="allowlistHosts"></div>
|
||||
</div>
|
||||
|
||||
<div class="source-policy">
|
||||
<span>Install Protection</span>
|
||||
<span data-i18n="settings.installProtection">Install Protection</span>
|
||||
<div id="sourcePolicyList"></div>
|
||||
</div>
|
||||
|
||||
<div class="storage-list">
|
||||
<span>Downloads on console <em id="storageTotal"></em></span>
|
||||
<span><span data-i18n="settings.storage">Downloads on console</span> <em id="storageTotal"></em></span>
|
||||
<div id="storageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="donate-row">
|
||||
<span data-i18n="settings.donateText">Like PatchDL? You can buy me a coffee.</span>
|
||||
<button type="button" class="row-button is-ghost" data-donate data-i18n="donate.button">Buy me a coffee</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -246,11 +281,11 @@
|
||||
<section class="view" data-view="logs">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Runtime</p>
|
||||
<h1>Logs</h1>
|
||||
<p class="eyebrow" data-i18n="logs.eyebrow">Runtime</p>
|
||||
<h1 data-i18n="logs.title">Logs</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-button" id="clearLogBtn" title="Clear log" aria-label="Clear log">
|
||||
<button class="icon-button" id="clearLogBtn" title="Clear log" data-i18n-title="logs.clear" aria-label="Clear log" data-i18n-aria-label="logs.clear">
|
||||
<svg><use href="#icon-log"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -261,6 +296,7 @@
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+307
-66
@@ -1,11 +1,13 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0d10;
|
||||
--surface: #14181d;
|
||||
--surface-2: #1b2027;
|
||||
--ink: #edf2f7;
|
||||
--muted: #9aa6b2;
|
||||
--border: #2a313a;
|
||||
--bg: #0a0c0f;
|
||||
--surface: #13171c;
|
||||
--surface-2: #1a1f26;
|
||||
--surface-3: #212731;
|
||||
--ink: #eef2f6;
|
||||
--muted: #a4afba;
|
||||
--border: #262d36;
|
||||
--border-strong: #36404b;
|
||||
--green: #38c172;
|
||||
--green-dark: #63d994;
|
||||
--green-soft: rgba(56, 193, 114, 0.16);
|
||||
@@ -18,7 +20,12 @@
|
||||
--blue-dark: #8cc2ff;
|
||||
--blue-soft: rgba(88, 166, 255, 0.16);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.38);
|
||||
--radius: 10px;
|
||||
--radius: 12px;
|
||||
--focus: 0 0 0 3px rgba(88, 166, 255, 0.55);
|
||||
--fs-xs: 13px;
|
||||
--fs-sm: 14px;
|
||||
--fs-md: 16px;
|
||||
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--rail-w: 240px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
@@ -30,9 +37,23 @@ body {
|
||||
min-height: 100dvh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* One focus ring for everything the controller, keyboard or a phone can
|
||||
reach. */
|
||||
:where(button, a, summary, select, input, textarea, [tabindex]):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
||||
}
|
||||
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
@@ -82,7 +103,8 @@ svg {
|
||||
}
|
||||
|
||||
.brand strong { display: block; font-size: 16px; }
|
||||
.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
|
||||
.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: var(--fs-xs); }
|
||||
.brand .brand-by { display: inline; margin-top: 0; }
|
||||
|
||||
.nav-list { display: grid; gap: 6px; }
|
||||
|
||||
@@ -113,9 +135,45 @@ svg {
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
.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: var(--fs-sm); }
|
||||
.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 ---------------- */
|
||||
|
||||
@@ -136,8 +194,9 @@ svg {
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--green-dark);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -152,15 +211,20 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-sm);
|
||||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 90ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.icon-button:hover { border-color: var(--border-strong); background: var(--surface-2); }
|
||||
.icon-button:active, .primary-button:active, .row-button:not(:disabled):active { transform: scale(0.97); }
|
||||
|
||||
.icon-button { width: 40px; padding: 0; flex: none; }
|
||||
.icon-button { width: 44px; padding: 0; flex: none; }
|
||||
.primary-button { padding: 0 16px; border-color: var(--green-dark); background: var(--green); color: #fff; }
|
||||
.primary-button:hover { background: var(--green-dark); }
|
||||
|
||||
@@ -226,7 +290,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
}
|
||||
.global-dl-eyebrow {
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
font-size: 11px; font-weight: 600; color: var(--green-dark);
|
||||
font-size: 12px; font-weight: 600; color: var(--green-dark);
|
||||
}
|
||||
.global-dl-name {
|
||||
font-size: 16px; font-weight: 600; color: #fff;
|
||||
@@ -234,7 +298,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
min-width: 0; flex: 1;
|
||||
}
|
||||
.global-dl-position {
|
||||
font-size: 12px; color: var(--muted);
|
||||
font-size: var(--fs-xs); color: var(--muted);
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border);
|
||||
}
|
||||
@@ -242,7 +306,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
|
||||
.global-dl-line2 {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
font-size: var(--fs-xs); color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.global-dl-line2 b { color: #d6dde4; font-weight: 600; }
|
||||
@@ -283,7 +347,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.metric span, .metric em {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-xs);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -353,15 +417,17 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.game-card:hover { border-color: var(--border-strong); }
|
||||
.game-card.is-downloading { border-color: rgba(56, 193, 114, 0.5); box-shadow: inset 3px 0 0 var(--green); }
|
||||
.game-card.is-installing { border-color: rgba(242, 184, 75, 0.5); box-shadow: inset 3px 0 0 var(--amber); }
|
||||
|
||||
.card-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lead { display: flex; flex-direction: column; align-items: center; gap: 10px; flex: none; }
|
||||
@@ -370,55 +436,112 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px; height: 64px;
|
||||
width: 72px; height: 72px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2d6d9d, #247857);
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.card-body { min-width: 0; }
|
||||
.card-body h3 { margin: 0 0 6px; overflow-wrap: anywhere; font-size: 16px; }
|
||||
.card-body { min-width: 0; display: grid; gap: 10px; align-content: start; }
|
||||
.card-body h3 { margin: 0; overflow-wrap: anywhere; font-size: 18px; line-height: 1.25; }
|
||||
|
||||
.subline { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--muted); font-size: 12px; }
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.tid {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.pills { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
|
||||
.versions {
|
||||
/* Installed -> update -> newest, read left to right. */
|
||||
.vtrack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.versions b { color: var(--ink); font-weight: 700; }
|
||||
.card-note { flex-basis: 100%; line-height: 1.45; }
|
||||
.card-note.is-warn { color: var(--amber); }
|
||||
.prospero-line a { color: var(--blue); text-decoration: none; }
|
||||
.prospero-line a:hover { text-decoration: underline; }
|
||||
.vstep {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.vstep > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.vstep > b { font-size: var(--fs-md); font-variant-numeric: tabular-nums; }
|
||||
.vstep > em { color: var(--muted); font-size: var(--fs-xs); font-style: normal; }
|
||||
.vstep.is-target { border-color: rgba(56, 193, 114, 0.45); background: var(--green-soft); }
|
||||
.vstep.is-target > span { color: var(--green-dark); }
|
||||
.vstep.is-dim { background: transparent; border-style: dashed; }
|
||||
.vstep.is-dim > b { color: var(--muted); }
|
||||
.vtrack-arrow { display: grid; place-items: center; color: var(--muted); }
|
||||
.vtrack-arrow svg { width: 20px; height: 20px; }
|
||||
|
||||
/* A note that belongs to a card or dialog: icon, text, optional action. */
|
||||
.callout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 6px 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.callout > svg { flex: none; margin-top: 1px; color: var(--green-dark); }
|
||||
.callout > span { flex: 1 1 220px; min-width: 0; }
|
||||
.callout .link-button { flex: none; font-weight: 700; }
|
||||
.callout.is-warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); }
|
||||
.callout.is-warn > svg { color: var(--amber); }
|
||||
.callout.is-quiet { color: var(--muted); }
|
||||
|
||||
.spinner {
|
||||
flex: none;
|
||||
width: 16px; height: 16px; margin-top: 2px;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--green);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
width: 136px;
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill.ok { border-color: rgba(56, 193, 114, 0.34); background: var(--green-soft); color: var(--green-dark); }
|
||||
.pill.warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
|
||||
@@ -438,6 +561,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.row-button {
|
||||
width: 100%; /* fills the fixed-width actions column -> never reflows */
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
/* go = green (Update / Resume / Install), pause = amber, cancel/stop = red */
|
||||
.row-button.is-update { border-color: var(--green); color: #04150c; background: var(--green); }
|
||||
@@ -446,8 +570,9 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.row-button.is-pause:hover { background: var(--amber-dark); }
|
||||
.row-button.is-cancel { border-color: var(--red); color: #1a0606; background: var(--red); }
|
||||
.row-button.is-cancel:hover { background: #ff8a8a; }
|
||||
.row-button.is-ghost { background: transparent; color: var(--muted); }
|
||||
.row-button.is-ghost:hover { color: var(--ink); border-color: var(--muted); }
|
||||
.row-button.is-ghost { background: transparent; color: var(--ink); border-color: var(--border-strong); }
|
||||
.row-button.is-ghost:hover { background: var(--surface-2); border-color: var(--muted); }
|
||||
.row-button.is-ghost svg { color: var(--muted); }
|
||||
.row-button:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* ---------------- in-tile download progress ---------------- */
|
||||
@@ -483,7 +608,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
justify-content: space-between;
|
||||
gap: 4px 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
.progress-meta b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -493,7 +618,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
|
||||
.settings-grid { display: grid; gap: 12px; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field span, .allowlist > span, .source-policy > span { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.field span, .allowlist > span, .source-policy > span { color: var(--muted); font-size: var(--fs-xs); font-weight: 700; }
|
||||
.field input, .field select {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
@@ -543,7 +668,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
color: var(--ink);
|
||||
}
|
||||
.switch-row > span:first-child strong { display: block; font-size: 14px; }
|
||||
.switch-row > span:first-child em { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; font-style: normal; }
|
||||
.switch-row > span:first-child em { display: block; margin-top: 3px; color: var(--muted); font-size: var(--fs-xs); font-style: normal; }
|
||||
|
||||
/* slider toggle (used for per-game enable + settings) */
|
||||
.toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; flex: none; }
|
||||
@@ -569,7 +694,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.toggle input:checked + .track { background: var(--green-soft); border-color: var(--green); }
|
||||
.toggle input:checked + .track::after { transform: translateX(18px); background: var(--green); }
|
||||
.toggle input:focus-visible + .track { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||||
.toggle .label { font-size: 12px; color: var(--muted); min-width: 22px; }
|
||||
.toggle .label { font-size: var(--fs-xs); color: var(--muted); min-width: 22px; }
|
||||
.lead .toggle .label { white-space: nowrap; }
|
||||
|
||||
.allowlist, .source-policy, .storage-list { display: grid; gap: 8px; margin-top: 16px; }
|
||||
@@ -585,10 +710,10 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.storage-row strong { font-size: 13px; overflow-wrap: anywhere; }
|
||||
.storage-row em { color: var(--muted); font-style: normal; font-size: 12px; }
|
||||
.storage-row strong { font-size: var(--fs-sm); overflow-wrap: anywhere; }
|
||||
.storage-row em { color: var(--muted); font-style: normal; font-size: var(--fs-xs); }
|
||||
.storage-row b { font-variant-numeric: tabular-nums; }
|
||||
.storage-empty { color: var(--muted); font-size: 13px; }
|
||||
.storage-empty { color: var(--muted); font-size: var(--fs-sm); }
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -616,8 +741,8 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.policy-row strong { font-size: 13px; }
|
||||
.policy-row em { overflow: hidden; color: var(--muted); font-size: 12px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.policy-row strong { font-size: var(--fs-sm); }
|
||||
.policy-row em { overflow: hidden; color: var(--muted); font-size: var(--fs-xs); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.host-chip {
|
||||
display: inline-flex;
|
||||
@@ -627,7 +752,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
background: var(--blue-soft);
|
||||
color: var(--blue);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -642,7 +767,7 @@ pre {
|
||||
border: 1px solid var(--border);
|
||||
color: #dfe7ef;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -690,24 +815,123 @@ pre {
|
||||
align-content: center;
|
||||
padding: 24px 16px;
|
||||
overflow-y: auto;
|
||||
background: rgba(5, 7, 9, 0.72);
|
||||
background: rgba(5, 7, 9, 0.74);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: fadeIn 160ms ease-out;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } }
|
||||
@keyframes sheetIn { from { opacity: 0; transform: translateY(10px) scale(0.985); } }
|
||||
.guide {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
animation: sheetIn 220ms var(--ease);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.guide, .guide-backdrop { animation: none; }
|
||||
}
|
||||
|
||||
/* Dialog with a long list: the header stays, the list scrolls. */
|
||||
.guide-backdrop:has(.is-sheet) { grid-template-columns: minmax(0, 620px); }
|
||||
.guide.is-sheet {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.is-sheet .guide-head { padding: 20px 22px 16px; border-bottom: 1px solid var(--border); }
|
||||
.sheet-body { display: grid; gap: 18px; align-content: start; padding: 18px 22px 22px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.sheet-title { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.sheet-title > div { min-width: 0; }
|
||||
.cover.cover-sm { width: 56px; height: 56px; flex: none; border-radius: 10px; font-size: 16px; }
|
||||
|
||||
.vgroup { display: grid; gap: 8px; }
|
||||
.vgroup h3 {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.vgroup h3 span {
|
||||
min-width: 22px; padding: 1px 7px; border-radius: 999px;
|
||||
background: var(--surface-3); color: var(--ink);
|
||||
font-size: 12px; letter-spacing: 0; text-align: center;
|
||||
}
|
||||
.vlist { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.vrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.vrow-main { display: grid; gap: 2px; min-width: 0; }
|
||||
.vrow-main strong { font-size: 18px; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
|
||||
.vrow-main span { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
|
||||
.vrow .row-button { width: auto; padding: 0 18px; }
|
||||
.vrow.is-ok { border-color: rgba(56, 193, 114, 0.35); }
|
||||
.vrow.is-ok:hover { background: var(--surface-3); }
|
||||
.vrow.is-needs_fw .vrow-main strong { color: var(--muted); }
|
||||
.vrow.is-installed { background: transparent; border-style: dashed; }
|
||||
.vrow.is-older { padding: 8px 14px; background: transparent; }
|
||||
.vrow.is-older .vrow-main strong { font-size: var(--fs-md); color: var(--muted); }
|
||||
|
||||
.vbadge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
min-height: 30px; padding: 0 12px;
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
font-size: var(--fs-xs); font-weight: 700; white-space: nowrap;
|
||||
}
|
||||
.vbadge svg { width: 15px; height: 15px; }
|
||||
.vbadge.is-warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
|
||||
.vbadge.is-ok { border-color: rgba(56, 193, 114, 0.34); background: var(--green-soft); color: var(--green-dark); }
|
||||
|
||||
.volder summary {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
min-height: 40px; padding: 0 10px 0 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--muted); font-size: var(--fs-sm); font-weight: 700;
|
||||
cursor: pointer; list-style: none;
|
||||
}
|
||||
.volder summary::-webkit-details-marker { display: none; }
|
||||
.volder summary:hover { color: var(--ink); }
|
||||
.volder summary svg { transition: transform 150ms ease; }
|
||||
.volder[open] summary svg { transform: rotate(90deg); }
|
||||
|
||||
.sheet-source { margin: 0; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.sheet-source a { display: inline-flex; align-items: center; gap: 4px; color: var(--blue-dark); text-decoration: none; }
|
||||
.sheet-source a:hover { text-decoration: underline; }
|
||||
.sheet-source svg { width: 14px; height: 14px; }
|
||||
|
||||
.vskeleton { display: grid; gap: 8px; }
|
||||
.vskeleton i {
|
||||
display: block; height: 62px; border-radius: 12px;
|
||||
background: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 50%, var(--surface-2) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .vskeleton i, .spinner { animation: none; } }
|
||||
/* minmax(0, 1fr): without it an <input>'s intrinsic width pushes the tracks wider than the sheet */
|
||||
.guide, .guide-steps, .guide-steps li { grid-template-columns: minmax(0, 1fr); }
|
||||
.guide-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.guide-head > div { min-width: 0; }
|
||||
.guide-head h2 { margin: 0; font-size: 22px; }
|
||||
.guide-game { margin: 4px 0 0; color: var(--muted); }
|
||||
.guide-close { flex: none; width: 40px; padding: 0; font-size: 22px; line-height: 1; }
|
||||
.guide-close { flex: none; width: 44px; padding: 0; }
|
||||
.guide-why { margin: 0; color: var(--muted); line-height: 1.55; }
|
||||
.guide-why b { color: var(--ink); }
|
||||
|
||||
@@ -723,7 +947,7 @@ pre {
|
||||
.guide-qr .qr { flex: none; width: 148px; height: 148px; border-radius: 6px; }
|
||||
.guide-qr > div { display: grid; gap: 6px; min-width: 0; }
|
||||
.guide-qr span { color: var(--muted); line-height: 1.5; }
|
||||
.guide-qr code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: 13px; }
|
||||
.guide-qr code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: var(--fs-sm); }
|
||||
|
||||
/* Console view: the PS5 can't copy links, so it only hands over to a phone. */
|
||||
.guide.is-handoff { width: min(560px, 100%); justify-self: center; text-align: center; }
|
||||
@@ -756,7 +980,7 @@ pre {
|
||||
background: var(--green-soft);
|
||||
color: var(--green-dark);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.guide-step-text { display: grid; gap: 4px; }
|
||||
.guide-step-text span { color: var(--muted); line-height: 1.5; }
|
||||
@@ -788,7 +1012,7 @@ pre {
|
||||
.guide-msg.is-warn { color: var(--amber-dark); }
|
||||
.guide-msg.is-error { color: var(--red); }
|
||||
.guide-action { width: auto; justify-self: start; padding: 0 16px; }
|
||||
.guide-progress { display: grid; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 13px; }
|
||||
.guide-progress { display: grid; gap: 6px; margin-top: 8px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
/* Already on a phone: the hand-off QR has nothing to add. */
|
||||
@@ -805,9 +1029,7 @@ pre {
|
||||
.game-card { padding: 18px 20px; }
|
||||
.cover { width: 104px; height: 104px; border-radius: 14px; font-size: 22px; }
|
||||
.card-row { gap: 20px; }
|
||||
.card-body h3 { font-size: 19px; margin-bottom: 8px; }
|
||||
.subline { font-size: 13px; }
|
||||
.versions { font-size: 13px; }
|
||||
.card-body h3 { font-size: 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -839,12 +1061,31 @@ pre {
|
||||
.toolbar { grid-template-columns: 1fr; }
|
||||
.segmented { overflow-x: auto; }
|
||||
.segmented button { flex: 0 0 auto; }
|
||||
.card-row { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.card-actions { grid-column: 1 / -1; width: 100%; flex-direction: row; }
|
||||
.card-actions .row-button { flex: 1; }
|
||||
/* The card body's parts join the card's grid, so the versions and notes
|
||||
use the full width under the cover instead of a narrow column. */
|
||||
.card-row { grid-template-columns: auto minmax(0, 1fr); column-gap: 14px; row-gap: 10px; }
|
||||
.card-body { display: contents; }
|
||||
.lead { grid-row: span 2; }
|
||||
.card-body h3 { align-self: end; }
|
||||
.card-body .pills { align-self: start; }
|
||||
.vtrack, .card-body .callout, .card-actions { grid-column: 1 / -1; }
|
||||
.cover { width: 64px; height: 64px; }
|
||||
.card-actions { width: 100%; flex-direction: row; flex-wrap: wrap; }
|
||||
.card-actions .row-button { flex: 1 1 140px; }
|
||||
.vstep { flex: 1 1 110px; }
|
||||
.global-dl-name { order: 3; flex-basis: 100%; }
|
||||
.guide.is-sheet { max-height: none; }
|
||||
.sheet-body { padding: 16px; }
|
||||
.is-sheet .guide-head { padding: 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.status-strip { grid-template-columns: 1fr; }
|
||||
.status-strip { gap: 8px; }
|
||||
.metric { padding: 12px; }
|
||||
.metric strong { font-size: 17px; }
|
||||
.topbar .eyebrow { display: none; }
|
||||
.switch-row { align-items: flex-start; }
|
||||
.vrow { flex-wrap: nowrap; padding: 12px; }
|
||||
.vrow .row-button { padding: 0 14px; }
|
||||
.vrow .row-button svg { display: none; }
|
||||
}
|
||||
Reference in new issue
Block a user