4 Commits
48 changed files with 1274 additions and 54 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="2113.89" height="432" role="img" aria-label="Overdrive">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="440" height="89.92" role="img" aria-label="Overdrive">
<path d="M26.34 12.24A11 11 0 1 1 19.76 5.66" fill="none" stroke="#f2f2f3" stroke-width="4.5"/>
<path d="M28.4 3.6 17.6 18.1a2.9 2.9 0 1 1-3.7-3.7Z" fill="#f2f2f3"/>
<path d="M55.06 26.5Q52.34 26.5 50.22 25.17Q48.09 23.84 46.87 21.41Q45.66 18.98 45.66 15.66Q45.66 12.34 46.87 9.9Q48.09 7.47 50.22 6.14Q52.34 4.8 55.06 4.8Q57.78 4.8 59.9 6.14Q62.03 7.47 63.24 9.9Q64.45 12.34 64.45 15.66Q64.45 18.98 63.24 21.41Q62.03 23.84 59.9 25.17Q57.78 26.5 55.06 26.5ZM55.06 24.02Q56.94 24.02 58.48 23.06Q60.02 22.09 60.93 20.23Q61.84 18.37 61.84 15.66Q61.84 12.95 60.93 11.08Q60.02 9.21 58.48 8.24Q56.94 7.28 55.06 7.28Q53.16 7.28 51.62 8.24Q50.07 9.21 49.17 11.08Q48.26 12.95 48.26 15.66Q48.26 18.37 49.17 20.23Q50.07 22.09 51.62 23.06Q53.16 24.02 55.06 24.02ZM72.18 26.2L66.17 10.51L68.94 10.51L72.28 19.77Q72.8 21.17 73.17 22.56Q73.54 23.95 73.95 25.25L73.07 25.25Q73.47 23.95 73.84 22.56Q74.22 21.17 74.73 19.77L78.05 10.51L80.82 10.51L74.81 26.2L72.18 26.2ZM89.62 26.54Q87.34 26.54 85.7 25.52Q84.06 24.5 83.17 22.69Q82.29 20.88 82.29 18.47Q82.29 16.06 83.16 14.22Q84.03 12.38 85.62 11.34Q87.22 10.3 89.34 10.3Q90.66 10.3 91.89 10.74Q93.12 11.18 94.1 12.12Q95.07 13.07 95.65 14.57Q96.22 16.08 96.22 18.2L96.22 19.19L83.93 19.19L83.93 17.05L94.86 17.05L93.69 17.83Q93.69 16.29 93.19 15.09Q92.7 13.89 91.73 13.22Q90.76 12.54 89.34 12.54Q87.94 12.54 86.92 13.22Q85.9 13.91 85.36 15.03Q84.82 16.16 84.82 17.49L84.82 18.85Q84.82 20.58 85.42 21.8Q86.01 23.01 87.1 23.65Q88.19 24.29 89.64 24.29Q90.59 24.29 91.36 24.01Q92.13 23.72 92.68 23.17Q93.23 22.62 93.52 21.82L95.97 22.39Q95.61 23.61 94.73 24.55Q93.86 25.49 92.55 26.02Q91.25 26.54 89.62 26.54ZM99.09 26.2L99.09 10.51L101.53 10.51L101.53 12.93L101.67 12.93Q102.11 11.74 103.19 11.01Q104.28 10.28 105.69 10.28Q105.99 10.28 106.36 10.31Q106.73 10.33 106.94 10.34L106.94 12.88Q106.83 12.85 106.38 12.79Q105.93 12.73 105.42 12.73Q104.33 12.73 103.47 13.19Q102.61 13.65 102.11 14.48Q101.62 15.3 101.62 16.4L101.62 26.2L99.09 26.2ZM115 26.53Q113.03 26.53 111.53 25.53Q110.03 24.53 109.18 22.7Q108.34 20.88 108.34 18.4Q108.34 15.92 109.18 14.1Q110.03 12.28 111.53 11.29Q113.04 10.3 115.01 10.3Q116.45 10.3 117.36 10.77Q118.27 11.25 118.79 11.88Q119.31 12.51 119.61 13L119.78 13L119.78 5.1L122.3 5.1L122.3 26.2L119.87 26.2L119.87 23.78L119.63 23.78Q119.31 24.29 118.78 24.93Q118.24 25.58 117.33 26.05Q116.43 26.53 115 26.53ZM115.38 24.27Q116.81 24.27 117.81 23.52Q118.8 22.77 119.32 21.44Q119.84 20.11 119.84 18.37Q119.84 16.63 119.33 15.32Q118.82 14.02 117.82 13.29Q116.82 12.56 115.38 12.56Q113.91 12.56 112.91 13.32Q111.91 14.08 111.41 15.39Q110.9 16.7 110.9 18.37Q110.9 20.05 111.41 21.39Q111.92 22.73 112.93 23.5Q113.93 24.27 115.38 24.27ZM126 26.2L126 10.51L128.43 10.51L128.43 12.93L128.57 12.93Q129.01 11.74 130.1 11.01Q131.18 10.28 132.6 10.28Q132.89 10.28 133.26 10.31Q133.63 10.33 133.84 10.34L133.84 12.88Q133.73 12.85 133.28 12.79Q132.84 12.73 132.33 12.73Q131.24 12.73 130.37 13.19Q129.51 13.65 129.01 14.48Q128.52 15.3 128.52 16.4L128.52 26.2L126 26.2ZM136.01 26.2L136.01 10.51L138.53 10.51L138.53 26.2L136.01 26.2ZM137.27 7.88Q136.56 7.88 136.04 7.38Q135.53 6.89 135.53 6.19Q135.53 5.48 136.04 5Q136.56 4.52 137.27 4.52Q137.99 4.52 138.51 5Q139.02 5.48 139.02 6.19Q139.02 6.89 138.51 7.38Q137.99 7.88 137.27 7.88ZM146.77 26.2L140.76 10.51L143.53 10.51L146.87 19.77Q147.39 21.17 147.76 22.56Q148.13 23.95 148.54 25.25L147.66 25.25Q148.06 23.95 148.43 22.56Q148.81 21.17 149.32 19.77L152.65 10.51L155.41 10.51L149.4 26.2L146.77 26.2ZM164.21 26.54Q161.93 26.54 160.29 25.52Q158.65 24.5 157.76 22.69Q156.88 20.88 156.88 18.47Q156.88 16.06 157.75 14.22Q158.62 12.38 160.21 11.34Q161.81 10.3 163.93 10.3Q165.25 10.3 166.48 10.74Q167.71 11.18 168.69 12.12Q169.67 13.07 170.24 14.57Q170.81 16.08 170.81 18.2L170.81 19.19L158.52 19.19L158.52 17.05L169.45 17.05L168.28 17.83Q168.28 16.29 167.78 15.09Q167.29 13.89 166.32 13.22Q165.35 12.54 163.93 12.54Q162.53 12.54 161.51 13.22Q160.49 13.91 159.95 15.03Q159.41 16.16 159.41 17.49L159.41 18.85Q159.41 20.58 160.01 21.8Q160.6 23.01 161.69 23.65Q162.78 24.29 164.23 24.29Q165.18 24.29 165.95 24.01Q166.72 23.72 167.27 23.17Q167.82 22.62 168.11 21.82L170.56 22.39Q170.2 23.61 169.33 24.55Q168.45 25.49 167.14 26.02Q165.84 26.54 164.21 26.54Z" fill="#f2f2f3" stroke="#f2f2f3" stroke-width="0.9" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="2113.89" height="432" role="img" aria-label="Overdrive">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="440" height="89.92" role="img" aria-label="Overdrive">
<path d="M26.34 12.24A11 11 0 1 1 19.76 5.66" fill="none" stroke="#0b0b0d" stroke-width="4.5"/>
<path d="M28.4 3.6 17.6 18.1a2.9 2.9 0 1 1-3.7-3.7Z" fill="#0b0b0d"/>
<path d="M55.06 26.5Q52.34 26.5 50.22 25.17Q48.09 23.84 46.87 21.41Q45.66 18.98 45.66 15.66Q45.66 12.34 46.87 9.9Q48.09 7.47 50.22 6.14Q52.34 4.8 55.06 4.8Q57.78 4.8 59.9 6.14Q62.03 7.47 63.24 9.9Q64.45 12.34 64.45 15.66Q64.45 18.98 63.24 21.41Q62.03 23.84 59.9 25.17Q57.78 26.5 55.06 26.5ZM55.06 24.02Q56.94 24.02 58.48 23.06Q60.02 22.09 60.93 20.23Q61.84 18.37 61.84 15.66Q61.84 12.95 60.93 11.08Q60.02 9.21 58.48 8.24Q56.94 7.28 55.06 7.28Q53.16 7.28 51.62 8.24Q50.07 9.21 49.17 11.08Q48.26 12.95 48.26 15.66Q48.26 18.37 49.17 20.23Q50.07 22.09 51.62 23.06Q53.16 24.02 55.06 24.02ZM72.18 26.2L66.17 10.51L68.94 10.51L72.28 19.77Q72.8 21.17 73.17 22.56Q73.54 23.95 73.95 25.25L73.07 25.25Q73.47 23.95 73.84 22.56Q74.22 21.17 74.73 19.77L78.05 10.51L80.82 10.51L74.81 26.2L72.18 26.2ZM89.62 26.54Q87.34 26.54 85.7 25.52Q84.06 24.5 83.17 22.69Q82.29 20.88 82.29 18.47Q82.29 16.06 83.16 14.22Q84.03 12.38 85.62 11.34Q87.22 10.3 89.34 10.3Q90.66 10.3 91.89 10.74Q93.12 11.18 94.1 12.12Q95.07 13.07 95.65 14.57Q96.22 16.08 96.22 18.2L96.22 19.19L83.93 19.19L83.93 17.05L94.86 17.05L93.69 17.83Q93.69 16.29 93.19 15.09Q92.7 13.89 91.73 13.22Q90.76 12.54 89.34 12.54Q87.94 12.54 86.92 13.22Q85.9 13.91 85.36 15.03Q84.82 16.16 84.82 17.49L84.82 18.85Q84.82 20.58 85.42 21.8Q86.01 23.01 87.1 23.65Q88.19 24.29 89.64 24.29Q90.59 24.29 91.36 24.01Q92.13 23.72 92.68 23.17Q93.23 22.62 93.52 21.82L95.97 22.39Q95.61 23.61 94.73 24.55Q93.86 25.49 92.55 26.02Q91.25 26.54 89.62 26.54ZM99.09 26.2L99.09 10.51L101.53 10.51L101.53 12.93L101.67 12.93Q102.11 11.74 103.19 11.01Q104.28 10.28 105.69 10.28Q105.99 10.28 106.36 10.31Q106.73 10.33 106.94 10.34L106.94 12.88Q106.83 12.85 106.38 12.79Q105.93 12.73 105.42 12.73Q104.33 12.73 103.47 13.19Q102.61 13.65 102.11 14.48Q101.62 15.3 101.62 16.4L101.62 26.2L99.09 26.2ZM115 26.53Q113.03 26.53 111.53 25.53Q110.03 24.53 109.18 22.7Q108.34 20.88 108.34 18.4Q108.34 15.92 109.18 14.1Q110.03 12.28 111.53 11.29Q113.04 10.3 115.01 10.3Q116.45 10.3 117.36 10.77Q118.27 11.25 118.79 11.88Q119.31 12.51 119.61 13L119.78 13L119.78 5.1L122.3 5.1L122.3 26.2L119.87 26.2L119.87 23.78L119.63 23.78Q119.31 24.29 118.78 24.93Q118.24 25.58 117.33 26.05Q116.43 26.53 115 26.53ZM115.38 24.27Q116.81 24.27 117.81 23.52Q118.8 22.77 119.32 21.44Q119.84 20.11 119.84 18.37Q119.84 16.63 119.33 15.32Q118.82 14.02 117.82 13.29Q116.82 12.56 115.38 12.56Q113.91 12.56 112.91 13.32Q111.91 14.08 111.41 15.39Q110.9 16.7 110.9 18.37Q110.9 20.05 111.41 21.39Q111.92 22.73 112.93 23.5Q113.93 24.27 115.38 24.27ZM126 26.2L126 10.51L128.43 10.51L128.43 12.93L128.57 12.93Q129.01 11.74 130.1 11.01Q131.18 10.28 132.6 10.28Q132.89 10.28 133.26 10.31Q133.63 10.33 133.84 10.34L133.84 12.88Q133.73 12.85 133.28 12.79Q132.84 12.73 132.33 12.73Q131.24 12.73 130.37 13.19Q129.51 13.65 129.01 14.48Q128.52 15.3 128.52 16.4L128.52 26.2L126 26.2ZM136.01 26.2L136.01 10.51L138.53 10.51L138.53 26.2L136.01 26.2ZM137.27 7.88Q136.56 7.88 136.04 7.38Q135.53 6.89 135.53 6.19Q135.53 5.48 136.04 5Q136.56 4.52 137.27 4.52Q137.99 4.52 138.51 5Q139.02 5.48 139.02 6.19Q139.02 6.89 138.51 7.38Q137.99 7.88 137.27 7.88ZM146.77 26.2L140.76 10.51L143.53 10.51L146.87 19.77Q147.39 21.17 147.76 22.56Q148.13 23.95 148.54 25.25L147.66 25.25Q148.06 23.95 148.43 22.56Q148.81 21.17 149.32 19.77L152.65 10.51L155.41 10.51L149.4 26.2L146.77 26.2ZM164.21 26.54Q161.93 26.54 160.29 25.52Q158.65 24.5 157.76 22.69Q156.88 20.88 156.88 18.47Q156.88 16.06 157.75 14.22Q158.62 12.38 160.21 11.34Q161.81 10.3 163.93 10.3Q165.25 10.3 166.48 10.74Q167.71 11.18 168.69 12.12Q169.67 13.07 170.24 14.57Q170.81 16.08 170.81 18.2L170.81 19.19L158.52 19.19L158.52 17.05L169.45 17.05L168.28 17.83Q168.28 16.29 167.78 15.09Q167.29 13.89 166.32 13.22Q165.35 12.54 163.93 12.54Q162.53 12.54 161.51 13.22Q160.49 13.91 159.95 15.03Q159.41 16.16 159.41 17.49L159.41 18.85Q159.41 20.58 160.01 21.8Q160.6 23.01 161.69 23.65Q162.78 24.29 164.23 24.29Q165.18 24.29 165.95 24.01Q166.72 23.72 167.27 23.17Q167.82 22.62 168.11 21.82L170.56 22.39Q170.2 23.61 169.33 24.55Q168.45 25.49 167.14 26.02Q165.84 26.54 164.21 26.54Z" fill="#0b0b0d" stroke="#0b0b0d" stroke-width="0.9" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+57 -2
View File
@@ -223,6 +223,7 @@ screen. In the other order ShadowMount+ finds the file again and puts the icon b
| `app/` | The application: JSX sources, state, assets, catalog data, and `app.json` (title id `PPSA99058`, version, capabilities) |
| `ps5-react/` | The framework it runs on, vendored from [half144/ps5-react](https://github.com/half144/ps5-react) |
| `packaging/make-exfat.sh` | Builds the exFAT image ShadowMount+ installs |
| `packaging/make-exfat-mac.sh` | The same image, built locally on macOS without `update.zip` |
| `.github/workflows/build.yml` | The build |
</details>
@@ -249,8 +250,9 @@ What a bundler throws away could not come back: most comments, the JSX syntax (i
Everything runs from the repository root through npm. Once after cloning, `npm run setup`
(`packaging/setup.sh`) installs every dependency: on Linux the apt packages for the desktop preview,
the PS5 build and the tests (it asks for `sudo` once), on macOS the Homebrew formulae; then Pillow
and the framework's npm packages. Read the script first if you'd rather install them yourself.
the PS5 build and the tests (it asks for `sudo` once); on macOS the Homebrew formulae, a
`ps5-react/.venv` with Pillow (Homebrew's `python3` refuses a global `pip install`) and the
framework's npm packages. Read the script first if you'd rather install them yourself.
```sh
git clone https://github.com/MyLuxy/overdrive.git
@@ -289,6 +291,42 @@ upstream ps5-react supports macOS only.
</details>
<details>
<summary><b>Developing on macOS</b></summary>
<br>
The preview and the PS5 build run on a Mac with Xcode's command line tools, Homebrew, Node 18 or
newer and Python 3.9 or newer. `npm run setup` installs the Homebrew formulae (CMake, LLVM 18,
SDL2, ninja, nasm, libarchive, xz, OpenSSL), creates `ps5-react/.venv` with Pillow, and installs the
framework's npm packages. Homebrew's `python3` is externally managed, so Pillow goes in the venv
rather than a global `pip install`.
```sh
xcode-select --install # once, if you have never built anything on this Mac
npm run setup # Homebrew formulae, ps5-react/.venv, framework npm packages
npm run preview:mac # the desktop preview
```
The `:mac` commands leave `preview`, `dev`, `test` and `build` untouched: those remain the ones
Linux and the other developers use. `preview:mac` and `dev:mac` run through `ps5-react/.venv`, put
LLVM 18 and Node on `PATH`, and prefer MacPorts at `/opt/local` with an SDL2 built in
`~/.local/overdrive-sdl2` when they exist. In that case `/usr/local/bin` is left out of `PATH`, so
CMake does not mix Homebrew's OpenSSL/CURL with the MacPorts copies and break the link.
| Command | What it does |
| --- | --- |
| `npm run setup:mac` | Creates `ps5-react/.venv` with Pillow when the native libraries are already installed |
| `npm run preview:mac` | Opens the desktop preview once |
| `npm run dev:mac` | Opens it and rebuilds on every save under `app/` |
| `npm run doctor:mac` | Checks the venv, Pillow, LLVM 18, Node, CMake and clang |
If the preview does not start: `npm run doctor:mac` first; `brew install llvm@18` when LLVM is
missing; `brew install sdl2` or `CMAKE_PREFIX_PATH=$HOME/.local/overdrive-sdl2` when SDL2 is
missing; and keep Homebrew's `/usr/local/bin` out of `PATH` when a link error mentions OpenSSL or
CURL and MacPorts supplies CMake.
</details>
<details>
<summary><b>Building</b></summary>
<br>
@@ -311,6 +349,23 @@ downloads its pinned dependencies into `ps5-react/.deps` and writes the app fold
`.receipt.json` into `ps5-react/dist`. It verifies the result itself (`tools/verify.py`), so a
green build means the payload structure and signatures check out.
On macOS, `npm run build:mac` does the same app build through `ps5-react/.venv` (Pillow for
`verify.py`) and LLVM 18, without `sudo` and without packaging. The image is a separate,
local-only step: `packaging/make-exfat-mac.sh` uses macOS's `newfs_exfat` and `hdiutil`, writes
`Overdrive.exfat` alone and **no `update.zip`**. The CI keeps using `packaging/make-exfat.sh` on
Ubuntu and still produces both files.
```sh
npm run build:mac # the PS5 app only, in ps5-react/dist/PPSA99058
npm run package:mac # Overdrive.exfat from the app already built
npm run image:mac # both, in one go
```
`npm run package:mac` overwrites `Overdrive.exfat` in the repository root, so keep a copy of the
image you had before. It checks the filesystem with `fsck_exfat` and that every file of the app is
in the image; `fsck_exfat -n Overdrive.exfat` checks it again afterwards. The image keeps the same
64 KiB clusters and `OVERDRIVE` label as the Linux one.
The image is a plain exFAT volume whose root is the app folder, with 64 KiB clusters and the
label `OVERDRIVE`. To release a new version, raise the version in `app/version.js` and
`app/app.json`, run the workflow, test the draft's image on a console, then publish the draft:
+1 -1
View File
@@ -102,7 +102,7 @@ import { Text, View } from '@ps5-react/core';
const problem = job2.error && explainError(job2.error, Boolean(release.parts));
// The files' own title id differs from the game chosen: said before anything else but an error.
const mismatch = job2.titleMismatch ? t2("downloads.titleMismatch", { found: job2.titleMismatch, expected: job2.titleId }) : "";
const note = problem ? problem.action ? t2("downloads.problem", { title: problem.title, action: problem.action }) : problem.title : mismatch || (state === "downloaded" ? job2.message || t2("downloads.installHint") : state === "extracted" ? job2.message || "" : "");
const note = problem ? problem.action ? t2("downloads.problem", { title: problem.title, action: problem.action }) : problem.title : mismatch || (state === "downloaded" ? job2.message || t2("downloads.installHint") : state === "extracted" || job2.streamed ? job2.message || "" : "");
const cdn = isOverdriveCdn(release.provider);
return /* @__PURE__ */ (0, jsxs)(View, { style: cdn ? { position: "relative", borderRadius: 18, backgroundColor: "#17171a" } : { overflow: "hidden", borderRadius: 18, borderWidth: 2, borderColor: "#2a2a2f", backgroundColor: "#17171a" }, children: [
cdn ? /* @__PURE__ */ (0, jsx)(CdnAura, { radius: 12, width: 904 }) : null,
+3 -1
View File
@@ -22,11 +22,13 @@ import { COMPLETE } from '../state/download-queue.js';
"installer-required",
"installing",
"installed",
"streamed",
"extraction-required",
"removing"
]);
var WAITING = ["resolving", "connecting", "verifying", "pausing", "finishing", "removing"];
var shownState = (job2) => job2.state === "extracting" && job2.phase === "finishing" ? "finishing" : job2.state;
// A PKG handed to the console's own downloads is done here, but not installed yet.
var shownState = (job2) => job2.state === "extracting" && job2.phase === "finishing" ? "finishing" : job2.state === "installed" && job2.streamed ? "streamed" : job2.state;
var hasStateLabel = (state) => LABELED.has(state);
var stateLabel = (state) => t(`downloadState.${state}`);
var clearAction = (state) => COMPLETE.includes(state) ? "remove" : "cancel";
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "تم تحميل الملف؛ تفقد صيغته",
"downloadState.finishing": "جاري الإنهاء…",
"downloadState.installed": "تم التثبيت",
"downloadState.streamed": "أُرسل إلى PS5",
"downloadState.installer-required": "تم التنزيل؛ جاهز للتثبيت",
"downloadState.installing": "جارٍ التثبيت",
"downloadState.paused": "مؤقف",
@@ -116,11 +117,13 @@
"downloads.installHint": "أغلق Overdrive لإنهاء: ShadowMount يثبت بمجرد عدم فتح أي لعبة.",
"downloads.installRequested": "تم طلب التثبيت. أغلق Overdrive لإنهاء: ShadowMount يثبت بمجرد عدم فتح أي لعبة.",
"downloads.installed": "تم التثبيت. اللعبة على الشاشة الرئيسية.",
"downloads.streamed": "يقوم PS5 بتنزيله وتثبيته: تابعه في التنزيلات. يمكنك إغلاق Overdrive.",
"downloads.installedFreed": "تم التثبيت. حُذف ملف PKG: تم تحرير {size}.",
"downloads.notice.complete": "اكتمل التحميل",
"downloads.notice.confirm": "تم العثور على ملف، أكّده في Overdrive",
"downloads.notice.failed": "فشل التحميل",
"downloads.notice.installed": "تم تثبيت اللعبة",
"downloads.notice.streamed": "أُرسل إلى تنزيلات PS5",
"downloads.packagesExtracted": "تم استخراج الحزم إلى {path}. مثبت PKG مطلوب.",
"downloads.panel.activity": "النشاط",
"downloads.panel.average": "متوسط",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "ফাইল ডাউনলোড হয়েছে; এর ফর্ম্যাট পরীক্ষা করুন",
"downloadState.finishing": "শেষ করছি…",
"downloadState.installed": "ইনস্টল হয়েছে",
"downloadState.streamed": "PS5-এ পাঠানো হয়েছে",
"downloadState.installer-required": "ডাউনলোড হয়েছে; ইনস্টলের জন্য প্রস্তুত",
"downloadState.installing": "ইনস্টল হচ্ছে",
"downloadState.paused": "থেমে গেছে",
@@ -116,11 +117,13 @@
"downloads.installHint": "Overdrive বন্ধ করুন যাতে এটি শেষ হয়: ShadowMount ইনস্টল করে একবার কোনো গেম খোলা না থাকে।",
"downloads.installRequested": "ইনস্টলের অনুরোধ করা হয়েছে। Overdrive বন্ধ করুন: কোনো গেম খোলা না থাকলে ShadowMount ইনস্টল করে।",
"downloads.installed": "ইনস্টল হয়েছে। এটি হোম স্ক্রিনে আছে।",
"downloads.streamed": "PS5 এটি ডাউনলোড ও ইনস্টল করে: ডাউনলোডে দেখুন। আপনি Overdrive বন্ধ করতে পারেন।",
"downloads.installedFreed": "ইনস্টল হয়েছে। PKG মুছে ফেলা হয়েছে: {size} খালি হয়েছে।",
"downloads.notice.complete": "ডাউনলোড সম্পূর্ণ",
"downloads.notice.confirm": "ফাইল পাওয়া গেছে, Overdrive-এ নিশ্চিত করুন",
"downloads.notice.failed": "ডাউনলোড ব্যর্থ",
"downloads.notice.installed": "গেম ইনস্টল হয়েছে",
"downloads.notice.streamed": "PS5-এর ডাউনলোডে পাঠানো হয়েছে",
"downloads.packagesExtracted": "প্যাকেজ {path} এ বের করা হয়েছে। একটি PKG ইনস্টলার প্রয়োজন।",
"downloads.panel.activity": "কার্যকলাপ",
"downloads.panel.average": "গড়",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Datei heruntergeladen; Format überprüfen",
"downloadState.finishing": "Wird abgeschlossen…",
"downloadState.installed": "Installiert",
"downloadState.streamed": "An die PS5 gesendet",
"downloadState.installer-required": "Heruntergeladen; bereit zur Installation",
"downloadState.installing": "Wird installiert",
"downloadState.paused": "Pausiert",
@@ -116,11 +117,13 @@
"downloads.installHint": "Schließen Sie Overdrive, um zu beenden: ShadowMount installiert, sobald kein Spiel offen ist.",
"downloads.installRequested": "Installation angefordert. Schließen Sie Overdrive, um zu beenden: ShadowMount installiert, sobald kein Spiel offen ist.",
"downloads.installed": "Installiert. Es ist auf dem Startbildschirm.",
"downloads.streamed": "Die PS5 lädt und installiert es: Verfolge es unter Downloads. Du kannst Overdrive schließen.",
"downloads.installedFreed": "Installiert. Die PKG wurde gelöscht: {size} frei.",
"downloads.notice.complete": "Download abgeschlossen",
"downloads.notice.confirm": "Datei gefunden, bestätige sie in Overdrive",
"downloads.notice.failed": "Download fehlgeschlagen",
"downloads.notice.installed": "Spiel installiert",
"downloads.notice.streamed": "An die PS5-Downloads gesendet",
"downloads.packagesExtracted": "Pakete zu {path} extrahiert. PKG-Installationstool erforderlich.",
"downloads.panel.activity": "AKTIVITÄT",
"downloads.panel.average": "Durchschnitt",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "File downloaded; inspect its format",
"downloadState.finishing": "Finishing up\u2026",
"downloadState.installed": "Installed",
"downloadState.streamed": "Sent to the PS5",
"downloadState.installer-required": "Downloaded; ready to install",
"downloadState.installing": "Installing",
"downloadState.paused": "Paused",
@@ -118,12 +119,14 @@
"downloads.titleMismatch": "The files are {found}, the game is {expected}: another game, or another region?",
"downloads.installRequested": "Install requested. Close Overdrive to finish: ShadowMount installs once no game is open.",
"downloads.installed": "Installed. It's on the home screen.",
"downloads.streamed": "The PS5 downloads and installs it: follow it in its Downloads. You can close Overdrive.",
"downloads.installedFreed": "Installed. The PKG was deleted: {size} freed.",
"downloads.notice.captured": "Link captured, return to Overdrive",
"downloads.notice.confirm": "File found, confirm it in Overdrive",
"downloads.notice.complete": "Download complete",
"downloads.notice.failed": "Download failed",
"downloads.notice.installed": "Game installed",
"downloads.notice.streamed": "Sent to the PS5's downloads",
"downloads.packagesExtracted": "Packages extracted to {path}. A PKG installer is required.",
"downloads.panel.activity": "ACTIVITY",
"downloads.panel.average": "Average",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Archivo descargado; inspecciona su formato",
"downloadState.finishing": "Finalizando…",
"downloadState.installed": "Instalado",
"downloadState.streamed": "Enviado a la PS5",
"downloadState.installer-required": "Descargado; listo para instalar",
"downloadState.installing": "Instalando",
"downloadState.paused": "Pausado",
@@ -116,11 +117,13 @@
"downloads.installHint": "Cierra Overdrive para terminar: ShadowMount instala cuando ningún juego está abierto.",
"downloads.installRequested": "Instalación solicitada. Cierra Overdrive para terminar: ShadowMount instala cuando ningún juego está abierto.",
"downloads.installed": "Instalado. Ya está en la pantalla de inicio.",
"downloads.streamed": "La PS5 lo descarga e instala: síguelo en sus Descargas. Puedes cerrar Overdrive.",
"downloads.installedFreed": "Instalado. Se eliminó el PKG: {size} liberados.",
"downloads.notice.complete": "Descarga completada",
"downloads.notice.confirm": "Archivo encontrado, confírmalo en Overdrive",
"downloads.notice.failed": "Descarga fallida",
"downloads.notice.installed": "Juego instalado",
"downloads.notice.streamed": "Enviado a las descargas de la PS5",
"downloads.packagesExtracted": "Paquetes extraídos a {path}. Se requiere un instalador PKG.",
"downloads.panel.activity": "ACTIVIDAD",
"downloads.panel.average": "Promedio",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "فایل دانلود شد؛ فرمتش را بررسی کنید",
"downloadState.finishing": "در حال اتمام…",
"downloadState.installed": "نصب شد",
"downloadState.streamed": "به PS5 فرستاده شد",
"downloadState.installer-required": "PKG را نصب کنید",
"downloadState.installing": "در حال نصب…",
"downloadState.paused": "متوقف‌شده",
@@ -116,12 +117,14 @@
"downloads.installHint": "Overdrive را ببندید تا تمام شود: ShadowMount وقتی هیچ بازی باز نباشد نصب می‌کند.",
"downloads.installRequested": "نصب درخواست شد. Overdrive را ببندید تا تمام شود: ShadowMount وقتی هیچ بازی باز نباشد نصب می‌کند.",
"downloads.installed": "نصب شد. روی صفحهٔ اصلی است.",
"downloads.streamed": "PS5 آن را دانلود و نصب می‌کند: در بخش دانلودها دنبالش کنید. می‌توانید Overdrive را ببندید.",
"downloads.installedFreed": "نصب شد. فایل PKG حذف شد: {size} آزاد شد.",
"downloads.notice.captured": "لینک گرفته شد، به Overdrive برگردید",
"downloads.notice.confirm": "فایل پیدا شد، آن را در Overdrive تأیید کنید",
"downloads.notice.complete": "دانلود کامل شد",
"downloads.notice.failed": "دانلود ناموفق بود",
"downloads.notice.installed": "بازی نصب شد",
"downloads.notice.streamed": "به دانلودهای PS5 فرستاده شد",
"downloads.packagesExtracted": "پکیج‌ها به {path} استخراج شدند. نصب‌کنندهٔ PKG لازم است.",
"downloads.panel.activity": "فعالیت",
"downloads.panel.average": "میانگین",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Fichier téléchargé ; inspectez son format",
"downloadState.finishing": "Finalisation…",
"downloadState.installed": "Installé",
"downloadState.streamed": "Envoyé à la PS5",
"downloadState.installer-required": "Téléchargé ; prêt à installer",
"downloadState.installing": "Installation",
"downloadState.paused": "En pause",
@@ -116,11 +117,13 @@
"downloads.installHint": "Fermez Overdrive pour terminer : ShadowMount installe quand aucun jeu n'est ouvert.",
"downloads.installRequested": "Installation demandée. Fermez Overdrive pour terminer : ShadowMount installe quand aucun jeu n'est ouvert.",
"downloads.installed": "Installé. Il est sur l'écran d'accueil.",
"downloads.streamed": "La PS5 le télécharge et l'installe : suivez-le dans ses Téléchargements. Vous pouvez fermer Overdrive.",
"downloads.installedFreed": "Installé. Le PKG a été supprimé : {size} libérés.",
"downloads.notice.complete": "Téléchargement terminé",
"downloads.notice.confirm": "Fichier trouvé, confirmez-le dans Overdrive",
"downloads.notice.failed": "Téléchargement échoué",
"downloads.notice.installed": "Jeu installé",
"downloads.notice.streamed": "Envoyé aux téléchargements de la PS5",
"downloads.packagesExtracted": "Paquets extraits vers {path}. Un installateur PKG est nécessaire.",
"downloads.panel.activity": "ACTIVITÉ",
"downloads.panel.average": "Moyenne",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "फ़ाइल डाउनलोड हुई; इसके प्रारूप का निरीक्षण करें",
"downloadState.finishing": "पूरा कर रहे हैं…",
"downloadState.installed": "इंस्टॉल हो गया",
"downloadState.streamed": "PS5 को भेजा गया",
"downloadState.installer-required": "डाउनलोड हो गया; इंस्टॉल के लिए तैयार",
"downloadState.installing": "इंस्टॉल हो रहा है",
"downloadState.paused": "रोका गया",
@@ -116,11 +117,13 @@
"downloads.installHint": "पूरा करने के लिए Overdrive बंद करें: कोई गेम खुला न होने पर ShadowMount इंस्टॉल करता है।",
"downloads.installRequested": "इंस्टॉल का अनुरोध भेजा गया। Overdrive बंद करें: कोई गेम खुला न होने पर ShadowMount इंस्टॉल करता है।",
"downloads.installed": "इंस्टॉल हो गया। यह होम स्क्रीन पर है।",
"downloads.streamed": "PS5 इसे डाउनलोड और इंस्टॉल करता है: डाउनलोड में देखें। आप Overdrive बंद कर सकते हैं।",
"downloads.installedFreed": "इंस्टॉल हो गया। PKG हटा दिया गया: {size} खाली हुआ।",
"downloads.notice.complete": "डाउनलोड पूर्ण",
"downloads.notice.confirm": "फ़ाइल मिली, Overdrive में इसकी पुष्टि करें",
"downloads.notice.failed": "डाउनलोड विफल",
"downloads.notice.installed": "गेम इंस्टॉल हो गया",
"downloads.notice.streamed": "PS5 के डाउनलोड में भेजा गया",
"downloads.packagesExtracted": "पैकेज {path} में निकाले गए। एक PKG इंस्टॉलर की आवश्यकता है।",
"downloads.panel.activity": "गतिविधि",
"downloads.panel.average": "औसत",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "File diunduh; periksa formatnya",
"downloadState.finishing": "Menyelesaikan…",
"downloadState.installed": "Terpasang",
"downloadState.streamed": "Dikirim ke PS5",
"downloadState.installer-required": "Terunduh; siap dipasang",
"downloadState.installing": "Memasang",
"downloadState.paused": "Dijeda",
@@ -116,11 +117,13 @@
"downloads.installHint": "Tutup Overdrive untuk menyelesaikan: ShadowMount memasang setelah tidak ada game yang terbuka.",
"downloads.installRequested": "Pemasangan diminta. Tutup Overdrive untuk menyelesaikan: ShadowMount memasang setelah tidak ada game yang terbuka.",
"downloads.installed": "Terpasang. Sudah ada di layar beranda.",
"downloads.streamed": "PS5 mengunduh dan memasangnya: pantau di Unduhan. Overdrive boleh ditutup.",
"downloads.installedFreed": "Terpasang. PKG sudah dihapus: {size} dikosongkan.",
"downloads.notice.complete": "Unduhan selesai",
"downloads.notice.confirm": "File ditemukan, konfirmasi di Overdrive",
"downloads.notice.failed": "Unduhan gagal",
"downloads.notice.installed": "Game terpasang",
"downloads.notice.streamed": "Dikirim ke unduhan PS5",
"downloads.packagesExtracted": "Paket diekstrak ke {path}. Installer PKG diperlukan.",
"downloads.panel.activity": "AKTIVITAS",
"downloads.panel.average": "Rata-rata",
+3
View File
@@ -99,6 +99,7 @@
"downloadState.file-downloaded": "File scaricato; controllane il formato",
"downloadState.finishing": "Ultimi ritocchi…",
"downloadState.installed": "Installato",
"downloadState.streamed": "Inviato alla PS5",
"downloadState.installer-required": "Scaricato; pronto da installare",
"downloadState.installing": "Installazione",
"downloadState.paused": "In pausa",
@@ -130,12 +131,14 @@
"downloads.titleMismatch": "I file sono {found}, il gioco è {expected}: un altro gioco o un'altra regione?",
"downloads.installRequested": "Installazione richiesta. Chiudi Overdrive per completare: ShadowMount installa quando nessun gioco è aperto.",
"downloads.installed": "Installato. Lo trovi nella schermata Home.",
"downloads.streamed": "La PS5 lo scarica e lo installa: seguilo nei suoi Download. Puoi chiudere Overdrive.",
"downloads.installedFreed": "Installato. Il PKG è stato eliminato: {size} liberati.",
"downloads.notice.captured": "Link acquisito, torna su Overdrive",
"downloads.notice.confirm": "File trovato, confermalo su Overdrive",
"downloads.notice.complete": "Download completato",
"downloads.notice.failed": "Download non riuscito",
"downloads.notice.installed": "Gioco installato",
"downloads.notice.streamed": "Inviato ai download della PS5",
"downloads.packagesExtracted": "Pacchetti estratti in {path}. Serve un installer di PKG.",
"downloads.panel.activity": "ATTIVITÀ",
"downloads.panel.average": "Media",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "ダウンロード済み・形式を確認してください",
"downloadState.finishing": "最終処理中…",
"downloadState.installed": "インストール済み",
"downloadState.streamed": "PS5に送信済み",
"downloadState.installer-required": "ダウンロード済み・インストール可能",
"downloadState.installing": "インストール中",
"downloadState.paused": "一時停止中",
@@ -116,11 +117,13 @@
"downloads.installHint": "Overdriveを終了すると完了します。ShadowMountはゲームが開いていないときにインストールします。",
"downloads.installRequested": "インストールを受け付けました。Overdriveを終了すると完了します。ShadowMountはゲームが開いていないときにインストールします。",
"downloads.installed": "インストールしました。ホーム画面にあります。",
"downloads.streamed": "PS5がダウンロードしてインストールします。ダウンロード画面で確認できます。Overdriveは閉じてもかまいません。",
"downloads.installedFreed": "インストールしました。PKGを削除し、{size}を空けました。",
"downloads.notice.complete": "ダウンロード完了",
"downloads.notice.confirm": "ファイルが見つかりました。Overdrive で確認してください",
"downloads.notice.failed": "ダウンロード失敗",
"downloads.notice.installed": "ゲームをインストールしました",
"downloads.notice.streamed": "PS5のダウンロードに送信しました",
"downloads.packagesExtracted": "パッケージを{path}に展開しました。PKGインストーラーが必要です。",
"downloads.panel.activity": "アクティビティ",
"downloads.panel.average": "平均",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Bestand gedownload; controleer het bestandstype",
"downloadState.finishing": "Afronden\u2026",
"downloadState.installed": "Geïnstalleerd",
"downloadState.streamed": "Naar de PS5 gestuurd",
"downloadState.installer-required": "Gedownload; klaar om te installeren",
"downloadState.installing": "Installeren",
"downloadState.paused": "Gepauzeerd",
@@ -118,12 +119,14 @@
"downloads.titleMismatch": "De bestanden zijn {found}, maar de game is {expected}: een andere game of een andere regio?",
"downloads.installRequested": "Installatie aangevraagd. Sluit Overdrive om de installatie af te ronden. ShadowMount installeert zodra er geen game geopend is.",
"downloads.installed": "Geïnstalleerd. De game staat op het startscherm.",
"downloads.streamed": "De PS5 downloadt en installeert het: volg het bij Downloads. Je kunt Overdrive sluiten.",
"downloads.installedFreed": "Geïnstalleerd. Het PKG is verwijderd: {size} vrijgemaakt.",
"downloads.notice.captured": "Link vastgelegd, ga terug naar Overdrive",
"downloads.notice.confirm": "Bestand gevonden, bevestig het in Overdrive",
"downloads.notice.complete": "Download voltooid",
"downloads.notice.failed": "Download mislukt",
"downloads.notice.installed": "Game geïnstalleerd",
"downloads.notice.streamed": "Naar de PS5-downloads gestuurd",
"downloads.packagesExtracted": "Pakketten uitgepakt naar {path}. Een PKG\u2013installatieprogramma is vereist.",
"downloads.panel.activity": "ACTIVITEIT",
"downloads.panel.average": "Gemiddeld",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Arquivo baixado; inspecione seu formato",
"downloadState.finishing": "Finalizando…",
"downloadState.installed": "Instalado",
"downloadState.streamed": "Enviado ao PS5",
"downloadState.installer-required": "Baixado; pronto para instalar",
"downloadState.installing": "Instalando",
"downloadState.paused": "Pausado",
@@ -116,11 +117,13 @@
"downloads.installHint": "Feche Overdrive para terminar: ShadowMount instala quando nenhum jogo está aberto.",
"downloads.installRequested": "Instalação solicitada. Feche Overdrive para terminar: ShadowMount instala quando nenhum jogo está aberto.",
"downloads.installed": "Instalado. Já está na tela inicial.",
"downloads.streamed": "O PS5 baixa e instala: acompanhe em Downloads. Você pode fechar o Overdrive.",
"downloads.installedFreed": "Instalado. O PKG foi excluído: {size} liberados.",
"downloads.notice.complete": "Download concluído",
"downloads.notice.confirm": "Arquivo encontrado, confirme no Overdrive",
"downloads.notice.failed": "Download falhou",
"downloads.notice.installed": "Jogo instalado",
"downloads.notice.streamed": "Enviado aos downloads do PS5",
"downloads.packagesExtracted": "Pacotes extraídos para {path}. Um instalador PKG é necessário.",
"downloads.panel.activity": "ATIVIDADE",
"downloads.panel.average": "Médio",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Ficheiro descarregado; inspecione o seu formato",
"downloadState.finishing": "A concluir\u2026",
"downloadState.installed": "Instalado",
"downloadState.streamed": "Enviado para a PS5",
"downloadState.installer-required": "Descarregado; pronto a instalar",
"downloadState.installing": "A instalar",
"downloadState.paused": "Pausado",
@@ -118,12 +119,14 @@
"downloads.titleMismatch": "Os ficheiros são {found}, o jogo é {expected}: outro jogo ou outra região?",
"downloads.installRequested": "Instalação solicitada. Feche o Overdrive para concluir: o ShadowMount instala assim que nenhum jogo estiver aberto.",
"downloads.installed": "Instalado. Está no ecrã inicial.",
"downloads.streamed": "A PS5 transfere-o e instala-o: acompanhe nas Transferências. Pode fechar o Overdrive.",
"downloads.installedFreed": "Instalado. O PKG foi eliminado: {size} libertados.",
"downloads.notice.captured": "Link capturado, regresse ao Overdrive",
"downloads.notice.confirm": "Ficheiro encontrado, confirme no Overdrive",
"downloads.notice.complete": "Transferência concluída",
"downloads.notice.failed": "Transferência falhou",
"downloads.notice.installed": "Jogo instalado",
"downloads.notice.streamed": "Enviado para as transferências da PS5",
"downloads.packagesExtracted": "Pacotes extraídos para {path}. É necessário um instalador de PKG.",
"downloads.panel.activity": "ATIVIDADE",
"downloads.panel.average": "Média",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Файл загружен; проверить его формат",
"downloadState.finishing": "Завершение…",
"downloadState.installed": "Установлено",
"downloadState.streamed": "Отправлено на PS5",
"downloadState.installer-required": "Загружено; готово к установке",
"downloadState.installing": "Установка",
"downloadState.paused": "Пауза",
@@ -116,12 +117,14 @@
"downloads.installHint": "Закройте Overdrive для завершения: ShadowMount установит, когда не будет открытых игр.",
"downloads.installRequested": "Установка запрошена. Закройте Overdrive для завершения: ShadowMount установит, когда не будет открытых игр.",
"downloads.installed": "Установлено. Игра на главном экране.",
"downloads.streamed": "PS5 скачивает и устанавливает игру: следите в разделе «Загрузки». Overdrive можно закрыть.",
"downloads.installedFreed": "Установлено. PKG удалён: освобождено {size}.",
"downloads.notice.captured": "Ссылка захвачена — вернитесь в Overdrive",
"downloads.notice.confirm": "Файл найден — подтвердите его в Overdrive",
"downloads.notice.complete": "Загрузка завершена",
"downloads.notice.failed": "Загрузка не удалась",
"downloads.notice.installed": "Игра установлена",
"downloads.notice.streamed": "Отправлено в загрузки PS5",
"downloads.packagesExtracted": "Пакеты распакованы в {path}. Требуется инсталлятор PKG.",
"downloads.panel.activity": "АКТИВНОСТЬ",
"downloads.panel.average": "Средняя",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "فائل ڈاؤن لوڈ ہو گئی؛ اس کی شکل کا معائنہ کریں",
"downloadState.finishing": "مکمل کیا جا رہا ہے…",
"downloadState.installed": "انسٹال ہو گیا",
"downloadState.streamed": "PS5 کو بھیج دیا گیا",
"downloadState.installer-required": "ڈاؤن لوڈ ہو گیا؛ انسٹال کے لیے تیار",
"downloadState.installing": "انسٹال ہو رہا ہے",
"downloadState.paused": "توقف دی گئی",
@@ -116,11 +117,13 @@
"downloads.installHint": "ختم کرنے کے لیے Overdrive بند کریں: ShadowMount انسٹال کرتا ہے جب کوئی گیم کھلی نہ ہو۔",
"downloads.installRequested": "انسٹال درخواست دی گئی۔ ختم کرنے کے لیے Overdrive بند کریں: ShadowMount انسٹال کرتا ہے جب کوئی گیم کھلی نہ ہو۔",
"downloads.installed": "انسٹال ہو گیا۔ یہ ہوم اسکرین پر ہے۔",
"downloads.streamed": "PS5 اسے ڈاؤن لوڈ اور انسٹال کرتا ہے: ڈاؤن لوڈز میں دیکھیں۔ آپ Overdrive بند کر سکتے ہیں۔",
"downloads.installedFreed": "انسٹال ہو گیا۔ PKG حذف کر دیا گیا: {size} خالی ہوا۔",
"downloads.notice.complete": "ڈاؤن لوڈ مکمل ہو گیا",
"downloads.notice.confirm": "فائل مل گئی، Overdrive میں اس کی تصدیق کریں",
"downloads.notice.failed": "ڈاؤن لوڈ ناکام",
"downloads.notice.installed": "گیم انسٹال ہو گئی",
"downloads.notice.streamed": "PS5 کے ڈاؤن لوڈز میں بھیج دیا گیا",
"downloads.packagesExtracted": "حزمے {path} میں نکالے گئے۔ PKG انسٹالر درکار ہے۔",
"downloads.panel.activity": "سرگرمی",
"downloads.panel.average": "اوسط",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "文件已下载;请检查其格式",
"downloadState.finishing": "正在完成…",
"downloadState.installed": "已安装",
"downloadState.streamed": "已发送到 PS5",
"downloadState.installer-required": "已下载,可以安装",
"downloadState.installing": "正在安装",
"downloadState.paused": "已暂停",
@@ -116,11 +117,13 @@
"downloads.installHint": "请关闭 Overdrive 以完成:没有游戏运行时 ShadowMount 才会安装。",
"downloads.installRequested": "已请求安装。请关闭 Overdrive 以完成:没有游戏运行时 ShadowMount 才会安装。",
"downloads.installed": "已安装,已在主屏幕上。",
"downloads.streamed": "PS5 会下载并安装:可在其“下载”中查看。可以关闭 Overdrive。",
"downloads.installedFreed": "已安装。PKG 已删除:释放了 {size}。",
"downloads.notice.complete": "下载完成",
"downloads.notice.confirm": "已找到文件,请在 Overdrive 中确认",
"downloads.notice.failed": "下载失败",
"downloads.notice.installed": "游戏已安装",
"downloads.notice.streamed": "已发送到 PS5 的下载",
"downloads.packagesExtracted": "安装包已解压到 {path}。需要 PKG 安装程序。",
"downloads.panel.activity": "活动",
"downloads.panel.average": "平均",
+3
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "檔案已下載;請檢查檔案格式",
"downloadState.finishing": "正在完成…",
"downloadState.installed": "已安裝",
"downloadState.streamed": "已傳送到 PS5",
"downloadState.installer-required": "已下載,可以安裝",
"downloadState.installing": "正在安裝",
"downloadState.paused": "已暫停",
@@ -118,12 +119,14 @@
"downloads.titleMismatch": "檔案是 {found},遊戲是 {expected}:可能是其他遊戲,或不同地區版本?",
"downloads.installRequested": "已送出安裝要求。請關閉 Overdrive 以完成安裝:沒有遊戲執行時,ShadowMount 才會開始安裝。",
"downloads.installed": "已安裝,可在主畫面找到。",
"downloads.streamed": "PS5 會下載並安裝:可在其「下載」中查看。可以關閉 Overdrive。",
"downloads.installedFreed": "已安裝。PKG 已刪除,已釋放 {size}。",
"downloads.notice.captured": "已擷取連結,請返回 Overdrive",
"downloads.notice.confirm": "已找到檔案,請在 Overdrive 中確認",
"downloads.notice.complete": "下載完成",
"downloads.notice.failed": "下載失敗",
"downloads.notice.installed": "遊戲已安裝",
"downloads.notice.streamed": "已傳送到 PS5 的下載",
"downloads.packagesExtracted": "安裝檔已解壓縮至 {path}。需要 PKG 安裝程式。",
"downloads.panel.activity": "活動",
"downloads.panel.average": "平均",
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createDownloadQueue} = await import('./download-queue.js');
const PS4_ID = 'UP0001-CUSA04013_00-TITANFALL2000000';
const PS4_HEAD = '\x7FCNT' + '\x00\x01\xff\xfe'.repeat(15) + PS4_ID + '\x00'.repeat(64);
const PS5_HEAD = '\x7FFIH' + '\x00'.repeat(120);
const RAR_HEAD = 'Rar!\x1a\x07\x01\x00' + '\x00'.repeat(120);
// The console's answer to a link: 'accepted' reports bytes, 'refused' fails, 'silent' says nothing.
function harness({head = PS4_HEAD, status = 206, console: answer = 'accepted', platform = 'ps5', format = 'binary', redirect = false} = {}) {
const files = new Map();
const dirs = new Set(['/data']);
const requests = [], installs = [], enqueued = [];
const fs = {
exists: path => dirs.has(path) || files.has(path),
stat: path => dirs.has(path) ? {isDirectory: true, device: 'dev', inode: 'root', modified: 0} :
files.has(path) ? {isFile: true, size: 4096, modified: 0} : null,
diskUsage: () => ({free: 1024 ** 4}),
mkdir: path => { dirs.add(path); },
writeFile: (path, data) => { files.set(path, data); },
readFile: path => files.get(path) ?? '',
rename: (source, target) => { if (files.has(source)) { files.set(target, files.get(source)); files.delete(source); } },
readDir: () => [],
remove: path => { files.delete(path); },
removeAsync: async path => { files.delete(path); dirs.delete(path); },
};
const http = {
async request(url, options) {
requests.push({url, options});
if (redirect && url === 'https://host.example/f/abc')
return {status: 302, ok: false, headers: {location: 'https://cdn.example/file.pkg'}, text: async () => ''};
return {status, ok: status < 300, headers: {}, text: async () => head};
},
};
const downloads = {
enqueue(options) {
enqueued.push(options);
files.set(options.destination, 'game bytes');
return {done: Promise.resolve({written: 4096}), snapshot: {state: 'completed'}, subscribe: () => () => {}, cancel: () => {}};
},
};
const packages = {
install(request) {
installs.push(request);
const listeners = new Set();
let finish, fail;
const done = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
done.catch(() => {});
const task = {
snapshot: {state: 'queued', written: 0},
done,
subscribe(listener) { listener(task.snapshot); listeners.add(listener); return () => listeners.delete(listener); },
cancel() { fail(new Error('cancelled')); },
};
setTimeout(() => {
if (answer === 'refused') return fail(new Error('Packages.install: The console refused the package. (0x80990085)'));
task.snapshot = {state: 'installing', written: 0};
if (answer === 'accepted') {
task.snapshot = {state: 'installing', written: 1 << 20, total: 1 << 30};
for (const listener of listeners) listener(task.snapshot);
}
}, 5);
return task;
},
};
const formats = {artifact: (release, where) => ({
directory: where + '/downloads/files',
destination: where + '/downloads/files/' + release.filename,
nextAction: release.format === 'pkg' ? 'installer-required' : 'downloaded',
})};
const queue = createDownloadQueue({
fs, http, downloads, packages, formats,
dataDir: '/data', platform,
archives: {streams: true, supportsAsyncInspection: false, inspect: () => ({kind: null})},
notifications: {show() {}}, power: {keepAwake() {}},
sources: {resolve: async entry => ({url: entry.url})},
});
const release = {id: 'source-1', filename: format === 'binary' ? 'game.binary' : 'game.' + format,
url: redirect ? 'https://host.example/f/abc' : 'https://cdn.example/file.pkg', format, provider: 'vikingfile'};
const id = queue.enqueue({id: 'CUSA04013', title: 'Titanfall 2'}, release, '/data');
return {queue, id, requests, installs, enqueued};
}
async function settled(fixture, states = ['installed', 'file-downloaded', 'installer-required', 'failed', 'downloaded']) {
for (const until = Date.now() + 10000; Date.now() < until;) {
const job = fixture.queue.snapshot().find(item => item.id === fixture.id);
if (states.includes(job?.state)) return job;
await new Promise(resolve => setTimeout(resolve, 5));
}
throw new Error('Queue did not settle');
}
test('a PS4 PKG link goes to the console with its content ID, and nothing downloads here', async () => {
const fixture = harness();
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.equal(job.streamed, true);
assert.deepEqual(fixture.installs, [{url: 'https://cdn.example/file.pkg', name: 'Titanfall 2', contentId: PS4_ID}]);
assert.equal(fixture.enqueued.length, 0);
assert.equal(fixture.requests[0].options.headers.Range, 'bytes=0-4095');
});
test('a PS5 PKG goes without a content ID, through the link a redirect names', async () => {
const fixture = harness({head: PS5_HEAD, redirect: true});
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.deepEqual(fixture.installs.map(item => [item.url, item.contentId]), [['https://cdn.example/file.pkg', '']]);
});
test('an archive downloads here as before', async () => {
const fixture = harness({head: RAR_HEAD});
const job = await settled(fixture);
assert.notEqual(job.state, 'installed');
assert.equal(fixture.installs.length, 0);
assert.equal(fixture.enqueued.length, 1);
});
test('a host that ignores ranges downloads here', async () => {
const fixture = harness({status: 200});
await settled(fixture);
assert.equal(fixture.installs.length, 0);
assert.equal(fixture.enqueued.length, 1);
});
test('a link the console refuses downloads here instead, and is not offered again', async () => {
const fixture = harness({console: 'refused', format: 'pkg'});
const job = await settled(fixture);
assert.equal(fixture.installs.length, 2, 'the link, then the downloaded file');
assert.equal(fixture.installs[1].path, '/data/downloads/files/game.pkg');
assert.equal(fixture.enqueued.length, 1);
assert.equal(job.streamTried, true);
assert.equal(job.streamed, undefined);
});
test('a source of a declared archive format is never probed', async () => {
const fixture = harness({format: 'rar', head: RAR_HEAD});
await settled(fixture, ['file-downloaded', 'failed', 'extraction-required', 'extracted', 'downloaded']);
assert.equal(fixture.requests.length, 0);
});
test('outside the PS5 nothing is probed', async () => {
const fixture = harness({platform: 'desktop'});
await settled(fixture);
assert.equal(fixture.requests.length, 0);
assert.equal(fixture.installs.length, 0);
});
+104 -1
View File
@@ -93,7 +93,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
delete job2.captureError;
delete job2.captureFile;
delete job2.fallbacks;
if (job2.state === "installing") return { ...job2, state: "installer-required" };
// Closed while the console was being handed its link: resumed, it downloads here.
if (job2.state === "installing" && !job2.streaming) return { ...job2, state: "installer-required" };
delete job2.streaming;
if (job2.state === "extracting") job2.written = job2.total ?? job2.written;
if (job2.state === "removing") {
job2.state = job2.removingFrom ?? "failed";
@@ -369,6 +371,106 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
change();
}
const canInstall = () => Boolean(packages) && platform === "ps5";
// PKG streaming (issue #50): a plain PKG goes by its link to the console's own download queue, which
// installs it in the background, with Overdrive closed too, and with no copy on the drive first.
// Archives and game images stay here, as does anything the console refuses: that downloads as before.
// The format is read from the file's first bytes, since most sources name none ("binary").
var STREAMABLE = ["pkg", "fpkg", "binary"];
var PKG_PROBE_BYTES = 4096;
var PS4_CONTENT_ID = /[A-Z]{2}\d{4}-[A-Z]{4}\d{5}_\d{2}-[A-Z0-9]{16}/;
// How long the console has to start fetching the link before the app leaves it to the console.
var STREAM_WATCH_MS = 3e4;
async function pkgLink(job2, signal) {
let url = job2.options.url;
for (let hop = 0; hop < 5; hop++) {
const response = await http.request(url, { headers: { Range: `bytes=0-${PKG_PROBE_BYTES - 1}` }, maxBytes: 64 * 1024, signal });
const location = response.headers.location;
if (response.status >= 300 && response.status < 400 && /^https?:\/\//i.test(location || "")) {
url = location;
continue;
}
// A host that ignores the range sends the whole file: the console's downloader needs ranges too.
if (response.status !== 206) return null;
const head = await response.text();
const ps4 = head.startsWith("\x7FCNT"), ps5 = head.startsWith("\x7FFIH");
const contentId = ps4 ? head.slice(0, 256).match(PS4_CONTENT_ID)?.[0] : "";
if (!ps4 && !ps5 || contentId === void 0) return null;
return { url, contentId, kind: ps4 ? "PS4" : "PS5" };
}
return null;
}
async function streamed(job2, signal) {
const options = job2.options;
const format = String(job2.release.format || "binary").toLowerCase();
if (!canInstall() || job2.streamTried || job2.written || job2.output || !STREAMABLE.includes(format) || !options?.url || options.pieces || Object.keys(options.headers || {}).length || fs.exists(`${job2.downloadDestination}.part`)) return false;
let link;
try {
link = await pkgLink(job2, signal);
} catch (error2) {
if (!signal.aborted) console.log(`[download-queue] ${job2.id}: not streamed, its first bytes were not read: ${error2.message}`);
return false;
}
if (!link) return false;
job2.streamTried = true;
job2.streaming = true;
job2.state = "installing";
job2.written = 0;
job2.message = "";
change();
console.log(`[download-queue] ${job2.id}: a ${link.kind} PKG, handed to the console's downloads${link.contentId ? ` as ${link.contentId}` : ""}: ${linkForLog(link.url)}`);
let task;
const accepted = await new Promise((resolve) => {
let settled = false, off = null, timer = null;
const settle = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
off?.();
resolve(value);
};
try {
task = packages.install({ url: link.url, name: job2.title, contentId: link.contentId });
} catch (error2) {
console.log(`[download-queue] ${job2.id}: the console was not asked: ${error2.message}`);
settle(false);
return;
}
active2.task = task;
// Accepted once the console reports bytes, or has said nothing against it within the watch.
off = task.subscribe((progress) => {
if (progress.written > 0) settle(true);
});
if (settled) off();
task.done.then(() => settle(true), (error2) => {
if (!settled) console.log(`[download-queue] ${job2.id}: the console refused the link: ${error2.message}`);
settle(false);
});
timer = setTimeout(() => settle(task.snapshot.state === "installing"), STREAM_WATCH_MS);
});
delete job2.streaming;
// The console goes on by itself with what it took: the app stops listening, and waits until the
// installer has let go, since it takes one package at a time (the next one, or this one's
// download here, would be turned away).
if (task) {
task.cancel();
await task.done.catch(() => {});
}
if (job2.state === "pausing") return true;
if (!accepted) {
job2.state = "resolving";
job2.written = 0;
change();
console.log(`[download-queue] ${job2.id}: downloading it here instead`);
return false;
}
job2.state = "installed";
job2.streamed = true;
job2.written = job2.total || 0;
job2.bytesPerSecond = 0;
job2.message = t("downloads.streamed");
notifications.show(t("downloads.notice.streamed"), job2.title);
return true;
}
async function execute(job2) {
let task;
let unsubscribe;
@@ -414,6 +516,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
change();
}
if (job2.state === "pausing") return;
if (await streamed(job2, signal) || job2.state === "pausing") return;
const root2 = storageIdentity(job2);
if (!job2.rootIdentity) {
job2.rootIdentity = root2;
+16 -1
View File
@@ -16,6 +16,14 @@
"dev Same window, rebuilt and restarted on every save under app/. State resets.",
"test The framework's checks; run after changing rendering or input in ps5-react/.",
"",
"macOS only: setup:mac creates ps5-react/.venv with Pillow; preview:mac, dev:mac and",
" doctor:mac run through that venv, with LLVM 18, Node and the MacPorts/SDL2",
" libraries when present. The commands above stay unchanged for other hosts.",
"build:mac The PS5 app only, through that venv, into ps5-react/dist/PPSA99058. No sudo.",
"package:mac Local only: builds Overdrive.exfat from the app above with newfs_exfat/hdiutil.",
" It writes no update.zip; the CI keeps producing that on Linux.",
"image:mac build:mac, then package:mac.",
"",
"CC=clang CXX=clang++: the preview is compiled with clang on every platform, since GCC",
"stops on a bounds warning in archive_preflight.cpp."
],
@@ -25,6 +33,13 @@
"build:app": "bash packaging/build.sh --no-package",
"preview": "cd ps5-react && CC=clang CXX=clang++ python3 tools/cli.py preview --app-dir ../app",
"dev": "cd ps5-react && CC=clang CXX=clang++ python3 tools/cli.py preview --app-dir ../app --watch",
"test": "cd ps5-react && CC=clang CXX=clang++ npm test"
"test": "cd ps5-react && CC=clang CXX=clang++ npm test",
"setup:mac": "[ -x ps5-react/.venv/bin/python ] || python3 -m venv ps5-react/.venv; ps5-react/.venv/bin/pip install -r ps5-react/requirements.txt",
"doctor:mac": "cd ps5-react && sh tools/mac-run.sh doctor",
"preview:mac": "cd ps5-react && CC=clang CXX=clang++ sh tools/mac-run.sh preview --app-dir ../app",
"dev:mac": "cd ps5-react && CC=clang CXX=clang++ sh tools/mac-run.sh preview --app-dir ../app --watch",
"build:mac": "bash packaging/build-mac.sh",
"package:mac": "bash packaging/make-exfat-mac.sh ps5-react/dist/PPSA99058 Overdrive.exfat OVERDRIVE",
"image:mac": "npm run build:mac && npm run package:mac"
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Builds the PS5 app on macOS.
#
# Uses ps5-react/.venv for Pillow (verify.py imports it and Homebrew's python3
# is externally managed), LLVM 18 from Homebrew, and ninja from Homebrew. The
# desktop preview's MacPorts/SDL2 environment does not apply here.
#
# Packaging is a separate, local-only step: packaging/make-exfat-mac.sh.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
venv="$root/ps5-react/.venv"
if [ ! -x "$venv/bin/python" ]; then
echo "error: $venv is missing; run 'npm run setup:mac' from the repository root." >&2
exit 1
fi
llvm="${LLVM:-}"
if [ -z "$llvm" ]; then
for candidate in /usr/local/opt/llvm@18/bin /opt/homebrew/opt/llvm@18/bin; do
if [ -x "$candidate/clang" ]; then llvm=$candidate; break; fi
done
fi
if [ -z "$llvm" ] || [ ! -x "$llvm/clang" ]; then
echo "error: LLVM 18 not found. Install it with 'brew install llvm@18' or set LLVM=<its bin directory>." >&2
exit 1
fi
PATH="$venv/bin:$llvm:/opt/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
# The PS5 build does not link the desktop's libraries, but a MacPorts setup keeps
# CMake away from a mixed Homebrew/MacPorts prefix when one is present.
prefixes=
if [ -d "$HOME/.local/overdrive-sdl2" ]; then
prefixes=$HOME/.local/overdrive-sdl2
fi
if [ -x /opt/local/bin/cmake ]; then
prefixes=${prefixes:+$prefixes:}/opt/local
fi
if [ -n "$prefixes" ]; then
export CMAKE_PREFIX_PATH="$prefixes${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}"
fi
exec bash "$root/packaging/build.sh" --no-package
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Packages a built PS5 app folder as the exFAT image ShadowMount+ installs, on
# macOS. Local only: CI keeps using make-exfat.sh on Linux, which also writes
# update.zip. This script writes the image alone.
#
# usage: make-exfat-mac.sh <app-folder> <out.exfat> [label] [cluster-size]
set -euo pipefail
app="${1:-}"
out="${2:-}"
label="${3:-OVERDRIVE}"
cluster="${4:-64K}"
if [ -z "$app" ] || [ -z "$out" ]; then
echo "usage: $0 <app-folder> <out.exfat> [label] [cluster-size]" >&2
exit 2
fi
[ -d "$app" ] || { echo "error: no such app folder: $app" >&2; exit 1; }
[ -f "$app/eboot.bin" ] || { echo "error: $app has no eboot.bin, it is not a built PS5 app" >&2; exit 1; }
for tool in hdiutil newfs_exfat fsck_exfat; do
command -v "$tool" >/dev/null || { echo "error: $tool not found; this script is macOS only" >&2; exit 1; }
done
case "$cluster" in
32K|32k) cluster_bytes=32768 ;;
64K|64k) cluster_bytes=65536 ;;
128K|128k) cluster_bytes=131072 ;;
*) echo "error: cluster size must be 32K, 64K or 128K" >&2; exit 2 ;;
esac
mount_point="/Volumes/$label"
if mount | grep -F " on $mount_point " >/dev/null; then
echo "error: $mount_point is already mounted; unmount it first" >&2
exit 1
fi
# Size the image from what exFAT actually consumes: every file takes whole
# clusters, and the app ships thousands of small JSON files, so the payload's
# apparent size underestimates the volume. This is the same formula as
# make-exfat.sh, with macOS's stat instead of GNU find -printf.
slack=$(( ${EXFAT_SLACK_MB:-128} * 1024 * 1024 ))
clusters=$(find "$app" -type f -exec stat -f%z {} + |
awk -v c="$cluster_bytes" '{ n += int(($1 + c - 1) / c) } END { print n + 0 }')
directories=$(find "$app" -type d | wc -l | tr -d ' ')
payload=$(( (clusters + directories) * cluster_bytes ))
size=$(( (payload + slack + 1048575) / 1048576 * 1048576 ))
printf '→ exFAT: %s MiB, %s clusters, label %s\n' "$((size / 1048576))" "$cluster" "$label"
if [ -e "$out" ]; then
echo "→ replacing $out"
fi
rm -f "$out"
truncate -s "$size" "$out"
dev=
mounted=0
cleanup() {
if [ "$mounted" = 1 ]; then
hdiutil detach "$mount_point" >/dev/null 2>&1 ||
hdiutil detach -force "$mount_point" >/dev/null 2>&1 || true
fi
if [ -n "$dev" ]; then
hdiutil detach "$dev" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
dev=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount "$out" |
awk '/^\/dev\/disk/{print $1; exit}')
[ -n "$dev" ] || { echo "error: hdiutil could not attach $out" >&2; exit 1; }
newfs_exfat -R -v "$label" -b "$cluster_bytes" "$dev" >/dev/null
hdiutil detach "$dev" >/dev/null
dev=
attach=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nobrowse "$out")
dev=$(printf '%s\n' "$attach" | awk '$3 ~ /^\/Volumes\// {print $1; exit}')
[ -n "$dev" ] || dev=$(printf '%s\n' "$attach" | awk '/^\/dev\/disk/{print $1; exit}')
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -d "$mount_point" ] && break
sleep 1
done
[ -d "$mount_point" ] || { echo "error: $mount_point did not mount" >&2; exit 1; }
mounted=1
cp -R "$app/." "$mount_point/"
# Finder and Spotlight may add their own directories; they are not part of the app.
rm -rf "$mount_point/.Spotlight-V100" "$mount_point/.fseventsd" "$mount_point/.Trashes"
src=$(find "$app" -type f | wc -l | tr -d ' ')
dst=$(find "$mount_point" -type f \
-not -path "$mount_point/.Spotlight-V100/*" \
-not -path "$mount_point/.fseventsd/*" \
-not -path "$mount_point/.Trashes/*" | wc -l | tr -d ' ')
[ "$src" = "$dst" ] || { echo "error: wrote $dst files, the app has $src" >&2; exit 1; }
sync
hdiutil detach "$mount_point" >/dev/null || hdiutil detach -force "$mount_point" >/dev/null
mounted=0
dev=
# Check the finished image itself: its filesystem, and the app's entry point.
dev=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount "$out" |
awk '/^\/dev\/disk/{print $1; exit}')
[ -n "$dev" ] || { echo "error: hdiutil could not reattach $out" >&2; exit 1; }
fsck_exfat -n "$dev" >/dev/null
hdiutil detach "$dev" >/dev/null
dev=
echo "wrote $out ($(stat -f%z "$out") bytes, $src files)"
+18 -5
View File
@@ -8,7 +8,8 @@
# preview (SDL2, OpenGL, curl, libarchive, OpenSSL, clang), the PS5 build
# (LLVM 18, ninja, nasm, exfatprogs) and the tests (bsdtar), then Pillow for
# the asset baking and the framework's npm packages.
# macOS (Homebrew): the same through brew and pip.
# macOS (Homebrew): the same through brew, plus a ps5-react/.venv for Pillow
# (Homebrew's python3 refuses a global pip install).
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
@@ -28,8 +29,10 @@ Darwin)
command -v brew >/dev/null || { echo "error: Homebrew is needed: https://brew.sh" >&2; exit 1; }
echo "→ Homebrew formulae"
brew install cmake sdl2 llvm@18 nasm ninja curl libarchive xz openssl@3 python node
echo "→ Pillow"
python3 -m pip install -r "$root/ps5-react/requirements.txt"
echo "→ Python environment"
[ -x "$root/ps5-react/.venv/bin/python" ] || python3 -m venv "$root/ps5-react/.venv"
"$root/ps5-react/.venv/bin/pip" install --disable-pip-version-check \
-r "$root/ps5-react/requirements.txt"
;;
*)
echo "error: unsupported OS $(uname -s)" >&2
@@ -41,10 +44,20 @@ echo "→ framework npm packages"
(cd "$root/ps5-react" && npm ci)
echo "→ checking"
python3 -c "import PIL; print('Pillow', PIL.__version__)"
for tool in cmake clang ninja nasm node mkfs.exfat; do
case "$(uname -s)" in
Darwin)
"$root/ps5-react/.venv/bin/python" -c "import PIL; print('Pillow', PIL.__version__)"
tools="cmake clang ninja nasm node newfs_exfat hdiutil"
;;
*)
python3 -c "import PIL; print('Pillow', PIL.__version__)"
tools="cmake clang ninja nasm node mkfs.exfat"
;;
esac
for tool in $tools; do
printf '%-10s %s\n' "$tool" "$(command -v "$tool" || echo MISSING)"
done
echo
echo "Ready. Next: npm run preview (desktop window), npm run build (Overdrive.exfat)."
echo "On macOS the :mac variants (preview:mac, dev:mac, build:mac) use ps5-react/.venv."
echo "The first preview and the first build download and compile the pinned dependencies; that takes a while."
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## Unreleased — packages installed from a link
- `Packages.install({url, name, contentId})` (ABI v20) hands an http(s) link to the console's own
download queue: the console fetches and installs the package in the background, with the app
closed too and no copy on a drive first. A PS4 package needs its content ID, which the app reads
from the header with a ranged request; a PS5 one goes without. `pkg-installer.elf` speaks `PKI2`
(a fourth line, the content ID) for paths and links alike.
## Unreleased — certificates for old firmwares
- Networking apps ship Mozilla's root certificates (curl's `cacert.pem`, pinned as `caBundle`). At
start the PS5 host writes the console's own store plus the bundle into one file in the cache and
hands curl that path, so sites whose roots an old firmware never received (4.03: GitHub, image
hosts) verify. Any failure keeps the system store alone, as before; trust is only added.
`python3 tools/ca_bundle.py` pins the newest copy after checking that OpenSSL loads every certificate.
## Unreleased — requests beside a download
- `Http.request` (and every task without a file destination) runs on a network thread of its own,
+5
View File
@@ -156,5 +156,10 @@
"patches/ffmpeg-mov-hls-seek.patch",
"patches/ffmpeg-hls-init-section.patch"
]
},
"caBundle": {
"version": "2026-10-20",
"url": "https://curl.se/ca/cacert-2026-10-20.pem",
"sha256": "796f1389bfea13c0ed78f663b765baaff54a508e5483786bd6ece73c5e3e3cbe"
}
}
+1
View File
@@ -11,6 +11,7 @@ No proprietary Sony modules are redistributed.
| ProsperoStore elevation client | Optional cooperative filesystem-access client and pinned helper build. GPL-3.0-or-later; revision `22dca63607cf5f1f95006eda6ffb802607bed699`. |
| PacBrew archive ports | Same hash-pinned v0.40.2 distribution: libarchive 3.7.4 (BSD), liblzma 5.4.6 (primarily public domain), bzip2 (bzip2 license), plus zlib/zstd/OpenSSL. Exact notice sources and hashes are in `licenses/archives-SOURCES.json`. |
| PacBrew networking ports | Optional SHA-256-pinned v0.40.2 archive: curl 8.18.0 (curl license), OpenSSL 3.5.2 (Apache-2.0), zlib, zstd and libpsl; individual notices and source links follow the pinned ProsperoStore `third_party/PACBREW_LICENSES.json`. Native-title `compat.c` and `netdb.c` reuse that pinned reference under GPL-3.0-or-later. |
| Mozilla CA certificates | The root store curl publishes as `cacert-<date>.pem` (MPL-2.0), SHA-256-pinned (`caBundle` in the lock file; `tools/ca_bundle.py` pins the newest dated copy and checks it). Networking apps ship it as `certs/cacert.pem`; at start the console merges it with its own store, which old firmwares let fall behind. Builds copy `licenses/MPL-2.0.txt` to `notices/cacert-MPL-2.0.txt`. |
| Lapy helper | Optional exact-title one-shot helper: mpereiraesaa/PS5-Lapy-JB-Daemon `54a095c0f19161825e845daa760a03b446e654fa`, MIT; protocol LGPL-2.1-or-later, ps5log GPL-3.0-or-later. The helper uses the app SDK v0.42 with `patches/lapy-sdk-042-attributes.patch`; source revision and ps5log hash are pinned in the lock file. |
| ps5-opengl | SDK 1.0.0; GPL and per-component/Mesa licenses. The release includes sources and notices. Its repository (github.com/blackbearreloaded/ps5-opengl) is gone; the same archive, checked by its SHA-256, is kept at [MyLuxy/overdrive-deps](https://github.com/MyLuxy/overdrive-deps/releases/tag/ps5-opengl-v1.0.0). |
| React / QuickJS-ng | React 18.3.1, reconciler 0.29.2, QuickJS-ng 0.15.0; MIT. npm lockfile and QuickJS commit are pinned. |
+9
View File
@@ -542,6 +542,15 @@ stops reporting only: the console finishes an install it accepted. The preview
fails each request. Fake packages need a kernel that accepts them (kstuff); on
firmware above 11.60, PS5 fake game packages install but do not launch.
`Packages.install({url, name, contentId})` hands an `http://` or `https://` link
to the console's own download queue instead: the console fetches and installs
the package in the background, also once the app has closed, with no copy on a
drive first. The console sends no cookies or headers of the app's, so only a
link that works on its own fits. A PS4 package needs its `contentId` (36
characters at offset 0x40 of its header, which the app reads with a ranged
request); a PS5 one goes without. Its progress shows in the console's
Downloads; `cancel()` stops reporting here only.
## Archive extraction
```js
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
@@ -11,7 +11,7 @@ std::mutex guard;
std::vector<Snapshot> pending;
std::uint32_t next_id = 1;
}
std::uint32_t install(std::string, std::string, std::string&) {
std::uint32_t install(std::string, std::string, std::string, std::string&) {
std::lock_guard lock(guard);
Snapshot snapshot;
snapshot.id = next_id++;
+58 -3
View File
@@ -6,6 +6,8 @@
#include "app_config.hpp"
#include "filesystem_access.hpp"
#include "platform/ps5/system.hpp"
#include <cstdio>
#include <string>
#include <unistd.h>
#if PS5_REACT_NETWORKING
extern "C" {
@@ -15,14 +17,67 @@ int sceNetPoolCreate(const char*, int, int);
int sceNetPoolDestroy(int);
}
namespace network {
namespace { int pool = -1; bool initialized = false; }
namespace {
int pool = -1; bool initialized = false;
constexpr const char* kSystemStore = "/system/common/cert/CA_LIST.cer";
constexpr const char* kCertificate = "-----BEGIN CERTIFICATE-----";
char store[256] = "/system/common/cert/CA_LIST.cer";
bool read_file(const char* path, std::string& out) {
FILE* file = std::fopen(path, "rb");
if (!file) return false;
out.clear();
char buffer[16384];
std::size_t n;
while ((n = std::fread(buffer, 1, sizeof buffer, file)) > 0) out.append(buffer, n);
const bool ok = !std::ferror(file);
std::fclose(file);
return ok;
}
// Old firmwares' store lacks roots that sites moved to since (4.03 fails GitHub and the cover servers).
// The app ships Mozilla's (certs/cacert.pem); both go into one file in the cache, passed to curl by path
// so each transfer pool loads one store and reuses it. Anything missing keeps the system store alone.
void merge_certificates() {
char bundle[256], merged[256];
std::string system, extra, current;
if (!resolve_filesystem_path("/app0/certs/cacert.pem", bundle, sizeof bundle) ||
!resolve_filesystem_path("/cache0/ca-bundle.pem", merged, sizeof merged)) {
hui::sys::log("[PS5-REACT] certificates: no path for the bundle; system store only"); return;
}
if (!read_file(bundle, extra) || extra.find(kCertificate) == std::string::npos) {
hui::sys::log("[PS5-REACT] certificates: %s unreadable; system store only", bundle); return;
}
if (!read_file(kSystemStore, system) || system.find(kCertificate) == std::string::npos) {
hui::sys::log("[PS5-REACT] certificates: the system store is not PEM; system store only"); return;
}
if (system.back() != '\n') system += '\n';
system += extra;
extra = std::string();
if (!read_file(merged, current) || current != system) {
const std::string temporary = std::string(merged) + ".tmp";
bool written = false;
if (FILE* file = std::fopen(temporary.c_str(), "wb")) {
written = std::fwrite(system.data(), 1, system.size(), file) == system.size();
written = std::fclose(file) == 0 && written;
}
if (!written || std::rename(temporary.c_str(), merged) != 0) {
std::remove(temporary.c_str());
hui::sys::log("[PS5-REACT] certificates: cannot write %s; system store only", merged); return;
}
}
std::snprintf(store, sizeof store, "%s", merged);
hui::sys::log("[PS5-REACT] certificates: system store + bundle, %zu bytes", system.size());
}
}
bool platform_start(std::string& error) {
if (!console_filesystem_accessible()) {
error = "native networking requires successful console filesystem elevation"; return false;
}
if (access("/system/common/cert/CA_LIST.cer", R_OK) != 0) {
if (access(kSystemStore, R_OK) != 0) {
error = "native networking cannot read the system certificate store"; return false;
}
merge_certificates();
initialized = sceNetInit() == 0;
pool = sceNetPoolCreate("ps5-react-http", 1024*1024, 0);
if (pool < 0) {
@@ -36,7 +91,7 @@ void platform_stop() {
if (initialized) sceNetTerm();
pool = -1; initialized = false;
}
const char* ca_path() { return "/system/common/cert/CA_LIST.cer"; }
const char* ca_path() { return store; }
// The title's heap (platform app_heap.c), so download telemetry shows how close it runs.
extern "C" void hui_heap_stats(std::size_t* live_bytes, std::size_t* peak_bytes, std::size_t* blocks, std::size_t* failures);
extern "C" void hui_heap_capacity(std::size_t* size, std::size_t* flexible_before, std::size_t* flexible_after);
+4 -3
View File
@@ -16,7 +16,7 @@
namespace packages {
namespace {
struct Job {
std::string path, name;
std::string path, name, content_id;
Snapshot snapshot;
std::atomic<bool> cancelled{false};
std::atomic<int> socket{-1};
@@ -37,7 +37,7 @@ void finish(Job& job, const char* state, const std::string& error = {}) {
// The payload reports once a second; half a minute of silence means it is gone.
int submit(Job& job, std::string& error) {
return payload_loader::send("pkg-installer.elf", "PKI1\n" + job.path + "\n" + job.name + "\n", 30'000'000, "Packages.install", error);
return payload_loader::send("pkg-installer.elf", "PKI2\n" + job.path + "\n" + job.name + "\n" + job.content_id + "\n", 30'000'000, "Packages.install", error);
}
// Lines: "ready", "started <content ID>", "p <written> <total> <status>", "ok", "fail <hex code> <reason>".
@@ -82,12 +82,13 @@ void run(std::shared_ptr<Job> job) {
}
}
std::uint32_t install(std::string path, std::string name, std::string& error) {
std::uint32_t install(std::string path, std::string name, std::string content_id, std::string& error) {
std::lock_guard lock(guard);
if (active) { error = "A package is already installing; poll its completion first."; return 0; }
auto job = std::make_shared<Job>();
job->path = std::move(path);
job->name = std::move(name);
job->content_id = std::move(content_id);
job->snapshot.id = next_id++;
active = job;
if (!job->worker.start([job] { run(job); }, 1024 * 1024)) {
+44 -14
View File
@@ -6,7 +6,9 @@
// engine sends this program to the payload loader (127.0.0.1:9021), which hands it the rest of the
// connection as standard input and output.
//
// Request lines: "PKI1", the package path, the name the home screen shows while it installs.
// Request lines: "PKI2", the package path or its http(s) link, the name the home screen shows while it
// installs, and the content ID of a link's PS4 package (empty otherwise). A link goes to the console's
// own download queue, which fetches and installs it in the background.
// Answers: "ready", then "started <content ID>", then "p <installed bytes> <total bytes> <status>"
// once a second, then "ok" or "fail <code as eight hex digits> <reason>". A PS5 package whose ID the
// service does not answer gets "started (unknown)" and "ok" as soon as the service takes it.
@@ -89,24 +91,52 @@ static int allowed(const char* path) {
&& !strstr(path, "/../") && !strstr(path, "//");
}
int main(void) {
char magic[8], path[1024], name[256], line[640];
if (!read_line(magic, sizeof magic) || strcmp(magic, "PKI1") || !read_line(path, sizeof path) ||
!read_line(name, sizeof name)) return 1;
if (!allowed(path)) return fail(0, "The package path is not allowed.");
static int is_link(const char* uri) {
if (strncmp(uri, "https://", 8) && strncmp(uri, "http://", 7)) return 0;
for (const char* c = uri; *c; ++c) if ((unsigned char)*c <= ' ' || *c == 0x7F) return 0;
return 1;
}
// A PS4 PKG starts with \x7FCNT and names its content ID at 0x40. A PS5 one starts with \x7FFIH,
// whose header has no content ID field: it goes to the service with none, and the service answers
// with the ID it read (as BFpilot does, verified there on 11.60).
// A PS4 content ID: "UP0000-CUSA00000_00-" and sixteen letters or digits.
static int content_id_valid(const char* id) {
if (strlen(id) != 36 || id[6] != '-' || id[16] != '_' || id[19] != '-') return 0;
for (int i = 0; i < 36; ++i) {
const char c = id[i];
if (i == 6 || i == 16 || i == 19) continue;
if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) return 0;
}
return 1;
}
// A PS4 PKG starts with \x7FCNT and names its content ID at 0x40. A PS5 one starts with \x7FFIH,
// whose header has no content ID field: it goes to the service with none, and the service answers
// with the ID it read (as BFpilot does, verified there on 11.60).
static const char* read_header(const char* path, char* content_id) {
char header[0x40 + 36 + 1] = {0};
const int file = open(path, O_RDONLY);
if (file < 0) return fail(0, "The package could not be opened.");
if (file < 0) return "The package could not be opened.";
const ssize_t got = read(file, header, sizeof header - 1);
close(file);
const int ps5 = got == (ssize_t)sizeof header - 1 && !memcmp(header, "\x7F" "FIH", 4);
if (got != (ssize_t)sizeof header - 1 || (!ps5 && memcmp(header, "\x7F" "CNT", 4))) return fail(0, "The file is not a PKG.");
char content_id[37] = {0};
if (got != (ssize_t)sizeof header - 1 || (!ps5 && memcmp(header, "\x7F" "CNT", 4))) return "The file is not a PKG.";
if (!ps5) memcpy(content_id, header + 0x40, 36);
return NULL;
}
int main(void) {
char magic[8], path[1024], name[256], given[64], line[640];
if (!read_line(magic, sizeof magic) || strcmp(magic, "PKI2") || !read_line(path, sizeof path) ||
!read_line(name, sizeof name) || !read_line(given, sizeof given)) return 1;
const int remote = is_link(path);
if (!remote && !allowed(path)) return fail(0, "The package path is not allowed.");
if (given[0] && (!remote || !content_id_valid(given))) return fail(0, "The content ID is not valid.");
// A link's header was read by the app, which names a PS4 package's content ID.
char content_id[37] = {0};
if (remote) memcpy(content_id, given, strlen(given));
else {
const char* refused = read_header(path, content_id);
if (refused) return fail(0, refused);
}
say("ready\n");
(void)sceUserServiceInitialize(NULL);
@@ -114,9 +144,9 @@ int main(void) {
if (result) return fail((unsigned)result, "The install library could not be loaded.");
if ((result = initialize())) return fail((unsigned)result, "The install service did not start.");
// The service reads internal-drive packages through its /user view of /data.
// The service reads internal-drive packages through its /user view of /data; a link goes as it is.
char uri[1100];
snprintf(uri, sizeof uri, strncmp(path, "/data/", 6) ? "%s" : "/user%s", path);
snprintf(uri, sizeof uri, remote || strncmp(path, "/data/", 6) ? "%s" : "/user%s", path);
MetaInfo meta;
memset(&meta, 0, sizeof meta);
meta.uri = uri; meta.ex_uri = ""; meta.playgo_scenario_id = ""; meta.content_id = content_id;
+5 -3
View File
@@ -1,7 +1,7 @@
// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
// Native API contract (ABI v19) between the hosts and `@ps5-react/core`. The input actions hosts
// Native API contract (ABI v20) between the hosts and `@ps5-react/core`. The input actions hosts
// pass to `__ps5ReactDispatch` are a separate contract with its own version (runtime/js/input.js).
//
// host_api.cpp (shared) installs `globalThis.__ps5ReactNative` through
@@ -82,8 +82,10 @@
// lan.cancel(id) stops gathering; the replies so far are kept
// lan.poll() [{id, state, error, replies: [{address, port, body}]}] finished
// since the last poll; state 'completed' | 'failed' | 'cancelled'
// packages.install(path, name) task ID; installs a .pkg under /data, /mnt/usb or /mnt/ext through the
// console's install service (PS5 only, needs the payload loader on 9021)
// packages.install(path, name, contentId) task ID; installs a .pkg under /data, /mnt/usb or /mnt/ext through
// the console's install service (PS5 only, needs the payload loader on 9021);
// ABI v20: `path` may be an http(s) link the console downloads itself, with
// a PS4 package's `contentId`
// packages.cancel(id) stops reporting; an install the console accepted carries on
// packages.poll() state/error/contentId/status/written/total; consumes terminal results
// network.version() transport version string
+36 -9
View File
@@ -22,18 +22,45 @@ bool package_path(JSContext* ctx, JSValueConst value, std::string& out) {
JS_FreeCString(ctx, input);
return ok;
}
// ABI v20: an http(s) link, which the console's own download queue fetches and installs.
bool package_link(JSContext* ctx, JSValueConst value, std::string& out) {
if (!JS_IsString(value)) return false;
std::size_t length = 0;
const char* input = JS_ToCStringLen(ctx, &length, value);
if (!input) return false;
bool ok = length < 1000 && (!std::strncmp(input, "https://", 8) || !std::strncmp(input, "http://", 7));
for (std::size_t i = 0; ok && i < length; ++i) ok = static_cast<unsigned char>(input[i]) > ' ' && input[i] != 0x7F;
if (ok) out.assign(input, length);
JS_FreeCString(ctx, input);
return ok;
}
std::string string_argument(JSContext* ctx, int argc, JSValueConst* argv, int index) {
std::string out;
if (argc > index && JS_IsString(argv[index])) {
const char* value = JS_ToCString(ctx, argv[index]);
if (value) { out = value; JS_FreeCString(ctx, value); }
}
return out;
}
// A PS4 content ID: "UP0000-CUSA00000_00-" and sixteen letters or digits.
bool content_id(const std::string& id) {
if (id.size() != 36 || id[6] != '-' || id[16] != '_' || id[19] != '-') return false;
for (std::size_t i = 0; i < id.size(); ++i)
if (i != 6 && i != 16 && i != 19 && !((id[i] >= 'A' && id[i] <= 'Z') || (id[i] >= '0' && id[i] <= '9'))) return false;
return true;
}
JSValue install(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
std::string path;
if (argc < 1 || !package_path(ctx, argv[0], path))
return JS_ThrowTypeError(ctx, "packages.install: expected a .pkg or .fpkg path under /data, /mnt/usb or /mnt/ext");
std::string name;
if (argc > 1 && JS_IsString(argv[1])) {
const char* value = JS_ToCString(ctx, argv[1]);
if (value) { name = value; JS_FreeCString(ctx, value); }
}
const bool remote = argc > 0 && package_link(ctx, argv[0], path);
if (argc < 1 || (!remote && !package_path(ctx, argv[0], path)))
return JS_ThrowTypeError(ctx, "packages.install: expected a .pkg or .fpkg path under /data, /mnt/usb or /mnt/ext, or an http(s) link");
std::string name = string_argument(ctx, argc, argv, 1);
if (name.find('\n') != std::string::npos || name.size() > 200) return JS_ThrowTypeError(ctx, "packages.install: name must be one line of at most 200 bytes");
std::string id_given = string_argument(ctx, argc, argv, 2);
if (!id_given.empty() && (!remote || !content_id(id_given)))
return JS_ThrowTypeError(ctx, "packages.install: a content ID goes with a link, as UP0000-CUSA00000_00-XXXXXXXXXXXXXXXX");
std::string error;
const auto id = packages::install(std::move(path), std::move(name), error);
const auto id = packages::install(std::move(path), std::move(name), std::move(id_given), error);
if (!id) return JS_ThrowPlainError(ctx, "packages.install: %s", error.c_str());
return JS_NewUint32(ctx, id);
}
@@ -63,7 +90,7 @@ JSValue poll(JSContext* ctx, JSValueConst, int, JSValueConst*) {
JSValue ps5_react_package_api(JSContext* ctx) {
JSValue api = JS_NewObject(ctx);
JS_SetPropertyStr(ctx, api, "install", JS_NewCFunction(ctx, install, "install", 2));
JS_SetPropertyStr(ctx, api, "install", JS_NewCFunction(ctx, install, "install", 3));
JS_SetPropertyStr(ctx, api, "cancel", JS_NewCFunction(ctx, cancel, "cancel", 1));
JS_SetPropertyStr(ctx, api, "poll", JS_NewCFunction(ctx, poll, "poll", 0));
return api;
+3 -1
View File
@@ -9,7 +9,9 @@
namespace packages {
// `status`: the console's install stage ("transferring", "promoting", "playable").
struct Snapshot { std::uint32_t id = 0; std::string state = "queued", error, content_id, status; std::uint64_t written = 0, total = 0; };
std::uint32_t install(std::string path, std::string name, std::string& error);
// `path`: a package on a drive, or an http(s) link the console downloads itself; `content_id` names a
// link's PS4 package (empty otherwise).
std::uint32_t install(std::string path, std::string name, std::string content_id, std::string& error);
// Stops reporting; an install the console already accepted carries on.
void cancel(std::uint32_t id);
std::vector<Snapshot> poll();
+4 -1
View File
@@ -10,7 +10,10 @@
"dev": "python3 tools/cli.py preview --watch",
"build": "python3 tools/cli.py build",
"test": "python3 tools/cli.py test",
"create": "python3 tools/cli.py create"
"create": "python3 tools/cli.py create",
"doctor:mac": "sh tools/mac-run.sh doctor",
"preview:mac": "sh tools/mac-run.sh preview",
"dev:mac": "sh tools/mac-run.sh preview --watch"
},
"dependencies": {
"react": "18.3.1"
+5 -3
View File
@@ -534,11 +534,13 @@ export const Archives = Object.freeze({
* PKG installation through the console's install service (PS5 only; it needs a payload loader on port
* 9021). The task's snapshot carries `written`/`total` bytes, the console's install `status` and the
* package's `contentId`; it completes once the title is playable. Cancelling stops reporting only.
* `url` in place of `path` (ABI v20) hands an http(s) link to the console's own download queue, which
* fetches and installs it in the background; a PS4 package needs its `contentId`.
*/
export const Packages = Object.freeze({
install({path, name = ''}) {
const id = host().packages.install(path, name);
return networkTask(id, 'Packages.install', path, 'packages');
install({path, url, name = '', contentId = ''}) {
const id = host().packages.install(url ?? path, name, contentId);
return networkTask(id, 'Packages.install', url ?? path, 'packages');
},
});
+8
View File
@@ -18,6 +18,7 @@ import zipfile
from rar_worker import build as build_rar_worker, unrar_source
from common import layouts, layout_directory, resource_files, ROOT, DEPS, LOCK, run, digest, verify, fetch, app_files, bundle, dependency, stb_image, refresh_rate_mask
from network_ports import ports, copy_notices
from ca_bundle import ca_bundle
import ffmpeg
from text_fonts import harfbuzz, sheenbidi
@@ -299,6 +300,11 @@ def main():
target = app / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
# Mozilla's roots beside the console's own store, which old firmwares let fall behind (network_platform.cpp).
shutil.rmtree(app / "certs", ignore_errors=True)
if network_ports:
(app / "certs").mkdir()
shutil.copy2(ca_bundle(), app / "certs/cacert.pem")
fonts = sorted((generated / "fonts").glob("*"))
if fonts:
(app / "fonts").mkdir()
@@ -319,6 +325,8 @@ def main():
stb = (stb_image() / "stb_image.h").read_text()
(notices / "stb_image-LICENSE").write_text(stb[stb.index("This software is available under 2 licenses"):])
copy_notices(compatibility_client, notices / "networking")
(notices / "cacert-MPL-2.0.txt").unlink(missing_ok=True)
if network_ports: shutil.copy2(ROOT / "licenses/MPL-2.0.txt", notices / "cacert-MPL-2.0.txt")
shutil.copy2(compatibility_client / "LICENSE", notices / "networking-compat-LICENSE")
for entry in json.loads((ROOT / "licenses/archives-SOURCES.json").read_text()):
source = ROOT / "licenses" / entry["file"]
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) 2026 half144 and PS5 React contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# Additional attribution term: see LICENSE-ATTRIBUTION.
"""Mozilla's root certificates as curl publishes them (MPL-2.0), pinned in the lock file.
Old firmwares' own certificate store lacks roots that sites use today; networking apps ship this
bundle and the console merges it with the system store at start (native/ps5/network_platform.cpp).
`python3 tools/ca_bundle.py` pins the newest dated copy from curl.se, after the same checks a build runs.
"""
import base64
import binascii
import json
import re
import ssl
import sys
import urllib.request
from common import DEPS, LOCK, ROOT, digest, fetch
PAGE = "https://curl.se/docs/caextract.html"
BLOCK = re.compile(rb"-----BEGIN CERTIFICATE-----\r?\n(.+?)-----END CERTIFICATE-----", re.S)
def validate(path):
"""Every block decodes, OpenSSL loads every one, and the count looks like Mozilla's store."""
data = path.read_bytes()
if len(data) > 1024 * 1024 or b"\0" in data:
raise RuntimeError(f"{path.name}: not a PEM bundle")
blocks = BLOCK.findall(data)
if data.count(b"-----BEGIN") != len(blocks) or not 100 <= len(blocks) <= 400:
raise RuntimeError(f"{path.name}: {len(blocks)} certificates, expected 100-400")
try:
for block in blocks: base64.b64decode(block, validate=False)
except binascii.Error as error:
raise RuntimeError(f"{path.name}: undecodable certificate: {error}")
loaded = ssl.create_default_context(cafile=str(path)).cert_store_stats()["x509_ca"]
if loaded != len(blocks):
raise RuntimeError(f"{path.name}: OpenSSL loaded {loaded} of {len(blocks)} certificates")
return len(blocks)
def ca_bundle():
item = LOCK["caBundle"]
path = fetch(item["url"], DEPS / "ca-bundle" / f"cacert-{item['version']}.pem", item["sha256"])
validate(path)
return path
def update():
with urllib.request.urlopen(PAGE, timeout=60) as page:
dates = sorted(set(re.findall(r"cacert-(\d{4}-\d{2}-\d{2})\.pem", page.read().decode())))
if not dates: raise RuntimeError(f"No dated bundle listed on {PAGE}")
version = dates[-1]
url = f"https://curl.se/ca/cacert-{version}.pem"
path = DEPS / "ca-bundle" / f"cacert-{version}.pem"
path.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(url, timeout=120) as source:
path.write_bytes(source.read())
count = validate(path)
lock_path = ROOT / "dependencies.lock.json"
lock = json.loads(lock_path.read_text())
previous = lock.get("caBundle", {}).get("version")
lock["caBundle"] = {"version": version, "url": url, "sha256": digest(path)}
lock_path.write_bytes((json.dumps(lock, indent=2) + "\n").encode())
print(f"caBundle {previous} -> {version}: {count} certificates")
if __name__ == "__main__":
if sys.argv[1:] not in ([], ["--check"]):
sys.exit("usage: ca_bundle.py [--check]")
if sys.argv[1:] == ["--check"]: print(f"{ca_bundle().name}: {validate(ca_bundle())} certificates")
else: update()
+1 -1
View File
@@ -139,7 +139,7 @@ def resource_files(app, config):
if path.is_symlink() or not path.resolve().is_relative_to(app.resolve()):
raise ValueError(f"Resource symlinks are not supported: {entry}")
target = path.relative_to(app)
reserved = ("sce_sys", "sce_module", "notices", *(("fonts",) if config.get("textFonts") else ()))
reserved = ("sce_sys", "sce_module", "notices", "certs", *(("fonts",) if config.get("textFonts") else ()))
if target.parts[0] in reserved or str(target) in ("eboot.bin", "lapy.elf", "lapy-manifest.json"):
raise ValueError(f"Resource would overwrite package infrastructure: {target}")
if path.is_file(): files[target] = path
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# macOS launcher for the desktop preview.
#
# Uses ps5-react/.venv for Pillow (Homebrew's python3 is externally managed) and
# puts it on PATH so the `python3` calls inside cli.py resolve to the same
# interpreter. LLVM 18 comes from Homebrew, Node from nvm, and the MacPorts
# libraries plus a locally built SDL2 are preferred when present.
#
# /usr/local/bin is left out when MacPorts supplies cmake: the preview's
# CMakeLists asks `brew --prefix`, and mixing Homebrew's OpenSSL/CURL with the
# MacPorts copies breaks the link.
set -eu
root=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
venv=$root/.venv
if [ ! -x "$venv/bin/python" ]; then
echo "error: $venv is missing. Run 'npm run setup:mac' from the repository root." >&2
exit 1
fi
llvm=
for dir in /usr/local/opt/llvm@18/bin /opt/homebrew/opt/llvm@18/bin; do
if [ -x "$dir/clang" ]; then llvm=$dir; break; fi
done
if [ -z "$llvm" ]; then
echo "error: LLVM 18 not found. Install it with 'brew install llvm@18'." >&2
exit 1
fi
node=$(command -v node || true)
if [ -z "$node" ]; then
echo "error: node not found; install Node 18 or newer." >&2
exit 1
fi
node_dir=$(dirname "$node")
prefixes=
tail=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
if [ -d "$HOME/.local/overdrive-sdl2" ]; then
prefixes=$HOME/.local/overdrive-sdl2
fi
if [ -x /opt/local/bin/cmake ]; then
prefixes=${prefixes:+$prefixes:}/opt/local
tail=/usr/bin:/bin:/usr/sbin:/sbin
fi
PATH="$venv/bin:$llvm:$node_dir:/opt/local/bin:$tail"
export PATH
if [ -n "$prefixes" ]; then
export CMAKE_PREFIX_PATH="$prefixes${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}"
fi
exec "$venv/bin/python" "$root/tools/cli.py" "$@"
+17 -2
View File
@@ -101,11 +101,26 @@ def build(output, compiler, flags=(), libraries=(), env=None):
return output
def archive_prefix():
"""The prefix holding archive.h, as pkg-config, Homebrew or MacPorts reports it."""
for command in (["pkg-config", "--variable=prefix", "libarchive"], ["brew", "--prefix", "libarchive"]):
try:
candidate = subprocess.check_output(command, text=True, stderr=subprocess.DEVNULL).strip()
except (OSError, subprocess.CalledProcessError):
continue
if candidate and (Path(candidate) / "include/archive.h").exists():
return candidate
for candidate in ("/opt/local", "/usr/local", "/opt/homebrew"):
if (Path(candidate) / "include/archive.h").exists():
return candidate
raise RuntimeError("libarchive not found: install it with Homebrew or MacPorts")
def build_host(output):
"""rar-extract for the desktop preview and the archive tests."""
# macOS hashes with CommonCrypto and keeps Homebrew's libarchive keg-only; elsewhere the driver uses
# OpenSSL's EVP and the system's libarchive.
# OpenSSL's EVP and the system's libarchive. MacPorts' libarchive is found through pkg-config too.
if sys.platform == "darwin":
prefix = subprocess.check_output(["brew", "--prefix", "libarchive"], text=True).strip()
prefix = archive_prefix()
return build(output, ["clang++"], ["-pthread", "-I", f"{prefix}/include"], ["-L", f"{prefix}/lib", "-larchive"])
return build(output, ["clang++"], ["-pthread"], ["-larchive", "-lcrypto"])