Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70863fb54e | ||
|
|
b115c0fd4b | ||
|
|
7ae09ce571 | ||
|
|
1739096aa1 | ||
|
|
25c2adc4bf | ||
|
|
36c7c02ebb | ||
|
|
4379ac3f59 | ||
|
|
91cfb2cd02 | ||
|
|
bc716339ba | ||
|
|
fbe984da87 | ||
|
|
f251e2aa98 |
No files matched your search
@@ -20,6 +20,7 @@ SRCS := src/main.c \
|
||||
src/patchdl_scan.c \
|
||||
src/patchdl_appdb.c \
|
||||
src/patchdl_net.c \
|
||||
src/patchdl_prospero.c \
|
||||
src/patchdl_resolve.c \
|
||||
src/patchdl_verxml.c \
|
||||
src/patchdl_install.c \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center">PatchDL</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser and on a phone, downloading a game update" width="900">
|
||||
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser and on a phone, fetching a patch that runs on an older firmware" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,11 +28,12 @@
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Your whole library at a glance.** Every installed game shows up with its version and the newest update Sony offers for it.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Survives reboots.** Pause, resume, or lose power: PatchDL saves progress after every finished piece and continues where it stopped.
|
||||
- **Made for your firmware.** You only see updates your console can run, so no game ends up asking for newer system software.
|
||||
- **Made for your firmware.** You only see updates your console can run. When Sony's newest patch needs a newer firmware, PatchDL finds the newest one that still runs on yours and helps you get it: scan a QR code with your phone, copy one link, and the PS5 does the rest.
|
||||
- **Knows where each game came from.** PatchDL checks every game's package and recognizes disc and PlayStation Store installs. It only ever patches games Sony signed.
|
||||
- **Checked against Sony's checksums.** Turn on verification and PatchDL compares every piece with the SHA-256 Sony publishes for it.
|
||||
- **A tile on your home screen.** One toggle adds PatchDL to the PS5 home screen. Tap it and the UI opens in the console's browser.
|
||||
- **Live progress.** A banner at the top shows the current game, speed, time left and where you are in the queue.
|
||||
@@ -41,12 +42,15 @@
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games view with a running download"><br><sub><b>Games</b> · every game, its version and the update waiting for it</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/queue.jpg" width="420" alt="Updating view with two queued updates"><br><sub><b>Updating</b> · the queue, next to the download that's running</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games view with a running download"><br><sub><b>Games</b> · every update waiting for you, one click away</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/library.jpg" width="420" alt="Full library with game icons, sorted by name"><br><sub><b>Library</b> · icons, versions and firmware needs, A to Z</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="docs/images/oldfw.jpg" width="420" alt="QR code on the TV and the step-by-step guide on a phone"><br><sub><b>Older firmware</b> · scan on the TV, paste one link on your phone</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/settings.jpg" width="420" alt="Settings view"><br><sub><b>Settings</b> · changes apply right away, no restart</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/tile-homescreen.jpg" width="420" alt="PatchDL tile on the PS5 home screen"><br><sub><b>Home screen</b> · the PatchDL tile on a real PS5</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><img src="docs/images/tile-homescreen.jpg" width="420" alt="PatchDL tile on the PS5 home screen"><br><sub><b>Home screen</b> · the PatchDL tile on a real PS5</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -55,7 +59,7 @@
|
||||
Start an update from the couch with your phone, check on it from your laptop, or open it on the TV. The UI adapts to whatever screen it's on.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/phones.jpg" alt="PatchDL on a phone: game list with a download, and Settings" width="560">
|
||||
<img src="docs/images/phones.jpg" alt="PatchDL on a phone: game list and Settings" width="560">
|
||||
</p>
|
||||
|
||||
## Get started
|
||||
@@ -72,8 +76,14 @@ Start an update from the couch with your phone, check on it from your laptop, or
|
||||
**Does PatchDL download games?**
|
||||
No. It fetches updates for games that are already installed on your console. Nothing else.
|
||||
|
||||
**Which games does PatchDL update?**
|
||||
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 only talks to a short list of PlayStation CDN hosts.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**Does it change the updates?**
|
||||
No. Each package stays exactly as Sony published it, and the console's own installer applies it.
|
||||
@@ -114,7 +124,7 @@ PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||
|
||||
## Credits
|
||||
|
||||
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). Thanks to everyone behind them.
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 197 KiB |
@@ -353,6 +353,90 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- external-host HTTP (no Sony CA, no allowlist) -------------- */
|
||||
|
||||
static int
|
||||
http_external(const char *url, const char *method,
|
||||
const char *content_type, const char *body,
|
||||
patchdl_buf_t *out) {
|
||||
#ifndef PATCHDL_HAVE_CURL
|
||||
(void)url; (void)method; (void)content_type; (void)body; (void)out;
|
||||
return -1;
|
||||
#else
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN];
|
||||
char resolve_str[512];
|
||||
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);
|
||||
|
||||
{
|
||||
size_t caller_max = out->max;
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->max = caller_max;
|
||||
}
|
||||
|
||||
curl = curl_easy_init();
|
||||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve_list);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT,
|
||||
"Mozilla/5.0 (PlayStation; PatchDL)");
|
||||
|
||||
if (body) {
|
||||
if (content_type) {
|
||||
char ct[256];
|
||||
snprintf(ct, sizeof(ct), "Content-Type: %s", content_type);
|
||||
headers = curl_slist_append(headers, ct);
|
||||
}
|
||||
headers = curl_slist_append(headers, "Accept: application/json");
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
}
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(resolve_list);
|
||||
curl_slist_free_all(headers);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
free(out->data);
|
||||
out->data = NULL;
|
||||
out->size = 0;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_get_external(const char *url, patchdl_buf_t *out) {
|
||||
return http_external(url, "GET", NULL, NULL, out);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_post_external(const char *url, const char *content_type,
|
||||
const char *body, patchdl_buf_t *out) {
|
||||
return http_external(url, "POST", content_type, body, out);
|
||||
}
|
||||
|
||||
/* Write sink: tees the body to the file and, when verifying, into a running
|
||||
SHA-256. curl always calls with size==1, so nmemb is the byte count. */
|
||||
typedef struct {
|
||||
|
||||
@@ -29,6 +29,13 @@ void patchdl_net_global_cleanup(void);
|
||||
|
||||
int patchdl_http_get(const char *url, patchdl_buf_t *out);
|
||||
|
||||
/* External-host variants: no Sony CA pinning, no Sony host allowlist.
|
||||
SSL peer verification is disabled (PS5 has no system CA store; these
|
||||
are used for public metadata only, never for sensitive data). */
|
||||
int patchdl_http_get_external(const char *url, patchdl_buf_t *out);
|
||||
int patchdl_http_post_external(const char *url, const char *content_type,
|
||||
const char *body, patchdl_buf_t *out);
|
||||
|
||||
/* ---- parallel piece download (used by the connection pool) ------------- */
|
||||
|
||||
/* One piece of a split manifest package. `url` is heap-allocated. */
|
||||
@@ -51,6 +58,7 @@ typedef struct {
|
||||
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
|
||||
void patchdl_manifest_free(patchdl_manifest_t *m);
|
||||
|
||||
|
||||
/* Live state shared with one in-flight piece download. The worker owns these;
|
||||
the curl callbacks read `abort` (set elsewhere) and publish progress into
|
||||
`bytes_slot` (single-writer per worker slot). */
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* 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_prospero.h"
|
||||
#include "patchdl_net.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define PROSPERO_BASE "https://prosperopatches.com"
|
||||
#define PROSPERO_API PROSPERO_BASE "/api/internal"
|
||||
#define BUF_MAX (512 * 1024)
|
||||
|
||||
/* Prospero firmware strings use the same hex-digits-as-decimal encoding
|
||||
as Sony's system_ver: "10.20.00.06" packs to 0x10200006. Parse with
|
||||
%x so the hex bytes compare correctly against g_fw.bin. */
|
||||
static uint32_t
|
||||
fw_str_to_bin(const char *s) {
|
||||
unsigned a = 0, b = 0, c = 0, d = 0;
|
||||
sscanf(s, "%x.%x.%x.%x", &a, &b, &c, &d);
|
||||
return (a << 24) | (b << 16) | (c << 8) | d;
|
||||
}
|
||||
|
||||
static void
|
||||
fw_bin_to_display(uint32_t bin, char *out, size_t sz) {
|
||||
snprintf(out, sz, "%x.%02x", (bin >> 24) & 0xff, (bin >> 16) & 0xff);
|
||||
}
|
||||
|
||||
/* Extract the first occurrence of `data-key="<hex>"` from HTML. */
|
||||
static int
|
||||
extract_data_key(const char *html, char *out, size_t out_sz) {
|
||||
const char *needle = "data-key=\"";
|
||||
const char *p = strstr(html, needle);
|
||||
if (!p) return -1;
|
||||
p += strlen(needle);
|
||||
size_t len = 0;
|
||||
while (p[len] && p[len] != '"' && len < out_sz - 1)
|
||||
len++;
|
||||
if (!len || p[len] != '"') return -1;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Find the next JSON string value for a given key inside a JSON object
|
||||
fragment. Crude but sufficient for the predictable Prospero response. */
|
||||
static const char *
|
||||
json_str_val(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char needle[64];
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
const char *p = strstr(json, needle);
|
||||
if (!p) return NULL;
|
||||
p += strlen(needle);
|
||||
while (*p == ' ' || *p == ':') p++;
|
||||
if (*p != '"') return NULL;
|
||||
p++;
|
||||
size_t len = 0;
|
||||
while (p[len] && p[len] != '"' && len < out_sz - 1)
|
||||
len++;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return p + len + 1;
|
||||
}
|
||||
|
||||
/* Check if a JSON fragment contains "success":true */
|
||||
static int
|
||||
json_success(const char *json) {
|
||||
const char *p = strstr(json, "\"success\"");
|
||||
if (!p) return 0;
|
||||
p += 9;
|
||||
while (*p == ' ' || *p == ':') p++;
|
||||
return !strncmp(p, "true", 4);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out) {
|
||||
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;
|
||||
|
||||
if (!title_id || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* Step 1: fetch the title page to get the data-key. */
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = BUF_MAX;
|
||||
snprintf(url, sizeof(url), "%s/%.16s", PROSPERO_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))) {
|
||||
free(buf.data);
|
||||
return -1;
|
||||
}
|
||||
free(buf.data);
|
||||
|
||||
/* Step 2: POST loadpatches. The API expects JSON in the body but
|
||||
application/x-www-form-urlencoded as Content-Type — this is how
|
||||
the website and every known third-party client calls it. */
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"titleid\":\"%.16s\",\"key\":\"%s\"}", title_id, key);
|
||||
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = BUF_MAX;
|
||||
if (patchdl_http_post_external(
|
||||
PROSPERO_API "/loadpatches",
|
||||
"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};
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
free(buf.data);
|
||||
|
||||
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));
|
||||
out->found = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* 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 <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
char version[16]; /* e.g. "01.003.000" */
|
||||
char required_fw[16]; /* PatchDL display format, e.g. "7.01" */
|
||||
char size[16]; /* Prospero's label, e.g. "71.7 GB" (GiB, rounded up) */
|
||||
int found;
|
||||
} patchdl_prospero_result_t;
|
||||
|
||||
/* Query prosperopatches.com for the newest patch of `title_id` whose
|
||||
firmware requirement is <= fw_bin. Returns 0 on success (result may
|
||||
still have found==0 if no compatible patch exists), -1 on network or
|
||||
parse failure. Best-effort: a failure means the UI shows no Prospero
|
||||
data, nothing breaks. */
|
||||
int patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out);
|
||||
@@ -48,6 +48,15 @@ typedef struct {
|
||||
char patch_title_id[16]; /* target title id from version.xml */
|
||||
char patch_storage_title_id[16]; /* title id embedded in delta_url */
|
||||
int verxml_done;
|
||||
/* Prospero Patches lookup (best compatible patch from version history) */
|
||||
char prospero_version[16];
|
||||
char prospero_required_fw[16];
|
||||
char prospero_size[16];
|
||||
int prospero_done;
|
||||
/* Manifest URL derived from a link the user pasted from Prospero. When
|
||||
set it replaces patch_url for download and install. */
|
||||
char manual_url[512];
|
||||
char manual_version[16];
|
||||
int enabled; /* user policy, persisted in config.json */
|
||||
int resumable; /* a partial download is on disk */
|
||||
long long partial_bytes; /* size of that partial, for the UI */
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.8"
|
||||
#define PATCHDL_VERSION "0.0.9"
|
||||
@@ -16,16 +16,21 @@
|
||||
#include "patchdl_resolve.h"
|
||||
#include "patchdl_tile.h"
|
||||
#include "patchdl_verxml.h"
|
||||
#include "patchdl_prospero.h"
|
||||
#include "patchdl_version.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <microhttpd.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <ctype.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <unistd.h>
|
||||
@@ -104,7 +109,9 @@ typedef struct dl_job {
|
||||
int pieces_failed;
|
||||
int inflight;
|
||||
int fd; /* O_RDWR dest fd, -1 until admit */
|
||||
int rc; /* 0 ok, -1 net/io, -2 verify */
|
||||
int rc; /* 0 ok, -1 net/io, -2 verify, -3 no space */
|
||||
long long need_bytes; /* rc -3: bytes the package still needed */
|
||||
long long free_bytes; /* rc -3: bytes free on /data at the time */
|
||||
int last_curl_rc; /* CURLcode from the most recent piece */
|
||||
long last_http_code; /* HTTP status from the most recent piece */
|
||||
struct dl_job *next;
|
||||
@@ -135,7 +142,7 @@ static const char config_tail_json[] =
|
||||
"\"source_policy\":{"
|
||||
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
||||
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
|
||||
"\"shadowmount\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":false},"
|
||||
"\"shadowmount\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false},"
|
||||
"\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false},"
|
||||
"\"fake\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}"
|
||||
"},"
|
||||
@@ -431,13 +438,11 @@ static const char *INSTALL_URI_HOSTS[] = {
|
||||
INSTALL_URI_HOSTS (or a subdomain). Rejects file://, http://, redirects
|
||||
to other hosts (libcurl enforces via host_allowed at fetch time). */
|
||||
static int
|
||||
install_uri_safe(const char *uri) {
|
||||
const char *host_start, *end;
|
||||
cdn_host_ok(const char *host_start) {
|
||||
const char *end;
|
||||
size_t hlen;
|
||||
char host[128];
|
||||
|
||||
if (!uri || strncmp(uri, "https://", 8) != 0) return 0;
|
||||
host_start = uri + 8;
|
||||
end = strpbrk(host_start, "/:?");
|
||||
hlen = end ? (size_t)(end - host_start) : strlen(host_start);
|
||||
if (hlen == 0 || hlen >= sizeof(host)) return 0;
|
||||
@@ -454,6 +459,11 @@ install_uri_safe(const char *uri) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
install_uri_safe(const char *uri) {
|
||||
return uri && !strncmp(uri, "https://", 8) && cdn_host_ok(uri + 8);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
int fd;
|
||||
uint64_t start;
|
||||
@@ -632,12 +642,58 @@ data_free_mb(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Headroom kept free on /data so a download never fills the console's
|
||||
internal storage to the last byte. */
|
||||
#define PATCHDL_SPACE_MARGIN (2LL * 1024 * 1024 * 1024)
|
||||
|
||||
/* Bytes still to be written for a package of `total` bytes at `path`: a
|
||||
partial from an earlier run already occupies part of it. */
|
||||
static long long
|
||||
space_needed(const char *path, long long total) {
|
||||
struct stat st;
|
||||
long long have = 0;
|
||||
if (stat(path, &st) == 0) have = (long long)st.st_blocks * 512;
|
||||
return total > have ? total - have : 0;
|
||||
}
|
||||
|
||||
/* 1 if `need` bytes plus the margin fit; *free_out gets the free bytes. */
|
||||
static int
|
||||
space_ok(long long need, long long *free_out) {
|
||||
long long free_b = data_free_mb() * 1024LL * 1024LL;
|
||||
if (free_out) *free_out = free_b;
|
||||
return need + PATCHDL_SPACE_MARGIN <= free_b;
|
||||
}
|
||||
|
||||
/* Built fresh per request so free space stays live (a 60+ GB download moves
|
||||
it a lot). g_fw is read-only after startup, so this is thread-safe. */
|
||||
/* The console's LAN address, so a page opened on the PS5 itself (127.0.0.1)
|
||||
can hand a phone a reachable link. Connecting a UDP socket sends nothing;
|
||||
it only makes the kernel pick the outgoing interface. */
|
||||
static void
|
||||
lan_ipv4(char *out, size_t sz) {
|
||||
struct sockaddr_in a;
|
||||
socklen_t len = sizeof a;
|
||||
int s = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
out[0] = '\0';
|
||||
if (s < 0) return;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_port = htons(53);
|
||||
a.sin_addr.s_addr = htonl(0x01010101);
|
||||
if (connect(s, (struct sockaddr *)&a, sizeof a) == 0 &&
|
||||
getsockname(s, (struct sockaddr *)&a, &len) == 0 &&
|
||||
!inet_ntop(AF_INET, &a.sin_addr, out, (socklen_t)sz))
|
||||
out[0] = '\0';
|
||||
close(s);
|
||||
}
|
||||
|
||||
static char *
|
||||
build_status_json(void) {
|
||||
char ip[INET_ADDRSTRLEN];
|
||||
char *out = malloc(512);
|
||||
if (!out) return NULL;
|
||||
lan_ipv4(ip, sizeof ip);
|
||||
snprintf(out, 512,
|
||||
"{\"firmware\":\"%s\","
|
||||
"\"firmware_build\":\"0x%08x\","
|
||||
@@ -645,8 +701,11 @@ build_status_json(void) {
|
||||
"\"resolver\":\"Internal allowlist\","
|
||||
"\"free_space_mb\":%lld,"
|
||||
"\"download_dir\":\"/data/patchdl (internal)\","
|
||||
"\"lan_ip\":\"%s\","
|
||||
"\"http_port\":%d,"
|
||||
"\"version\":\"%s\"}",
|
||||
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
|
||||
g_fw.str, g_fw.bin, data_free_mb(), ip, PATCHDL_HTTP_PORT,
|
||||
PATCHDL_VERSION);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -760,8 +819,11 @@ load_config(void) {
|
||||
static const char *
|
||||
title_status_str(const patchdl_title_t *t) {
|
||||
if (!t->verxml_done) return "checking";
|
||||
/* A mounted image can't take a patch, so neither download nor install
|
||||
would lead anywhere; treat it like a fake package. */
|
||||
if (t->source_type == PATCHDL_SOURCE_UNKNOWN ||
|
||||
t->source_type == PATCHDL_SOURCE_FAKE) return "blocked";
|
||||
t->source_type == PATCHDL_SOURCE_FAKE ||
|
||||
t->source_type == PATCHDL_SOURCE_SHADOWMOUNT) 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. */
|
||||
@@ -788,7 +850,7 @@ title_status_str(const patchdl_title_t *t) {
|
||||
static const char *
|
||||
title_mode_str(patchdl_source_t src) {
|
||||
switch (src) {
|
||||
case PATCHDL_SOURCE_SHADOWMOUNT: return "download_only";
|
||||
case PATCHDL_SOURCE_SHADOWMOUNT: return "disabled";
|
||||
case PATCHDL_SOURCE_UNKNOWN: return "disabled";
|
||||
case PATCHDL_SOURCE_FAKE: return "disabled";
|
||||
default: return "latest_compatible";
|
||||
@@ -842,6 +904,16 @@ build_titles_json(void) {
|
||||
jbuf_appendf(&j, ",\"patch_title_match\":%s",
|
||||
(!t->patch_title_id[0] ||
|
||||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
|
||||
jbuf_append(&j, ",\"prospero_version\":");
|
||||
jbuf_append_ver_or_null(&j, t->prospero_version);
|
||||
jbuf_append(&j, ",\"prospero_required_fw\":");
|
||||
jbuf_append_ver_or_null(&j, t->prospero_required_fw);
|
||||
jbuf_append(&j, ",\"prospero_size\":");
|
||||
jbuf_append_ver_or_null(&j, t->prospero_size);
|
||||
jbuf_appendf(&j, ",\"prospero_done\":%s",
|
||||
t->prospero_done ? "true" : "false");
|
||||
jbuf_append(&j, ",\"manual_version\":");
|
||||
jbuf_append_ver_or_null(&j, t->manual_url[0] ? t->manual_version : "");
|
||||
jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false");
|
||||
jbuf_appendf(&j, ",\"resumable\":%s", t->resumable ? "true" : "false");
|
||||
jbuf_appendf(&j, ",\"partial_bytes\":%lld", t->partial_bytes);
|
||||
@@ -905,6 +977,9 @@ build_downloads_json(void) {
|
||||
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total);
|
||||
jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld",
|
||||
job->rc, job->last_curl_rc, job->last_http_code);
|
||||
if (job->rc == -3)
|
||||
jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld",
|
||||
job->need_bytes, job->free_bytes);
|
||||
jbuf_append(&j, "}");
|
||||
}
|
||||
jbuf_append(&j, "]");
|
||||
@@ -987,6 +1062,29 @@ verxml_fetch_thread(void *arg) {
|
||||
|
||||
if (up_to_date && info.compatible_url[0])
|
||||
cleanup_installed_download(t->title_id, info.compatible_url);
|
||||
|
||||
/* When the latest patch needs newer firmware and no compatible
|
||||
version came from Sony, ask Prospero Patches for the full
|
||||
version history. */
|
||||
if (g_verxml_stop) break;
|
||||
if (!strcmp(title_status_str(t), "incompatible_fw") &&
|
||||
t->source_type != PATCHDL_SOURCE_FAKE &&
|
||||
t->source_type != PATCHDL_SOURCE_UNKNOWN) {
|
||||
patchdl_prospero_result_t pp;
|
||||
if (!patchdl_prospero_query(t->title_id, g_fw.bin, &pp) &&
|
||||
pp.found && strcmp(pp.version, t->installed_version) > 0) {
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
strncpy(t->prospero_version, pp.version,
|
||||
sizeof(t->prospero_version) - 1);
|
||||
strncpy(t->prospero_required_fw, pp.required_fw,
|
||||
sizeof(t->prospero_required_fw) - 1);
|
||||
strncpy(t->prospero_size, pp.size, sizeof(t->prospero_size) - 1);
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
t->prospero_done = 1;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -1024,6 +1122,15 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
|
||||
name[name_sz - 1] = '\0';
|
||||
strncpy(version, g_titles[i].compatible_version, ver_sz - 1);
|
||||
version[ver_sz - 1] = '\0';
|
||||
if (g_titles[i].manual_url[0]) {
|
||||
/* A pasted Prospero link wins: it is the title's own package
|
||||
(checked on submit), so no delta and no shared master. */
|
||||
snprintf(patch_url, url_sz, "%s", g_titles[i].manual_url);
|
||||
delta_url[0] = '\0';
|
||||
snprintf(patch_title_id, pt_sz, "%.9s", title_id);
|
||||
snprintf(patch_storage_title_id, pst_sz, "%.9s", title_id);
|
||||
snprintf(version, ver_sz, "%s", g_titles[i].manual_version);
|
||||
}
|
||||
*enabled = g_titles[i].enabled;
|
||||
found = 1;
|
||||
break;
|
||||
@@ -1089,6 +1196,7 @@ url_is_manifest(const char *url) {
|
||||
return n > 5 && !strcmp(url + n - 5, ".json");
|
||||
}
|
||||
|
||||
|
||||
/* ---------- resume sidecar + pool helpers ------------------------------- */
|
||||
|
||||
static void
|
||||
@@ -1124,6 +1232,79 @@ remove_title_dir(const char *title_id) {
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
/* ---------- user-supplied patch link (Prospero) ------------------------ */
|
||||
|
||||
static void
|
||||
manual_source_path(const char *title_id, char *out, size_t sz) {
|
||||
snprintf(out, sz, "%s/%s/manual.json", PATCHDL_DL_DIR, title_id);
|
||||
}
|
||||
|
||||
/* Persist the pasted link next to the download so install and resume still
|
||||
find it after a restart. Cancel deletes the directory, and with it this. */
|
||||
static void
|
||||
save_manual_source(const char *title_id, const char *url, const char *version) {
|
||||
char dir[288], path[320];
|
||||
FILE *f;
|
||||
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
|
||||
mkdir(PATCHDL_DL_DIR, 0777);
|
||||
mkdir(dir, 0777);
|
||||
manual_source_path(title_id, path, sizeof path);
|
||||
if ((f = fopen(path, "w"))) {
|
||||
fprintf(f, "{\"url\":\"%s\",\"version\":\"%s\"}\n", url, version);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
set_manual_source(const char *title_id, const char *url, const char *version) {
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (!strcmp(g_titles[i].title_id, title_id)) {
|
||||
snprintf(g_titles[i].manual_url, sizeof g_titles[i].manual_url, "%s", url);
|
||||
snprintf(g_titles[i].manual_version, sizeof g_titles[i].manual_version,
|
||||
"%s", version);
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
|
||||
static void
|
||||
clear_manual_source(const char *title_id) {
|
||||
char path[320], dir[288];
|
||||
set_manual_source(title_id, "", "");
|
||||
manual_source_path(title_id, path, sizeof path);
|
||||
unlink(path);
|
||||
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
|
||||
rmdir(dir); /* fails while the pkg is still there; the cancel path removes it */
|
||||
}
|
||||
|
||||
/* Startup, single-threaded. A link whose version is already installed is
|
||||
spent: drop it together with its package. */
|
||||
static void
|
||||
load_manual_sources(void) {
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
patchdl_title_t *t = &g_titles[i];
|
||||
char path[320], buf[1024];
|
||||
FILE *f;
|
||||
size_t n;
|
||||
|
||||
if (!path_segment_safe(t->title_id)) continue;
|
||||
manual_source_path(t->title_id, path, sizeof path);
|
||||
if (!(f = fopen(path, "r"))) continue;
|
||||
n = fread(buf, 1, sizeof buf - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
json_get_str(buf, "url", t->manual_url, sizeof t->manual_url);
|
||||
json_get_str(buf, "version", t->manual_version, sizeof t->manual_version);
|
||||
if (!url_is_manifest(t->manual_url) || !t->manual_version[0] ||
|
||||
(t->installed_version[0] &&
|
||||
strcmp(t->installed_version, t->manual_version) >= 0)) {
|
||||
t->manual_url[0] = t->manual_version[0] = '\0';
|
||||
remove_title_dir(t->title_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mirror the resumable flag into g_titles (read by /api/titles). Takes g_mutex,
|
||||
so it must be called with the POOL lock NOT held (lock order: g_mutex first). */
|
||||
static void
|
||||
@@ -1349,8 +1530,8 @@ admit_next(void) {
|
||||
dl_job_t *q = NULL;
|
||||
patchdl_manifest_t mf;
|
||||
char murl[768], dest[320], dir[288];
|
||||
int is_manifest, fd = -1, ok = 0;
|
||||
long long total = 0, old_size = 0;
|
||||
int is_manifest, fd = -1, ok = 0, no_space = 0;
|
||||
long long total = 0, old_size = 0, need = 0, free_b = 0;
|
||||
dl_pstate_t *ps = NULL;
|
||||
unsigned char *bitmap = NULL;
|
||||
|
||||
@@ -1375,7 +1556,9 @@ admit_next(void) {
|
||||
if (is_manifest) {
|
||||
if (patchdl_fetch_manifest(murl, &mf) == 0 && mf.count > 0) {
|
||||
total = mf.total;
|
||||
fd = open(dest, O_RDWR | O_CREAT, 0666);
|
||||
need = space_needed(dest, total);
|
||||
if (!space_ok(need, &free_b)) no_space = 1;
|
||||
else fd = open(dest, O_RDWR | O_CREAT, 0666);
|
||||
if (fd >= 0) {
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) == 0) old_size = (long long)st.st_size;
|
||||
@@ -1412,7 +1595,12 @@ admit_next(void) {
|
||||
free(ps);
|
||||
free(bitmap);
|
||||
if (q->state == JOB_CANCELLING) finalize_cancel_locked(q);
|
||||
else { q->state = JOB_FAILED; q->rc = -1; }
|
||||
else {
|
||||
q->state = JOB_FAILED;
|
||||
q->rc = no_space ? -3 : -1;
|
||||
q->need_bytes = need;
|
||||
q->free_bytes = free_b;
|
||||
}
|
||||
pthread_cond_broadcast(&g_pool.cv);
|
||||
return;
|
||||
}
|
||||
@@ -1601,6 +1789,9 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
if (src == PATCHDL_SOURCE_FAKE)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_fake\"}");
|
||||
if (src == PATCHDL_SOURCE_SHADOWMOUNT)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_shadowmount\"}");
|
||||
if (!patch_url[0])
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
|
||||
@@ -1750,6 +1941,7 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
|
||||
remove_title_dir(title_id);
|
||||
set_title_resumable(title_id, 0, 0);
|
||||
}
|
||||
clear_manual_source(title_id);
|
||||
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"cancelled\":true}");
|
||||
}
|
||||
|
||||
@@ -1823,6 +2015,180 @@ do_install(struct MHD_Connection *conn, const char *title_id,
|
||||
strdup(resp));
|
||||
}
|
||||
|
||||
/* Turn a link copied from Prospero's "Additional Files" into the URL of
|
||||
Sony's package manifest. Sony keeps the manifest ("<name>.json") in the same
|
||||
folder as the package's "<name>_sc.pkg" piece and "<name>.crc" file, and the
|
||||
manifest lists every piece with its offset and SHA-256, so the download then
|
||||
runs exactly like an official one. Piece links ("<name>_<n>.pkg") live in a
|
||||
different folder and can't lead to the manifest. Returns NULL on success,
|
||||
else a reason code for the UI. */
|
||||
static const char *
|
||||
manifest_from_link(const char *tid, const char *in, char *canon, size_t sz) {
|
||||
static const char *const ends[] = { "_sc.pkg", ".crc", ".json", NULL };
|
||||
char tag[16];
|
||||
const char *p, *rest, *base, *stem_end = NULL;
|
||||
size_t n = strlen(in);
|
||||
|
||||
/* The URL ends up unescaped in JSON sidecars and responses, and dot
|
||||
segments could walk the path into another title's folder. */
|
||||
for (p = in; *p; p++)
|
||||
if (!isalnum((unsigned char)*p) && !strchr("-._~:/", *p))
|
||||
return "url_invalid";
|
||||
if (strstr(in, "..")) return "url_invalid";
|
||||
if (!strncmp(in, "https://", 8)) rest = in + 8;
|
||||
else if (!strncmp(in, "http://", 7)) rest = in + 7;
|
||||
else return "url_not_allowed";
|
||||
if (!cdn_host_ok(rest)) return "url_not_allowed";
|
||||
|
||||
for (int i = 0; ends[i]; i++) {
|
||||
size_t el = strlen(ends[i]);
|
||||
if (n > el && !strcmp(in + n - el, ends[i])) { stem_end = in + n - el; break; }
|
||||
}
|
||||
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";
|
||||
|
||||
/* Sony keeps a title's files under ".../<TID>_00/..." and names them
|
||||
"<publisher>-<TID>_00-<label>..."; both must name this title. */
|
||||
snprintf(tag, sizeof tag, "/%.9s_", tid);
|
||||
if (!strstr(in, tag)) return "url_title_mismatch";
|
||||
tag[0] = '-';
|
||||
if (!strstr(base, tag)) return "url_title_mismatch";
|
||||
|
||||
/* Manifests are fetched over https with the pinned Sony root. */
|
||||
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);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Prospero labels sizes in GiB ("71.7 GB"), about 0.1 above the piece sum.
|
||||
Returns 1 when `found` bytes clearly don't match the size Prospero lists
|
||||
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) {
|
||||
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);
|
||||
for (char *c = lbl; *c; c++) /* external text, echoed into JSON */
|
||||
if (!isalnum((unsigned char)*c) && *c != '.' && *c != ' ') *c = '?';
|
||||
snprintf(label, lsz, "%s", lbl);
|
||||
|
||||
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;
|
||||
else if (!strcmp(unit, "MB")) unit_bytes = 1048576.0;
|
||||
else return 0;
|
||||
expect = v * unit_bytes;
|
||||
diff = (double)found - expect;
|
||||
if (diff < 0) diff = -diff;
|
||||
tol = expect * 0.02;
|
||||
if (tol < 0.15 * unit_bytes) tol = 0.15 * unit_bytes;
|
||||
return diff > tol;
|
||||
}
|
||||
|
||||
/* Queue a patch from an SC/CRC link copied off Prospero Patches. The link must
|
||||
point at this title's own folder on Sony's CDN, so a pasted URL can't put
|
||||
another game's package on this one. */
|
||||
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];
|
||||
patchdl_manifest_t mf;
|
||||
char purl[512], durl[512], ptid[16], pstid[16];
|
||||
char cid[64], tname[128], tv[16];
|
||||
const char *p, *why;
|
||||
struct stat st;
|
||||
int en = 0, busy = 0, pieces;
|
||||
long long found;
|
||||
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
|
||||
|
||||
json_get_str(body, "title_id", tid, sizeof tid);
|
||||
json_get_str(body, "url", in, sizeof in);
|
||||
json_get_str(body, "version", ver, sizeof ver);
|
||||
|
||||
if (!tid[0] || !in[0] || !ver[0])
|
||||
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"{\"ok\":false,\"reason\":\"title_id, url and version required\"}");
|
||||
if (!path_segment_safe(tid) || strlen(tid) < 9)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"title_id_unsafe\"}");
|
||||
for (p = ver; *p; p++)
|
||||
if (!isdigit((unsigned char)*p) && *p != '.')
|
||||
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"{\"ok\":false,\"reason\":\"version_invalid\"}");
|
||||
if ((why = manifest_from_link(tid, in, canon, sizeof canon))) {
|
||||
snprintf(resp, sizeof resp, "{\"ok\":false,\"reason\":\"%s\"}", why);
|
||||
return queue_json_owned(conn, MHD_HTTP_BAD_REQUEST, strdup(resp));
|
||||
}
|
||||
|
||||
if (!get_title_action_info(tid, &src, purl, sizeof purl, durl, sizeof durl,
|
||||
ptid, sizeof ptid, pstid, sizeof pstid,
|
||||
cid, sizeof cid, tname, sizeof tname,
|
||||
tv, sizeof tv, &en))
|
||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
|
||||
if (!en)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"title_disabled\"}");
|
||||
if (src == PATCHDL_SOURCE_UNKNOWN || src == PATCHDL_SOURCE_FAKE ||
|
||||
src == PATCHDL_SOURCE_SHADOWMOUNT)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"source_not_supported\"}");
|
||||
|
||||
/* Different versions share the same file name, so never mix a new link
|
||||
into an existing download or partial: the user cancels it first. */
|
||||
pthread_mutex_lock(&g_pool.mtx);
|
||||
for (dl_job_t *j = g_pool.jobs; j; j = j->next)
|
||||
if (!strcmp(j->title_id, tid)) busy = 1;
|
||||
pthread_mutex_unlock(&g_pool.mtx);
|
||||
title_state_path(tid, state, sizeof state);
|
||||
if (busy || stat(state, &st) == 0)
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"ok\":false,\"reason\":\"download_exists\","
|
||||
"\"hint\":\"cancel the current download first\"}");
|
||||
|
||||
/* Load the manifest here rather than only at admit: a dead or wrong link
|
||||
then fails with a reason the user sees right away, and the total can be
|
||||
checked against the size Prospero lists for the version. */
|
||||
if (patchdl_fetch_manifest(canon, &mf) != 0)
|
||||
return queue_json(conn, MHD_HTTP_BAD_GATEWAY,
|
||||
"{\"ok\":false,\"reason\":\"manifest_unavailable\"}");
|
||||
found = mf.total;
|
||||
pieces = mf.count;
|
||||
patchdl_manifest_free(&mf);
|
||||
{
|
||||
long long free_b;
|
||||
if (!space_ok(found, &free_b)) {
|
||||
snprintf(resp, sizeof resp,
|
||||
"{\"ok\":false,\"reason\":\"no_space\","
|
||||
"\"need_bytes\":%lld,\"free_bytes\":%lld}", found, free_b);
|
||||
return queue_json_owned(conn, 507, strdup(resp));
|
||||
}
|
||||
}
|
||||
if (size_mismatch(tid, ver, found, expect, sizeof expect) &&
|
||||
!json_get_bool(body, "force", 0)) {
|
||||
snprintf(resp, sizeof resp,
|
||||
"{\"ok\":false,\"reason\":\"size_mismatch\","
|
||||
"\"found_bytes\":%lld,\"pieces\":%d,\"expected\":\"%s\"}",
|
||||
found, pieces, expect);
|
||||
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
|
||||
}
|
||||
|
||||
save_manual_source(tid, canon, ver);
|
||||
set_manual_source(tid, canon, ver);
|
||||
return do_download(conn, tid, src, canon, "", tname, ver, en);
|
||||
}
|
||||
|
||||
/* ---------- request routing -------------------------------------------- */
|
||||
|
||||
static int
|
||||
@@ -2110,6 +2476,8 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
|
||||
strdup(resp));
|
||||
}
|
||||
if (!strcmp(url, "/api/download_url"))
|
||||
return handle_download_url(conn, body);
|
||||
if (!strcmp(url, "/api/install_tile")) {
|
||||
char msg[256], resp[320];
|
||||
int rc = patchdl_tile_install_if_needed(msg, sizeof(msg));
|
||||
@@ -2279,7 +2647,9 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
}
|
||||
|
||||
/* Read-only: report the .pkg's embedded content id / title id vs the target
|
||||
(installed app) ids, to expose the cross-region linkage. No install. */
|
||||
(installed app) ids. AppInstallPkg installs by the id inside the package,
|
||||
so it lands on the game only when they match; where the CDN stores the
|
||||
pieces (storage_title_id) doesn't matter. No install. */
|
||||
if (!strncmp(url, "/api/pkgmeta/", 13)) {
|
||||
const char *tid = url + 13;
|
||||
char purl[768] = {0}, durl_[512] = {0};
|
||||
@@ -2302,13 +2672,11 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
snprintf(resp, sizeof resp,
|
||||
"{\"ok\":%s,\"pkg_content_id\":\"%s\",\"pkg_title_id\":\"%s\","
|
||||
"\"is_app\":%s,\"target_content_id\":\"%s\",\"target_title_id\":\"%s\","
|
||||
"\"storage_title_id\":\"%s\",\"installable\":%s,\"install_plan\":\"%s\","
|
||||
"\"msg\":\"%s\"}",
|
||||
"\"storage_title_id\":\"%s\",\"installable\":%s,"
|
||||
"\"install_plan\":\"appinstallpkg\",\"msg\":\"%s\"}",
|
||||
rc == 0 ? "true" : "false", ecid, etid, is_app ? "true" : "false",
|
||||
cid, tid, psti,
|
||||
(psti[0] && strncmp(psti, tid, 9) != 0) ? "false" : "true",
|
||||
(psti[0] && strncmp(psti, tid, 9) != 0) ?
|
||||
"cross_region_storage_unsupported" : "installbypackage",
|
||||
(rc == 0 && !strncmp(etid, tid, 9)) ? "true" : "false",
|
||||
msg);
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
|
||||
}
|
||||
@@ -2393,6 +2761,7 @@ patchdl_websrv_start(unsigned short port) {
|
||||
|
||||
/* Flag titles that have a partial download on disk so the UI can offer
|
||||
Resume after a reboot. */
|
||||
load_manual_sources();
|
||||
detect_resumable_partials();
|
||||
|
||||
/* Build the diagnostic dump now, while single-threaded — the root-vnode
|
||||
|
||||
@@ -39,8 +39,9 @@ The UI assumes deny-by-default behavior:
|
||||
"source_policy": {
|
||||
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||
"shadowmount": { "allow_check": true, "allow_download": true, "allow_install": false },
|
||||
"unknown": { "allow_check": true, "allow_download": false, "allow_install": false }
|
||||
"shadowmount": { "allow_check": true, "allow_download": false, "allow_install": false },
|
||||
"unknown": { "allow_check": true, "allow_download": false, "allow_install": false },
|
||||
"fake": { "allow_check": true, "allow_download": false, "allow_install": false }
|
||||
},
|
||||
"cdn_allowlist": [
|
||||
"sgst.prod.dl.playstation.net",
|
||||
@@ -70,7 +71,7 @@ Per-title source fields:
|
||||
"source_path": "/system_ex/app/PPSA90001_00",
|
||||
"mount_from": "/mnt/usb0/itemzflow/Shadowmounted Test Title",
|
||||
"enabled": true,
|
||||
"mode": "download_only"
|
||||
"mode": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -83,16 +84,17 @@ shadowmount
|
||||
unknown
|
||||
```
|
||||
|
||||
The frontend treats `shadowmount` as download-only and `unknown` as blocked for
|
||||
downloads and installs. Backend code should enforce the same policy even if a
|
||||
client sends a forged request.
|
||||
The frontend lists `shadowmount`, `unknown` and `fake` titles under *Not
|
||||
supported* and offers neither download nor install for them. The backend refuses
|
||||
both as well, even if a client sends a forged request.
|
||||
|
||||
For PS5 game updates, the backend may turn a Sony `manifest_url` into a merged
|
||||
local `.pkg` by downloading all manifest pieces. The `delta_url` `*-DP.pkg` is
|
||||
not shown as a separate user action because it can bootstrap the storage/master
|
||||
title instead of the installed regional target.
|
||||
|
||||
If `patch_storage_match` is false, the UI keeps download/verify available but
|
||||
does not offer install or auto-install. Those shared-master packages are signed
|
||||
for a different storage title id and cannot be retargeted by standalone
|
||||
AppInstUtil on firmware 11.60.
|
||||
If `patch_storage_match` is false, the CDN stores the patch pieces under another
|
||||
regional (master) title id. That is fine: the merged `.pkg` assembled from the
|
||||
manifest carries the installed title's own content id, and AppInstUtil
|
||||
`AppInstallPkg` installs it onto that title. The UI only adds a "Shared master"
|
||||
label.
|
||||
@@ -13,7 +13,7 @@ const iconCache = new Map();
|
||||
const SOURCE_TYPES = {
|
||||
official: { label: "Official", className: "ok", installAllowed: true, description: "Native console installation" },
|
||||
external: { label: "External", className: "external", installAllowed: true, description: "Real external installation" },
|
||||
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Download only" },
|
||||
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Not supported" },
|
||||
unknown: { label: "Unknown", className: "blocked", installAllowed: false, description: "Blocked" },
|
||||
fake: { label: "Fake package", className: "blocked", installAllowed: false, description: "Not supported" },
|
||||
};
|
||||
@@ -26,6 +26,8 @@ const fallback = {
|
||||
resolver: "Internal allowlist",
|
||||
free_space_mb: 771916,
|
||||
download_dir: "/data/patchdl (internal)",
|
||||
lan_ip: "192.168.178.90",
|
||||
http_port: 12880,
|
||||
},
|
||||
config: {
|
||||
default_policy: "deny",
|
||||
@@ -38,7 +40,7 @@ const fallback = {
|
||||
source_policy: {
|
||||
official: { allow_check: true, allow_download: true, allow_install: true },
|
||||
external: { allow_check: true, allow_download: true, allow_install: true },
|
||||
shadowmount: { allow_check: true, allow_download: true, allow_install: false },
|
||||
shadowmount: { allow_check: true, allow_download: false, allow_install: false },
|
||||
unknown: { allow_check: true, allow_download: false, allow_install: false },
|
||||
fake: { allow_check: true, allow_download: false, allow_install: false },
|
||||
},
|
||||
@@ -74,7 +76,17 @@ const fallback = {
|
||||
latest_version: "01.009.000", latest_required_fw: "12.00",
|
||||
source_type: "shadowmount", source_path: "/system_ex/app/PPSA90001_00",
|
||||
patch_storage_match: true,
|
||||
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "available",
|
||||
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "blocked",
|
||||
},
|
||||
{
|
||||
title_id: "PPSA03396", name: "The Last of Us Part I",
|
||||
content_id: "UP9000-PPSA03396_00-THELASTOFUSPART1",
|
||||
installed_version: "01.000.000", compatible_version: null,
|
||||
latest_version: "02.002.000", latest_required_fw: "10.20",
|
||||
prospero_version: "01.003.000", prospero_required_fw: "7.01", prospero_size: "71.7 GB", prospero_done: true,
|
||||
source_type: "official", source_path: "/system_ex/app/PPSA03396",
|
||||
patch_storage_match: true,
|
||||
mount_from: "/dev/ssd0.system_ex", enabled: true, status: "incompatible_fw",
|
||||
},
|
||||
],
|
||||
downloads: [],
|
||||
@@ -88,7 +100,7 @@ let state = {
|
||||
downloads: fallback.downloads,
|
||||
logs: fallback.logs,
|
||||
view: "games",
|
||||
filter: "updatable",
|
||||
filter: "updates",
|
||||
query: "",
|
||||
usingFallback: false,
|
||||
};
|
||||
@@ -103,7 +115,8 @@ const els = {};
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindElements();
|
||||
bindEvents();
|
||||
loadInitialData();
|
||||
loadInitialData().then(openGuideFromHash);
|
||||
window.addEventListener("hashchange", openGuideFromHash);
|
||||
});
|
||||
|
||||
function bindElements() {
|
||||
@@ -208,7 +221,8 @@ async function loadInitialData() {
|
||||
titles.forEach((g) => {
|
||||
const old = prev.get(g.title_id);
|
||||
// Neither state has a patch left to fetch, so drop stale job flags.
|
||||
if (old && g.status !== "up_to_date" && g.status !== "incompatible_fw") {
|
||||
if (old && g.status !== "up_to_date" &&
|
||||
(g.status !== "incompatible_fw" || g.manual_version)) {
|
||||
if (old.downloaded) g.downloaded = true;
|
||||
if (old.installing) g.installing = true;
|
||||
if (old._autoInstalled) g._autoInstalled = true;
|
||||
@@ -314,6 +328,12 @@ function renderSettings() {
|
||||
|
||||
function renderGames() {
|
||||
renderGlobalStatus();
|
||||
const counts = { updates: 0, uptodate: 0, blocked: 0, all: state.titles.length };
|
||||
state.titles.forEach((g) => { counts[gameCategory(g)]++; });
|
||||
document.querySelectorAll(".segmented button").forEach((b) => {
|
||||
const c = b.querySelector(".chip-count");
|
||||
if (c) c.textContent = String(counts[b.dataset.filter] ?? "");
|
||||
});
|
||||
const visible = state.titles.filter(matchesFilter).filter(matchesQuery)
|
||||
.sort((a, b) => (a.name || a.title_id).localeCompare(b.name || b.title_id, undefined, { sensitivity: "base", numeric: true }));
|
||||
els.gameGrid.replaceChildren();
|
||||
@@ -328,29 +348,28 @@ function renderGames() {
|
||||
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
|
||||
}
|
||||
|
||||
// Mutually-exclusive bucket per game for the filter chips.
|
||||
// One bucket per game for the filter chips, answering a single question: is
|
||||
// there something to get for this console? Where the patch comes from (Sony,
|
||||
// Prospero) and whether it installs or only downloads is shown on the card.
|
||||
function gameCategory(game) {
|
||||
// Updating is the queue — jobs waiting for a pool slot. An actively
|
||||
// downloading game stays under Updatable so you don't lose sight of it
|
||||
// while drilling into the queue; same for paused (the user knows where
|
||||
// it is and can resume from the card) and installing.
|
||||
if (game._jobState === "queued") return "updating";
|
||||
// checking is transient (version lookup still running); keep it visible under
|
||||
// Updatable rather than letting it fall out of every specific filter.
|
||||
if (game.status === "checking") return "updatable";
|
||||
if (game.patch_title_match === false) return "blocked";
|
||||
// Updatable means PatchDL can install it. Download-only titles (shadowmounts,
|
||||
// shared-master patches) count as "can't update" unless a download of
|
||||
// theirs is already running or paused.
|
||||
if (game.status === "available" && isInstallAllowed(game)) return "updatable";
|
||||
if (game.status === "available" && isDownloadAllowed(game) &&
|
||||
(game.downloading || game.resumable)) return "updatable";
|
||||
if (game.status === "available") return "blocked";
|
||||
if (!sourcePolicy(game).allow_install && !sourcePolicy(game).allow_download) return "blocked";
|
||||
if (game.status === "incompatible_fw") return "needsfw";
|
||||
const policy = sourcePolicy(game);
|
||||
// Not supported = PatchDL can't install it (fake packages, mounted images).
|
||||
if (game.patch_title_match === false || !policy.allow_install)
|
||||
return "blocked";
|
||||
if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState)
|
||||
return "updates";
|
||||
if (game.status === "checking" || game.status === "available" || hasOlderPatch(game))
|
||||
return "updates";
|
||||
return "uptodate";
|
||||
}
|
||||
|
||||
// Sony's newest patch needs newer firmware, but Prospero knows an older one
|
||||
// that runs here and is newer than what is installed.
|
||||
function hasOlderPatch(game) {
|
||||
return game.status === "incompatible_fw" && Boolean(game.prospero_version) &&
|
||||
game.prospero_version > (game.installed_version || "");
|
||||
}
|
||||
|
||||
function matchesFilter(game) {
|
||||
if (state.filter === "all") return true;
|
||||
return gameCategory(game) === state.filter;
|
||||
@@ -412,11 +431,7 @@ function createGameCard(game) {
|
||||
${storagePill(game)}
|
||||
${sourcePill(game)}
|
||||
</div>
|
||||
<div class="versions">
|
||||
<span>Installed <b>${escapeHtml(game.installed_version || "--")}</b></span>
|
||||
<span>Compatible <b>${escapeHtml(game.compatible_version || "None")}</b></span>
|
||||
<span>Latest <b>${escapeHtml(game.latest_version || "--")}</b>${game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : ""}</span>
|
||||
</div>
|
||||
<div class="versions">${versionsHtml(game)}</div>
|
||||
`;
|
||||
|
||||
const actions = document.createElement("div");
|
||||
@@ -496,12 +511,15 @@ function primaryButton(game) {
|
||||
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
|
||||
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
|
||||
if (game.patch_title_match === false) return null;
|
||||
if (canGetOlderPatch(game))
|
||||
return { label: "Old FW patch", action: "guide", variant: "update", hint: `Get ${game.prospero_version} from Prospero Patches, the newest patch that runs on this firmware.` };
|
||||
if (game.resumable && isDownloadAllowed(game))
|
||||
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
|
||||
if (game.downloaded && game.status === "available" && isInstallAllowed(game))
|
||||
const hasPatch = game.status === "available" || Boolean(game.manual_version);
|
||||
if (game.downloaded && hasPatch && isInstallAllowed(game))
|
||||
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
|
||||
if (game.downloaded && game.status === "available") return null;
|
||||
if (game.status !== "available") return null;
|
||||
if (game.downloaded && hasPatch) return null;
|
||||
if (!hasPatch) return null;
|
||||
if (!isDownloadAllowed(game)) return null;
|
||||
return state.config.install_after_download && isInstallAllowed(game)
|
||||
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
|
||||
@@ -517,21 +535,52 @@ function stopButton(game) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Installed -> what PatchDL can get for this console -> newest overall with
|
||||
// the firmware it needs (left out when it's the same as the offered update).
|
||||
function versionsHtml(game) {
|
||||
const v = (x) => `<b>${escapeHtml(x)}</b>`;
|
||||
const parts = [`<span>Installed ${v(game.installed_version || "--")}</span>`];
|
||||
let offered = "";
|
||||
if (game.manual_version) {
|
||||
offered = game.manual_version;
|
||||
parts.push(`<span>Update ${v(offered)} · from Prospero</span>`);
|
||||
} else if (game.status === "available" && game.compatible_version) {
|
||||
offered = game.compatible_version;
|
||||
parts.push(`<span>Update ${v(offered)}</span>`);
|
||||
} else if (hasOlderPatch(game)) {
|
||||
offered = game.prospero_version;
|
||||
const fw = game.prospero_required_fw ? ` · needs FW ${escapeHtml(game.prospero_required_fw)}` : "";
|
||||
parts.push(`<span class="prospero-line">Update ${v(offered)}${fw} · <a href="https://prosperopatches.com/${encodeURIComponent(game.title_id)}" target="_blank" rel="noopener">Prospero</a></span>`);
|
||||
} else if (game.status === "incompatible_fw" && !game.prospero_done) {
|
||||
parts.push(`<span>Looking for a patch for this firmware\u2026</span>`);
|
||||
}
|
||||
const job = state.downloads.find((d) => d.title_id === game.title_id);
|
||||
if (job && job.state === "error" && Number(job.rc) === -3)
|
||||
parts.push(`<span class="card-note is-warn">${noSpaceText(job.need_bytes, job.free_bytes)}</span>`);
|
||||
if (game.latest_version && game.latest_version !== offered) {
|
||||
const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : "";
|
||||
parts.push(`<span>Newest ${v(game.latest_version)}${fw}</span>`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function statusPill(game) {
|
||||
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
||||
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
|
||||
if (sourceType(game) === "fake") return `<span class="pill">Not supported</span>`;
|
||||
if (game.status === "blocked" || !sourcePolicy(game).allow_install)
|
||||
return `<span class="pill">Not supported</span>`;
|
||||
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
|
||||
if (game.status === "available" && !isInstallAllowed(game))
|
||||
return `<span class="pill warn">Update · download only</span>`;
|
||||
if (game.manual_version || hasOlderPatch(game)) return `<span class="pill prospero">Via Prospero</span>`;
|
||||
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
|
||||
if (game.status === "incompatible_fw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
|
||||
if (game.status === "incompatible_fw")
|
||||
return `<span class="pill">Newest needs FW ${escapeHtml(game.latest_required_fw || "?")}</span>`;
|
||||
if (game.status === "up_to_date") return `<span class="pill">Up to date</span>`;
|
||||
return `<span class="pill">No patch info</span>`;
|
||||
}
|
||||
|
||||
function storagePill(game) {
|
||||
return hasSharedStorage(game) ? `<span class="pill warn">Shared master</span>` : "";
|
||||
return hasSharedStorage(game) && sourcePolicy(game).allow_install
|
||||
? `<span class="pill warn">Shared master</span>` : "";
|
||||
}
|
||||
|
||||
function sourcePill(game) {
|
||||
@@ -608,6 +657,7 @@ function reconcileFromJobs(jobs) {
|
||||
g._localDownloading = false; // the pool now tracks it
|
||||
g._jobState = j.state;
|
||||
if (j.state === "active" || j.state === "queued") {
|
||||
g._spaceWarned = false;
|
||||
g.downloading = true;
|
||||
g.resumable = false;
|
||||
g._wasActive = true;
|
||||
@@ -631,7 +681,10 @@ function reconcileFromJobs(jobs) {
|
||||
// network failure. Reflect that so primaryButton shows "Resume" and the
|
||||
// red Cancel stays available to delete the kept partial.
|
||||
const bytes = Number(j.bytes) || 0;
|
||||
if (g._wasActive) {
|
||||
if (Number(j.rc) === -3) {
|
||||
if (!g._spaceWarned) showToast(`${g.name}: ${noSpaceText(j.need_bytes, j.free_bytes)}`);
|
||||
g._spaceWarned = true;
|
||||
} else if (g._wasActive) {
|
||||
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`);
|
||||
}
|
||||
g._wasActive = false;
|
||||
@@ -865,7 +918,7 @@ async function saveConfig() {
|
||||
// is on, the per-job auto-install pipeline kicks in once each download
|
||||
// finishes — no further client action needed.
|
||||
async function updateAll() {
|
||||
const targets = state.games.filter((g) =>
|
||||
const targets = state.titles.filter((g) =>
|
||||
g.status === "available" && isInstallAllowed(g) &&
|
||||
!g.downloading && !g.downloaded);
|
||||
if (!targets.length) {
|
||||
@@ -955,6 +1008,7 @@ async function cancelDownload(titleId) {
|
||||
game.downloaded = false;
|
||||
game.resumable = false;
|
||||
game.partial_bytes = 0;
|
||||
game.manual_version = null; // the server drops the pasted link on cancel
|
||||
}
|
||||
state.downloads = state.downloads.filter((i) => i.title_id !== titleId);
|
||||
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`);
|
||||
@@ -971,6 +1025,7 @@ async function runTitleAction(titleId, action) {
|
||||
else if (action === "install") await doInstall(game);
|
||||
else if (action === "pause") await doPause(game);
|
||||
else if (action === "cancel") await cancelDownload(titleId);
|
||||
else if (action === "guide") openGuide(game);
|
||||
}
|
||||
|
||||
// Pause only sends the signal; the in-flight doDownload() request returns its
|
||||
@@ -1008,7 +1063,7 @@ function hasSharedStorage(game) {
|
||||
function isDownloadAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled !== false &&
|
||||
game.compatible_version &&
|
||||
(game.compatible_version || game.manual_version) &&
|
||||
game.patch_title_match !== false &&
|
||||
sourcePolicy(game).allow_download
|
||||
);
|
||||
@@ -1016,9 +1071,8 @@ function isDownloadAllowed(game) {
|
||||
function isInstallAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled !== false &&
|
||||
game.compatible_version &&
|
||||
(game.compatible_version || game.manual_version) &&
|
||||
game.patch_title_match !== false &&
|
||||
!hasSharedStorage(game) &&
|
||||
sourcePolicy(game).allow_install
|
||||
);
|
||||
}
|
||||
@@ -1047,10 +1101,10 @@ async function postJson(url, body) {
|
||||
|
||||
const REASON_TEXT = {
|
||||
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
|
||||
cross_region_storage_unsupported: "Patch bytes are signed for a shared master title; this standalone installer cannot retarget them.",
|
||||
install_not_allowed_for_source: "Install blocked for this source.",
|
||||
source_unknown: "Source unknown — blocked.",
|
||||
source_fake: "Fake packages are not supported.",
|
||||
source_shadowmount: "Shadowmount titles are not supported.",
|
||||
no_compatible_patch: "No compatible patch available.",
|
||||
download_failed: "Download failed.",
|
||||
download_in_progress: "Another download is already running.",
|
||||
@@ -1058,12 +1112,514 @@ const REASON_TEXT = {
|
||||
title_disabled: "This title is disabled.",
|
||||
download_paused: "Download paused.",
|
||||
not_downloading: "Nothing is downloading for this title.",
|
||||
not_an_sc_link: "That is not the SC link. Under Additional Files, copy the link of the SC button.",
|
||||
url_title_mismatch: "That link belongs to a different game.",
|
||||
url_not_allowed: "Only Sony CDN links (https) are accepted.",
|
||||
url_invalid: "The link contains unexpected characters.",
|
||||
no_space: "Not enough free space on the PS5.",
|
||||
manifest_unavailable: "The PS5 could not load the package list from Sony. Copy the SC link again and retry.",
|
||||
download_exists: "A download already exists for this game. Cancel it first.",
|
||||
source_not_supported: "Downloads are not supported for this source.",
|
||||
};
|
||||
// The server keeps 2 GB free on /data on top of the package itself.
|
||||
function noSpaceText(need, free) {
|
||||
return `Not enough space on the PS5: needs ${formatBytes(need)} plus 2 GB headroom, ${formatBytes(free)} free.`;
|
||||
}
|
||||
|
||||
function reasonText(error) {
|
||||
const r = error && error.body && error.body.reason;
|
||||
return (r && REASON_TEXT[r]) || (error && error.message) || "failed";
|
||||
}
|
||||
|
||||
/* ---------------- QR code (byte mode, ECC level M, versions 1-6) ----------------
|
||||
Enough for ~100 bytes, i.e. any PatchDL link. Follows ISO/IEC 18004; the
|
||||
structure mirrors Project Nayuki's reference encoder. */
|
||||
|
||||
const QR_ECC_M = [0, 10, 16, 26, 18, 24, 16]; // ECC codewords per block
|
||||
const QR_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4]; // number of blocks
|
||||
|
||||
function qrRawModules(ver) {
|
||||
let n = (16 * ver + 128) * ver + 64;
|
||||
if (ver >= 2) {
|
||||
const align = Math.floor(ver / 7) + 2;
|
||||
n -= (25 * align - 10) * align - 55;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function qrDataCodewords(ver) {
|
||||
return Math.floor(qrRawModules(ver) / 8) - QR_ECC_M[ver] * QR_BLOCKS_M[ver];
|
||||
}
|
||||
|
||||
function qrGfMul(x, y) {
|
||||
let z = 0;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
||||
z ^= ((y >>> i) & 1) * x;
|
||||
}
|
||||
return z;
|
||||
}
|
||||
|
||||
function qrRsDivisor(degree) {
|
||||
const r = new Array(degree - 1).fill(0);
|
||||
r.push(1);
|
||||
let root = 1;
|
||||
for (let i = 0; i < degree; i++) {
|
||||
for (let j = 0; j < r.length; j++) {
|
||||
r[j] = qrGfMul(r[j], root);
|
||||
if (j + 1 < r.length) r[j] ^= r[j + 1];
|
||||
}
|
||||
root = qrGfMul(root, 0x02);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function qrRsRemainder(data, divisor) {
|
||||
const r = divisor.map(() => 0);
|
||||
for (const b of data) {
|
||||
const factor = b ^ r.shift();
|
||||
r.push(0);
|
||||
divisor.forEach((coef, i) => { r[i] ^= qrGfMul(coef, factor); });
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Returns a square boolean matrix (true = dark), or null if text is too long.
|
||||
function qrEncode(text) {
|
||||
const bytes = Array.from(new TextEncoder().encode(text));
|
||||
let ver = 1;
|
||||
while (ver <= 6 && 4 + 8 + bytes.length * 8 > qrDataCodewords(ver) * 8) ver++;
|
||||
if (ver > 6) return null;
|
||||
|
||||
// Data bit stream: byte mode, 8-bit length, payload, terminator, padding.
|
||||
const bits = [];
|
||||
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
||||
push(0x4, 4);
|
||||
push(bytes.length, 8);
|
||||
bytes.forEach((b) => push(b, 8));
|
||||
const cap = qrDataCodewords(ver) * 8;
|
||||
push(0, Math.min(4, cap - bits.length));
|
||||
push(0, (8 - (bits.length % 8)) % 8);
|
||||
for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) push(pad, 8);
|
||||
const data = [];
|
||||
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
|
||||
|
||||
// Split into blocks, add Reed-Solomon ECC, interleave.
|
||||
const numBlocks = QR_BLOCKS_M[ver];
|
||||
const eccLen = QR_ECC_M[ver];
|
||||
const rawCodewords = Math.floor(qrRawModules(ver) / 8);
|
||||
const numShort = numBlocks - (rawCodewords % numBlocks);
|
||||
const shortLen = Math.floor(rawCodewords / numBlocks);
|
||||
const divisor = qrRsDivisor(eccLen);
|
||||
const blocks = [];
|
||||
for (let i = 0, k = 0; i < numBlocks; i++) {
|
||||
const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
|
||||
k += dat.length;
|
||||
const ecc = qrRsRemainder(dat, divisor);
|
||||
if (i < numShort) dat.push(0);
|
||||
blocks.push(dat.concat(ecc));
|
||||
}
|
||||
const codewords = [];
|
||||
for (let i = 0; i < blocks[0].length; i++)
|
||||
blocks.forEach((blk, j) => {
|
||||
if (i !== shortLen - eccLen || j >= numShort) codewords.push(blk[i]);
|
||||
});
|
||||
|
||||
// Function patterns.
|
||||
const size = ver * 4 + 17;
|
||||
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
|
||||
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
||||
const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
|
||||
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(([cx, cy]) => {
|
||||
for (let dy = -4; dy <= 4; dy++)
|
||||
for (let dx = -4; dx <= 4; dx++) {
|
||||
const x = cx + dx, y = cy + dy;
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) continue;
|
||||
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
set(x, y, d !== 2 && d !== 4);
|
||||
}
|
||||
});
|
||||
if (ver >= 2) {
|
||||
const pos = [6, size - 7]; // versions 2-6 have one alignment pattern
|
||||
const c = pos[1];
|
||||
for (let dy = -2; dy <= 2; dy++)
|
||||
for (let dx = -2; dx <= 2; dx++)
|
||||
set(c + dx, c + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
||||
}
|
||||
const drawFormat = (mask) => {
|
||||
const data5 = (0 << 3) | mask; // ECC level M = 0b00
|
||||
let rem = data5;
|
||||
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
||||
const f = ((data5 << 10) | rem) ^ 0x5412;
|
||||
const bit = (i) => ((f >>> i) & 1) === 1;
|
||||
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
|
||||
set(8, 7, bit(6));
|
||||
set(8, 8, bit(7));
|
||||
set(7, 8, bit(8));
|
||||
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
|
||||
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
|
||||
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
|
||||
set(8, size - 8, true);
|
||||
};
|
||||
drawFormat(0); // reserve the format areas before placing data
|
||||
|
||||
// Data placement: two-column zigzag from the bottom right.
|
||||
let i = 0;
|
||||
for (let right = size - 1; right >= 1; right -= 2) {
|
||||
if (right === 6) right = 5;
|
||||
for (let vert = 0; vert < size; vert++)
|
||||
for (let j = 0; j < 2; j++) {
|
||||
const x = right - j;
|
||||
const upward = ((right + 1) & 2) === 0;
|
||||
const y = upward ? size - 1 - vert : vert;
|
||||
if (!fn[y][x] && i < codewords.length * 8) {
|
||||
mod[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maskFn = [
|
||||
(x, y) => (x + y) % 2 === 0,
|
||||
(x, y) => y % 2 === 0,
|
||||
(x) => x % 3 === 0,
|
||||
(x, y) => (x + y) % 3 === 0,
|
||||
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
||||
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
||||
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
||||
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
||||
];
|
||||
const applyMask = (m) => {
|
||||
for (let y = 0; y < size; y++)
|
||||
for (let x = 0; x < size; x++)
|
||||
if (!fn[y][x] && maskFn[m](x, y)) mod[y][x] = !mod[y][x];
|
||||
};
|
||||
// Simplified penalty (runs, 2x2 blocks, balance) to pick a readable mask.
|
||||
const penalty = () => {
|
||||
let p = 0, dark = 0;
|
||||
for (let a = 0; a < size; a++) {
|
||||
let runR = 1, runC = 1;
|
||||
for (let b = 0; b < size; b++) {
|
||||
if (mod[a][b]) dark++;
|
||||
if (b > 0) {
|
||||
runR = mod[a][b] === mod[a][b - 1] ? runR + 1 : 1;
|
||||
runC = mod[b][a] === mod[b - 1][a] ? runC + 1 : 1;
|
||||
if (runR === 5) p += 3; else if (runR > 5) p++;
|
||||
if (runC === 5) p += 3; else if (runC > 5) p++;
|
||||
}
|
||||
if (a > 0 && b > 0) {
|
||||
const c = mod[a][b];
|
||||
if (c === mod[a - 1][b] && c === mod[a][b - 1] && c === mod[a - 1][b - 1]) p += 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
const total = size * size;
|
||||
p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
|
||||
return p;
|
||||
};
|
||||
let best = 0, bestScore = Infinity;
|
||||
for (let m = 0; m < 8; m++) {
|
||||
applyMask(m);
|
||||
drawFormat(m);
|
||||
const score = penalty();
|
||||
if (score < bestScore) { best = m; bestScore = score; }
|
||||
applyMask(m); // XOR again to undo
|
||||
}
|
||||
applyMask(best);
|
||||
drawFormat(best);
|
||||
return mod;
|
||||
}
|
||||
|
||||
// SVG markup for a QR matrix, dark on white with the required 4-module margin.
|
||||
function qrSvg(matrix) {
|
||||
const n = matrix.length, q = 4, s = n + q * 2;
|
||||
let d = "";
|
||||
matrix.forEach((row, y) => row.forEach((dark, x) => {
|
||||
if (dark) d += `M${x + q},${y + q}h1v1h-1z`;
|
||||
}));
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" class="qr" viewBox="0 0 ${s} ${s}" shape-rendering="crispEdges" role="img" aria-label="QR code"><rect width="${s}" height="${s}" fill="#fff" stroke="none"/><path d="${d}" fill="#000" stroke="none"/></svg>`;
|
||||
}
|
||||
|
||||
/* ---------------- older patch guide (Prospero) ---------------- */
|
||||
|
||||
// Sony's version.xml only lists the newest patch. When that needs newer
|
||||
// firmware, Prospero Patches still has older ones, but its links sit behind a
|
||||
// human check, so the user copies one link and PatchDL does the rest. Copying
|
||||
// is awkward in the PS5 browser, hence the QR hand-off to a phone.
|
||||
|
||||
const guide = { el: null, game: null, fromLink: false, started: false, busy: false,
|
||||
lastUrl: "", timer: null, returnFocus: null, onKey: null };
|
||||
|
||||
function canGetOlderPatch(game) {
|
||||
return hasOlderPatch(game) &&
|
||||
!game.manual_version && !game.downloading && !game.downloaded &&
|
||||
!game.resumable && !game.installing &&
|
||||
isDownloadAllowed({ ...game, manual_version: game.prospero_version });
|
||||
}
|
||||
|
||||
// A link a phone can open. A page opened on the PS5 itself runs on 127.0.0.1,
|
||||
// so the server reports its LAN address for this.
|
||||
function phoneLink(game) {
|
||||
const h = location.hostname;
|
||||
let base = location.origin;
|
||||
if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") {
|
||||
if (!state.status.lan_ip) return null;
|
||||
base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`;
|
||||
}
|
||||
return `${base}/#get/${encodeURIComponent(game.title_id)}`;
|
||||
}
|
||||
|
||||
function openGuideFromHash() {
|
||||
const m = location.hash.match(/^#get\/([A-Za-z0-9_.-]+)$/);
|
||||
if (!m) return;
|
||||
const game = state.titles.find((g) => g.title_id === m[1]);
|
||||
if (!game) { showToast("That game is not installed on this PS5."); clearGuideHash(); return; }
|
||||
if (!canGetOlderPatch(game)) {
|
||||
showToast(game.manual_version || game.downloading || game.downloaded
|
||||
? `${game.name}: the older patch is already on its way.`
|
||||
: `${game.name}: there is no older patch to fetch.`);
|
||||
clearGuideHash();
|
||||
return;
|
||||
}
|
||||
openGuide(game, true);
|
||||
}
|
||||
|
||||
function clearGuideHash() {
|
||||
if (location.hash.startsWith("#get/"))
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
}
|
||||
|
||||
// The PS5 browser can't copy links, so on the console the guide only hands
|
||||
// over to a phone. Opened via the home-screen tile the page runs on 127.0.0.1.
|
||||
function isConsoleBrowser() {
|
||||
const h = location.hostname;
|
||||
return /PlayStation/i.test(navigator.userAgent) || h === "127.0.0.1" || h === "localhost";
|
||||
}
|
||||
|
||||
function openGuide(game, fromLink = false) {
|
||||
closeGuide();
|
||||
Object.assign(guide, { game, fromLink, started: false, busy: false, lastUrl: "",
|
||||
returnFocus: document.activeElement });
|
||||
const ver = escapeHtml(game.prospero_version);
|
||||
const size = game.prospero_size ? ` (${escapeHtml(game.prospero_size)})` : "";
|
||||
const onConsole = !fromLink && isConsoleBrowser();
|
||||
const link = fromLink ? null : phoneLink(game);
|
||||
const matrix = link ? qrEncode(link) : null;
|
||||
|
||||
const head = `
|
||||
<header class="guide-head">
|
||||
<div>
|
||||
<p class="eyebrow">Old FW patch</p>
|
||||
<h2 id="guideTitle">Get ${ver}</h2>
|
||||
<p class="guide-game">${escapeHtml(game.name)}</p>
|
||||
</div>
|
||||
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
|
||||
</header>`;
|
||||
|
||||
let body;
|
||||
if (onConsole) {
|
||||
body = matrix
|
||||
? `<div class="guide-handoff">${qrSvg(matrix)}
|
||||
<p>Scan this code with your phone, or open this link on your phone:</p>
|
||||
<code>${escapeHtml(link)}</code>
|
||||
</div>`
|
||||
: `<p class="guide-why">Open PatchDL on your phone (this console's IP address, port ${escapeHtml(String(state.status.http_port || 12880))}) and pick the game there.</p>`;
|
||||
body += `<div class="guide-status" role="status" aria-live="polite"></div>`;
|
||||
} else {
|
||||
const handoff = matrix
|
||||
? `<div class="guide-qr">${qrSvg(matrix)}<div>
|
||||
<strong>Easier on your phone</strong>
|
||||
<span>Scan this with your phone camera and continue there. This screen follows along.</span>
|
||||
<code>${escapeHtml(link)}</code>
|
||||
</div></div>`
|
||||
: "";
|
||||
body = `
|
||||
<p class="guide-why">Sony only offers ${escapeHtml(game.latest_version || "the newest patch")}, which needs FW ${escapeHtml(game.latest_required_fw || "?")}. Prospero Patches still lists <b>${ver}</b>, which runs on FW ${escapeHtml(game.prospero_required_fw || "?")}.</p>
|
||||
${handoff}
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<div class="guide-step-text"><strong>Open Prospero Patches</strong>
|
||||
<span>Tap version <b>${ver}</b>${size}. If a human check shows up, confirm it.</span></div>
|
||||
<a class="row-button is-update guide-open" href="https://prosperopatches.com/${encodeURIComponent(game.title_id)}" target="_blank" rel="noopener">Open Prospero</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-step-text"><strong>Copy the SC link</strong>
|
||||
<span>Under <b>Additional Files</b>, press and hold the <b>SC</b> button and choose <i>Copy Link</i>. PatchDL takes the full piece list from it.</span></div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-step-text"><strong>Paste it here</strong>
|
||||
<span>The download starts on your PS5 as soon as the link is in.</span></div>
|
||||
<div class="guide-paste">
|
||||
<input class="guide-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="https://…_sc.pkg" aria-label="SC link">
|
||||
<button class="row-button guide-start" type="button">Start</button>
|
||||
</div>
|
||||
<div class="guide-status" role="status" aria-live="polite"></div>
|
||||
</li>
|
||||
</ol>`;
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "guide-backdrop";
|
||||
wrap.innerHTML = `<section class="guide${onConsole ? " is-handoff" : ""}" role="dialog" aria-modal="true" aria-labelledby="guideTitle">${head}${body}</section>`;
|
||||
document.body.appendChild(wrap);
|
||||
guide.el = wrap;
|
||||
|
||||
const input = wrap.querySelector(".guide-input");
|
||||
if (input) {
|
||||
const tryInput = () => {
|
||||
const url = extractLink(input.value);
|
||||
if (url && url !== guide.lastUrl && /\.(pkg|crc|json)$/i.test(url)) guideSubmit(url, false);
|
||||
};
|
||||
input.addEventListener("focus", () => input.select()); // a paste then replaces the old link
|
||||
input.addEventListener("input", tryInput);
|
||||
input.addEventListener("paste", () => setTimeout(tryInput, 0));
|
||||
wrap.querySelector(".guide-start").addEventListener("click",
|
||||
() => guideSubmit(extractLink(input.value), false));
|
||||
}
|
||||
wrap.querySelector(".guide-close").addEventListener("click", closeGuide);
|
||||
wrap.addEventListener("click", (e) => { if (e.target === wrap) closeGuide(); });
|
||||
guide.onKey = (e) => { if (e.key === "Escape") closeGuide(); };
|
||||
document.addEventListener("keydown", guide.onKey);
|
||||
|
||||
guideStatus("idle", matrix ? "Waiting for the link from your phone…" : "");
|
||||
guide.timer = setInterval(guidePoll, 2000);
|
||||
(fromLink ? wrap.querySelector(".guide-open") : wrap.querySelector(".guide-close")).focus();
|
||||
}
|
||||
|
||||
function closeGuide() {
|
||||
if (!guide.el) return;
|
||||
clearInterval(guide.timer);
|
||||
document.removeEventListener("keydown", guide.onKey);
|
||||
guide.el.remove();
|
||||
guide.el = null;
|
||||
guide.game = null;
|
||||
clearGuideHash();
|
||||
if (guide.returnFocus && document.contains(guide.returnFocus)) guide.returnFocus.focus();
|
||||
}
|
||||
|
||||
// The last link in the field wins, so pasting a new link next to a rejected
|
||||
// one (instead of over it) still picks up the new one.
|
||||
function extractLink(text) {
|
||||
const all = String(text || "").match(/https?:\/\/(?:(?!https?:\/\/)[^\s"'<>])+/g) || [];
|
||||
const pkgs = all.filter((u) => /\.(pkg|crc|json)$/i.test(u));
|
||||
return (pkgs.length ? pkgs[pkgs.length - 1] : all[all.length - 1]) || "";
|
||||
}
|
||||
|
||||
function guideStatus(kind, message, action) {
|
||||
if (!guide.el) return;
|
||||
const box = guide.el.querySelector(".guide-status");
|
||||
box.innerHTML = message ? `<p class="guide-msg is-${kind}">${message}</p>` : "";
|
||||
if (action) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "row-button is-ghost guide-action";
|
||||
b.textContent = action.label;
|
||||
b.addEventListener("click", action.run);
|
||||
box.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
async function guideSubmit(url, force) {
|
||||
const game = guide.game;
|
||||
if (!game || guide.busy) return;
|
||||
if (!url) { guideStatus("error", "Paste the SC link from Prospero first."); return; }
|
||||
guide.busy = true;
|
||||
guide.lastUrl = url;
|
||||
const start = guide.el.querySelector(".guide-start");
|
||||
start.disabled = true;
|
||||
guideStatus("busy", "Checking the link on your PS5…");
|
||||
try {
|
||||
await postJson("/api/download_url", {
|
||||
title_id: game.title_id, url, version: game.prospero_version, force,
|
||||
});
|
||||
} catch (error) {
|
||||
guide.busy = false;
|
||||
if (!guide.el) return;
|
||||
start.disabled = false;
|
||||
const b = (error && error.body) || {};
|
||||
if (b.reason === "no_space") {
|
||||
guideStatus("error", escapeHtml(noSpaceText(b.need_bytes, b.free_bytes)));
|
||||
} else if (b.reason === "size_mismatch") {
|
||||
guideStatus("warn",
|
||||
`This link is ${formatBytes(b.found_bytes)} in ${Number(b.pieces) || "?"} pieces, but ${escapeHtml(game.prospero_version)} should be about ${escapeHtml(b.expected || "?")}. It is probably a different version.`,
|
||||
{ label: "Download anyway", run: () => guideSubmit(url, true) });
|
||||
} else {
|
||||
guideStatus("error", escapeHtml(reasonText(error)) + (b.detail ? ` (${escapeHtml(b.detail)})` : ""));
|
||||
}
|
||||
state.logs.push(`[${timeNow()}] Prospero link for ${game.title_id} refused: ${reasonText(error)}`);
|
||||
renderLogs();
|
||||
return;
|
||||
}
|
||||
guide.busy = false;
|
||||
manualDownloadStarted(game);
|
||||
if (!guide.fromLink) {
|
||||
showToast(`${game.name}: download of ${game.prospero_version} started.`);
|
||||
closeGuide();
|
||||
return;
|
||||
}
|
||||
guide.started = true;
|
||||
guideStatus("ok", "Download started on your PS5.");
|
||||
}
|
||||
|
||||
// Card state for a download that the server has accepted (from this page or
|
||||
// from a phone); the pool's job list takes over from here.
|
||||
function manualDownloadStarted(game) {
|
||||
game.manual_version = game.prospero_version;
|
||||
game.downloading = true;
|
||||
game._localDownloading = true;
|
||||
game._localSince = Date.now();
|
||||
game._autoInstalled = false;
|
||||
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
|
||||
state.downloads.push({ title_id: game.title_id, name: game.name, version: game.manual_version,
|
||||
state: "queued", progress: 0, bytes: 0, total_bytes: 0 });
|
||||
state.logs.push(`[${timeNow()}] Download queued (Prospero): ${game.title_id} ${game.manual_version}`);
|
||||
renderGames(); renderLogs();
|
||||
startDownloadPolling();
|
||||
}
|
||||
|
||||
async function guidePoll() {
|
||||
const game = guide.game;
|
||||
if (!game) return;
|
||||
let jobs;
|
||||
try {
|
||||
const r = await fetch(API.downloads, { cache: "no-store" });
|
||||
if (!r.ok) return;
|
||||
jobs = await r.json();
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
if (guide.game !== game) return;
|
||||
const j = (jobs || []).find((x) => x.title_id === game.title_id);
|
||||
if (!j) return;
|
||||
if (!guide.started) {
|
||||
// Only a job that is actually running means a link arrived from elsewhere;
|
||||
// an old failed or paused entry does not.
|
||||
if (j.state !== "queued" && j.state !== "active") return;
|
||||
manualDownloadStarted(game);
|
||||
if (!guide.fromLink) {
|
||||
showToast(`${game.name}: link received, download of ${game.prospero_version} started.`);
|
||||
closeGuide();
|
||||
return;
|
||||
}
|
||||
guide.started = true;
|
||||
}
|
||||
const total = Number(j.total_bytes) || 0;
|
||||
const pct = total ? Math.min(100, Math.round((Number(j.bytes) || 0) * 100 / total)) : 0;
|
||||
const note = {
|
||||
queued: "Queued on your PS5…",
|
||||
active: "Downloading on your PS5. You can close this page.",
|
||||
paused: "Paused on your PS5.",
|
||||
done: state.config.install_after_download
|
||||
? "Downloaded. Your PS5 installs it now."
|
||||
: "Downloaded. Press Install on the game in PatchDL.",
|
||||
error: "The download stopped. Resume it from the game in PatchDL.",
|
||||
}[j.state] || "";
|
||||
guideStatus(j.state === "error" ? "error" : "ok",
|
||||
`${note}<span class="guide-progress"><span class="progress"><i style="width:${pct}%"></i></span>` +
|
||||
`<span>${total ? `${formatBytes(j.bytes)} / ${formatBytes(total)} · ` : ""}${pct}%</span></span>`);
|
||||
}
|
||||
|
||||
/* ---------------- formatting ---------------- */
|
||||
|
||||
function initials(name) {
|
||||
|
||||
@@ -132,12 +132,10 @@
|
||||
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Filter">
|
||||
<button class="is-selected" data-filter="updatable" aria-pressed="true">Updatable</button>
|
||||
<button data-filter="updating" aria-pressed="false">Updating</button>
|
||||
<button data-filter="uptodate" aria-pressed="false">Up to date</button>
|
||||
<button data-filter="needsfw" aria-pressed="false">Needs FW</button>
|
||||
<button data-filter="blocked" aria-pressed="false">Can't update</button>
|
||||
<button data-filter="all" aria-pressed="false">All</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -338,6 +338,8 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
.segmented button.is-selected { background: var(--surface); color: var(--ink); }
|
||||
.chip-count { margin-left: 4px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.chip-count:empty { display: none; }
|
||||
|
||||
/* ---------------- game cards ---------------- */
|
||||
|
||||
@@ -393,6 +395,10 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
color: var(--muted);
|
||||
}
|
||||
.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; }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
@@ -418,6 +424,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.pill.warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
|
||||
.pill.external { border-color: rgba(88, 166, 255, 0.34); background: var(--blue-soft); color: var(--blue); }
|
||||
.pill.shadow { border-color: rgba(181, 146, 255, 0.34); background: rgba(181, 146, 255, 0.14); color: #c7b1ff; }
|
||||
.pill.prospero { border-color: rgba(64, 201, 198, 0.38); background: rgba(64, 201, 198, 0.14); color: #6fe0dc; }
|
||||
.pill.blocked { border-color: rgba(255, 107, 107, 0.34); background: var(--red-soft); color: var(--red); }
|
||||
.pill.live { border-color: rgba(56, 193, 114, 0.5); background: var(--green-soft); color: var(--green-dark); }
|
||||
.pill.live::before {
|
||||
@@ -629,6 +636,125 @@ pre {
|
||||
}
|
||||
.toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
/* ---------------- older patch guide (Prospero) ---------------- */
|
||||
|
||||
.guide-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15; /* below the toast (20) */
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 560px); /* an auto track would grow to the content */
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
padding: 24px 16px;
|
||||
overflow-y: auto;
|
||||
background: rgba(5, 7, 9, 0.72);
|
||||
}
|
||||
.guide {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
/* 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-why { margin: 0; color: var(--muted); line-height: 1.55; }
|
||||
.guide-why b { color: var(--ink); }
|
||||
|
||||
.guide-qr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
.guide.is-handoff .guide-head { text-align: left; }
|
||||
.guide-handoff { display: grid; justify-items: center; gap: 12px; }
|
||||
.guide-handoff .qr { width: min(420px, 100%); height: auto; aspect-ratio: 1; border-radius: 8px; }
|
||||
.guide-handoff p { margin: 0; color: var(--muted); line-height: 1.5; }
|
||||
.guide-handoff code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: 16px; }
|
||||
|
||||
.guide-steps { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; counter-reset: step; }
|
||||
.guide-steps li {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 14px 52px;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
counter-increment: step;
|
||||
}
|
||||
.guide-steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--green-soft);
|
||||
color: var(--green-dark);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.guide-step-text { display: grid; gap: 4px; }
|
||||
.guide-step-text span { color: var(--muted); line-height: 1.5; }
|
||||
.guide-step-text b { color: var(--ink); }
|
||||
.guide-open { width: auto; justify-self: start; padding: 0 18px; text-decoration: none; }
|
||||
|
||||
.guide-paste { display: flex; gap: 8px; }
|
||||
.guide-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 16px; /* below 16px iOS zooms the page on focus */
|
||||
}
|
||||
.guide-input::placeholder { color: var(--muted); }
|
||||
.guide-input:focus { outline: none; border-color: var(--blue); }
|
||||
.guide-start { width: auto; flex: none; min-height: 44px; padding: 0 18px; }
|
||||
|
||||
.guide-status:empty { display: none; }
|
||||
.guide-status { display: grid; gap: 8px; }
|
||||
.guide-msg { margin: 0; line-height: 1.5; }
|
||||
.guide-msg.is-idle, .guide-msg.is-busy { color: var(--muted); }
|
||||
.guide-msg.is-ok { color: var(--green-dark); }
|
||||
.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; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
/* Already on a phone: the hand-off QR has nothing to add. */
|
||||
.guide-qr { display: none; }
|
||||
.guide-backdrop { align-content: start; padding: 12px; }
|
||||
.guide { padding: 16px; }
|
||||
}
|
||||
|
||||
/* ---------------- responsive ---------------- */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||