6 Commits
Author SHA1 Message Date
Knutwurst cdde608369 Bump version to 0.3.2 2026-10-19 20:49:33 +02:00
Knutwurst 85be96b02a Pick the download drive; move the home screen tile to Media
Settings lists the internal storage and every mounted drive (/mnt/extN,
/mnt/usbN) with its free space. New downloads go to <drive>/patchdl on
the chosen drive and install from there. A title keeps its folder on
the drive where it already has one, so packages downloaded earlier are
still found, listed and installed; while the chosen drive is unplugged,
downloads go to the internal storage. Config and caches stay in
/data/patchdl. Closes #18.

The tile's param.json sets applicationCategoryType 65536, which puts it
in the Media section. The home screen keeps the section a tile was
registered in, so an older tile is uninstalled once and registered
again. Closes #19.
2026-10-19 20:49:01 +02:00
Knutwurst 7af4866ed1 Bump version to 0.3.1 2026-10-19 08:36:22 +02:00
KnutwurstandNick Mikkelsen 256d9836b3 Buffer piece writes and raise the socket receive buffer
curl hands the body over in ~2 KB chunks, and every pwrite() to /data on
the PS5 costs about the same regardless of size, so downloads topped out
around 8 MB/s. Pieces are now collected in a 2 MB buffer and written in
blocks; the buffer is flushed after a failure or pause too, so resume
keeps what arrived. Only bytes on disk are reported as progress. Download
sockets ask for a 4 MB receive buffer (the console grants 512 KB).

Measured by the reporter on FW 13.60 over gigabit: 8.2 -> 116.6 MB/s with
16 connections, 12.5 -> 117.2 MB/s with 4. Closes #17.

Co-authored-by: Nick Mikkelsen <1895436+nikigx2@users.noreply.github.com>
2026-10-19 08:25:10 +02:00
Knutwurst 872bb2bd89 README: shorter and plainer
Replace the highlight list with a short description of what PatchDL
does, use plain captions, trim the FAQ and the build notes.
2026-10-18 10:21:30 +02:00
Knutwurst 2fa9d75000 README: screenshots for 0.3.0
New hero, games, library, settings, phone and social-preview images
with the redesigned cards, a PS4 game and the Other versions list.
The old-firmware screenshot gives way to one of Other versions, and the
highlights mention picking a version, the ten languages and downloads
that get through blocked DNS and dropped connections.
2026-10-18 09:06:16 +02:00
19 changed files with 500 additions and 162 deletions

No files matched your search

