mirror of
https://github.com/MyLuxy/overdrive.git
synced 2026-10-24 07:01:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34365d2c1a | ||
|
|
c08550c6e4 | ||
|
|
ddb158d7f6 | ||
|
|
13feffd4cb |
No files matched your search
@@ -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,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 |
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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": "متوسط",
|
||||
|
||||
@@ -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": "গড়",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "میانگین",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "औसत",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "平均",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Средняя",
|
||||
|
||||
@@ -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": "اوسط",
|
||||
|
||||
@@ -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": "平均",
|
||||
|
||||
@@ -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": "平均",
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+45
@@ -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
|
||||
Executable
+109
@@ -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
@@ -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."
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Executable
+54
@@ -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" "$@"
|
||||
@@ -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"])
|
||||
Reference in new issue
Block a user