32 Commits
Author SHA1 Message Date
Knutwurst 3b58a77a70 Resolve Sony's hosts past blocked DNS; version 0.3.0
The resolver asked one server over UDP and took the first answer, even
a blocker's 0.0.0.0. It now tries UDP on four public resolvers, then
TCP, then DNS over HTTPS (Cloudflare, Google), then the addresses the
last good lookup saved. Sinkhole answers count as blocked, curl gets
every address of a host, and a host whose addresses refuse or drop the
connection is looked up again. Sony hosts stay pinned to Sony's root;
only the DoH requests use public roots.

The network check reports where each answer came from and what failed
before it.
2026-10-17 15:19:00 +02:00
Knutwurst 23e4dda1e2 Redesign game cards and the Other versions dialog
Cards show installed, update and newest version side by side, and
problems as notes with their action. Other versions became a button.
The dialog groups versions into available, needs newer firmware,
installed and older (folded), with larger type throughout. Text is at
least 13px, buttons 44px tall, and every control has a focus ring.
Phones get two status tiles per row and full-width card content.
2026-10-17 15:19:00 +02:00
Knutwurst b8899c6b02 Revert "README: static license badge (shields.io's GitHub license badge shows invalid)"
This reverts commit 4816a0fbd7.
2026-10-17 11:52:00 +02:00
Knutwurst 4816a0fbd7 README: static license badge (shields.io's GitHub license badge shows invalid) 2026-10-17 11:51:02 +02:00
Knutwurst 1cc12e2640 Search the whole patch history for an older-firmware patch
The lookup took only the 64 newest versions, so a game whose 64 newest
patches all need newer firmware lost its compatible older patch.
2026-10-17 11:44:40 +02:00
Knutwurst 4dd254c20d Accept single-piece PS4 links, Orbis error text, natural UI terminology
A PS4 patch that fits in one piece is listed by Orbis as "<name>.pkg"
without a number; its manifest is "<name>.json" in the same gs2 folder,
like split packages. A wrong link on a PS4 game now explains Orbis' "Copy
all links!" instead of asking for Prospero's SC link.

All translations were reviewed against per-language glossaries (PS5 and
common app wording): e.g. German "Herunterladen" instead of "Holen",
status texts as "Wird ... ", requirements as "erfordert FW".
2026-10-17 11:43:05 +02:00
Knutwurst 76d40a5327 Translate the UI into ten languages
The UI follows the browser language, which on the PS5 is the system
language, and Settings has a selector to override it per device (kept in
localStorage). All UI text moved into web/i18n.js (306 keys) with English,
German, French, Spanish, Italian, Portuguese (Brazil), Japanese, Simplified
Chinese, Korean and Russian; missing keys fall back to English.

Log entries keep their key and values, so the Logs view shows them in the
chosen language while the diagnostic report stays English for issues.
2026-10-17 11:33:21 +02:00
Knutwurst a8aace1e55 Pick another patch version from Prospero (PS5) or Orbis (PS4)
An optional "Other versions" link on a game card lists every version the
patch site knows: Prospero Patches for PS5, its sister site Orbis Patches
for PS4 (same API). Versions newer than the installed one that run on this
firmware can be picked; older ones are shown but not offered, since
installing an older patch over a newer one is untested.

Picking a version opens the existing guide for exactly that version: QR
code to the phone, copy the link on the site, paste it. Orbis offers the
piece links of a PS4 patch ("Copy all links!"); they sit in the same gs2
folder as the manifest, so PatchDL derives "<name>.json" from any
"<name>_<n>.pkg" and fetches it over http like other PS4 manifests. The
size check uses the size the list showed for the version picked.

The Prospero lookup behind "Old FW patch" now uses the same list.
2026-10-17 11:18:38 +02:00
Knutwurst b4fbdd4681 Bump version to 0.2.1 2026-10-17 10:45:52 +02:00
Knutwurst 8053f5395b Mark sideloaded apps, explain failed downloads, diagnose from the UI
Sideloaded apps
- A Sony-signed package installed with another tool ("fake": true in
  app.json, but no PatchDL loopback URL) stays Official but is marked
  Sideloaded, and its Updates switch starts off, so Update all skips it.
  Switching it on is saved separately, so a switch saved earlier can't
  turn it on. The card says an update replaces this exact version.

Failed downloads
- When the package list can't be fetched, the download job records why
  (DNS, connection, timeout, TLS, redirect, HTTP status, not a manifest)
  with the curl and HTTP codes; the card says so with a Details link.
- Details runs /api/netcheck?title=<id>, which walks the game's update
  list and manifest the way a download does and reports PatchDL's own
  DNS answer for each host. The dialog shows a checklist and a report to
  copy into an issue; on the PS5 a QR code opens it on a phone.
2026-10-17 10:39:49 +02:00
Knutwurst bde585eab7 Add a Buy me a coffee link with a QR code to the UI
The sidebar footer and the end of Settings open a dialog with a QR code
for PayPal, so it works from the TV, plus a direct link for phones.
2026-10-16 19:04:03 +02:00
Knutwurst eaab113d8a Add a PayPal donation link to the README and the repo's Sponsor button 2026-10-16 17:42:48 +02:00
Knutwurst c3630000b6 Bump version to 0.2.0 2026-10-16 14:25:40 +02:00
Knutwurst 72b9136ff5 Say PS5 and PS4 games in the README and the UI header 2026-10-16 14:25:39 +02:00
Knutwurst c6f001f857 README: PS4 games are supported 2026-10-16 13:56:41 +02:00
Knutwurst e72c25a56e Update PS4 games too
PS4 patches install on the PS5 through the same loopback route as PS5
updates; the console puts them in /user/patch/<id>/ next to the game.
Tested with Bloodborne (CUSA00207, 01.00 to 01.09) on FW 11.60.

- Installed version: PS4 titles have no CONTENT_VERSION in app.db; use
  APP_VER, which includes an installed patch.
- Update check: Sony lists PS4 patches at a gs-sec URL derived from the
  title id with HMAC-SHA256 (the well-known PS4 update key). A PS4
  package's system_ver is a PS4 firmware in decimal and never blocks it.
- Download: PS4 manifests are served over plain http on gs2 only (its
  https edge has no Sony certificate); allow http for that host's
  manifests, keep everything else https. Piece hashes are SHA-1 there, so
  verification picks SHA-1 or SHA-256 by length.
- After an install, check /user/patch/<id>/patch.json instead of app.json.
  PS4 installs fetch no .crc, so a minute of quiet after reading ends one.
- The UI shows a PS4 badge instead of "not supported".

Also: a list reload no longer ends an install on the card early. The
console writes the new version during the install, and the card briefly
showed Delete before the install poll put it back.
2026-10-16 13:56:41 +02:00
Knutwurst bd08ad70c4 Keep the indeterminate install bar moving across card redraws
Cards are rebuilt on list updates, and each rebuild restarted the bar's
animation. A negative animation-delay taken from the clock lets a new bar
continue where the old one was. The sweep is linear now instead of easing
in from a standstill, and it stops for prefers-reduced-motion.
2026-10-16 12:08:41 +02:00
Knutwurst f0091943f2 Bump version to 0.1.0 2026-10-16 12:04:56 +02:00
Knutwurst c09f0481ab README: automatic rescan, package reuse and cleanup, PS4 not supported 2026-10-16 12:04:56 +02:00
Knutwurst 289688f727 Rescan on its own, track installs, verify and clean up packages
Library
- Rescan when /user/app or Sony's app.db changes, after an install, and on
  the Refresh button (which also asks Sony for updates again). Later scans
  keep a handle to the system root and use relative paths instead of
  swapping the process root while other threads run; app.db is copied out
  first because SQLite resolves relative paths with getcwd(). The scan
  thread gets a 1 MB stack (the default made reads fail with EFAULT).
- Take the newer of app.db and appmeta param.json as installed version:
  app.db lags behind until the game is started.
- A package header decides "fake"; the installer's "fake": true marker
  only says the package didn't come from Sony's CDN, and PatchDL's own
  installs set it too.
- PS4 titles are listed as not supported.

Downloads
- Fetch the manifest's .crc next to the package; the console asks for it.
- Keep the sidecar (with the version) after a download completes, so a
  restart still knows the package is whole.
- A package already on disk without a matching sidecar is hashed against
  the manifest piece by piece; only pieces that don't match are fetched.
  Install refuses an unverified package.

Installs
- One install per title: a second request (another tab, a double click)
  gets install_running; an installed version gets already_installed.
  Download, Cancel and Delete wait until the console is done reading.
- Progress comes from the bytes PatchDL serves to the console (distinct
  ranges), since Sony's status query fails for these installs. Phases:
  waiting, reading with percent, finishing, done or stalled. Each title
  is tracked on its own.
- After an install: check that the installed version matches and the new
  app.json names this package and its size. With "Delete PKG after
  install" on, delete the package only then. The setting existed but was
  never read; the old cleanup trusted app.db alone and deleted packages
  that were never applied.

UI
- Settings list every package on the console with size, version and
  whether it is installed, newer or for a game that's gone, with Delete.
  The storage tile links there.
- The list reloads quietly when the server reports a change.
- Bigger covers on wide screens, "Updates on/off" on the switch, no
  "Shared master" badge, no endless auto-install retries.
2026-10-16 12:04:49 +02:00
Knutwurst 533c25a2ac Install updates with InstallByPackage instead of AppInstallPkg
sceAppInstUtilAppInstallPkg returns 0 but installs no content: it writes
the new version to app.db, the tile and appmeta, while the game keeps
running the old one. That is what #9 and #11 report (version shows new,
game unchanged, install stuck at 0%).

InstallByPackage had been refused with 0x80B2116F because MetaInfo was the
published 48-byte struct. Sony reads past it; the stack bytes there sent
the install down another path. MetaInfo is now 0x70 bytes and fully zeroed.

- Try the package over http://127.0.0.1:12880/api/pkg first, so the console
  streams it from PatchDL, then the LAN address, the /user/data path and
  file://. content_id stays empty.
- Drop the AppInstallPkg fallback: a refusal beats a fake success.
- Wait up to 15 s for the install backend instead of failing the first
  click with "install backend not ready".

Verified on a PS5 (FW 11.60) with Dead Island 2, Ghostwire: Tokyo and
Atlas Fallen: app.pkg replaced, packageDigest equal to Sony's manifest,
games start on the new version.
2026-10-16 12:04:37 +02:00
Knutwurst 70863fb54e Show the newest version and its firmware on every card again
Since the version line rework, "Newest … · needs FW …" only appeared when
the newest patch was newer than the installed one, so up-to-date games,
fake packages and Shadowmount titles lost the firmware information that
0.0.8 showed. It is back whenever Sony reports a newest version, unless
the Update entry already names the same version.
2026-10-15 22:16:20 +02:00
Knutwurst b115c0fd4b Drop leftovers of the "shared master can't install" assumption
/api/pkgmeta now calls a package installable when its embedded title id
matches the installed game, which is what AppInstallPkg goes by, instead
of flagging every shared-master package. The unused UI message for the
old refusal is gone, and web/README.md describes the current policy:
Shadowmount, unknown and fake titles get neither download nor install,
and shared-master packages install like any other.
2026-10-15 22:05:44 +02:00
Knutwurst 7ae09ce571 Allow installing shared-master patches again
The UI hid Install for games whose patch pieces live under another
region's title id. That block predates the switch to AppInstallPkg on the
manifest-assembled package, which carries the installed game's own
content id; Dead Island 2 (EU disc, US master pieces) updated fine that
way. The server has a branch for exactly this case, so the UI now offers
Install again and only keeps the "Shared master" label.
2026-10-15 21:58:06 +02:00
Knutwurst 1739096aa1 Treat Shadowmount titles like fake packages: no download
A mounted image can't take a patch, so a download leads nowhere. The
server now refuses downloads and Prospero links for Shadowmount titles
and skips the Prospero lookup for them; the UI lists them under Not
supported without a button, the same as fake packages.
2026-10-15 21:53:46 +02:00
Knutwurst 25c2adc4bf Bump version to 0.0.9 2026-10-15 21:48:47 +02:00
Knutwurst 36c7c02ebb README: new screenshots with the old-firmware flow on TV and phone 2026-10-15 21:48:47 +02:00
Knutwurst 4379ac3f59 List download-only titles under Not supported, soften the human-check hint
Shadowmount titles can't take a patch, so they no longer sit under Updates.
They move to Not supported and keep their download: Download for Sony
updates, Old FW download for patches from Prospero. Fake packages stay
without any download.

Prospero's human check usually passes without the user noticing, so the
guide now only asks to confirm it if one shows up.
2026-10-15 21:48:47 +02:00
Knutwurst 91cfb2cd02 Flag region patches, check free space, slim the console guide
Disc and store installs whose update Sony serves from another region's
master package can't be installed from homebrew; Sony's installer refused
them in testing. They now sit under Not supported with a short reason
instead of offering a download that leads nowhere.

Before preallocating a package, PatchDL now checks that it fits on /data
with 2 GB to spare and reports how much it needs. The Prospero link check
runs the same test, so the phone gets the answer right away.

The PS5 browser can't copy links, so on the console the old-FW guide only
shows a large QR code and the link for the phone.
2026-10-15 21:31:00 +02:00
Knutwurst bc716339ba README: mention patches for older firmware and the Prospero lookup 2026-10-15 21:21:51 +02:00
Knutwurst fbe984da87 Offer the newest patch that fits the firmware via Prospero Patches
Sony's version.xml only lists the newest patch. When that one needs a
newer firmware, PatchDL now asks Prospero Patches for the version history
and offers the newest patch that still runs on the console. Prospero only
hands out download links after a human check, so a guide with a QR code
moves the user to a phone, where they copy the SC link. PatchDL derives
Sony's manifest from that link and downloads like an official update.

