mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-17 18:02:29 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fbdd4681 | ||
|
|
8053f5395b | ||
|
|
bde585eab7 | ||
|
|
eaab113d8a | ||
|
|
c3630000b6 | ||
|
|
72b9136ff5 | ||
|
|
c6f001f857 | ||
|
|
e72c25a56e | ||
|
|
bd08ad70c4 | ||
|
|
f0091943f2 | ||
|
|
c09f0481ab | ||
|
|
289688f727 | ||
|
|
533c25a2ac |
No files matched your search
@@ -0,0 +1 @@
|
||||
custom: ["https://paypal.me/oliverkoester"]
|
||||
@@ -5,14 +5,15 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Official game updates for your PS5, from any browser.</b><br>
|
||||
PatchDL finds the newest update for every game on your console, downloads it at full speed and installs it.<br>
|
||||
<b>Official updates for your PS5 and PS4 games, from any browser.</b><br>
|
||||
PatchDL finds the newest update for every PS5 and PS4 game on your console, downloads it at full speed and installs it.<br>
|
||||
Run it from your phone, your laptop or the PS5 itself.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/v/release/knutwurst/ps5-patchdl?style=for-the-badge&label=release&color=22c55e" alt="Latest release">
|
||||
<img src="https://img.shields.io/github/downloads/knutwurst/ps5-patchdl/total?style=for-the-badge&color=22c55e" alt="Downloads">
|
||||
<img src="https://img.shields.io/badge/games-PS5%20%7C%20PS4-22c55e?style=for-the-badge" alt="PS5 and PS4 games">
|
||||
<img src="https://img.shields.io/badge/FW-1.00%E2%80%9313.60-22c55e?style=for-the-badge" alt="Firmware 1.00 to 13.60">
|
||||
<img src="https://img.shields.io/badge/UI-any%20browser-22c55e?style=for-the-badge" alt="Web UI in any browser">
|
||||
<img src="https://img.shields.io/github/license/knutwurst/ps5-patchdl?style=for-the-badge&color=3a3a3a" alt="License">
|
||||
@@ -24,19 +25,28 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If you like what I do, you can buy me a coffee*:
|
||||
<a href="https://paypal.me/oliverkoester"><img src="https://img.shields.io/badge/Donate-PayPal-22c55e?style=flat-square&logo=paypal&logoColor=white" alt="Donate via PayPal"></a><br>
|
||||
<sub>*It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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.
|
||||
- **PS5 and PS4 games.** PatchDL updates both, straight from Sony's servers. PS4 updates land where the console keeps them, next to the game.
|
||||
- **Your whole library at a glance.** Every installed game shows up with its icon, its version and the newest update Sony offers for it, sorted A to Z. Install or remove a game and the list follows within seconds, no restart needed.
|
||||
- **Update all.** One click queues every game that has an update. PatchDL works through the queue and installs each update as soon as it lands.
|
||||
- **Fast downloads.** Up to 16 parallel connections per update. Change the number in Settings while a download runs.
|
||||
- **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. 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.
|
||||
- **Reuses what's already there.** If the update package is already on the console, PatchDL checks it against Sony's checksums and downloads only the pieces that don't match.
|
||||
- **Keeps your storage tidy.** After an install, PatchDL confirms the game runs the new version and then deletes the package. Prefer to keep packages? Turn that off, and Settings lists every package on the console with its size and version, ready to delete.
|
||||
- **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.
|
||||
- **Live progress.** A banner at the top shows the current game, speed, time left and where you are in the queue. During an install, the game's card shows how much of the update the console has read.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -77,7 +87,7 @@ Start an update from the couch with your phone, check on it from your laptop, or
|
||||
No. It fetches updates for games that are already installed on your console. Nothing else.
|
||||
|
||||
**Which games does PatchDL update?**
|
||||
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.
|
||||
PS5 and PS4 games you installed from disc or from the PlayStation Store. PatchDL reads each game's package before it offers anything, and it leaves every other title alone.
|
||||
|
||||
**Where do the updates come from?**
|
||||
From Sony's official update servers, the same files your console downloads on its own. PatchDL downloads only from a short list of PlayStation CDN hosts. To find older patches it also asks [Prospero Patches](https://prosperopatches.com/) which versions exist for a game. That lookup sends the game's title ID and nothing else.
|
||||
@@ -88,6 +98,9 @@ Yes. Sony still hosts older patches, but its servers only tell your console abou
|
||||
**Does it change the updates?**
|
||||
No. Each package stays exactly as Sony published it, and the console's own installer applies it.
|
||||
|
||||
**Does it delete the package after installing?**
|
||||
Only once the install is confirmed: the game reports the new version and the console's installer recorded this exact package. If either check fails, the package stays. Switch off **Delete PKG after install** in Settings to keep every package.
|
||||
|
||||
**Will it touch my system software?**
|
||||
No. PatchDL never downloads system software, and it only offers game updates that run on the firmware you have.
|
||||
|
||||
@@ -126,6 +139,12 @@ PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||
|
||||
PatchDL is written in C and builds on [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/), [libcurl](https://curl.se/), [OpenSSL](https://www.openssl.org/), [SQLite](https://sqlite.org/) and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk). The version history behind older patches comes from [Prospero Patches](https://prosperopatches.com/). Thanks to everyone behind them.
|
||||
|
||||
## Support
|
||||
|
||||
If you like what I do, you can buy me a coffee*: [](https://paypal.me/oliverkoester)
|
||||
|
||||
<sub>*It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</sub>
|
||||
|
||||
## License
|
||||
|
||||
PatchDL is free software under the **GNU General Public License v3.0 or later**. See [LICENSE](LICENSE). Use it, study it, change it, share it. Forks stay under the same license.
|
||||
|
||||
+85
-5
@@ -9,13 +9,68 @@
|
||||
|
||||
#include "patchdl_appdb.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define APP_DB_URI \
|
||||
"file:/system_data/priv/mms/app.db?immutable=1"
|
||||
/* A rescan can't open app.db by a relative path: SQLite turns it into an
|
||||
absolute one with getcwd(), which resolves inside our own root again. So
|
||||
it copies the file out through the working directory (the system root,
|
||||
see patchdl_scan) and opens the copy. */
|
||||
#define APP_DB_REL "system_data/priv/mms/app.db"
|
||||
#define APP_DB_SNAPSHOT "/data/patchdl/.appdb.snapshot"
|
||||
#define APP_DB_URI_SNAPSHOT "file:" APP_DB_SNAPSHOT "?immutable=1"
|
||||
|
||||
static char g_appdb_err[64];
|
||||
|
||||
const char *
|
||||
patchdl_appdb_error(void) {
|
||||
return g_appdb_err;
|
||||
}
|
||||
|
||||
static int
|
||||
snapshot_appdb(void) {
|
||||
char *buf;
|
||||
ssize_t n;
|
||||
int in, out, rc = 0;
|
||||
|
||||
if ((in = open(APP_DB_REL, O_RDONLY | O_CLOEXEC)) < 0) {
|
||||
snprintf(g_appdb_err, sizeof g_appdb_err, "open app.db errno %d", errno);
|
||||
return -1;
|
||||
}
|
||||
out = open(APP_DB_SNAPSHOT, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
|
||||
if (out < 0) {
|
||||
snprintf(g_appdb_err, sizeof g_appdb_err, "open snapshot errno %d", errno);
|
||||
close(in);
|
||||
return -1;
|
||||
}
|
||||
if (!(buf = malloc(65536))) {
|
||||
close(in);
|
||||
close(out);
|
||||
return -1;
|
||||
}
|
||||
while ((n = read(in, buf, 65536)) > 0)
|
||||
if (write(out, buf, (size_t)n) != n) {
|
||||
snprintf(g_appdb_err, sizeof g_appdb_err, "write snapshot errno %d", errno);
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
if (n < 0) {
|
||||
snprintf(g_appdb_err, sizeof g_appdb_err, "read app.db errno %d", errno);
|
||||
rc = -1;
|
||||
}
|
||||
free(buf);
|
||||
close(in);
|
||||
close(out);
|
||||
if (rc) unlink(APP_DB_SNAPSHOT);
|
||||
return rc;
|
||||
}
|
||||
|
||||
#define APPDB_MAX 1024
|
||||
|
||||
@@ -60,8 +115,9 @@ json_str(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
out[--len] = '\0';
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
static int
|
||||
load_db(const char *uri, patchdl_appinfo_t **out, size_t *count) {
|
||||
int step = SQLITE_DONE;
|
||||
sqlite3 *db = NULL;
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
patchdl_appinfo_t *arr;
|
||||
@@ -73,7 +129,7 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
/* FULLMUTEX: today only the startup thread calls this, but future code
|
||||
paths (a manual rescan triggered from the HTTP thread) would otherwise
|
||||
race the SQLite handle. Cost is one mutex per call. */
|
||||
if (sqlite3_open_v2(APP_DB_URI, &db,
|
||||
if (sqlite3_open_v2(uri, &db,
|
||||
SQLITE_OPEN_READONLY | SQLITE_OPEN_URI |
|
||||
SQLITE_OPEN_FULLMUTEX, NULL) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
@@ -94,7 +150,7 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && n < APPDB_MAX) {
|
||||
while ((step = sqlite3_step(stmt)) == SQLITE_ROW && n < APPDB_MAX) {
|
||||
patchdl_appinfo_t *e = &arr[n];
|
||||
const char *tid = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *name = (const char *)sqlite3_column_text(stmt, 1);
|
||||
@@ -108,13 +164,25 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
if (json) {
|
||||
json_str(json, "CONTENT_VERSION", e->content_version,
|
||||
sizeof(e->content_version));
|
||||
/* PS4 titles carry no CONTENT_VERSION; APP_VER is theirs, and
|
||||
it includes an installed patch. */
|
||||
if (!e->content_version[0])
|
||||
json_str(json, "APP_VER", e->content_version,
|
||||
sizeof(e->content_version));
|
||||
json_str(json, "VERSION_FILE_URI", e->version_file_uri,
|
||||
sizeof(e->version_file_uri));
|
||||
}
|
||||
n++;
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
/* Anything but the end of the table (or the row cap) means the read
|
||||
broke off, e.g. on a copy taken while the console wrote the file:
|
||||
report failure instead of a list that silently lacks titles. */
|
||||
if (step != SQLITE_DONE && step != SQLITE_ROW) {
|
||||
free(arr);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Icon paths in a separate query: if a firmware lacks the icon0Info
|
||||
column, only the icons go missing, not the whole title list. */
|
||||
@@ -141,6 +209,18 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count, int relative) {
|
||||
int rc;
|
||||
*out = NULL;
|
||||
*count = 0;
|
||||
if (!relative) return load_db(APP_DB_URI, out, count);
|
||||
if (snapshot_appdb()) return -2;
|
||||
rc = load_db(APP_DB_URI_SNAPSHOT, out, count);
|
||||
unlink(APP_DB_SNAPSHOT);
|
||||
return rc;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_appdb_free(patchdl_appinfo_t *arr) {
|
||||
free(arr);
|
||||
|
||||
+6
-3
@@ -22,9 +22,12 @@ typedef struct {
|
||||
|
||||
/* Read the PS5 app database (tbl_contentinfo) for installed-title metadata.
|
||||
Returns 0 on success with a malloc'd array (caller frees with
|
||||
patchdl_appdb_free). Requires the root-vnode swap + elevated authid to be
|
||||
in effect (the caller arranges this). */
|
||||
int patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count);
|
||||
patchdl_appdb_free). Requires elevated authid plus either the root-vnode
|
||||
swap or, with `relative` set, the working directory at the system root
|
||||
(the caller arranges this). */
|
||||
int patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count, int relative);
|
||||
/* Why the last relative load failed to copy app.db (errno text), or "". */
|
||||
const char *patchdl_appdb_error(void);
|
||||
void patchdl_appdb_free(patchdl_appinfo_t *arr);
|
||||
|
||||
const patchdl_appinfo_t *patchdl_appdb_find(const patchdl_appinfo_t *arr,
|
||||
|
||||
+38
-54
@@ -22,6 +22,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
@@ -48,6 +49,12 @@ typedef struct {
|
||||
const char *content_id;
|
||||
const char *content_name;
|
||||
const char *icon_url;
|
||||
/* Sony reads past the six pointers. The 8 bytes after icon_url select the
|
||||
install mode: 0 = plain install, other values take a patch/cancel path
|
||||
that refuses with 0x80B2116F. Left as stack garbage they caused our
|
||||
intermittent 0x80B2116F (measured by ps5upload on FW 13.60). */
|
||||
uint64_t unknown_30;
|
||||
uint64_t reserved[7];
|
||||
} ai_meta_info_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -87,7 +94,7 @@ typedef struct {
|
||||
|
||||
#define STATIC_ASSERT(c, n) typedef char static_assert_##n[(c) ? 1 : -1]
|
||||
STATIC_ASSERT(sizeof(ai_pkg_info_t) == 0x38, pkg_info_size);
|
||||
STATIC_ASSERT(sizeof(ai_meta_info_t) == (6 * sizeof(void *)), meta_info_size);
|
||||
STATIC_ASSERT(sizeof(ai_meta_info_t) == 0x70, meta_info_size);
|
||||
STATIC_ASSERT(sizeof(ai_playgo_info_t) == 0x2700, playgo_info_size);
|
||||
STATIC_ASSERT(offsetof(ai_meta_info_t, uri) == 0, meta_uri_offset);
|
||||
STATIC_ASSERT(offsetof(ai_meta_info_t, icon_url) == (5 * sizeof(void *)),
|
||||
@@ -359,6 +366,16 @@ backend_start(void) {
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
}
|
||||
|
||||
/* Start the backend if needed and give it up to 15 s to come up (it loads a
|
||||
system module and resolves symbols in its own thread), so an install
|
||||
request right after startup doesn't fail just because it was early. */
|
||||
static void
|
||||
backend_wait(void) {
|
||||
backend_start();
|
||||
for (int i = 0; i < 150 && g_stage > 0 && g_stage != 5; i++)
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
|
||||
static const char *
|
||||
stage_str(int s) {
|
||||
switch (s) {
|
||||
@@ -428,7 +445,7 @@ patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz
|
||||
snprintf(msg, msg_sz, "package not on disk");
|
||||
return -1;
|
||||
}
|
||||
backend_start();
|
||||
backend_wait();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
return -1;
|
||||
@@ -576,7 +593,7 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
char sdk_path[1024];
|
||||
char pkg_tid[48] = {0};
|
||||
struct stat st;
|
||||
int rc;
|
||||
int rc = -1;
|
||||
int pkg_tid_mismatch = 0;
|
||||
|
||||
if (!local_path || !local_path[0]) {
|
||||
@@ -588,7 +605,7 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
return -1;
|
||||
}
|
||||
|
||||
backend_start();
|
||||
backend_wait();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
return -1;
|
||||
@@ -681,23 +698,23 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
uris[0] = sdk_path; /* /user/data/... — the allowlisted path */
|
||||
uris[1] = file_uri; /* file:///user/data/... */
|
||||
uris[2] = http_loop_uri[0] ? http_loop_uri : NULL;
|
||||
uris[3] = http_lan_uri[0] ? http_lan_uri : NULL;
|
||||
/* Stream install first: Sony's installer pulls the package from our
|
||||
own /api/pkg over HTTP (ranges), the route that writes the content.
|
||||
The bare path forms come last. */
|
||||
uris[0] = http_loop_uri[0] ? http_loop_uri : NULL;
|
||||
uris[1] = http_lan_uri[0] ? http_lan_uri : NULL;
|
||||
uris[2] = sdk_path; /* /user/data/... */
|
||||
uris[3] = file_uri; /* file:///user/data/... */
|
||||
meta.ex_uri = "";
|
||||
meta.playgo_scenario_id = "";
|
||||
/* For cross-region shared-master packages pass the installed game's
|
||||
content_id so AppInstUtil binds the download to the right title. */
|
||||
meta.content_id = (pkg_tid_mismatch &&
|
||||
target_content_id && target_content_id[0])
|
||||
? target_content_id : "";
|
||||
/* Empty: the installer takes the id from the package itself. */
|
||||
meta.content_id = "";
|
||||
meta.content_name = "PatchDL";
|
||||
meta.icon_url = "";
|
||||
|
||||
{
|
||||
char tries[260] = {0};
|
||||
const char *labels[4] = { "userdata", "file", "loop", "lan" };
|
||||
const char *labels[4] = { "loop", "lan", "userdata", "file" };
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (!uris[i]) continue;
|
||||
memset(&pkg, 0, sizeof(pkg));
|
||||
@@ -724,44 +741,11 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
free(playgo);
|
||||
}
|
||||
|
||||
/* AppInstallPkg: simpler API, no MetaInfo content_id override. Tried for
|
||||
all packages including cross-region, since it may have different
|
||||
privilege requirements than InstallByPackage. For shared-master packages
|
||||
it will bind to the pkg's own embedded title, not the expected_title_id,
|
||||
so treat success with caution; also report the complete error set. */
|
||||
{
|
||||
char ibp_tries[260] = {0};
|
||||
ai_pkg_info_t pkg = {0};
|
||||
int rc2;
|
||||
|
||||
/* stash the InstallByPackage diagnostic if available */
|
||||
if (pkg_tid_mismatch)
|
||||
snprintf(ibp_tries, sizeof(ibp_tries),
|
||||
"ibp=0x%08x(pkg %.12s->%.12s)",
|
||||
(unsigned)rc, pkg_tid,
|
||||
expected_title_id ? expected_title_id : "");
|
||||
|
||||
rc2 = ai_install_pkg(sdk_path, &pkg);
|
||||
if (rc2 == 0) {
|
||||
remember_install(expected_title_id, "AppInstallPkg",
|
||||
&pkg, target_content_id, rc2);
|
||||
snprintf(msg, msg_sz, "install started (AppInstallPkg, content %.47s%s%s)",
|
||||
pkg.content_id[0] ? pkg.content_id :
|
||||
(target_content_id ? target_content_id : ""),
|
||||
ibp_tries[0] ? " " : "", ibp_tries);
|
||||
return 0;
|
||||
}
|
||||
if (pkg_tid_mismatch)
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (pkg %.12s->%.12s ibp=0x%08x aip=0x%08x)",
|
||||
pkg_tid, expected_title_id ? expected_title_id : "",
|
||||
(unsigned)rc, (unsigned)rc2);
|
||||
else
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)",
|
||||
(unsigned)rc, (unsigned)rc2);
|
||||
return rc2 ? rc2 : (rc ? rc : -1);
|
||||
}
|
||||
/* No AppInstallPkg fallback: it returns 0 and writes only the app
|
||||
metadata (version, tile) without installing any content, which shows
|
||||
up as "updated" while the game still runs the old version. */
|
||||
snprintf(msg, msg_sz, "install rejected (InstallByPackage=0x%08x)", (unsigned)rc);
|
||||
return rc ? rc : -1;
|
||||
}
|
||||
|
||||
int
|
||||
@@ -777,7 +761,7 @@ patchdl_install_by_uri(const char *uri, const char *target_title_id,
|
||||
snprintf(msg, msg_sz, "no uri");
|
||||
return -1;
|
||||
}
|
||||
backend_start();
|
||||
backend_wait();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
return -1;
|
||||
@@ -821,7 +805,7 @@ patchdl_install_app_pkg(const char *local_path,
|
||||
if (!local_path || !local_path[0]) { snprintf(msg, msg_sz, "no path"); return -1; }
|
||||
if (stat(local_path, &st) != 0) { snprintf(msg, msg_sz, "package not downloaded"); return -1; }
|
||||
|
||||
backend_start();
|
||||
backend_wait();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
return -1;
|
||||
|
||||
+95
-13
@@ -67,9 +67,16 @@ static const char *ALLOWED_HOSTS[] = {
|
||||
"sgst.prod.dl.playstation.net",
|
||||
"gst.prod.dl.playstation.net",
|
||||
"gs2.ww.prod.dl.playstation.net",
|
||||
"gs-sec.ww.np.dl.playstation.net", /* PS4 update lists */
|
||||
NULL,
|
||||
};
|
||||
|
||||
/* PS4 package manifests are published on gs2 over plain http only (https
|
||||
there is a CloudFront edge without Sony's certificate). The console's own
|
||||
installer checks the package signature, so a forged manifest can't get an
|
||||
unsigned package installed. Everything else stays https-only. */
|
||||
#define PLAIN_HTTP_HOST "gs2.ww.prod.dl.playstation.net"
|
||||
|
||||
static int
|
||||
host_allowed(const char *host) {
|
||||
size_t hlen = strlen(host);
|
||||
@@ -88,6 +95,43 @@ host_allowed(const char *host) {
|
||||
}
|
||||
|
||||
/* Extract hostname from https://host/path or http://host/path */
|
||||
/* Why the calling thread's last patchdl_http_get failed (or what status it
|
||||
got), for error reports. */
|
||||
static _Thread_local struct {
|
||||
char step[24];
|
||||
int curl_rc;
|
||||
long http_code;
|
||||
} g_last;
|
||||
|
||||
static void
|
||||
set_last(const char *step, int curl_rc, long http_code) {
|
||||
snprintf(g_last.step, sizeof g_last.step, "%s", step);
|
||||
g_last.curl_rc = curl_rc;
|
||||
g_last.http_code = http_code;
|
||||
}
|
||||
|
||||
/* PatchDL's own DNS answer for a host (first A record), for diagnostics. */
|
||||
static int dns_lookup(const char *host, char *ip_out, size_t ip_sz);
|
||||
|
||||
int
|
||||
patchdl_resolve_host(const char *host, char *ip_out, size_t ip_sz) {
|
||||
return dns_lookup(host, ip_out, ip_sz);
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
|
||||
if (step && step_sz) snprintf(step, step_sz, "%s", g_last.step);
|
||||
if (curl_rc) *curl_rc = g_last.curl_rc;
|
||||
if (http_code) *http_code = g_last.http_code;
|
||||
}
|
||||
|
||||
/* Manifest piece hashes: PS5 manifests carry SHA-256 (64 hex digits), PS4
|
||||
manifests SHA-1 (40). */
|
||||
static const EVP_MD *
|
||||
digest_for(const char *hex) {
|
||||
return (hex && strlen(hex) == 40) ? EVP_sha1() : EVP_sha256();
|
||||
}
|
||||
|
||||
static int
|
||||
url_host(const char *url, char *out, size_t out_sz) {
|
||||
const char *p = strstr(url, "://");
|
||||
@@ -291,9 +335,14 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
struct curl_slist *resolve_list = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
int plain = !strncmp(url, "http://", 7);
|
||||
long http_code = 0;
|
||||
|
||||
set_last("", 0, 0);
|
||||
if (url_host(url, host, sizeof(host))) { set_last("bad_url", 0, 0); return -1; }
|
||||
if (!host_allowed(host)) { set_last("host_not_allowed", 0, 0); return -1; }
|
||||
if (plain && strcasecmp(host, PLAIN_HTTP_HOST)) { set_last("http_not_allowed", 0, 0); return -1; }
|
||||
if (dns_lookup(host, ip, sizeof(ip))) { set_last("dns", 0, 0); return -1; }
|
||||
|
||||
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
|
||||
resolve_list = curl_slist_append(NULL, resolve_str);
|
||||
@@ -334,22 +383,30 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
/* Redirects must stay on HTTPS — host_allowed gates the initial URL, but
|
||||
once libcurl follows a 302 we want the protocol pinned too. The _STR
|
||||
variants replaced the bitfield options in libcurl 7.85. */
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
variants replaced the bitfield options in libcurl 7.85. Plain http only
|
||||
for the PS4 manifest host (see PLAIN_HTTP_HOST). */
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, plain ? "http" : "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(resolve_list);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
set_last(res == CURLE_PEER_FAILED_VERIFICATION ||
|
||||
res == CURLE_SSL_CONNECT_ERROR ? "tls"
|
||||
: res == CURLE_UNSUPPORTED_PROTOCOL ? "redirect"
|
||||
: res == CURLE_OPERATION_TIMEDOUT ? "timeout" : "network",
|
||||
(int)res, http_code);
|
||||
free(out->data);
|
||||
out->data = NULL;
|
||||
out->size = 0;
|
||||
return -1;
|
||||
}
|
||||
set_last(http_code >= 400 ? "http_status" : "", 0, http_code);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -517,7 +574,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
if (verify) {
|
||||
sink.md = EVP_MD_CTX_new();
|
||||
if (sink.md)
|
||||
EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
|
||||
EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_hex), NULL);
|
||||
}
|
||||
|
||||
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||
@@ -944,7 +1001,7 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
|
||||
if (verify) {
|
||||
sink.md = EVP_MD_CTX_new();
|
||||
if (sink.md) EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
|
||||
if (sink.md) EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_or_null), NULL);
|
||||
}
|
||||
|
||||
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||
@@ -1018,10 +1075,12 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex (>=65
|
||||
bytes). Uses pread so it doesn't disturb the fd offset. 0 on success. */
|
||||
/* Read-only: hash a [offset, offset+size) region of fd into out_hex (>=65
|
||||
bytes), with the algorithm `like_hex` is written in (see digest_for). Uses
|
||||
pread so it doesn't disturb the fd offset. 0 on success. */
|
||||
int
|
||||
patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex) {
|
||||
patchdl_hash_fd_region(int fd, long long offset, long long size,
|
||||
const char *like_hex, char *out_hex) {
|
||||
EVP_MD_CTX *md;
|
||||
unsigned char *buf;
|
||||
long long pos = offset, remaining = size;
|
||||
@@ -1033,7 +1092,7 @@ patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex
|
||||
if (!md) return -1;
|
||||
buf = malloc(CHUNK);
|
||||
if (!buf) { EVP_MD_CTX_free(md); return -1; }
|
||||
EVP_DigestInit_ex(md, EVP_sha256(), NULL);
|
||||
EVP_DigestInit_ex(md, digest_for(like_hex), NULL);
|
||||
while (remaining > 0) {
|
||||
size_t want = remaining > (long long)CHUNK ? CHUNK : (size_t)remaining;
|
||||
ssize_t got = pread(fd, buf, want, (off_t)pos);
|
||||
@@ -1074,10 +1133,18 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = PATCHDL_BUF_MAX_MANIFEST;
|
||||
if (patchdl_http_get(manifest_url, &buf)) return -1;
|
||||
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
||||
if (!buf.data || !buf.size) {
|
||||
free(buf.data);
|
||||
if (!g_last.step[0]) set_last("empty_reply", 0, g_last.http_code);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pieces = strstr(buf.data, "\"pieces\"");
|
||||
if (!pieces || !(pieces = strchr(pieces, '['))) { free(buf.data); return -1; }
|
||||
if (!pieces || !(pieces = strchr(pieces, '['))) {
|
||||
free(buf.data);
|
||||
if (!g_last.step[0]) set_last("bad_manifest", 0, g_last.http_code);
|
||||
return -1;
|
||||
}
|
||||
pieces_end = strchr(pieces, ']');
|
||||
|
||||
for (p = pieces; (p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end); p += 5)
|
||||
@@ -1132,6 +1199,8 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||
out->count = n; /* keep current so manifest_free frees exactly n */
|
||||
p = obj_end ? obj_end + 1 : p + 5;
|
||||
}
|
||||
json_string_after(buf.data, "playgoChunkCrcUrl", out->crc_url,
|
||||
sizeof(out->crc_url), NULL);
|
||||
free(buf.data);
|
||||
if (n == 0) { patchdl_manifest_free(out); return -1; }
|
||||
out->total = running; /* authoritative assembled size */
|
||||
@@ -1204,6 +1273,19 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
return -1; /* curl not available in this build; set CURL_DIR= to enable */
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_resolve_host(const char *host, char *ip_out, size_t ip_sz) {
|
||||
(void)host; (void)ip_out; (void)ip_sz;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
|
||||
if (step && step_sz) snprintf(step, step_sz, "no_curl");
|
||||
if (curl_rc) *curl_rc = 0;
|
||||
if (http_code) *http_code = 0;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_download(const char *url, const char *dest_path,
|
||||
long long *bytes_out) {
|
||||
|
||||
+15
-5
@@ -43,19 +43,28 @@ typedef struct {
|
||||
char *url;
|
||||
long long offset; /* byte offset of this piece in the assembled file */
|
||||
long long size; /* exact length of this piece */
|
||||
char hash[80]; /* manifest SHA-256 hex, or "" */
|
||||
char hash[80]; /* manifest hash hex (SHA-256, or SHA-1 for PS4), or "" */
|
||||
} patchdl_piece_t;
|
||||
|
||||
typedef struct {
|
||||
patchdl_piece_t *pieces;
|
||||
int count;
|
||||
long long total; /* assembled file size = sum of piece sizes */
|
||||
char crc_url[768]; /* playgoChunkCrcUrl, or "" */
|
||||
} patchdl_manifest_t;
|
||||
|
||||
/* Fetch + parse a Sony JSON manifest into a validated, contiguously-tiled
|
||||
piece list. Returns 0 on success (caller frees with patchdl_manifest_free),
|
||||
-1 on fetch/parse/tiling failure. */
|
||||
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
|
||||
|
||||
/* Why this thread's last patchdl_http_get / patchdl_fetch_manifest failed:
|
||||
step is "" on success, else e.g. "dns", "tls", "network", "timeout",
|
||||
"redirect", "http_status", "bad_manifest". curl_rc and http_code as seen. */
|
||||
void patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code);
|
||||
|
||||
/* PatchDL's own DNS answer for host (first IPv4 address). 0 on success. */
|
||||
int patchdl_resolve_host(const char *host, char *ip_out, size_t ip_sz);
|
||||
void patchdl_manifest_free(patchdl_manifest_t *m);
|
||||
|
||||
|
||||
@@ -78,10 +87,11 @@ int patchdl_http_download_piece(const char *url, int fd,
|
||||
patchdl_piece_ctx_t *ctx,
|
||||
int *curl_rc_out, long *http_code_out);
|
||||
|
||||
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
|
||||
(caller provides >= 65 bytes). Returns 0 on success. */
|
||||
int patchdl_sha256_fd_region(int fd, long long offset, long long size,
|
||||
char *out_hex);
|
||||
/* Read-only: hash a [offset, offset+size) region of fd into out_hex
|
||||
(caller provides >= 65 bytes), SHA-1 if like_hex has 40 hex digits (PS4
|
||||
manifests), else SHA-256. Returns 0 on success. */
|
||||
int patchdl_hash_fd_region(int fd, long long offset, long long size,
|
||||
const char *like_hex, char *out_hex);
|
||||
|
||||
/* Progress callback. Return non-zero to ABORT the in-flight download (used to
|
||||
cancel large patch downloads); return 0 to continue. */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include "patchdl_resolve.h"
|
||||
|
||||
#include <openssl/hmac.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -52,10 +53,43 @@ lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* PS4 titles: Sony's update list lives at a path derived from the title id
|
||||
with HMAC-SHA256 under a key that is part of the PS4 system software and
|
||||
widely published (it's how every PS4 update checker finds patches). */
|
||||
static const unsigned char PS4_UPDATE_KEY[32] = {
|
||||
0xAD, 0x62, 0xE3, 0x7F, 0x90, 0x5E, 0x06, 0xBC, 0x19, 0x59, 0x31, 0x42,
|
||||
0x28, 0x1C, 0x11, 0x2C, 0xEC, 0x0E, 0x7E, 0xC3, 0xE9, 0x7E, 0xFD, 0xCA,
|
||||
0xEF, 0xCD, 0xBA, 0xAF, 0xA6, 0x37, 0x8D, 0x84,
|
||||
};
|
||||
|
||||
static int
|
||||
ps4_update_url(const char *title_id, char *url_out, size_t url_sz) {
|
||||
unsigned char mac[32];
|
||||
unsigned int mac_len = 0;
|
||||
char msg[24], hex[65];
|
||||
int n;
|
||||
|
||||
if (strlen(title_id) != 9) return -1;
|
||||
n = snprintf(msg, sizeof msg, "np_%s", title_id);
|
||||
if (!HMAC(EVP_sha256(), PS4_UPDATE_KEY, sizeof PS4_UPDATE_KEY,
|
||||
(const unsigned char *)msg, (size_t)n, mac, &mac_len) ||
|
||||
mac_len != sizeof mac)
|
||||
return -1;
|
||||
for (unsigned i = 0; i < mac_len; i++)
|
||||
snprintf(hex + 2 * i, 3, "%02x", mac[i]);
|
||||
n = snprintf(url_out, url_sz,
|
||||
"https://gs-sec.ww.np.dl.playstation.net/plo/np/%s/%s/%s-ver.xml",
|
||||
title_id, hex, title_id);
|
||||
return (n > 0 && (size_t)n < url_sz) ? 0 : -1;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz) {
|
||||
if (!title_id) return -1;
|
||||
|
||||
if (!strncmp(title_id, "CUSA", 4))
|
||||
return ps4_update_url(title_id, url_out, url_sz);
|
||||
|
||||
/* Only PS5 native title IDs (PPSA/PPSE/PPSH/PPSJ/PPSC) */
|
||||
if (strncmp(title_id, "PPSA", 4) &&
|
||||
strncmp(title_id, "PPSE", 4) &&
|
||||
|
||||
+215
-39
@@ -26,6 +26,10 @@
|
||||
|
||||
#define MAX_TITLES 512
|
||||
|
||||
#ifndef PATCHDL_HTTP_PORT
|
||||
#define PATCHDL_HTTP_PORT 12880
|
||||
#endif
|
||||
|
||||
static const char *SCAN_DIRS[] = {
|
||||
"/user/app",
|
||||
"/mnt/ext0/user/app",
|
||||
@@ -34,6 +38,26 @@ static const char *SCAN_DIRS[] = {
|
||||
NULL,
|
||||
};
|
||||
|
||||
/* The first scan runs single-threaded with the process root swapped to the
|
||||
system root and keeps a handle to that root. Later scans (other threads are
|
||||
running, so the root must not move) change only the working directory to
|
||||
it and drop the leading '/' of every path, see sys_path. Nothing else in
|
||||
PatchDL opens relative paths. */
|
||||
static int g_sysroot_fd = -1;
|
||||
static int g_rel_paths;
|
||||
/* Step the running scan is in, for diagnosing slow rescans. */
|
||||
static const char *volatile g_scan_step = "idle";
|
||||
|
||||
const char *
|
||||
patchdl_scan_step(void) {
|
||||
return g_scan_step;
|
||||
}
|
||||
|
||||
static const char *
|
||||
sys_path(const char *p) {
|
||||
return (g_rel_paths && p[0] == '/') ? p + 1 : p;
|
||||
}
|
||||
|
||||
/* ---------- param.json -------------------------------------------------- */
|
||||
|
||||
static int
|
||||
@@ -91,7 +115,7 @@ try_param_json(const char *dir, patchdl_title_t *t) {
|
||||
size_t n;
|
||||
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.json", dir);
|
||||
fp = fopen(path, "r");
|
||||
fp = fopen(sys_path(path), "r");
|
||||
if (!fp) return -1;
|
||||
n = fread(buf, 1, sizeof(buf) - 1, fp);
|
||||
fclose(fp);
|
||||
@@ -190,7 +214,7 @@ try_param_sfo(const char *dir, patchdl_title_t *t) {
|
||||
struct stat st;
|
||||
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", dir);
|
||||
fp = fopen(path, "rb");
|
||||
fp = fopen(sys_path(path), "rb");
|
||||
if (!fp) return -1;
|
||||
|
||||
if (fstat(fileno(fp), &st) || (size_t)st.st_size > sizeof(buf)) {
|
||||
@@ -219,7 +243,7 @@ path_exists(const char *dir, const char *rel) {
|
||||
char p[PATH_MAX];
|
||||
struct stat st;
|
||||
snprintf(p, sizeof(p), "%s/%s", dir, rel);
|
||||
return stat(p, &st) == 0;
|
||||
return stat(sys_path(p), &st) == 0;
|
||||
}
|
||||
|
||||
/* Read up to sz-1 bytes of dir/rel into buf (NUL-terminated). 0 on success. */
|
||||
@@ -230,7 +254,7 @@ read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
|
||||
size_t n;
|
||||
|
||||
snprintf(p, sizeof(p), "%s/%s", dir, rel);
|
||||
fp = fopen(p, "rb");
|
||||
fp = fopen(sys_path(p), "rb");
|
||||
if (!fp) return -1;
|
||||
n = fread(buf, 1, sz - 1, fp);
|
||||
fclose(fp);
|
||||
@@ -251,7 +275,7 @@ pkg_sign_state(const char *dir) {
|
||||
size_t n;
|
||||
|
||||
snprintf(p, sizeof(p), "%s/app.pkg", dir);
|
||||
fp = fopen(p, "rb");
|
||||
fp = fopen(sys_path(p), "rb");
|
||||
if (!fp) return PATCHDL_PKG_UNREAD;
|
||||
n = fread(h, 1, sizeof(h), fp);
|
||||
fclose(fp);
|
||||
@@ -303,8 +327,9 @@ mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts)
|
||||
* - app.pkg (no shadow mount) -> its header decides: Sony-signed is a
|
||||
* genuine install (store or disc), debug/fake-signed is a fake package
|
||||
* (listed, but never checked, downloaded or patched), unreadable is unknown
|
||||
* - app.json with "fake":true -> fake package if it has an app.pkg,
|
||||
* otherwise homebrew (skip)
|
||||
* - app.json with "fake":true and no app.pkg -> homebrew (skip). With an
|
||||
* app.pkg the marker only says the package did not come from Sony's CDN
|
||||
* (PatchDL's own loopback install sets it too), so the header decides.
|
||||
*/
|
||||
static int
|
||||
scan_one(const char *base, const char *name, patchdl_title_t *t,
|
||||
@@ -345,12 +370,27 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
|
||||
/* app.json piece URLs are no signal: disc and store installs carry
|
||||
CDN URLs there too. The package header is. */
|
||||
t->pkg_sign = pkg_sign_state(dir);
|
||||
/* The installer's own fake marker wins over the header: block when
|
||||
either says fake. */
|
||||
t->source_type = (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
|
||||
/* The header wins over the installer's fake marker: a retail package
|
||||
installed from a local URL is marked fake but is still Sony's. The
|
||||
marker only decides when the header can't be read. */
|
||||
t->source_type = t->pkg_sign == PATCHDL_PKG_RETAIL ? PATCHDL_SOURCE_OFFICIAL
|
||||
: (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
|
||||
? PATCHDL_SOURCE_FAKE
|
||||
: t->pkg_sign == PATCHDL_PKG_RETAIL ? PATCHDL_SOURCE_OFFICIAL
|
||||
: PATCHDL_SOURCE_UNKNOWN;
|
||||
: PATCHDL_SOURCE_UNKNOWN;
|
||||
/* A Sony-signed package someone installed with another tool, often
|
||||
to keep one exact app version. PatchDL's own installs name its
|
||||
loopback server in app.json. An update would replace that
|
||||
version. */
|
||||
{
|
||||
/* PatchDL installs from its server (loopback or LAN address,
|
||||
both on its port) or, as a fallback, from its download folder
|
||||
(/user/data/patchdl/...). app.json escapes slashes, so match
|
||||
pieces without them. */
|
||||
char port[16];
|
||||
snprintf(port, sizeof port, ":%d", PATCHDL_HTTP_PORT);
|
||||
t->sideloaded = is_fake && t->source_type == PATCHDL_SOURCE_OFFICIAL &&
|
||||
!strstr(appjson, port) && !strstr(appjson, "patchdl");
|
||||
}
|
||||
} else if (is_shadow || has_mountlnk || has_paramjson ||
|
||||
path_exists(dir, "sce_sys/param.sfo")) {
|
||||
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
|
||||
@@ -382,7 +422,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap,
|
||||
DIR *d;
|
||||
struct dirent *de;
|
||||
|
||||
d = opendir(base);
|
||||
d = opendir(sys_path(base));
|
||||
if (!d) return;
|
||||
|
||||
while ((de = readdir(d))) {
|
||||
@@ -423,7 +463,7 @@ open_png(const char *path) {
|
||||
int fd;
|
||||
|
||||
if (!path || path[0] != '/') return -1;
|
||||
fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
fd = open(sys_path(path), O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
if (fstat(fd, &st) || !S_ISREG(st.st_mode) ||
|
||||
st.st_size < 8 || st.st_size > ICON_MAX_BYTES ||
|
||||
@@ -454,16 +494,41 @@ open_icon(const patchdl_title_t *t, const char *db_path) {
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* After an install the console rewrites /user/appmeta/<id>/param.json at
|
||||
once, while app.db keeps the old CONTENT_VERSION until later (seen: until
|
||||
the game was started). Take the newer of the two so a finished update
|
||||
doesn't look pending. */
|
||||
static void
|
||||
newer_appmeta_version(patchdl_title_t *t) {
|
||||
char path[PATH_MAX], buf[8192], ver[16] = {0};
|
||||
FILE *fp;
|
||||
size_t n;
|
||||
|
||||
snprintf(path, sizeof(path), "/user/appmeta/%s/param.json", t->title_id);
|
||||
if (!(fp = fopen(sys_path(path), "r"))) return;
|
||||
n = fread(buf, 1, sizeof(buf) - 1, fp);
|
||||
fclose(fp);
|
||||
buf[n] = '\0';
|
||||
if (json_str(buf, "contentVersion", ver, sizeof(ver)) == 0 &&
|
||||
strcmp(ver, t->installed_version) > 0)
|
||||
snprintf(t->installed_version, sizeof(t->installed_version), "%s", ver);
|
||||
}
|
||||
|
||||
/* Overlay app.db metadata (real title name, installed version, version.xml
|
||||
URL) onto the scanned titles. Genuine installs have no readable param.json,
|
||||
so this is the only source of their name/version. */
|
||||
static void
|
||||
static int
|
||||
merge_appdb(patchdl_title_t *arr, size_t cnt) {
|
||||
patchdl_appinfo_t *info = NULL;
|
||||
size_t n = 0;
|
||||
|
||||
if (patchdl_appdb_load(&info, &n))
|
||||
return;
|
||||
{
|
||||
int rc = patchdl_appdb_load(&info, &n, g_rel_paths);
|
||||
if (rc) {
|
||||
g_scan_step = rc == -2 ? patchdl_appdb_error() : "fail: appdb read";
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < cnt; i++) {
|
||||
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
|
||||
@@ -481,16 +546,21 @@ merge_appdb(patchdl_title_t *arr, size_t cnt) {
|
||||
sizeof(arr[i].version_file_uri) - 1);
|
||||
}
|
||||
|
||||
g_scan_step = "icons";
|
||||
for (size_t i = 0; i < cnt; i++) {
|
||||
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
|
||||
arr[i].icon_fd = open_icon(&arr[i], a ? a->icon_path : NULL);
|
||||
if (arr[i].pkg_sign != PATCHDL_PKG_NONE)
|
||||
newer_appmeta_version(&arr[i]);
|
||||
}
|
||||
|
||||
patchdl_appdb_free(info);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* See patchdl_scan_lock — both vnode-swap entry points return -1 / NULL once
|
||||
this is set so a late rescan call can't race the running MHD threads. */
|
||||
/* See patchdl_scan_lock — once set, patchdl_scan no longer swaps the root
|
||||
(it rescans via the kept system root handle) and the debug dump returns
|
||||
NULL, so neither can race the running MHD threads. */
|
||||
static _Atomic int g_scan_locked = 0;
|
||||
|
||||
void
|
||||
@@ -498,30 +568,72 @@ patchdl_scan_lock(void) {
|
||||
atomic_store(&g_scan_locked, 1);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
static int
|
||||
scan_all(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
patchdl_title_t *arr;
|
||||
size_t cnt = 0;
|
||||
pid_t pid = getpid();
|
||||
intptr_t saved_root = 0, root_vnode;
|
||||
int using_vswap = 0;
|
||||
|
||||
struct statfs *mounts = NULL;
|
||||
int nmounts;
|
||||
|
||||
if (atomic_load(&g_scan_locked)) return -1;
|
||||
|
||||
arr = calloc(MAX_TITLES, sizeof(*arr));
|
||||
if (!arr) return -1;
|
||||
if (!arr) { g_scan_step = "fail: oom"; return -1; }
|
||||
|
||||
/* Global mount table for shadowmount detection. getmntinfo's buffer is
|
||||
libc-managed — must NOT be freed. */
|
||||
g_scan_step = "mounts";
|
||||
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
|
||||
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
|
||||
|
||||
for (int i = 0; SCAN_DIRS[i]; i++) {
|
||||
g_scan_step = SCAN_DIRS[i];
|
||||
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
|
||||
}
|
||||
g_scan_step = "appdb";
|
||||
|
||||
/* At startup a list without app.db data still beats none. A rescan
|
||||
without it would wipe names and versions of a good list: fail instead
|
||||
and keep the old one. */
|
||||
if (merge_appdb(arr, cnt) && g_rel_paths) {
|
||||
free(arr);
|
||||
return -1;
|
||||
}
|
||||
g_scan_step = "idle";
|
||||
|
||||
*titles_out = arr;
|
||||
*count_out = cnt;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A rescan while other threads run: resolve every path from the system root
|
||||
kept by the first scan, without touching the process root. */
|
||||
static int
|
||||
rescan_relative(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
int cwd, rc;
|
||||
|
||||
if (g_sysroot_fd < 0) { g_scan_step = "fail: no system root handle"; return -1; }
|
||||
cwd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
if (cwd < 0) { g_scan_step = "fail: open cwd"; return -1; }
|
||||
if (fchdir(g_sysroot_fd)) { g_scan_step = "fail: fchdir"; close(cwd); return -1; }
|
||||
g_rel_paths = 1;
|
||||
rc = scan_all(titles_out, count_out);
|
||||
g_rel_paths = 0;
|
||||
(void)fchdir(cwd); /* best effort: nothing else uses the cwd */
|
||||
close(cwd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
pid_t pid = getpid();
|
||||
intptr_t saved_root = 0, root_vnode;
|
||||
int using_vswap = 0, rc;
|
||||
|
||||
/* Elevate so privileged paths (the app.db under /system_data) are
|
||||
readable; same authid ftpsrv uses. No-op without kernel R/W. */
|
||||
kernel_set_ucred_authid(pid, 0x4801000000000013L);
|
||||
|
||||
/* Global mount table for shadowmount detection. getmntinfo's buffer is
|
||||
libc-managed — must NOT be freed. */
|
||||
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
|
||||
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
|
||||
if (atomic_load(&g_scan_locked))
|
||||
return rescan_relative(titles_out, count_out);
|
||||
|
||||
root_vnode = kernel_get_root_vnode();
|
||||
if (root_vnode) {
|
||||
@@ -532,20 +644,84 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
if (saved_root) {
|
||||
kernel_set_proc_rootdir(pid, root_vnode);
|
||||
using_vswap = 1;
|
||||
if (g_sysroot_fd < 0)
|
||||
g_sysroot_fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; SCAN_DIRS[i]; i++)
|
||||
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
|
||||
|
||||
merge_appdb(arr, cnt);
|
||||
rc = scan_all(titles_out, count_out);
|
||||
|
||||
if (using_vswap)
|
||||
kernel_set_proc_rootdir(pid, saved_root);
|
||||
return rc;
|
||||
}
|
||||
|
||||
*titles_out = arr;
|
||||
*count_out = cnt;
|
||||
return 0;
|
||||
static const char *PATCH_DIRS[] = {
|
||||
"/user/patch",
|
||||
"/mnt/ext0/user/patch",
|
||||
"/mnt/ext1/user/patch",
|
||||
NULL,
|
||||
};
|
||||
|
||||
static int
|
||||
read_title_file(const char *const *bases, const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
char path[PATH_MAX];
|
||||
if (g_sysroot_fd < 0 || !sz) return -1;
|
||||
for (int i = 0; bases[i]; i++) {
|
||||
int fd;
|
||||
ssize_t n;
|
||||
snprintf(path, sizeof(path), "%s/%s/%s", bases[i] + 1, title_id, name);
|
||||
fd = openat(g_sysroot_fd, path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) continue;
|
||||
n = read(fd, buf, sz - 1);
|
||||
close(fd);
|
||||
if (n < 0) return -1;
|
||||
buf[n] = '\0';
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
return read_title_file(SCAN_DIRS, title_id, name, buf, sz);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan_read_patch_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
return read_title_file(PATCH_DIRS, title_id, name, buf, sz);
|
||||
}
|
||||
|
||||
/* Fold one stat result into an FNV-1a hash. */
|
||||
static unsigned long long
|
||||
sig_add(unsigned long long h, const char *rel) {
|
||||
struct stat st;
|
||||
long long v[4] = {0};
|
||||
if (fstatat(g_sysroot_fd, rel, &st, 0) == 0) {
|
||||
v[0] = (long long)st.st_mtim.tv_sec;
|
||||
v[1] = (long long)st.st_mtim.tv_nsec;
|
||||
v[2] = (long long)st.st_size;
|
||||
v[3] = (long long)st.st_ino;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof v; i++) {
|
||||
h ^= ((const unsigned char *)v)[i];
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
unsigned long long
|
||||
patchdl_scan_signature(void) {
|
||||
unsigned long long h = 14695981039346656037ULL;
|
||||
if (g_sysroot_fd < 0) return 0;
|
||||
for (int i = 0; SCAN_DIRS[i]; i++)
|
||||
h = sig_add(h, SCAN_DIRS[i] + 1);
|
||||
h = sig_add(h, "system_data/priv/mms/app.db");
|
||||
h = sig_add(h, "system_data/priv/mms/app.db-wal");
|
||||
return h ? h : 1;
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
+24
-2
@@ -38,6 +38,8 @@ typedef struct {
|
||||
char version_file_uri[256]; /* from app.db, for version.xml */
|
||||
patchdl_source_t source_type;
|
||||
patchdl_pkg_sign_t pkg_sign; /* app.pkg header verdict, for diagnostics */
|
||||
int sideloaded; /* Sony-signed, but installed by another
|
||||
tool (not Store, disc or PatchDL) */
|
||||
int icon_fd; /* open icon0.png, or -1; see patchdl_scan */
|
||||
/* populated by background verxml fetch — guarded by websrv mutex */
|
||||
char compatible_version[16];
|
||||
@@ -59,6 +61,7 @@ typedef struct {
|
||||
char manual_version[16];
|
||||
int enabled; /* user policy, persisted in config.json */
|
||||
int resumable; /* a partial download is on disk */
|
||||
int downloaded; /* a finished download is on disk */
|
||||
long long partial_bytes; /* size of that partial, for the UI */
|
||||
} patchdl_title_t;
|
||||
|
||||
@@ -70,8 +73,27 @@ const char *patchdl_source_str(patchdl_source_t src);
|
||||
listings. Caller frees. */
|
||||
char *patchdl_scan_debug_json(void);
|
||||
|
||||
/* Cheap change check for the automatic rescan: a hash over the title
|
||||
directories and Sony's app.db. 0 when it can't be computed (no system root
|
||||
handle), so callers never rescan on it. */
|
||||
unsigned long long patchdl_scan_signature(void);
|
||||
|
||||
/* Name of the step the running scan is in ("idle" when none). */
|
||||
const char *patchdl_scan_step(void);
|
||||
|
||||
/* Read <title dir>/<name> of an installed title (e.g. app.json) through the
|
||||
kept system root handle; thread-safe, no working-directory change.
|
||||
0 on success with a NUL-terminated buf. */
|
||||
int patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz);
|
||||
/* Same for an installed PS4 patch: /user/patch/<id>/<name>. */
|
||||
int patchdl_scan_read_patch_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz);
|
||||
|
||||
/* Mark scan/debug as no longer safe to call (must be set after MHD worker
|
||||
threads come up — patchdl_scan performs a process-wide vnode swap that
|
||||
would race any concurrent thread). After this is set, both entry points
|
||||
return immediately. Call once during startup, after MHD_start_daemon. */
|
||||
would race any concurrent thread). After this is set, patchdl_scan rescans
|
||||
through the system root handle kept by the first scan (working directory
|
||||
only, one caller at a time) and the debug dump returns NULL. Call once
|
||||
during startup, after MHD_start_daemon. */
|
||||
void patchdl_scan_lock(void);
|
||||
@@ -9,4 +9,4 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.9"
|
||||
#define PATCHDL_VERSION "0.2.1"
|
||||
+11
-5
@@ -134,20 +134,26 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||
char murl[512] = {0};
|
||||
|
||||
/* PS5 version.xml uses content_ver; PS4 uses version. */
|
||||
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
|
||||
int is_ps4 = attr_val(p, tag_end, "content_ver", ver, sizeof(ver)) != 0;
|
||||
if (is_ps4)
|
||||
attr_val(p, tag_end, "version", ver, sizeof(ver));
|
||||
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
|
||||
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
|
||||
attr_val(p, tag_end, "manifest_url", murl, sizeof(murl));
|
||||
|
||||
if (ver[0] && sver[0]) {
|
||||
uint32_t pkg_sver = parse_hex(sver);
|
||||
if (ver[0] && (sver[0] || is_ps4)) {
|
||||
/* A PS4 patch's system_ver is a PS4 firmware (in decimal); the
|
||||
PS5 runs PS4 games regardless, so it never blocks one. */
|
||||
uint32_t pkg_sver = is_ps4 ? 0 : parse_hex(sver);
|
||||
|
||||
/* Track latest overall */
|
||||
if (!out->latest_version[0] || ver_gt(ver, out->latest_version)) {
|
||||
strncpy(out->latest_version, ver, sizeof(out->latest_version) - 1);
|
||||
fw_bin_to_str(pkg_sver, out->latest_required_fw,
|
||||
sizeof(out->latest_required_fw));
|
||||
if (is_ps4)
|
||||
out->latest_required_fw[0] = '\0';
|
||||
else
|
||||
fw_bin_to_str(pkg_sver, out->latest_required_fw,
|
||||
sizeof(out->latest_required_fw));
|
||||
}
|
||||
|
||||
/* Track latest compatible + its installable patch source. PS5
|
||||
|
||||
+1216
-141
File diff suppressed because it is too large.
Load diff
+426
-29
@@ -4,6 +4,9 @@ const API = {
|
||||
config: "/api/config",
|
||||
downloads: "/api/downloads",
|
||||
installStatus: "/api/installstatus",
|
||||
rescan: "/api/rescan",
|
||||
storage: "/api/storage",
|
||||
storageDelete: (titleId) => `/api/storage/${encodeURIComponent(titleId)}/delete`,
|
||||
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
|
||||
};
|
||||
|
||||
@@ -48,6 +51,7 @@ const fallback = {
|
||||
"sgst.prod.dl.playstation.net",
|
||||
"gst.prod.dl.playstation.net",
|
||||
"gs2.ww.prod.dl.playstation.net",
|
||||
"gs-sec.ww.np.dl.playstation.net",
|
||||
],
|
||||
},
|
||||
titles: [
|
||||
@@ -115,10 +119,200 @@ const els = {};
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindElements();
|
||||
bindEvents();
|
||||
loadInitialData().then(openGuideFromHash);
|
||||
loadInitialData().then(() => { openGuideFromHash(); openDiagFromHash(); });
|
||||
window.addEventListener("hashchange", openGuideFromHash);
|
||||
setInterval(watchTitles, 5000);
|
||||
document.querySelectorAll("[data-donate]").forEach((b) => b.addEventListener("click", openDonate));
|
||||
// Cards are re-rendered often; listen once on the document.
|
||||
document.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("[data-diag]");
|
||||
if (b) openDiag(b.dataset.diag);
|
||||
});
|
||||
window.addEventListener("hashchange", openDiagFromHash);
|
||||
});
|
||||
|
||||
/* ---------------- download diagnosis ---------------- */
|
||||
|
||||
function diagLink(titleId) {
|
||||
const h = location.hostname;
|
||||
let base = location.origin;
|
||||
if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") {
|
||||
if (!state.status.lan_ip) return null;
|
||||
base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`;
|
||||
}
|
||||
return `${base}/#diag/${encodeURIComponent(titleId)}`;
|
||||
}
|
||||
|
||||
function openDiagFromHash() {
|
||||
const m = location.hash.match(/^#diag\/([A-Za-z0-9_.-]+)$/);
|
||||
if (!m) return;
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
openDiag(m[1]);
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
try {
|
||||
const r = await fetch(url, { cache: "no-store" });
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function diagRow(ok, label, detail) {
|
||||
return `<li class="diag-row ${ok ? "is-ok" : "is-bad"}"><b>${ok ? "✓" : "✗"}</b>
|
||||
<span><strong>${escapeHtml(label)}</strong>${detail ? `<br><em>${escapeHtml(detail)}</em>` : ""}</span></li>`;
|
||||
}
|
||||
|
||||
// Runs the network check for one game and shows it as a checklist plus a
|
||||
// report to paste into an issue. On the PS5 (no clipboard) a QR code opens
|
||||
// the same dialog on a phone.
|
||||
async function openDiag(titleId) {
|
||||
const game = state.titles.find((g) => g.title_id === titleId);
|
||||
const name = game ? game.name : titleId;
|
||||
const back = document.activeElement;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "guide-backdrop";
|
||||
wrap.innerHTML = `
|
||||
<section class="guide" role="dialog" aria-modal="true" aria-labelledby="diagTitle">
|
||||
<header class="guide-head">
|
||||
<div>
|
||||
<p class="eyebrow">Download problem</p>
|
||||
<h2 id="diagTitle">${escapeHtml(name)}</h2>
|
||||
<p class="guide-game">${escapeHtml(titleId)}</p>
|
||||
</div>
|
||||
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="diag-body"><p class="guide-why">Checking the connection to Sony's servers…</p></div>
|
||||
</section>`;
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
wrap.remove();
|
||||
if (back && document.contains(back)) back.focus();
|
||||
};
|
||||
const onKey = (e) => { if (e.key === "Escape") close(); };
|
||||
wrap.querySelector(".guide-close").addEventListener("click", close);
|
||||
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); });
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.body.appendChild(wrap);
|
||||
wrap.querySelector(".guide-close").focus();
|
||||
|
||||
const [check, status, downloads, pkg, inst] = await Promise.all([
|
||||
fetchJson(`/api/netcheck?title=${encodeURIComponent(titleId)}`),
|
||||
fetchJson(API.status),
|
||||
fetchJson(API.downloads),
|
||||
fetchJson("/api/pkgdiag"),
|
||||
fetchJson(API.installStatus),
|
||||
]);
|
||||
if (!document.contains(wrap)) return;
|
||||
const job = (downloads || []).find((d) => d.title_id === titleId) || null;
|
||||
const ul = check && check.update_list, mf = check && check.manifest;
|
||||
const mfDetail = !mf ? "" : mf.ok && mf.step === "direct" ? "Single package, no list needed." :
|
||||
mf.ok ? `${mf.pieces} piece(s)` :
|
||||
mf.url ? failStepText({ fail_step: mf.step || "manifest", last_http_code: mf.http }) :
|
||||
"No package for this firmware.";
|
||||
const rows = [
|
||||
diagRow(Boolean(ul && ul.ok), "Update list from Sony",
|
||||
!ul ? "No answer from PatchDL."
|
||||
: ul.ok ? `Newest: ${ul.latest || "?"}`
|
||||
: !ul.url ? "PatchDL knows no update address for this game."
|
||||
: failStepText({ fail_step: ul.step || "network", last_http_code: ul.http })),
|
||||
diagRow(Boolean(mf && mf.ok), "Package list (manifest)", mfDetail),
|
||||
];
|
||||
if (job) rows.push(diagRow(job.state !== "error", "Last download",
|
||||
job.state === "error" ? (job.fail_step ? failStepText(job) : "Failed while downloading the pieces.") : job.state));
|
||||
|
||||
const report = [
|
||||
"PatchDL diagnostic report",
|
||||
`PatchDL ${status ? status.version : "?"} · FW ${status ? status.firmware : "?"}`,
|
||||
`Game: ${name} (${titleId})${game ? ` · installed ${game.installed_version || "?"} · ${game.source_type}${game.sideloaded ? ", sideloaded" : ""}` : ""}`,
|
||||
`Download: ${job ? JSON.stringify(job) : "none"}`,
|
||||
`Network check: ${check ? JSON.stringify(check) : "unavailable"}`,
|
||||
`Install: ${inst ? JSON.stringify(inst.streams || inst) : "unavailable"}`,
|
||||
`Console requests: ${pkg && pkg.log ? pkg.log.slice(-8).join(" | ") : "none"}`,
|
||||
...(state.logs.length ? ["Log:", ...state.logs.slice(-15)] : []),
|
||||
].join("\n");
|
||||
|
||||
const onConsole = isConsoleBrowser();
|
||||
const link = onConsole ? diagLink(titleId) : null;
|
||||
const matrix = link ? qrEncode(link) : null;
|
||||
wrap.querySelector(".diag-body").innerHTML = `
|
||||
<ul class="diag-list">${rows.join("")}</ul>
|
||||
${onConsole
|
||||
? (matrix ? `<div class="guide-qr">${qrSvg(matrix)}<div>
|
||||
<span>To report this, scan with your phone. The same report opens there, ready to copy.</span></div></div>` : "")
|
||||
: `<p class="guide-why">To report this, copy the report and paste it into a GitHub issue.</p>`}
|
||||
<textarea class="diag-report" readonly rows="8" aria-label="Diagnostic report">${escapeHtml(report)}</textarea>
|
||||
${onConsole ? "" : `<button type="button" class="row-button is-update diag-copy">Copy report</button>`}`;
|
||||
const copy = wrap.querySelector(".diag-copy");
|
||||
if (copy) copy.addEventListener("click", async () => {
|
||||
const ta = wrap.querySelector(".diag-report");
|
||||
try { await navigator.clipboard.writeText(report); }
|
||||
catch (_) { ta.select(); document.execCommand("copy"); }
|
||||
showToast("Report copied.");
|
||||
});
|
||||
}
|
||||
|
||||
const DONATE_URL = "https://paypal.me/oliverkoester";
|
||||
|
||||
// Donation dialog. On the TV a link is no use, so it shows a QR code for
|
||||
// the phone (hidden on phones, where the button opens PayPal directly).
|
||||
function openDonate() {
|
||||
const back = document.activeElement;
|
||||
const matrix = qrEncode(DONATE_URL);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "guide-backdrop";
|
||||
wrap.innerHTML = `
|
||||
<section class="guide" role="dialog" aria-modal="true" aria-labelledby="donateTitle">
|
||||
<header class="guide-head">
|
||||
<div>
|
||||
<p class="eyebrow">Support PatchDL</p>
|
||||
<h2 id="donateTitle">Buy me a coffee</h2>
|
||||
</div>
|
||||
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<p class="guide-why">If you like what I do, you can buy me a coffee.</p>
|
||||
${matrix ? `<div class="guide-qr">${qrSvg(matrix)}<div>
|
||||
<span>Scan with your phone to open PayPal:</span>
|
||||
<code>${escapeHtml(DONATE_URL)}</code></div></div>` : ""}
|
||||
<a class="row-button is-update donate-open" href="${DONATE_URL}" target="_blank" rel="noopener">Open PayPal</a>
|
||||
<p class="guide-why"><small>It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</small></p>
|
||||
</section>`;
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
wrap.remove();
|
||||
if (back && document.contains(back)) back.focus();
|
||||
};
|
||||
const onKey = (e) => { if (e.key === "Escape") close(); };
|
||||
wrap.querySelector(".guide-close").addEventListener("click", close);
|
||||
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); });
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.body.appendChild(wrap);
|
||||
wrap.querySelector(".guide-close").focus();
|
||||
}
|
||||
|
||||
// The server rescans on its own when games are added or removed and bumps
|
||||
// titles_gen whenever the list or an update check changes; reload quietly then.
|
||||
let watchBusy = false;
|
||||
async function watchTitles() {
|
||||
if (watchBusy) return; // a slow reload is still running
|
||||
watchBusy = true;
|
||||
try {
|
||||
const response = await fetch(API.status, { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const status = await response.json();
|
||||
const changed = state.status && status.titles_gen !== state.status.titles_gen;
|
||||
if (changed) await loadInitialData({ quiet: true });
|
||||
else { state.status = status; renderStatus(); }
|
||||
} catch (_) { /* offline for a moment; the next tick retries */ }
|
||||
finally { watchBusy = false; }
|
||||
}
|
||||
|
||||
// Refresh button: ask for a rescan right away, then reload once it had time.
|
||||
async function rescanNow() {
|
||||
try { await postJson(API.rescan, {}); } catch (_) { /* older server: plain reload */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await loadInitialData();
|
||||
}
|
||||
|
||||
function bindElements() {
|
||||
Object.assign(els, {
|
||||
firmwareValue: document.getElementById("firmwareValue"),
|
||||
@@ -133,6 +327,8 @@ function bindElements() {
|
||||
logOutput: document.getElementById("logOutput"),
|
||||
allowlistHosts: document.getElementById("allowlistHosts"),
|
||||
sourcePolicyList: document.getElementById("sourcePolicyList"),
|
||||
storageList: document.getElementById("storageList"),
|
||||
storageTotal: document.getElementById("storageTotal"),
|
||||
searchInput: document.getElementById("searchInput"),
|
||||
defaultPolicy: document.getElementById("defaultPolicy"),
|
||||
downloadDir: document.getElementById("downloadDir"),
|
||||
@@ -182,7 +378,7 @@ function bindEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
els.refreshBtn.addEventListener("click", loadInitialData);
|
||||
els.refreshBtn.addEventListener("click", rescanNow);
|
||||
if (els.updateAllBtn) els.updateAllBtn.addEventListener("click", updateAll);
|
||||
els.saveBtn.addEventListener("click", saveConfig);
|
||||
els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); });
|
||||
@@ -201,9 +397,10 @@ function setView(view) {
|
||||
else b.removeAttribute("aria-current");
|
||||
});
|
||||
document.querySelectorAll(".view").forEach((s) => s.classList.toggle("is-active", s.dataset.view === view));
|
||||
if (view === "settings") loadStorage();
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
async function loadInitialData({ quiet = false } = {}) {
|
||||
state.usingFallback = false;
|
||||
const [status, config, titles, downloads] = await Promise.all([
|
||||
getJson(API.status, fallback.status),
|
||||
@@ -221,15 +418,26 @@ 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.
|
||||
// An install ends when the install poll says so, not when the list
|
||||
// already reports the new version (the console writes it mid-install).
|
||||
if (old && old.installing) {
|
||||
g.installing = true;
|
||||
g.installStatus = old.installStatus;
|
||||
g.installProgress = old.installProgress;
|
||||
g.installDone = old.installDone;
|
||||
g.installTotal = old.installTotal;
|
||||
}
|
||||
if (old && g.status !== "up_to_date" &&
|
||||
(g.status !== "incompatible_fw" || g.manual_version)) {
|
||||
if (old.downloaded) g.downloaded = true;
|
||||
if (old.installing) g.installing = true;
|
||||
if (old._autoInstalled) g._autoInstalled = true;
|
||||
if (old._localDownloading) g._localDownloading = true;
|
||||
if (old._wasActive) g._wasActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
// A background reload must not swap the real list for demo data because
|
||||
// one request failed for a moment.
|
||||
if (quiet && state.usingFallback) { state.usingFallback = false; return; }
|
||||
state = { ...state, status, config, titles, downloads };
|
||||
reconcileFromJobs(downloads);
|
||||
|
||||
@@ -238,7 +446,10 @@ async function loadInitialData() {
|
||||
state.titles.some((g) => g._localDownloading))
|
||||
startDownloadPolling();
|
||||
if (state.titles.some((g) => g.installing)) startInstallPolling();
|
||||
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
|
||||
// Pick up installs that are running while this page was (re)loaded.
|
||||
if (!quiet) refreshInstallStatus();
|
||||
if (!quiet)
|
||||
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
|
||||
}
|
||||
|
||||
async function getJson(url, fallbackValue) {
|
||||
@@ -266,7 +477,15 @@ function renderStatus() {
|
||||
els.resolverValue.textContent = state.status.resolver || "--";
|
||||
const space = formatMb(state.status.free_space_mb);
|
||||
els.spaceValue.textContent = space;
|
||||
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
|
||||
const kept = Number(state.status.downloads_bytes) || 0;
|
||||
if (kept > 0) {
|
||||
// Packages on disk take space too; the link opens the list to manage them.
|
||||
els.downloadDirValue.innerHTML =
|
||||
`<button type="button" class="link-button">${formatBytes(kept)} in downloads · manage</button>`;
|
||||
els.downloadDirValue.querySelector("button").addEventListener("click", () => setView("settings"));
|
||||
} else {
|
||||
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
|
||||
}
|
||||
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
|
||||
if (els.railSpace) els.railSpace.textContent = `${space} free`;
|
||||
if (els.brandVersion)
|
||||
@@ -351,10 +570,12 @@ function renderGames() {
|
||||
// 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 isPs4(game) { return (game.title_id || "").startsWith("CUSA"); }
|
||||
|
||||
function gameCategory(game) {
|
||||
const policy = sourcePolicy(game);
|
||||
// Not supported = PatchDL can't install it (fake packages, mounted images).
|
||||
if (game.patch_title_match === false || !policy.allow_install)
|
||||
if (game.patch_title_match === false || !policy.allow_install || game.status === "blocked")
|
||||
return "blocked";
|
||||
if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState)
|
||||
return "updates";
|
||||
@@ -428,8 +649,9 @@ function createGameCard(game) {
|
||||
${game.downloading && game._jobState !== "queued" ? `<span class="pill live">Downloading</span>` : ""}
|
||||
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
|
||||
${statusPill(game)}
|
||||
${storagePill(game)}
|
||||
${sourcePill(game)}
|
||||
${isPs4(game) ? `<span class="pill">PS4</span>` : ""}
|
||||
${game.sideloaded ? `<span class="pill warn" title="Installed outside the Store, disc or PatchDL">Sideloaded</span>` : ""}
|
||||
</div>
|
||||
<div class="versions">${versionsHtml(game)}</div>
|
||||
`;
|
||||
@@ -462,10 +684,11 @@ function createGameCard(game) {
|
||||
card.appendChild(prog);
|
||||
} else if (game.installing) {
|
||||
const pct = Math.max(0, Math.min(100, Number(game.installProgress) || 0));
|
||||
const indet = game.installStatus === "finishing" || game.installStatus === "waiting";
|
||||
const note = document.createElement("div");
|
||||
note.className = "card-progress";
|
||||
note.innerHTML = `
|
||||
<div class="progress"><i style="width:${pct}%"></i></div>
|
||||
<div class="progress${indet ? " is-indeterminate" : ""}"><i style="width:${indet ? 32 : pct}%${indet ? `;animation-delay:-${Date.now() % INDET_MS}ms` : ""}"></i></div>
|
||||
<div class="progress-meta">${installProgressHtml(game)}</div>
|
||||
`;
|
||||
card.appendChild(note);
|
||||
@@ -496,9 +719,9 @@ function buildToggle(game) {
|
||||
track.className = "track";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "label";
|
||||
lbl.textContent = cb.checked ? "On" : "Off";
|
||||
lbl.textContent = cb.checked ? "Updates on" : "Updates off";
|
||||
cb.addEventListener("change", () => {
|
||||
lbl.textContent = cb.checked ? "On" : "Off";
|
||||
lbl.textContent = cb.checked ? "Updates on" : "Updates off";
|
||||
updateGame(game.title_id, { enabled: cb.checked });
|
||||
});
|
||||
toggle.append(cb, track, lbl);
|
||||
@@ -530,7 +753,9 @@ function primaryButton(game) {
|
||||
// Cancel stops AND deletes (unlike Pause, which keeps the partial).
|
||||
function stopButton(game) {
|
||||
if (game.installing) return null;
|
||||
if (game.downloading || game.resumable || game.downloaded)
|
||||
if (game.downloaded && !game.downloading)
|
||||
return { label: "Delete", action: "cancel", variant: "cancel", hint: "Delete the downloaded package to free space." };
|
||||
if (game.downloading || game.resumable)
|
||||
return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop and delete the download." };
|
||||
return null;
|
||||
}
|
||||
@@ -557,10 +782,14 @@ function versionsHtml(game) {
|
||||
const job = state.downloads.find((d) => d.title_id === game.title_id);
|
||||
if (job && job.state === "error" && Number(job.rc) === -3)
|
||||
parts.push(`<span class="card-note is-warn">${noSpaceText(job.need_bytes, job.free_bytes)}</span>`);
|
||||
else if (job && job.state === "error")
|
||||
parts.push(`<span class="card-note is-warn">Download failed. ${job.fail_step ? escapeHtml(failStepText(job)) + " " : ""}<button type="button" class="link-button" data-diag="${escapeHtml(game.title_id)}">Details</button></span>`);
|
||||
if (game.latest_version && game.latest_version !== offered) {
|
||||
const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : "";
|
||||
parts.push(`<span>Newest ${v(game.latest_version)}${fw}</span>`);
|
||||
}
|
||||
if (game.sideloaded)
|
||||
parts.push(`<span class="card-note is-warn">Installed outside the Store. An update replaces this exact version, which some tools depend on. Switch on Updates only if you want that.</span>`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
@@ -578,11 +807,6 @@ function statusPill(game) {
|
||||
return `<span class="pill">No patch info</span>`;
|
||||
}
|
||||
|
||||
function storagePill(game) {
|
||||
return hasSharedStorage(game) && sourcePolicy(game).allow_install
|
||||
? `<span class="pill warn">Shared master</span>` : "";
|
||||
}
|
||||
|
||||
function sourcePill(game) {
|
||||
const info = sourceInfo(game);
|
||||
return `<span class="pill ${info.className}">${escapeHtml(info.label)}</span>`;
|
||||
@@ -605,6 +829,7 @@ function progressMetaHtml(d) {
|
||||
const speed = Number(d._speed) || 0;
|
||||
if (d.state === "queued") return `<span>Queued — waiting for a free slot</span>`;
|
||||
const parts = [];
|
||||
if (d.verifying) parts.push(`<span><b>Checking the package already on disk</b></span>`);
|
||||
parts.push(`<span><b>${formatBytes(done)}</b>${total > 0 ? ` / ${formatBytes(total)}` : ""}</span>`);
|
||||
if (speed > 0) parts.push(`<span><b>${formatBytes(speed)}/s</b></span>`);
|
||||
if (speed > 0 && total > done) parts.push(`<span>ETA <b>${formatEta((total - done) / speed)}</b></span>`);
|
||||
@@ -613,10 +838,30 @@ function progressMetaHtml(d) {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// One sweep of the indeterminate bar. Cards are rebuilt on every list
|
||||
// update; a negative delay taken from the clock lets a rebuilt bar continue
|
||||
// where the old one was instead of starting over.
|
||||
const INDET_MS = 1600;
|
||||
|
||||
const INSTALL_PHASE_TEXT = {
|
||||
waiting: "Waiting for the console",
|
||||
reading: "Console reading package",
|
||||
finishing: "Finishing on console",
|
||||
};
|
||||
|
||||
function installProgressHtml(game) {
|
||||
const status = game.installStatus || "waiting";
|
||||
const done = Number(game.installDone) || 0;
|
||||
const total = Number(game.installTotal) || 0;
|
||||
const phase = INSTALL_PHASE_TEXT[status];
|
||||
if (phase) {
|
||||
const parts = [`<span><b>${phase}</b></span>`];
|
||||
if (status === "reading" && total > 0) {
|
||||
parts.push(`<span><b>${formatBytes(done)}</b> / ${formatBytes(total)}</span>`);
|
||||
parts.push(`<span><b>${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%</b></span>`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
const parts = [`<span>Status <b>${escapeHtml(status)}</b></span>`];
|
||||
if (total > 0) parts.push(`<span><b>${formatBytes(done)}</b> / ${formatBytes(total)}</span>`);
|
||||
parts.push(`<span><b>${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%</b></span>`);
|
||||
@@ -654,6 +899,9 @@ function reconcileFromJobs(jobs) {
|
||||
g._jobState = null;
|
||||
return;
|
||||
}
|
||||
// A download started from this page counts as watched even if it
|
||||
// finished before a poll saw it running (a verified package on disk).
|
||||
if (g._localDownloading) g._wasActive = true;
|
||||
g._localDownloading = false; // the pool now tracks it
|
||||
g._jobState = j.state;
|
||||
if (j.state === "active" || j.state === "queued") {
|
||||
@@ -667,12 +915,18 @@ function reconcileFromJobs(jobs) {
|
||||
g.partial_bytes = Number(j.bytes) || g.partial_bytes || 0;
|
||||
g._wasActive = false;
|
||||
} else if (j.state === "done") {
|
||||
// Auto-install once, on the download finishing while this page watched
|
||||
// it, and only while the update is still pending. A finished job stays
|
||||
// in the pool; without these checks every poll would retry the install
|
||||
// (refused as already installed) and redraw the list each time.
|
||||
const justFinished = g._wasActive;
|
||||
const pending = g.status === "available" || Boolean(g.manual_version);
|
||||
g.downloading = false;
|
||||
g.resumable = false;
|
||||
g.downloaded = true;
|
||||
g._wasActive = false;
|
||||
if (state.config.install_after_download && isInstallAllowed(g) &&
|
||||
!g.installing && !g._autoInstalled) {
|
||||
if (justFinished && pending && state.config.install_after_download &&
|
||||
isInstallAllowed(g) && !g.installing && !g._autoInstalled) {
|
||||
g._autoInstalled = true;
|
||||
doInstall(g);
|
||||
}
|
||||
@@ -685,7 +939,12 @@ function reconcileFromJobs(jobs) {
|
||||
if (!g._spaceWarned) showToast(`${g.name}: ${noSpaceText(j.need_bytes, j.free_bytes)}`);
|
||||
g._spaceWarned = true;
|
||||
} else if (g._wasActive) {
|
||||
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`);
|
||||
const why = j.fail_step ? ` ${failStepText(j)}` : "";
|
||||
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}${why}`);
|
||||
if (j.fail_step) {
|
||||
state.logs.push(`[${timeNow()}] Download ${g.title_id} failed while fetching the package list: ${j.fail_step} (curl ${j.last_curl_rc}, HTTP ${j.last_http_code})`);
|
||||
renderLogs();
|
||||
}
|
||||
}
|
||||
g._wasActive = false;
|
||||
g.downloading = false;
|
||||
@@ -695,6 +954,22 @@ function reconcileFromJobs(jobs) {
|
||||
});
|
||||
}
|
||||
|
||||
// Why the package list (manifest) couldn't be fetched, in words.
|
||||
function failStepText(j) {
|
||||
const http = Number(j.last_http_code) || 0;
|
||||
return ({
|
||||
dns: "Sony's download server couldn't be found (DNS). Is it blocked on your network?",
|
||||
network: "Couldn't connect to Sony's download server. Is it blocked on your network?",
|
||||
timeout: "Sony's download server didn't answer in time.",
|
||||
tls: "The secure connection to Sony's server failed.",
|
||||
redirect: "Sony's server sent PatchDL somewhere it doesn't follow.",
|
||||
http_status: `Sony's server answered HTTP ${http}.`,
|
||||
bad_manifest: "Sony's server sent something that isn't a package list.",
|
||||
empty_reply: "Sony's server sent an empty reply.",
|
||||
host_not_allowed: "The package is on a server PatchDL doesn't use.",
|
||||
})[j.fail_step] || `Package list unavailable (${j.fail_step}).`;
|
||||
}
|
||||
|
||||
function startInstallPolling() {
|
||||
if (installPollTimer) return;
|
||||
installPollTimer = setInterval(refreshInstallStatus, 2000);
|
||||
@@ -722,6 +997,50 @@ async function refreshInstallStatus() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Installs streamed from PatchDL: what PatchDL measured itself (how much
|
||||
// of each package the console read) beats Sony's status query, which fails
|
||||
// or reports nothing useful for them. One entry per title.
|
||||
const streams = Array.isArray(s.streams) ? s.streams : [];
|
||||
if (streams.length) {
|
||||
let ended = false, changed = false;
|
||||
streams.forEach((st) => {
|
||||
const game = state.titles.find((g) => g.title_id.startsWith(st.title_id));
|
||||
if (!game) return;
|
||||
const over = st.phase === "done" || st.phase === "stalled";
|
||||
if (over && !game.installing) return; // finished before this page watched it
|
||||
const total = Number(st.total) || 0;
|
||||
const read = Math.min(Number(st.read) || 0, total || Infinity);
|
||||
const pct = total ? Math.floor((read * 100) / total) : 0;
|
||||
// Re-render only on a visible change: a rebuilt card restarts the
|
||||
// indeterminate bar's animation.
|
||||
if (game.installing !== !over || game.installStatus !== st.phase ||
|
||||
game.installProgress !== pct) changed = true;
|
||||
game.installing = !over;
|
||||
game.installStatus = st.phase;
|
||||
game.installProgress = pct;
|
||||
game.installDone = read;
|
||||
game.installTotal = total;
|
||||
if (over) {
|
||||
const msg = st.phase === "stalled"
|
||||
? "stopped: the console stopped reading the package"
|
||||
: st.verified
|
||||
? `done and verified${st.deleted ? ", package deleted" : ", package kept"}`
|
||||
: "finished, but the new version could not be confirmed; package kept";
|
||||
state.logs.push(`[${timeNow()}] Install ${game.title_id}: ${msg}.`);
|
||||
showToast(`${game.name}: install ${msg}.`);
|
||||
ended = true;
|
||||
}
|
||||
});
|
||||
if (changed) renderGames();
|
||||
if (state.titles.some((g) => g.installing)) startInstallPolling();
|
||||
if (ended) {
|
||||
renderLogs();
|
||||
if (!state.titles.some((g) => g.installing)) stopInstallPolling();
|
||||
loadInitialData({ quiet: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const titleId = s.target_title_id || "";
|
||||
const game = state.titles.find((g) => g.title_id === titleId || g.title_id.slice(0, 9) === titleId.slice(0, 9));
|
||||
if (!game) return;
|
||||
@@ -809,7 +1128,7 @@ function renderGlobalStatus() {
|
||||
const isActive = active && active.state === "active";
|
||||
|
||||
// Eyebrow + name
|
||||
els.globalDlState.textContent = isActive ? "Downloading" : "Queued";
|
||||
els.globalDlState.textContent = !isActive ? "Queued" : active.verifying ? "Verifying" : "Downloading";
|
||||
els.globalDlName.textContent = active.name || active.title_id || "—";
|
||||
|
||||
// Position chip ("3 of 9") only when there's more than one game in this run
|
||||
@@ -864,6 +1183,77 @@ function applyDownloadProgress() {
|
||||
|
||||
/* ---------------- settings ---------------- */
|
||||
|
||||
const STORAGE_STATE = {
|
||||
complete: { label: "Complete", className: "ok" },
|
||||
paused: { label: "Paused", className: "warn" },
|
||||
downloading: { label: "Downloading", className: "live" },
|
||||
installing: { label: "Installing", className: "live" },
|
||||
failed: { label: "Failed", className: "blocked" },
|
||||
};
|
||||
|
||||
// What PatchDL keeps under /data/patchdl, one row per title folder, with a
|
||||
// delete button that also works for games that are no longer installed.
|
||||
async function loadStorage() {
|
||||
if (!els.storageList) return;
|
||||
let data;
|
||||
try {
|
||||
const response = await fetch(API.storage, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
els.storageList.innerHTML = `<p class="storage-empty">Could not read the download folder.</p>`;
|
||||
return;
|
||||
}
|
||||
const items = (data.items || []).sort((a, b) => b.bytes - a.bytes);
|
||||
els.storageTotal.textContent = items.length ? `${formatBytes(data.total_bytes)} in total` : "";
|
||||
if (!items.length) {
|
||||
els.storageList.innerHTML = `<p class="storage-empty">Nothing downloaded.</p>`;
|
||||
return;
|
||||
}
|
||||
els.storageList.replaceChildren(...items.map((item) => {
|
||||
const info = STORAGE_STATE[item.state] || STORAGE_STATE.complete;
|
||||
const busy = item.state === "installing" || item.state === "downloading";
|
||||
const row = document.createElement("div");
|
||||
row.className = "storage-row";
|
||||
row.innerHTML = `
|
||||
<span><strong>${escapeHtml(item.name || item.title_id)}</strong><br>
|
||||
<em>${escapeHtml(item.title_id)}${item.version ? ` · ${escapeHtml(item.version)}` : ""} · ${storageRelation(item)}</em></span>
|
||||
<span class="pill ${info.className}">${info.label}</span>
|
||||
<b>${formatBytes(item.bytes)}</b>
|
||||
`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "row-button is-cancel";
|
||||
btn.textContent = "Delete";
|
||||
btn.disabled = busy;
|
||||
btn.title = busy ? "Busy right now" : `Delete ${formatBytes(item.bytes)} for ${item.name || item.title_id}`;
|
||||
btn.addEventListener("click", () => deleteStorage(item));
|
||||
row.appendChild(btn);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
// How the package relates to the installed game, so a kept package can be
|
||||
// judged: still useful (newer), spent (installed), or for a game not present.
|
||||
function storageRelation(item) {
|
||||
if (!item.installed) return "game not installed";
|
||||
if (!item.version || !item.installed_version) return "version unknown";
|
||||
return item.version > item.installed_version ? "newer than installed" : "already installed";
|
||||
}
|
||||
|
||||
async function deleteStorage(item) {
|
||||
const label = item.name || item.title_id;
|
||||
if (!window.confirm(`Delete the download for ${label} (${formatBytes(item.bytes)})?`)) return;
|
||||
try {
|
||||
await postJson(API.storageDelete(item.title_id), {});
|
||||
showToast(`${label}: download deleted.`);
|
||||
} catch (error) {
|
||||
showToast(`${label}: ${reasonText(error)}`);
|
||||
}
|
||||
await loadStorage();
|
||||
await loadInitialData({ quiet: true });
|
||||
}
|
||||
|
||||
function renderSourcePolicies() {
|
||||
const rows = Object.keys(SOURCE_TYPES).map((type) => {
|
||||
const info = SOURCE_TYPES[type];
|
||||
@@ -970,6 +1360,7 @@ async function doDownload(game) {
|
||||
}
|
||||
|
||||
async function doInstall(game) {
|
||||
const wasDownloaded = game.downloaded;
|
||||
game.installing = true;
|
||||
game.installStatus = "starting";
|
||||
game.installProgress = 0;
|
||||
@@ -978,7 +1369,13 @@ async function doInstall(game) {
|
||||
try {
|
||||
await postJson(API.action(game.title_id, "install"), {});
|
||||
} catch (error) {
|
||||
game.installing = false;
|
||||
game.downloaded = wasDownloaded; // refused: the package is untouched
|
||||
if (error.body && error.body.reason === "install_running") {
|
||||
// Another tab or device started it: follow that install here.
|
||||
startInstallPolling();
|
||||
} else {
|
||||
game.installing = false;
|
||||
}
|
||||
const why = reasonText(error);
|
||||
state.logs.push(`[${timeNow()}] install ${game.title_id} blocked: ${why}`);
|
||||
showToast(`${game.name}: ${why}`);
|
||||
@@ -1001,6 +1398,7 @@ async function cancelDownload(titleId) {
|
||||
showToast(`${game ? game.name : titleId}: download cancelled / deleted.`);
|
||||
} catch (error) {
|
||||
showToast(`${game ? game.name : titleId}: ${reasonText(error)}`);
|
||||
return; // refused (e.g. the console is installing from it): nothing changed
|
||||
}
|
||||
if (game) {
|
||||
game.downloading = false;
|
||||
@@ -1054,12 +1452,6 @@ function updateGame(titleId, patch) {
|
||||
/* ---------------- policy helpers ---------------- */
|
||||
|
||||
function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; }
|
||||
function hasSharedStorage(game) {
|
||||
if (game.patch_storage_match === false) return true;
|
||||
const storage = (game.patch_storage_title_id || "").slice(0, 9);
|
||||
const target = (game.title_id || "").slice(0, 9);
|
||||
return Boolean(storage && target && storage !== target);
|
||||
}
|
||||
function isDownloadAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled !== false &&
|
||||
@@ -1109,6 +1501,11 @@ const REASON_TEXT = {
|
||||
download_failed: "Download failed.",
|
||||
download_in_progress: "Another download is already running.",
|
||||
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
|
||||
install_running: "The console is installing from this package right now. Try again when it is done.",
|
||||
already_installed: "This version is already installed.",
|
||||
too_many_installs: "Too many installs running at once. Try again when one has finished.",
|
||||
download_incomplete: "The download is not complete yet.",
|
||||
download_unverified: "The package on disk hasn't been checked yet. Press Update: PatchDL verifies it and downloads only what's missing.",
|
||||
title_disabled: "This title is disabled.",
|
||||
download_paused: "Download paused.",
|
||||
not_downloading: "Nothing is downloading for this title.",
|
||||
|
||||
+13
-2
@@ -58,6 +58,7 @@
|
||||
<div class="rail-foot">
|
||||
<span id="railFw">FW --</span>
|
||||
<span id="railSpace">-- free</span>
|
||||
<button type="button" class="link-button rail-donate" data-donate>Buy me a coffee</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -66,7 +67,7 @@
|
||||
<section class="view is-active" data-view="games">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">PS5 update manager</p>
|
||||
<p class="eyebrow">PS5 & PS4 game updates</p>
|
||||
<h1>Games</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
@@ -169,7 +170,7 @@
|
||||
<label class="field">
|
||||
<span>Download Folder</span>
|
||||
<input id="downloadDir" type="text" spellcheck="false" readonly
|
||||
title="Patches always download internally and are removed after install" />
|
||||
title="Patches always download to the console's internal storage" />
|
||||
</label>
|
||||
|
||||
<label class="switch-row">
|
||||
@@ -234,6 +235,16 @@
|
||||
<span>Install Protection</span>
|
||||
<div id="sourcePolicyList"></div>
|
||||
</div>
|
||||
|
||||
<div class="storage-list">
|
||||
<span>Downloads on console <em id="storageTotal"></em></span>
|
||||
<div id="storageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="donate-row">
|
||||
<span>Like PatchDL? You can buy me a coffee.</span>
|
||||
<button type="button" class="row-button is-ghost" data-donate>Buy me a coffee</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+93
-4
@@ -116,6 +116,42 @@ svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
.rail-foot span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-foot .rail-donate { justify-self: start; margin-top: 4px; }
|
||||
|
||||
.donate-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.donate-row .row-button { width: auto; padding: 0 16px; }
|
||||
.diag-body { display: grid; gap: 14px; }
|
||||
.diag-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.diag-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; }
|
||||
.diag-row > b { font-size: 16px; }
|
||||
.diag-row.is-ok > b { color: var(--green); }
|
||||
.diag-row.is-bad > b { color: var(--red); }
|
||||
.diag-row em { color: var(--muted); font-style: normal; font-size: 13px; }
|
||||
.diag-report {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
.diag-copy { justify-self: start; width: auto; padding: 0 18px; }
|
||||
.donate-open { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; justify-self: start; padding: 0 18px; }
|
||||
|
||||
/* ---------------- views + topbar ---------------- */
|
||||
|
||||
@@ -370,11 +406,11 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px; height: 48px;
|
||||
width: 64px; height: 64px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2d6d9d, #247857);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -466,6 +502,17 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
background: linear-gradient(90deg, var(--green), var(--blue));
|
||||
transition: width 320ms ease;
|
||||
}
|
||||
.progress.is-indeterminate > i {
|
||||
animation: progressSweep 1.6s linear infinite; /* duration = INDET_MS */
|
||||
transition: none;
|
||||
}
|
||||
@keyframes progressSweep {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(320%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress.is-indeterminate > i { animation: none; width: 100% !important; opacity: 0.5; }
|
||||
}
|
||||
.progress-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -559,8 +606,39 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.toggle input:checked + .track::after { transform: translateX(18px); background: var(--green); }
|
||||
.toggle input:focus-visible + .track { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||||
.toggle .label { font-size: 12px; color: var(--muted); min-width: 22px; }
|
||||
.lead .toggle .label { white-space: nowrap; }
|
||||
|
||||
.allowlist, .source-policy { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.allowlist, .source-policy, .storage-list { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.storage-list > span em { color: var(--muted); font-style: normal; margin-left: 6px; }
|
||||
.storage-list > div { display: grid; gap: 8px; }
|
||||
.storage-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.storage-row strong { font-size: 13px; overflow-wrap: anywhere; }
|
||||
.storage-row em { color: var(--muted); font-style: normal; font-size: 12px; }
|
||||
.storage-row b { font-variant-numeric: tabular-nums; }
|
||||
.storage-empty { color: var(--muted); font-size: 13px; }
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--blue);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.link-button:hover { color: var(--ink); }
|
||||
@media (max-width: 640px) {
|
||||
.storage-row { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
}
|
||||
.allowlist div { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.source-policy > div { display: grid; gap: 8px; }
|
||||
|
||||
@@ -757,6 +835,17 @@ pre {
|
||||
|
||||
/* ---------------- responsive ---------------- */
|
||||
|
||||
/* Wide screens (desktop, PS5 browser): bigger covers so a card doesn't look
|
||||
empty. */
|
||||
@media (min-width: 900px) {
|
||||
.game-card { padding: 18px 20px; }
|
||||
.cover { width: 104px; height: 104px; border-radius: 14px; font-size: 22px; }
|
||||
.card-row { gap: 20px; }
|
||||
.card-body h3 { font-size: 19px; margin-bottom: 8px; }
|
||||
.subline { font-size: 13px; }
|
||||
.versions { font-size: 13px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.rail {
|
||||
|
||||
Reference in new issue
Block a user