+46 -69
View File
@@ -1,13 +1,11 @@
<h1 align="center">PatchDL</h1>
<p align="center">
<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">
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser, and the Other versions list on a phone" width="900">
</p>
<p align="center">
<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.
Downloads and installs official updates for PS5 and PS4 games, controlled from any browser.
</p>
<p align="center">
@@ -15,7 +13,6 @@
<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">
</p>
@@ -33,111 +30,91 @@
---
## Highlights
## What it does
- **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. During an install, the game's card shows how much of the update the console has read.
PatchDL runs as a payload on the PS5 and serves a small web page. Open that page on your phone, your laptop or in the console's browser and you get a list of your installed games with their version and the latest update Sony has for them. Click Update, and PatchDL downloads the patch from Sony's servers and hands it to the console's own installer.
A few things it does on top of that:
- Update all queues every game that has an update and works through them one by one.
- Downloads use several connections at once (4 by default, up to 16).
- If the newest patch needs a firmware you don't have, PatchDL looks for the latest one that still runs on your console. Under Other versions you can pick any version of a patch yourself.
- A download that gets interrupted continues where it stopped, also after a pause or a restart of PatchDL.
- After a successful install the package is deleted. You can switch that off.
- Optionally every piece is checked against Sony's checksums.
- There's a switch for a PatchDL tile on the home screen.
- The interface is available in ten languages.
PatchDL only updates games installed from disc or the PlayStation Store. It doesn't download games, and it doesn't touch system software.
## 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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; icons, versions and firmware needs, A to Z</sub></td>
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games with updates and a running download"><br><sub>Games with updates</sub></td>
<td align="center" width="50%"><img src="docs/images/library.jpg" width="420" alt="All installed games, sorted by name"><br><sub>All installed games</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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; changes apply right away, no restart</sub></td>
<td align="center" width="50%"><img src="docs/images/versions.jpg" width="420" alt="Other versions list in the browser and the guide on a phone"><br><sub>Picking another version</sub></td>
<td align="center" width="50%"><img src="docs/images/settings.jpg" width="420" alt="Settings"><br><sub>Settings</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> &nbsp;·&nbsp; the PatchDL tile on a real PS5</sub></td>
<td align="center" width="50%"><img src="docs/images/phones.jpg" width="420" alt="PatchDL on a phone: game list and settings"><br><sub>On a phone</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>Home screen tile</sub></td>
</tr>
</table>
## Fits any screen
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 and Settings" width="560">
</p>
## Get started
## Installation
1. Download `patchdl_<version>.elf` from the [latest release](https://github.com/knutwurst/ps5-patchdl/releases/latest).
2. Send it to your PS5 with the ELF loader you already use.
3. A notification on the TV shows the address, for example `http://192.168.0.42:12880/`. Open it in any browser on your network.
4. Optional: switch on **Home-screen shortcut** in Settings to get the PatchDL tile.
3. The console shows a notification with the address, e.g. `http://192.168.0.42:12880/`. Open it in a browser on the same network.
**You need** a PS5 that can load ELF payloads. PatchDL runs on firmware 1.00 to 13.60 (tested on 11.60 and 13.60).
You need a PS5 that can load ELF payloads. PatchDL should run on firmware 1.00 to 13.60; I've tested it on 11.60 and 13.60.
## Good to know
**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 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.
## FAQ
**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. It finds them through public DNS (Cloudflare, Google and Quad9, encrypted through Cloudflare or Google if your network blocks plain DNS) and never touches the console's own DNS settings, so a DNS block that keeps the system from updating stays in place. To find older patches it also asks [Prospero Patches](https://prosperopatches.com/) (PS5) or [Orbis Patches](https://orbispatches.com/) (PS4) which versions exist for a game. That lookup sends the game's title ID and nothing else.
From Sony's update servers, the same files the console would download itself. Nothing gets modified. To find Sony's servers PatchDL uses public DNS (Cloudflare, Google, Quad9) and leaves the console's DNS settings alone, so if you block system updates through DNS, that block stays in place.
**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.
**How does it know about older patches?**
Sony only tells the console about the newest patch. The version history comes from [Prospero Patches](https://prosperopatches.com/) for PS5 games and [Orbis Patches](https://orbispatches.com/) for PS4 games. PatchDL sends them the game's title ID, nothing else.
**Does it change the updates?**
No. Each package stays exactly as Sony published it, and the console's own installer applies it.
**How do I get a specific version?**
Open Other versions on the game, pick a version and follow the steps. You copy one link from the patch site (on the PS5 you scan a QR code and do that part on your phone), paste it, and PatchDL downloads that version from Sony. Only versions newer than the installed one can be installed.
**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.
**When is the package deleted?**
Only after the console reports the new version and its installer has registered the package. Otherwise it stays. You can turn deleting off in Settings.
**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.
**Do disc games work?**
Yes. Keep the disc in the drive while the update installs.
**What about disc games?**
Put the disc in, same as with any update.
**What happens if the power goes out?**
Start PatchDL again and press Resume. Finished pieces stay on disk.
**What if the power goes out during a download?**
Start PatchDL again and press Resume.
## Disclaimer
PatchDL is an independent project. It is not affiliated with, endorsed by or sponsored by Sony Interactive Entertainment. "PlayStation" and "PS5" are trademarks of Sony Interactive Entertainment Inc. and appear here only to name the console PatchDL works with.
PatchDL is an independent project and not affiliated with or endorsed by Sony Interactive Entertainment. "PlayStation", "PS5" and "PS4" are trademarks of Sony Interactive Entertainment Inc.
PatchDL downloads official, unmodified game updates that Sony publishes for games already installed on your console. It does not download games, does not contain or distribute Sony software, keys or game content, and does not install unsigned or modified packages. Use it with games you own, and follow the laws of your country and the terms that apply to you. This project does not support piracy, and issues about pirated content will be closed.
PatchDL downloads official, unmodified updates for games that are already installed. It doesn't download games, doesn't contain Sony software, keys or game content, and doesn't install unsigned or modified packages. Use it with games you own. Issues about pirated content will be closed.
<details>
<summary><b>Build from source</b></summary>
<br>
## Building
```sh
scripts/build_ps5.sh # produces patchdl-ps5.elf
scripts/build_ps5.sh # builds patchdl-ps5.elf
```
You need the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk), version 0.44 or newer for firmware 1.00 to 13.60, with `libcurl` and `OpenSSL` from its [pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo). `libmicrohttpd` and SQLite are vendored. GitHub Actions builds every push; tagged builds become releases.
This needs the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk) (0.44 or newer) with `libcurl` and `OpenSSL` from its [pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo). `libmicrohttpd` and SQLite are included in the repo. Every push is built by GitHub Actions, and tags become releases.
To try a build on your own console:
To try a build on your console:
```sh
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
```
</details>
## 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). The version history behind older patches comes from [Prospero Patches](https://prosperopatches.com/). Thanks to everyone behind them.
Built 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 patch history comes from [Prospero Patches](https://prosperopatches.com/) and [Orbis Patches](https://orbispatches.com/). Thanks to everyone working on these.
## Support
@@ -147,6 +124,6 @@ If you like what I do, you can buy me a coffee*: [![Donate](https://img.shields.
## 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.
GNU General Public License v3.0 or later, see [LICENSE](LICENSE).
Copyright © 2026 Knutwurst.
+1
View File
@@ -1,5 +1,6 @@
{
"titleId": "PTDL00001",
"applicationCategoryType": 65536,
"deeplinkUri": "http://127.0.0.1:12880/",
"localizedParameters": {
"defaultLanguage": "en-US",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+4 -3
View File
@@ -674,11 +674,12 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
}
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
title_dir = strstr(local_path, "/data/patchdl/");
/* <drive>/patchdl/<title>/<file>, on any download drive */
title_dir = strstr(local_path, "/patchdl/");
file_base = strrchr(local_path, '/');
if (title_dir && file_base && file_base > title_dir + strlen("/data/patchdl/")) {
if (title_dir && file_base && file_base > title_dir + strlen("/patchdl/")) {
char title_id[32] = {0};
const char *t = title_dir + strlen("/data/patchdl/");
const char *t = title_dir + strlen("/patchdl/");
size_t tlen = (size_t)(file_base - t);
if (tlen > 0 && tlen < sizeof(title_id)) {
char ip[INET_ADDRSTRLEN] = {0};
+70 -7
View File
@@ -174,6 +174,20 @@ url_host(const char *url, char *out, size_t out_sz) {
return 0;
}
/* The console's default socket receive buffer (64 KB) holds one connection
to ~11 MB/s; asking for more gets the system maximum (512 KB), which lets
a single connection reach ~45 MB/s. */
#define PATCHDL_RCVBUF_BYTES (4 * 1024 * 1024)
static int
sockopt_rcvbuf_cb(void *clientp, curl_socket_t fd, curlsocktype purpose) {
int sz = PATCHDL_RCVBUF_BYTES;
(void)clientp;
if (purpose == CURLSOCKTYPE_IPCXN)
(void)setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &sz, sizeof sz); /* best effort */
return CURL_SOCKOPT_OK;
}
/* ---------- HTTP GET via curl ------------------------------------------- */
static size_t
@@ -506,6 +520,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
curl_easy_setopt(curl, CURLOPT_SOCKOPTFUNCTION, sockopt_rcvbuf_cb);
if (range_start > 0) {
snprintf(range_hdr, sizeof(range_hdr), "%lld-", range_start);
curl_easy_setopt(curl, CURLOPT_RANGE, range_hdr);
@@ -832,15 +847,26 @@ patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
void patchdl_net_global_init(void) { curl_global_init(CURL_GLOBAL_ALL); }
void patchdl_net_global_cleanup(void) { curl_global_cleanup(); }
/* Each write to /data costs about the same whatever its size, and curl hands
the body over in ~2 KB pieces: writing each one straight to disk held a
download to ~8 MB/s in total. Collecting them into 2 MB blocks first lets
the disk keep up with a gigabit line. */
#define PATCHDL_PIECE_WBUF (2 * 1024 * 1024)
/* Write sink for one piece: pwrite at a fixed base offset (concurrent
non-overlapping pieces of the same fd are safe), tee into SHA-256 if asked,
and publish bytes-so-far for live progress. A resumed piece starts at
`resume` bytes; see piece_header_cb and piece_write_cb for how a server
that ignores the range is handled. */
that ignores the range is handled. Bytes are collected in wbuf and
written in blocks; `written` counts bytes received, `flushed` the ones
already on disk. */
typedef struct {
int fd;
long long base;
long long written;
long long flushed;
unsigned char *wbuf; /* NULL: write each chunk directly */
size_t wlen;
long long size; /* the piece's length: never write past it */
long long resume; /* bytes of the piece already on disk */
long long range_start; /* from Content-Range, -1 if none */
@@ -886,11 +912,31 @@ piece_header_cb(char *b, size_t size, size_t nitems, void *ud) {
return n;
}
/* Write len bytes at the piece's flushed position. 0 on success. */
static int
piece_pwrite(piece_sink_t *s, const unsigned char *p, size_t len) {
while (len > 0) {
ssize_t w = pwrite(s->fd, p, len, (off_t)(s->base + s->flushed));
if (w <= 0) return -1;
p += w; len -= (size_t)w;
s->flushed += w;
}
if (s->bytes_slot) *s->bytes_slot = s->flushed;
return 0;
}
/* Put the collected bytes on disk. 0 on success. */
static int
piece_flush(piece_sink_t *s) {
int rc = s->wlen ? piece_pwrite(s, s->wbuf, s->wlen) : 0;
s->wlen = 0;
return rc;
}
static size_t
piece_write_cb(void *ptr, size_t size, size_t nmemb, void *ud) {
piece_sink_t *s = (piece_sink_t *)ud;
size_t n = size * nmemb;
ssize_t w;
if (n == 0) return 0;
if (s->resume && !s->checked) {
long code = 0;
@@ -898,7 +944,8 @@ piece_write_cb(void *ptr, size_t size, size_t nmemb, void *ud) {
curl_easy_getinfo(s->curl, CURLINFO_RESPONSE_CODE, &code);
if (code != 206) {
/* the server sent the whole piece: start it over in place */
s->written = 0;
s->written = s->flushed = 0;
if (s->bytes_slot) *s->bytes_slot = 0;
if (s->md) EVP_DigestInit_ex(s->md, s->md_type, NULL);
} else if (s->range_start != s->resume) {
s->bad_range = 1;
@@ -907,11 +954,21 @@ piece_write_cb(void *ptr, size_t size, size_t nmemb, void *ud) {
}
/* more than the piece holds would land in the next piece's bytes */
if (s->size > 0 && s->written + (long long)n > s->size) return 0;
w = pwrite(s->fd, ptr, n, (off_t)(s->base + s->written));
if (w < 0 || (size_t)w != n) return 0; /* short write -> curl errors out */
if (s->md) EVP_DigestUpdate(s->md, ptr, n);
if (!s->wbuf) {
if (piece_pwrite(s, ptr, n)) return 0; /* short write -> curl errors out */
} else {
const unsigned char *p = ptr;
size_t left = n;
while (left > 0) {
size_t take = PATCHDL_PIECE_WBUF - s->wlen;
if (take > left) take = left;
memcpy(s->wbuf + s->wlen, p, take);
s->wlen += take; p += take; left -= take;
if (s->wlen == PATCHDL_PIECE_WBUF && piece_flush(s)) return 0;
}
}
s->written += (long long)n;
if (s->bytes_slot) *s->bytes_slot = s->written;
return n;
}
@@ -953,6 +1010,7 @@ patchdl_http_download_piece(const char *url, int fd,
sink.size = file_size;
sink.resume = resume_from;
sink.written = resume_from;
sink.flushed = resume_from;
sink.range_start = -1;
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
if (sink.bytes_slot) *sink.bytes_slot = resume_from;
@@ -972,7 +1030,7 @@ patchdl_http_download_piece(const char *url, int fd,
}
if (drc) { /* unreadable: fetch it all again */
EVP_DigestInit_ex(sink.md, sink.md_type, NULL);
sink.resume = sink.written = 0;
sink.resume = sink.written = sink.flushed = 0;
if (sink.bytes_slot) *sink.bytes_slot = 0;
}
}
@@ -1015,17 +1073,22 @@ patchdl_http_download_piece(const char *url, int fd,
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
curl_easy_setopt(curl, CURLOPT_SOCKOPTFUNCTION, sockopt_rcvbuf_cb);
if (ctx && ctx->abort) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, piece_xfer_cb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, (void *)ctx->abort);
}
sink.wbuf = malloc(PATCHDL_PIECE_WBUF); /* NULL: slower, still correct */
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
note_curl_result(host, res);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
/* also after a failure or pause: what arrived is kept for a resume */
if (piece_flush(&sink) && res == CURLE_OK) res = CURLE_WRITE_ERROR;
free(sink.wbuf);
if (curl_rc_out) *curl_rc_out = (int)res;
if (http_code_out) *http_code_out = http_code;
+27 -1
View File
@@ -47,6 +47,7 @@ typedef int (*ai_init_fn)(void);
typedef int (*ai_install_dir_fn)(const char *title_id, const char *parent_dir,
void *opts);
typedef int (*ai_install_all_fn)(void *opts);
typedef int (*ai_uninstall_fn)(const char *title_id);
static intptr_t
dynsym_by_name(const char *sym) {
@@ -123,7 +124,7 @@ patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
ai_init_fn ai_initialize = NULL;
ai_install_dir_fn ai_install_title = NULL;
ai_install_all_fn ai_install_all = NULL;
int rc;
int rc, was_registered;
snprintf(base_dir, sizeof base_dir, "/user/app/%s", TILE_TITLE_ID);
snprintf(sce_sys_dir, sizeof sce_sys_dir, "/user/app/%s/sce_sys", TILE_TITLE_ID);
@@ -205,6 +206,31 @@ patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
return -1;
}
/* The home screen keeps the section (Games or Media) a tile was first
registered in. A tile from an older PatchDL sits under Games; take
it off before registering it again so it moves to Media. Only ever
PatchDL's own tile id is passed here. */
{
struct stat st;
/* an older param.json on disk, whether or not the receipt exists
(tiles from before 0.0.7 have none) */
was_registered = stat(param_path, &st) == 0 &&
!file_matches(param_path, tile_param_json, tile_param_json_size);
}
if (was_registered) {
ai_uninstall_fn ai_uninstall = (ai_uninstall_fn)dynsym_by_name(
"sceAppInstUtilAppUnInstall");
if (ai_uninstall) (void)ai_uninstall(TILE_TITLE_ID);
if (mkdir(base_dir, 0755) && errno != EEXIST) {
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", base_dir, errno);
return -1;
}
if (mkdir(sce_sys_dir, 0755) && errno != EEXIST) {
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", sce_sys_dir, errno);
return -1;
}
}
unlink(receipt_path);
if (write_all(param_path, tile_param_json, tile_param_json_size)) {
snprintf(msg, msg_sz, "write param.json failed errno=%d", errno);
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.3.0"
#define PATCHDL_VERSION "0.3.2"
+281 -62
View File
@@ -35,6 +35,7 @@
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/mount.h>
#include <unistd.h>
static struct MHD_Daemon *web_daemon;
@@ -183,7 +184,8 @@ static struct {
int verify_downloads; /* SHA-256 each manifest piece (default off) */
int home_shortcut; /* user wants a home-screen browser tile */
int max_connections; /* parallel download connections (1..POOL_MAX_CONN) */
} g_cfg = { "deny", 0, 1, 0, 1, 4 };
char download_drive[32]; /* "/data" or a mounted drive like "/mnt/ext1" */
} g_cfg = { "deny", 0, 1, 0, 1, 4, "/data" };
/* ---------- download connection pool ----------------------------------- */
/* N worker threads share one job at a time (admit_max=1): a single download
@@ -270,13 +272,102 @@ static struct {
volatile long long inflight_bytes[POOL_MAX_CONN]; /* per-slot live counters */
} g_pool = { .mtx = PTHREAD_MUTEX_INITIALIZER, .cv = PTHREAD_COND_INITIALIZER };
#define PATCHDL_DL_DIR "/data/patchdl"
#define PATCHDL_DATA_DIR "/data/patchdl" /* config and caches; always internal */
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
/* ---------- download drives ---------------------------------------------
Packages live in <drive>/patchdl/<title>/. The drive is the internal
storage (/data) or a mounted drive (/mnt/extN for extended storage and
M.2, /mnt/usbN for USB). A title's folder stays where its download
started: lookups check every drive first, and only a title without a
folder anywhere goes to the drive chosen in Settings. */
#define MAX_DRIVES 12
/* Guards g_cfg.download_drive for current_root, which runs with the pool
lock held too: a leaf lock, taken after g_mutex or g_pool.mtx and never
holding anything else. Writers hold g_mutex as well. */
static pthread_mutex_t g_drive_mtx = PTHREAD_MUTEX_INITIALIZER;
static int
drive_name_ok(const char *m) {
if (!strcmp(m, "/data")) return 1;
return (!strncmp(m, "/mnt/ext", 8) || !strncmp(m, "/mnt/usb", 8)) &&
isdigit((unsigned char)m[8]) && (!m[9] || (isdigit((unsigned char)m[9]) && !m[10]));
}
/* The drive is mounted right now (an unplugged USB drive is not). */
static int
drive_mounted(const char *drive) {
struct statfs sf;
if (!strcmp(drive, "/data")) return 1;
return statfs(drive, &sf) == 0 && !strcmp(sf.f_mntonname, drive);
}
/* /data first, then every mounted extN/usbN drive. Returns the count. */
static int
list_drives(char out[][32], int max) {
struct statfs *mnt;
int n = 0, k;
snprintf(out[n++], 32, "/data");
/* getfsstat into our own buffer: getmntinfo shares a static one, and
this runs on several threads at once */
k = getfsstat(NULL, 0, MNT_NOWAIT);
if (k <= 0 || !(mnt = calloc((size_t)k + 4, sizeof *mnt))) return n;
k = getfsstat(mnt, (long)(((size_t)k + 4) * sizeof *mnt), MNT_NOWAIT);
for (int i = 0; i < k && n < max; i++) {
int dup = 0;
if (!drive_name_ok(mnt[i].f_mntonname) || !strcmp(mnt[i].f_mntonname, "/data")) continue;
for (int j = 0; j < n; j++) if (!strcmp(out[j], mnt[i].f_mntonname)) dup = 1;
if (!dup) snprintf(out[n++], 32, "%s", mnt[i].f_mntonname);
}
free(mnt);
return n;
}
/* Where new downloads go: <chosen drive>/patchdl, or the internal storage
while the chosen drive isn't there. */
static void
current_root(char *out, size_t sz) {
char drive[32];
pthread_mutex_lock(&g_drive_mtx);
snprintf(drive, sizeof drive, "%s", g_cfg.download_drive);
pthread_mutex_unlock(&g_drive_mtx);
if (!drive_name_ok(drive) || !drive_mounted(drive)) snprintf(drive, sizeof drive, "/data");
snprintf(out, sz, "%s/patchdl", drive);
}
/* The title's folder: an existing one on any drive, else one on the
current drive. title_id must already be a safe path segment. */
static void
title_dir(const char *title_id, char *out, size_t sz) {
char drives[MAX_DRIVES][32], root[64];
int n = list_drives(drives, MAX_DRIVES);
struct stat st;
for (int i = 0; i < n; i++) {
snprintf(out, sz, "%s/patchdl/%s", drives[i], title_id);
if (stat(out, &st) == 0 && S_ISDIR(st.st_mode)) return;
}
current_root(root, sizeof root);
snprintf(out, sz, "%s/%s", root, title_id);
}
/* mkdir the folder and its patchdl parent. */
static void
make_title_dir(const char *dir) {
char parent[288];
const char *slash = strrchr(dir, '/');
if (slash && (size_t)(slash - dir) < sizeof parent) {
memcpy(parent, dir, (size_t)(slash - dir));
parent[slash - dir] = '\0';
mkdir(parent, 0777);
}
mkdir(dir, 0777);
}
/* The source policy and CDN allowlist are fixed (the safety model), so they
stay constant; only the four mutable fields above are user-controlled. */
static const char config_tail_json[] =
"\"download_dir\":\"/data/patchdl (internal)\","
"\"source_policy\":{"
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
@@ -546,17 +637,24 @@ path_segment_safe(const char *s) {
}
/* Validate a local PKG path for /api/install_aip: must be exactly
<PATCHDL_DL_DIR>/<safe-title-id>/<safe-filename>. Rejects anything
outside /data/patchdl/, anything with .. or unsafe chars. */
<drive>/patchdl/<safe-title-id>/<safe-filename>. Rejects anything
outside a download drive, anything with .. or unsafe chars. */
static int
local_install_path_safe(const char *path) {
const char *prefix = PATCHDL_DL_DIR "/";
size_t plen = strlen(prefix);
char drives[MAX_DRIVES][32], prefix[64];
int nd = list_drives(drives, MAX_DRIVES), hit = -1;
size_t plen = 0;
const char *tail, *slash;
char seg[64];
size_t len;
if (!path || strncmp(path, prefix, plen) != 0) return 0;
if (!path) return 0;
for (int i = 0; i < nd && hit < 0; i++) {
snprintf(prefix, sizeof prefix, "%s/patchdl/", drives[i]);
plen = strlen(prefix);
if (!strncmp(path, prefix, plen)) hit = i;
}
if (hit < 0) return 0;
tail = path + plen;
slash = strchr(tail, '/');
if (!slash || slash == tail) return 0;
@@ -764,7 +862,11 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
if (!path_segment_safe(title_id) || !path_segment_safe(file))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
snprintf(path, sizeof(path), "%s/%s/%s", PATCHDL_DL_DIR, title_id, file);
{
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(path, sizeof(path), "%s/%s", dir, file);
}
fd = open(path, O_RDONLY);
if (fd < 0) {
record_pkg_diag(url, NULL, MHD_HTTP_NOT_FOUND, 0, 0, 0);
@@ -852,14 +954,29 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
/* ---------- status JSON ------------------------------------------------- */
/* Free space on the download partition, in MB. Best-effort: 0 if unavailable. */
/* Free space on the drive holding `path`, in MB. A folder that doesn't
exist yet counts as its closest existing parent. 0 if unavailable. */
static long long
drive_free_mb(const char *path) {
struct statvfs vfs;
char p[288], *slash;
snprintf(p, sizeof p, "%s", path);
for (;;) {
if (statvfs(p, &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
if (!(slash = strrchr(p, '/')) || slash == p) return 0;
*slash = '\0';
}
}
/* Free space where new downloads go, in MB. */
static long long
data_free_mb(void) {
struct statvfs vfs;
if (statvfs(PATCHDL_DL_DIR, &vfs) == 0 || statvfs("/data", &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
return 0;
char root[64], *slash;
current_root(root, sizeof root);
if ((slash = strrchr(root, '/'))) *slash = '\0'; /* the drive itself */
return drive_free_mb(root);
}
/* Headroom kept free on /data so a download never fills the console's
@@ -876,10 +993,11 @@ space_needed(const char *path, long long total) {
return total > have ? total - have : 0;
}
/* 1 if `need` bytes plus the margin fit; *free_out gets the free bytes. */
/* 1 if `need` bytes plus the margin fit on the drive holding `dir`;
*free_out gets the free bytes. */
static int
space_ok(long long need, long long *free_out) {
long long free_b = data_free_mb() * 1024LL * 1024LL;
space_ok(const char *dir, long long need, long long *free_out) {
long long free_b = drive_free_mb(dir) * 1024LL * 1024LL;
if (free_out) *free_out = free_b;
return need + PATCHDL_SPACE_MARGIN <= free_b;
}
@@ -919,7 +1037,8 @@ is_title_dir_name(const char *n) {
return 1;
}
/* Bytes in /data/patchdl/<title>/ folders, for the storage tile. */
/* Bytes in <drive>/patchdl/<title>/ folders on every drive, for the
storage tile. */
static long long
downloads_bytes(void) {
DIR *d, *sd;
@@ -928,19 +1047,25 @@ downloads_bytes(void) {
char dir[288], path[560];
long long sum = 0;
if (!(d = opendir(PATCHDL_DL_DIR))) return 0;
while ((de = readdir(d))) {
if (!is_title_dir_name(de->d_name)) continue;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, de->d_name);
if (!(sd = opendir(dir))) continue;
while ((fe = readdir(sd))) {
if (fe->d_name[0] == '.') continue;
snprintf(path, sizeof path, "%s/%s", dir, fe->d_name);
if (!stat(path, &st) && S_ISREG(st.st_mode)) sum += (long long)st.st_size;
char drives[MAX_DRIVES][32], root[64];
int nd = list_drives(drives, MAX_DRIVES);
for (int k = 0; k < nd; k++) {
snprintf(root, sizeof root, "%s/patchdl", drives[k]);
if (!(d = opendir(root))) continue;
while ((de = readdir(d))) {
if (!is_title_dir_name(de->d_name)) continue;
snprintf(dir, sizeof dir, "%s/%s", root, de->d_name);
if (!(sd = opendir(dir))) continue;
while ((fe = readdir(sd))) {
if (fe->d_name[0] == '.') continue;
snprintf(path, sizeof path, "%s/%s", dir, fe->d_name);
if (!stat(path, &st) && S_ISREG(st.st_mode)) sum += (long long)st.st_size;
}
closedir(sd);
}
closedir(sd);
closedir(d);
}
closedir(d);
return sum;
}
@@ -955,17 +1080,18 @@ titles_gen(void) {
static char *
build_status_json(void) {
char ip[INET_ADDRSTRLEN];
char *out = malloc(768);
char ip[INET_ADDRSTRLEN], root[64];
char *out = malloc(896);
if (!out) return NULL;
lan_ipv4(ip, sizeof ip);
snprintf(out, 768,
current_root(root, sizeof root);
snprintf(out, 896,
"{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\","
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\","
"\"download_dir\":\"%s\","
"\"lan_ip\":\"%s\","
"\"http_port\":%d,"
"\"version\":\"%s\","
@@ -973,7 +1099,7 @@ build_status_json(void) {
"\"downloads_bytes\":%lld,"
"\"rescan_ms\":[%ld,%ld,%ld],"
"\"rescan_step\":\"%s / %s\",\"rescan_running_ms\":%ld}",
g_fw.str, g_fw.bin, data_free_mb(), ip, PATCHDL_HTTP_PORT,
g_fw.str, g_fw.bin, data_free_mb(), root, ip, PATCHDL_HTTP_PORT,
PATCHDL_VERSION, titles_gen(), downloads_bytes(),
g_rescan_ms[0], g_rescan_ms[1], g_rescan_ms[2],
g_rescan_step, patchdl_scan_step(),
@@ -987,8 +1113,9 @@ build_status_json(void) {
are appended from config_tail_json. Caller owns the result (queue_json_owned). */
static char *
build_config_json(void) {
char head[224];
char head[384], root[64];
current_root(root, sizeof root);
pthread_mutex_lock(&g_mutex);
snprintf(head, sizeof(head),
"{\"default_policy\":\"%s\","
@@ -996,13 +1123,14 @@ build_config_json(void) {
"\"delete_pkg_after_install\":%s,"
"\"verify_downloads\":%s,"
"\"home_shortcut\":%s,"
"\"max_connections\":%d,",
"\"max_connections\":%d,"
"\"download_drive\":\"%s\",\"download_dir\":\"%s\",",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false",
g_cfg.max_connections);
g_cfg.max_connections, g_cfg.download_drive, root);
pthread_mutex_unlock(&g_mutex);
char *out = malloc(strlen(head) + sizeof(config_tail_json));
@@ -1018,7 +1146,7 @@ static void
save_config(void) {
FILE *f;
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(PATCHDL_DATA_DIR, 0777);
f = fopen(PATCHDL_CFG_PATH, "w");
if (!f) return;
@@ -1029,13 +1157,13 @@ save_config(void) {
"\"delete_pkg_after_install\":%s,\n"
"\"verify_downloads\":%s,\n"
"\"home_shortcut\":%s,\n"
"\"max_connections\":%d,\n\"titles\":{",
"\"max_connections\":%d,\n\"download_drive\":\"%s\",\n\"titles\":{",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false",
g_cfg.max_connections);
g_cfg.max_connections, g_cfg.download_drive);
for (size_t i = 0; i < g_title_count; i++)
fprintf(f, "%s\"%s\":%s", i ? "," : "",
g_titles[i].title_id, g_titles[i].enabled ? "true" : "false");
@@ -1111,6 +1239,15 @@ load_config(void) {
json_get_int(buf, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
{
char drive[32] = "";
json_get_str(buf, "download_drive", drive, sizeof drive);
if (drive_name_ok(drive)) {
pthread_mutex_lock(&g_drive_mtx);
snprintf(g_cfg.download_drive, sizeof g_cfg.download_drive, "%s", drive);
pthread_mutex_unlock(&g_drive_mtx);
}
}
overlay_title_policy(buf, g_titles, g_title_count);
free(buf);
@@ -1508,7 +1645,11 @@ title_pkg_path(const char *title_id, const char *patch_url,
snprintf(name, sizeof(name), "%s.pkg",
path_segment_safe(title_id) ? title_id : "patch");
}
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, name);
{
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(out, out_sz, "%s/%s", dir, name);
}
}
static int
@@ -1522,7 +1663,9 @@ url_is_manifest(const char *url) {
static void
title_state_path(const char *title_id, char *out, size_t sz) {
snprintf(out, sz, "%s/%s/state.json", PATCHDL_DL_DIR, title_id);
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(out, sz, "%s/state.json", dir);
}
static void
@@ -1532,15 +1675,24 @@ remove_dl_state(const char *title_id) {
unlink(path);
}
/* Delete a title's internal download directory and its contents. Best-effort;
only ever touches /data/patchdl (self-protecting against a bad segment). */
/* Delete a title's download folder and its contents. Best-effort; only ever
touches <drive>/patchdl/<title> (self-protecting against a bad segment). */
static void remove_dir_files(const char *dir);
static void
remove_title_dir(const char *title_id) {
char dir[288], path[560];
char dir[288];
if (!path_segment_safe(title_id)) return;
title_dir(title_id, dir, sizeof dir);
remove_dir_files(dir);
}
/* Delete the files in one flat download folder, then the folder. */
static void
remove_dir_files(const char *dir) {
char path[560];
DIR *d;
struct dirent *e;
if (!path_segment_safe(title_id)) return;
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
if ((d = opendir(dir))) {
while ((e = readdir(d))) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
@@ -1557,7 +1709,9 @@ remove_title_dir(const char *title_id) {
static void
manual_source_path(const char *title_id, char *out, size_t sz) {
snprintf(out, sz, "%s/%s/manual.json", PATCHDL_DL_DIR, title_id);
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(out, sz, "%s/manual.json", dir);
}
/* Persist the pasted link next to the download so install and resume still
@@ -1566,9 +1720,8 @@ static void
save_manual_source(const char *title_id, const char *url, const char *version) {
char dir[288], path[320];
FILE *f;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
title_dir(title_id, dir, sizeof dir);
make_title_dir(dir);
manual_source_path(title_id, path, sizeof path);
if ((f = fopen(path, "w"))) {
fprintf(f, "{\"url\":\"%s\",\"version\":\"%s\"}\n", url, version);
@@ -1595,7 +1748,7 @@ clear_manual_source(const char *title_id) {
set_manual_source(title_id, "", "");
manual_source_path(title_id, path, sizeof path);
unlink(path);
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
title_dir(title_id, dir, sizeof dir);
rmdir(dir); /* fails while the pkg is still there; the cancel path removes it */
}
@@ -2052,8 +2205,7 @@ admit_next(void) {
/* ---- network + disk, NO pool lock ---- */
memset(&mf, 0, sizeof mf);
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
make_title_dir(dir);
if (is_manifest) {
if (patchdl_fetch_manifest(murl, &mf) != 0 || mf.count <= 0) {
patchdl_http_last_error(fstep, sizeof fstep, &fcurl, &fhttp);
@@ -2062,7 +2214,7 @@ admit_next(void) {
total = mf.total;
adopted = adopt_by_size(dir, dest, total);
need = space_needed(dest, total);
if (!space_ok(need, &free_b)) no_space = 1;
if (!space_ok(dir, need, &free_b)) no_space = 1;
else fd = open(dest, O_RDWR | O_CREAT, 0666);
if (fd >= 0) {
struct stat st;
@@ -2447,7 +2599,11 @@ do_download(struct MHD_Connection *conn, const char *title_id,
{
snprintf(job->manifest_url, sizeof job->manifest_url, "%s", patch_url);
snprintf(job->dir, sizeof job->dir, "%s/%s", PATCHDL_DL_DIR, title_id);
title_dir(title_id, job->dir, sizeof job->dir);
/* create it now: from here on every lookup (sidecar, package,
install) finds this folder, even if the drive in Settings
changes before the download starts */
make_title_dir(job->dir);
title_pkg_path(title_id, patch_url, job->dest, sizeof job->dest);
job->is_manifest = url_is_manifest(patch_url);
}
@@ -2587,7 +2743,8 @@ download_complete(const char *title_id) {
return json_get_ll(buf, "done_bytes", 0) >= json_get_ll(buf, "total", 0);
}
/* Everything PatchDL keeps under /data/patchdl, one entry per title folder,
/* Everything PatchDL keeps in <drive>/patchdl on every drive, one entry per
title folder,
so the UI can show what takes space and delete it, including folders of
games that are no longer installed. */
static char *
@@ -2598,8 +2755,13 @@ build_storage_json(void) {
long long sum = 0;
int first = 1;
char drives[MAX_DRIVES][32], root[64];
int nd = list_drives(drives, MAX_DRIVES);
jbuf_append(&j, "{\"items\":[");
if ((d = opendir(PATCHDL_DL_DIR))) {
for (int k = 0; k < nd; k++) {
snprintf(root, sizeof root, "%s/patchdl", drives[k]);
if (!(d = opendir(root))) continue;
while ((de = readdir(d))) {
char dir[288], path[560], name[128] = "", inst[16] = "";
char ver[16] = "", buf[2048], pkgname[160] = "";
@@ -2613,7 +2775,7 @@ build_storage_json(void) {
dl_job_t *job;
if (!is_title_dir_name(de->d_name)) continue;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, de->d_name);
snprintf(dir, sizeof dir, "%s/%s", root, de->d_name);
if (stat(dir, &st) || !S_ISDIR(st.st_mode)) continue;
if ((sd = opendir(dir))) {
while ((fe = readdir(sd))) {
@@ -2692,6 +2854,7 @@ build_storage_json(void) {
jbuf_append(&j, ",\"version\":"); jbuf_append_ver_or_null(&j, ver);
jbuf_append(&j, ",\"installed_version\":");
jbuf_append_ver_or_null(&j, inst);
jbuf_append(&j, ",\"drive\":"); jbuf_append_str(&j, drives[k]);
jbuf_append(&j, "}");
}
closedir(d);
@@ -2700,6 +2863,33 @@ build_storage_json(void) {
return j.buf;
}
/* GET /api/drives: the drives packages can go to, with free and total
space, and which one new downloads use. */
static char *
build_drives_json(void) {
char drives[MAX_DRIVES][32], root[64];
int nd = list_drives(drives, MAX_DRIVES);
jbuf_t j = {0};
current_root(root, sizeof root);
jbuf_append(&j, "{\"current\":");
jbuf_append_str(&j, root);
jbuf_append(&j, ",\"drives\":[");
for (int i = 0; i < nd; i++) {
struct statvfs vfs;
unsigned long long fr = 0, tot = 0;
if (statvfs(drives[i], &vfs) == 0) {
fr = (unsigned long long)vfs.f_bavail * vfs.f_frsize / (1024ULL * 1024ULL);
tot = (unsigned long long)vfs.f_blocks * vfs.f_frsize / (1024ULL * 1024ULL);
}
jbuf_append(&j, i ? ",{\"path\":" : "{\"path\":");
jbuf_append_str(&j, drives[i]);
jbuf_appendf(&j, ",\"free_mb\":%llu,\"total_mb\":%llu}", fr, tot);
}
jbuf_append(&j, "]}");
return j.buf;
}
/* Host part of a URL into out ("" if none). */
static void
url_host_of(const char *url, char *out, size_t sz) {
@@ -3150,7 +3340,9 @@ handle_download_url(struct MHD_Connection *conn, const char *body) {
patchdl_manifest_free(&mf);
{
long long free_b;
if (!space_ok(found, &free_b)) {
char dir[160];
title_dir(tid, dir, sizeof dir);
if (!space_ok(dir, found, &free_b)) {
snprintf(resp, sizeof resp,
"{\"ok\":false,\"reason\":\"no_space\","
"\"need_bytes\":%lld,\"free_bytes\":%lld}", found, free_b);
@@ -3323,6 +3515,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
json_get_int(body, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
{
/* only a drive that is mounted right now can be picked */
char drive[32] = "";
json_get_str(body, "download_drive", drive, sizeof drive);
if (drive_name_ok(drive) && drive_mounted(drive)) {
pthread_mutex_lock(&g_drive_mtx);
snprintf(g_cfg.download_drive, sizeof g_cfg.download_drive, "%s", drive);
pthread_mutex_unlock(&g_drive_mtx);
}
}
mc = g_cfg.max_connections;
pthread_mutex_unlock(&g_mutex);
@@ -3414,6 +3616,21 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
tid[tl] = '\0';
if (!is_title_dir_name(tid))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
{
/* The same title can have a folder on two drives (one
downloaded while the other drive was unplugged). Delete
the one the list showed, not just the first one found. */
char drive[32] = "", dir[160], cur[160];
json_get_str(body, "drive", drive, sizeof drive);
if (drive[0] && drive_name_ok(drive)) {
snprintf(dir, sizeof dir, "%s/patchdl/%s", drive, tid);
title_dir(tid, cur, sizeof cur);
if (strcmp(dir, cur)) { /* not the folder downloads use */
remove_dir_files(dir);
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"cancelled\":true}");
}
}
}
return do_cancel(conn, tid);
}
if (!strcmp(url, "/api/install_uri")) {
@@ -3459,7 +3676,7 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
if (!local_install_path_safe(path))
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"path_not_allowed\","
"\"hint\":\"must be " PATCHDL_DL_DIR
"\"hint\":\"must be <drive>/patchdl"
"/<title_id>/<filename>\"}");
if (tid[0] && !path_segment_safe(tid))
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
@@ -3731,6 +3948,8 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
if (!strcmp(url, "/api/drives"))
return queue_json_owned(conn, MHD_HTTP_OK, build_drives_json());
if (!strcmp(url, "/api/versions")) {
const char *want = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "title");
return queue_versions(conn, want ? want : "");
+37 -6
View File
@@ -439,7 +439,7 @@ function bindElements() {
storageTotal: document.getElementById("storageTotal"),
searchInput: document.getElementById("searchInput"),
defaultPolicy: document.getElementById("defaultPolicy"),
downloadDir: document.getElementById("downloadDir"),
downloadDrive: document.getElementById("downloadDrive"),
installAfterDownload: document.getElementById("installAfterDownload"),
deleteAfterInstall: document.getElementById("deleteAfterInstall"),
verifyDownloads: document.getElementById("verifyDownloads"),
@@ -522,7 +522,7 @@ 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();
if (view === "settings") { loadStorage(); loadDrives(); }
}
async function loadInitialData({ quiet = false } = {}) {
@@ -653,7 +653,7 @@ function setConnections(n) {
function renderSettings() {
els.defaultPolicy.value = state.config.default_policy || "deny";
els.downloadDir.value = state.config.download_dir || "";
renderDrives();
els.installAfterDownload.checked = Boolean(state.config.install_after_download);
els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
if (els.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads);
@@ -1337,6 +1337,37 @@ const STORAGE_STATE = {
// 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.
/* ---------------- download drive ---------------- */
let drives = null; // from /api/drives: [{path, free_mb, total_mb}]
function driveLabel(path) {
return path === "/data" ? t("settings.driveInternal")
: t("settings.driveNamed", { name: path.replace(/^\/mnt\//, "") });
}
function renderDrives() {
const sel = els.downloadDrive;
if (!sel) return;
const current = state.config.download_drive || "/data";
const list = drives || [{ path: current }];
sel.replaceChildren(...list.map((d) => {
const opt = document.createElement("option");
opt.value = d.path;
opt.textContent = d.free_mb != null
? `${driveLabel(d.path)} · ${t("rail.free", { space: formatMb(d.free_mb) })}`
: driveLabel(d.path);
opt.selected = d.path === current;
return opt;
}));
}
async function loadDrives() {
const data = await fetchJson("/api/drives");
if (data && Array.isArray(data.drives)) drives = data.drives;
renderDrives();
}
async function loadStorage() {
if (!els.storageList) return;
let data;
@@ -1362,7 +1393,7 @@ async function loadStorage() {
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)}` : ""} · ${escapeHtml(storageRelation(item))}</em></span>
<em>${escapeHtml(item.title_id)}${item.version ? ` · ${escapeHtml(item.version)}` : ""} · ${escapeHtml(storageRelation(item))}${item.drive && item.drive !== "/data" ? ` · ${escapeHtml(driveLabel(item.drive))}` : ""}</em></span>
<span class="pill ${info.className}">${t(`storage.state.${stateKey}`)}</span>
<b>${formatBytes(item.bytes)}</b>
`;
@@ -1390,7 +1421,7 @@ async function deleteStorage(item) {
const label = item.name || item.title_id;
if (!window.confirm(t("storage.confirmDelete", { name: label, size: formatBytes(item.bytes) }))) return;
try {
await postJson(API.storageDelete(item.title_id), {});
await postJson(API.storageDelete(item.title_id), { drive: item.drive || "" });
showToast(t("toast.storageDeleted", { name: label }));
} catch (error) {
showToast(t("toast.nameMessage", { name: label, message: reasonText(error) }));
@@ -1435,7 +1466,7 @@ async function saveConfig() {
const config = {
...state.config,
default_policy: els.defaultPolicy.value,
download_dir: els.downloadDir.value.trim(),
download_drive: els.downloadDrive.value || state.config.download_drive || "/data",
install_after_download: els.installAfterDownload.checked,
delete_pkg_after_install: els.deleteAfterInstall.checked,
verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads),
+30 -10
View File
@@ -182,7 +182,9 @@ const I18N = {
"settings.language": "Language",
"settings.languageAuto": "Automatic",
"settings.downloadFolder": "Download Folder",
"settings.downloadFolderTitle": "Patches always download to the console's internal storage",
"settings.downloadFolderHint": "New downloads go here. Packages that are already downloaded stay where they are.",
"settings.driveInternal": "Internal storage",
"settings.driveNamed": "Drive {name}",
"settings.installAfter": "Install after download",
"settings.installAfterHint": "Global default, overridable per game",
"settings.deleteAfter": "Delete PKG after install",
@@ -522,7 +524,9 @@ I18N["de"] = {
"settings.language": "Sprache",
"settings.languageAuto": "Automatisch",
"settings.downloadFolder": "Downloadordner",
"settings.downloadFolderTitle": "Patches werden immer in den internen Speicher der Konsole heruntergeladen",
"settings.downloadFolderHint": "Neue Downloads landen hier. Bereits heruntergeladene Pakete bleiben, wo sie sind.",
"settings.driveInternal": "Interner Speicher",
"settings.driveNamed": "Laufwerk {name}",
"settings.installAfter": "Nach dem Download installieren",
"settings.installAfterHint": "Globale Vorgabe, pro Spiel änderbar",
"settings.deleteAfter": "PKG nach der Installation löschen",
@@ -843,7 +847,9 @@ I18N["fr"] = {
"settings.language": "Langue",
"settings.languageAuto": "Automatique",
"settings.downloadFolder": "Dossier de téléchargement",
"settings.downloadFolderTitle": "Les patchs sont toujours téléchargés sur le stockage interne de la console",
"settings.downloadFolderHint": "Les nouveaux téléchargements sont enregistrés ici. Les paquets déjà téléchargés restent où ils sont.",
"settings.driveInternal": "Stockage interne",
"settings.driveNamed": "Disque {name}",
"settings.installAfter": "Installer après le téléchargement",
"settings.installAfterHint": "Valeur globale, modifiable par jeu",
"settings.deleteAfter": "Supprimer le PKG après l'installation",
@@ -1164,7 +1170,9 @@ I18N["es"] = {
"settings.language": "Idioma",
"settings.languageAuto": "Automático",
"settings.downloadFolder": "Carpeta de descargas",
"settings.downloadFolderTitle": "Los parches siempre se descargan en el almacenamiento interno de la consola",
"settings.downloadFolderHint": "Las nuevas descargas se guardan aquí. Los paquetes ya descargados se quedan donde están.",
"settings.driveInternal": "Almacenamiento interno",
"settings.driveNamed": "Unidad {name}",
"settings.installAfter": "Instalar tras la descarga",
"settings.installAfterHint": "Valor global, se puede cambiar por juego",
"settings.deleteAfter": "Eliminar el PKG tras instalar",
@@ -1485,7 +1493,9 @@ I18N["it"] = {
"settings.language": "Lingua",
"settings.languageAuto": "Automatica",
"settings.downloadFolder": "Cartella download",
"settings.downloadFolderTitle": "Le patch vengono sempre scaricate nella memoria interna della console",
"settings.downloadFolderHint": "I nuovi download vengono salvati qui. I pacchetti già scaricati restano dove sono.",
"settings.driveInternal": "Memoria interna",
"settings.driveNamed": "Unità {name}",
"settings.installAfter": "Installa dopo il download",
"settings.installAfterHint": "Predefinito globale, modificabile per singolo gioco",
"settings.deleteAfter": "Elimina PKG dopo l'installazione",
@@ -1806,7 +1816,9 @@ I18N["pt-BR"] = {
"settings.language": "Idioma",
"settings.languageAuto": "Automático",
"settings.downloadFolder": "Pasta de downloads",
"settings.downloadFolderTitle": "Os patches sempre são baixados para o armazenamento interno do console",
"settings.downloadFolderHint": "Os novos downloads ficam aqui. Pacotes já baixados continuam onde estão.",
"settings.driveInternal": "Armazenamento interno",
"settings.driveNamed": "Unidade {name}",
"settings.installAfter": "Instalar após o download",
"settings.installAfterHint": "Padrão global, ajustável por jogo",
"settings.deleteAfter": "Excluir PKG após instalar",
@@ -2127,7 +2139,9 @@ I18N["ja"] = {
"settings.language": "言語",
"settings.languageAuto": "自動",
"settings.downloadFolder": "ダウンロードフォルダー",
"settings.downloadFolderTitle": "パッチは常に本体ストレージにダウンロードされます",
"settings.downloadFolderHint": "新しいダウンロードはここに保存されます。ダウンロード済みのパッケージは移動しません。",
"settings.driveInternal": "本体ストレージ",
"settings.driveNamed": "ドライブ {name}",
"settings.installAfter": "ダウンロード後にインストール",
"settings.installAfterHint": "全体のデフォルト。ゲームごとに変更できます",
"settings.deleteAfter": "インストール後に PKG を削除",
@@ -2448,7 +2462,9 @@ I18N["zh-Hans"] = {
"settings.language": "语言",
"settings.languageAuto": "自动",
"settings.downloadFolder": "下载文件夹",
"settings.downloadFolderTitle": "补丁始终下载到主机的内部存储",
"settings.downloadFolderHint": "新的下载会保存到这里。已下载的软件包保留在原位置。",
"settings.driveInternal": "内部存储",
"settings.driveNamed": "驱动器 {name}",
"settings.installAfter": "下载后安装",
"settings.installAfterHint": "全局默认值,可按游戏单独设置",
"settings.deleteAfter": "安装后删除 PKG",
@@ -2769,7 +2785,9 @@ I18N["ko"] = {
"settings.language": "언어",
"settings.languageAuto": "자동",
"settings.downloadFolder": "다운로드 폴더",
"settings.downloadFolderTitle": "패치는 항상 본체의 내부 저장 공간에 다운로드됩니다",
"settings.downloadFolderHint": "새 다운로드는 여기에 저장됩니다. 이미 다운로드한 패키지는 그대로 유지됩니다.",
"settings.driveInternal": "내부 저장소",
"settings.driveNamed": "드라이브 {name}",
"settings.installAfter": "다운로드 후 설치",
"settings.installAfterHint": "전체 기본값이며 게임별로 변경할 수 있습니다",
"settings.deleteAfter": "설치 후 PKG 삭제",
@@ -3090,7 +3108,9 @@ I18N["ru"] = {
"settings.language": "Язык",
"settings.languageAuto": "Автоматически",
"settings.downloadFolder": "Папка загрузок",
"settings.downloadFolderTitle": "Патчи всегда скачиваются во внутреннюю память консоли",
"settings.downloadFolderHint": "Новые загрузки сохраняются сюда. Уже загруженные пакеты остаются на месте.",
"settings.driveInternal": "Внутренняя память",
"settings.driveNamed": "Диск {name}",
"settings.installAfter": "Устанавливать после загрузки",
"settings.installAfterHint": "Общая настройка, можно изменить для каждой игры",
"settings.deleteAfter": "Удалять PKG после установки",
+2 -3
View File
@@ -193,9 +193,8 @@
</label>
<label class="field">
<span data-i18n="settings.downloadFolder">Download Folder</span>
<input id="downloadDir" type="text" spellcheck="false" readonly
title="Patches always download to the console's internal storage"
data-i18n-title="settings.downloadFolderTitle" />
<select id="downloadDrive"></select>
<em class="field-hint" data-i18n="settings.downloadFolderHint">New downloads go here. Packages that are already downloaded stay where they are.</em>
</label>
<label class="field">
<span data-i18n="settings.language">Language</span>
+1
View File
@@ -619,6 +619,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.settings-grid { display: grid; gap: 12px; }
.field { display: grid; gap: 6px; }
.field span, .allowlist > span, .source-policy > span { color: var(--muted); font-size: var(--fs-xs); font-weight: 700; }
.field-hint { color: var(--muted); font-size: var(--fs-xs); font-style: normal; }
.field input, .field select {
min-height: 44px;
padding: 0 12px;