The game list now groups by what the user can do: Updates, Up to date,
Not supported. Update all read a nonexistent state.games and did nothing;
it now uses state.titles.
2026-10-15 21:21:51 +02:00
Knutwurst f251e2aa98 README: show 0.0.8 with game icons and a library screenshot
New screenshots for the hero, games, queue, settings and phone views,
all with game icons. Add a library shot sorted A to Z, mention the
Needs FW label and the package check in the highlights, and answer
which games PatchDL updates in the FAQ.
2026-10-15 11:17:01 +02:00
32 changed files with 8689 additions and 758 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
custom: ["https://paypal.me/oliverkoester"]
+3 -1
View File
@@ -20,6 +20,8 @@ SRCS := src/main.c \
src/patchdl_scan.c \
src/patchdl_appdb.c \
src/patchdl_net.c \
src/patchdl_dns.c \
src/patchdl_prospero.c \
src/patchdl_resolve.c \
src/patchdl_verxml.c \
src/patchdl_install.c \
@@ -30,7 +32,7 @@ SRCS := src/main.c \
# assets must trigger a rebuild.
TILE_ASSETS := assets/param.json assets/icon0.png
WEB_ASSETS := web/index.html web/styles.css web/app.js
WEB_ASSETS := web/index.html web/styles.css web/i18n.js web/app.js
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
SQLITE_DIR := vendor/sqlite
+41 -12
View File
@@ -1,18 +1,19 @@
<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">
<b>Official game updates for your PS5, from any browser.</b><br>
PatchDL finds the newest update for every game on your console, downloads it at full speed and installs it.<br>
<b>Official updates for your PS5 and PS4 games, from any browser.</b><br>
PatchDL finds the newest update for every PS5 and PS4 game on your console, downloads it at full speed and installs it.<br>
Run it from your phone, your laptop or the PS5 itself.
</p>
<p align="center">
<img src="https://img.shields.io/github/v/release/knutwurst/ps5-patchdl?style=for-the-badge&label=release&color=22c55e" alt="Latest release">
<img src="https://img.shields.io/github/downloads/knutwurst/ps5-patchdl/total?style=for-the-badge&color=22c55e" alt="Downloads">
<img src="https://img.shields.io/badge/games-PS5%20%7C%20PS4-22c55e?style=for-the-badge" alt="PS5 and PS4 games">
<img src="https://img.shields.io/badge/FW-1.00%E2%80%9313.60-22c55e?style=for-the-badge" alt="Firmware 1.00 to 13.60">
<img src="https://img.shields.io/badge/UI-any%20browser-22c55e?style=for-the-badge" alt="Web UI in any browser">
<img src="https://img.shields.io/github/license/knutwurst/ps5-patchdl?style=for-the-badge&color=3a3a3a" alt="License">
@@ -24,29 +25,42 @@
</a>
</p>
<p align="center">
If you like what I do, you can buy me a coffee*:
<a href="https://paypal.me/oliverkoester"><img src="https://img.shields.io/badge/Donate-PayPal-22c55e?style=flat-square&logo=paypal&logoColor=white" alt="Donate via PayPal"></a><br>
<sub>*It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</sub>
</p>
---
## Highlights
- **Your whole library at a glance.** Every installed game shows up with its version and the newest update Sony offers for it.
- **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, 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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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> &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>
</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/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>
</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>
</tr>
</table>
@@ -55,7 +69,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 +86,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 and PS4 games you installed from disc or from the PlayStation Store. PatchDL reads each game's package before it offers anything, and it leaves every other title alone.
**Where do the updates come from?**
From Sony's official update servers, the same files your console downloads on its own. PatchDL 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. It finds them through public DNS (Cloudflare and Google, encrypted if your network blocks plain DNS) and never touches the console's own DNS settings, so a DNS block that keeps the system from updating stays in place. To find older patches it also asks [Prospero Patches](https://prosperopatches.com/) which versions exist for a game. That lookup sends the game's title ID and nothing else.
**Can I get an older patch that runs on my firmware?**
Yes. Sony still hosts older patches, but its servers only tell your console about the newest one. When that one needs a newer firmware, PatchDL looks up the version history on [Prospero Patches](https://prosperopatches.com/) and lists the game under *Updates* with an **Old FW patch** button. Tap it and scan the QR code with your phone. Open the game on Prospero, pick the version PatchDL names (confirm the human check if one shows up) and copy the **SC** link. Paste it on your phone, and the PS5 downloads the patch from Sony and handles it like any other update.
**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 +137,13 @@ 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.
## Support
If you like what I do, you can buy me a coffee*: [![Donate](https://img.shields.io/badge/Donate-PayPal-22c55e?style=flat-square&logo=paypal&logoColor=white)](https://paypal.me/oliverkoester)
<sub>*It doesn't have to be a generous donation. A few cents are enough to show me who is interested in further development. So the motivation stays and I just know that I am not programming for the bin ;)</sub>
## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 197 KiB

+85 -5
View File
@@ -9,13 +9,68 @@
#include "patchdl_appdb.h"
#include <errno.h>
#include <fcntl.h>
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define APP_DB_URI \
"file:/system_data/priv/mms/app.db?immutable=1"
/* A rescan can't open app.db by a relative path: SQLite turns it into an
absolute one with getcwd(), which resolves inside our own root again. So
it copies the file out through the working directory (the system root,
see patchdl_scan) and opens the copy. */
#define APP_DB_REL "system_data/priv/mms/app.db"
#define APP_DB_SNAPSHOT "/data/patchdl/.appdb.snapshot"
#define APP_DB_URI_SNAPSHOT "file:" APP_DB_SNAPSHOT "?immutable=1"
static char g_appdb_err[64];
const char *
patchdl_appdb_error(void) {
return g_appdb_err;
}
static int
snapshot_appdb(void) {
char *buf;
ssize_t n;
int in, out, rc = 0;
if ((in = open(APP_DB_REL, O_RDONLY | O_CLOEXEC)) < 0) {
snprintf(g_appdb_err, sizeof g_appdb_err, "open app.db errno %d", errno);
return -1;
}
out = open(APP_DB_SNAPSHOT, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (out < 0) {
snprintf(g_appdb_err, sizeof g_appdb_err, "open snapshot errno %d", errno);
close(in);
return -1;
}
if (!(buf = malloc(65536))) {
close(in);
close(out);
return -1;
}
while ((n = read(in, buf, 65536)) > 0)
if (write(out, buf, (size_t)n) != n) {
snprintf(g_appdb_err, sizeof g_appdb_err, "write snapshot errno %d", errno);
rc = -1;
break;
}
if (n < 0) {
snprintf(g_appdb_err, sizeof g_appdb_err, "read app.db errno %d", errno);
rc = -1;
}
free(buf);
close(in);
close(out);
if (rc) unlink(APP_DB_SNAPSHOT);
return rc;
}
#define APPDB_MAX 1024
@@ -60,8 +115,9 @@ json_str(const char *json, const char *key, char *out, size_t out_sz) {
out[--len] = '\0';
}
int
patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
static int
load_db(const char *uri, patchdl_appinfo_t **out, size_t *count) {
int step = SQLITE_DONE;
sqlite3 *db = NULL;
sqlite3_stmt *stmt = NULL;
patchdl_appinfo_t *arr;
@@ -73,7 +129,7 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
/* FULLMUTEX: today only the startup thread calls this, but future code
paths (a manual rescan triggered from the HTTP thread) would otherwise
race the SQLite handle. Cost is one mutex per call. */
if (sqlite3_open_v2(APP_DB_URI, &db,
if (sqlite3_open_v2(uri, &db,
SQLITE_OPEN_READONLY | SQLITE_OPEN_URI |
SQLITE_OPEN_FULLMUTEX, NULL) != SQLITE_OK) {
if (db) sqlite3_close(db);
@@ -94,7 +150,7 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
return -1;
}
while (sqlite3_step(stmt) == SQLITE_ROW && n < APPDB_MAX) {
while ((step = sqlite3_step(stmt)) == SQLITE_ROW && n < APPDB_MAX) {
patchdl_appinfo_t *e = &arr[n];
const char *tid = (const char *)sqlite3_column_text(stmt, 0);
const char *name = (const char *)sqlite3_column_text(stmt, 1);
@@ -108,13 +164,25 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
if (json) {
json_str(json, "CONTENT_VERSION", e->content_version,
sizeof(e->content_version));
/* PS4 titles carry no CONTENT_VERSION; APP_VER is theirs, and
it includes an installed patch. */
if (!e->content_version[0])
json_str(json, "APP_VER", e->content_version,
sizeof(e->content_version));
json_str(json, "VERSION_FILE_URI", e->version_file_uri,
sizeof(e->version_file_uri));
}
n++;
}
sqlite3_finalize(stmt);
/* Anything but the end of the table (or the row cap) means the read
broke off, e.g. on a copy taken while the console wrote the file:
report failure instead of a list that silently lacks titles. */
if (step != SQLITE_DONE && step != SQLITE_ROW) {
free(arr);
sqlite3_close(db);
return -1;
}
/* Icon paths in a separate query: if a firmware lacks the icon0Info
column, only the icons go missing, not the whole title list. */
@@ -141,6 +209,18 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
return 0;
}
int
patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count, int relative) {
int rc;
*out = NULL;
*count = 0;
if (!relative) return load_db(APP_DB_URI, out, count);
if (snapshot_appdb()) return -2;
rc = load_db(APP_DB_URI_SNAPSHOT, out, count);
unlink(APP_DB_SNAPSHOT);
return rc;
}
void
patchdl_appdb_free(patchdl_appinfo_t *arr) {
free(arr);
+6 -3
View File
@@ -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,
+630
View File
@@ -0,0 +1,630 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (C) 2026 Knutwurst
*
* PatchDL is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version. See the LICENSE file in the project root for details.
*/
#include "patchdl_dns.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#ifdef PATCHDL_HAVE_CURL
#include <curl/curl.h>
#include "patchdl_doh_ca.h"
#endif
#ifndef PATCHDL_DNS_SAVE_FILE
#define PATCHDL_DNS_SAVE_FILE "/data/patchdl/dns_cache.txt"
#endif
/* Worst case with everything blocked: 4 x 1.2 s UDP, 2 x 2 s TCP, 3 x 4 s
DoH; about 21 s, once per host, then the failure is cached. */
#define UDP_TIMEOUT_MS 1200
#define TCP_TIMEOUT_MS 2000
#define DOH_TIMEOUT_S 4L
#define POSITIVE_TTL_S 600 /* CDN edges rotate; ask again after 10 min */
#define NEGATIVE_TTL_S 20 /* don't run the whole chain for every piece */
#define CACHE_SLOTS 32
#define MAX_SAVED 32
static const char *PLAIN_SERVERS[] = { "1.1.1.1", "8.8.8.8", "9.9.9.9", "1.0.0.1", NULL };
static const char *TCP_SERVERS[] = { "1.1.1.1", "8.8.8.8", NULL };
/* DoH endpoints by name, pinned to an address so no DNS is needed to reach
them. The certificate is checked against the name. Only servers that
answer over HTTP/1.1: the SDK's libcurl has no HTTP/2 (Quad9 needs it). */
#ifdef PATCHDL_HAVE_CURL
static const struct { const char *name, *ip; } DOH_SERVERS[] = {
{ "cloudflare-dns.com", "1.1.1.1" },
{ "dns.google", "8.8.8.8" },
{ "cloudflare-dns.com", "1.0.0.1" },
{ NULL, NULL },
};
#endif
/* ---------- wire format ------------------------------------------------- */
enum {
ANS_OK = 0,
ANS_MALFORMED = -1,
ANS_NXDOMAIN = -2,
ANS_RCODE = -6, /* REFUSED, SERVFAIL ... */
ANS_NO_A = -3,
ANS_SINKHOLE = -4, /* only private/loopback/zero addresses */
ANS_TRUNCATED = -5,
};
static long long
now_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static uint16_t
next_query_id(void) {
static unsigned counter;
struct timeval tv;
gettimeofday(&tv, NULL);
return (uint16_t)((unsigned)tv.tv_usec * 2654435761u +
__atomic_fetch_add(&counter, 1, __ATOMIC_RELAXED) * 40503u +
(unsigned)getpid());
}
/* Query for host's A record. Returns the length, or -1. */
static int
build_query(const char *host, uint16_t id, uint8_t *buf, size_t sz) {
size_t pos = 12;
const char *p = host;
if (sz < 12 + 2 + 4) return -1;
memset(buf, 0, 12);
buf[0] = (uint8_t)(id >> 8); buf[1] = (uint8_t)id;
buf[2] = 0x01; /* RD */
buf[5] = 0x01; /* QDCOUNT = 1 */
while (*p) {
const char *dot = strchr(p, '.');
size_t len = dot ? (size_t)(dot - p) : strlen(p);
if (!len || len > 63 || pos + 1 + len + 1 + 4 > sz) return -1;
buf[pos++] = (uint8_t)len;
memcpy(buf + pos, p, len);
pos += len;
p += len;
if (*p == '.') p++;
}
buf[pos++] = 0;
buf[pos++] = 0; buf[pos++] = 1; /* QTYPE A */
buf[pos++] = 0; buf[pos++] = 1; /* QCLASS IN */
return (int)pos;
}
/* Past a (possibly compressed) name; 0 on success. */
static int
skip_name(const uint8_t *r, size_t n, size_t *pos) {
while (*pos < n) {
uint8_t len = r[*pos];
if (!len) { (*pos)++; return 0; }
if ((len & 0xC0) == 0xC0) { *pos += 2; return *pos <= n ? 0 : -1; }
if (len & 0xC0) return -1;
*pos += 1 + (size_t)len;
}
return -1;
}
/* Addresses a filtering resolver answers with instead of the real one. */
static int
is_sinkhole(const uint8_t a[4]) {
return a[0] == 0 || a[0] == 127 || a[0] == 10 || a[0] >= 224 ||
(a[0] == 172 && (a[1] & 0xF0) == 16) ||
(a[0] == 192 && a[1] == 168) ||
(a[0] == 169 && a[1] == 254) ||
(a[0] == 100 && (a[1] & 0xC0) == 64);
}
static int
parse_answer(const uint8_t *r, size_t n, uint16_t id, patchdl_dns_answer_t *out,
char *sinkhole_ip, size_t sinkhole_sz) {
size_t pos = 12;
uint16_t qd, an;
out->n = 0;
if (n < 12) return ANS_MALFORMED;
if (((r[0] << 8) | r[1]) != id || !(r[2] & 0x80)) return ANS_MALFORMED;
if (r[2] & 0x02) return ANS_TRUNCATED;
if ((r[3] & 0x0F) == 3) return ANS_NXDOMAIN;
if (r[3] & 0x0F) return ANS_RCODE;
qd = (uint16_t)((r[4] << 8) | r[5]);
an = (uint16_t)((r[6] << 8) | r[7]);
for (int q = 0; q < qd; q++) {
if (skip_name(r, n, &pos) || pos + 4 > n) return ANS_MALFORMED;
pos += 4;
}
for (int a = 0; a < an; a++) {
uint16_t type, rdlen;
if (skip_name(r, n, &pos) || pos + 10 > n) return ANS_MALFORMED;
type = (uint16_t)((r[pos] << 8) | r[pos + 1]);
rdlen = (uint16_t)((r[pos + 8] << 8) | r[pos + 9]);
pos += 10;
if (pos + rdlen > n) return ANS_MALFORMED;
if (type == 1 && rdlen == 4) {
if (is_sinkhole(r + pos)) {
if (sinkhole_ip && !sinkhole_ip[0])
snprintf(sinkhole_ip, sinkhole_sz, "%u.%u.%u.%u",
r[pos], r[pos + 1], r[pos + 2], r[pos + 3]);
} else if (out->n < PATCHDL_DNS_MAX_IPS) {
snprintf(out->ip[out->n++], sizeof out->ip[0], "%u.%u.%u.%u",
r[pos], r[pos + 1], r[pos + 2], r[pos + 3]);
}
}
pos += rdlen; /* CNAMEs and the like */
}
if (out->n) return ANS_OK;
return sinkhole_ip && sinkhole_ip[0] ? ANS_SINKHOLE : ANS_NO_A;
}
static const char *
answer_text(int rc) {
switch (rc) {
case ANS_NXDOMAIN: return "no such name";
case ANS_RCODE: return "refused";
case ANS_NO_A: return "no address";
case ANS_TRUNCATED: return "truncated";
default: return "bad reply";
}
}
/* ---------- transports -------------------------------------------------- */
static void
trail_add(char *trail, size_t sz, const char *fmt, const char *a, const char *b) {
size_t len = strlen(trail);
if (len + 3 >= sz) return;
if (len) { memcpy(trail + len, "; ", 3); len += 2; }
snprintf(trail + len, sz - len, fmt, a, b);
}
/* Shared tail of every transport: parse, log the outcome, 0 on a usable
answer. */
static int
finish(const uint8_t *resp, size_t n, uint16_t id, patchdl_dns_answer_t *out,
const char *label, char *trail, size_t trail_sz, int *truncated) {
char sink[16] = "";
int rc = parse_answer(resp, n, id, out, sink, sizeof sink);
if (truncated) *truncated = (rc == ANS_TRUNCATED);
if (rc == ANS_OK) return 0;
if (rc == ANS_SINKHOLE) trail_add(trail, trail_sz, "%s: blocked (%s)", label, sink);
else trail_add(trail, trail_sz, "%s: %s", label, answer_text(rc));
return -1;
}
static int
query_udp(const char *server, const char *host, patchdl_dns_answer_t *out,
char *trail, size_t trail_sz, int *truncated) {
uint8_t q[300], resp[1500];
struct sockaddr_in sa;
uint16_t id = next_query_id();
char label[32];
int qlen, fd;
ssize_t n = -1;
snprintf(label, sizeof label, "udp %s", server);
if ((qlen = build_query(host, id, q, sizeof q)) < 0) return -1;
if ((fd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
trail_add(trail, trail_sz, "%s: socket %s", label, strerror(errno));
return -1;
}
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons(53);
sa.sin_addr.s_addr = inet_addr(server);
/* Connected socket: replies from anyone but this server are dropped. */
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) || send(fd, q, (size_t)qlen, 0) != qlen) {
trail_add(trail, trail_sz, "%s: %s", label, strerror(errno));
close(fd);
return -1;
}
/* Skip stray datagrams (an older query's late answer) until ours or
the deadline. */
errno = EAGAIN;
for (long long deadline = now_ms() + UDP_TIMEOUT_MS, left;
(left = deadline - now_ms()) > 0;) {
struct pollfd pfd = { fd, POLLIN, 0 };
if (poll(&pfd, 1, (int)left) != 1) { errno = EAGAIN; break; }
n = recv(fd, resp, sizeof resp, 0);
if (n < 0) break;
if (n >= 2 && ((resp[0] << 8) | resp[1]) == id) {
close(fd);
if (!finish(resp, (size_t)n, id, out, label, trail, trail_sz, truncated)) {
snprintf(out->source, sizeof out->source, "%s", label);
return 0;
}
return -1;
}
}
trail_add(trail, trail_sz, "%s: %s", label, errno == EAGAIN || errno == EWOULDBLOCK
? "no answer" : strerror(errno));
close(fd);
return -1;
}
/* Send or receive exactly len bytes on a non-blocking socket before the
deadline. A server trickling bytes can't stretch the exchange. */
static int
io_until(int fd, uint8_t *buf, size_t len, int sending, long long deadline) {
size_t done = 0;
while (done < len) {
struct pollfd pfd = { fd, (short)(sending ? POLLOUT : POLLIN), 0 };
long long left = deadline - now_ms();
ssize_t k;
if (left <= 0 || poll(&pfd, 1, (int)left) != 1) return -1;
k = sending ? send(fd, buf + done, len - done, 0)
: recv(fd, buf + done, len - done, 0);
if (k < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) continue;
if (k <= 0) return -1;
done += (size_t)k;
}
return 0;
}
/* One DNS exchange over TCP, connect included, within TCP_TIMEOUT_MS. */
static int
query_tcp(const char *server, const char *host, patchdl_dns_answer_t *out,
char *trail, size_t trail_sz) {
uint8_t q[302], resp[4096], lenb[2];
struct sockaddr_in sa;
long long deadline = now_ms() + TCP_TIMEOUT_MS;
uint16_t id = next_query_id();
char label[32];
int qlen, fd, err = 0;
socklen_t elen = sizeof err;
size_t rlen;
snprintf(label, sizeof label, "tcp %s", server);
if ((qlen = build_query(host, id, q + 2, sizeof q - 2)) < 0) return -1;
q[0] = (uint8_t)(qlen >> 8); q[1] = (uint8_t)qlen;
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
trail_add(trail, trail_sz, "%s: socket %s", label, strerror(errno));
return -1;
}
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons(53);
sa.sin_addr.s_addr = inet_addr(server);
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) && errno != EINPROGRESS) {
trail_add(trail, trail_sz, "%s: %s", label, strerror(errno));
close(fd);
return -1;
}
{
struct pollfd pfd = { fd, POLLOUT, 0 };
if (poll(&pfd, 1, TCP_TIMEOUT_MS) != 1 ||
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen) || err) {
trail_add(trail, trail_sz, "%s: %s", label, err ? strerror(err) : "no connection");
close(fd);
return -1;
}
}
if (io_until(fd, q, (size_t)qlen + 2, 1, deadline) || io_until(fd, lenb, 2, 0, deadline) ||
(rlen = (size_t)((lenb[0] << 8) | lenb[1])) > sizeof resp ||
io_until(fd, resp, rlen, 0, deadline)) {
trail_add(trail, trail_sz, "%s: %s", label, "no answer");
close(fd);
return -1;
}
close(fd);
if (finish(resp, rlen, id, out, label, trail, trail_sz, NULL)) return -1;
snprintf(out->source, sizeof out->source, "%s", label);
return 0;
}
#ifdef PATCHDL_HAVE_CURL
typedef struct { uint8_t data[8192]; size_t len; } doh_buf_t;
static size_t
doh_write(void *p, size_t size, size_t nmemb, void *ud) {
doh_buf_t *b = ud;
size_t total = size * nmemb;
if (total > sizeof b->data - b->len) return 0;
memcpy(b->data + b->len, p, total);
b->len += total;
return total;
}
static int
query_doh(const char *name, const char *ip, const char *host,
patchdl_dns_answer_t *out, char *trail, size_t trail_sz) {
uint8_t q[300];
char url[96], pin[96], label[48];
doh_buf_t *buf;
CURL *curl;
CURLcode res;
long http = 0;
struct curl_slist *hdr = NULL, *rl = NULL;
struct curl_blob ca;
uint16_t id = next_query_id();
int qlen, rc = -1;
snprintf(label, sizeof label, "doh %s", name);
if ((qlen = build_query(host, id, q, sizeof q)) < 0) return -1;
if (!(buf = calloc(1, sizeof *buf))) return -1;
if (!(curl = curl_easy_init())) { free(buf); return -1; }
snprintf(url, sizeof url, "https://%s/dns-query", name);
snprintf(pin, sizeof pin, "%s:443:%s", name, ip);
rl = curl_slist_append(NULL, pin);
hdr = curl_slist_append(NULL, "Content-Type: application/dns-message");
hdr = curl_slist_append(hdr, "Accept: application/dns-message");
ca.data = (void *)PATCHDL_DOH_ROOTS_PEM;
ca.len = strlen(PATCHDL_DOH_ROOTS_PEM);
ca.flags = CURL_BLOB_COPY;
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdr);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, (const char *)q);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)qlen);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, doh_write);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, buf);
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, (long)CURL_HTTP_VERSION_1_1);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 2L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, DOH_TIMEOUT_S);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
curl_slist_free_all(hdr);
if (res != CURLE_OK) {
trail_add(trail, trail_sz, "%s: %s", label,
res == CURLE_PEER_FAILED_VERIFICATION ? "certificate rejected"
: curl_easy_strerror(res));
} else if (http != 200) {
char code[16];
snprintf(code, sizeof code, "http %ld", http);
trail_add(trail, trail_sz, "%s: %s", label, code);
} else if (!finish(buf->data, buf->len, id, out, label, trail, trail_sz, NULL)) {
snprintf(out->source, sizeof out->source, "%s", label);
rc = 0;
}
free(buf);
return rc;
}
#endif
/* ---------- last-known-good addresses on disk --------------------------- */
static pthread_mutex_t g_save_mtx = PTHREAD_MUTEX_INITIALIZER;
/* One line per host: "host ip,ip,...". */
static int
saved_get(const char *host, patchdl_dns_answer_t *out) {
char line[512];
FILE *f;
int found = -1;
pthread_mutex_lock(&g_save_mtx);
if ((f = fopen(PATCHDL_DNS_SAVE_FILE, "r"))) {
while (found && fgets(line, sizeof line, f)) {
char *sp = strchr(line, ' ');
if (!sp) continue;
*sp = '\0';
if (strcmp(line, host)) continue;
out->n = 0;
for (char *save = NULL, *tok = strtok_r(sp + 1, ",\r\n", &save);
tok && out->n < PATCHDL_DNS_MAX_IPS; tok = strtok_r(NULL, ",\r\n", &save)) {
struct in_addr a;
if (inet_pton(AF_INET, tok, &a) == 1 && !is_sinkhole((uint8_t *)&a))
snprintf(out->ip[out->n++], sizeof out->ip[0], "%s", tok);
}
if (out->n) found = 0;
}
fclose(f);
}
pthread_mutex_unlock(&g_save_mtx);
return found;
}
static void
saved_put(const char *host, const patchdl_dns_answer_t *a) {
char lines[MAX_SAVED][512], line[512], tmp[sizeof PATCHDL_DNS_SAVE_FILE + 8];
int n = 0, fd;
FILE *f;
pthread_mutex_lock(&g_save_mtx);
/* This host first, then the others it already knew. */
snprintf(lines[n], sizeof lines[0], "%s ", host);
for (int i = 0; i < a->n; i++) {
size_t len = strlen(lines[n]);
snprintf(lines[n] + len, sizeof lines[0] - len, "%s%s", i ? "," : "", a->ip[i]);
}
n++;
if ((f = fopen(PATCHDL_DNS_SAVE_FILE, "r"))) {
size_t hl = strlen(host);
while (n < MAX_SAVED && fgets(line, sizeof line, f)) {
line[strcspn(line, "\r\n")] = '\0';
if (!line[0] || (!strncmp(line, host, hl) && line[hl] == ' ')) continue;
snprintf(lines[n++], sizeof lines[0], "%s", line);
}
fclose(f);
}
snprintf(tmp, sizeof tmp, "%s.tmp", PATCHDL_DNS_SAVE_FILE);
fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd >= 0 && (f = fdopen(fd, "w"))) {
for (int i = 0; i < n; i++) fprintf(f, "%s\n", lines[i]);
if (!fclose(f)) rename(tmp, PATCHDL_DNS_SAVE_FILE);
else unlink(tmp);
} else if (fd >= 0) {
close(fd);
unlink(tmp);
}
pthread_mutex_unlock(&g_save_mtx);
}
/* ---------- the chain --------------------------------------------------- */
static int
resolve_fresh(const char *host, patchdl_dns_answer_t *out) {
char *trail = out->trail;
size_t tsz = sizeof out->trail;
int truncated = 0;
trail[0] = '\0';
for (int i = 0; PLAIN_SERVERS[i]; i++) {
truncated = 0;
if (!query_udp(PLAIN_SERVERS[i], host, out, trail, tsz, &truncated)) return 0;
if (truncated && !query_tcp(PLAIN_SERVERS[i], host, out, trail, tsz)) return 0;
}
for (int i = 0; TCP_SERVERS[i]; i++)
if (!query_tcp(TCP_SERVERS[i], host, out, trail, tsz)) return 0;
#ifdef PATCHDL_HAVE_CURL
for (int i = 0; DOH_SERVERS[i].name; i++)
if (!query_doh(DOH_SERVERS[i].name, DOH_SERVERS[i].ip, host, out, trail, tsz)) return 0;
#endif
if (!saved_get(host, out)) {
snprintf(out->source, sizeof out->source, "saved");
trail_add(trail, tsz, "%s%s", "using saved addresses", "");
return 0;
}
out->n = 0;
out->source[0] = '\0';
return -1;
}
/* ---------- cache ------------------------------------------------------- */
typedef struct {
char host[256];
patchdl_dns_answer_t ans;
int ok;
int inflight;
time_t expires;
time_t used;
} cache_slot_t;
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER;
static cache_slot_t g_cache[CACHE_SLOTS];
static time_t
now_s(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec;
}
static cache_slot_t *
slot_find(const char *host) {
for (int i = 0; i < CACHE_SLOTS; i++)
if (g_cache[i].host[0] && !strcmp(g_cache[i].host, host)) return &g_cache[i];
return NULL;
}
/* A free slot, else the least recently used one nobody is resolving. */
static cache_slot_t *
slot_claim(const char *host) {
cache_slot_t *pick = NULL;
for (int i = 0; i < CACHE_SLOTS; i++) {
cache_slot_t *s = &g_cache[i];
if (s->inflight) continue;
if (!s->host[0]) { pick = s; break; }
if (!pick || s->used < pick->used) pick = s;
}
if (pick) {
memset(pick, 0, sizeof *pick);
snprintf(pick->host, sizeof pick->host, "%s", host);
}
return pick;
}
int
patchdl_dns_lookup(const char *host, patchdl_dns_answer_t *out) {
patchdl_dns_answer_t fresh;
cache_slot_t *s;
int rc;
memset(out, 0, sizeof *out);
if (!host || !host[0] || strlen(host) >= sizeof g_cache[0].host) return -1;
pthread_mutex_lock(&g_mtx);
/* One lookup per host at a time: the download workers all want the same
CDN host at once, and the others wait for its answer. */
for (;;) {
s = slot_find(host);
if (!s || !s->inflight) break;
pthread_cond_wait(&g_cv, &g_mtx);
}
if (s && s->expires > now_s()) {
s->used = now_s();
*out = s->ans;
rc = s->ok ? 0 : -1;
pthread_mutex_unlock(&g_mtx);
return rc;
}
if (!s) s = slot_claim(host);
if (s) s->inflight = 1;
pthread_mutex_unlock(&g_mtx);
memset(&fresh, 0, sizeof fresh);
rc = resolve_fresh(host, &fresh);
if (!rc && strcmp(fresh.source, "saved")) saved_put(host, &fresh);
pthread_mutex_lock(&g_mtx);
if (s) {
s->ans = fresh;
s->ok = !rc;
s->expires = now_s() + (rc ? NEGATIVE_TTL_S : POSITIVE_TTL_S);
s->used = now_s();
s->inflight = 0;
pthread_cond_broadcast(&g_cv);
}
pthread_mutex_unlock(&g_mtx);
*out = fresh;
return rc;
}
void
patchdl_dns_forget(const char *host) {
cache_slot_t *s;
pthread_mutex_lock(&g_mtx);
if ((s = slot_find(host)) && !s->inflight) s->expires = 0;
pthread_mutex_unlock(&g_mtx);
}
int
patchdl_dns_resolve_entry(const patchdl_dns_answer_t *a, const char *host,
int port, char *out, size_t sz) {
size_t len;
int k = snprintf(out, sz, "%s:%d:", host, port);
if (k < 0 || (size_t)k >= sz) return -1;
for (int i = 0; i < a->n; i++) {
len = strlen(out);
k = snprintf(out + len, sz - len, "%s%s", i ? "," : "", a->ip[i]);
if (k < 0 || (size_t)k >= sz - len) return -1;
}
return 0;
}
+52
View File
@@ -0,0 +1,52 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (C) 2026 Knutwurst
*
* PatchDL is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version. See the LICENSE file in the project root for details.
*/
#pragma once
#include <stddef.h>
/*
* PatchDL's own resolver. The console's DNS is often set up to block Sony's
* hosts (that is how people keep the system from updating), so PatchDL never
* asks it. A lookup tries, in this order, until one gives a usable answer:
*
* 1. plain DNS over UDP to 1.1.1.1, 8.8.8.8, 9.9.9.9, 1.0.0.1
* 2. DNS over TCP to 1.1.1.1 and 8.8.8.8 (UDP/53 blocked or truncated)
* 3. DNS over HTTPS to Cloudflare and Google (port 53 blocked or
* redirected to a filtering resolver)
* 4. the addresses the last successful lookup saved to disk
*
* Answers in private, loopback or "this network" ranges count as blocked:
* that is what filtering resolvers send back for Sony's hosts.
*/
#define PATCHDL_DNS_MAX_IPS 8
typedef struct {
int n;
char ip[PATCHDL_DNS_MAX_IPS][16];
char source[40]; /* e.g. "udp 1.1.1.1", "doh dns.google", "saved" */
char trail[384]; /* what each step answered on the last fresh lookup */
} patchdl_dns_answer_t;
/* Resolve host (IPv4). Answers are cached for a few minutes and failures for
a short while. 0 on success, -1 if every source failed. */
int patchdl_dns_lookup(const char *host, patchdl_dns_answer_t *out);
/* Drop host's cached answer, e.g. after none of its addresses accepted a
connection, so the next lookup asks again. */
void patchdl_dns_forget(const char *host);
/* Room for any entry: a 255-character host, port and every address. */
#define PATCHDL_DNS_ENTRY_MAX (256 + 8 + PATCHDL_DNS_MAX_IPS * 16)
/* "host:port:ip1,ip2,..." for CURLOPT_RESOLVE. curl moves on to the next
address when one doesn't connect. -1 if it doesn't fit in sz. */
int patchdl_dns_resolve_entry(const patchdl_dns_answer_t *a, const char *host,
int port, char *out, size_t sz);
+142
View File
@@ -0,0 +1,142 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (C) 2026 Knutwurst
*
* PatchDL is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version. See the LICENSE file in the project root for details.
*/
#pragma once
/*
* Public root CAs of the DNS-over-HTTPS resolvers PatchDL falls back to when
* plain DNS is blocked or answers with a sinkhole address. Only the DoH
* requests use these; Sony's hosts stay pinned to the SCEI root
* (patchdl_ca.h). Public, non-secret certificates.
*/
static const char PATCHDL_DOH_ROOTS_PEM[] =
/* SSL.com Root Certification Authority ECC (Cloudflare 1.1.1.1 / 1.0.0.1) */
"-----BEGIN CERTIFICATE-----\n"
"MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMC\n"
"VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T\n"
"U0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0\n"
"aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNDAzWhcNNDEwMjEyMTgxNDAz\n"
"WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0\n"
"b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBS\n"
"b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB\n"
"BAAiA2IABEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI\n"
"7Z4INcgn64mMU1jrYor+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPg\n"
"CemB+vNH06NjMGEwHQYDVR0OBBYEFILRhXMw5zUE044CkvvlpNHEIejNMA8GA1Ud\n"
"EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTTjgKS++Wk0cQh6M0wDgYD\n"
"VR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCWe+0F+S8T\n"
"kdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+\n"
"gA0z5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl\n"
"-----END CERTIFICATE-----\n"
/* GTS Root R1 (Google 8.8.8.8) */
"-----BEGIN CERTIFICATE-----\n"
"MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw\n"
"CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"
"MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"
"MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"
"Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA\n"
"A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo\n"
"27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w\n"
"Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw\n"
"TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl\n"
"qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH\n"
"szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8\n"
"Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk\n"
"MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92\n"
"wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p\n"
"aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN\n"
"VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID\n"
"AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n"
"FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb\n"
"C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n"
"QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy\n"
"h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4\n"
"7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J\n"
"ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef\n"
"MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/\n"
"Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT\n"
"6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ\n"
"0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm\n"
"2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb\n"
"bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n"
"-----END CERTIFICATE-----\n"
/* GTS Root R4 (Google 8.8.8.8) */
"-----BEGIN CERTIFICATE-----\n"
"MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n"
"VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n"
"A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n"
"WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n"
"IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n"
"AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n"
"QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n"
"HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n"
"BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n"
"9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n"
"p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n"
"-----END CERTIFICATE-----\n"
/* GlobalSign Root CA (cross-signs the GTS roots) */
"-----BEGIN CERTIFICATE-----\n"
"MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n"
"A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n"
"b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n"
"MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n"
"YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n"
"aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n"
"jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n"
"xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n"
"1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n"
"snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n"
"U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n"
"9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n"
"BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n"
"AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n"
"yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n"
"38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n"
"AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n"
"DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n"
"HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n"
"-----END CERTIFICATE-----\n"
/* DigiCert Global Root G2 */
"-----BEGIN CERTIFICATE-----\n"
"MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n"
"MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"
"d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n"
"MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n"
"MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n"
"b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n"
"9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n"
"2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n"
"1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n"
"q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n"
"tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n"
"vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n"
"BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n"
"5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n"
"1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n"
"NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n"
"Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n"
"8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n"
"pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n"
"MrY=\n"
"-----END CERTIFICATE-----\n"
/* DigiCert Global Root G3 */
"-----BEGIN CERTIFICATE-----\n"
"MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQsw\n"
"CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu\n"
"ZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAe\n"
"Fw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUw\n"
"EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x\n"
"IDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0CAQYF\n"
"K4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FG\n"
"fp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPO\n"
"Z9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd\n"
"BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIx\n"
"AK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/\n"
"oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8\n"
"sycX\n"
"-----END CERTIFICATE-----\n";
+38 -54
View File
@@ -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;
+224 -211
View File
@@ -8,6 +8,7 @@
*/
#include "patchdl_net.h"
#include "patchdl_dns.h"
#include <arpa/inet.h>
#include <errno.h>
@@ -29,10 +30,6 @@
#include "patchdl_ca.h"
#endif
#define DNS_SERVER "1.1.1.1"
#define DNS_PORT 53
#define DNS_TIMEOUT_MS 3000
/* Manifest sanity caps — reject anything bigger than a real PS5 patch. The
largest title we've seen tops out around 70 GB / 18 pieces. */
#define PATCHDL_MAX_PIECES 4096
@@ -67,9 +64,16 @@ static const char *ALLOWED_HOSTS[] = {
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net",
"gs-sec.ww.np.dl.playstation.net", /* PS4 update lists */
NULL,
};
/* PS4 package manifests are published on gs2 over plain http only (https
there is a CloudFront edge without Sony's certificate). The console's own
installer checks the package signature, so a forged manifest can't get an
unsigned package installed. Everything else stays https-only. */
#define PLAIN_HTTP_HOST "gs2.ww.prod.dl.playstation.net"
static int
host_allowed(const char *host) {
size_t hlen = strlen(host);
@@ -88,6 +92,70 @@ host_allowed(const char *host) {
}
/* Extract hostname from https://host/path or http://host/path */
/* Why the calling thread's last patchdl_http_get failed (or what status it
got), for error reports. */
static _Thread_local struct {
char step[24];
int curl_rc;
long http_code;
} g_last;
static void
set_last(const char *step, int curl_rc, long http_code) {
snprintf(g_last.step, sizeof g_last.step, "%s", step);
g_last.curl_rc = curl_rc;
g_last.http_code = http_code;
}
/* CURLOPT_RESOLVE entries for host on 443 and 80 from PatchDL's own
resolver (patchdl_dns.c), with every address it found. NULL when the host
can't be resolved. */
static struct curl_slist *
resolve_list_for(const char *host) {
patchdl_dns_answer_t a;
char entry[PATCHDL_DNS_ENTRY_MAX];
struct curl_slist *rl, *both;
if (patchdl_dns_lookup(host, &a) ||
patchdl_dns_resolve_entry(&a, host, 443, entry, sizeof entry) ||
!(rl = curl_slist_append(NULL, entry)))
return NULL;
if (patchdl_dns_resolve_entry(&a, host, 80, entry, sizeof entry) ||
!(both = curl_slist_append(rl, entry))) {
curl_slist_free_all(rl);
return NULL;
}
return both;
}
/* None of the host's addresses took the connection (refused, or silently
dropped until the timeout): the next request asks DNS again instead of
reusing the cached answer. A timeout on an established transfer says
nothing about the addresses. Call before curl_easy_cleanup. */
static void
note_curl_result(CURL *curl, const char *host, CURLcode res) {
curl_off_t connect_us = 0;
if (res == CURLE_OPERATION_TIMEDOUT)
curl_easy_getinfo(curl, CURLINFO_CONNECT_TIME_T, &connect_us);
if (res == CURLE_COULDNT_CONNECT || res == CURLE_COULDNT_RESOLVE_HOST ||
(res == CURLE_OPERATION_TIMEDOUT && connect_us == 0))
patchdl_dns_forget(host);
}
void
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
if (step && step_sz) snprintf(step, step_sz, "%s", g_last.step);
if (curl_rc) *curl_rc = g_last.curl_rc;
if (http_code) *http_code = g_last.http_code;
}
/* Manifest piece hashes: PS5 manifests carry SHA-256 (64 hex digits), PS4
manifests SHA-1 (40). */
static const EVP_MD *
digest_for(const char *hex) {
return (hex && strlen(hex) == 40) ? EVP_sha1() : EVP_sha256();
}
static int
url_host(const char *url, char *out, size_t out_sz) {
const char *p = strstr(url, "://");
@@ -104,164 +172,6 @@ url_host(const char *url, char *out, size_t out_sz) {
return 0;
}
/* ---------- raw UDP DNS resolver ---------------------------------------- */
/* Diagnostics from the last dns_resolve() call (read by patchdl_net_diag).
step: 0=ok 1=socket 2=sendto 3=recv<12 4=no-answer. */
static int g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount;
static int
dns_encode_name(const char *host, uint8_t *buf, size_t bufsz) {
size_t pos = 0;
const char *p = host;
while (*p) {
const char *dot = strchr(p, '.');
size_t lablen = dot ? (size_t)(dot - p) : strlen(p);
if (lablen > 63 || pos + 1 + lablen + 1 >= bufsz) return -1;
buf[pos++] = (uint8_t)lablen;
memcpy(buf + pos, p, lablen);
pos += lablen;
p += lablen;
if (*p == '.') p++;
}
buf[pos++] = 0;
return (int)pos;
}
static int
dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
int fd;
struct sockaddr_in srv;
uint8_t pkt[512], resp[512];
ssize_t n;
int name_len;
size_t pos;
struct timeval tv;
g_dns_step = 0; g_dns_errno = 0; g_dns_n = 0; g_dns_ancount = 0;
fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { g_dns_step = 1; g_dns_errno = errno; return -1; }
tv.tv_sec = DNS_TIMEOUT_MS / 1000;
tv.tv_usec = (DNS_TIMEOUT_MS % 1000) * 1000;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
memset(&srv, 0, sizeof(srv));
srv.sin_family = AF_INET;
srv.sin_port = htons(DNS_PORT);
srv.sin_addr.s_addr = inet_addr(DNS_SERVER);
memset(pkt, 0, sizeof(pkt));
pkt[0] = 0xDE; pkt[1] = 0xAD; /* ID */
pkt[2] = 0x01; pkt[3] = 0x00; /* standard query, RD */
pkt[4] = 0x00; pkt[5] = 0x01; /* QDCOUNT = 1 */
pos = 12;
name_len = dns_encode_name(host, pkt + pos, sizeof(pkt) - pos - 4);
if (name_len < 0) { close(fd); return -1; }
pos += (size_t)name_len;
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QTYPE = A */
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QCLASS = IN */
if (sendto(fd, pkt, pos, 0,
(struct sockaddr *)&srv, sizeof(srv)) < 0) {
g_dns_step = 2; g_dns_errno = errno; close(fd); return -1;
}
n = recv(fd, resp, sizeof(resp), 0);
g_dns_n = (int)n;
if (n < 0) g_dns_errno = errno;
close(fd);
if (n < 12) { g_dns_step = 3; return -1; }
uint16_t ancount = (uint16_t)((resp[6] << 8) | resp[7]);
g_dns_ancount = ancount;
if (!ancount) { g_dns_step = 4; return -1; }
/* Skip header and question section */
pos = 12;
while (pos < (size_t)n) {
if (!resp[pos]) { pos++; break; }
if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; break; }
/* Bounds-check the label length BEFORE the increment — a malformed
response with a 0xFF label byte near the end would otherwise walk
past `n`. */
if (pos + 1 + (size_t)resp[pos] >= (size_t)n) { g_dns_step = 5; return -1; }
pos += 1 + resp[pos];
}
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
pos += 4; /* QTYPE + QCLASS */
/* Walk every answer RR; the CDN returns a CNAME chain, so skip
non-A records and return the first A (type 1, rdlen 4). */
for (int a = 0; a < ancount && pos < (size_t)n; a++) {
if ((resp[pos] & 0xC0) == 0xC0) {
pos += 2;
} else {
while (pos < (size_t)n && resp[pos]) {
if (pos + 1 + (size_t)resp[pos] >= (size_t)n) break;
pos += 1 + resp[pos];
}
pos++;
}
if (pos + 10 > (size_t)n) break;
uint16_t type = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
pos += 6; /* CLASS + TTL */
uint16_t rdlen = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
if (type == 1 && rdlen == 4 && pos + 4 <= (size_t)n) {
snprintf(ip_out, ip_sz, "%u.%u.%u.%u",
resp[pos], resp[pos + 1], resp[pos + 2], resp[pos + 3]);
return 0;
}
pos += rdlen; /* skip this RR's data (e.g. a CNAME target) */
}
g_dns_step = 6; /* no A record among the answers */
return -1;
}
/* Cache resolved IPs by host. The batch fetch hits the same CDN host dozens
of times; a burst of identical raw-UDP queries gets dropped/rate-limited,
so resolve once (with retries) and reuse. */
static pthread_mutex_t dns_cache_mtx = PTHREAD_MUTEX_INITIALIZER;
static struct { char host[256]; char ip[INET_ADDRSTRLEN]; } dns_cache[64];
static int dns_cache_n;
static int
dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
int rc = -1;
pthread_mutex_lock(&dns_cache_mtx);
for (int i = 0; i < dns_cache_n; i++) {
if (!strcmp(dns_cache[i].host, host)) {
strncpy(ip_out, dns_cache[i].ip, ip_sz - 1);
ip_out[ip_sz - 1] = '\0';
pthread_mutex_unlock(&dns_cache_mtx);
return 0;
}
}
/* Cold miss: resolve while HOLDING the cache lock (single-flight). N pool
workers needing the same CDN host would otherwise each blast Sony's
rate-limited resolver; this way one resolves and the rest get the cache.
It also serializes dns_resolve so its diagnostic globals can't be raced.
(This is the DNS lock, independent of the pool lock.) */
for (int attempt = 0; attempt < 4 && rc; attempt++)
rc = dns_resolve(host, ip_out, ip_sz);
if (!rc && dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
strncpy(dns_cache[dns_cache_n].host, host,
sizeof(dns_cache[0].host) - 1);
strncpy(dns_cache[dns_cache_n].ip, ip_out,
sizeof(dns_cache[0].ip) - 1);
dns_cache_n++;
}
pthread_mutex_unlock(&dns_cache_mtx);
return rc;
}
/* ---------- HTTP GET via curl ------------------------------------------- */
static size_t
@@ -286,22 +196,18 @@ int
patchdl_http_get(const char *url, patchdl_buf_t *out) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN];
char resolve_str[512];
char host[256];
struct curl_slist *resolve_list = NULL;
struct curl_blob ca_blob;
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
int plain = !strncmp(url, "http://", 7);
long http_code = 0;
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
resolve_list = curl_slist_append(NULL, resolve_str);
/* Also cover port 80 in case a redirect lands there */
char resolve_80[512];
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
resolve_list = curl_slist_append(resolve_list, resolve_80);
set_last("", 0, 0);
if (url_host(url, host, sizeof(host))) { set_last("bad_url", 0, 0); return -1; }
if (!host_allowed(host)) { set_last("host_not_allowed", 0, 0); return -1; }
if (plain && strcasecmp(host, PLAIN_HTTP_HOST)) { set_last("http_not_allowed", 0, 0); return -1; }
if (!(resolve_list = resolve_list_for(host))) { set_last("dns", 0, 0); return -1; }
{
size_t caller_max = out->max;
@@ -334,16 +240,95 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
/* Redirects must stay on HTTPS — host_allowed gates the initial URL, but
once libcurl follows a 302 we want the protocol pinned too. The _STR
variants replaced the bitfield options in libcurl 7.85. */
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
variants replaced the bitfield options in libcurl 7.85. Plain http only
for the PS4 manifest host (see PLAIN_HTTP_HOST). */
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, plain ? "http" : "https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
note_curl_result(curl, host, res);
curl_easy_cleanup(curl);
curl_slist_free_all(resolve_list);
if (res != CURLE_OK) {
set_last(res == CURLE_COULDNT_CONNECT ? "connect" :
res == CURLE_PEER_FAILED_VERIFICATION ||
res == CURLE_SSL_CONNECT_ERROR ? "tls"
: res == CURLE_UNSUPPORTED_PROTOCOL ? "redirect"
: res == CURLE_OPERATION_TIMEDOUT ? "timeout" : "network",
(int)res, http_code);
free(out->data);
out->data = NULL;
out->size = 0;
return -1;
}
set_last(http_code >= 400 ? "http_status" : "", 0, http_code);
return 0;
}
/* ---------- 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];
struct curl_slist *resolve_list = NULL;
struct curl_slist *headers = NULL;
if (url_host(url, host, sizeof(host))) return -1;
if (!(resolve_list = resolve_list_for(host))) return -1;
{
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);
note_curl_result(curl, host, res);
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;
@@ -351,6 +336,18 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
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
@@ -416,7 +413,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
long long range_start) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
char host[256];
char range_hdr[48];
long http_code = 0;
struct curl_slist *rl = NULL;
@@ -428,19 +425,14 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
if (bytes_out) *bytes_out = 0;
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
if (!(rl = resolve_list_for(host))) return -1;
if (verify) {
sink.md = EVP_MD_CTX_new();
if (sink.md)
EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_hex), NULL);
}
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs443);
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
rl = curl_slist_append(rl, rs80);
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
@@ -487,6 +479,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
note_curl_result(curl, host, res);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
@@ -841,7 +834,7 @@ patchdl_http_download_piece(const char *url, int fd,
CURL *curl;
CURLcode res;
long http_code = 0;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
char host[256];
struct curl_slist *rl = NULL;
struct curl_blob ca_blob;
piece_sink_t sink;
@@ -852,7 +845,7 @@ patchdl_http_download_piece(const char *url, int fd,
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
if (!(rl = resolve_list_for(host))) return -1;
memset(&sink, 0, sizeof(sink));
sink.fd = fd;
@@ -860,14 +853,9 @@ patchdl_http_download_piece(const char *url, int fd,
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
if (verify) {
sink.md = EVP_MD_CTX_new();
if (sink.md) EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
if (sink.md) EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_or_null), NULL);
}
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs443);
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
rl = curl_slist_append(rl, rs80);
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
@@ -905,6 +893,7 @@ patchdl_http_download_piece(const char *url, int fd,
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
note_curl_result(curl, host, res);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
@@ -934,10 +923,12 @@ patchdl_http_download_piece(const char *url, int fd,
return 0;
}
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex (>=65
bytes). Uses pread so it doesn't disturb the fd offset. 0 on success. */
/* Read-only: hash a [offset, offset+size) region of fd into out_hex (>=65
bytes), with the algorithm `like_hex` is written in (see digest_for). Uses
pread so it doesn't disturb the fd offset. 0 on success. */
int
patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex) {
patchdl_hash_fd_region(int fd, long long offset, long long size,
const char *like_hex, char *out_hex) {
EVP_MD_CTX *md;
unsigned char *buf;
long long pos = offset, remaining = size;
@@ -949,7 +940,7 @@ patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex
if (!md) return -1;
buf = malloc(CHUNK);
if (!buf) { EVP_MD_CTX_free(md); return -1; }
EVP_DigestInit_ex(md, EVP_sha256(), NULL);
EVP_DigestInit_ex(md, digest_for(like_hex), NULL);
while (remaining > 0) {
size_t want = remaining > (long long)CHUNK ? CHUNK : (size_t)remaining;
ssize_t got = pread(fd, buf, want, (off_t)pos);
@@ -990,10 +981,18 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
memset(&buf, 0, sizeof(buf));
buf.max = PATCHDL_BUF_MAX_MANIFEST;
if (patchdl_http_get(manifest_url, &buf)) return -1;
if (!buf.data || !buf.size) { free(buf.data); return -1; }
if (!buf.data || !buf.size) {
free(buf.data);
if (!g_last.step[0]) set_last("empty_reply", 0, g_last.http_code);
return -1;
}
pieces = strstr(buf.data, "\"pieces\"");
if (!pieces || !(pieces = strchr(pieces, '['))) { free(buf.data); return -1; }
if (!pieces || !(pieces = strchr(pieces, '['))) {
free(buf.data);
if (!g_last.step[0]) set_last("bad_manifest", 0, g_last.http_code);
return -1;
}
pieces_end = strchr(pieces, ']');
for (p = pieces; (p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end); p += 5)
@@ -1048,6 +1047,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 */
@@ -1056,26 +1057,31 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
void
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
int hostrc, allowrc, dnsrc;
long http_code = 0;
CURLcode res = CURLE_OK;
int did_curl = 0;
char host[256] = {0};
int hostrc, allowrc, dnsrc = -1;
long http_code = 0;
CURLcode res = CURLE_OK;
int did_curl = 0;
patchdl_dns_answer_t a;
memset(&a, 0, sizeof a);
hostrc = url_host(url, host, sizeof(host));
allowrc = (hostrc == 0) ? host_allowed(host) : 0;
dnsrc = (hostrc == 0) ? dns_resolve(host, ip, sizeof(ip)) : -1;
if (hostrc == 0) {
patchdl_dns_forget(host);
dnsrc = patchdl_dns_lookup(host, &a);
}
if (hostrc == 0 && allowrc && dnsrc == 0) {
patchdl_buf_t buf;
memset(&buf, 0, sizeof(buf));
CURL *curl = curl_easy_init();
if (curl) {
char rs[512];
char rs[PATCHDL_DNS_ENTRY_MAX];
struct curl_slist *rl;
struct curl_blob ca_blob;
snprintf(rs, sizeof(rs), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs);
rl = patchdl_dns_resolve_entry(&a, host, 443, rs, sizeof rs)
? NULL : curl_slist_append(NULL, rs);
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
@@ -1100,13 +1106,13 @@ patchdl_net_diag(const char *url, char *out_json, size_t sz) {
}
}
/* host, source and trail are plain ASCII from our own resolver; quotes
can't occur in them. */
snprintf(out_json, sz,
"{\"host\":\"%s\",\"host_ok\":%d,\"allowed\":%d,"
"\"dns_rc\":%d,\"ip\":\"%s\","
"\"dns_step\":%d,\"dns_errno\":%d,\"dns_recv\":%d,\"dns_ancount\":%d,"
"\"dns_rc\":%d,\"ip\":\"%s\",\"dns_source\":\"%s\",\"dns_trail\":\"%s\","
"\"curl_ran\":%d,\"curl_rc\":%d,\"curl_err\":\"%s\",\"http_code\":%ld}",
host, hostrc == 0, allowrc, dnsrc, ip,
g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount,
host, hostrc == 0, allowrc, dnsrc, a.n ? a.ip[0] : "", a.source, a.trail,
did_curl, (int)res,
did_curl ? curl_easy_strerror(res) : "n/a", http_code);
}
@@ -1120,6 +1126,13 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
return -1; /* curl not available in this build; set CURL_DIR= to enable */
}
void
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
if (step && step_sz) snprintf(step, step_sz, "no_curl");
if (curl_rc) *curl_rc = 0;
if (http_code) *http_code = 0;
}
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
+22 -5
View File
@@ -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. */
@@ -36,21 +43,30 @@ typedef struct {
char *url;
long long offset; /* byte offset of this piece in the assembled file */
long long size; /* exact length of this piece */
char hash[80]; /* manifest SHA-256 hex, or "" */
char hash[80]; /* manifest hash hex (SHA-256, or SHA-1 for PS4), or "" */
} patchdl_piece_t;
typedef struct {
patchdl_piece_t *pieces;
int count;
long long total; /* assembled file size = sum of piece sizes */
char crc_url[768]; /* playgoChunkCrcUrl, or "" */
} patchdl_manifest_t;
/* Fetch + parse a Sony JSON manifest into a validated, contiguously-tiled
piece list. Returns 0 on success (caller frees with patchdl_manifest_free),
-1 on fetch/parse/tiling failure. */
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
/* Why this thread's last patchdl_http_get / patchdl_fetch_manifest failed:
step is "" on success, else e.g. "dns", "connect", "tls", "network",
"timeout", "redirect", "http_status", "bad_manifest". curl_rc and
http_code as seen. */
void patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code);
void patchdl_manifest_free(patchdl_manifest_t *m);
/* 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). */
@@ -70,10 +86,11 @@ int patchdl_http_download_piece(const char *url, int fd,
patchdl_piece_ctx_t *ctx,
int *curl_rc_out, long *http_code_out);
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
(caller provides >= 65 bytes). Returns 0 on success. */
int patchdl_sha256_fd_region(int fd, long long offset, long long size,
char *out_hex);
/* Read-only: hash a [offset, offset+size) region of fd into out_hex
(caller provides >= 65 bytes), SHA-1 if like_hex has 40 hex digits (PS4
manifests), else SHA-256. Returns 0 on success. */
int patchdl_hash_fd_region(int fd, long long offset, long long size,
const char *like_hex, char *out_hex);
/* Progress callback. Return non-zero to ABORT the in-flight download (used to
cancel large patch downloads); return 0 to continue. */
+213
View File
@@ -0,0 +1,213 @@
/* 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 <ctype.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);
}
/* PS5 titles live on Prospero Patches, PS4 titles on its sister site Orbis
Patches; both serve the same page and API. */
const char *
patchdl_patchsite_base(const char *title_id) {
return (title_id && !strncmp(title_id, "CUSA", 4))
? "https://orbispatches.com" : PROSPERO_BASE;
}
/* The API key on the title page: Prospero puts it in data-key="<hex>",
Orbis in data-loadparams="{ 'titleid': ..., 'key': '<hex>' }". */
static int
extract_key(const char *html, char *out, size_t out_sz) {
const char *p;
size_t len = 0;
if (!extract_data_key(html, out, out_sz)) return 0;
if (!(p = strstr(html, "'key': '"))) return -1;
p += strlen("'key': '");
while (isxdigit((unsigned char)p[len]) && len < out_sz - 1) len++;
if (!len || p[len] != '\'') return -1;
memcpy(out, p, len);
out[len] = '\0';
return 0;
}
int
patchdl_patchsite_versions(const char *title_id, patchdl_patch_version_t *out,
int max, int *count) {
patchdl_buf_t buf;
char url[256], key[128], body[256];
const char *base = patchdl_patchsite_base(title_id);
const char *cur, *next;
int n = 0;
*count = 0;
if (!title_id || !out || max <= 0) return -1;
/* Step 1: fetch the title page for the API key. */
memset(&buf, 0, sizeof(buf));
buf.max = BUF_MAX;
snprintf(url, sizeof(url), "%s/%.16s", base, title_id);
if (patchdl_http_get_external(url, &buf)) return -1;
if (!buf.data) return -1;
if (extract_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);
snprintf(url, sizeof(url), "%s/api/internal/loadpatches", base);
memset(&buf, 0, sizeof(buf));
buf.max = BUF_MAX;
if (patchdl_http_post_external(url,
"application/x-www-form-urlencoded; charset=UTF-8",
body, &buf)) return -1;
if (!buf.data) return -1;
if (!json_success(buf.data)) {
free(buf.data);
return -1;
}
/* Each entry starts with "is_latest" and carries content_ver (Prospero)
or version (Orbis), filesize and required_firmware. Bound each lookup
to its own entry. */
for (cur = strstr(buf.data, "\"is_latest\""); cur && n < max; cur = next) {
char entry[2048], fw[32] = {0};
size_t len;
patchdl_patch_version_t *v = &out[n];
next = strstr(cur + 1, "\"is_latest\"");
len = next ? (size_t)(next - cur) : strlen(cur);
if (len >= sizeof entry) len = sizeof entry - 1;
memcpy(entry, cur, len);
entry[len] = '\0';
memset(v, 0, sizeof *v);
if (!json_str_val(entry, "content_ver", v->version, sizeof v->version))
json_str_val(entry, "version", v->version, sizeof v->version);
json_str_val(entry, "filesize", v->size, sizeof v->size);
json_str_val(entry, "required_firmware", fw, sizeof fw);
if (!v->version[0]) continue;
if (strchr(fw, '.') && strchr(strchr(fw, '.') + 1, '.')) {
/* PS5 style "07.01.01.00": packed like Sony's system_ver */
v->fw_bin = fw_str_to_bin(fw);
fw_bin_to_display(v->fw_bin, v->required_fw, sizeof v->required_fw);
} else {
/* PS4 style "3.00": a PS4 firmware, never a limit on the PS5 */
snprintf(v->required_fw, sizeof v->required_fw, "%s", fw);
v->fw_bin = 0;
}
n++;
}
free(buf.data);
*count = n;
return 0;
}
int
patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
patchdl_prospero_result_t *out) {
patchdl_patch_version_t *list;
int n = 0, best = -1;
if (!title_id || !out) return -1;
memset(out, 0, sizeof(*out));
/* The whole history: the newest versions may all need newer firmware. */
if (!(list = calloc(PATCHSITE_MAX_VERSIONS, sizeof *list))) return -1;
if (patchdl_patchsite_versions(title_id, list, PATCHSITE_MAX_VERSIONS, &n)) {
free(list);
return -1;
}
/* the newest version whose firmware requirement is met */
for (int i = 0; i < n; i++)
if (list[i].fw_bin <= fw_bin &&
(best < 0 || strcmp(list[i].version, list[best].version) > 0))
best = i;
if (best >= 0) {
snprintf(out->version, sizeof out->version, "%s", list[best].version);
snprintf(out->required_fw, sizeof out->required_fw, "%s", list[best].required_fw);
snprintf(out->size, sizeof out->size, "%s", list[best].size);
out->found = 1;
}
free(list);
return 0;
}
+46
View File
@@ -0,0 +1,46 @@
/* 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);
/* One patch version as Prospero Patches (PS5) or Orbis Patches (PS4) lists it. */
typedef struct {
char version[16]; /* "01.003.000" (PS5) or "01.07" (PS4) */
char required_fw[16]; /* display form; for PS4 a PS4 firmware */
char size[16]; /* the site's label, e.g. "8.7GB" */
uint32_t fw_bin; /* PS5 firmware it needs, 0 for PS4 */
} patchdl_patch_version_t;
/* More versions than any game has had (the response is capped at 512 KB). */
#define PATCHSITE_MAX_VERSIONS 512
/* Base URL of the site that lists this title's patches. */
const char *patchdl_patchsite_base(const char *title_id);
/* Every version the site lists for title_id, newest first as the site
orders them. 0 on success with *count entries (up to max). */
int patchdl_patchsite_versions(const char *title_id, patchdl_patch_version_t *out,
int max, int *count);
+34
View File
@@ -9,6 +9,7 @@
#include "patchdl_resolve.h"
#include <openssl/hmac.h>
#include <stdio.h>
#include <string.h>
@@ -52,10 +53,43 @@ lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
return -1;
}
/* PS4 titles: Sony's update list lives at a path derived from the title id
with HMAC-SHA256 under a key that is part of the PS4 system software and
widely published (it's how every PS4 update checker finds patches). */
static const unsigned char PS4_UPDATE_KEY[32] = {
0xAD, 0x62, 0xE3, 0x7F, 0x90, 0x5E, 0x06, 0xBC, 0x19, 0x59, 0x31, 0x42,
0x28, 0x1C, 0x11, 0x2C, 0xEC, 0x0E, 0x7E, 0xC3, 0xE9, 0x7E, 0xFD, 0xCA,
0xEF, 0xCD, 0xBA, 0xAF, 0xA6, 0x37, 0x8D, 0x84,
};
static int
ps4_update_url(const char *title_id, char *url_out, size_t url_sz) {
unsigned char mac[32];
unsigned int mac_len = 0;
char msg[24], hex[65];
int n;
if (strlen(title_id) != 9) return -1;
n = snprintf(msg, sizeof msg, "np_%s", title_id);
if (!HMAC(EVP_sha256(), PS4_UPDATE_KEY, sizeof PS4_UPDATE_KEY,
(const unsigned char *)msg, (size_t)n, mac, &mac_len) ||
mac_len != sizeof mac)
return -1;
for (unsigned i = 0; i < mac_len; i++)
snprintf(hex + 2 * i, 3, "%02x", mac[i]);
n = snprintf(url_out, url_sz,
"https://gs-sec.ww.np.dl.playstation.net/plo/np/%s/%s/%s-ver.xml",
title_id, hex, title_id);
return (n > 0 && (size_t)n < url_sz) ? 0 : -1;
}
int
patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz) {
if (!title_id) return -1;
if (!strncmp(title_id, "CUSA", 4))
return ps4_update_url(title_id, url_out, url_sz);
/* Only PS5 native title IDs (PPSA/PPSE/PPSH/PPSJ/PPSC) */
if (strncmp(title_id, "PPSA", 4) &&
strncmp(title_id, "PPSE", 4) &&
+215 -39
View File
@@ -26,6 +26,10 @@
#define MAX_TITLES 512
#ifndef PATCHDL_HTTP_PORT
#define PATCHDL_HTTP_PORT 12880
#endif
static const char *SCAN_DIRS[] = {
"/user/app",
"/mnt/ext0/user/app",
@@ -34,6 +38,26 @@ static const char *SCAN_DIRS[] = {
NULL,
};
/* The first scan runs single-threaded with the process root swapped to the
system root and keeps a handle to that root. Later scans (other threads are
running, so the root must not move) change only the working directory to
it and drop the leading '/' of every path, see sys_path. Nothing else in
PatchDL opens relative paths. */
static int g_sysroot_fd = -1;
static int g_rel_paths;
/* Step the running scan is in, for diagnosing slow rescans. */
static const char *volatile g_scan_step = "idle";
const char *
patchdl_scan_step(void) {
return g_scan_step;
}
static const char *
sys_path(const char *p) {
return (g_rel_paths && p[0] == '/') ? p + 1 : p;
}
/* ---------- param.json -------------------------------------------------- */
static int
@@ -91,7 +115,7 @@ try_param_json(const char *dir, patchdl_title_t *t) {
size_t n;
snprintf(path, sizeof(path), "%s/sce_sys/param.json", dir);
fp = fopen(path, "r");
fp = fopen(sys_path(path), "r");
if (!fp) return -1;
n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
@@ -190,7 +214,7 @@ try_param_sfo(const char *dir, patchdl_title_t *t) {
struct stat st;
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", dir);
fp = fopen(path, "rb");
fp = fopen(sys_path(path), "rb");
if (!fp) return -1;
if (fstat(fileno(fp), &st) || (size_t)st.st_size > sizeof(buf)) {
@@ -219,7 +243,7 @@ path_exists(const char *dir, const char *rel) {
char p[PATH_MAX];
struct stat st;
snprintf(p, sizeof(p), "%s/%s", dir, rel);
return stat(p, &st) == 0;
return stat(sys_path(p), &st) == 0;
}
/* Read up to sz-1 bytes of dir/rel into buf (NUL-terminated). 0 on success. */
@@ -230,7 +254,7 @@ read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
size_t n;
snprintf(p, sizeof(p), "%s/%s", dir, rel);
fp = fopen(p, "rb");
fp = fopen(sys_path(p), "rb");
if (!fp) return -1;
n = fread(buf, 1, sz - 1, fp);
fclose(fp);
@@ -251,7 +275,7 @@ pkg_sign_state(const char *dir) {
size_t n;
snprintf(p, sizeof(p), "%s/app.pkg", dir);
fp = fopen(p, "rb");
fp = fopen(sys_path(p), "rb");
if (!fp) return PATCHDL_PKG_UNREAD;
n = fread(h, 1, sizeof(h), fp);
fclose(fp);
@@ -303,8 +327,9 @@ mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts)
* - app.pkg (no shadow mount) -> its header decides: Sony-signed is a
* genuine install (store or disc), debug/fake-signed is a fake package
* (listed, but never checked, downloaded or patched), unreadable is unknown
* - app.json with "fake":true -> fake package if it has an app.pkg,
* otherwise homebrew (skip)
* - app.json with "fake":true and no app.pkg -> homebrew (skip). With an
* app.pkg the marker only says the package did not come from Sony's CDN
* (PatchDL's own loopback install sets it too), so the header decides.
*/
static int
scan_one(const char *base, const char *name, patchdl_title_t *t,
@@ -345,12 +370,27 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
/* app.json piece URLs are no signal: disc and store installs carry
CDN URLs there too. The package header is. */
t->pkg_sign = pkg_sign_state(dir);
/* The installer's own fake marker wins over the header: block when
either says fake. */
t->source_type = (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
/* The header wins over the installer's fake marker: a retail package
installed from a local URL is marked fake but is still Sony's. The
marker only decides when the header can't be read. */
t->source_type = t->pkg_sign == PATCHDL_PKG_RETAIL ? PATCHDL_SOURCE_OFFICIAL
: (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
? PATCHDL_SOURCE_FAKE
: t->pkg_sign == PATCHDL_PKG_RETAIL ? PATCHDL_SOURCE_OFFICIAL
: PATCHDL_SOURCE_UNKNOWN;
: PATCHDL_SOURCE_UNKNOWN;
/* A Sony-signed package someone installed with another tool, often
to keep one exact app version. PatchDL's own installs name its
loopback server in app.json. An update would replace that
version. */
{
/* PatchDL installs from its server (loopback or LAN address,
both on its port) or, as a fallback, from its download folder
(/user/data/patchdl/...). app.json escapes slashes, so match
pieces without them. */
char port[16];
snprintf(port, sizeof port, ":%d", PATCHDL_HTTP_PORT);
t->sideloaded = is_fake && t->source_type == PATCHDL_SOURCE_OFFICIAL &&
!strstr(appjson, port) && !strstr(appjson, "patchdl");
}
} else if (is_shadow || has_mountlnk || has_paramjson ||
path_exists(dir, "sce_sys/param.sfo")) {
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
@@ -382,7 +422,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap,
DIR *d;
struct dirent *de;
d = opendir(base);
d = opendir(sys_path(base));
if (!d) return;
while ((de = readdir(d))) {
@@ -423,7 +463,7 @@ open_png(const char *path) {
int fd;
if (!path || path[0] != '/') return -1;
fd = open(path, O_RDONLY | O_CLOEXEC);
fd = open(sys_path(path), O_RDONLY | O_CLOEXEC);
if (fd < 0) return -1;
if (fstat(fd, &st) || !S_ISREG(st.st_mode) ||
st.st_size < 8 || st.st_size > ICON_MAX_BYTES ||
@@ -454,16 +494,41 @@ open_icon(const patchdl_title_t *t, const char *db_path) {
return fd;
}
/* After an install the console rewrites /user/appmeta/<id>/param.json at
once, while app.db keeps the old CONTENT_VERSION until later (seen: until
the game was started). Take the newer of the two so a finished update
doesn't look pending. */
static void
newer_appmeta_version(patchdl_title_t *t) {
char path[PATH_MAX], buf[8192], ver[16] = {0};
FILE *fp;
size_t n;
snprintf(path, sizeof(path), "/user/appmeta/%s/param.json", t->title_id);
if (!(fp = fopen(sys_path(path), "r"))) return;
n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
buf[n] = '\0';
if (json_str(buf, "contentVersion", ver, sizeof(ver)) == 0 &&
strcmp(ver, t->installed_version) > 0)
snprintf(t->installed_version, sizeof(t->installed_version), "%s", ver);
}
/* Overlay app.db metadata (real title name, installed version, version.xml
URL) onto the scanned titles. Genuine installs have no readable param.json,
so this is the only source of their name/version. */
static void
static int
merge_appdb(patchdl_title_t *arr, size_t cnt) {
patchdl_appinfo_t *info = NULL;
size_t n = 0;
if (patchdl_appdb_load(&info, &n))
return;
{
int rc = patchdl_appdb_load(&info, &n, g_rel_paths);
if (rc) {
g_scan_step = rc == -2 ? patchdl_appdb_error() : "fail: appdb read";
return -1;
}
}
for (size_t i = 0; i < cnt; i++) {
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
@@ -481,16 +546,21 @@ merge_appdb(patchdl_title_t *arr, size_t cnt) {
sizeof(arr[i].version_file_uri) - 1);
}
g_scan_step = "icons";
for (size_t i = 0; i < cnt; i++) {
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
arr[i].icon_fd = open_icon(&arr[i], a ? a->icon_path : NULL);
if (arr[i].pkg_sign != PATCHDL_PKG_NONE)
newer_appmeta_version(&arr[i]);
}
patchdl_appdb_free(info);
return 0;
}
/* See patchdl_scan_lock — both vnode-swap entry points return -1 / NULL once
this is set so a late rescan call can't race the running MHD threads. */
/* See patchdl_scan_lock — once set, patchdl_scan no longer swaps the root
(it rescans via the kept system root handle) and the debug dump returns
NULL, so neither can race the running MHD threads. */
static _Atomic int g_scan_locked = 0;
void
@@ -498,30 +568,72 @@ patchdl_scan_lock(void) {
atomic_store(&g_scan_locked, 1);
}
int
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
static int
scan_all(patchdl_title_t **titles_out, size_t *count_out) {
patchdl_title_t *arr;
size_t cnt = 0;
pid_t pid = getpid();
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
struct statfs *mounts = NULL;
int nmounts;
if (atomic_load(&g_scan_locked)) return -1;
arr = calloc(MAX_TITLES, sizeof(*arr));
if (!arr) return -1;
if (!arr) { g_scan_step = "fail: oom"; return -1; }
/* Global mount table for shadowmount detection. getmntinfo's buffer is
libc-managed — must NOT be freed. */
g_scan_step = "mounts";
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
for (int i = 0; SCAN_DIRS[i]; i++) {
g_scan_step = SCAN_DIRS[i];
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
}
g_scan_step = "appdb";
/* At startup a list without app.db data still beats none. A rescan
without it would wipe names and versions of a good list: fail instead
and keep the old one. */
if (merge_appdb(arr, cnt) && g_rel_paths) {
free(arr);
return -1;
}
g_scan_step = "idle";
*titles_out = arr;
*count_out = cnt;
return 0;
}
/* A rescan while other threads run: resolve every path from the system root
kept by the first scan, without touching the process root. */
static int
rescan_relative(patchdl_title_t **titles_out, size_t *count_out) {
int cwd, rc;
if (g_sysroot_fd < 0) { g_scan_step = "fail: no system root handle"; return -1; }
cwd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (cwd < 0) { g_scan_step = "fail: open cwd"; return -1; }
if (fchdir(g_sysroot_fd)) { g_scan_step = "fail: fchdir"; close(cwd); return -1; }
g_rel_paths = 1;
rc = scan_all(titles_out, count_out);
g_rel_paths = 0;
(void)fchdir(cwd); /* best effort: nothing else uses the cwd */
close(cwd);
return rc;
}
int
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
pid_t pid = getpid();
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0, rc;
/* Elevate so privileged paths (the app.db under /system_data) are
readable; same authid ftpsrv uses. No-op without kernel R/W. */
kernel_set_ucred_authid(pid, 0x4801000000000013L);
/* Global mount table for shadowmount detection. getmntinfo's buffer is
libc-managed — must NOT be freed. */
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
if (atomic_load(&g_scan_locked))
return rescan_relative(titles_out, count_out);
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
@@ -532,20 +644,84 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
if (saved_root) {
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
if (g_sysroot_fd < 0)
g_sysroot_fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
}
}
for (int i = 0; SCAN_DIRS[i]; i++)
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
merge_appdb(arr, cnt);
rc = scan_all(titles_out, count_out);
if (using_vswap)
kernel_set_proc_rootdir(pid, saved_root);
return rc;
}
*titles_out = arr;
*count_out = cnt;
return 0;
static const char *PATCH_DIRS[] = {
"/user/patch",
"/mnt/ext0/user/patch",
"/mnt/ext1/user/patch",
NULL,
};
static int
read_title_file(const char *const *bases, const char *title_id, const char *name,
char *buf, size_t sz) {
char path[PATH_MAX];
if (g_sysroot_fd < 0 || !sz) return -1;
for (int i = 0; bases[i]; i++) {
int fd;
ssize_t n;
snprintf(path, sizeof(path), "%s/%s/%s", bases[i] + 1, title_id, name);
fd = openat(g_sysroot_fd, path, O_RDONLY | O_CLOEXEC);
if (fd < 0) continue;
n = read(fd, buf, sz - 1);
close(fd);
if (n < 0) return -1;
buf[n] = '\0';
return 0;
}
return -1;
}
int
patchdl_scan_read_app_file(const char *title_id, const char *name,
char *buf, size_t sz) {
return read_title_file(SCAN_DIRS, title_id, name, buf, sz);
}
int
patchdl_scan_read_patch_file(const char *title_id, const char *name,
char *buf, size_t sz) {
return read_title_file(PATCH_DIRS, title_id, name, buf, sz);
}
/* Fold one stat result into an FNV-1a hash. */
static unsigned long long
sig_add(unsigned long long h, const char *rel) {
struct stat st;
long long v[4] = {0};
if (fstatat(g_sysroot_fd, rel, &st, 0) == 0) {
v[0] = (long long)st.st_mtim.tv_sec;
v[1] = (long long)st.st_mtim.tv_nsec;
v[2] = (long long)st.st_size;
v[3] = (long long)st.st_ino;
}
for (size_t i = 0; i < sizeof v; i++) {
h ^= ((const unsigned char *)v)[i];
h *= 1099511628211ULL;
}
return h;
}
unsigned long long
patchdl_scan_signature(void) {
unsigned long long h = 14695981039346656037ULL;
if (g_sysroot_fd < 0) return 0;
for (int i = 0; SCAN_DIRS[i]; i++)
h = sig_add(h, SCAN_DIRS[i] + 1);
h = sig_add(h, "system_data/priv/mms/app.db");
h = sig_add(h, "system_data/priv/mms/app.db-wal");
return h ? h : 1;
}
void
+33 -2
View File
@@ -38,6 +38,8 @@ typedef struct {
char version_file_uri[256]; /* from app.db, for version.xml */
patchdl_source_t source_type;
patchdl_pkg_sign_t pkg_sign; /* app.pkg header verdict, for diagnostics */
int sideloaded; /* Sony-signed, but installed by another
tool (not Store, disc or PatchDL) */
int icon_fd; /* open icon0.png, or -1; see patchdl_scan */
/* populated by background verxml fetch — guarded by websrv mutex */
char compatible_version[16];
@@ -48,8 +50,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 +73,27 @@ const char *patchdl_source_str(patchdl_source_t src);
listings. Caller frees. */
char *patchdl_scan_debug_json(void);
/* Cheap change check for the automatic rescan: a hash over the title
directories and Sony's app.db. 0 when it can't be computed (no system root
handle), so callers never rescan on it. */
unsigned long long patchdl_scan_signature(void);
/* Name of the step the running scan is in ("idle" when none). */
const char *patchdl_scan_step(void);
/* Read <title dir>/<name> of an installed title (e.g. app.json) through the
kept system root handle; thread-safe, no working-directory change.
0 on success with a NUL-terminated buf. */
int patchdl_scan_read_app_file(const char *title_id, const char *name,
char *buf, size_t sz);
/* Same for an installed PS4 patch: /user/patch/<id>/<name>. */
int patchdl_scan_read_patch_file(const char *title_id, const char *name,
char *buf, size_t sz);
/* Mark scan/debug as no longer safe to call (must be set after MHD worker
threads come up — patchdl_scan performs a process-wide vnode swap that
would race any concurrent thread). After this is set, both entry points
return immediately. Call once during startup, after MHD_start_daemon. */
would race any concurrent thread). After this is set, patchdl_scan rescans
through the system root handle kept by the first scan (working directory
only, one caller at a time) and the debug dump returns NULL. Call once
during startup, after MHD_start_daemon. */
void patchdl_scan_lock(void);
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.0.8"
#define PATCHDL_VERSION "0.3.0"
+11 -5
View File
@@ -134,20 +134,26 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
char murl[512] = {0};
/* PS5 version.xml uses content_ver; PS4 uses version. */
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
int is_ps4 = attr_val(p, tag_end, "content_ver", ver, sizeof(ver)) != 0;
if (is_ps4)
attr_val(p, tag_end, "version", ver, sizeof(ver));
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
attr_val(p, tag_end, "manifest_url", murl, sizeof(murl));
if (ver[0] && sver[0]) {
uint32_t pkg_sver = parse_hex(sver);
if (ver[0] && (sver[0] || is_ps4)) {
/* A PS4 patch's system_ver is a PS4 firmware (in decimal); the
PS5 runs PS4 games regardless, so it never blocks one. */
uint32_t pkg_sver = is_ps4 ? 0 : parse_hex(sver);
/* Track latest overall */
if (!out->latest_version[0] || ver_gt(ver, out->latest_version)) {
strncpy(out->latest_version, ver, sizeof(out->latest_version) - 1);
fw_bin_to_str(pkg_sver, out->latest_required_fw,
sizeof(out->latest_required_fw));
if (is_ps4)
out->latest_required_fw[0] = '\0';
else
fw_bin_to_str(pkg_sver, out->latest_required_fw,
sizeof(out->latest_required_fw));
}
/* Track latest compatible + its installable patch source. PS5
+1687 -144
View File
File diff suppressed because it is too large. Load diff
+12 -10
View File
@@ -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.
+1275 -151
View File
File diff suppressed because it is too large. Load diff
+3344
View File
File diff suppressed because it is too large. Load diff
+102 -63
View File
@@ -31,33 +31,58 @@
<symbol id="icon-grid" viewBox="0 0 24 24">
<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" />
</symbol>
<symbol id="icon-close" viewBox="0 0 24 24">
<path d="M6 6l12 12M18 6L6 18" />
</symbol>
<symbol id="icon-arrow" viewBox="0 0 24 24">
<path d="M5 12h14" /><path d="M13 6l6 6-6 6" />
</symbol>
<symbol id="icon-layers" viewBox="0 0 24 24">
<path d="M12 3l9 5-9 5-9-5 9-5z" /><path d="M3 13l9 5 9-5" />
</symbol>
<symbol id="icon-alert" viewBox="0 0 24 24">
<path d="M12 3l10 18H2L12 3z" /><path d="M12 10v5" /><path d="M12 18h.01" />
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M5 12.5l4.5 4.5L19 7" />
</symbol>
<symbol id="icon-lock" viewBox="0 0 24 24">
<path d="M6 11h12v10H6z" /><path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
</symbol>
<symbol id="icon-external" viewBox="0 0 24 24">
<path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M18 14v6H4V6h6" />
</symbol>
<symbol id="icon-chevron" viewBox="0 0 24 24">
<path d="M9 6l6 6-6 6" />
</symbol>
</svg>
<div class="app-shell">
<aside class="rail" aria-label="Navigation">
<aside class="rail" aria-label="Navigation" data-i18n-aria-label="nav.label">
<div class="brand">
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>by Knutwurst · <span id="brandVersion">--</span></span>
<span><span class="brand-by" data-i18n="brand.by">by Knutwurst</span> · <span id="brandVersion">--</span></span>
</div>
</div>
<nav class="nav-list" aria-label="Sections">
<button class="nav-link is-active" data-view="games" aria-label="Games" aria-current="page">
<svg><use href="#icon-grid"></use></svg><span>Games</span>
<nav class="nav-list" aria-label="Sections" data-i18n-aria-label="nav.sections">
<button class="nav-link is-active" data-view="games" aria-label="Games" data-i18n-aria-label="nav.games" aria-current="page">
<svg><use href="#icon-grid"></use></svg><span data-i18n="nav.games">Games</span>
</button>
<button class="nav-link" data-view="settings" aria-label="Settings">
<svg><use href="#icon-settings"></use></svg><span>Settings</span>
<button class="nav-link" data-view="settings" aria-label="Settings" data-i18n-aria-label="nav.settings">
<svg><use href="#icon-settings"></use></svg><span data-i18n="nav.settings">Settings</span>
</button>
<button class="nav-link" data-view="logs" aria-label="Logs">
<svg><use href="#icon-log"></use></svg><span>Logs</span>
<button class="nav-link" data-view="logs" aria-label="Logs" data-i18n-aria-label="nav.logs">
<svg><use href="#icon-log"></use></svg><span data-i18n="nav.logs">Logs</span>
</button>
</nav>
<div class="rail-foot">
<span id="railFw">FW --</span>
<span id="railSpace">-- free</span>
<span id="railSpace" data-i18n="rail.freeUnknown">-- free</span>
<button type="button" class="link-button rail-donate" data-donate data-i18n="donate.button">Buy me a coffee</button>
</div>
</aside>
@@ -66,15 +91,15 @@
<section class="view is-active" data-view="games">
<header class="topbar">
<div>
<p class="eyebrow">PS5 update manager</p>
<h1>Games</h1>
<p class="eyebrow" data-i18n="games.eyebrow">PS5 &amp; PS4 game updates</p>
<h1 data-i18n="games.title">Games</h1>
</div>
<div class="topbar-actions">
<button class="primary-button" id="updateAllBtn" title="Download (and optionally install) every game that has an available, allowed update">
<button class="primary-button" id="updateAllBtn" title="Download (and optionally install) every game that has an available, allowed update" data-i18n-title="games.updateAllTitle">
<svg><use href="#icon-download"></use></svg>
Update all
<span data-i18n="games.updateAll">Update all</span>
</button>
<button class="icon-button" id="refreshBtn" title="Refresh status and games" aria-label="Refresh">
<button class="icon-button" id="refreshBtn" title="Refresh status and games" data-i18n-title="games.refreshTitle" aria-label="Refresh" data-i18n-aria-label="games.refresh">
<svg><use href="#icon-refresh"></use></svg>
</button>
</div>
@@ -85,7 +110,7 @@
<span class="global-dl-pulse" aria-hidden="true"></span>
<div class="global-dl-text">
<div class="global-dl-line1">
<span class="global-dl-eyebrow" id="globalDlState">Downloading</span>
<span class="global-dl-eyebrow" id="globalDlState" data-i18n="dl.downloading">Downloading</span>
<strong class="global-dl-name" id="globalDlName">—</strong>
<span class="global-dl-position" id="globalDlPosition" hidden></span>
</div>
@@ -103,41 +128,39 @@
</div>
</aside>
<section class="status-strip" aria-label="System status">
<section class="status-strip" aria-label="System status" data-i18n-aria-label="status.label">
<article class="metric">
<span>Firmware</span>
<span data-i18n="metric.firmware">Firmware</span>
<strong id="firmwareValue">--</strong>
<em id="firmwareBuild">System version</em>
<em id="firmwareBuild" data-i18n="metric.firmwareBuild">System version</em>
</article>
<article class="metric">
<span>DNS Guard</span>
<span data-i18n="metric.dns">DNS Guard</span>
<strong id="dnsValue">--</strong>
<em>System DNS left untouched</em>
<em data-i18n="metric.dnsNote">System DNS left untouched</em>
</article>
<article class="metric">
<span>CDN Access</span>
<span data-i18n="metric.cdn">CDN Access</span>
<strong id="resolverValue">--</strong>
<em>PatchDL resolver only</em>
<em data-i18n="metric.cdnNote">PatchDL resolver only</em>
</article>
<article class="metric">
<span>Storage</span>
<span data-i18n="metric.storage">Storage</span>
<strong id="spaceValue">--</strong>
<em id="downloadDirValue">Download target</em>
<em id="downloadDirValue" data-i18n="metric.downloadTarget">Download target</em>
</article>
</section>
<div class="toolbar">
<div class="search-box">
<svg><use href="#icon-search"></use></svg>
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" data-i18n-placeholder="search.placeholder" />
</div>
<div class="segmented" role="group" aria-label="Filter">
<button class="is-selected" data-filter="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>
<div class="segmented" role="group" aria-label="Filter" data-i18n-aria-label="filter.label">
<button class="is-selected" data-filter="updates" aria-pressed="true"><span data-i18n="filter.updates">Updates</span> <span class="chip-count"></span></button>
<button data-filter="uptodate" aria-pressed="false"><span data-i18n="filter.uptodate">Up to date</span> <span class="chip-count"></span></button>
<button data-filter="blocked" aria-pressed="false"><span data-i18n="filter.blocked">Not supported</span> <span class="chip-count"></span></button>
<button data-filter="all" aria-pressed="false"><span data-i18n="filter.all">All</span> <span class="chip-count"></span></button>
</div>
</div>
@@ -148,13 +171,13 @@
<section class="view" data-view="settings">
<header class="topbar">
<div>
<p class="eyebrow">Policy</p>
<h1>Settings</h1>
<p class="eyebrow" data-i18n="settings.eyebrow">Policy</p>
<h1 data-i18n="settings.title">Settings</h1>
</div>
<div class="topbar-actions">
<button class="primary-button" id="saveBtn">
<svg><use href="#icon-save"></use></svg>
Save
<span data-i18n="settings.save">Save</span>
</button>
</div>
</header>
@@ -162,80 +185,95 @@
<div class="panel">
<div class="settings-grid">
<label class="field">
<span>Default Policy</span>
<span data-i18n="settings.defaultPolicy">Default Policy</span>
<select id="defaultPolicy">
<option value="deny">Deny by default</option>
<option value="allow">Allow by default</option>
<option value="deny" data-i18n="settings.policyDeny">Deny by default</option>
<option value="allow" data-i18n="settings.policyAllow">Allow by default</option>
</select>
</label>
<label class="field">
<span>Download Folder</span>
<span data-i18n="settings.downloadFolder">Download Folder</span>
<input id="downloadDir" type="text" spellcheck="false" readonly
title="Patches always download internally and are removed after install" />
title="Patches always download to the console's internal storage"
data-i18n-title="settings.downloadFolderTitle" />
</label>
<label class="field">
<span data-i18n="settings.language">Language</span>
<select id="language"></select>
</label>
<label class="switch-row">
<span>
<strong>Install after download</strong>
<em>Global default, overridable per game</em>
<strong data-i18n="settings.installAfter">Install after download</strong>
<em data-i18n="settings.installAfterHint">Global default, overridable per game</em>
</span>
<span class="toggle">
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" />
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" data-i18n-aria-label="settings.installAfter" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<span>
<strong>Delete PKG after install</strong>
<em>Only after a successful install</em>
<strong data-i18n="settings.deleteAfter">Delete PKG after install</strong>
<em data-i18n="settings.deleteAfterHint">Only after a successful install</em>
</span>
<span class="toggle">
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" />
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" data-i18n-aria-label="settings.deleteAfterAria" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<span>
<strong>Verify downloaded pieces (SHA-256)</strong>
<em>Checks each manifest piece; off by default, verify on-device first</em>
<strong data-i18n="settings.verify">Verify downloaded pieces (SHA-256)</strong>
<em data-i18n="settings.verifyHint">Checks each manifest piece; off by default, verify on-device first</em>
</span>
<span class="toggle">
<input id="verifyDownloads" type="checkbox" aria-label="Verify downloaded pieces" />
<input id="verifyDownloads" type="checkbox" aria-label="Verify downloaded pieces" data-i18n-aria-label="settings.verifyAria" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<span>
<strong>Home-screen shortcut</strong>
<em id="shortcutHint">Add a PS5 home-screen icon that opens this UI in the browser</em>
<strong data-i18n="settings.shortcut">Home-screen shortcut</strong>
<em id="shortcutHint" data-i18n="settings.shortcutHint">Add a PS5 home-screen icon that opens this UI in the browser</em>
</span>
<span class="toggle">
<input id="homeShortcut" type="checkbox" aria-label="Install home-screen shortcut" />
<input id="homeShortcut" type="checkbox" aria-label="Install home-screen shortcut" data-i18n-aria-label="settings.shortcutAria" />
<span class="track"></span>
</span>
</label>
<div class="switch-row">
<span>
<strong>Parallel download connections</strong>
<em>1–16 · applies live, no payload restart</em>
<strong data-i18n="settings.connections">Parallel download connections</strong>
<em data-i18n="settings.connectionsHint">1–16 · applies live, no payload restart</em>
</span>
<div class="stepper" role="group" aria-label="Parallel download connections">
<button type="button" class="stepper-btn" id="connMinus" aria-label="Fewer connections">−</button>
<div class="stepper" role="group" aria-label="Parallel download connections" data-i18n-aria-label="settings.connections">
<button type="button" class="stepper-btn" id="connMinus" aria-label="Fewer connections" data-i18n-aria-label="settings.connFewer">−</button>
<output class="stepper-value" id="connValue" aria-live="polite">4</output>
<button type="button" class="stepper-btn" id="connPlus" aria-label="More connections">+</button>
<button type="button" class="stepper-btn" id="connPlus" aria-label="More connections" data-i18n-aria-label="settings.connMore">+</button>
</div>
</div>
</div>
<div class="allowlist">
<span>Internal CDN Allowlist</span>
<span data-i18n="settings.allowlist">Internal CDN Allowlist</span>
<div id="allowlistHosts"></div>
</div>
<div class="source-policy">
<span>Install Protection</span>
<span data-i18n="settings.installProtection">Install Protection</span>
<div id="sourcePolicyList"></div>
</div>
<div class="storage-list">
<span><span data-i18n="settings.storage">Downloads on console</span> <em id="storageTotal"></em></span>
<div id="storageList"></div>
</div>
<div class="donate-row">
<span data-i18n="settings.donateText">Like PatchDL? You can buy me a coffee.</span>
<button type="button" class="row-button is-ghost" data-donate data-i18n="donate.button">Buy me a coffee</button>
</div>
</div>
</section>
@@ -243,11 +281,11 @@
<section class="view" data-view="logs">
<header class="topbar">
<div>
<p class="eyebrow">Runtime</p>
<h1>Logs</h1>
<p class="eyebrow" data-i18n="logs.eyebrow">Runtime</p>
<h1 data-i18n="logs.title">Logs</h1>
</div>
<div class="topbar-actions">
<button class="icon-button" id="clearLogBtn" title="Clear log" aria-label="Clear log">
<button class="icon-button" id="clearLogBtn" title="Clear log" data-i18n-title="logs.clear" aria-label="Clear log" data-i18n-aria-label="logs.clear">
<svg><use href="#icon-log"></use></svg>
</button>
</div>
@@ -258,6 +296,7 @@
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
</html>
+472 -52
View File
@@ -1,11 +1,13 @@
:root {
color-scheme: dark;
--bg: #0b0d10;
--surface: #14181d;
--surface-2: #1b2027;
--ink: #edf2f7;
--muted: #9aa6b2;
--border: #2a313a;
--bg: #0a0c0f;
--surface: #13171c;
--surface-2: #1a1f26;
--surface-3: #212731;
--ink: #eef2f6;
--muted: #a4afba;
--border: #262d36;
--border-strong: #36404b;
--green: #38c172;
--green-dark: #63d994;
--green-soft: rgba(56, 193, 114, 0.16);
@@ -18,7 +20,12 @@
--blue-dark: #8cc2ff;
--blue-soft: rgba(88, 166, 255, 0.16);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.38);
--radius: 10px;
--radius: 12px;
--focus: 0 0 0 3px rgba(88, 166, 255, 0.55);
--fs-xs: 13px;
--fs-sm: 14px;
--fs-md: 16px;
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
--rail-w: 240px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@@ -30,9 +37,23 @@ body {
min-height: 100dvh;
background: var(--bg);
color: var(--ink);
font-size: var(--fs-md);
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
/* One focus ring for everything the controller, keyboard or a phone can
reach. */
:where(button, a, summary, select, input, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: var(--focus);
}
.visually-hidden {
position: absolute; width: 1px; height: 1px; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
button, input, select { font: inherit; }
button { cursor: pointer; }
@@ -82,7 +103,8 @@ svg {
}
.brand strong { display: block; font-size: 16px; }
.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: var(--fs-xs); }
.brand .brand-by { display: inline; margin-top: 0; }
.nav-list { display: grid; gap: 6px; }
@@ -113,9 +135,45 @@ svg {
border-radius: 8px;
background: var(--surface);
color: var(--muted);
font-size: 12px;
font-size: var(--fs-xs);
}
.rail-foot span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-foot .rail-donate { justify-self: start; margin-top: 4px; }
.donate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-2);
color: var(--muted);
}
.donate-row .row-button { width: auto; padding: 0 16px; }
.diag-body { display: grid; gap: 14px; }
.diag-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.diag-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; }
.diag-row > b { font-size: 16px; }
.diag-row.is-ok > b { color: var(--green); }
.diag-row.is-bad > b { color: var(--red); }
.diag-row em { color: var(--muted); font-style: normal; font-size: var(--fs-sm); }
.diag-report {
width: 100%;
box-sizing: border-box;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-2);
color: var(--ink);
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
resize: vertical;
}
.diag-copy { justify-self: start; width: auto; padding: 0 18px; }
.donate-open { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; justify-self: start; padding: 0 18px; }
/* ---------------- views + topbar ---------------- */
@@ -136,8 +194,9 @@ svg {
.eyebrow {
margin: 0 0 6px;
color: var(--green-dark);
font-size: 11px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -152,15 +211,20 @@ h2 { font-size: 18px; line-height: 1.2; }
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
min-height: 44px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
border-radius: 8px;
border-radius: 10px;
font-weight: 700;
font-size: var(--fs-sm);
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 90ms ease;
-webkit-tap-highlight-color: transparent;
}
.icon-button:hover { border-color: var(--border-strong); background: var(--surface-2); }
.icon-button:active, .primary-button:active, .row-button:not(:disabled):active { transform: scale(0.97); }
.icon-button { width: 40px; padding: 0; flex: none; }
.icon-button { width: 44px; padding: 0; flex: none; }
.primary-button { padding: 0 16px; border-color: var(--green-dark); background: var(--green); color: #fff; }
.primary-button:hover { background: var(--green-dark); }
@@ -226,7 +290,7 @@ h2 { font-size: 18px; line-height: 1.2; }
}
.global-dl-eyebrow {
text-transform: uppercase; letter-spacing: 0.08em;
font-size: 11px; font-weight: 600; color: var(--green-dark);
font-size: 12px; font-weight: 600; color: var(--green-dark);
}
.global-dl-name {
font-size: 16px; font-weight: 600; color: #fff;
@@ -234,7 +298,7 @@ h2 { font-size: 18px; line-height: 1.2; }
min-width: 0; flex: 1;
}
.global-dl-position {
font-size: 12px; color: var(--muted);
font-size: var(--fs-xs); color: var(--muted);
padding: 2px 8px; border-radius: 999px;
background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border);
}
@@ -242,7 +306,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.global-dl-line2 {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--muted);
font-size: var(--fs-xs); color: var(--muted);
font-variant-numeric: tabular-nums;
}
.global-dl-line2 b { color: #d6dde4; font-weight: 600; }
@@ -283,7 +347,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.metric span, .metric em {
display: block;
color: var(--muted);
font-size: 12px;
font-size: var(--fs-xs);
font-style: normal;
}
@@ -338,6 +402,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 ---------------- */
@@ -351,15 +417,17 @@ h2 { font-size: 18px; line-height: 1.2; }
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
transition: border-color 150ms ease;
}
.game-card:hover { border-color: var(--border-strong); }
.game-card.is-downloading { border-color: rgba(56, 193, 114, 0.5); box-shadow: inset 3px 0 0 var(--green); }
.game-card.is-installing { border-color: rgba(242, 184, 75, 0.5); box-shadow: inset 3px 0 0 var(--amber); }
.card-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
gap: 16px;
align-items: start;
}
.lead { display: flex; flex-direction: column; align-items: center; gap: 10px; flex: none; }
@@ -368,56 +436,118 @@ h2 { font-size: 18px; line-height: 1.2; }
.cover {
display: grid;
place-items: center;
width: 48px; height: 48px;
width: 72px; height: 72px;
color: #fff;
background: linear-gradient(135deg, #2d6d9d, #247857);
border-radius: 10px;
font-size: 14px;
border-radius: 12px;
font-size: 18px;
font-weight: 800;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 18px rgba(0, 0, 0, 0.35);
}
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-body { min-width: 0; }
.card-body h3 { margin: 0 0 6px; overflow-wrap: anywhere; font-size: 16px; }
.card-body { min-width: 0; display: grid; gap: 10px; align-content: start; }
.card-body h3 { margin: 0; overflow-wrap: anywhere; font-size: 18px; line-height: 1.25; }
.subline { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--muted); font-size: 12px; }
.pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.tid {
color: var(--muted);
font-size: var(--fs-xs);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
margin-right: 2px;
}
.pills { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.versions {
/* Installed -> update -> newest, read left to right. */
.vtrack {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin-top: 10px;
font-size: 12px;
color: var(--muted);
align-items: stretch;
gap: 8px;
}
.versions b { color: var(--ink); font-weight: 700; }
.vstep {
display: grid;
align-content: start;
gap: 2px;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-2);
}
.vstep > span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.vstep > b { font-size: var(--fs-md); font-variant-numeric: tabular-nums; }
.vstep > em { color: var(--muted); font-size: var(--fs-xs); font-style: normal; }
.vstep.is-target { border-color: rgba(56, 193, 114, 0.45); background: var(--green-soft); }
.vstep.is-target > span { color: var(--green-dark); }
.vstep.is-dim { background: transparent; border-style: dashed; }
.vstep.is-dim > b { color: var(--muted); }
.vtrack-arrow { display: grid; place-items: center; color: var(--muted); }
.vtrack-arrow svg { width: 20px; height: 20px; }
/* A note that belongs to a card or dialog: icon, text, optional action. */
.callout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 6px 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-2);
color: var(--ink);
font-size: var(--fs-sm);
line-height: 1.45;
}
.callout > svg { flex: none; margin-top: 1px; color: var(--green-dark); }
.callout > span { flex: 1 1 220px; min-width: 0; }
.callout .link-button { flex: none; font-weight: 700; }
.callout.is-warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); }
.callout.is-warn > svg { color: var(--amber); }
.callout.is-quiet { color: var(--muted); }
.spinner {
flex: none;
width: 16px; height: 16px; margin-top: 2px;
border: 2px solid var(--border-strong);
border-top-color: var(--green);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.card-actions {
display: flex;
flex-direction: column;
gap: 8px;
flex: none;
width: 136px;
width: 168px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
min-height: 26px;
padding: 0 10px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
border-radius: 999px;
font-size: 12px;
font-weight: 800;
font-size: var(--fs-xs);
font-weight: 700;
}
.pill.ok { border-color: rgba(56, 193, 114, 0.34); background: var(--green-soft); color: var(--green-dark); }
.pill.warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
.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 {
@@ -431,6 +561,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.row-button {
width: 100%; /* fills the fixed-width actions column -> never reflows */
padding: 0 12px;
font-size: 15px;
}
/* go = green (Update / Resume / Install), pause = amber, cancel/stop = red */
.row-button.is-update { border-color: var(--green); color: #04150c; background: var(--green); }
@@ -439,8 +570,9 @@ h2 { font-size: 18px; line-height: 1.2; }
.row-button.is-pause:hover { background: var(--amber-dark); }
.row-button.is-cancel { border-color: var(--red); color: #1a0606; background: var(--red); }
.row-button.is-cancel:hover { background: #ff8a8a; }
.row-button.is-ghost { background: transparent; color: var(--muted); }
.row-button.is-ghost:hover { color: var(--ink); border-color: var(--muted); }
.row-button.is-ghost { background: transparent; color: var(--ink); border-color: var(--border-strong); }
.row-button.is-ghost:hover { background: var(--surface-2); border-color: var(--muted); }
.row-button.is-ghost svg { color: var(--muted); }
.row-button:disabled { opacity: 0.6; cursor: default; }
/* ---------------- in-tile download progress ---------------- */
@@ -459,13 +591,24 @@ 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;
justify-content: space-between;
gap: 4px 14px;
color: var(--muted);
font-size: 12px;
font-size: var(--fs-xs);
}
.progress-meta b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
@@ -475,7 +618,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.settings-grid { display: grid; gap: 12px; }
.field { display: grid; gap: 6px; }
.field span, .allowlist > span, .source-policy > span { color: var(--muted); font-size: 12px; font-weight: 700; }
.field span, .allowlist > span, .source-policy > span { color: var(--muted); font-size: var(--fs-xs); font-weight: 700; }
.field input, .field select {
min-height: 44px;
padding: 0 12px;
@@ -525,7 +668,7 @@ h2 { font-size: 18px; line-height: 1.2; }
color: var(--ink);
}
.switch-row > span:first-child strong { display: block; font-size: 14px; }
.switch-row > span:first-child em { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; font-style: normal; }
.switch-row > span:first-child em { display: block; margin-top: 3px; color: var(--muted); font-size: var(--fs-xs); font-style: normal; }
/* slider toggle (used for per-game enable + settings) */
.toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; flex: none; }
@@ -551,9 +694,40 @@ h2 { font-size: 18px; line-height: 1.2; }
.toggle input:checked + .track { background: var(--green-soft); border-color: var(--green); }
.toggle input:checked + .track::after { transform: translateX(18px); background: var(--green); }
.toggle input:focus-visible + .track { outline: 2px solid var(--blue); outline-offset: 2px; }
.toggle .label { font-size: 12px; color: var(--muted); min-width: 22px; }
.toggle .label { font-size: var(--fs-xs); color: var(--muted); min-width: 22px; }
.lead .toggle .label { white-space: nowrap; }
.allowlist, .source-policy { 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: var(--fs-sm); overflow-wrap: anywhere; }
.storage-row em { color: var(--muted); font-style: normal; font-size: var(--fs-xs); }
.storage-row b { font-variant-numeric: tabular-nums; }
.storage-empty { color: var(--muted); font-size: var(--fs-sm); }
.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; }
@@ -567,8 +741,8 @@ h2 { font-size: 18px; line-height: 1.2; }
border: 1px solid var(--border);
border-radius: 8px;
}
.policy-row strong { font-size: 13px; }
.policy-row em { overflow: hidden; color: var(--muted); font-size: 12px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.policy-row strong { font-size: var(--fs-sm); }
.policy-row em { overflow: hidden; color: var(--muted); font-size: var(--fs-xs); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.host-chip {
display: inline-flex;
@@ -578,7 +752,7 @@ h2 { font-size: 18px; line-height: 1.2; }
background: var(--blue-soft);
color: var(--blue);
border-radius: 999px;
font-size: 12px;
font-size: var(--fs-xs);
font-weight: 800;
}
@@ -593,7 +767,7 @@ pre {
border: 1px solid var(--border);
color: #dfe7ef;
border-radius: var(--radius);
font-size: 12px;
font-size: var(--fs-xs);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
@@ -629,8 +803,235 @@ 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.74);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
animation: fadeIn 160ms ease-out;
}
@keyframes fadeIn { from { opacity: 0; } }
@keyframes sheetIn { from { opacity: 0; transform: translateY(10px) scale(0.985); } }
.guide {
display: grid;
gap: 16px;
padding: 22px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
box-shadow: var(--shadow);
animation: sheetIn 220ms var(--ease);
}
@media (prefers-reduced-motion: reduce) {
.guide, .guide-backdrop { animation: none; }
}
/* Dialog with a long list: the header stays, the list scrolls. */
.guide-backdrop:has(.is-sheet) { grid-template-columns: minmax(0, 620px); }
.guide.is-sheet {
grid-template-rows: auto minmax(0, 1fr);
max-height: calc(100dvh - 48px);
padding: 0;
gap: 0;
overflow: hidden;
}
.is-sheet .guide-head { padding: 20px 22px 16px; border-bottom: 1px solid var(--border); }
.sheet-body { display: grid; gap: 18px; align-content: start; padding: 18px 22px 22px; overflow-y: auto; overscroll-behavior: contain; }
.sheet-title { display: flex; align-items: center; gap: 14px; min-width: 0; }
.sheet-title > div { min-width: 0; }
.cover.cover-sm { width: 56px; height: 56px; flex: none; border-radius: 10px; font-size: 16px; }
.vgroup { display: grid; gap: 8px; }
.vgroup h3 {
display: flex; align-items: center; gap: 8px;
margin: 0;
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.vgroup h3 span {
min-width: 22px; padding: 1px 7px; border-radius: 999px;
background: var(--surface-3); color: var(--ink);
font-size: 12px; letter-spacing: 0; text-align: center;
}
.vlist { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.vrow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
.vrow-main { display: grid; gap: 2px; min-width: 0; }
.vrow-main strong { font-size: 18px; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
.vrow-main span { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.vrow .row-button { width: auto; padding: 0 18px; }
.vrow.is-ok { border-color: rgba(56, 193, 114, 0.35); }
.vrow.is-ok:hover { background: var(--surface-3); }
.vrow.is-needs_fw .vrow-main strong { color: var(--muted); }
.vrow.is-installed { background: transparent; border-style: dashed; }
.vrow.is-older { padding: 8px 14px; background: transparent; }
.vrow.is-older .vrow-main strong { font-size: var(--fs-md); color: var(--muted); }
.vbadge {
display: inline-flex; align-items: center; gap: 6px;
min-height: 30px; padding: 0 12px;
border: 1px solid var(--border); border-radius: 999px;
font-size: var(--fs-xs); font-weight: 700; white-space: nowrap;
}
.vbadge svg { width: 15px; height: 15px; }
.vbadge.is-warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
.vbadge.is-ok { border-color: rgba(56, 193, 114, 0.34); background: var(--green-soft); color: var(--green-dark); }
.volder summary {
display: inline-flex; align-items: center; gap: 6px;
min-height: 40px; padding: 0 10px 0 4px;
border-radius: 8px;
color: var(--muted); font-size: var(--fs-sm); font-weight: 700;
cursor: pointer; list-style: none;
}
.volder summary::-webkit-details-marker { display: none; }
.volder summary:hover { color: var(--ink); }
.volder summary svg { transition: transform 150ms ease; }
.volder[open] summary svg { transform: rotate(90deg); }
.sheet-source { margin: 0; color: var(--muted); font-size: var(--fs-sm); }
.sheet-source a { display: inline-flex; align-items: center; gap: 4px; color: var(--blue-dark); text-decoration: none; }
.sheet-source a:hover { text-decoration: underline; }
.sheet-source svg { width: 14px; height: 14px; }
.vskeleton { display: grid; gap: 8px; }
.vskeleton i {
display: block; height: 62px; border-radius: 12px;
background: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 50%, var(--surface-2) 100%);
background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite;
}
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@media (prefers-reduced-motion: reduce) { .vskeleton i, .spinner { animation: none; } }
/* minmax(0, 1fr): without it an <input>'s intrinsic width pushes the tracks wider than the sheet */
.guide, .guide-steps, .guide-steps li { grid-template-columns: minmax(0, 1fr); }
.guide-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.guide-head > div { min-width: 0; }
.guide-head h2 { margin: 0; font-size: 22px; }
.guide-game { margin: 4px 0 0; color: var(--muted); }
.guide-close { flex: none; width: 44px; padding: 0; }
.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: var(--fs-sm); }
/* Console view: the PS5 can't copy links, so it only hands over to a phone. */
.guide.is-handoff { width: min(560px, 100%); justify-self: center; text-align: center; }
.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: var(--fs-sm);
}
.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: var(--fs-sm); }
@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: 20px; }
}
@media (max-width: 1024px) {
.app-shell { grid-template-columns: 1fr; }
.rail {
@@ -660,12 +1061,31 @@ pre {
.toolbar { grid-template-columns: 1fr; }
.segmented { overflow-x: auto; }
.segmented button { flex: 0 0 auto; }
.card-row { grid-template-columns: auto minmax(0, 1fr); }
.card-actions { grid-column: 1 / -1; width: 100%; flex-direction: row; }
.card-actions .row-button { flex: 1; }
/* The card body's parts join the card's grid, so the versions and notes
use the full width under the cover instead of a narrow column. */
.card-row { grid-template-columns: auto minmax(0, 1fr); column-gap: 14px; row-gap: 10px; }
.card-body { display: contents; }
.lead { grid-row: span 2; }
.card-body h3 { align-self: end; }
.card-body .pills { align-self: start; }
.vtrack, .card-body .callout, .card-actions { grid-column: 1 / -1; }
.cover { width: 64px; height: 64px; }
.card-actions { width: 100%; flex-direction: row; flex-wrap: wrap; }
.card-actions .row-button { flex: 1 1 140px; }
.vstep { flex: 1 1 110px; }
.global-dl-name { order: 3; flex-basis: 100%; }
.guide.is-sheet { max-height: none; }
.sheet-body { padding: 16px; }
.is-sheet .guide-head { padding: 16px; }
}
@media (max-width: 460px) {
.status-strip { grid-template-columns: 1fr; }
.status-strip { gap: 8px; }
.metric { padding: 12px; }
.metric strong { font-size: 17px; }
.topbar .eyebrow { display: none; }
.switch-row { align-items: flex-start; }
.vrow { flex-wrap: nowrap; padding: 12px; }
.vrow .row-button { padding: 0 14px; }
.vrow .row-button svg { display: none; }
}