Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fd5a89d3 | ||
|
|
0dbc8774e2 | ||
|
|
383944d8cc |
No files matched your search
@@ -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,
|
||||
@@ -597,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;
|
||||
}
|
||||
|
||||
@@ -611,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;
|
||||
}
|
||||
|
||||
@@ -630,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()));
|
||||
@@ -724,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;
|
||||
}
|
||||
|
||||
@@ -1299,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_;
|
||||
}
|
||||
@@ -1314,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";
|
||||
@@ -1328,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;
|
||||
@@ -2098,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_);
|
||||
@@ -2197,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_);
|
||||
|
||||
@@ -30,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:
|
||||
@@ -65,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;
|
||||
|
||||
@@ -141,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).
|
||||
@@ -148,6 +167,7 @@ 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).
|
||||
@@ -179,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;
|
||||
@@ -203,6 +225,7 @@ 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_;
|
||||
@@ -254,6 +277,8 @@ class FakeBackend final : public Backend
|
||||
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.002",
|
||||
"contentVersion": "00.001.003",
|
||||
"downloadDataSize": 256,
|
||||
"gameIntent": {
|
||||
"permittedIntents": [
|
||||
|
||||
@@ -15,17 +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"
|
||||
@@ -92,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"};
|
||||
@@ -207,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
|
||||
{
|
||||
@@ -480,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()
|
||||
@@ -501,10 +584,18 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -80,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()
|
||||
@@ -136,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;
|
||||
}
|
||||
@@ -162,20 +267,31 @@ void App::update(const InputFrame &input, float dt, Feedback &feedback)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,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;
|
||||
@@ -588,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")},
|
||||
|
||||
@@ -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
|
||||
@@ -119,8 +119,10 @@ void DownloadAll::cancel()
|
||||
if (!active)
|
||||
return;
|
||||
cancel_ = true;
|
||||
if (!in_flight_)
|
||||
finish(End::cancelled); // nothing is out: a wait ends at once
|
||||
// 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);
|
||||
}
|
||||
|
||||
const std::string &DownloadAll::host_of(std::size_t ref) const
|
||||
|
||||
@@ -104,7 +104,8 @@ public:
|
||||
std::function<void(const LinkRef &)> queued, std::function<void()> finished);
|
||||
// Every frame: counts down a wait between attempts and starts the next one.
|
||||
void tick(float dt);
|
||||
// Stops after the part in flight (it still counts if it queues); a wait ends at once.
|
||||
// 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 any other end.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -198,6 +215,12 @@ Intent DownloadsScreen::update(const InputFrame &input, float dt, Feedback &feed
|
||||
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();
|
||||
@@ -547,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);
|
||||
@@ -568,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)
|
||||
@@ -593,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);
|
||||
@@ -658,7 +685,7 @@ 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -660,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;
|
||||
@@ -671,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;
|
||||
@@ -685,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;
|
||||
@@ -704,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()
|
||||
@@ -769,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())
|
||||
@@ -902,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;
|
||||
@@ -977,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)
|
||||
{
|
||||
@@ -1115,6 +1136,8 @@ void PegasusManager::run_send(std::string path, std::string note)
|
||||
{
|
||||
{
|
||||
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 "
|
||||
@@ -1124,6 +1147,8 @@ void PegasusManager::run_send(std::string path, std::string note)
|
||||
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.",
|
||||
|
||||
@@ -212,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.
|
||||
@@ -247,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,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
|
||||
@@ -55,7 +55,8 @@ bool WingsLocalPrefs::operator==(const WingsLocalPrefs &other) const
|
||||
after_download == other.after_download && update_check == other.update_check &&
|
||||
icon_source == other.icon_source && hero_source == other.hero_source &&
|
||||
notes_enabled == other.notes_enabled && carousel_enabled == other.carousel_enabled &&
|
||||
folder_confirmed == other.folder_confirmed;
|
||||
folder_confirmed == other.folder_confirmed && auto_reconnect == other.auto_reconnect &&
|
||||
keep_pkg_files == other.keep_pkg_files;
|
||||
}
|
||||
|
||||
bool valid_firmware(std::string_view value)
|
||||
@@ -91,6 +92,8 @@ std::string encode_local_prefs(const WingsLocalPrefs &prefs)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -138,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")
|
||||
|
||||
@@ -52,6 +52,12 @@ struct WingsLocalPrefs
|
||||
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,19 +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
|
||||
{
|
||||
@@ -275,6 +281,8 @@ SettingsScreen::SettingsScreen(Context &context) : ListScreen(context)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +293,8 @@ void SettingsScreen::store_local_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
|
||||
@@ -327,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;
|
||||
@@ -337,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)
|
||||
@@ -381,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)
|
||||
@@ -426,17 +465,6 @@ 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"),
|
||||
@@ -540,6 +568,12 @@ void SettingsScreen::build_pegasus(std::vector<Row> &out, std::vector<Act> *acts
|
||||
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);
|
||||
@@ -651,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);
|
||||
@@ -773,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);
|
||||
@@ -822,6 +867,18 @@ 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();
|
||||
@@ -836,31 +893,18 @@ void SettingsScreen::on_confirm(int row, Feedback &feedback, Intent &intent)
|
||||
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:
|
||||
@@ -1004,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();
|
||||
@@ -1096,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;
|
||||
@@ -1157,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()))
|
||||
{
|
||||
@@ -1193,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;
|
||||
@@ -1240,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);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#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"
|
||||
@@ -32,6 +33,7 @@
|
||||
#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"
|
||||
@@ -47,6 +49,8 @@
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
struct UpdateView; // update_screen.hpp
|
||||
|
||||
using hui::Action;
|
||||
using hui::Direction;
|
||||
using hui::InputFrame;
|
||||
@@ -130,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.
|
||||
@@ -152,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
|
||||
@@ -280,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
|
||||
{
|
||||
@@ -518,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
|
||||
{
|
||||
@@ -575,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);
|
||||
@@ -674,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_;
|
||||
@@ -1287,6 +1319,8 @@ class SettingsScreen final : public ListScreen
|
||||
capabilities,
|
||||
folder,
|
||||
connections,
|
||||
auto_reconnect,
|
||||
keep_pkg_files,
|
||||
real_debrid,
|
||||
torbox,
|
||||
group,
|
||||
@@ -1314,6 +1348,8 @@ class SettingsScreen final : public ListScreen
|
||||
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
|
||||
{
|
||||
@@ -1324,6 +1360,7 @@ class SettingsScreen final : public ListScreen
|
||||
notice,
|
||||
pegasus_download,
|
||||
pegasus_send,
|
||||
clear_bad_links,
|
||||
};
|
||||
enum class ListFor : std::uint8_t
|
||||
{
|
||||
@@ -1335,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,
|
||||
@@ -1506,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
|
||||
@@ -1544,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,10 +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>
|
||||
|
||||
@@ -223,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);
|
||||
@@ -606,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)
|
||||
{
|
||||
@@ -616,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;
|
||||
@@ -643,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())
|
||||
@@ -673,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);
|
||||
@@ -685,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)); });
|
||||
}
|
||||
@@ -728,14 +963,19 @@ 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;
|
||||
@@ -745,6 +985,7 @@ void Store::start_link(const std::string &package_id, int link_index, const std:
|
||||
[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;
|
||||
@@ -753,22 +994,32 @@ void Store::start_link(const std::string &package_id, int link_index, const std:
|
||||
!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));
|
||||
Outcome out = link_outcome(std::move(result), before);
|
||||
// After link_outcome(): the session is tracked and shown while it is waited for.
|
||||
if (capture)
|
||||
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,
|
||||
@@ -776,7 +1027,10 @@ 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()
|
||||
@@ -826,9 +1080,9 @@ void Store::wait_capture(const CaptureView &started, LinkStart *outcome)
|
||||
{
|
||||
Lock lock(mutex_);
|
||||
const double until = now_seconds() + poll;
|
||||
while (!series_stop_ && !stopping_ && now_seconds() < until)
|
||||
while (!series_stop_ && !stopping_ && !abandoned() && now_seconds() < until)
|
||||
signal_.wait(mutex_, until - now_seconds());
|
||||
if (series_stop_ || stopping_)
|
||||
if (series_stop_ || stopping_ || abandoned())
|
||||
{
|
||||
outcome->error = "stopped";
|
||||
outcome->transient = false;
|
||||
@@ -904,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);
|
||||
@@ -924,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())
|
||||
@@ -1061,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];
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -158,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
|
||||
@@ -176,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;
|
||||
});
|
||||
}
|
||||
@@ -241,6 +257,26 @@ class Store
|
||||
// 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();
|
||||
@@ -250,6 +286,9 @@ class Store
|
||||
// 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.
|
||||
@@ -287,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);
|
||||
@@ -303,7 +357,8 @@ 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);
|
||||
@@ -314,12 +369,28 @@ class Store
|
||||
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_;
|
||||
// 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_[7];
|
||||
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_;
|
||||
@@ -356,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
|
||||
@@ -9,9 +9,11 @@
|
||||
// and routes the controller.
|
||||
|
||||
#include "orbit/screens.hpp"
|
||||
#include "orbit/busy_cancel.hpp"
|
||||
#include "orbit/editorial.hpp"
|
||||
#include "orbit/filter_menu.hpp"
|
||||
#include "orbit/game_hero.hpp"
|
||||
#include "orbit/link_flags.hpp"
|
||||
|
||||
#include "orbit/model.hpp"
|
||||
#include "orbit/popularity.hpp"
|
||||
@@ -20,6 +22,8 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iterator>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
@@ -205,7 +209,7 @@ void StoreScreen::focus()
|
||||
bool StoreScreen::modal() const
|
||||
{
|
||||
return prompt_.is_open() || confirm_.is_open() || list_.is_open() || folder_ask_.is_open() ||
|
||||
panel_ != Panel::none || batch_.active ||
|
||||
panel_ != Panel::none || batch_.active || torbox_wait_.active() ||
|
||||
ctx_.store.link_action().busy;
|
||||
}
|
||||
|
||||
@@ -1436,12 +1440,48 @@ void StoreScreen::start_single(const std::string &service)
|
||||
{
|
||||
if (folder_ask_.guard(ctx_, [this, service] { start_single(service); }))
|
||||
return; // the first download asks for the folder; it resumes here
|
||||
ctx_.store.set_link_flag_id(ref_flag_id(page_, pending_ref_), page_.title);
|
||||
ctx_.store.start_link(pending_ref_.package_id, pending_ref_.link_index, service);
|
||||
popularity_hub().count_download(page_.title_ids); // "Your most downloaded"
|
||||
single_ref_ = pending_ref_;
|
||||
single_service_ = service;
|
||||
single_waiting_ = true;
|
||||
}
|
||||
|
||||
// B-19: the same link again, while TorBox prepares it.
|
||||
void StoreScreen::retry_torbox()
|
||||
{
|
||||
ctx_.store.set_link_flag_id(ref_flag_id(page_, torbox_ref_), page_.title);
|
||||
ctx_.store.start_link(torbox_ref_.package_id, torbox_ref_.link_index, torbox_service_);
|
||||
single_ref_ = torbox_ref_;
|
||||
single_service_ = torbox_service_;
|
||||
single_waiting_ = true;
|
||||
}
|
||||
|
||||
void StoreScreen::open_torbox_notice()
|
||||
{
|
||||
pending_ = Pending::torbox_preparing;
|
||||
std::vector<ListItem> items(3);
|
||||
items[0].label = tr("Try again");
|
||||
items[1].label = tr("Open TorBox website");
|
||||
items[2].label = tr("OK");
|
||||
list_.open(torbox_preparing_title(), torbox_gave_up_text(), std::move(items), ListDialog::Mode::single);
|
||||
}
|
||||
|
||||
void StoreScreen::open_torbox_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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A card: one link runs the link flow; a multipart set offers all parts or one.
|
||||
void StoreScreen::activate_mirror(int mirror, Feedback &feedback)
|
||||
{
|
||||
@@ -1485,6 +1525,53 @@ void StoreScreen::activate_mirror(int mirror, Feedback &feedback)
|
||||
feedback.play(hui::audio::Cue::modal_open);
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
// "Mark as bad" reasons, in the order the list shows them.
|
||||
constexpr FlagReason kBadReasons[] = {FlagReason::removed, FlagReason::wrong_file, FlagReason::password,
|
||||
FlagReason::broken_archive, FlagReason::host_error, FlagReason::by_hand};
|
||||
} // namespace
|
||||
|
||||
void StoreScreen::choose_flag_item(int mirror, Feedback &feedback)
|
||||
{
|
||||
if (mirror < 0 || mirror >= static_cast<int>(page_.page.mirrors.size()))
|
||||
return;
|
||||
if (page_.page.mirrors[static_cast<std::size_t>(mirror)].flagged_link(page_.page) >= 0)
|
||||
{
|
||||
mark_mirror(mirror, 0);
|
||||
return;
|
||||
}
|
||||
card_mirror_ = mirror;
|
||||
std::vector<ListItem> reasons;
|
||||
for (std::string label : {std::string(tr("File removed")), std::string(tr("Wrong file")),
|
||||
std::string(tr("Password never works")), std::string(tr("Broken archive")),
|
||||
std::string(tr("Host error")), std::string(tr("Other"))})
|
||||
{
|
||||
ListItem item;
|
||||
item.label = std::move(label);
|
||||
reasons.push_back(std::move(item));
|
||||
}
|
||||
pending_ = Pending::bad_reason;
|
||||
list_.open(tr("Mark as bad"), tr("What went wrong with this link?"), std::move(reasons),
|
||||
ListDialog::Mode::single);
|
||||
feedback.play(hui::audio::Cue::modal_open);
|
||||
}
|
||||
|
||||
void StoreScreen::mark_mirror(int mirror, int reason)
|
||||
{
|
||||
if (mirror < 0 || mirror >= static_cast<int>(page_.page.mirrors.size()))
|
||||
return;
|
||||
const FlagReason why =
|
||||
reason >= 1 && reason <= static_cast<int>(std::size(kBadReasons)) ? kBadReasons[reason - 1] : FlagReason::none;
|
||||
const std::vector<int> links = page_.page.mirrors[static_cast<std::size_t>(mirror)].links;
|
||||
mark_page_links(&page_.page, links, why, flag_title_id(page_), static_cast<std::int64_t>(std::time(nullptr)),
|
||||
page_.title);
|
||||
// Best and the card order follow the new flag.
|
||||
rerank_game_view(&page_, source_order(), link_prefs_);
|
||||
row_ = first_focusable(page_);
|
||||
dirty_ = true;
|
||||
}
|
||||
|
||||
void StoreScreen::activate_row(Feedback &feedback)
|
||||
{
|
||||
const PageRow *row = focused_page_row();
|
||||
@@ -1558,7 +1645,10 @@ void StoreScreen::start_batch(const std::string &service)
|
||||
batch_.begin(
|
||||
std::move(parts), parts_plan_.host,
|
||||
[this, service](const LinkRef &ref, DownloadAll::Done done)
|
||||
{ ctx_.store.start_link(ref.package_id, ref.link_index, service, std::move(done)); },
|
||||
{
|
||||
ctx_.store.set_link_flag_id(ref_flag_id(page_, ref), page_.title);
|
||||
ctx_.store.start_link(ref.package_id, ref.link_index, service, std::move(done));
|
||||
},
|
||||
[this](const LinkRef &ref)
|
||||
{
|
||||
started_.add(ref);
|
||||
@@ -1604,6 +1694,16 @@ void StoreScreen::update_detail(const InputFrame &input, Feedback &feedback, Int
|
||||
intent.toast = now ? std::string(tr("Added to favourites"))
|
||||
: std::string(tr("Removed from favourites"));
|
||||
}
|
||||
// B-20: Triangle on a link card marks it bad on this console, or clears the mark.
|
||||
if (input.is_pressed(Action::north) && !header_.on_header && link_flags() != nullptr)
|
||||
{
|
||||
if (const PageRow *row = focused_page_row();
|
||||
row != nullptr && (row->kind == PageRowKind::best || row->kind == PageRowKind::mirror))
|
||||
{
|
||||
choose_flag_item(row->mirror, feedback);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (input.is_pressed(Action::menu) && !page_.page.mirrors.empty())
|
||||
{
|
||||
open_link_filters();
|
||||
@@ -1773,13 +1873,25 @@ void StoreScreen::update_flow(Feedback &, Intent &intent)
|
||||
if (!action.error.empty())
|
||||
{
|
||||
single_waiting_ = false;
|
||||
pending_ = Pending::notice;
|
||||
confirm_.notice(tr("Could not start the download"), action.error);
|
||||
if (is_torbox_preparing(action.error))
|
||||
{
|
||||
// B-19: TorBox has not finished preparing the file; ask again by itself.
|
||||
torbox_ref_ = single_ref_;
|
||||
torbox_service_ = single_service_;
|
||||
torbox_wait_.start(torbox_clock_);
|
||||
}
|
||||
else
|
||||
{
|
||||
torbox_wait_.cancel();
|
||||
pending_ = Pending::notice;
|
||||
confirm_.notice(tr("Could not start the download"), action.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.link_serial == seen_link_)
|
||||
return;
|
||||
seen_link_ = state.link_serial;
|
||||
torbox_wait_.cancel(); // an answer came: the wait is over
|
||||
const LinkResult &result = state.link_result;
|
||||
if (single_waiting_)
|
||||
{
|
||||
@@ -1847,6 +1959,12 @@ Intent StoreScreen::update(const InputFrame &frame_input, float dt, Feedback &fe
|
||||
{
|
||||
Intent intent;
|
||||
const Snapshot &state = ctx_.store.state();
|
||||
torbox_clock_ += dt;
|
||||
torbox_tick_ = torbox_wait_.tick(torbox_clock_);
|
||||
if (torbox_tick_.kind == TorboxPrepareWait::Tick::Kind::retry_now)
|
||||
retry_torbox();
|
||||
else if (torbox_tick_.kind == TorboxPrepareWait::Tick::Kind::gave_up)
|
||||
open_torbox_notice();
|
||||
// The Store shows installed status: ask for the game list while it is open.
|
||||
if (state.online())
|
||||
ctx_.store.want_installed(true);
|
||||
@@ -1873,11 +1991,29 @@ Intent StoreScreen::update(const InputFrame &frame_input, float dt, Feedback &fe
|
||||
// Circle stops "Download all parts": a wait between attempts ends at once, a
|
||||
// capture the part waits for is let go. The press is used up here, so it
|
||||
// neither closes the report cancel() may open nor leaves the page.
|
||||
// B-18: the box closes at once; the part in flight is let go and anything it
|
||||
// still queues is cancelled by the Store. Circle on "Asking Pegasus DL..."
|
||||
// likewise goes back at once and the late answer is ignored.
|
||||
bool stop_pressed = false;
|
||||
if (batch_.active && frame_input.is_pressed(Action::back))
|
||||
{
|
||||
batch_.cancel();
|
||||
ctx_.store.stop_series();
|
||||
ctx_.store.cancel(Store::lane_link);
|
||||
stop_pressed = true;
|
||||
}
|
||||
else if (torbox_wait_.active() && frame_input.is_pressed(Action::back))
|
||||
{
|
||||
torbox_wait_.cancel();
|
||||
if (ctx_.store.link_action().busy)
|
||||
ctx_.store.cancel(Store::lane_link);
|
||||
single_waiting_ = false;
|
||||
stop_pressed = true;
|
||||
}
|
||||
else if (ctx_.store.link_action().busy && frame_input.is_pressed(Action::back))
|
||||
{
|
||||
ctx_.store.cancel(Store::lane_link);
|
||||
single_waiting_ = false; // the link was not started: "Download all" keeps it
|
||||
stop_pressed = true;
|
||||
}
|
||||
const InputFrame &input = stop_pressed ? kIdle : frame_input;
|
||||
@@ -1886,7 +2022,7 @@ Intent StoreScreen::update(const InputFrame &frame_input, float dt, Feedback &fe
|
||||
const bool had_confirm = confirm_.is_open();
|
||||
const bool had_list = list_.is_open();
|
||||
const bool had_panel = panel_ != Panel::none;
|
||||
const bool busy = ctx_.store.link_action().busy || batch_.active;
|
||||
const bool busy = ctx_.store.link_action().busy || batch_.active || torbox_wait_.active();
|
||||
|
||||
const TextPrompt::Result typed = prompt_.update(had_prompt ? input : kIdle, dt, feedback);
|
||||
if (typed == TextPrompt::Result::accepted)
|
||||
@@ -1981,6 +2117,17 @@ Intent StoreScreen::update(const InputFrame &frame_input, float dt, Feedback &fe
|
||||
}
|
||||
ctx_.store.debrid_select(selection_id_, indexes, selection_service_);
|
||||
}
|
||||
else if (listed == ListDialog::Result::chosen && was == Pending::bad_reason && chosen >= 0)
|
||||
{
|
||||
mark_mirror(card_mirror_, chosen + 1);
|
||||
}
|
||||
else if (listed == ListDialog::Result::chosen && was == Pending::torbox_preparing)
|
||||
{
|
||||
if (chosen == 0)
|
||||
retry_torbox();
|
||||
else if (chosen == 1)
|
||||
open_torbox_site();
|
||||
}
|
||||
else if (listed == ListDialog::Result::chosen && was == Pending::parts_menu && parts_mirror_ >= 0 &&
|
||||
parts_mirror_ < static_cast<int>(page_.page.mirrors.size()))
|
||||
{
|
||||
@@ -2793,14 +2940,15 @@ void StoreScreen::draw_mirror(hui::gfx::DrawList &list, int mirror, const ui::Re
|
||||
chip(c.version, neutral, soft, ui::Color{0, 0, 0, 0});
|
||||
chip(c.kind, neutral, soft, ui::Color{0, 0, 0, 0});
|
||||
chip(c.source, ui::Color{0, 0, 0, 0}, faint, ui::Color::rgb(0xffffff, 0.25f + 0.25f * focus));
|
||||
// Line 3: what is wrong with the set.
|
||||
if (!c.warning.empty())
|
||||
// Line 3: what is wrong with the set; a bad-link flag on this console first (B-20).
|
||||
const std::string &line3 = c.flag.empty() ? c.warning : c.flag;
|
||||
if (!line3.empty())
|
||||
{
|
||||
// On a dark backing so the red reads on the light focused fill too.
|
||||
const float ww = std::min(type.medium.measure(c.warning, 20.0f), r.w - 60.0f);
|
||||
const float ww = std::min(type.medium.measure(line3, 20.0f), r.w - 60.0f);
|
||||
list.rounded_rect({r.x + 22.0f, r.y + 92.0f, ww + 16.0f, 26.0f}, 8.0f,
|
||||
ui::Color::rgb(0x000000, 0.35f + 0.3f * focus));
|
||||
ui::text_fit(list, type.medium, c.warning, r.x + 30.0f, r.y + 111.0f, 20.0f, r.w - 60.0f,
|
||||
ui::text_fit(list, type.medium, line3, r.x + 30.0f, r.y + 111.0f, 20.0f, r.w - 60.0f,
|
||||
ui::color::tier_red);
|
||||
}
|
||||
}
|
||||
@@ -3115,9 +3263,17 @@ void StoreScreen::draw(Frame &frame, bool focused) const
|
||||
text += ")";
|
||||
draw_busy(ctx_, overlay, text.c_str());
|
||||
}
|
||||
else if (torbox_wait_.active())
|
||||
{
|
||||
const std::string text = torbox_tick_.kind == TorboxPrepareWait::Tick::Kind::waiting
|
||||
? torbox_waiting_text(torbox_tick_.seconds_left)
|
||||
: torbox_asking_text();
|
||||
draw_busy(ctx_, overlay, with_stop_hint(text.c_str()).c_str());
|
||||
}
|
||||
else if (ctx_.store.link_action().busy)
|
||||
{
|
||||
draw_busy(ctx_, overlay, tr("Asking Pegasus DL\xE2\x80\xA6"));
|
||||
const std::string text = std::string(tr("Asking Pegasus DL\xE2\x80\xA6")) + " (" + tr("Circle: stop") + ")";
|
||||
draw_busy(ctx_, overlay, text.c_str());
|
||||
}
|
||||
// The const draw path of the panels needs a mutable frame: it is one.
|
||||
Context &ctx = ctx_;
|
||||
@@ -3160,6 +3316,15 @@ std::vector<hui::ui::Hint> StoreScreen::hints() const
|
||||
std::vector<hui::ui::Hint> items;
|
||||
if (cross != nullptr)
|
||||
items.push_back({hui::ui::Button::cross, cross});
|
||||
if (const PageRow *row = focused_page_row();
|
||||
!header_.on_header && link_flags() != nullptr && row != nullptr &&
|
||||
(row->kind == PageRowKind::best || row->kind == PageRowKind::mirror) && row->mirror >= 0 &&
|
||||
row->mirror < static_cast<int>(page_.page.mirrors.size()))
|
||||
{
|
||||
const bool flagged =
|
||||
page_.page.mirrors[static_cast<std::size_t>(row->mirror)].flagged_link(page_.page) >= 0;
|
||||
items.push_back({hui::ui::Button::triangle, flagged ? tr("Clear bad mark") : tr("Mark as bad")});
|
||||
}
|
||||
items.push_back({hui::ui::Button::square, tr("Favourite")});
|
||||
if (!page_.page.mirrors.empty())
|
||||
items.push_back({hui::ui::Button::options, tr("Filters")});
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
// Wings TV app - streaming download to disk: a big HTTPS body goes to a .part file in chunks (never held
|
||||
// in memory), is hashed on the way, checked against the size and SHA-256 the release lists and, for a
|
||||
// package image, against the UFS2 superblock magic, and only then renamed into place.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Pure pieces (limits, free-space rule, magic rule, ChunkSink) have no network; the network arrives
|
||||
// through the StreamingDownloader interface (main.cpp wires it to the art service's TLS client).
|
||||
// Pattern after the Pegasus release downloader in pegasus_manager.cpp and Orbit's backend/tvapp.c.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
// An image Wings will fetch is at most this big; after it the volume keeps this much free.
|
||||
constexpr std::uint64_t kStreamMaxImage = 256ull << 20;
|
||||
constexpr std::uint64_t kStreamSpare = 64ull << 20;
|
||||
// UFS2 superblock magic 0x19540119 (stored little endian: 19 01 54 19) at 65536 + 1372.
|
||||
constexpr std::uint64_t kUfs2MagicOffset = 65536 + 1372;
|
||||
|
||||
// The four magic bytes read at kUfs2MagicOffset.
|
||||
bool ufs2_magic_ok(const std::uint8_t *four);
|
||||
// A listed size is acceptable: big enough to hold the magic, at most kStreamMaxImage.
|
||||
bool stream_size_ok(std::uint64_t want);
|
||||
// Enough room for `want` plus kStreamSpare. free_bytes < 0 means unknown, which does not block.
|
||||
bool stream_space_ok(double free_bytes, std::uint64_t want);
|
||||
|
||||
enum class StreamError : std::uint8_t
|
||||
{
|
||||
none,
|
||||
cancelled,
|
||||
bad_request, // size outside the limits, empty paths, bad checksum text
|
||||
no_space, // less free space than size + kStreamSpare
|
||||
network, // transport failure; StreamResult::detail says which
|
||||
http_status, // non-2xx final status (StreamResult::status)
|
||||
too_large, // more bytes arrived than listed
|
||||
size_mismatch, // fewer bytes than listed
|
||||
checksum, // SHA-256 differs
|
||||
not_image, // UFS2 magic missing
|
||||
disk // open/write/flush/rename failed
|
||||
};
|
||||
|
||||
// Receives the body in order. Writes the .part file, hashes it, counts, caps, reports, polls cancel.
|
||||
class ChunkSink
|
||||
{
|
||||
public:
|
||||
struct Config
|
||||
{
|
||||
std::string part_path;
|
||||
std::uint64_t limit = 0; // bytes accepted in total
|
||||
std::function<void(std::uint64_t)> progress;
|
||||
std::function<bool()> cancel; // true: stop
|
||||
};
|
||||
explicit ChunkSink(Config config);
|
||||
~ChunkSink(); // deletes the .part file unless finish() succeeded
|
||||
ChunkSink(const ChunkSink &) = delete;
|
||||
ChunkSink &operator=(const ChunkSink &) = delete;
|
||||
|
||||
bool open(); // creates/truncates the .part file
|
||||
bool write(const std::uint8_t *data, std::size_t n); // false: failed() says why, the file is gone
|
||||
// After the last byte: flush + fsync + close. Fills hex and *ufs2 (magic seen at its offset).
|
||||
bool finish(std::string *sha256_hex, bool *ufs2);
|
||||
void abort(); // close and delete the .part file
|
||||
|
||||
std::uint64_t bytes() const { return bytes_; }
|
||||
StreamError failed() const { return failure_; }
|
||||
|
||||
private:
|
||||
bool fail(StreamError why);
|
||||
Config config_;
|
||||
std::FILE *file_ = nullptr;
|
||||
void *hash_ = nullptr; // Sha256 (kept out of this header)
|
||||
std::uint64_t bytes_ = 0;
|
||||
std::uint8_t magic_[4] = {0, 0, 0, 0};
|
||||
StreamError failure_ = StreamError::none;
|
||||
bool done_ = false;
|
||||
};
|
||||
|
||||
struct StreamHooks
|
||||
{
|
||||
std::function<bool(const std::string &)> redirect_ok; // false refuses that redirect target
|
||||
std::function<void(std::uint64_t)> progress; // body bytes so far
|
||||
std::function<bool()> cancel; // true: stop
|
||||
};
|
||||
|
||||
class StreamingDownloader
|
||||
{
|
||||
public:
|
||||
virtual ~StreamingDownloader() = default;
|
||||
// The same network gate as the cover art. False with a sentence.
|
||||
virtual bool ready(std::string *why) = 0;
|
||||
// Blocks (worker thread). Delivers the final 2xx body to `sink` (false aborts) in at most 16 KiB
|
||||
// pieces; nothing is delivered for other statuses. *status is the final HTTP status (0: none).
|
||||
// Returns "" on success, else a short reason.
|
||||
virtual std::string get_stream(const std::string &url, std::uint64_t max_bytes, const StreamHooks &hooks,
|
||||
const std::function<bool(const std::uint8_t *, std::size_t)> &sink,
|
||||
int *status) = 0;
|
||||
};
|
||||
|
||||
// https on github.com, objects.githubusercontent.com or release-assets.githubusercontent.com only
|
||||
// (the same rule the Pegasus downloader pins its redirects to).
|
||||
bool stream_redirect_allowed(const std::string &url);
|
||||
|
||||
struct StreamRequest
|
||||
{
|
||||
std::string url; // the caller already validated the first URL
|
||||
std::string final_path; // the finished file; "<final_path>.part" is the work file
|
||||
std::uint64_t size = 0; // listed size, exact
|
||||
std::string sha256; // listed digest, 64 lower-case hex
|
||||
bool require_ufs2 = true; // check the package image magic
|
||||
StreamHooks hooks; // redirect_ok defaults to stream_redirect_allowed
|
||||
// Free bytes on the volume holding final_path, negative/unset: unknown (not blocking).
|
||||
std::function<double(const std::string &path)> free_bytes;
|
||||
};
|
||||
|
||||
struct StreamResult
|
||||
{
|
||||
bool ok = false;
|
||||
StreamError error = StreamError::none;
|
||||
int status = 0;
|
||||
std::string detail; // transport reason
|
||||
std::uint64_t bytes = 0; // bytes received
|
||||
std::string sha256; // of what was received (when it got that far)
|
||||
};
|
||||
|
||||
// Checks, streams, verifies, renames. On any failure the .part file is deleted and final_path is
|
||||
// left as it was; on success the .part file replaced final_path.
|
||||
StreamResult stream_download_to_file(StreamingDownloader &downloader, const StreamRequest &request);
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,122 @@
|
||||
// Wings TV app - TorBox "still preparing" status and the wait that forces it through (B-19).
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/torbox_status.hpp"
|
||||
|
||||
#include "orbit/i18n.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
|
||||
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(const std::string &text, const char *part)
|
||||
{
|
||||
return text.find(part) != std::string::npos;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool is_torbox_preparing(std::string_view error)
|
||||
{
|
||||
// Pegasus DL 1.11.0: "TorBox is still preparing this download; try again shortly".
|
||||
const std::string text = lower(error);
|
||||
if (!has(text, "torbox"))
|
||||
return false;
|
||||
return has(text, "still prepar") || has(text, "preparing this") || has(text, "being prepared") ||
|
||||
has(text, "still being cached") || has(text, "still caching");
|
||||
}
|
||||
|
||||
void TorboxPrepareWait::start(double now)
|
||||
{
|
||||
if (state_ != State::waiting && state_ != State::in_flight)
|
||||
{
|
||||
started_ = now;
|
||||
attempts_ = 0;
|
||||
}
|
||||
if (now - started_ >= kGiveUpAfter)
|
||||
{
|
||||
state_ = State::gave_up;
|
||||
return;
|
||||
}
|
||||
state_ = State::waiting;
|
||||
next_ = now + kRetryEvery;
|
||||
}
|
||||
|
||||
TorboxPrepareWait::Tick TorboxPrepareWait::tick(double now)
|
||||
{
|
||||
Tick t;
|
||||
switch (state_)
|
||||
{
|
||||
case State::idle:
|
||||
break;
|
||||
case State::in_flight:
|
||||
t.kind = Tick::Kind::in_flight;
|
||||
break;
|
||||
case State::gave_up:
|
||||
state_ = State::idle;
|
||||
t.kind = Tick::Kind::gave_up;
|
||||
break;
|
||||
case State::waiting:
|
||||
if (now >= next_)
|
||||
{
|
||||
state_ = State::in_flight;
|
||||
++attempts_;
|
||||
t.kind = Tick::Kind::retry_now;
|
||||
}
|
||||
else
|
||||
{
|
||||
t.kind = Tick::Kind::waiting;
|
||||
t.seconds_left = static_cast<int>(std::ceil(next_ - now));
|
||||
}
|
||||
break;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
void TorboxPrepareWait::cancel()
|
||||
{
|
||||
state_ = State::idle;
|
||||
attempts_ = 0;
|
||||
}
|
||||
|
||||
std::string torbox_waiting_text(int seconds)
|
||||
{
|
||||
if (seconds <= 0)
|
||||
return tr("TorBox is getting this file ready on its server\xE2\x80\xA6 trying again now");
|
||||
return tr("TorBox is getting this file ready on its server\xE2\x80\xA6 trying again in {s} s",
|
||||
{{"s", std::to_string(seconds)}});
|
||||
}
|
||||
|
||||
std::string torbox_asking_text()
|
||||
{
|
||||
return tr("TorBox is getting this file ready on its server\xE2\x80\xA6");
|
||||
}
|
||||
|
||||
std::string torbox_gave_up_text()
|
||||
{
|
||||
return tr("TorBox is still preparing this file. Try again later, or open TorBox to check it.");
|
||||
}
|
||||
|
||||
std::string torbox_preparing_title()
|
||||
{
|
||||
return tr("TorBox is still preparing this file");
|
||||
}
|
||||
|
||||
std::string torbox_card_label()
|
||||
{
|
||||
return tr("Preparing on TorBox");
|
||||
}
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,97 @@
|
||||
// Wings TV app - TorBox "still preparing" status and the wait that forces it through (B-19).
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// POST /api/debrid/queue with TorBox asks TorBox for the file and polls it every
|
||||
// 2 s for up to 60 s. When TorBox has not finished fetching the file to its own
|
||||
// server by then, Pegasus DL fails the call with
|
||||
// "TorBox is still preparing this download; try again shortly"
|
||||
// and keeps nothing: no job, no progress. Wings recognises that answer, tells the
|
||||
// player TorBox is preparing the file, and asks again by itself every 30 s for up
|
||||
// to 10 minutes (Circle stops it at once).
|
||||
//
|
||||
// Pure: no I/O, time is passed in (seconds, any steady clock).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
// The TorBox page that lists what TorBox is preparing for the player.
|
||||
constexpr const char *kTorboxDashboardUrl = "https://torbox.app/dashboard";
|
||||
|
||||
// True when Pegasus' error says TorBox is still preparing the file. Ignores case
|
||||
// and anything wrapped around it ("HTTP 502: ..."); false for every other error
|
||||
// (rate limit, HTTP status, unsupported host, no token, selection expired, ...).
|
||||
bool is_torbox_preparing(std::string_view error);
|
||||
|
||||
// The automatic retry while TorBox prepares the file.
|
||||
// start(now) TorBox answered "still preparing" (first time or after a retry).
|
||||
// tick(now) every frame: waiting (seconds left), retry_now (send it again),
|
||||
// gave_up (once, 10 minutes after the first answer), else idle /
|
||||
// in_flight (a retry is out, its answer not back yet).
|
||||
// cancel() Circle, or the retry worked: back to idle.
|
||||
class TorboxPrepareWait
|
||||
{
|
||||
public:
|
||||
static constexpr double kRetryEvery = 30.0; // seconds between tries
|
||||
static constexpr double kGiveUpAfter = 600.0; // 10 minutes after the first "still preparing"
|
||||
|
||||
struct Tick
|
||||
{
|
||||
enum class Kind
|
||||
{
|
||||
idle,
|
||||
waiting, // next try in `seconds_left`
|
||||
retry_now, // the caller sends the debrid queue again now
|
||||
in_flight, // a retry is out
|
||||
gave_up, // reported once; then idle
|
||||
};
|
||||
Kind kind = Kind::idle;
|
||||
int seconds_left = 0;
|
||||
};
|
||||
|
||||
void start(double now);
|
||||
Tick tick(double now);
|
||||
void cancel();
|
||||
|
||||
// Waiting or a retry in flight.
|
||||
bool active() const
|
||||
{
|
||||
return state_ == State::waiting || state_ == State::in_flight;
|
||||
}
|
||||
// Retries sent since the first "still preparing".
|
||||
int attempts() const
|
||||
{
|
||||
return attempts_;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class State
|
||||
{
|
||||
idle,
|
||||
waiting,
|
||||
in_flight,
|
||||
gave_up,
|
||||
};
|
||||
State state_ = State::idle;
|
||||
double started_ = 0.0;
|
||||
double next_ = 0.0;
|
||||
int attempts_ = 0;
|
||||
};
|
||||
|
||||
// "TorBox is getting this file ready on its server... trying again in 20 s"
|
||||
// (seconds <= 0: "... trying again now").
|
||||
std::string torbox_waiting_text(int seconds);
|
||||
// "TorBox is getting this file ready on its server..." (busy box while a try is out).
|
||||
std::string torbox_asking_text();
|
||||
// The notice once the wait gives up.
|
||||
std::string torbox_gave_up_text();
|
||||
// Title of the notice ("TorBox is still preparing this file").
|
||||
std::string torbox_preparing_title();
|
||||
// The small chip on the link card.
|
||||
std::string torbox_card_label();
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,566 @@
|
||||
// Wings TV app - Updating Wings: the startup dialog and the full-screen update page.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/update_screen.hpp"
|
||||
|
||||
#include "orbit/i18n.hpp"
|
||||
#include "orbit/screens.hpp"
|
||||
#include "orbit/theme.hpp"
|
||||
#include "orbit/wings_install.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr float kButtonWidth = 260.0f;
|
||||
constexpr float kStepRadius = 26.0f;
|
||||
constexpr float kStepsY = 330.0f; // centre line of the step dots
|
||||
constexpr float kCardTop = 450.0f; // the status block
|
||||
constexpr float kCardHeight = 470.0f;
|
||||
constexpr float kBodySize = 28.0f;
|
||||
constexpr float kBodyLine = 42.0f;
|
||||
|
||||
std::string one_decimal(double value)
|
||||
{
|
||||
char text[32];
|
||||
std::snprintf(text, sizeof text, "%.1f", value);
|
||||
return text;
|
||||
}
|
||||
|
||||
int step_index(UpdateView::Step step)
|
||||
{
|
||||
return static_cast<int>(step);
|
||||
}
|
||||
|
||||
// Moves the focus over a row of `count` buttons; refuses at the ends.
|
||||
bool move_focus(int &focus, int count, const hui::InputFrame &input, hui::ui::Feedback &feedback)
|
||||
{
|
||||
if (input.nav != Direction::left && input.nav != Direction::right)
|
||||
return false;
|
||||
const int next = focus + (input.nav == Direction::right ? 1 : -1);
|
||||
if (count > 0 && next >= 0 && next < count)
|
||||
{
|
||||
focus = next;
|
||||
feedback.play(hui::audio::Cue::focus);
|
||||
return true;
|
||||
}
|
||||
refuse(feedback, input);
|
||||
return false;
|
||||
}
|
||||
|
||||
void ease_buttons(float *values, int count, int focus, bool live, float dt)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const float target = live && i == focus ? 1.0f : 0.0f;
|
||||
values[i] += (target - values[i]) * (1.0f - std::exp(-18.0f * dt));
|
||||
}
|
||||
}
|
||||
|
||||
// A numbered line: the number in a small glass disc, the words beside it (two lines at most).
|
||||
float numbered(hui::gfx::DrawList &list, const ui::Type &type, int number, const std::string &words, float x,
|
||||
float baseline, float width)
|
||||
{
|
||||
list.circle(x + 20.0f, baseline - 10.0f, 20.0f, ui::color::field_open);
|
||||
ui::text(list, type.semibold, std::to_string(number), x + 20.0f, baseline - 2.0f, 22.0f, ui::color::text,
|
||||
ui::Align::center);
|
||||
return ui::paragraph(list, type.regular, words, x + 60.0f, baseline, kBodySize, width - 60.0f, kBodyLine,
|
||||
ui::color::text2, 2) +
|
||||
10.0f;
|
||||
}
|
||||
|
||||
// A 6 px semantic dot with its word (colour never alone).
|
||||
void state_word(hui::gfx::DrawList &list, const ui::Type &type, float x, float baseline, const char *word,
|
||||
ui::Color colour)
|
||||
{
|
||||
list.circle(x + 6.0f, baseline - 8.0f, 6.0f, colour);
|
||||
ui::text(list, type.semibold, word, x + 22.0f, baseline, 22.0f, colour);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---- pure helpers ----
|
||||
|
||||
std::string update_size_text(double bytes)
|
||||
{
|
||||
const double mb = std::max(0.0, bytes) / (1024.0 * 1024.0);
|
||||
if (mb >= 1024.0)
|
||||
return tr("{n} GB", {{"n", one_decimal(mb / 1024.0)}});
|
||||
return tr("{n} MB", {{"n", one_decimal(mb)}});
|
||||
}
|
||||
|
||||
std::string update_eta_text(double seconds)
|
||||
{
|
||||
if (!(seconds > 0.0) || !std::isfinite(seconds))
|
||||
return std::string();
|
||||
if (seconds < 60.0)
|
||||
return tr("about {n} s left", {{"n", std::to_string(static_cast<long>(std::ceil(seconds)))}});
|
||||
if (seconds < 3600.0)
|
||||
return tr("about {n} min left", {{"n", std::to_string(static_cast<long>(std::ceil(seconds / 60.0)))}});
|
||||
return tr("about {n} h left", {{"n", std::to_string(static_cast<long>(std::ceil(seconds / 3600.0)))}});
|
||||
}
|
||||
|
||||
std::string update_progress_text(const UpdateView &view)
|
||||
{
|
||||
std::string out = view.total_bytes > 0.0
|
||||
? tr("{done} of {total}", {{"done", update_size_text(view.done_bytes)},
|
||||
{"total", update_size_text(view.total_bytes)}})
|
||||
: tr("{done} downloaded", {{"done", update_size_text(view.done_bytes)}});
|
||||
if (view.bytes_per_s > 0.0 && std::isfinite(view.bytes_per_s))
|
||||
out += " · " + tr("{rate}/s", {{"rate", update_size_text(view.bytes_per_s)}});
|
||||
const std::string eta = update_eta_text(view.eta_s);
|
||||
if (!eta.empty())
|
||||
out += " · " + eta;
|
||||
return out;
|
||||
}
|
||||
|
||||
float update_fraction(const UpdateView &view)
|
||||
{
|
||||
if (!(view.total_bytes > 0.0))
|
||||
return 0.0f;
|
||||
return static_cast<float>(std::clamp(view.done_bytes / view.total_bytes, 0.0, 1.0));
|
||||
}
|
||||
|
||||
int update_current_step(const UpdateView &view)
|
||||
{
|
||||
switch (view.kind)
|
||||
{
|
||||
case UpdateView::Kind::available:
|
||||
return -1;
|
||||
case UpdateView::Kind::downloading: // Check while the published checksum is read, then Download
|
||||
return step_index(view.step == UpdateView::Step::check ? UpdateView::Step::check
|
||||
: UpdateView::Step::download);
|
||||
case UpdateView::Kind::checking: // the helper checks the finished download
|
||||
return step_index(UpdateView::Step::download);
|
||||
case UpdateView::Kind::ready_to_install: // downloaded and checked: Install is next
|
||||
return step_index(UpdateView::Step::install);
|
||||
case UpdateView::Kind::installing:
|
||||
return step_index(UpdateView::Step::install);
|
||||
case UpdateView::Kind::installed:
|
||||
return step_index(UpdateView::Step::restart);
|
||||
case UpdateView::Kind::manual:
|
||||
case UpdateView::Kind::failed:
|
||||
default:
|
||||
return step_index(view.step);
|
||||
}
|
||||
}
|
||||
|
||||
std::string update_row_value(const UpdateView *view)
|
||||
{
|
||||
if (view == nullptr)
|
||||
return tr("Up to date");
|
||||
if (view->kind == UpdateView::Kind::installed)
|
||||
return tr("Installed — restart to finish");
|
||||
return tr("{latest} available", {{"latest", view->latest}});
|
||||
}
|
||||
|
||||
std::vector<UpdateButton> update_buttons(const UpdateView &view)
|
||||
{
|
||||
std::vector<UpdateButton> out;
|
||||
const auto add = [&](const char *label, UpdateAction action, bool primary)
|
||||
{ out.push_back({tr(label), action, primary}); };
|
||||
switch (view.kind)
|
||||
{
|
||||
case UpdateView::Kind::available:
|
||||
add("Update now", UpdateAction::update_now, true);
|
||||
add("Back", UpdateAction::back, false);
|
||||
break;
|
||||
case UpdateView::Kind::downloading:
|
||||
case UpdateView::Kind::checking:
|
||||
if (view.can_cancel)
|
||||
add("Cancel", UpdateAction::cancel, false);
|
||||
break;
|
||||
case UpdateView::Kind::ready_to_install:
|
||||
add("Install", UpdateAction::install, true);
|
||||
add("Cancel", UpdateAction::cancel, false);
|
||||
break;
|
||||
case UpdateView::Kind::installing:
|
||||
break;
|
||||
case UpdateView::Kind::installed:
|
||||
add("Quit Wings now", UpdateAction::quit_now, true);
|
||||
add("Back", UpdateAction::back, false);
|
||||
break;
|
||||
case UpdateView::Kind::manual:
|
||||
add("Back", UpdateAction::back, false);
|
||||
break;
|
||||
case UpdateView::Kind::failed:
|
||||
if (view.can_retry)
|
||||
add("Retry", UpdateAction::retry, true);
|
||||
add("Back", UpdateAction::back, false);
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool update_view_from_flow(const WingsFlowSnapshot &flow, UpdateView *out)
|
||||
{
|
||||
using Kind = UpdateView::Kind;
|
||||
Kind kind = Kind::available;
|
||||
switch (flow.state)
|
||||
{
|
||||
case WingsFlowState::idle:
|
||||
return false;
|
||||
case WingsFlowState::available:
|
||||
kind = Kind::available;
|
||||
break;
|
||||
case WingsFlowState::downloading:
|
||||
kind = Kind::downloading;
|
||||
break;
|
||||
case WingsFlowState::checking:
|
||||
kind = Kind::checking;
|
||||
break;
|
||||
case WingsFlowState::ready_to_install:
|
||||
kind = Kind::ready_to_install;
|
||||
break;
|
||||
case WingsFlowState::installing:
|
||||
kind = Kind::installing;
|
||||
break;
|
||||
case WingsFlowState::installed:
|
||||
kind = Kind::installed;
|
||||
break;
|
||||
case WingsFlowState::manual:
|
||||
kind = Kind::manual;
|
||||
break;
|
||||
case WingsFlowState::failed:
|
||||
kind = Kind::failed;
|
||||
break;
|
||||
}
|
||||
UpdateView v;
|
||||
v.kind = kind;
|
||||
v.step = static_cast<UpdateView::Step>(std::clamp(flow.step_index, 0, kWingsFlowSteps - 1));
|
||||
v.current = flow.running_text;
|
||||
v.latest = flow.version_text;
|
||||
v.done_bytes = static_cast<double>(flow.bytes);
|
||||
v.total_bytes = static_cast<double>(flow.total);
|
||||
v.bytes_per_s = flow.speed_bps > 0.0 ? flow.speed_bps : 0.0;
|
||||
v.eta_s = flow.eta_s > 0 ? static_cast<double>(flow.eta_s) : 0.0;
|
||||
v.message = kind == Kind::failed && !flow.reason.empty() ? flow.reason : flow.text;
|
||||
for (const WingsInstallStep &step : flow.manual_steps)
|
||||
v.manual_steps.push_back(step.detail.empty() ? step.label : step.label + ": " + step.detail);
|
||||
v.can_cancel = flow.buttons.cancel;
|
||||
v.can_retry = flow.buttons.retry;
|
||||
*out = std::move(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- UpdateDialog ----
|
||||
|
||||
void UpdateDialog::open(const UpdateView &view)
|
||||
{
|
||||
open_ = true;
|
||||
focus_ = 0;
|
||||
title_ = tr("Wings {latest} is available", {{"latest", view.latest}});
|
||||
notes_.assign(view.notes.begin(), view.notes.begin() + std::min<std::size_t>(3, view.notes.size()));
|
||||
in_.snap(0.0f);
|
||||
}
|
||||
|
||||
UpdateAction UpdateDialog::update(const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
|
||||
{
|
||||
UpdateAction action = UpdateAction::none;
|
||||
in_.target = open_ ? 1.0f : 0.0f;
|
||||
in_.update(dt, 18.0f);
|
||||
if (open_)
|
||||
{
|
||||
move_focus(focus_, 2, input, feedback);
|
||||
if (input.is_pressed(Action::confirm))
|
||||
{
|
||||
open_ = false;
|
||||
feedback.play(hui::audio::Cue::select);
|
||||
action = focus_ == 0 ? UpdateAction::update_now : UpdateAction::later;
|
||||
}
|
||||
else if (input.is_pressed(Action::back))
|
||||
{
|
||||
open_ = false;
|
||||
feedback.play(hui::audio::Cue::modal_close);
|
||||
action = UpdateAction::later;
|
||||
}
|
||||
}
|
||||
ease_buttons(button_focus_, 2, focus_, open_, dt);
|
||||
return action;
|
||||
}
|
||||
|
||||
void UpdateDialog::draw(Context &ctx, Frame &frame) const
|
||||
{
|
||||
if (!open_ && in_.value < 0.01f)
|
||||
return;
|
||||
hui::gfx::DrawList &list = frame.overlay;
|
||||
const ui::Type &type = ctx.type;
|
||||
const float a = std::clamp(in_.value, 0.0f, 1.0f);
|
||||
draw_scrim(list, a);
|
||||
const float width = 960.0f;
|
||||
const float notes_height = notes_.empty() ? 0.0f : 56.0f + static_cast<float>(notes_.size()) * 76.0f;
|
||||
const float height = 72.0f + 60.0f + notes_height + 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);
|
||||
float y = r.y + 150.0f;
|
||||
if (!notes_.empty())
|
||||
{
|
||||
ui::text(list, type.semibold, tr("What's new"), r.x + 48.0f, y, 22.0f, ui::color::text3);
|
||||
y += 48.0f;
|
||||
for (const std::string ¬e : notes_)
|
||||
{
|
||||
list.circle(r.x + 56.0f, y - 9.0f, 4.0f, ui::color::text2);
|
||||
ui::paragraph(list, type.regular, note, r.x + 76.0f, y, 25.0f, r.w - 124.0f, 36.0f,
|
||||
ui::color::text2, 2);
|
||||
y += 76.0f;
|
||||
}
|
||||
}
|
||||
const float by = r.y + r.h - 44.0f - ui::kButtonHeight;
|
||||
const ui::Rect later{r.x + r.w - 48.0f - kButtonWidth, by, kButtonWidth, ui::kButtonHeight};
|
||||
const ui::Rect now{later.x - 20.0f - kButtonWidth, by, kButtonWidth, ui::kButtonHeight};
|
||||
ui::button(list, type, now, tr("Update now"), ui::ButtonKind::primary, button_focus_[0]);
|
||||
ui::button(list, type, later, tr("Later"), ui::ButtonKind::secondary, button_focus_[1]);
|
||||
list.pop_opacity();
|
||||
}
|
||||
|
||||
std::vector<hui::ui::Hint> UpdateDialog::hints() const
|
||||
{
|
||||
return {{hui::ui::Button::cross, tr("Select")}, {hui::ui::Button::circle, tr("Later")}};
|
||||
}
|
||||
|
||||
// ---- UpdatePage ----
|
||||
|
||||
void UpdatePage::open(const UpdateView &view)
|
||||
{
|
||||
open_ = true;
|
||||
shown_step_ = -1;
|
||||
focus_ = 0;
|
||||
buttons_.clear();
|
||||
in_.snap(0.0f);
|
||||
take(view);
|
||||
}
|
||||
|
||||
void UpdatePage::take(const UpdateView &view)
|
||||
{
|
||||
// Never look like it went backwards: the shown step only moves on, except when the player starts again.
|
||||
shown_step_ = std::max(shown_step_, update_current_step(view));
|
||||
std::vector<UpdateButton> next = update_buttons(view);
|
||||
const bool same = next.size() == buttons_.size() &&
|
||||
std::equal(next.begin(), next.end(), buttons_.begin(),
|
||||
[](const UpdateButton &l, const UpdateButton &r) { return l.action == r.action; });
|
||||
if (!same)
|
||||
{
|
||||
buttons_ = std::move(next);
|
||||
focus_ = 0; // the primary button (or the only one) takes the focus
|
||||
button_focus_.assign(buttons_.size(), 0.0f);
|
||||
}
|
||||
view_ = view;
|
||||
}
|
||||
|
||||
UpdateAction UpdatePage::update(const UpdateView &view, const hui::InputFrame &input, float dt,
|
||||
hui::ui::Feedback &feedback)
|
||||
{
|
||||
UpdateAction action = UpdateAction::none;
|
||||
in_.target = open_ ? 1.0f : 0.0f;
|
||||
in_.update(dt, 14.0f);
|
||||
if (!open_)
|
||||
return action;
|
||||
take(view);
|
||||
|
||||
const hui::InputFrame idle{};
|
||||
const bool had_confirm = quit_confirm_.is_open();
|
||||
const ConfirmDialog::Result result = quit_confirm_.update(had_confirm ? input : idle, dt, feedback);
|
||||
if (result == ConfirmDialog::Result::confirmed)
|
||||
action = UpdateAction::quit_now;
|
||||
if (!had_confirm)
|
||||
{
|
||||
move_focus(focus_, static_cast<int>(buttons_.size()), input, feedback);
|
||||
if (input.is_pressed(Action::confirm) && !buttons_.empty())
|
||||
{
|
||||
const UpdateAction chosen = buttons_[static_cast<std::size_t>(focus_)].action;
|
||||
feedback.play(hui::audio::Cue::select);
|
||||
if (chosen == UpdateAction::quit_now)
|
||||
{
|
||||
quit_confirm_.open(tr("Quit Wings now?"),
|
||||
tr("Wings closes. Open it again to start the new version."),
|
||||
tr("Quit Wings"), tr("Not yet"), false);
|
||||
feedback.play(hui::audio::Cue::modal_open);
|
||||
}
|
||||
else
|
||||
{
|
||||
action = chosen;
|
||||
if (chosen == UpdateAction::update_now || chosen == UpdateAction::retry)
|
||||
shown_step_ = -1; // the player starts again: the steps may begin anew
|
||||
}
|
||||
}
|
||||
else if (input.is_pressed(Action::back))
|
||||
{
|
||||
feedback.play(hui::audio::Cue::back);
|
||||
action = UpdateAction::back;
|
||||
}
|
||||
}
|
||||
if (action == UpdateAction::back)
|
||||
open_ = false;
|
||||
for (std::size_t i = 0; i < button_focus_.size(); ++i)
|
||||
{
|
||||
const float target = !quit_confirm_.is_open() && static_cast<int>(i) == focus_ ? 1.0f : 0.0f;
|
||||
button_focus_[i] += (target - button_focus_[i]) * (1.0f - std::exp(-18.0f * dt));
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
void UpdatePage::draw(Context &ctx, Frame &frame) const
|
||||
{
|
||||
if (!open_ && in_.value < 0.01f)
|
||||
return;
|
||||
hui::gfx::DrawList &list = frame.overlay;
|
||||
const ui::Type &type = ctx.type;
|
||||
const float a = std::clamp(in_.value, 0.0f, 1.0f);
|
||||
list.push_opacity(a);
|
||||
// Opaque and neutral: the page covers the tab under it (chrome never carries a hue of its own).
|
||||
list.rounded_rect({0.0f, 0.0f, 1920.0f, 1080.0f}, 0.0f, ui::color::night1);
|
||||
|
||||
draw_heading(ctx, list, tr("Wings update"),
|
||||
view_.current.empty() ? std::string()
|
||||
: tr("You have {current} · New {latest}",
|
||||
{{"current", view_.current}, {"latest", view_.latest}}));
|
||||
|
||||
// ---- the four steps ----
|
||||
const char *labels[4] = {tr("Check"), tr("Download"), tr("Install"), tr("Restart Wings")};
|
||||
const float left = ui::kGutter + 120.0f, right = 1920.0f - ui::kGutter - 120.0f;
|
||||
const float pitch = (right - left) / 3.0f;
|
||||
const int current = shown_step_;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
{
|
||||
const float x1 = left + static_cast<float>(i) * pitch + kStepRadius + 12.0f;
|
||||
const float x2 = left + static_cast<float>(i + 1) * pitch - kStepRadius - 12.0f;
|
||||
list.line(x1, kStepsY, x2, kStepsY, 3.0f,
|
||||
i < current ? ui::Color::rgb(0xffffff, 0.7f) : ui::color::line);
|
||||
}
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
const float cx = left + static_cast<float>(i) * pitch;
|
||||
const bool done = i < current;
|
||||
const bool now = i == current;
|
||||
if (now)
|
||||
ui::focus_ring(list, {cx - kStepRadius, kStepsY - kStepRadius, 2.0f * kStepRadius, 2.0f * kStepRadius},
|
||||
kStepRadius, 0.6f);
|
||||
list.circle(cx, kStepsY, kStepRadius,
|
||||
done ? ui::Color::rgb(0xffffff, 0.85f) : now ? ui::color::text : ui::color::field_open);
|
||||
if (done)
|
||||
ui::icon_check(list, cx, kStepsY, 24.0f, ui::color::night0);
|
||||
else
|
||||
ui::text(list, type.semibold, std::to_string(i + 1), cx, kStepsY + 9.0f, 26.0f,
|
||||
now ? ui::color::night0 : ui::color::text3, ui::Align::center);
|
||||
ui::text(list, now ? type.semibold : type.medium, labels[i], cx, kStepsY + 72.0f, 24.0f,
|
||||
now || done ? ui::color::text : ui::color::text3, ui::Align::center);
|
||||
}
|
||||
|
||||
// ---- the status block ----
|
||||
const ui::Rect card{ui::kGutter, kCardTop, 1920.0f - 2.0f * ui::kGutter, kCardHeight};
|
||||
ui::surface(list, card, ui::kPanelRadius, ui::color::field);
|
||||
const float x = card.x + 56.0f, width = card.w - 112.0f;
|
||||
float y = card.y + 84.0f;
|
||||
const auto title = [&](const std::string &words)
|
||||
{
|
||||
ui::text_fit(list, type.light, words, x, y, 46.0f, width, ui::color::text);
|
||||
y += 66.0f;
|
||||
};
|
||||
const auto body = [&](const std::string &words, int lines)
|
||||
{ y = ui::paragraph(list, type.regular, words, x, y, kBodySize, width, kBodyLine, ui::color::text2, lines) + 8.0f; };
|
||||
switch (view_.kind)
|
||||
{
|
||||
case UpdateView::Kind::available:
|
||||
title(tr("Wings {latest} is available", {{"latest", view_.latest}}));
|
||||
if (!view_.notes.empty())
|
||||
{
|
||||
ui::text(list, type.semibold, tr("What's new"), x, y, 22.0f, ui::color::text3);
|
||||
y += 44.0f;
|
||||
for (std::size_t i = 0; i < view_.notes.size() && i < 3; ++i)
|
||||
{
|
||||
list.circle(x + 8.0f, y - 10.0f, 4.0f, ui::color::text2);
|
||||
ui::text_fit(list, type.regular, view_.notes[i], x + 28.0f, y, kBodySize, width - 28.0f,
|
||||
ui::color::text2);
|
||||
y += kBodyLine;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case UpdateView::Kind::downloading:
|
||||
case UpdateView::Kind::checking:
|
||||
{
|
||||
const bool checking = view_.kind == UpdateView::Kind::checking;
|
||||
title(checking ? std::string(tr("Checking the download…"))
|
||||
: tr("Downloading Wings {latest}", {{"latest", view_.latest}}));
|
||||
// Checking comes after a whole download: the bar stays full, it never runs back.
|
||||
ui::progress_bar(list, {x, y, width, 12.0f}, checking ? 1.0f : update_fraction(view_), theme::semantic::info);
|
||||
y += 62.0f;
|
||||
body(checking ? std::string(tr("Making sure the file arrived whole and is the real release."))
|
||||
: update_progress_text(view_),
|
||||
1);
|
||||
break;
|
||||
}
|
||||
case UpdateView::Kind::ready_to_install:
|
||||
title(tr("Ready to install"));
|
||||
body(tr("Wings {latest} is downloaded and checked. Install puts it in place in a few seconds. Your current "
|
||||
"version is kept until the new one runs, so nothing is lost if something goes wrong.",
|
||||
{{"latest", view_.latest}}),
|
||||
3);
|
||||
break;
|
||||
case UpdateView::Kind::installing:
|
||||
{
|
||||
title(tr("Installing…"));
|
||||
body(tr("This takes a few seconds. Don't turn off the console."), 2);
|
||||
for (int i = 0; i < 3; ++i)
|
||||
{
|
||||
const float phase = 0.5f + 0.5f * std::sin(ctx.clock * 5.0f - static_cast<float>(i) * 0.9f);
|
||||
list.circle(x + 8.0f + static_cast<float>(i) * 24.0f, y + 20.0f, 7.0f,
|
||||
ui::Color::rgb(0xffffff, 0.3f + 0.7f * phase));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UpdateView::Kind::installed:
|
||||
state_word(list, type, x, y - 52.0f, tr("Installed"), theme::semantic::good);
|
||||
title(tr("Wings {latest} is installed", {{"latest", view_.latest}}));
|
||||
body(tr("Close Wings and open it again to use version {latest}.", {{"latest", view_.latest}}), 1);
|
||||
y += 6.0f;
|
||||
y = numbered(list, type, 1, tr("Quit Wings"), x, y, width);
|
||||
y = numbered(list, type, 2, tr("Open Wings again"), x, y, width);
|
||||
break;
|
||||
case UpdateView::Kind::manual:
|
||||
title(tr("Finish the update by hand"));
|
||||
if (!view_.message.empty())
|
||||
body(view_.message, 2);
|
||||
for (std::size_t i = 0; i < view_.manual_steps.size() && y < card.y + card.h - 120.0f; ++i)
|
||||
y = numbered(list, type, static_cast<int>(i) + 1, view_.manual_steps[i], x, y, width);
|
||||
break;
|
||||
case UpdateView::Kind::failed:
|
||||
state_word(list, type, x, y - 52.0f, tr("Failed"), theme::semantic::bad);
|
||||
title(tr("The update stopped"));
|
||||
body(view_.message.empty() ? std::string(tr("Something went wrong. Nothing was changed.")) : view_.message,
|
||||
4);
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- the buttons, along the foot of the block ----
|
||||
float bx = x;
|
||||
const float by = card.y + card.h - 48.0f - ui::kButtonHeight;
|
||||
for (std::size_t i = 0; i < buttons_.size(); ++i)
|
||||
{
|
||||
const UpdateButton &b = buttons_[i];
|
||||
const ui::ButtonKind kind = b.primary ? ui::ButtonKind::primary : ui::ButtonKind::secondary;
|
||||
const float w = std::max(kButtonWidth, ui::button_width(type, b.label, 20.0f, kind));
|
||||
ui::button(list, type, {bx, by, w, ui::kButtonHeight}, b.label, kind,
|
||||
i < button_focus_.size() ? button_focus_[i] : 0.0f);
|
||||
bx += w + 20.0f;
|
||||
}
|
||||
ui::hints(list, type, hints());
|
||||
list.pop_opacity();
|
||||
quit_confirm_.draw(ctx, frame);
|
||||
}
|
||||
|
||||
std::vector<hui::ui::Hint> UpdatePage::hints() const
|
||||
{
|
||||
std::vector<hui::ui::Hint> items;
|
||||
if (!buttons_.empty())
|
||||
items.push_back({hui::ui::Button::cross, tr("Select")});
|
||||
items.push_back({hui::ui::Button::circle, tr("Back")});
|
||||
return items;
|
||||
}
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,162 @@
|
||||
// Wings TV app - Updating Wings: the startup dialog and the full-screen update page.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// UI only. The orchestrator fills an UpdateView from the update and install logic every frame and acts on the
|
||||
// UpdateAction the dialog or the page returns; nothing here downloads, checks or installs anything.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/input.hpp"
|
||||
#include "orbit/panels.hpp"
|
||||
#include "orbit/ui.hpp"
|
||||
#include "ui/feedback.hpp"
|
||||
#include "ui/glyphs.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
struct Context;
|
||||
struct Frame;
|
||||
struct WingsFlowSnapshot; // wings_install.hpp
|
||||
|
||||
// What the update looks like right now (filled by the orchestrator).
|
||||
struct UpdateView
|
||||
{
|
||||
enum class Step
|
||||
{
|
||||
check, // the update and its checksum
|
||||
download, // the helper downloads and checks it
|
||||
install, // the helper puts it in place
|
||||
restart // close Wings and open it again
|
||||
};
|
||||
enum class Kind
|
||||
{
|
||||
available,
|
||||
downloading,
|
||||
checking,
|
||||
ready_to_install,
|
||||
installing,
|
||||
installed,
|
||||
manual,
|
||||
failed
|
||||
};
|
||||
Kind kind = Kind::available;
|
||||
Step step = Step::download; // used for manual and failed: the step that stopped
|
||||
std::string current, latest;
|
||||
std::vector<std::string> notes; // what's new, <= 3 lines
|
||||
double done_bytes = 0.0, total_bytes = 0.0, bytes_per_s = 0.0, eta_s = 0.0;
|
||||
std::string message; // status or failure reason
|
||||
std::vector<std::string> manual_steps;
|
||||
bool can_cancel = false, can_retry = false;
|
||||
};
|
||||
|
||||
// What the player asked for. quit_now comes only after the page's own "Quit Wings now?" confirm.
|
||||
enum class UpdateAction
|
||||
{
|
||||
none,
|
||||
update_now,
|
||||
later,
|
||||
cancel,
|
||||
install,
|
||||
retry,
|
||||
quit_now,
|
||||
back
|
||||
};
|
||||
|
||||
// ---- pure helpers (unit-tested) ----
|
||||
|
||||
// "12.3 MB" / "1.2 GB".
|
||||
std::string update_size_text(double bytes);
|
||||
// "about 10 s left" / "about 3 min left"; empty when unknown.
|
||||
std::string update_eta_text(double seconds);
|
||||
// "12.3 MB of 54.7 MB · 4.1 MB/s · about 10 s left" (parts that are unknown are left out).
|
||||
std::string update_progress_text(const UpdateView &view);
|
||||
// 0..1 of the download.
|
||||
float update_fraction(const UpdateView &view);
|
||||
// The step the page highlights: -1 none yet (available), 0 download .. 3 restart.
|
||||
int update_current_step(const UpdateView &view);
|
||||
// Settings > Wings > Wings update: "Up to date" (view null), "{latest} available", "Installed — restart to finish".
|
||||
std::string update_row_value(const UpdateView *view);
|
||||
|
||||
struct UpdateButton
|
||||
{
|
||||
std::string label;
|
||||
UpdateAction action = UpdateAction::none;
|
||||
bool primary = false;
|
||||
};
|
||||
// The page's buttons for a state, left to right (the primary one first). Empty: no buttons (installing).
|
||||
std::vector<UpdateButton> update_buttons(const UpdateView &view);
|
||||
|
||||
// The flow as the update logic sees it (wings_install.hpp) as the screen draws it. False (and *out untouched)
|
||||
// while nothing newer is known (idle).
|
||||
bool update_view_from_flow(const WingsFlowSnapshot &flow, UpdateView *out);
|
||||
|
||||
// ---- the startup dialog: "Wings X is available", what's new, Update now / Later ----
|
||||
class UpdateDialog
|
||||
{
|
||||
public:
|
||||
void open(const UpdateView &view);
|
||||
void close()
|
||||
{
|
||||
open_ = false;
|
||||
}
|
||||
bool is_open() const
|
||||
{
|
||||
return open_;
|
||||
}
|
||||
// update_now or later (Back = later) once, then the dialog closes itself.
|
||||
UpdateAction update(const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
|
||||
void draw(Context &ctx, Frame &frame) const;
|
||||
std::vector<hui::ui::Hint> hints() const;
|
||||
|
||||
private:
|
||||
bool open_ = false;
|
||||
int focus_ = 0; // 0 Update now, 1 Later
|
||||
std::string title_;
|
||||
std::vector<std::string> notes_;
|
||||
ui::Ease in_;
|
||||
float button_focus_[2] = {0.0f, 0.0f};
|
||||
};
|
||||
|
||||
// ---- the full-screen update page: 4-step indicator and one status block ----
|
||||
class UpdatePage
|
||||
{
|
||||
public:
|
||||
void open(const UpdateView &view);
|
||||
void close()
|
||||
{
|
||||
open_ = false;
|
||||
}
|
||||
bool is_open() const
|
||||
{
|
||||
return open_;
|
||||
}
|
||||
// view: this frame's state. Returns the player's action; back closes the page itself.
|
||||
UpdateAction update(const UpdateView &view, const hui::InputFrame &input, float dt,
|
||||
hui::ui::Feedback &feedback);
|
||||
// Draws over everything on frame.overlay (opaque), with its quit confirm on top.
|
||||
void draw(Context &ctx, Frame &frame) const;
|
||||
std::vector<hui::ui::Hint> hints() const;
|
||||
// The step shown: never lower than one already shown, until the player starts again (Update now, Retry).
|
||||
int shown_step() const
|
||||
{
|
||||
return shown_step_;
|
||||
}
|
||||
|
||||
private:
|
||||
void take(const UpdateView &view);
|
||||
|
||||
bool open_ = false;
|
||||
UpdateView view_;
|
||||
int shown_step_ = -1;
|
||||
int focus_ = 0;
|
||||
std::vector<UpdateButton> buttons_;
|
||||
std::vector<float> button_focus_;
|
||||
ConfirmDialog quit_confirm_;
|
||||
ui::Ease in_;
|
||||
};
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,298 @@
|
||||
// Wings TV app - talking to the Wings update helper (see wings_helper_client.hpp).
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/wings_helper_client.hpp"
|
||||
|
||||
#include "orbit/json.hpp"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr const char *kRoutePrefix = "/wings-helper/v1/";
|
||||
|
||||
WingsHelperPhase phase_of(const std::string &s)
|
||||
{
|
||||
if (s == "idle")
|
||||
return WingsHelperPhase::idle;
|
||||
if (s == "downloading")
|
||||
return WingsHelperPhase::downloading;
|
||||
if (s == "verifying")
|
||||
return WingsHelperPhase::verifying;
|
||||
if (s == "downloaded")
|
||||
return WingsHelperPhase::downloaded;
|
||||
if (s == "placing")
|
||||
return WingsHelperPhase::placing;
|
||||
if (s == "placed")
|
||||
return WingsHelperPhase::placed;
|
||||
if (s == "error")
|
||||
return WingsHelperPhase::error;
|
||||
return WingsHelperPhase::unknown;
|
||||
}
|
||||
|
||||
WingsHelperTarget target_of(const std::string &s)
|
||||
{
|
||||
if (s == "none")
|
||||
return WingsHelperTarget::none;
|
||||
if (s == "wings")
|
||||
return WingsHelperTarget::wings;
|
||||
if (s == "manual")
|
||||
return WingsHelperTarget::manual;
|
||||
if (s == "folder")
|
||||
return WingsHelperTarget::folder;
|
||||
if (s == "blocked")
|
||||
return WingsHelperTarget::blocked;
|
||||
return WingsHelperTarget::unknown;
|
||||
}
|
||||
|
||||
std::uint64_t count_of(const json::Value &v)
|
||||
{
|
||||
const double n = v.as_number(0.0);
|
||||
return n > 0.0 ? static_cast<std::uint64_t>(n) : 0u;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool decode_wings_helper_status(const std::string &text, WingsHelperStatus *out)
|
||||
{
|
||||
json::Value v;
|
||||
if (!json::parse(text, &v) || !v.is_object())
|
||||
return false;
|
||||
WingsHelperStatus s;
|
||||
s.build = v.text("helper");
|
||||
s.api = static_cast<int>(v["api"].as_int(0));
|
||||
s.phase = phase_of(v.text("phase"));
|
||||
if (s.api <= 0 || s.phase == WingsHelperPhase::unknown)
|
||||
return false;
|
||||
s.version = v.text("version");
|
||||
s.received = count_of(v["received"]);
|
||||
s.size = count_of(v["size"]);
|
||||
s.error = v.text("error");
|
||||
s.target = target_of(v.text("target"));
|
||||
s.installed_version = v.text("installed_version");
|
||||
s.old_kept = v["old_kept"].as_bool(false);
|
||||
*out = std::move(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *wings_helper_phase_name(WingsHelperPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case WingsHelperPhase::idle:
|
||||
return "idle";
|
||||
case WingsHelperPhase::downloading:
|
||||
return "downloading";
|
||||
case WingsHelperPhase::verifying:
|
||||
return "verifying";
|
||||
case WingsHelperPhase::downloaded:
|
||||
return "downloaded";
|
||||
case WingsHelperPhase::placing:
|
||||
return "placing";
|
||||
case WingsHelperPhase::placed:
|
||||
return "placed";
|
||||
case WingsHelperPhase::error:
|
||||
return "error";
|
||||
case WingsHelperPhase::unknown:
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
std::string describe_wings_helper_status(const WingsHelperStatus &s)
|
||||
{
|
||||
static const char *const targets[] = {"unknown", "none", "wings", "manual", "folder", "blocked"};
|
||||
const std::size_t t = static_cast<std::size_t>(s.target);
|
||||
std::string line = std::string("phase=") + wings_helper_phase_name(s.phase) +
|
||||
" version=" + (s.version.empty() ? "-" : s.version) +
|
||||
" helper=" + (s.build.empty() ? "-" : s.build) + " api=" + std::to_string(s.api) +
|
||||
" target=" + (t < sizeof targets / sizeof targets[0] ? targets[t] : "unknown") +
|
||||
" installed=" + (s.installed_version.empty() ? "-" : s.installed_version) +
|
||||
" old_kept=" + (s.old_kept ? "yes" : "no") + " bytes=" + std::to_string(s.received) + "/" +
|
||||
std::to_string(s.size);
|
||||
if (!s.error.empty())
|
||||
line += " error=" + s.error;
|
||||
return line;
|
||||
}
|
||||
|
||||
std::string encode_wings_helper_install(const WingsHelperInstall &r)
|
||||
{
|
||||
return "{\"version\":" + json::quote(r.version) + ",\"url\":" + json::quote(r.url) +
|
||||
",\"sha256\":" + json::quote(r.sha256) + ",\"size\":" + std::to_string(r.size) +
|
||||
",\"replace\":" + (r.replace ? "true" : "false") + ",\"running\":" + json::quote(r.running) + "}";
|
||||
}
|
||||
|
||||
std::string encode_wings_helper_tidy(const std::string &running)
|
||||
{
|
||||
return "{\"running\":" + json::quote(running) + "}";
|
||||
}
|
||||
|
||||
http::Request wings_helper_request(const std::string &route, const std::string &body)
|
||||
{
|
||||
http::Request r;
|
||||
r.method = route == "status" ? "GET" : "POST";
|
||||
r.path = std::string(kRoutePrefix) + route;
|
||||
r.body = r.method == "GET" ? std::string() : (body.empty() ? std::string("{}") : body);
|
||||
r.headers = "X-Wings-Helper: 1\r\n";
|
||||
r.max_bytes = 16u << 10;
|
||||
r.timeout_ms = route == "status" ? kWingsHelperProbeMs : 2000;
|
||||
return r;
|
||||
}
|
||||
|
||||
WingsHelperClient::WingsHelperClient(http::Transport &transport, WingsHelperSender sender, std::string elf_path,
|
||||
std::string build)
|
||||
: transport_(transport), sender_(std::move(sender)), elf_path_(std::move(elf_path)), build_(std::move(build))
|
||||
{
|
||||
}
|
||||
|
||||
void WingsHelperClient::wait(int ms)
|
||||
{
|
||||
if (sleep_ms)
|
||||
sleep_ms(ms);
|
||||
else
|
||||
::usleep(static_cast<useconds_t>(ms) * 1000u);
|
||||
}
|
||||
|
||||
bool WingsHelperClient::current(const WingsHelperStatus &s) const
|
||||
{
|
||||
return s.api == kWingsHelperApi && (build_.empty() || s.build == build_);
|
||||
}
|
||||
|
||||
bool WingsHelperClient::status(WingsHelperStatus *out)
|
||||
{
|
||||
const http::Response r = transport_.send(wings_helper_request("status"));
|
||||
return r.transported() && r.status == 200 && decode_wings_helper_status(r.body, out);
|
||||
}
|
||||
|
||||
http::Response WingsHelperClient::post(const std::string &route, const std::string &body)
|
||||
{
|
||||
return transport_.send(wings_helper_request(route, body));
|
||||
}
|
||||
|
||||
bool WingsHelperClient::ensure_running(WingsHelperStatus *out, std::string *why)
|
||||
{
|
||||
stopped_stale.clear();
|
||||
WingsHelperStatus s;
|
||||
if (status(&s))
|
||||
{
|
||||
if (current(s))
|
||||
{
|
||||
*out = s;
|
||||
return true;
|
||||
}
|
||||
// A helper left by another Wings package: its code is not the one this Wings talks to, so it is replaced.
|
||||
// A download is cancelled first; renaming files is never interrupted, so a helper placing is waited for.
|
||||
stopped_stale = describe_wings_helper_status(s);
|
||||
if (s.phase == WingsHelperPhase::downloading || s.phase == WingsHelperPhase::verifying)
|
||||
cancel();
|
||||
bool gone = false;
|
||||
for (int i = 0; i < kWingsHelperStartPolls && !gone; ++i)
|
||||
{
|
||||
WingsHelperStatus again;
|
||||
if (!status(&again))
|
||||
{
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
if (again.phase != WingsHelperPhase::placing)
|
||||
post("stop", "{}");
|
||||
wait(kWingsHelperStartPollMs);
|
||||
gone = !status(&again);
|
||||
}
|
||||
if (!gone)
|
||||
{
|
||||
*why = "An older Wings helper is still finishing. Try again in a minute.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::string error;
|
||||
bool sent = false;
|
||||
for (int attempt = 0; attempt < kWingsHelperSendTries && !sent; ++attempt)
|
||||
{
|
||||
if (attempt > 0)
|
||||
wait(1000);
|
||||
error.clear();
|
||||
sent = sender_ && sender_(elf_path_, &error);
|
||||
}
|
||||
if (!sent)
|
||||
{
|
||||
*why = error.empty() ? std::string("The payload loader is not running.") : error;
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < kWingsHelperStartPolls; ++i)
|
||||
{
|
||||
wait(kWingsHelperStartPollMs);
|
||||
if (status(&s))
|
||||
{
|
||||
if (!current(s))
|
||||
{
|
||||
*why = "The Wings helper that answered is not the one this Wings ships.";
|
||||
return false;
|
||||
}
|
||||
*out = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
*why = "The Wings helper did not start.";
|
||||
return false;
|
||||
}
|
||||
|
||||
WingsHelperClient::Answer WingsHelperClient::download(const WingsHelperInstall &request, std::string *why)
|
||||
{
|
||||
return start("download", request, why);
|
||||
}
|
||||
|
||||
WingsHelperClient::Answer WingsHelperClient::install(const WingsHelperInstall &request, std::string *why)
|
||||
{
|
||||
return start("install", request, why);
|
||||
}
|
||||
|
||||
WingsHelperClient::Answer WingsHelperClient::start(const std::string &route, const WingsHelperInstall &request,
|
||||
std::string *why)
|
||||
{
|
||||
const http::Response r = post(route, encode_wings_helper_install(request));
|
||||
if (!r.transported())
|
||||
return Answer::unreachable;
|
||||
if (r.status == 202 || r.status == 200)
|
||||
return Answer::accepted;
|
||||
// Refusals: {"error","reason"}, reason one of
|
||||
// invalid|busy|newer|folder|blocked|manual|old_waiting|space|not_downloaded.
|
||||
json::Value v;
|
||||
const bool readable = json::parse(r.body, &v) && v.is_object();
|
||||
const std::string reason = readable ? v.text("reason") : std::string();
|
||||
if (r.status == 409 && reason == "busy")
|
||||
return Answer::busy;
|
||||
if (readable && !v.text("error").empty())
|
||||
*why = v.text("error");
|
||||
else
|
||||
*why = reason.empty() ? "HTTP " + std::to_string(r.status) : reason;
|
||||
if (r.status == 409 && reason == "not_downloaded")
|
||||
return Answer::not_downloaded;
|
||||
return r.status == 409 && reason == "old_waiting" ? Answer::old_waiting : Answer::refused;
|
||||
}
|
||||
|
||||
bool WingsHelperClient::cancel()
|
||||
{
|
||||
const http::Response r = post("cancel", "{}");
|
||||
return r.transported() && r.status >= 200 && r.status <= 299;
|
||||
}
|
||||
|
||||
bool WingsHelperClient::stop()
|
||||
{
|
||||
const http::Response r = post("stop", "{}");
|
||||
return r.transported() && r.status >= 200 && r.status <= 299;
|
||||
}
|
||||
|
||||
bool WingsHelperClient::tidy(const std::string &running)
|
||||
{
|
||||
const http::Response r = post("tidy", encode_wings_helper_tidy(running));
|
||||
return r.transported() && r.status >= 200 && r.status <= 299;
|
||||
}
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,153 @@
|
||||
// Wings TV app - talking to the Wings update helper, the small payload that downloads and places a newer
|
||||
// Wings package with the rights the TV app does not have.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The helper listens on 127.0.0.1 (kWingsHelperPort) and answers plain HTTP under /wings-helper/v1/:
|
||||
// GET status -> {"helper","api","phase","version","received","size","error","target",
|
||||
// "installed_version","old_kept"}
|
||||
// POST download {"version","url","sha256","size","replace","running"} -> 202 (started), 409 (busy), 400;
|
||||
// fetches and checks the package, then phase "downloaded" (kept across helper restarts)
|
||||
// POST install the same body -> 202, or 409 "not_downloaded"; hashes the downloaded file again and places
|
||||
// it. Never downloads.
|
||||
// POST cancel stops a download between chunks (the partial file goes)
|
||||
// POST stop ends the helper (refused with 409 only while it renames files)
|
||||
// POST tidy {"running"} removes the kept .old once the running version is the placed one or newer
|
||||
// Every request carries "X-Wings-Helper: 1"; POST bodies are JSON.
|
||||
// When nothing answers, the app sends the helper ELF it ships to the payload loader (the same socket send
|
||||
// Pegasus DL uses) and waits for it to come up. A helper from an older Wings is stopped and sent again.
|
||||
#pragma once
|
||||
|
||||
#include "orbit/http.hpp"
|
||||
#include "../../helper/version.h" // WINGS_HELPER_BUILD, shared with the helper
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
inline constexpr std::uint16_t kWingsHelperPort = 9178;
|
||||
inline constexpr int kWingsHelperApi = WINGS_HELPER_API; // 2: separate download and install routes
|
||||
// The "helper" field of the build this Wings ships. It comes from the helper's own header (app/helper/version.h,
|
||||
// WINGS_HELPER_BUILD), the one place it is set; bump it there whenever the helper changes.
|
||||
inline constexpr const char *kWingsHelperBuild = WINGS_HELPER_BUILD;
|
||||
inline constexpr const char *kWingsHelperElf = "/app0/assets/helper/wings-helper.elf";
|
||||
inline constexpr int kWingsHelperProbeMs = 500; // one status read
|
||||
inline constexpr int kWingsHelperStartPollMs = 250; // after a send: a status read this often...
|
||||
inline constexpr int kWingsHelperStartPolls = 40; // ...for ten seconds
|
||||
inline constexpr int kWingsHelperSendTries = 3; // the loader refusing a send: tries, one second apart
|
||||
|
||||
enum class WingsHelperPhase : std::uint8_t
|
||||
{
|
||||
unknown, // not readable
|
||||
idle,
|
||||
downloading,
|
||||
verifying,
|
||||
downloaded, // a checked download waits to be installed (version names it)
|
||||
placing,
|
||||
placed,
|
||||
error
|
||||
};
|
||||
|
||||
// Where the package would go, as the helper sees /data/homebrew.
|
||||
enum class WingsHelperTarget : std::uint8_t
|
||||
{
|
||||
unknown,
|
||||
none, // no package there
|
||||
wings, // the file Wings placed (its record matches)
|
||||
manual, // a file the player placed: replacing it needs the player's yes
|
||||
folder, // the unpacked folder form: never installed beside
|
||||
blocked // something else is in the way
|
||||
};
|
||||
|
||||
struct WingsHelperStatus
|
||||
{
|
||||
std::string build; // "helper"
|
||||
int api = 0;
|
||||
WingsHelperPhase phase = WingsHelperPhase::unknown;
|
||||
std::string version; // the release being (or last) installed
|
||||
std::uint64_t received = 0;
|
||||
std::uint64_t size = 0;
|
||||
std::string error;
|
||||
WingsHelperTarget target = WingsHelperTarget::unknown;
|
||||
std::string installed_version;
|
||||
bool old_kept = false;
|
||||
bool busy() const
|
||||
{
|
||||
return phase == WingsHelperPhase::downloading || phase == WingsHelperPhase::verifying ||
|
||||
phase == WingsHelperPhase::placing;
|
||||
}
|
||||
};
|
||||
|
||||
struct WingsHelperInstall
|
||||
{
|
||||
std::string version; // the release tag
|
||||
std::string url; // the official package address for that tag
|
||||
std::string sha256; // agreed: the API digest and the published .sha256
|
||||
std::uint64_t size = 0;
|
||||
bool replace = false; // the player said yes to replacing their own copy
|
||||
std::string running; // this app's contentVersion
|
||||
};
|
||||
|
||||
// ---- pure (tests) ----
|
||||
bool decode_wings_helper_status(const std::string &json, WingsHelperStatus *out);
|
||||
const char *wings_helper_phase_name(WingsHelperPhase phase);
|
||||
// One line for the log: phase, release, helper build, target, installed version, old file, bytes. Never secrets.
|
||||
std::string describe_wings_helper_status(const WingsHelperStatus &s);
|
||||
std::string encode_wings_helper_install(const WingsHelperInstall &request);
|
||||
std::string encode_wings_helper_tidy(const std::string &running);
|
||||
// The request for one route ("status" is a GET, the others POST `body`).
|
||||
http::Request wings_helper_request(const std::string &route, const std::string &body = std::string());
|
||||
|
||||
// Sends the ELF at the path to the payload loader. False with a sentence in *why.
|
||||
using WingsHelperSender = std::function<bool(const std::string &elf_path, std::string *why)>;
|
||||
|
||||
class WingsHelperClient
|
||||
{
|
||||
public:
|
||||
// `build` is the helper build this Wings ships ("" accepts any build of the same api).
|
||||
WingsHelperClient(http::Transport &transport, WingsHelperSender sender, std::string elf_path = kWingsHelperElf,
|
||||
std::string build = std::string());
|
||||
|
||||
// Waits (tests replace it so nothing really sleeps).
|
||||
std::function<void(int ms)> sleep_ms;
|
||||
|
||||
// One status read; false when nothing (readable) answers.
|
||||
bool status(WingsHelperStatus *out);
|
||||
// The shipped helper answering: probes, stops a helper of another build (cancelling its download first; one
|
||||
// that is renaming files is waited for), sends the ELF, waits for it.
|
||||
bool ensure_running(WingsHelperStatus *out, std::string *why);
|
||||
// Set by ensure_running when it stopped a helper of another build: that helper's status line (for the log).
|
||||
std::string stopped_stale;
|
||||
|
||||
enum class Answer : std::uint8_t
|
||||
{
|
||||
accepted, // 202: the helper started
|
||||
busy, // 409 "busy": a job is running
|
||||
old_waiting, // 409 "old_waiting": PPSA99178.ffpkg.old from an earlier update is still there
|
||||
refused, // 400 or another status; *why has the helper's words
|
||||
unreachable, // nothing answered
|
||||
not_downloaded // install: 409 "not_downloaded", no checked download of that release
|
||||
};
|
||||
// Starts the download (and check) of the release; the helper then reports "downloaded".
|
||||
Answer download(const WingsHelperInstall &request, std::string *why);
|
||||
// Places the downloaded release. Never downloads.
|
||||
Answer install(const WingsHelperInstall &request, std::string *why);
|
||||
bool cancel();
|
||||
bool stop();
|
||||
bool tidy(const std::string &running);
|
||||
|
||||
private:
|
||||
bool current(const WingsHelperStatus &s) const;
|
||||
Answer start(const std::string &route, const WingsHelperInstall &request, std::string *why);
|
||||
http::Response post(const std::string &route, const std::string &body);
|
||||
void wait(int ms);
|
||||
|
||||
http::Transport &transport_;
|
||||
WingsHelperSender sender_;
|
||||
std::string elf_path_;
|
||||
std::string build_;
|
||||
};
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,294 @@
|
||||
// Wings TV app - fetching and placing a newer Wings package, only on the player's confirmed presses.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The flow (Settings > Wings > Updates), each step doing what its name says:
|
||||
// Check : the release and its published checksum (it must agree with the API digest).
|
||||
// Download : (Select) make sure the Wings update helper runs (wings_helper_client.hpp); it downloads the
|
||||
// package to its own folder and checks size, SHA-256 and the image magic. Nothing is installed:
|
||||
// "downloaded and checked" waits for the player. A download the helper already holds is reused.
|
||||
// Install : (a confirmed press) the helper checks the downloaded file again and puts it in /data/homebrew,
|
||||
// keeping the old file as PPSA99178.ffpkg.old. No download. The app follows its status.
|
||||
// When the helper cannot be started, the exact manual steps are shown.
|
||||
// Restart : (a second confirmed press, in the screen) quits Wings; opening it again runs the new version.
|
||||
// Never: install beside a folder copy (/data/homebrew/PPSA99178), replace a copy the player placed
|
||||
// themselves without being asked, or install a release that is not newer than the running one.
|
||||
//
|
||||
// Everything here is free of the network and of the console: the checksum downloader and the helper's
|
||||
// transport are interfaces (tests use fakes; main.cpp wires the console).
|
||||
#pragma once
|
||||
|
||||
#include "orbit/stream_download.hpp"
|
||||
#include "orbit/thread.hpp"
|
||||
#include "orbit/wings_update.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
inline constexpr const char *kWingsHomebrewDir = "/data/homebrew";
|
||||
inline constexpr const char *kWingsFolderName = "PPSA99178"; // the unpacked-folder form of the package
|
||||
inline constexpr const char *kWingsNoteDir = "/download0/wings"; // placed.v1
|
||||
|
||||
// ---- pure rules ----
|
||||
enum class WingsTarget : std::uint8_t
|
||||
{
|
||||
unknown, // not looked at yet
|
||||
none, // no PPSA99178.ffpkg in /data/homebrew (Wings runs from somewhere else)
|
||||
wings_copy, // the file Wings placed itself last time (the helper's record matches)
|
||||
own_copy, // a file the player placed: replacing it needs a question first
|
||||
folder, // /data/homebrew/PPSA99178 is a folder: Wings never installs beside it
|
||||
blocked // something else is where the file should be
|
||||
};
|
||||
|
||||
// Download speed and time left from progress samples (seconds on a steady clock, bytes so far). Pure.
|
||||
inline constexpr double kWingsRateWindowS = 5.0; // speed over the last few seconds
|
||||
class WingsRate
|
||||
{
|
||||
public:
|
||||
void reset();
|
||||
void sample(double t_s, std::uint64_t bytes); // bytes going backwards (or time) starts again
|
||||
double bytes_per_s() const; // 0 while unknown (less than half a second of samples)
|
||||
std::int64_t eta_s(std::uint64_t total) const; // -1 while unknown; 0 when done
|
||||
|
||||
private:
|
||||
struct Sample
|
||||
{
|
||||
double t;
|
||||
std::uint64_t bytes;
|
||||
};
|
||||
std::vector<Sample> samples_;
|
||||
};
|
||||
|
||||
// ---- the state machine ----
|
||||
enum class WingsInstallPhase : std::uint8_t
|
||||
{
|
||||
idle, // nothing fetched
|
||||
checking, // reading the published checksum, starting the helper
|
||||
downloading, // the helper streams the package (got_bytes == total_bytes: it checks it)
|
||||
verified, // checksums agree and the helper is ready: waiting for the player's confirm
|
||||
placing, // the helper moves it into /data/homebrew
|
||||
placed, // done: close Wings and open it again
|
||||
manual, // the helper cannot do it: the steps are for the player
|
||||
blocked, // nothing was changed and nothing can be: say why (folder copy, other thing)
|
||||
failed // something went wrong; the old file is as it was (or restored); retry is allowed
|
||||
};
|
||||
|
||||
enum class WingsPlacedBy : std::uint8_t
|
||||
{
|
||||
none,
|
||||
direct, // the console's file system (earlier Wings)
|
||||
pegasus, // Pegasus DL's file API (earlier Wings)
|
||||
helper // the Wings update helper
|
||||
};
|
||||
|
||||
// The note Wings keeps after it placed a package (written whether or not the file can be looked at from Wings'
|
||||
// sandbox), so "Installed, restart to finish" survives until the new version runs. Removed once the version
|
||||
// that runs is that release or newer.
|
||||
struct WingsPlacedNote
|
||||
{
|
||||
std::string tag;
|
||||
WingsPlacedBy by = WingsPlacedBy::none;
|
||||
};
|
||||
std::string encode_wings_placed(const WingsPlacedNote ¬e);
|
||||
bool decode_wings_placed(const std::string &text, WingsPlacedNote *out);
|
||||
|
||||
struct WingsInstallStatus
|
||||
{
|
||||
WingsInstallPhase phase = WingsInstallPhase::idle;
|
||||
std::string tag; // the release this is about
|
||||
std::uint64_t got_bytes = 0; // downloading
|
||||
std::uint64_t total_bytes = 0;
|
||||
WingsTarget target = WingsTarget::unknown; // known once verified
|
||||
WingsPlacedBy placed_by = WingsPlacedBy::none;
|
||||
bool needs_replace_confirm = false; // verified and the target is a copy the player placed
|
||||
std::string message; // one translated sentence for the row (what happened / what is next)
|
||||
std::string package_url; // the official package address (the manual steps name it)
|
||||
bool install_tried = false; // the helper was asked to install (a failure is on the install step)
|
||||
bool old_kept = false; // placed: the old file is at PPSA99178.ffpkg.old
|
||||
double speed_bps = 0.0; // downloading: bytes per second, 0 while unknown
|
||||
std::int64_t eta_s = -1; // downloading: seconds left, -1 while unknown
|
||||
bool busy() const
|
||||
{
|
||||
return phase == WingsInstallPhase::checking || phase == WingsInstallPhase::downloading ||
|
||||
phase == WingsInstallPhase::placing;
|
||||
}
|
||||
};
|
||||
|
||||
class WingsHelperClient; // wings_helper_client.hpp
|
||||
struct WingsHelperStatus;
|
||||
|
||||
// The helper keeps a release's version without the tag's "v" (0.1.2 for v0.1.2): true when they name one release.
|
||||
bool wings_same_release(const std::string &helper_version, const std::string &tag);
|
||||
|
||||
// What a helper status means for installing release `tag`. Each has its own handling (wings_install.cpp).
|
||||
enum class WingsHelperSituation : std::uint8_t
|
||||
{
|
||||
ready, // free, nothing in the way: the install can be asked for
|
||||
following, // already downloading/placing this release (Wings was reopened): follow it
|
||||
other_job, // working on another release: wait for it (with its progress), then look again
|
||||
already_placed, // this release is the file the helper placed: nothing to install, restart to finish
|
||||
old_waiting, // PPSA99178.ffpkg.old from an earlier update is still there: tidy it first
|
||||
downloaded // this release is downloaded and checked: Install places it (no new download)
|
||||
};
|
||||
WingsHelperSituation wings_helper_situation(const WingsHelperStatus &s, const std::string &tag);
|
||||
|
||||
struct WingsInstallConfig
|
||||
{
|
||||
StreamingDownloader *downloader = nullptr; // the published .sha256 (a small text file)
|
||||
WingsHelperClient *helper = nullptr; // null: the manual steps only
|
||||
std::string note_dir = kWingsNoteDir; // where placed.v1 lives
|
||||
std::string running; // this app's contentVersion, to tidy the .old file after a successful update
|
||||
std::function<void(const std::string &)> log;
|
||||
std::function<double()> clock; // seconds on a steady clock, for the download speed (default: steady_clock)
|
||||
std::function<void(int ms)> sleep_ms; // between status reads (default: usleep)
|
||||
int poll_ms = 250; // status reads while the helper works
|
||||
int lost_polls = 40; // unanswered reads in a row before the helper counts as gone
|
||||
};
|
||||
|
||||
class WingsInstaller
|
||||
{
|
||||
public:
|
||||
WingsInstaller() = default;
|
||||
~WingsInstaller();
|
||||
WingsInstaller(const WingsInstaller &) = delete;
|
||||
WingsInstaller &operator=(const WingsInstaller &) = delete;
|
||||
|
||||
// Frame thread. Restores "installed" from placed.v1, or (once the new version runs) asks the helper to tidy
|
||||
// the .old file in the background.
|
||||
void attach(WingsInstallConfig config);
|
||||
bool attached() const;
|
||||
WingsInstallStatus status() const; // any thread; a copy
|
||||
|
||||
// Frame thread. Remembers the release the screen shows (nothing is fetched or staged in the app).
|
||||
void note_release(const WingsRelease &release);
|
||||
|
||||
// Each starts the one background job unless busy or not allowed; false says nothing started.
|
||||
bool start_download(const WingsRelease &release); // Download update
|
||||
bool install(bool replace_own_confirmed); // Install update (after the player's confirm)
|
||||
void cancel(); // asks the helper to stop a download between chunks
|
||||
void wait_idle();
|
||||
|
||||
// The same jobs, synchronously (tests; the threads call these).
|
||||
void run_download(const WingsRelease &release);
|
||||
void run_install(bool replace_own_confirmed);
|
||||
|
||||
void run_tidy();
|
||||
|
||||
private:
|
||||
void set_phase(WingsInstallPhase phase, std::string message);
|
||||
void say(const std::string &line) const;
|
||||
void follow(const WingsRelease &release, bool installing);
|
||||
bool ask_helper(const WingsRelease &release, bool installing, bool replace, WingsHelperStatus *hs);
|
||||
void record_placed(const std::string &tag);
|
||||
void log_helper(const char *when, const WingsHelperStatus &s) const;
|
||||
// Resolves the helper's situation for the release until it is `ready` (true, *hs current). False: the
|
||||
// phase and message are set (following, placed, failed, cancelled ...) and the job is over.
|
||||
bool settle_helper(const WingsRelease &release, WingsHelperStatus *hs);
|
||||
bool wait_for_helper(WingsHelperStatus *hs); // polls while another job runs, showing its progress
|
||||
void already_placed(const WingsRelease &release, const WingsHelperStatus &hs);
|
||||
void restore_placed_locked(bool *tidy);
|
||||
bool start(std::function<void()> job);
|
||||
|
||||
WingsInstallConfig config_;
|
||||
mutable Mutex mutex_;
|
||||
WingsInstallStatus status_;
|
||||
WingsRelease release_;
|
||||
std::string noted_tag_;
|
||||
std::atomic<bool> cancel_{false};
|
||||
bool attached_ = false;
|
||||
WingsRate rate_; // guarded by mutex_
|
||||
Thread thread_;
|
||||
};
|
||||
WingsInstaller &wings_installer();
|
||||
|
||||
// ---- words ----
|
||||
struct WingsInstallStep
|
||||
{
|
||||
std::string label;
|
||||
std::string detail;
|
||||
};
|
||||
// The manual steps (translated): what the `manual` and `blocked` outcomes show.
|
||||
std::vector<WingsInstallStep> wings_install_manual_steps(const WingsInstallStatus &status);
|
||||
// The text of the install confirm dialog (translated); wording depends on the target.
|
||||
std::string wings_install_confirm_text(const WingsInstallStatus &status, const std::string &version_text);
|
||||
// The right-hand value and detail of the row for the status (translated).
|
||||
struct WingsInstallText
|
||||
{
|
||||
std::string value;
|
||||
std::string detail;
|
||||
};
|
||||
WingsInstallText wings_install_text(const WingsInstallStatus &status);
|
||||
|
||||
// ---- the update flow as the screen draws it ----
|
||||
// One state for the whole flow (the checker's answer plus the installer's phase), drawn as four steps.
|
||||
enum class WingsFlowState : std::uint8_t
|
||||
{
|
||||
idle, // nothing newer known (or the Updates switch is off)
|
||||
available, // a newer release with a package Wings can fetch
|
||||
downloading, // reading the published checksum, then streaming the package
|
||||
checking, // the whole package is in: SHA-256 and image check
|
||||
ready_to_install, // verified, waiting for the player's install confirm
|
||||
installing, // placing it in /data/homebrew
|
||||
installed, // in place: close Wings and open it again to finish
|
||||
manual, // Wings will not place it: the exact steps are shown
|
||||
failed // something went wrong; reason says what, can_retry whether Retry applies
|
||||
};
|
||||
enum class WingsFlowStep : std::uint8_t
|
||||
{
|
||||
check, // find the update and its published checksum
|
||||
download, // the helper downloads and checks the package
|
||||
install, // the helper puts the downloaded package in place
|
||||
restart // close Wings and open it again
|
||||
};
|
||||
inline constexpr int kWingsFlowSteps = 4;
|
||||
|
||||
// Which buttons apply now (the screen shows only these).
|
||||
struct WingsFlowButtons
|
||||
{
|
||||
bool download = false; // available: start the download
|
||||
bool later = false; // available: snooze this release (WingsUpdate::snooze_latest)
|
||||
bool cancel = false; // downloading: stop between chunks
|
||||
bool install = false; // ready_to_install: opens the install confirm (wings_install_confirm_text)
|
||||
bool quit = false; // installed: the quit confirm; Wings never restarts the chain itself
|
||||
bool steps = false; // manual / failed: show the manual steps
|
||||
bool retry = false; // failed and can_retry: download again
|
||||
};
|
||||
|
||||
struct WingsFlowSnapshot
|
||||
{
|
||||
WingsFlowState state = WingsFlowState::idle;
|
||||
WingsFlowStep step = WingsFlowStep::download; // the current step
|
||||
int step_index = 0; // 0..3, same as `step`
|
||||
bool step_done = false; // installed: every step up to restart is done
|
||||
std::string tag; // the release this is about
|
||||
std::string version_text; // "0.1.2"
|
||||
std::string running_text; // "0.1.1"
|
||||
std::string title; // one short line (translated)
|
||||
std::string text; // what is happening / what is next (translated)
|
||||
float progress = 0.0f; // 0..1; negative: working, amount unknown
|
||||
std::uint64_t bytes = 0;
|
||||
std::uint64_t total = 0;
|
||||
double speed_bps = 0.0; // 0 while unknown
|
||||
std::int64_t eta_s = -1; // -1 while unknown
|
||||
std::string speed_text; // "2.4 MB/s", "" while unknown
|
||||
std::string eta_text; // "About 12 s left", "" while unknown
|
||||
std::string reason; // failed: why
|
||||
bool can_retry = false;
|
||||
bool needs_replace_confirm = false; // ready_to_install: the confirm must name the player's copy
|
||||
std::vector<WingsInstallStep> manual_steps; // manual (and failed, for the Steps button)
|
||||
WingsFlowButtons buttons;
|
||||
};
|
||||
// Pure: the same inputs always give the same snapshot. A placed package stays `installed` whatever the
|
||||
// checker says, until Wings runs that version (WingsInstaller::attach then clears it).
|
||||
WingsFlowSnapshot wings_flow_snapshot(const WingsUpdateStatus &update, const WingsInstallStatus &install);
|
||||
// "2.4 MB/s" (translated), "" for 0.
|
||||
std::string wings_speed_text(double bytes_per_s);
|
||||
// "About 12 s left" / "About 3 min left" (translated), "" for unknown.
|
||||
std::string wings_eta_text(std::int64_t seconds);
|
||||
|
||||
} // namespace orbit
|
||||
@@ -7,6 +7,8 @@
|
||||
#include "core/version.hpp"
|
||||
#include "orbit/i18n.hpp"
|
||||
#include "orbit/json.hpp"
|
||||
#include "orbit/pegasus_manager.hpp" // parse_sha256_digest
|
||||
#include "orbit/stream_download.hpp" // stream_size_ok, stream_redirect_allowed
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
@@ -32,11 +34,123 @@ std::string tag_text(std::string_view tag)
|
||||
return std::to_string(v[0]) + "." + std::to_string(v[1]) + "." + std::to_string(v[2]);
|
||||
}
|
||||
|
||||
// The tag becomes part of an address: "v", digits and dots only.
|
||||
bool tag_safe(std::string_view tag)
|
||||
{
|
||||
int v[3];
|
||||
if (tag.size() > 24 || !hui::parse_release_tag(tag, v))
|
||||
return false;
|
||||
for (const char c : tag)
|
||||
if (!((c >= '0' && c <= '9') || c == '.' || c == 'v'))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
constexpr const char *kReleaseDownloadBase = "https://github.com/LaRazaDelSol/wings-app/releases/download/";
|
||||
|
||||
bool hex64(std::string_view text)
|
||||
{
|
||||
if (text.size() != 64)
|
||||
return false;
|
||||
for (const char c : text)
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool blank(char c)
|
||||
{
|
||||
return c == ' ' || c == '\t';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---- pure rules ----
|
||||
|
||||
bool parse_wings_latest(std::string_view text, std::string *tag, std::string *error)
|
||||
std::string wings_asset_url(std::string_view tag)
|
||||
{
|
||||
return tag_safe(tag) ? std::string(kReleaseDownloadBase) + std::string(tag) + "/" + kWingsAssetName
|
||||
: std::string();
|
||||
}
|
||||
|
||||
std::string wings_digest_url(std::string_view tag)
|
||||
{
|
||||
return tag_safe(tag) ? std::string(kReleaseDownloadBase) + std::string(tag) + "/" + kWingsDigestAssetName
|
||||
: std::string();
|
||||
}
|
||||
|
||||
bool wings_url_allowed(std::string_view url, bool redirect, std::string_view tag)
|
||||
{
|
||||
if (redirect)
|
||||
return stream_redirect_allowed(std::string(url));
|
||||
return !url.empty() && (url == wings_asset_url(tag) || url == wings_digest_url(tag));
|
||||
}
|
||||
|
||||
bool parse_wings_checksum_text(std::string_view text, std::string *hex)
|
||||
{
|
||||
// First non-empty line: 64 hex, then nothing, or white space and the file name (a leading '*' is the
|
||||
// binary-mode mark of sha256sum). Anything else (another file name, extra words) is refused.
|
||||
std::size_t pos = 0;
|
||||
while (pos < text.size() && (text[pos] == '\n' || text[pos] == '\r' || blank(text[pos])))
|
||||
++pos;
|
||||
std::size_t end = text.find_first_of("\r\n", pos);
|
||||
if (end == std::string_view::npos)
|
||||
end = text.size();
|
||||
std::string_view line = text.substr(pos, end - pos);
|
||||
while (!line.empty() && blank(line.back()))
|
||||
line.remove_suffix(1);
|
||||
if (line.size() < 64)
|
||||
return false;
|
||||
std::string lower;
|
||||
for (std::size_t i = 0; i < 64; ++i)
|
||||
{
|
||||
char c = line[i];
|
||||
if (c >= 'A' && c <= 'F')
|
||||
c = static_cast<char>(c - 'A' + 'a');
|
||||
lower.push_back(c);
|
||||
}
|
||||
if (!hex64(lower))
|
||||
return false;
|
||||
std::string_view rest = line.substr(64);
|
||||
if (!rest.empty())
|
||||
{
|
||||
if (!blank(rest[0]))
|
||||
return false;
|
||||
while (!rest.empty() && blank(rest[0]))
|
||||
rest.remove_prefix(1);
|
||||
if (!rest.empty() && rest[0] == '*')
|
||||
rest.remove_prefix(1);
|
||||
if (rest != kWingsAssetName)
|
||||
return false;
|
||||
}
|
||||
if (hex != nullptr)
|
||||
*hex = lower;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string wings_asset_issue_text(WingsAssetIssue issue)
|
||||
{
|
||||
switch (issue)
|
||||
{
|
||||
case WingsAssetIssue::none:
|
||||
return std::string();
|
||||
case WingsAssetIssue::missing:
|
||||
return tr("This release has no package file");
|
||||
case WingsAssetIssue::bad_size:
|
||||
return tr("The package size in this release is not one Wings will accept");
|
||||
case WingsAssetIssue::no_digest:
|
||||
return tr("This release lists no SHA-256 for the package, so Wings will not fetch it");
|
||||
case WingsAssetIssue::bad_url:
|
||||
return tr("The package address in this release is not the official one");
|
||||
case WingsAssetIssue::no_checksum:
|
||||
return tr("This release has no published checksum file, so Wings will not fetch it");
|
||||
case WingsAssetIssue::digest_differs:
|
||||
return tr("The two published checksums for this release disagree, so nothing was fetched");
|
||||
}
|
||||
return std::string();
|
||||
}
|
||||
|
||||
bool parse_wings_release(std::string_view text, WingsRelease *out, std::string *error)
|
||||
{
|
||||
const auto fail = [&](const char *why)
|
||||
{
|
||||
@@ -55,8 +169,61 @@ bool parse_wings_latest(std::string_view text, std::string *tag, std::string *er
|
||||
int v[3];
|
||||
if (!hui::parse_release_tag(name, v))
|
||||
return fail("the release tag is not a version");
|
||||
if (out == nullptr)
|
||||
return true;
|
||||
WingsRelease r;
|
||||
r.tag = name;
|
||||
r.asset.url = wings_asset_url(name);
|
||||
r.asset.digest_url = wings_digest_url(name);
|
||||
bool digest_asset_ok = false;
|
||||
for (const json::Value &asset : root["assets"].items())
|
||||
{
|
||||
if (!asset.is_object())
|
||||
continue;
|
||||
const std::string asset_name = asset.text("name");
|
||||
if (asset_name == kWingsDigestAssetName)
|
||||
{
|
||||
digest_asset_ok = asset.text("browser_download_url") == r.asset.digest_url;
|
||||
continue;
|
||||
}
|
||||
if (asset_name != kWingsAssetName)
|
||||
continue;
|
||||
r.asset.issue = WingsAssetIssue::bad_size;
|
||||
const double size = asset.number("size");
|
||||
if (size < 1.0 || size > static_cast<double>(kStreamMaxImage) ||
|
||||
!stream_size_ok(static_cast<std::uint64_t>(size)))
|
||||
continue;
|
||||
r.asset.size = static_cast<std::uint64_t>(size);
|
||||
r.asset.issue = WingsAssetIssue::no_digest;
|
||||
std::string hex;
|
||||
if (!parse_sha256_digest(asset.text("digest"), &hex))
|
||||
continue;
|
||||
r.asset.sha256 = hex;
|
||||
r.asset.issue = WingsAssetIssue::bad_url;
|
||||
if (asset.text("browser_download_url") != r.asset.url)
|
||||
continue;
|
||||
r.asset.issue = WingsAssetIssue::none;
|
||||
}
|
||||
if (r.asset.issue == WingsAssetIssue::none && !digest_asset_ok)
|
||||
r.asset.issue = WingsAssetIssue::no_checksum;
|
||||
r.asset.downloadable = r.asset.issue == WingsAssetIssue::none;
|
||||
if (!r.asset.downloadable)
|
||||
{
|
||||
// Nothing half-trusted is kept.
|
||||
r.asset.size = 0;
|
||||
r.asset.sha256.clear();
|
||||
}
|
||||
*out = std::move(r);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parse_wings_latest(std::string_view text, std::string *tag, std::string *error)
|
||||
{
|
||||
WingsRelease r;
|
||||
if (!parse_wings_release(text, &r, error))
|
||||
return false;
|
||||
if (tag != nullptr)
|
||||
*tag = name;
|
||||
*tag = r.tag;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -84,10 +251,25 @@ std::string encode_wings_cache(std::int64_t checked_at, std::string_view tag)
|
||||
return "checked=" + std::to_string(checked_at) + "\ntag=" + std::string(tag) + "\n";
|
||||
}
|
||||
|
||||
std::string encode_wings_cache(std::int64_t checked_at, std::string_view tag, const WingsAsset &asset)
|
||||
{
|
||||
std::string out = encode_wings_cache(checked_at, tag);
|
||||
if (asset.downloadable)
|
||||
out += "size=" + std::to_string(asset.size) + "\nsha256=" + asset.sha256 + "\n";
|
||||
return out;
|
||||
}
|
||||
|
||||
bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std::string *tag)
|
||||
{
|
||||
return decode_wings_cache(payload, checked_at, tag, nullptr);
|
||||
}
|
||||
|
||||
bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std::string *tag, WingsAsset *asset)
|
||||
{
|
||||
*checked_at = 0;
|
||||
tag->clear();
|
||||
std::uint64_t size = 0;
|
||||
std::string digest;
|
||||
bool have_time = false;
|
||||
std::size_t pos = 0;
|
||||
while (pos < payload.size())
|
||||
@@ -111,10 +293,99 @@ bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std:
|
||||
if (hui::parse_release_tag(line.substr(4), v))
|
||||
*tag = std::string(line.substr(4));
|
||||
}
|
||||
else if (line.substr(0, 5) == "size=")
|
||||
{
|
||||
const std::string num(line.substr(5));
|
||||
if (!num.empty() && num.size() <= 12 && num.find_first_not_of("0123456789") == std::string::npos)
|
||||
size = static_cast<std::uint64_t>(std::atoll(num.c_str()));
|
||||
}
|
||||
else if (line.substr(0, 7) == "sha256=")
|
||||
{
|
||||
if (hex64(line.substr(7)))
|
||||
digest = std::string(line.substr(7));
|
||||
}
|
||||
}
|
||||
if (asset != nullptr)
|
||||
{
|
||||
*asset = WingsAsset();
|
||||
// The addresses are rebuilt from the tag: nothing read from disk is used as an address.
|
||||
if (!tag->empty() && stream_size_ok(size) && !digest.empty())
|
||||
{
|
||||
asset->downloadable = true;
|
||||
asset->issue = WingsAssetIssue::none;
|
||||
asset->size = size;
|
||||
asset->sha256 = digest;
|
||||
asset->url = wings_asset_url(*tag);
|
||||
asset->digest_url = wings_digest_url(*tag);
|
||||
}
|
||||
}
|
||||
return have_time;
|
||||
}
|
||||
|
||||
// ---- the startup prompt ----
|
||||
|
||||
bool wings_should_prompt(std::int64_t now, std::string_view latest_tag, const WingsSnooze &snooze)
|
||||
{
|
||||
if (latest_tag.empty())
|
||||
return false;
|
||||
if (snooze.tag != latest_tag)
|
||||
return true; // never snoozed, or a different (newer) release
|
||||
if (now < snooze.until && snooze.until - now <= kWingsSnoozeS)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
WingsSnooze wings_snooze_for(std::string_view tag, std::int64_t now)
|
||||
{
|
||||
WingsSnooze s;
|
||||
s.tag = std::string(tag);
|
||||
s.until = now + kWingsSnoozeS;
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string encode_wings_snooze(const WingsSnooze &snooze)
|
||||
{
|
||||
return "snooze_tag=" + snooze.tag + "\nsnooze_until=" + std::to_string(snooze.until) + "\n";
|
||||
}
|
||||
|
||||
bool decode_wings_snooze(std::string_view payload, WingsSnooze *out)
|
||||
{
|
||||
*out = WingsSnooze();
|
||||
bool have_tag = false;
|
||||
bool have_until = false;
|
||||
std::size_t pos = 0;
|
||||
while (pos < payload.size())
|
||||
{
|
||||
std::size_t nl = payload.find('\n', pos);
|
||||
if (nl == std::string_view::npos)
|
||||
nl = payload.size();
|
||||
const std::string_view line = payload.substr(pos, nl - pos);
|
||||
pos = nl + 1;
|
||||
if (line.substr(0, 11) == "snooze_tag=")
|
||||
{
|
||||
int v[3];
|
||||
if (!hui::parse_release_tag(line.substr(11), v))
|
||||
return false;
|
||||
out->tag = std::string(line.substr(11));
|
||||
have_tag = true;
|
||||
}
|
||||
else if (line.substr(0, 13) == "snooze_until=")
|
||||
{
|
||||
const std::string num(line.substr(13));
|
||||
if (num.empty() || num.size() > 12 || num.find_first_not_of("0123456789") != std::string::npos)
|
||||
return false;
|
||||
out->until = std::atoll(num.c_str());
|
||||
have_until = true;
|
||||
}
|
||||
}
|
||||
if (!have_tag || !have_until)
|
||||
{
|
||||
*out = WingsSnooze();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- the service ----
|
||||
|
||||
WingsUpdate &wings_update()
|
||||
@@ -163,17 +434,80 @@ void WingsUpdate::attach(WingsUpdateConfig config)
|
||||
const hui::save::Decoded d = hui::save::decode(hui::save::Kind::stats, data);
|
||||
std::int64_t at = 0;
|
||||
std::string tag;
|
||||
if (d.ok && d.version == 1 && decode_wings_cache(d.payload, &at, &tag))
|
||||
WingsAsset asset;
|
||||
if (d.ok && d.version == 1 && decode_wings_cache(d.payload, &at, &tag, &asset))
|
||||
{
|
||||
status_.checked_at = at;
|
||||
status_.latest_tag = tag;
|
||||
status_.asset = asset;
|
||||
status_.have_latest = !tag.empty();
|
||||
break;
|
||||
}
|
||||
}
|
||||
prompted_tag_.clear();
|
||||
for (const std::string &path : snooze_paths())
|
||||
{
|
||||
std::string data;
|
||||
if (!hui::save::read_file(path, &data, 1024))
|
||||
continue;
|
||||
const hui::save::Decoded d = hui::save::decode(hui::save::Kind::stats, data);
|
||||
WingsSnooze snooze;
|
||||
if (d.ok && d.version == 1 && decode_wings_snooze(d.payload, &snooze))
|
||||
{
|
||||
status_.snooze = snooze;
|
||||
break;
|
||||
}
|
||||
}
|
||||
refresh_advice_locked();
|
||||
}
|
||||
|
||||
std::vector<std::string> WingsUpdate::snooze_paths() const
|
||||
{
|
||||
if (!config_.snooze_paths.empty())
|
||||
return config_.snooze_paths;
|
||||
std::vector<std::string> out;
|
||||
for (const std::string &path : config_.cache_paths)
|
||||
{
|
||||
const std::size_t slash = path.rfind('/');
|
||||
out.push_back((slash == std::string::npos ? std::string() : path.substr(0, slash + 1)) +
|
||||
"wings_update_snooze.v1");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool WingsUpdate::take_prompt()
|
||||
{
|
||||
Lock l(mutex_);
|
||||
if (!status_.attached || !status_.enabled || status_.advice != WingsAdvice::newer ||
|
||||
prompted_tag_ == status_.latest_tag || !wings_should_prompt(config_.now(), status_.latest_tag, status_.snooze))
|
||||
return false;
|
||||
prompted_tag_ = status_.latest_tag;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WingsUpdate::snooze_latest()
|
||||
{
|
||||
std::string data;
|
||||
std::vector<std::string> paths;
|
||||
{
|
||||
Lock l(mutex_);
|
||||
if (!status_.attached || status_.latest_tag.empty())
|
||||
return;
|
||||
status_.snooze = wings_snooze_for(status_.latest_tag, config_.now());
|
||||
prompted_tag_ = status_.latest_tag;
|
||||
data = hui::save::encode(hui::save::Kind::stats, 1, encode_wings_snooze(status_.snooze));
|
||||
paths = snooze_paths();
|
||||
}
|
||||
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())
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void WingsUpdate::set_enabled(bool on)
|
||||
{
|
||||
Lock l(mutex_);
|
||||
@@ -251,6 +585,7 @@ void WingsUpdate::run_check()
|
||||
else
|
||||
r.error = "no network client";
|
||||
std::string tag;
|
||||
WingsAsset asset;
|
||||
std::string error;
|
||||
if (!r.error.empty())
|
||||
error = r.error;
|
||||
@@ -263,8 +598,14 @@ void WingsUpdate::run_check()
|
||||
else
|
||||
{
|
||||
std::string why;
|
||||
if (!parse_wings_latest(r.body, &tag, &why))
|
||||
WingsRelease release;
|
||||
if (!parse_wings_release(r.body, &release, &why))
|
||||
error = tr("The release information could not be read");
|
||||
else
|
||||
{
|
||||
tag = release.tag;
|
||||
asset = release.asset;
|
||||
}
|
||||
}
|
||||
std::string data;
|
||||
{
|
||||
@@ -275,9 +616,10 @@ void WingsUpdate::run_check()
|
||||
return;
|
||||
status_.checked_at = config_.now();
|
||||
status_.latest_tag = tag;
|
||||
status_.asset = asset;
|
||||
status_.have_latest = true;
|
||||
refresh_advice_locked();
|
||||
data = hui::save::encode(hui::save::Kind::stats, 1, encode_wings_cache(status_.checked_at, tag));
|
||||
data = hui::save::encode(hui::save::Kind::stats, 1, encode_wings_cache(status_.checked_at, tag, asset));
|
||||
}
|
||||
// The saved answer is written outside the lock (the frame thread reads the status every frame).
|
||||
for (const std::string &path : config_.cache_paths)
|
||||
@@ -311,8 +653,11 @@ WingsUpdateText wings_update_text(const WingsUpdateStatus &s)
|
||||
if (s.advice == WingsAdvice::newer)
|
||||
{
|
||||
out.value = tr("{version} available", {{"version", s.latest_text}});
|
||||
out.detail = tr("You have {mine}. Select shows how to install it. Wings never installs itself.",
|
||||
{{"mine", s.running_text}});
|
||||
out.detail = s.asset.downloadable
|
||||
? tr("You have {mine}. Download update below fetches and installs it; Select here shows the steps by hand.",
|
||||
{{"mine", s.running_text}})
|
||||
: tr("You have {mine}. Select shows how to install it by hand.",
|
||||
{{"mine", s.running_text}});
|
||||
return out;
|
||||
}
|
||||
if (!s.error.empty())
|
||||
@@ -351,20 +696,20 @@ WingsUpdateGuide wings_update_guide(const WingsUpdateStatus &s)
|
||||
{
|
||||
WingsUpdateGuide g;
|
||||
g.title = tr("Update to Wings {latest}", {{"latest", s.latest_text}});
|
||||
g.subtitle = tr("You have {mine}. Wings never installs itself.", {{"mine", s.running_text}});
|
||||
g.subtitle = tr("You have {mine}. These are the steps to do it by hand.", {{"mine", s.running_text}});
|
||||
g.steps = {
|
||||
{tr("Download PPSA99178.ffpkg and check its SHA-256"),
|
||||
tr("{where}, on a PC. The hash is on that page", {{"where", kWingsReleasePage}})},
|
||||
{tr("Replace the old file"), tr("Copy it to /data/homebrew/ on this console, over the old one")},
|
||||
{tr("Remove the staged copies"), tr("Delete the copies ShadowMountPlus made of the old package")},
|
||||
{tr("Restart the jailbreak chain"), tr("So the new package is mounted. Then open Wings again")},
|
||||
{tr("Close Wings and open it again"), tr("So the new version runs")},
|
||||
};
|
||||
return g;
|
||||
}
|
||||
|
||||
std::string wings_update_toast(const WingsUpdateStatus &s)
|
||||
{
|
||||
return tr("Wings {latest} is available. Settings > Wings > Updates has the steps.",
|
||||
return tr("Wings {latest} is available. Install it from Settings > Wings > Updates.",
|
||||
{{"latest", s.latest_text}});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Wings TV app - "Is there a newer Wings?": a read-only look at the project's latest release.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Wings never updates itself. A mounted package cannot be replaced while it runs, and ShadowMount only
|
||||
// registers a new package after the old one is gone, so the player does that by hand (docs/guides/updating.md).
|
||||
// This only tells them there is something to fetch:
|
||||
// Looking is read-only. Fetching and placing the package is wings_install.hpp, and only on the player's
|
||||
// confirmed presses (a mounted package cannot be replaced while it runs, so the last step is by hand too:
|
||||
// docs/guides/updating.md). This tells them there is something to fetch:
|
||||
// * once the network is ready, and then at most every 6 hours, it asks GitHub for the latest release of
|
||||
// the Wings repository (one small JSON, no identifier sent beyond the fixed User-Agent) and remembers the tag;
|
||||
// * the answer is compared with this app's own contentVersion (00.001.001 is release v0.1.1);
|
||||
@@ -35,7 +35,52 @@ inline constexpr std::size_t kWingsReleaseCap = 256u << 10;
|
||||
inline constexpr int kWingsCheckTtlS = 6 * 3600;
|
||||
inline constexpr int kWingsRetryS = 600; // a failed attempt is not repeated sooner
|
||||
|
||||
inline constexpr const char *kWingsAssetName = "PPSA99178.ffpkg";
|
||||
inline constexpr const char *kWingsDigestAssetName = "PPSA99178.ffpkg.sha256";
|
||||
|
||||
// Why a release has no package Wings will fetch.
|
||||
enum class WingsAssetIssue : std::uint8_t
|
||||
{
|
||||
none,
|
||||
missing, // no PPSA99178.ffpkg in the release
|
||||
bad_size, // size absent, zero, too small to be an image or larger than the limit
|
||||
no_digest, // the API gives no readable sha256 digest
|
||||
bad_url, // the download address is not the official one for this tag
|
||||
no_checksum, // no PPSA99178.ffpkg.sha256 asset (or its address is not the official one)
|
||||
digest_differs // the API digest and the published .sha256 text disagree (set after reading the text)
|
||||
};
|
||||
|
||||
// The package of one release: everything the installer needs, taken from the release JSON only.
|
||||
struct WingsAsset
|
||||
{
|
||||
bool downloadable = false;
|
||||
WingsAssetIssue issue = WingsAssetIssue::missing;
|
||||
std::uint64_t size = 0;
|
||||
std::string sha256; // 64 lower-case hex, from the API digest
|
||||
std::string url; // the official address for this tag (never taken from the reply)
|
||||
std::string digest_url; // the .sha256 asset's official address
|
||||
};
|
||||
struct WingsRelease
|
||||
{
|
||||
std::string tag;
|
||||
WingsAsset asset;
|
||||
};
|
||||
|
||||
// ---- pure rules ----
|
||||
// The latest release: the tag plus its package asset (size, digest, official addresses). Same refusals as
|
||||
// parse_wings_latest; an asset problem does not refuse the release, it leaves asset.downloadable false.
|
||||
bool parse_wings_release(std::string_view json, WingsRelease *out, std::string *error);
|
||||
// "https://github.com/LaRazaDelSol/wings-app/releases/download/<tag>/<asset>", "" for a bad tag.
|
||||
std::string wings_asset_url(std::string_view tag);
|
||||
std::string wings_digest_url(std::string_view tag);
|
||||
// The first address must be exactly this tag's; a redirect only to GitHub's own hosts.
|
||||
bool wings_url_allowed(std::string_view url, bool redirect, std::string_view tag);
|
||||
// The .sha256 text ("<hex> PPSA99178.ffpkg" on one line, or just "<hex>"): the hex when the line names our
|
||||
// file or no file.
|
||||
bool parse_wings_checksum_text(std::string_view text, std::string *hex);
|
||||
// Words for an issue (translated).
|
||||
std::string wings_asset_issue_text(WingsAssetIssue issue);
|
||||
|
||||
// The tag of the latest published release from the "releases/latest" reply. False (with *error) when the
|
||||
// reply is not a release object, is a draft or a pre-release, or its tag is not v<major>.<minor>.<patch>.
|
||||
bool parse_wings_latest(std::string_view json, std::string *tag, std::string *error);
|
||||
@@ -57,7 +102,30 @@ WingsAdvice wings_advice(std::string_view running, std::string_view latest_tag);
|
||||
|
||||
// The saved answer (payload text, inside the checked save container on disk).
|
||||
std::string encode_wings_cache(std::int64_t checked_at, std::string_view tag);
|
||||
std::string encode_wings_cache(std::int64_t checked_at, std::string_view tag, const WingsAsset &asset);
|
||||
bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std::string *tag);
|
||||
// Also the package size and digest of that release (asset stays "missing" when the copy has none).
|
||||
bool decode_wings_cache(std::string_view payload, std::int64_t *checked_at, std::string *tag, WingsAsset *asset);
|
||||
|
||||
// ---- the startup prompt ----
|
||||
// A newer release is offered in a dialog once per start; "Later" leaves that version alone for this long,
|
||||
// then it is offered again. A newer tag than the snoozed one is offered at once.
|
||||
inline constexpr std::int64_t kWingsSnoozeS = 3 * 24 * 3600;
|
||||
|
||||
// The saved "Later": which release, and until when (unix seconds).
|
||||
struct WingsSnooze
|
||||
{
|
||||
std::string tag;
|
||||
std::int64_t until = 0;
|
||||
};
|
||||
// True when the dialog may be shown for latest_tag: a tag is known, and it is not the snoozed one or the
|
||||
// snooze has run out. A snooze reaching further than kWingsSnoozeS ahead (the clock went backwards) is over.
|
||||
bool wings_should_prompt(std::int64_t now, std::string_view latest_tag, const WingsSnooze &snooze);
|
||||
// "Later" pressed at `now` for `tag`.
|
||||
WingsSnooze wings_snooze_for(std::string_view tag, std::int64_t now);
|
||||
// The saved record (payload text, inside the checked save container on disk).
|
||||
std::string encode_wings_snooze(const WingsSnooze &snooze);
|
||||
bool decode_wings_snooze(std::string_view payload, WingsSnooze *out);
|
||||
|
||||
// ---- the service ----
|
||||
struct WingsUpdateConfig
|
||||
@@ -65,6 +133,8 @@ struct WingsUpdateConfig
|
||||
DocFetcher *docs = nullptr; // the same fetcher as popularity
|
||||
std::function<bool(std::string *)> ready; // the network gate; false with a sentence
|
||||
std::vector<std::string> cache_paths; // the saved answer; first writable wins
|
||||
// The saved "Later"; first writable wins. Empty: "wings_update_snooze.v1" beside each cache path.
|
||||
std::vector<std::string> snooze_paths;
|
||||
std::string running; // this app's contentVersion
|
||||
std::function<std::int64_t()> now; // unix seconds (default: the system clock)
|
||||
};
|
||||
@@ -77,10 +147,12 @@ struct WingsUpdateStatus
|
||||
std::int64_t checked_at = 0; // unix time of the last successful check, 0 never
|
||||
bool have_latest = false;
|
||||
std::string latest_tag; // "v0.1.2"
|
||||
WingsAsset asset; // the package of latest_tag
|
||||
std::string latest_text; // "0.1.2"
|
||||
std::string running_text; // "0.1.1"
|
||||
WingsAdvice advice = WingsAdvice::not_checked;
|
||||
std::string error; // why the last check failed, "" when it did not
|
||||
WingsSnooze snooze; // the saved "Later" (tag "" when none)
|
||||
};
|
||||
|
||||
class WingsUpdate
|
||||
@@ -101,15 +173,24 @@ class WingsUpdate
|
||||
bool check_now(); // the player pressed Check (works while the switch is off too: it was asked for)
|
||||
void wait_idle(); // tests and shutdown
|
||||
|
||||
// Frame thread. True once per start per release when the startup dialog should show now: attached, the
|
||||
// switch on, a newer release known and wings_should_prompt agrees. Marks it shown. The screen asks only
|
||||
// when the install flow is at "available" (wings_flow_snapshot), and asks this last.
|
||||
bool take_prompt();
|
||||
// Frame thread. "Later": the latest release is not offered again for kWingsSnoozeS; saved on disk.
|
||||
void snooze_latest();
|
||||
|
||||
private:
|
||||
void run_check();
|
||||
bool start_job();
|
||||
bool gate(std::string *why) const;
|
||||
void refresh_advice_locked();
|
||||
std::vector<std::string> snooze_paths() const;
|
||||
|
||||
WingsUpdateConfig config_;
|
||||
mutable Mutex mutex_;
|
||||
WingsUpdateStatus status_;
|
||||
std::string prompted_tag_; // the release the dialog was shown for in this start
|
||||
std::int64_t attempt_at_ = 0; // the last background check, so a failure is not retried every frame
|
||||
Thread thread_;
|
||||
};
|
||||
@@ -123,7 +204,8 @@ struct WingsUpdateText
|
||||
};
|
||||
WingsUpdateText wings_update_text(const WingsUpdateStatus &status);
|
||||
// How to install a newer release by hand (translated): a title, a line under it and four steps. The dialog is
|
||||
// a scrolling list (a confirm dialog would cut the text off).
|
||||
// a scrolling list (a confirm dialog would cut the text off). It is also the manual fallback when the
|
||||
// in-app install cannot place the file.
|
||||
struct WingsUpdateStep
|
||||
{
|
||||
std::string label;
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
/* Wings update helper - host tests against a fake GitHub on 127.0.0.1.
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Built with -DWINGS_HELPER_TEST (see `make helper-test`): WINGS_HELPER_TEST_BASE points the
|
||||
* pinned release URL at the fake server, as Orbit Store's ORBIT_TEST_UPDATE_BASE does, and
|
||||
* every console path lives under a temporary root. */
|
||||
#include "install.h"
|
||||
#include "server.h"
|
||||
#include "version.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <curl/curl.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define IMAGE_SIZE 70000
|
||||
#define MAGIC_AT (65536 + 1372)
|
||||
|
||||
static int failures;
|
||||
#define CHECK(cond) \
|
||||
do \
|
||||
{ \
|
||||
if (!(cond)) \
|
||||
{ \
|
||||
fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static unsigned char image[IMAGE_SIZE], bad_image[IMAGE_SIZE];
|
||||
static char image_sha[65], bad_sha[65];
|
||||
static uint16_t fake_port, helper_port;
|
||||
static char root[256];
|
||||
|
||||
/* ---- fake GitHub ---- */
|
||||
|
||||
static void sha_hex(const unsigned char *data, size_t n, char *out)
|
||||
{
|
||||
unsigned char d[32];
|
||||
unsigned len = 0;
|
||||
EVP_Digest(data, n, d, &len, EVP_sha256(), NULL);
|
||||
for (int i = 0; i < 32; i++)
|
||||
snprintf(out + i * 2, 3, "%02x", d[i]);
|
||||
}
|
||||
static void send_text(int fd, const char *text)
|
||||
{
|
||||
send(fd, text, strlen(text), MSG_NOSIGNAL);
|
||||
}
|
||||
static void send_body(int fd, const unsigned char *data, size_t n, int slow)
|
||||
{
|
||||
char head[160];
|
||||
snprintf(head, sizeof head, "HTTP/1.1 200 OK\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n",
|
||||
n);
|
||||
send_text(fd, head);
|
||||
for (size_t p = 0; p < n;)
|
||||
{
|
||||
size_t chunk = slow ? 1000 : n - p;
|
||||
if (send(fd, data + p, chunk, MSG_NOSIGNAL) <= 0)
|
||||
return;
|
||||
p += chunk;
|
||||
if (slow)
|
||||
usleep(50000);
|
||||
}
|
||||
}
|
||||
static void *fake_client(void *arg)
|
||||
{
|
||||
int fd = (int)(intptr_t)arg;
|
||||
char req[2048] = {0}, path[512] = {0};
|
||||
size_t got = 0;
|
||||
while (got < sizeof req - 1 && !strstr(req, "\r\n\r\n"))
|
||||
{
|
||||
ssize_t r = recv(fd, req + got, sizeof req - 1 - got, 0);
|
||||
if (r <= 0)
|
||||
break;
|
||||
got += (size_t)r;
|
||||
}
|
||||
sscanf(req, "GET %511s", path);
|
||||
char head[512];
|
||||
if (!strcmp(path, "/asset/good"))
|
||||
send_body(fd, image, sizeof image, 0);
|
||||
else if (!strcmp(path, "/asset/slow"))
|
||||
send_body(fd, image, sizeof image, 1);
|
||||
else if (!strcmp(path, "/download/v9.1/PPSA99178.ffpkg"))
|
||||
send_body(fd, bad_image, sizeof bad_image, 0);
|
||||
else if (!strcmp(path, "/download/v9.3/PPSA99178.ffpkg"))
|
||||
{
|
||||
snprintf(head, sizeof head,
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:%u/asset/slow\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
(unsigned)fake_port);
|
||||
send_text(fd, head);
|
||||
}
|
||||
else if (!strcmp(path, "/download/v9.2/PPSA99178.ffpkg"))
|
||||
{
|
||||
snprintf(head, sizeof head,
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.2:%u/asset/good\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
(unsigned)fake_port);
|
||||
send_text(fd, head);
|
||||
}
|
||||
else if (!strncmp(path, "/download/", 10))
|
||||
{
|
||||
snprintf(head, sizeof head,
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:%u/asset/good\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
(unsigned)fake_port);
|
||||
send_text(fd, head);
|
||||
}
|
||||
else
|
||||
send_text(fd, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
static void *fake_server(void *arg)
|
||||
{
|
||||
int fd = (int)(intptr_t)arg;
|
||||
for (;;)
|
||||
{
|
||||
int c = accept(fd, NULL, NULL);
|
||||
if (c < 0)
|
||||
continue;
|
||||
pthread_t t;
|
||||
pthread_create(&t, NULL, fake_client, (void *)(intptr_t)c);
|
||||
pthread_detach(t);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- helper client ---- */
|
||||
|
||||
typedef struct
|
||||
{
|
||||
int code;
|
||||
char body[4096];
|
||||
} Reply;
|
||||
|
||||
static Reply raw_request(const char *text)
|
||||
{
|
||||
Reply r = {0};
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(helper_port)};
|
||||
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&a, sizeof a))
|
||||
{
|
||||
close(fd);
|
||||
return r;
|
||||
}
|
||||
send_text(fd, text);
|
||||
char buf[8192] = {0};
|
||||
size_t got = 0;
|
||||
for (;;)
|
||||
{
|
||||
ssize_t n = recv(fd, buf + got, sizeof buf - 1 - got, 0);
|
||||
if (n <= 0)
|
||||
break;
|
||||
got += (size_t)n;
|
||||
}
|
||||
close(fd);
|
||||
sscanf(buf, "HTTP/1.1 %d", &r.code);
|
||||
const char *body = strstr(buf, "\r\n\r\n");
|
||||
if (body)
|
||||
snprintf(r.body, sizeof r.body, "%s", body + 4);
|
||||
return r;
|
||||
}
|
||||
static Reply call(const char *method, const char *route, const char *body)
|
||||
{
|
||||
char text[4096];
|
||||
if (!strcmp(method, "GET"))
|
||||
snprintf(text, sizeof text,
|
||||
"GET /wings-helper/v1/%s HTTP/1.1\r\nHost: 127.0.0.1:%u\r\nX-Wings-Helper: 1\r\n"
|
||||
"Connection: close\r\n\r\n",
|
||||
route, (unsigned)helper_port);
|
||||
else
|
||||
snprintf(text, sizeof text,
|
||||
"POST /wings-helper/v1/%s HTTP/1.1\r\nHost: 127.0.0.1:%u\r\nX-Wings-Helper: 1\r\n"
|
||||
"Content-Type: application/json\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n%s",
|
||||
route, (unsigned)helper_port, strlen(body), body);
|
||||
return raw_request(text);
|
||||
}
|
||||
static cJSON *status(void)
|
||||
{
|
||||
Reply r = call("GET", "status", "");
|
||||
return cJSON_Parse(r.body);
|
||||
}
|
||||
static const char *text_of(const cJSON *o, const char *key)
|
||||
{
|
||||
const cJSON *i = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||
return cJSON_IsString(i) ? i->valuestring : "";
|
||||
}
|
||||
/* Waits for the worker to finish; returns the final status. */
|
||||
static cJSON *wait_done(void)
|
||||
{
|
||||
for (int i = 0; i < 400; i++)
|
||||
{
|
||||
cJSON *s = status();
|
||||
if (s && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(s, "busy")))
|
||||
return s;
|
||||
cJSON_Delete(s);
|
||||
usleep(25000);
|
||||
}
|
||||
return status();
|
||||
}
|
||||
static Reply request(const char *route, const char *version, const char *tag, const char *sha,
|
||||
long size, const char *extra)
|
||||
{
|
||||
char body[1024];
|
||||
snprintf(body, sizeof body,
|
||||
"{\"version\":\"%s\",\"url\":\"http://127.0.0.1:%u/download/%s/PPSA99178.ffpkg\","
|
||||
"\"sha256\":\"%s\",\"size\":%ld%s}",
|
||||
version, (unsigned)fake_port, tag, sha, size, extra);
|
||||
return call("POST", route, body);
|
||||
}
|
||||
static Reply download(const char *version, const char *tag, const char *sha, long size,
|
||||
const char *extra)
|
||||
{
|
||||
return request("download", version, tag, sha, size, extra);
|
||||
}
|
||||
static Reply install(const char *version, const char *tag, const char *sha, long size,
|
||||
const char *extra)
|
||||
{
|
||||
return request("install", version, tag, sha, size, extra);
|
||||
}
|
||||
/* Downloads, then (once "downloaded") installs, and expects the given final phase. */
|
||||
static void install_expect(const char *version, const char *tag, const char *sha, long size,
|
||||
const char *extra, const char *phase)
|
||||
{
|
||||
Reply r = download(version, tag, sha, size, extra);
|
||||
CHECK(r.code == 202);
|
||||
cJSON *s = wait_done();
|
||||
if (!strcmp(text_of(s, "phase"), "downloaded") && strcmp(phase, "downloaded"))
|
||||
{
|
||||
CHECK(!strcmp(text_of(s, "version"), version));
|
||||
cJSON_Delete(s);
|
||||
r = install(version, tag, sha, size, extra);
|
||||
CHECK(r.code == 202);
|
||||
s = wait_done();
|
||||
}
|
||||
if (strcmp(text_of(s, "phase"), phase))
|
||||
fprintf(stderr, " %s %s -> %s (%s)\n", version, tag, text_of(s, "phase"),
|
||||
text_of(s, "error"));
|
||||
CHECK(!strcmp(text_of(s, "phase"), phase));
|
||||
cJSON_Delete(s);
|
||||
}
|
||||
static bool exists(const char *rel)
|
||||
{
|
||||
char p[512];
|
||||
struct stat st;
|
||||
snprintf(p, sizeof p, "%s%s", root, rel);
|
||||
return !lstat(p, &st);
|
||||
}
|
||||
static void write_file(const char *rel, const char *text)
|
||||
{
|
||||
char p[512];
|
||||
snprintf(p, sizeof p, "%s%s", root, rel);
|
||||
FILE *f = fopen(p, "wb");
|
||||
if (f)
|
||||
{
|
||||
fputs(text, f);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
static void remove_file(const char *rel)
|
||||
{
|
||||
char p[512];
|
||||
snprintf(p, sizeof p, "%s%s", root, rel);
|
||||
unlink(p);
|
||||
}
|
||||
static void *run_server(void *arg)
|
||||
{
|
||||
helper_server_run((int)(intptr_t)arg, 30);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
for (size_t i = 0; i < sizeof image; i++)
|
||||
image[i] = bad_image[i] = (unsigned char)(i * 31 + 7);
|
||||
const unsigned char magic[4] = {0x19, 0x01, 0x54, 0x19};
|
||||
memcpy(image + MAGIC_AT, magic, 4);
|
||||
sha_hex(image, sizeof image, image_sha);
|
||||
sha_hex(bad_image, sizeof bad_image, bad_sha);
|
||||
|
||||
int fake = socket(AF_INET, SOCK_STREAM, 0);
|
||||
struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = 0};
|
||||
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
socklen_t n = sizeof a;
|
||||
if (bind(fake, (struct sockaddr *)&a, sizeof a) || listen(fake, 16) ||
|
||||
getsockname(fake, (struct sockaddr *)&a, &n))
|
||||
return 2;
|
||||
fake_port = ntohs(a.sin_port);
|
||||
pthread_t fake_thread;
|
||||
pthread_create(&fake_thread, NULL, fake_server, (void *)(intptr_t)fake);
|
||||
char base[64];
|
||||
snprintf(base, sizeof base, "http://127.0.0.1:%u", (unsigned)fake_port);
|
||||
setenv("WINGS_HELPER_TEST_BASE", base, 1);
|
||||
|
||||
snprintf(root, sizeof root, "/tmp/wings-helper-test-XXXXXX");
|
||||
if (!mkdtemp(root))
|
||||
return 2;
|
||||
char data[300];
|
||||
snprintf(data, sizeof data, "%s/data", root);
|
||||
mkdir(data, 0755);
|
||||
CHECK(helper_jobs_init(root) == 0);
|
||||
|
||||
int fd = helper_server_open(0);
|
||||
CHECK(fd >= 0);
|
||||
helper_port = helper_server_port(fd);
|
||||
/* Single instance: the port is taken while the first one listens. */
|
||||
CHECK(helper_server_open(helper_port) == HELPER_SERVER_IN_USE);
|
||||
pthread_t server_thread;
|
||||
pthread_create(&server_thread, NULL, run_server, (void *)(intptr_t)fd);
|
||||
|
||||
/* Guards. */
|
||||
char text[512];
|
||||
snprintf(text, sizeof text,
|
||||
"GET /wings-helper/v1/status HTTP/1.1\r\nHost: evil.example:%u\r\n"
|
||||
"X-Wings-Helper: 1\r\n\r\n",
|
||||
(unsigned)helper_port);
|
||||
CHECK(raw_request(text).code == 403);
|
||||
snprintf(text, sizeof text,
|
||||
"GET /wings-helper/v1/status HTTP/1.1\r\nHost: 127.0.0.1:%u\r\n\r\n",
|
||||
(unsigned)helper_port);
|
||||
CHECK(raw_request(text).code == 403);
|
||||
snprintf(text, sizeof text,
|
||||
"GET /wings-helper/v1/status HTTP/1.1\r\nHost: 127.0.0.1:%u\r\nX-Wings-Helper: 1\r\n"
|
||||
"Origin: http://127.0.0.1:%u\r\n\r\n",
|
||||
(unsigned)helper_port, (unsigned)helper_port);
|
||||
CHECK(raw_request(text).code == 403);
|
||||
snprintf(text, sizeof text,
|
||||
"POST /wings-helper/v1/cancel HTTP/1.1\r\nHost: 127.0.0.1:%u\r\nX-Wings-Helper: 1\r\n"
|
||||
"Content-Type: text/plain\r\nContent-Length: 2\r\n\r\n{}",
|
||||
(unsigned)helper_port);
|
||||
CHECK(raw_request(text).code == 415);
|
||||
CHECK(call("GET", "nothing", "").code == 404);
|
||||
CHECK(call("POST", "status", "{}").code == 405);
|
||||
|
||||
cJSON *s = status();
|
||||
CHECK(!strcmp(text_of(s, "phase"), "idle"));
|
||||
CHECK(!strcmp(text_of(s, "target"), "none"));
|
||||
CHECK(!strcmp(text_of(s, "helper"), WINGS_HELPER_BUILD));
|
||||
cJSON_Delete(s);
|
||||
|
||||
/* Invalid requests. */
|
||||
CHECK(download("1.2", "v1.2", "abc", IMAGE_SIZE, "").code == 400);
|
||||
CHECK(download("1.2", "v1.2", image_sha, 10, "").code == 400);
|
||||
CHECK(download("1.2", "v1.2", image_sha, IMAGE_SIZE, ",\"replace\":\"yes\"").code == 400);
|
||||
char body[512];
|
||||
snprintf(body, sizeof body,
|
||||
"{\"version\":\"1.2\",\"url\":\"https://example.com/download/v1.2/PPSA99178.ffpkg\","
|
||||
"\"sha256\":\"%s\",\"size\":%d}",
|
||||
image_sha, IMAGE_SIZE);
|
||||
CHECK(call("POST", "download", body).code == 400);
|
||||
CHECK(install("1.2", "v1.2", "abc", IMAGE_SIZE, "").code == 400);
|
||||
|
||||
/* Failures leave nothing behind. */
|
||||
install_expect("1.2", "v1.2", image_sha, IMAGE_SIZE + 1, "", "error");
|
||||
install_expect("1.2", "v1.2", bad_sha, IMAGE_SIZE, "", "error");
|
||||
install_expect("9.1", "v9.1", bad_sha, IMAGE_SIZE, "", "error");
|
||||
install_expect("9.2", "v9.2", image_sha, IMAGE_SIZE, "", "error");
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg"));
|
||||
CHECK(!exists("/data/wings-helper/PPSA99178.ffpkg.part"));
|
||||
|
||||
/* A good install. */
|
||||
install_expect("1.2", "v1.2", image_sha, IMAGE_SIZE, "", "placed");
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "wings"));
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.2"));
|
||||
CHECK(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(s, "old_kept")));
|
||||
CHECK(cJSON_GetObjectItemCaseSensitive(s, "received")->valuedouble == IMAGE_SIZE);
|
||||
cJSON_Delete(s);
|
||||
|
||||
/* Never downgrade. */
|
||||
CHECK(download("1.1", "v1.1", image_sha, IMAGE_SIZE, "").code == 409);
|
||||
CHECK(download("1.3", "v1.3", image_sha, IMAGE_SIZE, ",\"running\":\"1.4\"").code == 409);
|
||||
|
||||
/* An update keeps the previous file until the new version runs. */
|
||||
install_expect("1.3", "v1.3", image_sha, IMAGE_SIZE, ",\"running\":\"1.2\"", "placed");
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
s = status();
|
||||
CHECK(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(s, "old_kept")));
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.3"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(download("1.4", "v1.4", image_sha, IMAGE_SIZE, "").code == 409); /* old_waiting */
|
||||
CHECK(call("POST", "tidy", "{\"running\":\"1.2\"}").code == 409);
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
CHECK(call("POST", "tidy", "{\"running\":\"1.3\"}").code == 200);
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
|
||||
/* A copy the player placed is replaced only when they confirm. */
|
||||
remove_file("/data/wings-helper/helper-placed.json");
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "manual"));
|
||||
cJSON_Delete(s);
|
||||
install_expect("1.4", "v1.4", image_sha, IMAGE_SIZE, "", "downloaded");
|
||||
CHECK(install("1.4", "v1.4", image_sha, IMAGE_SIZE, "").code == 409);
|
||||
install_expect("1.4", "v1.4", image_sha, IMAGE_SIZE, ",\"replace\":true", "placed");
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
CHECK(call("POST", "tidy", "{\"running\":\"1.4\"}").code == 200);
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
|
||||
/* The version asked for must be the release in the URL ("v" prefix or not). */
|
||||
CHECK(download("1.5", "v1.6", image_sha, IMAGE_SIZE, "").code == 400);
|
||||
CHECK(download("1.5", "1.6", image_sha, IMAGE_SIZE, "").code == 400);
|
||||
CHECK(download("99.0", "v1.5", image_sha, IMAGE_SIZE, "").code == 400);
|
||||
|
||||
/* The last step of placing fails: the installed package and its record stay as they were,
|
||||
* and no .old is left behind. */
|
||||
setenv("WINGS_HELPER_TEST_FAIL_PLACE", "1", 1);
|
||||
install_expect("1.5", "v1.5", image_sha, IMAGE_SIZE, ",\"running\":\"1.4\"", "error");
|
||||
unsetenv("WINGS_HELPER_TEST_FAIL_PLACE");
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg"));
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
/* The checked download stays, so installing can be tried again. */
|
||||
CHECK(exists("/data/wings-helper/PPSA99178.ffpkg.part"));
|
||||
CHECK(exists("/data/wings-helper/downloaded.json"));
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "wings"));
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.4"));
|
||||
CHECK(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(s, "old_kept")));
|
||||
cJSON_Delete(s);
|
||||
|
||||
/* An older helper stopped between its two renames: only the .old is there. The next
|
||||
* request puts it back, still recognised as the helper's install. */
|
||||
char from[512], to[512];
|
||||
snprintf(from, sizeof from, "%s/data/homebrew/PPSA99178.ffpkg", root);
|
||||
snprintf(to, sizeof to, "%s/data/homebrew/PPSA99178.ffpkg.old", root);
|
||||
CHECK(!rename(from, to));
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "none"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(call("POST", "tidy", "{\"running\":\"1.4\"}").code == 200);
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg"));
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "wings"));
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.4"));
|
||||
cJSON_Delete(s);
|
||||
/* The same at helper start. */
|
||||
CHECK(!rename(from, to));
|
||||
helper_jobs_shutdown();
|
||||
CHECK(helper_jobs_init(root) == 0);
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg"));
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
|
||||
/* Never beside a folder copy. */
|
||||
char folder[400];
|
||||
snprintf(folder, sizeof folder, "%s/data/homebrew/PPSA99178", root);
|
||||
mkdir(folder, 0755);
|
||||
write_file("/data/homebrew/PPSA99178/eboot.bin", "x");
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "folder"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(download("1.5", "v1.5", image_sha, IMAGE_SIZE, "").code == 409);
|
||||
remove_file("/data/homebrew/PPSA99178/eboot.bin");
|
||||
rmdir(folder);
|
||||
|
||||
/* Busy, then cancel. */
|
||||
CHECK(download("9.3", "v9.3", image_sha, IMAGE_SIZE, "").code == 202);
|
||||
usleep(300000);
|
||||
CHECK(download("1.5", "v1.5", image_sha, IMAGE_SIZE, "").code == 409);
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "phase"), "downloading"));
|
||||
CHECK(cJSON_GetObjectItemCaseSensitive(s, "received")->valuedouble > 0);
|
||||
cJSON_Delete(s);
|
||||
CHECK(call("POST", "cancel", "{}").code == 200);
|
||||
s = wait_done();
|
||||
CHECK(!strcmp(text_of(s, "phase"), "idle"));
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.4"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(!exists("/data/wings-helper/PPSA99178.ffpkg.part"));
|
||||
CHECK(!exists("/data/wings-helper/downloaded.json"));
|
||||
|
||||
/* Install without a download: refused, nothing placed. */
|
||||
Reply r = install("1.5", "v1.5", image_sha, IMAGE_SIZE, "");
|
||||
CHECK(r.code == 409 && strstr(r.body, "not_downloaded"));
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
|
||||
/* A download stops at "downloaded" and survives a helper restart. */
|
||||
install_expect("1.5", "v1.5", image_sha, IMAGE_SIZE, "", "downloaded");
|
||||
CHECK(exists("/data/wings-helper/PPSA99178.ffpkg.part"));
|
||||
CHECK(exists("/data/wings-helper/downloaded.json"));
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.4"));
|
||||
cJSON_Delete(s);
|
||||
helper_jobs_shutdown();
|
||||
CHECK(helper_jobs_init(root) == 0);
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "phase"), "downloaded"));
|
||||
CHECK(!strcmp(text_of(s, "version"), "1.5"));
|
||||
cJSON_Delete(s);
|
||||
/* Install only places what was downloaded: another version or checksum is refused. */
|
||||
CHECK(install("1.6", "v1.6", image_sha, IMAGE_SIZE, "").code == 409);
|
||||
CHECK(install("1.5", "v1.5", bad_sha, IMAGE_SIZE, "").code == 409);
|
||||
|
||||
/* The .part changed after the download (same size, inode and time): install hashes it
|
||||
* again, refuses, and nothing is placed. */
|
||||
char part_path[512];
|
||||
snprintf(part_path, sizeof part_path, "%s/data/wings-helper/PPSA99178.ffpkg.part", root);
|
||||
struct stat before;
|
||||
CHECK(!stat(part_path, &before));
|
||||
int pf = open(part_path, O_WRONLY);
|
||||
CHECK(pf >= 0 && pwrite(pf, "X", 1, 100) == 1);
|
||||
close(pf);
|
||||
struct timespec times[2] = {before.st_atim, before.st_mtim};
|
||||
CHECK(!utimensat(AT_FDCWD, part_path, times, 0));
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "phase"), "downloaded"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(install("1.5", "v1.5", image_sha, IMAGE_SIZE, "").code == 202);
|
||||
s = wait_done();
|
||||
CHECK(!strcmp(text_of(s, "phase"), "error"));
|
||||
CHECK(!strcmp(text_of(s, "installed_version"), "1.4"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
CHECK(!exists("/data/wings-helper/PPSA99178.ffpkg.part"));
|
||||
CHECK(!exists("/data/wings-helper/downloaded.json"));
|
||||
|
||||
/* An install finished, then the player put another copy in by hand (the console history of
|
||||
* 2026-10-14): the kept .old is no longer the way back and is tidied, even though the copy
|
||||
* running is older than the release the helper placed. */
|
||||
install_expect("1.5", "v1.5", image_sha, IMAGE_SIZE, ",\"running\":\"1.4\"", "placed");
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
CHECK(download("1.6", "v1.6", image_sha, IMAGE_SIZE, "").code == 409); /* old_waiting */
|
||||
remove_file("/data/homebrew/PPSA99178.ffpkg");
|
||||
write_file("/data/homebrew/PPSA99178.ffpkg", "a copy placed by hand");
|
||||
s = status();
|
||||
CHECK(!strcmp(text_of(s, "target"), "manual"));
|
||||
cJSON_Delete(s);
|
||||
CHECK(call("POST", "tidy", "{\"running\":\"1.1\"}").code == 200);
|
||||
CHECK(!exists("/data/homebrew/PPSA99178.ffpkg.old"));
|
||||
CHECK(exists("/data/homebrew/PPSA99178.ffpkg"));
|
||||
|
||||
/* Stop ends the server. */
|
||||
CHECK(call("POST", "stop", "{}").code == 200);
|
||||
pthread_join(server_thread, NULL);
|
||||
/* The port stays taken until the worker has finished; then main closes it. */
|
||||
CHECK(helper_server_open(helper_port) == HELPER_SERVER_IN_USE);
|
||||
helper_jobs_shutdown();
|
||||
close(fd);
|
||||
CHECK(call("GET", "status", "").code == 0);
|
||||
|
||||
/* Idle exit. */
|
||||
helper_server_reset();
|
||||
fd = helper_server_open(0);
|
||||
time_t started = time(NULL);
|
||||
helper_server_run(fd, 1);
|
||||
CHECK(time(NULL) - started <= 3);
|
||||
close(fd);
|
||||
|
||||
char cleanup[400];
|
||||
snprintf(cleanup, sizeof cleanup, "rm -rf '%s'", root);
|
||||
if (system(cleanup))
|
||||
fprintf(stderr, "could not remove %s\n", root);
|
||||
if (failures)
|
||||
{
|
||||
fprintf(stderr, "helper tests: %d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("helper tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// against the fake backend; nothing in this file talks to a real Pegasus DL.
|
||||
|
||||
#include "fake_backend.hpp"
|
||||
#include "orbit/torbox_status.hpp"
|
||||
#include "orbit/screens.hpp"
|
||||
#include "support.hpp"
|
||||
|
||||
@@ -128,7 +129,9 @@ TEST(SettingsEditing, ADebridTokenIsTypedMaskedSentOnceAndNeverShownBack)
|
||||
EXPECT_EQ(m.store.state().settings.torbox.configured, false);
|
||||
select_setting(m, "TorBox");
|
||||
EXPECT_TRUE(m.app.settings_screen().modal());
|
||||
EXPECT_EQ(m.app.settings_screen().menu_codes().size(), 3u);
|
||||
// Set, Check, Remove and (TorBox only) Open TorBox website.
|
||||
EXPECT_EQ(m.app.settings_screen().menu_codes().size(), 4u);
|
||||
EXPECT_EQ(m.app.settings_screen().menu_codes().back(), "open-site");
|
||||
m.press(Action::confirm); // "Set or replace the token"
|
||||
ASSERT_TRUE(m.app.settings_screen().prompt_open());
|
||||
m.app.settings_screen().type_into_prompt("good-secret-token-123");
|
||||
@@ -149,6 +152,21 @@ TEST(SettingsEditing, ADebridTokenIsTypedMaskedSentOnceAndNeverShownBack)
|
||||
}
|
||||
}
|
||||
|
||||
// B-19: the TorBox menu opens the TorBox website in the PS5 browser through Pegasus.
|
||||
TEST(SettingsEditing, TheTorBoxMenuOpensTheTorBoxWebsite)
|
||||
{
|
||||
Mgr m(true, tab::settings);
|
||||
m.app.settings_screen().set_group(SettingsScreen::Group::pegasus);
|
||||
select_setting(m, "TorBox");
|
||||
ASSERT_EQ(m.app.settings_screen().menu_codes().back(), "open-site");
|
||||
m.down(3);
|
||||
m.press(Action::confirm);
|
||||
m.work();
|
||||
const std::vector<std::string> opened = m.backend.opened_urls();
|
||||
ASSERT_EQ(opened.size(), 1u);
|
||||
EXPECT_EQ(opened[0], kTorboxDashboardUrl);
|
||||
}
|
||||
|
||||
TEST(SettingsEditing, ATokenPegasusRejectsIsReportedAsNotValid)
|
||||
{
|
||||
Mgr m(true, tab::settings);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Wings TV app - Circle on a waiting box (B-18): a late answer after cancel changes nothing.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "fake_backend.hpp"
|
||||
#include "orbit/store.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr int kAutomated = 1; // pkg1 link 1: an automated provider in the fake
|
||||
|
||||
Probe direct_probe()
|
||||
{
|
||||
Probe probe;
|
||||
probe.url = "https://example.com/files/late.bin";
|
||||
probe.final_url = probe.url;
|
||||
probe.file_name = "late.bin";
|
||||
probe.length = 1.0;
|
||||
return probe;
|
||||
}
|
||||
|
||||
std::set<std::string> job_ids(host::FakeBackend &backend)
|
||||
{
|
||||
std::set<std::string> ids;
|
||||
for (const Job &job : backend.queue().value)
|
||||
ids.insert(job.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
void run_all(Store &store)
|
||||
{
|
||||
while (store.run_next_action())
|
||||
{
|
||||
}
|
||||
store.tick();
|
||||
}
|
||||
|
||||
TEST(CancelWaiting, ADirectLinkQueuedAfterCircleIsCancelledInPegasus)
|
||||
{
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
const std::set<std::string> before = job_ids(backend);
|
||||
std::vector<std::string> lines;
|
||||
store.log = [&lines](const std::string &line) { lines.push_back(line); };
|
||||
// Circle while Pegasus is answering: the answer arrives after the cancel.
|
||||
store.on_answer = [&store] { store.cancel(Store::lane_probe); };
|
||||
store.queue_direct(direct_probe(), "");
|
||||
ASSERT_TRUE(store.probe_action().busy);
|
||||
const std::uint64_t serial = store.probe_action().serial;
|
||||
run_all(store);
|
||||
|
||||
EXPECT_FALSE(store.probe_action().busy);
|
||||
EXPECT_TRUE(store.probe_action().message.empty()); // no "queued" confirmation
|
||||
EXPECT_TRUE(store.probe_action().error.empty());
|
||||
EXPECT_EQ(store.probe_action().serial, serial); // no screen sees a finished action
|
||||
int added = 0;
|
||||
for (const Job &job : backend.queue().value)
|
||||
{
|
||||
if (before.count(job.id) != 0)
|
||||
continue;
|
||||
++added;
|
||||
EXPECT_EQ(job.status, "cancelled") << job.id; // nothing half-done stays queued
|
||||
}
|
||||
EXPECT_EQ(added, 1);
|
||||
bool logged = false;
|
||||
for (const std::string &line : lines)
|
||||
logged = logged || line.rfind("[WINGS-CANCEL] late download cancelled", 0) == 0;
|
||||
EXPECT_TRUE(logged);
|
||||
}
|
||||
|
||||
TEST(CancelWaiting, AnActionCancelledBeforeItRanIsNeverSent)
|
||||
{
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
const std::set<std::string> before = job_ids(backend);
|
||||
store.queue_direct(direct_probe(), "");
|
||||
store.cancel(Store::lane_probe);
|
||||
EXPECT_FALSE(store.probe_action().busy); // the box closes at once
|
||||
run_all(store);
|
||||
EXPECT_FALSE(store.probe_action().busy);
|
||||
EXPECT_EQ(job_ids(backend), before);
|
||||
}
|
||||
|
||||
TEST(CancelWaiting, AnAnswerThatCameBeforeTheFrameIsNotShown)
|
||||
{
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
store.probe_link("https://example.com/files/a.bin");
|
||||
while (store.run_next_action())
|
||||
{
|
||||
}
|
||||
store.cancel(Store::lane_probe); // Circle in the same frame the answer came
|
||||
const std::uint64_t probes = store.state().probe_serial;
|
||||
store.tick();
|
||||
EXPECT_FALSE(store.probe_action().busy);
|
||||
EXPECT_EQ(store.state().probe_serial, probes);
|
||||
EXPECT_TRUE(store.state().probe.url.empty());
|
||||
}
|
||||
|
||||
TEST(CancelWaiting, ALinkStartAnsweredAfterCircleIsDropped)
|
||||
{
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
const std::set<std::string> before = job_ids(backend);
|
||||
store.on_answer = [&store] { store.cancel(Store::lane_link); };
|
||||
const std::uint64_t links = store.state().link_serial;
|
||||
store.start_link("pkg1", 0, "");
|
||||
run_all(store);
|
||||
EXPECT_FALSE(store.link_action().busy);
|
||||
EXPECT_TRUE(store.link_action().error.empty());
|
||||
EXPECT_EQ(store.state().link_serial, links); // the answer is never applied
|
||||
for (const Job &job : backend.queue().value)
|
||||
{
|
||||
if (before.count(job.id) == 0) // whatever it queued was cancelled
|
||||
EXPECT_TRUE(job.status == "cancelled" || job.status == "completed") << job.status;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(CancelWaiting, CircleDuringAPartWaitStopsTheCaptureAndTheSeriesHearsNothing)
|
||||
{
|
||||
host::FakeBackend backend;
|
||||
backend.set_capture_script({"priming", "priming", "priming", "completed"});
|
||||
Store store(backend);
|
||||
store.set_series_wait(0.0, 1000.0);
|
||||
backend.set_capture_hook(
|
||||
[&store](int reads)
|
||||
{
|
||||
if (reads == 1)
|
||||
store.cancel(Store::lane_link);
|
||||
});
|
||||
int calls = 0;
|
||||
store.start_link("pkg1", kAutomated, "", [&calls](const LinkStart &) { ++calls; });
|
||||
run_all(store);
|
||||
store.tick();
|
||||
EXPECT_EQ(calls, 0);
|
||||
EXPECT_FALSE(store.link_action().busy);
|
||||
EXPECT_EQ(backend.cancelled_captures(), 1);
|
||||
EXPECT_FALSE(store.state().capture.active);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace orbit
|
||||
@@ -415,39 +415,42 @@ TEST(DownloadAll, AQueuedPartBreaksTheRowOfFailures)
|
||||
EXPECT_EQ(tail.all.done, 5);
|
||||
}
|
||||
|
||||
TEST(DownloadAll, CancelMidWayStopsAfterThePartInFlight)
|
||||
TEST(DownloadAll, CancelMidWayEndsAtOnceAndLetsThePartInFlightGo)
|
||||
{
|
||||
Series s;
|
||||
s.on_start = [&](const LinkRef &ref)
|
||||
{
|
||||
if (ref.link_index == 2)
|
||||
{
|
||||
s.all.cancel(); // Circle while part 3 is being queued
|
||||
EXPECT_FALSE(s.all.active); // the box closes right away
|
||||
}
|
||||
};
|
||||
s.run(s.parts(10));
|
||||
EXPECT_EQ(s.all.end, DownloadAll::End::cancelled);
|
||||
EXPECT_EQ(s.all.ok, 3); // the part in flight still counts
|
||||
EXPECT_EQ(s.all.ok, 2); // the part in flight's late answer changes nothing
|
||||
EXPECT_EQ(s.order.size(), 3u);
|
||||
EXPECT_EQ(s.queued.keys.size(), 3u);
|
||||
EXPECT_EQ(s.queued.keys.size(), 2u);
|
||||
EXPECT_EQ(s.finished_calls, 1);
|
||||
EXPECT_EQ(s.all.report_title(), "Cancelled: 3 of 10 parts queued");
|
||||
EXPECT_EQ(s.all.report_title(), "Cancelled: 2 of 10 parts queued");
|
||||
// Cancelling an idle series does nothing.
|
||||
s.all.cancel();
|
||||
EXPECT_FALSE(s.all.active);
|
||||
EXPECT_EQ(s.finished_calls, 1);
|
||||
}
|
||||
|
||||
TEST(DownloadAll, CancelWhileAPartIsOutWaitsForItsOutcome)
|
||||
TEST(DownloadAll, CancelWhileAPartIsOutEndsAtOnce)
|
||||
{
|
||||
Runner r;
|
||||
r.held = true;
|
||||
r.run(plans(5));
|
||||
r.pending[0](queued_outcome());
|
||||
r.all.cancel();
|
||||
EXPECT_TRUE(r.all.active); // part 2 is still out
|
||||
EXPECT_EQ(r.finished_calls, 0);
|
||||
r.pending[1](failure("timed out", true)); // it failed: no wait, no retry, the series ends
|
||||
EXPECT_FALSE(r.all.active);
|
||||
EXPECT_FALSE(r.all.active); // part 2 is still out: it is let go
|
||||
EXPECT_EQ(r.all.end, DownloadAll::End::cancelled);
|
||||
EXPECT_EQ(r.finished_calls, 1);
|
||||
r.pending[1](queued_outcome()); // its late answer changes nothing
|
||||
EXPECT_FALSE(r.all.active);
|
||||
EXPECT_EQ(r.all.ok, 1);
|
||||
EXPECT_EQ(r.asked.size(), 2u);
|
||||
EXPECT_TRUE(r.all.failed.empty());
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Wings TV app - Auto-reconnect of dropped downloads (download_auto_retry.hpp).
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/download_auto_retry.hpp"
|
||||
#include "orbit/prefs.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
RetryJobSnapshot snap(const std::string &status, double received, const std::string &error = "",
|
||||
bool resumable = true)
|
||||
{
|
||||
RetryJobSnapshot s;
|
||||
s.id = "7";
|
||||
s.status = status;
|
||||
s.received = received;
|
||||
s.error = error;
|
||||
s.resumable = resumable;
|
||||
return s;
|
||||
}
|
||||
|
||||
constexpr const char *kReset = "Segmented download failed: Failure when receiving data from the peer";
|
||||
|
||||
using Kind = DownloadAutoRetry::View::Kind;
|
||||
|
||||
// The job runs, then drops at `at` seconds with `error`.
|
||||
void run_then_fail(DownloadAutoRetry &m, double at, double bytes, const std::string &error = kReset)
|
||||
{
|
||||
EXPECT_TRUE(m.observe({snap("downloading", bytes)}, at - 1.0).empty());
|
||||
EXPECT_TRUE(m.observe({snap("failed", bytes, error)}, at).empty());
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, DroppedConnectionsAndBusyServersAreTransient)
|
||||
{
|
||||
EXPECT_TRUE(transient_download_error(kReset, ""));
|
||||
EXPECT_TRUE(transient_download_error("Connection died, retrying a fresh connect", ""));
|
||||
EXPECT_TRUE(transient_download_error("transfer closed with 1234 bytes remaining to read", ""));
|
||||
EXPECT_TRUE(transient_download_error("Operation timed out after 30000 milliseconds with 0 bytes received", ""));
|
||||
EXPECT_TRUE(transient_download_error("Recv failure: Connection reset by peer", ""));
|
||||
EXPECT_TRUE(transient_download_error("remote download failed: torbox status=503 Service Unavailable", ""));
|
||||
EXPECT_TRUE(transient_download_error("HTTP server returned status 429", ""));
|
||||
EXPECT_TRUE(transient_download_error("HTTP server returned status 502", ""));
|
||||
EXPECT_TRUE(transient_download_error("", "Transferred a partial file"));
|
||||
EXPECT_EQ(download_error_http_code("remote download failed: x status=504 Gateway Timeout"), 504);
|
||||
EXPECT_EQ(download_error_http_code("HTTP/1.1 206 Partial Content"), 206);
|
||||
EXPECT_EQ(download_error_http_code("1500 bytes"), 0);
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, RealErrorsAreNotTransient)
|
||||
{
|
||||
EXPECT_FALSE(transient_download_error("remote download failed: torbox status=404 Not Found", ""));
|
||||
EXPECT_FALSE(transient_download_error("HTTP server returned status 403", ""));
|
||||
EXPECT_FALSE(transient_download_error("Disk full or allocation exceeded", ""));
|
||||
EXPECT_FALSE(transient_download_error("Not enough free space to finish this download.", ""));
|
||||
EXPECT_FALSE(transient_download_error("Remote file size changed during download", ""));
|
||||
EXPECT_FALSE(transient_download_error("ETag mismatch", ""));
|
||||
EXPECT_FALSE(transient_download_error("Server returned an unexpected byte range", ""));
|
||||
EXPECT_FALSE(transient_download_error("Download cancelled", ""));
|
||||
EXPECT_FALSE(transient_download_error("Archive has too many installable candidates", ""));
|
||||
EXPECT_FALSE(transient_download_error("", ""));
|
||||
EXPECT_FALSE(transient_download_error("Something odd", ""));
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, BacksOffTenTwentyFortySeconds)
|
||||
{
|
||||
EXPECT_EQ(DownloadAutoRetry::backoff_seconds(1), 10.0);
|
||||
EXPECT_EQ(DownloadAutoRetry::backoff_seconds(2), 20.0);
|
||||
EXPECT_EQ(DownloadAutoRetry::backoff_seconds(3), 40.0);
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, WaitsThenRetriesAndShowsTheCountdown)
|
||||
{
|
||||
DownloadAutoRetry m;
|
||||
run_then_fail(m, 100.0, 500.0);
|
||||
DownloadAutoRetry::View v = m.view("7", 102.0);
|
||||
EXPECT_EQ(v.kind, Kind::waiting);
|
||||
EXPECT_EQ(v.seconds, 8);
|
||||
EXPECT_EQ(v.attempt, 1);
|
||||
EXPECT_EQ(auto_retry_text(v), "Reconnecting in 8 s (try 1 of 3)");
|
||||
EXPECT_TRUE(m.observe({snap("failed", 500.0, kReset)}, 109.0).empty());
|
||||
const std::vector<std::string> due = m.observe({snap("failed", 500.0, kReset)}, 110.0);
|
||||
ASSERT_EQ(due.size(), 1u);
|
||||
EXPECT_EQ(due[0], "7");
|
||||
m.sent("7", true, 110.0);
|
||||
EXPECT_EQ(m.view("7", 110.0).kind, Kind::reconnecting);
|
||||
// Resumed from the partial file and moving again.
|
||||
m.observe({snap("downloading", 500.0)}, 111.0);
|
||||
EXPECT_EQ(m.view("7", 111.0).kind, Kind::reconnecting);
|
||||
m.observe({snap("downloading", 900.0)}, 112.0);
|
||||
EXPECT_EQ(auto_retry_text(m.view("7", 113.0)), "Reconnected");
|
||||
EXPECT_EQ(m.view("7", 112.0 + DownloadAutoRetry::kReconnectedShown + 0.1).kind, Kind::none);
|
||||
bool logged = false;
|
||||
for (const std::string &line : m.take_log())
|
||||
logged = logged || line.rfind("[WINGS-RETRY] ", 0) == 0;
|
||||
EXPECT_TRUE(logged);
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, StopsAfterThreeTriesWithoutProgress)
|
||||
{
|
||||
DownloadAutoRetry m;
|
||||
run_then_fail(m, 100.0, 500.0);
|
||||
double t = 100.0;
|
||||
for (int attempt = 1; attempt <= 3; ++attempt)
|
||||
{
|
||||
EXPECT_EQ(m.view("7", t).attempt, attempt);
|
||||
t += DownloadAutoRetry::backoff_seconds(attempt);
|
||||
ASSERT_EQ(m.observe({snap("failed", 500.0, kReset)}, t).size(), 1u);
|
||||
m.sent("7", true, t);
|
||||
m.observe({snap("queued", 500.0)}, t + 1.0); // back in the queue, no new bytes
|
||||
t += 2.0;
|
||||
m.observe({snap("failed", 500.0, kReset)}, t);
|
||||
}
|
||||
// Fourth drop without progress: it stays failed.
|
||||
EXPECT_EQ(m.view("7", t).kind, Kind::none);
|
||||
EXPECT_TRUE(m.observe({snap("failed", 500.0, kReset)}, t + 100.0).empty());
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, NewBytesResetTheCountSoLongDownloadsKeepGoing)
|
||||
{
|
||||
DownloadAutoRetry m;
|
||||
double t = 100.0;
|
||||
double bytes = 0.0;
|
||||
for (int drop = 0; drop < 6; ++drop)
|
||||
{
|
||||
bytes += 1000.0;
|
||||
run_then_fail(m, t, bytes);
|
||||
EXPECT_EQ(m.view("7", t).attempt, 1) << "drop " << drop;
|
||||
t += 10.0;
|
||||
ASSERT_EQ(m.observe({snap("failed", bytes, kReset)}, t).size(), 1u) << "drop " << drop;
|
||||
m.sent("7", true, t);
|
||||
m.observe({snap("downloading", bytes + 10.0)}, t + 1.0);
|
||||
t += 50.0;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, RealErrorsAndUnresumableJobsAreLeftFailed)
|
||||
{
|
||||
DownloadAutoRetry m;
|
||||
run_then_fail(m, 100.0, 500.0, "Disk full or allocation exceeded");
|
||||
EXPECT_EQ(m.view("7", 100.0).kind, Kind::none);
|
||||
EXPECT_TRUE(m.observe({snap("failed", 500.0, "Disk full or allocation exceeded")}, 200.0).empty());
|
||||
|
||||
DownloadAutoRetry n;
|
||||
n.observe({snap("downloading", 10.0)}, 1.0);
|
||||
n.observe({snap("failed", 10.0, kReset, false)}, 2.0);
|
||||
EXPECT_EQ(n.view("7", 2.0).kind, Kind::none);
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, PausedOrCancelledJobsAreNeverRetried)
|
||||
{
|
||||
for (const char *status : {"paused", "cancelled", "cancelling", "pausing"})
|
||||
{
|
||||
DownloadAutoRetry m;
|
||||
run_then_fail(m, 100.0, 500.0);
|
||||
ASSERT_EQ(m.view("7", 100.0).kind, Kind::waiting);
|
||||
m.observe({snap(status, 500.0, kReset)}, 101.0);
|
||||
EXPECT_EQ(m.view("7", 101.0).kind, Kind::none) << status;
|
||||
EXPECT_TRUE(m.observe({snap(status, 500.0, kReset)}, 200.0).empty()) << status;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, OldFailuresTheSwitchAndStopAreRespected)
|
||||
{
|
||||
// Failed before Wings ever saw it running (e.g. from an earlier session).
|
||||
DownloadAutoRetry m;
|
||||
EXPECT_TRUE(m.observe({snap("failed", 500.0, kReset)}, 1.0).empty());
|
||||
EXPECT_EQ(m.view("7", 1.0).kind, Kind::none);
|
||||
|
||||
DownloadAutoRetry off;
|
||||
off.set_enabled(false);
|
||||
run_then_fail(off, 100.0, 500.0);
|
||||
EXPECT_EQ(off.view("7", 100.0).kind, Kind::none);
|
||||
|
||||
DownloadAutoRetry stop;
|
||||
run_then_fail(stop, 100.0, 500.0);
|
||||
stop.stop("7");
|
||||
EXPECT_EQ(stop.view("7", 100.0).kind, Kind::none);
|
||||
EXPECT_TRUE(stop.observe({snap("failed", 500.0, kReset)}, 200.0).empty());
|
||||
}
|
||||
|
||||
TEST(DownloadAutoRetry, ThePreferenceIsOnByDefaultAndSaved)
|
||||
{
|
||||
WingsLocalPrefs prefs;
|
||||
EXPECT_TRUE(prefs.auto_reconnect);
|
||||
prefs.auto_reconnect = false;
|
||||
WingsLocalPrefs back;
|
||||
ASSERT_TRUE(decode_local_prefs(encode_local_prefs(prefs), &back));
|
||||
EXPECT_FALSE(back.auto_reconnect);
|
||||
WingsLocalPrefs old;
|
||||
ASSERT_TRUE(decode_local_prefs("firmware=\n", &old));
|
||||
EXPECT_TRUE(old.auto_reconnect);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace orbit
|
||||
@@ -1,33 +0,0 @@
|
||||
// Wings TV app - The Downloads tip under a failed job (install_hint.hpp).
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/install_hint.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
TEST(FailedDownloadTip, ShownForFailedWithManyOrUnknownConnections)
|
||||
{
|
||||
const std::string tip = failed_download_tip("failed", 16);
|
||||
EXPECT_EQ(tip,
|
||||
"Tip: set Download connections to 4 or lower for the most compatible downloads "
|
||||
"(Settings > Pegasus DL).");
|
||||
EXPECT_EQ(failed_download_tip("failed", 5), tip);
|
||||
EXPECT_EQ(failed_download_tip("failed", 0), tip); // unknown
|
||||
}
|
||||
|
||||
TEST(FailedDownloadTip, HiddenWhenAlreadyFourOrLowerAndForOtherStatuses)
|
||||
{
|
||||
EXPECT_TRUE(failed_download_tip("failed", 4).empty());
|
||||
EXPECT_TRUE(failed_download_tip("failed", 1).empty());
|
||||
EXPECT_TRUE(failed_download_tip("completed", 16).empty());
|
||||
EXPECT_TRUE(failed_download_tip("cancelled", 0).empty());
|
||||
EXPECT_TRUE(failed_download_tip("downloading", 16).empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,215 @@
|
||||
// Wings TV app - a stand-in for the Wings update helper over a fake HTTP transport (no sockets, no files).
|
||||
// Each status read moves a job one step. Download: `step` bytes, verifying, downloaded. Install (only of a checked
|
||||
// download, else 409 "not_downloaded"): verifying, placing, placed.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "orbit/http.hpp"
|
||||
#include "orbit/json.hpp"
|
||||
#include "orbit/wings_helper_client.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
|
||||
class FakeWingsHelper final : public http::Transport
|
||||
{
|
||||
public:
|
||||
http::Response send(const http::Request &r) override
|
||||
{
|
||||
http::Response out;
|
||||
requests.push_back(r.method + " " + r.path);
|
||||
if (!running)
|
||||
{
|
||||
out.error = "connection refused";
|
||||
return out;
|
||||
}
|
||||
if (r.headers.find("X-Wings-Helper: 1\r\n") == std::string::npos)
|
||||
{
|
||||
out.status = 403;
|
||||
return out;
|
||||
}
|
||||
out.status = 200;
|
||||
const std::string route = r.path.substr(r.path.rfind('/') + 1);
|
||||
if (route == "status")
|
||||
{
|
||||
++status_reads;
|
||||
if (silent_after >= 0 && status_reads > silent_after)
|
||||
{
|
||||
out.status = 0;
|
||||
out.error = "timed out";
|
||||
return out;
|
||||
}
|
||||
if (!frozen)
|
||||
advance();
|
||||
out.body = body();
|
||||
}
|
||||
else if (route == "download" || route == "install")
|
||||
{
|
||||
const bool install = route == "install";
|
||||
(install ? installs : downloads).push_back(r.body);
|
||||
const int answer = install ? answer_install : answer_download;
|
||||
if (answer != 0)
|
||||
{
|
||||
out.status = answer;
|
||||
out.body = answer == 409 ? "{\"error\":\"an earlier update is waiting\",\"reason\":\"old_waiting\"}" : "{\"error\":\"url is not the official one\",\"reason\":\"invalid\"}";
|
||||
return out;
|
||||
}
|
||||
if (phase == "downloading" || phase == "verifying" || phase == "placing")
|
||||
{
|
||||
out.status = 409;
|
||||
out.body = "{\"error\":\"An update is already being installed. Wait for it.\",\"reason\":\"busy\"}";
|
||||
return out;
|
||||
}
|
||||
json::Value v;
|
||||
(void)json::parse(r.body, &v);
|
||||
std::string asked = v.text("version");
|
||||
if (!asked.empty() && asked[0] == 'v')
|
||||
asked.erase(0, 1); // like the real helper: 0.1.2 for v0.1.2
|
||||
if (install && (downloaded_version.empty() || downloaded_version != asked))
|
||||
{
|
||||
// Like the real helper: install only places a checked download, it never downloads.
|
||||
out.status = 409;
|
||||
out.body = "{\"error\":\"Download the update first.\",\"reason\":\"not_downloaded\"}";
|
||||
return out;
|
||||
}
|
||||
version = asked;
|
||||
size = static_cast<std::uint64_t>(v.number("size"));
|
||||
error.clear();
|
||||
installing = install;
|
||||
received = install ? size : 0;
|
||||
phase = install ? "verifying" : "downloading"; // install hashes the downloaded file again
|
||||
if (!install)
|
||||
downloaded_version.clear();
|
||||
out.status = 202;
|
||||
out.body = body();
|
||||
}
|
||||
else if (route == "cancel")
|
||||
{
|
||||
++cancels;
|
||||
if (phase == "downloading" || phase == "verifying")
|
||||
{
|
||||
phase = "idle";
|
||||
received = 0;
|
||||
}
|
||||
}
|
||||
else if (route == "stop")
|
||||
{
|
||||
if (phase == "placing")
|
||||
out.status = 409; // like the real helper: never stopped while renaming files
|
||||
else
|
||||
running = false;
|
||||
}
|
||||
else if (route == "tidy")
|
||||
{
|
||||
tidies.push_back(r.body);
|
||||
if (tidy_ok)
|
||||
old_kept = false;
|
||||
else
|
||||
{
|
||||
out.status = 409;
|
||||
out.body = "{\"error\":\"The new version isn't running yet.\",\"reason\":\"not_running\"}";
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// What the payload loader does with the ELF: the shipped build starts answering.
|
||||
WingsHelperSender sender()
|
||||
{
|
||||
return [this](const std::string &path, std::string *why)
|
||||
{
|
||||
sent.push_back(path);
|
||||
if (!loader_up)
|
||||
{
|
||||
*why = "Connection refused";
|
||||
return false;
|
||||
}
|
||||
running = true;
|
||||
build = shipped_build;
|
||||
frozen = false; // a fresh helper works its jobs
|
||||
// Like the real helper: a checked download (downloaded.json) survives a restart.
|
||||
phase = downloaded_version.empty() ? "idle" : "downloaded";
|
||||
if (!downloaded_version.empty())
|
||||
version = downloaded_version;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
std::string body() const
|
||||
{
|
||||
return "{\"helper\":\"" + build + "\",\"api\":" + std::to_string(api) + ",\"phase\":\"" + phase + "\",\"version\":\"" + version +
|
||||
"\",\"received\":" + std::to_string(received) + ",\"size\":" + std::to_string(size) +
|
||||
",\"error\":\"" + error + "\",\"target\":\"" + target + "\",\"installed_version\":\"" +
|
||||
installed_version + "\",\"old_kept\":" +
|
||||
(old_kept ? "true" : "false") + "}";
|
||||
}
|
||||
|
||||
void advance()
|
||||
{
|
||||
if (phase == "downloading")
|
||||
{
|
||||
received = received + step >= size ? size : received + step;
|
||||
if (received >= size)
|
||||
phase = "verifying";
|
||||
}
|
||||
else if (phase == "verifying")
|
||||
{
|
||||
if (!fail_with.empty())
|
||||
{
|
||||
phase = "error";
|
||||
error = fail_with;
|
||||
}
|
||||
else if (installing)
|
||||
phase = "placing";
|
||||
else
|
||||
{
|
||||
phase = "downloaded";
|
||||
downloaded_version = version;
|
||||
}
|
||||
}
|
||||
else if (phase == "placing")
|
||||
{
|
||||
phase = "placed";
|
||||
old_kept = true; // the file that was there is kept as .old
|
||||
target = "wings"; // like the real helper: its record now names what it placed
|
||||
installed_version = version;
|
||||
downloaded_version.clear(); // the download was moved into place
|
||||
}
|
||||
}
|
||||
|
||||
bool frozen = false; // true: status reads do not move the phase on
|
||||
bool running = false;
|
||||
bool loader_up = true;
|
||||
int api = kWingsHelperApi; // 2, from app/helper/version.h
|
||||
std::string build = kWingsHelperBuild;
|
||||
std::string shipped_build = kWingsHelperBuild;
|
||||
std::string phase = "idle";
|
||||
std::string version;
|
||||
std::uint64_t received = 0;
|
||||
std::uint64_t size = 0;
|
||||
std::uint64_t step = 30000;
|
||||
std::string error;
|
||||
std::string fail_with; // verifying ends in this error
|
||||
std::string target = "none";
|
||||
bool old_kept = false;
|
||||
std::string installed_version; // what the helper's record says is in /data/homebrew
|
||||
bool tidy_ok = true; // false: /tidy refuses (the placed version is not running yet)
|
||||
std::string downloaded_version; // the checked download the helper holds ("" none), kept across restarts
|
||||
bool installing = false; // the running job is an install (verifying leads to placing, not downloaded)
|
||||
int answer_install = 0; // non-zero: /install answers this status
|
||||
int answer_download = 0; // non-zero: /download answers this status
|
||||
int silent_after = -1; // >= 0: status reads after this many time out
|
||||
int status_reads = 0;
|
||||
int cancels = 0;
|
||||
std::vector<std::string> requests;
|
||||
std::vector<std::string> downloads;
|
||||
std::vector<std::string> installs;
|
||||
std::vector<std::string> tidies;
|
||||
std::vector<std::string> sent;
|
||||
};
|
||||
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,189 @@
|
||||
// Wings TV app - Bad-link flags: link ids, Best skips flagged links, expiry, cap,
|
||||
// the saved file and the error-to-reason table.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "orbit/game_page.hpp"
|
||||
#include "orbit/link_flags.hpp"
|
||||
#include "orbit/ranking.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr std::int64_t kNow = 1791936000; // 2026-10-14 00:00 UTC
|
||||
|
||||
std::string tmp_dir()
|
||||
{
|
||||
char path[] = "/tmp/wings-flags-XXXXXX";
|
||||
return mkdtemp(path) != nullptr ? std::string(path) : std::string("/tmp");
|
||||
}
|
||||
|
||||
TEST(LinkFlags, UrlNormalisationKeepsTheIdStable)
|
||||
{
|
||||
EXPECT_EQ(normalise_link_url("HTTPS://WWW.Example.COM:443/File/AbC/?utm_source=x&id=7&token=zz#frag"),
|
||||
"https://example.com/File/AbC?id=7");
|
||||
EXPECT_EQ(normalise_link_url("https://mega.nz/file/ABC#key"), "https://mega.nz/file/ABC");
|
||||
EXPECT_EQ(normalise_link_url("https://h.to/x?X-Amz-Signature=1&expires=2"), "https://h.to/x");
|
||||
EXPECT_EQ(normalise_link_url(""), "");
|
||||
const std::string id = link_id("https://1fichier.com/?abc123", "ppsa01234");
|
||||
EXPECT_EQ(id.size(), 16u);
|
||||
EXPECT_EQ(id, link_id(" HTTPS://1Fichier.com/?abc123&fbclid=q#x", "PPSA01234"));
|
||||
EXPECT_NE(id, link_id("https://1fichier.com/?abc124", "PPSA01234"));
|
||||
EXPECT_NE(id, link_id("https://1fichier.com/?abc123", "PPSA01235"));
|
||||
EXPECT_EQ(link_id("", "PPSA01234"), "");
|
||||
// The raw URL never shows in the id.
|
||||
EXPECT_EQ(id.find("fichier"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(LinkFlags, AddClearLookupAndExpiry)
|
||||
{
|
||||
LinkFlags flags;
|
||||
flags.add("00000000000000aa", FlagReason::removed, kNow);
|
||||
flags.add("00000000000000bb", FlagReason::none, kNow); // ignored
|
||||
EXPECT_EQ(flags.size(), 1u);
|
||||
ASSERT_NE(flags.find("00000000000000aa", kNow), nullptr);
|
||||
EXPECT_EQ(flags.find("00000000000000aa", kNow)->reason, FlagReason::removed);
|
||||
// Still there on day 59, gone on day 60: the link comes back.
|
||||
EXPECT_TRUE(flags.flagged("00000000000000aa", kNow + (kFlagDays - 1) * 86400));
|
||||
EXPECT_FALSE(flags.flagged("00000000000000aa", kNow + kFlagDays * 86400));
|
||||
EXPECT_EQ(flags.expire(kNow + kFlagDays * 86400), 1u);
|
||||
EXPECT_EQ(flags.size(), 0u);
|
||||
flags.add("00000000000000aa", FlagReason::password, kNow);
|
||||
flags.add("00000000000000aa", FlagReason::by_hand, kNow + 5); // newest reason wins
|
||||
EXPECT_EQ(flags.find("00000000000000aa", kNow)->reason, FlagReason::by_hand);
|
||||
EXPECT_TRUE(flags.clear("00000000000000aa"));
|
||||
EXPECT_FALSE(flags.clear("00000000000000aa"));
|
||||
flags.add("00000000000000aa", FlagReason::removed, kNow);
|
||||
flags.add("00000000000000bb", FlagReason::removed, kNow + 1);
|
||||
EXPECT_EQ(flags.list(kNow + 2).front().first, "00000000000000bb"); // newest first
|
||||
flags.clear_all();
|
||||
EXPECT_EQ(flags.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(LinkFlags, CapDropsTheOldest)
|
||||
{
|
||||
LinkFlags flags;
|
||||
for (std::size_t i = 0; i < kMaxFlags + 3; ++i)
|
||||
flags.add(link_id("https://h.to/" + std::to_string(i), "PPSA00001"), FlagReason::host_error,
|
||||
kNow + static_cast<std::int64_t>(i));
|
||||
EXPECT_EQ(flags.size(), kMaxFlags);
|
||||
EXPECT_FALSE(flags.flagged(link_id("https://h.to/0", "PPSA00001"), kNow + 1000));
|
||||
EXPECT_FALSE(flags.flagged(link_id("https://h.to/2", "PPSA00001"), kNow + 1000));
|
||||
EXPECT_TRUE(flags.flagged(link_id("https://h.to/3", "PPSA00001"), kNow + 1000));
|
||||
}
|
||||
|
||||
TEST(LinkFlags, SaveAndLoadRoundTrip)
|
||||
{
|
||||
const std::string path = tmp_dir() + "/link_flags.v1";
|
||||
LinkFlags flags;
|
||||
flags.add(link_id("https://a.to/1", "PPSA00001"), FlagReason::removed, kNow);
|
||||
flags.add(link_id("https://a.to/2", "PPSA00001"), FlagReason::by_hand, kNow - 10);
|
||||
EXPECT_EQ(flags.save({path}), 1);
|
||||
LinkFlags again;
|
||||
ASSERT_TRUE(again.load({tmp_dir() + "/missing", path}));
|
||||
EXPECT_EQ(again.encode(), flags.encode());
|
||||
EXPECT_EQ(again.find(link_id("https://a.to/2", "PPSA00001"), kNow)->reason, FlagReason::by_hand);
|
||||
// Damaged lines are skipped, not fatal.
|
||||
LinkFlags damaged;
|
||||
EXPECT_TRUE(damaged.decode("zz 1 5\n00000000000000aa 99 5\n00000000000000aa 2 x\n00000000000000ab 2 7\n"));
|
||||
EXPECT_EQ(damaged.size(), 1u);
|
||||
LinkFlags none;
|
||||
EXPECT_FALSE(none.load({tmp_dir() + "/missing"}));
|
||||
}
|
||||
|
||||
TEST(LinkFlags, ReasonFromError)
|
||||
{
|
||||
EXPECT_EQ(reason_from_error("", 404), FlagReason::removed);
|
||||
EXPECT_EQ(reason_from_error("", 410), FlagReason::removed);
|
||||
EXPECT_EQ(reason_from_error("", 503), FlagReason::host_error);
|
||||
EXPECT_EQ(reason_from_error("", 429), FlagReason::none);
|
||||
EXPECT_EQ(reason_from_error("File not found or deleted"), FlagReason::removed);
|
||||
EXPECT_EQ(reason_from_error("Provider resolve failed: premium only"), FlagReason::host_error);
|
||||
EXPECT_EQ(reason_from_error("Wrong password"), FlagReason::password);
|
||||
EXPECT_EQ(reason_from_error("CRC error in part 3"), FlagReason::broken_archive);
|
||||
EXPECT_EQ(reason_from_error("Unexpected end of archive"), FlagReason::broken_archive);
|
||||
EXPECT_EQ(reason_from_error("No PKG found in archive"), FlagReason::wrong_file);
|
||||
EXPECT_EQ(reason_from_error("Cancelled by user"), FlagReason::none);
|
||||
EXPECT_EQ(reason_from_error("No space left on device"), FlagReason::none);
|
||||
EXPECT_EQ(reason_from_error("Connection timed out"), FlagReason::none);
|
||||
EXPECT_EQ(reason_from_error("something odd"), FlagReason::none);
|
||||
}
|
||||
|
||||
TEST(LinkFlags, CardLine)
|
||||
{
|
||||
EXPECT_EQ(flag_date(kNow), "14 Oct");
|
||||
EXPECT_EQ(flag_date(0), "1 Jan");
|
||||
EXPECT_EQ(flag_line(LinkFlag{FlagReason::removed, kNow + 3600, ""}), "Failed for you on 14 Oct: file removed");
|
||||
EXPECT_EQ(flag_line(LinkFlag{FlagReason::by_hand, kNow, ""}), "Marked bad on 14 Oct");
|
||||
EXPECT_EQ(flag_line(LinkFlag{}), "");
|
||||
}
|
||||
|
||||
TEST(LinkFlags, BestSkipsFlaggedWhileAnotherExists)
|
||||
{
|
||||
std::vector<Candidate> c(3);
|
||||
c[0].tier = LinkTier::native_pkg;
|
||||
c[0].index = 0;
|
||||
c[0].flagged = true;
|
||||
c[1].tier = LinkTier::debrid;
|
||||
c[1].index = 1;
|
||||
c[2].tier = LinkTier::supported;
|
||||
c[2].index = 2;
|
||||
LinkPrefs prefs;
|
||||
Ranking r = rank_links(c, prefs);
|
||||
EXPECT_EQ(r.best, 1);
|
||||
EXPECT_EQ(r.order, (std::vector<int>{1, 2, 0})); // flagged last
|
||||
EXPECT_EQ(r.flagged, 1);
|
||||
// Every link flagged: Best still offers one, and says so.
|
||||
for (Candidate &x : c)
|
||||
x.flagged = true;
|
||||
r = rank_links(c, prefs);
|
||||
EXPECT_EQ(r.best, 0);
|
||||
EXPECT_NE(r.why.find("failed for you before"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(LinkFlags, AppliedToAGamePage)
|
||||
{
|
||||
PackageDetail d;
|
||||
d.package.id = "10";
|
||||
d.package.title = "Game X";
|
||||
d.package.source_name = "S";
|
||||
d.package.link_count = 2;
|
||||
PackageLink a;
|
||||
a.index = 0;
|
||||
a.name = "USA 12.xx Base - 1fichier";
|
||||
a.host = "1fichier.com";
|
||||
a.route = LinkRoute::debrid; // the better tier: Best without the flag
|
||||
a.url = "https://1fichier.com/?aaa";
|
||||
PackageLink b;
|
||||
b.index = 1;
|
||||
b.name = "USA 12.xx Base - Rootz";
|
||||
b.host = "rootz.so";
|
||||
b.provider = "Rootz";
|
||||
b.url = "https://rootz.so/d/bbb";
|
||||
d.links = {a, b};
|
||||
GamePage page = build_game_page({d});
|
||||
ASSERT_EQ(page.links.size(), 2u);
|
||||
LinkFlags flags;
|
||||
flags.add(page_link_id(page, 0, "PPSA00001"), FlagReason::removed, kNow);
|
||||
apply_link_flags(&page, flags, "PPSA00001", kNow);
|
||||
EXPECT_EQ(page.links[0].flag_reason, static_cast<std::uint8_t>(FlagReason::removed));
|
||||
EXPECT_EQ(page.links[1].flag_reason, 0);
|
||||
rank_page(&page, LinkPrefs{});
|
||||
ASSERT_GE(page.best, 0);
|
||||
EXPECT_EQ(page.mirrors[static_cast<std::size_t>(page.best)].host, "Rootz");
|
||||
EXPECT_EQ(page.mirrors[static_cast<std::size_t>(page.best)].flagged_link(page), -1);
|
||||
// Cleared: the flag is gone on the next apply.
|
||||
flags.clear(page_link_id(page, 0, "PPSA00001"));
|
||||
apply_link_flags(&page, flags, "PPSA00001", kNow);
|
||||
EXPECT_EQ(page.links[0].flag_reason, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace orbit
|
||||
@@ -0,0 +1,205 @@
|
||||
// Wings TV app - B-20 bad-link flags wired into the Store and Settings > Wings.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "fake_backend.hpp"
|
||||
#include "orbit/link_flags.hpp"
|
||||
#include "orbit/screens.hpp"
|
||||
#include "orbit/store.hpp"
|
||||
#include "support.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace orbit
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
const std::string kId = link_id("https://host.example/file.rar", "CUSA00001");
|
||||
|
||||
Job job(const std::string &id, const std::string &status, const std::string &error = {})
|
||||
{
|
||||
Job j;
|
||||
j.id = id;
|
||||
j.status = status;
|
||||
j.error = error;
|
||||
return j;
|
||||
}
|
||||
|
||||
// The app-wide flags for one test (no files written).
|
||||
struct Flags
|
||||
{
|
||||
LinkFlags flags;
|
||||
Flags()
|
||||
{
|
||||
set_link_flags(&flags, {});
|
||||
}
|
||||
~Flags()
|
||||
{
|
||||
set_link_flags(nullptr, {});
|
||||
}
|
||||
};
|
||||
|
||||
// A flagged start, then the job it queued shows up in the next queue read.
|
||||
void start_flagged(Store &store)
|
||||
{
|
||||
store.post_jobs_for_test({job("old", "completed")});
|
||||
store.tick();
|
||||
store.set_link_flag_id(kId, "Some Game");
|
||||
store.start_link("pkg1", 0, "");
|
||||
store.post_jobs_for_test({job("old", "completed"), job("j1", "downloading")});
|
||||
store.tick();
|
||||
ASSERT_EQ(store.job_flag_id("j1"), kId);
|
||||
EXPECT_EQ(store.job_flag_id("old"), "");
|
||||
}
|
||||
|
||||
TEST(LinkFlagsWiring, AFailedJobFlagsItsLink)
|
||||
{
|
||||
Flags f;
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
start_flagged(store);
|
||||
store.post_jobs_for_test({job("j1", "failed", "File not found or deleted")});
|
||||
store.tick();
|
||||
const LinkFlag *flag = f.flags.find(kId, static_cast<std::int64_t>(std::time(nullptr)));
|
||||
ASSERT_NE(flag, nullptr);
|
||||
EXPECT_EQ(flag->reason, FlagReason::removed);
|
||||
EXPECT_EQ(flag->title, "Some Game");
|
||||
}
|
||||
|
||||
TEST(LinkFlagsWiring, CancelledRetryingAndCatalogueMissesAreNotFlagged)
|
||||
{
|
||||
Flags f;
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
start_flagged(store);
|
||||
store.post_jobs_for_test({job("j1", "retrying", "HTTP 503")});
|
||||
store.tick();
|
||||
store.post_jobs_for_test({job("j1", "cancelled", "Cancelled")});
|
||||
store.tick();
|
||||
store.post_jobs_for_test({job("j1", "failed", "Download link not found.")});
|
||||
store.tick();
|
||||
EXPECT_EQ(f.flags.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(LinkFlagsWiring, AJobOfAnUnflaggedStartIsNotFlagged)
|
||||
{
|
||||
Flags f;
|
||||
host::FakeBackend backend;
|
||||
Store store(backend);
|
||||
store.post_jobs_for_test({});
|
||||
store.tick();
|
||||
store.start_link("pkg1", 0, ""); // no set_link_flag_id(): a direct link, say
|
||||
store.post_jobs_for_test({job("j2", "failed", "File not found or deleted")});
|
||||
store.tick();
|
||||
EXPECT_EQ(f.flags.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(LinkFlagsWiring, AMarkAndItsClearRoundTripThroughTheFile)
|
||||
{
|
||||
LinkFlags flags;
|
||||
flags.add(kId, FlagReason::by_hand, 1760400000, "Some\nGame");
|
||||
LinkFlags read;
|
||||
ASSERT_TRUE(read.decode(flags.encode()));
|
||||
const LinkFlag *flag = read.find(kId, 1760400000);
|
||||
ASSERT_NE(flag, nullptr);
|
||||
EXPECT_EQ(flag->reason, FlagReason::by_hand);
|
||||
EXPECT_EQ(flag->title, "Some Game");
|
||||
flags.add(kId, FlagReason::removed, 1760400100); // no name: keeps the old one
|
||||
EXPECT_EQ(flags.find(kId, 1760400100)->title, "Some Game");
|
||||
EXPECT_TRUE(flags.clear(kId));
|
||||
ASSERT_TRUE(read.decode(flags.encode()));
|
||||
EXPECT_EQ(read.size(), 0u);
|
||||
// An older file without names still reads.
|
||||
ASSERT_TRUE(read.decode(kId + " 2 1760400000\n"));
|
||||
EXPECT_EQ(read.size(), 1u);
|
||||
}
|
||||
|
||||
// ---- Settings > Wings ----
|
||||
|
||||
struct Mgr
|
||||
{
|
||||
test::TestFonts fonts;
|
||||
host::FakeBackend backend;
|
||||
test::FakeSink sink;
|
||||
Store store{backend};
|
||||
ArtCache art{backend, sink};
|
||||
Context context{fonts.type, fonts.fonts, store, art};
|
||||
App app{context};
|
||||
Feedback feedback;
|
||||
|
||||
Mgr() : backend(host::FakeMode::normal)
|
||||
{
|
||||
store.poll_once();
|
||||
store.load_catalog_once();
|
||||
app.show_tab(tab::settings);
|
||||
frame();
|
||||
}
|
||||
void frame(Direction nav = Direction::none, std::uint32_t press = 0)
|
||||
{
|
||||
InputFrame input;
|
||||
input.connected = true;
|
||||
input.nav = nav;
|
||||
input.pressed = press;
|
||||
feedback.clear();
|
||||
app.update(input, 1.0f / 60.0f, feedback);
|
||||
Frame drawn;
|
||||
app.draw(drawn);
|
||||
}
|
||||
void press(Action action)
|
||||
{
|
||||
frame(Direction::none, hui::action_bit(action));
|
||||
}
|
||||
void select(const std::string &title)
|
||||
{
|
||||
const auto rows = app.settings_screen().row_list();
|
||||
int row = -1;
|
||||
for (std::size_t i = 0; i < rows.size(); ++i)
|
||||
if (rows[i].title == title)
|
||||
row = static_cast<int>(i);
|
||||
ASSERT_GE(row, 0) << title;
|
||||
const int at = app.settings_screen().index();
|
||||
for (int i = 0; i < std::abs(row - at); ++i)
|
||||
frame(row > at ? Direction::down : Direction::up);
|
||||
press(Action::confirm);
|
||||
}
|
||||
};
|
||||
|
||||
TEST(LinkFlagsWiring, SettingsClearsAllFlagsAfterAsking)
|
||||
{
|
||||
Flags f;
|
||||
const auto now = static_cast<std::int64_t>(std::time(nullptr));
|
||||
f.flags.add(kId, FlagReason::removed, now, "Some Game");
|
||||
f.flags.add(link_id("https://other.example/b.rar", "CUSA00002"), FlagReason::by_hand, now);
|
||||
Mgr m;
|
||||
m.app.settings_screen().set_group(SettingsScreen::Group::wings);
|
||||
m.frame();
|
||||
m.select("Clear all bad-link flags");
|
||||
EXPECT_TRUE(m.app.settings_screen().modal());
|
||||
m.press(Action::confirm); // the focus starts on Cancel
|
||||
EXPECT_EQ(f.flags.size(), 2u);
|
||||
m.select("Clear all bad-link flags");
|
||||
m.frame(Direction::right);
|
||||
m.press(Action::confirm);
|
||||
EXPECT_EQ(f.flags.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(LinkFlagsWiring, SettingsListsFlagsAndSelectClearsOne)
|
||||
{
|
||||
Flags f;
|
||||
const auto now = static_cast<std::int64_t>(std::time(nullptr));
|
||||
f.flags.add(kId, FlagReason::removed, now, "Some Game");
|
||||
Mgr m;
|
||||
m.app.settings_screen().set_group(SettingsScreen::Group::wings);
|
||||
m.frame();
|
||||
m.select("Bad links");
|
||||
EXPECT_TRUE(m.app.settings_screen().modal());
|
||||
m.press(Action::confirm); // the one flag
|
||||
EXPECT_EQ(f.flags.size(), 0u);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace orbit
|
||||