23 Commits
Author SHA1 Message Date
LaRazaDelSolandClaude Opus 5.5 90fd5a89d3 0.1.3: Wings updates itself, auto-reconnect, bad-link flags, TorBox status, faster .pkg installs
- Self-update: Check, Download, Install, Restart Wings. A small helper (adapted from Orbit's
  updater) downloads and checks the package, then installs it crash-safely; no jailbreak restart.
- Dropped downloads reconnect by themselves (Settings > Pegasus DL).
- Circle cancels every waiting screen at once.
- Links that fail are marked on this console and Best skips them (Triangle to mark or clear).
- TorBox: Wings waits while TorBox prepares a file and can open the TorBox website.
- .pkg links download with Pegasus' fast path, then install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:47:26 -04:00
LaRazaDelSol 0dbc8774e2 Fix download link in README.md 2026-10-13 01:16:42 -04:00
LaRazaDelSol 383944d8cc Revise README for clarity on Wings app features
Updated README to clarify Wings functionality and links.
2026-10-13 01:13:34 -04:00
LaRazaDelSolandClaude Sonnet 5.5 7c63a09d86 0.1.2: hero-shot Store carousel, resilient multipart downloads, faster Pegasus transfers
Store: the featured carousel is now a full-width hero shot (title, View game, heart) with the grid's
header and first row peeking under it; Down slides the page up. The game page and the hero show one
banner picture whole (Prosperopatches or IGDB, alternating per open) instead of swapping pictures.
The known-problem note slides with the header.

Download all parts: each part is asked up to three times with backoff, automated provider sessions are
waited on before the next part starts, a failing part is retried on another host that carries the same
part, failed parts are listed and the run goes on; link-starting requests get a 30 s limit. The busy
and report texts are no longer cut off.

Experimental, on in this build: a hash-gated in-memory patch of the player's own Pegasus DL copy that
keeps one slow disk write from stalling the other connections (about double the throughput with 8
connections).

Version 00.001.002.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-13 00:29:53 -04:00
LaRazaDelSolandClaude Sonnet 5.5 98798a4726 Carousel always shows (falls back to the most popular games); known-problem note stays beside the facts panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 19:33:33 -04:00
github-actions[bot] de8e29ec6c chore: heartbeat 2026-10-12T22:54:55Z 2026-10-12 22:54:56 +00:00
LaRazaDelSolandClaude Sonnet 5.5 be590ffa78 Tests: a long note is accepted
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 18:53:45 -04:00
LaRazaDelSolandClaude Sonnet 5.5 4f49824057 Notes have no length limit; the page shrinks the type to fit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 18:50:05 -04:00
LaRazaDelSolandClaude Sonnet 5.5 5e32d2806d Open the page on the cached 16:9 art, default download folder, notes and carousel
- Game page holds the blurred cover back briefly when its 16:9 picture is on disk
- Set a default download folder when Pegasus has none; ask once on the first download
- Known-problem note on the game page and a curated carousel on the Store (both switchable)
- Workflow can publish the optional notes and featured lists

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUzMaa8Kn5ZRrkw8QbJtCe
2026-10-12 18:27:27 -04:00
LaRazaDelSolandClaude Sonnet 5.5 341ecfe2db Fetch the focused tile's 16:9 picture ahead of time
When the cursor rests on a Store tile for half a second, its game-page picture
is fetched into the disk cache (once per landing, at most one every 6 seconds,
following the Art sources setting) so the page finds it there.

The disk cache gives game-page pictures a budget of their own (a quarter of the
cache) so they cannot push covers out; a picture fetched ahead of time and not
opened for half an hour is evicted before ones that were read. README and the
privacy guide now list the picture hosts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 17:36:30 -04:00
github-actions[bot] 36c284f79f chore: heartbeat 2026-10-12T20:56:34Z 2026-10-12 20:56:35 +00:00
LaRazaDelSolandClaude Sonnet 5.5 56d7534209 Build identity, update check, art sources, safer CI report
- Every build is stamped (assets/build-info.json: build time and test/release
  channel); tools/mark-release-wsl.sh turns a tested package into a release
  package without compiling again and re-verifies the image.
- Wings checks the project's latest release at start-up (6 h cache, switch in
  Settings), announces a newer one and shows the manual update steps.
- Popularity data is refetched on the first start of a new build; a newer data
  format says to update Wings; no more refetch on every start for data without
  hero art.
- Settings > Art sources: cover icons from the catalogue or Prosperopatches,
  16:9 game art from all sources or Prosperopatches only.
- The CI report lists title IDs only (the report artifact is public).
- Docs: updating guide, privacy table, build notes. Version 00.001.001.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 16:51:02 -04:00
LaRazaDelSol bb1b908cd2 Fix badge link in README for GitHub release 2026-10-12 16:33:02 -04:00
LaRazaDelSol fdaac4b8c5 Enhance README with badges and alignment
Added badges and centered text in the README.
2026-10-12 16:29:52 -04:00
github-actions[bot] 48794c17b2 chore: heartbeat 2026-10-12T19:58:23Z 2026-10-12 19:58:23 +00:00
LaRazaDelSolandClaude Sonnet 5.5 b8a6d0e87a Repo cleanup: drop internal docs and dev-only files, pin build files to LF
Remove internal-only material from the public tree (first-console test
script, colour design notes, custom-catalogue note, route parity table,
and the link-parser reference scripts) and fix the links and comments that
pointed at them. The uninstall steps now live in the README.

.gitattributes keeps checksum files, the runtime and tooling folders, the
Makefile, the Dockerfile and the CA bundle on LF line endings: a Windows
checkout used to turn them into CRLF and the build rejected them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 15:51:19 -04:00
LaRazaDelSolandClaude Sonnet 5.5 bef0e8ca44 Game page: hero art always fills the screen, small image first, no box edges
The hero is scaled uniformly and cropped to cover the whole screen (never
stretched, never black below it). Images that would need an extreme zoom
(wider than 3.3:1) or that are nearly square are not used and the page
keeps the cover wash.

The page asks for the small heroLow image first and fades the full hero in
over it; the small one stays if the full one fails. A saved popularity copy
with no hero art is re-downloaded instead of waiting out the 24 h cache.
The data size cap is 512 KB and the hero cache holds 2 images.

The "more below" fade at the bottom of the page is now full width and fades
into the colour the hero band ends on, so it no longer shows a box with
edges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 15:51:08 -04:00
LaRazaDelSolandClaude Sonnet 5.5 60d86cba2d Popularity index: choose hero art by real shape, add pic0 and heroLow
hero is now picked in this order: the console's own 1920x1080 background
(pic0, looked up per title and verified by its WebP header), the IGDB
screenshot closest to 16:9, the IGDB artwork closest to 16:9, then Steam's
library_hero. IGDB images need a width of at least 1280 and a shape within
15% of 16:9, so wide banners are never chosen. heroLow is a small IGDB
screenshot the app can show while the full image loads.

The per-title fetchedAt field is gone (the app never read it). The size
limit is 500000 bytes. pic0 lookups are polite (1 request per second),
cached, and can never fail a run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 15:51:02 -04:00
LaRazaDelSolandClaude Sonnet 5.5 aa630b62cb README: link VirusTotal scans for the release files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHcwRjnNjFZaRkbpYQoCKL
2026-10-12 14:19:15 -04:00
github-actions[bot] 728b15750f chore: heartbeat 2026-10-12T17:57:17Z 2026-10-12 17:57:18 +00:00
LaRazaDelSol 2e077854c1 Update README to emphasize legal use of games
Clarified that Wings only allows use of legally owned games.
2026-10-12 13:35:15 -04:00
LaRazaDelSol fe57e59e5d Clarify screenshot rendering description in README
Updated the description of the screenshot rendering to clarify that it is from a stand-in back-end and may not represent actual results.
2026-10-12 13:33:23 -04:00
LaRazaDelSolandClaude Sonnet 5.5 26a1264ba8 Fix build docs and script defaults for the published layout; absolute release link
Adds docs/build.md (the stock Orbit notes were wrong for this repo), makes tools/build-wings-wsl.sh use the repository
root as the source by default, and links the latest release with an absolute URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDzhvMMBBFhS9sLJNGtK4k
2026-10-12 13:32:27 -04:00
220 changed files with 26655 additions and 1870 deletions

No files matched your search

+6
View File
@@ -3,3 +3,9 @@
*.ps1 text eol=crlf
*.png binary
*.ffpkg binary
*.sha256 text eol=lf
app/runtime/** text eol=lf
app/tooling/** text eol=lf
app/Makefile text eol=lf
app/Dockerfile text eol=lf
*.crt text eol=lf
+9 -3
View File
@@ -50,12 +50,18 @@ jobs:
WINGS_CONTACT: ${{ vars.WINGS_CONTACT }}
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
# IGDB is opt-in: set repository variable WINGS_PROVIDERS to "wikipedia,steam,igdb" to enable it.
WINGS_PROVIDERS: ${{ vars.WINGS_PROVIDERS || 'wikipedia,steam' }}
# IGDB is opt-in: set repository variable WINGS_PROVIDERS to "wikipedia,steam,pic0,igdb" to enable it.
WINGS_PROVIDERS: ${{ vars.WINGS_PROVIDERS || 'wikipedia,steam,pic0' }}
run: |
python build_index.py --catalogue catalogue-titles.json --overrides overrides.json \
--providers "$WINGS_PROVIDERS" --out site/popularity.v1.json --report out/report.md \
--max-bytes 400000
--max-bytes 500000 --report-ids-only
- name: Build editorial files (optional)
# Each secret is optional; unset or empty means that file is simply not published. Content is never printed.
env:
NOTES_JSON: ${{ secrets.NOTES_JSON }}
CURATED_JSON: ${{ secrets.CURATED_JSON }}
run: python build_editorial.py --notes-env NOTES_JSON --curated-env CURATED_JSON --out site
- name: Save provider response cache (also after a failure, so a retry is fast)
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+4 -1
View File
@@ -10,7 +10,7 @@ Wings is a derivative work of the native TV app of **Orbit Store** by **saawant1
- Forked from: Orbit Store 1.1.0 (TV app 1.4.0), source commit `987022e51eaf925f38093b44f5c24f5e69f57cc9`
- License: GPL-3.0-or-later. Wings keeps the same license.
What Wings kept from Orbit: the app shell, the screen and navigation model, the on-screen keyboard and prompts, the download-queue and library screen structure, the build and packaging pipeline, and the visual language. What Wings replaced: everything that talks to a backend (Wings talks only to Pegasus DL), the Store, Files, Settings and search screens, link grouping and ranking, and all names, art and icons. Orbit's payload, loader launch, pairing, ShadowMount library client, Zero companion and update feeds are not part of Wings. Files that were rewritten say in their first lines which Orbit file they were adapted from, and the original `SPDX-License-Identifier` headers are kept. Parts of the Pegasus HTTP client (`core/src/http.cpp`) and the JSON reader (`core/src/json.cpp`) were adapted from Orbit's `http.cpp` and `json.cpp`.
What Wings kept from Orbit: the app shell, the screen and navigation model, the on-screen keyboard and prompts, the download-queue and library screen structure, the build and packaging pipeline, and the visual language. What Wings replaced: everything that talks to a backend (Wings talks only to Pegasus DL), the Store, Files, Settings and search screens, link grouping and ranking, and all names, art and icons. Orbit's payload, pairing, ShadowMount library client, Zero companion and update feeds are not part of Wings. The Wings update helper (`app/helper/`, a separate payload that installs Wings updates) ports code from Orbit Store 1.3.5's backend (`backend/tvapp.c` and `backend/api.c`). Files that were rewritten say in their first lines which Orbit file they were adapted from, and the original `SPDX-License-Identifier` headers are kept. Parts of the Pegasus HTTP client (`core/src/http.cpp`) and the JSON reader (`core/src/json.cpp`) were adapted from Orbit's `http.cpp` and `json.cpp`.
## The UI kit and platform layer
@@ -39,6 +39,9 @@ Running Wings needs a PS5 set up with these separate projects, which have their
| LLVM libc++, libc++abi, libunwind | 18.1.8 | Apache-2.0 with LLVM exceptions |
| compiler-rt builtins | 18.1.3 | Apache-2.0 with LLVM exceptions |
| OpenSSL (PacBrew ports v0.40.2), statically linked for the cover art TLS client | 3.5.2 | Apache-2.0 |
| OpenSSL (PacBrew ports v0.40.2), statically linked into the update helper | 3.5.2 | Apache-2.0 |
| curl / libcurl (PacBrew ports v0.40.2), with libpsl, zstd and zlib, statically linked into the update helper | 8.18.0 | curl licence (MIT/X style); MIT; BSD-3-Clause; zlib |
| cJSON, compiled into the update helper | 1.7.19 | MIT |
| Mozilla CA certificate bundle (curl.se `caextract`, 121 roots, as of 2026-08-23) | n/a | MPL-2.0 (data, unmodified) |
| stb_image, stb_vorbis, stb_image_write (stb_image_write: PC preview only) | see `src/third_party/stb/` | Public domain or MIT |
| Inter typeface | 4.1 | SIL Open Font License 1.1 |
+8 -3
View File
@@ -26,8 +26,13 @@ first line ("Wings TV app - ...") and say in a comment which Orbit Store file
they were adapted from. Orbit Store's name, logo, icon and artwork are not
used by Wings; Wings' mark, icon and the preview art are original.
Wings contains none of Orbit Store's backend: no Orbit payload, loader launch
code, pairing, ShadowMount library client, Zero companion code or update feeds.
Wings contains no Orbit payload, pairing, ShadowMount library client, Zero
companion code or update feeds. The Wings update helper (wings/app/helper/,
shipped as assets/helper/wings-helper.elf) is a separate payload that downloads,
checks and places Wings' own updates. It ports code from Orbit Store's backend
(backend/tvapp.c and backend/api.c, orbit-store-1.3.5, GPL-3.0-or-later); the
ported files say so in their first lines. It bundles cJSON (MIT) and statically
links curl (curl licence) and OpenSSL (Apache-2.0).
ShadowMountPlus
---------------
@@ -62,7 +67,7 @@ Components
The app components (kit, ps5-opengl SDK, PS5 payload SDK headers and stubs,
libwebp, libc++ family, compiler-rt builtins, stb, Inter typeface, glass sound
set) and their licenses are listed in wings/app/THIRD-PARTY-NOTICES.md. The
set, and the update helper's cJSON, curl and OpenSSL) and their licenses are listed in wings/app/THIRD-PARTY-NOTICES.md. The
Inter typeface is under the SIL Open Font License 1.1; keep
app/third_party/fonts/Inter-LICENSE.txt and assets/fonts/Inter-LICENSE.txt
with the fonts.
+44 -17
View File
@@ -1,8 +1,20 @@
<p align="center">
<img src="./app/sce_sys/icon0.png" alt="Wings Logo" width="25%%">
</p>
<p align="center">
<a href="https://github.com/LaRazaDelSol/wings-app/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/LaRazaDelSol/wings-app?label=release&color=24292f"></a>
<a href="https://github.com/LaRazaDelSol/wings-app/releases/latest/download/PPSA99178.ffpkg"><img alt="Download" src="https://img.shields.io/badge/download-release-24292f"></a>
<img alt="PS5 homebrew" src="https://img.shields.io/badge/PS5-homebrew-24292f">
</p>
<p align="center">
A native PS5 front end for Pegasus DL, made for the couch and a controller.
</p>
<div align="center">
# Wings
### A native PS5 front end for Pegasus DL, made for the couch and a controller.
---
One clean page per game. See what every link is before you press it. Manage your files. Let Pegasus do the downloading.
@@ -12,9 +24,9 @@ One clean page per game. See what every link is before you press it. Manage your
| What Wings is | What Wings is not |
|---|---|
| A PS5 app (title ID `PPSA99178`) that is a front end for a **Pegasus DL you already run** on the same console, through its local API at `127.0.0.1:6970`. | Not Pegasus DL, not affiliated with its author, and not a replacement for it. Pegasus does the downloading, installing and extracting. |
| A better way to browse what Pegasus already knows: games grouped one per tile, links explained, files managed with a controller. | Not a source of games. Wings ships **no catalogues, no sources and no download links**. You add your own sources in Pegasus. |
| Free software, GPL-3.0-or-later, built on the TV app of Orbit Store. | Not Orbit Store. None of Orbit's backend (payload, loader, pairing, update feeds) is in Wings. |
| A PS5 app (title ID `PPSA99178`) that is a front end & performance patcher for a **Pegasus DL** on the same console, through its local API at `127.0.0.1:6970`. | Not Pegasus DL, not affiliated with its author, and not a replacement for it. Pegasus does the downloading, installing and extracting. |
| A better way to browse what Pegasus already knows: games grouped one per tile, links explained, files managed with a controller. | Not a source of games. Wings ships **no catalogues, no sources and no download links**. Only play and use games you legally own and are permitted to install. |
| Free software, GPL-3.0-or-later, built on the TV app of Orbit Store. | Not Orbit Store. The only part of Orbit's backend in Wings is its small self-update helper, adapted for Wings (see [Updating Wings](docs/guides/updating.md)). |
> **Use it responsibly.** Wings is a front end: it ships no catalogues, sources or links. Use it only with content you own or have permission to download, the same as Pegasus DL asks.
>
@@ -22,21 +34,26 @@ One clean page per game. See what every link is before you press it. Manage your
## Screenshots
Rendered from the PC preview against a stand-in backend, so the titles are invented.
> **Only download and install games you own.** Use Wings with your own backups and archives, or with content you have the right to download and install. Wings ships no games, catalogues, sources or links.
<p>
<img src="docs/screenshots/store.png" width="49%" alt="The Store: featured game banner above the game grid">
<img src="docs/screenshots/store-grid.png" width="49%" alt="The Store grid, one tile per game">
<img src="docs/screenshots/game-page.png" width="49%" alt="A game page with the Best card and mirror cards">
</p>
<p>
<img src="docs/screenshots/files-whereto.png" width="49%" alt="The Files tab, Where to? browser">
<img src="docs/screenshots/search.png" width="49%" alt="Search with live results and recent searches">
<img src="docs/screenshots/game-page.png" width="49%" alt="A game page with its picture, rating and Install">
<img src="docs/screenshots/library.png" width="49%" alt="The Library of installed games">
</p>
<p>
<img src="docs/screenshots/downloads.png" width="49%" alt="The downloads queue">
<img src="docs/screenshots/settings-wings.png" width="49%" alt="Settings, Wings group">
<img src="docs/screenshots/files.png" width="49%" alt="The Files tab">
</p>
<p>
<img src="docs/screenshots/settings-wings.png" width="49%" alt="Settings, Wings group">
<img src="docs/screenshots/update.png" width="49%" alt="Wings updating itself">
</p>
The games pictured are free and open-source games, shown with their own openly licensed art ([credits](app/host/fixtures/ART-CREDITS.md)); the pictures are rendered from a stand-in back-end, not a real catalogue. They show what Wings looks like, not where to get games: again, only download and install from your own archives or sources you are permitted to use.
## Credits
@@ -67,6 +84,8 @@ Libraries, fonts and versions are listed in full in [CREDITS.md](CREDITS.md). If
- **Cover art.** Store posters are downloaded over HTTPS by Wings itself and cached on the console (up to 96 MiB). **On by default**; turn it off in Settings.
- **Optional popularity.** Sort by most popular or highest rated, and show a score and rating on tiles, from a small public data file. **On by default**; turn it off in Settings. See [Popularity data and privacy](docs/guides/popularity-and-privacy.md).
- **Tile details setting.** Store tiles show their link tier and regions Always, On focus (default) or Never.
- **Choose where art comes from.** Settings > Wings > Art sources: cover icons from the catalogue (default) or from Prosperopatches (each title's PS5 icon), and the wide picture behind a game's page from every source (default) or from Prosperopatches only (the PS5's own background).
- **Updates itself, on your say-so.** Wings checks this project's latest release when it starts and tells you if a newer one exists. You can download it from Settings > Wings > Updates; Wings verifies it (size, SHA-256 and the image itself) and installs it only after you confirm, keeping the old file until the new one is in place. Where the console does not allow the swap, it shows the exact steps. See [Updating Wings](docs/guides/updating.md).
## Install
@@ -77,15 +96,16 @@ Libraries, fonts and versions are listed in full in [CREDITS.md](CREDITS.md). If
This is the same setup Orbit Store asks for. Pegasus DL must be running on the console when you open Wings.
1. Download `PPSA99178.ffpkg` from the [latest release](../../releases/latest).
1. Download `PPSA99178.ffpkg` from the [latest release](https://github.com/LaRazaDelSol/wings-app/releases/latest).
2. Check the file. On Windows, in PowerShell: `Get-FileHash .\PPSA99178.ffpkg -Algorithm SHA256`. The value must equal the SHA-256 shown on the release page (case does not matter). If it does not, download again.
VirusTotal scans: [PPSA99178.ffpkg](https://www.virustotal.com/gui/file/7597dc7c53f4174302af89aafa1d049745564c0b8f76433b6fade88e4a05105d) · [PPSA99178-Alternative.zip](https://www.virustotal.com/gui/file/f14f05763f85e9863a8d9b63c0b4ce0c48344cc68b7fa8e2e17026aec84b610e)
3. Copy the file to `/data/homebrew/` on the console (FTP or your usual file transfer). Check that the size on the console equals the size on the release page.
4. Wait about ten seconds. ShadowMountPlus registers it and a **Wings** tile appears in the Games row.
5. Open **Wings**.
To replace an older build under the same title ID, replace the file, remove the staged copies ShadowMountPlus made, then restart the jailbreak chain so the new package is mounted. To remove Wings, delete the `.ffpkg`; see section 10 of [FIRST-CONSOLE-TEST.md](docs/FIRST-CONSOLE-TEST.md).
Wings 0.1.3 and newer update themselves: when a new version is out, Wings offers it and does Download and Install for you; then close Wings and open it again (see [Updating Wings](docs/guides/updating.md)). By hand: replace the file, remove the staged copies ShadowMountPlus made, then close Wings and open it again. To remove Wings, close it, delete `/data/homebrew/PPSA99178.ffpkg` (or the folder `/data/homebrew/PPSA99178/`), and restart the jailbreak chain so the tile disappears. Optionally delete `/data/wings/` (diagnostics and a copy of the settings) and `/data/wings-helper/` (the update helper's download). Pegasus DL and its downloads are not touched.
Wings writes only to its own folders: `/download0/wings/` (cover cache, settings, diagnostics, inside the title's storage) and `/data/wings/` (diagnostics and a copy of the settings, so you can read them in the file manager).
Wings writes only to its own folders: `/download0/wings/` (cover cache, settings, diagnostics, bad-link marks, inside the title's storage) and `/data/wings/` (diagnostics and a copy of the settings, so you can read them in the file manager). When you install an update, its helper also writes `/data/wings-helper/` and replaces `/data/homebrew/PPSA99178.ffpkg` (keeping the old one as `.old`).
## Quick start
@@ -108,6 +128,7 @@ More detail: [Getting started](docs/guides/getting-started.md).
| [The Files tab](docs/guides/files-tab.md) | Browse, select, copy, move, extract, install, cancel |
| [Archive passwords](docs/guides/archive-passwords.md) | The auto-try, the prompt, "choose what to keep" |
| [After a download: ShadowMount scan](docs/guides/after-install-scan.md) | Why a new game waits for Wings to close, and the banner |
| [Updating Wings](docs/guides/updating.md) | How Wings tells you about a new release, and how to install it |
| [Popularity data and privacy](docs/guides/popularity-and-privacy.md) | What is fetched, from where, how to stop it |
| [Troubleshooting and FAQ](docs/guides/troubleshooting.md) | Waiting for Pegasus, covers, "extraction failed", diagnostics |
@@ -139,7 +160,13 @@ Wings has no account, no telemetry that this project operates, and no analytics.
| Cover art **on** | The cover servers named in your catalogue's poster URLs, over HTTPS | Downloading the images. Those servers see the console's IP address. |
| Only when you press Check, or at most every 6 hours while Settings > Pegasus DL is open (needs Cover art on) | `api.github.com` (the author's release list, one small file) | Showing the latest official Pegasus DL version. GitHub sees the console's IP address. |
| Only when you press Download and confirm | `github.com`, then GitHub's `objects.githubusercontent.com` | The official `Pegasus-dl.elf` (about 20 MB), checked against GitHub's SHA-256 before it is saved. Nothing else is accepted. |
| Popularity **on** (default) | `larazadelsol.github.io` (GitHub Pages), one file, at most once per 24 hours | Scores and ratings, keyed by title ID. GitHub sees the console's IP address. The file has no user data and Wings sends no identifier beyond a fixed `WingsTV/0.1` User-Agent. |
| Popularity **on** (default) | `larazadelsol.github.io` (GitHub Pages), one file, at most once per 24 hours and once after Wings is updated | Scores and ratings, keyed by title ID. GitHub sees the console's IP address. The file has no user data and Wings sends no identifier beyond a fixed `WingsTV/0.1` User-Agent. |
| Known-problem notes or the featured carousel **on** (default) | `larazadelsol.github.io`, two more small files (`notes.v1.json`, `curated.v1.json`), on the same schedule | A short note on games known not to download, and the list of featured games, keyed by title ID. Same User-Agent, no user data. Turn them off in Settings > Wings > Store extras. |
| Popularity **on** and Cover art **on**: game-page pictures | The hosts named in the popularity data: `cdn.prosperopatches.com`, `images.igdb.com`, `cdn.cloudflare.steamstatic.com` (HTTPS) | The 16:9 picture behind a game's page, fetched when you open the page and, ahead of time, for the one game your cursor rests on for half a second (at most one every 6 seconds). Those servers see the console's IP address and the picture asked for. Art sources > *Prosperopatches only* limits this to the first host. |
| Art sources set to **Prosperopatches** (off by default; needs Cover art on) | `cdn.prosperopatches.com` over HTTPS | Cover icons and game-page backgrounds, for the titles the popularity data lists there. Prosperopatches sees the console's IP address and the image asked for. |
| Update check **on** (default; needs Cover art on) | `api.github.com` (the latest release of this project, one small file), when Wings starts and then at most every 6 hours | Telling you when a newer Wings exists. GitHub sees the console's IP address and the same fixed User-Agent. Turn it off in Settings > Wings > Updates. |
| Only when you press Download on the update page | `127.0.0.1:9021` (the payload loader on this console), then the update helper on `127.0.0.1:9178`, which downloads from `github.com`, `release-assets.githubusercontent.com` and `objects.githubusercontent.com` | The new Wings package (about 55 MB) and its `.sha256` file, checked before it is kept. GitHub sees the console's IP address. |
| Only when you choose **Open TorBox website** | The PS5 browser opens `torbox.app`, through Pegasus DL | Checking what TorBox is preparing for you. Wings itself sends nothing to TorBox. |
Turn **Cover art** off and Popularity (which needs it) stops too: Wings then makes **no internet connection at all**. Pegasus DL has its own network behaviour (sources, debrid, downloads); that is Pegasus, not Wings. Details: [Popularity data and privacy](docs/guides/popularity-and-privacy.md).
+1
View File
@@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
clang clang-18 lld-18 llvm-18 libclang-rt-18-dev clang-format-18 clang-tidy-18 \
python3 python3-venv python3-pil \
libegl-dev libgl-dev mesa-common-dev libegl1 libegl-mesa0 libgl1-mesa-dri libwebp-dev \
libcurl4-openssl-dev libssl-dev \
dotnet-sdk-8.0 \
&& rm -rf /var/lib/apt/lists/*
# The console is x86-64. The OpenGL link group needs compiler-rt builtins for that
+50 -6
View File
@@ -43,23 +43,65 @@ RUNTIME_INPUTS := tools/rebuild-libc.sh tools/build-host-tools.sh tools/ninja-bu
$(wildcard tooling/native/*.cpp tooling/native/*.hpp) \
$(wildcard tooling/native/runtime/*.txt)
.PHONY: app ffpkg ffpfsc packages deps opengl libc preview test fonts clean distclean help
.PHONY: app ffpkg ffpfsc packages deps opengl libc preview test fonts clean distclean help \
helper helper-test
app: $(RUNTIME) opengl
app: $(RUNTIME) opengl helper
@printf '%s\n' '==> [app] Compiling, linking, signing and assembling the app folder'
@bash tools/build.sh Folder
ffpkg: $(RUNTIME) opengl
ffpkg: $(RUNTIME) opengl helper
@printf '%s\n' '==> [ffpkg] Building the app folder and its UFS2 image'
@bash tools/build.sh Ffpkg
ffpfsc: $(RUNTIME) opengl
ffpfsc: $(RUNTIME) opengl helper
@printf '%s\n' '==> [ffpfsc] Building the app folder and its compressed image'
@bash tools/build.sh Ffpfsc
packages: $(RUNTIME) opengl
packages: $(RUNTIME) opengl helper
@bash tools/build.sh All
# The update helper (helper/): a payload the app sends to the console's ELF loader to place a
# downloaded Wings release in /data/homebrew. Built like Orbit Store's backend: the payload SDK's
# clang, static curl/OpenSSL from the PacBrew sysroot, cJSON compiled in, the CA bundle embedded.
# It lands in assets/, so build.sh ships it as /app0/assets/helper/wings-helper.elf.
HELPER_SDK := .deps/native/ps5-payload-sdk
HELPER_SYSROOT := .deps/pacbrew/v0.40.2/sysroot/user/homebrew
HELPER_CORE := helper/install.c helper/server.c third_party/cjson/cJSON.c
HELPER_HEADERS := $(wildcard helper/*.h) third_party/cjson/cJSON.h build/helper/ca.h
HELPER_CFLAGS := -std=c11 -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 \
-Ihelper -Ithird_party/cjson -Ibuild/helper
HELPER_LIBS := -l:libcurl.a -l:libpsl.a -l:libssl.a -l:libcrypto.a -l:libzstd.a -l:libz.a -lpthread
HELPER_HOST_CC ?= clang-18
HELPER_ELF := assets/helper/wings-helper.elf
helper: $(HELPER_ELF)
build/helper/ca.h: assets/ca-bundle.crt
@mkdir -p $(@D)
@{ printf 'static const char wings_helper_ca[] =\n'; \
sed -e 's/\r$$//' -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/^/"/' -e 's/$$/\\n"/' $<; \
printf ';\n'; } > $@
$(HELPER_ELF): helper/main.c $(HELPER_CORE) $(HELPER_HEADERS) Makefile
@printf '%s\n' '==> [helper] Building the update helper payload'
@mkdir -p $(@D)
LLVM_CONFIG=$${LLVM_CONFIG:-$$(command -v llvm-config-18)} $(HELPER_SDK)/bin/prospero-clang $(HELPER_CFLAGS) -Wno-unreachable-code-generic-assoc \
-I$(HELPER_SYSROOT)/include helper/main.c $(HELPER_CORE) -o $@ \
-L$(HELPER_SYSROOT)/lib $(HELPER_LIBS)
LLVM_CONFIG=$${LLVM_CONFIG:-$$(command -v llvm-config-18)} $(HELPER_SDK)/bin/prospero-strip --strip-debug $@
# Host tests of the helper against a fake GitHub on 127.0.0.1 (needs libcurl and OpenSSL dev
# packages on the host).
build/helper/helper-test: $(HELPER_CORE) $(HELPER_HEADERS) tests/helper/helper_test.c Makefile
@mkdir -p $(@D)
$(HELPER_HOST_CC) $(HELPER_CFLAGS) -g -D_DEFAULT_SOURCE -DWINGS_HELPER_TEST \
-fsanitize=address,undefined -fno-sanitize-recover=undefined \
$(HELPER_CORE) tests/helper/helper_test.c -o $@ -lcurl -lssl -lcrypto -lpthread
helper-test: build/helper/helper-test
@./build/helper/helper-test
deps:
@bash tools/setup-test-dependencies.sh >/dev/null
@bash tools/setup-native-dependencies.sh
@@ -90,7 +132,7 @@ fonts:
clean:
@rm -rf -- build dist
@rm -f -- $(RUNTIME)
@rm -f -- $(RUNTIME) $(HELPER_ELF)
distclean: clean
@rm -rf -- .deps
@@ -101,4 +143,6 @@ help:
'make ffpkg Also build dist/PPSA99178.ffpkg (ShadowMountPlus image)' \
'make preview Render every screen on this machine to PNG' \
'make test Host unit tests (sanitizers on)' \
'make helper Build the update helper payload (assets/helper/wings-helper.elf)' \
'make helper-test Host tests of the update helper' \
'make fonts Rebake the Inter SDF fonts'
+11 -3
View File
@@ -6,9 +6,12 @@ Orbit Store, https://github.com/saawant12/orbit-store-ps5, GPL-3.0-or-later,
from which this app is forked).
These are the components the Wings app itself builds from or ships. Orbit's
payload components (libmicrohttpd, curl, cJSON, React) are NOT part of Wings.
OpenSSL and the Mozilla CA bundle are: Wings fetches https cover art itself
(`wings/art`, below). Component versions and hashes are those pinned by
payload, libmicrohttpd and React are NOT part of Wings. OpenSSL and the Mozilla
CA bundle are: Wings fetches https cover art itself (`wings/art`, below). The
Wings update helper (`helper/`, shipped as `assets/helper/wings-helper.elf`) is
a separate payload that installs Wings updates; it ports code from Orbit Store's
backend and statically links curl, OpenSSL and cJSON (below). Component versions
and hashes are those pinned by
`tools/setup-*.sh`, `tools/fetch-opengl-sdk.sh` and `Dockerfile`, unchanged from
Orbit Store 1.1.0 (see its `THIRD-PARTY-NOTICES.md` for the full table and the
source bundle).
@@ -26,6 +29,11 @@ source bundle).
| OpenSSL 3.5.2 (PacBrew ports v0.40.2; `libssl.a`, `libcrypto.a`) | Apache-2.0 | fetched at build time, statically linked into `eboot.bin` for the cover art TLS client. Licence text: https://github.com/openssl/openssl/blob/openssl-3.5.2/LICENSE.txt |
| Mozilla CA certificate bundle (curl.se `caextract`, 121 roots, as of 2026-08-23; sha256 `11163db4...36fe6c`) | MPL-2.0 (Mozilla data) | `assets/ca-bundle.crt` (copied from the pinned PacBrew sysroot by `wings/art/tools/stage-into-app.sh`); the player may override it with `/download0/wings/ca-bundle.pem` |
| Wings cover art module (`wings/art`: TLS client, DNS-over-HTTPS, disk cache, art service) | GPL-3.0-or-later (this project) | `src/platform/wings_art/`, console build only |
| Wings update helper (download, SHA-256 check and placement of Wings updates; local HTTP API on 127.0.0.1) | GPL-3.0-or-later; ports code from Orbit Store 1.3.5 `backend/tvapp.c` and `backend/api.c` (GPL-3.0-or-later), https://github.com/saawant12/orbit-store-ps5 | `helper/`, built into `assets/helper/wings-helper.elf` |
| cJSON 1.7.19 | MIT (licence text in `third_party/cjson/`) | `third_party/cjson/`, compiled into `wings-helper.elf` |
| curl / libcurl 8.18.0 (PacBrew ports v0.40.2) | curl licence (MIT/X style), https://curl.se/docs/copyright.html | fetched at build time, statically linked into `wings-helper.elf` |
| OpenSSL 3.5.2 (PacBrew ports v0.40.2) | Apache-2.0 | also statically linked into `wings-helper.elf` (HTTPS for update downloads) |
| libpsl, zstd, zlib (PacBrew ports v0.40.2, curl's dependencies) | MIT; BSD-3-Clause (zstd, dual-licensed with GPL-2.0); zlib licence | fetched at build time, statically linked into `wings-helper.elf` |
| Interface sound effects (glass set) | GPL-3.0-or-later (BlackBearReloaded, from the kit) | `assets/audio/sfx/glass/` |
Provenance of the glass sound set still needs confirming in the kit repository
+1168
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
/* Wings update helper - download, check and place a Wings release.
* SPDX-License-Identifier: GPL-3.0-or-later
* Adapted from Orbit Store 1.3.5 backend/tvapp.c (GPL-3.0-or-later). */
#ifndef WINGS_HELPER_INSTALL_H
#define WINGS_HELPER_INSTALL_H
#include "cJSON.h"
#include <stdbool.h>
#include <stddef.h>
/* Starts the worker thread. root prefixes every console path ("" on the console; a temporary
* directory in the host tests). 0 on success. */
int helper_jobs_init(const char *root);
/* Interrupts a download and joins the worker. */
void helper_jobs_shutdown(void);
/* The status object of GET /wings-helper/v1/status (caller frees). */
cJSON *helper_status(void);
/* The routes' work. Each returns the HTTP status; on a refusal error holds the message and
* *reason a short machine word (busy, invalid, folder, blocked, newer, manual, old_waiting,
* space, not_running, not_downloaded).
* download fetches the release to the .part, checks it and records it (phase "downloaded").
* install only places a downloaded, re-checked .part; it never uses the network. */
int helper_download(const cJSON *input, char *error, size_t cap, const char **reason);
int helper_install(const cJSON *input, char *error, size_t cap, const char **reason);
int helper_cancel(void);
int helper_tidy(const cJSON *input, char *error, size_t cap, const char **reason);
bool helper_busy(void);
/* True only between the two renames; stopping then could leave no PPSA99178.ffpkg. */
bool helper_renaming(void);
/* -1, 0 or 1 for dotted numeric versions ("1.2" < "1.10"). */
int helper_version_compare(const char *a, const char *b);
#endif
+52
View File
@@ -0,0 +1,52 @@
/* Wings update helper - entry point.
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Started by the Wings TV app through the console's ELF loader. Serves 127.0.0.1:9178 until
* the app says stop or nothing has asked for two minutes while no update runs. A second copy
* finds the port taken and exits at once, leaving the running one in charge. The start-up
* order follows Orbit Store 1.3.5 backend/main.c (GPL-3.0-or-later). */
#include "install.h"
#include "server.h"
#include "version.h"
#include <curl/curl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static void stop_signal(int signal_number)
{
(void)signal_number;
helper_server_request_stop();
}
int main(void)
{
const char *root = "";
#ifdef WINGS_HELPER_TEST
if (getenv("WINGS_HELPER_ROOT"))
root = getenv("WINGS_HELPER_ROOT");
#endif
signal(SIGTERM, stop_signal);
signal(SIGINT, stop_signal);
signal(SIGPIPE, SIG_IGN);
int fd = helper_server_open(WINGS_HELPER_PORT);
if (fd == HELPER_SERVER_IN_USE)
return 0; /* the helper already running wins */
if (fd < 0)
{
fprintf(stderr, "Wings helper: cannot listen on port %d.\n", WINGS_HELPER_PORT);
return 1;
}
if (curl_global_init(CURL_GLOBAL_DEFAULT) || helper_jobs_init(root))
{
fprintf(stderr, "Wings helper: cannot start.\n");
return 1;
}
printf("Wings helper %s | port %d\n", WINGS_HELPER_BUILD, WINGS_HELPER_PORT);
helper_server_run(fd, WINGS_HELPER_IDLE_SECONDS);
helper_jobs_shutdown(); /* joins the worker: all file work is done */
close(fd); /* only now may another helper take the port */
curl_global_cleanup();
return 0;
}
+394
View File
@@ -0,0 +1,394 @@
/* Wings update helper - the loopback HTTP server.
* SPDX-License-Identifier: GPL-3.0-or-later
*
* One request per connection, served in turn: the app polls status a few times a second and
* the work runs on the install worker. The guards follow Orbit Store 1.3.5 backend/api.c
* (GPL-3.0-or-later): a browser on the same console can reach 127.0.0.1, so a request must
* name this helper's own Host (no DNS rebinding), carry no Origin, carry X-Wings-Helper: 1,
* and send JSON on POST (a page cannot add those headers without a CORS preflight, which the
* helper never answers). The download URL is pinned, so the most a hostile caller could do is
* install a genuine Wings release. */
#include "server.h"
#include "install.h"
#include "version.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <poll.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define HEAD_LIMIT 8192
#define BODY_LIMIT 4096
#define ROUTE "/wings-helper/v1/"
static atomic_bool stopping;
void helper_server_request_stop(void)
{
atomic_store(&stopping, true);
}
void helper_server_reset(void)
{
atomic_store(&stopping, false);
}
int helper_server_open(uint16_t port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
return -1;
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in a;
memset(&a, 0, sizeof a);
a.sin_family = AF_INET;
a.sin_port = htons(port);
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(fd, (struct sockaddr *)&a, sizeof a))
{
int e = errno;
close(fd);
return e == EADDRINUSE ? HELPER_SERVER_IN_USE : -1;
}
if (listen(fd, 8))
{
close(fd);
return -1;
}
return fd;
}
uint16_t helper_server_port(int fd)
{
struct sockaddr_in a;
socklen_t n = sizeof a;
if (getsockname(fd, (struct sockaddr *)&a, &n))
return 0;
return ntohs(a.sin_port);
}
static void send_all(int fd, const char *data, size_t n)
{
while (n)
{
ssize_t w = send(fd, data, n, MSG_NOSIGNAL);
if (w < 0 && errno == EINTR)
continue;
if (w <= 0)
return;
data += w;
n -= (size_t)w;
}
}
static const char *reason_phrase(int code)
{
switch (code)
{
case 200:
return "OK";
case 202:
return "Accepted";
case 400:
return "Bad Request";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 405:
return "Method Not Allowed";
case 409:
return "Conflict";
case 411:
return "Length Required";
case 413:
return "Payload Too Large";
case 415:
return "Unsupported Media Type";
case 431:
return "Request Header Fields Too Large";
case 507:
return "Insufficient Storage";
default:
return "Internal Server Error";
}
}
static void respond(int fd, int code, cJSON *body)
{
char *text = body ? cJSON_PrintUnformatted(body) : NULL;
cJSON_Delete(body);
const char *payload = text ? text : "{}";
char head[256];
int n = snprintf(head, sizeof head,
"HTTP/1.1 %d %s\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\nCache-Control: no-store\r\n"
"X-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",
code, reason_phrase(code), strlen(payload));
send_all(fd, head, (size_t)n);
send_all(fd, payload, strlen(payload));
free(text);
}
static void respond_error(int fd, int code, const char *message, const char *reason)
{
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "error", message);
cJSON_AddStringToObject(o, "reason", reason);
respond(fd, code, o);
}
/* Only this helper's own address: 127.0.0.1:<port> or localhost:<port>. */
static bool own_host(const char *host, uint16_t port)
{
char expect[40];
snprintf(expect, sizeof expect, "127.0.0.1:%u", (unsigned)port);
if (!strcmp(host, expect))
return true;
snprintf(expect, sizeof expect, "localhost:%u", (unsigned)port);
return !strcasecmp(host, expect);
}
typedef struct
{
char method[8], path[64];
const char *host, *origin, *marker, *type, *length, *encoding;
} Head;
/* Splits the request head in place. False: not a request this server reads. */
static bool parse_head(char *text, Head *h)
{
memset(h, 0, sizeof *h);
char *line_end = strstr(text, "\r\n");
if (!line_end)
return false;
*line_end = 0;
char version[16];
if (sscanf(text, "%7s %63s %15s", h->method, h->path, version) != 3 ||
strncmp(version, "HTTP/1.", 7))
return false;
for (char *line = line_end + 2; *line;)
{
char *end = strstr(line, "\r\n");
if (!end)
return false;
*end = 0;
char *colon = strchr(line, ':');
if (!colon)
return false;
*colon = 0;
char *value = colon + 1;
while (*value == ' ' || *value == '\t')
value++;
for (char *t = value + strlen(value); t > value && (t[-1] == ' ' || t[-1] == '\t');)
*--t = 0;
const char **slot = !strcasecmp(line, "Host") ? &h->host
: !strcasecmp(line, "Origin") ? &h->origin
: !strcasecmp(line, "X-Wings-Helper") ? &h->marker
: !strcasecmp(line, "Content-Type") ? &h->type
: !strcasecmp(line, "Content-Length") ? &h->length
: !strcasecmp(line, "Transfer-Encoding") ? &h->encoding
: NULL;
if (slot)
{
if (*slot)
return false; /* a repeated header is never ours */
*slot = value;
}
line = end + 2;
}
return true;
}
/* Serves one request. *trusted is set once the request passes the guards (only those keep the
* helper from going idle). The whole request must arrive within REQUEST_SECONDS. */
#define REQUEST_SECONDS 5
static void handle(int fd, uint16_t port, bool *trusted)
{
time_t deadline = time(NULL) + REQUEST_SECONDS;
struct timeval tv = {.tv_sec = 3, .tv_usec = 0};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
char buffer[HEAD_LIMIT + BODY_LIMIT + 1];
size_t got = 0;
char *head_end = NULL;
while (!head_end)
{
if (got >= HEAD_LIMIT)
{
respond_error(fd, 431, "The request is too large.", "invalid");
return;
}
if (time(NULL) >= deadline)
return;
ssize_t r = recv(fd, buffer + got, HEAD_LIMIT - got, 0);
if (r < 0 && errno == EINTR)
continue;
if (r <= 0)
return;
got += (size_t)r;
buffer[got] = 0;
head_end = strstr(buffer, "\r\n\r\n");
}
size_t head_length = (size_t)(head_end - buffer) + 4;
head_end[2] = 0; /* keep the last header's CRLF for the parser */
Head h;
if (!parse_head(buffer, &h))
{
respond_error(fd, 400, "The request could not be read.", "invalid");
return;
}
if (!h.host || !own_host(h.host, port) || h.origin || !h.marker || strcmp(h.marker, "1"))
{
respond_error(fd, 403, "This helper only answers the Wings app.", "forbidden");
return;
}
*trusted = true;
bool get = !strcmp(h.method, "GET"), post = !strcmp(h.method, "POST");
if (strncmp(h.path, ROUTE, strlen(ROUTE)))
{
respond_error(fd, 404, "Not found.", "not_found");
return;
}
const char *route = h.path + strlen(ROUTE);
bool status = !strcmp(route, "status");
bool known = status || !strcmp(route, "download") || !strcmp(route, "install") ||
!strcmp(route, "cancel") ||
!strcmp(route, "stop") || !strcmp(route, "tidy");
if (!known)
{
respond_error(fd, 404, "Not found.", "not_found");
return;
}
if ((status && !get) || (!status && !post))
{
respond_error(fd, 405, "Method not allowed.", "invalid");
return;
}
cJSON *input = NULL;
if (post)
{
if (h.encoding)
{
respond_error(fd, 411, "Send a Content-Length.", "invalid");
return;
}
if (!h.type || strncasecmp(h.type, "application/json", 16) ||
(h.type[16] && h.type[16] != ';'))
{
respond_error(fd, 415, "Send JSON.", "invalid");
return;
}
char *end = NULL;
unsigned long length = h.length ? strtoul(h.length, &end, 10) : 0;
if (!h.length || !*h.length || *end || h.length[0] == '-')
{
respond_error(fd, 411, "Send a Content-Length.", "invalid");
return;
}
if (length > BODY_LIMIT)
{
respond_error(fd, 413, "The request body is too large.", "invalid");
return;
}
size_t want = head_length + length;
while (got < want)
{
if (time(NULL) >= deadline)
return;
ssize_t r = recv(fd, buffer + got, want - got, 0);
if (r < 0 && errno == EINTR)
continue;
if (r <= 0)
return;
got += (size_t)r;
}
buffer[want] = 0;
input = length ? cJSON_Parse(buffer + head_length) : cJSON_CreateObject();
if (!cJSON_IsObject(input))
{
cJSON_Delete(input);
respond_error(fd, 400, "The request body isn't a JSON object.", "invalid");
return;
}
}
char error[256] = {0};
const char *reason = "invalid";
int code = 200;
if (status)
respond(fd, 200, helper_status());
else if (!strcmp(route, "download") || !strcmp(route, "install"))
{
code = !strcmp(route, "download") ? helper_download(input, error, sizeof error, &reason)
: helper_install(input, error, sizeof error, &reason);
if (code == 202)
respond(fd, 202, helper_status());
else
respond_error(fd, code, error, reason);
}
else if (!strcmp(route, "cancel"))
respond(fd, helper_cancel(), helper_status());
else if (!strcmp(route, "tidy"))
{
code = helper_tidy(input, error, sizeof error, &reason);
if (code == 200)
respond(fd, 200, helper_status());
else
respond_error(fd, code, error, reason);
}
else if (helper_renaming())
respond_error(fd, 409, "Wings is being moved into place. Try again in a moment.",
"busy");
else
{
cJSON *o = cJSON_CreateObject();
cJSON_AddBoolToObject(o, "stopping", true);
respond(fd, 200, o);
helper_server_request_stop();
}
cJSON_Delete(input);
}
static bool peer_is_loopback(const struct sockaddr_in *peer)
{
return peer->sin_family == AF_INET && peer->sin_addr.s_addr == htonl(INADDR_LOOPBACK);
}
void helper_server_run(int fd, int idle_seconds)
{
uint16_t port = helper_server_port(fd);
time_t last = time(NULL);
while (!atomic_load(&stopping))
{
struct pollfd p = {.fd = fd, .events = POLLIN};
int ready = poll(&p, 1, 500);
time_t now = time(NULL);
if (helper_busy())
last = now;
if (ready < 0 && errno != EINTR)
break;
if (ready <= 0)
{
if (now - last >= idle_seconds)
break;
continue;
}
struct sockaddr_in peer;
socklen_t n = sizeof peer;
int client = accept(fd, (struct sockaddr *)&peer, &n);
if (client < 0)
continue;
bool trusted = false;
if (peer_is_loopback(&peer))
handle(client, port, &trusted);
close(client);
if (trusted)
last = time(NULL);
}
/* fd stays open: the port is the single-instance lock until the caller has finished all
* file work (helper_jobs_shutdown) and closes it. */
}
+24
View File
@@ -0,0 +1,24 @@
/* Wings update helper - the loopback HTTP server.
* SPDX-License-Identifier: GPL-3.0-or-later
* The request guards follow Orbit Store 1.3.5 backend/api.c (GPL-3.0-or-later). */
#ifndef WINGS_HELPER_SERVER_H
#define WINGS_HELPER_SERVER_H
#include <stdint.h>
#define HELPER_SERVER_IN_USE (-2)
/* Listens on 127.0.0.1:port (0 picks a free port, for tests). Returns the socket,
* HELPER_SERVER_IN_USE when another helper already owns the port, or -1. */
int helper_server_open(uint16_t port);
/* The port the socket is bound to. */
uint16_t helper_server_port(int fd);
/* Serves until a stop request, helper_server_request_stop(), or idle_seconds pass with no
* request while nothing is being installed. Leaves fd open: the caller closes it after
* helper_jobs_shutdown(), so no second helper starts while file work still runs. */
void helper_server_run(int fd, int idle_seconds);
void helper_server_request_stop(void);
/* Clears the stop flag (tests run the server more than once). */
void helper_server_reset(void);
#endif
+27
View File
@@ -0,0 +1,27 @@
/* Wings update helper - build constants.
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The helper is a small payload the Wings TV app sends to the console's ELF loader. It
* downloads a Wings release and places it in /data/homebrew, where the TV app itself cannot
* write. Its design follows Orbit Store 1.3.5's backend (tvapp.c, api.c, main.c),
* GPL-3.0-or-later. */
#ifndef WINGS_HELPER_VERSION_H
#define WINGS_HELPER_VERSION_H
/* The one source of the helper's build stamp. The app includes this header
* (kWingsHelperBuild in app/src/orbit/wings_helper_client.hpp), compares it with what a
* running helper reports, and replaces any other helper. Bump it whenever the helper changes,
* or a helper left running by an earlier package is taken for this one. Do not override it
* with -DWINGS_HELPER_BUILD for one build only: the helper and the app must see the same value
* (tests/unit/wings_helper_client_test.cpp checks the app side). */
#ifndef WINGS_HELPER_BUILD
#define WINGS_HELPER_BUILD "1.0.5"
#endif
/* 2: download and install are separate routes; install only places a downloaded file. */
#define WINGS_HELPER_API 2
#define WINGS_HELPER_PORT 9178
#define WINGS_HELPER_IDLE_SECONDS 120
/* Console threads may default to small stacks; curl and OpenSSL need headroom (Orbit main.c). */
#define WINGS_HELPER_STACK (1024 * 1024)
#endif
+318 -3
View File
@@ -12,6 +12,8 @@
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iterator>
#include <unistd.h>
@@ -114,6 +116,86 @@ std::string make_art(const std::string &seed, bool hero)
return png;
}
constexpr FixtureGame kFixtureGames[] = {
{1, "SuperTuxKart", "supertuxkart", "PPSA90001"},
{2, "0 A.D.", "0ad", "PPSA90002"},
{3, "Xonotic", "xonotic", "PPSA90003"},
{4, "OpenArena", "openarena", "PPSA90004"},
{5, "Veloren", "veloren", "PPSA90005"},
{7, "The Battle for Wesnoth", "wesnoth", "PPSA90007"},
{8, "Luanti", "luanti", "PPSA90101"},
{9, "Warzone 2100", "warzone2100", "PPSA90009"},
{10, "Unvanquished", "unvanquished", "PPSA90102"},
{11, "Red Eclipse", "redeclipse", "PPSA90103"},
{12, "Mindustry", "mindustry", "PPSA90104"},
{13, "Cataclysm: Dark Days Ahead", "cataclysm", "PPSA90013"},
};
const FixtureGame *fixture_for(int package)
{
for (const FixtureGame &game : kFixtureGames)
if (game.package == package)
return &game;
return nullptr;
}
// The picture file of a fixture game ("cover" or "hero"), or "" when the folder has none.
std::string fixture_file(const std::string &dir, int package, const char *kind)
{
const FixtureGame *game = dir.empty() ? nullptr : fixture_for(package);
if (game == nullptr)
return {};
const std::string path = dir + "/" + game->slug + "-" + kind + ".jpg";
return std::ifstream(path, std::ios::binary).good() ? path : std::string();
}
// Showcase: the fixture game with picture files for this title id, or nullptr.
const FixtureGame *fixture_by_title(const std::string &dir, std::string_view title_id)
{
for (const FixtureGame &game : kFixtureGames)
if (title_id == game.title_id && !fixture_file(dir, game.package, "cover").empty())
return &game;
return nullptr;
}
// Showcase: a fixture game with picture files for each title id: its own when it has one, else one the
// others do not use (from the end of the list), else any.
std::vector<const FixtureGame *> fixtures_for_titles(const std::string &dir, const std::vector<std::string> &title_ids)
{
std::vector<const FixtureGame *> out;
for (const std::string &id : title_ids)
out.push_back(fixture_by_title(dir, id));
for (const FixtureGame *&slot : out)
{
if (slot != nullptr)
continue;
const FixtureGame *spare = nullptr;
for (auto it = std::rbegin(kFixtureGames); it != std::rend(kFixtureGames); ++it)
{
if (fixture_file(dir, it->package, "cover").empty())
continue;
if (spare == nullptr)
spare = &*it;
if (std::find(out.begin(), out.end(), &*it) == out.end())
{
spare = &*it;
break;
}
}
slot = spare;
}
return out;
}
// Every `from` in `text` becomes `to`.
void replace_all(std::string &text, const std::string &from, const std::string &to)
{
if (from.empty())
return;
for (std::size_t at = text.find(from); at != std::string::npos; at = text.find(from, at + to.size()))
text.replace(at, from.size(), to);
}
struct SamplePackage
{
const char *title;
@@ -348,6 +430,48 @@ Package make_package(int n, const SamplePackage &sample)
} // namespace
void FakeBackend::retitle(Package &package, int n) const
{
if (fixture_file(fixture_dir_, n, "cover").empty())
return;
package.title = fixture_for(n)->title;
package.description = package.title + " is a free and open-source game, shown here as sample art for Wings.";
}
const FixtureGame *fixture_games(int *count)
{
*count = static_cast<int>(sizeof(kFixtureGames) / sizeof(kFixtureGames[0]));
return kFixtureGames;
}
void FakeBackend::set_fixture_dir(const std::string &dir)
{
Lock lock(mutex_);
fixture_dir_ = dir;
}
void FakeBackend::set_showcase(bool on)
{
Lock lock(mutex_);
showcase_ = on;
if (!on)
return;
// The sample files named after invented games take fixture titles instead.
const auto rename = [&](const char *from, int package, const char *suffix)
{
const auto found = fs_.find(from);
if (found == fs_.end() || fixture_file(fixture_dir_, package, "cover").empty())
return;
const FsNode node = found->second;
fs_.erase(found);
const std::string path(from);
fs_[path.substr(0, path.rfind('/')) + "/" + fixture_for(package)->title + suffix] = node;
};
rename("/data/pegasus-dl/downloads/Ember Hollow.pkg", 1, ".pkg");
rename("/data/pegasus-dl/downloads/Neon Courier.part1.rar", 3, ".part1.rar");
rename("/mnt/usb0/Quiet Harbour.pkg", 4, ".pkg");
}
FakeBackend::FakeBackend(FakeMode mode) : mode_(mode)
{
const auto job = [](const char *id, const char *name, const char *title_id, const char *status,
@@ -487,6 +611,28 @@ void FakeBackend::set_capture_steps(int steps)
capture_steps_ = steps;
}
void FakeBackend::set_capture_script(std::vector<std::string> states, bool requires_browser,
std::string message)
{
Lock lock(mutex_);
script_states_ = std::move(states);
script_pos_ = 0;
script_browser_ = requires_browser;
script_message_ = std::move(message);
}
void FakeBackend::set_capture_hook(std::function<void(int)> hook)
{
Lock lock(mutex_);
capture_hook_ = std::move(hook);
}
int FakeBackend::capture_reads() const
{
Lock lock(mutex_);
return script_reads_;
}
int FakeBackend::cancelled_captures() const
{
Lock lock(mutex_);
@@ -575,6 +721,26 @@ Result<std::vector<Job>> FakeBackend::queue()
Result<std::vector<Job>> result;
result.status = 200;
result.value = jobs_;
if (showcase_)
{
std::vector<std::string> ids;
for (const Job &job : result.value)
ids.push_back(job.title_id);
const std::vector<const FixtureGame *> games = fixtures_for_titles(fixture_dir_, ids);
for (std::size_t i = 0; i < games.size(); ++i)
{
Job &job = result.value[i];
if (games[i] == nullptr)
continue;
// "Name (archive)" keeps its suffix; the archive's file names take the new title too.
const std::size_t suffix = job.name.find(" (");
const std::string old_title = job.name.substr(0, suffix);
replace_all(job.name, old_title, games[i]->title);
for (ArchiveChoice &choice : job.choices)
replace_all(choice.name, old_title, games[i]->title);
job.title_id = games[i]->title_id;
}
}
return result;
}
@@ -589,7 +755,16 @@ Result<std::vector<Package>> FakeBackend::library()
return result;
int n = 1;
for (const SamplePackage &sample : kPackages)
result.value.push_back(make_package(n++, sample));
{
// Showcase: only the games whose real pictures are in the fixture folder.
if (showcase_ && fixture_file(fixture_dir_, n, "cover").empty())
{
++n;
continue;
}
result.value.push_back(make_package(n, sample));
retitle(result.value.back(), n++);
}
return result;
}
@@ -608,6 +783,7 @@ Result<PackageDetail> FakeBackend::package(std::string_view id)
}
result.status = 200;
result.value.package = make_package(std::atoi(std::string(id.substr(3)).c_str()), *sample);
retitle(result.value.package, std::atoi(std::string(id.substr(3)).c_str()));
if (is_rich(std::atoi(std::string(id.substr(3)).c_str())))
{
result.value.links = rich_links(std::atoi(std::string(id.substr(3)).c_str()));
@@ -702,6 +878,22 @@ Result<std::vector<InstalledGame>> FakeBackend::installed()
if (mode_ == FakeMode::empty || mode_ == FakeMode::onboarding)
return result;
result.value = installed_;
if (showcase_)
{
std::vector<std::string> ids;
for (const InstalledGame &game : result.value)
ids.push_back(game.title_id);
const std::vector<const FixtureGame *> games = fixtures_for_titles(fixture_dir_, ids);
for (std::size_t i = 0; i < games.size(); ++i)
{
InstalledGame &game = result.value[i];
if (games[i] == nullptr)
continue;
replace_all(game.path, game.title_id, games[i]->title_id);
game.title_id = games[i]->title_id;
game.title = games[i]->title;
}
}
return result;
}
@@ -908,6 +1100,13 @@ Result<LinkResult> FakeBackend::start_link(std::string_view package_id, int link
out.capture.message = capture_automated_
? "Browser opened. Waiting for Cloudflare verification..."
: "Browser opened. Waiting for the download URL...";
if (!script_states_.empty())
{
script_pos_ = 0;
out.capture.requires_browser = script_browser_;
if (!script_browser_)
out.capture.state = "queueing";
}
break;
default:
out.kind = LinkResult::Kind::browser_opened;
@@ -944,6 +1143,52 @@ Result<LinkResult> FakeBackend::debrid_select(std::string_view selection_id,
Result<CaptureView> FakeBackend::capture_status(std::string_view session_id)
{
{
Result<CaptureView> scripted;
std::function<void(int)> hook;
int reads = 0;
{
Lock lock(mutex_);
if (!script_states_.empty() && mode_ != FakeMode::offline)
{
reads = ++script_reads_;
hook = capture_hook_;
const std::size_t at = std::min(script_pos_, script_states_.size() - 1);
++script_pos_;
scripted.status = 200;
CaptureView &view = scripted.value;
view.session_id = std::string(session_id);
view.title = capture_title_;
view.automated = capture_automated_;
view.requires_browser = script_browser_;
view.state = script_states_[at];
view.active = !view.terminal();
if (view.state == "completed")
{
view.message = "Supported download queued.";
add_job_locked(capture_title_, "queued", 20.0);
view.result.queued = true;
view.result.has_jobs = true;
view.result.jobs = jobs_;
view.result.toast = view.message;
}
else if (view.terminal())
{
view.message = script_message_;
}
else
{
view.message = "Link resolving is in process. Please wait.";
}
}
}
if (reads > 0)
{
if (hook)
hook(reads);
return scripted;
}
}
Lock lock(mutex_);
if (mode_ == FakeMode::offline)
return refuse<CaptureView>();
@@ -1224,8 +1469,18 @@ Result<Art> FakeBackend::art(std::string_view key, ArtKind kind)
const std::string name(key);
bool poster = false;
int asks = 0;
std::string fixture;
{
Lock lock(mutex_);
if (key.substr(0, 4) == "pkg:")
fixture = fixture_file(fixture_dir_, std::atoi(name.c_str() + (name.compare(0, 7, "pkg:pkg") == 0 ? 7 : 4)),
kind == ArtKind::cover ? "cover" : "hero");
// Showcase: installed games show their fixture game's picture.
else if (showcase_ && key.substr(0, 4) == "app:")
{
if (const FixtureGame *game = fixture_by_title(fixture_dir_, key.substr(4)))
fixture = fixture_file(fixture_dir_, game->package, kind == ArtKind::cover ? "cover" : "hero");
}
asks = ++art_asks_[name];
poster = key.substr(0, 4) == "pkg:" && store_art_;
}
@@ -1239,7 +1494,7 @@ Result<Art> FakeBackend::art(std::string_view key, ArtKind kind)
}
if (poster)
{
if (hash(name) % 7 == 0)
if (fixture.empty() && hash(name) % 7 == 0)
{
result.status = 404;
result.error = "artwork unavailable";
@@ -1253,8 +1508,18 @@ Result<Art> FakeBackend::art(std::string_view key, ArtKind kind)
}
}
result.status = 200;
if (!fixture.empty())
{
std::ifstream in(fixture, std::ios::binary);
result.value.bytes.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
if (!result.value.bytes.empty())
{
result.value.content_type = "image/jpeg";
return result;
}
}
result.value.content_type = "image/png";
result.value.bytes = make_art(name, kind == ArtKind::hero);
result.value.bytes = make_art(name, kind != ArtKind::cover);
return result;
}
@@ -1371,6 +1636,7 @@ Result<SettingsView> FakeBackend::settings_update(const SettingsPatch &patch)
return result;
}
settings_.download_root = patch.root;
last_root_written_ = patch.root;
}
if (patch.has_connections)
settings_.connections = patch.connections;
@@ -1502,6 +1768,13 @@ Result<bool> FakeBackend::mkdir(std::string_view full_path)
return refuse<bool>();
Result<bool> result;
const std::string path(full_path);
mkdir_calls_.push_back(path);
if (mkdir_fails_)
{
result.status = 403;
result.error = "That location is read-only.";
return result;
}
bool found = false;
const FolderListing parent = list_locked(parent_of(path), true, &found);
if (!found || !parent.writable)
@@ -2015,6 +2288,18 @@ Result<std::vector<FileOp>> FakeBackend::file_pkg_install(std::string_view path)
return result;
}
Result<bool> FakeBackend::open_url(std::string_view url)
{
Lock lock(mutex_);
if (mode_ == FakeMode::offline)
return refuse<bool>();
opened_urls_.push_back(std::string(url));
Result<bool> result;
result.status = 200;
result.value = true;
return result;
}
Result<bool> FakeBackend::file_cancel(std::string_view operation_id)
{
Lock lock(mutex_);
@@ -2114,6 +2399,12 @@ std::vector<std::string> FakeBackend::pkg_installs() const
return installs_;
}
std::vector<std::string> FakeBackend::opened_urls() const
{
Lock lock(mutex_);
return opened_urls_;
}
std::string FakeBackend::last_token(bool torbox) const
{
Lock lock(mutex_);
@@ -2126,6 +2417,30 @@ int FakeBackend::settings_reads() const
return settings_reads_;
}
void FakeBackend::set_download_root(const std::string &root)
{
Lock lock(mutex_);
settings_.download_root = root;
}
std::vector<std::string> FakeBackend::mkdir_calls() const
{
Lock lock(mutex_);
return mkdir_calls_;
}
void FakeBackend::set_mkdir_fails(bool fails)
{
Lock lock(mutex_);
mkdir_fails_ = fails;
}
std::string FakeBackend::last_root_written() const
{
Lock lock(mutex_);
return last_root_written_;
}
int FakeBackend::settings_writes() const
{
Lock lock(mutex_);
+55
View File
@@ -14,6 +14,7 @@
#include "orbit/backend.hpp"
#include "orbit/thread.hpp"
#include <functional>
#include <map>
#include <string>
#include <vector>
@@ -29,6 +30,16 @@ enum class FakeMode
onboarding, // no sources yet, and Pegasus offers its default list
};
// The preview's showcase games: catalogue package number, real (free) game title, file slug, title id.
struct FixtureGame
{
int package;
const char *title;
const char *slug;
const char *title_id;
};
const FixtureGame *fixture_games(int *count);
class FakeBackend final : public Backend
{
public:
@@ -45,6 +56,18 @@ class FakeBackend final : public Backend
void set_failing_link(const std::string &package_id, int link_index);
// start_link() calls that reached a link (tests).
int started_links() const;
// Scripted capture (tests): while `states` is not empty, an automated or
// capture link starts a session with this requires_browser value, and each
// capture_status() answers the next of `states` ("priming", "queueing",
// "completed", "failed", ...), the last one repeating. `message` goes with a
// "failed" or "cancelled" state; "completed" queues a job.
void set_capture_script(std::vector<std::string> states, bool requires_browser = false,
std::string message = std::string());
// Called at every scripted capture_status(), outside the fake's lock, with
// the number of scripted reads so far (tests: act in the middle of a wait).
void set_capture_hook(std::function<void(int)> hook);
// Scripted capture_status() calls seen (tests).
int capture_reads() const;
// Store posters ("pkg:" keys). Off (default): no artwork, as with the HTTPS
// art service switched off. On: stands in for the service, so the preview and
// tests see covers arriving: the first two asks per key answer "pending",
@@ -52,6 +75,14 @@ class FakeBackend final : public Backend
// placeholder).
void set_store_art(bool on);
bool store_art() const;
// Preview only: a folder with real (openly licensed) pictures, <slug>-cover.jpg and <slug>-hero.jpg
// per game of fixture_games(). A game whose picture files exist takes the game's name and its pictures;
// the rest keep the generated art. "" (default): nothing is read.
void set_fixture_dir(const std::string &dir);
// Preview only (README pictures): the Store catalogue holds only the fixture games that have picture
// files; installed games, downloads and a few file names take those games' titles (installed games
// their covers too). Set it before the Store starts. Needs set_fixture_dir() first.
void set_showcase(bool on);
// Asks seen for a key since the start (tests).
int art_asks(const std::string &key) const;
@@ -128,6 +159,7 @@ class FakeBackend final : public Backend
std::string_view password) override;
Result<std::vector<FileOp>> file_pkg_install(std::string_view path) override;
Result<bool> file_cancel(std::string_view operation_id) override;
Result<bool> open_url(std::string_view url) override;
// ---- test hooks ----
// Pegasus answers 503 on the APR version list (the user's real console does).
@@ -135,8 +167,17 @@ class FakeBackend final : public Backend
std::vector<std::string> deleted_games() const;
std::vector<std::string> deleted_paths() const;
std::vector<std::string> pkg_installs() const;
std::vector<std::string> opened_urls() const;
std::string last_token(bool torbox) const;
int settings_writes() const;
// Starts a test with this download root ("" = Pegasus reports none yet).
void set_download_root(const std::string &root);
// Every mkdir() call received, in order (also the refused ones).
std::vector<std::string> mkdir_calls() const;
// mkdir() answers a host error (403) without creating anything.
void set_mkdir_fails(bool fails);
// Folder of the last accepted root update ("" before any).
std::string last_root_written() const;
int settings_reads() const; // settings() calls that were answered
int logs_cleared() const;
int apr_updates() const;
@@ -158,6 +199,8 @@ class FakeBackend final : public Backend
int file_list_calls() const;
private:
// Preview fixtures: the game's real title when its pictures are in the fixture folder.
void retitle(Package &package, int n) const;
struct FsNode
{
bool dir = false;
@@ -182,11 +225,15 @@ class FakeBackend final : public Backend
std::vector<std::string> deleted_;
std::vector<std::string> deleted_paths_;
std::vector<std::string> installs_;
std::vector<std::string> opened_urls_;
std::string last_rd_token_;
std::string last_tb_token_;
std::string last_collision_;
bool apr_manifest_ok_ = true;
int settings_writes_ = 0;
std::vector<std::string> mkdir_calls_;
bool mkdir_fails_ = false;
std::string last_root_written_;
int settings_reads_ = 0;
int logs_cleared_ = 0;
int apr_updates_ = 0;
@@ -222,8 +269,16 @@ class FakeBackend final : public Backend
bool capture_automated_ = false;
int cancelled_ = 0;
std::string failing_link_; // "<packageId>#<linkIndex>"
std::vector<std::string> script_states_;
std::size_t script_pos_ = 0;
bool script_browser_ = false;
std::string script_message_;
int script_reads_ = 0;
std::function<void(int)> capture_hook_;
int started_links_ = 0;
bool store_art_ = false;
std::string fixture_dir_;
bool showcase_ = false;
std::map<std::string, int> art_asks_;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

+49
View File
@@ -0,0 +1,49 @@
# Preview art credits
Pictures used only by the PC preview (app/host/preview_main.cpp). They are not part of the console package.
Every file must be openly licensed; record author, license and source URL here before it is added.
Files read by the preview, per game (slug, title): `<slug>-cover.jpg` (about 600x600) and `<slug>-hero.jpg` (16:9, 1920x1080 or smaller; one of them may be 3.1:1).
| Slug | Title |
| --- | --- |
| supertuxkart | SuperTuxKart |
| 0ad | 0 A.D. |
| xonotic | Xonotic |
| openarena | OpenArena |
| veloren | Veloren |
| wesnoth | The Battle for Wesnoth |
| luanti | Luanti |
| warzone2100 | Warzone 2100 |
| unvanquished | Unvanquished |
| redeclipse | Red Eclipse |
| mindustry | Mindustry |
| cataclysm | Cataclysm: Dark Days Ahead |
Credits (from Wikimedia Commons unless the row says otherwise; images were resized/cropped and re-encoded as JPEG; covers are 600x600 compositions of the official logo over the game's hero art):
| File | Title | Author | License | Source |
| --- | --- | --- | --- | --- |
| supertuxkart-hero.jpg | STK track 8.png | SuperTuxKart contributors | CC BY 1.0 | https://commons.wikimedia.org/wiki/File:STK_track_8.png |
| supertuxkart-cover.jpg | composed: logo over hero art (supertuxkart-hero.jpg, blurred/darkened); logo: Logo de SuperTuxKart.svg (official logo) | Original: rastapax; vector: Pduive23 | CC BY-SA 3.0 | https://commons.wikimedia.org/wiki/File:Logo_de_SuperTuxKart.svg |
| 0ad-hero.jpg | 0 A.D. alpha 25 - playing as Spartans.jpg | Wildfire Games | CC BY-SA 3.0 | https://commons.wikimedia.org/wiki/File:0_A.D._alpha_25_-_playing_as_Spartans.jpg |
| 0ad-cover.jpg | composed: logo over hero art (0ad-hero.jpg, blurred/darkened); logo: 0 A.D. logo.svg (official logo) | Original: Wildfire Games; vector: VulcanSphere | Public domain (as stated on Commons; PNG version CC BY-SA 2.0) | https://commons.wikimedia.org/wiki/File:0_A.D._logo.svg |
| xonotic-hero.jpg | Xonotic gameplay.png | OliwierJaszczyszyn (game: Xonotic developers) | GPL (as stated on Commons) | https://commons.wikimedia.org/wiki/File:Xonotic_gameplay.png |
| xonotic-cover.jpg | composed: logo over hero art (xonotic-hero.jpg, blurred/darkened); logo: Xonotic logo.svg (official logo) | Team Xonotic | GPL (as stated on Commons) | https://commons.wikimedia.org/wiki/File:Xonotic_logo.svg |
| openarena-hero.jpg | OpenArena-Rocket.jpg | OpenArena developers | GPL (as stated on Commons) | https://commons.wikimedia.org/wiki/File:OpenArena-Rocket.jpg |
| openarena-cover.jpg | composed: logo over hero art (openarena-hero.jpg, blurred/darkened); logo: Openarena-logo.svg (official logo; recoloured white for legibility) | OpenArena Team | GPL (as stated on Commons) | https://commons.wikimedia.org/wiki/File:Openarena-logo.svg |
| veloren-hero.jpg (1920x620 crop) | Veloren Screenshot 2023.02.09 - 16.06.36.28.png | Veloren community and team; screenshot PantheraLeo1359531 | GPLv3 | https://commons.wikimedia.org/wiki/File:Veloren_Screenshot_2023.02.09_-_16.06.36.28.png |
| veloren-cover.jpg | composed: logo over hero art (veloren-hero.jpg, blurred/darkened); logo: Veloren logo.png (official logo, from assets/voxygen/element/v_logo.png) | Veloren team | GPL (as stated on Commons; Veloren is GPLv3) | https://commons.wikimedia.org/wiki/File:Veloren_logo.png |
| wesnoth-hero.jpg | The Battle for Wesnoth 1.16.8 title screen fi.png | 0dorkmann (game: Battle for Wesnoth developers) | CC BY-SA 4.0 | https://commons.wikimedia.org/wiki/File:The_Battle_for_Wesnoth_1.16.8_title_screen_fi.png |
| wesnoth-cover.jpg | composed: logo over hero art (wesnoth-hero.jpg, blurred/darkened); logo: Battle for Wesnoth logo.png (official logo) | Battle for Wesnoth Art Development Team | GPL (as stated on Commons) | https://commons.wikimedia.org/wiki/File:Battle_for_Wesnoth_logo.png |
| luanti-hero.jpg | Minetest Game Main Menu.png | Perttu Ahola and Minetest developers | CC BY-SA 3.0 | https://commons.wikimedia.org/wiki/File:Minetest_Game_Main_Menu.png |
| luanti-cover.jpg | composed: logo over hero art (luanti-hero.jpg, blurred/darkened); logo: textures/base/pack/logo.png + textures/base/pack/menu_header.png (official icon + wordmark, Luanti repo) | logo.png: erle; menu_header.png: grorp, modified by DS (font "undefined medium", SIL OFL 1.1) | CC BY-SA 3.0 (logo.png); CC BY-SA 3.0/4.0 (menu_header.png, DS changes CC BY-SA 4.0) per LICENSE.txt | https://github.com/luanti-org/luanti/blob/master/textures/base/pack/logo.png , https://github.com/luanti-org/luanti/blob/master/textures/base/pack/menu_header.png , https://github.com/luanti-org/luanti/blob/master/LICENSE.txt |
| warzone2100-hero.jpg | WZ2100BASEANDUNITS.jpg | Pumpkin Studios, Pivotal Games, Eidos Interactive | GPLv2 | https://commons.wikimedia.org/wiki/File:WZ2100BASEANDUNITS.jpg |
| warzone2100-cover.jpg | composed: logo over hero art (warzone2100-hero.jpg, blurred/darkened); logo: icons/warzone2100logo.png (official logo, Warzone 2100 repo) | Pumpkin Studios / Warzone 2100 Project | GPLv2 or later (COPYING.README) | https://github.com/Warzone2100/warzone2100/blob/master/icons/warzone2100logo.png |
| unvanquished-hero.jpg | Unvanquished-0.52-alien-builder.jpg | Unvanquished Development | CC BY-SA 2.5 | https://commons.wikimedia.org/wiki/File:Unvanquished-0.52-alien-builder.jpg |
| unvanquished-cover.jpg | composed: logo over hero art (unvanquished-hero.jpg, blurred/darkened); logo: Unvanquished logo.png (official logo; inverted to white for legibility) | Unvanquished Development (unvanquished.net) | CC BY-SA 2.5 | https://commons.wikimedia.org/wiki/File:Unvanquished_logo.png |
| redeclipse-hero.jpg | Red Eclipse 2 Screenshot - Edge.png | TucanHolmes | CC BY-SA 4.0 | https://commons.wikimedia.org/wiki/File:Red_Eclipse_2_Screenshot_-_Edge.png |
| redeclipse-cover.jpg | composed: logo over hero art (redeclipse-hero.jpg, blurred/darkened); logo: logo.png (official logo, redeclipse/textures repo) | Rémi "LuckystrikeRx" Clouet d'Orval, Red Eclipse Team | CC BY-SA 4.0, plus the Red Eclipse Mark Policy (trademark.txt) | https://github.com/redeclipse/textures/blob/master/logo.png , https://github.com/redeclipse/textures/blob/master/readme.txt |
No art (preview falls back to generated art): mindustry, cataclysm.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+264
View File
@@ -21,7 +21,12 @@
#include "orbit/mark.hpp"
#include "orbit/screens.hpp"
#include "orbit/panels.hpp"
#include "orbit/editorial.hpp"
#include "orbit/pegasus_manager.hpp"
#include "orbit/popularity.hpp"
#include "orbit/wings_helper_client.hpp"
#include "orbit/wings_install.hpp"
#include "orbit/wings_update.hpp"
#include "orbit/store.hpp"
#include "orbit/i18n.hpp"
#include "ui/fonts.hpp"
@@ -34,11 +39,15 @@
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <filesystem>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
@@ -144,6 +153,190 @@ Step run(float seconds, std::function<void(orbit::App &, orbit::host::FakeBacken
return step;
}
// Settings > Wings > Updates: the real WingsUpdate and WingsInstaller run against this stand-in release
// and a stand-in Wings update helper (its HTTP status, no files), so the screens are the ones the console shows.
class UpdateKit final : public orbit::DocFetcher, public orbit::StreamingDownloader, public orbit::http::Transport
{
public:
UpdateKit() : helper(*this, [](const std::string &, std::string *) { return true; })
{
package.resize(300000);
for (std::size_t i = 0; i < package.size(); ++i)
package[i] = static_cast<char>((i * 31 + 7) & 0xff);
const unsigned char magic[4] = {0x19, 0x01, 0x54, 0x19};
for (std::size_t i = 0; i < 4; ++i)
package[static_cast<std::size_t>(orbit::kUfs2MagicOffset) + i] = static_cast<char>(magic[i]);
sha = orbit::sha256_hex(package);
root = (std::filesystem::temp_directory_path() / "wings-preview-update").string();
}
orbit::DocResult get(const std::string &, std::size_t) override
{
const std::string base = "https://github.com/LaRazaDelSol/wings-app/releases/download/v0.1.2/";
orbit::DocResult result;
result.status = 200;
result.body = "{\"tag_name\":\"v0.1.2\",\"draft\":false,\"prerelease\":false,\"assets\":["
"{\"name\":\"PPSA99178.ffpkg\",\"size\":" + std::to_string(package.size()) +
",\"digest\":\"sha256:" + sha + "\",\"browser_download_url\":\"" + base +
"PPSA99178.ffpkg\"},{\"name\":\"PPSA99178.ffpkg.sha256\",\"size\":90,\"digest\":\"sha256:" +
std::string(64, 'a') + "\",\"browser_download_url\":\"" + base + "PPSA99178.ffpkg.sha256\"}]}";
return result;
}
bool ready(std::string *) override
{
return true;
}
std::string get_stream(const std::string &url, std::uint64_t, const orbit::StreamHooks &hooks,
const std::function<bool(const std::uint8_t *, std::size_t)> &sink, int *status) override
{
*status = 200;
const bool digest = url.size() > 7 && url.compare(url.size() - 7, 7, ".sha256") == 0;
const std::string body = digest ? sha + " PPSA99178.ffpkg\n" : package;
std::size_t at = 0;
while (at < body.size())
{
// The download rests part-way while `hold` is set, so the progress bar can be photographed.
while (!digest && hold && at > body.size() * 4 / 10 && !(hooks.cancel && hooks.cancel()))
usleep(10000);
if (hooks.cancel && hooks.cancel())
return "cancelled";
const std::size_t piece = std::min<std::size_t>(8192, body.size() - at);
if (!sink(reinterpret_cast<const std::uint8_t *>(body.data()) + at, piece))
return "sink";
at += piece;
if (!digest)
usleep(300);
}
return {};
}
// The stand-in helper, like the real one (build and API from app/helper/version.h): a download streams in
// pieces (resting part-way while `hold` is set) and stops at "downloaded"; install only places it.
orbit::http::Response send(const orbit::http::Request &request) override
{
orbit::http::Response r;
r.status = 200;
std::lock_guard<std::mutex> lock(helper_mutex);
if (request.path == "/wings-helper/v1/download")
{
phase = "downloading";
received = 0;
r.status = 202;
}
else if (request.path == "/wings-helper/v1/install")
{
phase = "placing";
r.status = 202;
}
else if (request.path == "/wings-helper/v1/cancel")
phase = "idle";
else if (phase == "downloading")
{
if (!(hold && received > package.size() * 4 / 10))
received = std::min<std::uint64_t>(package.size(), received + 24000);
if (received >= package.size())
phase = "downloaded";
}
else if (phase == "placing")
phase = "placed";
// Like the real helper: only after placing does it name what it placed and keep the old file as .old.
const bool placed = phase == "placed";
r.body = std::string("{\"helper\":\"") + orbit::kWingsHelperBuild + "\",\"api\":" +
std::to_string(orbit::kWingsHelperApi) + ",\"phase\":\"" + phase +
"\",\"version\":\"v0.1.2\",\"received\":" +
std::to_string(received) + ",\"size\":" + std::to_string(package.size()) + ",\"error\":\"\",\"target\":\"" +
(placed ? std::string("wings") : target) + "\",\"installed_version\":\"" +
(placed ? "0.1.2" : "") + "\",\"old_kept\":" + (placed ? "true" : "false") + "}";
return r;
}
// A fresh temporary "console": the helper sees an empty /data/homebrew (optionally with the player's own
// copy, or a folder copy that blocks the install), then the check for a newer release.
void reset(bool own_copy, bool folder_copy)
{
namespace fs = std::filesystem;
orbit::wings_installer().wait_idle();
std::error_code ec;
fs::remove_all(root, ec);
fs::create_directories(root, ec);
{
std::lock_guard<std::mutex> lock(helper_mutex);
phase = "idle";
received = 0;
target = folder_copy ? "folder" : own_copy ? "manual" : "none";
}
hold = false;
helper.sleep_ms = [](int) {};
orbit::WingsInstallConfig install;
install.downloader = this;
install.helper = &helper;
install.note_dir = root;
install.running = "00.001.001";
install.poll_ms = 15;
orbit::wings_installer().attach(std::move(install));
orbit::WingsUpdateConfig update;
update.docs = this;
update.ready = [](std::string *) { return true; };
update.running = "00.001.001";
orbit::wings_update().attach(std::move(update));
orbit::wings_update().set_enabled(true);
orbit::wings_update().check_now();
orbit::wings_update().wait_idle();
}
void download()
{
const orbit::WingsUpdateStatus up = orbit::wings_update().status();
orbit::WingsRelease release;
release.tag = up.latest_tag;
release.asset = up.asset;
orbit::wings_installer().start_download(release);
}
std::string package;
std::string sha;
std::string root;
std::atomic<bool> hold{false};
std::mutex helper_mutex;
std::string phase = "idle";
std::string target = "none";
std::uint64_t received = 0;
orbit::WingsHelperClient helper;
};
UpdateKit &update_kit()
{
static UpdateKit kit;
return kit;
}
// A small popularity index for the fixture games, with their 16:9 pictures (the fake backend serves the
// files whatever the address says), so the Store's hero shot and the game page show art.
void install_showcase_index()
{
int count = 0;
const orbit::host::FixtureGame *games = orbit::host::fixture_games(&count);
std::string json = "{\"schemaVersion\":1,\"generatedAt\":\"2026-10-12T00:00:00Z\","
"\"attribution\":{\"igdb\":\"Ratings and popularity data from IGDB.com\"},\"titles\":{";
for (int i = 0; i < count; ++i)
{
const std::string base = std::string("https://fixtures.example/art/") + games[i].slug;
json += std::string(i ? "," : "") + "\"" + games[i].title_id + "\":{\"score\":" + std::to_string(97 - i * 5) +
",\"rating\":" + std::to_string(90 - i * 2) + ",\"ratingCount\":" + std::to_string(5200 - i * 300) +
",\"confidence\":1.0,\"sources\":[\"igdb\"],\"fetchedAt\":\"2026-10-12\",\"hero\":\"" + base +
"/hero.jpg\",\"heroLow\":\"" + base + "/low.jpg\"}";
}
json += "}}";
auto index = std::make_shared<orbit::PopularityIndex>();
std::string why;
if (orbit::PopularityIndex::parse(json, index.get(), &why))
orbit::popularity_hub().set_index(index);
orbit::popularity_hub().set_enabled(true);
orbit::editorial_hub().set_enabled(false, true); // no notes, the hero carousel on
}
void remove_showcase_index()
{
orbit::popularity_hub().set_enabled(false);
orbit::popularity_hub().set_index(nullptr);
orbit::editorial_hub().set_enabled(false, false);
}
std::vector<Scenario> scenarios()
{
using Mode = orbit::host::FakeMode;
@@ -842,6 +1035,73 @@ std::vector<Scenario> scenarios()
move(Direction::down), move(Direction::down), tap(Action::confirm),
pause(0.6f, "logs-clear-confirm"), tap(Action::back, 0.4f), tap(Action::north),
pause(0.8f, "logs-diagnostics")}});
// README screenshots: the real UI with free-game art (host/fixtures, see ART-CREDITS.md). Outputs:
// showcase-store-hero.png, showcase-store-grid.png, showcase-game-page.png, showcase-downloads.png.
const auto showcase_on = run(0.1f,
[](App &, FakeBackend &backend)
{
backend.set_store_art(true);
install_showcase_index();
});
const auto showcase_off = run(0.1f, [](App &, FakeBackend &) { remove_showcase_index(); });
list.push_back({"showcase-store",
Mode::normal,
{showcase_on, tab(orbit::tab::store), wait(1500), pause(0.8f, "showcase-store-hero"),
move(Direction::down), wait(500), pause(0.8f, "showcase-store-grid"), showcase_off}});
list.push_back({"showcase-game-page",
Mode::normal,
{showcase_on, tab(orbit::tab::store), wait(900), open_package("pkg1"), wait(1200),
pause(0.8f, "showcase-game-page"), showcase_off}});
list.push_back({"showcase-downloads",
Mode::normal,
{showcase_on, tab(orbit::tab::downloads), wait(1200), pause(0.8f, "showcase-downloads"),
showcase_off}});
// Updating Wings on the real update and install code: the release is a generated stand-in (UpdateKit).
const auto updates_setup = [](bool own_copy, bool folder_copy)
{ return run(0.1f, [own_copy, folder_copy](App &, FakeBackend &) { update_kit().reset(own_copy, folder_copy); }); };
const auto updates_wait = run(0.1f, [](App &, FakeBackend &) { orbit::wings_installer().wait_idle(); });
// The download rests part-way (hold) so the progress can be photographed.
const auto updates_download_held = run(0.1f,
[](App &, FakeBackend &)
{
update_kit().hold = true;
update_kit().download();
});
const auto updates_release = run(0.1f, [](App &, FakeBackend &) { update_kit().hold = false; });
const auto updates_page = run(0.1f, [](App &app, FakeBackend &) { app.show_update(false); });
const auto updates_dialog = run(0.1f, [](App &app, FakeBackend &) { app.show_update(true); });
// The README pictures, one per screen, in showcase mode (main() turns it on for this scenario): only the
// fixture games, with their real titles, covers and heroes. Outputs: readme-store.png (the resting hero
// shot), readme-store-grid.png, readme-game-page.png, readme-downloads.png, readme-library.png,
// readme-files.png, readme-settings.png, readme-update.png (the update page, downloading).
list.push_back({"readme",
Mode::normal,
{showcase_on, tab(orbit::tab::store), wait(3000), pause(1.5f, "readme-store"),
move(Direction::down), wait(800), pause(0.8f, "readme-store-grid"), open_package("pkg1"),
wait(1500), pause(0.8f, "readme-game-page"), tap(Action::back, 0.5f),
tab(orbit::tab::downloads), wait(1200), pause(0.8f, "readme-downloads"),
tab(orbit::tab::library), wait(1200), pause(1.2f, "readme-library"), tab(orbit::tab::files),
wait(800), open_files("/data/pegasus-dl/downloads"), wait(500), pause(0.8f, "readme-files"),
tab(orbit::tab::settings), wait(500), pause(0.8f, "readme-settings"), updates_setup(true, false),
updates_download_held, updates_page, wait(1000), pause(0.8f, "readme-update"), updates_release,
updates_wait, tap(Action::back, 0.5f), showcase_off}});
// The update flow as the player sees it: the startup dialog, then Update now opens the page and starts the
// download. Outputs: update-dialog.png, update-downloading.png, update-ready.png, update-installed.png.
list.push_back({"settings-update-flow",
Mode::normal,
{updates_setup(true, false), tab(orbit::tab::settings), wait(500), updates_dialog, wait(800),
pause(0.8f, "update-dialog"), run(0.1f, [](App &, FakeBackend &) { update_kit().hold = true; }),
tap(Action::confirm), wait(1000), pause(0.8f, "update-downloading"), updates_release,
updates_wait, wait(500), pause(0.8f, "update-ready"),
run(0.1f, [](App &, FakeBackend &) { orbit::wings_installer().install(true); }), updates_wait,
wait(500), pause(0.8f, "update-installed"), tap(Action::back, 0.5f)}});
// A folder copy is in the way: Wings will not place the file and the page shows the steps. Output:
// update-manual.png.
list.push_back({"settings-update-blocked",
Mode::normal,
{updates_setup(false, true), tab(orbit::tab::settings),
run(0.1f, [](App &, FakeBackend &) { update_kit().download(); }), updates_wait, wait(300),
updates_page, wait(1000), pause(0.8f, "update-manual"), tap(Action::back, 0.5f)}});
// Waiting state on a tab other than the Store, and tab switching by L1/R1.
list.push_back({"offline-tabs",
Mode::offline,
@@ -923,6 +1183,10 @@ int main(int argc, char **argv)
continue;
++ran;
orbit::host::FakeBackend backend(scenario.mode);
backend.set_fixture_dir(assets + "/../host/fixtures");
// The README pictures show only the fixture games; the catalogue must hold them before the Store starts.
if (std::string(scenario.name) == "readme")
backend.set_showcase(true);
orbit::Store store(backend);
orbit::ArtCache art(backend, textures);
store.start();
+1 -1
View File
@@ -15,7 +15,7 @@
"conceptId": "99178",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA99178_00-WINGSPEGASUS0001",
"contentVersion": "00.001.000",
"contentVersion": "00.001.003",
"downloadDataSize": 256,
"gameIntent": {
"permittedIntents": [
+139 -17
View File
@@ -1,4 +1,4 @@
// ps5-homebrew-ui - The app version, read from the packaged param.json.
// ps5-homebrew-ui - The app version, read from the packaged param.json, and which build of it this is.
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -11,28 +11,39 @@
namespace hui
{
std::string content_version(std::string_view param_json)
namespace
{
constexpr std::string_view kKey = "\"contentVersion\"";
const std::size_t key = param_json.find(kKey);
if (key == std::string_view::npos)
bool digit(char c)
{
return std::isdigit(static_cast<unsigned char>(c)) != 0;
}
// The text of "key": "..." (no escapes in what the build writes), or "" when it is not there.
std::string_view string_value(std::string_view json, std::string_view key)
{
const std::size_t at = json.find(key);
if (at == std::string_view::npos)
return {};
const std::size_t open = param_json.find('"', param_json.find(':', key + kKey.size()));
const std::size_t colon = json.find(':', at + key.size());
if (colon == std::string_view::npos)
return {};
const std::size_t open = json.find('"', colon);
if (open == std::string_view::npos)
return {};
const std::size_t close = param_json.find('"', open + 1);
const std::size_t close = json.find('"', open + 1);
if (close == std::string_view::npos)
return {};
const std::string_view value = param_json.substr(open + 1, close - open - 1);
// NN.NNN.NNN, as the build and release tooling require.
if (value.size() != 10 || value[2] != '.' || value[6] != '.')
return {};
for (std::size_t i = 0; i < value.size(); ++i)
{
if (i != 2 && i != 6 && std::isdigit(static_cast<unsigned char>(value[i])) == 0)
return {};
}
return std::string(value);
return json.substr(open + 1, close - open - 1);
}
} // namespace
std::string content_version(std::string_view param_json)
{
const std::string_view value = string_value(param_json, "\"contentVersion\"");
int parts[3];
return parse_content_version(value, parts) ? std::string(value) : std::string();
}
std::string read_content_version(const std::string &path)
@@ -41,4 +52,115 @@ std::string read_content_version(const std::string &path)
return save::read_file(path, &text) ? content_version(text) : std::string();
}
bool parse_content_version(std::string_view value, int out[3])
{
// NN.NNN.NNN, as the build and release tooling require.
if (value.size() != 10 || value[2] != '.' || value[6] != '.')
return false;
for (std::size_t i = 0; i < value.size(); ++i)
{
if (i != 2 && i != 6 && !digit(value[i]))
return false;
}
out[0] = (value[0] - '0') * 10 + (value[1] - '0');
out[1] = (value[3] - '0') * 100 + (value[4] - '0') * 10 + (value[5] - '0');
out[2] = (value[7] - '0') * 100 + (value[8] - '0') * 10 + (value[9] - '0');
return true;
}
bool parse_release_tag(std::string_view tag, int out[3])
{
if (tag.size() < 6 || tag.size() > 12 || tag[0] != 'v')
return false;
int part = 0;
int digits = 0;
int value = 0;
for (std::size_t i = 1; i <= tag.size(); ++i)
{
if (i < tag.size() && digit(tag[i]))
{
value = value * 10 + (tag[i] - '0');
if (++digits > 3)
return false;
}
else if ((i == tag.size() || tag[i] == '.') && digits > 0 && part < 3)
{
out[part++] = value;
digits = 0;
value = 0;
}
else
{
return false;
}
}
return part == 3;
}
int compare_versions(const int a[3], const int b[3])
{
for (int i = 0; i < 3; ++i)
{
if (a[i] != b[i])
return a[i] < b[i] ? -1 : 1;
}
return 0;
}
std::string release_version_text(std::string_view content_version)
{
int v[3];
if (!parse_content_version(content_version, v))
return {};
return std::to_string(v[0]) + "." + std::to_string(v[1]) + "." + std::to_string(v[2]);
}
BuildInfo parse_build_info(std::string_view json)
{
BuildInfo info;
const std::string_view build = string_value(json, "\"build\"");
std::string_view channel = string_value(json, "\"channel\"");
// YYYYMMDD-HHMMSS
if (build.size() != 15 || build[8] != '-')
return info;
for (std::size_t i = 0; i < build.size(); ++i)
{
if (i != 8 && !digit(build[i]))
return info;
}
// The channel is padded to a fixed width so the stamp can be changed in place.
while (!channel.empty() && channel.back() == ' ')
channel.remove_suffix(1);
if (channel != "test" && channel != "release")
return info;
info.known = true;
info.release = channel == "release";
info.build = std::string(build);
return info;
}
BuildInfo read_build_info(const std::string &path)
{
std::string text;
if (!save::read_file(path, &text, 1024))
return {};
return parse_build_info(text);
}
std::string version_label(std::string_view content_version, const BuildInfo &info)
{
std::string label = release_version_text(content_version);
if (!label.empty() && info.known && !info.release)
label += " \xC2\xB7 test " + info.build;
return label;
}
std::string build_tag(std::string_view content_version, const BuildInfo &info)
{
std::string tag(content_version);
if (info.known)
tag += "+" + info.build;
return tag;
}
} // namespace hui
+29 -2
View File
@@ -1,4 +1,4 @@
// ps5-homebrew-ui - The app version, read from the packaged param.json.
// ps5-homebrew-ui - The app version, read from the packaged param.json, and which build of it this is.
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -12,10 +12,37 @@ namespace hui
// The "contentVersion" value (PlayStation NN.NNN.NNN, e.g. "01.000.000") from
// param.json text, or "" when it is missing or malformed. Release tags use the
// same string, so what the app shows matches the GitHub release.
// same numbers (v1.0.0), so what the app shows matches the GitHub release.
std::string content_version(std::string_view param_json);
// Reads param.json from path; "" when it cannot be read.
std::string read_content_version(const std::string &path);
// "00.001.001" -> {0, 1, 1}. False for anything but NN.NNN.NNN.
bool parse_content_version(std::string_view text, int out[3]);
// "v0.1.1" -> {0, 1, 1}. A release tag is v<major>.<minor>.<patch>: digits only, one to three each.
bool parse_release_tag(std::string_view tag, int out[3]);
// -1, 0, 1.
int compare_versions(const int a[3], const int b[3]);
// "00.001.001" -> "0.1.1", the form the release page and its tag use. "" for a malformed one.
std::string release_version_text(std::string_view content_version);
// Which build of that version this is: assets/build-info.json, written by the build
// (tools/wings-stamp.py): {"build":"20261012-203105","channel":"test "}.
struct BuildInfo
{
bool known = false; // the stamp was there and valid
bool release = false; // channel "release" (a test package says "test")
std::string build; // UTC build time, YYYYMMDD-HHMMSS
};
BuildInfo parse_build_info(std::string_view json);
BuildInfo read_build_info(const std::string &path); // not known when it cannot be read
// What the player reads: "0.1.1" for a release build, "0.1.1 · test 20261012-203105" for a test build.
// An unknown stamp shows the version alone.
std::string version_label(std::string_view content_version, const BuildInfo &info);
// Identity of this exact build ("00.001.001+20261012-203105"; the version alone when the stamp is
// unknown). Saved data is tagged with it, so a new build fetches fresh data.
std::string build_tag(std::string_view content_version, const BuildInfo &info);
} // namespace hui
+157 -4
View File
@@ -15,14 +15,23 @@
#include "gfx/renderer.hpp"
#include "orbit/art.hpp"
#include "orbit/cover_art.hpp"
#include "orbit/download_auto_retry.hpp"
#include "orbit/pkg_install.hpp"
#include "orbit/pegasus_manager.hpp"
#include "orbit/editorial.hpp"
#include "orbit/game_header.hpp"
#include "orbit/popularity.hpp"
#include "orbit/steam_reviews.hpp"
#include "orbit/stream_download.hpp"
#include "orbit/wings_helper_client.hpp"
#include "orbit/wings_install.hpp"
#include "orbit/wings_update.hpp"
#include "orbit/prefs.hpp"
#include "orbit/diagnostics.hpp"
#include "orbit/gl_textures.hpp"
#include "orbit/http.hpp"
#include "orbit/i18n.hpp"
#include "orbit/link_flags.hpp"
#include "orbit/mark.hpp"
#include "orbit/screens.hpp"
#include "orbit/shadowmount_scan.hpp"
@@ -89,6 +98,11 @@ std::vector<std::string> wings_prefs_paths()
return {std::string(kWingsDir) + "/linkprefs.v1", "/data/wings/linkprefs.v1"};
}
std::vector<std::string> link_flags_paths()
{
return {std::string(kWingsDir) + "/link_flags.v1", "/data/wings/link_flags.v1"};
}
std::vector<std::string> prefs_paths()
{
return {std::string(kWingsDir) + "/settings.v1", "/data/wings/settings.v1"};
@@ -204,6 +218,44 @@ class ConsolePegasusDownloader final : public orbit::PegasusDownloader
wings::art::ArtService &service_;
};
// Streaming downloads (orbit/stream_download.hpp): the same TLS client and network gate, the body
// goes to a file in chunks instead of memory.
class ConsoleStreamingDownloader final : public orbit::StreamingDownloader
{
public:
explicit ConsoleStreamingDownloader(wings::art::ArtService &service) : service_(service) {}
bool ready(std::string *why) override
{
const wings::art::ServiceState state = service_.state();
if (state.phase == wings::art::Phase::ready)
return true;
*why = state.detail.empty() ? std::string("The network is off. Turn on cover downloads first.")
: state.detail;
return false;
}
std::string get_stream(const std::string &url, std::uint64_t max_bytes, const orbit::StreamHooks &hooks,
const std::function<bool(const std::uint8_t *, std::size_t)> &sink,
int *status) override
{
wings::art::ArtService::DownloadHooks h;
h.redirect_ok = hooks.redirect_ok;
h.progress = hooks.progress;
h.cancel = hooks.cancel;
h.sink = sink;
h.total_timeout_s = 1800;
wings::art::ArtService::Download d = service_.fetch_download(url, static_cast<std::size_t>(max_bytes), h);
*status = d.status;
std::string error = d.error;
if (d.time_error)
error += (error.empty() ? "" : " ") + std::string("(check the console clock)");
hui::sys::log("[WINGS-UPDATE] stream status=%d %s", d.status, error.c_str());
return error;
}
private:
wings::art::ArtService &service_;
};
// Settings > Cover art on top of the art service.
class ConsoleCoverArt final : public orbit::CoverArtControl
{
@@ -356,10 +408,11 @@ int main()
hui::sys::log("[WINGS] language=%s system=%d", orbit::i18n::language().data(), system_language);
orbit::Store store(backend);
store.log = [](const std::string &line) { hui::sys::log("%s", line.c_str()); };
// ---- Cover art (wings/art): https posters, fetched by the app itself ----
// Start-up facts first, so the very first console run is informative even
// if everything after this fails: see docs/FIRST-CONSOLE-TEST.md.
// if everything after this fails.
const std::int64_t boot_clock = wall_clock_seconds();
sys::log("[WINGS-ART] clock %s UTC plausible=%d", orbit::format_utc(boot_clock).c_str(),
orbit::clock_plausible(boot_clock) ? 1 : 0);
@@ -425,13 +478,24 @@ int main()
// Popularity data: off unless the Wings preference is on; then a background fetch (never
// blocks the frame). The saved copy and the local download counter live beside the art cache.
ConsoleDocFetcher doc_fetcher(art_service);
// Which Wings this is (param.json) and which build of it (assets/build-info.json, written by the build).
// Saved data is tagged with the build, so a new build fetches fresh data on its first start.
const std::string app_content_version = read_content_version("/app0/sce_sys/param.json");
const BuildInfo app_build = read_build_info(std::string(kAssets) + "/build-info.json");
orbit::PopularityServiceConfig pop_config;
pop_config.cache_paths = {std::string(kWingsDir) + "/popularity.v1"};
pop_config.app_tag = build_tag(app_content_version, app_build);
orbit::PopularityService popularity(orbit::popularity_hub(), doc_fetcher, pop_config);
orbit::popularity_hub().apply_hook = [&popularity](bool on, const std::string &url)
{ popularity.apply(on, url); };
orbit::popularity_hub().refresh_hook = [&popularity] { popularity.refresh(); };
orbit::popularity_hub().set_downloads_paths({std::string(kWingsDir) + "/downloads.v1"});
// Editorial data (game notes, store carousel): same fetcher, same 24 h and build rule as the popularity data.
orbit::EditorialServiceConfig editorial_config;
editorial_config.cache_paths = {std::string(kWingsDir) + "/editorial.v1", "/data/wings/editorial.v1"};
editorial_config.app_tag = pop_config.app_tag;
orbit::EditorialService editorial(orbit::editorial_hub(), doc_fetcher, editorial_config);
orbit::editorial_hub().apply_hook = [&editorial](bool notes, bool carousel) { editorial.apply(notes, carousel); };
// Pegasus DL on this console: the release list on demand (6 h cache), a download only on a confirm.
ConsolePegasusDownloader pegasus_downloader(art_service);
{
@@ -439,8 +503,73 @@ int main()
pm.docs = &doc_fetcher;
pm.downloader = &pegasus_downloader;
pm.cache_paths = {std::string(kWingsDir) + "/pegasus_releases.v1", "/data/wings/pegasus_releases.v1"};
// EXPERIMENTAL BUILD ONLY - set back to false before any real release package. Turns on the
// P0 Pegasus DL patch (docs/research/pegasus-dl-patch.md): before a send, pegasus_patch.hpp
// tries its catalogue against the exact verified stock file; it only ever changes bytes when
// the file's SHA-256 matches a catalogue entry precisely, and it never touches the original
// on disk (it writes a `.patched.elf` sibling and sends that instead, falling back to the
// stock file unchanged on any mismatch or error).
pm.experimental_patch_pegasus = true;
orbit::pegasus_manager().attach(std::move(pm));
}
// A newer Wings: a look at the project's latest release once the network is ready (wings_update.hpp).
{
orbit::WingsUpdateConfig wu;
wu.docs = &doc_fetcher;
wu.ready = [&art_service](std::string *why)
{
const wings::art::ServiceState state = art_service.state();
if (state.phase == wings::art::Phase::ready)
return true;
*why = state.detail.empty() ? std::string("The network is off. Turn on cover downloads first.")
: state.detail;
return false;
};
wu.cache_paths = {std::string(kWingsDir) + "/wings_update.v1", "/data/wings/wings_update.v1"};
wu.running = app_content_version;
orbit::wings_update().attach(std::move(wu));
}
// Fetching and placing a newer package, only on the player's confirmed presses (wings_install.hpp).
ConsoleStreamingDownloader update_downloader(art_service);
// The Wings update helper: a status read on its loopback port; when nothing answers, the shipped ELF goes
// to the payload loader (the same socket send Pegasus DL uses).
orbit::http::SocketTransport helper_transport(kBackend, orbit::kWingsHelperPort, 2000);
orbit::WingsHelperClient update_helper(
helper_transport,
[](const std::string &elf, std::string *why)
{
return orbit::send_file_to_loopback(kBackend, orbit::kPegasusLoaderPort, elf,
[](std::uint64_t) {}, why);
},
std::string(kAssets) + "/helper/wings-helper.elf", orbit::kWingsHelperBuild);
{
orbit::WingsInstallConfig wi;
wi.downloader = &update_downloader;
wi.helper = &update_helper;
wi.note_dir = kWingsDir;
wi.running = app_content_version;
wi.log = [&problems](const std::string &line)
{
hui::sys::log("%s", line.c_str());
problems.add(line); // so the update's steps reach diagnostics.txt and the Logs tab
};
orbit::wings_installer().attach(std::move(wi));
}
struct InstallStop
{
~InstallStop()
{
orbit::wings_installer().cancel();
orbit::wings_installer().wait_idle();
}
} install_stop;
struct UpdateStop
{
~UpdateStop()
{
orbit::wings_update().wait_idle();
}
} update_stop;
// The worker uses the downloader above: stop it before that goes away.
struct PegasusStop
{
@@ -455,10 +584,20 @@ int main()
steam_config.cache_paths = {std::string(kWingsDir) + "/steam_reviews.v1"};
orbit::SteamReviewService steam_reviews(orbit::popularity_hub(), doc_fetcher, steam_config);
orbit::set_steam_reviews(&steam_reviews);
// Links that failed for the player on this console (B-20): Best skips them, the card says why.
orbit::LinkFlags link_flags;
link_flags.load(link_flags_paths());
if (link_flags.expire(static_cast<std::int64_t>(time(nullptr))) > 0)
link_flags.save(link_flags_paths());
orbit::set_link_flags(&link_flags, link_flags_paths());
{
orbit::WingsLocalPrefs local;
orbit::load_local_prefs(wings_prefs_paths(), &local);
orbit::wings_update().set_enabled(local.update_check);
orbit::download_reconnect().set_enabled(local.auto_reconnect);
orbit::pkg_installs().set_keep_files(local.keep_pkg_files);
popularity.apply(local.popularity_enabled, local.popularity_url);
orbit::editorial_hub().apply_prefs(local.notes_enabled, local.carousel_enabled);
}
// Package id -> poster URL, refreshed from the Store's snapshot in the frame
@@ -479,15 +618,26 @@ int main()
orbit::Context context(type, fonts, store, art);
context.cover_art = &cover_art;
context.posters = &posters;
context.catalogue_paths = catalogue_paths();
context.wings_prefs_paths = wings_prefs_paths();
// Files tab diagnostics (request ids, drops, errors) go to the title log.
context.log_line = [](const std::string &line) { sys::log("[WINGS-FILES] %s", line.c_str()); };
context.prefetch_art = [&art_source](const std::vector<std::string> &keys)
{ art_source.prefetch_keys(keys, orbit::ArtKind::cover); };
// The focused tile's 16:9 picture, into the disk cache only (the background lane: never ahead of what is on screen).
context.prefetch_hero = [&art_service](const std::string &url)
{
const std::size_t queued = art_service.prefetch({url}, wings::art::Kind::hero, wings::art::Lane::prefetch);
hui::sys::log("[WINGS-ART] hero prefetch %s", queued > 0 ? "queued" : "skipped (cached or not allowed)");
};
// The game page holds the blurred cover back when its 16:9 picture is already on disk (game_header.hpp).
orbit::hero_hooks().cached = [&art_service](const std::string &url) { return art_service.is_cached(url); };
orbit::hero_hooks().log = [](const std::string &line) { hui::sys::log("%s", line.c_str()); };
context.mark = mark;
context.loader_mark = loader_mark;
context.app_version = read_content_version("/app0/sce_sys/param.json");
context.app_version = app_content_version;
context.app_label = version_label(app_content_version, app_build);
context.open_pegasus_ui = []
{ return sceSystemServiceLaunchWebBrowser(kBrowserUrl, nullptr) == 0; };
// ShadowMount's local API (loopback only): after a download finishes, Wings asks it to scan.
@@ -514,7 +664,9 @@ int main()
orbit::App app(context);
orbit::Frame frame;
frame.glass_texture = renderer.glass_texture();
sys::log("[WINGS] ready version=%s", context.app_version.c_str());
sys::log("[WINGS] ready version=%s build=%s channel=%s", context.app_version.c_str(),
app_build.known ? app_build.build.c_str() : "unknown",
app_build.known ? (app_build.release ? "release" : "test") : "unknown");
// The diagnostics file: at start, 10 s later (Pegasus and the catalogue have
// answered by then), then every minute and whenever the player changes a
@@ -528,7 +680,8 @@ int main()
const wings::art::ServiceState st = art_service.state();
const wings::art::ArtStats stats = art_service.stats();
orbit::DiagnosticsInput in;
in.app_version = context.app_version;
in.app_version = context.app_label.empty() ? context.app_version : context.app_label;
in.app_build = app_build.known ? app_build.build + (app_build.release ? " (release)" : " (test)") : std::string();
in.title_id = kTitleId;
in.unix_time = wall_clock_seconds();
in.uptime_s = static_cast<double>(now_us - start_us) / 1e6;
+179 -1
View File
@@ -9,6 +9,9 @@
#include "orbit/mark.hpp"
#include "orbit/screens.hpp"
#include "orbit/i18n.hpp"
#include "orbit/update_screen.hpp"
#include "orbit/wings_install.hpp"
#include "orbit/wings_update.hpp"
#include <algorithm>
#include <cmath>
@@ -63,6 +66,14 @@ App::App(Context &context)
context.after_install = &after_install_;
// The Store ranks links with the preferences Settings > Wings keeps.
store_.use_prefs(&settings_.local_prefs());
// The first download asks about the download folder once (panels.hpp FolderAsk); the answer is a Wings pref.
context.folder_confirmed = [this] { return settings_.local_prefs().folder_confirmed; };
context.confirm_folder = [this]
{
WingsLocalPrefs prefs = settings_.local_prefs();
prefs.folder_confirmed = true;
settings_.set_local_prefs(prefs);
};
// Settings > Wings > Reset Store grid: sort and filters only, favourites stay.
context.reset_store_grid = [this]
{
@@ -71,6 +82,99 @@ App::App(Context &context)
reset.reset_filters();
store_.set_catalogue_state(reset);
};
// Settings > Wings update and the update page read the one flow (checker + installer).
context.wings_update_view = [](UpdateView &view)
{ return update_view_from_flow(wings_flow_snapshot(wings_update().status(), wings_installer().status()), &view); };
}
void App::start_update_download()
{
const WingsUpdateStatus up = wings_update().status();
if (!up.have_latest)
return;
WingsRelease release;
release.tag = up.latest_tag;
release.asset = up.asset;
wings_installer().start_download(release);
}
bool App::show_update(bool dialog)
{
UpdateView view;
if (!ctx_.wings_update_view || !ctx_.wings_update_view(view))
return false;
if (dialog)
{
update_page_.close();
update_dialog_.open(view);
}
else
{
update_dialog_.close();
update_page_.open(view);
}
return true;
}
void App::update_flow(const InputFrame &input, float dt, Feedback &feedback)
{
const InputFrame idle{};
UpdateView view;
const bool known = ctx_.wings_update_view && ctx_.wings_update_view(view);
if (!known && update_page_.is_open())
update_page_.close(); // nothing newer any more (the switch went off): nothing to follow
const bool had_confirm = install_confirm_.is_open();
const bool had_dialog = update_dialog_.is_open();
const bool had_page = update_page_.is_open();
// "Install Wings X?" sits on top of the page.
if (install_confirm_.update(had_confirm ? input : idle, dt, feedback) == ConfirmDialog::Result::confirmed)
wings_installer().install(install_replace_);
// The startup dialog: Update now opens the page and starts the download; Later snoozes this release.
const UpdateAction said = update_dialog_.update(had_dialog && !had_confirm ? input : idle, dt, feedback);
if (said == UpdateAction::update_now)
{
if (known)
update_page_.open(view);
start_update_download();
}
else if (said == UpdateAction::later)
{
wings_update().snooze_latest();
}
const UpdateAction act =
update_page_.update(view, had_page && !had_dialog && !had_confirm ? input : idle, dt, feedback);
switch (act)
{
case UpdateAction::update_now:
case UpdateAction::retry:
start_update_download();
break;
case UpdateAction::cancel:
if (view.kind == UpdateView::Kind::ready_to_install)
update_page_.close(); // the verified file stays; Install is offered again from Settings
else
wings_installer().cancel();
break;
case UpdateAction::install:
{
const WingsInstallStatus st = wings_installer().status();
install_replace_ = st.needs_replace_confirm;
install_confirm_.open(tr("Install Wings {version}?", {{"version", view.latest}}),
wings_install_confirm_text(st, view.latest), tr("Install"), tr("Not now"),
install_replace_);
feedback.play(hui::audio::Cue::modal_open);
break;
}
case UpdateAction::quit_now:
if (ctx_.close_app)
ctx_.close_app();
break;
default: // none, later, back (the page closed itself)
break;
}
}
Screen &App::screen()
@@ -127,6 +231,16 @@ void App::handle_intent(const Intent &intent, Feedback &feedback)
show_tab(intent.tab);
feedback.play(hui::audio::Cue::tab);
break;
case Intent::Kind::open_update:
{
UpdateView view;
if (ctx_.wings_update_view && ctx_.wings_update_view(view))
{
update_page_.open(view);
feedback.play(hui::audio::Cue::modal_open);
}
break;
}
default:
break;
}
@@ -144,11 +258,48 @@ void App::update(const InputFrame &input, float dt, Feedback &feedback)
ctx_.store.want_logs(false);
ctx_.store.want_installed(false);
toast_time_ = std::max(0.0f, toast_time_ - dt);
// Pegasus DL without a download folder gets the default one, once (shown in Settings > Pegasus DL).
if (ctx_.store.state().have_settings)
ctx_.store.ensure_default_download_folder();
// A finished download asks ShadowMount to scan (whichever tab shows; the Downloads tab
// draws the answer). The setting is Settings > Pegasus DL > After a download finishes.
after_install_.set_mode(settings_.local_prefs().after_download);
after_install_.update(static_cast<double>(ctx_.clock), ctx_.store.state().jobs,
ctx_.store.state().have_queue);
// A newer Wings: ask GitHub once the network is ready (at most every 6 hours; the service decides),
// and offer it in the startup dialog once per start unless snoozed. Checked every two seconds.
update_poll_ += dt;
if (update_poll_ >= 2.0f)
{
update_poll_ = 0.0f;
wings_update().auto_check();
const WingsUpdateStatus up = wings_update().status();
if (up.have_latest && up.advice == WingsAdvice::newer)
{
// The release is known: the installer picks up a file verified for it in an earlier start.
WingsRelease release;
release.tag = up.latest_tag;
release.asset = up.asset;
wings_installer().note_release(release);
}
if (!update_modal() && !capture_panel_open())
{
const WingsFlowSnapshot flow = wings_flow_snapshot(up, wings_installer().status());
UpdateView view;
// take_prompt last: it marks the release as offered.
if (flow.state == WingsFlowState::available && update_view_from_flow(flow, &view) &&
wings_update().take_prompt())
{
update_dialog_.open(view);
feedback.play(hui::audio::Cue::modal_open);
}
}
}
// The cover icon source (Settings > Art sources): covers are asked for again when what they resolve to changes.
if (ctx_.posters != nullptr &&
ctx_.posters->set_icon_source(settings_.local_prefs().icon_source == IconSource::prospero,
popularity_hub().index()))
ctx_.art.reset_covers();
// The one wash: aimed at the tab's hue, then at the section the screen reports.
wash_.retarget(tab_, screen().section_accent());
wash_.update(dt);
@@ -174,6 +325,24 @@ void App::update(const InputFrame &input, float dt, Feedback &feedback)
}
}
// The update dialog, page and install confirm sit above the tabs and take the input.
{
const bool shown_before = update_modal();
update_flow(input, dt, feedback);
if (shown_before || update_modal())
{
const InputFrame quiet{};
screen().update(quiet, dt, feedback, false);
for (int i = 0; i < kTabCount; ++i)
{
top_focus_[static_cast<std::size_t>(i)].target =
area_ == Area::top && i == top_index_ ? 1.0f : 0.0f;
top_focus_[static_cast<std::size_t>(i)].update(dt, 16.0f);
}
return;
}
}
const InputFrame idle{};
// The first-run "load Pegasus DL" flow: it runs on every tab; its choices show on the waiting card.
const bool offer_tab = tab_ != tab::settings && tab_ != tab::logs;
@@ -553,10 +722,19 @@ void App::draw(Frame &frame) const
}
draw_capture(frame);
update_page_.draw(ctx_, frame);
update_dialog_.draw(ctx_, frame);
install_confirm_.draw(ctx_, frame);
// ---- controller hints, over a fade that lets content scroll beneath ----
std::vector<hui::ui::Hint> items;
if (capture_panel_open())
if (install_confirm_.is_open())
items = {{hui::ui::Button::cross, tr("Select")}, {hui::ui::Button::circle, tr("Back")}};
else if (update_dialog_.is_open())
items = update_dialog_.hints();
else if (update_page_.is_open())
items = update_page_.hints();
else if (capture_panel_open())
items = {{hui::ui::Button::cross, tr("Select")}, {hui::ui::Button::circle, tr("Hide")}};
else if (area_ == Area::top)
items = {{hui::ui::Button::cross, tr("Select")},
+35 -3
View File
@@ -23,12 +23,23 @@ constexpr std::size_t kMaxQueued = 96;
std::string make_key(const std::string &art_key, ArtKind kind)
{
return art_key + (kind == ArtKind::hero ? "#hero" : "#cover");
switch (kind)
{
case ArtKind::hero:
return art_key + "#hero";
case ArtKind::hero_low:
return art_key + "#low";
default:
return art_key + "#cover";
}
}
// Backgrounds of either kind (they share one budget).
bool is_hero(const std::string &key)
{
return key.size() > 5 && key.compare(key.size() - 5, 5, "#hero") == 0;
const auto ends = [&key](const char *tail, std::size_t n)
{ return key.size() > n && key.compare(key.size() - n, n, tail) == 0; };
return ends("#hero", 5) || ends("#low", 4);
}
} // namespace
@@ -116,6 +127,8 @@ void ArtCache::tick()
if (found == entries_.end())
continue; // dropped while it was decoding
Entry &entry = found->second;
if (entry.state == State::ready)
continue; // an older request for the same image finished late: keep what is shown
if (!decoded.ok)
{
entry.state = State::failed;
@@ -183,6 +196,25 @@ void ArtCache::clear()
uploads_.clear();
}
void ArtCache::reset_covers()
{
for (auto it = entries_.begin(); it != entries_.end();)
{
if (is_hero(it->first))
{
++it;
continue;
}
if (it->second.state == State::ready)
{
sink_.destroy(it->second.art.texture);
if (it->second.art.ambient != 0)
sink_.destroy(it->second.art.ambient);
}
it = entries_.erase(it); // a decode still on its way finds no entry and is dropped
}
}
void ArtCache::retry_failed()
{
for (auto &[key, entry] : entries_)
@@ -237,7 +269,7 @@ bool ArtCache::work_once()
}
if (art.ok() && !art.value.pending)
{
const bool hero = request.kind == ArtKind::hero;
const bool hero = request.kind != ArtKind::cover;
const int width = hero ? limits_.hero_width : limits_.cover_size;
const int height = hero ? limits_.hero_height : limits_.cover_size;
decoded.ok = image::decode(art.value.bytes, width, height, &decoded.pixels);
+4 -1
View File
@@ -48,7 +48,7 @@ struct ArtLimits
int hero_width = 1920; // backgrounds fit inside this ...
int hero_height = 1080; // ... and this
int max_covers = 96; // textures kept before the least recent go (4K grids)
int max_heroes = 4;
int max_heroes = 3; // an open page: the small one and the full one; the Store's hero: the same two and the next small one
int uploads_per_frame = 2;
double retry_failed = 60.0; // seconds before a failed image is asked again
// Decoded images waiting for upload: the worker stops decoding at this many
@@ -74,6 +74,9 @@ class ArtCache
bool idle();
// Releases every texture (before the GL context goes away).
void clear();
// Frame thread: forgets every cover (not the game-page backgrounds); they are asked for again as they are
// drawn. For when what the covers resolve to changed (Settings > Art sources).
void reset_covers();
// Frame thread: images that failed are asked for again the next time they are
// drawn instead of after limits.retry_failed (Settings turned something on,
// or the player asked to retry).
+158
View File
@@ -0,0 +1,158 @@
// Wings TV app - Where art comes from (see art_sources.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/art_sources.hpp"
#include "orbit/popularity.hpp"
namespace orbit
{
const char *icon_source_key(IconSource source)
{
return source == IconSource::prospero ? "prospero" : "catalogue";
}
bool parse_icon_source(std::string_view text, IconSource *out)
{
if (text == "catalogue")
*out = IconSource::catalogue;
else if (text == "prospero")
*out = IconSource::prospero;
else
return false;
return true;
}
const char *hero_source_key(HeroSource source)
{
return source == HeroSource::prospero ? "prospero" : "all";
}
bool parse_hero_source(std::string_view text, HeroSource *out)
{
if (text == "all")
*out = HeroSource::all;
else if (text == "prospero")
*out = HeroSource::prospero;
else
return false;
return true;
}
bool is_prospero_pic0_url(std::string_view url)
{
constexpr std::string_view head = "https://cdn.prosperopatches.com/titles/PPSA";
constexpr std::string_view tail = "/pic0.webp";
// head + 5 digits + "_" + 64 hex + tail
if (url.size() != head.size() + 5 + 1 + 64 + tail.size() || url.substr(0, head.size()) != head ||
url.substr(url.size() - tail.size()) != tail)
return false;
std::size_t at = head.size();
for (int i = 0; i < 5; ++i, ++at)
if (url[at] < '0' || url[at] > '9')
return false;
if (url[at++] != '_')
return false;
for (int i = 0; i < 64; ++i, ++at)
if (!((url[at] >= '0' && url[at] <= '9') || (url[at] >= 'a' && url[at] <= 'f')))
return false;
return true;
}
std::string prospero_icon_url(std::string_view pic0_url)
{
if (!is_prospero_pic0_url(pic0_url))
return {};
std::string out(pic0_url.substr(0, pic0_url.size() - std::string_view("pic0.webp").size()));
out += "icon0.webp";
return out;
}
bool hero_url_allowed(std::string_view url, HeroSource source)
{
return source == HeroSource::all || is_prospero_pic0_url(url);
}
std::string choose_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source)
{
for (const std::string &id : title_ids)
{
const PopRecord *record = index.find(id);
if (record != nullptr && !record->hero.empty() && hero_url_allowed(record->hero, source))
return record->hero;
}
return {};
}
std::string igdb_full_url(std::string_view low_url)
{
constexpr std::string_view kPrefix = "https://images.igdb.com/igdb/image/upload/";
constexpr std::string_view kSmall = "t_screenshot_big/";
if (low_url.substr(0, kPrefix.size()) != kPrefix)
return {};
const std::string_view rest = low_url.substr(kPrefix.size());
if (rest.substr(0, kSmall.size()) != kSmall || rest.size() == kSmall.size())
return {};
return std::string(kPrefix) + "t_1080p/" + std::string(rest.substr(kSmall.size()));
}
HeroPick pick_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source,
bool use_second)
{
HeroPick pick;
pick.full = choose_hero(index, title_ids, source);
// The small IGDB picture is not used when only Prosperopatches art is wanted.
if (source == HeroSource::all)
pick.low = index.hero_low(title_ids);
const std::string igdb = igdb_full_url(pick.low);
pick.has_both = is_prospero_pic0_url(pick.full) && !igdb.empty();
if (pick.has_both)
{
if (use_second)
{
pick.second = true;
pick.full = igdb; // its small stand-in is the same picture: the swap is only sharpening
}
else
{
pick.low.clear(); // Prosperopatches' picture alone: no other picture shown first
}
}
return pick;
}
std::string hero_art_key(const std::string &art_key, bool second)
{
return second ? art_key + kHeroSecondTail : art_key;
}
bool HeroPrefetchGate::update(const std::string &game_id, double now_s)
{
if (game_id.empty())
{
game_.clear();
done_ = false;
return false;
}
if (game_id != game_)
{
game_ = game_id;
since_ = now_s;
done_ = false;
return false;
}
if (done_ || now_s - since_ < kDwellS || now_s - last_fetch_ < kMinGapS)
return false;
done_ = true;
last_fetch_ = now_s;
return true;
}
void HeroPrefetchGate::reset()
{
game_.clear();
done_ = false;
}
} // namespace orbit
+96
View File
@@ -0,0 +1,96 @@
// Wings TV app - Where art comes from: the cover icon source and the 16:9 game-page art source.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Two player choices (Settings > Wings > Art sources), both kept in WingsLocalPrefs:
// * Cover icons: "Catalogue" (default: the poster link of each catalogue entry) or "Prosperopatches": the
// title's PS5 icon from cdn.prosperopatches.com. The popularity data already carries the address of that
// title's 16:9 picture (.../pic0.webp); the icon (.../icon0.webp) sits next to it, so no extra lookup is
// made. A title Prosperopatches has no art for keeps its catalogue cover.
// * 16:9 game-page art: "All sources" (default: Prosperopatches first, then IGDB, Steam) or "Prosperopatches
// only" (the PS5's own background, nothing from IGDB or Steam; the page keeps the cover wash otherwise).
// Only the exact address shape Prosperopatches uses is ever derived or accepted.
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
class PopularityIndex;
enum class IconSource : std::uint8_t
{
catalogue,
prospero
};
enum class HeroSource : std::uint8_t
{
all,
prospero
};
const char *icon_source_key(IconSource source); // "catalogue" / "prospero"
bool parse_icon_source(std::string_view text, IconSource *out);
const char *hero_source_key(HeroSource source); // "all" / "prospero"
bool parse_hero_source(std::string_view text, HeroSource *out);
// https://cdn.prosperopatches.com/titles/PPSA12345_<64 hex>/pic0.webp, exactly (no query, no other host).
bool is_prospero_pic0_url(std::string_view url);
// The icon0.webp next to such a pic0.webp; "" for any other address.
std::string prospero_icon_url(std::string_view pic0_url);
// Whether a hero address may be used under `source`.
bool hero_url_allowed(std::string_view url, HeroSource source);
// The 16:9 picture address for a game: the first of its title ids whose record has one the source allows. "" none.
std::string choose_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source);
// Two equally good pictures: for many games the data has Prosperopatches' picture (hero) AND a different IGDB
// screenshot (heroLow, small). Showing the small one first and then swapping to the other made the page flash
// one picture for half a second before another. Instead the page (and the Store's hero shot) shows ONE of them,
// whole and sharp, and the choice alternates: the picture a game showed last is not the one it shows next time.
// The IGDB variant lives under its own art key (art_key + kHeroSecondTail) so the two never share a texture.
constexpr const char *kHeroSecondTail = "~b";
// .../t_screenshot_big/<id>.jpg -> .../t_1080p/<id>.jpg (the same IGDB picture at full size); "" for any other
// address.
std::string igdb_full_url(std::string_view low_url);
struct HeroPick
{
std::string full; // the picture to show ("" none)
std::string low; // a small stand-in of THE SAME picture ("" none)
bool second = false; // the IGDB variant: its art key carries kHeroSecondTail
bool has_both = false; // the game has the two different pictures, so the choice alternates
};
// use_second picks the IGDB picture when the game has both; otherwise it is Prosperopatches'. A game with only one
// kind of picture gets it, with its own small stand-in when the data has one (as before).
HeroPick pick_hero(const PopularityIndex &index, const std::vector<std::string> &title_ids, HeroSource source,
bool use_second);
// The art key a pick's pictures are cached under.
std::string hero_art_key(const std::string &art_key, bool second);
// Decides when the Store grid fetches a game's 16:9 picture ahead of time (the focused tile). The picture goes to
// the disk cache only, so that opening the page finds it there. Rules: focus has to rest on the same game for
// `kDwellS` (scrolling past fetches nothing), a game is fetched once per time focus lands on it, and at most
// one fetch every `kMinGapS` seconds (a long run of resting on tiles cannot pull megabytes).
class HeroPrefetchGate
{
public:
static constexpr double kDwellS = 0.5;
static constexpr double kMinGapS = 6.0;
// Every frame with the focused game's id ("" when no game is focused) and the time in seconds.
// True when its picture should be fetched now.
bool update(const std::string &game_id, double now_s);
// The grid or the setting changed under it: forget what was fetched, so a game can be fetched again.
void reset();
private:
std::string game_;
double since_ = 0.0;
bool done_ = false;
double last_fetch_ = -1.0e9;
};
} // namespace orbit
+4 -1
View File
@@ -46,7 +46,8 @@ template <typename T> struct Result
enum class ArtKind : std::uint8_t
{
cover,
hero,
hero, // the full background
hero_low, // a small, fast stand-in for it
};
struct Art
@@ -182,6 +183,8 @@ class Backend : public ArtSource
std::string_view password) = 0;
virtual Result<std::vector<FileOp>> file_pkg_install(std::string_view path) = 0;
virtual Result<bool> file_cancel(std::string_view operation_id) = 0;
// Opens a web page in the PS5 browser (POST /api/browser/open, unguarded).
virtual Result<bool> open_url(std::string_view url) = 0;
};
} // namespace orbit
+27
View File
@@ -0,0 +1,27 @@
// Wings TV app - Circle on a waiting box (B-18): the hint every such box shows.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Every box that waits on Pegasus DL or the network takes Circle and goes back
// at once; the request in flight is ignored when it answers (Store::cancel for
// Store lanes). This is the line those boxes show.
#pragma once
#include "orbit/i18n.hpp"
#include <string>
namespace orbit
{
// "Asking Pegasus DL... (Circle: stop)"
inline std::string with_stop_hint(const char *text)
{
std::string line = text;
line += " (";
line += tr("Circle: stop");
line += ")";
return line;
}
} // namespace orbit
+119
View File
@@ -0,0 +1,119 @@
// Wings TV app - The Store's curated carousel: the pure rules (carousel.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/carousel.hpp"
#include <algorithm>
namespace orbit
{
std::vector<int> resolve_carousel(const std::vector<std::string> &ids, const std::vector<GameGroup> &games)
{
std::vector<int> out;
for (const std::string &id : ids)
{
if (out.size() >= kCarouselMaxItems)
break;
for (std::size_t g = 0; g < games.size(); ++g)
{
const std::vector<std::string> &have = games[g].title_ids;
if (std::find(have.begin(), have.end(), id) == have.end())
continue;
if (std::find(out.begin(), out.end(), static_cast<int>(g)) == out.end())
out.push_back(static_cast<int>(g));
break;
}
}
return out;
}
bool carousel_shown(bool pref_on, std::size_t resolved, bool query_empty, std::size_t tiles)
{
return pref_on && resolved > 0 && query_empty && tiles > 0;
}
std::vector<int> pick_most_popular(const std::vector<long> &keys, std::size_t count)
{
std::vector<int> order;
bool any = false;
for (std::size_t i = 0; i < keys.size(); ++i)
{
order.push_back(static_cast<int>(i));
any = any || keys[i] >= 0;
}
if (any)
std::stable_sort(order.begin(), order.end(), [&keys](int a, int b)
{ return keys[static_cast<std::size_t>(a)] > keys[static_cast<std::size_t>(b)]; });
if (order.size() > count)
order.resize(count);
return order;
}
int carousel_wrap(int slide, int delta, int count)
{
if (count <= 0)
return 0;
const int at = (slide + delta) % count;
return at < 0 ? at + count : at;
}
CarouselStep carousel_step(int slide, int button, int count, hui::Direction nav)
{
CarouselStep step;
step.slide = slide;
step.button = button;
if (nav == hui::Direction::right)
{
if (button == kCarouselView)
{
step.button = kCarouselFavourite;
step.moved = true;
}
else if (count > 1)
{
step.slide = carousel_wrap(slide, 1, count);
step.moved = step.slide_changed = true;
}
}
else if (nav == hui::Direction::left)
{
if (button == kCarouselFavourite)
{
step.button = kCarouselView;
step.moved = true;
}
else if (count > 1)
{
step.slide = carousel_wrap(slide, -1, count);
step.moved = step.slide_changed = true;
}
}
return step;
}
bool CarouselClock::update(float dt, bool paused, int count)
{
if (paused || count < 2)
{
elapsed_ = 0.0f;
return false;
}
elapsed_ += dt;
if (elapsed_ < kCarouselAdvanceS)
return false;
elapsed_ = 0.0f;
return true;
}
float grid_scroll_target(int row, float pitch, float extra)
{
return extra + std::max(0.0f, static_cast<float>(row - 1) * pitch);
}
float carousel_fade(float scroll)
{
return std::clamp(1.0f - scroll / (kCarouselExtra * 0.8f), 0.0f, 1.0f);
}
} // namespace orbit
+94
View File
@@ -0,0 +1,94 @@
// Wings TV app - The Store's curated carousel: which games, when it shows, the pad rules, the timer and the
// grid offset it adds. Pure and tested on the host; drawing and input wiring are in store_screen.cpp.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The carousel is the project's featured games (editorial.hpp, curated.v1.json), the Store's hero shot in its
// default view only: the first thing on the page, full width, with the grid below it. Each slide is one game of
// the player's OWN catalogue (found by title id; ids they do not have are hidden) with a 16:9 picture, the title,
// a View game button (opens the game page, as Cross on the tile does) and a heart (Favourite).
//
// Pad rules (the focus is always on one of the two buttons; the slide is whatever the buttons belong to):
// Right on View game -> heart Left on heart -> View game
// Right on heart -> next slide, focus stays on the heart (the last slide wraps to the first)
// Left on View game -> previous slide, focus stays on View game (the first slide wraps to the last)
// Down scrolls the page up to the grid (its header and first row), Up leaves to the top bar. Up from the grid's
// first row comes back to the hero, on View game. The Store opens with the focus on the hero. Holding Right or
// Left therefore walks through the slides after two steps.
#pragma once
#include "core/input.hpp"
#include "orbit/game_group.hpp"
#include <cstddef>
#include <string>
#include <vector>
namespace orbit
{
constexpr std::size_t kCarouselMaxItems = 8;
constexpr float kCarouselAdvanceS = 8.0f;
// Geometry (virtual 1920 x 1080). The hero is the page's first viewport: the picture fills the whole width from
// the top of the screen (under the app bar) down to kCarouselArtH and fades into the page at its foot; the title
// and the buttons sit over it at the left. The grid is pushed down by kCarouselExtra, so its header and the top of
// its first row peek in under the hero (as Orbit's first rail does); Down scrolls the page up by that much.
constexpr float kCarouselArtH = 800.0f;
constexpr float kCarouselExtra = 590.0f; // what the grid is pushed down by
// The games of the curated ids, in the file's order: the first game of the player's catalogue that carries each
// id (a game that comes up twice is kept once), at most kCarouselMaxItems. Indexes into `games`.
std::vector<int> resolve_carousel(const std::vector<std::string> &ids, const std::vector<GameGroup> &games);
// Shown whenever the preference is on and there is something to show: at least one game, no search under way and
// something in the grid below it. Filters and the sort do not hide it.
bool carousel_shown(bool pref_on, std::size_t resolved, bool query_empty, std::size_t tiles);
// How many games the carousel falls back to when the featured list gives none (nothing fetched, or none of the
// featured games is in the catalogue).
constexpr std::size_t kCarouselFallbackItems = 5;
// The `count` games with the largest popularity key (keys[i] belongs to games[i]; -1 = no data, sorts last). With
// no data at all it is simply the first `count` games. Indexes into the list the keys came from.
std::vector<int> pick_most_popular(const std::vector<long> &keys, std::size_t count);
// The two focus stops.
constexpr int kCarouselView = 0;
constexpr int kCarouselFavourite = 1;
struct CarouselStep
{
int slide = 0;
int button = 0;
bool moved = false; // the focus or the slide changed (the pad gets a sound; false: a refusal)
bool slide_changed = false;
};
// Left / Right on the buttons (the rules above). Other directions change nothing. count is >= 1.
CarouselStep carousel_step(int slide, int button, int count, hui::Direction nav);
// slide + delta, wrapping inside 0..count-1.
int carousel_wrap(int slide, int delta, int count);
// The auto-advance clock.
class CarouselClock
{
public:
// Every frame. paused: the carousel has focus, a dialog or panel is open, it is not on screen. True when the
// slide should advance now (the clock then starts again).
bool update(float dt, bool paused, int count);
void restart() { elapsed_ = 0.0f; }
float elapsed() const { return elapsed_; }
private:
float elapsed_ = 0.0f;
};
// The grid's scroll target (px) with `extra` px of carousel above row 0. Row 0 scrolls the carousel away (the
// header and the first row then sit where they do without one); from there the focused row sits second from the
// top once the list scrolls. With extra == 0 this is exactly the grid's own rule: max(0, (row - 1) * pitch).
float grid_scroll_target(int row, float pitch, float extra);
// How much of the hero is left at a page scroll (1 at rest, 0 once it is far enough up to be out of the way of
// the header that rides up with the page): the hero fades as it slides, so it never meets the header's words.
float carousel_fade(float scroll);
} // namespace orbit
+1
View File
@@ -50,6 +50,7 @@ class CoreTransport final : public wings::http::Transport
mine.body = request.body;
mine.max_bytes = request.max_bytes == 0 ? http::kDefaultLimit
: std::min(request.max_bytes, kMaxReply);
mine.timeout_ms = request.timeout_ms;
http::Response reply = transport_.send(mine);
out.status = reply.status;
out.body = std::move(reply.body);
+36 -1
View File
@@ -4,7 +4,9 @@
#include "orbit/cover_art.hpp"
#include "core/save_file.hpp"
#include "orbit/art_sources.hpp"
#include "orbit/game_header.hpp"
#include "orbit/popularity.hpp"
namespace orbit
{
@@ -18,15 +20,21 @@ bool PosterIndex::sync(std::uint64_t serial, const std::vector<Package> &package
}
// Built outside the lock: the art worker is never held up by 3,000 inserts.
std::unordered_map<std::string, std::string> next;
std::unordered_map<std::string, std::string> next_titles;
next.reserve(packages.size());
for (const Package &package : packages)
{
if (package.id.empty() || package.poster_url.compare(0, 8, "https://") != 0)
if (package.id.empty())
continue;
if (!package.title_id.empty())
next_titles.emplace(package.id, package.title_id);
if (package.poster_url.compare(0, 8, "https://") != 0)
continue;
next.emplace(package.id, package.poster_url);
}
Lock lock(mutex_);
urls_.swap(next);
title_ids_.swap(next_titles);
serial_ = serial;
return true;
}
@@ -39,12 +47,39 @@ std::string PosterIndex::resolve(std::string_view key, ArtKind kind) const
// URL from the popularity index). Grid tiles never ask, so nothing is prefetched.
if (kind == ArtKind::hero)
return hero_urls().find(key);
if (kind == ArtKind::hero_low)
return hero_urls().find_low(key);
const std::string id(key.substr(4));
Lock lock(mutex_);
if (prospero_active_)
{
// The icon sits next to the title's 16:9 picture in the popularity data.
const auto title = title_ids_.find(id);
if (title != title_ids_.end())
{
const PopRecord *record = index_->find(title->second);
if (record != nullptr)
{
const std::string icon = prospero_icon_url(record->hero);
if (!icon.empty())
return icon;
}
}
}
const auto found = urls_.find(id);
return found == urls_.end() ? std::string() : found->second;
}
bool PosterIndex::set_icon_source(bool prospero, std::shared_ptr<const PopularityIndex> index)
{
Lock lock(mutex_);
const bool active = prospero && index != nullptr;
const bool changed = active != prospero_active_ || (active && index.get() != index_.get());
prospero_active_ = active;
index_ = active ? std::move(index) : nullptr;
return changed;
}
std::size_t PosterIndex::size() const
{
Lock lock(mutex_);
+11
View File
@@ -18,6 +18,7 @@
#include "orbit/thread.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -26,6 +27,8 @@
namespace orbit
{
class PopularityIndex;
// The sentence Settings shows beside the DNS-over-HTTPS switch.
inline constexpr const char *kDohPrivacySentence =
"Looking up cover servers this way sends their host names (for example "
@@ -44,10 +47,18 @@ class PosterIndex
std::size_t size() const;
// Frame thread. `prospero`: covers resolve to the title's Prosperopatches icon where the popularity data
// has one (art_sources.hpp); every other title keeps its catalogue cover. Returns true when what covers
// resolve to changed, so the caller drops the covers it holds and they are asked for again.
bool set_icon_source(bool prospero, std::shared_ptr<const PopularityIndex> index);
private:
mutable Mutex mutex_;
std::unordered_map<std::string, std::string> urls_;
std::unordered_map<std::string, std::string> title_ids_; // package id -> title id (when it has one)
std::uint64_t serial_ = ~0ull;
bool prospero_active_ = false; // the Prosperopatches source is chosen and the data to use it is there
std::shared_ptr<const PopularityIndex> index_;
};
struct CoverArtStatus
+2
View File
@@ -117,6 +117,8 @@ std::string format_diagnostics(const DiagnosticsInput &in)
line("");
line("App");
line(" version: " + (in.app_version.empty() ? std::string("unknown") : in.app_version));
if (!in.app_build.empty())
line(" build: " + in.app_build);
line(" title id: " + (in.title_id.empty() ? std::string("unknown") : in.title_id));
line(" uptime: " + std::to_string(static_cast<long long>(in.uptime_s)) + " s");
line("");
+1
View File
@@ -25,6 +25,7 @@ struct DiagnosticsInput
{
// Identity
std::string app_version;
std::string app_build; // "20261012-203105 (test)"; empty when the package carries no stamp
std::string title_id;
// Time
std::int64_t unix_time = 0; // 0 when the clock cannot be read
+230 -40
View File
@@ -12,6 +12,29 @@
namespace orbit
{
namespace
{
// "7", "7 and 12", "7, 12 and 33", "7, 12, 33 and 4 more".
std::string compact_list(const std::vector<std::string> &items)
{
constexpr std::size_t kShown = 3;
if (items.empty())
return {};
if (items.size() == 1)
return items[0];
const std::size_t head_count = items.size() <= kShown ? items.size() - 1 : kShown;
std::string head = items[0];
for (std::size_t i = 1; i < head_count; ++i)
head += ", " + items[i];
if (items.size() <= kShown)
return tr("{first} and {last}", {{"first", head}, {"last", items.back()}});
return trn(static_cast<long>(items.size() - kShown), "{list} and {count} more", "{list} and {count} more",
{{"list", head}});
}
} // namespace
std::string part_failure_reason(const LinkStart &outcome)
{
if (!outcome.error.empty())
@@ -22,39 +45,95 @@ std::string part_failure_reason(const LinkStart &outcome)
return tr("the debrid service asks which files to take: open this part on its own");
if (outcome.kind == LinkResult::Kind::browser_opened)
return tr("it opened in the PS5 browser instead of the queue");
if (outcome.kind == LinkResult::Kind::capture)
return tr("still resolving"); // a capture that started is not a queued part
return {};
}
void DownloadAll::begin(std::vector<LinkRef> refs, std::vector<int> numbers, std::string host_name, Start start,
void DownloadAll::begin(std::vector<PartPlan> parts, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished)
{
++generation_;
refs_ = std::move(refs);
numbers_ = std::move(numbers);
numbers_.resize(refs_.size(), 0);
++call_; // a Done still out from an earlier run is ignored
parts_ = std::move(parts);
host = std::move(host_name);
total = static_cast<int>(refs_.size());
total = static_cast<int>(parts_.size());
done = 0;
ok = 0;
next_ = 0;
end = End::none;
current = 0;
attempt = 0;
trying_host.clear();
waiting = 0.0f;
failed.clear();
via_alternate = 0;
alternates.clear();
failed_number = 0;
failed_why.clear();
part_ = 0;
ref_ = 0;
tries_ = 0;
in_a_row_ = 0;
last_why_.clear();
in_flight_ = false;
waiting_ = false;
cancel_ = false;
start_ = std::move(start);
queued_ = std::move(queued);
finished_ = std::move(finished);
active = total > 0;
step();
if (active)
attempt_now();
}
void DownloadAll::begin(std::vector<LinkRef> refs, std::vector<int> numbers, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished)
{
std::vector<PartPlan> parts;
parts.reserve(refs.size());
for (std::size_t i = 0; i < refs.size(); ++i)
{
PartPlan part;
part.number = i < numbers.size() ? numbers[i] : 0;
part.refs.push_back(std::move(refs[i]));
part.hosts.push_back(host_name);
parts.push_back(std::move(part));
}
begin(std::move(parts), std::move(host_name), std::move(start), std::move(queued), std::move(finished));
}
void DownloadAll::tick(float dt)
{
if (!active || !waiting_ || in_flight_)
return;
if (dt > 0.0f)
waiting -= dt;
if (waiting > 0.0f)
return;
waiting = 0.0f;
waiting_ = false;
attempt_now();
}
void DownloadAll::cancel()
{
if (active)
cancel_ = true;
if (!active)
return;
cancel_ = true;
// Ends at once (B-18): a part still out is let go, its late answer is ignored
// (on_result sees the series ended) and the Store cancels what it queued.
in_flight_ = false;
finish(End::cancelled);
}
void DownloadAll::step()
const std::string &DownloadAll::host_of(std::size_t ref) const
{
const PartPlan &part = parts_[part_];
if (ref < part.hosts.size() && !part.hosts[ref].empty())
return part.hosts[ref];
return host;
}
void DownloadAll::attempt_now()
{
if (!active)
return;
@@ -63,42 +142,114 @@ void DownloadAll::step()
finish(End::cancelled);
return;
}
if (next_ >= total)
const PartPlan &part = parts_[part_];
current = part.number;
if (ref_ >= part.refs.size())
{
finish(End::finished);
// Every host of this part was tried (or it has none).
part_failed(last_why_.empty() ? std::string(tr("no link for this part")) : last_why_);
return;
}
const int index = next_;
const int generation = generation_;
start_(refs_[static_cast<std::size_t>(index)],
[this, index, generation](const LinkStart &outcome)
{
if (generation == generation_ && active)
on_result(index, outcome);
});
attempt = tries_ + 1;
trying_host = host_of(ref_);
waiting = 0.0f;
waiting_ = false;
in_flight_ = true;
const int call = ++call_;
const LinkRef ref = part.refs[ref_]; // a copy: Done may run inside start_ and move the series on
start_(ref, [this, call](const LinkStart &outcome) { on_result(call, outcome); });
}
void DownloadAll::on_result(int index, const LinkStart &outcome)
void DownloadAll::on_result(int call, const LinkStart &outcome)
{
++done;
if (call != call_ || !in_flight_ || !active)
return; // an older start, a second Done, or a series that ended
in_flight_ = false;
const std::string why = part_failure_reason(outcome);
if (!why.empty())
if (why.empty())
{
failed_number = numbers_[static_cast<std::size_t>(index)];
failed_why = why;
finish(End::failed);
part_queued();
return;
}
// A capture without an error has not decided anything yet: worth asking again.
const bool resolving = outcome.error.empty() && outcome.kind == LinkResult::Kind::capture;
const bool transient = outcome.transient || resolving;
last_why_ = parts_[part_].refs.size() > 1 ? host_of(ref_) + ": " + why : why;
++tries_;
if (cancel_)
{
finish(End::cancelled);
return;
}
if (transient && tries_ < kPartAttempts)
{
waiting = tries_ == 1 ? kFirstBackoff : kSecondBackoff;
waiting_ = true;
return; // tick() starts the next attempt
}
++ref_; // the next host of the same part, or the part failed
tries_ = 0;
attempt_now();
}
void DownloadAll::part_queued()
{
const PartPlan &part = parts_[part_];
++done;
++ok;
in_a_row_ = 0;
if (ref_ > 0)
{
++via_alternate;
alternates.push_back(AlternatePart{part.number, host_of(ref_)});
}
const LinkRef ref = part.refs[ref_];
if (queued_)
queued_(refs_[static_cast<std::size_t>(index)]);
next_ = index + 1;
step();
queued_(ref);
next_part();
}
void DownloadAll::part_failed(const std::string &why)
{
++done;
failed.push_back(FailedPart{current, why});
failed_number = current;
failed_why = why;
++in_a_row_;
if (in_a_row_ >= kStopAfterFailedParts && part_ + 1 < parts_.size())
{
finish(End::stopped);
return;
}
next_part();
}
void DownloadAll::next_part()
{
++part_;
ref_ = 0;
tries_ = 0;
attempt = 0;
last_why_.clear();
if (part_ >= parts_.size())
{
finish(failed.empty() ? End::finished : End::finished_with_failures);
return;
}
if (cancel_)
{
finish(End::cancelled);
return;
}
attempt_now();
}
void DownloadAll::finish(End how)
{
active = false;
in_flight_ = false;
waiting_ = false;
waiting = 0.0f;
end = how;
std::function<void()> callback = finished_;
if (callback)
@@ -107,26 +258,65 @@ void DownloadAll::finish(End how)
std::string DownloadAll::toast() const
{
return trn(ok, "{count} part queued from {host}", "{count} parts queued from {host}", {{"host", host}});
std::string out =
trn(ok, "{count} part queued from {host}", "{count} parts queued from {host}", {{"host", host}});
if (via_alternate > 0)
out += " " + trn(via_alternate, "({count} from another host)", "({count} from other hosts)");
return out;
}
std::string DownloadAll::report_title() const
{
const std::string ok_text = std::to_string(ok);
const std::string total_text = std::to_string(total);
if (end == End::cancelled)
return tr("Cancelled: {ok} of {total} parts queued",
{{"ok", std::to_string(ok)}, {"total", std::to_string(total)}});
return tr("{ok} of {total} parts queued", {{"ok", std::to_string(ok)}, {"total", std::to_string(total)}});
return tr("Cancelled: {ok} of {total} parts queued", {{"ok", ok_text}, {"total", total_text}});
if (end == End::stopped)
return tr("Stopped: {ok} of {total} parts queued", {{"ok", ok_text}, {"total", total_text}});
return tr("{ok} of {total} parts queued", {{"ok", ok_text}, {"total", total_text}});
}
std::string DownloadAll::report_message() const
{
const std::string retry = tr("Choose \"Download all\" again to continue: parts already queued are left out.");
std::string out;
const auto add = [&out](const std::string &sentence)
{
if (sentence.empty())
return;
if (!out.empty())
out += ' ';
out += sentence;
};
if (end == End::cancelled)
return tr("Stopped on your request after part {n} of {total}.",
{{"n", std::to_string(done)}, {"total", std::to_string(total)}}) +
" " + retry;
return tr("Stopped at part {n}: {why}.", {{"n", std::to_string(failed_number)}, {"why", failed_why}}) + " " +
trn(ok, "{count} part was queued before it.", "{count} parts were queued before it.") + " " + retry;
add(tr("Stopped on your request after part {n} of {total}.",
{{"n", std::to_string(done)}, {"total", std::to_string(total)}}));
else if (end == End::stopped)
add(trn(kStopAfterFailedParts,
"Pegasus seems stuck: {count} part in a row did not queue, so the rest was not tried.",
"Pegasus seems stuck: {count} parts in a row did not queue, so the rest was not tried."));
if (failed.size() == 1)
{
add(tr("Part {n} did not queue: {why}.", {{"n", std::to_string(failed[0].number)}, {"why", failed[0].why}}));
}
else if (failed.size() > 1)
{
std::vector<std::string> numbers;
for (const FailedPart &part : failed)
numbers.push_back(std::to_string(part.number));
add(tr("Parts {list} did not queue. Last reason: {why}.",
{{"list", compact_list(numbers)}, {"why", failed.back().why}}));
}
add(trn(ok, "{count} part was queued.", "{count} parts were queued."));
if (!alternates.empty())
{
std::vector<std::string> items;
for (const AlternatePart &part : alternates)
items.push_back(tr("part {n} from {host}", {{"n", std::to_string(part.number)}, {"host", part.host}}));
add(trn(via_alternate, "{count} part came from another host: {list}.",
"{count} parts came from other hosts: {list}.", {{"list", compact_list(items)}}));
}
add(tr("Choose \"Download all\" again to continue: parts already queued are left out."));
return out;
}
// ---- the record of queued parts ----
+83 -23
View File
@@ -3,10 +3,15 @@
//
// The series runner starts one part through the existing per-link queue call,
// waits for its outcome, and only then starts the next (never a flood of
// commands). The first hard failure stops it and says how many parts were
// queued; Circle stops it after the part in flight. The (packageId, linkIndex)
// pairs queued are kept in a small versioned file of the checked save container
// (prefs kind), so a second press, or a new run of the app, skips them.
// commands). A part that does not queue is asked again after a short wait when
// the error may pass by itself (timed out, Pegasus busy), then tried on the
// other hosts that carry the same part; when every one fails the part is
// listed and the series goes on with the next. Only a row of failed parts
// (Pegasus seems stuck) or Circle stops it early. The waits are driven by
// tick() on the frame thread: nothing sleeps, no thread is started. The
// (packageId, linkIndex) pairs queued are kept in a small versioned file of the
// checked save container (prefs kind), so a second press, or a new run of the
// app, skips them.
#pragma once
@@ -14,6 +19,7 @@
#include "orbit/game_view.hpp"
#include "orbit/store.hpp"
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
@@ -23,54 +29,108 @@ namespace orbit
{
// Empty when the part was queued; otherwise why it was not ("Part n: ..." is built by the caller).
// A capture that started without an error is not a queued part: "still resolving".
std::string part_failure_reason(const LinkStart &outcome);
// One part of a set: refs[0] is the link of the chosen host, refs[1..] the same
// part on other hosts, tried in order. `hosts` names them (parallel to refs).
struct PartPlan
{
int number = 0;
std::vector<LinkRef> refs;
std::vector<std::string> hosts;
};
struct FailedPart
{
int number = 0;
std::string why;
};
struct AlternatePart
{
int number = 0;
std::string host; // the host the part was queued from
};
// Attempts per link while the error is the kind that may pass by itself.
constexpr int kPartAttempts = 3;
// Waits before the second and the third attempt, in seconds.
constexpr float kFirstBackoff = 2.0f;
constexpr float kSecondBackoff = 6.0f;
// This many failed parts in a row stop the series ("Pegasus seems stuck").
constexpr int kStopAfterFailedParts = 4;
class DownloadAll
{
public:
using Done = std::function<void(const LinkStart &)>;
// Starts one part and calls Done once, on the frame thread, when it ended.
// Starts one part and calls Done once, on the frame thread, when its fate is known.
using Start = std::function<void(const LinkRef &, Done)>;
enum class End : std::uint8_t
{
none, // never ran, or running
finished, // every part queued
failed, // stopped at the first part that did not queue
cancelled, // stopped by the player after the part in flight
none, // never ran, or running
finished, // every part queued
finished_with_failures, // went to the end; some parts did not queue
stopped, // kStopAfterFailedParts failed parts in a row
cancelled, // stopped by the player
};
// Public so the screen can draw progress: "Queueing part {done+1} of {total}".
// Public so the screen can draw progress: "Queueing part {current} ({done+1} of {total})".
bool active = false;
std::string host;
std::string host; // the chosen host of the set
int total = 0;
int done = 0; // parts whose outcome came back
int done = 0; // parts decided (queued or failed)
int ok = 0; // parts queued by this run
End end = End::none;
int failed_number = 0; // the part number that stopped it
int current = 0; // number of the part being tried
int attempt = 0; // 1-based attempt on the current link
std::string trying_host; // host of the link being tried
float waiting = 0.0f; // seconds left before the next attempt; 0 when none is pending
std::vector<FailedPart> failed;
int via_alternate = 0; // parts queued from another host than the chosen one
std::vector<AlternatePart> alternates;
// The last part that did not queue (kept for older callers; `failed` has them all).
int failed_number = 0;
std::string failed_why;
// `numbers` are the parts' own numbers (for messages), same length as `refs`.
// `queued` is called for every part that was queued, `finished` once at the end.
// `queued` is called for every part that was queued, with the link that worked;
// `finished` once at the end.
void begin(std::vector<PartPlan> parts, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished);
// One link per part, no other hosts. `numbers` are the parts' own numbers (for messages).
void begin(std::vector<LinkRef> refs, std::vector<int> numbers, std::string host_name, Start start,
std::function<void(const LinkRef &)> queued, std::function<void()> finished);
// Stops after the part in flight (it still counts if it queues). Nothing else is started.
// Every frame: counts down a wait between attempts and starts the next one.
void tick(float dt);
// Stops at once: a wait ends, a part in flight is let go (its late answer
// changes nothing; the screen also cancels the Store's link action).
void cancel();
// The toast of a clean run, and the notice (title + message) of a stopped one.
// The toast of a clean run, and the notice (title + message) of any other end.
std::string toast() const;
std::string report_title() const;
std::string report_message() const;
private:
void step();
void on_result(int index, const LinkStart &outcome);
void attempt_now();
void on_result(int call, const LinkStart &outcome);
void part_queued();
void part_failed(const std::string &why);
void next_part();
void finish(End how);
const std::string &host_of(std::size_t ref) const;
std::vector<LinkRef> refs_;
std::vector<int> numbers_;
int next_ = 0;
int generation_ = 0;
std::vector<PartPlan> parts_;
std::size_t part_ = 0; // index in parts_
std::size_t ref_ = 0; // index in the part's refs
int tries_ = 0; // attempts made on the current link
int in_a_row_ = 0; // failed parts in a row
std::string last_why_; // why the current part's last attempt failed
bool in_flight_ = false;
bool waiting_ = false;
int call_ = 0; // numbers every start; a Done of an older one is ignored
bool cancel_ = false;
Start start_;
std::function<void(const LinkRef &)> queued_;
+422
View File
@@ -0,0 +1,422 @@
// Wings TV app - Reconnect downloads that dropped (see download_auto_retry.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/download_auto_retry.hpp"
#include "orbit/i18n.hpp"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <iterator>
#include <set>
namespace orbit
{
namespace
{
std::string lower(std::string_view text)
{
std::string out(text);
for (char &c : out)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return out;
}
bool has_any(const std::string &text, std::initializer_list<const char *> words)
{
for (const char *word : words)
if (text.find(word) != std::string::npos)
return true;
return false;
}
// Statuses the player chose: never retried.
bool player_stopped(const std::string &status)
{
return status == "paused" || status == "pausing" || status == "cancelled" || status == "cancelling";
}
std::string fmt_bytes(double bytes)
{
char buf[32];
std::snprintf(buf, sizeof buf, "%.0f", bytes);
return buf;
}
} // namespace
int download_error_http_code(std::string_view text)
{
const std::string t = lower(text);
// Shapes Pegasus DL 1.11.0 writes: "remote download failed: %s status=%d %s",
// "HTTP server returned status %d", curl's "HTTP/1.1 503 ...".
static const char *const kLeads[] = {"status=", "status:", "status ", "http/1.1 ", "http/1.0 ", "http/2 ",
"http "};
for (const char *lead : kLeads)
{
const std::string l(lead);
std::size_t at = t.find(l);
while (at != std::string::npos)
{
std::size_t i = at + l.size();
while (i < t.size() && t[i] == ' ')
++i;
std::size_t j = i;
while (j < t.size() && std::isdigit(static_cast<unsigned char>(t[j])))
++j;
if (j - i == 3)
{
const int code = std::stoi(t.substr(i, 3));
if (code >= 100 && code <= 599)
return code;
}
at = t.find(l, at + 1);
}
}
return 0;
}
bool transient_download_error(std::string_view error, std::string_view detail)
{
std::string text = lower(error);
if (!detail.empty())
text += " | " + lower(detail);
if (text.find_first_not_of(" |") == std::string::npos)
return false; // nothing to go on
// Answers that will not change (Orbit: 404, disk full, size changed, ETag mismatch).
if (has_any(text, {"cancel", "not found", "disk full", "no space", "not enough free", "free space",
"storage allocation", "size changed", "etag", "mismatch", "byte range",
"range not satisfiable", "support byte ranges", "unable to resume", "not resumable",
"expired", "password", "install", "invalid", "unsupported", "not supported",
"forbidden", "unauthorized", "permission", "checksum", "corrupt"}))
return false;
const int code = download_error_http_code(text);
if (code == 408 || code == 429 || code == 500 || code == 502 || code == 503 || code == 504)
return true;
if (code != 0 && code != 206 && code != 200)
return false; // 400, 401, 403, 404, 410, 416, ...
// Dropped, reset, timed out or stalled connections (curl and Pegasus wording).
return has_any(text, {"timed out", "timeout", "connection reset", "reset by peer", "connection died",
"connection closed", "closed abruptly", "peer closed", "transfer closed",
"partial file", "recv failure", "failure when receiving", "send failure",
"server returned nothing", "couldn't connect", "could not connect",
"failed to connect", "connection refused", "couldn't resolve", "could not resolve",
"too slow", "stall", "gateway", "service unavailable", "temporarily", "try again",
"segmented download failed", "range download failed", "download failed",
"network"});
}
double DownloadAutoRetry::backoff_seconds(int attempt)
{
if (attempt < 1)
attempt = 1;
if (attempt > kMaxTries)
attempt = kMaxTries;
return static_cast<double>(5 << attempt); // 10, 20, 40
}
void DownloadAutoRetry::say(const std::string &line)
{
log_.push_back("[WINGS-RETRY] " + line);
if (log_.size() > 64)
log_.erase(log_.begin());
}
void DownloadAutoRetry::set_enabled(bool on)
{
if (enabled_ == on)
return;
enabled_ = on;
say(on ? "auto-reconnect on" : "auto-reconnect off");
if (!on)
for (auto &entry : tracks_)
{
entry.second.due = -1.0;
entry.second.in_flight = false;
}
}
std::vector<std::string> DownloadAutoRetry::observe(const std::vector<RetryJobSnapshot> &jobs, double now)
{
std::vector<std::string> due;
std::set<std::string> present;
for (const RetryJobSnapshot &job : jobs)
{
present.insert(job.id);
if (job.status == "completed")
{
tracks_.erase(job.id);
continue;
}
Track &t = tracks_[job.id];
if (player_stopped(job.status))
{
if (t.due >= 0.0 || t.in_flight)
say("job " + job.id + ": " + job.status + " by the player, not reconnecting");
const bool live = job.status == "paused" || job.status == "pausing";
t = Track{};
t.seen_live = live; // a paused job that fails after Resume may still be retried
continue;
}
if (job.status == "failed")
{
if (t.due >= 0.0)
{
if (!enabled_)
{
t.due = -1.0;
continue;
}
if (now >= t.due)
{
t.due = -1.0;
t.in_flight = true;
t.sent_at = now;
t.mark = std::max(t.high, job.received);
due.push_back(job.id);
say("job " + job.id + ": retrying now (try " + std::to_string(t.tries) + " of " +
std::to_string(kMaxTries) + ", " + fmt_bytes(t.mark) + " bytes kept)");
}
continue;
}
// A retry that went out but never got the job moving counts as a failed try.
if (t.in_flight && now - t.sent_at > kStuckAfter)
t.handled = false;
if (t.handled)
continue;
t.handled = true;
t.in_flight = false;
t.high = std::max(t.high, job.received);
if (!t.seen_live)
continue; // failed before Wings watched it run
const std::string why = job.error.empty() ? job.detail : job.error;
if (!enabled_)
{
say("job " + job.id + ": failed (" + why + "); auto-reconnect is off");
continue;
}
if (!transient_download_error(job.error, job.detail))
{
say("job " + job.id + ": real error, not retrying: " + why);
continue;
}
if (!job.resumable)
{
say("job " + job.id + ": dropped but cannot resume, not retrying: " + why);
continue;
}
if (t.tries >= kMaxTries)
{
t.gave_up = true;
say("job " + job.id + ": gave up after " + std::to_string(kMaxTries) +
" tries without progress: " + why);
continue;
}
++t.tries;
t.due = now + backoff_seconds(t.tries);
say("job " + job.id + ": dropped (" + why + "), reconnecting in " +
std::to_string(static_cast<int>(backoff_seconds(t.tries))) + " s (try " +
std::to_string(t.tries) + " of " + std::to_string(kMaxTries) + ")");
continue;
}
// Queued, starting, downloading, merging, ...
if (t.gave_up)
t.tries = 0; // the player pressed Retry after Wings gave up: a fresh start
t.gave_up = false;
t.seen_live = true;
t.handled = false;
if (t.due >= 0.0)
{
// Retried by hand while Wings was waiting.
t.due = -1.0;
t.in_flight = true;
t.mark = std::max(t.high, job.received);
}
if (t.in_flight && job.received > t.mark)
{
t.in_flight = false;
t.tries = 0;
t.reconnected_until = now + kReconnectedShown;
say("job " + job.id + ": reconnected, moving again at " + fmt_bytes(job.received) + " bytes");
}
t.high = std::max(t.high, job.received);
}
// Entries cleared from Pegasus' list.
for (auto it = tracks_.begin(); it != tracks_.end();)
it = present.count(it->first) != 0 ? std::next(it) : tracks_.erase(it);
return due;
}
void DownloadAutoRetry::sent(const std::string &id, bool ok, double now)
{
(void)now;
const auto it = tracks_.find(id);
if (it == tracks_.end())
return;
if (!ok)
{
// Pegasus refused the retry: decide again on the next poll (it counts as a try).
it->second.in_flight = false;
it->second.handled = false;
}
}
void DownloadAutoRetry::stop(const std::string &id)
{
const auto it = tracks_.find(id);
if (it == tracks_.end())
return;
if (it->second.due >= 0.0 || it->second.in_flight)
say("job " + id + ": the player stopped reconnecting");
it->second.due = -1.0;
it->second.in_flight = false;
it->second.handled = true;
it->second.gave_up = true;
}
DownloadAutoRetry::View DownloadAutoRetry::view(const std::string &id, double now) const
{
View v;
const auto it = tracks_.find(id);
if (it == tracks_.end())
return v;
const Track &t = it->second;
v.attempt = t.tries;
if (t.due >= 0.0)
{
v.kind = View::Kind::waiting;
v.seconds = std::max(0, static_cast<int>(std::ceil(t.due - now)));
}
else if (t.in_flight)
v.kind = View::Kind::reconnecting;
else if (t.reconnected_until > now)
v.kind = View::Kind::reconnected;
return v;
}
std::vector<std::string> DownloadAutoRetry::take_log()
{
std::vector<std::string> out;
out.swap(log_);
return out;
}
std::string auto_retry_text(const DownloadAutoRetry::View &view)
{
using Kind = DownloadAutoRetry::View::Kind;
const std::string n = std::to_string(view.attempt);
const std::string max = std::to_string(view.max);
switch (view.kind)
{
case Kind::waiting:
if (view.seconds <= 0)
return tr("Reconnecting now (try {n} of {max})", {{"n", n}, {"max", max}});
return tr("Reconnecting in {s} s (try {n} of {max})",
{{"s", std::to_string(view.seconds)}, {"n", n}, {"max", max}});
case Kind::reconnecting:
return tr("Reconnecting (try {n} of {max})", {{"n", n}, {"max", max}});
case Kind::reconnected:
return tr("Reconnected");
case Kind::none:
break;
}
return std::string();
}
// ------------------------------------------------------------- shared copy --
namespace
{
double steady_seconds()
{
using namespace std::chrono;
return duration<double>(steady_clock::now().time_since_epoch()).count();
}
} // namespace
void DownloadReconnect::set_enabled(bool on)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.set_enabled(on);
flush();
}
bool DownloadReconnect::enabled() const
{
std::lock_guard<std::mutex> lock(mutex_);
return model_.enabled();
}
std::vector<std::string> DownloadReconnect::observe(const std::vector<RetryJobSnapshot> &jobs)
{
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> due = model_.observe(jobs, steady_seconds());
flush();
return due;
}
void DownloadReconnect::sent(const std::string &id, bool ok, const std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.sent(id, ok, steady_seconds());
if (!ok)
{
const std::string line = "[WINGS-RETRY] job " + id + ": Pegasus refused the retry: " + error;
if (sink_)
sink_(line);
else
{
std::printf("%s\n", line.c_str());
std::fflush(stdout);
}
}
flush();
}
void DownloadReconnect::stop(const std::string &id)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.stop(id);
flush();
}
DownloadAutoRetry::View DownloadReconnect::view(const std::string &id) const
{
std::lock_guard<std::mutex> lock(mutex_);
return model_.view(id, steady_seconds());
}
void DownloadReconnect::set_log(std::function<void(const std::string &)> sink)
{
std::lock_guard<std::mutex> lock(mutex_);
sink_ = std::move(sink);
}
void DownloadReconnect::flush()
{
for (const std::string &line : model_.take_log())
{
if (sink_)
sink_(line);
else
{
std::printf("%s\n", line.c_str());
std::fflush(stdout);
}
}
}
DownloadReconnect &download_reconnect()
{
static DownloadReconnect instance;
return instance;
}
} // namespace orbit
+140
View File
@@ -0,0 +1,140 @@
// Wings TV app - Reconnect downloads that dropped, the way Orbit does.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// At full speed (many connections) debrid hosts sometimes reset a connection and
// Pegasus DL marks the job failed. Pressing Retry carries on from the partial
// file. This does that by itself: a job that failed with a connection-type error
// and can resume is retried after 10, 20, then 40 seconds (Orbit's 5 s << attempt).
// Three tries in a row without new bytes and it stays failed. New bytes after a
// retry reset the count, so a long download that drops now and then keeps going.
// Real errors (not found, disk full, size changed, ...), and jobs the player
// paused or cancelled, are never retried.
//
// DownloadAutoRetry is the pure model (time is passed in, tested on its own).
// download_reconnect() is the one shared copy: the Store worker feeds it every
// queue poll (PegasusBackend::queue), the Downloads screen reads its row text.
#pragma once
#include <functional>
#include <map>
#include <mutex>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
// The HTTP status inside Pegasus' error text ("status=503", "returned status 404",
// "HTTP/1.1 502"), or 0 when there is none.
int download_error_http_code(std::string_view text);
// True when the failure is a dropped / reset / timed-out connection or a busy
// server (429, 5xx) that a retry may get past. False for answers that will not
// change: not found, refused, disk full, size or ETag changed, cancelled, ...
bool transient_download_error(std::string_view error, std::string_view detail);
// What the model needs from one queue entry.
struct RetryJobSnapshot
{
std::string id;
std::string status; // orbit status names: "failed", "downloading", "paused", ...
std::string error;
std::string detail;
double received = 0.0; // bytes done
bool resumable = false; // Pegasus says it can carry on from the partial file
};
class DownloadAutoRetry
{
public:
static constexpr int kMaxTries = 3;
static constexpr double kReconnectedShown = 5.0; // seconds "Reconnected" stays on the row
static constexpr double kStuckAfter = 30.0; // a sent retry still "failed" this long: a new failure
// 10, 20, 40 seconds for try 1, 2, 3.
static double backoff_seconds(int attempt);
struct View
{
enum class Kind
{
none,
waiting, // failed, retry due in `seconds`
reconnecting, // retry sent, no new bytes yet
reconnected, // new bytes after a retry, shown briefly
};
Kind kind = Kind::none;
int seconds = 0;
int attempt = 0;
int max = kMaxTries;
};
void set_enabled(bool on);
bool enabled() const
{
return enabled_;
}
// One queue poll. Returns the ids whose retry is due now; the caller sends
// them and reports each with sent().
std::vector<std::string> observe(const std::vector<RetryJobSnapshot> &jobs, double now);
// The retry request went out (ok) or Pegasus refused it.
void sent(const std::string &id, bool ok, double now);
// The player asked to stop reconnecting this one: it stays failed.
void stop(const std::string &id);
View view(const std::string &id, double now) const;
// [WINGS-RETRY] lines written since the last call.
std::vector<std::string> take_log();
private:
struct Track
{
bool seen_live = false; // seen running: only failures Wings watched happen are retried
bool handled = false; // the current failure has been decided
bool gave_up = false;
bool in_flight = false; // retry sent, waiting for new bytes
int tries = 0; // tries in a row without new bytes
double due = -1.0; // when the next retry goes out; negative: none waiting
double mark = 0.0; // bytes when the retry went out
double sent_at = 0.0;
double high = 0.0; // most bytes seen
double reconnected_until = -1.0;
};
void say(const std::string &line);
std::map<std::string, Track> tracks_;
std::vector<std::string> log_;
bool enabled_ = true;
};
// The row text, or "" for View::Kind::none. Short and plain.
std::string auto_retry_text(const DownloadAutoRetry::View &view);
// The shared, locked copy (Store worker writes, UI thread reads). Time is the
// steady clock. Log lines go to stdout (the title log) unless a sink is set.
class DownloadReconnect
{
public:
void set_enabled(bool on);
bool enabled() const;
std::vector<std::string> observe(const std::vector<RetryJobSnapshot> &jobs);
void sent(const std::string &id, bool ok, const std::string &error);
void stop(const std::string &id);
DownloadAutoRetry::View view(const std::string &id) const;
void set_log(std::function<void(const std::string &)> sink);
private:
void flush();
mutable std::mutex mutex_;
DownloadAutoRetry model_;
std::function<void(const std::string &)> sink_;
};
DownloadReconnect &download_reconnect();
} // namespace orbit
+34
View File
@@ -0,0 +1,34 @@
// Wings TV app - The download folder: the one-time default and the first-download confirm.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/download_folder.hpp"
namespace orbit
{
FolderNeed folder_need(bool have_settings, const std::string &download_root,
bool already_defaulted)
{
FolderNeed need;
if (!have_settings)
{
need.root_unknown = true;
return need;
}
need.set_default = download_root.empty() && !already_defaulted;
return need;
}
bool needs_first_confirm(bool folder_confirmed, bool have_settings)
{
return have_settings && !folder_confirmed;
}
FirstDownloadGate::Verdict FirstDownloadGate::check(bool folder_confirmed, bool have_settings) const
{
if (answered_ || !needs_first_confirm(folder_confirmed, have_settings))
return Verdict::proceed;
return Verdict::ask;
}
} // namespace orbit
+80
View File
@@ -0,0 +1,80 @@
// Wings TV app - The download folder: the one-time default and the first-download confirm.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Pure logic, no drawing. Two jobs (docs B-13):
// 1. When Pegasus reports an EMPTY downloadRoot, Wings sets a default folder
// once (Store::ensure_default_download_folder). A non-empty root is never touched.
// 2. The first download the player tries asks them to confirm or change the
// folder; the answer is remembered (WingsLocalPrefs::folder_confirmed) so it
// is asked once. FirstDownloadGate is the check every download entry point calls.
#pragma once
#include <functional>
#include <string>
#include <utility>
namespace orbit
{
namespace download_folder
{
// The folder Wings sets when Pegasus has none.
inline constexpr const char *default_path = "/data/pegasus-dl/downloads";
} // namespace download_folder
struct FolderNeed
{
bool set_default = false; // create the default folder and make it the root
bool root_unknown = false; // settings not read yet: decide nothing
};
// have_settings: Pegasus' settings were read. download_root: what it reported.
// already_defaulted: this run already tried (success or failure; never retried).
FolderNeed folder_need(bool have_settings, const std::string &download_root,
bool already_defaulted);
// First-download gate. Frame thread only.
class FirstDownloadGate
{
public:
enum class Verdict
{
proceed, // start the download now
ask, // show the "Download folder" confirm first, then resume
};
// Unknown settings never block: the root is not known, there is nothing to
// confirm yet, and an offline Pegasus fails the download by itself.
Verdict check(bool folder_confirmed, bool have_settings) const;
// The player answered (kept the folder or picked another): never ask again
// this run, even before the persisted flag is saved. The caller also sets
// WingsLocalPrefs::folder_confirmed = true and saves.
void answered()
{
answered_ = true;
}
// Convenience: remember what to do once the confirm closes (the download
// that was interrupted). take_pending() returns it (empty if none).
void defer(std::function<void()> resume)
{
pending_ = std::move(resume);
}
std::function<void()> take_pending()
{
std::function<void()> out = std::move(pending_);
pending_ = nullptr;
return out;
}
private:
bool answered_ = false;
std::function<void()> pending_;
};
// Free function form of the same rule (used by the gate and by tests).
bool needs_first_confirm(bool folder_confirmed, bool have_settings);
} // namespace orbit
+66 -25
View File
@@ -9,6 +9,8 @@
#include "orbit/screens.hpp"
#include "orbit/archive_passwords.hpp"
#include "orbit/busy_cancel.hpp"
#include "orbit/download_auto_retry.hpp"
#include "orbit/install_hint.hpp"
#include "orbit/model.hpp"
@@ -32,7 +34,7 @@ void DownloadsScreen::focus()
bool DownloadsScreen::modal() const
{
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || probing_;
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || folder_ask_.is_open() || probing_;
}
void DownloadsScreen::rebuild()
@@ -77,6 +79,12 @@ AutoPasswords &auto_passwords(std::shared_ptr<void> &slot)
return *static_cast<AutoPasswords *>(slot.get());
}
// status_label() plus "retrying", the state Wings adds while it reconnects a dropped download.
const char *row_status_label(const std::string &status)
{
return status == "retrying" ? tr("Reconnecting") : status_label(status);
}
PasswordTrials::Seen seen_of(const Job &job)
{
if (job.status == "waitingPassword")
@@ -111,9 +119,14 @@ void DownloadsScreen::open_menu(const Job &job)
add("resume", tr("Resume"), tr("Carry on from where it stopped"));
if (job.status == "failed" || job.status == "cancelled")
add("retry", tr("Retry"), tr("Put it back in the queue"));
if (job.status == "retrying")
{
add("retry", tr("Retry now"), tr("Reconnect without waiting"));
add("stop_retry", tr("Stop reconnecting"), tr("Leave it failed"));
}
if (job.status == "downloading" || job.status == "starting" || job.status == "queued")
add("pause", tr("Pause"), tr("Stop for now, keep what is downloaded"));
if (job_active(job))
if (job_active(job) && job.status != "retrying")
add("cancel", tr("Cancel download"), tr("Stop it and take it out of the queue"));
if (job_finished(job))
add("clear", tr("Clear finished downloads"), tr("Remove completed, failed and cancelled entries"));
@@ -124,7 +137,7 @@ void DownloadsScreen::open_menu(const Job &job)
if (items.empty())
return;
list_for_ = ListFor::menu;
list_.open(job.name, status_label(job.status), std::move(items), ListDialog::Mode::single);
list_.open(job.name, row_status_label(job.status), std::move(items), ListDialog::Mode::single);
}
void DownloadsScreen::run_menu(const std::string &code, const Job &job, Feedback &feedback)
@@ -133,6 +146,10 @@ void DownloadsScreen::run_menu(const std::string &code, const Job &job, Feedback
{
ctx_.store.job_action(job.id, code);
}
else if (code == "stop_retry")
{
download_reconnect().stop(job.id);
}
else if (code == "cancel")
{
pending_ = Pending::cancel_job;
@@ -192,6 +209,19 @@ Intent DownloadsScreen::update(const InputFrame &input, float dt, Feedback &feed
const int count = static_cast<int>(order_.size());
index_ = std::clamp(index_, 0, std::max(0, count - 1));
// The first-download folder question takes the whole input while it is open.
if (folder_ask_.is_open())
{
folder_ask_.update(ctx_, input, dt, feedback);
return intent;
}
// "Checking the link..." goes back at once on Circle; the late answer is dropped by the Store.
if (ctx_.store.probe_action().busy && input.is_pressed(Action::back))
{
ctx_.store.cancel(Store::lane_probe);
return intent;
}
const bool had_prompt = prompt_.is_open();
const bool had_confirm = confirm_.is_open();
const bool had_list = list_.is_open();
@@ -318,10 +348,16 @@ Intent DownloadsScreen::update(const InputFrame &input, float dt, Feedback &feed
}
else if (pending_ == Pending::direct_link)
{
ctx_.store.queue_direct(state.probe, "");
probing_ = true; // wait for the queue reply
seen_probe_action_ = ctx_.store.probe_action().serial;
seen_probe_ = state.probe_serial;
const auto queue = [this]
{
const Snapshot &now = ctx_.store.state();
ctx_.store.queue_direct(now.probe, "");
probing_ = true; // wait for the queue reply
seen_probe_action_ = ctx_.store.probe_action().serial;
seen_probe_ = now.probe_serial;
};
if (!folder_ask_.guard(ctx_, queue))
queue();
}
}
if (confirmed != ConfirmDialog::Result::none)
@@ -534,9 +570,12 @@ void DownloadsScreen::draw(Frame &frame, bool focused) const
const float title_w = std::min(type.medium.measure(job->name, 26.0f), 780.0f);
ui::text_fit(list, type.medium, job->name, r.x + 30.0f, r.y + 42.0f, 26.0f, title_w + 1.0f,
ui::color::text);
// A .pkg Wings installs itself shows its install stage instead of "Completed" (pkg_install.hpp).
const char *install_chip = install_label(job->install_state);
const bool bad = job->status == "failed" || job->status == "waitingPassword" ||
job->status == "waitingChoice";
ui::chip(list, type, r.x + 30.0f + title_w + 16.0f, r.y + 33.0f, status_label(job->status),
job->status == "waitingChoice" || job->install_state == "install_failed";
ui::chip(list, type, r.x + 30.0f + title_w + 16.0f, r.y + 33.0f,
install_chip != nullptr ? install_chip : row_status_label(job->status),
bad ? ui::color::action
: hui::gfx::mix(ui::Color::rgb(0xffffff, 0.16f), ui::Color::rgb(0x000000, 0.42f), f),
bad ? ui::color::night1 : hui::gfx::mix(ui::color::text2, ui::color::text, f), 16.0f);
@@ -555,6 +594,19 @@ void DownloadsScreen::draw(Frame &frame, bool focused) const
{
add(job->error.empty() ? std::string(tr("The download failed")) : job->error);
}
else if (!job->install_text.empty())
{
// "Installing...", "Installed - find it on your home screen", "Install failed: ..."
add(job->install_text);
}
else if (job->status == "retrying")
{
// Wings retries a dropped connection by itself (download_auto_retry.hpp).
const std::string again = auto_retry_text(download_reconnect().view(job->id));
add(!again.empty() ? again : job->error);
if (job->total > 0.0)
add(format_bytes(job->received) + " / " + format_bytes(job->total));
}
else if (job->status == "waitingPassword")
{
if (const int n = auto_passwords(auto_passwords_state_).trials.trying_number(job->id); n > 0)
@@ -580,26 +632,14 @@ void DownloadsScreen::draw(Frame &frame, bool focused) const
if (job->attempts > 1 && job->max_attempts > 0)
add(tr("Attempt {n} of {max}", {{"n", std::to_string(job->attempts)},
{"max", std::to_string(job->max_attempts)}}));
// "Reconnecting (try 2 of 3)" until new bytes arrive, then "Reconnected" briefly.
add(auto_retry_text(download_reconnect().view(job->id)));
}
// A failed install-selection keeps Pegasus' text and gets one plain line under it.
const std::string install_hint =
job->status == "failed" ? install_failure_hint(job->error) : std::string();
const std::string tip = failed_download_tip(job->status, state.settings.connections);
const ui::Color detail_color = hui::gfx::mix(ui::color::text3, ui::color::text, f);
if (!tip.empty())
{
// Pegasus' error, then the install hint when it matched, then the tip.
const bool both = !install_hint.empty();
const ui::Color soft = hui::gfx::mix(ui::color::text2, ui::color::text, f);
ui::text_fit(list, type.regular, detail, r.x + 30.0f, r.y + (both ? 62.0f : 68.0f), 18.0f,
r.w - right_w - 80.0f, detail_color);
if (both)
ui::text_fit(list, type.regular, install_hint, r.x + 30.0f, r.y + 80.0f, 18.0f,
r.w - 60.0f, soft);
ui::text_fit(list, type.regular, tip, r.x + 30.0f, r.y + (both ? 98.0f : 92.0f), 18.0f,
r.w - 60.0f, soft);
}
else if (install_hint.empty())
if (install_hint.empty())
{
ui::text_fit(list, type.regular, detail, r.x + 30.0f, r.y + 76.0f, 20.0f,
r.w - right_w - 80.0f, detail_color);
@@ -645,11 +685,12 @@ void DownloadsScreen::draw(Frame &frame, bool focused) const
list.pop_clip();
}
if (probing_)
draw_busy(ctx_, frame.overlay, tr("Checking the link\xE2\x80\xA6"));
draw_busy(ctx_, frame.overlay, with_stop_hint(tr("Checking the link\xE2\x80\xA6")).c_str());
banner_draw(frame, focused);
prompt_.draw(ctx_, frame);
confirm_.draw(ctx_, frame);
list_.draw(ctx_, frame);
folder_ask_.draw(ctx_, frame);
}
std::vector<hui::ui::Hint> DownloadsScreen::hints() const
+405
View File
@@ -0,0 +1,405 @@
// Wings TV app - Editorial data: see editorial.hpp.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/editorial.hpp"
#include "core/save_file.hpp"
#include "orbit/i18n.hpp"
#include "orbit/json.hpp"
#include <algorithm>
#include <cstdlib>
namespace orbit
{
namespace
{
// Version check shared by both files; same wording as the popularity data.
bool check_header(std::string_view text, std::size_t max_bytes, json::Value *root, std::string *error)
{
const auto fail = [&](const std::string &why)
{
if (error != nullptr)
*error = why;
return false;
};
if (text.size() > max_bytes)
return fail("the file is too large");
std::string why;
if (!json::parse(text, root, &why) || !root->is_object())
return fail("the file is not valid JSON");
if (!(*root)["schemaVersion"].is_number())
return fail("unsupported data version");
if ((*root)["schemaVersion"].as_int(0) > 1)
return fail(tr("this data needs a newer Wings (Settings > Wings > Updates)"));
if ((*root)["schemaVersion"].as_int(0) != 1)
return fail("unsupported data version");
return true;
}
// At least one character, no control characters (the length is not limited; the page scales the type).
bool valid_note(const std::string &text)
{
std::size_t chars = 0;
for (const char c : text)
{
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || u == 0x7f)
return false;
if ((u & 0xC0) != 0x80)
++chars;
}
return chars >= 1;
}
enum class Part
{
ok,
absent, // HTTP 404: the file is not published
failed
};
Part fetch_part(DocFetcher &fetcher, const std::string &url, std::size_t max_bytes, bool is_notes, std::string *body,
std::string *problem)
{
if (!valid_popularity_url(url))
{
*problem = "the source URL must start with https://";
return Part::failed;
}
const DocResult doc = fetcher.get(url, max_bytes);
if (doc.status == 404 && doc.error.empty())
return Part::absent;
if (!doc.error.empty() || doc.status == 0)
{
*problem = doc.error.empty() ? std::string("no answer") : doc.error;
return Part::failed;
}
if (doc.status != 200)
{
*problem = "HTTP " + std::to_string(doc.status);
return Part::failed;
}
EditorialData probe;
const bool good = is_notes ? EditorialData::parse_notes(doc.body, &probe, problem)
: EditorialData::parse_curated(doc.body, &probe, problem);
if (!good)
return Part::failed;
*body = doc.body;
return Part::ok;
}
} // namespace
// ---- the data ----
bool valid_editorial_id(std::string_view id)
{
if (id.size() != 9 || (id.substr(0, 4) != "PPSA" && id.substr(0, 4) != "CUSA"))
return false;
for (std::size_t i = 4; i < 9; ++i)
if (id[i] < '0' || id[i] > '9')
return false;
return true;
}
bool EditorialData::parse_notes(std::string_view text, EditorialData *into, std::string *error)
{
json::Value root;
if (!check_header(text, kEditorialNotesMaxBytes, &root, error))
return false;
const json::Value &notes = root["notes"];
if (!notes.is_object())
{
if (error != nullptr)
*error = "the file has no notes";
return false;
}
std::map<std::string, std::string> kept;
for (const std::string &id : notes.keys())
{
if (kept.size() >= kEditorialMaxNotes)
break;
const json::Value &v = notes[id];
if (!valid_editorial_id(id) || !v.is_string() || !valid_note(v.as_string()))
continue;
kept[id] = v.as_string();
}
into->notes_ = std::move(kept);
return true;
}
bool EditorialData::parse_curated(std::string_view text, EditorialData *into, std::string *error)
{
json::Value root;
if (!check_header(text, kEditorialCuratedMaxBytes, &root, error))
return false;
const json::Value &items = root["items"];
if (!items.is_array())
{
if (error != nullptr)
*error = "the file has no items";
return false;
}
std::vector<std::string> kept;
for (const json::Value &item : items.items())
{
if (kept.size() >= kEditorialMaxCurated)
break;
if (!item.is_string() || !valid_editorial_id(item.as_string()))
continue;
bool seen = false;
for (const std::string &id : kept)
seen = seen || id == item.as_string();
if (!seen)
kept.push_back(item.as_string());
}
into->curated_ = std::move(kept);
return true;
}
std::string EditorialData::note(const std::vector<std::string> &title_ids) const
{
for (const std::string &id : title_ids)
{
const auto it = notes_.find(id);
if (it != notes_.end())
return it->second;
}
return {};
}
// ---- the hub ----
void EditorialHub::set_enabled(bool notes, bool carousel)
{
Lock lock(mutex_);
if (notes_ != notes || carousel_ != carousel)
{
notes_ = notes;
carousel_ = carousel;
++serial_;
}
}
bool EditorialHub::notes_enabled() const
{
Lock lock(mutex_);
return notes_;
}
bool EditorialHub::carousel_enabled() const
{
Lock lock(mutex_);
return carousel_;
}
void EditorialHub::set_data(std::shared_ptr<const EditorialData> data)
{
Lock lock(mutex_);
data_ = std::move(data);
++serial_;
}
std::shared_ptr<const EditorialData> EditorialHub::data() const
{
Lock lock(mutex_);
return data_;
}
std::string EditorialHub::note(const std::vector<std::string> &title_ids) const
{
std::shared_ptr<const EditorialData> data;
{
Lock lock(mutex_);
if (notes_)
data = data_;
}
return data ? data->note(title_ids) : std::string();
}
std::vector<std::string> EditorialHub::curated_ids() const
{
std::shared_ptr<const EditorialData> data;
{
Lock lock(mutex_);
if (carousel_)
data = data_;
}
return data ? data->curated_ids() : std::vector<std::string>();
}
void EditorialHub::set_status(const EditorialStatus &status)
{
Lock lock(mutex_);
status_ = status;
++serial_;
}
EditorialStatus EditorialHub::status() const
{
Lock lock(mutex_);
return status_;
}
std::uint64_t EditorialHub::serial() const
{
Lock lock(mutex_);
return serial_;
}
EditorialHub &editorial_hub()
{
static EditorialHub hub;
return hub;
}
// ---- the service ----
EditorialService::EditorialService(EditorialHub &hub, DocFetcher &fetcher, EditorialServiceConfig config)
: hub_(hub), fetcher_(fetcher), config_(std::move(config))
{
if (!config_.now)
config_.now = [] { return static_cast<std::int64_t>(unix_seconds()); };
}
EditorialService::~EditorialService()
{
thread_.join();
}
void EditorialService::apply(bool notes, bool carousel)
{
hub_.set_enabled(notes, carousel);
if ((!notes && !carousel) || started_)
return;
started_ = true;
EditorialStatus status = hub_.status();
status.busy = true;
hub_.set_status(status);
if (!thread_.start([this] { run(); }, 256u << 10))
{
status.busy = false;
status.message = "Could not start the update";
hub_.set_status(status);
}
}
void EditorialService::wait_idle()
{
thread_.join();
}
void EditorialService::run()
{
EditorialStatus status;
status.busy = true;
std::string notes_body; // the raw files in use; "" = none
std::string curated_body;
bool cache_ok = false;
bool cache_current = false;
std::int64_t fetched = 0;
const std::size_t cap = kEditorialNotesMaxBytes + kEditorialCuratedMaxBytes + 4096;
// 1. The saved copy: "fetched=<unix>\napp=<build tag>\nnotes=<bytes>\ncurated=<bytes>\n<notes><curated>".
for (const std::string &path : config_.cache_paths)
{
std::string data;
if (!hui::save::read_file(path, &data, cap + 4096))
continue;
const hui::save::Decoded d = hui::save::decode(hui::save::Kind::stats, data);
if (!d.ok || d.version != 1)
continue;
const std::string &p = d.payload;
std::size_t at = 0;
std::string tag;
long long t = 0, nlen = -1, clen = -1;
bool header_ok = true;
for (int line = 0; line < 4 && header_ok; ++line)
{
const std::size_t nl = p.find('\n', at);
if (nl == std::string::npos)
{
header_ok = false;
break;
}
const std::string row = p.substr(at, nl - at);
at = nl + 1;
if (line == 0 && row.compare(0, 8, "fetched=") == 0)
t = std::atoll(row.c_str() + 8);
else if (line == 1 && row.compare(0, 4, "app=") == 0)
tag = row.substr(4);
else if (line == 2 && row.compare(0, 6, "notes=") == 0)
nlen = std::atoll(row.c_str() + 6);
else if (line == 3 && row.compare(0, 8, "curated=") == 0)
clen = std::atoll(row.c_str() + 8);
else
header_ok = false;
}
if (!header_ok || nlen < 0 || clen < 0 || static_cast<std::size_t>(nlen + clen) != p.size() - at)
continue;
notes_body = p.substr(at, static_cast<std::size_t>(nlen));
curated_body = p.substr(at + static_cast<std::size_t>(nlen));
fetched = t;
cache_ok = true;
cache_current = tag == config_.app_tag;
break;
}
// Parses what is held and hands it to the hub; a half that no longer parses is dropped.
const auto publish = [&]()
{
auto data = std::make_shared<EditorialData>();
std::string why;
if (!notes_body.empty() && !EditorialData::parse_notes(notes_body, data.get(), &why))
notes_body.clear();
if (!curated_body.empty() && !EditorialData::parse_curated(curated_body, data.get(), &why))
curated_body.clear();
hub_.set_data(data);
};
publish();
const std::int64_t now = config_.now();
if (cache_ok)
{
status.updated = fetched;
status.message = "Using the saved copy";
}
// A file that was not there is looked for again soon: it may simply not have been published yet.
const int ttl = (notes_body.empty() || curated_body.empty()) ? std::min(config_.ttl_s, 600) : config_.ttl_s;
if (cache_ok && cache_current && fetched <= now && now - fetched < ttl)
{
status.busy = false;
hub_.set_status(status);
return;
}
hub_.set_status(status);
// 2. The fetch of both files. A failed file keeps its saved copy; a 404 means that file is not published.
std::string problem;
const Part notes = fetch_part(fetcher_, config_.notes_url, kEditorialNotesMaxBytes, true, &notes_body, &problem);
if (notes == Part::absent)
notes_body.clear();
std::string curated_problem;
const Part curated =
fetch_part(fetcher_, config_.curated_url, kEditorialCuratedMaxBytes, false, &curated_body, &curated_problem);
if (curated == Part::absent)
curated_body.clear();
if (problem.empty())
problem = curated_problem;
if (notes != Part::failed || curated != Part::failed)
{
publish();
write_everywhere(config_.cache_paths,
hui::save::encode(hui::save::Kind::stats, 1,
"fetched=" + std::to_string(now) + "\napp=" + config_.app_tag +
"\nnotes=" + std::to_string(notes_body.size()) +
"\ncurated=" + std::to_string(curated_body.size()) + "\n" + notes_body +
curated_body),
nullptr);
status.updated = now;
status.message = problem.empty() ? "Updated" : "Updated in part: " + problem;
}
else
status.message = "Could not refresh: " + problem + (cache_ok ? ". Using the saved copy" : "");
status.busy = false;
hub_.set_status(status);
}
} // namespace orbit
+131
View File
@@ -0,0 +1,131 @@
// Wings TV app - Editorial data: short per-game notes and the curated store carousel.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Two small JSON files Wings fetches over https from the project site, like the popularity data:
// notes.v1.json {schemaVersion:1, notes:{titleId:"text, any length"}} (<= 600 notes, <= 64000 bytes)
// curated.v1.json {schemaVersion:1, items:[titleId, ...]} (ordered, <= 24 ids)
// Both are switched by Wings preferences (notes_enabled, carousel_enabled). EditorialService loads the saved copy and
// fetches both in one background job through a DocFetcher; a failure keeps the saved copy and a missing file (404)
// just means "none". Everything here is platform neutral and tested on the host.
#pragma once
#include "orbit/popularity.hpp"
#include "orbit/thread.hpp"
#include <cstdint>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
constexpr std::size_t kEditorialNotesMaxBytes = 64000;
constexpr std::size_t kEditorialCuratedMaxBytes = 4096;
constexpr std::size_t kEditorialMaxNotes = 600;
constexpr std::size_t kEditorialMaxCurated = 24;
constexpr const char *kEditorialNotesUrl = "https://larazadelsol.github.io/wings-app/notes.v1.json";
constexpr const char *kEditorialCuratedUrl = "https://larazadelsol.github.io/wings-app/curated.v1.json";
// "PPSA01234" / "CUSA01234": the only title id shape the files may use.
bool valid_editorial_id(std::string_view id);
class EditorialData
{
public:
// Each fills its own half of `*into` (the other half is left as it is). Oversize text, bad JSON and a
// missing/newer schemaVersion are errors; single bad entries are skipped. An empty result is fine.
static bool parse_notes(std::string_view text, EditorialData *into, std::string *error);
static bool parse_curated(std::string_view text, EditorialData *into, std::string *error);
// The note of the first of the game's title ids that has one; "" none.
std::string note(const std::vector<std::string> &title_ids) const;
// The carousel's title ids in the file's order (deduplicated, valid only).
const std::vector<std::string> &curated_ids() const { return curated_; }
std::size_t note_count() const { return notes_.size(); }
void clear_notes() { notes_.clear(); }
void clear_curated() { curated_.clear(); }
private:
std::map<std::string, std::string> notes_;
std::vector<std::string> curated_;
};
struct EditorialStatus
{
std::string message; // "Updated", "Using the saved copy", "Could not refresh: ..." (never a popup)
std::int64_t updated = 0;
bool busy = false;
};
// Shared by the worker (data, status) and the frame thread (everything else).
class EditorialHub
{
public:
void set_enabled(bool notes, bool carousel);
bool notes_enabled() const;
bool carousel_enabled() const;
void set_data(std::shared_ptr<const EditorialData> data); // null: forget
std::shared_ptr<const EditorialData> data() const; // regardless of the switches
// The game's note while notes are on; "" otherwise.
std::string note(const std::vector<std::string> &title_ids) const;
// The carousel's ids while the carousel is on; empty otherwise.
std::vector<std::string> curated_ids() const;
void set_status(const EditorialStatus &status);
EditorialStatus status() const;
std::uint64_t serial() const; // changes with the data and the switches
// Settings and startup call this; the app wires the hook to its EditorialService (none in the preview and in
// tests: then only the switches change).
void apply_prefs(bool notes, bool carousel)
{
set_enabled(notes, carousel);
if (apply_hook)
apply_hook(notes, carousel);
}
std::function<void(bool, bool)> apply_hook;
private:
mutable Mutex mutex_;
bool notes_ = false;
bool carousel_ = false;
std::shared_ptr<const EditorialData> data_;
EditorialStatus status_;
std::uint64_t serial_ = 0;
};
EditorialHub &editorial_hub();
struct EditorialServiceConfig
{
std::vector<std::string> cache_paths; // first readable wins; both writable ones are written
std::string notes_url = kEditorialNotesUrl;
std::string curated_url = kEditorialCuratedUrl;
int ttl_s = 24 * 3600;
std::string app_tag; // as PopularityServiceConfig::app_tag
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
};
// Runs the cache load and the fetch of both files on one background thread.
class EditorialService
{
public:
EditorialService(EditorialHub &hub, DocFetcher &fetcher, EditorialServiceConfig config);
~EditorialService();
// Frame thread. Sets the hub's switches; the first time either is on, loads the saved copy and fetches when it
// is missing, from another build or older than the TTL.
void apply(bool notes, bool carousel);
void wait_idle(); // tests
private:
void run();
EditorialHub &hub_;
DocFetcher &fetcher_;
EditorialServiceConfig config_;
Thread thread_;
bool started_ = false;
};
} // namespace orbit
+128 -6
View File
@@ -74,6 +74,11 @@ bool valid_hero_url(std::string_view url)
return url.size() <= kHeroUrlMax && valid_popularity_url(url);
}
namespace
{
const char *const kLowTail = "#low";
}
bool HeroUrls::set(const std::string &art_key, const std::string &url)
{
if (art_key.empty() || art_key.size() > 64 || !valid_hero_url(url))
@@ -85,6 +90,23 @@ bool HeroUrls::set(const std::string &art_key, const std::string &url)
return true;
}
bool HeroUrls::set_low(const std::string &art_key, const std::string &url)
{
if (art_key.empty() || art_key.size() > 64 || !valid_hero_url(url))
return false;
const std::string key = art_key + kLowTail;
Lock lock(mutex_);
if (urls_.size() >= 8 && urls_.find(key) == urls_.end())
urls_.clear();
urls_[key] = url;
return true;
}
std::string HeroUrls::find_low(std::string_view art_key) const
{
return find(std::string(art_key) + kLowTail);
}
std::string HeroUrls::find(std::string_view art_key) const
{
Lock lock(mutex_);
@@ -110,6 +132,31 @@ HeroUrls &hero_urls()
return urls;
}
HeroPlacement place_hero(int width, int height, const ui::Rect &band)
{
HeroPlacement p;
if (width <= 0 || height <= 0 || band.w <= 0.0f || band.h <= 0.0f)
return p;
const float a = static_cast<float>(width) / static_cast<float>(height);
if (a < kHeroAspectMin || a > kHeroAspectMax)
return p;
p.usable = true;
p.dest = band;
// Fill the band, cropping what overflows (a bit more from the bottom than the top, where the faces are).
const float box = band.w / band.h;
if (a > box)
{
p.uv.w = box / a;
p.uv.x = (1.0f - p.uv.w) * 0.5f;
}
else
{
p.uv.h = a / box;
p.uv.y = (1.0f - p.uv.h) * 0.3f;
}
return p;
}
// ---- cards ----
std::vector<HeaderCard> build_header_cards(const HeaderFacts &f)
@@ -263,12 +310,36 @@ void GameHeaderState::reset()
for (float &f : focus)
f = 0.0f;
hero_url.clear();
hero_low_url.clear();
hero_key.clear();
hero_resolved = false;
hero_cached = false;
open_s = 0.0f;
hero_logged = false;
more.clear();
}
bool hold_cover_wash(bool has_hero_address, bool cached, float since_open_s, bool hero_refused)
{
return has_hero_address && cached && !hero_refused && since_open_s < kHeroWashHoldS;
}
HeroHooks &hero_hooks()
{
static HeroHooks hooks;
return hooks;
}
bool hero_is_cached(const std::string &url)
{
const HeroHooks &h = hero_hooks();
return !url.empty() && h.cached && h.cached(url);
}
void GameHeaderState::update(float dt, bool focused)
{
if (open_s < 3600.0f)
open_s += dt;
slide.target = on_header ? 0.0f : 1.0f;
slide.update(dt, 10.0f);
for (int i = 0; i < kHeaderButtons; ++i)
@@ -372,16 +443,67 @@ void draw_game_header(Context &ctx, hui::gfx::DrawList &list, const HeaderView &
const ui::Rect band{0.0f, 0.0f, kPageW, hero::kScreenH};
list.gradient_rect(band, 0.0f, indigo, indigo_deep);
float hero_alpha = 0.0f;
bool hero_refused = false;
if (!view.hero_key.empty())
{
if (const Artwork *art = ctx.art.get(view.hero_key, ArtKind::hero))
// The full picture is asked for first and the small one after it: the worker serves the newest
// request first, so the small one arrives first. Once the full one is there the small one is
// not asked for any more. A picture of an unusable shape (place_hero) counts as not there.
const Artwork *full = view.hero_full ? ctx.art.get(view.hero_key, ArtKind::hero) : nullptr;
const HeroPlacement full_at =
full != nullptr ? place_hero(full->width, full->height, band) : HeroPlacement{};
const float full_alpha = full_at.usable ? fade_in(*full) : 0.0f;
hero_refused = full != nullptr && !full_at.usable;
// The small one: until the full one is fully faded in (it stays when the full one is refused).
if (view.hero_low && full_alpha < 1.0f)
{
hero_alpha = fade_in(*art);
ui::image_cover(list, art->texture, art->width, art->height, band,
ui::Color::rgb(0xffffff, hero_alpha), 0.0f, 0.5f, 0.3f);
if (const Artwork *low = ctx.art.get(view.hero_key, ArtKind::hero_low))
{
const HeroPlacement at = place_hero(low->width, low->height, band);
if (at.usable)
{
hero_alpha = fade_in(*low);
list.image(low->texture, at.dest, at.uv, ui::Color::rgb(0xffffff, hero_alpha), 0.0f);
}
}
}
if (full_at.usable)
{
hero_alpha = std::max(hero_alpha, full_alpha);
list.image(full->texture, full_at.dest, full_at.uv, ui::Color::rgb(0xffffff, full_alpha), 0.0f);
}
}
if (hero_alpha < 1.0f && !view.page->art_key.empty())
// Once per page open: how long until the first frame with the picture on screen (or why none).
if (!state.hero_logged)
{
const HeroHooks &hooks = hero_hooks();
char msg[96];
bool say = false;
if (hero_alpha > 0.0f)
{
std::snprintf(msg, sizeof msg, "[WINGS-ART] hero first frame %d ms after page open%s",
static_cast<int>(state.open_s * 1000.0f), state.hero_cached ? " (cached)" : "");
say = true;
}
else if (hero_refused || (state.hero_resolved && view.hero_key.empty()))
{
std::snprintf(msg, sizeof msg, "[WINGS-ART] hero: no hero%s", hero_refused ? " (refused shape)" : "");
say = true;
}
else if (state.open_s > 8.0f)
{
std::snprintf(msg, sizeof msg, "[WINGS-ART] hero: none drawn after 8000 ms");
say = true;
}
if (say)
{
state.hero_logged = true;
if (hooks.log)
hooks.log(msg);
}
}
const bool hold_wash = hold_cover_wash(view.hero_full, state.hero_cached, state.open_s, hero_refused);
if (hero_alpha < 1.0f && !view.page->art_key.empty() && !hold_wash)
{
// No hero (or not yet): the cover, scaled up, as a blurred wash.
if (const Artwork *cover = ctx.art.get(view.page->art_key, ArtKind::cover))
@@ -394,7 +516,7 @@ void draw_game_header(Context &ctx, hui::gfx::DrawList &list, const HeaderView &
ui::Color::rgb(0xffffff, 0.30f * a), 0.0f, 0.5f, 0.3f);
}
}
// The Store hue veil (docs/DESIGN-colour.md layer 4), then the dark left-to-right veil under the
// The Store hue veil (colour layer 4), then the dark left-to-right veil under the
// words, the top veil under the app bar and the fade into the page where the list starts.
list.rounded_rect(band, 0.0f, blue_wash);
const ui::Color left_clear = ui::Color::rgb(0x03061a, 0.0f);
+50 -1
View File
@@ -21,6 +21,7 @@
#include "orbit/ui.hpp"
#include <cstdint>
#include <functional>
#include <map>
#include <string>
#include <string_view>
@@ -45,7 +46,10 @@ class HeroUrls
public:
// False (and nothing stored) for an invalid URL or an empty key.
bool set(const std::string &art_key, const std::string &url);
// The small stand-in shown while the full one loads (same rules; stored beside it).
bool set_low(const std::string &art_key, const std::string &url);
std::string find(std::string_view art_key) const;
std::string find_low(std::string_view art_key) const;
void clear();
std::size_t size() const;
@@ -55,6 +59,44 @@ class HeroUrls
};
HeroUrls &hero_urls();
// ---- hero placement ----
// Where an image of width x height goes in the band, by its shape (a = width / height). The art is only
// ever scaled uniformly (its proportions never change) until it covers the whole band, and the overflow is
// cropped: nothing is stretched and the band is always full. A very wide strip would have to zoom so far
// that a few letters fill the screen, and a square logo would show almost nothing of itself, so those are
// not used and the page keeps the cover wash:
// 1.5 .. 3.3 used (Steam's 3.1:1 art loses its sides),
// anything else is not used.
constexpr float kHeroAspectMin = 1.5f;
constexpr float kHeroAspectMax = 3.3f;
struct HeroPlacement
{
bool usable = false;
ui::Rect dest; // inside the band
ui::Rect uv{0.0f, 0.0f, 1.0f, 1.0f};
};
HeroPlacement place_hero(int width, int height, const ui::Rect &band);
// ---- holding the cover wash back for a cached hero (B-14 step 1) ----
constexpr float kHeroWashHoldS = 0.3f;
// True while the blurred cover wash should NOT be drawn (only the dark gradient), because the
// 16:9 picture is on disk and is about to fade in. False when there is no hero address, the file
// is not cached, the picture was refused (unusable shape), or the hold (300 ms from page open) ran out.
bool hold_cover_wash(bool has_hero_address, bool cached, float since_open_s, bool hero_refused);
// Hooks the app wires once (main.cpp); both optional. cached answers "is this URL on disk";
// log writes one line to the title log.
struct HeroHooks
{
std::function<bool(const std::string &url)> cached;
std::function<void(const std::string &line)> log;
};
HeroHooks &hero_hooks();
// The cached fact for a url ("" or no hook: false). Call where hero_url is set.
bool hero_is_cached(const std::string &url);
// ---- the info cards ----
enum class CardTone : std::uint8_t
@@ -166,7 +208,12 @@ struct GameHeaderState
ui::Ease slide; // 0: header shown, 1: slid up and the list is at the top
float focus[kHeaderButtons] = {0.0f, 0.0f, 0.0f};
std::string hero_url; // registered for this page ("" none)
std::string hero_low_url; // the small stand-in registered for this page ("" none)
std::string hero_key; // the art key the hero pictures were registered under ("" none; art_sources.hpp)
bool hero_resolved = false; // the popularity index has been asked for this page
bool hero_cached = false; // the hero_url's file is already on disk (hero_is_cached, set with hero_url)
float open_s = 0.0f; // seconds since the page opened (update)
mutable bool hero_logged = false; // the once-per-open "first hero frame" line has been written
std::vector<HeaderMore> more; // the open "more" menu's rows
void reset(); // a page opens
@@ -186,7 +233,9 @@ struct HeaderView
bool failed = false;
std::string status; // "Loading the links...", or the error
std::string meta_line; // "22 links · 5 releases · 2 sources"
std::string hero_key; // art key to ask for ArtKind::hero ("" none registered)
std::string hero_key; // art key to ask for ArtKind::hero / hero_low ("" none registered)
bool hero_full = false; // a full hero is registered
bool hero_low = false; // a small one is registered
};
// The measured layout (the title's width decides one or two lines).
+8
View File
@@ -81,6 +81,14 @@ Section section_of(LinkKind kind)
return Section::other;
}
int Mirror::flagged_link(const GamePage &page) const
{
for (const int l : links)
if (l >= 0 && l < static_cast<int>(page.links.size()) && page.links[static_cast<std::size_t>(l)].flag_reason != 0)
return l;
return -1;
}
std::string Mirror::parts_text() const
{
if (!multipart())
+8
View File
@@ -14,6 +14,7 @@
#include "orbit/link_sets.hpp"
#include "orbit/model.hpp"
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
@@ -68,8 +69,13 @@ struct GameLink
// shown once, every copy kept.
bool duplicate = false; // another link shows this URL; this one is hidden
std::vector<LinkRef> copies; // on the shown link: the hidden copies
// Bad-link flag on this console (link_flags.hpp apply_link_flags()); 0 = none.
std::uint8_t flag_reason = 0; // FlagReason
std::int64_t flag_when = 0;
};
struct GamePage;
struct Mirror
{
int release = 0; // index into GamePage::releases
@@ -82,6 +88,8 @@ struct Mirror
LinkTier tier = LinkTier::unknown; // the worst tier of its links
bool needs_browser = false; // any of its links opens the PS5 browser
// The first flagged link of the mirror (GameLink::flag_reason), or -1.
int flagged_link(const GamePage &page) const;
bool multipart() const
{
return status != PartStatus::single;
+145
View File
@@ -5,11 +5,13 @@
#include "orbit/grid_filters.hpp"
#include "orbit/i18n.hpp"
#include "orbit/link_flags.hpp"
#include "orbit/popularity.hpp"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <ctime>
#include <map>
namespace orbit
@@ -546,6 +548,31 @@ int GameView::section_of_mirror(int mirror) const
return static_cast<int>(page.releases[static_cast<std::size_t>(m.release)].section);
}
std::string flag_title_id(const GameView &view)
{
return view.title_ids.empty() ? std::string() : view.title_ids.front();
}
std::string ref_flag_id(const GameView &view, const LinkRef &ref)
{
const GamePage &page = view.page;
for (std::size_t i = 0; i < page.links.size(); ++i)
{
const GameLink &l = page.links[i];
if (l.index == ref.link_index && l.package >= 0 && l.package < static_cast<int>(page.packages.size()) &&
page.packages[static_cast<std::size_t>(l.package)].id == ref.package_id)
return page_link_id(page, static_cast<int>(i), flag_title_id(view));
}
return {};
}
// B-20: the console's bad-link flags onto the page, before it is ranked.
static void apply_flags(GameView *view)
{
if (const LinkFlags *flags = link_flags())
apply_link_flags(&view->page, *flags, flag_title_id(*view), static_cast<std::int64_t>(std::time(nullptr)));
}
void load_game_view(GameView *view, std::vector<PackageDetail> details, const std::vector<std::string> &source_order,
const LinkPrefs &prefs)
{
@@ -569,6 +596,7 @@ void load_game_view(GameView *view, std::vector<PackageDetail> details, const st
}
}
view->page = build_game_page(view->details, source_order);
apply_flags(view);
rank_page(&view->page, prefs);
count_facets(view, prefs);
// Research 4.2: firmware, region and format from the preferences, never hiding every download.
@@ -582,6 +610,7 @@ void rerank_game_view(GameView *view, const std::vector<std::string> &source_ord
if (!view->loaded)
return;
view->page = build_game_page(view->details, source_order);
apply_flags(view);
rank_page(&view->page, prefs);
count_facets(view, prefs);
MirrorFilter filter = view->filter;
@@ -655,6 +684,16 @@ MirrorCard mirror_card(const GameView &view, int mirror, const LinkPrefs &prefs,
c.kind = meta.all_dlc ? tr("Game + all DLC") : kind_label(meta.kind);
c.parts = m.parts_text();
c.warning = m.warning();
const int flagged = m.flagged_link(page);
if (flagged >= 0 && flagged < static_cast<int>(page.links.size()))
{
const GameLink &bad = page.links[static_cast<std::size_t>(flagged)];
LinkFlag flag;
flag.reason = static_cast<FlagReason>(bad.flag_reason);
flag.when = bad.flag_when;
c.flag = flag_line(flag);
c.flagged = true;
}
c.name = first.name;
c.tier = m.tier;
c.needs_browser = m.needs_browser;
@@ -675,6 +714,108 @@ std::string best_summary(const GameView &view)
return join(view.page.best_chips, kDot);
}
int mirror_part_number(const GamePage &page, int mirror, const LinkRef &ref)
{
if (mirror < 0 || mirror >= static_cast<int>(page.mirrors.size()))
return 0;
const Mirror &m = page.mirrors[static_cast<std::size_t>(mirror)];
for (std::size_t i = 0; i < m.links.size(); ++i)
{
const GameLink &l = page.links[static_cast<std::size_t>(m.links[i])];
if (l.index == ref.link_index && page.packages[static_cast<std::size_t>(l.package)].id == ref.package_id)
return i < m.numbers.size() ? m.numbers[i] : static_cast<int>(i) + 1;
}
return 0;
}
namespace
{
// A set whose numbers can be trusted part for part: 1..N, each once, one run, no gap.
bool aligned_set(const Mirror &m)
{
return m.status == PartStatus::complete && m.runs.size() <= 1 && m.missing.empty() && !m.links.empty() &&
m.numbers.size() == m.links.size();
}
std::vector<int> sorted_numbers(const Mirror &m)
{
std::vector<int> out = m.numbers;
std::sort(out.begin(), out.end());
return out;
}
// plan->numbers, alternates and alternate_hosts for plan->refs: the same part
// on the other hosts of the same release, when both sets line up exactly.
void plan_alternates(const GamePage &page, int mirror, const StartedLinks &started, PartsPlan *plan)
{
const std::size_t count = plan->refs.size();
plan->numbers.assign(count, 0);
plan->alternates = std::vector<std::vector<LinkRef>>(count);
plan->alternate_hosts = std::vector<std::vector<std::string>>(count);
for (std::size_t i = 0; i < count; ++i)
plan->numbers[i] = mirror_part_number(page, mirror, plan->refs[i]);
const Mirror &m = page.mirrors[static_cast<std::size_t>(mirror)];
if (!aligned_set(m))
return;
const std::vector<int> numbers = sorted_numbers(m);
std::vector<int> others;
for (std::size_t o = 0; o < page.mirrors.size(); ++o)
{
const Mirror &other = page.mirrors[o];
if (static_cast<int>(o) == mirror || other.release != m.release || other.host == m.host ||
!aligned_set(other) || other.links.size() != m.links.size() || sorted_numbers(other) != numbers)
continue;
others.push_back(static_cast<int>(o));
}
std::stable_sort(others.begin(), others.end(),
[&](int a, int b)
{
const Mirror &ma = page.mirrors[static_cast<std::size_t>(a)];
const Mirror &mb = page.mirrors[static_cast<std::size_t>(b)];
const int ra = tier_rank(ma.tier);
const int rb = tier_rank(mb.tier);
return ra != rb ? ra < rb : ma.host < mb.host;
});
for (std::size_t i = 0; i < count; ++i)
{
if (plan->numbers[i] <= 0)
continue;
for (const int o : others)
{
const Mirror &other = page.mirrors[static_cast<std::size_t>(o)];
for (std::size_t k = 0; k < other.links.size(); ++k)
{
if (other.numbers[k] != plan->numbers[i])
continue;
const GameLink &gl = page.links[static_cast<std::size_t>(other.links[k])];
LinkRef ref{page.packages[static_cast<std::size_t>(gl.package)].id, gl.index};
const bool blocked = gl.tier == LinkTier::unsupported || (gl.needs_browser && !m.needs_browser);
if (!gl.duplicate && !blocked && !started.has(ref))
{
plan->alternates[i].push_back(std::move(ref));
plan->alternate_hosts[i].push_back(other.host);
}
break;
}
}
}
}
// " If a part keeps failing, the same part is tried on Rootz, 1fichier." or "".
std::string alternates_sentence(const PartsPlan &plan)
{
std::vector<std::string> hosts;
for (const std::vector<std::string> &part : plan.alternate_hosts)
for (const std::string &h : part)
add_unique(&hosts, h);
if (hosts.empty())
return {};
return " " + tr("If a part keeps failing, the same part is tried on {hosts}.", {{"hosts", join(hosts, ", ")}});
}
} // namespace
PartsPlan plan_parts(const GameView &view, int mirror, const StartedLinks &started)
{
PartsPlan plan;
@@ -715,6 +856,8 @@ PartsPlan plan_parts(const GameView &view, int mirror, const StartedLinks &start
{{"host", plan.host}});
if (!m.warning().empty())
plan.message += " " + tr("Careful: {warning}.", {{"warning", m.warning()}});
plan_alternates(page, mirror, started, &plan);
plan.message += alternates_sentence(plan);
return plan;
}
plan.action = m.status == PartStatus::complete || m.status == PartStatus::unnumbered
@@ -734,6 +877,8 @@ PartsPlan plan_parts(const GameView &view, int mirror, const StartedLinks &start
if (!m.warning().empty())
message += " " + tr("Careful: {warning}.", {{"warning", m.warning()}});
message += " " + std::string(tr("It is a multi-part archive: you need every part to extract it."));
plan_alternates(page, mirror, started, &plan);
message += alternates_sentence(plan);
plan.message = std::move(message);
return plan;
}
+15
View File
@@ -197,6 +197,10 @@ void load_game_view(GameView *view, std::vector<PackageDetail> details,
const std::vector<std::string> &source_order, const LinkPrefs &prefs);
// The preferences changed: ranked again, the filter kept (where it still applies).
void rerank_game_view(GameView *view, const std::vector<std::string> &source_order, const LinkPrefs &prefs);
// The title id bad-link flags of this game are keyed by ("" when none is known).
std::string flag_title_id(const GameView &view);
// The bad-link flag id of one link of the page ("" when the page has no such link).
std::string ref_flag_id(const GameView &view, const LinkRef &ref);
// Another filter: rows again.
void set_game_filter(GameView *view, const MirrorFilter &filter, const LinkPrefs &prefs);
@@ -220,6 +224,8 @@ struct MirrorCard
std::string kind; // "Game + DLC", "Update", ...
std::string parts; // "1 file", "22 parts (1-22)"
std::string warning; // "part 4 missing", ...
std::string flag; // "Failed for you on 14 Oct: file removed" (shown before the warning)
bool flagged = false;
std::string name; // the first link's name as the catalogue has it
LinkTier tier = LinkTier::unknown;
bool needs_browser = false;
@@ -234,10 +240,19 @@ MirrorCard mirror_card(const GameView &view, int mirror, const LinkPrefs &prefs,
// The Best card: "Debrid · USA · BP 4.xx · 1 file" and the reason.
std::string best_summary(const GameView &view);
// The part number a link has inside its mirror (by position when unnumbered); 0 not in it.
int mirror_part_number(const GamePage &page, int mirror, const LinkRef &ref);
// "Download all parts": what would be queued and how to say it.
struct PartsPlan
{
std::vector<LinkRef> refs; // to queue, catalogue order, already-started ones left out
// Parallel to refs: each part's number, and the same part on the release's
// other hosts (best tier first, then host A to Z) to try when it keeps
// failing. Only between sets numbered 1..N exactly alike; never guessed.
std::vector<int> numbers;
std::vector<std::vector<LinkRef>> alternates;
std::vector<std::vector<std::string>> alternate_hosts;
int total = 0; // links in the set
int skipped = 0; // already started by this app
bool one_at_a_time = false; // the parts open the PS5 browser: one per check, never all at once
+5 -2
View File
@@ -240,6 +240,7 @@ std::string format_request(const Request &request, std::string_view host, std::u
out += request.bearer;
out += "\r\n";
}
out += request.headers; // extra "Name: value\r\n" lines, as given
if (request.method != "GET")
{
// The backend accepts mutations only as JSON. No Origin header is sent:
@@ -269,9 +270,11 @@ Response SocketTransport::send(const Request &request)
response.error = "socket failed";
return response;
}
// Per request (no shared state): the worker and the catalogue thread share this transport.
const int limit_ms = request.timeout_ms > 0 ? request.timeout_ms : timeout_ms_;
timeval timeout{};
timeout.tv_sec = timeout_ms_ / 1000;
timeout.tv_usec = (timeout_ms_ % 1000) * 1000;
timeout.tv_sec = limit_ms / 1000;
timeout.tv_usec = (limit_ms % 1000) * 1000;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#ifdef SO_NOSIGPIPE
+2
View File
@@ -21,7 +21,9 @@ struct Request
std::string path; // "/api/v1/system"
std::string body; // JSON for POST
std::string bearer; // pairing token, sent as Authorization
std::string headers; // extra header lines, each "Name: value\r\n" (sent as given)
std::size_t max_bytes = 0; // reply size limit; 0 = kDefaultLimit
int timeout_ms = 0; // socket time limit for this request; 0 = the transport's own
};
struct Response
-8
View File
@@ -31,12 +31,4 @@ std::string install_failure_hint(std::string_view error)
"Pegasus. Open Files to look, or install manually.");
}
std::string failed_download_tip(std::string_view status, int connections)
{
if (status != "failed" || (connections > 0 && connections <= 4))
return std::string();
return tr("Tip: set Download connections to 4 or lower for the most compatible downloads "
"(Settings > Pegasus DL).");
}
} // namespace orbit
-5
View File
@@ -17,9 +17,4 @@ bool looks_like_install_failure(std::string_view error);
// The extra line (translated), or "" when the error does not look like one.
std::string install_failure_hint(std::string_view error);
// A one-line tip under any failed download: fewer connections are the most compatible. `connections` is
// the Pegasus "Download connections" setting, <= 0 when not known. Shown for status "failed" only, and
// not when the setting is already 4 or lower. It is a tip, never a claim about the cause.
std::string failed_download_tip(std::string_view status, int connections);
} // namespace orbit
+8 -1
View File
@@ -7,6 +7,7 @@
#include "orbit/screens.hpp"
#include "orbit/busy_cancel.hpp"
#include "orbit/model.hpp"
#include <algorithm>
@@ -368,6 +369,12 @@ Intent LibraryScreen::update(const InputFrame &input, float dt, Feedback &feedba
const bool had_prompt = prompt_.is_open();
const bool had_confirm = confirm_.is_open();
const bool had_list = list_.is_open();
// Circle on "Asking Pegasus DL...": back at once, the late answer is ignored (B-18).
if (ctx_.store.lane_state(Store::lane_admin).busy && input.is_pressed(Action::back))
{
ctx_.store.cancel(Store::lane_admin);
return intent;
}
const bool busy = ctx_.store.lane_state(Store::lane_admin).busy;
// The typed confirmation of a delete.
@@ -608,7 +615,7 @@ void LibraryScreen::draw(Frame &frame, bool focused) const
list.pop_clip();
}
if (ctx_.store.lane_state(Store::lane_admin).busy)
draw_busy(ctx_, frame.overlay, tr("Asking Pegasus DL\xE2\x80\xA6"));
draw_busy(ctx_, frame.overlay, with_stop_hint(tr("Asking Pegasus DL\xE2\x80\xA6")).c_str());
prompt_.draw(ctx_, frame);
confirm_.draw(ctx_, frame);
list_.draw(ctx_, frame);
+476
View File
@@ -0,0 +1,476 @@
// Wings TV app - Bad-link flags: see link_flags.hpp.
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/link_flags.hpp"
#include "core/save_file.hpp"
#include "orbit/game_page.hpp"
#include "orbit/i18n.hpp"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
namespace orbit
{
namespace
{
char lower(char c)
{
return c >= 'A' && c <= 'Z' ? static_cast<char>(c + 32) : c;
}
char upper(char c)
{
return c >= 'a' && c <= 'z' ? static_cast<char>(c - 32) : c;
}
std::string lowered(std::string_view s)
{
std::string out(s);
for (char &c : out)
c = lower(c);
return out;
}
bool starts_with(std::string_view s, std::string_view prefix)
{
return s.size() >= prefix.size() && s.substr(0, prefix.size()) == prefix;
}
bool has(std::string_view text, std::string_view mark)
{
return text.find(mark) != std::string_view::npos;
}
// Query parameters that track the click or expire: never part of the link's identity.
bool volatile_param(std::string_view key)
{
const std::string k = lowered(key);
if (starts_with(k, "utm_") || starts_with(k, "x-amz-") || starts_with(k, "response-"))
return true;
static const char *const kDrop[] = {"fbclid", "gclid", "dclid", "msclkid", "ref",
"referrer", "_ga", "mc_cid", "mc_eid", "token",
"access_token", "download_token", "dl_token", "expires", "expire",
"exp", "signature", "sig", "st", "ts",
"timestamp", "session", "sessionid", "sid"};
for (const char *d : kDrop)
if (k == d)
return true;
return false;
}
bool valid_id(std::string_view id)
{
if (id.size() != 16)
return false;
for (const char c : id)
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')))
return false;
return true;
}
} // namespace
const char *flag_reason_text(FlagReason reason)
{
switch (reason)
{
case FlagReason::none:
break;
case FlagReason::host_error:
return tr("host error");
case FlagReason::removed:
return tr("file removed");
case FlagReason::wrong_file:
return tr("wrong file");
case FlagReason::password:
return tr("password never worked");
case FlagReason::broken_archive:
return tr("broken archive");
case FlagReason::by_hand:
return tr("marked bad by you");
}
return "";
}
std::string normalise_link_url(std::string_view url)
{
while (!url.empty() && (url.front() == ' ' || url.front() == '\t'))
url.remove_prefix(1);
while (!url.empty() && (url.back() == ' ' || url.back() == '\t' || url.back() == '\r' || url.back() == '\n'))
url.remove_suffix(1);
if (url.empty())
return {};
// Fragment: never sent to the server (MEGA keys live there, the file id does not).
const std::size_t hash = url.find('#');
if (hash != std::string_view::npos)
url = url.substr(0, hash);
std::string out;
std::string scheme;
const std::size_t sep = url.find("://");
if (sep != std::string_view::npos)
{
scheme = lowered(url.substr(0, sep));
out = scheme + "://";
url.remove_prefix(sep + 3);
}
// Authority: up to the path or query.
std::size_t end = url.find_first_of("/?");
if (end == std::string_view::npos)
end = url.size();
std::string host = lowered(url.substr(0, end));
url.remove_prefix(end);
const std::size_t at = host.rfind('@');
if (at != std::string::npos)
host.erase(0, at + 1);
if (starts_with(host, "www."))
host.erase(0, 4);
const std::size_t colon = host.rfind(':');
if (colon != std::string::npos)
{
const std::string port = host.substr(colon + 1);
if ((port == "80" && scheme == "http") || (port == "443" && scheme == "https") || port.empty())
host.erase(colon);
}
out += host;
// Path as given, without trailing slashes.
const std::size_t q = url.find('?');
std::string_view path = url.substr(0, q);
while (!path.empty() && path.back() == '/')
path.remove_suffix(1);
out += std::string(path);
if (q == std::string_view::npos)
return out;
std::string_view query = url.substr(q + 1);
std::string kept;
while (!query.empty())
{
std::size_t amp = query.find('&');
if (amp == std::string_view::npos)
amp = query.size();
const std::string_view param = query.substr(0, amp);
query.remove_prefix(std::min(amp + 1, query.size()));
if (param.empty())
continue;
if (volatile_param(param.substr(0, param.find('='))))
continue;
kept += (kept.empty() ? "" : "&") + std::string(param);
}
if (!kept.empty())
out += "?" + kept;
return out;
}
std::string link_id(std::string_view url, std::string_view title_id)
{
const std::string norm = normalise_link_url(url);
if (norm.empty())
return {};
std::string key;
for (const char c : title_id)
key += upper(c);
key += "|" + norm;
std::uint64_t h = 1469598103934665603ull;
for (const char c : key)
{
h ^= static_cast<unsigned char>(c);
h *= 1099511628211ull;
}
char buf[17];
std::snprintf(buf, sizeof buf, "%016llx", static_cast<unsigned long long>(h));
return buf;
}
FlagReason reason_from_error(std::string_view error, int http_status)
{
if (http_status == 404 || http_status == 410)
return FlagReason::removed;
if (http_status == 401 || http_status == 403 || (http_status >= 500 && http_status < 600))
return FlagReason::host_error;
const std::string e = lowered(error);
if (e.empty())
return FlagReason::none;
// Ours or passing, not the link's: cancelled, storage, network, rate limits.
static const char *const kNotTheLink[] = {"cancel", "no space", "disk full", "not enough space", "timed out",
"timeout", "network", "connection reset", "dns", "rate limit",
"too many requests", "paused"};
for (const char *m : kNotTheLink)
if (has(e, m))
return FlagReason::none;
if (has(e, "password"))
return FlagReason::password;
static const char *const kBroken[] = {"crc", "corrupt", "damaged", "unexpected end", "broken archive",
"checksum", "bad archive", "truncated", "incomplete archive"};
for (const char *m : kBroken)
if (has(e, m))
return FlagReason::broken_archive;
// install_hint.cpp's "could not decide what to install" family.
static const char *const kWrong[] = {"no pkg", "nothing to install", "no installable", "not installable",
"too many", "multiple pkg", "multiple install", "wrong file", "not a game"};
for (const char *m : kWrong)
if (has(e, m))
return FlagReason::wrong_file;
static const char *const kRemoved[] = {"404", "not found", "removed", "deleted", "no longer available",
"does not exist", "doesn't exist", "file unavailable", "taken down",
"expired"};
for (const char *m : kRemoved)
if (has(e, m))
return FlagReason::removed;
static const char *const kHost[] = {"provider", "resolve", "forbidden", "access denied", "premium",
"server error", "bad gateway", "service unavailable", "host error",
"http 403", "http 500", "http 502", "http 503"};
for (const char *m : kHost)
if (has(e, m))
return FlagReason::host_error;
return FlagReason::none;
}
// ---- the store ----
void LinkFlags::add(const std::string &id, FlagReason reason, std::int64_t now, std::string_view title)
{
if (reason == FlagReason::none || id.empty())
return;
// One line in the file: no line breaks, kept short.
std::string name;
for (char ch : title.substr(0, 80))
name += ch == '\n' || ch == '\r' || ch == '\t' ? ' ' : ch;
while (!name.empty() && name.back() == ' ')
name.pop_back();
if (name.empty())
{
// A flag added again without a name keeps the one it had.
const auto old = flags_.find(id);
if (old != flags_.end())
name = old->second.title;
}
flags_[id] = LinkFlag{reason, now, std::move(name)};
while (flags_.size() > kMaxFlags)
{
auto oldest = flags_.begin();
for (auto it = flags_.begin(); it != flags_.end(); ++it)
if (it->second.when < oldest->second.when)
oldest = it;
flags_.erase(oldest);
}
}
bool LinkFlags::clear(const std::string &id)
{
return flags_.erase(id) > 0;
}
void LinkFlags::clear_all()
{
flags_.clear();
}
const LinkFlag *LinkFlags::find(const std::string &id, std::int64_t now) const
{
const auto it = flags_.find(id);
if (it == flags_.end() || now - it->second.when >= kFlagSeconds)
return nullptr;
return &it->second;
}
std::size_t LinkFlags::expire(std::int64_t now)
{
std::size_t gone = 0;
for (auto it = flags_.begin(); it != flags_.end();)
{
if (now - it->second.when >= kFlagSeconds)
{
it = flags_.erase(it);
++gone;
}
else
++it;
}
return gone;
}
std::vector<std::pair<std::string, LinkFlag>> LinkFlags::list(std::int64_t now) const
{
std::vector<std::pair<std::string, LinkFlag>> out;
for (const auto &entry : flags_)
if (now - entry.second.when < kFlagSeconds)
out.push_back(entry);
std::stable_sort(out.begin(), out.end(),
[](const auto &a, const auto &b) { return a.second.when > b.second.when; });
return out;
}
std::string LinkFlags::encode() const
{
std::string out;
for (const auto &entry : flags_)
out += entry.first + " " + std::to_string(static_cast<int>(entry.second.reason)) + " " +
std::to_string(entry.second.when) + (entry.second.title.empty() ? "" : " " + entry.second.title) +
"\n";
return out;
}
bool LinkFlags::decode(std::string_view payload)
{
LinkFlags parsed;
std::size_t pos = 0;
while (pos < payload.size())
{
std::size_t end = payload.find('\n', pos);
if (end == std::string_view::npos)
end = payload.size();
const std::string line(payload.substr(pos, end - pos));
pos = end + 1;
const std::size_t a = line.find(' ');
const std::size_t b = a == std::string::npos ? a : line.find(' ', a + 1);
if (b == std::string::npos || !valid_id(std::string_view(line).substr(0, a)))
continue;
const std::string reason_text = line.substr(a + 1, b - a - 1);
// "<id> <reason> <when>[ <title>]": the title is optional (older files have none).
const std::size_t c = line.find(' ', b + 1);
const std::string when_text = line.substr(b + 1, c == std::string::npos ? std::string::npos : c - b - 1);
const std::string title = c == std::string::npos ? std::string() : line.substr(c + 1);
char *stop = nullptr;
const long reason = std::strtol(reason_text.c_str(), &stop, 10);
if (stop == reason_text.c_str() || *stop != '\0' || reason <= 0 || reason >= kFlagReasonCount)
continue;
const long long when = std::strtoll(when_text.c_str(), &stop, 10);
if (stop == when_text.c_str() || *stop != '\0' || when <= 0)
continue;
parsed.add(line.substr(0, a), static_cast<FlagReason>(reason), static_cast<std::int64_t>(when), title);
}
flags_ = std::move(parsed.flags_);
return true;
}
bool LinkFlags::load(const std::vector<std::string> &paths)
{
for (const std::string &path : paths)
{
std::string data;
if (!hui::save::read_file(path, &data, 1u << 20))
continue;
const hui::save::Decoded d = hui::save::decode(hui::save::Kind::stats, data);
if (d.ok && d.version == 1)
return decode(d.payload);
}
return false;
}
int LinkFlags::save(const std::vector<std::string> &paths) const
{
const std::string data = hui::save::encode(hui::save::Kind::stats, 1, encode());
int written = 0;
for (const std::string &path : paths)
{
const std::size_t slash = path.rfind('/');
if (slash != std::string::npos && slash > 0)
hui::save::ensure_directory(path.substr(0, slash));
if (hui::save::write_atomic(path, data).empty())
++written;
}
return written;
}
// ---- text ----
std::string flag_date(std::int64_t when)
{
// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
std::int64_t z = (when >= 0 ? when : when - 86399) / 86400 + 719468;
const std::int64_t era = (z >= 0 ? z : z - 146096) / 146097;
const std::int64_t doe = z - era * 146097;
const std::int64_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
const std::int64_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
const std::int64_t mp = (5 * doy + 2) / 153;
const int day = static_cast<int>(doy - (153 * mp + 2) / 5 + 1);
const int month = static_cast<int>(mp < 10 ? mp + 3 : mp - 9); // 1..12
static const char *const kMonths[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
return std::to_string(day) + " " + tr(kMonths[month - 1]);
}
std::string flag_line(const LinkFlag &flag)
{
if (flag.reason == FlagReason::none)
return {};
if (flag.reason == FlagReason::by_hand)
return tr("Marked bad on {date}", {{"date", flag_date(flag.when)}});
return tr("Failed for you on {date}: {reason}",
{{"date", flag_date(flag.when)}, {"reason", flag_reason_text(flag.reason)}});
}
// ---- the game page ----
std::string page_link_id(const GamePage &page, int link, std::string_view title_id)
{
if (link < 0 || link >= static_cast<int>(page.links.size()))
return {};
const GameLink &l = page.links[static_cast<std::size_t>(link)];
if (!l.url.empty())
return link_id(l.url, title_id);
const std::string &package = page.packages[static_cast<std::size_t>(l.package)].id;
return link_id("pkg:" + package + "/" + std::to_string(l.index), title_id);
}
void apply_link_flags(GamePage *page, const LinkFlags &flags, std::string_view title_id, std::int64_t now)
{
for (std::size_t i = 0; i < page->links.size(); ++i)
{
GameLink &l = page->links[i];
const LinkFlag *f = flags.find(page_link_id(*page, static_cast<int>(i), title_id), now);
l.flag_reason = f != nullptr ? static_cast<std::uint8_t>(f->reason) : 0;
l.flag_when = f != nullptr ? f->when : 0;
}
}
// ---- the app-wide flags ----
namespace
{
LinkFlags *g_flags = nullptr;
std::vector<std::string> &flag_paths()
{
static std::vector<std::string> paths;
return paths;
}
} // namespace
LinkFlags *link_flags()
{
return g_flags;
}
void set_link_flags(LinkFlags *flags, std::vector<std::string> paths)
{
g_flags = flags;
flag_paths() = std::move(paths);
}
void save_link_flags()
{
if (g_flags != nullptr && !flag_paths().empty())
g_flags->save(flag_paths());
}
void mark_page_links(GamePage *page, const std::vector<int> &links, FlagReason reason, std::string_view title_id,
std::int64_t now, std::string_view title)
{
if (g_flags == nullptr || page == nullptr)
return;
for (int link : links)
{
const std::string id = page_link_id(*page, link, title_id);
if (reason == FlagReason::none)
g_flags->clear(id);
else
g_flags->add(id, reason, now, title);
}
save_link_flags();
apply_link_flags(page, *g_flags, title_id, now);
}
} // namespace orbit
+124
View File
@@ -0,0 +1,124 @@
// Wings TV app - Bad-link flags on this console: links that failed for the player.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// When a link fails (host error, file removed, wrong file, password never
// works, broken archive) Wings flags it here, or the player marks it by hand.
// A flag is keyed by link_id(): a hash of the title id and the normalised URL
// (the raw URL is never stored), keeps its reason and date, and expires after
// kFlagDays so a link comes back. Best never picks a flagged link while
// another exists (ranking.hpp). Saved as "link_flags.v1" in the checked save
// container of core/save_file.
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
struct GamePage;
enum class FlagReason : std::uint8_t
{
none = 0, // no flag / not a link failure
host_error, // the provider or host refused or errored
removed, // 404, file deleted or taken down
wrong_file, // not the game, or nothing to install
password, // no password worked
broken_archive, // CRC error, damaged or incomplete archive
by_hand, // the player marked it bad
};
constexpr int kFlagReasonCount = 7;
// "host error", "file removed", "wrong file", "password never worked",
// "broken archive", "marked bad by you"; "" for none.
const char *flag_reason_text(FlagReason reason);
constexpr std::size_t kMaxFlags = 500; // oldest dropped beyond this
constexpr std::int64_t kFlagDays = 60; // a flag older than this is gone
constexpr std::int64_t kFlagSeconds = kFlagDays * 86400;
// The URL as compared: scheme and host lower case, "www." and default ports
// dropped, no fragment, no tracking or expiring query parameters (utm_*,
// fbclid, token, expires, signature, X-Amz-*, ...). Path and other
// parameters kept as given.
std::string normalise_link_url(std::string_view url);
// 16 hex digits: FNV-1a 64 of "<TITLE ID upper>|<normalised url>". An empty
// URL gives "".
std::string link_id(std::string_view url, std::string_view title_id);
// The reason a failure means, from Pegasus' error text and/or an HTTP status
// (0 = none). Cancelled, network or disk problems on our side give none.
FlagReason reason_from_error(std::string_view error, int http_status = 0);
struct LinkFlag
{
FlagReason reason = FlagReason::none;
std::int64_t when = 0; // unix seconds
std::string title; // the game's name for Settings > Wings ("" unknown)
};
class LinkFlags
{
public:
// Adds or replaces a flag (the newest reason wins); none is ignored.
// Drops the oldest beyond kMaxFlags.
// `title` is the game's name shown in Settings (one line, cut at 80 bytes;
// empty keeps the name an earlier flag of the same id had).
void add(const std::string &id, FlagReason reason, std::int64_t now, std::string_view title = {});
bool clear(const std::string &id); // false if it was not flagged
void clear_all();
// The live flag, or nullptr (expired flags count as absent).
const LinkFlag *find(const std::string &id, std::int64_t now) const;
bool flagged(const std::string &id, std::int64_t now) const
{
return find(id, now) != nullptr;
}
// Forgets flags older than kFlagDays; returns how many went.
std::size_t expire(std::int64_t now);
std::size_t size() const
{
return flags_.size();
}
// Newest first, for Settings > Wings.
std::vector<std::pair<std::string, LinkFlag>> list(std::int64_t now) const;
// "<id> <reason> <when>\n" per flag.
std::string encode() const;
bool decode(std::string_view payload);
// First readable path wins; false if none.
bool load(const std::vector<std::string> &paths);
int save(const std::vector<std::string> &paths) const; // files written
private:
std::map<std::string, LinkFlag> flags_;
};
// "14 Oct" (UTC).
std::string flag_date(std::int64_t when);
// "Failed for you on 14 Oct: file removed"; "Marked bad on 14 Oct" by hand.
std::string flag_line(const LinkFlag &flag);
// Fills GameLink::flag of every link of the page from `flags` (title id of
// the game; links without a URL are keyed by "pkg:<id>/<index>").
void apply_link_flags(GamePage *page, const LinkFlags &flags, std::string_view title_id, std::int64_t now);
// The id of one link of a page, as apply_link_flags() keys it.
std::string page_link_id(const GamePage &page, int link, std::string_view title_id);
// The app-wide flags of this console (main.cpp sets them, frame thread only);
// nullptr until set. save_link_flags() writes them to the paths given here
// (none: nothing written) and is called after every change.
LinkFlags *link_flags();
void set_link_flags(LinkFlags *flags, std::vector<std::string> paths);
void save_link_flags();
// Flags (or clears, reason none) the given links of the page in the app-wide
// flags, saves, and refreshes the page's flag fields. No-op without flags.
void mark_page_links(GamePage *page, const std::vector<int> &links, FlagReason reason, std::string_view title_id,
std::int64_t now, std::string_view title = {});
} // namespace orbit
+1 -1
View File
@@ -6,7 +6,7 @@
// expression of the reference became a small matcher below; the comment on
// each quotes the expression it replaces. Matchers find the leftmost match, as
// Python's re.search does, and take() cuts it out of the working text, leaving
// a space. app/tools/linkref/ holds the reference and a dump script; the unit
// a space. The unit
// test compares both over the recorded catalogue.
#include "orbit/link_meta.hpp"
+43
View File
@@ -0,0 +1,43 @@
// Wings TV app - Is a failed "start this link" worth asking again later?
// SPDX-License-Identifier: GPL-3.0-or-later
//
// "Download all parts" asks Pegasus for one part at a time. When a part fails,
// the series needs to know whether the failure may pass by itself (Pegasus busy
// or slow, a host error) or is a definite answer (not supported, not found, the
// link is gone). Pure, so it is tested on its own.
#pragma once
#include <cctype>
#include <string>
#include <string_view>
namespace orbit
{
// status: the HTTP status (0 when Pegasus was not reached); error: the text;
// unreachable: Result::unreachable() (connection refused, timed out, ...).
inline bool transient_link_error(int status, std::string_view error, bool unreachable)
{
if (unreachable)
return true;
std::string text(error);
for (char &c : text)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const auto has = [&text](const char *word) { return text.find(word) != std::string::npos; };
// Answers that will not change, whatever the status says.
if (has("not found") || has("expired") || has("gone") || has("removed") || has("deleted") ||
has("not supported") || has("unsupported") || has("blocked") || has("invalid"))
return false;
if (status >= 500 || status == 408 || status == 425 || status == 429)
return true;
if (has("timed out") || has("timeout") || has("busy") || has("try again") || has("temporarily") ||
has("in progress") || has("connection refused") || has("connect failed") || has("no response"))
return true;
// "A capture session is already running" and the like: Pegasus does one at a time.
if (has("already") && (has("capture") || has("session") || has("resolv") || has("running") || has("active")))
return true;
return false;
}
} // namespace orbit
+8 -1
View File
@@ -3,6 +3,7 @@
#include "orbit/screens.hpp"
#include "orbit/busy_cancel.hpp"
#include "orbit/diagnostics.hpp"
#include "orbit/model.hpp"
@@ -153,6 +154,12 @@ Intent LogsScreen::update(const InputFrame &input, float dt, Feedback &feedback,
const Snapshot &state = ctx_.store.state();
if (!diagnostics_)
ctx_.store.want_logs(true);
// Circle on "Asking Pegasus DL...": back at once, the late answer is ignored (B-18).
if (ctx_.store.lane_state(Store::lane_admin).busy && input.is_pressed(Action::back))
{
ctx_.store.cancel(Store::lane_admin);
return intent;
}
const bool had_confirm = confirm_.is_open();
const bool had_list = list_.is_open();
const bool busy = ctx_.store.lane_state(Store::lane_admin).busy;
@@ -360,7 +367,7 @@ void LogsScreen::draw(Frame &frame, bool focused) const
list.pop_clip();
}
if (ctx_.store.lane_state(Store::lane_admin).busy)
draw_busy(ctx_, frame.overlay, tr("Asking Pegasus DL\xE2\x80\xA6"));
draw_busy(ctx_, frame.overlay, with_stop_hint(tr("Asking Pegasus DL\xE2\x80\xA6")).c_str());
confirm_.draw(ctx_, frame);
list_.draw(ctx_, frame);
}
+13
View File
@@ -156,11 +156,24 @@ const char *status_label(std::string_view status)
return tr("Completed");
if (status == "failed")
return tr("Failed");
if (status == "retrying")
return tr("Reconnecting");
if (status == "cancelled")
return tr("Cancelled");
return tr("Unknown");
}
const char *install_label(std::string_view install_state)
{
if (install_state == "installing")
return tr("Installing");
if (install_state == "installed")
return tr("Installed");
if (install_state == "install_failed")
return tr("Install failed");
return nullptr;
}
std::string format_bytes(double bytes)
{
// 49.4 GB in English, 49,4 GB in German.
+6
View File
@@ -273,6 +273,10 @@ struct Job
int attempts = 0;
int max_attempts = 0;
std::vector<ArchiveChoice> choices; // when status is waitingChoice
// A .pkg Wings downloaded to disk and installs itself (pkg_install.hpp), on a completed job:
// "installing", "installed", "install_failed", or empty. install_text is the player text.
std::string install_state;
std::string install_text;
};
// A game installed on the console (GET /appdb).
@@ -410,6 +414,8 @@ bool job_active(const Job &job); // still moving or waiting its turn
bool job_finished(const Job &job); // completed, failed or cancelled
bool job_running(const Job &job); // actually transferring or working
const char *status_label(std::string_view status);
// The chip for a completed .pkg Wings installs itself (Job::install_state); nullptr when there is none.
const char *install_label(std::string_view install_state);
// The fraction to draw (0..1), or negative when nothing is known.
double job_fraction(const Job &job);
+97 -4
View File
@@ -184,16 +184,19 @@ void ConfirmDialog::draw(Context &ctx, Frame &frame) const
const float a = std::clamp(in_.value, 0.0f, 1.0f);
draw_scrim(list, a);
const float width = 900.0f;
// Measure the message to size the panel.
const float message_lines = 5.0f;
const float height = 72.0f + 50.0f + message_lines * 36.0f + 40.0f + ui::kButtonHeight + 44.0f;
// Measure the message to size the panel: room for five lines at least, and
// taller for a longer message (a "Download all parts" report) up to 14.
const int message_lines = std::clamp(
static_cast<int>(type.regular.font->wrap(message_, 25.0f, width - 96.0f).size()), 5, 14);
const float height = 72.0f + 50.0f + static_cast<float>(message_lines) * 36.0f + 40.0f +
ui::kButtonHeight + 44.0f;
const ui::Rect r{960.0f - width * 0.5f, 540.0f - height * 0.5f + (1.0f - a) * 20.0f, width, height};
list.push_opacity(a);
ui::modal(list, r);
ui::text_fit(list, type.light, title_, r.x + 48.0f, r.y + 82.0f, 38.0f, r.w - 96.0f,
ui::color::text);
ui::paragraph(list, type.regular, message_, r.x + 48.0f, r.y + 140.0f, 25.0f, r.w - 96.0f,
36.0f, ui::color::text2, 5);
36.0f, ui::color::text2, message_lines);
const float y = r.y + r.h - 44.0f - ui::kButtonHeight;
const ui::Rect confirm{r.x + r.w - 48.0f - kButtonWidth, y, kButtonWidth, ui::kButtonHeight};
const ui::Rect cancel{confirm.x - 20.0f - kButtonWidth, y, kButtonWidth, ui::kButtonHeight};
@@ -793,4 +796,94 @@ void FolderPicker::draw(Context &ctx, Frame &frame) const
prompt_.draw(ctx, frame);
}
// ---- FolderAsk ----
bool FolderAsk::guard(Context &ctx, std::function<void()> resume)
{
if (!ctx.folder_confirmed || is_open())
return is_open();
const Snapshot &state = ctx.store.state();
if (ctx.folder_gate.check(ctx.folder_confirmed(), state.have_settings) ==
FirstDownloadGate::Verdict::proceed)
return false;
ctx.folder_gate.defer(std::move(resume));
std::vector<ListItem> items(2);
items[0].label = tr("Keep this folder");
items[1].label = tr("Choose folder");
list_.open(tr("Download folder"),
tr("Downloads will be saved to {folder}. Keep this folder or choose another.",
{{"folder", state.settings.download_root}}),
std::move(items), ListDialog::Mode::single);
opened_ = true;
return true;
}
void FolderAsk::answered(Context &ctx)
{
ctx.folder_gate.answered();
if (ctx.confirm_folder)
ctx.confirm_folder();
if (std::function<void()> resume = ctx.folder_gate.take_pending())
resume();
}
void FolderAsk::update(Context &ctx, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
static const hui::InputFrame idle{};
if (opened_)
feedback.play(hui::audio::Cue::modal_open);
opened_ = false;
const bool had_list = list_.is_open();
const bool had_picker = picker_.is_open();
const bool had_notice = notice_.is_open();
if (notice_.update(had_notice ? input : idle, dt, feedback) != ConfirmDialog::Result::none)
return;
const ListDialog::Result listed = list_.update(had_list ? input : idle, dt, feedback);
if (listed == ListDialog::Result::chosen)
{
if (list_.chosen() == 0)
answered(ctx);
else
{
picker_.open(ctx.store, tr("Download folder"), ctx.store.state().settings.download_root);
feedback.play(hui::audio::Cue::modal_open);
}
}
else if (listed == ListDialog::Result::cancelled)
{
(void)ctx.folder_gate.take_pending(); // nothing starts; the next download asks again
}
const FolderPicker::Result picked = picker_.update(ctx.store, had_picker ? input : idle, dt, feedback);
if (picked == FolderPicker::Result::chosen)
{
SettingsPatch patch;
patch.has_root = true;
patch.root = picker_.chosen();
ctx.store.call<SettingsView>(
Store::lane_admin, [patch](Backend &b) { return b.settings_update(patch); },
[this, &ctx](Result<SettingsView> &&r)
{
if (!r.ok())
{
(void)ctx.folder_gate.take_pending();
notice_.notice(tr("Pegasus DL did not accept that"), r.error);
return;
}
ctx.store.apply_settings(r.value);
answered(ctx);
});
}
else if (picked == FolderPicker::Result::cancelled)
{
(void)ctx.folder_gate.take_pending();
}
}
void FolderAsk::draw(Context &ctx, Frame &frame) const
{
list_.draw(ctx, frame);
picker_.draw(ctx, frame);
notice_.draw(ctx, frame);
}
} // namespace orbit
+31
View File
@@ -15,6 +15,7 @@
#include "ui/components/input_prompt.hpp"
#include "ui/feedback.hpp"
#include <functional>
#include <string>
#include <string_view>
#include <vector>
@@ -112,6 +113,14 @@ class ConfirmDialog
{
return open_;
}
const std::string &title() const
{
return title_;
}
const std::string &message() const
{
return message_;
}
Result update(const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(Context &ctx, Frame &frame) const;
@@ -292,6 +301,28 @@ class FolderPicker
std::vector<float> row_focus_;
};
// The first-download "Download folder" question (docs B-13), shared by the Store and Downloads tabs: each owns one.
// guard() is called where a download would start; true means it was held back and the question is open. Keep or a
// picked folder runs the held action; Back leaves nothing started (it is asked again next time).
class FolderAsk
{
public:
bool guard(Context &ctx, std::function<void()> resume);
bool is_open() const
{
return list_.is_open() || picker_.is_open() || notice_.is_open();
}
void update(Context &ctx, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(Context &ctx, Frame &frame) const;
private:
void answered(Context &ctx);
ListDialog list_;
FolderPicker picker_;
ConfirmDialog notice_;
bool opened_ = false; // the question opened since the last update (plays the cue)
};
// The dim veil behind every panel.
void draw_scrim(hui::gfx::DrawList &list, float amount);
+240 -2
View File
@@ -3,6 +3,9 @@
#include "orbit/pegasus_backend.hpp"
#include "orbit/download_auto_retry.hpp"
#include "orbit/pkg_install.hpp"
#include "wings/logic.hpp"
#include <algorithm>
@@ -152,6 +155,39 @@ std::vector<Job> to_jobs(const wings::QueueSnapshot &queue)
return jobs;
}
// What the auto-reconnect model reads from each queue entry.
std::vector<RetryJobSnapshot> retry_snapshots(const wings::QueueSnapshot &queue)
{
std::vector<RetryJobSnapshot> out;
out.reserve(queue.jobs.size());
for (const wings::QueueJob &j : queue.jobs)
{
RetryJobSnapshot s;
s.id = std::to_string(j.id);
s.status = status_name(j.status);
s.error = j.error;
s.detail = j.detail;
s.received = static_cast<double>(j.bytesDone);
s.resumable = j.supportsResume || j.resumed;
out.push_back(std::move(s));
}
return out;
}
// A failed job Wings is about to retry (or just retried) reads "retrying", not "failed".
void mark_reconnecting(std::vector<Job> *jobs)
{
using Kind = DownloadAutoRetry::View::Kind;
for (Job &job : *jobs)
{
if (job.status != "failed")
continue;
const Kind kind = download_reconnect().view(job.id).kind;
if (kind == Kind::waiting || kind == Kind::reconnecting)
job.status = "retrying";
}
}
Source to_source(const wings::Source &s)
{
Source source;
@@ -342,6 +378,118 @@ FileOp to_op(const wings::FileOperation &o)
return op;
}
// ---- B-17: a .pkg downloaded to disk, then installed (pkg_install.hpp) ----
std::vector<PkgOpSeen> pkg_ops_of(const std::vector<wings::FileOperation> &ops)
{
std::vector<PkgOpSeen> out;
for (const wings::FileOperation &o : ops)
{
const FileOp op = to_op(o);
out.push_back({op.id, op.type, op.status, op.error});
}
return out;
}
// One queue poll: feed the snapshot, then do the next few steps (rename, install, then
// at most one operations poll, installed-games check or delete).
void drive_pkg_installs(wings::IPegasus &a, const wings::QueueSnapshot &queue)
{
std::vector<PkgJobSeen> seen;
seen.reserve(queue.jobs.size());
for (const wings::QueueJob &j : queue.jobs)
seen.push_back({std::to_string(j.id), status_name(j.status), j.destinationPath, j.error});
PkgInstalls &pkgs = pkg_installs();
pkgs.observe_queue(seen);
for (int i = 0; i < 4; ++i)
{
const PkgStep step = pkgs.next();
switch (step.kind)
{
case PkgStep::Kind::none:
return;
case PkgStep::Kind::rename:
{
const auto r = a.fileRename(step.path, step.name);
pkgs.renamed(step.job_id, r.ok(), r.ok() ? r.value.path : std::string(),
r.ok() ? std::string() : r.error.message);
break;
}
case PkgStep::Kind::install:
{
if (step.title_id.empty())
{
// No title id: the install is confirmed later by a title that was not there before.
const auto games = a.getInstalledGames();
if (games.ok())
{
std::vector<std::string> ids;
for (const wings::InstalledGame &g : games.value.titles)
ids.push_back(g.titleId);
pkgs.titles_before(step.job_id, ids);
}
}
const auto r = a.filePkgInstall(step.path);
pkgs.install_started(step.job_id, r.ok(), r.ok() ? pkg_ops_of(r.value) : std::vector<PkgOpSeen>(),
r.ok() ? std::string() : r.error.message);
if (!r.ok())
return;
break;
}
case PkgStep::Kind::poll:
{
const auto r = a.fileOperations();
if (r.ok())
pkgs.observe_ops(pkg_ops_of(r.value));
return;
}
case PkgStep::Kind::confirm:
{
const auto r = a.getInstalledGames();
if (!r.ok())
return;
std::vector<std::string> ids;
for (const wings::InstalledGame &g : r.value.titles)
ids.push_back(g.titleId);
pkgs.confirmed(step.job_id, pkg_install_seen(step.title_id, step.before, ids));
return;
}
case PkgStep::Kind::remove:
{
const auto r = a.fileDelete({step.path});
pkgs.removed(step.job_id, r.ok(), r.ok() ? std::string() : r.error.message);
return;
}
}
}
}
// The download row reads the install stage of a tracked .pkg job.
void mark_pkg_installs(std::vector<Job> *jobs)
{
for (Job &job : *jobs)
{
if (job.status != "completed")
continue;
switch (pkg_installs().stage(job.id))
{
case PkgStage::downloading:
continue;
case PkgStage::renaming:
case PkgStage::installing:
job.install_state = "installing";
break;
case PkgStage::installed:
job.install_state = "installed";
break;
case PkgStage::failed:
job.install_state = "install_failed";
break;
}
job.install_text = pkg_installs().row_text(job.id);
}
}
AprView to_apr(const wings::AprEmu &a)
{
AprView v;
@@ -717,14 +865,31 @@ Result<std::vector<Job>> PegasusBackend::queue()
const Api a = need_api(&out);
if (!a)
return out;
const wings::Result<wings::QueueSnapshot> reply = a->getQueue();
wings::Result<wings::QueueSnapshot> reply = a->getQueue();
if (!reply.ok())
{
fail(&out, reply.error);
return out;
}
// Dropped downloads are retried here, on every Store poll, whatever screen is open.
const std::vector<std::string> due = download_reconnect().observe(retry_snapshots(reply.value));
if (!due.empty())
{
for (const std::string &id : due)
{
const wings::Result<wings::QueueResponse> sent = a->retryJob(to_id(id));
download_reconnect().sent(id, sent.ok(), sent.ok() ? std::string() : sent.error.message);
}
wings::Result<wings::QueueSnapshot> again = a->getQueue();
if (again.ok())
reply = std::move(again);
}
// A .pkg that downloaded to disk is renamed and installed from here too.
drive_pkg_installs(*a, reply.value);
out.status = 200;
out.value = to_jobs(reply.value);
mark_reconnecting(&out.value);
mark_pkg_installs(&out.value);
return out;
}
@@ -982,6 +1147,7 @@ Result<std::vector<Job>> PegasusBackend::jobs_after(
if (reply.value.hasQueue)
{
out.value = to_jobs(reply.value.queue);
mark_reconnecting(&out.value);
return out;
}
const wings::Result<wings::QueueSnapshot> queue = api.getQueue();
@@ -991,6 +1157,7 @@ Result<std::vector<Job>> PegasusBackend::jobs_after(
return out;
}
out.value = to_jobs(queue.value);
mark_reconnecting(&out.value);
return out;
}
@@ -1085,6 +1252,67 @@ Result<LinkResult> PegasusBackend::start_link(std::string_view package_id, int l
return out;
case Kind::nativePkg:
{
// B-17: per link, try a fast download to disk first. Asking for "<Title>.fpkg" keeps the
// file in Pegasus' own queue; it is renamed to .pkg and installed when it finishes
// (drive_pkg_installs). The old hand-off to the PS5's Downloads is the fallback.
const std::string link = "link " + std::string(package_id) + "/" + std::to_string(action.linkIndex);
const std::string url = wings::logic::safeHttpUrl(action.url);
bool probe_ok = false;
wings::ResolveProbe probe;
if (!url.empty())
{
auto probed = a->resolveProbe(url);
if (probed.ok())
{
probe_ok = true;
probe = std::move(probed.value);
}
}
const double size = probe_ok && probe.hasContentLength ? static_cast<double>(probe.contentLength) : -1.0;
std::vector<StorageMount> mounts;
if (size > 0.0)
{
const auto places = a->getMounts(); // GET /api/mounts: small, has free bytes
if (places.ok())
{
for (const wings::Mount &m : places.value)
mounts.push_back(to_mount(m));
}
}
const PkgRouteChoice route = pkg_route_before_queue(
probe_ok, probe_ok ? wings::logic::probeRejection(probe) : std::string(), size,
current_settings().downloadRoot, mounts);
pkg_installs().log(link + ": " + (route.route == PkgRoute::direct ? "direct" : "hand-off") + " (" +
route.reason + ")");
if (route.route == PkgRoute::direct)
{
const std::string stem = pkg_file_stem(!pkg.title.empty() ? pkg.title : probe.fileName);
const auto direct = a->queueDirect(probe.finalUrl.empty() ? url : probe.finalUrl, stem + ".fpkg",
probe.contentType);
const wings::DispatchResult *first =
direct.ok() && !direct.value.dispatchResults.empty() ? &direct.value.dispatchResults.front()
: nullptr;
const PkgDirectAnswer answer = pkg_classify_direct(
direct.ok(), first != nullptr && first->kind == wings::DispatchKind::nativePkg,
first != nullptr && first->kind == wings::DispatchKind::queued, first ? first->jobId : 0);
if (answer == PkgDirectAnswer::track)
{
pkg_installs().track(std::to_string(first->jobId), pkg.title, pkg.titleId, stem + ".pkg");
out.value.kind = LinkResult::Kind::dispatched;
out.value.dispatch = to_dispatch(direct.value, "Download queued. It installs when it finishes.");
return out;
}
if (answer == PkgDirectAnswer::handed_off)
{
pkg_installs().log(link + ": Pegasus handed it to the PS5's Downloads anyway");
out.value.kind = LinkResult::Kind::dispatched;
out.value.dispatch = to_dispatch(direct.value, "Download queued.");
return out;
}
pkg_installs().log(link + ": direct queue refused (" +
(direct.ok() ? std::string("no job id") : direct.error.message) +
"); using the hand-off");
}
const auto reply = a->enqueuePackage(pid, action.linkIndex);
if (!reply.ok())
{
@@ -1545,7 +1773,7 @@ Result<Art> PegasusBackend::art(std::string_view key, ArtKind kind)
wings::InstalledGame game;
game.titleId = std::string(key.substr(4));
const auto reply =
a->fetchAppdbArt(wings::logic::installedArtUrl(game, kind == ArtKind::hero ? "pic0" : "icon"));
a->fetchAppdbArt(wings::logic::installedArtUrl(game, kind != ArtKind::cover ? "pic0" : "icon"));
if (!reply.ok())
{
fail(&out, reply.error);
@@ -1901,6 +2129,16 @@ Result<bool> PegasusBackend::file_cancel(std::string_view operation_id)
return out;
}
Result<bool> PegasusBackend::open_url(std::string_view url)
{
WB_API(bool)
const auto reply = a->browserOpen(std::string(url), false, std::nullopt, std::nullopt);
WB_CHECK(reply)
out.status = 200;
out.value = true;
return out;
}
#undef WB_API
#undef WB_CHECK
+1
View File
@@ -111,6 +111,7 @@ class PegasusBackend final : public Backend
std::string_view password) override;
Result<std::vector<FileOp>> file_pkg_install(std::string_view path) override;
Result<bool> file_cancel(std::string_view operation_id) override;
Result<bool> open_url(std::string_view url) override;
// ---- for tests and the About screen ----
// Forgets the connection: the next call connects (and probes) again.
+12
View File
@@ -105,6 +105,18 @@ LoadCommand PegasusLoadFlow::tick(const LoadFacts &facts)
return LoadCommand::none;
}
LoadCommand PegasusLoadFlow::cancel_by_player()
{
if (!cancellable())
return LoadCommand::none;
const bool running = busy();
state_ = LoadState::offering;
fail_ = LoadFail::none;
retry_run_ = false;
retry_since_ = -1.0;
return running ? LoadCommand::cancel : LoadCommand::none;
}
LoadCommand PegasusLoadFlow::choose(LoadChoice choice, const LoadFacts &facts)
{
if (!choosable() || facts.health_ok)
+7
View File
@@ -91,6 +91,13 @@ class PegasusLoadFlow
LoadCommand choose(LoadChoice choice, const LoadFacts &facts);
// Settings changed the mode.
void set_mode(PegasusStartMode mode);
// Circle while loading (B-18): back to the choices at once. LoadCommand::cancel
// when a job runs (the runner stops it and ignores its result).
LoadCommand cancel_by_player();
bool cancellable() const
{
return busy() || state_ == LoadState::loader_wait || state_ == LoadState::waiting_for_pegasus;
}
// What the runner saw.
void on_downloading();
+128 -6
View File
@@ -7,6 +7,7 @@
#include "orbit/i18n.hpp"
#include "orbit/json.hpp"
#include "orbit/pegasus_load_flow.hpp" // sha256_file
#include "orbit/pegasus_patch.hpp"
#include "orbit/pegasus_version.hpp"
#include <arpa/inet.h>
@@ -659,8 +660,9 @@ bool PegasusManager::start_job(PegasusPhase phase, std::function<void()> body)
{
{
Lock l(mutex_);
if (!status_.attached || status_.phase != PegasusPhase::idle)
return false;
if (!status_.attached || status_.phase != PegasusPhase::idle || running_)
return false; // running_: a cancelled job has not returned yet
running_ = true;
status_.phase = phase;
status_.message.clear();
status_.message_is_error = false;
@@ -670,9 +672,16 @@ bool PegasusManager::start_job(PegasusPhase phase, std::function<void()> body)
cancel_ = false;
}
thread_.join();
if (!thread_.start(std::move(body), 512u << 10))
auto run = [this, body = std::move(body)]
{
body();
Lock l(mutex_);
running_ = false;
};
if (!thread_.start(std::move(run), 512u << 10))
{
Lock l(mutex_);
running_ = false;
status_.phase = PegasusPhase::idle;
status_.message = tr("Could not start the background job");
status_.message_is_error = true;
@@ -684,6 +693,8 @@ bool PegasusManager::start_job(PegasusPhase phase, std::function<void()> body)
void PegasusManager::finish(const std::string &message, bool is_error)
{
Lock l(mutex_);
if (cancel_)
return; // cancel() already ended the job: a late result changes nothing
status_.phase = PegasusPhase::idle;
status_.message = message;
status_.message_is_error = is_error;
@@ -703,7 +714,12 @@ void PegasusManager::wait_idle()
void PegasusManager::cancel()
{
Lock l(mutex_);
if (status_.phase == PegasusPhase::idle)
return; // nothing runs: a later job must not start cancelled
cancel_ = true;
status_.message = status_.phase == PegasusPhase::downloading ? tr("Download cancelled") : tr("Stopped");
status_.message_is_error = status_.phase == PegasusPhase::downloading;
status_.phase = PegasusPhase::idle;
}
void PegasusManager::auto_check()
@@ -768,6 +784,8 @@ void PegasusManager::run_check()
error = tr("The release list could not be read");
}
Lock l(mutex_);
if (cancel_)
return; // Circle: the late answer changes nothing
status_.phase = PegasusPhase::idle;
status_.check_error = error;
if (!error.empty())
@@ -901,6 +919,8 @@ void PegasusManager::run_download(PegasusRelease release)
return;
}
Lock l(mutex_);
if (cancel_)
return; // Circle came first: the status stays "cancelled"
status_.phase = PegasusPhase::idle;
status_.downloaded = true;
status_.path = final_path;
@@ -976,6 +996,8 @@ void PegasusManager::run_verify_send(PegasusRelease release)
bool answered = false;
{
Lock l(mutex_);
if (cancel_)
return; // Circle before the send: nothing is sent
answered = online_now_; // the health re-check, immediately before the send
if (!answered)
{
@@ -989,7 +1011,9 @@ void PegasusManager::run_verify_send(PegasusRelease release)
finish(tr("Pegasus DL answered, so nothing was sent."), false);
return;
}
run_send(path);
std::string note;
const std::string send_path = apply_experimental_patch(path, &note);
run_send(send_path, note);
}
bool PegasusManager::send_to_loader(bool pegasus_online)
@@ -1001,10 +1025,102 @@ bool PegasusManager::send_to_loader(bool pegasus_online)
return false;
path = status_.path;
}
return start_job(PegasusPhase::sending, [this, path] { run_send(path); });
return start_job(PegasusPhase::sending,
[this, path]
{
std::string note;
const std::string send_path = apply_experimental_patch(path, &note);
run_send(send_path, note);
});
}
void PegasusManager::run_send(std::string path)
// Experimental, off unless config_.experimental_patch_pegasus: writes a patched sibling of the
// verified `stock_path` next to it (never overwrites the original) and returns its path, or
// `stock_path` unchanged when the config flag is off, no catalogue patch matched this exact file, or
// anything about writing the copy failed. A send never blocks on this; worst case it sends the same
// verified stock file it always did.
std::string PegasusManager::apply_experimental_patch(const std::string &stock_path, std::string *note) const
{
if (!config_.experimental_patch_pegasus)
return stock_path;
// This build compiles with -fno-exceptions (confirmed from the actual target compile command),
// so there is no try/catch to fall back on here: an allocation failure anywhere below aborts
// directly rather than throwing. The re-read-and-verify step after the write is the hardening
// that IS possible under that constraint - it catches a torn/short write or filesystem
// corruption before anything downstream (the send, the loader) ever sees a bad file.
{
std::string data;
if (!hui::save::read_file(stock_path, &data, kPegasusDownloadCap))
{
*note = tr("experimental patch: could not read the verified file, sent unchanged");
return stock_path;
}
std::vector<std::uint8_t> bytes(data.begin(), data.end());
data.clear();
data.shrink_to_fit(); // drop the string copy before the byte-vector copies below
pegasus_patch::Result result;
std::vector<std::uint8_t> patched =
pegasus_patch::apply(bytes, pegasus_patch::builtin_catalogue(), &result);
bytes.clear();
bytes.shrink_to_fit(); // one fewer ~20MB buffer alive during apply_structural's own copy
// P1 (the structural writer-thread patch) runs on whatever P0 produced; the two touch
// disjoint regions of the file, so order does not matter. Independent of whether P0
// matched - either, both or neither may apply to a given build.
pegasus_patch::StructuralResult structural;
patched = pegasus_patch::apply_structural(patched, pegasus_patch::builtin_structural_catalogue(), &structural);
std::vector<std::string> applied = result.applied;
if (structural.changed)
applied.push_back(structural.id);
if (applied.empty())
{
*note = tr("experimental patch: no catalogue entry matched this exact build, sent unchanged");
return stock_path;
}
std::string ids;
for (const std::string &id : applied)
ids += (ids.empty() ? "" : ", ") + id;
const std::string out_path = stock_path + ".patched.elf";
const std::string part = out_path + ".part";
FILE *f = std::fopen(part.c_str(), "wb");
bool wrote = f != nullptr;
if (f != nullptr)
{
wrote = std::fwrite(patched.data(), 1, patched.size(), f) == patched.size();
wrote = (std::fflush(f) == 0) && wrote;
wrote = (std::fclose(f) == 0) && wrote;
}
if (wrote)
{
std::remove(out_path.c_str());
wrote = std::rename(part.c_str(), out_path.c_str()) == 0;
}
if (!wrote)
{
std::remove(part.c_str());
*note = tr("experimental patch: {ids} matched but the patched copy could not be saved, sent "
"unchanged",
{{"ids", ids}});
return stock_path;
}
// Re-read the file we just wrote and verify, byte for byte, that it matches what we
// intended to write (catches a torn/short write, a filesystem that silently truncates, or
// disk corruption on this specific console, before anything downstream ever touches it).
std::string verify;
if (!hui::save::read_file(out_path, &verify, kPegasusDownloadCap) || verify.size() != patched.size() ||
!std::equal(verify.begin(), verify.end(), reinterpret_cast<const char *>(patched.data())))
{
std::remove(out_path.c_str());
*note = tr("experimental patch: {ids} matched but the saved copy failed verification, sent "
"unchanged",
{{"ids", ids}});
return stock_path;
}
*note = tr("experimental patch applied: {ids}", {{"ids", ids}});
return out_path;
}
}
void PegasusManager::run_send(std::string path, std::string note)
{
std::string error;
const std::string where = config_.loader_host + ":" + std::to_string(config_.loader_port);
@@ -1020,6 +1136,8 @@ void PegasusManager::run_send(std::string path)
{
{
Lock l(mutex_);
if (cancel_)
return;
status_.fail = error == "connection refused" ? PegasusFail::no_loader : PegasusFail::send;
}
finish(tr("Could not send to the payload loader at {where} ({reason}). Send the file with your usual "
@@ -1029,10 +1147,14 @@ void PegasusManager::run_send(std::string path)
return;
}
Lock l(mutex_);
if (cancel_)
return; // Circle came first: the screen already went back
status_.phase = PegasusPhase::idle;
status_.sent = true;
status_.message = tr("Sent to the payload loader at {where}. Wait for Wings to say Pegasus DL is running.",
{{"where", where}});
if (!note.empty())
status_.message += " \xC2\xB7 " + note;
status_.message_is_error = false;
}
+15 -2
View File
@@ -187,6 +187,11 @@ struct PegasusManagerConfig
std::string loader_host = "127.0.0.1";
std::uint16_t loader_port = kPegasusLoaderPort;
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
// Experimental, off by default everywhere: before a send, try the patches in
// pegasus_patch.hpp against the verified stock file. A patch only ever applies to the exact
// source SHA-256 it was derived from; anything else (a different Pegasus build, a pattern that
// moved) sends the stock file unchanged. docs/research/pegasus-dl-patch.md has the detail.
bool experimental_patch_pegasus = false;
};
class PegasusManager
@@ -207,7 +212,10 @@ class PegasusManager
bool download_latest(); // the player confirmed; only a downloadable `latest`
// pegasus_online: Pegasus DL answers on 6970 right now; then the send is refused.
bool send_to_loader(bool pegasus_online);
void cancel(); // stops a download between reads
// Circle (B-18): any job (check, download, verify, send) ends now: the phase is
// idle at once and the job's late result changes nothing. A download also stops
// between reads. A new job waits until the abandoned one has returned.
void cancel();
// First-run flow (pegasus_offer.cpp): any listed release, and a send that first re-checks the file
// on disk against the published SHA-256. Both refuse when a job runs; the send refuses while
// Pegasus answers.
@@ -225,8 +233,12 @@ class PegasusManager
private:
void run_check();
void run_download(PegasusRelease release);
void run_send(std::string path);
void run_send(std::string path, std::string note = {});
void run_verify_send(PegasusRelease release);
// Experimental (config_.experimental_patch_pegasus only): a patched sibling of the verified
// `stock_path`, or `stock_path` itself unchanged when nothing applied or anything went wrong.
// Never fails the send; *note describes what happened, for the caller to report.
std::string apply_experimental_patch(const std::string &stock_path, std::string *note) const;
void finish(const std::string &message, bool is_error);
bool gate(std::string *why) const;
void refresh_downloaded_locked();
@@ -238,6 +250,7 @@ class PegasusManager
PegasusStatus status_;
std::vector<PegasusRelease> releases_;
bool cancel_ = false;
bool running_ = false; // under mutex_: a job thread has not returned yet
bool online_now_ = false; // the last note_pegasus_online()
std::int64_t attempt_at_ = 0; // the last background check, so a failure is not retried every frame
Thread thread_;
+14 -1
View File
@@ -3,6 +3,7 @@
#include "orbit/pegasus_offer.hpp"
#include "orbit/busy_cancel.hpp"
#include "orbit/i18n.hpp"
#include "orbit/pegasus_manager.hpp"
#include "orbit/pegasus_offer_layout.hpp"
@@ -222,6 +223,16 @@ OfferOutcome PegasusOffer::update(const hui::InputFrame &input, bool pegasus_scr
pm.cancel();
stage_ = Stage::none;
}
// Circle while downloading, verifying, sending or waiting (B-18): back to the
// choices at once; the job's late result changes nothing (PegasusManager::cancel).
if (pegasus_screen && focused && !online && flow_.cancellable() && input.is_pressed(hui::Action::back))
{
if (flow_.cancel_by_player() == LoadCommand::cancel)
pm.cancel();
stage_ = Stage::none;
detail_.clear();
feedback.play(hui::audio::Cue::back);
}
if (pegasus_screen && focused && flow_.choosable() && !online)
{
@@ -373,7 +384,9 @@ void PegasusOffer::draw(const ui::Type &type, hui::gfx::DrawList &list, double c
if (!visible())
return;
constexpr float kLineY = 790.0f;
const std::string line = status_line();
std::string line = status_line();
if (!line.empty() && flow_.cancellable())
line = with_stop_hint(line.c_str()); // B-18: Circle goes back to the choices
if (!line.empty())
ui::text_fit(list, type.regular, line, 960.0f, kLineY, 23.0f, 1500.0f,
flow_.retryable() ? ui::color::tier_red : ui::color::text2,
+523
View File
@@ -0,0 +1,523 @@
// Wings TV app - Experimental: byte-level patches to a verified pegasus_dl.elf (see pegasus_patch.hpp).
// SPDX-License-Identifier: GPL-3.0-or-later
#include "orbit/pegasus_patch.hpp"
#include "orbit/pegasus_manager.hpp" // sha256_hex
#include <algorithm>
namespace orbit::pegasus_patch
{
bool hex_decode(std::string_view hex, std::vector<std::uint8_t> *out)
{
out->clear();
if (hex.size() % 2 != 0)
return false;
out->reserve(hex.size() / 2);
const auto nibble = [](char c) -> int
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
};
for (std::size_t i = 0; i < hex.size(); i += 2)
{
const int hi = nibble(hex[i]);
const int lo = nibble(hex[i + 1]);
if (hi < 0 || lo < 0)
{
out->clear();
return false;
}
out->push_back(static_cast<std::uint8_t>((hi << 4) | lo));
}
return true;
}
namespace
{
// Every offset in `data` where `pattern` matches under `mask` ('?' = any byte). Stops early past
// `limit` matches (the catalogue only ever needs to know "0", "1" or "more than 1").
std::vector<std::size_t> find_matches(const std::vector<std::uint8_t> &data, const std::vector<std::uint8_t> &pattern,
std::string_view mask, std::size_t limit)
{
std::vector<std::size_t> hits;
if (pattern.empty() || pattern.size() != mask.size() || data.size() < pattern.size())
return hits;
const std::size_t last = data.size() - pattern.size();
for (std::size_t at = 0; at <= last; ++at)
{
bool ok = true;
for (std::size_t i = 0; i < pattern.size() && ok; ++i)
ok = mask[i] == '?' || data[at + i] == pattern[i];
if (ok)
{
hits.push_back(at);
if (hits.size() >= limit)
break;
}
}
return hits;
}
} // namespace
const std::vector<Patch> &builtin_catalogue()
{
// P0 - the per-segment write buffer in download_segmented()/segment_write()/segment_flush() is
// capped at 256 KiB (0x00040000) before each pwrite(). Orbit Store's equivalent path holds up to
// 8 MiB total, split across the active ranges, written by a separate thread. This patch only
// raises the cap (to 2 MiB, Orbit's per-range share at 4 connections) so a flush needs 1/8th as
// many pwrite() calls; it does NOT move the write off the socket-servicing thread (that is P1,
// still a design in docs/research/pegasus-dl-patch.md, not implemented here).
//
// Anchor (16 bytes, unique in the v1.11.0 ELF this was derived from - see the doc for how to
// re-derive it for a later release):
// 48 81 FF 00 00 04 00 cmp rdi, 0x40000
// B8 00 00 04 00 mov eax, 0x40000
// 48 0F 43 F8 cmovae rdi, rax
static const std::vector<Patch> kCatalogue = {
Patch{
"p0-segment-buffer-2m",
"Raise the 256 KiB per-segment write buffer to 2 MiB (Orbit Store's per-range share at 4 "
"connections), so each flush needs 8x fewer pwrite() calls. Diagnostic only; does not move "
"the write off the network thread.",
"32b69c2089bb545abfabd04d5827444698f9c4f1bc4915d72247cb781e8c23d9",
"4881ff00000400b800000400480f43f8",
"xxxxxxxxxxxxxxxx",
"4881ff00002000b800002000480f43f8",
},
};
return kCatalogue;
}
std::vector<std::uint8_t> apply(const std::vector<std::uint8_t> &data, const std::vector<Patch> &catalogue,
Result *out_result, bool require_all)
{
Result local;
Result &result = out_result != nullptr ? *out_result : local;
result = Result{};
std::vector<std::uint8_t> out = data;
if (data.empty())
{
result.error = "empty input";
return out;
}
const std::string source_hash =
sha256_hex(std::string_view(reinterpret_cast<const char *>(data.data()), data.size()));
for (const Patch &patch : catalogue)
{
if (patch.source_sha256.empty() || patch.pattern_hex.empty())
{
if (require_all)
{
result.error = "patch '" + patch.id + "' is incomplete";
return data;
}
continue;
}
if (source_hash != patch.source_sha256)
continue; // a different Pegasus build: never guess, always leave it alone
std::vector<std::uint8_t> pattern, replacement;
if (!hex_decode(patch.pattern_hex, &pattern) || !hex_decode(patch.replacement_hex, &replacement) ||
pattern.size() != patch.mask.size() || pattern.size() != replacement.size())
{
if (require_all)
{
result.error = "patch '" + patch.id + "' has a malformed pattern";
return data;
}
continue;
}
const std::vector<std::size_t> hits = find_matches(out, pattern, patch.mask, 2);
if (hits.size() != 1)
{
if (require_all)
{
result.error = "patch '" + patch.id + "' anchor is " +
(hits.empty() ? "absent" : "not unique") + " in this build";
return data;
}
continue; // 0 or >1 matches: this build is not the one byte-for-byte shape expected
}
std::copy(replacement.begin(), replacement.end(), out.begin() + static_cast<std::ptrdiff_t>(hits[0]));
result.applied.push_back(patch.id);
result.changed = true;
}
return result.changed ? out : data;
}
namespace
{
std::uint64_t read_u64(const std::vector<std::uint8_t> &d, std::size_t at)
{
std::uint64_t v = 0;
for (int i = 7; i >= 0; --i)
v = (v << 8) | d[at + static_cast<std::size_t>(i)];
return v;
}
std::uint32_t read_u32(const std::vector<std::uint8_t> &d, std::size_t at)
{
std::uint32_t v = 0;
for (int i = 3; i >= 0; --i)
v = (v << 8) | d[at + static_cast<std::size_t>(i)];
return v;
}
std::uint16_t read_u16(const std::vector<std::uint8_t> &d, std::size_t at)
{
return static_cast<std::uint16_t>(d[at] | (d[at + 1] << 8));
}
void write_u64(std::vector<std::uint8_t> &d, std::size_t at, std::uint64_t v)
{
for (int i = 0; i < 8; ++i)
d[at + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(v >> (8 * i));
}
void write_u32(std::vector<std::uint8_t> &d, std::size_t at, std::uint32_t v)
{
for (int i = 0; i < 4; ++i)
d[at + static_cast<std::size_t>(i)] = static_cast<std::uint8_t>(v >> (8 * i));
}
// One attempt at `patch`; true and *out mutated on success, false (and *out untouched) on any
// mismatch - never a partial edit.
bool try_apply_one(const std::vector<std::uint8_t> &in, const StructuralPatch &patch,
std::vector<std::uint8_t> *out, std::string *why)
{
if (in.size() < 64)
{
*why = "file too small to be an ELF";
return false;
}
const std::uint64_t e_phoff = read_u64(in, 32);
const std::uint16_t e_phentsize = read_u16(in, 54);
const std::uint16_t e_phnum = read_u16(in, 56);
if (e_phentsize != 56 || patch.phdr_index >= e_phnum ||
e_phoff > in.size() || static_cast<std::uint64_t>(e_phnum) * e_phentsize > in.size() - e_phoff)
{
*why = "program header table does not look as expected";
return false;
}
const std::size_t ph = static_cast<std::size_t>(e_phoff) + patch.phdr_index * e_phentsize;
const std::uint32_t p_type = read_u32(in, ph);
const std::uint32_t p_flags = read_u32(in, ph + 4);
const std::uint64_t p_offset = read_u64(in, ph + 8);
const std::uint64_t p_vaddr = read_u64(in, ph + 16);
const std::uint64_t p_filesz = read_u64(in, ph + 32);
const std::uint64_t p_memsz = read_u64(in, ph + 40);
if (p_type != 1 || p_offset != patch.expect_p_offset || p_vaddr != patch.expect_p_vaddr ||
p_filesz != patch.expect_p_filesz || p_memsz != patch.expect_p_memsz)
{
*why = "target PT_LOAD entry does not match exactly";
return false;
}
if (patch.call_site_va < patch.text_p_vaddr)
{
*why = "call-site VA precedes its expected text segment";
return false;
}
const std::uint64_t call_site_off = patch.call_site_va - patch.text_p_vaddr + patch.text_p_offset;
if (call_site_off > in.size() || in.size() - call_site_off < 6 ||
in[call_site_off] != 0xFF || in[call_site_off + 1] != 0x15)
{
*why = "call site is not the expected FF 15 indirect call";
return false;
}
const std::int32_t old_disp = static_cast<std::int32_t>(read_u32(in, call_site_off + 2));
const std::int64_t old_target = static_cast<std::int64_t>(patch.call_site_va) + 6 + old_disp;
if (old_target < 0 || static_cast<std::uint64_t>(old_target) != patch.call_site_expected_target_va)
{
*why = "call site no longer targets the expected address";
return false;
}
std::vector<std::uint8_t> blob;
if (!hex_decode(patch.blob_hex, &blob) || blob.empty())
{
*why = "patch blob is malformed or empty";
return false;
}
if (p_filesz > UINT64_MAX - blob.size() || p_offset > UINT64_MAX - p_filesz)
{
*why = "PT_LOAD size arithmetic overflow";
return false;
}
// The PT_LOAD's file-backed content ends exactly where the zero-filled cave begins.
// Overlay there instead of inserting bytes: inserting would shift later segments and ELF
// metadata. Require the whole cave to be in-file, all-zero, and free of any other PT_LOAD.
const std::uint64_t cave_off = p_offset + p_filesz;
if (cave_off > UINT64_MAX - blob.size())
{
*why = "patch cave offset overflows";
return false;
}
const std::uint64_t cave_end_off = cave_off + blob.size();
const std::uint64_t code_va = p_vaddr + p_filesz;
const std::uint64_t code_end_va = code_va + blob.size();
if (cave_end_off > in.size() || code_end_va < code_va)
{
*why = "patch blob does not fit in the file-backed cave";
return false;
}
if (!std::all_of(in.begin() + static_cast<std::ptrdiff_t>(cave_off),
in.begin() + static_cast<std::ptrdiff_t>(cave_end_off),
[](std::uint8_t b) { return b == 0; }))
{
*why = "executable cave is not all-zero padding";
return false;
}
for (std::uint16_t i = 0; i < e_phnum; ++i)
{
if (i == patch.phdr_index)
continue;
const std::size_t other_ph = static_cast<std::size_t>(e_phoff) + i * e_phentsize;
if (read_u32(in, other_ph) != 1) // only PT_LOAD ranges matter here
continue;
const std::uint64_t other_off = read_u64(in, other_ph + 8);
const std::uint64_t other_va = read_u64(in, other_ph + 16);
const std::uint64_t other_filesz = read_u64(in, other_ph + 32);
const std::uint64_t other_memsz = read_u64(in, other_ph + 40);
if (other_off <= UINT64_MAX - other_filesz &&
cave_off < other_off + other_filesz && other_off < cave_end_off)
{
*why = "patch cave overlaps another PT_LOAD file range";
return false;
}
if (other_va <= UINT64_MAX - other_memsz &&
code_va < other_va + other_memsz && other_va < code_end_va)
{
*why = "patch code overlaps another PT_LOAD memory range";
return false;
}
}
const std::uint64_t new_filesz = p_filesz + blob.size();
const std::uint64_t new_memsz = std::max(p_memsz, new_filesz);
if (new_memsz < new_filesz || p_vaddr > UINT64_MAX - new_memsz)
{
*why = "new PT_LOAD size overflows";
return false;
}
const std::int64_t new_disp =
static_cast<std::int64_t>(code_va) - static_cast<std::int64_t>(patch.call_site_va + 5);
if (new_disp < -(1LL << 31) || new_disp >= (1LL << 31))
{
*why = "CALL rel32 displacement out of range";
return false;
}
// P0 and P1 must compose regardless of application order. P0's ordinary patch may already
// have changed this one anchor, or P1 can apply it here atomically with the structural edit.
std::vector<std::uint8_t> p0_before, p0_after;
bool p0_already_applied = false;
if (!patch.p0_before_hex.empty() || !patch.p0_after_hex.empty())
{
if (!hex_decode(patch.p0_before_hex, &p0_before) ||
!hex_decode(patch.p0_after_hex, &p0_after) || p0_before.empty() ||
p0_before.size() != p0_after.size() ||
patch.p0_file_offset > in.size() || in.size() - patch.p0_file_offset < p0_before.size())
{
*why = "P0 companion anchor is malformed";
return false;
}
const std::string exact_mask(p0_before.size(), 'x');
const auto before_hits = find_matches(in, p0_before, exact_mask, 2);
const auto after_hits = find_matches(in, p0_after, exact_mask, 2);
if (before_hits.size() == 1 && before_hits[0] == patch.p0_file_offset && after_hits.empty())
p0_already_applied = false;
else if (after_hits.size() == 1 && after_hits[0] == patch.p0_file_offset && before_hits.empty())
p0_already_applied = true;
else
{
*why = "P0 companion anchor is absent, duplicated, or not at the verified offset";
return false;
}
}
// Format v2: validate EVERY extra E8 call redirect against the live file before any byte is
// changed, so one bad site refuses the whole patch (never a partial P1).
struct ResolvedRedirect
{
std::uint64_t off;
std::int32_t disp;
};
const auto ranges_overlap = [](std::uint64_t a, std::uint64_t alen, std::uint64_t b, std::uint64_t blen)
{ return a < b + blen && b < a + alen; };
std::vector<ResolvedRedirect> redirects;
for (const DirectCallRedirect &r : patch.direct_call_redirects)
{
if (r.va < patch.text_p_vaddr || r.blob_entry_offset >= blob.size())
{
*why = "direct call redirect is outside its text segment or the blob";
return false;
}
const std::uint64_t off = r.va - patch.text_p_vaddr + patch.text_p_offset;
if (off > in.size() || in.size() - off < 5 || in[off] != 0xE8)
{
*why = "direct call redirect site is not the expected E8 call";
return false;
}
const std::int64_t old_target =
static_cast<std::int64_t>(r.va) + 5 + static_cast<std::int32_t>(read_u32(in, off + 1));
if (old_target < 0 || static_cast<std::uint64_t>(old_target) != r.expected_target_va)
{
*why = "direct call redirect site no longer targets the expected address";
return false;
}
const std::int64_t disp = static_cast<std::int64_t>(code_va + r.blob_entry_offset) -
static_cast<std::int64_t>(r.va + 5);
if (disp < -(1LL << 31) || disp >= (1LL << 31))
{
*why = "direct call redirect displacement out of range";
return false;
}
bool clash = ranges_overlap(off, 5, call_site_off, 6) || ranges_overlap(off, 5, cave_off, blob.size());
if (!p0_before.empty())
clash = clash || ranges_overlap(off, 5, patch.p0_file_offset, p0_before.size());
for (const ResolvedRedirect &prev : redirects)
clash = clash || ranges_overlap(off, 5, prev.off, 5);
if (clash)
{
*why = "direct call redirect overlaps another edit";
return false;
}
redirects.push_back({off, static_cast<std::int32_t>(disp)});
}
std::vector<std::uint8_t> result = in;
if (!p0_already_applied && !p0_before.empty())
std::copy(p0_after.begin(), p0_after.end(),
result.begin() + static_cast<std::ptrdiff_t>(patch.p0_file_offset));
std::copy(blob.begin(), blob.end(), result.begin() + static_cast<std::ptrdiff_t>(cave_off));
result[call_site_off] = 0xE8; // CALL rel32
write_u32(result, static_cast<std::size_t>(call_site_off + 1), static_cast<std::uint32_t>(new_disp));
result[call_site_off + 5] = 0x90; // NOP: preserve the original six-byte instruction footprint
for (const ResolvedRedirect &r : redirects) // format v2: same-length E8 -> E8, new rel32 only
write_u32(result, static_cast<std::size_t>(r.off + 1), static_cast<std::uint32_t>(r.disp));
// Segment #0 is already RWE; preserve p_flags and every section/header/tail byte.
(void)p_flags;
write_u64(result, ph + 32, new_filesz);
write_u64(result, ph + 40, new_memsz);
*out = std::move(result);
return true;
}
} // namespace
const std::vector<StructuralPatch> &builtin_structural_catalogue()
{
// P1 is hash-gated to the exact stock ELF. The embedded blob is relocation-resolved code
// generated from w1.c plus the zero-initialized g_w1 state; the ring itself is host-malloc'd.
static const std::vector<StructuralPatch> kCatalogue = {
StructuralPatch{
"p1-writer-thread",
"Redirect segment_flush's pwrite() to a bounded ring-buffered background writer thread, "
"and drain it (reporting async write failures) before fsync/close. "
"Experimental.",
"32b69c2089bb545abfabd04d5827444698f9c4f1bc4915d72247cb781e8c23d9",
/*phdr_index*/ 0,
/*expect p_offset/p_vaddr/p_filesz/p_memsz*/ 0x4000, 0x0, 0x5f1a65, 0x5f1a65,
/*call_site_va*/ 0x4e22b,
/*call_site_expected_target_va*/ 0x7ef838,
/*text_p_offset/text_p_vaddr*/ 0x4000, 0x0,
/*already-resolved code + zeroed g_w1 state*/
"554889e54157415641554154534883ec3848894dc0488955c84189ff8b055106000083f8020f84c0010000b901000000"
"31c0f00fb10d390600000f85a00100004989f6488d0589dd1f00488b1848891db7050000488d0578da1f004c8b184c89"
"1dae050000488d0567dc1f004c8b104c8915a5050000488d0586d91f004c8b084c890d9c050000488d058dd91f004c8b"
"004c890593050000488d05a4df1f00488b3848893d8a050000488d054bdc1f00488b3048893581050000488d058adf1f"
"00488b1048891578050000488d05c1d91f00488b0848890d6f050000488d0590d91f00488b00488905660500004885db"
"0f84cc0000004d85db0f84c30000004d85d20f84ba0000004d85c90f84b10000004d85c00f84a80000004885ff0f849f"
"0000004885f60f84960000004885d20f848d0000004885c90f84840000004885c0747f488d05f1d81f00488b004885c0"
"7470bfc0008000ffd0488905ab0500004885c0745d488d3def04000031f6ff15a704000085c07532488d3de404000031"
"f6ff15ac04000085c0751f488d3dd9040000488d15ea01000031f631c9ff15700400004c89f685c07425488b3d5a0500"
"00ff159404000048c7054905000000000000c705ab040000010000004c89f6c7059a04000002000000eb100f1f440000"
"8b058d04000083f80275f58b058604000085c0741931c0488945d0488b45d04883c4385b415c415d415e415f5dc34181"
"ffff0300007622488b05ed0300004489ff488b55c8488b4dc04883c4385b415c415d415e415f5dffe0488975b84489f8"
"c1e806488945a0b8010000004c897db0c4e281f7c0488945a84531f64c8d2df80300004c8d3df9030000eb1a0f1f4000"
"4c89efff15b20300004c8975d04885db0f8575ffffff4c8b65c84d29f40f86e70000004981fc00001000b8000010004c"
"0f43e04c89efff1577030000833d5004000008752066662e0f1f8400000000004c89ff4c89eeff156f030000833d3004"
"00000874eb488b45a0488d0d2b030000488b5cc17048235da87585488b0d190400008b05070400004869d01800100048"
"8b45b0890411488b45c04c01f0488944110844896411104585e47424488b45b84c01f0488d4c111431d2660f1f440000"
"0fb634104088341148ffc24939d475f08b05b9030000ffc083e0078905ae030000ff05ac0300004c89ffff15e3020000"
"4c89efff15c20200004d01e64885db0f8411ffffffe981feffff488b45c8e974feffff554889e5415741564155415453"
"4883ec28488d1dc00200004c8d35c1020000eb3f6666666666662e0f1f8400000000008b0542030000ffc083e0078905"
"37030000ff0d390300004c8d35920200004c89f7ff15690200004889dfff15480200004889dfff1537020000833d1003"
"000000752366666666662e0f1f8400000000004c89f74889deff152c020000833ded0200000074eb4889df488b1de902"
"00008b05d30200004c69f018001000468b3c334a8b443308488945b8428b4433108945d44489f9c1e9064489fa83e23f"
"b801000000488955c8c462e9f7e8488d05a601000048894dc04c236cc870ff15b70100004e8d7433144531e431db0f1f"
"4400004d85ed75398b45d489c229da763089d9498d340e48034db84489ffff15670100004885c089c1ba000000000f4e"
"ca01cb4885c0b801000000440f4ee07fc2488d1d930100004889dfff15520100004585e40f84d9feffffb80100000048"
"8b4dc8c4e2f1f7c0488d0d1c010000488b55c0480944d170e9b6feffff554889e541574156535089fb8b056401000085"
"c00f84dc000000660f1f44000083f801750b8b054b01000083f80174f58b054401000085c07417488b05150100004885"
"c00f848f000000ffd0e982000000488d3d06010000ff15c8000000833da101000000742e4c8d35f80000004c8d3de900"
"000066662e0f1f8400000000004c89f74c89feff15b2000000833d730100000075eb81fbff030000774c89d8c1e80648"
"8d0d65000000488b54c170480fa3da733583e33f480fb3da488954c170488d3d97000000ff1561000000ff1583000000"
"c70005000000b8ffffffff4883c4085b415e415f5dc3488d3d6e000000ff153800000089df4883c4085b415e415f5de9"
"07b8a5ff9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000000000",
/*known P0-only source hash*/ "9e13e060f8810f1c2dda4e25f6169f4a34deb02d889556dfeac8f4a224d7d5bf",
/*P0 file offset*/ 0x4FE77,
/*P0 before/after*/ "4881ff00000400b800000400480f43f8",
"4881ff00002000b800002000480f43f8",
// Both `call sync_segment_output` sites in download_segmented() -> w1_sync (the
// completion barrier). Entry offset = w1_sync's offset in the blob.
/*direct-call redirects*/
{
{0x4c1ae, 0x4d870, 0x4fd},
{0x4c24a, 0x4d870, 0x4fd},
}
},
};
return kCatalogue;
}
std::vector<std::uint8_t> apply_structural(const std::vector<std::uint8_t> &data,
const std::vector<StructuralPatch> &catalogue,
StructuralResult *out_result, bool require_all)
{
StructuralResult local;
StructuralResult &result = out_result != nullptr ? *out_result : local;
result = StructuralResult{};
if (data.empty())
{
result.error = "empty input";
return data;
}
const std::string source_hash =
sha256_hex(std::string_view(reinterpret_cast<const char *>(data.data()), data.size()));
for (const StructuralPatch &patch : catalogue)
{
if (source_hash != patch.source_sha256 &&
(patch.source_sha256_p0.empty() || source_hash != patch.source_sha256_p0))
continue;
std::vector<std::uint8_t> patched;
std::string why;
if (!try_apply_one(data, patch, &patched, &why))
{
if (require_all)
{
result.error = "patch '" + patch.id + "': " + why;
return data;
}
continue;
}
result.changed = true;
result.id = patch.id;
return patched; // one structural patch per file; there is exactly one in the catalogue
}
return data;
}
} // namespace orbit::pegasus_patch
+136
View File
@@ -0,0 +1,136 @@
// Wings TV app - Experimental: byte-level patches to a verified pegasus_dl.elf (see pegasus_patch.cpp).
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Off by default everywhere. Nothing here runs unless the caller explicitly asks for a named patch
// set, and every patch is matched by an exact source SHA-256 plus a byte pattern that must occur
// EXACTLY once in the file; anything else (wrong version, pattern missing, pattern not unique) is
// refused, never guessed. The caller always has the original verified bytes and can fall back to
// sending those unchanged.
//
// This exists to test one hypothesis (docs/research/pegasus-dl-patch.md): Pegasus DL's segmented
// downloader writes each range's buffer with a direct pwrite() on the thread that also drives every
// socket in the batch, so a slow write can stall transfers that have nothing to do with it. Orbit
// Store decouples this with a ring buffer and a dedicated writer thread. The patches below are a
// diagnostic, not a redistributed binary: Wings never ships a patched pegasus_dl.elf, it only patches
// the player's own already-downloaded, already-verified copy, in memory, when the player turns this
// on in an experimental build.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace orbit::pegasus_patch
{
// One edit: `pattern` (with `mask`, 'x' = must match, '?' = any byte) must occur in `data` exactly
// once, at a file offset inside the loaded .text segment; `replacement` is written over the matched
// bytes (same length). `source_sha256` is the stock pegasus_dl.elf this was derived against; a file
// with a different hash never reaches the matcher, so a future Pegasus release always falls back to
// its own stock bytes until someone re-derives and adds a new entry (docs/research/pegasus-dl-patch.md
// says how).
struct Patch
{
std::string id; // "p0-segment-buffer-256k", stable, goes in the cache file name
std::string description; // one line, shown in diagnostics
std::string source_sha256; // lower-case 64 hex; the exact stock file this patch targets
std::string pattern_hex; // the bytes to find, hex, no spaces ("48 81 FF.." -> "4881ff..")
std::string mask; // same length as pattern_hex/2, one char per byte: 'x' or '?'
std::string replacement_hex; // same length as pattern_hex; what to write over the match
};
// The built-in catalogue. One entry today (P0, the 256 KiB segment buffer cap); new entries are
// appended as new Pegasus releases are checked, never edited in place once shipped (the source hash
// keys each one, so stale entries are simply never matched again).
const std::vector<Patch> &builtin_catalogue();
struct Result
{
bool changed = false; // true only if at least one patch matched and was applied
std::vector<std::string> applied; // ids, in catalogue order
std::string error; // set only when `changed` could not become true as asked
};
// Applies every patch in `catalogue` whose `source_sha256` matches sha256_hex(data) to a COPY of
// `data` (the input is never modified). A patch whose pattern is absent or not unique in `data` is
// skipped, not an error, unless `require_all` is true (used by the unit test, not by the manager).
// Returns the possibly-patched bytes; `out_result` (optional) says what happened.
std::vector<std::uint8_t> apply(const std::vector<std::uint8_t> &data, const std::vector<Patch> &catalogue,
Result *out_result = nullptr, bool require_all = false);
// Decodes one hex string ("4881ff") into bytes; false on odd length or a non-hex character.
bool hex_decode(std::string_view hex, std::vector<std::uint8_t> *out);
// ---- P1: adds the writer-thread module (docs/research/pegasus-dl-patch.md) ----
//
// A `Patch` is a same-length byte swap; P1 needs more than that (bytes written into a verified
// cave and a PT_LOAD's sizes grown). `StructuralPatch` is the data for that kind
// of edit; `apply_structural` is the one, generic, hash-gated engine for it - every number in a
// `StructuralPatch` is re-checked against the live file before anything is touched, and any
// mismatch (wrong hash, a program header that doesn't look exactly as expected, a call site that
// isn't the exact instruction this was derived from) refuses that entry, same rule as `Patch`.
// Format v2 (additive; an empty `direct_call_redirects` is exactly the v1 behaviour).
// A direct-call redirect repoints an existing "E8 <rel32>" call (5 bytes, no NOP needed) at an
// entry point INSIDE the blob. P1 uses two of these to put its completion barrier in front of the
// host's fsync: both `call sync_segment_output` sites in download_segmented() (VA 0x4c1ae and
// 0x4c24a) are redirected to w1_sync, which drains the write ring, reports async failures, and
// only then tail-calls the original sync function.
struct DirectCallRedirect
{
std::uint64_t va = 0; // VA of the E8 opcode, inside the text PT_LOAD
std::uint64_t expected_target_va = 0; // the callee it must currently resolve to, or refuse
std::uint64_t blob_entry_offset = 0; // entry point, as a byte offset into the blob
};
struct StructuralPatch
{
std::string id;
std::string description;
std::string source_sha256;
// The PT_LOAD entry this grows (its index in the program header table) - checked against the
// live file's e_phoff/e_phnum/e_phentsize, never assumed to be at a fixed byte offset.
std::size_t phdr_index = 0;
std::uint64_t expect_p_offset = 0, expect_p_vaddr = 0, expect_p_filesz = 0, expect_p_memsz = 0;
// The one call site this redirects: an "FF 15 <disp32>" indirect call (6 bytes) at this VA,
// which must currently resolve to `call_site_expected_target_va` or the patch refuses.
// text_p_offset`/`text_p_vaddr` locate the PT_LOAD that VA lives in (to turn it into a file
// offset), checked the same way.
std::uint64_t call_site_va = 0, call_site_expected_target_va = 0;
std::uint64_t text_p_offset = 0, text_p_vaddr = 0;
// Already relocation-resolved code plus the zero-initialized g_w1 state object. The blob
// overlays the verified all-zero padding immediately after this PT_LOAD's current file content;
// the engine grows this PT_LOAD's p_filesz/p_memsz without shifting or truncating the ELF.
std::string blob_hex;
// P0 can be applied before or after P1. Accept the known P0-only hash as well as stock,
// and ensure the P0 anchor is in the corresponding before/after state as part of P1.
std::string source_sha256_p0;
std::size_t p0_file_offset = 0;
std::string p0_before_hex;
std::string p0_after_hex;
// Format v2: extra E8 call redirects into the blob (see DirectCallRedirect). Every site is
// validated - exact opcode, exact original target, in-file, inside the blob, no overlap with
// another edit - before ANY byte of the file is modified; one bad entry refuses the whole patch.
std::vector<DirectCallRedirect> direct_call_redirects;
};
struct StructuralResult
{
bool changed = false;
std::string id; // empty if nothing applied
std::string error; // set only when require_all asked for a patch that could not apply
};
const std::vector<StructuralPatch> &builtin_structural_catalogue();
std::vector<std::uint8_t> apply_structural(const std::vector<std::uint8_t> &data,
const std::vector<StructuralPatch> &catalogue,
StructuralResult *out_result = nullptr, bool require_all = false);
} // namespace orbit::pegasus_patch
+622
View File
@@ -0,0 +1,622 @@
#include "orbit/pkg_install.hpp"
#include "orbit/i18n.hpp"
#include <chrono>
#include <cstdio>
#include <utility>
namespace orbit
{
namespace
{
bool under(std::string_view path, std::string_view mount)
{
while (mount.size() > 1 && mount.back() == '/')
mount.remove_suffix(1);
if (mount.empty() || path.substr(0, mount.size()) != mount)
return false;
return path.size() == mount.size() || mount == "/" || path[mount.size()] == '/';
}
const StorageMount *mount_for(std::string_view path, const std::vector<StorageMount> &mounts)
{
const StorageMount *best = nullptr;
for (const StorageMount &m : mounts)
{
if (under(path, m.path) && (best == nullptr || m.path.size() > best->path.size()))
best = &m;
}
return best;
}
bool ends_with_nocase(std::string_view text, std::string_view tail)
{
if (text.size() < tail.size())
return false;
for (std::size_t i = 0; i < tail.size(); ++i)
{
char a = text[text.size() - tail.size() + i];
if (a >= 'A' && a <= 'Z')
a = static_cast<char>(a - 'A' + 'a');
if (a != tail[i])
return false;
}
return true;
}
bool contains(std::string_view text, std::string_view part)
{
return text.find(part) != std::string_view::npos;
}
std::string gb(double bytes)
{
char buf[32];
std::snprintf(buf, sizeof buf, "%.1f GB", bytes / 1e9);
return buf;
}
double steady_seconds()
{
using namespace std::chrono;
return duration<double>(steady_clock::now().time_since_epoch()).count();
}
constexpr int kMissingPollsBeforeFail = 5;
constexpr int kConfirmChecks = 90; // about 30 minutes
constexpr double kConfirmEverySeconds = 20.0;
} // namespace
// ------------------------------------------------------------------- route --
bool pkg_space_ok(double size_bytes, const std::string &download_root,
const std::vector<StorageMount> &mounts, std::string *why)
{
if (size_bytes <= 0.0 || mounts.empty())
return true; // unknown: let Pegasus try
const StorageMount *root = mount_for(download_root, mounts);
const StorageMount *data = mount_for("/data", mounts);
const bool root_on_data = under(download_root, "/data") || under(download_root, "/user/data");
// The .pkg in the root; the installed app on internal storage; a staging copy there too
// when the root is not under /data (Pegasus copies it before installing).
const double root_need = root_on_data ? size_bytes * 2.0 : size_bytes;
const double data_need = size_bytes * 2.0;
if (root != nullptr && root->free_bytes < root_need)
{
if (why)
*why = "needs " + gb(root_need) + " free on " + root->path + ", has " + gb(root->free_bytes);
return false;
}
if (!root_on_data && data != nullptr && data->free_bytes < data_need)
{
if (why)
*why = "needs " + gb(data_need) + " free on " + data->path + ", has " + gb(data->free_bytes);
return false;
}
return true;
}
PkgRouteChoice pkg_route_before_queue(bool probe_ok, std::string_view probe_rejection,
double size_bytes, const std::string &download_root,
const std::vector<StorageMount> &mounts)
{
PkgRouteChoice out;
if (!probe_ok)
{
out.reason = "the link probe failed";
return out;
}
if (!probe_rejection.empty())
{
out.reason = "the probe refused the link: " + std::string(probe_rejection);
return out;
}
std::string why;
if (!pkg_space_ok(size_bytes, download_root, mounts, &why))
{
out.reason = "not enough free space (" + why + ")";
return out;
}
out.route = PkgRoute::direct;
out.reason = size_bytes > 0.0 ? "probe ok, " + gb(size_bytes) + " fits" : "probe ok, size unknown";
return out;
}
PkgDirectAnswer pkg_classify_direct(bool call_ok, bool native, bool queued, std::int64_t job_id)
{
if (!call_ok)
return PkgDirectAnswer::use_handoff;
if (native)
return PkgDirectAnswer::handed_off;
if (queued && job_id > 0)
return PkgDirectAnswer::track;
return PkgDirectAnswer::use_handoff;
}
std::string pkg_file_stem(std::string_view name)
{
const std::size_t slash = name.find_last_of("/\\");
if (slash != std::string_view::npos)
name.remove_prefix(slash + 1);
if (ends_with_nocase(name, ".fpkg"))
name.remove_suffix(5);
else if (ends_with_nocase(name, ".pkg"))
name.remove_suffix(4);
std::string out;
for (const char c : name)
{
const auto u = static_cast<unsigned char>(c);
const bool bad = u < 0x20 || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' ||
c == '>' || c == '|' || c == '/' || c == '\\';
const char put = bad ? ' ' : c;
if (put == ' ' && (out.empty() || out.back() == ' '))
continue;
out += put;
}
while (!out.empty() && (out.back() == ' ' || out.back() == '.'))
out.pop_back();
if (out.size() > 120)
out.resize(120); // may cut a UTF-8 sequence's tail; trim it back to a lead byte
while (!out.empty() && (static_cast<unsigned char>(out.back()) & 0xC0) == 0xC0)
out.pop_back();
return out.empty() ? std::string("package") : out;
}
std::string pkg_folder_of(std::string_view path)
{
const std::size_t slash = path.find_last_of('/');
if (slash == std::string_view::npos)
return std::string(path);
return std::string(path.substr(0, slash == 0 ? 1 : slash));
}
bool pkg_install_seen(const std::string &title_id, const std::vector<std::string> &before,
const std::vector<std::string> &now)
{
for (const std::string &id : now)
{
if (!title_id.empty())
{
if (id == title_id)
return true;
continue;
}
bool old = false;
for (const std::string &b : before)
old = old || b == id;
if (!old)
return true;
}
return false;
}
// ------------------------------------------------------------------- model --
void PkgInstallModel::track(const std::string &job_id, const std::string &title,
const std::string &title_id, const std::string &wanted_name)
{
Entry e;
e.job_id = job_id;
e.title = title;
e.title_id = title_id;
e.wanted_name = wanted_name;
jobs_[job_id] = std::move(e);
log("job " + job_id + ": fast download to disk, then install as \"" + wanted_name + "\"");
}
const PkgInstallModel::Entry *PkgInstallModel::find(const std::string &job_id) const
{
const auto it = jobs_.find(job_id);
return it == jobs_.end() ? nullptr : &it->second;
}
PkgInstallModel::Entry *PkgInstallModel::find_mut(const std::string &job_id)
{
const auto it = jobs_.find(job_id);
return it == jobs_.end() ? nullptr : &it->second;
}
void PkgInstallModel::fail(Entry &entry, const std::string &reason)
{
entry.stage = PkgStage::failed;
entry.error = reason;
entry.cleanup_done = true; // a failed install never deletes the file
log("job " + entry.job_id + ": install failed: " + reason + "; the file stays at " + entry.path);
}
void PkgInstallModel::observe_queue(const std::vector<PkgJobSeen> &jobs)
{
for (auto it = jobs_.begin(); it != jobs_.end();)
{
Entry &e = it->second;
if (e.stage != PkgStage::downloading)
{
++it;
continue;
}
const PkgJobSeen *seen = nullptr;
for (const PkgJobSeen &j : jobs)
{
if (j.id == e.job_id)
seen = &j;
}
if (seen == nullptr)
{
log("job " + e.job_id + ": left the queue before it finished; no install");
it = jobs_.erase(it);
continue;
}
if (seen->status == "completed")
{
if (seen->destination.empty())
{
fail(e, tr("Pegasus DL did not say where the file is"));
}
else
{
e.path = seen->destination;
const bool named = ends_with_nocase(e.path, ".pkg");
e.stage = named ? PkgStage::installing : PkgStage::renaming;
log("job " + e.job_id + ": downloaded to " + e.path);
}
}
++it;
}
}
PkgStep PkgInstallModel::next(double now_seconds) const
{
PkgStep step;
// An install in flight blocks everything else: Pegasus runs one at a time.
for (const auto &[id, e] : jobs_)
{
if (e.stage == PkgStage::installing && !e.op_id.empty())
{
step.kind = PkgStep::Kind::poll;
step.job_id = id;
return step;
}
}
for (const auto &[id, e] : jobs_)
{
if (e.stage == PkgStage::installing)
{
step.kind = PkgStep::Kind::install;
step.job_id = id;
step.path = e.path;
step.title_id = e.title_id;
return step;
}
}
for (const auto &[id, e] : jobs_)
{
if (e.stage == PkgStage::renaming)
{
step.kind = PkgStep::Kind::rename;
step.job_id = id;
step.path = e.path;
step.name = e.wanted_name;
return step;
}
}
if (keep_files_)
return step;
for (const auto &[id, e] : jobs_)
{
if (e.stage != PkgStage::installed || e.cleanup_done)
continue;
if (e.on_console)
{
step.kind = PkgStep::Kind::remove;
step.job_id = id;
step.path = e.path;
return step;
}
if (now_seconds >= e.next_confirm)
{
step.kind = PkgStep::Kind::confirm;
step.job_id = id;
step.title_id = e.title_id;
step.before = e.before;
return step;
}
}
return step;
}
void PkgInstallModel::renamed(const std::string &job_id, bool ok, const std::string &new_path,
const std::string &error)
{
Entry *e = find_mut(job_id);
if (e == nullptr || e->stage != PkgStage::renaming)
return;
if (!ok)
{
fail(*e, tr("could not rename it to .pkg") + (error.empty() ? std::string() : " (" + error + ")"));
return;
}
e->path = !new_path.empty() ? new_path : pkg_folder_of(e->path) + "/" + e->wanted_name;
e->stage = PkgStage::installing;
log("job " + job_id + ": renamed to " + e->path);
}
void PkgInstallModel::titles_before(const std::string &job_id, const std::vector<std::string> &title_ids)
{
Entry *e = find_mut(job_id);
if (e == nullptr || e->stage != PkgStage::installing || !e->op_id.empty())
return;
e->before = title_ids;
e->has_before = true;
}
void PkgInstallModel::install_started(const std::string &job_id, bool ok,
const std::vector<PkgOpSeen> &ops, const std::string &error)
{
Entry *e = find_mut(job_id);
if (e == nullptr || e->stage != PkgStage::installing || !e->op_id.empty())
return;
if (!ok)
{
if (contains(error, "already running"))
{
log("job " + job_id + ": another install is running; waiting");
return;
}
fail(*e, error.empty() ? std::string(tr("Pegasus DL refused the install")) : error);
return;
}
e->op_id = "any";
for (const PkgOpSeen &op : ops)
{
if (contains(op.type, "pkg") || ops.size() == 1)
e->op_id = op.id;
}
e->missing_polls = 0;
log("job " + job_id + ": install started (operation " + e->op_id + ")");
}
void PkgInstallModel::observe_ops(const std::vector<PkgOpSeen> &ops)
{
for (auto &[id, e] : jobs_)
{
if (e.stage != PkgStage::installing || e.op_id.empty())
continue;
const PkgOpSeen *op = nullptr;
for (const PkgOpSeen &o : ops)
{
if (o.id == e.op_id || (e.op_id == "any" && contains(o.type, "pkg")))
op = &o;
}
if (op == nullptr)
{
if (++e.missing_polls >= kMissingPollsBeforeFail)
fail(e, tr("Pegasus DL lost track of the install"));
continue;
}
e.missing_polls = 0;
if (op->status == "accepted" || op->status == "completed")
{
e.stage = PkgStage::installed;
log("job " + id + ": the PS5 accepted the package" +
std::string(keep_files_ ? "; keeping the file (setting)" : ""));
if (e.title_id.empty() && !e.has_before && !keep_files_)
{
e.cleanup_done = true;
log("job " + id + ": no way to confirm the install; keeping " + e.path);
}
}
else if (op->status == "failed" || op->status == "cancelled")
{
fail(e, op->error.empty() ? std::string(tr("the PS5 refused the package")) : op->error);
}
}
}
void PkgInstallModel::confirmed(const std::string &job_id, bool found, double now_seconds)
{
Entry *e = find_mut(job_id);
if (e == nullptr || e->stage != PkgStage::installed || e->cleanup_done)
return;
if (found)
{
e->on_console = true;
log("job " + job_id + ": " + (e->title_id.empty() ? std::string("a new title") : e->title_id) +
" is on the console");
return;
}
e->next_confirm = now_seconds + kConfirmEverySeconds;
if (++e->confirm_checks >= kConfirmChecks)
{
e->cleanup_done = true;
log("job " + job_id + ": " + e->title_id + " never showed as installed; keeping " + e->path);
}
}
void PkgInstallModel::removed(const std::string &job_id, bool ok, const std::string &error)
{
Entry *e = find_mut(job_id);
if (e == nullptr || e->stage != PkgStage::installed)
return;
e->cleanup_done = true;
if (ok)
log("job " + job_id + ": deleted " + e->path);
else
log("job " + job_id + ": could not delete " + e->path + ": " + error);
}
std::string PkgInstallModel::row_text(const std::string &job_id) const
{
const Entry *e = find(job_id);
if (e == nullptr)
return {};
switch (e->stage)
{
case PkgStage::downloading:
return {};
case PkgStage::renaming:
case PkgStage::installing:
return tr("Installing...");
case PkgStage::installed:
return tr("Installed - find it on your home screen");
case PkgStage::failed:
return tr("Install failed: {reason} (the file is kept in {folder})",
{{"reason", e->error}, {"folder", pkg_folder_of(e->path)}});
}
return {};
}
std::vector<std::string> PkgInstallModel::take_log()
{
std::vector<std::string> out;
out.swap(log_);
return out;
}
// ------------------------------------------------------------ shared copy --
void PkgInstalls::set_keep_files(bool keep)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.set_keep_files(keep);
}
bool PkgInstalls::keep_files() const
{
std::lock_guard<std::mutex> lock(mutex_);
return model_.keep_files();
}
void PkgInstalls::track(const std::string &job_id, const std::string &title,
const std::string &title_id, const std::string &wanted_name)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.track(job_id, title, title_id, wanted_name);
flush();
}
bool PkgInstalls::tracked(const std::string &job_id) const
{
std::lock_guard<std::mutex> lock(mutex_);
return model_.find(job_id) != nullptr;
}
std::string PkgInstalls::row_text(const std::string &job_id) const
{
std::lock_guard<std::mutex> lock(mutex_);
return model_.row_text(job_id);
}
PkgStage PkgInstalls::stage(const std::string &job_id) const
{
std::lock_guard<std::mutex> lock(mutex_);
const PkgInstallModel::Entry *e = model_.find(job_id);
return e == nullptr ? PkgStage::downloading : e->stage;
}
void PkgInstalls::observe_queue(const std::vector<PkgJobSeen> &jobs)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.observe_queue(jobs);
flush();
}
PkgStep PkgInstalls::next() const
{
std::lock_guard<std::mutex> lock(mutex_);
return model_.next(steady_seconds());
}
void PkgInstalls::renamed(const std::string &job_id, bool ok, const std::string &new_path,
const std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.renamed(job_id, ok, new_path, error);
flush();
}
void PkgInstalls::titles_before(const std::string &job_id, const std::vector<std::string> &title_ids)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.titles_before(job_id, title_ids);
}
void PkgInstalls::install_started(const std::string &job_id, bool ok, const std::vector<PkgOpSeen> &ops,
const std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.install_started(job_id, ok, ops, error);
flush();
}
void PkgInstalls::observe_ops(const std::vector<PkgOpSeen> &ops)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.observe_ops(ops);
flush();
}
void PkgInstalls::confirmed(const std::string &job_id, bool found)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.confirmed(job_id, found, steady_seconds());
flush();
}
void PkgInstalls::removed(const std::string &job_id, bool ok, const std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
model_.removed(job_id, ok, error);
flush();
}
void PkgInstalls::log(const std::string &line)
{
std::lock_guard<std::mutex> lock(mutex_);
const std::string full = "[WINGS-PKG] " + line;
if (sink_)
sink_(full);
else
{
std::printf("%s\n", full.c_str());
std::fflush(stdout);
}
}
void PkgInstalls::set_log(std::function<void(const std::string &)> sink)
{
std::lock_guard<std::mutex> lock(mutex_);
sink_ = std::move(sink);
}
void PkgInstalls::reset()
{
std::lock_guard<std::mutex> lock(mutex_);
const bool keep = model_.keep_files();
model_ = PkgInstallModel();
model_.set_keep_files(keep);
}
void PkgInstalls::flush()
{
for (const std::string &line : model_.take_log())
{
if (sink_)
sink_(line);
else
{
std::printf("%s\n", line.c_str());
std::fflush(stdout);
}
}
}
PkgInstalls &pkg_installs()
{
static PkgInstalls instance;
return instance;
}
} // namespace orbit
+206
View File
@@ -0,0 +1,206 @@
// .pkg links that download fast to disk and then install (B-17).
//
// Pegasus DL hands a link to the PS5's own (slow) Downloads when the URL or the file
// name ends in ".pkg", but a file name ending in ".fpkg" wins and keeps the download
// in Pegasus' fast segmented queue (POST /api/queue/direct). Wings asks for
// "<Title>.fpkg", renames the finished file to "<Title>.pkg", installs it with
// POST /api/files/pkg/install and watches GET /api/files/operations.
//
// PkgInstallModel is the pure part (no I/O, no clock): the per-link route choice and
// the per-job stages downloading -> renaming -> installing -> installed | failed.
// PkgInstalls is the shared, locked copy the Store worker drives and the UI reads.
#pragma once
#include "orbit/model.hpp"
#include <cstdint>
#include <functional>
#include <map>
#include <mutex>
#include <string>
#include <string_view>
#include <vector>
namespace orbit
{
// ---- route choice (per link; Pegasus' settings are never changed) ----
enum class PkgRoute : std::uint8_t
{
direct, // POST /api/queue/direct with "<Title>.fpkg"
handoff, // the old POST /api/queue: the PS5's own Downloads
};
struct PkgRouteChoice
{
PkgRoute route = PkgRoute::handoff;
std::string reason; // for the [WINGS-PKG] log
};
// Before queueing: the probe answer and the free space. size_bytes < 0 means unknown
// (then space is not checked). mounts empty means the free space is unknown.
PkgRouteChoice pkg_route_before_queue(bool probe_ok, std::string_view probe_rejection,
double size_bytes, const std::string &download_root,
const std::vector<StorageMount> &mounts);
// After POST /api/queue/direct: Pegasus may refuse it or hand the file to the PS5 anyway.
enum class PkgDirectAnswer : std::uint8_t
{
track, // queued in Pegasus' own queue with a job id: watch it
handed_off, // Pegasus already sent it to the PS5's Downloads: show that, queue nothing more
use_handoff, // refused or no job id: fall back to the old hand-off
};
PkgDirectAnswer pkg_classify_direct(bool call_ok, bool native, bool queued, std::int64_t job_id);
// Bytes a .pkg of this size needs: the file in the download root, the installed app on
// /data, and a staging copy on /data when the root is elsewhere.
bool pkg_space_ok(double size_bytes, const std::string &download_root,
const std::vector<StorageMount> &mounts, std::string *why);
// "God of War: Ragnarok (USA).pkg" -> "God of War Ragnarok (USA)". Never empty.
std::string pkg_file_stem(std::string_view title_or_file);
std::string pkg_folder_of(std::string_view path);
// ---- per-job stages ----
enum class PkgStage : std::uint8_t
{
downloading,
renaming,
installing,
installed,
failed,
};
struct PkgJobSeen // one queue job, as Pegasus reports it
{
std::string id;
std::string status; // Job::status words
std::string destination;
std::string error;
};
struct PkgOpSeen // one file operation
{
std::string id;
std::string type;
std::string status; // FileOp::status words
std::string error;
};
struct PkgStep
{
enum class Kind : std::uint8_t
{
none,
rename, // POST /api/files/rename {path, name}
install, // POST /api/files/pkg/install {path}
poll, // GET /api/files/operations
confirm, // GET /appdb: is title_id installed yet?
remove, // POST /api/files/delete {paths:[path]}
} kind = Kind::none;
std::string job_id;
std::string path;
std::string name; // rename
std::string title_id; // confirm; when empty, any title not in `before` counts
std::vector<std::string> before; // confirm: the installed title ids from before the install
};
// Is the install on the console? By title id, or (no title id) a title that was not there before.
bool pkg_install_seen(const std::string &title_id, const std::vector<std::string> &before,
const std::vector<std::string> &now);
class PkgInstallModel
{
public:
struct Entry
{
std::string job_id;
std::string title;
std::string title_id;
std::string wanted_name; // "<Title>.pkg"
std::string path; // where the file is now
PkgStage stage = PkgStage::downloading;
std::string op_id;
std::string error;
int missing_polls = 0;
int confirm_checks = 0;
double next_confirm = 0.0;
bool on_console = false; // seen in the installed games
bool cleanup_done = false; // deleted, kept on purpose, or given up
bool has_before = false; // `before` was read before the install started
std::vector<std::string> before;
};
void set_keep_files(bool keep) { keep_files_ = keep; }
bool keep_files() const { return keep_files_; }
void track(const std::string &job_id, const std::string &title, const std::string &title_id,
const std::string &wanted_name);
const Entry *find(const std::string &job_id) const;
void observe_queue(const std::vector<PkgJobSeen> &jobs);
// What to do now. One rename/install at a time (Pegasus runs one install at a time).
PkgStep next(double now_seconds) const;
void renamed(const std::string &job_id, bool ok, const std::string &new_path, const std::string &error);
// The installed title ids just before POST /api/files/pkg/install (for a package with no title id).
void titles_before(const std::string &job_id, const std::vector<std::string> &title_ids);
void install_started(const std::string &job_id, bool ok, const std::vector<PkgOpSeen> &ops,
const std::string &error);
void observe_ops(const std::vector<PkgOpSeen> &ops);
void confirmed(const std::string &job_id, bool found, double now_seconds);
void removed(const std::string &job_id, bool ok, const std::string &error);
// Player text for the download row; empty when the job is not a tracked .pkg or still downloading.
std::string row_text(const std::string &job_id) const;
std::vector<std::string> take_log();
private:
Entry *find_mut(const std::string &job_id);
void fail(Entry &entry, const std::string &reason);
void log(const std::string &line) { log_.push_back("[WINGS-PKG] " + line); }
std::map<std::string, Entry> jobs_;
bool keep_files_ = false;
std::vector<std::string> log_;
};
// The shared copy. Log lines go to stdout (the title log) unless a sink is set.
class PkgInstalls
{
public:
void set_keep_files(bool keep);
bool keep_files() const;
void track(const std::string &job_id, const std::string &title, const std::string &title_id,
const std::string &wanted_name);
bool tracked(const std::string &job_id) const;
std::string row_text(const std::string &job_id) const;
PkgStage stage(const std::string &job_id) const; // downloading when untracked
// Runs on the Store worker after every queue poll; call() performs one step's I/O.
void observe_queue(const std::vector<PkgJobSeen> &jobs);
PkgStep next() const;
void renamed(const std::string &job_id, bool ok, const std::string &new_path, const std::string &error);
void titles_before(const std::string &job_id, const std::vector<std::string> &title_ids);
void install_started(const std::string &job_id, bool ok, const std::vector<PkgOpSeen> &ops,
const std::string &error);
void observe_ops(const std::vector<PkgOpSeen> &ops);
void confirmed(const std::string &job_id, bool found);
void removed(const std::string &job_id, bool ok, const std::string &error);
void log(const std::string &line); // adds the [WINGS-PKG] prefix
void set_log(std::function<void(const std::string &)> sink);
void reset(); // tests
private:
void flush();
mutable std::mutex mutex_;
PkgInstallModel model_;
std::function<void(const std::string &)> sink_;
};
PkgInstalls &pkg_installs();
} // namespace orbit
+40 -6
View File
@@ -25,6 +25,7 @@ long clamp_count(const json::Value &v)
const double n = v.as_number(-1.0);
return n < 0.0 || n > 1e9 ? -1 : static_cast<long>(n);
}
} // namespace
void write_everywhere(const std::vector<std::string> &paths, const std::string &data, int *written)
{
@@ -37,7 +38,6 @@ void write_everywhere(const std::vector<std::string> &paths, const std::string &
++*written;
}
}
} // namespace
// ---- the index ----
@@ -50,12 +50,18 @@ bool PopularityIndex::parse(std::string_view text, PopularityIndex *out, std::st
return false;
};
if (text.size() > kPopularityMaxBytes)
return fail("the file is larger than 400 KB");
return fail("the file is too large");
json::Value root;
std::string why;
if (!json::parse(text, &root, &why) || !root.is_object())
return fail("the file is not valid JSON");
if (!root["schemaVersion"].is_number() || root["schemaVersion"].as_int(0) != 1)
if (!root["schemaVersion"].is_number())
return fail("unsupported data version");
// A newer format goes to a new address and old apps keep the old file; if a source still serves one,
// the way out is a newer Wings.
if (root["schemaVersion"].as_int(0) > 1)
return fail(tr("this data needs a newer Wings (Settings > Wings > Updates)"));
if (root["schemaVersion"].as_int(0) != 1)
return fail("unsupported data version");
const json::Value &titles = root["titles"];
if (!titles.is_object())
@@ -89,6 +95,9 @@ bool PopularityIndex::parse(std::string_view text, PopularityIndex *out, std::st
if (r["hero"].is_string() && r["hero"].as_string().size() <= 300 &&
valid_popularity_url(r["hero"].as_string()))
rec.hero = r["hero"].as_string();
if (r["heroLow"].is_string() && r["heroLow"].as_string().size() <= 300 &&
valid_popularity_url(r["heroLow"].as_string()))
rec.hero_low = r["heroLow"].as_string();
index.records_[id] = std::move(rec);
}
if (index.records_.empty())
@@ -129,6 +138,17 @@ std::string PopularityIndex::hero(const std::vector<std::string> &title_ids) con
return {};
}
std::string PopularityIndex::hero_low(const std::vector<std::string> &title_ids) const
{
for (const std::string &id : title_ids)
{
const PopRecord *r = find(id);
if (r != nullptr && !r->hero_low.empty())
return r->hero_low;
}
return {};
}
bool valid_popularity_url(std::string_view url)
{
if (url.size() > 300 || url.size() < 12 || url.substr(0, 8) != "https://")
@@ -466,6 +486,7 @@ void PopularityService::run(bool force, std::string url)
PopularityStatus status;
status.busy = true;
bool cache_ok = false;
bool cache_current = false; // the saved copy was fetched by this very build of the app
std::int64_t fetched = 0;
// 1. The saved copy. Missing or damaged is fine.
for (const std::string &path : config_.cache_paths)
@@ -479,9 +500,20 @@ void PopularityService::run(bool force, std::string url)
const std::size_t nl = d.payload.find('\n');
if (nl == std::string::npos)
continue;
// "fetched=<unix>\napp=<build tag>\n<file>"; a copy from before the tag has no app line.
std::size_t body_at = nl + 1;
std::string saved_tag;
if (d.payload.compare(body_at, 4, "app=") == 0)
{
const std::size_t nl2 = d.payload.find('\n', body_at);
if (nl2 == std::string::npos)
continue;
saved_tag = d.payload.substr(body_at + 4, nl2 - body_at - 4);
body_at = nl2 + 1;
}
auto index = std::make_shared<PopularityIndex>();
std::string why;
if (!PopularityIndex::parse(std::string_view(d.payload).substr(nl + 1), index.get(), &why))
if (!PopularityIndex::parse(std::string_view(d.payload).substr(body_at), index.get(), &why))
continue;
fetched = std::atoll(d.payload.substr(8, nl - 8).c_str());
status.updated = fetched;
@@ -489,10 +521,11 @@ void PopularityService::run(bool force, std::string url)
status.message = tr("Using the saved copy");
hub_.set_index(index);
cache_ok = true;
cache_current = saved_tag == config_.app_tag;
break;
}
const std::int64_t now = config_.now();
const bool fresh = cache_ok && fetched <= now && now - fetched < config_.ttl_s;
const bool fresh = cache_ok && cache_current && fetched <= now && now - fetched < config_.ttl_s;
if (fresh && !force)
{
status.busy = false;
@@ -521,7 +554,8 @@ void PopularityService::run(bool force, std::string url)
else
write_everywhere(config_.cache_paths,
hui::save::encode(hui::save::Kind::stats, 1,
"fetched=" + std::to_string(now) + "\n" + doc.body),
"fetched=" + std::to_string(now) + "\napp=" + config_.app_tag + "\n" +
doc.body),
nullptr);
}
}
+12 -3
View File
@@ -3,7 +3,7 @@
//
// The data is a small JSON file Wings fetches over https (docs/research/popularity.md):
// {schemaVersion:1, generatedAt, sources[], attribution{}, titles{titleId:{score, rating?,
// ratingCount?, sources[], confidence, fetchedAt}}}
// ratingCount?, sources[], confidence, hero?, heroLow?}}}
// On by default (the player can turn it off). Nothing here touches the network on its own: PopularityService asks a
// DocFetcher (main.cpp wires it to the cover-art HTTPS client) and only when the Wings
// preference is on. Everything else is platform neutral and tested on the host.
@@ -25,7 +25,7 @@
namespace orbit
{
constexpr std::size_t kPopularityMaxBytes = 400u * 1024u;
constexpr std::size_t kPopularityMaxBytes = 512u * 1024u;
constexpr double kPopularityMinConfidence = 0.6; // records below this are hidden
constexpr const char *kPopularityDefaultUrl = "https://larazadelsol.github.io/wings-app/popularity.v1.json";
@@ -36,7 +36,8 @@ struct PopRecord
long rating_count = -1;
double confidence = 0.0;
std::vector<std::string> sources;
std::string hero; // optional https:// art URL for the game page header ("" none; game_header.hpp)
std::string hero; // optional https:// art URL for the game page header ("" none; game_header.hpp)
std::string hero_low; // optional small, fast ~16:9 https:// URL shown until the full hero is there
};
class PopularityIndex
@@ -54,6 +55,8 @@ class PopularityIndex
// The hero art URL of a game: the first of its title ids whose record has one (confidence is not
// asked: art is not a ranking claim). "" when none does.
std::string hero(const std::vector<std::string> &title_ids) const;
// The same for the small "heroLow" image.
std::string hero_low(const std::vector<std::string> &title_ids) const;
std::size_t size() const { return records_.size(); }
const std::string &generated_at() const { return generated_at_; }
const std::vector<std::string> &attribution() const { return attribution_; } // one line per source
@@ -66,6 +69,8 @@ class PopularityIndex
// "Only https://": the source URL is refused otherwise (also credentials, spaces, > 300 bytes).
bool valid_popularity_url(std::string_view url);
// Writes `data` to every path (creating the folder); `written` counts the files that worked. Shared with editorial.cpp.
void write_everywhere(const std::vector<std::string> &paths, const std::string &data, int *written);
// 4300 -> "4.3k", 1200000 -> "1.2M", 950 -> "950".
std::string short_count(long n);
// "Popularity 87 · Rating 92 (4.3k)"; "" for a null record.
@@ -177,6 +182,10 @@ struct PopularityServiceConfig
{
std::vector<std::string> cache_paths; // first writable wins; the saved copy of the file
int ttl_s = 24 * 3600; // no ETag support in the client: re-fetch only when older
// Which build of the app this is ("00.001.001+20261012-203105"). The saved copy carries the tag of the build
// that fetched it; a copy from another build (or from before the tag existed) is fetched again at the first
// start, whatever its age, so updating the app always updates the data too. Empty: not asked (tests, preview).
std::string app_tag;
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
};
+29 -1
View File
@@ -52,7 +52,11 @@ bool WingsLocalPrefs::operator==(const WingsLocalPrefs &other) const
prefer_debrid == other.prefer_debrid &&
popularity_enabled == other.popularity_enabled && popularity_url == other.popularity_url &&
tile_details == other.tile_details && pegasus_start == other.pegasus_start &&
after_download == other.after_download;
after_download == other.after_download && update_check == other.update_check &&
icon_source == other.icon_source && hero_source == other.hero_source &&
notes_enabled == other.notes_enabled && carousel_enabled == other.carousel_enabled &&
folder_confirmed == other.folder_confirmed && auto_reconnect == other.auto_reconnect &&
keep_pkg_files == other.keep_pkg_files;
}
bool valid_firmware(std::string_view value)
@@ -82,6 +86,14 @@ std::string encode_local_prefs(const WingsLocalPrefs &prefs)
out += std::string("tile_details=") + tile_details_key(prefs.tile_details) + "\n";
out += std::string("pegasus_start=") + pegasus_start_mode_key(prefs.pegasus_start) + "\n";
out += std::string("after_download=") + after_download_key(prefs.after_download) + "\n";
out += std::string("update_check=") + (prefs.update_check ? "1" : "0") + "\n";
out += std::string("icon_source=") + icon_source_key(prefs.icon_source) + "\n";
out += std::string("hero_source=") + hero_source_key(prefs.hero_source) + "\n";
out += std::string("notes=") + (prefs.notes_enabled ? "1" : "0") + "\n";
out += std::string("carousel=") + (prefs.carousel_enabled ? "1" : "0") + "\n";
out += std::string("folder_confirmed=") + (prefs.folder_confirmed ? "1" : "0") + "\n";
out += std::string("auto_reconnect=") + (prefs.auto_reconnect ? "1" : "0") + "\n";
out += std::string("keep_pkg_files=") + (prefs.keep_pkg_files ? "1" : "0") + "\n";
return out;
}
@@ -113,6 +125,12 @@ bool decode_local_prefs(std::string_view payload, WingsLocalPrefs *out)
prefs.prefer_debrid = value != "0";
else if (key == "popularity")
prefs.popularity_enabled = value == "1";
else if (key == "notes")
prefs.notes_enabled = value != "0";
else if (key == "carousel")
prefs.carousel_enabled = value != "0";
else if (key == "folder_confirmed")
prefs.folder_confirmed = value == "1";
else if (key == "popularity_url" && plain_line(value))
prefs.popularity_url = std::string(value);
else if (key == "tile_details")
@@ -121,6 +139,16 @@ bool decode_local_prefs(std::string_view payload, WingsLocalPrefs *out)
parse_pegasus_start_mode(value, &prefs.pegasus_start);
else if (key == "after_download")
parse_after_download(value, &prefs.after_download);
else if (key == "update_check")
prefs.update_check = value != "0";
else if (key == "auto_reconnect")
prefs.auto_reconnect = value != "0";
else if (key == "keep_pkg_files")
prefs.keep_pkg_files = value == "1";
else if (key == "icon_source")
parse_icon_source(value, &prefs.icon_source);
else if (key == "hero_source")
parse_hero_source(value, &prefs.hero_source);
}
*out = prefs;
return true;
+19
View File
@@ -9,6 +9,7 @@
#pragma once
#include "orbit/after_download_mode.hpp"
#include "orbit/art_sources.hpp"
#include "orbit/pegasus_start_mode.hpp"
#include "orbit/tile_details.hpp"
@@ -39,6 +40,24 @@ struct WingsLocalPrefs
PegasusStartMode pegasus_start = PegasusStartMode::ask;
// What Wings does when a download finishes: ask ShadowMount to scan (default) or nothing.
AfterDownload after_download = AfterDownload::scan;
// Look for a newer Wings release when the app starts (wings_update.hpp). On by default; files from before
// this choice have no key and load the default.
bool update_check = true;
// Where cover icons and the 16:9 game-page art come from (art_sources.hpp). Defaults: the catalogue's own
// covers, and every art source.
IconSource icon_source = IconSource::catalogue;
HeroSource hero_source = HeroSource::all;
// Editorial data (editorial.hpp): the short notes on game pages and the curated carousel on the store. On by
// default; folder_confirmed is set once the player has confirmed the games folder (files from before have no keys).
bool notes_enabled = true;
bool carousel_enabled = true;
bool folder_confirmed = false;
// Retry a download by itself when the connection drops (download_auto_retry.hpp). On by default; files
// from before this choice have no key and load the default.
bool auto_reconnect = true;
// Keep the .pkg file after Wings installed it (pkg_install.hpp). Off by default: a .pkg that
// installed is deleted. A failed install always keeps the file.
bool keep_pkg_files = false;
bool operator==(const WingsLocalPrefs &other) const;
};
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more