Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd08ad70c4 | ||
|
|
f0091943f2 | ||
|
|
c09f0481ab | ||
|
|
289688f727 | ||
|
|
533c25a2ac | ||
|
|
70863fb54e | ||
|
|
b115c0fd4b | ||
|
|
7ae09ce571 | ||
|
|
1739096aa1 | ||
|
|
25c2adc4bf | ||
|
|
36c7c02ebb | ||
|
|
4379ac3f59 | ||
|
|
91cfb2cd02 | ||
|
|
bc716339ba | ||
|
|
fbe984da87 | ||
|
|
f251e2aa98 |
No files matched your search
@@ -20,6 +20,7 @@ SRCS := src/main.c \
|
||||
src/patchdl_scan.c \
|
||||
src/patchdl_appdb.c \
|
||||
src/patchdl_net.c \
|
||||
src/patchdl_prospero.c \
|
||||
src/patchdl_resolve.c \
|
||||
src/patchdl_verxml.c \
|
||||
src/patchdl_install.c \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center">PatchDL</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser and on a phone, downloading a game update" width="900">
|
||||
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser and on a phone, fetching a patch that runs on an older firmware" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,25 +28,31 @@
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Your whole library at a glance.** Every installed game shows up with its version and the newest update Sony offers for it.
|
||||
- **Your whole library at a glance.** Every installed game shows up with its icon, its version and the newest update Sony offers for it, sorted A to Z. 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, so no game ends up asking for newer system software.
|
||||
- **Made for your firmware.** You only see updates your console can run. When Sony's newest patch needs a newer firmware, PatchDL finds the newest one that still runs on yours and helps you get it: scan a QR code with your phone, copy one link, and the PS5 does the rest.
|
||||
- **Knows where each game came from.** PatchDL checks every game's package and recognizes disc and PlayStation Store installs. It only ever patches games Sony signed.
|
||||
- **Checked against Sony's checksums.** Turn on verification and PatchDL compares every piece with the SHA-256 Sony publishes for it.
|
||||
- **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
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games view with a running download"><br><sub><b>Games</b> · every game, its version and the update waiting for it</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/queue.jpg" width="420" alt="Updating view with two queued updates"><br><sub><b>Updating</b> · the queue, next to the download that's running</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games view with a running download"><br><sub><b>Games</b> · every update waiting for you, one click away</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/library.jpg" width="420" alt="Full library with game icons, sorted by name"><br><sub><b>Library</b> · icons, versions and firmware needs, A to Z</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="docs/images/oldfw.jpg" width="420" alt="QR code on the TV and the step-by-step guide on a phone"><br><sub><b>Older firmware</b> · scan on the TV, paste one link on your phone</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/settings.jpg" width="420" alt="Settings view"><br><sub><b>Settings</b> · changes apply right away, no restart</sub></td>
|
||||
<td align="center" width="50%"><img src="docs/images/tile-homescreen.jpg" width="420" alt="PatchDL tile on the PS5 home screen"><br><sub><b>Home screen</b> · the PatchDL tile on a real PS5</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><img src="docs/images/tile-homescreen.jpg" width="420" alt="PatchDL tile on the PS5 home screen"><br><sub><b>Home screen</b> · the PatchDL tile on a real PS5</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -55,7 +61,7 @@
|
||||
Start an update from the couch with your phone, check on it from your laptop, or open it on the TV. The UI adapts to whatever screen it's on.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/phones.jpg" alt="PatchDL on a phone: game list with a download, and Settings" width="560">
|
||||
<img src="docs/images/phones.jpg" alt="PatchDL on a phone: game list and Settings" width="560">
|
||||
</p>
|
||||
|
||||
## Get started
|
||||
@@ -72,12 +78,21 @@ Start an update from the couch with your phone, check on it from your laptop, or
|
||||
**Does PatchDL download games?**
|
||||
No. It fetches updates for games that are already installed on your console. Nothing else.
|
||||
|
||||
**Which games does PatchDL update?**
|
||||
PS5 games you installed from disc or from the PlayStation Store. PatchDL reads each game's package before it offers anything, and it leaves every other title alone. PS4 games are not supported.
|
||||
|
||||
**Where do the updates come from?**
|
||||
From Sony's official update servers, the same files your console downloads on its own. PatchDL only talks to a short list of PlayStation CDN hosts.
|
||||
From Sony's official update servers, the same files your console downloads on its own. PatchDL downloads only from a short list of PlayStation CDN hosts. To find older patches it also asks [Prospero Patches](https://prosperopatches.com/) which versions exist for a game. That lookup sends the game's title ID and nothing else.
|
||||
|
||||
**Can I get an older patch that runs on my firmware?**
|
||||
Yes. Sony still hosts older patches, but its servers only tell your console about the newest one. When that one needs a newer firmware, PatchDL looks up the version history on [Prospero Patches](https://prosperopatches.com/) and lists the game under *Updates* with an **Old FW patch** button. Tap it and scan the QR code with your phone. Open the game on Prospero, pick the version PatchDL names (confirm the human check if one shows up) and copy the **SC** link. Paste it on your phone, and the PS5 downloads the patch from Sony and handles it like any other update.
|
||||
|
||||
**Does it change the updates?**
|
||||
No. Each package stays exactly as Sony published it, and the console's own installer applies it.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -114,7 +129,7 @@ PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||
|
||||
## Credits
|
||||
|
||||
PatchDL is written in C and builds on [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/), [libcurl](https://curl.se/), [OpenSSL](https://www.openssl.org/), [SQLite](https://sqlite.org/) and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk). Thanks to everyone behind them.
|
||||
PatchDL is written in C and builds on [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/), [libcurl](https://curl.se/), [OpenSSL](https://www.openssl.org/), [SQLite](https://sqlite.org/) and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk). The version history behind older patches comes from [Prospero Patches](https://prosperopatches.com/). Thanks to everyone behind them.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 197 KiB |
@@ -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);
|
||||
@@ -113,8 +169,15 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
}
|
||||
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 +204,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -353,6 +353,90 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- external-host HTTP (no Sony CA, no allowlist) -------------- */
|
||||
|
||||
static int
|
||||
http_external(const char *url, const char *method,
|
||||
const char *content_type, const char *body,
|
||||
patchdl_buf_t *out) {
|
||||
#ifndef PATCHDL_HAVE_CURL
|
||||
(void)url; (void)method; (void)content_type; (void)body; (void)out;
|
||||
return -1;
|
||||
#else
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN];
|
||||
char resolve_str[512];
|
||||
struct curl_slist *resolve_list = NULL;
|
||||
struct curl_slist *headers = NULL;
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
|
||||
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
|
||||
resolve_list = curl_slist_append(NULL, resolve_str);
|
||||
|
||||
{
|
||||
size_t caller_max = out->max;
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->max = caller_max;
|
||||
}
|
||||
|
||||
curl = curl_easy_init();
|
||||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve_list);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT,
|
||||
"Mozilla/5.0 (PlayStation; PatchDL)");
|
||||
|
||||
if (body) {
|
||||
if (content_type) {
|
||||
char ct[256];
|
||||
snprintf(ct, sizeof(ct), "Content-Type: %s", content_type);
|
||||
headers = curl_slist_append(headers, ct);
|
||||
}
|
||||
headers = curl_slist_append(headers, "Accept: application/json");
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
}
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(resolve_list);
|
||||
curl_slist_free_all(headers);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
free(out->data);
|
||||
out->data = NULL;
|
||||
out->size = 0;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_get_external(const char *url, patchdl_buf_t *out) {
|
||||
return http_external(url, "GET", NULL, NULL, out);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_http_post_external(const char *url, const char *content_type,
|
||||
const char *body, patchdl_buf_t *out) {
|
||||
return http_external(url, "POST", content_type, body, out);
|
||||
}
|
||||
|
||||
/* Write sink: tees the body to the file and, when verifying, into a running
|
||||
SHA-256. curl always calls with size==1, so nmemb is the byte count. */
|
||||
typedef struct {
|
||||
@@ -1048,6 +1132,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 */
|
||||
|
||||
@@ -29,6 +29,13 @@ void patchdl_net_global_cleanup(void);
|
||||
|
||||
int patchdl_http_get(const char *url, patchdl_buf_t *out);
|
||||
|
||||
/* External-host variants: no Sony CA pinning, no Sony host allowlist.
|
||||
SSL peer verification is disabled (PS5 has no system CA store; these
|
||||
are used for public metadata only, never for sensitive data). */
|
||||
int patchdl_http_get_external(const char *url, patchdl_buf_t *out);
|
||||
int patchdl_http_post_external(const char *url, const char *content_type,
|
||||
const char *body, patchdl_buf_t *out);
|
||||
|
||||
/* ---- parallel piece download (used by the connection pool) ------------- */
|
||||
|
||||
/* One piece of a split manifest package. `url` is heap-allocated. */
|
||||
@@ -43,6 +50,7 @@ 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
|
||||
@@ -51,6 +59,7 @@ typedef struct {
|
||||
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
|
||||
void patchdl_manifest_free(patchdl_manifest_t *m);
|
||||
|
||||
|
||||
/* Live state shared with one in-flight piece download. The worker owns these;
|
||||
the curl callbacks read `abort` (set elsewhere) and publish progress into
|
||||
`bytes_slot` (single-writer per worker slot). */
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Copyright (C) 2026 Knutwurst
|
||||
*
|
||||
* PatchDL is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version. See the LICENSE file in the project root for details.
|
||||
*/
|
||||
|
||||
#include "patchdl_prospero.h"
|
||||
#include "patchdl_net.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define PROSPERO_BASE "https://prosperopatches.com"
|
||||
#define PROSPERO_API PROSPERO_BASE "/api/internal"
|
||||
#define BUF_MAX (512 * 1024)
|
||||
|
||||
/* Prospero firmware strings use the same hex-digits-as-decimal encoding
|
||||
as Sony's system_ver: "10.20.00.06" packs to 0x10200006. Parse with
|
||||
%x so the hex bytes compare correctly against g_fw.bin. */
|
||||
static uint32_t
|
||||
fw_str_to_bin(const char *s) {
|
||||
unsigned a = 0, b = 0, c = 0, d = 0;
|
||||
sscanf(s, "%x.%x.%x.%x", &a, &b, &c, &d);
|
||||
return (a << 24) | (b << 16) | (c << 8) | d;
|
||||
}
|
||||
|
||||
static void
|
||||
fw_bin_to_display(uint32_t bin, char *out, size_t sz) {
|
||||
snprintf(out, sz, "%x.%02x", (bin >> 24) & 0xff, (bin >> 16) & 0xff);
|
||||
}
|
||||
|
||||
/* Extract the first occurrence of `data-key="<hex>"` from HTML. */
|
||||
static int
|
||||
extract_data_key(const char *html, char *out, size_t out_sz) {
|
||||
const char *needle = "data-key=\"";
|
||||
const char *p = strstr(html, needle);
|
||||
if (!p) return -1;
|
||||
p += strlen(needle);
|
||||
size_t len = 0;
|
||||
while (p[len] && p[len] != '"' && len < out_sz - 1)
|
||||
len++;
|
||||
if (!len || p[len] != '"') return -1;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Find the next JSON string value for a given key inside a JSON object
|
||||
fragment. Crude but sufficient for the predictable Prospero response. */
|
||||
static const char *
|
||||
json_str_val(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char needle[64];
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
const char *p = strstr(json, needle);
|
||||
if (!p) return NULL;
|
||||
p += strlen(needle);
|
||||
while (*p == ' ' || *p == ':') p++;
|
||||
if (*p != '"') return NULL;
|
||||
p++;
|
||||
size_t len = 0;
|
||||
while (p[len] && p[len] != '"' && len < out_sz - 1)
|
||||
len++;
|
||||
memcpy(out, p, len);
|
||||
out[len] = '\0';
|
||||
return p + len + 1;
|
||||
}
|
||||
|
||||
/* Check if a JSON fragment contains "success":true */
|
||||
static int
|
||||
json_success(const char *json) {
|
||||
const char *p = strstr(json, "\"success\"");
|
||||
if (!p) return 0;
|
||||
p += 9;
|
||||
while (*p == ' ' || *p == ':') p++;
|
||||
return !strncmp(p, "true", 4);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out) {
|
||||
patchdl_buf_t buf;
|
||||
char url[256], key[128], body[256];
|
||||
char best_ver[16] = {0}, best_size[16] = {0};
|
||||
uint32_t best_fw = 0;
|
||||
|
||||
if (!title_id || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* Step 1: fetch the title page to get the data-key. */
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = BUF_MAX;
|
||||
snprintf(url, sizeof(url), "%s/%.16s", PROSPERO_BASE, title_id);
|
||||
if (patchdl_http_get_external(url, &buf)) return -1;
|
||||
if (!buf.data) return -1;
|
||||
|
||||
if (extract_data_key(buf.data, key, sizeof(key))) {
|
||||
free(buf.data);
|
||||
return -1;
|
||||
}
|
||||
free(buf.data);
|
||||
|
||||
/* Step 2: POST loadpatches. The API expects JSON in the body but
|
||||
application/x-www-form-urlencoded as Content-Type — this is how
|
||||
the website and every known third-party client calls it. */
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"titleid\":\"%.16s\",\"key\":\"%s\"}", title_id, key);
|
||||
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = BUF_MAX;
|
||||
if (patchdl_http_post_external(
|
||||
PROSPERO_API "/loadpatches",
|
||||
"application/x-www-form-urlencoded; charset=UTF-8",
|
||||
body, &buf)) return -1;
|
||||
if (!buf.data) return -1;
|
||||
|
||||
if (!json_success(buf.data)) {
|
||||
free(buf.data);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Walk the patches array. Each entry looks like:
|
||||
{"content_ver":"01.003.000","required_firmware":"07.01.01.00",...}
|
||||
Find the newest version whose firmware requirement is met. */
|
||||
const char *cursor = buf.data;
|
||||
while ((cursor = strstr(cursor, "\"content_ver\""))) {
|
||||
char ver[16] = {0}, fw[32] = {0}, size[16] = {0};
|
||||
|
||||
json_str_val(cursor, "content_ver", ver, sizeof(ver));
|
||||
json_str_val(cursor, "filesize", size, sizeof(size));
|
||||
json_str_val(cursor, "required_firmware", fw, sizeof(fw));
|
||||
|
||||
if (ver[0] && fw[0]) {
|
||||
uint32_t pkg_fw = fw_str_to_bin(fw);
|
||||
if (pkg_fw <= fw_bin) {
|
||||
if (!best_ver[0] || strcmp(ver, best_ver) > 0) {
|
||||
strncpy(best_ver, ver, sizeof(best_ver) - 1);
|
||||
memcpy(best_size, size, sizeof(best_size));
|
||||
best_fw = pkg_fw;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
free(buf.data);
|
||||
|
||||
if (best_ver[0]) {
|
||||
strncpy(out->version, best_ver, sizeof(out->version) - 1);
|
||||
fw_bin_to_display(best_fw, out->required_fw, sizeof(out->required_fw));
|
||||
memcpy(out->size, best_size, sizeof(out->size));
|
||||
out->found = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Copyright (C) 2026 Knutwurst
|
||||
*
|
||||
* PatchDL is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version. See the LICENSE file in the project root for details.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
char version[16]; /* e.g. "01.003.000" */
|
||||
char required_fw[16]; /* PatchDL display format, e.g. "7.01" */
|
||||
char size[16]; /* Prospero's label, e.g. "71.7 GB" (GiB, rounded up) */
|
||||
int found;
|
||||
} patchdl_prospero_result_t;
|
||||
|
||||
/* Query prosperopatches.com for the newest patch of `title_id` whose
|
||||
firmware requirement is <= fw_bin. Returns 0 on success (result may
|
||||
still have found==0 if no compatible patch exists), -1 on network or
|
||||
parse failure. Best-effort: a failure means the UI shows no Prospero
|
||||
data, nothing breaks. */
|
||||
int patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
|
||||
patchdl_prospero_result_t *out);
|
||||
@@ -34,6 +34,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 +111,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 +210,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 +239,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 +250,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 +271,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 +323,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 +366,13 @@ 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;
|
||||
} 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 +404,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 +445,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 +476,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 +528,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 +550,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 +626,65 @@ 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;
|
||||
int
|
||||
patchdl_scan_read_app_file(const char *title_id, const char *name,
|
||||
char *buf, size_t sz) {
|
||||
char path[PATH_MAX];
|
||||
if (g_sysroot_fd < 0 || !sz) return -1;
|
||||
for (int i = 0; SCAN_DIRS[i]; i++) {
|
||||
int fd;
|
||||
ssize_t n;
|
||||
snprintf(path, sizeof(path), "%s/%s/%s", SCAN_DIRS[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;
|
||||
}
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -48,8 +48,18 @@ typedef struct {
|
||||
char patch_title_id[16]; /* target title id from version.xml */
|
||||
char patch_storage_title_id[16]; /* title id embedded in delta_url */
|
||||
int verxml_done;
|
||||
/* Prospero Patches lookup (best compatible patch from version history) */
|
||||
char prospero_version[16];
|
||||
char prospero_required_fw[16];
|
||||
char prospero_size[16];
|
||||
int prospero_done;
|
||||
/* Manifest URL derived from a link the user pasted from Prospero. When
|
||||
set it replaces patch_url for download and install. */
|
||||
char manual_url[512];
|
||||
char manual_version[16];
|
||||
int enabled; /* user policy, persisted in config.json */
|
||||
int resumable; /* a partial download is on disk */
|
||||
int downloaded; /* a finished download is on disk */
|
||||
long long partial_bytes; /* size of that partial, for the UI */
|
||||
} patchdl_title_t;
|
||||
|
||||
@@ -61,8 +71,24 @@ 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);
|
||||
|
||||
/* 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.8"
|
||||
#define PATCHDL_VERSION "0.1.0"
|
||||
@@ -39,8 +39,9 @@ The UI assumes deny-by-default behavior:
|
||||
"source_policy": {
|
||||
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||
"shadowmount": { "allow_check": true, "allow_download": true, "allow_install": false },
|
||||
"unknown": { "allow_check": true, "allow_download": false, "allow_install": false }
|
||||
"shadowmount": { "allow_check": true, "allow_download": false, "allow_install": false },
|
||||
"unknown": { "allow_check": true, "allow_download": false, "allow_install": false },
|
||||
"fake": { "allow_check": true, "allow_download": false, "allow_install": false }
|
||||
},
|
||||
"cdn_allowlist": [
|
||||
"sgst.prod.dl.playstation.net",
|
||||
@@ -70,7 +71,7 @@ Per-title source fields:
|
||||
"source_path": "/system_ex/app/PPSA90001_00",
|
||||
"mount_from": "/mnt/usb0/itemzflow/Shadowmounted Test Title",
|
||||
"enabled": true,
|
||||
"mode": "download_only"
|
||||
"mode": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -83,16 +84,17 @@ shadowmount
|
||||
unknown
|
||||
```
|
||||
|
||||
The frontend treats `shadowmount` as download-only and `unknown` as blocked for
|
||||
downloads and installs. Backend code should enforce the same policy even if a
|
||||
client sends a forged request.
|
||||
The frontend lists `shadowmount`, `unknown` and `fake` titles under *Not
|
||||
supported* and offers neither download nor install for them. The backend refuses
|
||||
both as well, even if a client sends a forged request.
|
||||
|
||||
For PS5 game updates, the backend may turn a Sony `manifest_url` into a merged
|
||||
local `.pkg` by downloading all manifest pieces. The `delta_url` `*-DP.pkg` is
|
||||
not shown as a separate user action because it can bootstrap the storage/master
|
||||
title instead of the installed regional target.
|
||||
|
||||
If `patch_storage_match` is false, the UI keeps download/verify available but
|
||||
does not offer install or auto-install. Those shared-master packages are signed
|
||||
for a different storage title id and cannot be retargeted by standalone
|
||||
AppInstUtil on firmware 11.60.
|
||||
If `patch_storage_match` is false, the CDN stores the patch pieces under another
|
||||
regional (master) title id. That is fine: the merged `.pkg` assembled from the
|
||||
manifest carries the installed title's own content id, and AppInstUtil
|
||||
`AppInstallPkg` installs it onto that title. The UI only adds a "Shared master"
|
||||
label.
|
||||
@@ -132,12 +132,10 @@
|
||||
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Filter">
|
||||
<button class="is-selected" data-filter="updatable" aria-pressed="true">Updatable</button>
|
||||
<button data-filter="updating" aria-pressed="false">Updating</button>
|
||||
<button data-filter="uptodate" aria-pressed="false">Up to date</button>
|
||||
<button data-filter="needsfw" aria-pressed="false">Needs FW</button>
|
||||
<button data-filter="blocked" aria-pressed="false">Can't update</button>
|
||||
<button data-filter="all" aria-pressed="false">All</button>
|
||||
<button class="is-selected" data-filter="updates" aria-pressed="true">Updates <span class="chip-count"></span></button>
|
||||
<button data-filter="uptodate" aria-pressed="false">Up to date <span class="chip-count"></span></button>
|
||||
<button data-filter="blocked" aria-pressed="false">Not supported <span class="chip-count"></span></button>
|
||||
<button data-filter="all" aria-pressed="false">All <span class="chip-count"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +169,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">
|
||||
@@ -236,6 +234,11 @@
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -338,6 +338,8 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
.segmented button.is-selected { background: var(--surface); color: var(--ink); }
|
||||
.chip-count { margin-left: 4px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.chip-count:empty { display: none; }
|
||||
|
||||
/* ---------------- game cards ---------------- */
|
||||
|
||||
@@ -368,11 +370,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;
|
||||
}
|
||||
@@ -393,6 +395,10 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
color: var(--muted);
|
||||
}
|
||||
.versions b { color: var(--ink); font-weight: 700; }
|
||||
.card-note { flex-basis: 100%; line-height: 1.45; }
|
||||
.card-note.is-warn { color: var(--amber); }
|
||||
.prospero-line a { color: var(--blue); text-decoration: none; }
|
||||
.prospero-line a:hover { text-decoration: underline; }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
@@ -418,6 +424,7 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
.pill.warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
|
||||
.pill.external { border-color: rgba(88, 166, 255, 0.34); background: var(--blue-soft); color: var(--blue); }
|
||||
.pill.shadow { border-color: rgba(181, 146, 255, 0.34); background: rgba(181, 146, 255, 0.14); color: #c7b1ff; }
|
||||
.pill.prospero { border-color: rgba(64, 201, 198, 0.38); background: rgba(64, 201, 198, 0.14); color: #6fe0dc; }
|
||||
.pill.blocked { border-color: rgba(255, 107, 107, 0.34); background: var(--red-soft); color: var(--red); }
|
||||
.pill.live { border-color: rgba(56, 193, 114, 0.5); background: var(--green-soft); color: var(--green-dark); }
|
||||
.pill.live::before {
|
||||
@@ -459,6 +466,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;
|
||||
@@ -552,8 +570,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; }
|
||||
|
||||
@@ -629,8 +678,138 @@ pre {
|
||||
}
|
||||
.toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
/* ---------------- older patch guide (Prospero) ---------------- */
|
||||
|
||||
.guide-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15; /* below the toast (20) */
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 560px); /* an auto track would grow to the content */
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
padding: 24px 16px;
|
||||
overflow-y: auto;
|
||||
background: rgba(5, 7, 9, 0.72);
|
||||
}
|
||||
.guide {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
/* minmax(0, 1fr): without it an <input>'s intrinsic width pushes the tracks wider than the sheet */
|
||||
.guide, .guide-steps, .guide-steps li { grid-template-columns: minmax(0, 1fr); }
|
||||
.guide-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.guide-head > div { min-width: 0; }
|
||||
.guide-head h2 { margin: 0; font-size: 22px; }
|
||||
.guide-game { margin: 4px 0 0; color: var(--muted); }
|
||||
.guide-close { flex: none; width: 40px; padding: 0; font-size: 22px; line-height: 1; }
|
||||
.guide-why { margin: 0; color: var(--muted); line-height: 1.55; }
|
||||
.guide-why b { color: var(--ink); }
|
||||
|
||||
.guide-qr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.guide-qr .qr { flex: none; width: 148px; height: 148px; border-radius: 6px; }
|
||||
.guide-qr > div { display: grid; gap: 6px; min-width: 0; }
|
||||
.guide-qr span { color: var(--muted); line-height: 1.5; }
|
||||
.guide-qr code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: 13px; }
|
||||
|
||||
/* Console view: the PS5 can't copy links, so it only hands over to a phone. */
|
||||
.guide.is-handoff { width: min(560px, 100%); justify-self: center; text-align: center; }
|
||||
.guide.is-handoff .guide-head { text-align: left; }
|
||||
.guide-handoff { display: grid; justify-items: center; gap: 12px; }
|
||||
.guide-handoff .qr { width: min(420px, 100%); height: auto; aspect-ratio: 1; border-radius: 8px; }
|
||||
.guide-handoff p { margin: 0; color: var(--muted); line-height: 1.5; }
|
||||
.guide-handoff code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: 16px; }
|
||||
|
||||
.guide-steps { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; counter-reset: step; }
|
||||
.guide-steps li {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 14px 52px;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
counter-increment: step;
|
||||
}
|
||||
.guide-steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--green-soft);
|
||||
color: var(--green-dark);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.guide-step-text { display: grid; gap: 4px; }
|
||||
.guide-step-text span { color: var(--muted); line-height: 1.5; }
|
||||
.guide-step-text b { color: var(--ink); }
|
||||
.guide-open { width: auto; justify-self: start; padding: 0 18px; text-decoration: none; }
|
||||
|
||||
.guide-paste { display: flex; gap: 8px; }
|
||||
.guide-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 16px; /* below 16px iOS zooms the page on focus */
|
||||
}
|
||||
.guide-input::placeholder { color: var(--muted); }
|
||||
.guide-input:focus { outline: none; border-color: var(--blue); }
|
||||
.guide-start { width: auto; flex: none; min-height: 44px; padding: 0 18px; }
|
||||
|
||||
.guide-status:empty { display: none; }
|
||||
.guide-status { display: grid; gap: 8px; }
|
||||
.guide-msg { margin: 0; line-height: 1.5; }
|
||||
.guide-msg.is-idle, .guide-msg.is-busy { color: var(--muted); }
|
||||
.guide-msg.is-ok { color: var(--green-dark); }
|
||||
.guide-msg.is-warn { color: var(--amber-dark); }
|
||||
.guide-msg.is-error { color: var(--red); }
|
||||
.guide-action { width: auto; justify-self: start; padding: 0 16px; }
|
||||
.guide-progress { display: grid; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 13px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
/* Already on a phone: the hand-off QR has nothing to add. */
|
||||
.guide-qr { display: none; }
|
||||
.guide-backdrop { align-content: start; padding: 12px; }
|
||||
.guide { padding: 16px; }
|
||||
}
|
||||
|
||||
/* ---------------- responsive ---------------- */
|
||||
|
||||
/* 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 {
|
||||
|
||||