Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fd5a89d3 | ||
|
|
0dbc8774e2 | ||
|
|
383944d8cc | ||
|
|
7c63a09d86 | ||
|
|
98798a4726 | ||
|
|
de8e29ec6c | ||
|
|
be590ffa78 | ||
|
|
4f49824057 | ||
|
|
5e32d2806d |
No files matched your search
@@ -56,6 +56,12 @@ jobs:
|
||||
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 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -3,8 +3,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LaRazaDelSol/wings-app/releases/tag/v0.1.0"><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/download/v0.1.0/PPSA99178.ffpkg"><img alt="Download" src="https://img.shields.io/badge/download-release-24292f"></a>
|
||||
<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>
|
||||
|
||||
@@ -24,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 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. None of Orbit's backend (payload, loader, pairing, update feeds) is in Wings. |
|
||||
| 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.
|
||||
>
|
||||
@@ -34,21 +34,26 @@ One clean page per game. See what every link is before you press it. Manage your
|
||||
|
||||
## Screenshots
|
||||
|
||||
Rendered from a stand-in back-end. May not represent actual.
|
||||
> **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
|
||||
|
||||
@@ -80,7 +85,7 @@ Libraries, fonts and versions are listed in full in [CREDITS.md](CREDITS.md). If
|
||||
- **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).
|
||||
- **Knows when it is out of date.** Wings checks this project's latest release when it starts and tells you if a newer one exists, with the steps to install it. It never installs itself. See [Updating Wings](docs/guides/updating.md).
|
||||
- **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
|
||||
|
||||
@@ -98,9 +103,9 @@ This is the same setup Orbit Store asks for. Pegasus DL must be running on the c
|
||||
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 (see [Updating Wings](docs/guides/updating.md)), replace the file, remove the staged copies ShadowMountPlus made, then restart the jailbreak chain so the new package is mounted. 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). Pegasus DL and its downloads are not touched.
|
||||
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
|
||||
|
||||
@@ -156,9 +161,12 @@ Wings has no account, no telemetry that this project operates, and no analytics.
|
||||
| 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 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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,6 +1508,16 @@ 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::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_);
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 1015 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 273 KiB |
@@ -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();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"conceptId": "99178",
|
||||
"contentBadgeType": 1,
|
||||
"contentId": "UP9000-PPSA99178_00-WINGSPEGASUS0001",
|
||||
"contentVersion": "00.001.001",
|
||||
"contentVersion": "00.001.003",
|
||||
"downloadDataSize": 256,
|
||||
"gameIntent": {
|
||||
"permittedIntents": [
|
||||
|
||||
@@ -15,15 +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"
|
||||
@@ -90,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"};
|
||||
@@ -205,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
|
||||
{
|
||||
@@ -357,6 +408,7 @@ 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
|
||||
@@ -438,6 +490,12 @@ int main()
|
||||
{ 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);
|
||||
{
|
||||
@@ -445,6 +503,13 @@ 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).
|
||||
@@ -464,6 +529,40 @@ int main()
|
||||
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()
|
||||
@@ -485,11 +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
|
||||
@@ -523,6 +631,9 @@ int main()
|
||||
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 = app_content_version;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#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>
|
||||
@@ -64,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]
|
||||
{
|
||||
@@ -72,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()
|
||||
@@ -128,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;
|
||||
}
|
||||
@@ -145,26 +258,40 @@ 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 say so once per start. Checked every two seconds, not every frame.
|
||||
// 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();
|
||||
if (!update_announced_)
|
||||
const WingsUpdateStatus up = wings_update().status();
|
||||
if (up.have_latest && up.advice == WingsAdvice::newer)
|
||||
{
|
||||
const WingsUpdateStatus up = wings_update().status();
|
||||
if (up.attached && up.enabled && 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_announced_ = true;
|
||||
show_toast(wings_update_toast(up), 8.0f);
|
||||
feedback.play(hui::audio::Cue::notify);
|
||||
update_dialog_.open(view);
|
||||
feedback.play(hui::audio::Cue::modal_open);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,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;
|
||||
@@ -577,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")},
|
||||
|
||||
@@ -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 = 2; // an open page: the small one and the full one
|
||||
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
|
||||
|
||||
@@ -85,6 +85,48 @@ std::string choose_hero(const PopularityIndex &index, const std::vector<std::str
|
||||
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())
|
||||
|
||||
@@ -47,6 +47,30 @@ 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
|
||||
|
||||
@@ -183,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 ¬es = 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, ¬es_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
|
||||
@@ -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
|
||||
@@ -311,12 +311,35 @@ void GameHeaderState::reset()
|
||||
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)
|
||||
@@ -420,6 +443,7 @@ 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())
|
||||
{
|
||||
// The full picture is asked for first and the small one after it: the worker serves the newest
|
||||
@@ -429,6 +453,7 @@ void draw_game_header(Context &ctx, hui::gfx::DrawList &list, const HeaderView &
|
||||
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)
|
||||
{
|
||||
@@ -448,7 +473,37 @@ void draw_game_header(Context &ctx, hui::gfx::DrawList &list, const HeaderView &
|
||||
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))
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "orbit/ui.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -77,6 +78,25 @@ struct HeroPlacement
|
||||
};
|
||||
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
|
||||
@@ -189,7 +209,11 @@ struct GameHeaderState
|
||||
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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¬e);
|
||||
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, ¬e);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -69,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.
|
||||
|
||||
@@ -53,7 +53,10 @@ bool WingsLocalPrefs::operator==(const WingsLocalPrefs &other) const
|
||||
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 && update_check == other.update_check &&
|
||||
icon_source == other.icon_source && hero_source == other.hero_source;
|
||||
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)
|
||||
@@ -86,6 +89,11 @@ std::string encode_local_prefs(const WingsLocalPrefs &prefs)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -117,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")
|
||||
@@ -127,6 +141,10 @@ bool decode_local_prefs(std::string_view payload, WingsLocalPrefs *out)
|
||||
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")
|
||||
|
||||
@@ -47,6 +47,17 @@ struct WingsLocalPrefs
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace
|
||||
|
||||
struct Key
|
||||
{
|
||||
std::array<int, 8> head{}; // too new, tier, fit, region, format, parts class, parts, size unknown
|
||||
std::array<int, 9> head{}; // flagged, too new, tier, fit, region, format, parts class, parts, size unknown
|
||||
int version_unknown = 0;
|
||||
std::vector<int> version; // compared descending
|
||||
int source_order = 0;
|
||||
@@ -69,15 +69,16 @@ Key key_of(const Candidate &c, const LinkPrefs &p)
|
||||
{
|
||||
Key k;
|
||||
const FwFit fit = fw_fit(c.fw, p.firmware);
|
||||
k.head[0] = fit == FwFit::too_new ? 1 : 0;
|
||||
k.head[1] = tier_key(c.tier, p.prefer_debrid);
|
||||
k.head[2] = static_cast<int>(fit);
|
||||
k.head[3] = p.region.empty() || c.region == p.region ? 0 : (c.region.empty() ? 1 : 2);
|
||||
k.head[4] = p.format == LinkFormat::none || c.format == p.format ? 0 : 1;
|
||||
k.head[0] = c.flagged ? 1 : 0;
|
||||
k.head[1] = fit == FwFit::too_new ? 1 : 0;
|
||||
k.head[2] = tier_key(c.tier, p.prefer_debrid);
|
||||
k.head[3] = static_cast<int>(fit);
|
||||
k.head[4] = p.region.empty() || c.region == p.region ? 0 : (c.region.empty() ? 1 : 2);
|
||||
k.head[5] = p.format == LinkFormat::none || c.format == p.format ? 0 : 1;
|
||||
const int cls = status_class(c.status);
|
||||
k.head[5] = p.prefer_single_file ? cls : (cls >= 2 ? 1 : 0);
|
||||
k.head[6] = p.prefer_single_file ? c.parts : 0;
|
||||
k.head[7] = c.size_known ? 0 : 1;
|
||||
k.head[6] = p.prefer_single_file ? cls : (cls >= 2 ? 1 : 0);
|
||||
k.head[7] = p.prefer_single_file ? c.parts : 0;
|
||||
k.head[8] = c.size_known ? 0 : 1;
|
||||
k.version = version_parts(c.version);
|
||||
k.version_unknown = k.version.empty() ? 1 : 0;
|
||||
k.source_order = c.source_order;
|
||||
@@ -191,6 +192,8 @@ Ranking rank_links(const std::vector<Candidate> &candidates, const LinkPrefs &pr
|
||||
r.order.push_back(static_cast<int>(i));
|
||||
if (fw_fit(candidates[i].fw, prefs.firmware) == FwFit::too_new)
|
||||
++r.too_new;
|
||||
if (candidates[i].flagged)
|
||||
++r.flagged;
|
||||
}
|
||||
std::stable_sort(r.order.begin(), r.order.end(),
|
||||
[&](int a, int b) { return keys[static_cast<std::size_t>(a)] < keys[static_cast<std::size_t>(b)]; });
|
||||
@@ -208,6 +211,8 @@ Ranking rank_links(const std::vector<Candidate> &candidates, const LinkPrefs &pr
|
||||
r.chips.push_back(b.parts == 0 ? std::string(tr("1 file")) : trn(b.parts, "{count} part", "{count} parts"));
|
||||
std::vector<std::string> why;
|
||||
why.emplace_back(tier_why(b.tier));
|
||||
if (b.flagged)
|
||||
why.emplace_back(tr("failed for you before (no other link)"));
|
||||
if (!prefs.firmware.empty())
|
||||
{
|
||||
switch (fw_fit(b.fw, prefs.firmware))
|
||||
@@ -268,6 +273,7 @@ Candidate candidate_of(const GamePage &page, int mirror)
|
||||
c.source_order = pkg.source_order;
|
||||
c.index = first.index;
|
||||
c.host = m.host;
|
||||
c.flagged = m.flagged_link(page) >= 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The order of docs/research/links-grouping.md section 4.5, lowest key first:
|
||||
// 0. flagged bad on this console (link_flags.hpp: never Best while an
|
||||
// unflagged link exists, so flagged links sort last),
|
||||
// 1. too new for my firmware (never Best while anything else exists),
|
||||
// 2. tier: native .pkg, debrid, automated, browser capture, unknown, unsupported
|
||||
// ("prefer debrid" off: debrid between automated and browser),
|
||||
@@ -68,6 +70,7 @@ struct Candidate
|
||||
int source_order = 0;
|
||||
int index = 0; // link index (first part)
|
||||
std::string host;
|
||||
bool flagged = false; // a link of it is flagged bad on this console (GameLink::flag_reason)
|
||||
};
|
||||
|
||||
struct Ranking
|
||||
@@ -77,6 +80,7 @@ struct Ranking
|
||||
std::vector<std::string> chips; // "Debrid", "USA", "BP 4.xx", "exFAT", "1 file"
|
||||
std::string why; // "debrid: fast, no captcha, runs on your 10.xx, your region, single file"
|
||||
int too_new = 0; // candidates that need a newer firmware than the console's
|
||||
int flagged = 0; // candidates flagged bad on this console
|
||||
// "Debrid · USA · 12.xx · exFAT · 1 file" (separator as given).
|
||||
std::string summary(const char *separator = " \xC2\xB7 ") const;
|
||||
// "Best: Debrid · USA · 12.xx · 1 file — debrid: fast, no captcha, ..."
|
||||
|
||||
@@ -7,18 +7,25 @@
|
||||
|
||||
#include "orbit/screens.hpp"
|
||||
|
||||
#include "orbit/busy_cancel.hpp"
|
||||
#include "orbit/diagnostics.hpp"
|
||||
#include "orbit/download_auto_retry.hpp"
|
||||
#include "orbit/pkg_install.hpp"
|
||||
#include "orbit/editorial.hpp"
|
||||
#include "orbit/i18n.hpp"
|
||||
#include "orbit/link_flags.hpp"
|
||||
#include "orbit/model.hpp"
|
||||
#include "orbit/pegasus_manager.hpp"
|
||||
#include "orbit/pegasus_version.hpp"
|
||||
#include "orbit/title_export.hpp"
|
||||
#include "orbit/update_screen.hpp"
|
||||
#include "orbit/wings_update.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
@@ -272,7 +279,10 @@ SettingsScreen::SettingsScreen(Context &context) : ListScreen(context)
|
||||
{
|
||||
load_local_prefs(ctx_.wings_prefs_paths, &prefs_);
|
||||
popularity_hub().apply_prefs(prefs_.popularity_enabled, prefs_.popularity_url);
|
||||
editorial_hub().apply_prefs(prefs_.notes_enabled, prefs_.carousel_enabled);
|
||||
wings_update().set_enabled(prefs_.update_check);
|
||||
download_reconnect().set_enabled(prefs_.auto_reconnect);
|
||||
pkg_installs().set_keep_files(prefs_.keep_pkg_files);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +291,10 @@ void SettingsScreen::store_local_prefs()
|
||||
if (!ctx_.wings_prefs_paths.empty())
|
||||
save_local_prefs(ctx_.wings_prefs_paths, prefs_);
|
||||
popularity_hub().apply_prefs(prefs_.popularity_enabled, prefs_.popularity_url);
|
||||
editorial_hub().apply_prefs(prefs_.notes_enabled, prefs_.carousel_enabled);
|
||||
wings_update().set_enabled(prefs_.update_check);
|
||||
download_reconnect().set_enabled(prefs_.auto_reconnect);
|
||||
pkg_installs().set_keep_files(prefs_.keep_pkg_files);
|
||||
}
|
||||
|
||||
std::vector<ListScreen::Row> SettingsScreen::build(std::vector<Act> *acts) const
|
||||
@@ -324,7 +337,7 @@ void SettingsScreen::build_wings(std::vector<Row> &out, std::vector<Act> *acts)
|
||||
accent = accent_id;
|
||||
};
|
||||
const auto add = [&](const char *title, std::string detail, std::string right,
|
||||
Act act = Act::none, bool disabled = false)
|
||||
Act act = Act::none, bool disabled = false, float progress = -1.0f)
|
||||
{
|
||||
Row row;
|
||||
row.section = section;
|
||||
@@ -334,10 +347,26 @@ void SettingsScreen::build_wings(std::vector<Row> &out, std::vector<Act> *acts)
|
||||
row.right = std::move(right);
|
||||
row.disabled = disabled;
|
||||
row.info = act == Act::none && !disabled;
|
||||
row.progress = progress;
|
||||
out.push_back(std::move(row));
|
||||
if (acts != nullptr)
|
||||
acts->push_back(act);
|
||||
};
|
||||
// Updates first: one row that says where the update stands; Select opens the full-screen update page
|
||||
// (update_screen.hpp, the App owns it). The auto-check switch sits under it.
|
||||
begin("Updates", theme::accent_about);
|
||||
{
|
||||
UpdateView view;
|
||||
const bool newer = ctx_.wings_update_view && ctx_.wings_update_view(view);
|
||||
add("Wings update",
|
||||
newer ? tr("Select to see the new version and update step by step.")
|
||||
: std::string(tr("Select to look for a newer Wings now.")),
|
||||
update_row_value(newer ? &view : nullptr), Act::wings_update);
|
||||
}
|
||||
add("Check for Wings updates",
|
||||
tr("When Wings starts it asks GitHub if a newer release exists (needs Cover art on). Looking never "
|
||||
"downloads or installs anything: that is a separate, confirmed step."),
|
||||
prefs_.update_check ? std::string(tr("On")) : std::string(tr("Off")), Act::update_check);
|
||||
// Cover art: store posters are https links the app fetches itself.
|
||||
begin("Cover art", theme::accent_cover_art);
|
||||
if (ctx_.cover_art == nullptr)
|
||||
@@ -378,6 +407,19 @@ void SettingsScreen::build_wings(std::vector<Row> &out, std::vector<Act> *acts)
|
||||
std::string(), Act::reset_grid);
|
||||
add("Tile details", tr("When Store tiles show their link tier and regions"),
|
||||
std::string(tr(tile_details_label(prefs_.tile_details))), Act::tile_details);
|
||||
// B-20: links that failed for the player on this console; Best skips them.
|
||||
{
|
||||
const LinkFlags *flags = link_flags();
|
||||
const long count =
|
||||
flags != nullptr ? static_cast<long>(flags->list(static_cast<std::int64_t>(std::time(nullptr))).size()) : 0;
|
||||
add("Bad links",
|
||||
tr("Links that failed for you on this console (kept {days} days). Best never picks them. Select to see "
|
||||
"or clear them.",
|
||||
{{"days", std::to_string(kFlagDays)}}),
|
||||
trn(count, "{count} link", "{count} links"), Act::bad_links);
|
||||
add("Clear all bad-link flags", tr("Every flagged link can be picked as Best again"), std::string(),
|
||||
Act::clear_bad_links);
|
||||
}
|
||||
// Link ranking: which link Wings lists first. Kept by Wings; Pegasus is not asked.
|
||||
begin("Link ranking", theme::accent_link_ranking);
|
||||
const auto any_or = [](const std::string &value)
|
||||
@@ -423,17 +465,12 @@ void SettingsScreen::build_wings(std::vector<Row> &out, std::vector<Act> *acts)
|
||||
prefs_.after_download == AfterDownload::scan ? std::string(tr("Ask ShadowMount to scan"))
|
||||
: std::string(tr("Do nothing")),
|
||||
Act::after_download);
|
||||
// Updates: a read-only look at the project's latest release (wings_update.hpp). Wings never installs itself.
|
||||
begin("Updates", theme::accent_about);
|
||||
add("Check for Wings updates",
|
||||
tr("When Wings starts it asks GitHub if a newer release exists (needs Cover art on). It only tells you: "
|
||||
"it never downloads or installs anything."),
|
||||
prefs_.update_check ? std::string(tr("On")) : std::string(tr("Off")), Act::update_check);
|
||||
{
|
||||
const WingsUpdateStatus up = wings_update().status();
|
||||
const WingsUpdateText words = wings_update_text(up);
|
||||
add("Wings update", words.detail, words.value, Act::wings_update, !up.attached);
|
||||
}
|
||||
// Editorial: short notes about games and the store carousel (editorial.hpp), both on by default.
|
||||
begin("Store extras", theme::accent_store_grid);
|
||||
add("Known-problem notes", tr("Show a short warning on a game's page when the project site lists one"),
|
||||
prefs_.notes_enabled ? std::string(tr("On")) : std::string(tr("Off")), Act::notes);
|
||||
add("Featured carousel", tr("Show the project's featured games above the Store grid"),
|
||||
prefs_.carousel_enabled ? std::string(tr("On")) : std::string(tr("Off")), Act::carousel);
|
||||
// Diagnostics and About: what Wings knows about itself and about Pegasus DL (read-only).
|
||||
begin("About", theme::accent_about);
|
||||
const bool known = state.online() && state.have_info;
|
||||
@@ -520,11 +557,23 @@ void SettingsScreen::build_pegasus(std::vector<Row> &out, std::vector<Act> *acts
|
||||
}
|
||||
const SettingsView &s = state.settings;
|
||||
begin("Downloads", theme::accent_downloads);
|
||||
add("Download folder", s.download_root.empty() ? std::string("-") : s.download_root,
|
||||
tr("Change"), Act::folder);
|
||||
{
|
||||
// Why no default folder could be set is a dim sentence after the path (empty: nothing to say).
|
||||
const std::string &why = ctx_.store.default_folder_status();
|
||||
std::string detail = s.download_root.empty() ? std::string("-") : s.download_root;
|
||||
if (!why.empty())
|
||||
detail = s.download_root.empty() ? why : detail + " · " + why;
|
||||
add("Download folder", detail, tr("Change"), Act::folder);
|
||||
}
|
||||
add("Download connections",
|
||||
tr("Parallel connections Pegasus DL opens for each download (1 to 64)"),
|
||||
std::to_string(s.connections), Act::connections);
|
||||
add("Auto-reconnect dropped downloads",
|
||||
tr("When a download loses its connection, Wings retries it by itself (3 tries without progress)"),
|
||||
prefs_.auto_reconnect ? std::string(tr("On")) : std::string(tr("Off")), Act::auto_reconnect);
|
||||
add("Keep PKG files after install",
|
||||
tr("After Wings installs a downloaded .pkg it deletes the file. Turn on to keep it (a failed install always keeps it)"),
|
||||
prefs_.keep_pkg_files ? std::string(tr("On")) : std::string(tr("Off")), Act::keep_pkg_files);
|
||||
begin("Accounts", theme::accent_accounts);
|
||||
add("Real-Debrid", debrid_detail(s.real_debrid, true), debrid_right(s.real_debrid),
|
||||
Act::real_debrid);
|
||||
@@ -636,6 +685,8 @@ void SettingsScreen::open_token_menu(bool torbox)
|
||||
add("refresh", "Check the token again", tr("Ask the service whether it is still valid"),
|
||||
view.configured);
|
||||
add("clear", "Remove the token", tr("Pegasus DL forgets the account"), view.configured);
|
||||
if (torbox)
|
||||
add("open-site", "Open TorBox website", tr("See what TorBox is preparing for you, in the PS5 browser"), true);
|
||||
list_for_ = ListFor::token_menu;
|
||||
list_.open(torbox ? "TorBox" : "Real-Debrid", debrid_detail(view, true), std::move(items),
|
||||
ListDialog::Mode::single);
|
||||
@@ -758,6 +809,15 @@ void SettingsScreen::on_confirm(int row, Feedback &feedback, Intent &intent)
|
||||
"This changes only Wings."),
|
||||
tr("Reset"), tr("Cancel"), false);
|
||||
break;
|
||||
case Act::bad_links:
|
||||
open_bad_links();
|
||||
break;
|
||||
case Act::clear_bad_links:
|
||||
pending_ = Pending::clear_bad_links;
|
||||
confirm_.open(tr("Clear all bad-link flags?"),
|
||||
tr("Wings forgets every link that failed for you. Best may pick them again."), tr("Clear all"),
|
||||
tr("Cancel"), true);
|
||||
break;
|
||||
case Act::pref_firmware:
|
||||
open_choice(ListFor::firmware, "Preferred firmware", kFirmwareChoices,
|
||||
kFirmwareChoiceCount, prefs_.firmware);
|
||||
@@ -807,33 +867,44 @@ void SettingsScreen::on_confirm(int row, Feedback &feedback, Intent &intent)
|
||||
intent.toast = prefs_.update_check ? tr("Wings will check for updates when it starts")
|
||||
: tr("Wings will not check for updates");
|
||||
break;
|
||||
case Act::auto_reconnect:
|
||||
prefs_.auto_reconnect = !prefs_.auto_reconnect;
|
||||
store_local_prefs();
|
||||
intent.toast = prefs_.auto_reconnect ? tr("Dropped downloads reconnect by themselves")
|
||||
: tr("Dropped downloads stay failed");
|
||||
break;
|
||||
case Act::keep_pkg_files:
|
||||
prefs_.keep_pkg_files = !prefs_.keep_pkg_files;
|
||||
store_local_prefs();
|
||||
intent.toast = prefs_.keep_pkg_files ? tr("PKG files are kept after install")
|
||||
: tr("PKG files are deleted after install");
|
||||
break;
|
||||
case Act::notes:
|
||||
prefs_.notes_enabled = !prefs_.notes_enabled;
|
||||
store_local_prefs();
|
||||
intent.toast = prefs_.notes_enabled ? tr("Game pages show known-problem notes")
|
||||
: tr("Game pages will not show notes");
|
||||
break;
|
||||
case Act::carousel:
|
||||
prefs_.carousel_enabled = !prefs_.carousel_enabled;
|
||||
store_local_prefs();
|
||||
intent.toast = prefs_.carousel_enabled ? tr("The Store shows the featured carousel")
|
||||
: tr("The Store will not show the carousel");
|
||||
break;
|
||||
case Act::wings_update:
|
||||
{
|
||||
UpdateView view;
|
||||
if (ctx_.wings_update_view && ctx_.wings_update_view(view))
|
||||
{
|
||||
intent.kind = Intent::Kind::open_update;
|
||||
break;
|
||||
}
|
||||
const WingsUpdateStatus up = wings_update().status();
|
||||
if (!up.attached || up.busy)
|
||||
break;
|
||||
if (up.advice == WingsAdvice::newer)
|
||||
{
|
||||
// How to install it by hand: a scrolling list (a confirm dialog would cut the steps off).
|
||||
const WingsUpdateGuide guide = wings_update_guide(up);
|
||||
std::vector<ListItem> items;
|
||||
for (const WingsUpdateStep &step : guide.steps)
|
||||
{
|
||||
ListItem item;
|
||||
item.label = step.label;
|
||||
item.detail = step.detail;
|
||||
items.push_back(std::move(item));
|
||||
}
|
||||
list_for_ = ListFor::wings_update;
|
||||
list_.open(guide.title, guide.subtitle, std::move(items), ListDialog::Mode::single);
|
||||
feedback.play(hui::audio::Cue::modal_open);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The row says why when the network is not ready.
|
||||
intent.toast = wings_update().check_now() ? tr("Checking for a newer Wings...")
|
||||
: tr("Could not check for updates");
|
||||
}
|
||||
// The row says why when the network is not ready.
|
||||
intent.toast = wings_update().check_now() ? tr("Checking for a newer Wings...")
|
||||
: tr("Could not check for updates");
|
||||
break;
|
||||
}
|
||||
case Act::pegasus_startup:
|
||||
@@ -977,10 +1048,43 @@ void SettingsScreen::open_choice(ListFor which, const char *title, const char *c
|
||||
std::move(items), ListDialog::Mode::single);
|
||||
}
|
||||
|
||||
void SettingsScreen::open_bad_links()
|
||||
{
|
||||
bad_link_ids_.clear();
|
||||
const LinkFlags *flags = link_flags();
|
||||
if (flags == nullptr)
|
||||
return;
|
||||
std::vector<ListItem> items;
|
||||
for (const auto &[id, flag] : flags->list(static_cast<std::int64_t>(std::time(nullptr))))
|
||||
{
|
||||
ListItem item;
|
||||
item.label = flag.title.empty() ? std::string(tr("A link")) : flag.title;
|
||||
item.detail = flag_line(flag);
|
||||
item.right = tr("Clear");
|
||||
items.push_back(std::move(item));
|
||||
bad_link_ids_.push_back(id);
|
||||
}
|
||||
if (items.empty())
|
||||
{
|
||||
list_for_ = ListFor::none;
|
||||
toast_ = tr("No bad links on this console");
|
||||
return;
|
||||
}
|
||||
list_for_ = ListFor::bad_links;
|
||||
list_.open(tr("Bad links"), tr("Links that failed for you on this console. Select one to clear its flag."),
|
||||
std::move(items), ListDialog::Mode::single);
|
||||
}
|
||||
|
||||
Intent SettingsScreen::update(const InputFrame &input, float dt, Feedback &feedback, bool focused)
|
||||
{
|
||||
static const InputFrame idle{};
|
||||
Intent intent;
|
||||
// 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 had_prompt = prompt_.is_open();
|
||||
@@ -1069,6 +1173,15 @@ Intent SettingsScreen::update(const InputFrame &input, float dt, Feedback &feedb
|
||||
ctx_.reset_store_grid();
|
||||
intent.toast = tr("Store grid reset");
|
||||
}
|
||||
else if (pending_ == Pending::clear_bad_links)
|
||||
{
|
||||
if (LinkFlags *flags = link_flags())
|
||||
{
|
||||
flags->clear_all();
|
||||
save_link_flags();
|
||||
}
|
||||
intent.toast = tr("Bad-link flags cleared");
|
||||
}
|
||||
else if (pending_ == Pending::clear_token)
|
||||
{
|
||||
SettingsPatch patch;
|
||||
@@ -1130,6 +1243,20 @@ Intent SettingsScreen::update(const InputFrame &input, float dt, Feedback &feedb
|
||||
toast_ = tr("Link preference saved");
|
||||
}
|
||||
}
|
||||
else if (list_for_ == ListFor::bad_links)
|
||||
{
|
||||
// Select on a flag clears it; the list opens again with the rest.
|
||||
if (chosen >= 0 && chosen < static_cast<int>(bad_link_ids_.size()))
|
||||
{
|
||||
if (LinkFlags *flags = link_flags())
|
||||
{
|
||||
flags->clear(bad_link_ids_[static_cast<std::size_t>(chosen)]);
|
||||
save_link_flags();
|
||||
}
|
||||
toast_ = tr("Bad-link flag cleared");
|
||||
open_bad_links();
|
||||
}
|
||||
}
|
||||
else if (list_for_ == ListFor::token_menu && chosen >= 0 &&
|
||||
chosen < static_cast<int>(menu_codes_.size()))
|
||||
{
|
||||
@@ -1166,6 +1293,19 @@ Intent SettingsScreen::update(const InputFrame &input, float dt, Feedback &feedb
|
||||
debrid_detail(view, true));
|
||||
});
|
||||
}
|
||||
else if (code == "open-site")
|
||||
{
|
||||
ctx_.store.call<bool>(
|
||||
Store::lane_admin, [](Backend &b) { return b.open_url(kTorboxDashboardUrl); },
|
||||
[this](Result<bool> &&r)
|
||||
{
|
||||
if (!r.ok())
|
||||
{
|
||||
pending_ = Pending::notice;
|
||||
confirm_.notice(tr("Could not open the PS5 browser"), r.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (code == "clear")
|
||||
{
|
||||
pending_ = Pending::clear_token;
|
||||
@@ -1213,7 +1353,7 @@ void SettingsScreen::draw(Frame &frame, bool focused) const
|
||||
ListScreen::draw(frame, focused && !modal());
|
||||
Context &ctx = ctx_;
|
||||
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);
|
||||
list_.draw(ctx, frame);
|
||||
picker_.draw(ctx, frame);
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
#include "gfx/backdrop_spec.hpp"
|
||||
#include "gfx/draw_list.hpp"
|
||||
#include "orbit/art.hpp"
|
||||
#include "orbit/carousel.hpp"
|
||||
#include "orbit/catalogue_view.hpp"
|
||||
#include "orbit/torbox_status.hpp"
|
||||
#include "orbit/cover_art.hpp"
|
||||
#include "orbit/destination_browser.hpp"
|
||||
#include "orbit/download_all.hpp"
|
||||
#include "orbit/download_folder.hpp"
|
||||
#include "orbit/files_logic.hpp"
|
||||
#include "orbit/game_header.hpp"
|
||||
#include "orbit/game_view.hpp"
|
||||
@@ -30,11 +33,13 @@
|
||||
#include "orbit/popularity.hpp"
|
||||
#include "orbit/theme.hpp"
|
||||
#include "orbit/ui.hpp"
|
||||
#include "orbit/update_screen.hpp"
|
||||
#include "ui/components/keyboard.hpp"
|
||||
#include "ui/feedback.hpp"
|
||||
#include "ui/glyphs.hpp"
|
||||
#include "ui/theme.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
@@ -44,6 +49,8 @@
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
struct UpdateView; // update_screen.hpp
|
||||
|
||||
using hui::Action;
|
||||
using hui::Direction;
|
||||
using hui::InputFrame;
|
||||
@@ -104,6 +111,11 @@ struct Context
|
||||
std::vector<std::string> wings_prefs_paths;
|
||||
// Settings > Wings > Store grid: back to A to Z with no filters (the App sets it).
|
||||
std::function<void()> reset_store_grid;
|
||||
// The first-download "Download folder" question (docs B-13), shared by the Store and Downloads tabs. The App sets
|
||||
// the two functions from Settings' prefs; empty (preview, tests): downloads are never held back.
|
||||
FirstDownloadGate folder_gate;
|
||||
std::function<bool()> folder_confirmed;
|
||||
std::function<void()> confirm_folder; // sets and saves WingsLocalPrefs::folder_confirmed
|
||||
float clock = 0.0f; // seconds since the app opened
|
||||
// The first-run "load Pegasus DL" block is drawn under the waiting card (the App sets it every
|
||||
// frame): the card then drops its "Need it? Settings > ..." line and the raw link error.
|
||||
@@ -122,6 +134,9 @@ struct Context
|
||||
// Ends the title cleanly (the console build: the system is asked to close it). Empty
|
||||
// where Wings cannot close itself (preview, tests).
|
||||
std::function<void()> close_app;
|
||||
// Settings > Wings > Wings update: fills the view and returns true while a newer Wings is known (the App
|
||||
// sets it from the update logic). Empty or false: the row says "Up to date".
|
||||
std::function<bool(UpdateView &)> wings_update_view;
|
||||
};
|
||||
|
||||
// The top-level tabs, left to right.
|
||||
@@ -144,6 +159,7 @@ struct Intent
|
||||
none,
|
||||
top_bar, // the focus left the screen upward
|
||||
show_tab, // tab: one of tab::
|
||||
open_update, // Settings > Wings update: the App opens its full-screen update page
|
||||
} kind = Kind::none;
|
||||
int tab = 0;
|
||||
std::string toast; // a short confirmation the app shows
|
||||
@@ -272,6 +288,11 @@ class ListScreen : public Screen
|
||||
{
|
||||
return index_;
|
||||
}
|
||||
// Previews: put the focus on a row.
|
||||
void set_index(int index)
|
||||
{
|
||||
index_ = index;
|
||||
}
|
||||
// The accent of the focused row's section (theme::Accent; -1 none). Tests.
|
||||
int focus_accent() const
|
||||
{
|
||||
@@ -440,10 +461,18 @@ class StoreScreen final : public Screen
|
||||
{
|
||||
return list_;
|
||||
}
|
||||
bool folder_ask_open() const
|
||||
{
|
||||
return folder_ask_.is_open();
|
||||
}
|
||||
bool confirm_open() const
|
||||
{
|
||||
return confirm_.is_open();
|
||||
}
|
||||
const ConfirmDialog &confirm_dialog() const
|
||||
{
|
||||
return confirm_;
|
||||
}
|
||||
// The focused tile's position and what it shows (tests).
|
||||
int focus_index() const
|
||||
{
|
||||
@@ -457,6 +486,27 @@ class StoreScreen final : public Screen
|
||||
{
|
||||
return panel_ != Panel::none;
|
||||
}
|
||||
// The carousel (tests, previews): shown, how many slides, the focus on it, its slide and button.
|
||||
bool carousel_visible() const
|
||||
{
|
||||
return carousel_on_;
|
||||
}
|
||||
int carousel_count() const
|
||||
{
|
||||
return static_cast<int>(carousel_games_.size());
|
||||
}
|
||||
bool on_carousel() const
|
||||
{
|
||||
return in_carousel_;
|
||||
}
|
||||
int carousel_slide() const
|
||||
{
|
||||
return car_slide_;
|
||||
}
|
||||
int carousel_button() const
|
||||
{
|
||||
return car_button_;
|
||||
}
|
||||
// -1 / 0 / 1: unknown / not installed / installed, for the tile at position.
|
||||
int tile_installed(int position) const;
|
||||
bool tile_favourite(int position) const;
|
||||
@@ -481,6 +531,8 @@ class StoreScreen final : public Screen
|
||||
parts_confirm, // "Download 22 parts from VikingFile?"
|
||||
parts_service, // which debrid service takes all the parts
|
||||
more_menu, // the game page header's three-dots menu
|
||||
bad_reason, // "Mark as bad": why
|
||||
torbox_preparing, // TorBox still preparing after 10 minutes: Try again / Open TorBox / OK
|
||||
};
|
||||
enum class Panel : std::uint8_t
|
||||
{
|
||||
@@ -492,7 +544,27 @@ class StoreScreen final : public Screen
|
||||
};
|
||||
static constexpr int kColumns = 5;
|
||||
|
||||
// The carousel's picture addresses for one game.
|
||||
struct CarouselArt
|
||||
{
|
||||
std::string key; // art key of the game's cover ("pkg:<id>"), shared with the tile and the page
|
||||
std::string hero_key; // the key its 16:9 pictures are cached under (key, or key + "~b": art_sources.hpp)
|
||||
std::string low_url; // the small 16:9 picture ("" none)
|
||||
std::string hero_url; // the full 16:9 picture ("" none); drawn over the small one once it is there
|
||||
};
|
||||
ui::Rect tile_rect(int index) const;
|
||||
// Extra height above grid row 0 while the carousel is shown (0 otherwise: the grid is exactly as it was).
|
||||
float carousel_extra() const
|
||||
{
|
||||
return carousel_on_ ? kCarouselExtra : 0.0f;
|
||||
}
|
||||
void update_carousel(float dt, bool focused);
|
||||
void update_carousel_input(const InputFrame &input, Feedback &feedback, Intent &intent);
|
||||
bool toggle_carousel_favourite();
|
||||
void draw_carousel(hui::gfx::DrawList &list) const;
|
||||
// The slide's full / small picture if ready (asks for it otherwise). Null: none, or not yet.
|
||||
const Artwork *carousel_full_art(const CarouselArt &art) const;
|
||||
const Artwork *carousel_low_art(const CarouselArt &art) const;
|
||||
void refresh_prefs();
|
||||
void rebuild();
|
||||
void read_facts(std::uint64_t serial, std::size_t from);
|
||||
@@ -518,6 +590,15 @@ class StoreScreen final : public Screen
|
||||
void update_flow(Feedback &feedback, Intent &intent);
|
||||
void activate_row(Feedback &feedback);
|
||||
void activate_mirror(int mirror, Feedback &feedback);
|
||||
// B-20: the card menu's last item, "Mark as bad" or "Clear bad mark".
|
||||
// That item chosen: clears the card's flag, or asks why it is bad.
|
||||
void choose_flag_item(int mirror, Feedback &feedback);
|
||||
// B-19: TorBox is still preparing the file on its server.
|
||||
void retry_torbox();
|
||||
void open_torbox_notice();
|
||||
void open_torbox_site();
|
||||
// Flags (reason none: clears) every link of the card, saves, ranks the page again.
|
||||
void mark_mirror(int mirror, int reason);
|
||||
void activate_ref(const LinkRef &ref, Feedback &feedback);
|
||||
void start_single(const std::string &service);
|
||||
void start_batch(const std::string &service);
|
||||
@@ -566,6 +647,23 @@ class StoreScreen final : public Screen
|
||||
HeroPrefetchGate hero_gate_; // when the focused tile's 16:9 picture is fetched ahead
|
||||
std::uint64_t prefetch_serial_ = ~0ull;
|
||||
bool on_button_ = false; // the focus is on the round sort/filter button
|
||||
// The curated carousel above the grid (carousel.hpp). carousel_games_ are indexes into games_.
|
||||
bool carousel_on_ = false; // shown (default grid view, switch on, at least one game found)
|
||||
bool in_carousel_ = false; // the focus is on the carousel's buttons
|
||||
int car_slide_ = 0; // index into carousel_games_
|
||||
int car_button_ = 0; // kCarouselView / kCarouselFavourite
|
||||
std::vector<int> carousel_games_;
|
||||
std::vector<CarouselArt> carousel_art_; // per carousel game
|
||||
CarouselClock car_clock_;
|
||||
ui::Ease car_focus_; // the carousel has the focus
|
||||
ui::Ease car_btn_[2]; // per button
|
||||
ui::Ease car_in_; // a new slide fades in
|
||||
float car_on_slide_s_ = 0.0f; // seconds the current slide has been up
|
||||
std::vector<std::string> car_prefetched_; // picture addresses already sent to the disk cache
|
||||
// Per game (its art key): the picture shown now is the IGDB one (true) or Prosperopatches' (false); a page
|
||||
// that opens flips it for the next time (art_sources.hpp, pick_hero). Memory only.
|
||||
std::map<std::string, bool> hero_second_;
|
||||
std::uint64_t seen_editorial_ = ~0ull;
|
||||
int index_ = 0;
|
||||
ui::Ease scroll_;
|
||||
ui::Ease button_focus_;
|
||||
@@ -592,6 +690,7 @@ class StoreScreen final : public Screen
|
||||
bool waiting_detail_ = false;
|
||||
GameHeaderState header_; // the page header: focus, slide, hero (game_header.hpp)
|
||||
// Panels and the flow they belong to.
|
||||
FolderAsk folder_ask_; // the first-download folder question (start_single, start_batch)
|
||||
TextPrompt prompt_;
|
||||
ConfirmDialog confirm_;
|
||||
ListDialog list_;
|
||||
@@ -599,6 +698,14 @@ class StoreScreen final : public Screen
|
||||
LinkRef pending_ref_; // the link a confirmation or choice is about
|
||||
PackageLink pending_link_;
|
||||
int parts_mirror_ = -1; // the multipart card whose menu is open
|
||||
int card_mirror_ = -1; // the card of bad_reason
|
||||
// B-19: while TorBox prepares a file, the link is asked again every 30 s for up to 10 minutes.
|
||||
TorboxPrepareWait torbox_wait_;
|
||||
TorboxPrepareWait::Tick torbox_tick_;
|
||||
double torbox_clock_ = 0.0;
|
||||
LinkRef torbox_ref_;
|
||||
std::string torbox_service_;
|
||||
std::string single_service_; // the service of the last single start
|
||||
PartsPlan parts_plan_;
|
||||
std::string selection_id_;
|
||||
std::string selection_service_;
|
||||
@@ -687,6 +794,7 @@ class DownloadsScreen final : public Screen
|
||||
TextPrompt prompt_;
|
||||
ConfirmDialog confirm_;
|
||||
ListDialog list_;
|
||||
FolderAsk folder_ask_; // the first-download folder question (queue a direct link)
|
||||
Pending pending_ = Pending::none;
|
||||
ListFor list_for_ = ListFor::none;
|
||||
std::string menu_job_;
|
||||
@@ -1211,6 +1319,8 @@ class SettingsScreen final : public ListScreen
|
||||
capabilities,
|
||||
folder,
|
||||
connections,
|
||||
auto_reconnect,
|
||||
keep_pkg_files,
|
||||
real_debrid,
|
||||
torbox,
|
||||
group,
|
||||
@@ -1236,6 +1346,10 @@ class SettingsScreen final : public ListScreen
|
||||
wings_update,
|
||||
icon_source,
|
||||
hero_source,
|
||||
notes,
|
||||
carousel,
|
||||
bad_links, // B-20: the bad-link flags of this console
|
||||
clear_bad_links, // B-20: forget them all (asks first)
|
||||
};
|
||||
enum class Pending : std::uint8_t
|
||||
{
|
||||
@@ -1246,6 +1360,7 @@ class SettingsScreen final : public ListScreen
|
||||
notice,
|
||||
pegasus_download,
|
||||
pegasus_send,
|
||||
clear_bad_links,
|
||||
};
|
||||
enum class ListFor : std::uint8_t
|
||||
{
|
||||
@@ -1257,8 +1372,11 @@ class SettingsScreen final : public ListScreen
|
||||
region,
|
||||
format,
|
||||
providers,
|
||||
wings_update,
|
||||
bad_links,
|
||||
};
|
||||
// B-20: the flag ids the bad_links list shows, in its order.
|
||||
std::vector<std::string> bad_link_ids_;
|
||||
void open_bad_links();
|
||||
enum class Typing : std::uint8_t
|
||||
{
|
||||
none,
|
||||
@@ -1428,6 +1546,9 @@ class App
|
||||
{
|
||||
return after_install_;
|
||||
}
|
||||
// The preview scenes open the update dialog (true) or page (false) at once; false while nothing newer
|
||||
// is known. (On the console the dialog comes from the startup prompt, the page from Settings.)
|
||||
bool show_update(bool dialog);
|
||||
|
||||
private:
|
||||
enum class Area : std::uint8_t
|
||||
@@ -1466,9 +1587,19 @@ class App
|
||||
int capture_button_ = 0; // 0 hide / close, 1 cancel the capture
|
||||
ui::Ease capture_in_;
|
||||
std::uint64_t capture_toasted_ = 0; // capture_serial whose completion was announced
|
||||
// A newer Wings (wings_update.hpp): looked for when the network is ready, announced once per start.
|
||||
// A newer Wings (wings_update.hpp, wings_install.hpp): looked for when the network is ready, offered in a
|
||||
// dialog once per start (unless snoozed), followed on the full-screen update page.
|
||||
float update_poll_ = 0.0f;
|
||||
bool update_announced_ = false;
|
||||
UpdateDialog update_dialog_;
|
||||
UpdatePage update_page_;
|
||||
ConfirmDialog install_confirm_; // "Install Wings X?" before the page's Install acts
|
||||
bool install_replace_ = false; // the confirm named the player's own copy (needs_replace_confirm)
|
||||
bool update_modal() const
|
||||
{
|
||||
return update_dialog_.is_open() || update_page_.is_open() || install_confirm_.is_open();
|
||||
}
|
||||
void update_flow(const InputFrame &input, float dt, Feedback &feedback);
|
||||
void start_update_download();
|
||||
};
|
||||
|
||||
} // namespace orbit
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "orbit/screens.hpp"
|
||||
|
||||
#include "orbit/busy_cancel.hpp"
|
||||
#include "orbit/model.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -128,6 +129,12 @@ Intent SourcesScreen::update(const InputFrame &input, float dt, Feedback &feedba
|
||||
const int count = static_cast<int>(state.sources.size());
|
||||
index_ = std::clamp(index_, 0, std::max(0, count - 1));
|
||||
|
||||
// Circle on "Asking Pegasus DL...": back at once, the late answer is ignored (B-18).
|
||||
if (ctx_.store.source_action().busy && input.is_pressed(Action::back))
|
||||
{
|
||||
ctx_.store.cancel(Store::lane_sources);
|
||||
return intent;
|
||||
}
|
||||
const bool had_prompt = prompt_.is_open();
|
||||
const bool had_confirm = confirm_.is_open();
|
||||
const bool had_list = list_.is_open();
|
||||
@@ -335,7 +342,7 @@ void SourcesScreen::draw(Frame &frame, bool focused) const
|
||||
list.pop_clip();
|
||||
}
|
||||
if (ctx_.store.source_action().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);
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
|
||||
#include "orbit/store.hpp"
|
||||
|
||||
#include "orbit/download_auto_retry.hpp"
|
||||
#include "orbit/i18n.hpp"
|
||||
#include "orbit/install_hint.hpp"
|
||||
#include "orbit/link_flags.hpp"
|
||||
#include "orbit/link_retry.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
@@ -34,6 +40,35 @@ enum Chan
|
||||
chan_probe = 3,
|
||||
chan_none = -1,
|
||||
};
|
||||
|
||||
const char *kind_name(LinkResult::Kind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case LinkResult::Kind::dispatched:
|
||||
return "dispatched";
|
||||
case LinkResult::Kind::capture:
|
||||
return "capture";
|
||||
case LinkResult::Kind::select_files:
|
||||
return "select_files";
|
||||
case LinkResult::Kind::browser_opened:
|
||||
return "browser_opened";
|
||||
case LinkResult::Kind::blocked:
|
||||
return "blocked";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
// [WINGS-PARTS] pkg=<id> link=<n> kind=<k> transient=<0|1> ms=<elapsed> err=<text, 80 chars at most>
|
||||
std::string parts_line(const std::string &package_id, int link_index, const LinkStart &outcome,
|
||||
double seconds)
|
||||
{
|
||||
std::string line = "[WINGS-PARTS] pkg=" + package_id + " link=" + std::to_string(link_index) +
|
||||
" kind=" + kind_name(outcome.kind) + " transient=" + (outcome.transient ? "1" : "0") +
|
||||
" ms=" + std::to_string(static_cast<long long>(seconds * 1000.0)) + " err=";
|
||||
line += outcome.error.substr(0, 80);
|
||||
return line;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
const Package *Snapshot::package(std::string_view id) const
|
||||
@@ -101,6 +136,43 @@ void Store::apply_settings(const SettingsView &value)
|
||||
++state_.serial;
|
||||
}
|
||||
|
||||
void Store::ensure_default_download_folder(const std::string &folder)
|
||||
{
|
||||
const FolderNeed need = folder_need(state_.have_settings, state_.settings.download_root,
|
||||
default_folder_tried_);
|
||||
if (!need.set_default)
|
||||
return;
|
||||
default_folder_tried_ = true;
|
||||
submit(-1,
|
||||
[this, folder]
|
||||
{
|
||||
Outcome out;
|
||||
std::string failure;
|
||||
const Result<bool> made = backend_.mkdir(folder);
|
||||
// 409: the folder is already there, which is all we need.
|
||||
if (!made.ok() && made.status != 409)
|
||||
failure = made.error.empty() ? "The default download folder could not be created."
|
||||
: made.error;
|
||||
if (failure.empty())
|
||||
{
|
||||
SettingsPatch patch;
|
||||
patch.has_root = true;
|
||||
patch.root = folder;
|
||||
Result<SettingsView> updated = backend_.settings_update(patch);
|
||||
if (updated.ok())
|
||||
{
|
||||
inbox_.post([this, value = std::move(updated.value)]() mutable
|
||||
{ apply_settings(value); });
|
||||
return out;
|
||||
}
|
||||
failure = updated.error.empty() ? "The default download folder was refused."
|
||||
: updated.error;
|
||||
}
|
||||
inbox_.post([this, failure] { default_folder_status_ = failure; });
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
void Store::apply_installed(const std::vector<InstalledGame> &value)
|
||||
{
|
||||
state_.installed = value;
|
||||
@@ -156,16 +228,104 @@ void Store::post_jobs(std::vector<Job> jobs)
|
||||
}
|
||||
if (seen_completed_.size() > 512)
|
||||
seen_completed_.clear();
|
||||
known_jobs_.clear();
|
||||
for (const Job &job : jobs)
|
||||
known_jobs_.insert(job.id);
|
||||
known_jobs_valid_ = true;
|
||||
active_jobs_.store(active);
|
||||
inbox_.post(
|
||||
[this, jobs = std::move(jobs)]() mutable
|
||||
{
|
||||
note_flag_jobs(jobs);
|
||||
state_.jobs = std::move(jobs);
|
||||
state_.have_queue = true;
|
||||
++state_.serial;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- bad-link flags (B-20) ----
|
||||
|
||||
namespace
|
||||
{
|
||||
// A start's jobs show up within this long (a browser capture can take a while).
|
||||
constexpr double kFlagWindowSeconds = 600.0;
|
||||
} // namespace
|
||||
|
||||
std::string Store::job_flag_id(const std::string &job_id) const
|
||||
{
|
||||
const auto it = job_flag_ids_.find(job_id);
|
||||
return it == job_flag_ids_.end() ? std::string() : it->second;
|
||||
}
|
||||
|
||||
void Store::take_flag_id()
|
||||
{
|
||||
pending_flag_id_ = std::move(next_flag_id_);
|
||||
pending_flag_title_ = std::move(next_flag_title_);
|
||||
next_flag_id_.clear();
|
||||
next_flag_title_.clear();
|
||||
pending_flag_at_ = now_seconds();
|
||||
}
|
||||
|
||||
void Store::note_flag_jobs(const std::vector<Job> &jobs)
|
||||
{
|
||||
// Jobs that were not in the last queue belong to the last flagged start, while it is recent.
|
||||
if (state_.have_queue && !pending_flag_id_.empty() && now_seconds() - pending_flag_at_ <= kFlagWindowSeconds)
|
||||
{
|
||||
std::set<std::string> old;
|
||||
for (const Job &job : state_.jobs)
|
||||
old.insert(job.id);
|
||||
for (const Job &job : jobs)
|
||||
if (old.count(job.id) == 0 && job_flag_ids_.count(job.id) == 0)
|
||||
{
|
||||
job_flag_ids_[job.id] = pending_flag_id_;
|
||||
job_flag_titles_[job.id] = pending_flag_title_;
|
||||
}
|
||||
}
|
||||
std::set<std::string> listed;
|
||||
for (const Job &job : jobs)
|
||||
{
|
||||
listed.insert(job.id);
|
||||
if (job.status != "failed")
|
||||
{
|
||||
flagged_jobs_.erase(job.id); // a retry that fails again is flagged again
|
||||
continue;
|
||||
}
|
||||
if (job_flag_ids_.count(job.id) == 0 || flagged_jobs_.count(job.id) != 0)
|
||||
continue;
|
||||
// Still being reconnected: not final (the backend already shows these as "retrying").
|
||||
const DownloadAutoRetry::View::Kind kind = download_reconnect().view(job.id).kind;
|
||||
if (kind == DownloadAutoRetry::View::Kind::waiting || kind == DownloadAutoRetry::View::Kind::reconnecting)
|
||||
continue;
|
||||
// Pegasus' catalogue miss is not the link's fault.
|
||||
if (job.error.find("Download link not found") != std::string::npos)
|
||||
continue;
|
||||
FlagReason reason = reason_from_error(job.error, download_error_http_code(job.error));
|
||||
if (reason == FlagReason::none && looks_like_install_failure(job.error))
|
||||
reason = FlagReason::wrong_file;
|
||||
if (reason == FlagReason::none)
|
||||
continue;
|
||||
flagged_jobs_.insert(job.id);
|
||||
flag_job(job.id, static_cast<int>(reason));
|
||||
}
|
||||
// Forget jobs Pegasus no longer lists (cleared).
|
||||
for (auto it = job_flag_ids_.begin(); it != job_flag_ids_.end();)
|
||||
it = listed.count(it->first) == 0 ? job_flag_ids_.erase(it) : std::next(it);
|
||||
for (auto it = job_flag_titles_.begin(); it != job_flag_titles_.end();)
|
||||
it = listed.count(it->first) == 0 ? job_flag_titles_.erase(it) : std::next(it);
|
||||
}
|
||||
|
||||
void Store::flag_job(const std::string &job_id, int reason)
|
||||
{
|
||||
LinkFlags *flags = link_flags();
|
||||
const auto it = job_flag_ids_.find(job_id);
|
||||
if (flags == nullptr || it == job_flag_ids_.end())
|
||||
return;
|
||||
const auto title = job_flag_titles_.find(job_id);
|
||||
flags->add(it->second, static_cast<FlagReason>(reason), static_cast<std::int64_t>(std::time(nullptr)),
|
||||
title == job_flag_titles_.end() ? std::string() : title->second);
|
||||
save_link_flags();
|
||||
}
|
||||
|
||||
void Store::post_sources(std::vector<Source> sources)
|
||||
{
|
||||
note_sources(sources);
|
||||
@@ -238,22 +398,29 @@ void Store::poll_capture()
|
||||
}
|
||||
if (session.empty())
|
||||
return;
|
||||
poll_session(session, nullptr);
|
||||
}
|
||||
|
||||
bool Store::poll_session(const std::string &session, CaptureView *seen)
|
||||
{
|
||||
Result<CaptureView> status = backend_.capture_status(session);
|
||||
if (!status.ok())
|
||||
{
|
||||
// A missed poll is not a failed capture; try again next time.
|
||||
if (status.unreachable())
|
||||
post_failure(status.error, true);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
CaptureView view = std::move(status.value);
|
||||
if (view.session_id.empty())
|
||||
view.session_id = session;
|
||||
const bool done = view.terminal();
|
||||
if (done)
|
||||
if (seen != nullptr)
|
||||
*seen = view;
|
||||
if (view.terminal())
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
capture_session_.clear();
|
||||
if (capture_session_ == session)
|
||||
capture_session_.clear();
|
||||
}
|
||||
if (view.state == "completed" && view.result.has_jobs)
|
||||
post_jobs(view.result.jobs);
|
||||
@@ -263,6 +430,7 @@ void Store::poll_capture()
|
||||
poll_requested_ = true;
|
||||
}
|
||||
post_capture(std::move(view));
|
||||
return true;
|
||||
}
|
||||
|
||||
void Store::poll_once()
|
||||
@@ -531,7 +699,7 @@ void Store::catalog_worker()
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
void Store::submit(int channel, std::function<Outcome()> work)
|
||||
void Store::submit(int channel, std::function<Outcome()> work, int guard)
|
||||
{
|
||||
if (channel >= 0)
|
||||
{
|
||||
@@ -541,13 +709,134 @@ void Store::submit(int channel, std::function<Outcome()> work)
|
||||
c.state.error.clear();
|
||||
c.state.message.clear();
|
||||
}
|
||||
if (guard == kSameLane)
|
||||
guard = channel;
|
||||
Command command;
|
||||
command.channel = channel;
|
||||
command.work = std::move(work);
|
||||
command.guard = guard >= 0 && guard < kLanes ? guard : -1;
|
||||
command.gen = command.guard >= 0 ? gen_[command.guard].load() : 0;
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
commands_.push_back({channel, std::move(work)});
|
||||
commands_.push_back(std::move(command));
|
||||
}
|
||||
signal_.notify();
|
||||
}
|
||||
|
||||
void Store::cancel(int lane)
|
||||
{
|
||||
if (lane < 0 || lane >= kLanes)
|
||||
return;
|
||||
gen_[lane].fetch_add(1);
|
||||
// The screen goes back now: no busy state, no stale error or confirmation.
|
||||
// The serial is left alone, so no screen takes this for a finished action.
|
||||
Channel &c = channel_[lane];
|
||||
c.pending = 0;
|
||||
c.state.busy = false;
|
||||
c.state.error.clear();
|
||||
c.state.message.clear();
|
||||
++state_.serial;
|
||||
std::size_t dropped = 0;
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
const std::size_t before = commands_.size();
|
||||
commands_.erase(std::remove_if(commands_.begin(), commands_.end(),
|
||||
[lane](const Command &command) { return command.guard == lane; }),
|
||||
commands_.end());
|
||||
dropped = before - commands_.size();
|
||||
}
|
||||
signal_.notify(); // a series wait for a capture ends at once
|
||||
if (this->log)
|
||||
this->log("[WINGS-CANCEL] lane=" + std::to_string(lane) + " dropped=" + std::to_string(dropped));
|
||||
}
|
||||
|
||||
bool Store::abandoned() const
|
||||
{
|
||||
return running_guard_ >= 0 && gen_[running_guard_].load() != running_gen_;
|
||||
}
|
||||
|
||||
void Store::post_current(std::function<void()> apply)
|
||||
{
|
||||
const int guard = running_guard_;
|
||||
const std::uint64_t gen = running_gen_;
|
||||
inbox_.post(
|
||||
[this, guard, gen, apply = std::move(apply)]
|
||||
{
|
||||
if (guard >= 0 && gen_[guard].load() != gen)
|
||||
return; // cancelled meanwhile: the answer changes nothing
|
||||
apply();
|
||||
});
|
||||
}
|
||||
|
||||
void Store::note_cancel(const std::string &what)
|
||||
{
|
||||
if (this->log)
|
||||
this->log("[WINGS-CANCEL] " + what);
|
||||
}
|
||||
|
||||
std::set<std::string> Store::jobs_before()
|
||||
{
|
||||
if (!known_jobs_valid_)
|
||||
{
|
||||
Result<std::vector<Job>> queue = backend_.queue();
|
||||
if (queue.ok())
|
||||
post_jobs(std::move(queue.value));
|
||||
}
|
||||
return known_jobs_;
|
||||
}
|
||||
|
||||
void Store::undo_queued(const std::set<std::string> &before, const std::vector<Job> *after,
|
||||
const std::string &session)
|
||||
{
|
||||
if (!session.empty())
|
||||
{
|
||||
backend_.capture_cancel(session);
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
if (capture_session_ == session)
|
||||
capture_session_.clear();
|
||||
}
|
||||
note_cancel("late capture stopped session=" + session);
|
||||
// A read posted before the cancel may still show it: the capture box closes.
|
||||
inbox_.post(
|
||||
[this, session]
|
||||
{
|
||||
if (state_.capture.session_id != session)
|
||||
return;
|
||||
state_.capture = {};
|
||||
++state_.capture_serial;
|
||||
++state_.serial;
|
||||
});
|
||||
}
|
||||
std::vector<Job> jobs;
|
||||
if (after != nullptr)
|
||||
{
|
||||
jobs = *after;
|
||||
}
|
||||
else
|
||||
{
|
||||
Result<std::vector<Job>> queue = backend_.queue();
|
||||
if (!queue.ok())
|
||||
return;
|
||||
jobs = std::move(queue.value);
|
||||
}
|
||||
std::vector<std::string> added;
|
||||
for (const Job &job : jobs)
|
||||
{
|
||||
if (before.count(job.id) == 0 && job.status != "completed" && job.status != "cancelled" &&
|
||||
job.status != "failed")
|
||||
added.push_back(job.id);
|
||||
}
|
||||
for (const std::string &id : added)
|
||||
{
|
||||
Result<std::vector<Job>> result = backend_.job_action(id, "cancel");
|
||||
note_cancel("late download cancelled job=" + id + (result.ok() ? "" : " error=" + result.error));
|
||||
if (result.ok())
|
||||
jobs = std::move(result.value);
|
||||
}
|
||||
post_jobs(std::move(jobs));
|
||||
}
|
||||
|
||||
Store::Outcome Store::jobs_outcome(Result<std::vector<Job>> &&result)
|
||||
{
|
||||
Outcome out;
|
||||
@@ -568,9 +857,25 @@ Store::Outcome Store::sources_outcome(Result<std::vector<Source>> &&result)
|
||||
return out;
|
||||
}
|
||||
|
||||
Store::Outcome Store::link_outcome(Result<LinkResult> &&result)
|
||||
Store::Outcome Store::link_outcome(Result<LinkResult> &&result, const std::set<std::string> &before)
|
||||
{
|
||||
if (on_answer)
|
||||
on_answer();
|
||||
Outcome out;
|
||||
if (abandoned())
|
||||
{
|
||||
// Circle came first: nothing of this answer is shown, and what it started is undone.
|
||||
note_cancel("late link answer dropped");
|
||||
if (result.ok())
|
||||
{
|
||||
const LinkResult &value = result.value;
|
||||
if (value.kind == LinkResult::Kind::capture)
|
||||
undo_queued(before, nullptr, value.capture.terminal() ? std::string() : value.capture.session_id);
|
||||
else if (value.kind == LinkResult::Kind::dispatched && (value.dispatch.queued || value.dispatch.has_jobs))
|
||||
undo_queued(before, value.dispatch.has_jobs ? &value.dispatch.jobs : nullptr, std::string());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.error = result.error;
|
||||
out.unreachable = result.unreachable();
|
||||
if (!result.ok())
|
||||
@@ -598,7 +903,7 @@ Store::Outcome Store::link_outcome(Result<LinkResult> &&result)
|
||||
{
|
||||
out.message = value.message;
|
||||
}
|
||||
inbox_.post(
|
||||
post_current(
|
||||
[this, value = std::move(value)]() mutable
|
||||
{
|
||||
state_.link_result = std::move(value);
|
||||
@@ -610,6 +915,11 @@ Store::Outcome Store::link_outcome(Result<LinkResult> &&result)
|
||||
|
||||
void Store::job_action(const std::string &job_id, const std::string &action)
|
||||
{
|
||||
// The player gave up on an archive no password opened: the link is flagged (B-20).
|
||||
if (action == "cancel")
|
||||
for (const Job &job : state_.jobs)
|
||||
if (job.id == job_id && job.status == "waitingPassword")
|
||||
flag_job(job_id, static_cast<int>(FlagReason::password));
|
||||
submit(chan_jobs,
|
||||
[this, job_id, action] { return jobs_outcome(backend_.job_action(job_id, action)); });
|
||||
}
|
||||
@@ -653,32 +963,63 @@ void Store::open_package(const std::string &package_id)
|
||||
|
||||
void Store::start_link(const std::string &package_id, int link_index, const std::string &service)
|
||||
{
|
||||
take_flag_id();
|
||||
submit(chan_link,
|
||||
[this, package_id, link_index, service]
|
||||
{ return link_outcome(backend_.start_link(package_id, link_index, service)); });
|
||||
{
|
||||
const std::set<std::string> before = jobs_before();
|
||||
return link_outcome(backend_.start_link(package_id, link_index, service), before);
|
||||
});
|
||||
}
|
||||
|
||||
void Store::start_link(const std::string &package_id, int link_index, const std::string &service,
|
||||
std::function<void(const LinkStart &)> done)
|
||||
{
|
||||
take_flag_id();
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
series_stop_ = false;
|
||||
}
|
||||
auto outcome = std::make_shared<LinkStart>();
|
||||
submit(chan_link,
|
||||
[this, package_id, link_index, service, outcome]
|
||||
{
|
||||
const double begun = now_seconds();
|
||||
const std::set<std::string> before = jobs_before();
|
||||
Result<LinkResult> result = backend_.start_link(package_id, link_index, service);
|
||||
outcome->error = result.error;
|
||||
outcome->kind = result.value.kind;
|
||||
outcome->message = result.value.message;
|
||||
return link_outcome(std::move(result));
|
||||
outcome->transient =
|
||||
!result.ok() && transient_link_error(result.status, result.error, result.unreachable());
|
||||
const bool capture = result.ok() && result.value.kind == LinkResult::Kind::capture;
|
||||
const CaptureView started = capture ? result.value.capture : CaptureView{};
|
||||
Outcome out = link_outcome(std::move(result), before);
|
||||
// After link_outcome(): the session is tracked and shown while it is waited for.
|
||||
const bool dropped_early = abandoned();
|
||||
if (capture && !dropped_early)
|
||||
wait_capture(started, outcome.get());
|
||||
// Circle during the wait: the capture is stopped, a part it queued is cancelled.
|
||||
if (capture && !dropped_early && abandoned())
|
||||
{
|
||||
note_cancel("series wait abandoned");
|
||||
undo_queued(before, nullptr, started.session_id);
|
||||
}
|
||||
if (this->log)
|
||||
this->log(parts_line(package_id, link_index, *outcome, now_seconds() - begun));
|
||||
return out;
|
||||
});
|
||||
// A second command posts after the first one's channel update: the callback
|
||||
// sees the snapshot and link_action() as they are once this link ended.
|
||||
submit(chan_none,
|
||||
[this, outcome, done = std::move(done)]
|
||||
{
|
||||
inbox_.post([outcome, done] { done(*outcome); });
|
||||
return Outcome{};
|
||||
});
|
||||
// Guarded by the link lane: after cancel() the series hears nothing more.
|
||||
submit(
|
||||
chan_none,
|
||||
[this, outcome, done = std::move(done)]
|
||||
{
|
||||
post_current([outcome, done] { done(*outcome); });
|
||||
return Outcome{};
|
||||
},
|
||||
chan_link);
|
||||
}
|
||||
|
||||
void Store::debrid_select(const std::string &selection_id, const std::vector<int> &indexes,
|
||||
@@ -686,7 +1027,86 @@ void Store::debrid_select(const std::string &selection_id, const std::vector<int
|
||||
{
|
||||
submit(chan_link,
|
||||
[this, selection_id, indexes, service]
|
||||
{ return link_outcome(backend_.debrid_select(selection_id, indexes, service)); });
|
||||
{
|
||||
const std::set<std::string> before = jobs_before();
|
||||
return link_outcome(backend_.debrid_select(selection_id, indexes, service), before);
|
||||
});
|
||||
}
|
||||
|
||||
void Store::stop_series()
|
||||
{
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
series_stop_ = true;
|
||||
}
|
||||
signal_.notify();
|
||||
}
|
||||
|
||||
void Store::set_series_wait(double poll_seconds, double limit_seconds)
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
series_poll_ = poll_seconds;
|
||||
series_limit_ = limit_seconds;
|
||||
}
|
||||
|
||||
// Worker: waits for the automated capture a series start_link() began. The
|
||||
// session stays tracked (track_session), so the capture UI and Downloads follow
|
||||
// every read; a timed-out or stopped wait leaves it to the normal poll.
|
||||
void Store::wait_capture(const CaptureView &started, LinkStart *outcome)
|
||||
{
|
||||
if (started.requires_browser)
|
||||
{
|
||||
outcome->error = "needs a check in the PS5 browser: open this part on its own";
|
||||
outcome->transient = false;
|
||||
return;
|
||||
}
|
||||
if (started.session_id.empty())
|
||||
{
|
||||
outcome->error = "Pegasus did not start a capture session";
|
||||
outcome->transient = false;
|
||||
return;
|
||||
}
|
||||
double poll = 1.0;
|
||||
double limit = 120.0;
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
poll = series_poll_;
|
||||
limit = series_limit_;
|
||||
}
|
||||
const double begun = now_seconds();
|
||||
CaptureView view = started;
|
||||
while (!view.terminal())
|
||||
{
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
const double until = now_seconds() + poll;
|
||||
while (!series_stop_ && !stopping_ && !abandoned() && now_seconds() < until)
|
||||
signal_.wait(mutex_, until - now_seconds());
|
||||
if (series_stop_ || stopping_ || abandoned())
|
||||
{
|
||||
outcome->error = "stopped";
|
||||
outcome->transient = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
poll_session(started.session_id, &view);
|
||||
if (!view.terminal() && now_seconds() - begun >= limit)
|
||||
{
|
||||
outcome->error = "timed out waiting for the provider";
|
||||
outcome->transient = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
outcome->message = view.message;
|
||||
if (view.state == "completed")
|
||||
{
|
||||
outcome->error.clear();
|
||||
outcome->kind = LinkResult::Kind::dispatched;
|
||||
outcome->transient = false;
|
||||
return;
|
||||
}
|
||||
outcome->error = view.message.empty() ? "capture " + view.state : view.message;
|
||||
outcome->transient = transient_link_error(0, outcome->error, false);
|
||||
}
|
||||
|
||||
bool Store::cancel_capture()
|
||||
@@ -738,13 +1158,18 @@ void Store::probe_link(const std::string &url)
|
||||
{
|
||||
Outcome out;
|
||||
Result<Probe> result = backend_.probe_link(url);
|
||||
if (abandoned())
|
||||
{
|
||||
note_cancel("late link check dropped");
|
||||
return out;
|
||||
}
|
||||
out.unreachable = result.unreachable();
|
||||
out.error = result.error;
|
||||
if (result.ok() && !result.value.rejection.empty())
|
||||
out.error = result.value.rejection;
|
||||
if (result.ok() && out.error.empty())
|
||||
{
|
||||
inbox_.post(
|
||||
post_current(
|
||||
[this, probe = std::move(result.value)]() mutable
|
||||
{
|
||||
state_.probe = std::move(probe);
|
||||
@@ -758,11 +1183,23 @@ void Store::probe_link(const std::string &url)
|
||||
|
||||
void Store::queue_direct(const Probe &probe, const std::string &file_name)
|
||||
{
|
||||
next_flag_id_.clear();
|
||||
take_flag_id(); // a direct link is no catalogue link: its jobs are not flagged
|
||||
submit(chan_probe,
|
||||
[this, probe, file_name]
|
||||
{
|
||||
Outcome out;
|
||||
const std::set<std::string> before = jobs_before();
|
||||
Result<Dispatch> result = backend_.queue_direct(probe, file_name);
|
||||
if (on_answer)
|
||||
on_answer();
|
||||
if (abandoned())
|
||||
{
|
||||
note_cancel("late direct link answer dropped");
|
||||
if (result.ok() && (result.value.queued || result.value.has_jobs))
|
||||
undo_queued(before, result.value.has_jobs ? &result.value.jobs : nullptr, std::string());
|
||||
return out;
|
||||
}
|
||||
out.error = result.error;
|
||||
out.unreachable = result.unreachable();
|
||||
if (result.ok())
|
||||
@@ -895,13 +1332,29 @@ bool Store::run_next_action()
|
||||
command = std::move(commands_.front());
|
||||
commands_.pop_front();
|
||||
}
|
||||
const int guard = command.guard;
|
||||
const std::uint64_t gen = command.gen;
|
||||
if (guard >= 0 && gen_[guard].load() != gen)
|
||||
return true; // cancelled before it was sent: nothing goes out
|
||||
running_guard_ = guard;
|
||||
running_gen_ = gen;
|
||||
const Outcome outcome = command.work();
|
||||
const bool dropped = abandoned();
|
||||
running_guard_ = -1;
|
||||
running_gen_ = 0;
|
||||
if (dropped)
|
||||
{
|
||||
note_cancel("late answer ignored lane=" + std::to_string(guard));
|
||||
return true; // cancel() already ended the lane's busy state
|
||||
}
|
||||
if (outcome.unreachable)
|
||||
post_failure(outcome.error, true);
|
||||
const int channel = command.channel;
|
||||
inbox_.post(
|
||||
[this, channel, outcome]
|
||||
[this, channel, outcome, guard, gen]
|
||||
{
|
||||
if (guard >= 0 && gen_[guard].load() != gen)
|
||||
return; // cancelled after the answer came, before it was shown
|
||||
if (channel >= 0)
|
||||
{
|
||||
Channel &c = channel_[channel];
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "orbit/backend.hpp"
|
||||
#include "orbit/download_folder.hpp"
|
||||
#include "orbit/model.hpp"
|
||||
#include "orbit/thread.hpp"
|
||||
|
||||
@@ -23,6 +24,7 @@
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -103,6 +105,9 @@ struct LinkStart
|
||||
std::string error; // empty: Pegasus took it
|
||||
LinkResult::Kind kind = LinkResult::Kind::dispatched;
|
||||
std::string message;
|
||||
// The error is the kind that may pass by itself (timed out, Pegasus busy or not answering, a 5xx):
|
||||
// asking again later is worth it. False for answers that will not change (not supported, not found).
|
||||
bool transient = false;
|
||||
};
|
||||
|
||||
// The outcome of the last player action of one kind.
|
||||
@@ -154,6 +159,20 @@ class Store
|
||||
return channel_[3].state;
|
||||
}
|
||||
|
||||
// Lane numbers of the four player-action channels above, for cancel().
|
||||
static constexpr int lane_jobs = 0;
|
||||
static constexpr int lane_link = 1;
|
||||
static constexpr int lane_sources = 2;
|
||||
static constexpr int lane_probe = 3;
|
||||
|
||||
// ---- Circle on a waiting screen (B-18) ----
|
||||
// Frame thread: the player backed out. The lane is not busy any more right
|
||||
// away, its queued actions are dropped unsent, and an answer still in flight
|
||||
// is ignored when it comes (one "[WINGS-CANCEL] ..." line to `log`). A link
|
||||
// start or direct link Pegasus queued anyway is cancelled in Pegasus, and a
|
||||
// capture it started is stopped: nothing half-done stays queued.
|
||||
void cancel(int lane);
|
||||
|
||||
// ---- management calls (settings, folders, APR Emu, file manager, logs) ----
|
||||
// Lanes: admin and files show a busy state; poll is for status polls that
|
||||
// must not flash one. Calls run in order on the worker, the callback on the
|
||||
@@ -172,8 +191,9 @@ class Store
|
||||
Outcome out;
|
||||
out.error = result.error;
|
||||
out.unreachable = result.unreachable();
|
||||
inbox_.post([done, result = std::move(result)]() mutable
|
||||
{ done(std::move(result)); });
|
||||
// Dropped when the lane was cancelled meanwhile (Circle).
|
||||
post_current([done, result = std::move(result)]() mutable
|
||||
{ done(std::move(result)); });
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -201,6 +221,24 @@ class Store
|
||||
}
|
||||
void refresh_now();
|
||||
|
||||
// ---- default download folder (B-13) ----
|
||||
// Frame thread, call once settings are known (every frame is fine): if
|
||||
// Pegasus reported an EMPTY downloadRoot, create `folder` (mkdir; "already
|
||||
// exists" counts as created) and make it the root. Never touches a
|
||||
// non-empty root. At most once per run, success or failure: no retry loop.
|
||||
// No busy state, no error toast; a failure shows in default_folder_status().
|
||||
void ensure_default_download_folder(const std::string &folder = download_folder::default_path);
|
||||
// "" while nothing failed; else the English reason the default was not set.
|
||||
const std::string &default_folder_status() const
|
||||
{
|
||||
return default_folder_status_;
|
||||
}
|
||||
// True once ensure_default_download_folder() decided to act (this run).
|
||||
bool default_folder_tried() const
|
||||
{
|
||||
return default_folder_tried_;
|
||||
}
|
||||
|
||||
// ---- queue ----
|
||||
void job_action(const std::string &job_id, const std::string &action);
|
||||
void clear_finished();
|
||||
@@ -213,8 +251,44 @@ class Store
|
||||
void start_link(const std::string &package_id, int link_index, const std::string &service);
|
||||
// The same call, and `done` on the frame thread once it ended (after the
|
||||
// snapshot and link_action() show it), so a series can count its parts.
|
||||
// An automated capture (no browser needed) is waited for on the worker:
|
||||
// `done` comes once the session completed (queued), failed, was stopped
|
||||
// (stop_series) or the wait limit passed (transient "timed out ..."). A
|
||||
// capture that needs the PS5 browser is not waited for: it ends with an error.
|
||||
void start_link(const std::string &package_id, int link_index, const std::string &service,
|
||||
std::function<void(const LinkStart &)> done);
|
||||
// ---- bad-link flags on this console (B-20) ----
|
||||
// Frame thread, right before start_link(): the link_flags.hpp id of that
|
||||
// link. The downloads that start queues are flagged in link_flags() when
|
||||
// they end "failed" for a reason of the link (reason_from_error(), or wrong
|
||||
// file for an install failure); a job that is cancelled or still being
|
||||
// reconnected ("retrying") is not. Cancelling a job that waits for an
|
||||
// archive password flags "password never worked".
|
||||
// `title` is the game's name the flag shows in Settings.
|
||||
void set_link_flag_id(const std::string &id, const std::string &title = {})
|
||||
{
|
||||
next_flag_id_ = id;
|
||||
next_flag_title_ = title;
|
||||
}
|
||||
// Tests: the flag id a queued job is remembered under ("" none).
|
||||
std::string job_flag_id(const std::string &job_id) const;
|
||||
// Tests: a queue read as the worker would post it (tick() delivers it).
|
||||
void post_jobs_for_test(std::vector<Job> jobs)
|
||||
{
|
||||
post_jobs(std::move(jobs));
|
||||
}
|
||||
// Frame thread: a series start_link() waiting for a capture ends at its next
|
||||
// poll with the error "stopped". Cleared by the next series start_link().
|
||||
void stop_series();
|
||||
// How often a series start_link() asks for the capture state, and for how
|
||||
// long at most, in seconds (defaults 1 and 120; tests use 0).
|
||||
void set_series_wait(double poll_seconds, double limit_seconds);
|
||||
// Optional: one "[WINGS-PARTS] ..." line per series start_link(), from the worker.
|
||||
// Set before start().
|
||||
std::function<void(const std::string &)> log;
|
||||
// Tests: runs on the worker right after Pegasus answered a link start,
|
||||
// file pick or direct-link queue, before the answer is used (cancel there).
|
||||
std::function<void()> on_answer;
|
||||
void debrid_select(const std::string &selection_id, const std::vector<int> &indexes,
|
||||
const std::string &service);
|
||||
// False (and nothing sent) while Pegasus is already resolving the link.
|
||||
@@ -252,14 +326,29 @@ class Store
|
||||
{
|
||||
int channel = 0;
|
||||
std::function<Outcome()> work;
|
||||
int guard = -1; // the lane whose cancel() drops this command (-1: none)
|
||||
std::uint64_t gen = 0; // that lane's generation when it was submitted
|
||||
};
|
||||
static constexpr int kLanes = 7;
|
||||
static constexpr int kSameLane = -2;
|
||||
struct Channel
|
||||
{
|
||||
ActionState state;
|
||||
int pending = 0; // frame thread: queued actions without a result yet
|
||||
};
|
||||
|
||||
void submit(int channel, std::function<Outcome()> work);
|
||||
void submit(int channel, std::function<Outcome()> work, int guard = kSameLane);
|
||||
// Worker, inside a command: the player cancelled its lane since it was submitted.
|
||||
bool abandoned() const;
|
||||
// Worker, inside a command: posts `apply`, dropped at drain time if abandoned by then.
|
||||
void post_current(std::function<void()> apply);
|
||||
void note_cancel(const std::string &what);
|
||||
// Worker: job ids Pegasus listed before a queueing call (reads the queue once if none seen yet).
|
||||
std::set<std::string> jobs_before();
|
||||
// Worker, after an abandoned queueing call: cancels the downloads it added
|
||||
// (`after`: the reply's queue, or nullptr to read it) and stops `session`'s capture.
|
||||
void undo_queued(const std::set<std::string> &before, const std::vector<Job> *after,
|
||||
const std::string &session);
|
||||
void worker();
|
||||
void catalog_worker();
|
||||
void post_failure(const std::string &error, bool unreachable);
|
||||
@@ -268,17 +357,40 @@ class Store
|
||||
void post_capture(CaptureView view);
|
||||
Outcome jobs_outcome(Result<std::vector<Job>> &&result);
|
||||
Outcome sources_outcome(Result<std::vector<Source>> &&result);
|
||||
Outcome link_outcome(Result<LinkResult> &&result);
|
||||
// `before`: jobs_before() taken ahead of the call, to undo it if abandoned.
|
||||
Outcome link_outcome(Result<LinkResult> &&result, const std::set<std::string> &before);
|
||||
void post_defaults(DefaultSources defaults);
|
||||
void post_tiers(Result<std::vector<std::uint8_t>> &&tiers);
|
||||
void note_sources(const std::vector<Source> &sources);
|
||||
void poll_capture();
|
||||
// One status read of `session`, posted like poll_capture() does; true (and
|
||||
// *seen set) when Pegasus answered.
|
||||
bool poll_session(const std::string &session, CaptureView *seen);
|
||||
void wait_capture(const CaptureView &started, LinkStart *outcome);
|
||||
void track_session(const CaptureView &view);
|
||||
|
||||
// Frame thread: remembers the jobs a flagged start queued, flags the failed ones.
|
||||
void take_flag_id();
|
||||
void note_flag_jobs(const std::vector<Job> &jobs);
|
||||
void flag_job(const std::string &job_id, int reason);
|
||||
|
||||
Backend &backend_;
|
||||
Inbox inbox_;
|
||||
Snapshot state_;
|
||||
Channel channel_[7];
|
||||
// Frame thread (B-20).
|
||||
std::string next_flag_id_; // set_link_flag_id(), taken by start_link()
|
||||
std::string pending_flag_id_; // the last start's link: new jobs are its
|
||||
double pending_flag_at_ = 0.0; // when that start was sent
|
||||
std::string next_flag_title_; // the game's name of those
|
||||
std::string pending_flag_title_;
|
||||
std::map<std::string, std::string> job_flag_ids_; // job id -> link flag id
|
||||
std::map<std::string, std::string> job_flag_titles_; // job id -> game name
|
||||
std::set<std::string> flagged_jobs_; // already flagged while failed
|
||||
bool default_folder_tried_ = false; // frame thread
|
||||
std::string default_folder_status_; // frame thread
|
||||
Channel channel_[kLanes];
|
||||
// Bumped by cancel() on the frame thread; read by the worker.
|
||||
std::atomic<std::uint64_t> gen_[kLanes] = {};
|
||||
|
||||
// Shared with the workers.
|
||||
Mutex mutex_;
|
||||
@@ -287,6 +399,9 @@ class Store
|
||||
bool poll_requested_ = true;
|
||||
bool stopping_ = false;
|
||||
std::string capture_session_; // under mutex_: the session being watched
|
||||
bool series_stop_ = false; // under mutex_: stop_series() was asked
|
||||
double series_poll_ = 1.0; // under mutex_: seconds between capture reads of a series wait
|
||||
double series_limit_ = 120.0; // under mutex_: longest series wait, seconds
|
||||
std::atomic<bool> fast_{false};
|
||||
std::atomic<bool> want_logs_{false};
|
||||
std::atomic<bool> want_installed_{false};
|
||||
@@ -312,6 +427,10 @@ class Store
|
||||
bool capture_recovered_ = false;
|
||||
int tiers_ready_bits_ = 0; // debrid accounts the grid's tiers were computed for
|
||||
std::set<std::string> seen_completed_;
|
||||
std::set<std::string> known_jobs_; // ids of the last queue posted
|
||||
bool known_jobs_valid_ = false;
|
||||
int running_guard_ = -1; // the command being run: its guard lane
|
||||
std::uint64_t running_gen_ = 0; // and that lane's generation then
|
||||
};
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,209 @@
|
||||
// Wings TV app - streaming download to disk (see stream_download.hpp).
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/stream_download.hpp"
|
||||
|
||||
#include "orbit/pegasus_manager.hpp"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
bool ufs2_magic_ok(const std::uint8_t *four)
|
||||
{
|
||||
return four != nullptr && four[0] == 0x19 && four[1] == 0x01 && four[2] == 0x54 && four[3] == 0x19;
|
||||
}
|
||||
|
||||
bool stream_size_ok(std::uint64_t want)
|
||||
{
|
||||
return want >= kUfs2MagicOffset + 4 && want <= kStreamMaxImage;
|
||||
}
|
||||
|
||||
bool stream_space_ok(double free_bytes, std::uint64_t want)
|
||||
{
|
||||
if (free_bytes < 0.0)
|
||||
return true;
|
||||
return free_bytes >= static_cast<double>(want) + static_cast<double>(kStreamSpare);
|
||||
}
|
||||
|
||||
bool stream_redirect_allowed(const std::string &url)
|
||||
{
|
||||
return pegasus_url_allowed(url, PegasusUrlRole::redirect);
|
||||
}
|
||||
|
||||
ChunkSink::ChunkSink(Config config) : config_(std::move(config)) {}
|
||||
|
||||
ChunkSink::~ChunkSink()
|
||||
{
|
||||
if (!done_)
|
||||
abort();
|
||||
delete static_cast<Sha256 *>(hash_);
|
||||
}
|
||||
|
||||
bool ChunkSink::fail(StreamError why)
|
||||
{
|
||||
if (failure_ == StreamError::none)
|
||||
failure_ = why;
|
||||
abort();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChunkSink::open()
|
||||
{
|
||||
if (file_ != nullptr || config_.part_path.empty())
|
||||
return fail(StreamError::bad_request);
|
||||
file_ = std::fopen(config_.part_path.c_str(), "wb");
|
||||
if (file_ == nullptr)
|
||||
return fail(StreamError::disk);
|
||||
std::setvbuf(file_, nullptr, _IOFBF, 64u << 10);
|
||||
delete static_cast<Sha256 *>(hash_);
|
||||
hash_ = new Sha256();
|
||||
bytes_ = 0;
|
||||
std::memset(magic_, 0, sizeof magic_);
|
||||
failure_ = StreamError::none;
|
||||
done_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChunkSink::write(const std::uint8_t *data, std::size_t n)
|
||||
{
|
||||
if (file_ == nullptr)
|
||||
return false;
|
||||
if (config_.cancel && config_.cancel())
|
||||
return fail(StreamError::cancelled);
|
||||
if (n == 0)
|
||||
return true;
|
||||
if (bytes_ + n > config_.limit)
|
||||
return fail(StreamError::too_large);
|
||||
// Keep the four magic bytes as they go by (no second read of the file).
|
||||
const std::uint64_t lo = kUfs2MagicOffset, hi = kUfs2MagicOffset + 4;
|
||||
if (bytes_ < hi && bytes_ + n > lo)
|
||||
{
|
||||
for (std::uint64_t at = std::max(bytes_, lo); at < std::min<std::uint64_t>(bytes_ + n, hi); ++at)
|
||||
magic_[at - lo] = data[at - bytes_];
|
||||
}
|
||||
if (std::fwrite(data, 1, n, file_) != n)
|
||||
return fail(StreamError::disk);
|
||||
static_cast<Sha256 *>(hash_)->update(data, n);
|
||||
bytes_ += n;
|
||||
if (config_.progress)
|
||||
config_.progress(bytes_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChunkSink::finish(std::string *sha256_hex, bool *ufs2)
|
||||
{
|
||||
if (file_ == nullptr)
|
||||
return false;
|
||||
bool good = std::fflush(file_) == 0;
|
||||
good = good && ::fsync(::fileno(file_)) == 0;
|
||||
good = (std::fclose(file_) == 0) && good;
|
||||
file_ = nullptr;
|
||||
if (!good)
|
||||
return fail(StreamError::disk);
|
||||
if (sha256_hex != nullptr)
|
||||
*sha256_hex = static_cast<Sha256 *>(hash_)->finish_hex();
|
||||
if (ufs2 != nullptr)
|
||||
*ufs2 = bytes_ >= kUfs2MagicOffset + 4 && ufs2_magic_ok(magic_);
|
||||
done_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChunkSink::abort()
|
||||
{
|
||||
if (file_ != nullptr)
|
||||
{
|
||||
std::fclose(file_);
|
||||
file_ = nullptr;
|
||||
}
|
||||
if (!done_ && !config_.part_path.empty())
|
||||
std::remove(config_.part_path.c_str());
|
||||
}
|
||||
|
||||
StreamResult stream_download_to_file(StreamingDownloader &downloader, const StreamRequest &request)
|
||||
{
|
||||
StreamResult out;
|
||||
auto fail = [&out](StreamError e)
|
||||
{
|
||||
out.ok = false;
|
||||
out.error = e;
|
||||
return out;
|
||||
};
|
||||
std::string hex_check = request.sha256;
|
||||
const bool hex_ok = hex_check.size() == 64 &&
|
||||
hex_check.find_first_not_of("0123456789abcdef") == std::string::npos;
|
||||
if (request.url.empty() || request.final_path.empty() || !hex_ok || !stream_size_ok(request.size))
|
||||
return fail(StreamError::bad_request);
|
||||
if (request.free_bytes && !stream_space_ok(request.free_bytes(request.final_path), request.size))
|
||||
return fail(StreamError::no_space);
|
||||
|
||||
const std::string part = request.final_path + ".part";
|
||||
ChunkSink::Config cfg;
|
||||
cfg.part_path = part;
|
||||
cfg.limit = request.size;
|
||||
cfg.progress = request.hooks.progress;
|
||||
cfg.cancel = request.hooks.cancel;
|
||||
ChunkSink sink(std::move(cfg));
|
||||
if (!sink.open())
|
||||
return fail(sink.failed());
|
||||
|
||||
StreamHooks hooks = request.hooks;
|
||||
if (!hooks.redirect_ok)
|
||||
hooks.redirect_ok = &stream_redirect_allowed;
|
||||
// The transport's own cap is one byte over the listed size, so an oversize file is noticed.
|
||||
const std::string why = downloader.get_stream(
|
||||
request.url, request.size + 1, hooks,
|
||||
[&sink](const std::uint8_t *p, std::size_t n) { return sink.write(p, n); }, &out.status);
|
||||
out.bytes = sink.bytes();
|
||||
if (sink.failed() != StreamError::none)
|
||||
return fail(sink.failed()); // cancel, too much data or disk; the .part file is gone
|
||||
if (!why.empty())
|
||||
{
|
||||
out.detail = why;
|
||||
sink.abort();
|
||||
if (hooks.cancel && hooks.cancel())
|
||||
return fail(StreamError::cancelled);
|
||||
return fail(StreamError::network);
|
||||
}
|
||||
if (out.status < 200 || out.status > 299)
|
||||
{
|
||||
sink.abort();
|
||||
return fail(StreamError::http_status);
|
||||
}
|
||||
if (sink.bytes() != request.size)
|
||||
{
|
||||
sink.abort();
|
||||
return fail(StreamError::size_mismatch);
|
||||
}
|
||||
bool ufs2 = false;
|
||||
if (!sink.finish(&out.sha256, &ufs2))
|
||||
return fail(StreamError::disk);
|
||||
auto drop = [&]()
|
||||
{
|
||||
std::remove(part.c_str());
|
||||
};
|
||||
if (out.sha256 != request.sha256)
|
||||
{
|
||||
drop();
|
||||
return fail(StreamError::checksum);
|
||||
}
|
||||
if (request.require_ufs2 && !ufs2)
|
||||
{
|
||||
drop();
|
||||
return fail(StreamError::not_image);
|
||||
}
|
||||
std::remove(request.final_path.c_str());
|
||||
if (std::rename(part.c_str(), request.final_path.c_str()) != 0)
|
||||
{
|
||||
drop();
|
||||
return fail(StreamError::disk);
|
||||
}
|
||||
out.ok = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace orbit
|
||||