mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 03:20:06 -05:00
Compare commits
No files matched your search
@@ -175,6 +175,18 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.tag }}
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
|
||||
# make payload embeds the console web page (payload/Makefile: build/webui_page.c runs
|
||||
# `npm run build:console` in client/), so the client's dependencies must be installed.
|
||||
- name: Install client deps (console web page)
|
||||
working-directory: client
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Install payload toolchain
|
||||
timeout-minutes: 12
|
||||
run: |
|
||||
|
||||
@@ -696,6 +696,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
|
||||
# make payload embeds the console web page (payload/Makefile: build/webui_page.c runs
|
||||
# `npm run build:console` in client/), so the client's dependencies must be installed.
|
||||
- name: Install client deps (console web page)
|
||||
working-directory: client
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Install payload toolchain
|
||||
timeout-minutes: 12
|
||||
run: |
|
||||
|
||||
@@ -8,10 +8,9 @@ name: pages
|
||||
#
|
||||
# When it deploys:
|
||||
# - a change to site/ (or this file) on main;
|
||||
# - after the publish workflow finishes. A release made by publish.yml does not fire the
|
||||
# `release` event below: GitHub doesn't start workflows from its own token's actions;
|
||||
# - a release published, edited or deleted by hand (promoting a pre-release is an edit);
|
||||
# - once a day, so the snapshot never drifts far;
|
||||
# - after the publish workflow finishes;
|
||||
# - once a day, so the snapshot never drifts far (this is what picks up a pre-release promoted
|
||||
# by hand; the page itself reads the releases API live, so visitors see it at once);
|
||||
# - by hand (Actions → pages → Run workflow).
|
||||
|
||||
on:
|
||||
@@ -21,8 +20,8 @@ on:
|
||||
workflow_run:
|
||||
workflows: [publish]
|
||||
types: [completed]
|
||||
release:
|
||||
types: [published, edited, deleted]
|
||||
# No `release` trigger: that run's ref is the tag, and the github-pages environment only
|
||||
# deploys from main, so it was always rejected ("Tag ... is not allowed to deploy").
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
@@ -54,9 +53,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v4
|
||||
- uses: actions/configure-pages@v6
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: site
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -67,6 +67,8 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
- name: Sync version from VERSION
|
||||
run: node scripts/update-version.js
|
||||
|
||||
@@ -99,6 +101,12 @@ jobs:
|
||||
- name: Install pinned PS5 Payload SDK
|
||||
run: sudo env PS5_SDK_INSTALL_DIR=/opt/ps5-payload-sdk bash scripts/install-ps5-sdk.sh
|
||||
|
||||
# make payload embeds the console web page (payload/Makefile: build/webui_page.c runs
|
||||
# `npm run build:console` in client/), so the client's dependencies must be installed.
|
||||
- name: Install client deps (console web page)
|
||||
working-directory: client
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Build payload ELF
|
||||
env:
|
||||
PS5_PAYLOAD_SDK: /opt/ps5-payload-sdk
|
||||
|
||||
@@ -53,6 +53,7 @@ scripts/.translate-cache.json
|
||||
|
||||
# ─── Client (Vite / Tauri) ───────────────────────────────────────────────
|
||||
client/dist/
|
||||
client/dist-console/
|
||||
client/.vite/
|
||||
client/out/
|
||||
client/.cache/
|
||||
|
||||
+671
-1
@@ -4,7 +4,669 @@ What's new in ps5upload, written for humans.
|
||||
|
||||
---
|
||||
|
||||
## 7.0.3
|
||||
## 7.1.11
|
||||
|
||||
- **Browser avatar upload (#486).** Choose an image from the browser device, preview crop or
|
||||
fit, and confirm applying it to the selected console user. Works with Docker engines too.
|
||||
- **Remote conversion inspection (#485).** Allow slow console image reads to complete beyond
|
||||
the ordinary one-minute request deadline, including build preflight and size estimates.
|
||||
|
||||
- **Organize the whole collection.** Include supported images and extracted game folders as
|
||||
well as packages. File them under each configured collection root, preserve image formats
|
||||
and intact game folders, and report skipped items without claiming they are already organized.
|
||||
|
||||
## 7.1.10
|
||||
|
||||
- **Compressed images in Game files.** `.ffpfsc` files now appear, including images beyond the
|
||||
first directory page. Open their folder in Files to decompress before editing; compressed
|
||||
images use ShadowMount+ for playback.
|
||||
- **Truthful tile removal (#480).** Removing PS5Upload's own tile now waits for the removal
|
||||
result and reports save/uninstall failures. Update the helper for this fix.
|
||||
- **Bulk conversion and copying (#479, #481).** Convert recognizes packages inside game
|
||||
subfolders, names generic `game.pkg` image outputs after the game folder, and accepts supported
|
||||
image/package sources for image conversion. Files can queue further picks during a copy;
|
||||
desktop Stream can select multiple packages and queues bases before updates and DLC.
|
||||
- **Payload setup.** Repair the LZ4 Compression catalogue source, add the upstream elfldr
|
||||
download, recognize HyperHEN's specific mount process, and make Quick bring-up easier to find.
|
||||
- **Back navigation.** Duplicate Back requests now share one pending history navigation.
|
||||
Firmware-specific controller mapping still needs the reporter's input log.
|
||||
- **Conversion diagnostics.** Record stage timings and compression settings for performance
|
||||
reports. No compression or small-file speed improvement is claimed.
|
||||
|
||||
- **Keep Rename first in the Files menu.** Properties and permissions remain available just
|
||||
below it, with Delete last. The phone menu now preserves its familiar action order.
|
||||
- **Reliable coverage checks.** Transfer speed thresholds run on normal builds; instrumented
|
||||
coverage builds continue to check the transfers without comparing their speed.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.9
|
||||
|
||||
- **File properties and custom permissions (#478).** In Files, open Properties to see a file or
|
||||
folder's size, modification time and permissions, and set the permissions you need. Applying
|
||||
to a whole folder is optional; drives that cannot keep Unix permissions are called out.
|
||||
- **Restore a hidden Minion tile (#478).** Settings can show, restore or hide PS5Upload Minion
|
||||
on the PS5 home screen through your paired connection, even when Minion's LAN access is off.
|
||||
Update the helper to use this control.
|
||||
- **Import a local JSON cheat.** Cheats can now import a trainer from your device or engine
|
||||
computer. Imports are checked, never replace existing files and leave toggles off. Use the
|
||||
exact title ID and game version; renaming a trainer does not make its patches compatible.
|
||||
- **Clearer save backup guidance.** Android shows where backups are saved. Save guidance now
|
||||
explains account and title restrictions, and no longer presents restarting as a verified fix
|
||||
for PS4 restore errors.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.8
|
||||
|
||||
- **Convert reads more Kraken-compressed PS5 packages (#477).** Some packages compressed at the
|
||||
highest Kraken level stopped Convert with "kraken: chunk too short" although they were fine:
|
||||
a small piece at the start of a block was refused. They convert now.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.7
|
||||
|
||||
- **Organize packages works on your servers too.** Folders on a saved SMB, FTP, SFTP or WebDAV
|
||||
server can now be organized like folders on this computer: the same preview, the same
|
||||
Platform › Game ID - Title layout, renames only, nothing overwritten, and every run can be
|
||||
undone. A dropped connection stops the run cleanly and it can still be undone once the server is
|
||||
back. Undo refuses if the saved connection now points at a different server. On WebDAV, folders
|
||||
a run empties are left in place.
|
||||
- **WebDAV servers that redirect folder addresses work again.** Opening a folder on such a server
|
||||
(Apache, for example) failed with a sign-in error.
|
||||
- **Clearer connection errors.** A very short saved password no longer garbles error messages.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.6
|
||||
|
||||
- **Uploading many files from Files is much faster.** Picking several files now sends them to the
|
||||
PS5 together instead of one at a time: 240 small files went from over 2 minutes to about 5
|
||||
seconds, and 5,000 small files from 44 minutes to under 30 seconds. A pick shows as one task
|
||||
with one Cancel, and a stopped upload resumes where it left off.
|
||||
- **Choose what happens when a name is taken.** Instead of one all-or-nothing Overwrite question,
|
||||
each clash offers Skip, Keep both or Replace (Merge for a folder onto a folder), with "do the
|
||||
same for the rest". Keep both is the default, so Enter never overwrites, and names that differ
|
||||
only in upper and lower case count as the same name.
|
||||
- **Uninstall tells you whether the game is really gone (#476).** Uninstall used to say
|
||||
"Unregistered" whatever the console answered, and then quietly showed the game again if the
|
||||
console had kept it. The helper now checks that the game has actually left the console. If the
|
||||
PS5 refused, you see an error with its code, and a game that is still installed stays in the
|
||||
list with a note instead of disappearing and coming back.
|
||||
- **Organize packages finds packages that sit loose in a collection folder.** A .pkg placed
|
||||
directly in a collection folder (not in a subfolder) was skipped, and with only loose packages
|
||||
Organize said no folder held any. They are now organized into the same Platform › Game ID -
|
||||
Title layout inside that folder.
|
||||
- **The server folder browser opens in front.** In Collection → Folders, "Add a folder on …"
|
||||
opened its folder browser behind the dialog.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.5
|
||||
|
||||
- **Uploading many small files no longer gets stuck (#475).** Picking hundreds of files in Files
|
||||
(for example a Switch firmware folder) stopped after about 30 files with "the console is busy"
|
||||
and never recovered. The console now lets go of each finished upload, so every file goes
|
||||
through: a 240-file, 347 MB folder uploaded in about 2 minutes 20 seconds in testing.
|
||||
- **PS5Upload Files is now PS5Upload Minion.** Same page and tile, new name; the tile on the
|
||||
home screen picks up the new name the next time the helper starts.
|
||||
- **Payload catalog fixes.** websrv's newest release showed an error instead of the release,
|
||||
because its release notes are empty. A payload whose GitHub page has been taken down (such as
|
||||
Ghostpad) now says so plainly and keeps offering the version saved in the app. How long ago a
|
||||
release was saved reads like "saved 3 days ago" instead of a count of seconds.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.4
|
||||
|
||||
- **Cheats no longer crash the console.** Switching a cheat on or off could crash the game or the
|
||||
whole console on newer firmware (seen with Marvel's Wolverine). Cheats are now written straight
|
||||
into the game's memory, the way that works on these firmwares, instead of changing the game's
|
||||
memory protection. A cheat now goes on completely or not at all: if any part can't be written,
|
||||
the parts already written are put back. Cheats can only change the game itself, never the
|
||||
system, and turning one cheat off no longer breaks another that shares code with it. Cheat
|
||||
files too large to read are refused instead of being applied in part.
|
||||
- **Find and download cheats on the console.** In PS5Upload Files, Cheats has **Find cheats**:
|
||||
it lists the cheat files for the running game from the same collections the PC app uses,
|
||||
marks the one for your game's version, and downloads it straight to the console, where its
|
||||
cheats show up at once. If the PS5 can't reach GitHub (for example when DNS blocking is on),
|
||||
the page says so.
|
||||
- **Save backups are named after the game too.** A backup saved on your computer is now named
|
||||
like `PPSA01234-Game Name-User.zip`.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.3
|
||||
|
||||
- **Payloads: one place for what's on your PS5.** The Payloads screen now has three tabs:
|
||||
Catalog, Send and **On your PS5**. On your PS5 shows a card for each payload that is running
|
||||
or installed on the console, with its status, Open (for payloads with a web page), Update when a
|
||||
newer release is out, and Stop. **Manage** opens that payload's tools in the card:
|
||||
ShadowMount+ mounts, the nanoDNS editor, Payload Manager and HearBridge. Old links to the
|
||||
separate tabs still work.
|
||||
- **HearBridge in the catalog and on your PS5.** HearBridge plays the PS5's audio on ordinary
|
||||
Bluetooth headphones or speakers. Its card shows the connected headset, battery, link quality,
|
||||
latency, volume and the game's profile; Manage sets volume and mute, reconnects, switches
|
||||
between saved headsets, scans for new ones, turns night mode on or off, sets latency, and stops
|
||||
it. Pairing, EQ and everything else open in HearBridge's own page.
|
||||
- **Processes & ports.** The Processes screen now has a Ports tab next to it, showing which ports
|
||||
are open on the console and what usually listens there (HearBridge and PS5Upload Files
|
||||
included).
|
||||
- **PS5Upload Files: permissions, new files and text editing.** Info → Permissions sets read,
|
||||
write and execute for owner, group and others (or the number, like 755), for a folder's
|
||||
contents too; drives that don't keep permissions (exFAT, FAT) say so. Create a new file, and
|
||||
edit text files up to 1 MiB with the on-screen keyboard. Saving never leaves a half-written
|
||||
file, keeps the file's permissions and line endings, and warns when the file changed since
|
||||
you opened it.
|
||||
- **PS5Upload Files: upload and download from your phone or computer.** When you open the page
|
||||
from another device (Settings → Allow other devices), Files has **Upload here** to send files
|
||||
and folders to the console and **Download** for a file, or a whole folder as one .tar file.
|
||||
The usual Skip / Replace / Keep both choices apply, a cancelled upload leaves nothing behind,
|
||||
and a stalled connection is let go so the page stays responsive. On a home network this
|
||||
moved about 75 MB/s up and 110 MB/s down.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.2
|
||||
|
||||
- **Save data on a shared PS5 shows whose save is whose.** Each save is labelled with its
|
||||
owner's name instead of a number, and the screen starts on the user who is signed in at the
|
||||
console; "Whose saves" switches to another user or to everyone. Back up all and Restore all
|
||||
act on the saves shown. A backup saved on your computer is named after the game and the user
|
||||
(`PPSA01234-Alice.zip`).
|
||||
- **USB backups are kept per user.** Backups to a USB drive on the console now go into a folder
|
||||
per user (`<game>/<name>-<user id>/<date>/`). Before, every user's backups of a game shared one
|
||||
folder, so on a shared console "Restore all from USB" could put one user's latest backup into
|
||||
another user's save. Backups made before this update are still used when only one user on the
|
||||
console has a save of that game.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.1
|
||||
|
||||
- **The app scrolls again on macOS Big Sur.** On Big Sur's older built-in web engine the app
|
||||
window lost its fixed height, so no screen could scroll, and the side bar's rows lost their
|
||||
left padding. The app's styles now carry the older forms that engine understands.
|
||||
|
||||
---
|
||||
|
||||
## 7.1.0
|
||||
|
||||
- **PS5Upload Files: manage your files from the couch.** A new PS5Upload Files tile on the
|
||||
PS5 home screen opens a page in the PS5 browser that fills the TV. It has four sections:
|
||||
**Home** (the running game with Close game, the tasks that are running with Cancel, and your
|
||||
drives), **Files**, **Cheats** (switch the running game's cheats on and off) and **Settings**.
|
||||
The PS5Upload helper must be loaded. If it isn't, the page tries to show a note that says so
|
||||
(from a copy the PS5 browser keeps) and opens by itself once the helper answers. After an
|
||||
update, a banner offers to reload.
|
||||
- **Made for the controller.** ✕ selects, ○ steps back, △ opens an item's actions, and the PS5's
|
||||
own pointer works too. Every action also has a button on screen. Three short tips show the
|
||||
first time, Help lists the buttons that work on the current screen, and Controls shows which
|
||||
buttons your PS5 passes on to the page. Dark, Light, OLED and Peach themes, high contrast,
|
||||
the language and full screen are in Settings.
|
||||
- **Browse and organise.** A path bar with Go to path, pinned and recent folders, and your
|
||||
drives, with Eject for USB drives and Unmount for game images (refused while a game is
|
||||
running). Sort, filter, show hidden files and select all. Copy, cut and paste, with Replace,
|
||||
Keep both or Skip when a name is taken (and Apply to all); rename one item or many at once
|
||||
with a pattern (a change of letter case alone works too); new folder; delete; Fix permissions.
|
||||
- **Look inside files.** Info with folder size; Verify (BLAKE3 and CRC32); Decompress a
|
||||
`.ffpfsc` back to its image, with progress. The viewer shows text (with find), JSON, a
|
||||
summary of a game's `param.json`, hex, and pictures (step through a folder's images); a game
|
||||
folder shows its banner. Search finds files by name in this folder or on all drives (the first
|
||||
search builds an index), and Disk usage shows what takes the space.
|
||||
- **Works with the app on your PC.** File System shows the folders pinned on the console
|
||||
("Pinned on console"), and **Show on console** opens a folder on the TV; **Show on PC** on the
|
||||
console opens it in the PC app. Files being uploaded from your PC show an Uploading badge.
|
||||
These need the 7.1.0 helper; with an older one the app simply doesn't show them.
|
||||
- **Only the console can open it by default.** Settings → Allow other devices lets a phone or
|
||||
computer on your network open the page too. There is no password, so anyone on your network
|
||||
can then manage your files; turn it off when you're done. While it's on, text boxes on the
|
||||
console show a "Continue on your phone" code to type there instead.
|
||||
- **The tile is yours to remove.** Settings → Show PS5Upload Files on the home screen removes or
|
||||
restores it. The tile is left out of the app's game list (an older engine or Docker image may
|
||||
still list PSUP00001).
|
||||
- **Big app databases no longer hide installed games.** The console's app database was read
|
||||
only up to its first 256 entries, and a long list could be cut shorter still, with nothing
|
||||
saying so. On a console with many entries, a game installed by a homebrew installer (no
|
||||
home-screen metadata folder) that sorted past that point was missing from Games. The app now
|
||||
reads the whole database page by page. If the database still can't be read in full, games
|
||||
whose folder and registration are on the console are listed anyway, and the app logs a warning
|
||||
that the list may be incomplete (#473).
|
||||
|
||||
---
|
||||
|
||||
## 7.0.34
|
||||
|
||||
- **Unity games no longer crash when they run as a folder.** Some game dumps name a file
|
||||
`Il2cppUserAssemblies.prx` while the game loads `Il2CppUserAssemblies.prx`. An exFAT image
|
||||
ignores the difference, so the game played from its `.exfat`. The console's internal storage
|
||||
does not: the same game uploaded as a folder, or converted to one, crashed a second after it
|
||||
started. Uploading a game folder, and Convert's game-folder and `.ffpkg` outputs, now give each
|
||||
such file the spelling the game loads. Your copy on the computer is left as it is, and
|
||||
Convert's checks list what was renamed. Four affected games (PPSA22999, PPSA30883, PPSA31909,
|
||||
PPSA33090) launched and stayed running on a PS5 Pro (FW 13.60) once renamed.
|
||||
|
||||
## 7.0.33
|
||||
|
||||
- **A PS5 update package installs even when its base game isn't there.** The app refused any
|
||||
update whose base game wasn't installed ("Install the base game first"), but some PS5 updates
|
||||
carry the whole app: a YouTube update package installed fine through the PS5's own debug
|
||||
Package Installer while ps5upload refused it. For PS5 packages the app now warns ("the PS5 may
|
||||
still accept it") and lets the PS5 decide; if it can't, the PS5's own error still shows. PS4
|
||||
updates are still refused, since there Sony accepts the request and installs nothing.
|
||||
Stream-installed on both consoles (FW 13.60) with YouTube removed first.
|
||||
- **An install counts as done when its exact bytes are on the console.** Such a package lands as
|
||||
the app, not in the updates folder, so the app looked in the wrong place: it called an
|
||||
installed copy "not installed" and could not confirm a finished install.
|
||||
- **About links to the website.** About now has a Website link to ps5upload.phantomptr.com, and
|
||||
the author's name opens phantomptr.com.
|
||||
|
||||
## 7.0.32
|
||||
|
||||
- **Rest mode is found on a computer with Wi-Fi and a cable at once.** A Mac on both, sending
|
||||
through Wi-Fi, got "Host is down" from a sleeping PS5 and showed it as not answering, though
|
||||
the same console answered "standby" over the cable. The app now asks every network connection
|
||||
on the computer that shares the console's network, and treats "Host is down" as a reason to ask.
|
||||
Wake goes out the same way, so it now reaches a sleeping console from such a computer too
|
||||
(Mac, Linux and Android; Windows still uses its default connection).
|
||||
- **Wake is offered whenever the console isn't awake.** The PS5 power card showed Wake only when
|
||||
it had confirmed rest mode; it now shows whenever wake is set up and the console isn't known
|
||||
to be awake. The card says "In rest mode" or "Not answering", and greys out Rest mode, Reboot
|
||||
and Shut down while the console sleeps, since those need the helper.
|
||||
|
||||
## 7.0.31
|
||||
|
||||
- **Rest mode is shown as rest mode, not as a problem.** A PS5 in rest mode used to look the
|
||||
same as one that was off or broken: a red "Not connected", "Helper isn't running" on every
|
||||
console screen, and Connection's Check blaming the jailbreak or a VPN. The app now asks the
|
||||
console the way Remote Play does (it answers "standby" while it sleeps), and shows a calm,
|
||||
blue "In rest mode" in the status bar, the console tabs, Home and the game page. Console screens
|
||||
say "Your PS5 is in rest mode" with a Wake button when wake is set up. A console that answers
|
||||
nothing at all reads "Not answering", with the setting it needs to stay reachable in rest mode
|
||||
(Settings › System › Power Saving › Features Available in Rest Mode).
|
||||
- **Nothing fights a sleeping console.** The automatic reconnect no longer pokes a console in rest
|
||||
mode every 30 seconds all night; it waits, and sends the helper again once the console is
|
||||
awake. Uploads that meet rest mode wait ("Waiting for the PS5 to wake") and carry on after the
|
||||
wake, without using up their retries; in the browser version they used to fail. Keep-awake skips
|
||||
a sleeping console. Measured on both consoles on FW 13.60, including a full rest → wake →
|
||||
helper back → rest cycle on the Pro.
|
||||
|
||||
## 7.0.30
|
||||
|
||||
- **Files fits on a phone (#470).** On a narrow screen every file and folder row carried up to
|
||||
nine action buttons, which pushed the name out entirely, even at the smallest text size. Each
|
||||
row now shows its name and size with one ⋮ button that opens the same actions as a menu. The
|
||||
selection bar stays on one line, with its actions in a strip you can scroll sideways. The
|
||||
Recent paths list is solid instead of see-through (on every screen size). The toolbar, drive
|
||||
chips and path bar are tighter, so the list starts higher up. The desktop layout is unchanged.
|
||||
- **Stop helper (#471).** Connection has a "Stop helper" button next to Send helper. It turns
|
||||
the helper off on that console and keeps it off: the automatic reconnect leaves the console
|
||||
alone until you send the helper again or start something that needs it. The loader stays up,
|
||||
so sending payloads still works. It is refused while an upload, download or install is
|
||||
running on that console. Measured on a PS5 Pro: the helper exits in under a second.
|
||||
|
||||
## 7.0.29
|
||||
|
||||
- **Turn a game image or package back into a folder (#469).** Convert → What to make has a third
|
||||
choice, "A game folder". It takes any image Convert reads (`.exfat`, `.ffpkg`, `.ffpfs`,
|
||||
`.ffpfsc`) or a `.pkg` made with the default debug passcode (Convert's own packages, and fake
|
||||
packages from other tools when they used that passcode), wherever it is: this computer, a saved server or the PS5. It writes the game's
|
||||
files as a plain folder on this computer, empty folders and the package's icons included,
|
||||
checks them against the source, and only then gives the folder its name, so a stopped or failed
|
||||
run never leaves a folder that looks finished. A 125 GB exFAT image unpacked in 81 s, and a
|
||||
Minecraft package came back file-for-file equal however it was compressed. A retail package, or
|
||||
a fake one made with another passcode, can't be opened and is refused with that reason.
|
||||
|
||||
## 7.0.28
|
||||
|
||||
- **Android: transfers keep going when you leave the app, and you get told when they finish
|
||||
(#457).** Android pauses an app soon after it leaves the screen, and the notices that a job
|
||||
finished lived in the app's page, which pauses with it. Now, while anything is uploading,
|
||||
downloading or installing, ps5upload runs as a foreground service with a progress
|
||||
notification ("Uploading … 42% · 1.2 of 2.9 GB · 38 MB/s"), and posts "Upload complete" or
|
||||
"Upload failed" when it ends. A queue moves on to the next item in the background too.
|
||||
Measured on Android 16: a 1.8 GB upload with the screen off for 12 minutes finished, with one
|
||||
"complete" notification. On a computer, a finished job is announced right away while the
|
||||
window is minimised or hidden, and the queue no longer waits for the window to come back.
|
||||
- **Decompress a .ffpfsc on the console (#453).** Files → right-click a `.ffpfsc` →
|
||||
"Decompress to .exfat…" writes the game image it holds next to it, on the console, with
|
||||
progress and Stop: no download and re-upload through a computer. The console's own kernel
|
||||
does the decoding (the way ShadowMount+ reads these files), so nothing extra runs in the
|
||||
helper; a first version that decoded in the helper made it crash and was pulled from 7.0.23.
|
||||
Measured on both consoles: a 4.5 GB image in 40-65 s, byte-identical to the source. Needs the
|
||||
7.0.28 helper.
|
||||
- **Console to console: pick up a send from another device, and test the connection first
|
||||
(#444).** Tasks lists sends between your consoles that another phone or computer started,
|
||||
with Watch (follow a direct send), Take over, and Resume here for one that was paused or
|
||||
whose device went away. Pausing now stops both consoles' halves, so any device can resume it
|
||||
straight away. "Test connection" in Send to another console checks that the sending console
|
||||
can reach the other one on the route and address you chose (handy for a ps5-tailscale
|
||||
forward), optionally with a small real send. Watching or taking over a send that is still
|
||||
running needs the 7.0.28 helper on both consoles.
|
||||
- **A large .7z upload is no longer called "damaged" when the phone just failed to read it
|
||||
(#446).** The 7z decoder turns any failed read of the archive file into "dist overflow", the
|
||||
same error a broken archive gives, and since 7.0.22 a broken archive is never retried. So one
|
||||
read that failed on a busy phone ended a 70 GB upload as corrupt. A failed read is now reported
|
||||
as itself, and the upload retries it. An 84 GiB single-block archive made with 7-Zip's
|
||||
defaults decoded cleanly well past 40 GB in testing.
|
||||
- **"Helper not running" says what to do next everywhere.** Status checks used the old "not
|
||||
running or an old version" text; they now say, like a job does, whether the app can send the
|
||||
helper (the loader is up) or the jailbreak's loader needs starting first.
|
||||
|
||||
## 7.0.27
|
||||
|
||||
- **A stream install no longer fails on the PS5 after ps5upload says it's done.** The PS5
|
||||
downloads a package in pieces and re-reads some of them; ps5upload added up every byte it
|
||||
sent, repeats included, and took that total as "the console has it all". On a small package
|
||||
the repeats made up the size of a part never sent, so ps5upload closed the download link
|
||||
first: the app's tile appeared and could even open, but the PS5's Downloads showed it failed,
|
||||
Retry failed too and Cancel removed it. ps5upload now keeps the link open until every byte has
|
||||
been sent at least once. Seen with a 37 MB package on FW 13.60; any size could hit it.
|
||||
- **"Nothing answered on the ELF loader port :9021" when starting a stream install, then fine
|
||||
on a retry, is fixed.** When an install starts, the app also replaces the console's stock
|
||||
elfldr with its own fixed build, and the loader port is closed for about a second during that
|
||||
swap. An install that started in that second failed. It now waits for the swap to finish, and
|
||||
a loader port that is closed for a moment is tried again for a few seconds before the install
|
||||
gives up.
|
||||
|
||||
## 7.0.26
|
||||
|
||||
- **Uploaded game and homebrew folders show up with ShadowMount+ again.** ShadowMount+
|
||||
registers a folder as soon as its `sce_sys` has been still for 10 seconds; an upload sends
|
||||
those small files first, so it tried while `eboot.bin` was still on its way, failed twice and
|
||||
gave up on the title for good (measured on a 13.60 console). A game's `param.json` (and a PS4
|
||||
game's `param.sfo`) is now put in place only when everything else has arrived, so the folder
|
||||
becomes a game exactly when it is complete. Needs the 7.0.26 helper.
|
||||
- **A stalled or stopped upload can be continued.** A row that has moved no data for 90 seconds
|
||||
says so and offers **Restart transfer**; a row that was stopped, or interrupted by closing the
|
||||
app, says so and offers **Resume**. Both pick up from what the PS5 already has. **Retry
|
||||
failed uploads** now runs them (it only put them back in the list), and its label no longer
|
||||
reads "the retry failed" in most languages.
|
||||
- **Large solid .rar uploads no longer fail over and over on a retry.** Resuming one reads
|
||||
through everything the PS5 already has before sending anything new; the console took that
|
||||
silence for a stalled sender and ended the job after 15 minutes, every time. The app now
|
||||
tells the console it is still working. Needs the 7.0.26 helper.
|
||||
- **A .rar's unpacked size is right** when it holds entries of unknown size (RAR5), which made
|
||||
the free-space check before an upload add up to nonsense.
|
||||
- **Payload Manager: update what you stored.** Payloads stored from the Catalog are remembered,
|
||||
and the Payload Manager tab offers **Update** when the catalogue has a newer release: it
|
||||
stores the new one in its place and keeps its spot in the boot list. Payloads Payload Manager
|
||||
installed from its own repository are left to it. (Desktop and Android, where the catalogue
|
||||
is.)
|
||||
- **Files: drag to move.** Drag a folder onto another folder, or onto the path above the list,
|
||||
to move it there (across drives it copies, then removes). Files move the same way in the web
|
||||
UI and on Android; on the desktop a dragged file still goes out to your computer. Not in the
|
||||
Windows app, where the window takes every drag for its own file drop; use Move to… there.
|
||||
- **A deleted Collection folder no longer breaks the Collection (#461).** Saving the folder
|
||||
list checked that every folder still existed, so once one was gone nothing could be saved,
|
||||
removing that folder included. Only a folder being added is checked now. A scan skips a
|
||||
folder it can't open instead of failing, and Collection names it, marked "Not found" in
|
||||
Folders, so you can remove it or plug the drive back in.
|
||||
|
||||
## 7.0.25
|
||||
|
||||
- **Profile no longer offers "Create User" (#460).** The PS5 has no create-user call a payload
|
||||
can use, and the old button only ever tried to rename the console's first user, which fails
|
||||
on a jailbroken console with "sceUserServiceGetInitialUser failed". Profile now says to add
|
||||
users on the PS5 (Switch User, then Add User). Renaming and deleting users are unchanged.
|
||||
|
||||
## 7.0.24
|
||||
|
||||
- **Payloads → Ports: see what is listening on the PS5.** Every port in use, TCP and UDP, with
|
||||
what usually listens there (elfldr, ftpsrv, Payload Manager, nanodns, the ps5upload helper,
|
||||
Remote Play) and whether a PC can reach it or only the console itself. Ports with a web page
|
||||
open in your browser from there. The helper finds them by binding each port and letting go
|
||||
at once, so no payload ever sees a connection; a full pass takes about a second. Needs the
|
||||
7.0.24 helper.
|
||||
- **Folder games on a USB drive are no longer described as /data/homebrew folders (#452).**
|
||||
|
||||
## 7.0.23
|
||||
|
||||
- **Payload Manager 0.6 works again (#458).** 0.6 refuses changes without its request marker
|
||||
and wants POST for launch and delete, and keeps payloads in slots. The fallback launch,
|
||||
storing, deleting and "Load at boot" now work with 0.6.1 as well as 0.5.2.
|
||||
- **Games is smooth with a big library (#450).** Covers come as small cached thumbnails
|
||||
(about 20 KB instead of 300-700 KB), and screenshot thumbnails load two at a time and stop
|
||||
when you leave the screen instead of filling every connection to the console.
|
||||
- **Hidden home-screen icons stay hidden (#450).** The console shows an app's icon again every
|
||||
time it starts; the helper now hides it again a couple of seconds later. Icons hidden with
|
||||
earlier versions are picked up.
|
||||
- **The game page shows where the game runs from (#452):** the image on your external drive,
|
||||
not a leftover folder in /data/homebrew.
|
||||
- **Sending the helper no longer hangs for 15 s** when an old copy is stuck and serving nothing.
|
||||
- **Android save backups are no longer 0 bytes (#451).** They go to Downloads as
|
||||
`<title>-<date>.zip`, and the message says where.
|
||||
- **Tasks shows speed and ETA (#444)** for transfers, like the Files queue.
|
||||
- **Convert:** "backported" only for real backports (an older SDK than declared is no longer
|
||||
called one), the game's ampr_emu 0.3.x version is read (#449), and when LZ4 needs the newer
|
||||
runtime it points at the checkbox that adds it.
|
||||
- **Fast verification by default (#459, thanks quer3q).** Convert checks structure, sizes and
|
||||
small files in full and a seeded sample of the rest; "Full verification" re-reads every byte.
|
||||
- **"Last seen" is translated (#454, thanks EnriqueWood).**
|
||||
- **Payloads: export and import playlists**, with the auto-loader choice and your custom repos,
|
||||
to share a working setup.
|
||||
- **Linux packages: the update command to copy (#456).** On Bazzite and other rpm-ostree
|
||||
systems, Settings → Updates shows `rpm-ostree uninstall ps5-upload --install <rpm>`; dnf and
|
||||
apt installs get theirs.
|
||||
- **Helper crash reports say where.** A fault now logs the address and instruction.
|
||||
|
||||
## 7.0.22
|
||||
|
||||
- **HDR PNG carries the capture's ICC profile (#448).** 7.0.15 wrote the cICP tag only;
|
||||
viewers that go by the ICC profile (and the reporter's) showed the image as SDR. Sony's
|
||||
"ScreenCapturePQ" profile is now read out of the .jxr and embedded as iCCP in both HDR
|
||||
variants, with cICP beside it in the tagged one.
|
||||
|
||||
## 7.0.21
|
||||
|
||||
- **Fix: the image format could not be changed.** Picking .exfat or .ffpfs snapped back to
|
||||
.ffpkg (7.0.20 wrote the old format back a moment after the new one).
|
||||
- **An image can become another image.** An .exfat, .ffpkg or .ffpfs on this computer, a
|
||||
saved server or the PS5 can be rewritten in another format (an .exfat into a .ffpkg, say);
|
||||
the output is named after the source without its old extension, and never overwrites it.
|
||||
|
||||
## 7.0.20
|
||||
|
||||
- **Convert remembers what it is making.** Leaving the screen during an image build and
|
||||
coming back showed "A package" selected and package stage names while the image kept
|
||||
building. What to make and the image choice are now remembered, follow the build that is
|
||||
running, and an image build's stages read "Plan image", "Write image", "Read the image
|
||||
back".
|
||||
- **Fix: the game page could crash on "Measure how it plays".** When the engine's reply had
|
||||
no state (an engine without the route, or a test stand-in), the card threw and the whole
|
||||
game page fell back to the error screen. It now treats that as nothing recorded.
|
||||
|
||||
## 7.0.18
|
||||
|
||||
- **LZ4 packs for games whose ampr_emu is missing or too old.** When the check finds a game
|
||||
that reads through AMPR but ships no `fakelib/libSceAmpr.sprx`, or one that predates asset
|
||||
packs, the LZ4 option now offers "Put ampr_emu 0.4.2.1 into the image": the engine fetches
|
||||
drakmor's release once (checked against a known digest, never bundled) and writes it into
|
||||
the image, keeping the game's own copy beside it as `libSceAmpr.sprx.orig`. This is the
|
||||
runtime gap behind #449.
|
||||
- **A game that does not use AMPR is told so.** The LZ4 option explains that nothing in such a
|
||||
game could serve asset packs, and points at .ffpfsc, which ShadowMount+ decompresses for any
|
||||
game, instead of just refusing.
|
||||
|
||||
## 7.0.17
|
||||
|
||||
- **Open a save and edit its files.** Save data has an Open save button per save: the helper
|
||||
copies the save image under `/data`, unseals its key through the console's own pfsmgr and
|
||||
mounts the image decrypted, and Files opens on it. Read, replace or add files; Close &
|
||||
write back unmounts it and copies the image back into the real save only if something
|
||||
changed, so a look leaves the save untouched. One save open at a time. PS5 saves verified
|
||||
on FW 13.60 (an untouched open keeps the image's timestamp); PS4 saves (`sdimg_*` with the
|
||||
key in `<name>.bin`) follow the same path but have not been tried. The recipe is
|
||||
earthonion's garlic-savemgr, credited in the README. Needs the 7.0.17 helper.
|
||||
|
||||
## 7.0.16
|
||||
|
||||
- **Measure how a game plays, then pack it that way.** A game's page has a new card for a
|
||||
folder game on the connected console: Start measuring puts drakmor's measuring build of
|
||||
ampr_emu into the game's `fakelib2` (ShadowMount+ loads it over `fakelib`, nothing the
|
||||
game ships is touched), you play, and each session is recorded when the game closes (or
|
||||
with Record session). Build profile turns the sessions into a per-game LZ4 profile: block
|
||||
size, layout and hot flag per file from how it was actually read, and the decoded cache,
|
||||
physical cache and worker counts from an LRU simulation of those reads. Convert's LZ4
|
||||
option picks the profile up for that title ("measured from N play sessions"). Stop
|
||||
measuring removes the measuring build again. The generator is a port of drakmor's
|
||||
`ampr_pack_profile.py` and matches its output byte for byte on a reference trace.
|
||||
|
||||
## 7.0.15
|
||||
|
||||
- **Game images of a libSceAmpr game get a fresh ampr_emu.index (#449).** A dump's index is
|
||||
often stale (files added, trimmed or resized since it was written), and a plain .exfat,
|
||||
.ffpkg or .ffpfs image carried it as it was, which crashes the game the moment it touches a
|
||||
file the index has wrong. The image now carries one generated from its files, as a package
|
||||
and an LZ4 image already did.
|
||||
- **Connection says which of three things is wrong (#447).** "The helper is not running or is
|
||||
an old version" sent people in circles. The app now tells them apart: the helper is down
|
||||
but the console's payload loader answers (so Update helper will work); nothing answers at
|
||||
all (run your jailbreak's loader first); or an older helper is running (Update helper).
|
||||
- **HDR screenshots as HDR PNG (#448).** Screenshots has a "Convert as" choice: SDR PNG as
|
||||
before (tone-mapped, opens everywhere), HDR PNG (16-bit BT.2020 PQ with a cICP tag, which
|
||||
HDR-aware viewers and browsers show as captured) or HDR PNG without the tag, for apps that
|
||||
refuse it. Desktop app only, like the converter itself.
|
||||
|
||||
## 7.0.14
|
||||
|
||||
- **A game on the PS5 or a saved server can become a game image.** Convert refused to write
|
||||
an image unless the game folder was on this computer, while a package build happily read a
|
||||
`ps5://` or `remote://` folder in place. Images now take the same sources: the game is read
|
||||
through the helper (or from the server) as it is written, nothing is copied first.
|
||||
- **Convert walks you through it.** Four steps that read in order: ① the game, ② what to
|
||||
make, ③ its options, ④ build & install. "What to make" explains a package and a game image
|
||||
side by side and marks the one that suits the game on your PS5 (a PS5 game on firmware
|
||||
above 11.60 installs as a package but will not play, so the image is marked there; a PS4
|
||||
game or an older firmware marks the package). Options show only what applies: Kraken
|
||||
compression and game language for a package; filesystem, .ffpfsc compression, LZ4 packs
|
||||
and the read-back check for an image. Build & install says in one line what it will make,
|
||||
of which game, where. The queue has the whole width below.
|
||||
|
||||
## 7.0.13
|
||||
|
||||
- **Game image builds: the read-back check is no longer the slow part.** After writing an
|
||||
.exfat image the engine reads it back and hash-checks every file. The exFAT reader fetched
|
||||
one 64 KiB cluster per read and, for a big file, walked its chain from the start on every
|
||||
read, so the check crawled on fast drives. It now keeps the FAT in memory, continues where
|
||||
the last read stopped and reads whole runs of clusters in one call, and hashing uses every
|
||||
core. Measured on a 10 GB image in cache: 169 MiB/s before, 3.7 GiB/s after. Convert has a
|
||||
new **read-back check, per read** choice (1 to 32 MiB) for anyone whose disk prefers a
|
||||
different size; 8 MiB measured fastest and is the default. The check itself is unchanged.
|
||||
- **The game page is lighter.** The Overview card that repeated the hero (title id, name,
|
||||
size, path) is gone: the path, last played and Screenshots & clips now sit in the hero. Each
|
||||
copy on your drives is one compact row: its facts on one line, the path under it, the one
|
||||
action most people want (Send to PS5 or Install) as a button and the rest (copy path, copy
|
||||
install link, Convert, show in file manager, move to Trash) behind a ⋯ menu.
|
||||
- **7z uploads: a damaged archive stops instead of retrying (#446).** When the LZMA decoder
|
||||
gives up on a cut-short or corrupt stream ("dist overflow"), the job used to fail as if the
|
||||
connection had dropped, so the queue retried it again and again, decoding the same bytes to
|
||||
the same end. It is now a corrupt-archive failure that names the entry and how far into it
|
||||
the decoder got, and asks you to test the archive with 7-Zip before trying again. The
|
||||
archive in the report was not available to us; if yours passes 7-Zip's test and still fails,
|
||||
say so on the issue.
|
||||
- **Console to console: choose the route and the address (#444).** Send to another console…
|
||||
has a **Change…** link under the route note: **Auto** (as before), **Direct only** (never
|
||||
through this device, for a phone on mobile data; if the consoles cannot connect the send
|
||||
fails and says why) or **Through this device**. With Auto or Direct only you can also give
|
||||
the address the sending console dials to reach the other one, for setups where the address
|
||||
this device uses is not the one the consoles use between themselves: with ps5-tailscale, a
|
||||
local forward on the sending console (127.0.0.1:19120 → the other console:9120). The choice
|
||||
is remembered per pair of consoles. The consoles' keys and the one-time ticket still decide
|
||||
who may send, not the address. Taking over a running send from another device, and a
|
||||
"test direct connection" button, are not in this release.
|
||||
|
||||
## 7.0.12
|
||||
|
||||
- **Convert: a "Stored" package option (no compression).** Reports of packages that install but
|
||||
will not start on FW 8.60 and 10.40 arrived while compressed packages have only been proven
|
||||
to play on FW 5.10. Stored writes the same block layout with every block uncompressed, the
|
||||
one form that has played on hardware; the package is the game's size. Pick it under
|
||||
Compression if a compressed package installs but will not launch, and please say so on
|
||||
GitHub, with your firmware, so the default can be set right.
|
||||
- **Helper mismatch notice** now says there is nothing to do on the PS5 itself: Reload helper
|
||||
sends the matching helper from the app.
|
||||
- **RAR errors keep the library's reason** after the advice, so a report can be diagnosed.
|
||||
|
||||
---
|
||||
|
||||
## 7.0.11
|
||||
|
||||
- **Game page: Install on a package card.** "On your drives" showed a package with only Copy
|
||||
path and Copy install link; packages now have Install, which queues them streamed from the
|
||||
drive (or server) they sit on, like the console row's offers.
|
||||
- **Settings → Accessibility → Reduce transparency (#443).** Turns off the frosted-glass blur
|
||||
behind panels, the one thing that keeps the graphics card busy while an upload redraws its
|
||||
progress on Windows. It follows the OS "transparency effects" setting until you change it.
|
||||
(Motion → Reduced already did this as a side effect and still does.)
|
||||
|
||||
---
|
||||
|
||||
## 7.0.10
|
||||
|
||||
7.0.9 was tagged but never published: its Android build failed on a desktop-only permission
|
||||
(the file drag-out plugin), caught before the release existed. Everything below ships here.
|
||||
|
||||
- **The app no longer sends the helper into a console that is still booting.** With the app
|
||||
open while the PS5 started, the automatic helper restore fired the moment the loader port
|
||||
answered, which on a cold boot is before kstuff and the rest of the jailbreak chain are
|
||||
loaded; an FW 11.40 console crashed three times out of three. The automatic restore now
|
||||
waits 90 seconds after the loader first appears. Your own sends from the Connection screen
|
||||
are not held.
|
||||
- **Payloads → Payload Manager.** A new tab for itsPLK's Payload Manager on the console: what
|
||||
it stores, Launch and Delete, and its boot list (load at boot, order). On the Catalog tab,
|
||||
**Store in Payload Manager** keeps a downloaded payload on the PS5 under its own name; an
|
||||
existing folder is confirmed first, because Payload Manager replaces that folder's other
|
||||
files. Payload Manager itself and LZ4 Compression (on-console game compression by
|
||||
theghostonline) are in the catalogue, and the Connection screen's scene tools open their
|
||||
web pages.
|
||||
- **Files: drag a file out of the window** onto your desktop or file manager (desktop app).
|
||||
The first drag fetches the file into a staging folder; keep holding the mouse button, or
|
||||
drag it again once it says it is ready. Files over 4 GB keep using right-click → Download.
|
||||
- **Volumes: Eject a USB drive.** Unmounts `/mnt/usbN` so it can be unplugged safely. The
|
||||
PS5's own services usually hold the drive open, so the app offers "Eject anyway", which
|
||||
flushes the drive first; just make sure no game or app is reading from it.
|
||||
- **Stopped uploads resume for 7 days, and Stop says so (#440).** Stop has kept what already
|
||||
reached the PS5 and Start continued from there since 6.0; the app forgot a one-shot
|
||||
upload's resume id after 24 hours though, while the console keeps the data for 7 days.
|
||||
Both now agree on 7 days, and the Stop buttons explain what is kept.
|
||||
- **Home at a large Text size.** The action tiles squeezed their labels to one letter per line
|
||||
("U p l o a d") once the text was big enough. Home's grids now answer to their own width,
|
||||
so they drop to fewer columns, and a tile too narrow for icon and label side by side
|
||||
stacks them.
|
||||
- **"Coffee or tokens"** replaces "Buy me a coffee" on Home, About and the site.
|
||||
- README and DISCLAIMER say how the project is built (with AI coding tools, directed and
|
||||
tested by the maintainer) and what that means for you.
|
||||
|
||||
---
|
||||
|
||||
## 7.0.8
|
||||
|
||||
- **Hide an icon from the PS5 home screen.** For shortcuts and loaders you set up once and never
|
||||
open again: Games → ⋯ → **Hide from home screen**. It stays installed and playable, a
|
||||
"Hidden" badge marks it, "N hidden from the home screen" at the top finds them, and ⋯ →
|
||||
**Show on home screen** brings it back. Only the icon's visibility changes (the same flag the
|
||||
PS5 uses for its own hidden apps); system apps can't be hidden, and the console's app list is
|
||||
backed up before the first change. The home screen updates the next time you open and close
|
||||
any app.
|
||||
- **Upload: "Limited by" names the right cause.** When the console was idle waiting for data, the
|
||||
upload said "console memory"; it now says "network" (for example a PC sharing its connection
|
||||
with the PS5 while it is busy converting).
|
||||
|
||||
---
|
||||
|
||||
## 7.0.6
|
||||
|
||||
- **Your PS5 got a new address? The app finds it.** When the router gives the console a new IP,
|
||||
the saved address stops answering. While it does, the desktop and Android apps look for the
|
||||
same paired console on your network and, once it proves who it is, show "Your PS5 now answers
|
||||
at …" with **Switch**. A different PS5 is never offered. (It needs the helper running at the
|
||||
new address; after a reboot, use Find PS5s on Connection.)
|
||||
|
||||
---
|
||||
|
||||
## 7.0.5
|
||||
|
||||
- **OLED theme is back, and there's a new Peach theme.** OLED is dark on pure black, so an OLED
|
||||
screen's pixels stay off; Peach is a warmer light theme with a cream window and a burnt-peach
|
||||
@@ -14,6 +676,14 @@ What's new in ps5upload, written for humans.
|
||||
- **Remote Play: the pxplay number is right.** It was worked out with the account ID's bytes in
|
||||
the wrong order, so pxplay refused it. The account ID field now takes the hex ID (with or
|
||||
without 0x), the base64 ID or the pxplay number. The PIN itself was always correct.
|
||||
- **7z uploads on 32-bit Android:** archives with more than 4 GiB in one block (most game
|
||||
backups) failed with "dist overflow", and files over 4 GiB inside an archive were cut short.
|
||||
They upload in full now.
|
||||
- **Upload's buttons say what they do.** The next step is the one blue button: "Start upload"
|
||||
in the confirm is blue again, Retry is blue after a failure, and Add to queue is blue while
|
||||
the queue runs (Upload waits then). "Cancel" before an upload has started is now "Change
|
||||
source", and "Build the image, then upload it" is no longer a second blue button competing
|
||||
with Upload.
|
||||
- Remote Play names the signed-in account by its slot ("Slot 7"), the same as Profile.
|
||||
- **Upload: the "exfat" destination is a folder, not a conversion.** Its hint is now shown as
|
||||
text (phones have no hover) and says so, and sending a game folder there warns that it will
|
||||
|
||||
+13
-2
@@ -96,7 +96,18 @@ claims, liabilities, damages, losses, and expenses, including
|
||||
reasonable legal fees, arising out of or in any way connected with your
|
||||
use or misuse of the Software or your violation of these terms.
|
||||
|
||||
## 8. Severability and governing terms
|
||||
## 8. Development process
|
||||
|
||||
The Software is developed with AI coding tools ("vibe-coded"), directed
|
||||
and tested by its maintainer. This is a deliberate choice to keep a fast
|
||||
development cycle and a short turnaround on bug reports, feature
|
||||
requests and enhancements. It also means the code has not received the
|
||||
extended, line-by-line human review that a conventionally written
|
||||
project of comparable size would. Releases are tested on real consoles
|
||||
before publication, but defects may remain. You accept the Software on
|
||||
that basis and use it at your own discretion.
|
||||
|
||||
## 9. Severability and governing terms
|
||||
|
||||
If any provision of this disclaimer is held to be unenforceable or
|
||||
invalid, that provision shall be limited or eliminated to the minimum
|
||||
@@ -109,7 +120,7 @@ distributed. See [LICENSE](LICENSE). Where this disclaimer and the
|
||||
licence differ on warranty and liability, the broader exclusion
|
||||
applies to the maximum extent permitted by applicable law.
|
||||
|
||||
## 9. If you do not agree
|
||||
## 10. If you do not agree
|
||||
|
||||
If you do not agree with any part of this disclaimer, **do not
|
||||
download, build, install, or use the Software**, and delete any copy in
|
||||
|
||||
@@ -139,6 +139,9 @@ straight to the console is a Public network.
|
||||
| 19113 TCP | this computer | The engine: the PS5 fetches packages from it during Stream & install |
|
||||
| 9295, 9302 | PS5 | Remote Play pairing and wake |
|
||||
|
||||
To see every port in use on your PS5, and which payload usually sits on it, open **Payloads →
|
||||
Ports** (7.0.24 and later).
|
||||
|
||||
Every connection is encrypted and each computer pairs with the console once. A helper
|
||||
the app launched pairs by itself; one loaded another way shows a 6-digit code on the console,
|
||||
which you enter in the app. If the console shows a different PS5 at an address you reuse, forget
|
||||
@@ -160,7 +163,9 @@ Set `PS5UPLOAD_BANDWIDTH_MBPS` (megabytes per second) before starting the app or
|
||||
zero means no cap. The pre-6.0 name of this variable still works for one release.
|
||||
|
||||
**Q: Can I run the engine on a different machine (self-hosted)?**
|
||||
Yes. Run it on a server or NAS and point the app at it in **Settings → Engine URL**. Images:
|
||||
Yes. Run it on a server or NAS and point the app at it in **Settings → Engine URL**. For a
|
||||
step-by-step guide that ends with the app open on a phone, see "How do I use PS5Upload from my phone
|
||||
or another computer?" below. Images:
|
||||
`ghcr.io/phantomptr/ps5upload-engine` (engine only) and `ghcr.io/phantomptr/ps5upload-engine-webui`
|
||||
(full web UI), tagged `:latest` or `:<version>`.
|
||||
|
||||
@@ -194,7 +199,8 @@ in the compose file to the folder's owner (`ls -ln` shows the numbers), or run
|
||||
|
||||
**Q: Can I use ps5upload from a web browser (no desktop app)?**
|
||||
Yes. The `webui` image (above) serves the full app over HTTP; open `http://<host>:19113` from an
|
||||
allowed IP. Same security rules: no password, trusted LAN only.
|
||||
allowed IP. Same security rules: no password, trusted LAN only. "How do I use PS5Upload from my
|
||||
phone or another computer?" below walks through it from start to finish.
|
||||
|
||||
- **Upload works**, but the file picker browses the **engine's** disk, not your browser machine's.
|
||||
Mount your games into the container (`-v /host/games:/pkgs:ro`) and set
|
||||
@@ -206,6 +212,87 @@ allowed IP. Same security rules: no password, trusted LAN only.
|
||||
report. Everything that works on the PS5 itself is the same as on desktop.
|
||||
- The docker image can be built using `PS5UPLOAD_BASE_URL` to change the root path of the app.
|
||||
|
||||
**Q: How do I use PS5Upload from my phone or another computer?**
|
||||
Run PS5Upload on one machine, for example a Mac tucked away next to the router, and use the whole
|
||||
app from a browser anywhere on your home network, including Safari on an iPhone or iPad. This
|
||||
uses the web UI Docker image, not the desktop app.
|
||||
|
||||
**Why not the desktop app?** Its engine only takes orders from its own window: a request from any
|
||||
other device is refused, and it has no web version of the app to give a browser. The Docker image
|
||||
`ghcr.io/phantomptr/ps5upload-engine-webui` is the same engine with the full app built in.
|
||||
|
||||
**1. Install Docker.** On a Mac, install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||
and start it. On Linux, a NAS or a mini PC, any recent Docker with Compose works.
|
||||
|
||||
**2. Write the compose file.** Make a folder (for example `~/ps5upload`), and in it a file
|
||||
named `compose.yaml`. On **Linux**, copy [`engine/compose.yaml`](engine/compose.yaml) as it is and
|
||||
edit the three values marked `EDIT`. On a **Mac or Windows PC**, Docker Desktop has no host
|
||||
networking, so publish the engine's port instead and tell the engine the computer's own address:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ps5upload:
|
||||
image: ghcr.io/phantomptr/ps5upload-engine-webui:latest
|
||||
container_name: ps5upload
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "19113:19113"
|
||||
environment:
|
||||
PS5_ADDR: "192.168.1.50" # your PS5's IP
|
||||
PS5UPLOAD_PKG_HOST_IP: "192.168.1.10" # this computer's IP (step 3)
|
||||
PS5UPLOAD_ALLOW_IP: "192.168.1.0/24" # who may use it (step 5)
|
||||
PS5UPLOAD_BROWSE_ROOTS: "/pkgs"
|
||||
volumes:
|
||||
- ps5upload-data:/data
|
||||
- /Users/you/PS5:/pkgs:ro # your packages and game folders
|
||||
|
||||
volumes:
|
||||
ps5upload-data:
|
||||
```
|
||||
|
||||
- Keep port **19113** the same on both sides (`"19113:19113"`). The PS5 downloads packages from
|
||||
that port during Stream & install, and the engine tells it to use 19113.
|
||||
- `PS5UPLOAD_PKG_HOST_IP` is the address the PS5 is told to download from. Without it, a stream
|
||||
install hands the PS5 an address inside Docker that it cannot reach.
|
||||
- `/pkgs` is what the web UI's file picker shows: change `/Users/you/PS5` to the folder that holds
|
||||
your `.pkg` files and games. The picker browses this computer, not your phone. To send a package
|
||||
that is on the phone itself, use **Install Package → From this device**.
|
||||
- `ps5upload-data` holds the engine's pairing key and saved settings. Don't delete it, or the PS5
|
||||
sees a new device and you pair again.
|
||||
- Quit the PS5Upload desktop app on this computer first: it also uses port 19113.
|
||||
|
||||
**3. Find the computer's IP address.** On a Mac: **System Settings → Wi-Fi** (or **Network →
|
||||
Ethernet**) → **Details** → **TCP/IP**, or run `ipconfig getifaddr en0` in Terminal. Put it in
|
||||
`PS5UPLOAD_PKG_HOST_IP`. Give the computer and the PS5 fixed addresses in your router if you can,
|
||||
so they don't change.
|
||||
|
||||
**4. Start it.** In that folder run `docker compose up -d`. `docker compose pull` followed by
|
||||
`docker compose up -d` updates it later.
|
||||
|
||||
**5. Open it on your phone.** On the same Wi-Fi, open `http://<computer's IP>:19113/` in Safari
|
||||
(or any browser), for example `http://192.168.1.10:19113/`. On an iPhone, tap **Share → Add to
|
||||
Home Screen** to get an app icon. If the page says **"loopback only: this engine refused"**
|
||||
followed by an address, the engine saw your phone as that address. Add it to `PS5UPLOAD_ALLOW_IP`
|
||||
(comma-separated, for example `"192.168.1.0/24,192.168.65.1"`) and run `docker compose up -d`
|
||||
again. Docker Desktop often shows every device as one internal address like that one, which is
|
||||
also the address the PS5's downloads come from, so stream installs need it allowed too.
|
||||
|
||||
**6. Pair with the PS5.** On the **Connection** screen press **Send helper**, and the web UI
|
||||
pairs by itself. If your desktop app is already paired and the console shows no code, see "The
|
||||
web UI says it isn't paired, and no code shows on the console."
|
||||
|
||||
**7. Keep the computer awake.** A sleeping computer takes the engine with it, and the page stops
|
||||
answering. On a Mac, turn on **System Settings → Battery → Options → Prevent automatic sleeping on
|
||||
power adapter when the display is off** (desktop Macs: **Energy**), or run `caffeinate -s` in a
|
||||
Terminal window and leave it open. A MacBook still sleeps when its lid is closed unless an
|
||||
external display is attached, so leave the lid open. In Docker Desktop's settings, turn on
|
||||
**Start Docker Desktop when you sign in to your computer** so it comes back after a restart;
|
||||
`restart: unless-stopped` brings the engine back with it.
|
||||
|
||||
**Keep it on your home network.** The engine has **no password**: anyone who can open the page can
|
||||
install, delete and read files on your PS5. `PS5UPLOAD_ALLOW_IP` limits who that is; keep it to
|
||||
your own network, and never forward port 19113 on your router or open it to the internet.
|
||||
|
||||
**Q: What is "Stream install"?**
|
||||
It installs a `.pkg` **straight from your computer**: the PS5 pulls the bytes over your network, so
|
||||
nothing is copied to the console first (no package plus installed game taking room). Keep the
|
||||
@@ -992,8 +1079,44 @@ of a UFS `.ffpkg` regardless of what you asked for — when that happens
|
||||
the app tells you the mount came back read-only, and writes will fail
|
||||
until you convert or re-create the image.
|
||||
|
||||
**Q: Which image formats can I edit?**
|
||||
|
||||
| Format | Play on the console | Edit |
|
||||
| --- | --- | --- |
|
||||
| `.exfat` | ShadowMount+ or compatible integrated mount service | Game files → Edit files; or mount locally on a supported OS |
|
||||
| `.ffpkg`, `.ffpfs` | ShadowMount+ or compatible integrated mount service | Game files → Edit files, when the console permits a writable mount |
|
||||
| `.ffpfsc` | ShadowMount+ or compatible integrated mount service | Open its folder in Files → Decompress to .exfat; edit the extracted image, then recompress on the computer if desired |
|
||||
| `.pkg` | Install Package | Convert supports unpacking packages made with the debug key; arbitrary encrypted retail packages are not editable sources |
|
||||
|
||||
Compressed images appear in Game files alongside uncompressed images. Decompression keeps the
|
||||
original file and requires space for the full extracted image. The direct Edit files action
|
||||
is not offered for compressed containers. A successful read-only mount does not establish
|
||||
that a firmware permits writable mounting.
|
||||
|
||||
**Q: Can I queue more files or convert a folder of packages?**
|
||||
|
||||
In Files, Add files and Add folder remain available while a copy runs. Further picks keep
|
||||
their destination and conflict choices in the queue; a failure or cancellation pauses it.
|
||||
Pending picks can be removed. After an app restart, review the saved queue and press Resume.
|
||||
|
||||
Convert → Queue → Add games from a folder accepts `PS5 Games/<game>/game.pkg`, alongside
|
||||
actual game folders and supported images/archives. Generic `game.pkg` outputs use the parent
|
||||
folder's name. Review the queue before starting; unsupported/encrypted inputs fail individually,
|
||||
and existing output files are not overwritten. Desktop Stream & install accepts multiple
|
||||
packages, reads their headers first, and queues bases before updates and DLC.
|
||||
|
||||
**Q: A transfer or conversion gets slow near the end. What should I report?**
|
||||
|
||||
Include app/helper versions, source and destination drive types, file count and size range,
|
||||
and the stage shown when it slows (transfer, compression or verification). Export a bug report
|
||||
while it is happening. Small-file work and verification can behave very differently from large
|
||||
sequential copies; a percentage alone cannot identify the bottleneck. Conversion logs now
|
||||
record planning, writing and verification times, sizes, and compression settings. Comparisons
|
||||
need the same source, settings and storage. The current small-file and compression reports
|
||||
remain investigations; no throughput improvement is claimed for this release.
|
||||
|
||||
**Q: ShadowMount+ mounts my game read-only — how do I edit it?**
|
||||
Use **Games → Game files → the image → ⋯ → Edit files…**. That is a *checkout*: the
|
||||
Use **Games → Game files → the image → Edit files** for an uncompressed image. That is a *checkout*: the
|
||||
app moves the image out of ShadowMount+'s scan folder, waits for
|
||||
ShadowMount+ to let go of it, and mounts it read-write where you choose.
|
||||
When you press **Finish editing** it unmounts (which is what flushes your
|
||||
@@ -1073,8 +1196,9 @@ Yes. Save the server under **Connections** (SMB, FTP, FTPS or SFTP), then in **C
|
||||
Folders** press **Add a folder on <server>** and pick the folder. It is scanned where it is:
|
||||
packages, game folders and covers are read over the network, and nothing is copied to this
|
||||
computer. Its games install straight from the server, and **Convert…** reads them in place.
|
||||
Move to Trash, Organize, junk clean-up and Copy install link work on this computer's folders
|
||||
only. A mounted share also still works as an ordinary folder.
|
||||
**Organize collection** works there too, renaming on the server itself; Move to Trash, junk
|
||||
clean-up and Copy install link work on this computer's folders only. A mounted share also still
|
||||
works as an ordinary folder.
|
||||
|
||||
**Q: Does it keep the computer awake or rescan all the time?**
|
||||
No. **Look for changes automatically** (in Folders) rescans only while the app is open, never
|
||||
@@ -1104,13 +1228,27 @@ carries a random code, there is no folder listing, and only the packages you sha
|
||||
reached. A link lasts until you stop it (one at a time, or **Stop all**) or the app quits;
|
||||
nothing stays published after that. **Copy all links** copies every one at once.
|
||||
|
||||
**Q: What does Organize packages do, and can I undo it?**
|
||||
It renames every package `Title - Game ID - vVersion - Region - KIND.pkg` and files it under
|
||||
`Platform / Game ID - Title`, inside the folder it already sits in. DLC is filed with its game,
|
||||
a second copy of the same release is filed beside it and marked `DUPLICATE`, split packages
|
||||
(`_0`, `_1`, …) move together, and a package whose kind had to be inferred is marked `_UNSURE`.
|
||||
You see every move first and can untick any. Nothing is overwritten or deleted and files are
|
||||
only renamed, never copied. Every run can be undone from **Earlier runs** in the same window.
|
||||
**Q: What does Organize collection do, and can I undo it?**
|
||||
It organizes packages, images (`.exfat`, `.ffpkg`, `.ffpfs`, `.ffpfsc`) and extracted game folders
|
||||
under `Platform / Game ID - Title`, relative to each configured collection root. Packages and
|
||||
images are named `Title - Game ID - vVersion - Region - KIND` with their original format's
|
||||
extension. Extracted game folders move intact with the same name and no added extension;
|
||||
their internal files are never individually organized. Archives must be extracted first.
|
||||
DLC is filed with its game, split packages (`_0`, `_1`, …) move together, and inferred package
|
||||
kinds are marked `_UNSURE`. Extra copies of the same release and format are marked `DUPLICATE`.
|
||||
|
||||
You see every move first and can untick any. Items with unreadable or missing metadata are
|
||||
listed as left alone, with a reason. Already organized items stay put. Existing names are
|
||||
reserved rather than merged or overwritten. Moves rename items, never copy them. Every run
|
||||
can be undone from **Earlier runs** in the same window. A collection root that is itself an
|
||||
extracted game stays put; choose its parent folder to organize it.
|
||||
|
||||
A folder on a saved server (SMB, FTP, SFTP, WebDAV) is organized on that server. Items stay
|
||||
within their configured root, and a connection that drops stops the run where it is, with
|
||||
Undo still putting back whatever moved. On WebDAV emptied folders are left in place because
|
||||
its directory delete could remove contents. FTP checks destination names before renaming;
|
||||
another program creating the exact same destination between that check and the rename could
|
||||
have its file replaced.
|
||||
|
||||
**Q: What are the junk files, and is cleaning them safe?**
|
||||
A Mac writes a hidden `._` file beside every file it copies to an exFAT drive (each one takes a
|
||||
@@ -1310,34 +1448,29 @@ content id or region**; or the jailbreak **lacks live kernel patches** (kstuff /
|
||||
official Docker images and an engine built from a full checkout include it; otherwise point
|
||||
`PS5UPLOAD_PAYLOAD_DIR` at a folder that holds `ps5upload-installer.elf`.
|
||||
|
||||
**Q: Can I move a save from one PS5 to another?**
|
||||
**Q: Can I move a save between consoles, profiles or game regions?**
|
||||
|
||||
Yes, and you don't need a USB stick for it. There's no one-click
|
||||
"send to my other console" button, but the two-step route works today:
|
||||
Saves backup and restore copies the existing encrypted save files. It does **not**
|
||||
re-sign saves, change their owning account, or convert title IDs/regions. A backup
|
||||
must match the target title and account, and the target environment must accept
|
||||
its encryption. Renaming a GTA V (or other game's) title folder does not convert it.
|
||||
Cross-console compatibility is not guaranteed, even with matching title IDs.
|
||||
|
||||
1. On the source console, open **Saves**, find the title, and press
|
||||
**Download**. You get a `.zip` on your PC.
|
||||
2. Switch to the other console in the roster sidebar, find the same
|
||||
title in **Saves**, press **Restore**, and pick that `.zip`.
|
||||
Download a backup of the target save before trying a restore. The target must
|
||||
already list a save for that title: launch the game once to create it. Restore
|
||||
wipes the current save first, with no automatic rollback if an upload is interrupted.
|
||||
|
||||
**Backup to USB** / **Restore from USB** does the same thing without the
|
||||
PC in the middle, if you'd rather carry a drive between the consoles.
|
||||
Either way there are "all titles" buttons at the top to move everything
|
||||
at once.
|
||||
PS4-format restore has unresolved reliability reports. Closing the game or
|
||||
restarting the console may help some cases, but is not a verified fix. If a restore
|
||||
still fails, include the title ID, game/app/helper versions, backup origin, whether
|
||||
the account and console match, and the exact error or a bug-report bundle.
|
||||
|
||||
Two things to know before you do it:
|
||||
**Q: Where does an Android save backup go?**
|
||||
|
||||
- **The target console must already list a save for that title.** Restore
|
||||
writes into an existing save's folder; it can't conjure a save slot out
|
||||
of nothing. So install the game on the second console and launch it once
|
||||
— far enough for it to create its own save — then restore over it.
|
||||
- **Restore wipes the current save first, and there's no rollback.** If
|
||||
the upload is interrupted partway, that save can be left empty. If the
|
||||
save on the target console matters, download it first.
|
||||
|
||||
For PS4-format saves, the emulator may not notice the new data right
|
||||
away; close and reopen the game, or restart the console, if it doesn't
|
||||
show up.
|
||||
The **Backup** button writes a dated `.zip` to Android's **Download** folder
|
||||
(`/storage/emulated/0/Download`). The success notification shows the full path.
|
||||
Open your phone's Files app → Downloads and look for the title ID/game name.
|
||||
**Save to USB** is a separate action using the configured PS5 USB backup path.
|
||||
|
||||
**Q: Can I install a split package (`*.0`, `*.1`, …)?**
|
||||
Not through Install Package: it takes a single package file. Pick the single lead `.pkg`.
|
||||
@@ -1522,6 +1655,50 @@ For save data specifically, use the Save data screen.
|
||||
|
||||
---
|
||||
|
||||
## PS5Upload Minion (on the console)
|
||||
|
||||
**Q: What is PS5Upload Minion?**
|
||||
A file manager that runs on the PS5 itself, in the PS5's web browser, for when your PC isn't
|
||||
nearby. It has four sections: **Home** (the running game, running tasks and your drives),
|
||||
**Files** (browse, copy, move, rename, delete, view, verify, search and more), **Cheats** for the
|
||||
running game, and **Settings**. The PS5Upload helper serves it on port 9180.
|
||||
|
||||
**Q: How do I open it?**
|
||||
Pick the **PS5Upload Minion** tile on the PS5 home screen. You can also open
|
||||
`http://127.0.0.1:9180/` in the PS5's web browser. Either way the PS5Upload helper must be
|
||||
loaded (send it from the app, or from your payload loader), and it must be the 7.1.0 helper or
|
||||
newer.
|
||||
|
||||
**Q: The tile shows "PS5Upload Minion isn't running".**
|
||||
The helper isn't loaded, for example after a reboot or rest mode. Load the PS5Upload helper from
|
||||
the app on your PC or from your payload loader; the page notices and opens by itself (or press
|
||||
**Try again**). It is the browser's saved copy of the page saying the helper didn't answer;
|
||||
nothing is wrong with your files. If you see the browser's own error page instead, the browser
|
||||
had no saved copy: load the helper the same way and pick the tile again.
|
||||
|
||||
**Q: How do I use it with the controller?**
|
||||
✕ selects what is highlighted, ○ goes back one step (out of a dialog, up a folder, back to
|
||||
Home), and △ opens the actions for the highlighted item; the **⋯** button on each row does the
|
||||
same. The PS5's pointer works too: move it with the left stick, and ✕ clicks what it is over.
|
||||
Every action has a button on screen. **Help**, at the bottom of the left bar, lists the buttons
|
||||
that work on the current screen, and **Settings** shows the first-time tips again.
|
||||
|
||||
**Q: Can I use it from my phone or PC?**
|
||||
Yes. On the console, open **Settings → Allow other devices on the network** and confirm. Then
|
||||
open `http://<console-ip>:9180/` in a browser on the same network (the console's address is the
|
||||
one you use in the app). There is no password: while this is on, **anyone on your network can
|
||||
use the page and manage your files**, so turn it off when you're done. Turning it off
|
||||
disconnects the other devices; the console keeps access. While it is on, text boxes on the
|
||||
console show a QR code ("Continue on your phone") so you can type on your phone instead.
|
||||
|
||||
**Q: How do I hide or bring back the tile?**
|
||||
In **Settings**, switch off **Show PS5Upload Minion on the home screen** to remove the tile, and
|
||||
switch it on again to bring it back. With the tile hidden you can still open
|
||||
`http://127.0.0.1:9180/` in the PS5's browser. The tile (title id `PSUP00001`) is left out of
|
||||
the app's game list; an older engine or Docker image may still list it.
|
||||
|
||||
---
|
||||
|
||||
## Servers, safe moves, and backport helpers
|
||||
|
||||
**Q: Why can't I rename a file from one PS5 drive to another?**
|
||||
|
||||
@@ -529,6 +529,7 @@ _wait-payload-ready:
|
||||
# Exceptions (each is a path excluded from the search; keep this list to files that must name
|
||||
# the legacy protocol, and reword anything else to AVA1 terms):
|
||||
# CHANGELOG.md history keeps FTX2 by design.
|
||||
# site/releases.json the website's copy of the release notes (same history).
|
||||
# protocol/ava1/ the spec, the cutover checklist and the method
|
||||
# checklist describe the migration from FTX2.
|
||||
# Makefile, .github/workflows/engine-ci.yml
|
||||
@@ -543,11 +544,12 @@ _wait-payload-ready:
|
||||
# *.test.ts, *.test.tsx use 9113/9114 as fixture addresses for that same tolerance.
|
||||
# engine .../install/, fakelibs_api.rs, icon_cache.rs, ps5upload-ava1 pool.rs, the lab's
|
||||
# main.rs and bench.rs old-port strip logic and its fixtures.
|
||||
# payload/third_party vendored code (zlib's CRC table holds 0x...9113 words).
|
||||
CHECK_NO_FTX2_PATTERN := ftx2|(^|[^0-9])911[34]([^0-9]|$$)
|
||||
CHECK_NO_FTX2_SCOPE := .
|
||||
CHECK_NO_FTX2_EXCEPT := \
|
||||
':!engine/crates/ava1-ctest/tests/payload_cutover.rs' \
|
||||
':!CHANGELOG.md' \
|
||||
':!CHANGELOG.md' ':!site/releases.json' \
|
||||
':!CONTRIBUTING.md' ':!TESTING.md' \
|
||||
':!protocol/ava1' \
|
||||
':!Makefile' \
|
||||
@@ -562,7 +564,8 @@ CHECK_NO_FTX2_EXCEPT := \
|
||||
':!engine/crates/ps5upload-lab/src/bench.rs' \
|
||||
':!client/src/lib/addr.ts' \
|
||||
':!client/src/lib/humanizeError.ts' \
|
||||
':!client/src/**/*.test.ts' ':!client/src/**/*.test.tsx'
|
||||
':!client/src/**/*.test.ts' ':!client/src/**/*.test.tsx' \
|
||||
':!payload/third_party'
|
||||
|
||||
check-no-ftx2:
|
||||
@if git grep -n -I -i -E '$(CHECK_NO_FTX2_PATTERN)' -- $(CHECK_NO_FTX2_SCOPE) $(CHECK_NO_FTX2_EXCEPT); then \
|
||||
@@ -867,22 +870,127 @@ test-payload: payload
|
||||
@cc -O2 -Wall -Wextra -Werror -o /tmp/ps5upload-app-info-selftest \
|
||||
$(PAYLOAD_DIR)/tests/app_info_selftest.c
|
||||
@/tmp/ps5upload-app-info-selftest
|
||||
@echo "Running uninstall outcome self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-unregister-outcome-selftest \
|
||||
$(PAYLOAD_DIR)/tests/unregister_outcome_selftest.c
|
||||
@/tmp/ps5upload-unregister-outcome-selftest
|
||||
@echo "Running metadata title-id normalizer self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -o /tmp/ps5upload-tmdb-id-selftest \
|
||||
$(PAYLOAD_DIR)/tests/tmdb_id_selftest.c
|
||||
@/tmp/ps5upload-tmdb-id-selftest
|
||||
@echo "Running console port scan self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-net-ports-selftest \
|
||||
$(PAYLOAD_DIR)/tests/net_ports_selftest.c
|
||||
@/tmp/ps5upload-net-ports-selftest
|
||||
@echo "Running cheat XML UTF-16 normalizer self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-xml-encoding-selftest \
|
||||
$(PAYLOAD_DIR)/tests/xml_encoding_selftest.c
|
||||
@/tmp/ps5upload-xml-encoding-selftest
|
||||
@echo "✓ UTF-16 SHN/MC4 cheat files convert so their cheats appear"
|
||||
@echo "Running web UI HTTP parser self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-webui-http-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_http_selftest.c
|
||||
@/tmp/ps5upload-webui-http-selftest
|
||||
@echo "Running webui strings lookup self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-webui-strings-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_strings_selftest.c $(PAYLOAD_DIR)/src/webui_strings.c
|
||||
@/tmp/ps5upload-webui-strings-selftest
|
||||
@echo "✓ GET /strings/<lang>.json answers only the embedded languages, never a crafted path"
|
||||
@echo "Running webui offline copy lookup self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-webui-offline-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_offline_selftest.c $(PAYLOAD_DIR)/src/webui_offline.c
|
||||
@/tmp/ps5upload-webui-offline-selftest
|
||||
@echo "✓ GET /sw.js and /cache.appcache answer with their own types; nothing else matches"
|
||||
@echo "Running webui auth self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-webui-auth-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_auth_selftest.c $(PAYLOAD_DIR)/src/webui_auth.c
|
||||
@/tmp/ps5upload-webui-auth-selftest
|
||||
@echo "Running webui server self-test (host build, ~25 s)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 \
|
||||
-o /tmp/ps5upload-webui-server-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_server_selftest.c $(PAYLOAD_DIR)/src/webui_server.c \
|
||||
$(PAYLOAD_DIR)/src/webui_http.c $(PAYLOAD_DIR)/src/webui_auth.c
|
||||
@/tmp/ps5upload-webui-server-selftest
|
||||
@echo "✓ silent, half-sent and flooding web clients never stall the helper"
|
||||
@echo "Running webui codec self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 -I$(PAYLOAD_DIR)/ava1/gen \
|
||||
-o /tmp/ps5upload-webui-codec-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_codec_selftest.c $(PAYLOAD_DIR)/src/webui_codec.c \
|
||||
$(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
|
||||
@/tmp/ps5upload-webui-codec-selftest
|
||||
@echo "✓ Unicode, quoted and very large folders survive the web UI's JSON both ways"
|
||||
@echo "Running webui file transfer self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-webui-files-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_files_selftest.c $(PAYLOAD_DIR)/src/webui_files.c
|
||||
@/tmp/ps5upload-webui-files-selftest
|
||||
@echo "✓ text saves and uploads go through a same-folder temp + rename; a cut-off upload leaves no file; folders stream as a valid .tar"
|
||||
@echo "Running file index name-match self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-index-glob-selftest $(PAYLOAD_DIR)/tests/index_glob_selftest.c
|
||||
@/tmp/ps5upload-index-glob-selftest
|
||||
@echo "✓ index name patterns match in linear time, whatever the pattern"
|
||||
@echo "Running file manager prefs self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 -I$(PAYLOAD_DIR)/ava1/gen \
|
||||
-o /tmp/ps5upload-files-prefs-selftest \
|
||||
$(PAYLOAD_DIR)/tests/files_prefs_selftest.c $(PAYLOAD_DIR)/src/files_prefs.c $(PAYLOAD_DIR)/src/webui_codec.c \
|
||||
$(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
|
||||
@/tmp/ps5upload-files-prefs-selftest
|
||||
@echo "✓ pinned/recent folders stay bounded; a garbled prefs file reads as empty; saves are atomic"
|
||||
@echo "Running Show on TV / Show on PC handoff self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 -I$(PAYLOAD_DIR)/ava1/gen \
|
||||
-o /tmp/ps5upload-files-handoff-selftest \
|
||||
$(PAYLOAD_DIR)/tests/files_handoff_selftest.c $(PAYLOAD_DIR)/src/files_handoff.c $(PAYLOAD_DIR)/src/files_prefs.c \
|
||||
$(PAYLOAD_DIR)/src/webui_codec.c $(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
|
||||
@/tmp/ps5upload-files-handoff-selftest
|
||||
@echo "✓ a handoff opens once, expires after two minutes, and the PC reads the same prefs as the console"
|
||||
@echo "Running webui settings + tile self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -DWEBUI_TILE_HOST_TEST -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 -I$(PAYLOAD_DIR)/ava1/gen \
|
||||
-o /tmp/ps5upload-webui-settings-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_settings_selftest.c $(PAYLOAD_DIR)/src/webui_settings.c \
|
||||
$(PAYLOAD_DIR)/src/webui_tile.c $(PAYLOAD_DIR)/src/webui_codec.c \
|
||||
$(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
|
||||
@/tmp/ps5upload-webui-settings-selftest
|
||||
@echo "✓ a missing or garbled settings file falls back to defaults; the tile is written only when it differs"
|
||||
@echo "Running webui input log self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-webui-inputlog-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_inputlog_selftest.c $(PAYLOAD_DIR)/src/webui_inputlog.c
|
||||
@/tmp/ps5upload-webui-inputlog-selftest
|
||||
@echo "✓ the controller input log takes only plain short lines and never grows past its 64 KiB ring"
|
||||
@echo "Running webui cheat download store self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 -I$(PAYLOAD_DIR)/ava1/gen \
|
||||
-o /tmp/ps5upload-webui-cheats-store-selftest \
|
||||
$(PAYLOAD_DIR)/tests/webui_cheats_store_selftest.c $(PAYLOAD_DIR)/src/webui_cheats_store.c \
|
||||
$(PAYLOAD_DIR)/src/webui_files.c $(PAYLOAD_DIR)/src/webui_codec.c \
|
||||
$(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
|
||||
@/tmp/ps5upload-webui-cheats-store-selftest
|
||||
@echo "✓ a downloaded cheat is stored only under a plain name with its format's extension, through a same-folder temp + rename"
|
||||
@echo "Running AVA1 in-flight RPC gate self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/ava1 \
|
||||
-o /tmp/ps5upload-ava1-rpc-gate-selftest \
|
||||
$(PAYLOAD_DIR)/tests/ava1_rpc_gate_selftest.c
|
||||
@/tmp/ps5upload-ava1-rpc-gate-selftest
|
||||
@echo "✓ the helper's exit waits for web UI calls and refuses new ones"
|
||||
@echo "Running cheat title list self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-cheats-list-selftest \
|
||||
$(PAYLOAD_DIR)/tests/cheats_list_selftest.c
|
||||
@/tmp/ps5upload-cheats-list-selftest
|
||||
@echo "✓ a large or badly encoded cheat pack lists as valid, bounded JSON"
|
||||
@echo "Running cheat apply-plan self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-cheats-plan-selftest \
|
||||
$(PAYLOAD_DIR)/tests/cheats_plan_selftest.c
|
||||
@/tmp/ps5upload-cheats-plan-selftest
|
||||
@echo "✓ a cheat's code caves are written before the jumps into them, and removed after"
|
||||
@echo "Checking per-console isolation..."
|
||||
@./scripts/check-per-console-isolation.sh
|
||||
@echo "✓ one console's data cannot be shown under another's name"
|
||||
@@ -934,6 +1042,7 @@ test-payload: payload
|
||||
-I$(PAYLOAD_DIR)/third_party/sqlite3 \
|
||||
-DCONTENT_DB_APP='"/tmp/ps5upload-cdb/app.db"' \
|
||||
-DCONTENT_DB_APPINFO='"/tmp/ps5upload-cdb/appinfo.db"' \
|
||||
-DCONTENT_DB_APP_BACKUP='"/tmp/ps5upload-cdb/app.db.before-hide"' \
|
||||
-o /tmp/ps5upload-content-db-selftest \
|
||||
$(PAYLOAD_DIR)/src/content_db.c \
|
||||
$(PAYLOAD_DIR)/tests/content_db_selftest.c \
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
- **Power.** Rest mode, reboot, shut down, and wake over the network (optionally straight
|
||||
into your signed-in user).
|
||||
- **Runs everywhere:** macOS, Windows, Linux, Android, or any browser through the self-hosted
|
||||
Docker web UI. 21 languages.
|
||||
Docker web UI ([compose file](engine/compose.yaml), see
|
||||
[Self-hosted engine and web UI](#self-hosted-engine-and-web-ui)). 21 languages.
|
||||
|
||||
## What it doesn't do
|
||||
|
||||
@@ -64,7 +65,7 @@ Pre-built downloads land on the
|
||||
| macOS (Apple Silicon / Intel) | `PS5Upload-<ver>-mac-{arm64,x64}.dmg` | Open the `.dmg`, drag PS5Upload into Applications. See **First launch on macOS** below — Gatekeeper blocks downloaded apps the first time. |
|
||||
| Windows (x64 / ARM64) | `PS5Upload-<ver>-win-{x64,arm64}.zip` | Unzip, double-click `PS5Upload.exe` — portable, no installer. See **First launch on Windows** — SmartScreen warns on first run. |
|
||||
| Linux — Debian / Ubuntu (x64 / ARM64) | `PS5Upload-<ver>-linux-{x64,arm64}.deb` | `sudo apt install ./PS5Upload-<ver>-linux-<arch>.deb` — installs a normal app with a menu entry; pulls in the WebKitGTK deps automatically. |
|
||||
| Linux — Fedora / RHEL / Bazzite (x64 / ARM64) | `PS5Upload-<ver>-linux-{x64,arm64}.rpm` | `sudo dnf install ./PS5Upload-<ver>-linux-<arch>.rpm` (Bazzite/Silverblue: `rpm-ostree install`) — menu entry + auto deps. |
|
||||
| Linux — Fedora / RHEL / Bazzite (x64 / ARM64) | `PS5Upload-<ver>-linux-{x64,arm64}.rpm` | `sudo dnf install ./PS5Upload-<ver>-linux-<arch>.rpm` (Bazzite/Silverblue: `rpm-ostree install`; to update, `rpm-ostree uninstall ps5-upload --install ./<new>.rpm` and reboot, which Settings → Updates shows ready to copy) — menu entry + auto deps. |
|
||||
| Linux — any distro (x64 / ARM64) | `PS5Upload-<ver>-linux-{x64,arm64}.zip` | Universal fallback (no install). Unzip, then `chmod +x PS5Upload.sh PS5Upload.AppImage` and run **`./PS5Upload.sh`** (the wrapper — handles the FUSE-less and WebKit white-screen cases for you). Running `./PS5Upload.AppImage` directly also works if your system has libfuse2 and a happy WebKitGTK. |
|
||||
| Linux — NixOS (x64 / ARM64) | no release artifact | Packaged in [NUR](https://github.com/GriefNorth/nur-packages) as `ps5upload` — see **NixOS** below. |
|
||||
| Android | `PS5Upload-<ver>-android.apk` | Enable "install unknown apps" for your browser/file manager, then open the `.apk`. Same interface, mobile-friendly; connects to and manages your PS5 over Wi-Fi. On Android 17, allow local network access ("nearby devices") when asked. |
|
||||
@@ -302,6 +303,8 @@ with **Settings → Engine URL**.
|
||||
LAN, never on the internet.
|
||||
- You may build the docker container with the `PS5UPLOAD_BASE_URL` to host the webui through a
|
||||
reverse proxy. This cannot be changed on runtime.
|
||||
- **Scripting it:** [ps5upctl](https://github.com/yuanzhengze/ps5upctl) is a community
|
||||
command-line client for the engine API (unofficial; the API can change between versions).
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -315,6 +318,19 @@ Common questions (connection problems, install errors, USB drives, firmware) are
|
||||
- Pull requests welcome. Read [`CONTRIBUTING.md`](CONTRIBUTING.md) and run the checks in
|
||||
[`TESTING.md`](TESTING.md) before opening.
|
||||
|
||||
## How this project is built
|
||||
|
||||
ps5upload is a vibe-coded project. The code is written with AI coding
|
||||
tools, directed, tested on real consoles and released by one maintainer.
|
||||
That is what keeps the development cycle fast: bug reports, feature
|
||||
requests and enhancements are usually turned around in days, not months.
|
||||
|
||||
The trade-off is that the code has not had the slow, line-by-line human
|
||||
review a hand-written project of this size would get. Every release is
|
||||
tested on hardware, but you should expect rough edges, read the
|
||||
[disclaimer](DISCLAIMER.md), keep backups of anything you care about on
|
||||
the console, and use the tool at your own discretion.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> **ps5upload is provided for research, educational, interoperability,
|
||||
@@ -408,6 +424,8 @@ This software builds on the following open-source projects:
|
||||
|
||||
* [elf-arsenal](https://git.etawen.dev/soniciso/elf-arsenal) (soniciso, Sanad) — parts of the drive sensor, cheat and wake-watchdog code
|
||||
* [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge) (quer3q) — the UFS2 (`.ffpkg`) and PFS (`.ffpfs`) image writers, the streaming `.ffpfsc` container, reader and safety fixes to the package crates, and the ideas behind hash-checked images and spare space for read-write mounts
|
||||
* [ampr_emu](https://github.com/drakmor/ampr_emu) and [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus) (drakmor) — the AMPR emulator that plays LZ4 asset packs, and the mount tool that shows our images on the home screen. Our packer is a port of its `ampr_pack.py`, the index writer follows `build_ampr_index.py`, and "Measure how it plays" is a port of `ampr_pack_profile.py` fed by the debug build's command journal
|
||||
* [garlic-savemgr](https://github.com/earthonion/garlic-savemgr) (earthonion) — how a save image is opened on the console: the sealed key unsealed through `/dev/pfsmgr` and the image mounted with `sceFsMountSaveData` from a copy under `/data`, which "Open save" follows
|
||||
* [PROSPEROPatches](https://prosperopatches.com/), [ORBISPatches](https://orbispatches.com/) and [TMDB](https://www.themoviedb.org/) — game details and artwork
|
||||
* [etaHEN PS5_Cheats](https://github.com/etaHEN/PS5_Cheats) and GoldHEN — cheat files
|
||||
* Everyone who contributed code, translations and docs, and the Discord testers
|
||||
@@ -419,6 +437,12 @@ maintainers, the homebrew tool developers whose work this lives alongside,
|
||||
and everyone in the community who tests, reports bugs, and shares
|
||||
knowledge. None of this exists without your collective effort. 🙏
|
||||
|
||||
ps5upload is one tool among many. The scene is full of great work:
|
||||
loaders, kernel patches, mount tools, dumpers, cheat engines, file
|
||||
managers, save tools and more, most of it made by people in their spare
|
||||
time. If something here saves you time, the people behind the tools it
|
||||
lives alongside deserve the same thanks. **Please support them too.**
|
||||
|
||||
## License
|
||||
|
||||
GNU General Public License v3.0 (GPLv3).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const hasMoves of [true, false]) {
|
||||
test(`collection organizer explains mixed sources and skipped items (${hasMoves ? "moves" : "no moves"})`, async ({ page }) => {
|
||||
const move = (from: string, to: string) => ({
|
||||
container: "/games", from, to, platform: "PS5", title_id: "PPSA01234", title: "Example",
|
||||
kind: "base", confident: true, reason: "", version: "1.00", region: "US", size: 10, content_id: "",
|
||||
});
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
let body: unknown = {};
|
||||
if (path === "/api/version") body = { version: "7.1.10", caps: {} };
|
||||
if (path === "/api/collection/settings") body = { roots: ["/games"], refresh_secs: 0, sweep_sidecars: false };
|
||||
if (path === "/api/collection/library") body = { games: {}, summary: { total_games: 0, total_locations: 0, total_size_bytes: 0, duplicates_count: 0, reclaimable_bytes: 0 } };
|
||||
if (path === "/api/collection/scan") body = { running: false, done: 0, found: 0 };
|
||||
if (path === "/api/collection/organize/runs") body = [];
|
||||
if (path === "/api/collection/organize/plan") body = {
|
||||
token: "preview", containers: ["/games"], moves: hasMoves ? [
|
||||
move("game.ffpfsc", "PS5/PPSA01234 - Example/Example.ffpfsc"),
|
||||
move("dump", "PS5/PPSA01234 - Example/Example"),
|
||||
] : [],
|
||||
skipped: [{ container: "/games", path: "broken.ffpfs", reason: "unreadable", detail: "invalid image" }],
|
||||
unsure_count: 0, total_bytes: hasMoves ? 20 : 0,
|
||||
};
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
|
||||
});
|
||||
await page.goto("/collection", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /Organize/ }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("heading", { name: "Organize collection", exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText(/extracted game folders/i)).toBeVisible();
|
||||
if (hasMoves) {
|
||||
await expect(dialog.getByText("game.ffpfsc", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("dump", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "Organize 2 items", exact: true })).toBeVisible();
|
||||
} else {
|
||||
await expect(dialog.getByText("No moves planned.", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText(/Every .*already/)).toHaveCount(0);
|
||||
}
|
||||
await dialog.getByText("Left alone (1)", { exact: true }).click();
|
||||
await expect(dialog.getByText("broken.ffpfs", { exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
// #470: on a phone the Files row shows its name and one ⋮ that holds the row's
|
||||
// actions; the selection bar stays one line. The engine here is a fake.
|
||||
|
||||
const HOST = "192.168.0.5";
|
||||
const LONG = "UP0000-CUSA07842_00-JAKXCOMBATRACING-A0100-V0100.pkg";
|
||||
|
||||
test.use({ viewport: { width: 360, height: 800 }, hasTouch: true });
|
||||
|
||||
test("phone width: row names show, ⋮ opens the row's actions, the selection bar is one line", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((host) => {
|
||||
window.localStorage.setItem("ps5upload.host", host);
|
||||
}, HOST);
|
||||
const json = (route: Route, body: unknown) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
|
||||
await page.route(
|
||||
(u) => u.pathname.startsWith("/api/"),
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const p = url.pathname;
|
||||
if (p === "/api/version") return json(route, { caps: { rar: true }, version: "7.0.29" });
|
||||
if (p === "/api/ps5/status")
|
||||
return json(route, {
|
||||
command_count: 1,
|
||||
instance_id: 1,
|
||||
max_transfer_streams: 8,
|
||||
prior_instance: "clean",
|
||||
ps5_kernel: "r229358/releases/13.60 Jul 17 2026 02:16:29",
|
||||
started_at_unix: 1791360198,
|
||||
ucred_elevated: true,
|
||||
version: "7.0.29",
|
||||
});
|
||||
if (p === "/api/ps5/volumes") return json(route, { volumes: [] });
|
||||
if (p === "/api/ps5/list-dir")
|
||||
return json(route, {
|
||||
path: url.searchParams.get("path") ?? "/data",
|
||||
entries: [
|
||||
{ name: "homebrew", kind: "dir", size: 0, mtime: 1791360199 },
|
||||
{ name: LONG, kind: "file", size: 101502976, mtime: 1791360199 },
|
||||
],
|
||||
truncated: false,
|
||||
total_scanned: 2,
|
||||
returned: 2,
|
||||
});
|
||||
if (p === "/api/jobs") return json(route, []);
|
||||
return json(route, {});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const main = page.getByRole("main");
|
||||
const name = main.getByRole("button", { name: LONG, exact: true });
|
||||
await expect(name).toBeVisible({ timeout: 30_000 });
|
||||
// The name gets real room, not the zero width the action buttons left it.
|
||||
expect((await name.boundingBox())?.width ?? 0).toBeGreaterThan(100);
|
||||
await expect(name).toHaveAttribute("title", LONG);
|
||||
|
||||
// The desktop icon buttons are hidden; the ⋮ holds them.
|
||||
await expect(main.getByTestId("fs-row-actions").first()).toBeHidden();
|
||||
const more = main.getByRole("button", { name: `Actions for ${LONG}` });
|
||||
await more.click();
|
||||
const menu = page.getByTestId("fs-row-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
const items = menu.getByRole("menuitem");
|
||||
await expect(items.first()).toHaveText("Rename");
|
||||
await expect(menu.getByRole("menuitem", { name: "Properties & permissions" })).toBeVisible();
|
||||
await expect(items.last()).toHaveText("Delete");
|
||||
await expect(menu.getByRole("menuitem", { name: "Install" })).toBeVisible();
|
||||
const box = await menu.boundingBox();
|
||||
if (!box) throw new Error("menu has no box");
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(360);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(800);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toHaveCount(0);
|
||||
|
||||
// Rename from the menu opens the row's rename field, as the desktop button does.
|
||||
await more.click();
|
||||
await menu.getByRole("menuitem", { name: "Rename" }).click();
|
||||
await expect(main.locator(`input[value="${LONG}"]`)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Selecting a row: "1 selected" on one line.
|
||||
await main.locator("li input[type=checkbox]").first().check();
|
||||
const count = main.getByTestId("fs-selection-bar").getByText("1 selected");
|
||||
await expect(count).toBeVisible();
|
||||
const countBox = await count.boundingBox();
|
||||
expect(countBox?.height ?? 99).toBeLessThan(30);
|
||||
});
|
||||
@@ -132,7 +132,9 @@ test("an Add files upload keeps its progress across a screen change", async ({
|
||||
await primary.getByRole("link", { name: "Files", exact: true }).click();
|
||||
await expect(progress).toBeVisible({ timeout: 15_000 });
|
||||
await expect(progress).toContainText("25%");
|
||||
await expect(main.getByRole("button", { name: "Add files" })).toBeDisabled();
|
||||
// Further picks join the queue while the current copy stays active.
|
||||
await expect(main.getByRole("button", { name: "Add files" })).toBeEnabled();
|
||||
await expect(page.getByTestId("fs-upload-queue")).toContainText("big.exfat");
|
||||
|
||||
// From another screen it is visible as a running task.
|
||||
await primary.getByRole("link", { name: "Tasks", exact: true }).click();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Buy me a coffee sits right under the connection badge on Home, on a wide window and a phone.
|
||||
// Coffee or tokens sits right under the connection badge on Home, on a wide window and a phone.
|
||||
|
||||
for (const [name, width] of [["desktop", 1280], ["phone", 390]] as const)
|
||||
test(`Home shows Buy me a coffee under the connection badge (${name})`, async ({ page }) => {
|
||||
test(`Home shows Coffee or tokens under the connection badge (${name})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("ps5upload.host", "192.168.86.100");
|
||||
@@ -24,7 +24,7 @@ for (const [name, width] of [["desktop", 1280], ["phone", 390]] as const)
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const main = page.getByRole("main");
|
||||
const badge = main.getByTestId("home-connection-badge");
|
||||
const coffee = main.getByRole("button", { name: "Buy me a coffee" });
|
||||
const coffee = main.getByRole("button", { name: "Coffee or tokens" });
|
||||
await expect(coffee).toBeVisible({ timeout: 30_000 });
|
||||
const [b, c] = [await badge.boundingBox(), await coffee.boundingBox()];
|
||||
expect(c!.y).toBeGreaterThan(b!.y + b!.height - 1);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Reported on 7.1.9: compressed images are absent despite being visible in Files.
|
||||
// Exercise the browser/engine path and prevent an unsupported native writable mount.
|
||||
test("Game files finds compressed images and directs editing through decompression", async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("ps5upload.host", "192.0.2.8"));
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
let body: unknown = {};
|
||||
if (url.pathname === "/api/version") body = { version: "7.1.10", caps: {} };
|
||||
if (url.pathname === "/api/ps5/status") body = { version: "7.1.10", command_count: 1, instance_id: 1, ucred_elevated: true };
|
||||
if (url.pathname === "/api/ps5/volumes") body = { volumes: [{ path: "/data", fs_type: "nullfs", writable: true, is_placeholder: false }] };
|
||||
if (url.pathname === "/api/ps5/list-dir") body = { entries: [
|
||||
{ name: "game.FFPFSC", kind: "file", size: 123456 },
|
||||
], truncated: false };
|
||||
if (url.pathname === "/api/ps5/smp/status") body = { running: false, installed: false, mounted_images: [], errors: [] };
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
|
||||
});
|
||||
await page.goto("/games?tab=files", { waitUntil: "domcontentloaded" });
|
||||
const main = page.getByRole("main");
|
||||
await expect(main.getByText("game.FFPFSC", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(main.getByRole("button", { name: "Mount", exact: true })).toBeDisabled();
|
||||
await expect(main.getByRole("button", { name: "Edit files", exact: true })).toHaveCount(0);
|
||||
await expect(main.getByRole("link", { name: "Open in Files" })).toHaveAttribute("href", "/files?path=%2Fdata");
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=";
|
||||
|
||||
test("browser avatar selection previews local bytes and applies only after confirmation", async ({ page }) => {
|
||||
const previews: Record<string, unknown>[] = [];
|
||||
const applies: Record<string, unknown>[] = [];
|
||||
let rejectPreview = false;
|
||||
await page.addInitScript(() => window.localStorage.setItem("ps5upload.host", "192.168.1.100"));
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
let body: unknown = {};
|
||||
if (path === "/api/version") body = { version: "7.1.10", caps: {} };
|
||||
if (path === "/api/ps5/status") body = { ok: true };
|
||||
if (path === "/api/profile/info") body = { ok: true, uid: 123, uid_hex: "0x0000007B", username: "Tester", users: [{ uid: 123, uid_hex: "0x0000007B", username: "Tester" }], slots: [] };
|
||||
if (path === "/api/profile/avatar/current") body = { data_url: null };
|
||||
if (path === "/api/profile/avatar/preview") {
|
||||
previews.push(route.request().postDataJSON());
|
||||
if (rejectPreview) return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "Invalid image" }) });
|
||||
body = { data_url: `data:image/png;base64,${png}` };
|
||||
}
|
||||
if (path === "/api/profile/avatar") {
|
||||
applies.push(route.request().postDataJSON());
|
||||
body = { uid: 123, files_copied: 11 };
|
||||
}
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
|
||||
});
|
||||
await page.goto("/profile", { waitUntil: "domcontentloaded" });
|
||||
const picker = page.getByRole("button", { name: "Choose image…", exact: true });
|
||||
await expect(picker).toBeVisible({ timeout: 30_000 });
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await picker.click();
|
||||
await (await chooser).setFiles({ name: "avatar.png", mimeType: "image/png", buffer: Buffer.from(png, "base64") });
|
||||
await expect(page.getByText("avatar.png", { exact: true })).toBeVisible();
|
||||
await expect.poll(() => previews.length).toBeGreaterThan(0);
|
||||
expect(previews.at(-1)?.image_path).toBe(`data:image/png;base64,${png}`);
|
||||
await page.getByRole("button", { name: "Fit", exact: true }).click();
|
||||
await expect.poll(() => previews.at(-1)?.mode).toBe("fit");
|
||||
await page.getByRole("button", { name: "Apply avatar", exact: true }).click();
|
||||
const dialog = page.getByRole("alertdialog", { name: "Change avatar?" });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(applies).toHaveLength(0);
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
expect(applies).toHaveLength(0);
|
||||
await page.getByRole("button", { name: "Apply avatar", exact: true }).click();
|
||||
await dialog.getByRole("button", { name: "Confirm", exact: true }).click();
|
||||
await expect.poll(() => applies.length).toBe(1);
|
||||
await expect(page.getByText("Avatar applied (11 files written).", { exact: true })).toBeVisible();
|
||||
expect(applies[0]).toMatchObject({ image_path: `data:image/png;base64,${png}`, mode: "fit", uid: 123, username: "Tester" });
|
||||
// A failed preview must not allow applying a stale successful selection.
|
||||
rejectPreview = true;
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles({ name: "broken.png", mimeType: "image/png", buffer: Buffer.from("invalid") });
|
||||
await expect(page.getByText(/Invalid image/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Apply avatar", exact: true })).toBeDisabled();
|
||||
const beforeOversized = previews.length;
|
||||
await input.setInputFiles({ name: "large.png", mimeType: "image/png", buffer: Buffer.alloc(8 * 1024 * 1024 + 1) });
|
||||
await expect(page.getByText("Choose an image of 8 MiB or smaller.", { exact: true })).toBeVisible();
|
||||
expect(previews).toHaveLength(beforeOversized);
|
||||
expect(applies).toHaveLength(1);
|
||||
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"dist-console/**",
|
||||
"node_modules/**",
|
||||
"src-tauri/**",
|
||||
"coverage/**",
|
||||
|
||||
Generated
+295
-295
File diff suppressed because it is too large.
Load diff
+13
-12
@@ -2,7 +2,7 @@
|
||||
"name": "ps5upload-client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "7.0.3",
|
||||
"version": "7.1.11",
|
||||
"description": "The all-in-one PS5 companion app.",
|
||||
"homepage": "https://github.com/phantomptr/ps5upload",
|
||||
"author": "PhantomPtr <phantomptr@gmail.com>",
|
||||
@@ -10,6 +10,7 @@
|
||||
"dev": "tauri dev",
|
||||
"dev:vite": "vite",
|
||||
"build:vite": "vite build",
|
||||
"build:console": "node ../scripts/build-console-strings.mjs && vite build -c vite.console.config.ts",
|
||||
"build": "tauri build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
@@ -29,13 +30,13 @@
|
||||
"dist:android": "tauri android build --apk --target aarch64 --target armv7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||
"@tauri-apps/plugin-notification": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||
"@tauri-apps/api": "^2.12.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||
"@tauri-apps/plugin-notification": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^1.52.0",
|
||||
"lucide-react": "^1.56.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^8.4.0",
|
||||
@@ -44,20 +45,20 @@
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-cascade-layers": "^6.0.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/cli": "^2.12.1",
|
||||
"@tauri-apps/cli": "^2.12.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"@vitest/coverage-v8": "^5.0.3",
|
||||
"@vitest/coverage-v8": "^5.0.4",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"postcss": "^8.5.28",
|
||||
"postcss": "^8.5.30",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vite": "^8.3.3",
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Fallbacks for the old WebKit in macOS Big Sur (Safari 14 / early 15).
|
||||
//
|
||||
// The desktop app renders in the system WKWebView, which on Big Sur can be Safari 14.0. The
|
||||
// built stylesheet uses three things that WebKit doesn't know, and the minifier adds no
|
||||
// fallback for them (it even drops the hand-written `height:100%` before `height:100dvh`):
|
||||
//
|
||||
// * dynamic viewport units (`100dvh`, Safari 15.4): `html, body, #root` lost their height,
|
||||
// so the app shell grew to its content and nothing could scroll;
|
||||
// * `padding-inline` / `margin-inline` / `padding-block` / `margin-block` (Safari 14.1):
|
||||
// every px-*, mx-auto, py-* … did nothing, so rows sat flush against the left edge;
|
||||
// * the `inset` shorthand (Safari 14.1).
|
||||
//
|
||||
// Each such declaration gets an older equivalent written just BEFORE it, so newer engines
|
||||
// still use the original (the later declaration wins) and old ones keep the fallback.
|
||||
// Two-value inline shorthands depend on writing direction and are left alone.
|
||||
// Run on the final CSS (vite.config.ts, generateBundle).
|
||||
import postcss from "postcss";
|
||||
|
||||
const DYN_VH = /(\d*\.?\d+)[dsl]vh\b/g;
|
||||
const SIDES = {
|
||||
"padding-inline": ["padding-left", "padding-right"],
|
||||
"margin-inline": ["margin-left", "margin-right"],
|
||||
"padding-block": ["padding-top", "padding-bottom"],
|
||||
"margin-block": ["margin-top", "margin-bottom"],
|
||||
};
|
||||
|
||||
function before(decl, prop, value) {
|
||||
decl.cloneBefore({ prop, value });
|
||||
}
|
||||
|
||||
export function addOldWebkitFallbacks(css) {
|
||||
if (!/[dsl]vh\b|(padding|margin)-(inline|block)\s*:|[{;]\s*inset\s*:/.test(css)) return css;
|
||||
const root = postcss.parse(css);
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop.toLowerCase();
|
||||
if (DYN_VH.test(decl.value)) {
|
||||
DYN_VH.lastIndex = 0;
|
||||
before(decl, decl.prop, decl.value.replace(DYN_VH, "$1vh"));
|
||||
}
|
||||
DYN_VH.lastIndex = 0;
|
||||
const sides = SIDES[prop];
|
||||
if (sides) {
|
||||
const parts = postcss.list.space(decl.value);
|
||||
if (parts.length === 1) {
|
||||
for (const side of sides) before(decl, side, decl.value);
|
||||
} else if (parts.length === 2 && prop.endsWith("-block")) {
|
||||
before(decl, sides[0], parts[0]);
|
||||
before(decl, sides[1], parts[1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (prop === "inset") {
|
||||
const p = postcss.list.space(decl.value);
|
||||
const [t, r = t, b = t, l = r] = p;
|
||||
if (p.length >= 1 && p.length <= 4) {
|
||||
before(decl, "top", t);
|
||||
before(decl, "right", r);
|
||||
before(decl, "bottom", b);
|
||||
before(decl, "left", l);
|
||||
}
|
||||
}
|
||||
});
|
||||
return root.toResult({ map: false }).css;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// @ts-expect-error plain .mjs helper, no type declarations
|
||||
import { addOldWebkitFallbacks } from "./oldWebkit.mjs";
|
||||
|
||||
describe("addOldWebkitFallbacks", () => {
|
||||
it("gives dynamic viewport heights a vh fallback first, so Big Sur keeps a fixed app height", () => {
|
||||
expect(addOldWebkitFallbacks("html,body,#root{height:100dvh}")).toBe(
|
||||
"html,body,#root{height:100vh;height:100dvh}",
|
||||
);
|
||||
expect(addOldWebkitFallbacks(".m{max-height:calc(100dvh - .5rem)}")).toBe(
|
||||
".m{max-height:calc(100vh - .5rem);max-height:calc(100dvh - .5rem)}",
|
||||
);
|
||||
expect(addOldWebkitFallbacks(".s{min-height:90svh}")).toBe(".s{min-height:90vh;min-height:90svh}");
|
||||
});
|
||||
|
||||
it("writes left/right before a one-value inline padding or margin", () => {
|
||||
expect(addOldWebkitFallbacks(".px-4{padding-inline:calc(var(--spacing) * 4)}")).toBe(
|
||||
".px-4{padding-left:calc(var(--spacing) * 4);padding-right:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 4)}",
|
||||
);
|
||||
expect(addOldWebkitFallbacks(".mx{margin-inline:auto!important}")).toBe(
|
||||
".mx{margin-left:auto!important;margin-right:auto!important;margin-inline:auto!important}",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits block shorthands and inset, and leaves direction-dependent two-value inline alone", () => {
|
||||
expect(addOldWebkitFallbacks(".py{padding-block:1px 2px}")).toBe(
|
||||
".py{padding-top:1px;padding-bottom:2px;padding-block:1px 2px}",
|
||||
);
|
||||
expect(addOldWebkitFallbacks(".i{inset:0}")).toBe(".i{top:0;right:0;bottom:0;left:0;inset:0}");
|
||||
expect(addOldWebkitFallbacks(".x{padding-inline:1px 2px}")).toBe(".x{padding-inline:1px 2px}");
|
||||
});
|
||||
|
||||
it("leaves other CSS untouched", () => {
|
||||
const css = ".a{color:red}.b{inset-inline-start:0}";
|
||||
expect(addOldWebkitFallbacks(css)).toBe(css);
|
||||
});
|
||||
});
|
||||
Generated
+244
-37
@@ -305,7 +305,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "ava1"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"blake2 0.11.0",
|
||||
"blake3",
|
||||
@@ -494,6 +494,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.0",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -942,6 +943,19 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.25.0"
|
||||
@@ -1008,6 +1022,25 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
@@ -1410,6 +1443,28 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drag"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
|
||||
dependencies = [
|
||||
"core-graphics 0.24.0",
|
||||
"dunce",
|
||||
"gdk",
|
||||
"gdkx11",
|
||||
"gtk",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"windows 0.52.0",
|
||||
"windows-core 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
@@ -2987,6 +3042,12 @@ dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.21.0"
|
||||
@@ -3343,8 +3404,15 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
@@ -3366,6 +3434,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -3426,6 +3495,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4084,7 +4166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5-dump-forge-pfs"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"ps5upload-fpkg",
|
||||
@@ -4092,7 +4174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5-dump-forge-ufs2"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"ps5upload-fpkg",
|
||||
"unicode-normalization",
|
||||
@@ -4100,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5upload-ava1"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ava1",
|
||||
@@ -4109,6 +4191,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"ps5upload-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sevenz-rust2",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4118,7 +4201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5upload-core"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"aes 0.9.2",
|
||||
"anyhow",
|
||||
@@ -4140,7 +4223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5upload-desktop"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ava1",
|
||||
@@ -4155,6 +4238,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mdns-sd",
|
||||
"open",
|
||||
"png 0.18.1",
|
||||
"ps5upload-core",
|
||||
"ps5upload-engine",
|
||||
"reqwest 0.12.28",
|
||||
@@ -4163,6 +4247,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-drag",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
@@ -4172,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5upload-engine"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"anyhow",
|
||||
@@ -4182,6 +4267,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"blake3",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"ps5-dump-forge-pfs",
|
||||
"ps5-dump-forge-ufs2",
|
||||
"ps5upload-ava1",
|
||||
@@ -4210,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5upload-fpkg"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"aes 0.9.2",
|
||||
"crc32fast",
|
||||
@@ -4218,7 +4304,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hmac",
|
||||
"libc",
|
||||
"lz4_flex",
|
||||
"lz4_flex 0.14.0",
|
||||
"num-bigint",
|
||||
"ps5upload-pkg",
|
||||
"serde",
|
||||
@@ -4231,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ps5upload-pkg"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
dependencies = [
|
||||
"aes 0.9.2",
|
||||
"blake3",
|
||||
@@ -4400,6 +4486,16 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -5017,9 +5113,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -5156,8 +5252,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sevenz-rust2"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a4f883677093690e91fef8ae81fad8bce9e2c3a079b61054f05ce0d3ecf681e"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"js-sys",
|
||||
@@ -5291,7 +5385,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hmac",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"lz4_flex 0.13.1",
|
||||
"md-5",
|
||||
"md4",
|
||||
"num_enum",
|
||||
@@ -5616,7 +5710,7 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"core-graphics 0.25.0",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
"dispatch2",
|
||||
@@ -5666,9 +5760,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.12.1"
|
||||
version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9"
|
||||
checksum = "d061d042ccd2df9c9f1508c1e63d835278c0a4c8801eb41764080e51f2eaaf34"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5718,9 +5812,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
|
||||
checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -5796,9 +5890,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.8.1"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
|
||||
checksum = "3dca325d6e66e9170f2e1b9631722e4e544bcbc51781575d266abdaba697bd51"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
@@ -5813,10 +5907,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.6.0"
|
||||
name = "tauri-plugin-drag"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb"
|
||||
checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"drag",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -5838,9 +5947,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.5.1"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533770889abb60a0407d5a32096182f0e6463f5a3dfaa675be94580aa14070e1"
|
||||
checksum = "8cf3cce3ea492b3a8a7f431a4e5dd5d31dc41b2a4a243faa660cbe8095a577bd"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
@@ -5853,13 +5962,14 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
"url",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.7.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345"
|
||||
checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
@@ -5879,9 +5989,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.2"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
|
||||
checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6596,9 +6706,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
version = "1.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -6944,6 +7054,18 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||
dependencies = [
|
||||
"windows-core 0.52.0",
|
||||
"windows-implement 0.52.0",
|
||||
"windows-interface 0.52.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -6987,14 +7109,36 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -7006,8 +7150,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -7035,6 +7179,28 @@ dependencies = [
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -7046,6 +7212,28 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -7089,6 +7277,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -7107,6 +7304,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ps5upload-desktop"
|
||||
version = "7.0.3"
|
||||
version = "7.1.11"
|
||||
description = "The all-in-one PS5 companion app."
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
@@ -118,9 +118,16 @@ libc = "0.2"
|
||||
# is a desktop action (decode a downloaded screenshot on your computer);
|
||||
# the command returns a clear "desktop only" error on mobile. Used from
|
||||
# commands/screenshot_convert.rs.
|
||||
# Desktop-only: drag a file out of Files onto the desktop (commands/dragout.rs).
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-drag = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios", all(target_os = "windows", target_arch = "aarch64"))))'.dependencies]
|
||||
jpegxr = "0.3"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# 16-bit HDR PNGs with a cICP chunk (#448): `image`'s encoder cannot add chunks. Already in
|
||||
# the tree through `image`.
|
||||
png = "0.18"
|
||||
|
||||
# Mobile (Android/iOS): link the engine as a library and run it
|
||||
# in-process (engine_mobile.rs). Desktop spawns it as a sidecar binary
|
||||
@@ -163,3 +170,5 @@ panic = "abort"
|
||||
# docs/unrar-upstream-bug.md.
|
||||
[patch.crates-io]
|
||||
unrar = { path = "../../third_party/unrar" }
|
||||
# 32-bit fix: archive sizes stay u64 (third_party/sevenz-rust2/README.md, #434).
|
||||
sevenz-rust2 = { path = "../../third_party/sevenz-rust2" }
|
||||
@@ -2,7 +2,9 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability set for the main window. Grants the plugin scopes the renderer calls through Tauri's invoke layer (file dialogs, opener, notifications, core window controls).",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop-drag",
|
||||
"description": "Drag a file out of Files onto the desktop (tauri-plugin-drag). Desktop only: the plugin is not compiled for Android or iOS, so its permission must not appear in the capability those builds load.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"permissions": [
|
||||
"drag:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Android side of the background watcher (`bg_activity`): drives
|
||||
//! `com.phantomptr.ps5upload.TransferService`, the foreground service that keeps the process
|
||||
//! (and with it the in-process engine) alive while a transfer runs with the app in the
|
||||
//! background, and posts the complete / failed notifications. The Kotlin class is written into
|
||||
//! `gen/android` by `scripts/release/android-postinit-patch.sh`.
|
||||
//!
|
||||
//! Every call is best-effort: a failure is logged and the transfer carries on without the
|
||||
//! service, as it did before it existed.
|
||||
|
||||
use jni::objects::{GlobalRef, JClass, JObject, JValue};
|
||||
use jni::JNIEnv;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::android_jni::{app_class, app_context, vm};
|
||||
|
||||
const SERVICE_CLASS: &str = "com.phantomptr.ps5upload.TransferService";
|
||||
|
||||
/// The service class, looked up once through the app's class loader.
|
||||
static CLASS: OnceLock<GlobalRef> = OnceLock::new();
|
||||
|
||||
fn with_service(
|
||||
what: &str,
|
||||
f: impl for<'a> FnOnce(&mut JNIEnv<'a>, &JClass<'a>, &JObject<'a>) -> jni::errors::Result<()>,
|
||||
) {
|
||||
let result = (|| -> Result<(), String> {
|
||||
let mut env = vm()?
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach: {e}"))?;
|
||||
let out = (|| {
|
||||
let context = app_context(&mut env)?;
|
||||
let global = match CLASS.get() {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
let class = app_class(&mut env, &context, SERVICE_CLASS)?;
|
||||
let g = env
|
||||
.new_global_ref(class)
|
||||
.map_err(|e| format!("global ref: {e}"))?;
|
||||
CLASS.get_or_init(|| g)
|
||||
}
|
||||
};
|
||||
let local = env
|
||||
.new_local_ref(global.as_obj())
|
||||
.map_err(|e| format!("local ref: {e}"))?;
|
||||
let class = JClass::from(local);
|
||||
f(&mut env, &class, &context).map_err(|e| e.to_string())
|
||||
})();
|
||||
// Never hand the thread back with a pending exception.
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
out
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
eprintln!("[bg] TransferService.{what}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the foreground service with its first progress line. `percent` < 0 = indeterminate.
|
||||
pub fn start(title: &str, text: &str, percent: i32, channel: &str) {
|
||||
with_service("start", |env, class, ctx| {
|
||||
let (t, x, c) = (
|
||||
env.new_string(title)?,
|
||||
env.new_string(text)?,
|
||||
env.new_string(channel)?,
|
||||
);
|
||||
env.call_static_method(
|
||||
class,
|
||||
"start",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(ctx),
|
||||
JValue::Object(&t),
|
||||
JValue::Object(&x),
|
||||
JValue::Int(percent),
|
||||
JValue::Object(&c),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh the ongoing progress notification.
|
||||
pub fn update(title: &str, text: &str, percent: i32) {
|
||||
with_service("update", |env, class, ctx| {
|
||||
let (t, x) = (env.new_string(title)?, env.new_string(text)?);
|
||||
env.call_static_method(
|
||||
class,
|
||||
"update",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;I)V",
|
||||
&[
|
||||
JValue::Object(ctx),
|
||||
JValue::Object(&t),
|
||||
JValue::Object(&x),
|
||||
JValue::Int(percent),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Post a complete / failed notification on the results channel. `id` keeps one per job.
|
||||
pub fn finished(id: i32, title: &str, text: &str, ok: bool, channel: &str) {
|
||||
with_service("finished", |env, class, ctx| {
|
||||
let (t, x, c) = (
|
||||
env.new_string(title)?,
|
||||
env.new_string(text)?,
|
||||
env.new_string(channel)?,
|
||||
);
|
||||
env.call_static_method(
|
||||
class,
|
||||
"finished",
|
||||
"(Landroid/content/Context;ILjava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(ctx),
|
||||
JValue::Int(id),
|
||||
JValue::Object(&t),
|
||||
JValue::Object(&x),
|
||||
JValue::Bool(ok as u8),
|
||||
JValue::Object(&c),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the service and remove its progress notification.
|
||||
pub fn stop() {
|
||||
with_service("stop", |env, class, ctx| {
|
||||
env.call_static_method(
|
||||
class,
|
||||
"stop",
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(ctx)],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! The JavaVM and Application context every Android JNI caller in this crate shares
|
||||
//! (`commands::local_fs`, `android_bg`). `JNI_OnLoad` may be defined once per library, so it
|
||||
//! lives here rather than with any one caller.
|
||||
|
||||
use jni::objects::{JClass, JObject, JValue};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// JavaVM captured at native-library load.
|
||||
///
|
||||
/// Tauri 2's Android backend (a Kotlin WryActivity that `loadLibrary`s
|
||||
/// this `.so`) does NOT initialize the `ndk_context` crate's global —
|
||||
/// tao carries its own internal glue and never calls
|
||||
/// `ndk_context::initialize_android_context`. So the old
|
||||
/// `ndk_context::android_context()` here always hit an uninitialized
|
||||
/// global and aborted the whole app on first use ("android context was
|
||||
/// not initialized"). We capture the VM ourselves in `JNI_OnLoad`
|
||||
/// instead — the runtime calls it once when the library loads, before
|
||||
/// any Tauri command can run, and neither wry nor tao defines it.
|
||||
static JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||
|
||||
/// Android runtime entry point, invoked on `System.loadLibrary`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut c_void,
|
||||
) -> jni::sys::jint {
|
||||
if let Ok(vm) = unsafe { JavaVM::from_raw(vm) } {
|
||||
let _ = JVM.set(vm);
|
||||
}
|
||||
jni::sys::JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
pub(crate) fn vm() -> Result<&'static JavaVM, String> {
|
||||
JVM.get()
|
||||
.ok_or_else(|| "Android JavaVM unavailable (JNI_OnLoad not run)".to_string())
|
||||
}
|
||||
|
||||
/// The app's Application context — a `Context` we can use for
|
||||
/// `getSystemService` and `startActivity(FLAG_ACTIVITY_NEW_TASK)`.
|
||||
/// Fetched via `ActivityThread.currentApplication()` so we don't need a
|
||||
/// captured Activity (or the unavailable `ndk_context`).
|
||||
pub(crate) fn app_context<'local>(env: &mut JNIEnv<'local>) -> Result<JObject<'local>, String> {
|
||||
let app = env
|
||||
.call_static_method(
|
||||
"android/app/ActivityThread",
|
||||
"currentApplication",
|
||||
"()Landroid/app/Application;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("currentApplication: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("application obj: {e}"))?;
|
||||
if app.is_null() {
|
||||
return Err("currentApplication() returned null".into());
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// One of the app's own classes (`com.phantomptr.ps5upload.…`). `FindClass` on a thread Rust
|
||||
/// attached searches the system class loader, which does not have them; the context's loader
|
||||
/// does. `name` is dotted (`com.example.Foo`).
|
||||
pub(crate) fn app_class<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject,
|
||||
name: &str,
|
||||
) -> Result<JClass<'local>, String> {
|
||||
let loader = env
|
||||
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||
.map_err(|e| format!("getClassLoader: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("loader obj: {e}"))?;
|
||||
let jname = env
|
||||
.new_string(name)
|
||||
.map_err(|e| format!("class name: {e}"))?;
|
||||
let class = env
|
||||
.call_method(
|
||||
&loader,
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&jname)],
|
||||
)
|
||||
.map_err(|e| format!("loadClass {name}: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("class obj: {e}"))?;
|
||||
Ok(JClass::from(class))
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
//! Background watcher (#457): notices from the host, not the WebView, that work is running or
|
||||
//! has just ended.
|
||||
//!
|
||||
//! A hidden window throttles the renderer's timers (WebView2, WebKitGTK, a hidden WKWebView can
|
||||
//! be suspended) and Android freezes a backgrounded app's process outright, so the renderer is
|
||||
//! the wrong place to decide "a job finished, tell the person and start the next one". This
|
||||
//! task polls the engine's `GET /api/activity` every second instead and:
|
||||
//!
|
||||
//! - on Android, runs a foreground service (`android_bg`) while anything is active, which keeps
|
||||
//! the process and the in-process engine alive, with an ongoing progress notification;
|
||||
//! - posts a complete / failed notification when the app is in the background;
|
||||
//! - emits `bg-job-finished` (and a `bg-tick` heartbeat while work runs) so the renderer's queue
|
||||
//! advances without waiting on a throttled timer.
|
||||
//!
|
||||
//! "Active" is an engine job running, or a renderer hold (queued items waiting their turn)
|
||||
//! refreshed within the last 5 minutes; the service stops 30 s after nothing is active.
|
||||
//! The decisions live in [`step`], which is pure and unit-tested.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, OnceLock, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
const POLL: Duration = Duration::from_secs(1);
|
||||
/// A renderer hold counts this long after its last refresh (the renderer refreshes every 60 s):
|
||||
/// a frozen renderer cannot keep the service up forever.
|
||||
const HOLD_TTL_MS: u64 = 5 * 60 * 1000;
|
||||
/// Nothing active for this long stops the service. Covers the gap between queue items.
|
||||
const IDLE_GRACE_MS: u64 = 30 * 1000;
|
||||
/// Weight of the newest sample in the speed average.
|
||||
const EMA_ALPHA: f64 = 0.3;
|
||||
|
||||
// ── Engine snapshot ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct ActiveJob {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub bytes_done: u64,
|
||||
#[serde(default)]
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||
pub struct FinishedJob {
|
||||
pub seq: u64,
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub cancelled: bool,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bytes: u64,
|
||||
#[serde(default)]
|
||||
pub elapsed_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct Snapshot {
|
||||
pub seq: u64,
|
||||
#[serde(default)]
|
||||
pub active: Vec<ActiveJob>,
|
||||
#[serde(default)]
|
||||
pub finished: Vec<FinishedJob>,
|
||||
}
|
||||
|
||||
// ── Strings (the renderer sends its language's; English until it does) ──────
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct BgStrings {
|
||||
pub uploading: String,
|
||||
pub downloading: String,
|
||||
pub installing: String,
|
||||
pub working: String,
|
||||
pub jobs_running: String,
|
||||
pub waiting: String,
|
||||
pub progress_of: String,
|
||||
pub done_upload: String,
|
||||
pub done_download: String,
|
||||
pub done_install: String,
|
||||
pub done_job: String,
|
||||
pub failed_upload: String,
|
||||
pub failed_download: String,
|
||||
pub failed_install: String,
|
||||
pub failed_job: String,
|
||||
pub channel_progress: String,
|
||||
pub channel_results: String,
|
||||
}
|
||||
|
||||
impl Default for BgStrings {
|
||||
fn default() -> Self {
|
||||
let s = |v: &str| v.to_string();
|
||||
Self {
|
||||
uploading: s("Uploading {name}"),
|
||||
downloading: s("Downloading {name}"),
|
||||
installing: s("Installing {name}"),
|
||||
working: s("Working on {name}"),
|
||||
jobs_running: s("{n} jobs running"),
|
||||
waiting: s("Waiting for the next item"),
|
||||
progress_of: s("{done} of {total}"),
|
||||
done_upload: s("Upload complete"),
|
||||
done_download: s("Download complete"),
|
||||
done_install: s("Installed"),
|
||||
done_job: s("Finished"),
|
||||
failed_upload: s("Upload failed"),
|
||||
failed_download: s("Download failed"),
|
||||
failed_install: s("Install failed"),
|
||||
failed_job: s("Failed"),
|
||||
channel_progress: s("Transfers in progress"),
|
||||
channel_results: s("Finished transfers"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The pure core ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Progress {
|
||||
pub title: String,
|
||||
pub text: String,
|
||||
/// `None` when the total is unknown (indeterminate bar).
|
||||
pub percent: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
StartFg(Progress),
|
||||
Update(Progress),
|
||||
Finished(FinishedJob),
|
||||
StopFg,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Rate {
|
||||
bytes: u64,
|
||||
at_ms: u64,
|
||||
bps: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {
|
||||
/// The engine seq already seen; `None` until the first poll.
|
||||
pub seq: Option<u64>,
|
||||
/// The service (and progress notification) is up.
|
||||
pub fg: bool,
|
||||
idle_since_ms: Option<u64>,
|
||||
last: Option<Progress>,
|
||||
rates: HashMap<String, Rate>,
|
||||
pub strings: BgStrings,
|
||||
}
|
||||
|
||||
/// One poll's decisions. The first poll only records `seq`, so a launch never re-announces
|
||||
/// endings from before it; a `seq` lower than the one seen means the engine restarted, and
|
||||
/// counting starts again from its value.
|
||||
pub fn step(state: &mut State, snap: &Snapshot, hold: bool, now_ms: u64) -> Vec<Action> {
|
||||
let mut out = Vec::new();
|
||||
match state.seq {
|
||||
Some(seen) if snap.seq >= seen => {
|
||||
for f in snap.finished.iter().filter(|f| f.seq > seen) {
|
||||
out.push(Action::Finished(f.clone()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
state.seq = Some(snap.seq);
|
||||
|
||||
update_rates(&mut state.rates, &snap.active, now_ms);
|
||||
let active = !snap.active.is_empty() || hold;
|
||||
if active {
|
||||
state.idle_since_ms = None;
|
||||
let p = format_progress(&snap.active, &state.rates, &state.strings);
|
||||
if !state.fg {
|
||||
state.fg = true;
|
||||
out.push(Action::StartFg(p.clone()));
|
||||
state.last = Some(p);
|
||||
} else if state.last.as_ref() != Some(&p) {
|
||||
out.push(Action::Update(p.clone()));
|
||||
state.last = Some(p);
|
||||
}
|
||||
} else if state.fg {
|
||||
match state.idle_since_ms {
|
||||
None => {
|
||||
state.idle_since_ms = Some(now_ms);
|
||||
// The last job's 99% would otherwise sit there through the grace period.
|
||||
let p = format_progress(&[], &state.rates, &state.strings);
|
||||
if state.last.as_ref() != Some(&p) {
|
||||
out.push(Action::Update(p.clone()));
|
||||
state.last = Some(p);
|
||||
}
|
||||
}
|
||||
Some(t) if now_ms.saturating_sub(t) >= IDLE_GRACE_MS => {
|
||||
state.fg = false;
|
||||
state.idle_since_ms = None;
|
||||
state.last = None;
|
||||
out.push(Action::StopFg);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-job speed, an exponential moving average of bytes over time between polls. Jobs that
|
||||
/// are no longer running are forgotten.
|
||||
fn update_rates(rates: &mut HashMap<String, Rate>, active: &[ActiveJob], now_ms: u64) {
|
||||
rates.retain(|id, _| active.iter().any(|j| &j.id == id));
|
||||
for j in active {
|
||||
match rates.get_mut(&j.id) {
|
||||
None => {
|
||||
rates.insert(
|
||||
j.id.clone(),
|
||||
Rate {
|
||||
bytes: j.bytes_done,
|
||||
at_ms: now_ms,
|
||||
bps: 0.0,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(r) => {
|
||||
let dt = now_ms.saturating_sub(r.at_ms);
|
||||
if dt == 0 {
|
||||
continue;
|
||||
}
|
||||
let sample = j.bytes_done.saturating_sub(r.bytes) as f64 * 1000.0 / dt as f64;
|
||||
r.bps = if r.bps == 0.0 {
|
||||
sample
|
||||
} else {
|
||||
EMA_ALPHA * sample + (1.0 - EMA_ALPHA) * r.bps
|
||||
};
|
||||
r.bytes = j.bytes_done;
|
||||
r.at_ms = now_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `1.2 GB`, `38 MB`: SI units like the engine's MB/s, one decimal below 10.
|
||||
pub fn fmt_bytes(n: f64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
|
||||
let mut v = n.max(0.0);
|
||||
let mut u = 0;
|
||||
while v >= 1000.0 && u < UNITS.len() - 1 {
|
||||
v /= 1000.0;
|
||||
u += 1;
|
||||
}
|
||||
if u == 0 || v >= 10.0 {
|
||||
format!("{v:.0} {}", UNITS[u])
|
||||
} else {
|
||||
format!("{v:.1} {}", UNITS[u])
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress notification: "Uploading <label>" / "42% · 1.2 GB of 2.9 GB · 38 MB/s" for
|
||||
/// one job, "<n> jobs running" with the summed progress for several, "Waiting for the next
|
||||
/// item" while only a renderer hold is active.
|
||||
pub fn format_progress(
|
||||
active: &[ActiveJob],
|
||||
rates: &HashMap<String, Rate>,
|
||||
s: &BgStrings,
|
||||
) -> Progress {
|
||||
let title = match active {
|
||||
[] => s.waiting.clone(),
|
||||
[one] => {
|
||||
let tmpl = match one.kind.as_str() {
|
||||
"upload" => &s.uploading,
|
||||
"download" => &s.downloading,
|
||||
"install" => &s.installing,
|
||||
_ => &s.working,
|
||||
};
|
||||
tmpl.replace("{name}", one.label.trim()).trim().to_string()
|
||||
}
|
||||
many => s.jobs_running.replace("{n}", &many.len().to_string()),
|
||||
};
|
||||
if active.is_empty() {
|
||||
return Progress {
|
||||
title,
|
||||
text: String::new(),
|
||||
percent: None,
|
||||
};
|
||||
}
|
||||
let done: u64 = active.iter().map(|j| j.bytes_done).sum();
|
||||
// A job with no known total makes the sum meaningless as a fraction.
|
||||
let total: Option<u64> = active
|
||||
.iter()
|
||||
.map(|j| (j.bytes_total > 0).then_some(j.bytes_total))
|
||||
.sum();
|
||||
let bps: f64 = active
|
||||
.iter()
|
||||
.filter_map(|j| rates.get(&j.id))
|
||||
.map(|r| r.bps)
|
||||
.sum();
|
||||
let percent = total.map(|t| ((done.min(t) as f64 / t as f64) * 100.0).floor() as u8);
|
||||
let mut parts = Vec::new();
|
||||
if let Some(p) = percent {
|
||||
parts.push(format!("{p}%"));
|
||||
}
|
||||
match total {
|
||||
Some(t) => parts.push(
|
||||
s.progress_of
|
||||
.replace("{done}", &fmt_bytes(done as f64))
|
||||
.replace("{total}", &fmt_bytes(t as f64)),
|
||||
),
|
||||
None if done > 0 => parts.push(fmt_bytes(done as f64)),
|
||||
None => {}
|
||||
}
|
||||
if bps >= 1.0 {
|
||||
parts.push(format!("{}/s", fmt_bytes(bps)));
|
||||
}
|
||||
Progress {
|
||||
title,
|
||||
text: parts.join(" · "),
|
||||
percent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a job's ending is worth a notification: not one the person stopped, and not an
|
||||
/// unnamed job (the client names everything it started; the rest is the app's own plumbing).
|
||||
pub fn should_notify(f: &FinishedJob) -> bool {
|
||||
!f.cancelled && !f.label.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Title and body of a complete / failed notification.
|
||||
pub fn finished_text(f: &FinishedJob, s: &BgStrings) -> (String, String) {
|
||||
let title = match (f.ok, f.kind.as_str()) {
|
||||
(true, "upload") => &s.done_upload,
|
||||
(true, "download") => &s.done_download,
|
||||
(true, "install") => &s.done_install,
|
||||
(true, _) => &s.done_job,
|
||||
(false, "upload") => &s.failed_upload,
|
||||
(false, "download") => &s.failed_download,
|
||||
(false, "install") => &s.failed_install,
|
||||
(false, _) => &s.failed_job,
|
||||
};
|
||||
let mut body = f.label.trim().to_string();
|
||||
if let Some(e) = f.error.as_deref().filter(|e| !f.ok && !e.is_empty()) {
|
||||
body = format!("{body}\n{e}");
|
||||
}
|
||||
(title.clone(), body)
|
||||
}
|
||||
|
||||
// ── Renderer inputs ──────────────────────────────────────────────────────────
|
||||
|
||||
/// When the renderer last said work is queued or running (ms), `None` = released.
|
||||
static HOLD: Mutex<Option<u64>> = Mutex::new(None);
|
||||
/// The page is showing (renderer `visibilitychange`). Android's only foreground signal.
|
||||
static VISIBLE: AtomicBool = AtomicBool::new(true);
|
||||
/// The person's "Mirror notifications to the system" setting.
|
||||
static NOTIFY: AtomicBool = AtomicBool::new(true);
|
||||
static STRINGS: OnceLock<RwLock<BgStrings>> = OnceLock::new();
|
||||
|
||||
fn strings() -> BgStrings {
|
||||
STRINGS
|
||||
.get_or_init(|| RwLock::new(BgStrings::default()))
|
||||
.read()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn hold_active(now: u64) -> bool {
|
||||
HOLD.lock()
|
||||
.map(|g| g.is_some_and(|t| now.saturating_sub(t) < HOLD_TTL_MS))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The renderer has work queued or running (`true`, refreshed while it lasts) or none.
|
||||
#[tauri::command]
|
||||
pub fn bg_work_hold(active: bool) {
|
||||
if let Ok(mut g) = HOLD.lock() {
|
||||
*g = active.then(now_ms);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BgConfig {
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub notify: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub strings: Option<BgStrings>,
|
||||
}
|
||||
|
||||
/// The page's visibility, the notification setting and the notification texts in the
|
||||
/// person's language. Each field is optional; absent ones are left as they were.
|
||||
#[tauri::command]
|
||||
pub fn bg_configure(config: BgConfig) {
|
||||
if let Some(v) = config.visible {
|
||||
VISIBLE.store(v, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(n) = config.notify {
|
||||
NOTIFY.store(n, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(s) = config.strings {
|
||||
if let Ok(mut g) = STRINGS
|
||||
.get_or_init(|| RwLock::new(BgStrings::default()))
|
||||
.write()
|
||||
{
|
||||
*g = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BgCapabilities {
|
||||
/// The host posts job complete / failed notifications itself; the renderer must not
|
||||
/// mirror the same ones.
|
||||
pub native_job_notify: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bg_capabilities() -> BgCapabilities {
|
||||
BgCapabilities {
|
||||
native_job_notify: WATCHING.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
// ── The task ─────────────────────────────────────────────────────────────────
|
||||
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
/// The engine has answered `/api/activity`: endings will be seen and announced here. An
|
||||
/// engine without the route (a remote one on an older version) leaves the page announcing them.
|
||||
static WATCHING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Start the watcher once per process. Android can run setup again when its Activity is
|
||||
/// recreated; the second call is a no-op.
|
||||
pub fn spawn<R: Runtime>(app: &AppHandle<R>) {
|
||||
if STARTED.set(()).is_err() {
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let client = match crate::engine_http::engine_client_builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[bg] no HTTP client: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut state = State::default();
|
||||
let mut ticker = tokio::time::interval(POLL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let url = format!(
|
||||
"{}/api/activity?since={}",
|
||||
crate::engine::url(),
|
||||
state.seq.unwrap_or(0)
|
||||
);
|
||||
let snap = match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => r.json::<Snapshot>().await.ok(),
|
||||
// An engine without the route (a remote one on an older version) or one that
|
||||
// is down: nothing to report this round.
|
||||
_ => None,
|
||||
};
|
||||
let Some(snap) = snap else { continue };
|
||||
WATCHING.store(true, Ordering::Relaxed);
|
||||
state.strings = strings();
|
||||
let now = now_ms();
|
||||
let actions = step(&mut state, &snap, hold_active(now), now);
|
||||
for a in &actions {
|
||||
dispatch(&app, a, &state.strings);
|
||||
}
|
||||
if state.fg {
|
||||
let _ = app.emit("bg-tick", ());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch<R: Runtime>(app: &AppHandle<R>, action: &Action, s: &BgStrings) {
|
||||
match action {
|
||||
Action::Finished(f) => {
|
||||
let _ = app.emit("bg-job-finished", f);
|
||||
if should_notify(f) && NOTIFY.load(Ordering::Relaxed) && !app_in_foreground(app) {
|
||||
let (title, body) = finished_text(f, s);
|
||||
notify_finished(app, f, &title, &body, s);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
Action::StartFg(p) => crate::android_bg::start(
|
||||
&p.title,
|
||||
&p.text,
|
||||
p.percent.map_or(-1, i32::from),
|
||||
&s.channel_progress,
|
||||
),
|
||||
#[cfg(target_os = "android")]
|
||||
Action::Update(p) => {
|
||||
crate::android_bg::update(&p.title, &p.text, p.percent.map_or(-1, i32::from))
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
Action::StopFg => crate::android_bg::stop(),
|
||||
// Desktop has no service to run: the engine is its own process and keep_awake.rs holds
|
||||
// the machine awake. Progress stays in the window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
Action::StartFg(_) | Action::Update(_) | Action::StopFg => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the person is looking at the app, so an OS notification would only repeat the
|
||||
/// in-app one. Desktop asks the window; a phone has one app on screen, and the page reports it.
|
||||
#[cfg(desktop)]
|
||||
fn app_in_foreground<R: Runtime>(app: &AppHandle<R>) -> bool {
|
||||
use tauri::Manager;
|
||||
app.get_webview_window("main").is_some_and(|w| {
|
||||
w.is_visible().unwrap_or(false)
|
||||
&& !w.is_minimized().unwrap_or(false)
|
||||
&& w.is_focused().unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
fn app_in_foreground<R: Runtime>(_app: &AppHandle<R>) -> bool {
|
||||
VISIBLE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn notify_finished<R: Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
f: &FinishedJob,
|
||||
title: &str,
|
||||
body: &str,
|
||||
s: &BgStrings,
|
||||
) {
|
||||
crate::android_bg::finished(notif_id(&f.id), title, body, f.ok, &s.channel_results);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn notify_finished<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
_f: &FinishedJob,
|
||||
title: &str,
|
||||
body: &str,
|
||||
_s: &BgStrings,
|
||||
) {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
if let Err(e) = app.notification().builder().title(title).body(body).show() {
|
||||
eprintln!("[bg] notification: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable notification id per job, so a repeat replaces rather than stacks.
|
||||
#[cfg(target_os = "android")]
|
||||
fn notif_id(job: &str) -> i32 {
|
||||
let h = job.bytes().fold(0x811c_9dc5_u32, |h, b| {
|
||||
(h ^ b as u32).wrapping_mul(0x0100_0193)
|
||||
});
|
||||
// Clear of the service's own id (1).
|
||||
(h & 0x3fff_ffff) as i32 + 2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn job(id: &str, kind: &str, label: &str, done: u64, total: u64) -> ActiveJob {
|
||||
ActiveJob {
|
||||
id: id.into(),
|
||||
kind: kind.into(),
|
||||
label: label.into(),
|
||||
bytes_done: done,
|
||||
bytes_total: total,
|
||||
}
|
||||
}
|
||||
|
||||
fn fin(seq: u64, id: &str) -> FinishedJob {
|
||||
FinishedJob {
|
||||
seq,
|
||||
id: id.into(),
|
||||
kind: "upload".into(),
|
||||
label: "Game".into(),
|
||||
ok: true,
|
||||
..FinishedJob::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn snap(seq: u64, active: Vec<ActiveJob>, finished: Vec<FinishedJob>) -> Snapshot {
|
||||
Snapshot {
|
||||
seq,
|
||||
active,
|
||||
finished,
|
||||
}
|
||||
}
|
||||
|
||||
fn finished_ids(a: &[Action]) -> Vec<String> {
|
||||
a.iter()
|
||||
.filter_map(|a| match a {
|
||||
Action::Finished(f) => Some(f.id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_poll_only_records_seq() {
|
||||
let mut st = State::default();
|
||||
let a = step(&mut st, &snap(5, vec![], vec![fin(5, "old")]), false, 0);
|
||||
assert!(a.is_empty(), "{a:?}");
|
||||
assert_eq!(st.seq, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_endings_are_reported_once() {
|
||||
let mut st = State::default();
|
||||
step(&mut st, &snap(5, vec![], vec![]), false, 0);
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(7, vec![], vec![fin(6, "a"), fin(7, "b")]),
|
||||
false,
|
||||
1000,
|
||||
);
|
||||
assert_eq!(finished_ids(&a), vec!["a", "b"]);
|
||||
// The same entries again (a poll raced the since) are not news.
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(7, vec![], vec![fin(6, "a"), fin(7, "b")]),
|
||||
false,
|
||||
2000,
|
||||
);
|
||||
assert!(finished_ids(&a).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_engine_restart_resets_the_seq() {
|
||||
let mut st = State::default();
|
||||
step(&mut st, &snap(50, vec![], vec![]), false, 0);
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(2, vec![], vec![fin(1, "x"), fin(2, "y")]),
|
||||
false,
|
||||
1000,
|
||||
);
|
||||
assert!(finished_ids(&a).is_empty());
|
||||
assert_eq!(st.seq, Some(2));
|
||||
let a = step(&mut st, &snap(3, vec![], vec![fin(3, "z")]), false, 2000);
|
||||
assert_eq!(finished_ids(&a), vec!["z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_job_starts_then_updates_the_service() {
|
||||
let mut st = State::default();
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(0, vec![job("j", "upload", "Game", 0, 100)], vec![]),
|
||||
false,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(a.as_slice(), [Action::StartFg(p)] if p.title == "Uploading Game"));
|
||||
// Unchanged progress: nothing to send.
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(0, vec![job("j", "upload", "Game", 0, 100)], vec![]),
|
||||
false,
|
||||
1000,
|
||||
);
|
||||
assert!(a.is_empty(), "{a:?}");
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(0, vec![job("j", "upload", "Game", 50, 100)], vec![]),
|
||||
false,
|
||||
2000,
|
||||
);
|
||||
assert!(matches!(a.as_slice(), [Action::Update(p)] if p.percent == Some(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renderer_hold_alone_keeps_the_service_up() {
|
||||
let mut st = State::default();
|
||||
let a = step(&mut st, &snap(0, vec![], vec![]), true, 0);
|
||||
assert!(
|
||||
matches!(a.as_slice(), [Action::StartFg(p)] if p.title == "Waiting for the next item")
|
||||
);
|
||||
let a = step(&mut st, &snap(0, vec![], vec![]), true, 60_000);
|
||||
assert!(a.is_empty());
|
||||
assert!(st.fg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_service_stops_after_the_grace_period_not_before() {
|
||||
let mut st = State::default();
|
||||
step(
|
||||
&mut st,
|
||||
&snap(0, vec![job("j", "upload", "Game", 1, 2)], vec![]),
|
||||
false,
|
||||
0,
|
||||
);
|
||||
let a = step(&mut st, &snap(1, vec![], vec![fin(1, "j")]), false, 1_000);
|
||||
assert_eq!(finished_ids(&a), vec!["j"]);
|
||||
assert!(a
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::Update(p) if p.percent.is_none())));
|
||||
assert!(step(&mut st, &snap(1, vec![], vec![]), false, 30_999).is_empty());
|
||||
let a = step(&mut st, &snap(1, vec![], vec![]), false, 31_000);
|
||||
assert_eq!(a, vec![Action::StopFg]);
|
||||
assert!(!st.fg);
|
||||
// Idle and stopped: nothing more.
|
||||
assert!(step(&mut st, &snap(1, vec![], vec![]), false, 90_000).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_returning_in_the_grace_period_keeps_the_service() {
|
||||
let mut st = State::default();
|
||||
step(
|
||||
&mut st,
|
||||
&snap(0, vec![job("a", "upload", "A", 1, 2)], vec![]),
|
||||
false,
|
||||
0,
|
||||
);
|
||||
step(&mut st, &snap(0, vec![], vec![]), false, 1_000);
|
||||
let a = step(
|
||||
&mut st,
|
||||
&snap(0, vec![job("b", "install", "B", 0, 0)], vec![]),
|
||||
false,
|
||||
20_000,
|
||||
);
|
||||
assert!(matches!(a.as_slice(), [Action::Update(p)] if p.title == "Installing B"));
|
||||
// The idle clock restarted: 30 s after the first idle poll is not a stop.
|
||||
assert!(!step(&mut st, &snap(0, vec![], vec![]), false, 32_000).contains(&Action::StopFg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_text_for_one_job_has_percent_bytes_and_speed() {
|
||||
let mut rates = HashMap::new();
|
||||
let j0 = job("j", "upload", "Astro Bot", 1_180_000_000, 2_900_000_000);
|
||||
update_rates(&mut rates, &[j0], 0);
|
||||
let j1 = job("j", "upload", "Astro Bot", 1_218_000_000, 2_900_000_000);
|
||||
update_rates(&mut rates, std::slice::from_ref(&j1), 1000);
|
||||
let p = format_progress(&[j1], &rates, &BgStrings::default());
|
||||
assert_eq!(p.title, "Uploading Astro Bot");
|
||||
assert_eq!(p.percent, Some(42));
|
||||
assert_eq!(p.text, "42% · 1.2 GB of 2.9 GB · 38 MB/s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_is_a_moving_average() {
|
||||
let mut rates = HashMap::new();
|
||||
update_rates(&mut rates, &[job("j", "upload", "", 0, 0)], 0);
|
||||
update_rates(&mut rates, &[job("j", "upload", "", 10_000_000, 0)], 1000);
|
||||
update_rates(&mut rates, &[job("j", "upload", "", 10_000_000, 0)], 2000);
|
||||
// 10 MB/s, then a stalled second: 0.3 * 0 + 0.7 * 10 MB/s.
|
||||
assert!((rates["j"].bps - 7_000_000.0).abs() < 1.0);
|
||||
update_rates(&mut rates, &[], 3000);
|
||||
assert!(rates.is_empty(), "finished jobs are forgotten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_jobs_sum_and_an_unknown_total_is_indeterminate() {
|
||||
let rates = HashMap::new();
|
||||
let s = BgStrings::default();
|
||||
let p = format_progress(
|
||||
&[
|
||||
job("a", "upload", "A", 1_000_000, 4_000_000),
|
||||
job("b", "download", "B", 1_000_000, 4_000_000),
|
||||
],
|
||||
&rates,
|
||||
&s,
|
||||
);
|
||||
assert_eq!(p.title, "2 jobs running");
|
||||
assert_eq!(p.percent, Some(25));
|
||||
assert_eq!(p.text, "25% · 2.0 MB of 8.0 MB");
|
||||
let p = format_progress(&[job("a", "job", "", 5_000, 0)], &rates, &s);
|
||||
assert_eq!(p.title, "Working on");
|
||||
assert_eq!(p.percent, None);
|
||||
assert_eq!(p.text, "5.0 kB");
|
||||
let p = format_progress(&[job("a", "install", "X", 0, 0)], &rates, &s);
|
||||
assert_eq!(p.text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_strings_follow_the_language_sent() {
|
||||
let s = BgStrings {
|
||||
uploading: "{name} wird hochgeladen".into(),
|
||||
progress_of: "{done} von {total}".into(),
|
||||
..BgStrings::default()
|
||||
};
|
||||
let p = format_progress(&[job("a", "upload", "Spiel", 1, 2)], &HashMap::new(), &s);
|
||||
assert_eq!(p.title, "Spiel wird hochgeladen");
|
||||
assert_eq!(p.text, "50% · 1 B von 2 B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_read_naturally() {
|
||||
assert_eq!(fmt_bytes(0.0), "0 B");
|
||||
assert_eq!(fmt_bytes(999.0), "999 B");
|
||||
assert_eq!(fmt_bytes(1_500.0), "1.5 kB");
|
||||
assert_eq!(fmt_bytes(38_000_000.0), "38 MB");
|
||||
assert_eq!(fmt_bytes(2_900_000_000.0), "2.9 GB");
|
||||
assert_eq!(fmt_bytes(12_000_000_000_000.0), "12 TB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_and_unnamed_jobs_are_not_announced() {
|
||||
let mut f = fin(1, "a");
|
||||
assert!(should_notify(&f));
|
||||
f.cancelled = true;
|
||||
assert!(!should_notify(&f));
|
||||
let mut g = fin(2, "b");
|
||||
g.label = " ".into();
|
||||
assert!(!should_notify(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notification_names_the_outcome_and_the_error() {
|
||||
let s = BgStrings::default();
|
||||
let mut f = fin(1, "a");
|
||||
assert_eq!(
|
||||
finished_text(&f, &s),
|
||||
("Upload complete".into(), "Game".into())
|
||||
);
|
||||
f.ok = false;
|
||||
f.kind = "install".into();
|
||||
f.error = Some("The PS5 refused it".into());
|
||||
assert_eq!(
|
||||
finished_text(&f, &s),
|
||||
("Install failed".into(), "Game\nThe PS5 refused it".into())
|
||||
);
|
||||
f.kind = "relay".into();
|
||||
assert_eq!(finished_text(&f, &s).0, "Failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_strings_fall_back_to_english() {
|
||||
let s: BgStrings = serde_json::from_str(r#"{"uploading":"Envoi de {name}"}"#).unwrap();
|
||||
assert_eq!(s.uploading, "Envoi de {name}");
|
||||
assert_eq!(s.done_install, "Installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_expires_without_refresh() {
|
||||
bg_work_hold(true);
|
||||
let now = now_ms();
|
||||
assert!(hold_active(now));
|
||||
assert!(!hold_active(now + HOLD_TTL_MS));
|
||||
bg_work_hold(false);
|
||||
assert!(!hold_active(now));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ struct Companion {
|
||||
role: &'static str,
|
||||
/// TCP port the tool listens on when running.
|
||||
port: u16,
|
||||
/// The tool serves its own web UI on that port, which the strip can open.
|
||||
web: bool,
|
||||
}
|
||||
|
||||
/// The companions we probe. Port values cross-checked against each
|
||||
@@ -58,11 +60,25 @@ const COMPANIONS: &[Companion] = &[
|
||||
name: "Homebrew enabler",
|
||||
role: "Homebrew enabler + launcher",
|
||||
port: 2323,
|
||||
web: false,
|
||||
},
|
||||
Companion {
|
||||
name: "FTP",
|
||||
role: "FTP server payload",
|
||||
port: 2121,
|
||||
web: false,
|
||||
},
|
||||
Companion {
|
||||
name: "Payload Manager",
|
||||
role: "Keeps payloads on the console and loads them at boot (itsPLK)",
|
||||
port: 8084,
|
||||
web: true,
|
||||
},
|
||||
Companion {
|
||||
name: "LZ4 Compression",
|
||||
role: "Compresses games on the PS5 itself (LZ4 packs for AMPR titles)",
|
||||
port: 5920,
|
||||
web: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -76,6 +92,7 @@ pub struct CompanionStatus {
|
||||
name: &'static str,
|
||||
role: &'static str,
|
||||
port: u16,
|
||||
web: bool,
|
||||
reachable: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
@@ -105,6 +122,7 @@ pub async fn companion_probe(host: String) -> Vec<CompanionStatus> {
|
||||
name: c.name,
|
||||
role: c.role,
|
||||
port: c.port,
|
||||
web: c.web,
|
||||
reachable: false,
|
||||
error: Some("No PS5 host configured".to_string()),
|
||||
})
|
||||
@@ -129,6 +147,7 @@ pub async fn companion_probe(host: String) -> Vec<CompanionStatus> {
|
||||
name: c.name,
|
||||
role: c.role,
|
||||
port: c.port,
|
||||
web: c.web,
|
||||
reachable: false,
|
||||
error: Some(e),
|
||||
}
|
||||
@@ -142,6 +161,7 @@ pub async fn companion_probe(host: String) -> Vec<CompanionStatus> {
|
||||
name: c.name,
|
||||
role: c.role,
|
||||
port: c.port,
|
||||
web: c.web,
|
||||
reachable: true,
|
||||
error: None,
|
||||
}
|
||||
@@ -154,6 +174,7 @@ pub async fn companion_probe(host: String) -> Vec<CompanionStatus> {
|
||||
name: c.name,
|
||||
role: c.role,
|
||||
port: c.port,
|
||||
web: c.web,
|
||||
reachable: false,
|
||||
error: Some(last),
|
||||
}
|
||||
@@ -171,6 +192,7 @@ pub async fn companion_probe(host: String) -> Vec<CompanionStatus> {
|
||||
name: "probe",
|
||||
role: "internal error",
|
||||
port: 0,
|
||||
web: false,
|
||||
reachable: false,
|
||||
error: Some(format!("task join: {e}")),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Staging folder for "drag a file out of Files onto the desktop".
|
||||
//!
|
||||
//! The OS drag (tauri-plugin-drag) can only carry files that exist on this
|
||||
//! computer, so the renderer first downloads the PS5 file into a folder under
|
||||
//! the app's local data and then starts the drag with that path. The folder is
|
||||
//! per console-and-directory so the file keeps its own name, and old folders
|
||||
//! are pruned on each use.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Staged files older than this are removed whenever a new drag is prepared.
|
||||
const KEEP_FOR: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Create (or reuse) the staging folder for `key` and return its path. `key`
|
||||
/// is any string that identifies the console and remote directory; it is
|
||||
/// reduced to a filesystem-safe name.
|
||||
#[tauri::command]
|
||||
pub fn dragout_stage_dir(app: AppHandle, key: String) -> Result<String, String> {
|
||||
let root: PathBuf = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|e| format!("no app data dir: {e}"))?
|
||||
.join("dragout");
|
||||
std::fs::create_dir_all(&root).map_err(|e| format!("create {}: {e}", root.display()))?;
|
||||
prune(&root);
|
||||
let safe: String = key
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
let dir = root.join(format!("{safe}_{:08x}", fxhash(&key)));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
|
||||
Ok(dir.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Remove staging folders that have not been touched for `KEEP_FOR`.
|
||||
fn prune(root: &std::path::Path) {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
let now = SystemTime::now();
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let old = e
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| now.duration_since(t).ok())
|
||||
.is_some_and(|age| age > KEEP_FOR);
|
||||
if old {
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A short, stable hash so two keys that sanitise alike still get their own folder.
|
||||
fn fxhash(s: &str) -> u32 {
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in s.bytes() {
|
||||
h ^= u32::from(b);
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
h
|
||||
}
|
||||
@@ -200,61 +200,9 @@ mod tests {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use crate::android_jni::{app_context, vm};
|
||||
use jni::objects::{GlobalRef, JObject, JString, JValue};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// JavaVM captured at native-library load.
|
||||
///
|
||||
/// Tauri 2's Android backend (a Kotlin WryActivity that `loadLibrary`s
|
||||
/// this `.so`) does NOT initialize the `ndk_context` crate's global —
|
||||
/// tao carries its own internal glue and never calls
|
||||
/// `ndk_context::initialize_android_context`. So the old
|
||||
/// `ndk_context::android_context()` here always hit an uninitialized
|
||||
/// global and aborted the whole app on first use ("android context was
|
||||
/// not initialized"). We capture the VM ourselves in `JNI_OnLoad`
|
||||
/// instead — the runtime calls it once when the library loads, before
|
||||
/// any Tauri command can run, and neither wry nor tao defines it.
|
||||
static JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||
|
||||
/// Android runtime entry point, invoked on `System.loadLibrary`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut c_void,
|
||||
) -> jni::sys::jint {
|
||||
if let Ok(vm) = unsafe { JavaVM::from_raw(vm) } {
|
||||
let _ = JVM.set(vm);
|
||||
}
|
||||
jni::sys::JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
fn vm() -> Result<&'static JavaVM, String> {
|
||||
JVM.get()
|
||||
.ok_or_else(|| "Android JavaVM unavailable (JNI_OnLoad not run)".to_string())
|
||||
}
|
||||
|
||||
/// The app's Application context — a `Context` we can use for
|
||||
/// `getSystemService` and `startActivity(FLAG_ACTIVITY_NEW_TASK)`.
|
||||
/// Fetched via `ActivityThread.currentApplication()` so we don't need a
|
||||
/// captured Activity (or the unavailable `ndk_context`).
|
||||
fn app_context<'local>(env: &mut JNIEnv<'local>) -> Result<JObject<'local>, String> {
|
||||
let app = env
|
||||
.call_static_method(
|
||||
"android/app/ActivityThread",
|
||||
"currentApplication",
|
||||
"()Landroid/app/Application;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("currentApplication: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("application obj: {e}"))?;
|
||||
if app.is_null() {
|
||||
return Err("currentApplication() returned null".into());
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn sdk_int(env: &mut jni::JNIEnv) -> Result<i32, String> {
|
||||
env.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod crash_reports;
|
||||
pub mod diag_log;
|
||||
pub mod diagnostics;
|
||||
pub mod discover;
|
||||
pub mod dragout;
|
||||
pub mod folder_inspect;
|
||||
pub mod heal_appmeta;
|
||||
pub mod host_platform;
|
||||
@@ -79,6 +80,7 @@ pub use app_lifecycle::*;
|
||||
pub use companions::*;
|
||||
pub use diagnostics::*;
|
||||
pub use discover::*;
|
||||
pub use dragout::*;
|
||||
pub use folder_inspect::*;
|
||||
pub use heal_appmeta::*;
|
||||
pub use keep_awake::*;
|
||||
|
||||
@@ -148,7 +148,24 @@ struct CatalogueEntry {
|
||||
/// use on the platform.
|
||||
/// - etahen / itemzflow: best-effort entries; users can edit this
|
||||
/// table to point at the variant they prefer.
|
||||
const CATALOGUE_HOSTS: &[&str] = &["github.com", "git.etawen.dev", "theghostonline.dev"];
|
||||
const CATALOGUE: &[CatalogueEntry] = &[
|
||||
CatalogueEntry {
|
||||
id: "elfldr",
|
||||
display_name: "elfldr",
|
||||
role: "ELF loader — load before ps5upload.elf",
|
||||
description: "Initial ELF loader for WebKit/payload-listener setups. Download elfldr-ps5.elf and load it through your jailbreak or autoloader before the PS5Upload helper. Sending requires an already working loader; this cannot bootstrap a closed port. This is the upstream build; PS5Upload can replace the standard running loader with its bundled patched build once the helper is connected.",
|
||||
repo_host: "github.com",
|
||||
repo_owner: "ps5-payload-dev",
|
||||
repo_name: "elfldr",
|
||||
asset_name_hint: "elfldr-ps5.elf",
|
||||
on_console_marker_path: None,
|
||||
process_name_hint: Some("elfldr.elf"),
|
||||
ports: &[9021],
|
||||
autoload_priority: 1,
|
||||
autoload_delay_ms: 1000,
|
||||
homepage: "https://github.com/ps5-payload-dev/elfldr",
|
||||
},
|
||||
CatalogueEntry {
|
||||
// display_name says "kstuff-lite (EchoStretch)" so this entry
|
||||
// must point at EchoStretch/kstuff-lite, not EchoStretch/kstuff
|
||||
@@ -391,6 +408,46 @@ const CATALOGUE: &[CatalogueEntry] = &[
|
||||
autoload_delay_ms: 200,
|
||||
homepage: "https://github.com/itsPLK/ps5-webkit-autoloader",
|
||||
},
|
||||
CatalogueEntry {
|
||||
// itsPLK's Payload Manager: keeps payloads on the console
|
||||
// (/data/pldmgr), launches them from its own web page on :8084 and
|
||||
// at boot. ps5upload's Payloads → Payload Manager tab talks to it,
|
||||
// and the catalogue's "Store in Payload Manager" fills it.
|
||||
id: "payload-manager",
|
||||
display_name: "Payload Manager (itsPLK)",
|
||||
role: "Keeps payloads on the console; loads them at boot",
|
||||
description: "Stores payloads on the PS5 itself and starts the ones you mark when the console boots, so a USB stick or a PC is not needed after setup. Has its own web page on port 8084. ps5upload's Payload Manager tab lists what it holds, launches or deletes entries, and edits the boot list; the Catalog's \"Store in Payload Manager\" keeps a payload there.",
|
||||
repo_host: "github.com",
|
||||
repo_owner: "itsPLK",
|
||||
repo_name: "ps5-payload-manager",
|
||||
asset_name_hint: "pldmgr",
|
||||
on_console_marker_path: Some("/data/pldmgr/pldmgr_config.txt"),
|
||||
process_name_hint: None,
|
||||
ports: &[8084],
|
||||
autoload_priority: 2,
|
||||
autoload_delay_ms: 1000,
|
||||
homepage: "https://github.com/itsPLK/ps5-payload-manager",
|
||||
},
|
||||
CatalogueEntry {
|
||||
// theghostonline's LZ4 Compression: packs an installed game's AMPR
|
||||
// assets with LZ4 on the console itself (no PC round trip), the
|
||||
// same pack format ps5upload's Convert writes on the PC. Web page
|
||||
// on :5920; the Connection screen's scene-tools strip opens it.
|
||||
id: "lz4-compression",
|
||||
display_name: "LZ4 Compression (theghostonline)",
|
||||
role: "Compresses games on the PS5 itself",
|
||||
description: "Shrinks a game that is already on the PS5 by LZ4-packing its AMPR assets, entirely on the console: the game never leaves it. Works on folder games and on mounted .exfat/.ffpfs images, packs in place with rollback, verifies every file, and can undo. Needs ShadowMount+ for image sources. Open its page on port 5920 from the Connection screen's scene tools once it is running. ps5upload's Convert does the same packing on this computer when the game is here instead.",
|
||||
repo_host: "theghostonline.dev",
|
||||
repo_owner: "theghostonline",
|
||||
repo_name: "PS5-LZ4-Compression",
|
||||
asset_name_hint: "lz4-compression",
|
||||
on_console_marker_path: Some("/data/LZ4Compression"),
|
||||
process_name_hint: None,
|
||||
ports: &[5920],
|
||||
autoload_priority: 9,
|
||||
autoload_delay_ms: 500,
|
||||
homepage: "https://theghostonline.dev/theghostonline/PS5-LZ4-Compression",
|
||||
},
|
||||
CatalogueEntry {
|
||||
id: "np-fake-signin",
|
||||
display_name: "NP Fake Sign-in",
|
||||
@@ -554,6 +611,27 @@ const CATALOGUE: &[CatalogueEntry] = &[
|
||||
autoload_delay_ms: 200,
|
||||
homepage: "https://github.com/StonedModder/Ghostpad",
|
||||
},
|
||||
CatalogueEntry {
|
||||
// X-F1REBALL-X/HearBridge-PS5 (GPL-3) — plays the console's audio on ordinary
|
||||
// Bluetooth (A2DP) headphones or speakers through the PS5's own radio, no dongle.
|
||||
// Releases ship one asset, `HearBridge-<version>.elf`, so the hint stops before the
|
||||
// version. Its own web page (pairing, volume, latency, EQ) is on TCP 8090, or the next
|
||||
// free port up to 8095; /data/hearbridge/ holds its state.
|
||||
id: "hearbridge",
|
||||
display_name: "HearBridge",
|
||||
role: "Bluetooth headphones and speakers for PS5 audio",
|
||||
description: "Sends the PS5's game and system audio to ordinary Bluetooth headphones or speakers (A2DP), using the console's own Bluetooth radio, so no dongle is needed. Pair, switch headsets, set volume, latency, EQ and night mode, and keep per-game profiles from its own web page (port 8090), which also adds a HearBridge tile to the home screen. Tested by its author on the original PS5 (CFI-10xx) and the Slim (CFI-2000).",
|
||||
repo_host: "github.com",
|
||||
repo_owner: "X-F1REBALL-X",
|
||||
repo_name: "HearBridge-PS5",
|
||||
asset_name_hint: "HearBridge-",
|
||||
on_console_marker_path: Some("/data/hearbridge/hearbridge.log"),
|
||||
process_name_hint: Some("hearbridge.elf"),
|
||||
ports: &[8090],
|
||||
autoload_priority: 5,
|
||||
autoload_delay_ms: 300,
|
||||
homepage: "https://github.com/X-F1REBALL-X/HearBridge-PS5",
|
||||
},
|
||||
CatalogueEntry {
|
||||
// Pharaoh2k/ps5debug-NG — a debugger for jailbroken PS5s (GPL-3). The
|
||||
// payload is an installer: it loads the debugger into the console's own
|
||||
@@ -955,6 +1033,13 @@ pub async fn payloads_remove_custom_repo(app: AppHandle, id: String) -> Result<b
|
||||
|
||||
/// Subset of GitHub's release JSON we care about. Trailing fields are
|
||||
/// dropped silently by serde-derive's default.
|
||||
/// A text field GitHub sometimes sends as `null` instead of leaving it out (a release with no
|
||||
/// notes has `"body": null`). `#[serde(default)]` alone only covers a missing field, so a null
|
||||
/// failed the whole release list ("invalid type: null, expected a string").
|
||||
fn null_as_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
@@ -965,15 +1050,15 @@ struct GithubAsset {
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "null_as_empty")]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "null_as_empty")]
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "null_as_empty")]
|
||||
published_at: String,
|
||||
#[serde(default)]
|
||||
assets: Vec<GithubAsset>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "null_as_empty")]
|
||||
html_url: String,
|
||||
/// GitHub/Gitea "this is a pre-release" flag. Surfaced so the UI can
|
||||
/// warn that a version may be unstable (payload dev moves fast).
|
||||
@@ -1230,8 +1315,7 @@ pub async fn payloads_release(
|
||||
// fail fast. A CUSTOM repo's host was typed + approved by the user at
|
||||
// add-time (validated by `is_plausible_repo_host`), so it isn't subject to
|
||||
// this fixed list — that's the whole point of "custom".
|
||||
const ALLOWED_HOSTS: &[&str] = &["github.com", "git.etawen.dev"];
|
||||
if !entry.is_custom && !ALLOWED_HOSTS.contains(&entry.repo_host.as_str()) {
|
||||
if !entry.is_custom && !CATALOGUE_HOSTS.contains(&entry.repo_host.as_str()) {
|
||||
return Err(format!(
|
||||
"catalog entry {} declares repo_host={:?} which is not in the allowlist",
|
||||
entry.id, entry.repo_host
|
||||
@@ -1383,8 +1467,7 @@ pub async fn payloads_releases(
|
||||
}
|
||||
|
||||
// Allowlist guard for catalogue entries only — see payloads_release.
|
||||
const ALLOWED_HOSTS: &[&str] = &["github.com", "git.etawen.dev"];
|
||||
if !entry.is_custom && !ALLOWED_HOSTS.contains(&entry.repo_host.as_str()) {
|
||||
if !entry.is_custom && !CATALOGUE_HOSTS.contains(&entry.repo_host.as_str()) {
|
||||
return Err(format!(
|
||||
"catalog entry {} declares repo_host={:?} which is not in the allowlist",
|
||||
entry.id, entry.repo_host
|
||||
@@ -1810,6 +1893,29 @@ use std::io::Write;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_release_with_null_notes_still_parses() {
|
||||
// websrv v0.35 shipped with `"body": null`, which failed the whole list.
|
||||
let json = r#"[{"tag_name":"v0.35","name":null,"body":null,"published_at":null,
|
||||
"html_url":null,"prerelease":false,"assets":[{"name":"websrv-ps5.elf",
|
||||
"browser_download_url":"https://example/websrv-ps5.elf","size":1,"label":null}]}]"#;
|
||||
let r: Vec<GithubRelease> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r[0].tag_name, "v0.35");
|
||||
assert_eq!(r[0].body, "");
|
||||
assert_eq!(r[0].assets[0].name, "websrv-ps5.elf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_hosts_allowed_and_loader_asset_pinned() {
|
||||
for entry in CATALOGUE {
|
||||
assert!(CATALOGUE_HOSTS.contains(&entry.repo_host), "{}", entry.id);
|
||||
}
|
||||
let lz4 = find_entry("lz4-compression").expect("LZ4 catalogue entry");
|
||||
assert_eq!(lz4.repo_host, "theghostonline.dev");
|
||||
let loader = find_entry("elfldr").unwrap();
|
||||
assert_eq!(loader.asset_name_hint, "elfldr-ps5.elf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_ids_unique() {
|
||||
let mut ids: Vec<&str> = CATALOGUE.iter().map(|e| e.id).collect();
|
||||
@@ -2154,7 +2260,9 @@ mod tests {
|
||||
.filter(|e| e.autoload_priority == 1)
|
||||
.collect();
|
||||
assert!(p0.iter().all(|e| e.id.starts_with("kstuff-")));
|
||||
assert!(p1.iter().all(|e| e.id == "shadowmountplus"));
|
||||
assert!(p1
|
||||
.iter()
|
||||
.all(|e| matches!(e.id, "shadowmountplus" | "elfldr")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -273,15 +273,17 @@ pub async fn send_payload_history_clear(app: AppHandle) -> Result<bool, String>
|
||||
// the resumed BEGIN_TX finds the interrupted entry and skips past the
|
||||
// last-acked shard.
|
||||
//
|
||||
// Entry lifetime: 24 h. After that we assume the payload's tx table has
|
||||
// evicted the entry (FIFO under PS5UPLOAD2_MAX_TX, so heavy users will
|
||||
// roll through faster), and resuming would just cause a full re-upload
|
||||
// anyway — might as well forget the stale tx_id client-side so the
|
||||
// engine mints a fresh one.
|
||||
// Entry lifetime: 7 days, the console's own journal retention. After that
|
||||
// the helper has cleaned the job up and resuming would be a full re-upload
|
||||
// anyway — might as well forget the stale tx_id client-side so the engine
|
||||
// mints a fresh one.
|
||||
//
|
||||
// Store: `<app_data_dir>/resume_txids.json`.
|
||||
|
||||
const RESUME_TXID_TTL_MS: u64 = 24 * 60 * 60 * 1000;
|
||||
/// Matches the console: the helper keeps a job's journal (and its staged `.ava-part` data)
|
||||
/// for seven days of inactivity before cleaning it up, so a stored id is useful that long.
|
||||
/// 24 h used to orphan a journal that the console would still have resumed (#440).
|
||||
const RESUME_TXID_TTL_MS: u64 = 7 * 24 * 60 * 60 * 1000;
|
||||
const RESUME_TXID_MAX_RECORDS: usize = 128;
|
||||
|
||||
fn empty_resume_txids() -> JsonValue {
|
||||
|
||||
@@ -157,16 +157,7 @@ pub async fn payload_check(ip: String) -> serde_json::Value {
|
||||
Ok(r) => {
|
||||
let code = r.status();
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"ok": false,
|
||||
"reachable": false,
|
||||
"engine": true,
|
||||
"error": if body.is_empty() {
|
||||
format!("engine returned HTTP {code}")
|
||||
} else {
|
||||
body
|
||||
},
|
||||
})
|
||||
status_failure(code.as_u16(), &body)
|
||||
}
|
||||
// Connection refused / timed out / reset: the engine side of the
|
||||
// proxy failed, so the console was never consulted.
|
||||
@@ -179,6 +170,63 @@ pub async fn payload_check(ip: String) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply for an engine that answered the status probe with a failure. `error` stays the
|
||||
/// raw body (the renderer matches tokens in it); `reason` (the engine's token, e.g.
|
||||
/// `console_rest_mode`) and `power` (`awake` / `rest` / `unreachable`) are passed through
|
||||
/// from a JSON body `{ error, reason, power }` when the engine sent them.
|
||||
fn status_failure(code: u16, body: &str) -> serde_json::Value {
|
||||
let mut out = serde_json::json!({
|
||||
"ok": false,
|
||||
"reachable": false,
|
||||
"engine": true,
|
||||
"error": if body.is_empty() {
|
||||
format!("engine returned HTTP {code}")
|
||||
} else {
|
||||
body.to_string()
|
||||
},
|
||||
});
|
||||
if let Ok(serde_json::Value::Object(parsed)) = serde_json::from_str(body) {
|
||||
for key in ["reason", "power"] {
|
||||
if let Some(v @ serde_json::Value::String(_)) = parsed.get(key) {
|
||||
out[key] = v.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod status_failure_tests {
|
||||
use super::status_failure;
|
||||
|
||||
#[test]
|
||||
fn reason_and_power_pass_through_from_the_engine_body() {
|
||||
let body = r#"{"error":"payload rejected NODE_STATUS: console_rest_mode: The PS5 is in rest mode.","reason":"console_rest_mode","power":"rest"}"#;
|
||||
let v = status_failure(502, body);
|
||||
assert_eq!(v["ok"], false);
|
||||
assert_eq!(v["reachable"], false);
|
||||
assert_eq!(v["engine"], true);
|
||||
assert_eq!(v["reason"], "console_rest_mode");
|
||||
assert_eq!(v["power"], "rest");
|
||||
assert_eq!(v["error"], body, "error stays the raw body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_engine_or_a_plain_body_has_no_reason_or_power() {
|
||||
let v = status_failure(502, r#"{"error":"helper_not_ava1: gone"}"#);
|
||||
assert!(v.get("reason").is_none() && v.get("power").is_none(), "{v}");
|
||||
let v = status_failure(502, "not json");
|
||||
assert_eq!(v["error"], "not json");
|
||||
assert!(v.get("reason").is_none());
|
||||
let v = status_failure(503, "");
|
||||
assert_eq!(v["error"], "engine returned HTTP 503");
|
||||
// A reason only, without power (the engine did not ask DDP).
|
||||
let v = status_failure(502, r#"{"error":"x","reason":"helper_not_ava1"}"#);
|
||||
assert_eq!(v["reason"], "helper_not_ava1");
|
||||
assert!(v.get("power").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream a file to a PS5 loader port. Extracted from the
|
||||
/// `#[tauri::command]` wrapper so the core flow (open → optional ELF
|
||||
/// magic check → connect → stream → half-close) is reachable from
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine;
|
||||
|
||||
@@ -187,6 +187,70 @@ pub async fn engine_restart(app: tauri::AppHandle) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the console's helper off (`POST /api/ps5/helper/stop`, #471). The engine's decided
|
||||
/// refusals (409 busy, 502 refused / still running) come back as `{ state: "refused", reason,
|
||||
/// error }` rather than an error string, so the renderer can say which; anything else that is not
|
||||
/// a success is an error as usual.
|
||||
#[tauri::command]
|
||||
pub async fn ps5_helper_stop(addr: String) -> Result<JsonValue, String> {
|
||||
let url = format!("{}/api/ps5/helper/stop", engine::url());
|
||||
let resp = http_client()
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "addr": addr }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"engine request failed: {}",
|
||||
crate::engine_http::error_chain(&e)
|
||||
)
|
||||
})?;
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("engine response body read failed: {e}"))?;
|
||||
helper_stop_answer(status.as_u16(), &text)
|
||||
}
|
||||
|
||||
/// The renderer's view of a helper-stop answer (see `ps5_helper_stop`).
|
||||
fn helper_stop_answer(status: u16, body: &str) -> Result<JsonValue, String> {
|
||||
let v = serde_json::from_str::<JsonValue>(body).unwrap_or(JsonValue::Null);
|
||||
if (200..300).contains(&status) {
|
||||
return Ok(v);
|
||||
}
|
||||
let error = v
|
||||
.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| body.to_string());
|
||||
match v.get("reason").and_then(|r| r.as_str()) {
|
||||
Some(reason) => Ok(serde_json::json!({
|
||||
"state": "refused",
|
||||
"reason": reason,
|
||||
"error": error,
|
||||
})),
|
||||
None => Err(format!("engine HTTP {status}: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod helper_stop_tests {
|
||||
use super::helper_stop_answer;
|
||||
|
||||
#[test]
|
||||
fn a_decided_refusal_is_an_answer_and_anything_else_an_error() {
|
||||
let ok = helper_stop_answer(200, r#"{"state":"stopped"}"#).unwrap();
|
||||
assert_eq!(ok["state"], "stopped");
|
||||
let busy = helper_stop_answer(409, r#"{"error":"in use","reason":"busy"}"#).unwrap();
|
||||
assert_eq!(busy["state"], "refused");
|
||||
assert_eq!(busy["reason"], "busy");
|
||||
assert_eq!(busy["error"], "in use");
|
||||
assert!(helper_stop_answer(400, r#"{"error":"addr is required"}"#).is_err());
|
||||
assert!(helper_stop_answer(500, "boom").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ps5_volumes(addr: Option<String>) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
@@ -340,6 +404,39 @@ pub async fn transfer_dir(req: TransferDirReq) -> Result<JsonValue, String> {
|
||||
post_json(&url, &body).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TransferFileListEntry {
|
||||
pub src: String,
|
||||
pub dest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TransferFileListReq {
|
||||
pub dest_root: String,
|
||||
pub files: Vec<TransferFileListEntry>,
|
||||
pub addr: Option<String>,
|
||||
pub tx_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bandwidth_cap_mbps: Option<f64>,
|
||||
}
|
||||
|
||||
/// Upload a set of files as ONE job (the Files screen's multi-file pick). Proxies the
|
||||
/// engine's `/api/transfer/file-list`. Long request: the engine stats every source before it
|
||||
/// answers with the job id, which on a slow disk and tens of thousands of files takes a while.
|
||||
#[tauri::command]
|
||||
pub async fn transfer_file_list(req: TransferFileListReq) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/transfer/file-list");
|
||||
let body = serde_json::json!({
|
||||
"dest_root": req.dest_root,
|
||||
"files": req.files,
|
||||
"addr": req.addr,
|
||||
"tx_id": req.tx_id,
|
||||
"bandwidth_cap_mbps": req.bandwidth_cap_mbps,
|
||||
});
|
||||
post_json_long(&url, &body).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TransferZipReq {
|
||||
pub zip_path: String,
|
||||
@@ -1796,9 +1893,8 @@ pub async fn ps5_app_register(req: AppRegisterReq) -> Result<JsonValue, String>
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reverse of `app_register`. Succeeds when the nullfs at
|
||||
/// `/system_ex/app/<title_id>` is fully torn down, even if the Sony
|
||||
/// AppUninstall API isn't available on the firmware.
|
||||
/// Reverse of `app_register`, and a package's uninstall. The engine's reply
|
||||
/// says whether Sony refused and whether the title is still on the console.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct AppUnregisterReq {
|
||||
pub addr: Option<String>,
|
||||
@@ -1967,20 +2063,34 @@ async fn engine_icon_data_url(url: String) -> Result<String, String> {
|
||||
/// Cover art for an installed title, as a `data:` URL. See
|
||||
/// [`engine_icon_data_url`].
|
||||
#[tauri::command]
|
||||
pub async fn ps5_app_icon_data(addr: Option<String>, title_id: String) -> Result<String, String> {
|
||||
pub async fn ps5_app_icon_data(
|
||||
addr: Option<String>,
|
||||
title_id: String,
|
||||
size: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
let mut url = addr_url("/api/ps5/app-icon", addr.as_deref());
|
||||
url.push_str(if url.contains('?') { "&" } else { "?" });
|
||||
url.push_str(&format!("title_id={}", urlencoding(&title_id)));
|
||||
if let Some(px) = size {
|
||||
url.push_str(&format!("&size={px}"));
|
||||
}
|
||||
engine_icon_data_url(url).await
|
||||
}
|
||||
|
||||
/// Cover art for an on-console game folder, as a `data:` URL. See
|
||||
/// [`engine_icon_data_url`].
|
||||
#[tauri::command]
|
||||
pub async fn ps5_game_icon_data(addr: Option<String>, path: String) -> Result<String, String> {
|
||||
pub async fn ps5_game_icon_data(
|
||||
addr: Option<String>,
|
||||
path: String,
|
||||
size: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
let mut url = addr_url("/api/ps5/game-icon", addr.as_deref());
|
||||
url.push_str(if url.contains('?') { "&" } else { "?" });
|
||||
url.push_str(&format!("path={}", urlencoding(&path)));
|
||||
if let Some(px) = size {
|
||||
url.push_str(&format!("&size={px}"));
|
||||
}
|
||||
engine_icon_data_url(url).await
|
||||
}
|
||||
|
||||
@@ -2225,12 +2335,13 @@ pub async fn game_inspect_files(token: String) -> Result<JsonValue, String> {
|
||||
}
|
||||
|
||||
/// What a game source is, before converting it: readiness, size and cost.
|
||||
/// Remote image metadata reads may exceed the standard 60-second request limit.
|
||||
/// POST /api/fpkg/inspect.
|
||||
#[tauri::command]
|
||||
pub async fn fpkg_inspect(source: String, output_dir: Option<String>) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/fpkg/inspect");
|
||||
post_json(
|
||||
post_json_long(
|
||||
&url,
|
||||
&serde_json::json!({ "source": source, "output_dir": output_dir }),
|
||||
)
|
||||
@@ -2241,6 +2352,7 @@ pub async fn fpkg_inspect(source: String, output_dir: Option<String>) -> Result<
|
||||
/// to poll with `job_status`; the finished job carries the package's path.
|
||||
/// POST /api/fpkg/build.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn fpkg_build(
|
||||
source: String,
|
||||
output_dir: Option<String>,
|
||||
@@ -2249,10 +2361,12 @@ pub async fn fpkg_build(
|
||||
compression: Option<String>,
|
||||
firmware: Option<String>,
|
||||
language: Option<String>,
|
||||
// Read everything back to verify; absent means the fast, sampled check.
|
||||
full_verify: Option<bool>,
|
||||
) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/fpkg/build");
|
||||
post_json(
|
||||
post_json_long(
|
||||
&url,
|
||||
&serde_json::json!({
|
||||
"source": source,
|
||||
@@ -2262,6 +2376,7 @@ pub async fn fpkg_build(
|
||||
"compression": compression,
|
||||
"firmware": firmware,
|
||||
"language": language,
|
||||
"full_verify": full_verify.unwrap_or(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -2294,7 +2409,7 @@ pub async fn fpkg_estimate(
|
||||
) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/fpkg/estimate");
|
||||
post_json(
|
||||
post_json_long(
|
||||
&url,
|
||||
&serde_json::json!({ "source": source, "output_dir": output_dir }),
|
||||
)
|
||||
@@ -2345,6 +2460,7 @@ pub async fn ffpfsc_compress(
|
||||
source: String,
|
||||
output_dir: Option<String>,
|
||||
level: Option<u32>,
|
||||
full_verify: Option<bool>,
|
||||
) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/ffpfsc/compress");
|
||||
@@ -2354,6 +2470,7 @@ pub async fn ffpfsc_compress(
|
||||
"source": source,
|
||||
"output_dir": output_dir,
|
||||
"level": level,
|
||||
"full_verify": full_verify.unwrap_or(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -2368,6 +2485,9 @@ pub async fn exfat_build(
|
||||
compress: Option<bool>,
|
||||
// AMPR LZ4 asset packs, passed through as the engine takes them ({level, profile_toml}).
|
||||
ampr_lz4: Option<JsonValue>,
|
||||
// The read-back check's chunk in KiB; the engine's default when absent.
|
||||
verify_chunk_kib: Option<u32>,
|
||||
full_verify: Option<bool>,
|
||||
) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/exfat/build");
|
||||
@@ -2379,6 +2499,30 @@ pub async fn exfat_build(
|
||||
"format": format,
|
||||
"compress": compress.unwrap_or(false),
|
||||
"ampr_lz4": ampr_lz4,
|
||||
"verify_chunk_kib": verify_chunk_kib,
|
||||
"full_verify": full_verify.unwrap_or(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Unpack a game image or a debug-key package into a plain folder; runs as an engine job.
|
||||
#[tauri::command]
|
||||
pub async fn folder_build(
|
||||
source: String,
|
||||
output_dir: Option<String>,
|
||||
name: Option<String>,
|
||||
full_verify: Option<bool>,
|
||||
) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/folder/build");
|
||||
post_json(
|
||||
&url,
|
||||
&serde_json::json!({
|
||||
"source": source,
|
||||
"output_dir": output_dir,
|
||||
"name": name,
|
||||
"full_verify": full_verify.unwrap_or(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -144,7 +144,9 @@ pub struct SaveArchiveZipReq {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_archive_zip(req: SaveArchiveZipReq) -> Result<(), String> {
|
||||
/// Returns the path the zip was written to: the picked one on desktop, the Downloads file an
|
||||
/// Android `content://` (or empty) destination was redirected to.
|
||||
pub async fn save_archive_zip(req: SaveArchiveZipReq) -> Result<String, String> {
|
||||
// Android save dialogs return content:// URIs std::fs can't create; redirect
|
||||
// those to a real path under Downloads (desktop paths pass through), using
|
||||
// the renderer's filename so distinct backups stay distinct.
|
||||
@@ -168,7 +170,7 @@ pub async fn save_archive_zip(req: SaveArchiveZipReq) -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
return Err(format!("zip: {e:#}"));
|
||||
}
|
||||
Ok(())
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
fn zip_folder(src_dir: &Path, inner_root: &str, dest_zip: &Path) -> Result<()> {
|
||||
|
||||
@@ -58,9 +58,13 @@ pub async fn screenshot_convert(
|
||||
src_path: String,
|
||||
dst_path: String,
|
||||
delete_source: bool,
|
||||
// `sdr` (default: tone-mapped 8-bit sRGB), `hdr` (16-bit PQ BT.2020 with a cICP chunk) or
|
||||
// `hdr_plain` (the same without the chunk, for viewers that choke on it; #448).
|
||||
mode: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let mode = mode.unwrap_or_else(|| "sdr".to_string());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
convert_in_child(&src_path, &dst_path)?;
|
||||
convert_in_child(&src_path, &dst_path, &mode)?;
|
||||
// Only remove the original after the PNG is safely written, and
|
||||
// never if it somehow resolves to the same path as the output.
|
||||
if delete_source && src_path != dst_path {
|
||||
@@ -89,12 +93,13 @@ const CHILD_ARG: &str = "--ps5upload-jxr-to-png";
|
||||
target_os = "ios",
|
||||
all(target_os = "windows", target_arch = "aarch64")
|
||||
)))]
|
||||
fn convert_in_child(src_path: &str, dst_path: &str) -> Result<(), String> {
|
||||
fn convert_in_child(src_path: &str, dst_path: &str, mode: &str) -> Result<(), String> {
|
||||
let exe = std::env::current_exe().map_err(|e| format!("locate the app: {e}"))?;
|
||||
let out = std::process::Command::new(exe)
|
||||
.arg(CHILD_ARG)
|
||||
.arg(src_path)
|
||||
.arg(dst_path)
|
||||
.arg(mode)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| format!("start the decoder: {e}"))?;
|
||||
@@ -123,12 +128,13 @@ fn convert_in_child(src_path: &str, dst_path: &str) -> Result<(), String> {
|
||||
)))]
|
||||
pub fn child_mode() -> bool {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.get(1).map(String::as_str) != Some(CHILD_ARG) || args.len() != 4 {
|
||||
if args.get(1).map(String::as_str) != Some(CHILD_ARG) || !(4..=5).contains(&args.len()) {
|
||||
return false;
|
||||
}
|
||||
let mode = desktop::Mode::parse(args.get(4).map(String::as_str));
|
||||
let result = std::fs::read(&args[2])
|
||||
.map_err(|e| format!("read {}: {e}", args[2]))
|
||||
.and_then(|bytes| desktop::convert_jxr_to_png(&bytes))
|
||||
.and_then(|bytes| desktop::convert_jxr_to_png(&bytes, mode))
|
||||
.and_then(|png| {
|
||||
std::fs::write(&args[3], png).map_err(|e| format!("write {}: {e}", args[3]))
|
||||
});
|
||||
@@ -165,6 +171,7 @@ pub async fn screenshot_convert(
|
||||
_src_path: String,
|
||||
_dst_path: String,
|
||||
_delete_source: bool,
|
||||
_mode: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
Err("Screenshot conversion is available in the desktop app only.".into())
|
||||
}
|
||||
@@ -179,8 +186,30 @@ mod desktop {
|
||||
use jpegxr::{ImageDecode, PixelFormat, PixelInfo};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Decode a JPEG XR byte buffer and return PNG-encoded bytes (RGB8).
|
||||
pub fn convert_jxr_to_png(jxr: &[u8]) -> Result<Vec<u8>, String> {
|
||||
/// What the PNG carries.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Mode {
|
||||
/// Tone-mapped to SDR, 8-bit sRGB: opens everywhere.
|
||||
Sdr,
|
||||
/// 16-bit BT.2020 PQ, as captured, with a cICP chunk (primaries 9, transfer 16, matrix
|
||||
/// 0, full range) so HDR-aware viewers and browsers show it as HDR (#448).
|
||||
Hdr { cicp: bool },
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn parse(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("hdr") => Mode::Hdr { cicp: true },
|
||||
Some("hdr_plain") => Mode::Hdr { cicp: false },
|
||||
_ => Mode::Sdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a JPEG XR byte buffer and return PNG-encoded bytes: RGB8 tone-mapped for
|
||||
/// [`Mode::Sdr`]; 16-bit PQ BT.2020 for [`Mode::Hdr`] when the capture is HDR (an SDR
|
||||
/// capture has nothing more to give and comes out as 8-bit sRGB either way).
|
||||
pub fn convert_jxr_to_png(jxr: &[u8], mode: Mode) -> Result<Vec<u8>, String> {
|
||||
// Validate the JPEG XR file signature before handing the buffer to
|
||||
// the C codec (jxrlib). A truncated or corrupted .jxr — common for
|
||||
// PS5 thumbnail variants that are still being written, or a
|
||||
@@ -205,6 +234,14 @@ mod desktop {
|
||||
&jxr[..4]
|
||||
));
|
||||
}
|
||||
if let Mode::Hdr { cicp } = mode {
|
||||
if let Some((width, height, rgb16)) = decode_to_pq16(jxr)? {
|
||||
// The capture's own ICC profile (Sony's "ScreenCapturePQ"): viewers that do
|
||||
// not read cICP, and HDR-capable browsers, show the image as HDR through it.
|
||||
let icc = jxr_icc_profile(jxr);
|
||||
return encode_png16(width, height, &rgb16, cicp, icc.as_deref());
|
||||
}
|
||||
}
|
||||
let (width, height, rgb8) = decode_to_rgb8(jxr)?;
|
||||
let mut out = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(Cursor::new(&mut out))
|
||||
@@ -213,6 +250,184 @@ mod desktop {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The ICC profile embedded in a JPEG XR container, if any. The container is TIFF-like:
|
||||
/// `II`/`MM`, 0xBC, version, then an IFD whose entries are (tag u16, type u16, count u32,
|
||||
/// value-or-offset u32); the profile is TIFF's InterColorProfile tag 0x8773 (JXRMeta.h's
|
||||
/// WMP_tagIccProfile), type UNDEFINED, its bytes at the offset.
|
||||
pub fn jxr_icc_profile(jxr: &[u8]) -> Option<Vec<u8>> {
|
||||
if jxr.len() < 8 || jxr[2] != 0xBC {
|
||||
return None;
|
||||
}
|
||||
let le = match &jxr[0..2] {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16_at = |at: usize| -> Option<u16> {
|
||||
let b = jxr.get(at..at + 2)?;
|
||||
Some(if le {
|
||||
u16::from_le_bytes([b[0], b[1]])
|
||||
} else {
|
||||
u16::from_be_bytes([b[0], b[1]])
|
||||
})
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<u32> {
|
||||
let b = jxr.get(at..at + 4)?;
|
||||
Some(if le {
|
||||
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
|
||||
} else {
|
||||
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
|
||||
})
|
||||
};
|
||||
let ifd = u32_at(4)? as usize;
|
||||
let entries = u16_at(ifd)? as usize;
|
||||
for i in 0..entries.min(4096) {
|
||||
let e = ifd + 2 + i * 12;
|
||||
if u16_at(e)? != 0x8773 {
|
||||
continue;
|
||||
}
|
||||
let count = u32_at(e + 4)? as usize;
|
||||
let at = u32_at(e + 8)? as usize;
|
||||
// Four bytes or fewer live in the entry itself; a profile never does.
|
||||
if count <= 4 || count > 16 << 20 {
|
||||
return None;
|
||||
}
|
||||
return jxr.get(at..at.checked_add(count)?).map(<[u8]>::to_vec);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A 16-bit RGB PNG (big-endian samples, as PNG stores them). Right after IHDR: the
|
||||
/// capture's ICC profile as iCCP when it has one, and a cICP chunk when asked (BT.2020
|
||||
/// primaries 9, PQ transfer 16, RGB 0, full range 1).
|
||||
fn encode_png16(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgb16: &[u16],
|
||||
cicp: bool,
|
||||
icc: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::with_capacity(rgb16.len() * 2 / 2);
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, width, height);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.set_depth(png::BitDepth::Sixteen);
|
||||
let mut w = enc.write_header().map_err(|e| format!("png header: {e}"))?;
|
||||
if let Some(profile) = icc {
|
||||
w.write_chunk(png::chunk::ChunkType(*b"iCCP"), &iccp_chunk(profile))
|
||||
.map_err(|e| format!("png iCCP: {e}"))?;
|
||||
}
|
||||
if cicp {
|
||||
w.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1])
|
||||
.map_err(|e| format!("png cICP: {e}"))?;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(rgb16.len() * 2);
|
||||
for v in rgb16 {
|
||||
bytes.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
w.write_image_data(&bytes)
|
||||
.map_err(|e| format!("png data: {e}"))?;
|
||||
w.finish().map_err(|e| format!("png finish: {e}"))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An iCCP chunk body: profile name, NUL, compression method 0, zlib-compressed profile.
|
||||
fn iccp_chunk(profile: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let mut body = b"ScreenCapturePQ\0\0".to_vec();
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let _ = z.write_all(profile);
|
||||
body.extend(z.finish().unwrap_or_default());
|
||||
body
|
||||
}
|
||||
|
||||
/// The capture as 16-bit BT.2020 PQ codes, or `None` for an SDR capture. A PS5 capture
|
||||
/// is PQ already (10 bits in a 16-bit word on a 4K capture, scaled to the full 16 bits);
|
||||
/// an scRGB float capture is converted to the same.
|
||||
fn decode_to_pq16(jxr: &[u8]) -> Result<Option<(u32, u32, Vec<u16>)>, String> {
|
||||
let (w, h, raw, bpp, kind) = decode_raw(jxr)?;
|
||||
let npix = w * h;
|
||||
let mut out: Vec<u16> = Vec::with_capacity(npix * 3);
|
||||
for p in 0..npix {
|
||||
let off = p * (bpp / 8);
|
||||
let px = &raw[off..off + bpp / 8];
|
||||
let (r, g, b) = match kind {
|
||||
Kind::Sdr8 { .. } => return Ok(None),
|
||||
Kind::PqInt { bits, channels } => read_int_rgb(px, bits, channels),
|
||||
Kind::ScRgbFloat { half, channels } => {
|
||||
// Linear Rec.709 with 1.0 = SDR white (100 nits) → linear Rec.2020 as a
|
||||
// share of 10000 nits → PQ code.
|
||||
let lin = read_float_rgb(px, half, channels);
|
||||
let c = rec709_to_rec2020([lin[0].max(0.0), lin[1].max(0.0), lin[2].max(0.0)]);
|
||||
(
|
||||
pq_oetf(c[0] * 100.0 / 10000.0),
|
||||
pq_oetf(c[1] * 100.0 / 10000.0),
|
||||
pq_oetf(c[2] * 100.0 / 10000.0),
|
||||
)
|
||||
}
|
||||
};
|
||||
out.push(to_u16(r));
|
||||
out.push(to_u16(g));
|
||||
out.push(to_u16(b));
|
||||
}
|
||||
Ok(Some((w as u32, h as u32, out)))
|
||||
}
|
||||
|
||||
fn to_u16(v: f32) -> u16 {
|
||||
(v.clamp(0.0, 1.0) * 65535.0).round() as u16
|
||||
}
|
||||
|
||||
/// Inverse of [`pq_eotf`]: a share of 10000 nits → PQ code in [0,1] (SMPTE ST 2084).
|
||||
fn pq_oetf(l: f32) -> f32 {
|
||||
const M1: f32 = 0.159_301_76;
|
||||
const M2: f32 = 78.84375;
|
||||
const C1: f32 = 0.8359375;
|
||||
const C2: f32 = 18.851_562;
|
||||
const C3: f32 = 18.6875;
|
||||
let lm = l.clamp(0.0, 1.0).powf(M1);
|
||||
((C1 + C2 * lm) / (1.0 + C3 * lm)).powf(M2)
|
||||
}
|
||||
|
||||
/// Linear Rec.709 → linear Rec.2020 (the inverse of [`rec2020_to_rec709`]).
|
||||
fn rec709_to_rec2020(c: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
0.6274 * c[0] + 0.3293 * c[1] + 0.0433 * c[2],
|
||||
0.0691 * c[0] + 0.9195 * c[1] + 0.0114 * c[2],
|
||||
0.0164 * c[0] + 0.0880 * c[1] + 0.8956 * c[2],
|
||||
]
|
||||
}
|
||||
|
||||
/// The decoded samples with their layout: shared by the SDR and HDR paths.
|
||||
fn decode_raw(jxr: &[u8]) -> Result<(usize, usize, Vec<u8>, usize, Kind), String> {
|
||||
let mut dec = ImageDecode::with_reader(Cursor::new(jxr))
|
||||
.map_err(|e| format!("jxr open (not a JPEG XR file?): {e:?}"))?;
|
||||
let (w, h) = dec.get_size().map_err(|e| format!("jxr size: {e:?}"))?;
|
||||
if w <= 0 || h <= 0 {
|
||||
return Err(format!("jxr bad size {w}x{h}"));
|
||||
}
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let format = dec
|
||||
.get_pixel_format()
|
||||
.map_err(|e| format!("jxr format: {e:?}"))?;
|
||||
let info = PixelInfo::from_format(format);
|
||||
let bpp = info.bits_per_pixel();
|
||||
if bpp == 0 || bpp % 8 != 0 {
|
||||
return Err(format!("unsupported jxr packing: {format:?} ({bpp} bpp)"));
|
||||
}
|
||||
let stride = w * (bpp / 8);
|
||||
let mut raw = vec![0u8; stride * h];
|
||||
dec.copy_all(&mut raw, stride)
|
||||
.map_err(|e| format!("jxr decode: {e:?}"))?;
|
||||
let kind = classify(format).ok_or_else(|| {
|
||||
format!(
|
||||
"unsupported jxr pixel format: {format:?}. \
|
||||
Please report this so the converter can handle it."
|
||||
)
|
||||
})?;
|
||||
Ok((w, h, raw, bpp, kind))
|
||||
}
|
||||
|
||||
/// Decode + colour-pipeline a JPEG XR into a packed RGB8 buffer.
|
||||
fn decode_to_rgb8(jxr: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
|
||||
let mut dec = ImageDecode::with_reader(Cursor::new(jxr))
|
||||
|
||||
@@ -289,9 +289,28 @@ fn is_newer(current: &str, latest: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod is_newer_tests {
|
||||
use super::{
|
||||
is_newer, is_safe_update_url, UpdateChannel, DEFAULT_MANIFEST_URL, PRERELEASE_LIST_URL,
|
||||
install_command_for, is_newer, is_safe_update_url, UpdateChannel, DEFAULT_MANIFEST_URL,
|
||||
PRERELEASE_LIST_URL,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn a_packaged_linux_install_gets_its_own_command() {
|
||||
let p = "/home/me/Downloads/PS5Upload-7.0.23-linux-x64.rpm";
|
||||
assert_eq!(
|
||||
install_command_for(p, Some("rpm"), true, Some("ps5-upload")).as_deref(),
|
||||
Some("rpm-ostree uninstall ps5-upload --install '/home/me/Downloads/PS5Upload-7.0.23-linux-x64.rpm' && systemctl reboot")
|
||||
);
|
||||
assert_eq!(
|
||||
install_command_for(p, Some("rpm"), false, None).as_deref(),
|
||||
Some("sudo dnf install '/home/me/Downloads/PS5Upload-7.0.23-linux-x64.rpm'")
|
||||
);
|
||||
assert_eq!(
|
||||
install_command_for("/tmp/it's.deb", Some("deb"), false, None).as_deref(),
|
||||
Some("sudo apt install '/tmp/it'\\''s.deb'")
|
||||
);
|
||||
assert_eq!(install_command_for(p, None, true, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_numeric_is_newer() {
|
||||
assert!(is_newer("2.2.0", "2.2.1"));
|
||||
@@ -537,6 +556,65 @@ pub struct UpdateDownload {
|
||||
pub path: String,
|
||||
/// Total bytes written to disk.
|
||||
pub bytes: u64,
|
||||
/// For a packaged Linux install: the one command that installs the download, to copy into
|
||||
/// a terminal (#456: on Bazzite, `rpm-ostree uninstall ps5-upload --install <rpm>`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub install_command: Option<String>,
|
||||
}
|
||||
|
||||
/// `path` quoted for a POSIX shell.
|
||||
fn shell_quote(path: &str) -> String {
|
||||
format!("'{}'", path.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// The command that installs `path` over this packaged Linux copy, or None (portable copy,
|
||||
/// another OS). An rpm-ostree system (Bazzite, Silverblue, Kinoite) cannot `dnf install`; its
|
||||
/// layered package is swapped in one transaction that applies on the next boot.
|
||||
fn install_command_for(
|
||||
path: &str,
|
||||
kind: Option<&str>,
|
||||
ostree: bool,
|
||||
package: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let q = shell_quote(path);
|
||||
match kind? {
|
||||
"rpm" if ostree => Some(format!(
|
||||
"rpm-ostree uninstall {} --install {q} && systemctl reboot",
|
||||
package.unwrap_or("ps5-upload")
|
||||
)),
|
||||
"rpm" => Some(format!("sudo dnf install {q}")),
|
||||
"deb" => Some(format!("sudo apt install {q}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn install_command(path: &str) -> Option<String> {
|
||||
let kind = linux_package_kind();
|
||||
let ostree = std::path::Path::new("/run/ostree-booted").exists();
|
||||
let package = (kind == Some("rpm"))
|
||||
.then(|| {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
|
||||
let out = std::process::Command::new("rpm")
|
||||
.args(["-qf", "--qf", "%{NAME}"])
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.ok()?;
|
||||
let name = String::from_utf8(out.stdout).ok()?;
|
||||
let name = name.trim();
|
||||
(out.status.success() && !name.is_empty() && !name.contains(char::is_whitespace))
|
||||
.then(|| name.to_string())
|
||||
})
|
||||
.flatten();
|
||||
install_command_for(path, kind, ostree, package.as_deref())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn install_command(path: &str) -> Option<String> {
|
||||
let _ = install_command_for;
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
|
||||
/// Download the platform-specific asset from the URL returned by
|
||||
@@ -649,8 +727,10 @@ pub async fn update_download(
|
||||
if let Err(e) = reveal(&app, &target).await {
|
||||
eprintln!("[updates] cached bundle at {target:?} but reveal failed: {e}");
|
||||
}
|
||||
let path = target.to_string_lossy().into_owned();
|
||||
return Ok(UpdateDownload {
|
||||
path: target.to_string_lossy().into_owned(),
|
||||
install_command: install_command(&path),
|
||||
path,
|
||||
bytes: meta.len(),
|
||||
});
|
||||
}
|
||||
@@ -712,8 +792,10 @@ pub async fn update_download(
|
||||
eprintln!("[updates] download saved to {target:?} but reveal failed: {e}");
|
||||
}
|
||||
|
||||
let path = target.to_string_lossy().into_owned();
|
||||
Ok(UpdateDownload {
|
||||
path: target.to_string_lossy().into_owned(),
|
||||
install_command: install_command(&path),
|
||||
path,
|
||||
bytes: total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ const BIND: &str = "0.0.0.0:19113";
|
||||
/// The renderer's engine-status tick tolerates the brief startup race
|
||||
/// (it retries), matching how the desktop readiness probe is advisory.
|
||||
pub async fn start(app: &AppHandle) -> Result<&'static str> {
|
||||
// Android can run setup again when its Activity is recreated while the process lives on;
|
||||
// the engine from the first run still holds the port, and a second bind would only fail
|
||||
// and tell the renderer the engine did not start.
|
||||
static STARTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
if STARTED.set(()).is_err() {
|
||||
return Ok(DEFAULT_ENGINE_URL);
|
||||
}
|
||||
point_engine_at_app_data(app);
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -43,6 +43,13 @@ mod mgmt_forward;
|
||||
// One-time 6.0 upgrade clean-up of the app's data folder (see the module doc).
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod migrate_6;
|
||||
// Background watcher: job notifications and, on Android, the transfer service (#457).
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_bg;
|
||||
mod bg_activity;
|
||||
// The JavaVM / Application context every Android JNI caller shares.
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_jni;
|
||||
|
||||
/// Build and run the Tauri application. The desktop `main.rs` calls this
|
||||
/// directly; on mobile the `tauri::mobile_entry_point` macro generates
|
||||
@@ -143,6 +150,10 @@ pub fn run() {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}));
|
||||
// Drag a file from Files onto the desktop (desktop only; the renderer
|
||||
// stages the download first, see commands/dragout.rs).
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
let builder = builder.plugin(tauri_plugin_drag::init());
|
||||
let app = builder
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// Opener: the cross-platform way to open external URLs / reveal files.
|
||||
@@ -192,6 +203,9 @@ pub fn run() {
|
||||
// something was squatting it.
|
||||
use tauri::Emitter;
|
||||
let _ = handle.emit("ps5upload-engine-ready", &url);
|
||||
// Watches the engine for running and finished jobs from here on,
|
||||
// whatever the WebView is doing.
|
||||
bg_activity::spawn(&handle);
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("engine failed to start: {e:#}");
|
||||
@@ -215,6 +229,7 @@ pub fn run() {
|
||||
commands::engine_diagnose,
|
||||
commands::engine_restart,
|
||||
commands::ps5_volumes,
|
||||
commands::ps5_helper_stop,
|
||||
commands::pkg_scan_external,
|
||||
commands::pkg_metadata_console,
|
||||
commands::ps5_list_dir,
|
||||
@@ -222,6 +237,7 @@ pub fn run() {
|
||||
commands::ps5_readiness,
|
||||
commands::transfer_file,
|
||||
commands::transfer_dir,
|
||||
commands::transfer_file_list,
|
||||
commands::transfer_zip,
|
||||
commands::zip_inspect,
|
||||
commands::zip_inspect_stream,
|
||||
@@ -318,6 +334,7 @@ pub fn run() {
|
||||
commands::ps5_elfldr_ensure,
|
||||
commands::ffpfsc_compress,
|
||||
commands::exfat_build,
|
||||
commands::folder_build,
|
||||
commands::fpkg_extract,
|
||||
commands::fpkg_extract_cleanup,
|
||||
commands::job_status,
|
||||
@@ -441,6 +458,7 @@ pub fn run() {
|
||||
// (dialog paths fall outside the fs scope). See
|
||||
// commands/save_text_file.rs.
|
||||
commands::save_text_file,
|
||||
commands::dragout_stage_dir,
|
||||
commands::read_text_file,
|
||||
// ── Automatic crash/error report collection ─────────────
|
||||
// See commands/crash_reports.rs + src/lib/crashReporter.ts.
|
||||
@@ -549,6 +567,12 @@ pub fn run() {
|
||||
commands::local_storage_roots,
|
||||
commands::storage_access_granted,
|
||||
commands::request_storage_access,
|
||||
// ── Background work (#457) ──────────────────────────────
|
||||
// The renderer's queued-work hold, its visibility / language,
|
||||
// and whether the host posts job notifications itself.
|
||||
bg_activity::bg_work_hold,
|
||||
bg_activity::bg_configure,
|
||||
bg_activity::bg_capabilities,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("tauri runtime failed to build");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "PS5Upload",
|
||||
"version": "7.0.3",
|
||||
"version": "7.1.11",
|
||||
"identifier": "com.phantomptr.ps5upload",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:vite",
|
||||
@@ -23,7 +23,8 @@
|
||||
"fullscreen": false,
|
||||
"center": true,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
"transparent": false,
|
||||
"backgroundThrottling": "disabled"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+14
-4
@@ -6,6 +6,7 @@ import {
|
||||
useConnectionStore,
|
||||
} from "./state/connection";
|
||||
import { Navigate, Route, Routes, useLocation, type Location } from "react-router";
|
||||
import { payloadCardLink } from "./screens/Payloads/payloadTabs";
|
||||
|
||||
import { lazyWithReload } from "./lib/lazyWithReload";
|
||||
import AppShell from "./layout/AppShell";
|
||||
@@ -396,16 +397,25 @@ function AppRoutes({ location }: { location: Location }) {
|
||||
<Route
|
||||
path="/payloads"
|
||||
element={
|
||||
// The browser build gets the ShadowMount+ and nanoDNS tabs only
|
||||
// The browser build gets the "On your PS5" tab only
|
||||
// (screens/Payloads/payloadTabs.ts).
|
||||
<Suspense fallback={<ScreenLoader />}>
|
||||
<PayloadsScreen />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/nanodns" element={<Navigate to="/payloads?tab=nanodns" replace />} />
|
||||
<Route path="/nano-dns" element={<Navigate to="/payloads?tab=nanodns" replace />} />
|
||||
<Route path="/shadowmount" element={<Navigate to="/payloads?tab=shadowmount" replace />} />
|
||||
{/* Integrations were tabs (and before that screens); they are cards on "On your
|
||||
PS5" now, and Ports sits with Processes. Old links open the card. */}
|
||||
<Route path="/nanodns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
|
||||
<Route path="/nano-dns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
|
||||
<Route path="/shadowmount" element={<Navigate to={payloadCardLink("shadowmountplus")} replace />} />
|
||||
<Route path="/hearbridge" element={<Navigate to={payloadCardLink("hearbridge")} replace />} />
|
||||
<Route path="/payloads/manager" element={<Navigate to={payloadCardLink("payload-manager")} replace />} />
|
||||
<Route path="/payloads/shadowmount" element={<Navigate to={payloadCardLink("shadowmountplus")} replace />} />
|
||||
<Route path="/payloads/nanodns" element={<Navigate to={payloadCardLink("nanodns")} replace />} />
|
||||
<Route path="/payloads/hearbridge" element={<Navigate to={payloadCardLink("hearbridge")} replace />} />
|
||||
<Route path="/payloads/ports" element={<Navigate to="/processes?tab=ports" replace />} />
|
||||
<Route path="/ports" element={<Navigate to="/processes?tab=ports" replace />} />
|
||||
{/* The wizard's whole point is step 2: download the payload ELFs to
|
||||
this machine and send them to the console over a raw socket.
|
||||
Neither is possible from a browser (the Payloads screen there has
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Measuring how a game reads its files, for the LZ4 asset packs' profile (engine
|
||||
// `ampr_trace`): the debug ampr_emu goes into the game's fakelib2, sessions are collected
|
||||
// after play, and the profile Convert uses is generated from them.
|
||||
|
||||
import { consoleAddr } from "../lib/addr";
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
|
||||
export interface TraceState {
|
||||
tracing: boolean;
|
||||
host: string | null;
|
||||
game_path: string | null;
|
||||
started_at: number | null;
|
||||
}
|
||||
|
||||
export interface TraceSession {
|
||||
id: string;
|
||||
collected_at: number;
|
||||
journal_bytes: number;
|
||||
}
|
||||
|
||||
export interface TraceProfile {
|
||||
generated_at: number;
|
||||
sessions: number;
|
||||
summary: {
|
||||
traces?: number;
|
||||
reads?: number;
|
||||
files_observed?: number;
|
||||
files_packed?: number;
|
||||
decoded_cache_bytes?: number;
|
||||
physical_cache_bytes?: number;
|
||||
runtime_workers?: number;
|
||||
warnings?: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TraceStatus {
|
||||
title_id: string;
|
||||
state: TraceState;
|
||||
sessions: TraceSession[];
|
||||
profile: TraceProfile | null;
|
||||
/** The debug emulator build the engine places. */
|
||||
runtime: string;
|
||||
/** Set by collect. */
|
||||
collected?: { session: string; journal_bytes: number };
|
||||
/** Set by stop: whether a fakelib2 of ours was removed. */
|
||||
removed?: boolean;
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${getEngineUrl()}${path}`, {
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok) throw new Error(body.error || `${path} failed (${res.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
call<TraceStatus>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
export const amprTrace = {
|
||||
status: (titleId: string) =>
|
||||
call<TraceStatus>(`/api/ampr/trace?title_id=${encodeURIComponent(titleId)}`),
|
||||
start: (host: string, gamePath: string, titleId: string) =>
|
||||
post("/api/ampr/trace/start", { addr: consoleAddr(host), game_path: gamePath, title_id: titleId }),
|
||||
stop: (host: string, gamePath: string, titleId: string) =>
|
||||
post("/api/ampr/trace/stop", { addr: consoleAddr(host), game_path: gamePath, title_id: titleId }),
|
||||
collect: (host: string, gamePath: string, titleId: string) =>
|
||||
post("/api/ampr/trace/collect", { addr: consoleAddr(host), game_path: gamePath, title_id: titleId }),
|
||||
generate: (titleId: string) => post("/api/ampr/trace/generate", { title_id: titleId }),
|
||||
/** The measured profile's text, or null when there is none. */
|
||||
profile: async (titleId: string): Promise<{ toml: string; profile: TraceProfile } | null> => {
|
||||
const r = await call<{ exists: boolean; toml: string | null; profile: TraceProfile | null }>(
|
||||
`/api/ampr/profile?title_id=${encodeURIComponent(titleId)}`,
|
||||
);
|
||||
return r.exists && r.toml && r.profile ? { toml: r.toml, profile: r.profile } : null;
|
||||
},
|
||||
forget: (titleId: string) =>
|
||||
call<TraceStatus>(`/api/ampr/profile?title_id=${encodeURIComponent(titleId)}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
/** A PS5/PS4 title id out of a content id (`UP9000-PPSA03671_00-X` → `PPSA03671`). */
|
||||
export function titleIdOf(contentId: string | null | undefined): string | null {
|
||||
const m = /([A-Z]{4}\d{5})/.exec(contentId ?? "");
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
pairingConfirm,
|
||||
pairingForget,
|
||||
pairingStatus,
|
||||
sameConsole,
|
||||
startPs5ToPs5,
|
||||
} from "./ava1";
|
||||
|
||||
@@ -93,6 +94,8 @@ describe("PS5 to PS5", () => {
|
||||
to: "10.0.0.3",
|
||||
dest: "/data/b",
|
||||
tx_id: null,
|
||||
route: "auto",
|
||||
direct_addr: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,3 +109,25 @@ describe("PS5 to PS5", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameConsole (a console whose address changed)", () => {
|
||||
it("asks the engine with both bare hosts and reads its verdict", async () => {
|
||||
const f = vi.fn().mockResolvedValue(reply(200, { ok: true, result: "same" }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
expect(await sameConsole("192.168.137.2:9113", "192.168.2.36")).toBe("same");
|
||||
expect(f.mock.calls[0][0]).toBe("http://engine.test:19113/api/ava1/same-console");
|
||||
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({
|
||||
old: "192.168.137.2",
|
||||
new: "192.168.2.36",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads anything it cannot use as unknown, never as same", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(reply(200, { result: "maybe" })));
|
||||
expect(await sameConsole("10.0.0.2", "10.0.0.3")).toBe("unknown");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(reply(404, {})));
|
||||
expect(await sameConsole("10.0.0.2", "10.0.0.3")).toBe("unknown");
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
expect(await sameConsole("10.0.0.2", "10.0.0.3")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,11 @@ export async function startPs5ToPs5(
|
||||
to: string,
|
||||
dest: string,
|
||||
txId?: string | null,
|
||||
/** #444: `auto` (absent), `direct` or `relay`, and what the sending console dials. */
|
||||
route?: { mode?: "auto" | "direct" | "relay"; directAddr?: string | null },
|
||||
/** #444, a send another device started (`txId` is its id): `take` ends it there and sends
|
||||
* again from here, resuming; `watch` follows a direct send still running. */
|
||||
adopt?: "take" | "watch" | null,
|
||||
): Promise<string> {
|
||||
const res = await fetchStart("the PS5 to PS5 copy", `${getEngineUrl()}/api/transfer/ps5-to-ps5`, {
|
||||
method: "POST",
|
||||
@@ -130,6 +135,9 @@ export async function startPs5ToPs5(
|
||||
to: consoleAddr(to),
|
||||
dest,
|
||||
tx_id: txId ?? null,
|
||||
route: route?.mode ?? "auto",
|
||||
direct_addr: route?.directAddr?.trim() || null,
|
||||
...(adopt ? { adopt } : {}),
|
||||
}),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
@@ -141,3 +149,178 @@ export async function startPs5ToPs5(
|
||||
}
|
||||
return body.job_id;
|
||||
}
|
||||
|
||||
/** How a console-to-console send reads to this app (engine `GET .../ps5-to-ps5/remote`). */
|
||||
export type RemoteSendClass = "mine" | "running_elsewhere" | "paused" | "interrupted";
|
||||
|
||||
/** One send into a console, from the shared record the driving device keeps there (#444). */
|
||||
export interface RemoteSend {
|
||||
txId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
src: string;
|
||||
dest: string;
|
||||
route: "auto" | "direct" | "relay";
|
||||
directAddr: string | null;
|
||||
ownerName: string;
|
||||
/** Started from this app's engine (another run of it, or before a restart). */
|
||||
mine: boolean;
|
||||
cls: RemoteSendClass;
|
||||
/** False when an older helper could not say whether it still runs. */
|
||||
confirmed: boolean;
|
||||
liveRoute: "direct" | "relay" | null;
|
||||
bytesDone: number;
|
||||
bytesTotal: number;
|
||||
updatedMs: number;
|
||||
error: string | null;
|
||||
canWatch: boolean;
|
||||
canTakeOver: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteSends {
|
||||
sends: RemoteSend[];
|
||||
/** The receiving console's helper shares transfers with every paired device; null = unknown. */
|
||||
sharedJobs: boolean | null;
|
||||
}
|
||||
|
||||
const ROUTES = ["auto", "direct", "relay"] as const;
|
||||
const CLASSES: readonly RemoteSendClass[] = ["mine", "running_elsewhere", "paused", "interrupted"];
|
||||
|
||||
/** The engine's answer, read defensively (an older engine has no such route). */
|
||||
export function parseRemoteSends(raw: unknown): RemoteSends {
|
||||
const o = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const list = Array.isArray(o.sends) ? o.sends : [];
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
||||
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
const sends: RemoteSend[] = [];
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const s = item as Record<string, unknown>;
|
||||
const cls = CLASSES.find((c) => c === s.class);
|
||||
if (!cls || !/^[0-9a-f]{32}$/.test(str(s.tx_id))) continue;
|
||||
const live = s.live_route === "direct" || s.live_route === "relay" ? s.live_route : null;
|
||||
sends.push({
|
||||
txId: str(s.tx_id),
|
||||
from: str(s.from),
|
||||
to: str(s.to),
|
||||
src: str(s.src),
|
||||
dest: str(s.dest),
|
||||
route: ROUTES.find((r) => r === s.route) ?? "auto",
|
||||
directAddr: str(s.direct_addr) || null,
|
||||
ownerName: str(s.owner_name),
|
||||
mine: s.mine === true,
|
||||
cls,
|
||||
confirmed: s.confirmed !== false,
|
||||
liveRoute: live,
|
||||
bytesDone: num(s.bytes_done),
|
||||
bytesTotal: num(s.bytes_total),
|
||||
updatedMs: num(s.updated_ms),
|
||||
error: str(s.error) || null,
|
||||
canWatch: s.can_watch === true,
|
||||
canTakeOver: s.can_take_over === true,
|
||||
});
|
||||
}
|
||||
return { sends, sharedJobs: typeof o.shared_jobs === "boolean" ? o.shared_jobs : null };
|
||||
}
|
||||
|
||||
/** The sends into console `host` that this app could watch, take over or resume. */
|
||||
export async function fetchRemoteSends(host: string): Promise<RemoteSends> {
|
||||
const res = await fetch(
|
||||
`${getEngineUrl()}/api/transfer/ps5-to-ps5/remote?addr=${encodeURIComponent(consoleAddr(host))}`,
|
||||
{ signal: AbortSignal.timeout(20_000) },
|
||||
);
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(body.error || `listing sends failed (${res.status})`);
|
||||
return parseRemoteSends(body);
|
||||
}
|
||||
|
||||
/** One connect from the sending console, or one leg through this device. */
|
||||
export interface ReachResult {
|
||||
/** `reachable` `refused` `timeout` `unreachable` `bad_address` `helper_too_old` `error`, or
|
||||
* for a relay leg `ok` / the failure's reason. */
|
||||
verdict: string;
|
||||
ms: number | null;
|
||||
detail: string | null;
|
||||
target: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectionTest {
|
||||
direct: ReachResult | null;
|
||||
relay: { from: ReachResult; to: ReachResult } | null;
|
||||
deep: { ok: boolean; ms: number | null; detail: string | null } | null;
|
||||
}
|
||||
|
||||
function reach(v: unknown): ReachResult {
|
||||
const o = (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
|
||||
return {
|
||||
verdict: typeof o.verdict === "string" ? o.verdict : "error",
|
||||
ms: typeof o.ms === "number" ? o.ms : null,
|
||||
detail: typeof o.detail === "string" ? o.detail : null,
|
||||
target: typeof o.target === "string" ? o.target : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConnectionTest(raw: unknown): ConnectionTest {
|
||||
const o = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const relay = o.relay && typeof o.relay === "object" ? (o.relay as Record<string, unknown>) : null;
|
||||
const deep = o.deep && typeof o.deep === "object" ? (o.deep as Record<string, unknown>) : null;
|
||||
return {
|
||||
direct: o.direct ? reach(o.direct) : null,
|
||||
relay: relay ? { from: reach(relay.from), to: reach(relay.to) } : null,
|
||||
deep: deep
|
||||
? {
|
||||
ok: deep.ok === true,
|
||||
ms: typeof deep.ms === "number" ? deep.ms : null,
|
||||
detail: typeof deep.detail === "string" ? deep.detail : null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** "Test connection" in Send to another console (#444): can `from` reach `to` the way a send
|
||||
* would dial it, and (Auto, relay) can this device reach both? `deep` also sends 4 KiB. */
|
||||
export async function testPs5Connection(
|
||||
from: string,
|
||||
to: string,
|
||||
route: { mode: "auto" | "direct" | "relay"; directAddr: string },
|
||||
deep = false,
|
||||
): Promise<ConnectionTest> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/transfer/ps5-to-ps5/test`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
from: consoleAddr(from),
|
||||
to: consoleAddr(to),
|
||||
route: route.mode,
|
||||
direct_addr: route.directAddr.trim() || null,
|
||||
deep,
|
||||
}),
|
||||
signal: AbortSignal.timeout(deep ? 60_000 : 30_000),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(body.error || `the connection test failed (${res.status})`);
|
||||
return parseConnectionTest(body);
|
||||
}
|
||||
|
||||
/** Whether the console at `newHost` is the one paired at `oldHost` (engine
|
||||
* `POST /api/ava1/same-console`: it compares the console's key with the one pinned for the old
|
||||
* address). Anything it cannot read, an engine without the route included, is `unknown`: only
|
||||
* `same` lets the app offer the new address. */
|
||||
export async function sameConsole(
|
||||
oldHost: string,
|
||||
newHost: string,
|
||||
): Promise<"same" | "different" | "unknown"> {
|
||||
try {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ava1/same-console`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ old: consoleAddr(oldHost), new: consoleAddr(newHost) }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return "unknown";
|
||||
const body = (await res.json().catch(() => ({}))) as { result?: string };
|
||||
return body.result === "same" || body.result === "different" ? body.result : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export interface CollectionSummary {
|
||||
|
||||
export interface CollectionLibrary {
|
||||
roots?: string[];
|
||||
/** Roots the last scan could not open (deleted, unplugged, server down); skipped. */
|
||||
missing_roots?: string[];
|
||||
generated_at?: string;
|
||||
summary: CollectionSummary;
|
||||
games: Record<string, CollectionGame>;
|
||||
@@ -205,6 +207,8 @@ export interface OrganizeSkipped {
|
||||
export interface OrganizePlan {
|
||||
token: string;
|
||||
containers: string[];
|
||||
/** How to show each container: its path, or "Server: /path" on a saved server. */
|
||||
labels?: Record<string, string>;
|
||||
moves: OrganizeMove[];
|
||||
skipped: OrganizeSkipped[];
|
||||
unsure_count: number;
|
||||
@@ -222,6 +226,8 @@ export interface OrganizeResult {
|
||||
export interface OrganizeRun {
|
||||
id: string;
|
||||
container_root: string;
|
||||
/** The folder as shown: its path, or "Server: /path" on a saved server. */
|
||||
label?: string;
|
||||
created_at: string;
|
||||
moved: number;
|
||||
failed: number;
|
||||
@@ -330,7 +336,7 @@ export const collection = {
|
||||
}),
|
||||
organizeRuns: () => call<OrganizeRun[]>("/organize/runs"),
|
||||
organizeRevert: (id: string) =>
|
||||
call<{ reverted: OrganizeMove[]; problems: unknown[] }>(
|
||||
call<{ reverted: OrganizeMove[]; problems: unknown[]; note?: string }>(
|
||||
"/organize/revert",
|
||||
{ method: "POST", body: JSON.stringify({ id }) },
|
||||
),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchConsoleStat, importLocalCheat, minionTile } from "./consoleFiles";
|
||||
vi.mock("../state/engine", () => ({ getEngineUrl: () => "http://engine" }));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
function reply(body: object, status = 200) {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
describe("paired console controls", () => {
|
||||
it("reads and sets Minion without changing LAN access", async () => {
|
||||
const fetcher = reply({
|
||||
supported: true,
|
||||
tile: true,
|
||||
tile_busy: true,
|
||||
tile_failed: false,
|
||||
});
|
||||
await minionTile("10.0.0.2", true);
|
||||
expect(fetcher.mock.calls[0][0]).toBe("http://engine/api/ps5/minion/tile");
|
||||
expect(JSON.parse(fetcher.mock.calls[0][1].body)).toEqual({
|
||||
addr: "10.0.0.2",
|
||||
tile: true,
|
||||
});
|
||||
await minionTile("10.0.0.2");
|
||||
expect(JSON.parse(fetcher.mock.calls[1][1].body)).not.toHaveProperty(
|
||||
"tile",
|
||||
);
|
||||
});
|
||||
it("feature detects old helpers and rejects malformed settings", async () => {
|
||||
reply({ supported: false });
|
||||
expect(await minionTile("h")).toEqual({ supported: false });
|
||||
reply({ supported: true });
|
||||
await expect(minionTile("h")).rejects.toThrow("Invalid Minion");
|
||||
});
|
||||
it("rejects invalid stats and surfaces helper errors", async () => {
|
||||
reply({ kind: "file", mode: "0644" });
|
||||
await expect(fetchConsoleStat("h", "/data/x")).rejects.toThrow(
|
||||
"Invalid file stat",
|
||||
);
|
||||
reply({ error: "permission denied" }, 502);
|
||||
await expect(fetchConsoleStat("h", "/data/x")).rejects.toThrow(
|
||||
"permission denied",
|
||||
);
|
||||
});
|
||||
it("imports via the engine and requires a confirmed destination", async () => {
|
||||
const fetcher = reply({
|
||||
ok: true,
|
||||
path: "/data/ps5upload/cheats/json/PPSA00001.json",
|
||||
});
|
||||
expect((await importLocalCheat("h", "/tmp/PPSA00001.json")).path).toContain(
|
||||
"PPSA00001.json",
|
||||
);
|
||||
expect(JSON.parse(fetcher.mock.calls[0][1].body).path).toBe(
|
||||
"/tmp/PPSA00001.json",
|
||||
);
|
||||
reply({ ok: false, error: "already exists" });
|
||||
await expect(importLocalCheat("h", "/tmp/PPSA00001.json")).rejects.toThrow(
|
||||
"already exists",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// The console file manager, the PC side (engine /api/ps5/files/*): the folders pinned on the
|
||||
// console's own Files page, and the "Show on console" / "Show on PC" handoffs between the TV
|
||||
// page and this app. An older helper answers {supported:false}: callers then hide the features.
|
||||
// A plain fetch against the engine origin, so the desktop app and the web UI take the same path.
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
import { consoleAddr } from "../lib/addr";
|
||||
|
||||
export interface ConsoleFilesPrefs {
|
||||
favorites: string[];
|
||||
recent: string[];
|
||||
last: string | null;
|
||||
}
|
||||
|
||||
/** null: the helper is older and has no shared prefs. */
|
||||
export type ConsolePrefsAnswer = ConsoleFilesPrefs | null;
|
||||
|
||||
export type PrefsOp = "pin" | "unpin" | "visit";
|
||||
export type HandoffSide = "console" | "pc";
|
||||
|
||||
const paths = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.startsWith("/")) : [];
|
||||
|
||||
async function engineJson(route: string, init?: RequestInit): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(`${getEngineUrl()}${route}`, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) {
|
||||
throw new Error(typeof body.error === "string" && body.error ? body.error : `request failed (${res.status})`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const post = (route: string, body: object) =>
|
||||
engineJson(route, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
function toPrefs(b: Record<string, unknown>): ConsolePrefsAnswer {
|
||||
if (b.supported !== true) return null;
|
||||
return {
|
||||
favorites: paths(b.favorites),
|
||||
recent: paths(b.recent),
|
||||
last: typeof b.last === "string" && b.last.startsWith("/") ? b.last : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchConsolePrefs(host: string): Promise<ConsolePrefsAnswer> {
|
||||
const addr = encodeURIComponent(consoleAddr(host));
|
||||
return toPrefs(await engineJson(`/api/ps5/files/prefs?addr=${addr}`));
|
||||
}
|
||||
|
||||
export async function changeConsolePrefs(host: string, op: PrefsOp, path: string): Promise<ConsolePrefsAnswer> {
|
||||
return toPrefs(await post("/api/ps5/files/prefs/set", { addr: consoleAddr(host), op, path }));
|
||||
}
|
||||
|
||||
/** Leave `path` for the console page to open. False: the helper is older (no handoffs). */
|
||||
export async function handoffToConsole(host: string, path: string): Promise<boolean> {
|
||||
const b = await post("/api/ps5/files/handoff/put", { addr: consoleAddr(host), to: "console", path });
|
||||
return b.supported === true;
|
||||
}
|
||||
|
||||
export type HandoffTake =
|
||||
| { supported: false }
|
||||
| { supported: true; path: string | null; ageMs: number };
|
||||
|
||||
/** Take the handoff the console page left for this app (`path` null: none waiting). */
|
||||
export async function takeHandoffForPc(host: string): Promise<HandoffTake> {
|
||||
const b = await post("/api/ps5/files/handoff/take", { addr: consoleAddr(host), for: "pc" });
|
||||
if (b.supported !== true) return { supported: false };
|
||||
const path = typeof b.path === "string" && b.path.startsWith("/") ? b.path : null;
|
||||
return { supported: true, path, ageMs: typeof b.age_ms === "number" ? b.age_ms : 0 };
|
||||
}
|
||||
|
||||
/** A path as the helper stores it: '//' collapsed, no trailing '/' (except "/"). */
|
||||
export function cleanConsolePath(p: string): string {
|
||||
const c = p.replace(/\/{2,}/g, "/");
|
||||
return c.length > 1 ? c.replace(/\/$/, "") : c;
|
||||
}
|
||||
|
||||
export interface ConsolePathStat {
|
||||
kind: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
mode: number;
|
||||
dev: number;
|
||||
}
|
||||
|
||||
export async function fetchConsoleStat(
|
||||
host: string,
|
||||
path: string,
|
||||
): Promise<ConsolePathStat> {
|
||||
const b = await post("/api/ps5/fs/stat", { addr: consoleAddr(host), path });
|
||||
if (
|
||||
typeof b.kind !== "string" ||
|
||||
![b.size, b.mtime, b.mode, b.dev].every(
|
||||
(v) => typeof v === "number" && Number.isFinite(v),
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid file stat reply");
|
||||
return b as unknown as ConsolePathStat;
|
||||
}
|
||||
|
||||
export type MinionTileState =
|
||||
| { supported: false }
|
||||
| {
|
||||
supported: true;
|
||||
tile: boolean;
|
||||
tile_busy: boolean;
|
||||
tile_failed: boolean;
|
||||
};
|
||||
export async function minionTile(
|
||||
host: string,
|
||||
tile?: boolean,
|
||||
): Promise<MinionTileState> {
|
||||
const b = await post("/api/ps5/minion/tile", {
|
||||
addr: consoleAddr(host),
|
||||
...(tile === undefined ? {} : { tile }),
|
||||
});
|
||||
if (b.supported === false) return { supported: false };
|
||||
if (
|
||||
b.supported !== true ||
|
||||
typeof b.tile !== "boolean" ||
|
||||
typeof b.tile_busy !== "boolean" ||
|
||||
typeof b.tile_failed !== "boolean"
|
||||
)
|
||||
throw new Error("Invalid Minion settings reply");
|
||||
return {
|
||||
supported: true,
|
||||
tile: b.tile,
|
||||
tile_busy: b.tile_busy,
|
||||
tile_failed: b.tile_failed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importLocalCheat(
|
||||
host: string,
|
||||
path: string,
|
||||
): Promise<{ path: string }> {
|
||||
const b = await post("/api/ps5/cheats/import", {
|
||||
addr: consoleAddr(host),
|
||||
path,
|
||||
});
|
||||
if (b.ok !== true || typeof b.path !== "string")
|
||||
throw new Error(
|
||||
typeof b.error === "string" ? b.error : "Cheat import failed",
|
||||
);
|
||||
return { path: b.path };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../state/engine", () => ({ getEngineUrl: () => "http://engine.test:19113" }));
|
||||
|
||||
import { consolePorts, isOutgoing, portLink, type ConsolePort } from "./consolePorts";
|
||||
|
||||
const reply = (status: number, body: unknown) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ports in use on the console", () => {
|
||||
it("asks the engine by bare host and returns the list", async () => {
|
||||
const ports = [{ port: 9021, proto: "tcp", scope: "all", name: "elfldr (payload loader)" }];
|
||||
const f = vi.fn().mockResolvedValue(reply(200, { ports, elapsed_ms: 890 }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
const r = await consolePorts("10.0.0.2:9120");
|
||||
expect(f.mock.calls[0][0]).toBe("http://engine.test:19113/api/ps5/net/ports?addr=10.0.0.2");
|
||||
expect(r.ports).toEqual(ports);
|
||||
expect(r.elapsed_ms).toBe(890);
|
||||
});
|
||||
|
||||
it("says why it failed (an older helper has no net.ports)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(reply(502, { error: "unknown method" })));
|
||||
await expect(consolePorts("10.0.0.2")).rejects.toThrow("unknown method");
|
||||
});
|
||||
|
||||
it("links only TCP ports a PC can reach", () => {
|
||||
const p = (port: number, proto: "tcp" | "udp", scope: ConsolePort["scope"]): ConsolePort => ({
|
||||
port,
|
||||
proto,
|
||||
scope,
|
||||
});
|
||||
expect(portLink("10.0.0.2:9120", p(8084, "tcp", "all"))).toBe("http://10.0.0.2:8084/");
|
||||
expect(portLink("10.0.0.2", p(53, "udp", "all"))).toBeNull();
|
||||
expect(portLink("10.0.0.2", { ...p(9021, "tcp", "all"), web: false })).toBeNull();
|
||||
expect(portLink("10.0.0.2", { ...p(8084, "tcp", "all"), web: true })).toBe("http://10.0.0.2:8084/");
|
||||
expect(portLink("10.0.0.2", p(41800, "tcp", "console"))).toBeNull();
|
||||
expect(isOutgoing(p(49464, "udp", "lan"))).toBe(true);
|
||||
expect(isOutgoing(p(2121, "tcp", "all"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// The ports in use on the console (Discord ask: "see what payloads are using what ports, with
|
||||
// links"): the engine's /api/ps5/net/ports, which asks the helper to bind each port and let go.
|
||||
// A plain fetch against the engine origin, so the desktop app and the web UI take the same path.
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
import { consoleAddr, withPort } from "../lib/addr";
|
||||
|
||||
/** Where a held port can be reached from (see payload/include/net_ports.h). */
|
||||
export type PortScope = "all" | "console" | "lan" | "busy";
|
||||
|
||||
export interface ConsolePort {
|
||||
port: number;
|
||||
proto: "tcp" | "udp";
|
||||
scope: PortScope;
|
||||
/** What usually listens there, guessed from the number. */
|
||||
name?: string | null;
|
||||
/** For a well-known port: whether it serves a web page. Unknown ports leave it out. */
|
||||
web?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ConsolePorts {
|
||||
ports: ConsolePort[];
|
||||
elapsed_ms: number;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export async function consolePorts(host: string): Promise<ConsolePorts> {
|
||||
const addr = encodeURIComponent(consoleAddr(host));
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/net/ports?addr=${addr}`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as Partial<ConsolePorts> & { error?: string };
|
||||
if (!res.ok || !Array.isArray(body.ports)) {
|
||||
throw new Error(body.error || `request failed (${res.status})`);
|
||||
}
|
||||
return { ports: body.ports, elapsed_ms: body.elapsed_ms ?? 0, truncated: body.truncated };
|
||||
}
|
||||
|
||||
/** A port bound only to the LAN address is almost always the console's end of an outgoing
|
||||
* connection, not a service: hidden unless asked for. */
|
||||
export const isOutgoing = (p: ConsolePort): boolean => p.scope === "lan";
|
||||
|
||||
/** The browser link for a port a PC can reach (TCP on every address); null otherwise, and for
|
||||
* a known service with no web page (elfldr, the helper: a stray HTTP request is not what they
|
||||
* expect). An unknown port gets a link: the user is the one who can tell what it is. */
|
||||
export function portLink(host: string, p: ConsolePort): string | null {
|
||||
if (p.proto !== "tcp" || p.scope !== "all" || p.web === false) return null;
|
||||
const hp = withPort(host, p.port);
|
||||
return hp ? `http://${hp}/` : null;
|
||||
}
|
||||
+28
-4
@@ -56,6 +56,9 @@ export interface AmprPacksReadiness {
|
||||
runtime_version?: string | null;
|
||||
/** Why packs cannot be used for this game; null when they can. */
|
||||
refusal?: string | null;
|
||||
/** `ok`; `runtime` (installing ampr_emu 0.4.2.1 into the image makes packs possible);
|
||||
* `no_ampr` (the game does not read through AMPR: .ffpfsc is its compression). */
|
||||
kind?: "ok" | "runtime" | "no_ampr";
|
||||
}
|
||||
|
||||
/** Pack a game image's files into AMPR LZ4 asset packs first (exFAT images only). */
|
||||
@@ -64,6 +67,8 @@ export interface AmprLz4Options {
|
||||
level?: number;
|
||||
/** A profile in drakmor's ampr_pack TOML format, in place of the built-in one. */
|
||||
profileToml?: string;
|
||||
/** Put ampr_emu 0.4.2.1 into the image when the game's own is missing or too old. */
|
||||
installRuntime?: boolean;
|
||||
}
|
||||
|
||||
export interface FpkgBuildRequest {
|
||||
@@ -80,9 +85,12 @@ export interface FpkgBuildRequest {
|
||||
firmware?: string;
|
||||
/** The one PlayGo language the package declares (`de-DE`, …); every language when absent. */
|
||||
language?: string;
|
||||
/** Re-read every byte to check the package; the engine's fast check when false or absent. */
|
||||
fullVerify?: boolean;
|
||||
}
|
||||
|
||||
export type FpkgCompression = "fast" | "balanced" | "smallest";
|
||||
/** `stored` = no compression: the fallback for a console that refuses a compressed package. */
|
||||
export type FpkgCompression = "fast" | "balanced" | "smallest" | "stored";
|
||||
|
||||
export const fpkg = {
|
||||
inspect: (source: string, outputDir?: string) =>
|
||||
@@ -97,6 +105,7 @@ export const fpkg = {
|
||||
compression: req.compression,
|
||||
firmware: req.firmware,
|
||||
language: req.language,
|
||||
fullVerify: req.fullVerify ?? false,
|
||||
}),
|
||||
/** Unpack a .zip / .7z / .rar into an unpack folder in `outputDir`, as a job; the job's
|
||||
* `dest` is the game found inside. A RAR's password goes with it, never stored. */
|
||||
@@ -113,8 +122,8 @@ export const fpkg = {
|
||||
deletePackage: (path: string) => invoke<{ ok: boolean }>("fpkg_delete", { path }),
|
||||
/** Compress an .exfat / .ffpkg game image into a .ffpfsc for ShadowMountPlus.
|
||||
* Starts a job; the output lands next to the source unless outputDir says otherwise. */
|
||||
compress: (source: string, outputDir?: string) =>
|
||||
invoke<{ job_id: string }>("ffpfsc_compress", { source, outputDir }),
|
||||
compress: (source: string, outputDir?: string, fullVerify = false) =>
|
||||
invoke<{ job_id: string }>("ffpfsc_compress", { source, outputDir, fullVerify }),
|
||||
/** Write a game folder as one .exfat image for ShadowMountPlus. Starts a job; the image
|
||||
* lands in the output folder, named after the game folder. */
|
||||
/** One job: the image is planned, written (straight into a .ffpfsc when `compress`), read
|
||||
@@ -126,12 +135,27 @@ export const fpkg = {
|
||||
format: ImageFormat = "exfat",
|
||||
compress = false,
|
||||
lz4?: AmprLz4Options,
|
||||
/** The read-back check's KiB per read (Convert's preference); the engine's default
|
||||
* when absent. */
|
||||
verifyChunkKib?: number,
|
||||
/** Read the whole image back (Convert's preference); the fast sampled check when false. */
|
||||
fullVerify = false,
|
||||
) =>
|
||||
invoke<{ job_id: string }>("exfat_build", {
|
||||
source,
|
||||
outputDir,
|
||||
format,
|
||||
compress,
|
||||
amprLz4: lz4 ? { level: lz4.level, profile_toml: lz4.profileToml } : undefined,
|
||||
amprLz4: lz4
|
||||
? { level: lz4.level, profile_toml: lz4.profileToml, install_runtime: lz4.installRuntime ?? false }
|
||||
: undefined,
|
||||
verifyChunkKib,
|
||||
fullVerify,
|
||||
}),
|
||||
/** Unpack a game image (.exfat, .ffpkg, .ffpfs, .ffpfsc) or a package made with the debug
|
||||
* key into a plain folder in the output folder, named after the source (or `name`). One
|
||||
* job: written, read back (sizes, and SHA-256 of a sample or of every file), then given its
|
||||
* name; the job's `dest` is the folder. */
|
||||
buildFolder: (source: string, outputDir?: string, name?: string, fullVerify = false) =>
|
||||
invoke<{ job_id: string }>("folder_build", { source, outputDir, name, fullVerify }),
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { hbAct, hbOverview, parseHbStatus, parseSaved, parseScanned } from "./hearbridge";
|
||||
|
||||
// A /api/status body as HearBridge's status_json writes it (token already removed by the
|
||||
// engine), trimmed to the fields the app reads plus a few it ignores.
|
||||
const FULL = {
|
||||
version: "1.9.0",
|
||||
connected: 1,
|
||||
state: "streaming",
|
||||
device: "WH-1000XM4",
|
||||
gain_pct: 140,
|
||||
muted: 0,
|
||||
codec: "SBC",
|
||||
latency: { target_ms: 120, estimate_ms: 185, capture_ms: 10 },
|
||||
lat_extra: { backoff_ms: 0, normal_ms: 120, auto: 1, auto_ms: 110 },
|
||||
battery: { status: "ok", level: 3, pct: 70, src: "hf", none: 0 },
|
||||
link: { score: 88, rssi: -54, lq: 255, drops_min: 0 },
|
||||
night: { on: 1, db10: 60 },
|
||||
game: { avail: 1, id: "PPSA01234", name: "Astro Bot", profile: 1, active: 1 },
|
||||
events: ["Connected", "Streaming"],
|
||||
chip: { vid: "0e8d", vendor: "MediaTek" },
|
||||
};
|
||||
|
||||
describe("HearBridge status", () => {
|
||||
it("reads every field the card and panel show", () => {
|
||||
expect(parseHbStatus(FULL)).toEqual({
|
||||
version: "1.9.0",
|
||||
state: "streaming",
|
||||
device: "WH-1000XM4",
|
||||
gainPct: 140,
|
||||
muted: false,
|
||||
codec: "SBC",
|
||||
latencyTargetMs: 120,
|
||||
latencyEstimateMs: 185,
|
||||
latencyAuto: true,
|
||||
batteryPct: 70,
|
||||
linkScore: 88,
|
||||
night: true,
|
||||
gameName: "Astro Bot",
|
||||
gameProfile: true,
|
||||
events: ["Connected", "Streaming"],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves out what an older HearBridge doesn't send, never guessing", () => {
|
||||
const s = parseHbStatus({ version: "0.3", state: "disconnected" });
|
||||
expect(s.gainPct).toBeNull();
|
||||
expect(s.muted).toBeNull();
|
||||
expect(s.latencyAuto).toBeNull();
|
||||
expect(s.batteryPct).toBeNull();
|
||||
expect(s.events).toEqual([]);
|
||||
// -1 is HearBridge's "unknown".
|
||||
expect(parseHbStatus({ battery: { pct: -1 }, link: { score: -1 } }).batteryPct).toBeNull();
|
||||
expect(parseHbStatus(null).version).toBe("");
|
||||
expect(parseHbStatus({ events: [1, "x", null] }).events).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("reads saved and scanned headsets, dropping malformed rows", () => {
|
||||
expect(
|
||||
parseSaved({ devices: [{ addr: "AA:BB:CC:DD:EE:FF", name: "Buds", kind: "audio", current: 1 }, { name: "no addr" }] }),
|
||||
).toEqual([{ addr: "AA:BB:CC:DD:EE:FF", name: "Buds", kind: "audio", current: true }]);
|
||||
expect(
|
||||
parseScanned({ version: 1, devices: [{ index: 2, addr: "11:22:33:44:55:66", name: "Pods", rssi: null }, { addr: "x" }] }),
|
||||
).toEqual([{ index: 2, addr: "11:22:33:44:55:66", name: "Pods", kind: "", rssi: null }]);
|
||||
expect(parseSaved("garbage")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HearBridge through the engine", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("reports a console without HearBridge as absent, not an error", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true, present: false, error: "no answer" })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(hbOverview("192.168.1.5")).resolves.toEqual({ present: false, error: "no answer" });
|
||||
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("/api/ps5/hearbridge/status?host=192.168.1.5");
|
||||
});
|
||||
|
||||
it("posts one change with the console and returns the new status", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () => new Response(JSON.stringify({ ok: true, result: { ...FULL, gain_pct: 200 } })),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const s = await hbAct("192.168.1.5:9120", { action: "volume", pct: 200 });
|
||||
expect(s?.gainPct).toBe(200);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toContain("/api/ps5/hearbridge/action");
|
||||
expect(JSON.parse(String(init.body))).toEqual({ host: "192.168.1.5", action: "volume", pct: 200 });
|
||||
});
|
||||
|
||||
it("returns no status for an action HearBridge answers with {ok:1}", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true, result: { ok: 1 } }))));
|
||||
await expect(hbAct("10.0.0.2", { action: "reconnect" })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces the engine's error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ error: "HearBridge refused" }), { status: 502 })),
|
||||
);
|
||||
await expect(hbAct("10.0.0.2", { action: "stop" })).rejects.toThrow("HearBridge refused");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// The console's HearBridge (Bluetooth headset audio; its own web page on :8090..8095)
|
||||
// through the engine's /api/ps5/hearbridge routes. HearBridge sends no CORS headers, so
|
||||
// the app never calls it directly; the engine finds its port, holds its token and passes
|
||||
// only the changes below. Plain fetches against the engine origin, so the desktop app, the
|
||||
// browser build and the Docker build take the same path.
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
import { hostOf } from "../lib/addr";
|
||||
|
||||
/** HearBridge's status, read tolerantly: an older or newer HearBridge may leave any field
|
||||
* out, and a missing field is null rather than a guess. */
|
||||
export interface HbStatus {
|
||||
version: string;
|
||||
/** "streaming", "connecting", "scanning", "reconnecting", "disconnected", "error", … */
|
||||
state: string;
|
||||
/** The headset's name, when one is connected or being connected. */
|
||||
device: string;
|
||||
/** Audio volume boost, 0..500 %. */
|
||||
gainPct: number | null;
|
||||
muted: boolean | null;
|
||||
codec: string;
|
||||
latencyTargetMs: number | null;
|
||||
latencyEstimateMs: number | null;
|
||||
latencyAuto: boolean | null;
|
||||
batteryPct: number | null;
|
||||
/** Link quality 0..100. */
|
||||
linkScore: number | null;
|
||||
night: boolean | null;
|
||||
/** The running game HearBridge has a profile for, if any. */
|
||||
gameName: string;
|
||||
gameProfile: boolean;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface HbSavedDevice {
|
||||
addr: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface HbScannedDevice {
|
||||
index: number;
|
||||
addr: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
rssi: number | null;
|
||||
}
|
||||
|
||||
export type HbOverview =
|
||||
| { present: true; port: number; status: HbStatus }
|
||||
| { present: false; error: string };
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {});
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const flag = (v: unknown): boolean | null =>
|
||||
typeof v === "boolean" ? v : typeof v === "number" ? v !== 0 : null;
|
||||
|
||||
export function parseHbStatus(raw: unknown): HbStatus {
|
||||
const s = obj(raw);
|
||||
const latency = obj(s.latency);
|
||||
const battery = obj(s.battery);
|
||||
const link = obj(s.link);
|
||||
const game = obj(s.game);
|
||||
const pct = num(battery.pct);
|
||||
const score = num(link.score);
|
||||
return {
|
||||
version: str(s.version),
|
||||
state: str(s.state),
|
||||
device: str(s.device),
|
||||
gainPct: num(s.gain_pct),
|
||||
muted: flag(s.muted),
|
||||
codec: str(s.codec),
|
||||
latencyTargetMs: num(latency.target_ms),
|
||||
latencyEstimateMs: num(latency.estimate_ms),
|
||||
latencyAuto: flag(obj(s.lat_extra).auto),
|
||||
// -1 is HearBridge's "not known".
|
||||
batteryPct: pct !== null && pct >= 0 ? pct : null,
|
||||
linkScore: score !== null && score >= 0 ? score : null,
|
||||
night: flag(obj(s.night).on),
|
||||
gameName: str(game.name),
|
||||
gameProfile: flag(game.profile) === true,
|
||||
events: Array.isArray(s.events) ? s.events.filter((e): e is string => typeof e === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSaved(raw: unknown): HbSavedDevice[] {
|
||||
const list = obj(raw).devices;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map(obj)
|
||||
.filter((d) => str(d.addr))
|
||||
.map((d) => ({ addr: str(d.addr), name: str(d.name), kind: str(d.kind), current: flag(d.current) === true }));
|
||||
}
|
||||
|
||||
export function parseScanned(raw: unknown): HbScannedDevice[] {
|
||||
const list = obj(raw).devices;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map(obj)
|
||||
.filter((d) => num(d.index) !== null)
|
||||
.map((d) => ({
|
||||
index: num(d.index) as number,
|
||||
addr: str(d.addr),
|
||||
name: str(d.name),
|
||||
kind: str(d.kind),
|
||||
rssi: num(d.rssi),
|
||||
}));
|
||||
}
|
||||
|
||||
async function read(res: Response): Promise<Obj> {
|
||||
const body = obj(await res.json().catch(() => ({})));
|
||||
if (!res.ok) throw new Error(str(body.error) || `request failed (${res.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
const q = (host: string) => `host=${encodeURIComponent(hostOf(host))}`;
|
||||
|
||||
/** Whether HearBridge answers on `host`, on which port, and its status. */
|
||||
export async function hbOverview(host: string): Promise<HbOverview> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/status?${q(host)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const w = await read(res);
|
||||
if (!w.present) return { present: false, error: str(w.error) || "not running" };
|
||||
return { present: true, port: num(w.port) ?? 8090, status: parseHbStatus(w.status) };
|
||||
}
|
||||
|
||||
export async function hbSaved(host: string): Promise<HbSavedDevice[]> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/saved?${q(host)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
return parseSaved(await read(res));
|
||||
}
|
||||
|
||||
export async function hbScanned(host: string): Promise<HbScannedDevice[]> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/devices?${q(host)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
return parseScanned(await read(res));
|
||||
}
|
||||
|
||||
export type HbAction =
|
||||
| { action: "volume"; pct: number }
|
||||
| { action: "mute"; on: boolean }
|
||||
| { action: "night"; on: boolean }
|
||||
| { action: "latency"; auto: boolean }
|
||||
| { action: "latency"; ms: number }
|
||||
| { action: "select"; addr: string }
|
||||
| { action: "select"; index: number }
|
||||
| { action: "reconnect" }
|
||||
| { action: "scan" }
|
||||
| { action: "stop" };
|
||||
|
||||
/** Make one change. Returns HearBridge's new status when its answer carries one. */
|
||||
export async function hbAct(host: string, a: HbAction): Promise<HbStatus | null> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/hearbridge/action`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ host: hostOf(host), ...a }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const w = await read(res);
|
||||
const result = obj(w.result);
|
||||
return typeof result.version === "string" ? parseHbStatus(result) : null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../state/engine", () => ({ getEngineUrl: () => "http://engine.test:19113" }));
|
||||
|
||||
import { hiddenIcons, setIconHidden } from "./hiddenIcons";
|
||||
|
||||
const reply = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status });
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("hidden home-screen icons (#407)", () => {
|
||||
it("reads the hidden list from the console by bare host", async () => {
|
||||
const f = vi.fn().mockResolvedValue(reply(200, { ok: true, hidden: ["ITEM00001"] }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
expect(await hiddenIcons("10.0.0.2:9113")).toEqual(["ITEM00001"]);
|
||||
expect(f.mock.calls[0][0]).toBe("http://engine.test:19113/api/ps5/apps/hidden?addr=10.0.0.2");
|
||||
});
|
||||
|
||||
it("says why it cannot read the list", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(reply(200, { ok: false, error: "cannot read app.db" })));
|
||||
await expect(hiddenIcons("10.0.0.2")).rejects.toThrow("cannot read app.db");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(reply(502, { error: "helper_not_ava1" })));
|
||||
await expect(hiddenIcons("10.0.0.2")).rejects.toThrow("helper_not_ava1");
|
||||
});
|
||||
|
||||
it("hides and shows with the console's reason on a refusal", async () => {
|
||||
const f = vi.fn().mockResolvedValue(reply(200, { ok: true, err: null }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
await setIconHidden("10.0.0.2", "ITEM00001", true);
|
||||
expect(f.mock.calls[0][0]).toBe("http://engine.test:19113/api/ps5/apps/hidden");
|
||||
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({
|
||||
addr: "10.0.0.2",
|
||||
title_id: "ITEM00001",
|
||||
hidden: true,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(reply(200, { ok: false, error: "NPXS40000 is a system app and cannot be hidden" })),
|
||||
);
|
||||
await expect(setIconHidden("10.0.0.2", "NPXS40000", true)).rejects.toThrow("system app");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Home-screen icons hidden on the console (#407): the engine's /api/ps5/apps/hidden, which asks
|
||||
// the helper to read or flip app.db's `visible` flag. Plain fetches against the engine origin,
|
||||
// so the desktop app, the browser build and the Docker build take the same path.
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
import { consoleAddr } from "../lib/addr";
|
||||
|
||||
interface HiddenWire {
|
||||
ok?: boolean;
|
||||
hidden?: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
async function read(res: Response): Promise<HiddenWire> {
|
||||
const body = (await res.json().catch(() => ({}))) as HiddenWire;
|
||||
if (!res.ok || !body.ok) {
|
||||
throw new Error(body.error || `request failed (${res.status})`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** The title ids whose home-screen icon is hidden, read live from the console. */
|
||||
export async function hiddenIcons(host: string): Promise<string[]> {
|
||||
const addr = encodeURIComponent(consoleAddr(host));
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/apps/hidden?addr=${addr}`, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
return (await read(res)).hidden ?? [];
|
||||
}
|
||||
|
||||
/** Hide (`true`) or show a title's home-screen icon. Throws with the console's reason when it
|
||||
* refuses (a system app, a title with no icon, app.db busy). */
|
||||
export async function setIconHidden(host: string, titleId: string, hidden: boolean): Promise<void> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/apps/hidden`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ addr: consoleAddr(host), title_id: titleId, hidden }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
await read(res);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// The console's Payload Manager (itsPLK's pldmgr.elf, :8084) through the engine's
|
||||
// /api/ps5/pldmgr routes. Plain fetches against the engine origin, so the desktop app, the
|
||||
// browser build and the Docker build take the same path.
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
import { hostOf } from "../lib/addr";
|
||||
|
||||
export interface PmStoredPayload {
|
||||
path: string;
|
||||
/** Payload Manager 0.6's slot (`local/<stem>`, `repo/<id>@<source>`); "" on 0.5. */
|
||||
slot: string;
|
||||
filename: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
version: string;
|
||||
source: string;
|
||||
install_source: string;
|
||||
}
|
||||
|
||||
export interface PmAutoload {
|
||||
enabled: boolean;
|
||||
delay_s: number;
|
||||
/** What Payload Manager loads at boot, in order: file names (0.5) or slots (0.6), with
|
||||
* `!<ms>` pauses. `autoloadKey` gives a payload's entry. */
|
||||
list: string[];
|
||||
}
|
||||
|
||||
export type PmOverview =
|
||||
| { present: false; error: string }
|
||||
| {
|
||||
present: true;
|
||||
version: string;
|
||||
payloads: PmStoredPayload[];
|
||||
payloadsError: string | null;
|
||||
autoload: PmAutoload | null;
|
||||
autoloadError: string | null;
|
||||
};
|
||||
|
||||
interface Wire {
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
present?: boolean;
|
||||
version?: string;
|
||||
payloads?: PmStoredPayload[] | null;
|
||||
payloads_error?: string | null;
|
||||
autoload?: PmAutoload | null;
|
||||
autoload_error?: string | null;
|
||||
needs_confirm?: boolean;
|
||||
folder?: string;
|
||||
file_exists?: boolean;
|
||||
}
|
||||
|
||||
async function read(res: Response): Promise<Wire> {
|
||||
const body = (await res.json().catch(() => ({}))) as Wire;
|
||||
if (!res.ok) throw new Error(body.error || `request failed (${res.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function post(path: string, body: unknown, timeoutMs = 30_000): Promise<Wire> {
|
||||
const res = await fetch(`${getEngineUrl()}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return read(res);
|
||||
}
|
||||
|
||||
/** What Payload Manager on `host` stores and how its autoload is set. */
|
||||
export async function pmOverview(host: string): Promise<PmOverview> {
|
||||
const h = encodeURIComponent(hostOf(host));
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/pldmgr?host=${h}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const w = await read(res);
|
||||
if (!w.present) return { present: false, error: w.error ?? "not running" };
|
||||
return {
|
||||
present: true,
|
||||
version: w.version ?? "",
|
||||
payloads: w.payloads ?? [],
|
||||
payloadsError: w.payloads_error ?? null,
|
||||
autoload: w.autoload ?? null,
|
||||
autoloadError: w.autoload_error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pmLaunch(host: string, path: string): Promise<void> {
|
||||
const w = await post("/api/ps5/pldmgr/launch", { host: hostOf(host), path });
|
||||
if (!w.ok) throw new Error(w.error || "launch failed");
|
||||
}
|
||||
|
||||
export async function pmDelete(host: string, filename: string): Promise<void> {
|
||||
const w = await post("/api/ps5/pldmgr/delete", { host: hostOf(host), filename });
|
||||
if (!w.ok) throw new Error(w.error || "delete failed");
|
||||
}
|
||||
|
||||
export async function pmSetAutoload(host: string, enabled: boolean, list: string[]): Promise<void> {
|
||||
const w = await post("/api/ps5/pldmgr/autoload", { host: hostOf(host), enabled, list });
|
||||
if (!w.ok) throw new Error(w.error || "autoload update failed");
|
||||
}
|
||||
|
||||
export type PmStoreResult =
|
||||
| { stored: true; folder: string }
|
||||
| { stored: false; needsConfirm: true; folder: string; fileExists: boolean };
|
||||
|
||||
/** Store a file from this computer in Payload Manager under `filename`. Without `confirmed`,
|
||||
* an existing folder is reported back instead of written to (storing removes that folder's
|
||||
* other files). */
|
||||
export async function pmStore(
|
||||
host: string,
|
||||
filename: string,
|
||||
localPath: string,
|
||||
confirmed: boolean,
|
||||
): Promise<PmStoreResult> {
|
||||
const w = await post(
|
||||
"/api/ps5/pldmgr/store",
|
||||
{ host: hostOf(host), filename, local_path: localPath, confirmed },
|
||||
120_000,
|
||||
);
|
||||
if (w.ok) return { stored: true, folder: w.folder ?? "" };
|
||||
if (w.needs_confirm) {
|
||||
return { stored: false, needsConfirm: true, folder: w.folder ?? "", fileExists: !!w.file_exists };
|
||||
}
|
||||
throw new Error(w.error || "store failed");
|
||||
}
|
||||
|
||||
/** The autoload-list entry that names `p`: its slot on Payload Manager 0.6, else its file name. */
|
||||
export function autoloadKey(p: Pick<PmStoredPayload, "slot" | "filename">): string {
|
||||
return p.slot || p.filename;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { scanLibrary } from "./ps5";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
vi.mock("../lib/tauriEnv", () => ({ isTauriEnv: () => true }));
|
||||
const call = vi.mocked(invoke);
|
||||
beforeEach(() => { call.mockReset(); });
|
||||
|
||||
it("finds compressed images, case insensitively, alongside raw images", async () => {
|
||||
call.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === "ps5_volumes") return { volumes: [{ path: "/data", is_placeholder: false }] };
|
||||
if (cmd === "ps5_list_dir" && (args as { path: string }).path === "/data")
|
||||
return { entries: ["a.exfat", "b.FFPFSC", "c.ffpkg", "d.ffpfs", "notes.txt"].map((name) => ({ name, kind: "file", size: 123 })) };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const entries = await scanLibrary("192.0.2.8");
|
||||
expect(entries.map((e) => e.imageFormat)).toEqual(["exfat", "ffpfsc", "ffpkg", "ffpfs"]);
|
||||
});
|
||||
|
||||
it("keeps images beyond the first directory page", async () => {
|
||||
call.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === "ps5_volumes") return { volumes: [{ path: "/data", is_placeholder: false }] };
|
||||
if (cmd === "ps5_list_dir") {
|
||||
const { offset } = args as { offset: number };
|
||||
return offset === 0
|
||||
? { entries: [{ name: "notes.txt", kind: "file" }], truncated: true }
|
||||
: { entries: [{ name: "last.ffpfsc", kind: "file", size: 20 }], truncated: false };
|
||||
}
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
expect((await scanLibrary("192.0.2.8")).map((e) => e.name)).toEqual(["last.ffpfsc"]);
|
||||
expect(call).toHaveBeenCalledWith("ps5_list_dir", expect.objectContaining({ offset: 1 }));
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { payloadCheck } from "./ps5";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
vi.mock("../lib/tauriEnv", () => ({ isTauriEnv: () => true }));
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
|
||||
/** The status probe's failure carries the engine's `reason` and `power` (rest mode design). */
|
||||
describe("payloadCheck reason and power", () => {
|
||||
beforeEach(() => mockedInvoke.mockReset());
|
||||
|
||||
it("passes a rest-mode verdict through", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: false,
|
||||
reachable: false,
|
||||
engine: true,
|
||||
error: '{"error":"x","reason":"console_rest_mode","power":"rest"}',
|
||||
reason: "console_rest_mode",
|
||||
power: "rest",
|
||||
});
|
||||
const r = await payloadCheck("10.0.0.2");
|
||||
expect(r.reachable).toBe(false);
|
||||
expect(r.session).toBe("down");
|
||||
expect(r.reason).toBe("console_rest_mode");
|
||||
expect(r.power).toBe("rest");
|
||||
});
|
||||
|
||||
it("drops an unknown power value and leaves both out when absent", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: false,
|
||||
reachable: false,
|
||||
engine: true,
|
||||
error: "x",
|
||||
reason: "ava1_unreachable",
|
||||
power: "asleep",
|
||||
});
|
||||
const a = await payloadCheck("10.0.0.2");
|
||||
expect(a.reason).toBe("ava1_unreachable");
|
||||
expect("power" in a).toBe(false);
|
||||
|
||||
mockedInvoke.mockResolvedValue({ ok: false, reachable: false, engine: true, error: "x" });
|
||||
const b = await payloadCheck("10.0.0.2");
|
||||
expect("reason" in b || "power" in b).toBe(false);
|
||||
});
|
||||
|
||||
it("has no reason or power when the console answered", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: true,
|
||||
reachable: true,
|
||||
engine: true,
|
||||
status: { version: "7.0.30" },
|
||||
});
|
||||
const r = await payloadCheck("10.0.0.2");
|
||||
expect(r.reachable).toBe(true);
|
||||
expect("reason" in r || "power" in r).toBe(false);
|
||||
});
|
||||
});
|
||||
+260
-36
@@ -543,6 +543,33 @@ export async function startTransferDir(
|
||||
return res.job_id;
|
||||
}
|
||||
|
||||
/** Start ONE upload job for a set of files (POST /api/transfer/file-list): each `src` lands
|
||||
* at its `dest` (absolute, below `destRoot`). One AVA1 job instead of one per file, so a pick
|
||||
* of thousands of small files goes at the console's small-file speed rather than paying a
|
||||
* job's setup for each. `txId` (32 hex): the same list under the same id resumes, the
|
||||
* console's journal skipping the files it already has. The engine stats every source before
|
||||
* it answers with the job id, hence the long request (a slow disk, tens of thousands of
|
||||
* files). */
|
||||
export async function startTransferFileList(
|
||||
destRoot: string,
|
||||
files: { src: string; dest: string }[],
|
||||
addr: string,
|
||||
txId?: string | null,
|
||||
bandwidthCapMbps?: number,
|
||||
): Promise<string> {
|
||||
const res = await invoke<{ job_id: string }>("transfer_file_list", {
|
||||
req: {
|
||||
dest_root: destRoot,
|
||||
files,
|
||||
addr,
|
||||
tx_id: txId ?? null,
|
||||
bandwidth_cap_mbps:
|
||||
bandwidthCapMbps && bandwidthCapMbps > 0 ? bandwidthCapMbps : null,
|
||||
},
|
||||
});
|
||||
return res.job_id;
|
||||
}
|
||||
|
||||
/** Start a zip-archive upload. The engine decompresses on the host so files
|
||||
* land already extracted under `destRoot` — no temp copy of the whole game,
|
||||
* no payload changes. Same `destRoot` contract as `startTransferDir`: pass
|
||||
@@ -1142,6 +1169,25 @@ export async function fsDelete(
|
||||
});
|
||||
}
|
||||
|
||||
/** Decompress a `.ffpfsc` on the console into the image it holds, beside it (#453). Resolves
|
||||
* when the image is written and checked; follow it with `fsOpStatus(addr, opId)` and stop it
|
||||
* with `fsOpCancel`. Throws "cancelled" when stopped, else the helper's cause
|
||||
* (`fs_unpack_no_space: ...`, `fs_unpack_destination_exists`, ...). */
|
||||
export async function fsUnpack(
|
||||
transferAddr: string,
|
||||
path: string,
|
||||
opId: number,
|
||||
): Promise<{ path: string; size: number }> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/fs/unpack`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ addr: toMgmtAddr(transferAddr), path, op_id: opId }),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as { path?: string; size?: number; error?: string };
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return { path: body.path ?? "", size: body.size ?? 0 };
|
||||
}
|
||||
|
||||
export interface FsDirEntry {
|
||||
name: string;
|
||||
kind: "file" | "dir" | "unknown";
|
||||
@@ -1426,6 +1472,21 @@ export async function fsMount(
|
||||
});
|
||||
}
|
||||
|
||||
/** Safely remove a USB drive: the helper unmounts `/mnt/usbN` so it can be unplugged. Without
|
||||
* `force` it rejects with `usb_eject_busy` while anything on the console holds the drive open
|
||||
* (the PS5's own services usually do); `force` unmounts anyway after a flush. Goes through the
|
||||
* engine directly (desktop, browser and Docker alike). */
|
||||
export async function usbEject(host: string, mountPoint: string, force = false): Promise<void> {
|
||||
const res = await fetch(`${getEngineUrl()}/api/ps5/usb/eject`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ addr: _mgmtAddr(host), mount_point: mountPoint, force }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
||||
if (!res.ok || !body.ok) throw new Error(body.error || `eject failed (${res.status})`);
|
||||
}
|
||||
|
||||
/** Unmount a previously-mounted image. `mountPoint` must be the exact
|
||||
* path returned from `fsMount` — the payload rejects anything outside
|
||||
* `/mnt/ps5upload/`. */
|
||||
@@ -1493,19 +1554,41 @@ export async function appRegister(
|
||||
});
|
||||
}
|
||||
|
||||
/** Reverse of `appRegister`: tears down the nullfs at
|
||||
* `/system_ex/app/<titleId>`, removes tracking, calls Sony's
|
||||
* AppUninstall when available. Best-effort — succeeds even when
|
||||
* Sony's API isn't available on the running firmware as long as
|
||||
* the unmount went through. */
|
||||
/** What an uninstall left behind. */
|
||||
export interface UnregisterResult {
|
||||
/** The PS5 accepted the uninstall, but the title's folders were still there when the helper
|
||||
* looked (a large game can take minutes). Keep it listed and refresh. False from a helper
|
||||
* older than 7.1.6, which did not check. */
|
||||
stillPresent: boolean;
|
||||
}
|
||||
|
||||
/** Reverse of `appRegister` (tears down our nullfs at `/system_ex/app/<titleId>`), and the
|
||||
* uninstall of a package install: both end in Sony's AppUninstall.
|
||||
*
|
||||
* Rejects when the PS5 refused (#476). A 7.1.6 helper reports that as an error; an older one
|
||||
* acknowledged it with `sony_uninstall_refused`, and the app used to drop the game from its
|
||||
* list until the next refresh brought it back. */
|
||||
export async function appUnregister(
|
||||
transferAddr: string,
|
||||
titleId: string,
|
||||
): Promise<void> {
|
||||
): Promise<UnregisterResult> {
|
||||
const addr = toMgmtAddr(transferAddr);
|
||||
await invoke("ps5_app_unregister", {
|
||||
const res = await invoke<{
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
sony_uninstall_refused?: boolean;
|
||||
sony_uninstall_rc?: string;
|
||||
still_present?: boolean | null;
|
||||
} | null>("ps5_app_unregister", {
|
||||
req: { addr, title_id: titleId },
|
||||
});
|
||||
if (res) assertOk(res, "Uninstall");
|
||||
// A 7.1.6 helper that tore down our own folder registration reports Sony's refusal with
|
||||
// still_present:false: the title is gone, so that is not a failure.
|
||||
if (res?.sony_uninstall_refused && res.still_present !== false) {
|
||||
throw new Error(`uninstall_refused:${res.sony_uninstall_rc ?? "unknown"}`);
|
||||
}
|
||||
return { stillPresent: res?.still_present === true };
|
||||
}
|
||||
|
||||
// ─── Hardware monitoring ──────────────────────────────────────────────
|
||||
@@ -1679,6 +1762,8 @@ export interface CompanionStatus {
|
||||
name: string;
|
||||
role: string;
|
||||
port: number;
|
||||
/** The tool serves a web UI on its port; the strip offers to open it. */
|
||||
web?: boolean;
|
||||
reachable: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -2130,6 +2215,19 @@ export async function powerShutdown(addr: string): Promise<PowerControlAck> {
|
||||
return invoke<PowerControlAck>("power_shutdown", { addr });
|
||||
}
|
||||
|
||||
/** What a helper stop ended with (#471): `stopped` (its port went quiet), `not_running`
|
||||
* (nothing answered), or `refused` with the engine's reason — `busy` while a transfer, install
|
||||
* or console file operation runs against that console, `refused` / `still_running` otherwise. */
|
||||
export type HelperStopResult =
|
||||
| { state: "stopped" | "not_running" }
|
||||
| { state: "refused"; reason: string; error: string };
|
||||
|
||||
/** Turn the console's helper off (`POST /api/ps5/helper/stop`). The engine refuses while it has
|
||||
* a job on that console. Throws only when the engine could not be asked at all. */
|
||||
export async function helperStop(addr: string): Promise<HelperStopResult> {
|
||||
return invoke<HelperStopResult>("ps5_helper_stop", { addr });
|
||||
}
|
||||
|
||||
/** Wake a console in standby, over Sony's Device Discovery Protocol.
|
||||
*
|
||||
* Not Wake-on-LAN — a PS5 does not wake from a magic packet. This speaks the
|
||||
@@ -2363,6 +2461,39 @@ export interface SaveList {
|
||||
saves: SaveEntry[];
|
||||
}
|
||||
|
||||
/** What the helper has open as a decrypted save (`/api/ps5/saves/open`, `/close`). */
|
||||
export interface OpenSave {
|
||||
mounted: boolean;
|
||||
/** The save image under /user/home; "" when nothing is open. */
|
||||
image: string;
|
||||
mount_point: string;
|
||||
kind: "ps5" | "ps4" | "";
|
||||
was_open?: boolean | null;
|
||||
written_back?: boolean | null;
|
||||
}
|
||||
|
||||
async function engineJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${getEngineUrl()}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const out = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok) throw new Error(out.error || `${path} failed (${res.status})`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mount a save image decrypted on the PS5 (the helper copies it under /data first); an
|
||||
* empty `image` reports what is open. Its files then show under `mount_point` in Files. */
|
||||
export function savesOpen(addr: string, image: string): Promise<OpenSave> {
|
||||
return engineJson<OpenSave>("/api/ps5/saves/open", { addr, image });
|
||||
}
|
||||
|
||||
/** Unmount the open save and write the image back into the real save when it changed. */
|
||||
export function savesClose(addr: string): Promise<OpenSave> {
|
||||
return engineJson<OpenSave>("/api/ps5/saves/close", { addr });
|
||||
}
|
||||
|
||||
/** List save data folders. user_id=0 lists every user's saves. */
|
||||
export async function savesList(
|
||||
addr: string,
|
||||
@@ -2409,14 +2540,20 @@ export async function convertScreenshot(
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
deleteSource: boolean,
|
||||
/** `sdr` (tone-mapped 8-bit, the default), `hdr` (16-bit PQ BT.2020 with a cICP chunk) or
|
||||
* `hdr_plain` (the same without the chunk, for viewers that refuse it); #448. */
|
||||
mode: ScreenshotPngMode = "sdr",
|
||||
): Promise<string> {
|
||||
return invoke<string>("screenshot_convert", {
|
||||
srcPath,
|
||||
dstPath,
|
||||
deleteSource,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
export type ScreenshotPngMode = "sdr" | "hdr" | "hdr_plain";
|
||||
|
||||
// ─── Save data .zip backup / restore ──────────────────────────────────
|
||||
//
|
||||
// The Saves screen wraps backups into `<title_id>.zip` (containing
|
||||
@@ -2465,8 +2602,9 @@ export async function saveArchiveZip(
|
||||
// is a content:// URI with no usable name; pass this so the backend keeps
|
||||
// distinct backups from colliding on one fixed Downloads filename.
|
||||
fileName?: string,
|
||||
): Promise<void> {
|
||||
await invoke("save_archive_zip", {
|
||||
): Promise<string> {
|
||||
// Where the zip landed: `destZip` on desktop, a Downloads file when Android redirected it.
|
||||
const written = await invoke<string | null>("save_archive_zip", {
|
||||
req: {
|
||||
src_dir: srcDir,
|
||||
inner_root: innerRoot,
|
||||
@@ -2474,6 +2612,7 @@ export async function saveArchiveZip(
|
||||
dest_filename: fileName,
|
||||
},
|
||||
});
|
||||
return typeof written === "string" && written ? written : destZip;
|
||||
}
|
||||
|
||||
export interface SaveArchiveUnzipResult {
|
||||
@@ -2763,6 +2902,10 @@ export interface AppDbEntry {
|
||||
export interface AppDbList {
|
||||
apps: AppDbEntry[];
|
||||
err?: string;
|
||||
/** Which reader answered: "sqlite", "sqlite-immutable" or "scan". */
|
||||
source?: string;
|
||||
/** True when app.db holds rows this list does not (#473). */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export async function appdbQuery(addr: string): Promise<AppDbList> {
|
||||
@@ -2974,9 +3117,10 @@ export interface LibraryEntry {
|
||||
/** For `image` kind only: the disk image type derived from the file
|
||||
* extension. `exfat` = .exfat (exFAT volume), `ffpkg` = .ffpkg
|
||||
* (UFS2 volume), `ffpfs` = .ffpfs (PFS save-data volume,
|
||||
* experimental). Null for game entries. Drives the label + mount
|
||||
* experimental), `ffpfsc` = compressed container (SMP playback, decompress to edit).
|
||||
* Null for game entries. Drives the label + mount
|
||||
* behavior in the Library row. */
|
||||
imageFormat?: "exfat" | "ffpkg" | "ffpfs" | null;
|
||||
imageFormat?: "exfat" | "ffpkg" | "ffpfs" | "ffpfsc" | null;
|
||||
/** For `game` kind: PS5 title id (e.g. "PPSA00000") read from the
|
||||
* game's sce_sys/param.json during scan. Used to dedup rows when
|
||||
* the same game appears at multiple paths -- typically when a
|
||||
@@ -2992,7 +3136,7 @@ export interface LibraryEntry {
|
||||
mtime?: number;
|
||||
}
|
||||
|
||||
/** Walk every writable PS5 volume recursively and return all games
|
||||
/** Walk every non-placeholder PS5 volume recursively and return all games
|
||||
* and disk images found anywhere on them.
|
||||
*
|
||||
* Detection rules:
|
||||
@@ -3001,7 +3145,7 @@ export interface LibraryEntry {
|
||||
* against extracted fragments since fragments don't carry
|
||||
* `sce_sys/param.json`.
|
||||
* - **Disk image** = a file whose name (case-insensitive) ends in
|
||||
* `.exfat` or `.ffpkg`.
|
||||
* `.exfat`, `.ffpkg`, `.ffpfs` or `.ffpfsc`.
|
||||
*
|
||||
* Determinism guarantees:
|
||||
* - Listings are sorted by (kind, name) before they drive traversal
|
||||
@@ -3092,15 +3236,18 @@ export async function scanLibrary(
|
||||
> =>
|
||||
scanLimit(async () => {
|
||||
try {
|
||||
const res = await invoke<{
|
||||
entries?: Array<{
|
||||
name?: string;
|
||||
kind?: string;
|
||||
size?: number;
|
||||
mtime?: number;
|
||||
}>;
|
||||
}>("ps5_list_dir", { addr, path, offset: 0, limit: 512 });
|
||||
const out = res.entries ?? [];
|
||||
type Entry = { name?: string; kind?: string; size?: number; mtime?: number };
|
||||
const out: Entry[] = [];
|
||||
// The helper paginates directory listings. A busy games directory can
|
||||
// put every image beyond the first page; honor the same scan budget.
|
||||
while (out.length < MAX_ENTRIES_PER_VOLUME) {
|
||||
const res = await invoke<{ entries?: Entry[]; truncated?: boolean }>(
|
||||
"ps5_list_dir", { addr, path, offset: out.length, limit: 512 },
|
||||
);
|
||||
const page = res.entries ?? [];
|
||||
out.push(...page);
|
||||
if (!res.truncated || page.length === 0) break;
|
||||
}
|
||||
// Stable sort: dir/file kind first, then name. Eliminates
|
||||
// non-determinism from the payload returning entries in
|
||||
// inode or readdir order instead of alphabetical.
|
||||
@@ -3139,14 +3286,16 @@ export async function scanLibrary(
|
||||
if (!e.name) continue;
|
||||
if (e.kind === "file") {
|
||||
const lower = e.name.toLowerCase();
|
||||
const imageFormat: "exfat" | "ffpkg" | "ffpfs" | null =
|
||||
const imageFormat: LibraryEntry["imageFormat"] =
|
||||
lower.endsWith(".exfat")
|
||||
? "exfat"
|
||||
: lower.endsWith(".ffpkg")
|
||||
? "ffpkg"
|
||||
: lower.endsWith(".ffpfs")
|
||||
? "ffpfs"
|
||||
: null;
|
||||
: lower.endsWith(".ffpfsc")
|
||||
? "ffpfsc"
|
||||
: null;
|
||||
if (imageFormat) {
|
||||
const full = path === "/" ? `/${e.name}` : `${path}/${e.name}`;
|
||||
byPath.set(full, {
|
||||
@@ -3378,11 +3527,29 @@ export async function fetchGameMeta(
|
||||
/** Stable URL pointing at the engine's game-icon endpoint for this folder.
|
||||
* Suitable for `<img src=...>` — the engine streams back `image/png`
|
||||
* (or 404 if no icon). Browser caches the response for 5 minutes. */
|
||||
export function gameIconUrl(transferAddr: string, path: string): string {
|
||||
export function gameIconUrl(transferAddr: string, path: string, size?: IconSize): string {
|
||||
const mgmt = toMgmtAddr(transferAddr);
|
||||
return `${engineBase()}/api/ps5/game-icon?addr=${encodeURIComponent(
|
||||
mgmt,
|
||||
)}&path=${encodeURIComponent(path)}`;
|
||||
)}&path=${encodeURIComponent(path)}${sizeQuery(size)}`;
|
||||
}
|
||||
|
||||
/** A scaled cover the engine makes once and caches (128, 256 or 384 px). A console's
|
||||
* icon0.png is 300-700 KiB at 512 px; a grid of hundreds of them stuttered on macOS (#450).
|
||||
* Absent: the full cover. */
|
||||
export type IconSize = 128 | 256 | 384;
|
||||
|
||||
/** The size grids ask for: sharp at a card's width on a 2x display. */
|
||||
export const GRID_ICON_SIZE: IconSize = 384;
|
||||
|
||||
/** The scaled cover for an icon drawn `cssPx` wide, sharp on a 2x display. */
|
||||
export function iconSizeFor(cssPx: number): IconSize {
|
||||
const device = cssPx * 2;
|
||||
return device <= 128 ? 128 : device <= 256 ? 256 : 384;
|
||||
}
|
||||
|
||||
function sizeQuery(size: IconSize | undefined): string {
|
||||
return size ? `&size=${size}` : "";
|
||||
}
|
||||
|
||||
/** How an installed title got onto the PS5:
|
||||
@@ -3411,6 +3578,9 @@ export interface InstalledAppsResult {
|
||||
/** True if the payload couldn't report the "registered" set (older
|
||||
* payload); the UI then shows everything as "installed" with a note. */
|
||||
registeredUnavailable: boolean;
|
||||
/** True if the console's list may be missing titles (a listing hit its
|
||||
* page size, or app.db could not be read in full, #473). */
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
/** List every installed title on the PS5, grouped by origin. Routes through
|
||||
@@ -3430,6 +3600,7 @@ export async function appsInstalled(
|
||||
system?: boolean;
|
||||
}>;
|
||||
registered_unavailable?: boolean;
|
||||
incomplete?: boolean;
|
||||
}>("ps5_apps_installed", { addr });
|
||||
const titles: InstalledTitle[] = (res?.titles ?? [])
|
||||
.filter((t): t is { title_id: string } & typeof t => !!t.title_id)
|
||||
@@ -3442,7 +3613,11 @@ export async function appsInstalled(
|
||||
source: t.source ?? "",
|
||||
system: !!t.system,
|
||||
}));
|
||||
return { titles, registeredUnavailable: !!res?.registered_unavailable };
|
||||
return {
|
||||
titles,
|
||||
registeredUnavailable: !!res?.registered_unavailable,
|
||||
incomplete: !!res?.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
export type InstalledPkgKind = "base" | "patch" | "dlc";
|
||||
@@ -3693,11 +3868,11 @@ export async function pkgMetadataConsole(
|
||||
* (/user/appmeta/<titleId>/icon0.png), streamed back as `image/png` by
|
||||
* the engine. Works identically on desktop and Android (in-process engine
|
||||
* on the same loopback port; CSP allows 127.0.0.1:19113 for img-src). */
|
||||
export function appIconUrl(transferAddr: string, titleId: string): string {
|
||||
export function appIconUrl(transferAddr: string, titleId: string, size?: IconSize): string {
|
||||
const mgmt = toMgmtAddr(transferAddr);
|
||||
return `${engineBase()}/api/ps5/app-icon?addr=${encodeURIComponent(
|
||||
mgmt,
|
||||
)}&title_id=${encodeURIComponent(titleId)}`;
|
||||
)}&title_id=${encodeURIComponent(titleId)}${sizeQuery(size)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3723,8 +3898,10 @@ export function appIconUrl(transferAddr: string, titleId: string): string {
|
||||
* Keyed on the console as well as the title: one console's artwork must
|
||||
* never be served under another's name.
|
||||
*/
|
||||
const appIconKey = (addr: string, titleId: string) => `app|${addr}|${titleId}`;
|
||||
const gameIconKey = (addr: string, path: string) => `game|${addr}|${path}`;
|
||||
const appIconKey = (addr: string, titleId: string, size?: IconSize) =>
|
||||
`app|${addr}|${titleId}${size ? `|${size}` : ""}`;
|
||||
const gameIconKey = (addr: string, path: string, size?: IconSize) =>
|
||||
`game|${addr}|${path}${size ? `|${size}` : ""}`;
|
||||
|
||||
/**
|
||||
* Artwork this session already holds for a title, or undefined.
|
||||
@@ -3736,30 +3913,34 @@ const gameIconKey = (addr: string, path: string) => `game|${addr}|${path}`;
|
||||
export function cachedAppIcon(
|
||||
transferAddr: string,
|
||||
titleId: string,
|
||||
size?: IconSize,
|
||||
): string | undefined {
|
||||
return getCachedIcon(appIconKey(toMgmtAddr(transferAddr), titleId));
|
||||
return getCachedIcon(appIconKey(toMgmtAddr(transferAddr), titleId, size));
|
||||
}
|
||||
|
||||
/** Folder cover art this session already holds. See {@link cachedAppIcon}. */
|
||||
export function cachedGameIcon(
|
||||
transferAddr: string,
|
||||
path: string,
|
||||
size?: IconSize,
|
||||
): string | undefined {
|
||||
return getCachedIcon(gameIconKey(toMgmtAddr(transferAddr), path));
|
||||
return getCachedIcon(gameIconKey(toMgmtAddr(transferAddr), path, size));
|
||||
}
|
||||
|
||||
export async function appIconDataUrl(
|
||||
transferAddr: string,
|
||||
titleId: string,
|
||||
size?: IconSize,
|
||||
): Promise<string | null> {
|
||||
const addr = toMgmtAddr(transferAddr);
|
||||
const key = appIconKey(addr, titleId);
|
||||
const key = appIconKey(addr, titleId, size);
|
||||
const hit = getCachedIcon(key);
|
||||
if (hit) return hit;
|
||||
try {
|
||||
const url = await invoke<string>("ps5_app_icon_data", {
|
||||
addr,
|
||||
titleId,
|
||||
size: size ?? null,
|
||||
});
|
||||
if (typeof url === "string" && url.startsWith("data:")) {
|
||||
setCachedIcon(key, url);
|
||||
@@ -3775,13 +3956,14 @@ export async function appIconDataUrl(
|
||||
export async function gameIconDataUrl(
|
||||
transferAddr: string,
|
||||
path: string,
|
||||
size?: IconSize,
|
||||
): Promise<string | null> {
|
||||
const addr = toMgmtAddr(transferAddr);
|
||||
const key = gameIconKey(addr, path);
|
||||
const key = gameIconKey(addr, path, size);
|
||||
const hit = getCachedIcon(key);
|
||||
if (hit) return hit;
|
||||
try {
|
||||
const url = await invoke<string>("ps5_game_icon_data", { addr, path });
|
||||
const url = await invoke<string>("ps5_game_icon_data", { addr, path, size: size ?? null });
|
||||
if (typeof url === "string" && url.startsWith("data:")) {
|
||||
setCachedIcon(key, url);
|
||||
return url;
|
||||
@@ -4183,11 +4365,32 @@ export function humanizeJobErrorReason(
|
||||
"joberr.ava1_busy",
|
||||
"The PS5 helper is busy and did not accept this transfer in time. Wait for its other work to finish, then click Retry.",
|
||||
);
|
||||
// Console to console (#444).
|
||||
case "ava1_taken_over":
|
||||
return trStatic(
|
||||
"joberr.ava1_taken_over",
|
||||
"Another device took over this send; it continues there.",
|
||||
);
|
||||
case "ava1_shared_jobs_unsupported":
|
||||
return trStatic(
|
||||
"joberr.ava1_shared_jobs_unsupported",
|
||||
"Another device is still running this send. Taking it over (or watching it) while it runs needs the newer helper on both consoles: update them, or pause the send on that device first.",
|
||||
);
|
||||
case "ava1_cannot_watch":
|
||||
return trStatic(
|
||||
"joberr.ava1_cannot_watch",
|
||||
"This send goes through the other device, so it can be taken over but not watched.",
|
||||
);
|
||||
case "ava1_unreachable":
|
||||
return trStatic(
|
||||
"joberr.ava1_unreachable",
|
||||
"The PS5 could not be reached. Check that it is on and on the same network, and that the helper is running, then click Retry.",
|
||||
);
|
||||
case "console_rest_mode":
|
||||
return trStatic(
|
||||
"joberr.console_rest_mode",
|
||||
"The PS5 is in rest mode. Wake it, and the transfer carries on from where it stopped.",
|
||||
);
|
||||
case "ava1_no_space":
|
||||
return trStatic(
|
||||
"joberr.fs_write_failed_no_space",
|
||||
@@ -4526,6 +4729,8 @@ export interface UpdateDownload {
|
||||
path: string;
|
||||
/** Total bytes written. Handy for "Downloaded N MB" status lines. */
|
||||
bytes: number;
|
||||
/** A packaged Linux install: the command that installs the download (#456). */
|
||||
install_command?: string;
|
||||
}
|
||||
|
||||
/** Ask GitHub for the latest-release manifest and compare to our
|
||||
@@ -4599,12 +4804,22 @@ export async function payloadCheck(ip: string): Promise<{
|
||||
* went wrong (TCP connect refused, STATUS_ACK timeout, etc.)
|
||||
* instead of just "didn't come up". null when reachable. */
|
||||
error: string | null;
|
||||
/** The engine's token for why the probe failed (`console_rest_mode`, `ava1_unreachable`,
|
||||
* `helper_not_ava1`, `not_paired`, ...), from the status 502 body. Absent when reachable
|
||||
* or from an engine too old to send it. */
|
||||
reason?: string;
|
||||
/** What DDP said about the console's power when the engine asked it: `rest` (DDP 620),
|
||||
* `unreachable` (no DDP reply and no TCP answer), `awake` (DDP 200). Absent when the
|
||||
* engine did not ask. */
|
||||
power?: "awake" | "rest" | "unreachable";
|
||||
}> {
|
||||
const resp = await invoke<{
|
||||
reachable?: boolean;
|
||||
loaded?: boolean;
|
||||
engine?: boolean;
|
||||
error?: string;
|
||||
reason?: string;
|
||||
power?: string;
|
||||
status?: {
|
||||
version?: string;
|
||||
ps5_kernel?: string;
|
||||
@@ -4613,7 +4828,16 @@ export async function payloadCheck(ip: string): Promise<{
|
||||
};
|
||||
}>("payload_check", { ip });
|
||||
const error = resp?.reachable ? null : (resp?.error ?? null);
|
||||
const reason =
|
||||
!resp?.reachable && typeof resp?.reason === "string" ? resp.reason : undefined;
|
||||
const power =
|
||||
!resp?.reachable &&
|
||||
(resp?.power === "awake" || resp?.power === "rest" || resp?.power === "unreachable")
|
||||
? resp.power
|
||||
: undefined;
|
||||
return {
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
...(power !== undefined ? { power } : {}),
|
||||
reachable: !!resp?.reachable,
|
||||
loaded: !!resp?.loaded,
|
||||
engineReachable: resp?.engine !== false,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { appUnregister } from "./ps5";
|
||||
import { humanizePs5Error } from "../lib/humanizeError";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
vi.mock("../lib/tauriEnv", () => ({ isTauriEnv: () => true }));
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
|
||||
// #476: the PS5 refused (or did not finish) an uninstall, the app dropped the game from its
|
||||
// list anyway, and the next refresh brought it back.
|
||||
describe("appUnregister results", () => {
|
||||
beforeEach(() => {
|
||||
mockedInvoke.mockReset();
|
||||
});
|
||||
|
||||
it("resolves for a finished uninstall and sends the named console", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: true,
|
||||
sony_uninstall_refused: false,
|
||||
sony_uninstall_rc: "0x00000000",
|
||||
still_present: false,
|
||||
});
|
||||
await expect(appUnregister("192.0.2.8:9113", "CUSA33878")).resolves.toEqual({
|
||||
stillPresent: false,
|
||||
});
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("ps5_app_unregister", {
|
||||
req: { addr: "192.0.2.8", title_id: "CUSA33878" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a refusal an older helper acknowledged", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: true,
|
||||
sony_uninstall_refused: true,
|
||||
sony_uninstall_rc: "0x80B21B02",
|
||||
});
|
||||
await expect(appUnregister("192.0.2.8", "CUSA33878")).rejects.toThrow(
|
||||
"uninstall_refused:0x80B21B02",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a 7.1.6 helper's refusal error through", async () => {
|
||||
mockedInvoke.mockRejectedValue(
|
||||
new Error("payload rejected APP_UNREGISTER: uninstall_refused:0x80B21B02"),
|
||||
);
|
||||
await expect(appUnregister("192.0.2.8", "CUSA33878")).rejects.toThrow(
|
||||
"uninstall_refused:0x80B21B02",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts our folder registration removed although Sony refused", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: true,
|
||||
sony_uninstall_refused: true,
|
||||
sony_uninstall_rc: "0x80B21B02",
|
||||
still_present: false,
|
||||
});
|
||||
await expect(appUnregister("192.0.2.8", "PPSA01234")).resolves.toEqual({
|
||||
stillPresent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a title still on the console instead of claiming it is gone", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: true,
|
||||
sony_uninstall_refused: false,
|
||||
sony_uninstall_rc: "0x00000000",
|
||||
still_present: true,
|
||||
});
|
||||
await expect(appUnregister("192.0.2.8", "CUSA33878")).resolves.toEqual({
|
||||
stillPresent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps working with an older helper that reports nothing", async () => {
|
||||
mockedInvoke.mockResolvedValue({
|
||||
ok: true,
|
||||
sony_uninstall_refused: false,
|
||||
sony_uninstall_rc: "0x00000000",
|
||||
still_present: null,
|
||||
});
|
||||
await expect(appUnregister("192.0.2.8", "CUSA33878")).resolves.toEqual({
|
||||
stillPresent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an ok:false reply", async () => {
|
||||
mockedInvoke.mockResolvedValue({ ok: false, error: "unregister_unmount_failed" });
|
||||
await expect(appUnregister("192.0.2.8", "CUSA33878")).rejects.toThrow(
|
||||
"unregister_unmount_failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstall errors in plain words", () => {
|
||||
it("names the console's code for a refusal", () => {
|
||||
const msg = humanizePs5Error(
|
||||
"payload rejected APP_UNREGISTER: uninstall_refused:0x80B21B02",
|
||||
);
|
||||
expect(msg).toContain("0x80B21B02");
|
||||
expect(msg).toContain("still installed");
|
||||
});
|
||||
|
||||
it("explains a firmware without an uninstall function", () => {
|
||||
expect(humanizePs5Error("payload rejected APP_UNREGISTER: uninstall_unavailable")).toContain(
|
||||
"still installed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { getEngineUrl } from "../state/engine";
|
||||
|
||||
export type Protocol = "smb" | "ftp" | "ftps" | "sftp";
|
||||
export type Protocol = "smb" | "ftp" | "ftps" | "sftp" | "webdav" | "webdavs";
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
@@ -15,6 +15,8 @@ export interface Connection {
|
||||
user: string;
|
||||
start_path: string;
|
||||
host_key: string | null;
|
||||
/** WebDAV over HTTPS: the server's self-signed certificate is accepted unverified. */
|
||||
insecure_tls?: boolean;
|
||||
has_secret: boolean;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export interface ConnectionInput {
|
||||
share?: string;
|
||||
user?: string;
|
||||
start_path?: string;
|
||||
insecure_tls?: boolean;
|
||||
}
|
||||
|
||||
export interface SecretInput {
|
||||
|
||||
@@ -17,6 +17,8 @@ const PROTOCOL: Record<Connection["protocol"], string> = {
|
||||
ftp: "FTP",
|
||||
ftps: "FTPS",
|
||||
sftp: "SFTP",
|
||||
webdav: "WebDAV",
|
||||
webdavs: "WebDAV (HTTPS)",
|
||||
};
|
||||
|
||||
export interface BrowseButtonProps {
|
||||
|
||||
@@ -37,7 +37,8 @@ const NAV_KEYWORDS: Record<string, string[]> = {
|
||||
"/volumes": ["disk", "drives"],
|
||||
"/files": ["browse", "file system"],
|
||||
"/console": ["hardware", "temps", "power"],
|
||||
"/payloads": ["homebrew catalog", "payload library", "kstuff", "shadowmount", "etahen"],
|
||||
"/payloads": ["homebrew catalog", "payload library", "kstuff", "shadowmount", "etahen", "nanodns", "hearbridge", "payload manager"],
|
||||
"/processes": ["ports", "task manager", "kill"],
|
||||
"/tasks": ["transfer log", "jobs"],
|
||||
"/bug-report": ["report", "problem", "issue", "discord", "github"],
|
||||
"/shell": ["terminal"],
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { Gamepad2, PlugZap, ServerCrash } from "lucide-react";
|
||||
import { Gamepad2, Moon, PlugZap, Power, ServerCrash, WifiOff } from "lucide-react";
|
||||
|
||||
import { useConnectionStore } from "../state/connection";
|
||||
import { useHelperStopped } from "../state/helperStopped";
|
||||
import { useTr } from "../state/lang";
|
||||
import { useUploadSettingsStore } from "../state/uploadSettings";
|
||||
import { isTauriEnv } from "../lib/tauriEnv";
|
||||
import { useConsoleWake } from "../lib/useConsoleWake";
|
||||
import { REST_MODE_NETWORK_SETTING_PATH } from "../lib/consolePower";
|
||||
import { Button } from "./Button";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { ShapesLoader } from "./ShapesLoader";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* The one shared answer to "can this screen actually talk to a PS5?".
|
||||
@@ -22,7 +28,10 @@ import { ShapesLoader } from "./ShapesLoader";
|
||||
* 1. engine down — the local sidecar is dead; nothing can work.
|
||||
* 2. no console — fresh install / cleared roster; point to setup.
|
||||
* 3. helper down — console known but the on-PS5 helper isn't
|
||||
* running (require="payload" only).
|
||||
* running (require="payload" only). Told apart:
|
||||
* stopped by the user, in rest mode (calm, with
|
||||
* Wake when this console's profile can), not
|
||||
* answering at all, or just no helper.
|
||||
* 4. probing — status unknown, first check in flight.
|
||||
* 5. ready — render children.
|
||||
*
|
||||
@@ -44,6 +53,8 @@ export function ConnectionGate({
|
||||
const host = useConnectionStore((s) => s.host);
|
||||
const payloadStatus = useConnectionStore((s) => s.payloadStatus);
|
||||
const engineStatus = useConnectionStore((s) => s.engineStatus);
|
||||
const helperStopped = useHelperStopped(host ?? "");
|
||||
const power = useConnectionStore((s) => s.power);
|
||||
|
||||
if (engineStatus === "down") {
|
||||
// The cause and the Restart button are in the notice every screen shows at the top
|
||||
@@ -93,6 +104,27 @@ export function ConnectionGate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (helperStopped) {
|
||||
// Turned off on purpose (Connection → Stop helper, #471): not a fault.
|
||||
return (
|
||||
<EmptyState
|
||||
fill
|
||||
icon={PlugZap}
|
||||
title={tr("gate_helper_stopped_title", "You stopped the helper")}
|
||||
message={tr(
|
||||
"gate_helper_stopped_body",
|
||||
"This screen needs the helper. Send it again from the Connection screen when you need it.",
|
||||
)}
|
||||
action={
|
||||
<Button variant="primary" onClick={() => navigate("/connection")}>
|
||||
{tr("gate_send_helper_cta", "Open Connection")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (power === "rest") return <RestModeGate host={host} />;
|
||||
if (power === "unreachable") return <PowerNotice power="unreachable" />;
|
||||
return (
|
||||
<EmptyState
|
||||
fill
|
||||
@@ -113,3 +145,115 @@ export function ConnectionGate({
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** The rest-mode notice wired to this console's wake credentials and the reconnect setting. */
|
||||
function RestModeGate({ host }: { host: string }) {
|
||||
const wake = useConsoleWake(host);
|
||||
// Desktop with "send the helper again after a wake" on: the helper comes back unasked.
|
||||
const autoRedeploy = useUploadSettingsStore((s) => s.autoRedeployOnWake);
|
||||
return (
|
||||
<PowerNotice
|
||||
power="rest"
|
||||
helperReturns={isTauriEnv() && autoRedeploy}
|
||||
canWake={wake.canWake}
|
||||
canSignIn={wake.canSignIn}
|
||||
waking={wake.busy}
|
||||
wakeError={wake.error}
|
||||
onWake={() => void wake.wake()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a console screen says when the console is asleep or not answering — calm, because
|
||||
* neither is a fault of the app or the helper. Pure (props only) so each state can be
|
||||
* rendered in a test.
|
||||
*/
|
||||
export function PowerNotice({
|
||||
power,
|
||||
helperReturns = false,
|
||||
canWake = false,
|
||||
canSignIn = false,
|
||||
waking = false,
|
||||
wakeError = null,
|
||||
onWake,
|
||||
}: {
|
||||
power: "rest" | "unreachable";
|
||||
/** The helper is sent again by itself once the console is awake. */
|
||||
helperReturns?: boolean;
|
||||
/** This console's profile holds a wake credential or session keys. */
|
||||
canWake?: boolean;
|
||||
canSignIn?: boolean;
|
||||
waking?: boolean;
|
||||
wakeError?: string | null;
|
||||
onWake?: () => void;
|
||||
}) {
|
||||
const tr = useTr();
|
||||
if (power === "unreachable") {
|
||||
return (
|
||||
<EmptyState
|
||||
fill
|
||||
icon={WifiOff}
|
||||
title={tr("gate_not_answering_title", "Your PS5 isn't answering")}
|
||||
message={tr(
|
||||
"gate_not_answering_body",
|
||||
{
|
||||
// The console's own menu path, as the wake setup already translates it.
|
||||
path: tr("power_wake_req_restmode_path", undefined, REST_MODE_NETWORK_SETTING_PATH),
|
||||
},
|
||||
"It is off, on another network, or in rest mode with its network features off. To reach it while it rests, turn them on in {path}.",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const body = canWake
|
||||
? helperReturns
|
||||
? tr(
|
||||
"gate_rest_body_auto",
|
||||
"Wake it with the controller or Wake below. The helper comes back by itself once it is awake.",
|
||||
)
|
||||
: tr(
|
||||
"gate_rest_body_manual",
|
||||
"Wake it with the controller or Wake below, then send the helper.",
|
||||
)
|
||||
: helperReturns
|
||||
? tr(
|
||||
"gate_rest_body_auto_nowake",
|
||||
"Wake it with the controller. The helper comes back by itself once it is awake.",
|
||||
)
|
||||
: tr(
|
||||
"gate_rest_body_manual_nowake",
|
||||
"Wake it with the controller, then send the helper.",
|
||||
);
|
||||
return (
|
||||
<EmptyState
|
||||
fill
|
||||
icon={Moon}
|
||||
title={tr("gate_rest_title", "Your PS5 is in rest mode")}
|
||||
body={
|
||||
<>
|
||||
<span>{body}</span>
|
||||
{wakeError && (
|
||||
<span className="mt-2 block text-xs text-[var(--color-bad)]">{wakeError}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
action={
|
||||
canWake ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onWake}
|
||||
disabled={waking}
|
||||
leftIcon={waking ? <Spinner size={12} tone="inherit" /> : <Power size={12} />}
|
||||
>
|
||||
{waking && canSignIn
|
||||
? tr("power_action_wake_signin_busy", undefined, "Waking & signing in…")
|
||||
: canSignIn
|
||||
? tr("power_action_wake_signin", undefined, "Wake & sign in")
|
||||
: tr("power_action_wake", undefined, "Wake")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,17 @@ import { GameIcon } from "./GameIcon";
|
||||
describe("GameIcon", () => {
|
||||
it("shows a suffixed save folder's icon by its title id", () => {
|
||||
const out = renderToStaticMarkup(<GameIcon host="10.0.0.2" titleId="PPSA17221.bak" />);
|
||||
expect(out).toContain('title_id=PPSA17221"');
|
||||
expect(out).toContain("title_id=PPSA17221&size=128");
|
||||
expect(out).not.toContain("PPSA17221.bak");
|
||||
});
|
||||
|
||||
it("asks the engine for a scaled cover sized to the icon (#450)", () => {
|
||||
expect(renderToStaticMarkup(<GameIcon host="10.0.0.2" titleId="PPSA17221" size={112} />)).toContain(
|
||||
"size=256",
|
||||
);
|
||||
expect(renderToStaticMarkup(<GameIcon host="10.0.0.2" titleId="PPSA17221" fill />)).toContain("size=384");
|
||||
});
|
||||
|
||||
it("asks the console nothing for a name that isn't a title id", () => {
|
||||
const out = renderToStaticMarkup(<GameIcon host="10.0.0.2" titleId="sce_sdmemory" />);
|
||||
expect(out).not.toContain("app-icon");
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
cachedGameIcon,
|
||||
gameIconUrl,
|
||||
gameIconDataUrl,
|
||||
GRID_ICON_SIZE,
|
||||
iconSizeFor,
|
||||
} from "../api/ps5";
|
||||
import { transferAddr } from "../lib/addr";
|
||||
import { iconTitleId } from "../lib/titleId";
|
||||
@@ -55,6 +57,8 @@ export function GameIcon({
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const hostReady = !!host.trim();
|
||||
// A scaled copy from the engine: a full icon0.png is 300-700 KiB (#450).
|
||||
const px = fill ? GRID_ICON_SIZE : iconSizeFor(size);
|
||||
// A folder named after a title but not exactly it (a save backup `PPSA17221.bak`) shows that
|
||||
// title's icon; a name that isn't a title id at all skips the console lookup.
|
||||
const iconId = titleId ? iconTitleId(titleId) : null;
|
||||
@@ -62,8 +66,8 @@ export function GameIcon({
|
||||
// Filtering nulls here means the fallback chain naturally skips sources we
|
||||
// don't have rather than rendering a broken <img>.
|
||||
const candidates = [
|
||||
hostReady && iconId ? appIconUrl(transferAddr(host), iconId) : null,
|
||||
hostReady && gamePath ? gameIconUrl(transferAddr(host), gamePath) : null,
|
||||
hostReady && iconId ? appIconUrl(transferAddr(host), iconId, px) : null,
|
||||
hostReady && gamePath ? gameIconUrl(transferAddr(host), gamePath, px) : null,
|
||||
fallbackSrc || null,
|
||||
].filter((s): s is string => !!s);
|
||||
const [idx, setIdx] = useState(0);
|
||||
@@ -87,15 +91,15 @@ export function GameIcon({
|
||||
// already holds its bytes there is nothing to fetch.
|
||||
cached:
|
||||
idx === 0 && hostReady && iconId
|
||||
? cachedAppIcon(transferAddr(host), iconId)
|
||||
? cachedAppIcon(transferAddr(host), iconId, px)
|
||||
: idx === (iconId && hostReady ? 1 : 0) && hostReady && gamePath
|
||||
? cachedGameIcon(transferAddr(host), gamePath)
|
||||
? cachedGameIcon(transferAddr(host), gamePath, px)
|
||||
: undefined,
|
||||
fallbackLoader: () =>
|
||||
idx === 0 && hostReady && iconId
|
||||
? appIconDataUrl(transferAddr(host), iconId)
|
||||
? appIconDataUrl(transferAddr(host), iconId, px)
|
||||
: idx === (iconId && hostReady ? 1 : 0) && hostReady && gamePath
|
||||
? gameIconDataUrl(transferAddr(host), gamePath)
|
||||
? gameIconDataUrl(transferAddr(host), gamePath, px)
|
||||
: Promise.resolve(null),
|
||||
});
|
||||
const advance = useCallback(() => setIdx((i) => i + 1), [setIdx]);
|
||||
|
||||
@@ -114,8 +114,10 @@ export function PickerView(p: PickerViewProps) {
|
||||
[],
|
||||
);
|
||||
return (
|
||||
// z-[55]: above a Modal (z-50) that opened the picker — Collection's folders dialog opened it
|
||||
// behind itself — and below the confirm/prompt layer (z-[60]).
|
||||
<div
|
||||
className="anim-scrim scrim fixed inset-0 z-50 flex items-end justify-center sm:items-center"
|
||||
className="anim-scrim scrim fixed inset-0 z-[55] flex items-end justify-center sm:items-center"
|
||||
style={{ paddingTop: "env(safe-area-inset-top)", paddingBottom: "var(--safe-bottom)" }}
|
||||
onClick={p.onCancel}
|
||||
>
|
||||
|
||||
@@ -164,7 +164,9 @@ export function PackagePanelView({
|
||||
inst.relation === "not-installed"
|
||||
? tr("viewer_not_installed", undefined, "Not installed")
|
||||
: inst.relation === "base-missing"
|
||||
? tr("viewer_base_missing", undefined, "Base game not installed")
|
||||
? inst.verdict === "warn"
|
||||
? tr("viewer_base_missing_ps5", undefined, "Base game not installed (the PS5 may still accept it)")
|
||||
: tr("viewer_base_missing", undefined, "Base game not installed")
|
||||
: inst.relation === "installed"
|
||||
? ver
|
||||
? tr("viewer_installed_same", { ver }, `Already installed (${ver})`)
|
||||
@@ -437,7 +439,7 @@ export function PackagePanel({
|
||||
})
|
||||
.then((pre) => {
|
||||
if (!current()) return;
|
||||
setInstalled(installedFromPreflight(g.specs.app_ver, pre));
|
||||
setInstalled(installedFromPreflight(g.specs.app_ver, pre, g.identity.platform));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
vi.mock("react-router", () => ({ useNavigate: () => () => {} }));
|
||||
|
||||
import { PowerNotice } from "./ConnectionGate";
|
||||
|
||||
const view = (props: Parameters<typeof PowerNotice>[0]) =>
|
||||
renderToStaticMarkup(<PowerNotice {...props} />);
|
||||
|
||||
describe("rest-mode notice on console screens", () => {
|
||||
it("says rest mode calmly, with Wake when this console can be woken", () => {
|
||||
const html = view({ power: "rest", canWake: true, helperReturns: true });
|
||||
expect(html).toContain("Your PS5 is in rest mode");
|
||||
expect(html).toContain("The helper comes back by itself once it is awake.");
|
||||
expect(html).toContain(">Wake<");
|
||||
expect(html).not.toContain("Helper isn't running");
|
||||
});
|
||||
|
||||
it("offers Wake & sign in when the session keys are stored", () => {
|
||||
expect(view({ power: "rest", canWake: true, canSignIn: true })).toContain("Wake & sign in");
|
||||
});
|
||||
|
||||
it("asks for the helper after the wake when reconnect is off", () => {
|
||||
const html = view({ power: "rest", canWake: true, helperReturns: false });
|
||||
expect(html).toContain("then send the helper.");
|
||||
});
|
||||
|
||||
it("has no Wake button without a stored credential", () => {
|
||||
const html = view({ power: "rest", canWake: false, helperReturns: true });
|
||||
expect(html).toContain("Wake it with the controller. The helper comes back");
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
|
||||
it("not answering names the rest-mode network setting", () => {
|
||||
const html = view({ power: "unreachable" });
|
||||
expect(html).toContain("Your PS5 isn't answering");
|
||||
expect(html).toContain("Features Available in Rest Mode");
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ const kindIconMap: Record<TaskKind, typeof Upload> = {
|
||||
"fs-copy": Copy,
|
||||
"fs-move": FolderInput,
|
||||
"fs-rename": FolderInput,
|
||||
"fs-unpack": FolderInput,
|
||||
"pkg-install": Package,
|
||||
"pkg-dpi-install": Package,
|
||||
"save-backup": Save,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { call, isOffline, onTokenExpired } from "./api";
|
||||
import { useBackLevel } from "./backHistory";
|
||||
import { toggleFullscreen } from "./fullscreen";
|
||||
import { Controls } from "./help/Controls";
|
||||
import { HelpOverlay } from "./help/HelpOverlay";
|
||||
import { overlayReducer } from "./help/overlay";
|
||||
import { Tour } from "./help/Tour";
|
||||
import { focusElement, onNavAction, type InputMap } from "./nav";
|
||||
import { SECTIONS, type SectionContext, type SectionParams } from "./sections";
|
||||
import { HintBar } from "./shell/HintBar";
|
||||
import { Rail } from "./shell/Rail";
|
||||
import { ReloadBanner } from "./shell/ReloadBanner";
|
||||
import { TopBar, type ConsoleStatus } from "./shell/TopBar";
|
||||
import { focusLost } from "./ui/restoreFocus";
|
||||
import { pollWhileVisible } from "./ui/visiblePoll";
|
||||
import { useHandoffPoll } from "./files/handoff";
|
||||
import { t } from "./strings";
|
||||
|
||||
/** The console chip refreshes this often (the temperature moves). */
|
||||
const STATUS_POLL_MS = 10_000;
|
||||
|
||||
function railButton(id: string): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>(`[data-rail-id="${id}"]`);
|
||||
}
|
||||
|
||||
/** The page: top bar, rail of sections, the active section, hint bar, reload banner. */
|
||||
export function App({ inputMap }: { inputMap: InputMap }) {
|
||||
const [active, setActive] = useState(SECTIONS[0].id);
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [status, setStatus] = useState<ConsoleStatus | null>(null);
|
||||
const [offline, setOffline] = useState(false);
|
||||
/** Bumped to read /api/status again at once. */
|
||||
const [statusTick, setStatusTick] = useState(0);
|
||||
/** What the active section was opened with (Home's drive cards → Files at a path). */
|
||||
const [params, setParams] = useState<SectionParams | null>(null);
|
||||
/** The tour (first run), the help overlay or the Controls screen; at most one. */
|
||||
const [overlay, overlayDo] = useReducer(overlayReducer, null, () => overlayReducer(null, { type: "start" }));
|
||||
const closeOverlay = useCallback(() => overlayDo({ type: "close" }), []);
|
||||
const openHelp = useCallback(() => overlayDo({ type: "help" }), []);
|
||||
const openControls = useCallback(() => overlayDo({ type: "controls" }), []);
|
||||
|
||||
/** Switch section; params go only to a section opened through openSection. */
|
||||
const show = useCallback((id: string, p: SectionParams | null = null) => {
|
||||
setActive(id);
|
||||
setParams(p);
|
||||
}, []);
|
||||
|
||||
// Away from Home, the browser's Back (○) comes back towards it (rail, then Home).
|
||||
useBackLevel(active !== SECTIONS[0].id);
|
||||
|
||||
useEffect(() => onTokenExpired(() => setExpired(true)), []);
|
||||
|
||||
// The console chip. Stops once the token is gone: every call would 401.
|
||||
useEffect(() => {
|
||||
if (expired) return;
|
||||
let live = true;
|
||||
const load = () => {
|
||||
call<ConsoleStatus>("/api/status").then(
|
||||
(s) => {
|
||||
if (!live) return;
|
||||
setStatus(s);
|
||||
setOffline(false);
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (live && isOffline(e)) setOffline(true);
|
||||
},
|
||||
);
|
||||
};
|
||||
// Not while the page is hidden (the browser in the background): no reason to wake the helper.
|
||||
const stop = pollWhileVisible(load, STATUS_POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
stop();
|
||||
};
|
||||
}, [expired, statusTick]);
|
||||
|
||||
// Start with focus on the rail, so the D-pad works at once (after the tour, when it shows first:
|
||||
// it opened before anything had focus, so there is nothing to go back to).
|
||||
useEffect(() => {
|
||||
if (overlay !== null || !focusLost()) return;
|
||||
const el = railButton(active);
|
||||
if (el) focusElement(el);
|
||||
// Only when an overlay closes (or the page opens), not on every section switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overlay]);
|
||||
|
||||
// L1/R1 step through the sections; ○ goes from the content to the rail, then to Home;
|
||||
// Create re-reads the status (sections re-read their own data first); the touchpad toggles full screen.
|
||||
useEffect(
|
||||
() =>
|
||||
onNavAction((action) => {
|
||||
if (action === "refresh") {
|
||||
setStatusTick((n) => n + 1);
|
||||
return true;
|
||||
}
|
||||
if (action === "help") {
|
||||
openHelp();
|
||||
return true;
|
||||
}
|
||||
if (action === "fullscreen") {
|
||||
toggleFullscreen();
|
||||
return true;
|
||||
}
|
||||
const i = SECTIONS.findIndex((s) => s.id === active);
|
||||
if (action === "prevSection" || action === "nextSection") {
|
||||
const step = action === "nextSection" ? 1 : SECTIONS.length - 1;
|
||||
const next = SECTIONS[(i + step) % SECTIONS.length].id;
|
||||
show(next);
|
||||
const el = railButton(next);
|
||||
if (el) focusElement(el);
|
||||
return true;
|
||||
}
|
||||
if (action === "back") {
|
||||
const inRail = !!document.activeElement?.closest(".cw-rail");
|
||||
if (!inRail) {
|
||||
const el = railButton(active);
|
||||
if (el) focusElement(el);
|
||||
} else if (active !== SECTIONS[0].id) {
|
||||
show(SECTIONS[0].id);
|
||||
const el = railButton(SECTIONS[0].id);
|
||||
if (el) focusElement(el);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, undefined, { fallback: true }),
|
||||
[active, show, openHelp],
|
||||
);
|
||||
|
||||
// "Show on TV": the PC app left a folder; Files opens there (any section).
|
||||
const openFromPc = useCallback(
|
||||
(p: SectionParams) => {
|
||||
show("files", p);
|
||||
const el = railButton("files");
|
||||
if (el) focusElement(el);
|
||||
},
|
||||
[show],
|
||||
);
|
||||
useHandoffPoll(openFromPc, !expired);
|
||||
|
||||
const ctx = useMemo<SectionContext>(
|
||||
() => ({
|
||||
status,
|
||||
refreshStatus: () => setStatusTick((n) => n + 1),
|
||||
openSection: (id, p) => {
|
||||
if (!SECTIONS.some((s) => s.id === id)) return;
|
||||
show(id, p ?? null);
|
||||
const el = railButton(id);
|
||||
if (el) focusElement(el);
|
||||
},
|
||||
hasSection: (id) => SECTIONS.some((s) => s.id === id),
|
||||
canOpen: (id) => SECTIONS.some((s) => s.id === id && s.acceptsParams),
|
||||
params,
|
||||
openControls,
|
||||
showTips: () => overlayDo({ type: "tips" }),
|
||||
}),
|
||||
[status, params, show, openControls],
|
||||
);
|
||||
|
||||
const section = SECTIONS.find((s) => s.id === active) ?? SECTIONS[0];
|
||||
const chipFallback = offline || expired ? t("cw_status_offline") : status ? null : t("cw_status_connecting");
|
||||
|
||||
return (
|
||||
<div className="cw-app">
|
||||
<TopBar status={status} chipFallback={chipFallback} />
|
||||
<div className="cw-body" aria-hidden={expired ? true : undefined}>
|
||||
<Rail sections={SECTIONS} active={active} onSelect={(id) => show(id)} onHelp={openHelp} />
|
||||
<main className="cw-content" data-nav-region="content">
|
||||
{section.render(ctx)}
|
||||
</main>
|
||||
</div>
|
||||
<HintBar map={inputMap} screen={overlay ?? section.id} />
|
||||
{overlay === "tour" ? <Tour onClose={closeOverlay} /> : null}
|
||||
{overlay === "help" ? (
|
||||
<HelpOverlay map={inputMap} screen={section.id} onControls={openControls} onClose={closeOverlay} />
|
||||
) : null}
|
||||
{overlay === "controls" ? <Controls map={inputMap} onClose={closeOverlay} /> : null}
|
||||
{expired ? <ReloadBanner /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Api = typeof import("./api");
|
||||
|
||||
interface Seen {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** A fetch stand-in: /api/token answers a fixed token, other routes `routeReply`. */
|
||||
function installFetch(routeReply: () => Response): Seen[] {
|
||||
const seen: Seen[] = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
seen.push({ url, init });
|
||||
if (url === "/api/token") return jsonResponse(200, { token: "ab".repeat(32) });
|
||||
return routeReply();
|
||||
}) as typeof fetch;
|
||||
return seen;
|
||||
}
|
||||
|
||||
let api: Api;
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
api = await import("./api");
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("call", () => {
|
||||
it("call_sends_token_header", async () => {
|
||||
const seen = installFetch(() => jsonResponse(200, { ok: 1 }));
|
||||
const got = await api.call<{ ok: number }>("/api/status", { a: 1 });
|
||||
expect(got).toEqual({ ok: 1 });
|
||||
const post = seen.find((s) => s.url === "/api/status");
|
||||
expect(post?.init?.method).toBe("POST");
|
||||
const headers = new Headers(post?.init?.headers);
|
||||
expect(headers.get("X-PS5U-Token")).toBe("ab".repeat(32));
|
||||
expect(post?.init?.body).toBe(JSON.stringify({ a: 1 }));
|
||||
});
|
||||
|
||||
it("fetches the token once for many calls", async () => {
|
||||
const seen = installFetch(() => jsonResponse(200, {}));
|
||||
await api.call("/api/status");
|
||||
await api.call("/api/volumes");
|
||||
expect(seen.filter((s) => s.url === "/api/token")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("call_401_triggers_token_expired", async () => {
|
||||
installFetch(() => jsonResponse(401, { error: "token" }));
|
||||
const fn = vi.fn();
|
||||
const off = api.onTokenExpired(fn);
|
||||
await expect(api.call("/api/status")).rejects.toMatchObject({ status: 401, code: "token" });
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
off();
|
||||
await expect(api.call("/api/status")).rejects.toBeTruthy();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("503 stopping reads as offline, an API error keeps its code", async () => {
|
||||
installFetch(() => jsonResponse(503, { error: "stopping" }));
|
||||
const err = await api.call("/api/status").catch((e: unknown) => e);
|
||||
expect(api.isOffline(err)).toBe(true);
|
||||
|
||||
installFetch(() => jsonResponse(400, { error: "path_not_allowed" }));
|
||||
const err2 = await api.call("/api/fs/list").catch((e: unknown) => e);
|
||||
expect(err2).toMatchObject({ status: 400, code: "path_not_allowed" });
|
||||
expect(api.isOffline(err2)).toBe(false);
|
||||
});
|
||||
|
||||
it("a network failure is offline and the token is fetched again next time", async () => {
|
||||
let down = true;
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
seen.push(String(input));
|
||||
if (down) throw new TypeError("Load failed");
|
||||
if (String(input) === "/api/token") return jsonResponse(200, { token: "cd".repeat(32) });
|
||||
return jsonResponse(200, { up: true });
|
||||
}) as typeof fetch;
|
||||
const err = await api.call("/api/status").catch((e: unknown) => e);
|
||||
expect(api.isOffline(err)).toBe(true);
|
||||
down = false;
|
||||
await expect(api.call("/api/status")).resolves.toEqual({ up: true });
|
||||
expect(seen.filter((u) => u === "/api/token")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeHelper (is the helper there, or is this the offline copy?)", () => {
|
||||
it("true when the token arrives, and the token is kept for the calls after", async () => {
|
||||
const seen = installFetch(() => jsonResponse(200, { ok: true }));
|
||||
expect(await api.probeHelper()).toBe(true);
|
||||
await api.call("/api/status");
|
||||
expect(seen.filter((s) => s.url === "/api/token")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("false when nothing answers, when it is stopping, or when the answer is not the helper's", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}) as typeof fetch;
|
||||
expect(await api.probeHelper()).toBe(false);
|
||||
globalThis.fetch = (async () => jsonResponse(503, { error: "stopping" })) as typeof fetch;
|
||||
expect(await api.probeHelper()).toBe(false);
|
||||
globalThis.fetch = (async () => new Response("<!doctype html><p>router</p>", { status: 200 })) as typeof fetch;
|
||||
expect(await api.probeHelper()).toBe(false);
|
||||
});
|
||||
|
||||
it("true for any other answer from the helper (the page then shows its own errors)", async () => {
|
||||
globalThis.fetch = (async () => jsonResponse(500, { error: "x" })) as typeof fetch;
|
||||
expect(await api.probeHelper()).toBe(true);
|
||||
});
|
||||
|
||||
it("a token read that never answers gives up after TOKEN_TIMEOUT_MS (offline screen, not a blank page)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
globalThis.fetch = ((_u: unknown, init?: RequestInit) =>
|
||||
new Promise<Response>((_res, rej) => {
|
||||
init?.signal?.addEventListener("abort", () => rej(new Error("aborted")));
|
||||
})) as typeof fetch;
|
||||
const p = api.probeHelper();
|
||||
await vi.advanceTimersByTimeAsync(api.TOKEN_TIMEOUT_MS);
|
||||
expect(await p).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The console page's client for the helper's web API.
|
||||
*
|
||||
* Every route is POST + JSON and needs the per-start token in X-PS5U-Token.
|
||||
* The page reads the token once from GET /api/token (same origin; another
|
||||
* site's page cannot read that response). A 401 {"error":"token"} means the
|
||||
* helper restarted and issued a new token: the page must be reloaded, so
|
||||
* onTokenExpired listeners hear about it (the ReloadBanner).
|
||||
*/
|
||||
|
||||
export const TOKEN_HEADER = "X-PS5U-Token";
|
||||
|
||||
/** A failed call. status 0 = the request never got an answer. */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
constructor(status: number, code: string) {
|
||||
super(`${status} ${code}`);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The helper is not answering (network failure) or is shutting down (503 stopping). */
|
||||
export function isOffline(err: unknown): boolean {
|
||||
return err instanceof ApiError && (err.status === 0 || err.code === "stopping");
|
||||
}
|
||||
|
||||
const expiredListeners = new Set<() => void>();
|
||||
|
||||
/** Called on every 401 from the API. Returns the unsubscribe function. */
|
||||
export function onTokenExpired(fn: () => void): () => void {
|
||||
expiredListeners.add(fn);
|
||||
return () => {
|
||||
expiredListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
let tokenPromise: Promise<string> | null = null;
|
||||
|
||||
/** A token read that has not answered by then counts as "nothing answered" (the offline screen). */
|
||||
export const TOKEN_TIMEOUT_MS = 4000;
|
||||
|
||||
async function fetchToken(): Promise<string> {
|
||||
let res: Response;
|
||||
const stop = typeof AbortController === "function" ? new AbortController() : null;
|
||||
const timer = setTimeout(() => stop?.abort(), TOKEN_TIMEOUT_MS);
|
||||
try {
|
||||
res = await fetch("/api/token", { method: "GET", credentials: "same-origin", cache: "no-store", signal: stop?.signal });
|
||||
} catch {
|
||||
throw new ApiError(0, "offline");
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, res.status === 503 ? "stopping" : "token_unavailable");
|
||||
// Anything but JSON (another server on the port, a captive page) is not the helper.
|
||||
const body = (await res.json().catch(() => {
|
||||
throw new ApiError(0, "offline");
|
||||
})) as { token?: unknown } | null;
|
||||
if (!body || typeof body !== "object") throw new ApiError(0, "offline");
|
||||
if (typeof body.token !== "string" || !body.token) throw new ApiError(res.status, "token_unavailable");
|
||||
return body.token;
|
||||
}
|
||||
|
||||
function token(): Promise<string> {
|
||||
if (!tokenPromise) {
|
||||
tokenPromise = fetchToken();
|
||||
// A failed read is not cached: the next call asks again.
|
||||
tokenPromise.catch(() => {
|
||||
tokenPromise = null;
|
||||
});
|
||||
}
|
||||
return tokenPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the helper there? Reads the token (kept for the calls that follow). False only when nothing
|
||||
* answered like the helper (the page came from the browser's offline copy: the service worker or
|
||||
* the appcache) or it is shutting down; any other answer means it is running.
|
||||
*/
|
||||
export async function probeHelper(): Promise<boolean> {
|
||||
try {
|
||||
await token();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return !isOffline(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The ApiError for a reply's status and its {"error":..} body (`http_<status>` without one). */
|
||||
function errorOf(status: number, json: unknown): ApiError {
|
||||
const code =
|
||||
json && typeof (json as { error?: unknown }).error === "string" ? (json as { error: string }).error : `http_${status}`;
|
||||
return new ApiError(status, code);
|
||||
}
|
||||
|
||||
function tokenExpired(): ApiError {
|
||||
for (const fn of Array.from(expiredListeners)) fn();
|
||||
return new ApiError(401, "token");
|
||||
}
|
||||
|
||||
/** POST `body` (JSON, or raw bytes for a streamed route) to `route`; the Response when it is 2xx, else the ApiError. */
|
||||
async function post(route: string, body?: object, signal?: AbortSignal, keepalive?: boolean, raw?: Blob): Promise<Response> {
|
||||
const tok = await token();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(route, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": raw ? "application/octet-stream" : "application/json", [TOKEN_HEADER]: tok },
|
||||
body: raw ?? JSON.stringify(body ?? {}),
|
||||
signal,
|
||||
...(keepalive ? { keepalive: true } : {}),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "offline");
|
||||
}
|
||||
if (res.status === 401) throw tokenExpired();
|
||||
if (!res.ok) throw errorOf(res.status, await res.json().catch(() => null));
|
||||
return res;
|
||||
}
|
||||
|
||||
/** POST `body` as JSON to `route` and return the parsed JSON reply. */
|
||||
export async function call<T>(route: string, body?: object): Promise<T> {
|
||||
const res = await post(route, body);
|
||||
return (await res.json().catch(() => null)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST with `keepalive`: the request is sent even if the page is being hidden or
|
||||
* left right after (the input log's last batch). Bodies stay small (≤ 64 KiB is the
|
||||
* browser's keepalive limit; the input log sends ≤ 4 KiB).
|
||||
*/
|
||||
export async function callKeepalive(route: string, body?: object): Promise<void> {
|
||||
await post(route, body, undefined, true);
|
||||
}
|
||||
|
||||
/** POST `body` as JSON to `route` and return the raw reply (/api/fs/read's bytes). `signal` aborts it. */
|
||||
export async function callBlob(route: string, body?: object, signal?: AbortSignal): Promise<Blob> {
|
||||
const res = await post(route, body, signal);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/** POST raw bytes to a streamed route (/api/fs/save-text) and return the parsed JSON reply. */
|
||||
export async function callRaw<T>(route: string, body: Blob): Promise<T> {
|
||||
const res = await post(route, undefined, undefined, false, body);
|
||||
return (await res.json().catch(() => null)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `body` with XMLHttpRequest (fetch reports no upload progress): `onProgress(sent, total)` as the
|
||||
* bytes leave, `signal` aborts it (ApiError 0 "aborted"). The parsed JSON reply.
|
||||
*/
|
||||
export async function sendWithProgress<T>(
|
||||
route: string,
|
||||
body: Blob,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const tok = await token();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const onAbort = () => xhr.abort();
|
||||
const done = () => signal?.removeEventListener("abort", onAbort);
|
||||
xhr.open("POST", route);
|
||||
xhr.setRequestHeader(TOKEN_HEADER, tok);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded, e.lengthComputable ? e.total : body.size);
|
||||
xhr.onload = () => {
|
||||
done();
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
if (xhr.status === 401) reject(tokenExpired());
|
||||
else if (xhr.status < 200 || xhr.status >= 300) reject(errorOf(xhr.status, json));
|
||||
else resolve(json as T);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
done();
|
||||
reject(new ApiError(0, "offline"));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
done();
|
||||
reject(new ApiError(0, "aborted"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
reject(new ApiError(0, "aborted"));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort);
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACK_ECHO_MS, createBackHistory, isPs5Browser, type HistoryEnv } from "./backHistory";
|
||||
|
||||
/** A browser history in memory: entries, popstate, a clock and a deferred queue. */
|
||||
function fakeEnv(startsOnGuard = false) {
|
||||
const entries: string[] = startsOnGuard ? ["page", "guard"] : ["page"];
|
||||
let pop: (() => void) | null = null;
|
||||
let clock = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
const env: HistoryEnv = {
|
||||
push: vi.fn(() => {
|
||||
entries.push("guard");
|
||||
}),
|
||||
back: vi.fn(() => {
|
||||
// history.back() is async: the popstate comes later.
|
||||
queue.push(() => {
|
||||
entries.pop();
|
||||
pop?.();
|
||||
});
|
||||
}),
|
||||
onPop: (fn) => {
|
||||
pop = fn;
|
||||
return () => {
|
||||
pop = null;
|
||||
};
|
||||
},
|
||||
now: () => clock,
|
||||
defer: (fn) => {
|
||||
queue.push(fn);
|
||||
},
|
||||
startsOnGuard: () => startsOnGuard,
|
||||
};
|
||||
return {
|
||||
env,
|
||||
entries,
|
||||
/** Run everything queued (React commits, async pops) until nothing is left. */
|
||||
flush() {
|
||||
while (queue.length) queue.shift()!();
|
||||
},
|
||||
/** The browser's own Back (the PS5's ○): leaves the page when only "page" is left. */
|
||||
browserBack(): "left" | "stayed" {
|
||||
if (entries.length === 1) return "left";
|
||||
entries.pop();
|
||||
pop?.();
|
||||
return "stayed";
|
||||
},
|
||||
tick(ms: number) {
|
||||
clock += ms;
|
||||
},
|
||||
/** A popstate that moves nothing in history (WebKit fires one as a page loads). */
|
||||
spuriousPop() {
|
||||
pop?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The page: a stack of levels (Files folder, modal...), the back action closes the top one. */
|
||||
function page(f: ReturnType<typeof fakeEnv>) {
|
||||
const open: (() => void)[] = [];
|
||||
const runBack = vi.fn(() => {
|
||||
open.pop()?.();
|
||||
});
|
||||
const h = createBackHistory(f.env, runBack);
|
||||
return {
|
||||
h,
|
||||
runBack,
|
||||
open() {
|
||||
open.push(h.addLevel());
|
||||
f.flush();
|
||||
},
|
||||
/** Closed with an on-screen button, not Back. */
|
||||
closeByButton() {
|
||||
open.pop()?.();
|
||||
f.flush();
|
||||
},
|
||||
depth: () => open.length,
|
||||
};
|
||||
}
|
||||
|
||||
describe("back history", () => {
|
||||
it("one guard while the page has somewhere to go back to, however deep", () => {
|
||||
const f = fakeEnv();
|
||||
const p = page(f);
|
||||
expect(f.entries).toEqual(["page"]);
|
||||
p.open(); // a folder
|
||||
p.open(); // a sheet over it
|
||||
p.open(); // a dialog from the sheet
|
||||
expect(f.entries).toEqual(["page", "guard"]);
|
||||
expect(f.env.push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("○_steps_back_inside_the_page_and_leaves_only_from_the_first_screen", () => {
|
||||
const f = fakeEnv();
|
||||
const p = page(f);
|
||||
p.open();
|
||||
p.open();
|
||||
f.tick(1000);
|
||||
expect(f.browserBack()).toBe("stayed");
|
||||
f.flush();
|
||||
expect(p.runBack).toHaveBeenCalledTimes(1);
|
||||
expect(p.depth()).toBe(1);
|
||||
expect(f.entries).toEqual(["page", "guard"]); // put back: still somewhere to go
|
||||
f.tick(1000);
|
||||
expect(f.browserBack()).toBe("stayed");
|
||||
f.flush();
|
||||
expect(p.depth()).toBe(0);
|
||||
expect(f.entries).toEqual(["page"]); // the first screen: no guard
|
||||
expect(f.browserBack()).toBe("left");
|
||||
expect(p.runBack).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a level closed by a button removes the guard, and that pop runs nothing", () => {
|
||||
const f = fakeEnv();
|
||||
const p = page(f);
|
||||
p.open();
|
||||
p.closeByButton();
|
||||
expect(f.entries).toEqual(["page"]);
|
||||
expect(f.env.back).toHaveBeenCalledTimes(1);
|
||||
expect(p.runBack).not.toHaveBeenCalled();
|
||||
// Closing one of two keeps the guard.
|
||||
p.open();
|
||||
p.open();
|
||||
p.closeByButton();
|
||||
expect(f.entries).toEqual(["page", "guard"]);
|
||||
});
|
||||
|
||||
it("Escape/Backspace take the same path: history.back() when the guard is up", () => {
|
||||
const f = fakeEnv();
|
||||
const p = page(f);
|
||||
p.open();
|
||||
f.tick(1000);
|
||||
p.h.requestBack();
|
||||
expect(f.env.back).toHaveBeenCalledTimes(1);
|
||||
expect(p.runBack).not.toHaveBeenCalled(); // not yet: the popstate runs it
|
||||
f.flush();
|
||||
expect(p.runBack).toHaveBeenCalledTimes(1);
|
||||
expect(f.entries).toEqual(["page"]);
|
||||
// No guard (first screen): the back action runs at once, nothing is popped.
|
||||
p.h.requestBack();
|
||||
expect(p.runBack).toHaveBeenCalledTimes(2);
|
||||
expect(f.env.back).toHaveBeenCalledTimes(1);
|
||||
expect(f.entries).toEqual(["page"]);
|
||||
});
|
||||
|
||||
it("one press reported twice (key and browser Back) steps back once", () => {
|
||||
const f = fakeEnv();
|
||||
const p = page(f);
|
||||
p.open();
|
||||
p.open();
|
||||
f.tick(1000);
|
||||
f.browserBack();
|
||||
f.tick(BACK_ECHO_MS - 1);
|
||||
f.env.push(); // the browser had more history: a second pop arrives at once
|
||||
f.browserBack();
|
||||
f.flush();
|
||||
expect(p.runBack).toHaveBeenCalledTimes(1);
|
||||
expect(p.depth()).toBe(1);
|
||||
expect(f.entries[f.entries.length - 1]).toBe("guard");
|
||||
});
|
||||
|
||||
it("a reload with the guard on top drops it", () => {
|
||||
const f = fakeEnv(true);
|
||||
const p = page(f);
|
||||
f.flush();
|
||||
expect(f.entries).toEqual(["page"]);
|
||||
expect(p.runBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isPs5Browser matches the PS5's user agent only", () => {
|
||||
expect(isPs5Browser("Mozilla/5.0 (PlayStation; PlayStation 5/13.60) AppleWebKit/605.1.15 (KHTML, like Gecko)")).toBe(true);
|
||||
expect(isPs5Browser("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("back history on the PS5 (close at the first screen)", () => {
|
||||
function ps5(graceMs = 0) {
|
||||
const f = fakeEnv();
|
||||
f.env.atGuard = () => f.entries[f.entries.length - 1] === "guard";
|
||||
const onRoot = vi.fn();
|
||||
const runBack = vi.fn();
|
||||
const h = createBackHistory(f.env, runBack, { onRoot, graceMs });
|
||||
f.flush();
|
||||
return { f, h, onRoot, runBack };
|
||||
}
|
||||
|
||||
it("○ on the first screen closes the browser instead of leaving the page", () => {
|
||||
const { f, onRoot, runBack } = ps5();
|
||||
expect(f.entries).toEqual(["page", "guard"]); // a guard even with nothing open
|
||||
f.tick(1000);
|
||||
expect(f.browserBack()).toBe("stayed");
|
||||
f.flush();
|
||||
expect(onRoot).toHaveBeenCalledTimes(1);
|
||||
expect(runBack).not.toHaveBeenCalled();
|
||||
// Still open (close refused): the guard stays down, the next ○ is the browser's own.
|
||||
expect(f.entries).toEqual(["page"]);
|
||||
expect(f.browserBack()).toBe("left");
|
||||
});
|
||||
|
||||
it("a level opened after a refused close puts the first-screen guard back", () => {
|
||||
const { f, h, onRoot } = ps5();
|
||||
f.tick(1000);
|
||||
f.browserBack();
|
||||
f.flush();
|
||||
expect(onRoot).toHaveBeenCalledTimes(1);
|
||||
const close = h.addLevel();
|
||||
f.flush();
|
||||
expect(f.entries).toEqual(["page", "guard"]);
|
||||
close();
|
||||
f.flush();
|
||||
expect(f.entries).toEqual(["page", "guard"]); // kept for the first screen
|
||||
});
|
||||
|
||||
it("closing a level by ○ steps back, and never closes the browser", () => {
|
||||
const { f, h, onRoot, runBack } = ps5();
|
||||
const close = h.addLevel();
|
||||
runBack.mockImplementation(() => close());
|
||||
f.flush();
|
||||
f.tick(1000);
|
||||
f.browserBack();
|
||||
f.flush();
|
||||
expect(runBack).toHaveBeenCalledTimes(1);
|
||||
expect(onRoot).not.toHaveBeenCalled();
|
||||
expect(f.entries).toEqual(["page", "guard"]);
|
||||
});
|
||||
|
||||
it("a popstate that pops nothing (WebKit at load) is ignored", () => {
|
||||
const { f, onRoot, runBack } = ps5();
|
||||
f.tick(1000);
|
||||
f.spuriousPop();
|
||||
f.flush();
|
||||
expect(f.entries).toEqual(["page", "guard"]);
|
||||
expect(onRoot).not.toHaveBeenCalled();
|
||||
expect(runBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("before any key or click, a pop inside the load grace only puts the guard back", () => {
|
||||
const { f, h, onRoot } = ps5(1500);
|
||||
f.tick(500);
|
||||
f.browserBack();
|
||||
f.flush();
|
||||
expect(onRoot).not.toHaveBeenCalled();
|
||||
expect(f.entries).toEqual(["page", "guard"]);
|
||||
// After a key the same pop is real.
|
||||
h.noteInput();
|
||||
f.browserBack();
|
||||
f.flush();
|
||||
expect(onRoot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("after the grace a pop is real even with no key pressed (○ is not a key on the PS5)", () => {
|
||||
const { f, onRoot } = ps5(1500);
|
||||
f.tick(1600);
|
||||
f.browserBack();
|
||||
f.flush();
|
||||
expect(onRoot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("coalesces two Back requests before the asynchronous history pop", () => {
|
||||
const f = fakeEnv();
|
||||
const p = page(f);
|
||||
p.open();
|
||||
p.open();
|
||||
f.tick(2000);
|
||||
p.h.requestBack();
|
||||
p.h.requestBack();
|
||||
expect(f.env.back).toHaveBeenCalledTimes(1);
|
||||
f.flush();
|
||||
expect(p.depth()).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The PS5 browser's ○ is its own Back button: it never reaches the page as a
|
||||
* key (measured on 13.60: only a keyup Escape as the page was left). So every
|
||||
* in-page "back" step — a modal or sheet open, a folder entered in Files, a
|
||||
* section other than Home — is backed by browser history: while the page has
|
||||
* anywhere to go back to, one history entry (the guard) sits on top of the
|
||||
* page's own entry. ○ pops it, `popstate` runs the page's back action (close
|
||||
* the top modal, else up a folder, else rail, else Home), and the guard is put
|
||||
* back while there is still somewhere to go. On the first screen (Home, no
|
||||
* modal) there is no guard, so ○ leaves the page as the browser intends.
|
||||
*
|
||||
* One guard instead of one entry per step: the page also closes modals and
|
||||
* leaves folders with on-screen buttons, and a per-step stack would drift
|
||||
* from the page's real state; a single guard is right whatever the depth.
|
||||
* A level closed by a button with nothing left behind removes the guard
|
||||
* (history.back()), and that pop is ignored.
|
||||
*
|
||||
* Escape, Backspace and a gamepad ○ call requestBack(): history.back() when
|
||||
* the guard is up (so they take exactly the path ○ takes), else the back
|
||||
* action directly.
|
||||
*
|
||||
* On the PS5 (opts.onRoot) the guard stays up on the first screen too, and ○
|
||||
* there calls onRoot (window.close(): the browser goes back to the PS5's home
|
||||
* screen, as Payload Manager does). Escape, Backspace and a gamepad ○ take
|
||||
* the same path (requestBack → history.back()), so on the PS5 they too close
|
||||
* the browser from Home with nothing open. If the page is still open after that, the
|
||||
* guard is left down so the next ○ does what the browser does by itself.
|
||||
*
|
||||
* WebKit can fire a popstate as the page loads that pops nothing. Such a pop
|
||||
* leaves history on the guard entry (atGuard) and is ignored; and for
|
||||
* opts.graceMs after start, before any key or click, every pop only puts the
|
||||
* guard back (Payload Manager's userNavigated flag + 1.5 s grace), so neither
|
||||
* a tour opened at load nor the page itself is closed by it.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** A second pop this soon after a back action is the same press reported twice: ignored. */
|
||||
export const BACK_ECHO_MS = 150;
|
||||
|
||||
export interface HistoryEnv {
|
||||
/** history.pushState(guard) */
|
||||
push(): void;
|
||||
/** history.back() */
|
||||
back(): void;
|
||||
/** Listen for popstate; returns the unsubscribe function. */
|
||||
onPop(fn: () => void): () => void;
|
||||
now(): number;
|
||||
/** Run `fn` soon (after the current React commit). */
|
||||
defer(fn: () => void): void;
|
||||
/** The page loaded with the guard on top (a reload while it was up). */
|
||||
startsOnGuard(): boolean;
|
||||
/** History's current entry is the guard (a popstate that popped nothing). Optional. */
|
||||
atGuard?(): boolean;
|
||||
}
|
||||
|
||||
export interface BackHistoryOptions {
|
||||
/** ○ on the first screen (no level open): the guard stays up there and this runs instead. */
|
||||
onRoot?: () => void;
|
||||
/** For this long after start, until noteInput(), a pop only puts the guard back. */
|
||||
graceMs?: number;
|
||||
}
|
||||
|
||||
export interface BackHistory {
|
||||
/** One more place to go back from; call the returned function when it closes. */
|
||||
addLevel(): () => void;
|
||||
/** Escape / Backspace / gamepad ○: the same path as the browser's Back. */
|
||||
requestBack(): void;
|
||||
/** The guard entry is on top (tests). */
|
||||
hasGuard(): boolean;
|
||||
/** The user pressed a key or clicked: pops are real from now on (ends the grace). */
|
||||
noteInput(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createBackHistory(env: HistoryEnv, runBack: () => void, opts: BackHistoryOptions = {}): BackHistory {
|
||||
let levels = 0;
|
||||
let guard = env.startsOnGuard();
|
||||
let ignorePops = 0;
|
||||
let backPending = false;
|
||||
let lastBackAt = -Infinity;
|
||||
let scheduled = false;
|
||||
const startedAt = env.now();
|
||||
const graceMs = opts.graceMs ?? 0;
|
||||
let userInput = false;
|
||||
/** onRoot ran and the page is still here: no guard on the first screen until a level opens. */
|
||||
let rootTried = false;
|
||||
|
||||
const wantGuard = () => levels > 0 || (!!opts.onRoot && !rootTried);
|
||||
const sync = () => {
|
||||
scheduled = false;
|
||||
if (wantGuard() && !guard) {
|
||||
env.push();
|
||||
guard = true;
|
||||
} else if (!wantGuard() && guard) {
|
||||
guard = false;
|
||||
ignorePops++;
|
||||
env.back();
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
env.defer(sync);
|
||||
};
|
||||
|
||||
const off = env.onPop(() => {
|
||||
backPending = false;
|
||||
if (env.atGuard?.()) {
|
||||
guard = true; // nothing was popped (WebKit's load-time popstate)
|
||||
return;
|
||||
}
|
||||
guard = false; // the guard entry was just consumed
|
||||
if (ignorePops > 0) {
|
||||
ignorePops--;
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
const now = env.now();
|
||||
if (!userInput && now - startedAt < graceMs) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
if (now - lastBackAt < BACK_ECHO_MS) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
lastBackAt = now;
|
||||
if (levels === 0 && opts.onRoot) {
|
||||
rootTried = true;
|
||||
opts.onRoot();
|
||||
return;
|
||||
}
|
||||
runBack();
|
||||
schedule();
|
||||
});
|
||||
if (guard || wantGuard()) schedule();
|
||||
|
||||
return {
|
||||
addLevel() {
|
||||
levels++;
|
||||
rootTried = false;
|
||||
schedule();
|
||||
let closed = false;
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
levels--;
|
||||
schedule();
|
||||
};
|
||||
},
|
||||
requestBack() {
|
||||
// Keyboard and native browser Back can describe the same press before popstate.
|
||||
if (backPending) return;
|
||||
userInput = true;
|
||||
if (guard && ignorePops === 0) {
|
||||
backPending = true;
|
||||
env.back();
|
||||
return;
|
||||
}
|
||||
lastBackAt = env.now();
|
||||
runBack();
|
||||
schedule();
|
||||
},
|
||||
hasGuard: () => guard,
|
||||
noteInput() {
|
||||
userInput = true;
|
||||
},
|
||||
dispose: off,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- the page's instance ---- */
|
||||
|
||||
const GUARD = "cwBack";
|
||||
let instance: BackHistory | null = null;
|
||||
|
||||
function isGuardState(): boolean {
|
||||
const st: unknown = window.history.state;
|
||||
return !!st && typeof st === "object" && (st as Record<string, unknown>)[GUARD] === 1;
|
||||
}
|
||||
|
||||
/** WebKit's load-time popstate is ignored for this long (until a key or click). */
|
||||
export const LOAD_GRACE_MS = 1500;
|
||||
|
||||
/** The PS5's own browser (its ○ leaves the page; window.close() returns to the home screen). */
|
||||
export function isPs5Browser(ua: string = typeof navigator !== "undefined" ? navigator.userAgent : ""): boolean {
|
||||
return /PlayStation/i.test(ua);
|
||||
}
|
||||
|
||||
/** Close the browser (PS5: back to the home screen). Never throws. */
|
||||
function closeWindow(): void {
|
||||
try {
|
||||
window.close();
|
||||
} catch {
|
||||
/* not closable: the page stays */
|
||||
}
|
||||
}
|
||||
|
||||
let offInput: (() => void) | null = null;
|
||||
|
||||
/** Install on the real window (main.tsx). `runBack` is the page's back action. */
|
||||
export function installBackHistory(runBack: () => void): BackHistory {
|
||||
instance?.dispose();
|
||||
offInput?.();
|
||||
instance = createBackHistory(
|
||||
{
|
||||
push: () => window.history.pushState({ [GUARD]: 1 }, ""),
|
||||
back: () => window.history.back(),
|
||||
onPop: (fn) => {
|
||||
window.addEventListener("popstate", fn);
|
||||
return () => window.removeEventListener("popstate", fn);
|
||||
},
|
||||
now: () => performance.now(),
|
||||
defer: (fn) => {
|
||||
setTimeout(fn, 0);
|
||||
},
|
||||
startsOnGuard: isGuardState,
|
||||
atGuard: isGuardState,
|
||||
},
|
||||
runBack,
|
||||
{ onRoot: isPs5Browser() ? closeWindow : undefined, graceMs: LOAD_GRACE_MS },
|
||||
);
|
||||
const h = instance;
|
||||
const note = () => h.noteInput();
|
||||
window.addEventListener("keydown", note, true);
|
||||
window.addEventListener("mousedown", note, true);
|
||||
window.addEventListener("touchstart", note, true);
|
||||
offInput = () => {
|
||||
window.removeEventListener("keydown", note, true);
|
||||
window.removeEventListener("mousedown", note, true);
|
||||
window.removeEventListener("touchstart", note, true);
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Escape / Backspace / gamepad ○ (nav.ts): history.back() when installed, else `fallback`. */
|
||||
export function requestBack(fallback: () => void): void {
|
||||
if (instance) instance.requestBack();
|
||||
else fallback();
|
||||
}
|
||||
|
||||
/** While `on`, this component is a place the browser's Back returns from. No-op until installed. */
|
||||
export function useBackLevel(on: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!on || !instance) return;
|
||||
return instance.addLevel();
|
||||
}, [on]);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../api", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("../api")>();
|
||||
return { ...real, call: vi.fn() };
|
||||
});
|
||||
|
||||
import { ApiError, call } from "../api";
|
||||
import { SECTIONS } from "../sections";
|
||||
import { t } from "../strings";
|
||||
import { CheatsView, finderClosesFor, focusedCheatIndex, type CheatsViewProps } from "./Cheats";
|
||||
import {
|
||||
cheatsPhase,
|
||||
createCheatsController,
|
||||
EMPTY_STATE,
|
||||
readList,
|
||||
readStatus,
|
||||
toggleErrorText,
|
||||
type CheatMod,
|
||||
type CheatsState,
|
||||
} from "./model";
|
||||
|
||||
const mockCall = vi.mocked(call);
|
||||
|
||||
const GAME = "PPSA01650";
|
||||
const mod = (over: Partial<CheatMod> = {}): CheatMod => ({
|
||||
index: 0,
|
||||
name: "Infinite HP",
|
||||
desc: "",
|
||||
button: false,
|
||||
on: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A fake helper: status says `game` runs; list answers `mods`; toggle flips in place. */
|
||||
function helper(opts: { game: string | null; mods?: CheatMod[]; toggleError?: ApiError; engine?: boolean }) {
|
||||
const h = { ...opts, mods: (opts.mods ?? []).map((m) => ({ ...m })), calls: [] as [string, unknown][] };
|
||||
mockCall.mockImplementation(async (route: string, body?: object) => {
|
||||
h.calls.push([route, body]);
|
||||
if (route === "/api/cheats/status") return { engine: h.engine ?? false, game: h.game ? { title_id: h.game } : null } as never;
|
||||
if (route === "/api/cheats/list") return { mods: h.mods, truncated: false } as never;
|
||||
if (route === "/api/cheats/toggle") {
|
||||
if (h.toggleError) throw h.toggleError;
|
||||
const b = body as { index: number; on: boolean };
|
||||
const m = h.mods.find((x) => x.index === b.index)!;
|
||||
if (!m.button) m.on = b.on;
|
||||
return { ok: true, on: b.on } as never;
|
||||
}
|
||||
throw new Error(`unexpected ${route}`);
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
const view = (state: Partial<CheatsState>, over: Partial<CheatsViewProps> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<CheatsView state={{ ...EMPTY_STATE, ...state }} onToggle={() => {}} onRefresh={() => {}} {...over} />,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockCall.mockReset();
|
||||
});
|
||||
|
||||
describe("cheats section", () => {
|
||||
it("is_registered_between_files_and_settings", () => {
|
||||
expect(SECTIONS.map((s) => s.id)).toEqual(["home", "files", "cheats", "settings"]);
|
||||
expect(SECTIONS.find((s) => s.id === "cheats")?.labelKey).toBe("cw_cheats_title");
|
||||
});
|
||||
|
||||
it("no_game_says_what_to_do", async () => {
|
||||
helper({ game: null });
|
||||
const states: CheatsState[] = [];
|
||||
const c = createCheatsController((s) => states.push(s));
|
||||
await c.loadStatus();
|
||||
expect(cheatsPhase(c.get())).toEqual({ kind: "no_game" });
|
||||
const html = view(c.get());
|
||||
expect(html).toContain(t("cw_cheats_no_game"));
|
||||
expect(html).toContain(t("cw_cheats_no_game_next"));
|
||||
expect(t("cw_cheats_no_game_next")).toBe("Start a game, then open Cheats.");
|
||||
expect(html).not.toContain("data-cheat-index");
|
||||
// No list read without a game.
|
||||
expect(mockCall.mock.calls.map((x) => x[0])).toEqual(["/api/cheats/status"]);
|
||||
});
|
||||
|
||||
it("no_cheats_offers_find_and_the_pc_app", async () => {
|
||||
helper({ game: GAME, mods: [] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
expect(cheatsPhase(c.get())).toEqual({ kind: "none", title: GAME });
|
||||
const html = view(c.get(), { onFind: () => {} });
|
||||
expect(html).toContain(t("cw_cheats_none"));
|
||||
expect(t("cw_cheats_none")).toBe("No cheats for this game yet.");
|
||||
expect(html).toContain(t("cw_cheats_none_next"));
|
||||
expect(t("cw_cheats_none_next")).toBe("Find cheats for it online, or add them from the PS5Upload app on your PC.");
|
||||
expect(html).toContain(GAME);
|
||||
expect(html).toContain('data-action="cheats-find"');
|
||||
expect(html).toContain(t("cw_cheats_find"));
|
||||
expect(html).not.toContain(t("cw_cheats_find_more"));
|
||||
// Without a finder (no game known) there is no Find button.
|
||||
expect(view(c.get())).not.toContain('data-action="cheats-find"');
|
||||
});
|
||||
|
||||
it("a_list_offers_find_more_and_shows_a_finished_download", async () => {
|
||||
helper({ game: GAME, mods: [mod()] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
const html = view(c.get(), { onFind: () => {}, notice: "saved it" });
|
||||
expect(html).toContain(t("cw_cheats_find_more"));
|
||||
expect(html).toContain("saved it");
|
||||
// no game: no Find
|
||||
expect(view({ ...EMPTY_STATE, status: { engine: false, game: null } }, { onFind: () => {} })).not.toContain(
|
||||
'data-action="cheats-find"',
|
||||
);
|
||||
});
|
||||
|
||||
it("lists_the_running_games_cheats_as_switches", async () => {
|
||||
helper({ game: GAME, mods: [mod({ index: 0, on: true }), mod({ index: 3, name: "Max money", desc: "999999" }), mod({ index: 4, name: "Heal", button: true })] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
expect(mockCall).toHaveBeenCalledWith("/api/cheats/list", { title_id: GAME });
|
||||
const html = view(c.get());
|
||||
expect(html).toContain('role="switch" aria-checked="true"');
|
||||
expect(html).toContain('data-cheat-index="3"');
|
||||
expect(html).toContain("999999");
|
||||
// A one-shot cheat is an Apply button, not a switch.
|
||||
expect(html).toMatch(/<button type="button" class="[^"]*cw-cheat"[^>]*data-cheat-index="4"/);
|
||||
expect(html).toContain(t("cw_cheats_apply"));
|
||||
// The engine is off: the header says a switch-on starts it.
|
||||
expect(html).toContain(t("cw_cheats_engine_off"));
|
||||
});
|
||||
|
||||
it("toggle_calls_the_route_and_reflects_the_result", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod({ index: 0 }), mod({ index: 1, name: "B" })] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
const p = c.toggle(c.get().list!.mods[1]);
|
||||
// In flight: every row is inert, the pressed one says so.
|
||||
expect(c.get().busy).toBe(1);
|
||||
const busyHtml = view(c.get());
|
||||
expect(busyHtml.match(/aria-disabled="true"/g)).toHaveLength(2);
|
||||
expect(busyHtml).toContain(t("cw_cheats_switching"));
|
||||
// A second press meanwhile is ignored.
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
await p;
|
||||
expect(h.calls.filter((x) => x[0] === "/api/cheats/toggle")).toEqual([
|
||||
["/api/cheats/toggle", { title_id: GAME, index: 1, on: true }],
|
||||
]);
|
||||
// Re-read after the toggle: the helper's state is what shows.
|
||||
expect(h.calls[h.calls.length - 1][0]).toBe("/api/cheats/list");
|
||||
expect(c.get().busy).toBeNull();
|
||||
expect(c.get().list!.mods.find((m) => m.index === 1)?.on).toBe(true);
|
||||
expect(view(c.get())).not.toContain("aria-disabled");
|
||||
// And off again.
|
||||
await c.toggle(c.get().list!.mods[1]);
|
||||
expect(h.calls.filter((x) => x[0] === "/api/cheats/toggle")[1][1]).toEqual({ title_id: GAME, index: 1, on: false });
|
||||
expect(c.get().list!.mods[1].on).toBe(false);
|
||||
});
|
||||
|
||||
it("a_one_shot_cheat_is_always_applied_on", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod({ index: 2, button: true })] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
expect(h.calls.filter((x) => x[0] === "/api/cheats/toggle").map((x) => (x[1] as { on: boolean }).on)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("game_exited_while_open_drops_the_switches", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod()] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
expect(cheatsPhase(c.get()).kind).toBe("list");
|
||||
h.game = null;
|
||||
await c.loadStatus();
|
||||
expect(cheatsPhase(c.get())).toEqual({ kind: "exited", title: GAME });
|
||||
expect(c.get().list).toBeNull();
|
||||
const html = view(c.get());
|
||||
expect(html).toContain(t("cw_cheats_exited"));
|
||||
expect(html).not.toContain("data-cheat-index");
|
||||
expect(html).toContain('data-action="cheats-refresh"');
|
||||
});
|
||||
|
||||
it("toggle_after_the_game_closed_shows_closed_not_an_error", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod()] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
h.game = null;
|
||||
h.toggleError = new ApiError(409, "game_not_running");
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
expect(c.get().message).toBeNull();
|
||||
expect(cheatsPhase(c.get()).kind).toBe("exited");
|
||||
});
|
||||
|
||||
it("toggle_right_after_the_game_exited_says_closed_at_once", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod()] });
|
||||
const states: CheatsState[] = [];
|
||||
const c = createCheatsController((st) => states.push(st));
|
||||
await c.loadStatus();
|
||||
h.game = null;
|
||||
h.toggleError = new ApiError(409, "game_not_running");
|
||||
let statusAsked = -1;
|
||||
const real = mockCall.getMockImplementation()!;
|
||||
mockCall.mockImplementation(async (route: string, body?: object) => {
|
||||
if (route === "/api/cheats/status" && statusAsked < 0) statusAsked = states.length;
|
||||
return real(route, body);
|
||||
});
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
// "Closed" was on screen before the status was read again (no wait for the next poll).
|
||||
expect(statusAsked).toBeGreaterThan(0);
|
||||
expect(states.slice(0, statusAsked).some((st) => cheatsPhase(st).kind === "exited")).toBe(true);
|
||||
expect(cheatsPhase(c.get()).kind).toBe("exited");
|
||||
});
|
||||
|
||||
it("a_toggle_racing_the_pc_app_says_try_again", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod()] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
h.toggleError = new ApiError(409, "game_busy");
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
expect(c.get().message).toBe(t("cw_cheats_err_busy"));
|
||||
expect(cheatsPhase(c.get()).kind).toBe("list");
|
||||
});
|
||||
|
||||
it("another_game_never_shows_the_old_games_cheats", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod({ name: "Old" })] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
// The list read for the new game is slow: nothing of the old game shows meanwhile.
|
||||
h.game = "CUSA07842";
|
||||
let release!: () => void;
|
||||
mockCall.mockImplementationOnce(async () => ({ engine: false, game: { title_id: "CUSA07842" } }) as never);
|
||||
mockCall.mockImplementationOnce(
|
||||
() => new Promise((res) => (release = () => res({ mods: [mod({ name: "New" })], truncated: false } as never))),
|
||||
);
|
||||
const p = c.loadStatus();
|
||||
await Promise.resolve();
|
||||
expect(cheatsPhase(c.get()).kind).toBe("loading");
|
||||
expect(view(c.get())).not.toContain("Old");
|
||||
release();
|
||||
await p;
|
||||
expect(view(c.get())).toContain("New");
|
||||
});
|
||||
|
||||
it("helper_errors_are_short_and_retryable", async () => {
|
||||
mockCall.mockRejectedValue(new ApiError(500, "oom"));
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
const ph = cheatsPhase(c.get());
|
||||
expect(ph.kind).toBe("error");
|
||||
const html = view(c.get());
|
||||
expect(html).toContain("oom");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('data-action="cheats-refresh"');
|
||||
// Offline reads as offline; a 401 is left to the reload banner.
|
||||
expect(toggleErrorText(new ApiError(0, "offline"))).toBe(t("cw_home_err_offline"));
|
||||
expect(toggleErrorText(new ApiError(401, "token"))).toBeNull();
|
||||
expect(toggleErrorText(new ApiError(409, "module_missing"))).toBe(t("cw_cheats_err_module"));
|
||||
expect(toggleErrorText(new ApiError(400, "bad_index"))).toBe(t("cw_cheats_err_changed"));
|
||||
expect(toggleErrorText(new ApiError(500, "toggle_failed"))).toContain("toggle_failed");
|
||||
});
|
||||
|
||||
it("toggle_failure_keeps_the_list_and_says_why", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod()] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
h.toggleError = new ApiError(409, "module_missing");
|
||||
await c.toggle(c.get().list!.mods[0]);
|
||||
expect(c.get().message).toBe(t("cw_cheats_err_module"));
|
||||
expect(cheatsPhase(c.get()).kind).toBe("list");
|
||||
expect(c.get().list!.mods[0].on).toBe(false);
|
||||
expect(view(c.get())).toContain(t("cw_cheats_err_module"));
|
||||
});
|
||||
|
||||
it("refresh_rereads_status_and_list", async () => {
|
||||
const h = helper({ game: GAME, mods: [mod()] });
|
||||
const c = createCheatsController(() => {});
|
||||
await c.loadStatus();
|
||||
h.calls.length = 0;
|
||||
h.mods[0].on = true; // the PC switched it meanwhile
|
||||
await c.refresh();
|
||||
expect(h.calls.map((x) => x[0])).toEqual(["/api/cheats/status", "/api/cheats/list"]);
|
||||
expect(c.get().list!.mods[0].on).toBe(true);
|
||||
});
|
||||
|
||||
it("disposed_controller_stops_reporting", async () => {
|
||||
helper({ game: GAME, mods: [mod()] });
|
||||
const seen = vi.fn();
|
||||
const c = createCheatsController(seen);
|
||||
c.dispose();
|
||||
await c.loadStatus();
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replies_are_checked", () => {
|
||||
expect(readStatus(null)).toEqual({ engine: false, game: null });
|
||||
expect(readStatus({ engine: true, game: { title_id: GAME } })).toEqual({ engine: true, game: { title_id: GAME } });
|
||||
expect(readStatus({ engine: "yes", game: { title_id: 5 } })).toEqual({ engine: false, game: null });
|
||||
const l = readList(GAME, { mods: [{ index: 1, name: "A", on: true }, { index: "2", name: "B" }, { index: 3 }, null], truncated: true });
|
||||
expect(l).toEqual({ title: GAME, mods: [{ index: 1, name: "A", desc: "", button: false, on: true }], truncated: true });
|
||||
expect(readList(GAME, "nope")).toEqual({ title: GAME, mods: [], truncated: false });
|
||||
});
|
||||
|
||||
it("square_targets_the_focused_cheat_only", () => {
|
||||
const row = { closest: () => ({ getAttribute: () => "7" }) } as unknown as Element;
|
||||
const root = { contains: (el: Element) => el === row } as unknown as Element;
|
||||
expect(focusedCheatIndex(root, row)).toBe(7);
|
||||
const outside = { closest: () => ({ getAttribute: () => "7" }) } as unknown as Element;
|
||||
expect(focusedCheatIndex(root, outside)).toBeNull();
|
||||
const notACheat = { closest: () => null } as unknown as Element;
|
||||
const root2 = { contains: () => true } as unknown as Element;
|
||||
expect(focusedCheatIndex(root2, notACheat)).toBeNull();
|
||||
expect(focusedCheatIndex(null, row)).toBeNull();
|
||||
});
|
||||
|
||||
it("the_finder_closes_only_when_its_game_is_confirmed_gone", () => {
|
||||
expect(finderClosesFor(GAME, GAME, 0)).toBe(false);
|
||||
expect(finderClosesFor(GAME, "CUSA00001", 0)).toBe(true);
|
||||
// one empty status read is not enough (a transient answer); two in a row are
|
||||
expect(finderClosesFor(GAME, null, 1)).toBe(false);
|
||||
expect(finderClosesFor(GAME, null, 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Gamepad2, RefreshCw, Search } from "lucide-react";
|
||||
import { useAppCover } from "../home/Home";
|
||||
import { onNavAction } from "../nav";
|
||||
import { t } from "../strings";
|
||||
import { pollWhileVisible } from "../ui/visiblePoll";
|
||||
import { createFinder, FINDER_CLOSED, savedText, type Finder, type FinderState } from "./finder";
|
||||
import { FindCheats } from "./FindCheats";
|
||||
import {
|
||||
cheatsPhase,
|
||||
createCheatsController,
|
||||
EMPTY_STATE,
|
||||
type CheatMod,
|
||||
type CheatsController,
|
||||
type CheatsState,
|
||||
} from "./model";
|
||||
|
||||
/** The running game is looked for this often while Cheats is open and the page is visible. */
|
||||
export const CHEATS_POLL_MS = 5_000;
|
||||
|
||||
export interface CheatsViewProps {
|
||||
state: CheatsState;
|
||||
/** The running game's cover (an object URL), or null (the generic icon shows). */
|
||||
cover?: string | null;
|
||||
onCoverError?: () => void;
|
||||
onToggle: (mod: CheatMod) => void;
|
||||
onRefresh: () => void;
|
||||
/** Find cheats online for the running game (absent: no Find button). */
|
||||
onFind?: () => void;
|
||||
/** A finished download, one line (shown until the section changes). */
|
||||
notice?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cheat: a switch (✕ clicks it, □ switches the focused one), or Apply for a one-shot cheat.
|
||||
* While any switch runs the rows are aria-disabled, not disabled: a disabled button drops the
|
||||
* focus ring to nowhere, and the controller (model.toggle) ignores presses meanwhile anyway.
|
||||
*/
|
||||
function CheatRow({ mod, busy, disabled, onToggle }: { mod: CheatMod; busy: boolean; disabled: boolean; onToggle: (m: CheatMod) => void }) {
|
||||
let tag: string;
|
||||
if (busy) tag = t("cw_cheats_switching");
|
||||
else if (mod.button) tag = t("cw_cheats_apply");
|
||||
else tag = t(mod.on ? "cw_cheats_on" : "cw_cheats_off");
|
||||
return (
|
||||
<li className="cw-cheat-item">
|
||||
<button
|
||||
type="button"
|
||||
role={mod.button ? undefined : "switch"}
|
||||
aria-checked={mod.button ? undefined : mod.on}
|
||||
aria-busy={busy || undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={`cw-focusable cw-button cw-cheat${mod.on && !mod.button ? " is-on" : ""}`}
|
||||
data-cheat-index={mod.index}
|
||||
onClick={() => onToggle(mod)}
|
||||
>
|
||||
<span className="cw-cheat-text">
|
||||
<span className="cw-cheat-name">{mod.name}</span>
|
||||
{mod.desc ? <span className="cw-dim cw-cheat-desc">{mod.desc}</span> : null}
|
||||
</span>
|
||||
<span className={`cw-tag${mod.on && !mod.button ? " is-on" : ""}`}>{tag}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
|
||||
return (
|
||||
<button type="button" className="cw-focusable cw-button" data-action="cheats-refresh" onClick={onRefresh}>
|
||||
<RefreshCw aria-hidden />
|
||||
<span>{t("cw_cheats_refresh")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FindButton({ onFind, more }: { onFind: () => void; more?: boolean }) {
|
||||
return (
|
||||
<button type="button" className={`cw-focusable cw-button${more ? "" : " is-primary"}`} data-action="cheats-find" onClick={onFind}>
|
||||
<Search aria-hidden />
|
||||
<span>{t(more ? "cw_cheats_find_more" : "cw_cheats_find")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** An empty state: what is true, what to do next, and Refresh (Find first when given). */
|
||||
function Empty({
|
||||
title,
|
||||
next,
|
||||
onRefresh,
|
||||
onFind,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
next?: string;
|
||||
onRefresh: () => void;
|
||||
onFind?: () => void;
|
||||
tone?: "error";
|
||||
}) {
|
||||
return (
|
||||
<div className={`cw-card cw-cheats-empty${tone === "error" ? " is-error" : ""}`} role={tone === "error" ? "alert" : "status"}>
|
||||
<p className="cw-cheats-empty-title">{title}</p>
|
||||
{next ? <p className="cw-dim">{next}</p> : null}
|
||||
<div className="cw-cheats-empty-buttons">
|
||||
{onFind ? <FindButton onFind={onFind} /> : null}
|
||||
<RefreshButton onRefresh={onRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cheats without state: everything comes in through props (tests render it directly). */
|
||||
export function CheatsView(p: CheatsViewProps) {
|
||||
const phase = cheatsPhase(p.state);
|
||||
const s = p.state;
|
||||
const title = phase.kind === "list" || phase.kind === "none" ? phase.title : null;
|
||||
|
||||
let body;
|
||||
switch (phase.kind) {
|
||||
case "loading":
|
||||
body = <p className="cw-dim">{t("cw_cheats_loading")}</p>;
|
||||
break;
|
||||
case "error":
|
||||
body = <Empty title={phase.text} onRefresh={p.onRefresh} tone="error" />;
|
||||
break;
|
||||
case "no_game":
|
||||
body = <Empty title={t("cw_cheats_no_game")} next={t("cw_cheats_no_game_next")} onRefresh={p.onRefresh} />;
|
||||
break;
|
||||
case "exited":
|
||||
body = <Empty title={t("cw_cheats_exited")} next={t("cw_cheats_exited_next")} onRefresh={p.onRefresh} />;
|
||||
break;
|
||||
case "none":
|
||||
body = <Empty title={t("cw_cheats_none")} next={t("cw_cheats_none_next")} onRefresh={p.onRefresh} onFind={p.onFind} />;
|
||||
break;
|
||||
case "list":
|
||||
body = (
|
||||
<>
|
||||
<ul className="cw-cheats">
|
||||
{phase.list.mods.map((m) => (
|
||||
<CheatRow key={m.index} mod={m} busy={s.busy === m.index} disabled={s.busy !== null} onToggle={p.onToggle} />
|
||||
))}
|
||||
</ul>
|
||||
{phase.list.truncated ? <p className="cw-dim cw-cheats-note">{t("cw_cheats_truncated")}</p> : null}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cw-cheats-section">
|
||||
<h1 className="cw-h1">{t("cw_cheats_title")}</h1>
|
||||
{title ? (
|
||||
<section className="cw-card cw-game" aria-label={t("cw_home_now_running")}>
|
||||
{p.cover ? (
|
||||
<img className="cw-game-cover" src={p.cover} alt="" onError={p.onCoverError} />
|
||||
) : (
|
||||
<Gamepad2 className="cw-game-icon" aria-hidden />
|
||||
)}
|
||||
<div className="cw-game-text">
|
||||
<div className="cw-dim">{t("cw_home_now_running")}</div>
|
||||
<div className="cw-game-title">{title}</div>
|
||||
<div className="cw-dim cw-cheats-how">
|
||||
{s.status && !s.status.engine ? t("cw_cheats_engine_off") : t("cw_cheats_how")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cw-cheats-game-buttons">
|
||||
{phase.kind === "list" && p.onFind ? <FindButton onFind={p.onFind} more /> : null}
|
||||
<RefreshButton onRefresh={p.onRefresh} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{p.notice ? (
|
||||
<div className="cw-msg is-info" role="status">
|
||||
{p.notice}
|
||||
</div>
|
||||
) : null}
|
||||
{s.message ? (
|
||||
<div className="cw-msg is-error" role="alert">
|
||||
{s.message}
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Does a finder open for `finderTitle` close, with `running` the game the last status read reported
|
||||
* (null: none) and `noGameReads` the status reads in a row that reported none? */
|
||||
export function finderClosesFor(finderTitle: string, running: string | null, noGameReads: number): boolean {
|
||||
if (running) return running !== finderTitle;
|
||||
return noGameReads >= 2;
|
||||
}
|
||||
|
||||
/** □ on a focused cheat inside `root`: that cheat's index, or null. */
|
||||
export function focusedCheatIndex(root: Element | null, active: Element | null): number | null {
|
||||
if (!root || !active || !root.contains(active)) return null;
|
||||
const el = active.closest("[data-cheat-index]");
|
||||
if (!el) return null;
|
||||
const n = Number(el.getAttribute("data-cheat-index"));
|
||||
return Number.isInteger(n) ? n : null;
|
||||
}
|
||||
|
||||
/** The Cheats section: the running game's cheats, each switched on or off from the controller. */
|
||||
export function Cheats() {
|
||||
const [state, setState] = useState<CheatsState>(EMPTY_STATE);
|
||||
const ctl = useRef<CheatsController | null>(null);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const [finderState, setFinderState] = useState<FinderState>(FINDER_CLOSED);
|
||||
const finder = useRef<Finder | null>(null);
|
||||
const [notice, setNotice] = useState<{ title: string; text: string } | null>(null);
|
||||
|
||||
// The finder: a stored file is read at once (the game's cheats again) and closes the finder that
|
||||
// asked for it, never one opened since (another game, or opened again).
|
||||
useEffect(() => {
|
||||
finder.current = createFinder(setFinderState, ({ entry, title, replaced, current }) => {
|
||||
const running = ctl.current?.get().status?.game?.title_id ?? null;
|
||||
if (running !== title) return;
|
||||
setNotice({ title, text: savedText(entry.filename, replaced) });
|
||||
if (current) finder.current?.close();
|
||||
void ctl.current?.loadList();
|
||||
});
|
||||
return () => {
|
||||
finder.current?.close();
|
||||
finder.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Opening the section reads at once; then the status every few seconds while the page shows.
|
||||
// Coming back to the page (it was hidden) reads the cheats again too: the PC may have changed them.
|
||||
useEffect(() => {
|
||||
const c = createCheatsController(setState);
|
||||
ctl.current = c;
|
||||
const stop = pollWhileVisible(() => void c.loadStatus(), CHEATS_POLL_MS);
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) void c.loadList();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
c.dispose();
|
||||
ctl.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create re-reads (App's fallback still re-reads the console status); □ switches the focused cheat.
|
||||
useEffect(
|
||||
() =>
|
||||
onNavAction(
|
||||
(a) => {
|
||||
const c = ctl.current;
|
||||
if (!c) return false;
|
||||
if (a === "refresh") {
|
||||
void c.refresh();
|
||||
return false;
|
||||
}
|
||||
if (a === "select") {
|
||||
const index = focusedCheatIndex(root.current, document.activeElement);
|
||||
if (index == null) return false;
|
||||
const mod = c.get().list?.mods.find((m) => m.index === index);
|
||||
if (mod) void c.toggle(mod);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => root.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const titleId = state.status?.game?.title_id ?? null;
|
||||
// The finder goes when its game is confirmed gone: another game reported running, or no game on two
|
||||
// status reads in a row. A status not read yet (or a failed read, which keeps the last one) or a
|
||||
// single empty read keeps it open.
|
||||
const noGameReads = useRef(0);
|
||||
const status = state.status;
|
||||
useEffect(() => {
|
||||
if (!status) return;
|
||||
noGameReads.current = status.game ? 0 : noGameReads.current + 1;
|
||||
}, [status]);
|
||||
useEffect(() => {
|
||||
if (finderState.kind === "closed" || !status) return;
|
||||
const now = status.game?.title_id ?? null;
|
||||
if (finderClosesFor(finderState.title, now, noGameReads.current)) finder.current?.close();
|
||||
}, [status, finderState]);
|
||||
const loadedCover = useAppCover(titleId);
|
||||
const [coverFailed, setCoverFailed] = useState<string | null>(null);
|
||||
const cover = loadedCover && coverFailed !== loadedCover ? loadedCover : null;
|
||||
|
||||
return (
|
||||
<div ref={root}>
|
||||
<CheatsView
|
||||
state={state}
|
||||
cover={cover}
|
||||
onCoverError={() => setCoverFailed(loadedCover)}
|
||||
onToggle={(m) => void ctl.current?.toggle(m)}
|
||||
onRefresh={() => void ctl.current?.refresh()}
|
||||
onFind={
|
||||
titleId
|
||||
? () => {
|
||||
setNotice(null);
|
||||
void finder.current?.open(titleId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
notice={notice && notice.title === titleId ? notice.text : null}
|
||||
/>
|
||||
<FindCheats
|
||||
state={finderState}
|
||||
onDownload={(e) => void finder.current?.download(e)}
|
||||
onRetry={() => {
|
||||
if (finderState.kind !== "closed") void finder.current?.open(finderState.title);
|
||||
}}
|
||||
onClose={() => finder.current?.close()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect } from "react";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
import { focusElement } from "../nav";
|
||||
import { t } from "../strings";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import type { FinderState } from "./finder";
|
||||
import { CHEAT_REPOS, sameVersion, type CheatEntry } from "./repos";
|
||||
|
||||
const repoName = (id: string) => CHEAT_REPOS.find((r) => r.id === id)?.name ?? id;
|
||||
|
||||
function EntryRow({
|
||||
entry,
|
||||
match,
|
||||
busy,
|
||||
disabled,
|
||||
done,
|
||||
first,
|
||||
onDownload,
|
||||
}: {
|
||||
entry: CheatEntry;
|
||||
match: boolean;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
done: boolean;
|
||||
first: boolean;
|
||||
onDownload: (e: CheatEntry) => void;
|
||||
}) {
|
||||
let tag: string | null = null;
|
||||
if (busy) tag = t("cw_cheats_find_downloading");
|
||||
else if (done) tag = t("cw_cheats_find_downloaded");
|
||||
return (
|
||||
<li className="cw-finder-item">
|
||||
<button
|
||||
type="button"
|
||||
className={`cw-focusable cw-button cw-finder-entry${match ? " is-match" : ""}`}
|
||||
aria-busy={busy || undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
data-cheat-file={entry.filename}
|
||||
data-nav-default={first ? "" : undefined}
|
||||
onClick={() => onDownload(entry)}
|
||||
>
|
||||
<Download aria-hidden />
|
||||
<span className="cw-finder-text">
|
||||
<span className="cw-finder-name">
|
||||
{entry.gameVersion ? `v${entry.gameVersion}` : t("cw_cheats_find_any_version")}
|
||||
{match ? <span className="cw-tag is-on">{t("cw_cheats_find_your_version")}</span> : null}
|
||||
</span>
|
||||
<span className="cw-dim cw-finder-meta">
|
||||
{entry.filename} · {entry.format.toUpperCase()} · {repoName(entry.repoId)}
|
||||
{entry.gameTitle ? ` · ${entry.gameTitle}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{tag ? <span className="cw-tag">{tag}</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The finder over the Cheats section: ✕ on a file downloads it to the console; ○ or Close leaves. */
|
||||
export function FindCheats({
|
||||
state,
|
||||
onDownload,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: {
|
||||
state: FinderState;
|
||||
onDownload: (e: CheatEntry) => void;
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// The list (or Try again) replaces "Looking…" after the modal took focus: focus moves to it.
|
||||
const ready = state.kind === "list" ? `list:${state.entries.length}` : state.kind;
|
||||
useEffect(() => {
|
||||
if (ready !== "offline" && !ready.startsWith("list:")) return;
|
||||
const el = document.querySelector<HTMLElement>(".cw-finder [data-nav-default]");
|
||||
if (el) focusElement(el);
|
||||
}, [ready]);
|
||||
if (state.kind === "closed") return null;
|
||||
const title = t("cw_cheats_find_title", { title: state.title });
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<p className="cw-dim" role="status">
|
||||
{t("cw_cheats_find_loading")}
|
||||
</p>
|
||||
);
|
||||
} else if (state.kind === "offline") {
|
||||
body = (
|
||||
<div className="cw-msg is-error" role="alert">
|
||||
{t("cw_cheats_find_offline")}
|
||||
</div>
|
||||
);
|
||||
} else if (state.entries.length === 0) {
|
||||
body = (
|
||||
<>
|
||||
<p role="status">{t("cw_cheats_find_none")}</p>
|
||||
{state.partial ? <p className="cw-dim">{t("cw_cheats_find_partial")}</p> : null}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
const v = state.version;
|
||||
const noMatch = !!v && !state.entries.some((e) => sameVersion(e.gameVersion, v));
|
||||
body = (
|
||||
<>
|
||||
<p className="cw-finder-hint">{t("cw_cheats_find_hint")}</p>
|
||||
<p className="cw-dim cw-finder-hint">
|
||||
{v ? <span className="cw-finder-version">{t("cw_cheats_find_version", { v })}</span> : null}
|
||||
{t("cw_cheats_find_how")}
|
||||
</p>
|
||||
{noMatch ? <div className="cw-msg">{t("cw_cheats_find_no_match", { v: v ?? "" })}</div> : null}
|
||||
{state.partial ? <p className="cw-dim cw-finder-hint">{t("cw_cheats_find_partial")}</p> : null}
|
||||
<ul className="cw-finder-list">
|
||||
{state.entries.map((e, i) => (
|
||||
<EntryRow
|
||||
key={`${e.repoId}/${e.filename}`}
|
||||
entry={e}
|
||||
match={sameVersion(e.gameVersion, v)}
|
||||
busy={state.busy === e.filename}
|
||||
disabled={state.busy !== null}
|
||||
done={state.done.includes(e.filename)}
|
||||
first={i === 0}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const message = state.kind === "list" ? state.message : null;
|
||||
return (
|
||||
<Modal label={title} className="cw-finder" onBack={onClose}>
|
||||
<h2 className="cw-sheet-title">{title}</h2>
|
||||
{body}
|
||||
{message ? (
|
||||
<div className={`cw-msg ${message.tone === "error" ? "is-error" : "is-info"}`} role={message.tone === "error" ? "alert" : "status"}>
|
||||
{message.text}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
{state.kind === "offline" ? (
|
||||
<button type="button" className="cw-focusable cw-button" data-nav-default="" onClick={onRetry}>
|
||||
<RefreshCw aria-hidden />
|
||||
<span>{t("cw_cheats_find_retry")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="cw-focusable cw-button" data-action="finder-close" onClick={onClose}>
|
||||
{t("cw_cheats_find_close")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../api", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("../api")>();
|
||||
return { ...real, call: vi.fn(), callRaw: vi.fn() };
|
||||
});
|
||||
|
||||
import { ApiError, call, callRaw } from "../api";
|
||||
import { t } from "../strings";
|
||||
import { createFinder, downloadErrorText, FINDER_CLOSED, storeRoute, type FinderState, type StoredFile } from "./finder";
|
||||
import { FindCheats } from "./FindCheats";
|
||||
import { CHEAT_REPOS, contentUrls, parseCheatFilename, RepoError, storeFormat, type CheatEntry, type FetchLike, type RepoIndex } from "./repos";
|
||||
|
||||
const mockCall = vi.mocked(call);
|
||||
const mockRaw = vi.mocked(callRaw);
|
||||
const GAME = "CUSA15438";
|
||||
const MIX = CHEAT_REPOS[2];
|
||||
|
||||
const entry = (filename: string, repoId = "etahen", gameTitle = "Game"): CheatEntry => {
|
||||
const [titleId, gameVersion] = parseCheatFilename(filename);
|
||||
return { filename, gameTitle, format: storeFormat(filename), repoId, titleId, gameVersion };
|
||||
};
|
||||
|
||||
const INDEX: RepoIndex = {
|
||||
entries: [
|
||||
entry("CUSA15438_01.00.json"),
|
||||
entry("CUSA15438_01.02.shn", "goldhen"),
|
||||
entry("CUSA15438_01.03.json", "henmix", ""),
|
||||
entry("CUSA99999_01.00.json"),
|
||||
entry("CUSA15438_01.02 copy.json"), // a name the helper would refuse: never listed
|
||||
],
|
||||
failed: [],
|
||||
offline: false,
|
||||
};
|
||||
|
||||
/** The helper: /api/cheats/version answers `version`; /api/cheats/store records what it got. */
|
||||
function helper(opts: { version?: string | null; versionFails?: boolean; storeError?: ApiError; replaced?: boolean } = {}) {
|
||||
const stored: { route: string; body: string }[] = [];
|
||||
mockCall.mockImplementation(async (route: string) => {
|
||||
if (route === "/api/cheats/version") {
|
||||
if (opts.versionFails) throw new ApiError(500, "internal");
|
||||
return { version: opts.version ?? null } as never;
|
||||
}
|
||||
throw new Error(`unexpected ${route}`);
|
||||
});
|
||||
mockRaw.mockImplementation(async (route: string, body: Blob) => {
|
||||
if (opts.storeError) throw opts.storeError;
|
||||
stored.push({ route, body: await body.text() });
|
||||
return { ok: true, replaced: opts.replaced === true } as never;
|
||||
});
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** GitHub for the content files: url → body; anything else 404. */
|
||||
function github(files: Record<string, string>, down = false): FetchLike {
|
||||
return async (url) => {
|
||||
if (down) throw new TypeError("Load failed");
|
||||
return url in files ? new Response(files[url], { status: 200 }) : new Response("404", { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
function finder(opts: { index?: RepoIndex | Error; fetch?: FetchLike } = {}) {
|
||||
const states: FinderState[] = [];
|
||||
const stored: [CheatEntry, string][] = [];
|
||||
const full: StoredFile[] = [];
|
||||
const f = createFinder(
|
||||
(s) => states.push(s),
|
||||
(x) => {
|
||||
stored.push([x.entry, x.title]);
|
||||
full.push(x);
|
||||
},
|
||||
{
|
||||
index: async () => {
|
||||
if (opts.index instanceof Error) throw opts.index;
|
||||
return opts.index ?? INDEX;
|
||||
},
|
||||
fetch: opts.fetch ?? github({}),
|
||||
},
|
||||
);
|
||||
return { f, states, stored, full };
|
||||
}
|
||||
|
||||
const render = (state: FinderState) =>
|
||||
renderToStaticMarkup(<FindCheats state={state} onDownload={() => {}} onRetry={() => {}} onClose={() => {}} />);
|
||||
|
||||
beforeEach(() => {
|
||||
mockCall.mockReset();
|
||||
mockRaw.mockReset();
|
||||
});
|
||||
|
||||
describe("find cheats", () => {
|
||||
it("lists_the_running_games_files_installed_version_first", async () => {
|
||||
helper({ version: "01.02" });
|
||||
const { f } = finder();
|
||||
await f.open(GAME);
|
||||
const s = f.get();
|
||||
expect(s.kind).toBe("list");
|
||||
if (s.kind !== "list") return;
|
||||
expect(s.version).toBe("01.02");
|
||||
expect(s.entries.map((e) => e.filename)).toEqual(["CUSA15438_01.02.shn", "CUSA15438_01.03.json", "CUSA15438_01.00.json"]);
|
||||
expect(mockCall).toHaveBeenCalledWith("/api/cheats/version", { title_id: GAME });
|
||||
const html = render(s);
|
||||
expect(html).toContain(t("cw_cheats_find_title", { title: GAME }));
|
||||
expect(html).toContain(t("cw_cheats_find_your_version"));
|
||||
expect(html).toContain("is-match");
|
||||
expect(html).toContain("GoldHEN/GoldHEN_Cheat_Repository");
|
||||
expect(html).toContain("SHN");
|
||||
expect(html).toContain(t("cw_cheats_find_version", { v: "01.02" }));
|
||||
expect(html).not.toContain("CUSA99999");
|
||||
expect(html).not.toContain("copy.json");
|
||||
// the best file is where focus starts
|
||||
expect(html.indexOf("data-nav-default")).toBeLessThan(html.indexOf("CUSA15438_01.03.json"));
|
||||
});
|
||||
|
||||
it("an_unknown_version_lists_all_newest_first", async () => {
|
||||
helper({ versionFails: true });
|
||||
const { f } = finder();
|
||||
await f.open(GAME);
|
||||
const s = f.get();
|
||||
if (s.kind !== "list") throw new Error(s.kind);
|
||||
expect(s.version).toBeNull();
|
||||
expect(s.entries.map((e) => e.gameVersion)).toEqual(["01.03", "01.02", "01.00"]);
|
||||
const html = render(s);
|
||||
expect(html).not.toContain(t("cw_cheats_find_your_version"));
|
||||
expect(html).not.toContain(t("cw_cheats_find_no_match", { v: "" }).slice(0, 10));
|
||||
});
|
||||
|
||||
it("no_file_for_the_installed_version_warns", async () => {
|
||||
helper({ version: "02.00" });
|
||||
const { f } = finder();
|
||||
await f.open(GAME);
|
||||
expect(render(f.get())).toContain(t("cw_cheats_find_no_match", { v: "02.00" }));
|
||||
});
|
||||
|
||||
it("nobody_published_any", async () => {
|
||||
helper();
|
||||
const { f } = finder({ index: { entries: [], failed: ["henmix"], offline: false } });
|
||||
await f.open(GAME);
|
||||
const html = render(f.get());
|
||||
expect(html).toContain(t("cw_cheats_find_none"));
|
||||
expect(html).toContain(t("cw_cheats_find_partial"));
|
||||
});
|
||||
|
||||
it("github_unreachable_says_what_to_do", async () => {
|
||||
helper();
|
||||
const { f } = finder({ index: { entries: [], failed: ["etahen", "goldhen", "henmix"], offline: true } });
|
||||
await f.open(GAME);
|
||||
expect(f.get()).toEqual({ kind: "offline", title: GAME });
|
||||
const html = render(f.get());
|
||||
expect(t("cw_cheats_find_offline")).toBe(
|
||||
"This PS5 can't reach GitHub. Get cheats in the PS5Upload app on your PC, or allow raw.githubusercontent.com in your DNS settings.",
|
||||
);
|
||||
expect(html).toContain("raw.githubusercontent.com");
|
||||
expect(html).toContain(t("cw_cheats_find_retry"));
|
||||
});
|
||||
|
||||
it("download_fetches_in_the_browser_and_stores_on_the_console", async () => {
|
||||
const stored = helper({ version: "01.03" });
|
||||
const e = INDEX.entries[2]; // henmix, nested under cheats/
|
||||
const urls = contentUrls(MIX, e.filename);
|
||||
const { f, stored: done } = finder({ fetch: github({ [urls[0]]: '{"name":"Game","mods":[]}' }) });
|
||||
await f.open(GAME);
|
||||
await f.download(e);
|
||||
expect(stored).toEqual([
|
||||
{ route: "/api/cheats/store?format=json&name=CUSA15438_01.03.json", body: '{"name":"Game","mods":[]}' },
|
||||
]);
|
||||
expect(done).toEqual([[e, GAME]]);
|
||||
const s = f.get();
|
||||
if (s.kind !== "list") throw new Error(s.kind);
|
||||
expect(s.busy).toBeNull();
|
||||
expect(s.done).toEqual([e.filename]);
|
||||
expect(s.message).toEqual({ tone: "info", text: t("cw_cheats_find_saved", { name: e.filename }) });
|
||||
});
|
||||
|
||||
it("the_store_route_names_the_format_by_extension", () => {
|
||||
expect(storeRoute(entry("CUSA15438_01.02.shn"))).toBe("/api/cheats/store?format=shn&name=CUSA15438_01.02.shn");
|
||||
expect(storeRoute(entry("CUSA15438_01.02.MC4"))).toBe("/api/cheats/store?format=mc4&name=CUSA15438_01.02.MC4");
|
||||
});
|
||||
|
||||
it("one_download_at_a_time", async () => {
|
||||
helper();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const fetchSlow: FetchLike = async () => {
|
||||
await gate;
|
||||
return new Response("x", { status: 200 });
|
||||
};
|
||||
const { f } = finder({ fetch: fetchSlow });
|
||||
await f.open(GAME);
|
||||
const a = f.download(INDEX.entries[0]);
|
||||
const s = f.get();
|
||||
expect(s.kind === "list" && s.busy).toBe(INDEX.entries[0].filename);
|
||||
expect(render(s)).toContain(t("cw_cheats_find_downloading"));
|
||||
await f.download(INDEX.entries[1]); // ignored
|
||||
release();
|
||||
await a;
|
||||
expect(mockRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("failures_are_one_line_and_keep_the_list", async () => {
|
||||
helper({ storeError: new ApiError(500, "io_error") });
|
||||
const e = INDEX.entries[0];
|
||||
const { f, stored } = finder({ fetch: github({ [contentUrls(CHEAT_REPOS[0], e.filename)[0]]: "{}" }) });
|
||||
await f.open(GAME);
|
||||
await f.download(e);
|
||||
const s = f.get();
|
||||
if (s.kind !== "list") throw new Error(s.kind);
|
||||
expect(s.message).toEqual({ tone: "error", text: t("cw_cheats_find_err_store", { name: e.filename, code: "io_error" }) });
|
||||
expect(s.entries).toHaveLength(3);
|
||||
expect(stored).toEqual([]);
|
||||
expect(render(s)).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("download_errors_read_well", () => {
|
||||
expect(downloadErrorText(new RepoError(true, "unreachable"), "a.json")).toBe(t("cw_cheats_find_offline"));
|
||||
expect(downloadErrorText(new RepoError(false, "too_large"), "a.json")).toBe(t("cw_cheats_find_too_large", { name: "a.json" }));
|
||||
expect(downloadErrorText(new RepoError(false, "not_found"), "a.json")).toBe(
|
||||
t("cw_cheats_find_err_download", { name: "a.json", code: "not_found" }),
|
||||
);
|
||||
expect(downloadErrorText(new ApiError(401, "token"), "a.json")).toBeNull();
|
||||
expect(downloadErrorText(new ApiError(0, "offline"), "a.json")).toBe(t("cw_home_err_offline"));
|
||||
expect(downloadErrorText(new ApiError(413, "too_large"), "a.json")).toBe(t("cw_cheats_find_too_large", { name: "a.json" }));
|
||||
});
|
||||
|
||||
it("github_down_mid_download_says_unreachable", async () => {
|
||||
helper();
|
||||
const { f } = finder({ fetch: github({}, true) });
|
||||
await f.open(GAME);
|
||||
await f.download(INDEX.entries[0]);
|
||||
const s = f.get();
|
||||
expect(s.kind === "list" && s.message?.text).toBe(t("cw_cheats_find_offline"));
|
||||
expect(mockRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closing_drops_a_late_answer", async () => {
|
||||
helper();
|
||||
let release: (i: RepoIndex) => void = () => {};
|
||||
const states: FinderState[] = [];
|
||||
const f = createFinder(
|
||||
(s) => states.push(s),
|
||||
() => {},
|
||||
{ index: () => new Promise<RepoIndex>((r) => (release = r)) },
|
||||
);
|
||||
const p = f.open(GAME);
|
||||
expect(f.get()).toEqual({ kind: "loading", title: GAME });
|
||||
expect(render(f.get())).toContain(t("cw_cheats_find_loading"));
|
||||
f.close();
|
||||
release(INDEX);
|
||||
await p;
|
||||
expect(f.get()).toEqual(FINDER_CLOSED);
|
||||
expect(render(f.get())).toBe("");
|
||||
});
|
||||
|
||||
it("an_index_read_that_throws_reads_as_offline", async () => {
|
||||
helper();
|
||||
const { f } = finder({ index: new Error("boom") });
|
||||
await f.open(GAME);
|
||||
expect(f.get().kind).toBe("offline");
|
||||
});
|
||||
|
||||
it("a_file_already_there_says_replaced", async () => {
|
||||
helper({ replaced: true });
|
||||
const e = INDEX.entries[0];
|
||||
const { f, full } = finder({ fetch: github({ [contentUrls(CHEAT_REPOS[0], e.filename)[0]]: "{}" }) });
|
||||
await f.open(GAME);
|
||||
await f.download(e);
|
||||
const s = f.get();
|
||||
expect(s.kind === "list" && s.message?.text).toBe(t("cw_cheats_find_replaced", { name: e.filename }));
|
||||
expect(t("cw_cheats_find_replaced", { name: "x" })).toBe("x was replaced on the console. Its cheats are in the list now.");
|
||||
expect(full[0]).toMatchObject({ replaced: true, current: true, title: GAME });
|
||||
});
|
||||
|
||||
it("a_download_finishing_after_the_finder_was_reopened_is_not_current", async () => {
|
||||
helper();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const slow: FetchLike = async () => {
|
||||
await gate;
|
||||
return new Response("x", { status: 200 });
|
||||
};
|
||||
const { f, full } = finder({ fetch: slow });
|
||||
await f.open(GAME);
|
||||
const p = f.download(INDEX.entries[0]);
|
||||
f.close();
|
||||
await f.open("CUSA99999");
|
||||
release();
|
||||
await p;
|
||||
expect(full).toHaveLength(1);
|
||||
expect(full[0]).toMatchObject({ title: GAME, current: false });
|
||||
expect(f.get()).toMatchObject({ kind: "list", title: "CUSA99999" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Find cheats: the running game's cheat files in the community repos (read by the PS5 browser,
|
||||
* repos.ts), its installed version from the helper (/api/cheats/version), and a download that
|
||||
* fetches the file in the browser and hands it to the helper (/api/cheats/store), which puts it
|
||||
* where the cheat engine looks. Kept out of React so tests drive it directly.
|
||||
*/
|
||||
import { ApiError, call, callRaw, isOffline } from "../api";
|
||||
import { t } from "../strings";
|
||||
import {
|
||||
downloadCheat,
|
||||
entriesFor,
|
||||
rankEntries,
|
||||
RepoError,
|
||||
repoIndex,
|
||||
storeFormat,
|
||||
type CheatEntry,
|
||||
type FetchLike,
|
||||
type RepoIndex,
|
||||
} from "./repos";
|
||||
|
||||
export type FinderMessage = { tone: "error" | "info"; text: string };
|
||||
|
||||
export type FinderState =
|
||||
| { kind: "closed" }
|
||||
| { kind: "loading"; title: string }
|
||||
| { kind: "offline"; title: string }
|
||||
| {
|
||||
kind: "list";
|
||||
title: string;
|
||||
/** The installed version, or null when the helper could not say (every version is listed). */
|
||||
version: string | null;
|
||||
/** Best first (rankEntries). */
|
||||
entries: CheatEntry[];
|
||||
/** Some repos could not be read. */
|
||||
partial: boolean;
|
||||
/** The file being downloaded, or null. */
|
||||
busy: string | null;
|
||||
/** Files downloaded while the finder was open. */
|
||||
done: string[];
|
||||
message: FinderMessage | null;
|
||||
};
|
||||
|
||||
export const FINDER_CLOSED: FinderState = { kind: "closed" };
|
||||
|
||||
export interface FinderDeps {
|
||||
index?: () => Promise<RepoIndex>;
|
||||
fetch?: FetchLike;
|
||||
}
|
||||
|
||||
export interface Finder {
|
||||
/** Look for `titleId`'s cheats (the repos are read once per page session). */
|
||||
open(titleId: string): Promise<void>;
|
||||
close(): void;
|
||||
/** Download `entry` and store it on the console; ignored while another one runs. */
|
||||
download(entry: CheatEntry): Promise<void>;
|
||||
get(): FinderState;
|
||||
}
|
||||
|
||||
/** The game's installed version from the helper; null when it does not know (or cannot be asked). */
|
||||
export async function readVersion(titleId: string): Promise<string | null> {
|
||||
try {
|
||||
const r = await call<{ version?: unknown }>("/api/cheats/version", { title_id: titleId });
|
||||
return r && typeof r.version === "string" && r.version ? r.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed download or store as one line; null for a 401 (the reload banner says it). */
|
||||
export function downloadErrorText(err: unknown, name: string): string | null {
|
||||
if (err instanceof RepoError) {
|
||||
if (err.network) return t("cw_cheats_find_offline");
|
||||
if (err.message === "too_large") return t("cw_cheats_find_too_large", { name });
|
||||
return t("cw_cheats_find_err_download", { name, code: err.message });
|
||||
}
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401) return null;
|
||||
if (isOffline(err)) return t("cw_home_err_offline");
|
||||
if (err.code === "too_large") return t("cw_cheats_find_too_large", { name });
|
||||
return t("cw_cheats_find_err_store", { name, code: err.code });
|
||||
}
|
||||
return t("cw_cheats_find_err_download", { name, code: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
/** The helper route that stores `entry` (its name passed as is: only [A-Za-z0-9._-] are listed). */
|
||||
export function storeRoute(entry: CheatEntry): string {
|
||||
return `/api/cheats/store?format=${storeFormat(entry.filename)}&name=${encodeURIComponent(entry.filename)}`;
|
||||
}
|
||||
|
||||
/** A file the helper stored: for which game, whether it replaced one of the same name, and whether
|
||||
* the finder that asked is still the open one (`current`: only then may the section close it). */
|
||||
export interface StoredFile {
|
||||
entry: CheatEntry;
|
||||
title: string;
|
||||
replaced: boolean;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/** The saved line: "replaced" when a file of that name was already on the console. */
|
||||
export function savedText(name: string, replaced: boolean): string {
|
||||
return t(replaced ? "cw_cheats_find_replaced" : "cw_cheats_find_saved", { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* The finder's state machine. `onStored` runs after a file is on the console (the section reads
|
||||
* the game's cheats again, and closes the finder when it is still the one that asked).
|
||||
*/
|
||||
export function createFinder(
|
||||
onChange: (s: FinderState) => void,
|
||||
onStored: (stored: StoredFile) => void,
|
||||
deps: FinderDeps = {},
|
||||
): Finder {
|
||||
let s: FinderState = FINDER_CLOSED;
|
||||
/** Every open gets a number: an older one's answer never lands on a newer finder. */
|
||||
let seq = 0;
|
||||
const set = (next: FinderState) => {
|
||||
s = next;
|
||||
onChange(s);
|
||||
};
|
||||
const index = deps.index ?? (() => repoIndex(deps.fetch));
|
||||
|
||||
return {
|
||||
async open(titleId) {
|
||||
const mine = ++seq;
|
||||
set({ kind: "loading", title: titleId });
|
||||
let idx: RepoIndex;
|
||||
let version: string | null;
|
||||
try {
|
||||
[idx, version] = await Promise.all([index(), readVersion(titleId)]);
|
||||
} catch {
|
||||
if (mine === seq) set({ kind: "offline", title: titleId });
|
||||
return;
|
||||
}
|
||||
if (mine !== seq) return;
|
||||
if (idx.offline) {
|
||||
set({ kind: "offline", title: titleId });
|
||||
return;
|
||||
}
|
||||
set({
|
||||
kind: "list",
|
||||
title: titleId,
|
||||
version,
|
||||
entries: rankEntries(entriesFor(idx, titleId), version),
|
||||
partial: idx.failed.length > 0,
|
||||
busy: null,
|
||||
done: [],
|
||||
message: null,
|
||||
});
|
||||
},
|
||||
close() {
|
||||
seq++;
|
||||
set(FINDER_CLOSED);
|
||||
},
|
||||
async download(entry) {
|
||||
if (s.kind !== "list" || s.busy !== null) return;
|
||||
const mine = seq;
|
||||
const title = s.title;
|
||||
set({ ...s, busy: entry.filename, message: null });
|
||||
const still = () => mine === seq && s.kind === "list";
|
||||
let replaced: boolean;
|
||||
try {
|
||||
const bytes = await downloadCheat(entry, deps.fetch);
|
||||
const r = await callRaw<{ replaced?: unknown } | null>(storeRoute(entry), new Blob([bytes], { type: "application/octet-stream" }));
|
||||
replaced = r?.replaced === true;
|
||||
} catch (e) {
|
||||
if (still() && s.kind === "list") {
|
||||
const text = downloadErrorText(e, entry.filename);
|
||||
set({ ...s, busy: null, message: text ? { tone: "error", text } : null });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (still() && s.kind === "list") {
|
||||
set({
|
||||
...s,
|
||||
busy: null,
|
||||
done: s.done.includes(entry.filename) ? s.done : [...s.done, entry.filename],
|
||||
message: { tone: "info", text: savedText(entry.filename, replaced) },
|
||||
});
|
||||
}
|
||||
onStored({ entry, title, replaced, current: still() });
|
||||
},
|
||||
get: () => s,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The Cheats section's state, kept out of React so tests drive it directly.
|
||||
*
|
||||
* The helper's routes (payload/src/webui_api.c) adapt the cheat methods the PC
|
||||
* engine calls (engine/crates/ps5upload-core/src/cheats.rs):
|
||||
* /api/cheats/status → {engine, game: {title_id} | null} (CHEATS_STATUS)
|
||||
* /api/cheats/list {title_id} → {mods: [{index,name,desc,button,on}], truncated} (CHEATS_GET)
|
||||
* /api/cheats/toggle {title_id, index, on} → {ok, on} (CHEATS_TOGGLE)
|
||||
* A toggle needs that game running (the helper patches the live process); a
|
||||
* switch-on also turns the helper's cheat engine on.
|
||||
*/
|
||||
import { ApiError, call, isOffline } from "../api";
|
||||
import { t } from "../strings";
|
||||
|
||||
export interface CheatsStatus {
|
||||
/** The helper's cheat engine (re-applies switched-on cheats when the game restarts). */
|
||||
engine: boolean;
|
||||
game: { title_id: string } | null;
|
||||
}
|
||||
|
||||
export interface CheatMod {
|
||||
/** The helper's flat index across the game's cheat files (what a toggle names). */
|
||||
index: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
/** A one-shot cheat: applied each time, it has no on/off state. */
|
||||
button: boolean;
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
export interface CheatList {
|
||||
title: string;
|
||||
mods: CheatMod[];
|
||||
/** The helper's reply was cut: not every cheat is listed. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface CheatsState {
|
||||
status: CheatsStatus | null;
|
||||
/** The last status read failed (null once one succeeds). */
|
||||
statusError: string | null;
|
||||
/** The last game seen running while the section was open: tells "closed" from "never ran". */
|
||||
seen: string | null;
|
||||
/** The running game's cheats; null while not read (or no game runs). */
|
||||
list: CheatList | null;
|
||||
listError: string | null;
|
||||
/** The index of the cheat being switched, or null. */
|
||||
busy: number | null;
|
||||
/** The last toggle's failure, one translated line. */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export type CheatsPhase =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; text: string }
|
||||
| { kind: "no_game" }
|
||||
| { kind: "exited"; title: string }
|
||||
| { kind: "none"; title: string }
|
||||
| { kind: "list"; title: string; list: CheatList };
|
||||
|
||||
export const EMPTY_STATE: CheatsState = {
|
||||
status: null,
|
||||
statusError: null,
|
||||
seen: null,
|
||||
list: null,
|
||||
listError: null,
|
||||
busy: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
/** What the section shows for `s`. Never a stale list: only the running game's own cheats. */
|
||||
export function cheatsPhase(s: CheatsState): CheatsPhase {
|
||||
if (!s.status) return s.statusError ? { kind: "error", text: s.statusError } : { kind: "loading" };
|
||||
const game = s.status.game;
|
||||
if (!game) return s.seen ? { kind: "exited", title: s.seen } : { kind: "no_game" };
|
||||
const title = game.title_id;
|
||||
if (s.list && s.list.title === title) {
|
||||
return s.list.mods.length ? { kind: "list", title, list: s.list } : { kind: "none", title };
|
||||
}
|
||||
if (s.listError) return { kind: "error", text: s.listError };
|
||||
return { kind: "loading" };
|
||||
}
|
||||
|
||||
/** A failed read as one line; null for a 401 (the reload banner says it). */
|
||||
export function loadErrorText(err: unknown): string | null {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401) return null;
|
||||
if (isOffline(err)) return t("cw_home_err_offline");
|
||||
return t("cw_cheats_err_load", { code: err.code });
|
||||
}
|
||||
return t("cw_cheats_err_load", { code: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
/** A failed toggle as one line; null when the page says it some other way (game gone, 401). */
|
||||
export function toggleErrorText(err: unknown): string | null {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401 || err.code === "game_not_running") return null;
|
||||
if (isOffline(err)) return t("cw_home_err_offline");
|
||||
if (err.code === "module_missing") return t("cw_cheats_err_module");
|
||||
if (err.code === "game_busy") return t("cw_cheats_err_busy");
|
||||
if (err.code === "bad_index" || err.code === "no_cheats") return t("cw_cheats_err_changed");
|
||||
return t("cw_cheats_err_toggle", { code: err.code });
|
||||
}
|
||||
return t("cw_cheats_err_toggle", { code: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
/** The status reply, checked (a helper that answered nonsense reads as "no game"). */
|
||||
export function readStatus(r: unknown): CheatsStatus {
|
||||
const o = (r ?? {}) as { engine?: unknown; game?: unknown };
|
||||
const g = o.game as { title_id?: unknown } | null | undefined;
|
||||
const title = g && typeof g.title_id === "string" && g.title_id ? g.title_id : null;
|
||||
return { engine: o.engine === true, game: title ? { title_id: title } : null };
|
||||
}
|
||||
|
||||
/** The list reply, checked: entries without an index or a name are dropped. */
|
||||
export function readList(title: string, r: unknown): CheatList {
|
||||
const o = (r ?? {}) as { mods?: unknown; truncated?: unknown };
|
||||
const mods: CheatMod[] = [];
|
||||
if (Array.isArray(o.mods)) {
|
||||
for (const m of o.mods as Record<string, unknown>[]) {
|
||||
if (!m || typeof m.index !== "number" || !Number.isInteger(m.index) || !str(m.name)) continue;
|
||||
mods.push({ index: m.index, name: str(m.name), desc: str(m.desc), button: m.button === true, on: m.on === true });
|
||||
}
|
||||
}
|
||||
return { title, mods, truncated: o.truncated === true };
|
||||
}
|
||||
|
||||
export interface CheatsController {
|
||||
/** Read the status; a new game's cheats are read with it. */
|
||||
loadStatus(): Promise<void>;
|
||||
/** Read the running game's cheats again. */
|
||||
loadList(): Promise<void>;
|
||||
/** Switch `mod` (a one-shot cheat is applied); ignored while another switch runs. */
|
||||
toggle(mod: CheatMod): Promise<void>;
|
||||
/** Create / coming back: the status and the cheats again. */
|
||||
refresh(): Promise<void>;
|
||||
/** Stop reporting (the section closed). */
|
||||
dispose(): void;
|
||||
get(): CheatsState;
|
||||
}
|
||||
|
||||
export function createCheatsController(onChange: (s: CheatsState) => void): CheatsController {
|
||||
let s: CheatsState = EMPTY_STATE;
|
||||
let live = true;
|
||||
/** Every list read gets a number; only the newest one's answer is kept. */
|
||||
let listSeq = 0;
|
||||
|
||||
const set = (patch: Partial<CheatsState>) => {
|
||||
if (!live) return;
|
||||
s = { ...s, ...patch };
|
||||
onChange(s);
|
||||
};
|
||||
const title = () => s.status?.game?.title_id ?? null;
|
||||
|
||||
const loadList = async () => {
|
||||
const tid = title();
|
||||
if (!tid) return;
|
||||
const seq = ++listSeq;
|
||||
try {
|
||||
const r = await call<unknown>("/api/cheats/list", { title_id: tid });
|
||||
// A switch running now re-reads when it ends: this older answer would flash its old state.
|
||||
if (seq !== listSeq || title() !== tid || s.busy !== null) return;
|
||||
set({ list: readList(tid, r), listError: null });
|
||||
} catch (e) {
|
||||
if (seq !== listSeq || title() !== tid) return;
|
||||
// A list already shown stays (a failed refresh); the first read's failure is the page's error.
|
||||
set({ listError: s.list?.title === tid ? null : (loadErrorText(e) ?? s.listError) });
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
let next: CheatsStatus;
|
||||
try {
|
||||
next = readStatus(await call<unknown>("/api/cheats/status"));
|
||||
} catch (e) {
|
||||
set({ statusError: loadErrorText(e) ?? s.statusError });
|
||||
return;
|
||||
}
|
||||
const before = title();
|
||||
const now = next.game?.title_id ?? null;
|
||||
if (now === before) {
|
||||
set({ status: next, statusError: null });
|
||||
return;
|
||||
}
|
||||
// Another game, or none: the old switches go at once (never toggle a game that is not running).
|
||||
listSeq++;
|
||||
set({ status: next, statusError: null, list: null, listError: null, message: null, seen: now ?? s.seen });
|
||||
if (now) await loadList();
|
||||
};
|
||||
|
||||
const toggle = async (mod: CheatMod) => {
|
||||
const tid = title();
|
||||
if (s.busy !== null || !tid || s.list?.title !== tid) return;
|
||||
const on = mod.button ? true : !mod.on;
|
||||
set({ busy: mod.index, message: null });
|
||||
let gone = false;
|
||||
try {
|
||||
const r = await call<{ on?: unknown }>("/api/cheats/toggle", { title_id: tid, index: mod.index, on });
|
||||
// Show the switch's new state at once; the re-read below confirms it.
|
||||
if (!mod.button && s.list?.title === tid) {
|
||||
const done = r && typeof r.on === "boolean" ? r.on : on;
|
||||
set({ list: { ...s.list, mods: s.list.mods.map((m) => (m.index === mod.index ? { ...m, on: done } : m)) } });
|
||||
}
|
||||
} catch (e) {
|
||||
gone = e instanceof ApiError && e.code === "game_not_running";
|
||||
set({ message: toggleErrorText(e) });
|
||||
}
|
||||
if (gone && s.status && title() === tid) {
|
||||
// Said at once (the "closed" screen), not after the next status read: the helper dropped its
|
||||
// running-game cache with this answer, so the read below confirms it (or brings the game back).
|
||||
listSeq++;
|
||||
set({ busy: null, status: { ...s.status, game: null }, seen: tid, list: null, listError: null });
|
||||
await loadStatus();
|
||||
return;
|
||||
}
|
||||
set({ busy: null });
|
||||
if (gone) await loadStatus();
|
||||
else await loadList();
|
||||
};
|
||||
|
||||
return {
|
||||
loadStatus,
|
||||
loadList,
|
||||
toggle,
|
||||
refresh: async () => {
|
||||
const before = title();
|
||||
await loadStatus();
|
||||
// loadStatus read the list already when the game changed.
|
||||
if (before && title() === before) await loadList();
|
||||
},
|
||||
dispose: () => {
|
||||
live = false;
|
||||
},
|
||||
get: () => s,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CHEAT_FILE_MAX,
|
||||
CHEAT_REPOS,
|
||||
contentUrls,
|
||||
downloadCheat,
|
||||
entriesFor,
|
||||
fetchLimited,
|
||||
formatFromIndex,
|
||||
parseCheatFilename,
|
||||
parseIndexLine,
|
||||
rankEntries,
|
||||
readRepoIndex,
|
||||
RepoError,
|
||||
repoIndex,
|
||||
resetRepoIndex,
|
||||
sameVersion,
|
||||
storableName,
|
||||
storeFormat,
|
||||
treeEntryFromPath,
|
||||
type CheatEntry,
|
||||
type FetchLike,
|
||||
} from "./repos";
|
||||
|
||||
/** A fake network: url → body (string), status number, or "down" (the request throws). */
|
||||
function net(routes: Record<string, string | number | "down" | ArrayBuffer>) {
|
||||
const calls: string[] = [];
|
||||
const f: FetchLike = async (url) => {
|
||||
calls.push(url);
|
||||
const r = routes[url];
|
||||
if (r === undefined || r === "down") throw new TypeError("Load failed");
|
||||
if (typeof r === "number") return new Response("nope", { status: r });
|
||||
return new Response(r, { status: 200 });
|
||||
};
|
||||
return { f, calls };
|
||||
}
|
||||
|
||||
const ETA = CHEAT_REPOS[0];
|
||||
const GOLD = CHEAT_REPOS[1];
|
||||
const MIX = CHEAT_REPOS[2];
|
||||
|
||||
describe("cheat file names (cheats.rs parse_cheat_filename)", () => {
|
||||
it("reads the title and version from a published name", () => {
|
||||
expect(parseCheatFilename("CUSA15438_01.00.json")).toEqual(["CUSA15438", "01.00"]);
|
||||
expect(parseCheatFilename("CUSA09193_01.05_2.json")).toEqual(["CUSA09193", "01.05"]);
|
||||
expect(parseCheatFilename("PPSA01650_01.000.016.shn")).toEqual(["PPSA01650", "01.000.016"]);
|
||||
expect(parseCheatFilename("cusa34394_05.01.mc4")).toEqual(["CUSA34394", "05.01"]);
|
||||
});
|
||||
it("keeps the title when the version is missing or not a version", () => {
|
||||
expect(parseCheatFilename("CUSA15438.json")).toEqual(["CUSA15438", ""]);
|
||||
expect(parseCheatFilename("CUSA15438_2.json")).toEqual(["CUSA15438", ""]);
|
||||
expect(parseCheatFilename("CUSA15438_v1.00.json")).toEqual(["CUSA15438", ""]);
|
||||
expect(parseCheatFilename("CUSA15438_c20ae0e8.json")).toEqual(["CUSA15438", ""]);
|
||||
});
|
||||
it("gives nothing for a name that breaks the convention", () => {
|
||||
expect(parseCheatFilename("README.md")).toEqual(["", ""]);
|
||||
expect(parseCheatFilename("CUSA1543_01.00.json")).toEqual(["", ""]);
|
||||
expect(parseCheatFilename("12345CUSA_01.00.json")).toEqual(["", ""]);
|
||||
expect(parseCheatFilename("")).toEqual(["", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("index files (cheats.rs parse_index_line / format_from_index)", () => {
|
||||
it("splits filename=title and skips blanks and comments", () => {
|
||||
expect(parseIndexLine("CUSA15438_01.00.json=Game name")).toEqual(["CUSA15438_01.00.json", "Game name"]);
|
||||
expect(parseIndexLine(" CUSA1_1.0.shn = A = B ")).toEqual(["CUSA1_1.0.shn", "A = B"]);
|
||||
expect(parseIndexLine("x.json=")).toEqual(["x.json", ""]);
|
||||
expect(parseIndexLine("")).toBeNull();
|
||||
expect(parseIndexLine(" ")).toBeNull();
|
||||
expect(parseIndexLine("# comment=x")).toBeNull();
|
||||
expect(parseIndexLine("no equals")).toBeNull();
|
||||
expect(parseIndexLine("=title only")).toBeNull();
|
||||
});
|
||||
it("names the format by the index file", () => {
|
||||
expect(formatFromIndex("json.txt")).toBe("json");
|
||||
expect(formatFromIndex("shn.txt")).toBe("shn");
|
||||
expect(formatFromIndex("mc4.txt")).toBe("mc4");
|
||||
expect(formatFromIndex("other.txt")).toBe("json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("git tree paths (cheats.rs tree_entry_from_path)", () => {
|
||||
it("accepts <prefix><format>/<file> with a matching extension", () => {
|
||||
expect(treeEntryFromPath("cheats/json/CUSA00002_01.00_c20ae0e8.json", "cheats/")).toEqual(["CUSA00002_01.00_c20ae0e8.json", "json"]);
|
||||
expect(treeEntryFromPath("cheats/shn/CUSA123.shn", "cheats/")).toEqual(["CUSA123.shn", "shn"]);
|
||||
expect(treeEntryFromPath("cheats/mc4/CUSA123.MC4", "cheats/")).toEqual(["CUSA123.MC4", "mc4"]);
|
||||
expect(treeEntryFromPath("json/CUSA1.json", "")).toEqual(["CUSA1.json", "json"]);
|
||||
});
|
||||
it("refuses anything else", () => {
|
||||
expect(treeEntryFromPath("docs/json/a.json", "cheats/")).toBeNull();
|
||||
expect(treeEntryFromPath("cheats/misc/a.json", "cheats/")).toBeNull();
|
||||
expect(treeEntryFromPath("cheats/json/sub/a.json", "cheats/")).toBeNull();
|
||||
expect(treeEntryFromPath("cheats/json/README.md", "cheats/")).toBeNull();
|
||||
expect(treeEntryFromPath("cheats/json/noext", "cheats/")).toBeNull();
|
||||
expect(treeEntryFromPath("cheats/json/", "cheats/")).toBeNull();
|
||||
expect(treeEntryFromPath("cheats/json", "cheats/")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("content URLs (cheats.rs cheats_repo_download)", () => {
|
||||
it("tries json, shn, mc4, misc under the content base, in that order", () => {
|
||||
expect(contentUrls(MIX, "CUSA1_1.00.shn")).toEqual([
|
||||
"https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/json/CUSA1_1.00.shn",
|
||||
"https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/shn/CUSA1_1.00.shn",
|
||||
"https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/mc4/CUSA1_1.00.shn",
|
||||
"https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/misc/CUSA1_1.00.shn",
|
||||
]);
|
||||
expect(contentUrls(ETA, "a.json")[0]).toBe("https://raw.githubusercontent.com/etaHEN/PS5_Cheats/main/json/a.json");
|
||||
});
|
||||
it("matches the engine's repo list", () => {
|
||||
expect(CHEAT_REPOS.map((r) => r.id)).toEqual(["etahen", "goldhen", "henmix"]);
|
||||
for (const r of CHEAT_REPOS) {
|
||||
expect(r.indexFiles.length > 0 || r.treeApi !== "").toBe(true);
|
||||
expect(r.contentBase.endsWith("/")).toBe(true);
|
||||
for (const u of [r.rawBase, r.contentBase, r.treeApi].filter(Boolean)) {
|
||||
expect(u.startsWith("https://raw.githubusercontent.com/") || u.startsWith("https://api.github.com/")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
it("stores by extension, only names the helper accepts", () => {
|
||||
expect(storeFormat("a.SHN")).toBe("shn");
|
||||
expect(storeFormat("a.mc4")).toBe("mc4");
|
||||
expect(storeFormat("a.json")).toBe("json");
|
||||
expect(storableName("CUSA15438_01.00.json")).toBe(true);
|
||||
expect(storableName("CUSA15438_01.00 (1).json")).toBe(false);
|
||||
expect(storableName("../a.json")).toBe(false);
|
||||
expect(storableName(".json")).toBe(false);
|
||||
expect(storableName("a..json")).toBe(false);
|
||||
expect(storableName("a.txt")).toBe(false);
|
||||
expect(storableName(`${"a".repeat(123)}.json`)).toBe(true);
|
||||
expect(storableName(`${"a".repeat(124)}.json`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions", () => {
|
||||
it("compares by number", () => {
|
||||
expect(sameVersion("01.000.016", "1.0.16")).toBe(true);
|
||||
expect(sameVersion("01.05", "1.5")).toBe(true);
|
||||
expect(sameVersion("01.05", "01.10")).toBe(false);
|
||||
expect(sameVersion("", "01.00")).toBe(false);
|
||||
expect(sameVersion("01.00", null)).toBe(false);
|
||||
});
|
||||
it("ranks the installed version first, then newest", () => {
|
||||
const e = (filename: string): CheatEntry => {
|
||||
const [titleId, gameVersion] = parseCheatFilename(filename);
|
||||
return { filename, gameTitle: "", format: "json", repoId: "etahen", titleId, gameVersion };
|
||||
};
|
||||
const list = [e("CUSA00001_01.00.json"), e("CUSA00001_01.10.json"), e("CUSA00001_01.05.json"), e("CUSA00001.json"), e("CUSA00001_01.05_2.json")];
|
||||
expect(rankEntries(list, "01.05").map((x) => x.filename)).toEqual([
|
||||
"CUSA00001_01.05.json",
|
||||
"CUSA00001_01.05_2.json",
|
||||
"CUSA00001_01.10.json",
|
||||
"CUSA00001_01.00.json",
|
||||
"CUSA00001.json",
|
||||
]);
|
||||
expect(rankEntries(list, null).map((x) => x.filename)).toEqual([
|
||||
"CUSA00001_01.10.json",
|
||||
"CUSA00001_01.05.json",
|
||||
"CUSA00001_01.05_2.json",
|
||||
"CUSA00001_01.00.json",
|
||||
"CUSA00001.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const tree = (paths: string[]) => JSON.stringify({ tree: paths.map((path) => ({ type: "blob", path })).concat([{ type: "tree", path: "cheats/json" } as never]) });
|
||||
|
||||
describe("reading the repos", () => {
|
||||
beforeEach(() => resetRepoIndex());
|
||||
|
||||
it("lists every repo, first repo wins a file name", async () => {
|
||||
const { f } = net({
|
||||
[`${ETA.rawBase}json.txt`]: "CUSA15438_01.00.json=Game A\nCUSA15438_01.02.json=Game A\n",
|
||||
[`${ETA.rawBase}shn.txt`]: "# none\n",
|
||||
[`${ETA.rawBase}mc4.txt`]: 404,
|
||||
[`${GOLD.rawBase}json.txt`]: "CUSA15438_01.00.json=Game A (gold)\r\nCUSA99999_01.00.json=Other\r\n",
|
||||
[`${GOLD.rawBase}shn.txt`]: "CUSA15438_01.02.shn=Game A\n",
|
||||
[`${GOLD.rawBase}mc4.txt`]: "CUSA15438_01.02.mc4=Game A\n",
|
||||
[MIX.treeApi]: tree(["cheats/json/CUSA15438_01.03.json", "cheats/README.md", "cheats/json/CUSA15438_01.00.json"]),
|
||||
});
|
||||
const idx = await readRepoIndex(f);
|
||||
expect(idx.offline).toBe(false);
|
||||
expect(idx.failed).toEqual([]);
|
||||
const mine = entriesFor(idx, "cusa15438");
|
||||
expect(mine.map((e) => `${e.repoId}:${e.filename}:${e.format}`).sort()).toEqual(
|
||||
[
|
||||
"etahen:CUSA15438_01.00.json:json",
|
||||
"etahen:CUSA15438_01.02.json:json",
|
||||
"goldhen:CUSA15438_01.02.shn:shn",
|
||||
"goldhen:CUSA15438_01.02.mc4:mc4",
|
||||
"henmix:CUSA15438_01.03.json:json",
|
||||
].sort(),
|
||||
);
|
||||
expect(mine.find((e) => e.filename === "CUSA15438_01.00.json")?.gameTitle).toBe("Game A");
|
||||
expect(entriesFor(idx, "CUSA99999")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a rate-limited tree is that source unavailable, not offline", async () => {
|
||||
const { f } = net({
|
||||
[`${ETA.rawBase}json.txt`]: "CUSA1_01.00.json=A\n",
|
||||
[`${ETA.rawBase}shn.txt`]: 404,
|
||||
[`${ETA.rawBase}mc4.txt`]: 404,
|
||||
[`${GOLD.rawBase}json.txt`]: "down",
|
||||
[`${GOLD.rawBase}shn.txt`]: "down",
|
||||
[`${GOLD.rawBase}mc4.txt`]: "down",
|
||||
[MIX.treeApi]: 403,
|
||||
});
|
||||
const idx = await readRepoIndex(f);
|
||||
expect(idx.offline).toBe(false);
|
||||
expect(idx.failed.sort()).toEqual(["goldhen", "henmix"]);
|
||||
expect(idx.entries.map((e) => e.filename)).toEqual(["CUSA1_01.00.json"]);
|
||||
});
|
||||
|
||||
it("nothing answering is offline (no internet, or GitHub blocked by DNS)", async () => {
|
||||
const { f } = net({});
|
||||
const idx = await readRepoIndex(f);
|
||||
expect(idx.offline).toBe(true);
|
||||
expect(idx.failed).toEqual(["etahen", "goldhen", "henmix"]);
|
||||
});
|
||||
|
||||
it("a garbled tree reply is that source failing", async () => {
|
||||
const { f } = net({ [`${ETA.rawBase}json.txt`]: "CUSA1_01.00.json=A\n", [MIX.treeApi]: "<html>" });
|
||||
const idx = await readRepoIndex(f);
|
||||
expect(idx.failed).toContain("henmix");
|
||||
expect(idx.offline).toBe(false);
|
||||
});
|
||||
|
||||
it("is read once per session, again after a failed source", async () => {
|
||||
const ok = net({
|
||||
[`${ETA.rawBase}json.txt`]: "CUSA1_01.00.json=A\n",
|
||||
[`${ETA.rawBase}shn.txt`]: "",
|
||||
[`${ETA.rawBase}mc4.txt`]: "",
|
||||
[`${GOLD.rawBase}json.txt`]: "",
|
||||
[`${GOLD.rawBase}shn.txt`]: "",
|
||||
[`${GOLD.rawBase}mc4.txt`]: "",
|
||||
[MIX.treeApi]: tree([]),
|
||||
});
|
||||
await repoIndex(ok.f);
|
||||
const n = ok.calls.length;
|
||||
await repoIndex(ok.f);
|
||||
expect(ok.calls.length).toBe(n);
|
||||
|
||||
resetRepoIndex();
|
||||
const bad = net({ [`${ETA.rawBase}json.txt`]: "CUSA1_01.00.json=A\n" });
|
||||
await repoIndex(bad.f);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const m = bad.calls.length;
|
||||
await repoIndex(bad.f);
|
||||
expect(bad.calls.length).toBe(2 * m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloading a file", () => {
|
||||
const entry: CheatEntry = {
|
||||
filename: "CUSA1_01.00.shn",
|
||||
gameTitle: "",
|
||||
format: "shn",
|
||||
repoId: "henmix",
|
||||
titleId: "CUSA1",
|
||||
gameVersion: "01.00",
|
||||
};
|
||||
it("takes the first content URL that answers", async () => {
|
||||
const urls = contentUrls(MIX, entry.filename);
|
||||
const { f, calls } = net({ [urls[0]]: 404, [urls[1]]: "<Trainer/>" });
|
||||
const buf = await downloadCheat(entry, f);
|
||||
expect(new TextDecoder().decode(buf)).toBe("<Trainer/>");
|
||||
expect(calls).toEqual(urls.slice(0, 2));
|
||||
});
|
||||
it("says not found when every URL answered an error", async () => {
|
||||
const urls = contentUrls(MIX, entry.filename);
|
||||
const { f } = net(Object.fromEntries(urls.map((u) => [u, 404])));
|
||||
await expect(downloadCheat(entry, f)).rejects.toMatchObject({ network: false, message: "not_found" });
|
||||
});
|
||||
it("says unreachable when nothing answered", async () => {
|
||||
const { f } = net({});
|
||||
const err = await downloadCheat(entry, f).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(RepoError);
|
||||
expect((err as RepoError).network).toBe(true);
|
||||
});
|
||||
it("refuses a file over the helper's limit", async () => {
|
||||
const urls = contentUrls(MIX, entry.filename);
|
||||
const { f } = net({ [urls[0]]: new ArrayBuffer(CHEAT_FILE_MAX + 1) });
|
||||
await expect(downloadCheat(entry, f)).rejects.toMatchObject({ message: "too_large" });
|
||||
});
|
||||
it("never_fetches_a_name_the_helper_would_refuse", async () => {
|
||||
const { f, calls } = net({});
|
||||
await expect(downloadCheat({ ...entry, filename: "CUSA1 copy.shn" }, f)).rejects.toMatchObject({ message: "bad_name" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
it("a_stalled_body_times_out_instead_of_hanging", async () => {
|
||||
const urls = contentUrls(MIX, entry.filename);
|
||||
const f: FetchLike = async (url) =>
|
||||
url === urls[0]
|
||||
? new Response(new ReadableStream({ start: (c) => c.enqueue(new Uint8Array([1, 2])) }), { status: 200 })
|
||||
: new Response("404", { status: 404 });
|
||||
const err = await downloadCheat(entry, f, 50).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ network: true, message: "cut_off" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded reads (fetchLimited)", () => {
|
||||
const chunked = (sizes: number[]) => {
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(c) {
|
||||
if (pulled >= sizes.length) return c.close();
|
||||
c.enqueue(new Uint8Array(sizes[pulled++]));
|
||||
},
|
||||
});
|
||||
return { body, pulled: () => pulled };
|
||||
};
|
||||
it("stops reading past the limit", async () => {
|
||||
const big = chunked([600, 600, 600, 600, 600]);
|
||||
const f: FetchLike = async () => new Response(big.body, { status: 200 });
|
||||
await expect(fetchLimited(f, "u", 1000, 1000)).rejects.toMatchObject({ message: "too_large", network: false });
|
||||
expect(big.pulled()).toBeLessThan(5);
|
||||
});
|
||||
it("reads a body in chunks", async () => {
|
||||
const f: FetchLike = async () => new Response(chunked([3, 4]).body, { status: 200 });
|
||||
const r = await fetchLimited(f, "u", 1000, 7);
|
||||
expect(r.ok && r.body.byteLength).toBe(7);
|
||||
});
|
||||
it("refuses a declared length over the limit without reading", async () => {
|
||||
const f: FetchLike = async () => new Response("x".repeat(10), { status: 200, headers: { "Content-Length": "5000" } });
|
||||
await expect(fetchLimited(f, "u", 1000, 100)).rejects.toMatchObject({ message: "too_large" });
|
||||
});
|
||||
it("falls back to arrayBuffer without a body stream", async () => {
|
||||
const res = { ok: true, status: 200, headers: new Headers(), body: null, arrayBuffer: async () => new ArrayBuffer(9) } as unknown as Response;
|
||||
const r = await fetchLimited(async () => res, "u", 1000, 10);
|
||||
expect(r.ok && r.body.byteLength).toBe(9);
|
||||
const big = { ...res, arrayBuffer: async () => new ArrayBuffer(11) } as unknown as Response;
|
||||
await expect(fetchLimited(async () => big, "u", 1000, 10)).rejects.toMatchObject({ message: "too_large" });
|
||||
});
|
||||
it("an_http_error_is_not_ok", async () => {
|
||||
const r = await fetchLimited(async () => new Response("no", { status: 403 }), "u", 1000, 10);
|
||||
expect(r).toEqual({ ok: false, status: 403 });
|
||||
});
|
||||
it("a_reply_that_never_comes_times_out", async () => {
|
||||
const r = fetchLimited(() => new Promise<Response>(() => {}), "u", 30, 10);
|
||||
await expect(r).rejects.toMatchObject({ network: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* The community cheat repos, read by the PS5 browser itself (the console page has no PC engine to
|
||||
* ask). A port of the PC engine's engine/crates/ps5upload-core/src/cheats.rs (cheat_repos,
|
||||
* parse_cheat_filename, parse_index_line, format_from_index, tree_entry_from_path,
|
||||
* cheats_repo_search's de-duplication, cheats_repo_download's content URLs): keep the two in step.
|
||||
*
|
||||
* Both hosts answer CORS-open (Access-Control-Allow-Origin: *), and the helper's CSP allows exactly
|
||||
* these two in connect-src (payload/src/webui_api.c PAGE_HEADERS).
|
||||
*/
|
||||
|
||||
export type CheatFormat = "json" | "shn" | "mc4";
|
||||
|
||||
export interface CheatRepo {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Where the index files live (`json.txt`, ...). */
|
||||
rawBase: string;
|
||||
/** Index files in the repo root; empty when the repo is listed through `treeApi`. */
|
||||
indexFiles: string[];
|
||||
/** Where the `json/`, `shn/`, `mc4/` folders live (not always the root). */
|
||||
contentBase: string;
|
||||
/** GitHub git-trees API URL for a repo that publishes no index ("" = use indexFiles). */
|
||||
treeApi: string;
|
||||
/** The folder inside the tree holding the format folders (`cheats/`). */
|
||||
treePrefix: string;
|
||||
}
|
||||
|
||||
/** cheats.rs cheat_repos(), in the same order (the first repo listing a file name wins). */
|
||||
export const CHEAT_REPOS: readonly CheatRepo[] = [
|
||||
{
|
||||
id: "etahen",
|
||||
name: "etaHEN/PS5_Cheats",
|
||||
rawBase: "https://raw.githubusercontent.com/etaHEN/PS5_Cheats/main/",
|
||||
indexFiles: ["json.txt", "shn.txt", "mc4.txt"],
|
||||
contentBase: "https://raw.githubusercontent.com/etaHEN/PS5_Cheats/main/",
|
||||
treeApi: "",
|
||||
treePrefix: "",
|
||||
},
|
||||
{
|
||||
id: "goldhen",
|
||||
name: "GoldHEN/GoldHEN_Cheat_Repository",
|
||||
rawBase: "https://raw.githubusercontent.com/GoldHEN/GoldHEN_Cheat_Repository/main/",
|
||||
indexFiles: ["json.txt", "shn.txt", "mc4.txt"],
|
||||
contentBase: "https://raw.githubusercontent.com/GoldHEN/GoldHEN_Cheat_Repository/main/",
|
||||
treeApi: "",
|
||||
treePrefix: "",
|
||||
},
|
||||
{
|
||||
// No `*.txt` index, `master`, cheats nested under `cheats/`: listed through the git-trees API
|
||||
// (60 requests an hour without a login, so a failure is "this source is unavailable").
|
||||
id: "henmix",
|
||||
name: "TeeKay87/HEN-Cheats-Collection",
|
||||
rawBase: "https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/",
|
||||
indexFiles: [],
|
||||
contentBase: "https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/",
|
||||
treeApi: "https://api.github.com/repos/TeeKay87/HEN-Cheats-Collection/git/trees/master?recursive=1",
|
||||
treePrefix: "cheats/",
|
||||
},
|
||||
];
|
||||
|
||||
export interface CheatEntry {
|
||||
filename: string;
|
||||
/** The game's name from the index ("" for a tree-listed repo). */
|
||||
gameTitle: string;
|
||||
format: CheatFormat;
|
||||
repoId: string;
|
||||
/** From the file name; "" when it breaks the convention. */
|
||||
titleId: string;
|
||||
/** The game version the cheat was made for; "" when the name does not say. */
|
||||
gameVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* cheats.rs parse_cheat_filename: `<TITLE_ID>_<VERSION>[_<variant>].<ext>` → [title id, version].
|
||||
* A title id is four letters then five digits; the version is the next segment when it is digits
|
||||
* and dots with a dot (`_2` after it is a variant counter). Empty strings when it does not fit.
|
||||
*/
|
||||
export function parseCheatFilename(filename: string): [string, string] {
|
||||
const dot = filename.lastIndexOf(".");
|
||||
const stem = dot >= 0 ? filename.slice(0, dot) : filename;
|
||||
const parts = stem.split("_");
|
||||
const title = parts[0] ?? "";
|
||||
if (!/^[A-Za-z]{4}[0-9]{5}$/.test(title)) return ["", ""];
|
||||
const v = parts[1];
|
||||
const version = v !== undefined && v !== "" && /^[0-9.]+$/.test(v) && v.includes(".") ? v : "";
|
||||
return [title.toUpperCase(), version];
|
||||
}
|
||||
|
||||
/** cheats.rs parse_index_line: `filename=game title`; blank and `#` lines skipped. */
|
||||
export function parseIndexLine(line: string): [string, string] | null {
|
||||
const l = line.trim();
|
||||
if (!l || l.startsWith("#")) return null;
|
||||
const eq = l.indexOf("=");
|
||||
if (eq < 0) return null;
|
||||
const filename = l.slice(0, eq).trim();
|
||||
if (!filename) return null;
|
||||
return [filename, l.slice(eq + 1).trim()];
|
||||
}
|
||||
|
||||
/** cheats.rs format_from_index. */
|
||||
export function formatFromIndex(indexName: string): CheatFormat {
|
||||
if (indexName === "shn.txt") return "shn";
|
||||
if (indexName === "mc4.txt") return "mc4";
|
||||
return "json";
|
||||
}
|
||||
|
||||
/**
|
||||
* cheats.rs tree_entry_from_path: exactly `<prefix><format>/<filename>`, the format a known folder and
|
||||
* the file's extension agreeing with it (any case); anything else (nested, README, unknown) null.
|
||||
*/
|
||||
export function treeEntryFromPath(path: string, prefix: string): [string, CheatFormat] | null {
|
||||
if (!path.startsWith(prefix)) return null;
|
||||
const rest = path.slice(prefix.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash < 0) return null;
|
||||
const dir = rest.slice(0, slash);
|
||||
const filename = rest.slice(slash + 1);
|
||||
if (!filename || filename.includes("/")) return null;
|
||||
if (dir !== "json" && dir !== "shn" && dir !== "mc4") return null;
|
||||
const dot = filename.lastIndexOf(".");
|
||||
if (dot < 0) return null;
|
||||
if (filename.slice(dot + 1).toLowerCase() !== dir) return null;
|
||||
return [filename, dir];
|
||||
}
|
||||
|
||||
/**
|
||||
* cheats_repo_download's URLs, tried in this order until one answers: `<content base><dir>/<file>`
|
||||
* for json, shn, mc4, misc (the same order as the engine, whatever the entry's own format).
|
||||
*/
|
||||
export function contentUrls(repo: CheatRepo, filename: string): string[] {
|
||||
const base = repo.contentBase || repo.rawBase;
|
||||
return ["json", "shn", "mc4", "misc"].map((d) => `${base}${d}/${filename}`);
|
||||
}
|
||||
|
||||
/** The format the file is stored as: by its extension (cheats_repo_download), json by default. */
|
||||
export function storeFormat(filename: string): CheatFormat {
|
||||
const f = filename.toLowerCase();
|
||||
if (f.endsWith(".shn")) return "shn";
|
||||
if (f.endsWith(".mc4")) return "mc4";
|
||||
return "json";
|
||||
}
|
||||
|
||||
/** The helper's rule for a stored name (webui_cheat_name_ok): only these can be downloaded here. */
|
||||
export function storableName(filename: string): boolean {
|
||||
if (filename.length < 1 || filename.length > 128 || filename.startsWith(".") || filename.includes("..")) return false;
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(filename)) return false;
|
||||
const fmt = storeFormat(filename);
|
||||
return filename.toLowerCase().endsWith(`.${fmt}`) && filename.length > fmt.length + 1;
|
||||
}
|
||||
|
||||
// ---- versions (client/src/lib/cheatGames.ts, kept local: the console bundle stays small) ----
|
||||
|
||||
/** Version numbers: `01.000.016` equals `1.0.16`; non-numeric parts count as 0. */
|
||||
export function versionParts(v: string | null | undefined): number[] {
|
||||
return (v ?? "")
|
||||
.trim()
|
||||
.split(".")
|
||||
.filter((p) => p.length > 0)
|
||||
.map((p) => {
|
||||
const n = parseInt(p, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const x = versionParts(a);
|
||||
const y = versionParts(b);
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sameVersion(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
if (versionParts(a).length === 0 || versionParts(b).length === 0) return false;
|
||||
return compareVersions(a, b) === 0;
|
||||
}
|
||||
|
||||
/** A game's cheat files, best first: made for `installed` first, then newest version first. */
|
||||
export function rankEntries(entries: CheatEntry[], installed: string | null | undefined): CheatEntry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
const am = sameVersion(a.gameVersion, installed) ? 0 : 1;
|
||||
const bm = sameVersion(b.gameVersion, installed) ? 0 : 1;
|
||||
if (am !== bm) return am - bm;
|
||||
const v = compareVersions(b.gameVersion, a.gameVersion);
|
||||
if (v !== 0) return v;
|
||||
return a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- reading the repos ----
|
||||
|
||||
/** One source's files as (filename, game title, format). */
|
||||
type Listing = [string, string, CheatFormat][];
|
||||
|
||||
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/** A source this long without an answer counts as unreachable. */
|
||||
export const REPO_TIMEOUT_MS = 20_000;
|
||||
|
||||
export class RepoError extends Error {
|
||||
/** true: nothing answered (no internet, DNS blocked, timed out); false: an HTTP error or bad data. */
|
||||
readonly network: boolean;
|
||||
constructor(network: boolean, message: string) {
|
||||
super(message);
|
||||
this.name = "RepoError";
|
||||
this.network = network;
|
||||
}
|
||||
}
|
||||
|
||||
/** The longest index or tree reply read (the git tree is ~2 MB today). */
|
||||
export const LISTING_MAX = 16 * 1024 * 1024;
|
||||
|
||||
/** A whole GET (headers and body) that must finish within `timeoutMs`. */
|
||||
export type Fetched = { ok: true; body: ArrayBuffer } | { ok: false; status: number };
|
||||
|
||||
/**
|
||||
* GET `url` with one deadline for the reply AND its body (a body that stalls cannot hang the
|
||||
* caller), reading the body in chunks and giving up past `max` bytes (RepoError "too_large").
|
||||
* Nothing answering (or the deadline) is RepoError(network); an HTTP error is { ok: false }.
|
||||
*/
|
||||
export async function fetchLimited(f: FetchLike, url: string, timeoutMs: number, max: number): Promise<Fetched> {
|
||||
const stop = typeof AbortController === "function" ? new AbortController() : null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
stop?.abort();
|
||||
reject(new RepoError(true, `timed out ${url}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
// Every await below races the deadline: a fetch that ignores its signal still cannot hang us.
|
||||
const within = <T,>(p: Promise<T>): Promise<T> => Promise.race([p, deadline]);
|
||||
deadline.catch(() => {});
|
||||
let res: Response;
|
||||
try {
|
||||
try {
|
||||
res = await within(f(url, { method: "GET", cache: "no-store", credentials: "omit", signal: stop?.signal }));
|
||||
} catch (e) {
|
||||
throw e instanceof RepoError ? e : new RepoError(true, `unreachable ${url}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
void res.body?.cancel().catch(() => {});
|
||||
return { ok: false, status: res.status };
|
||||
}
|
||||
const tooLarge = () => {
|
||||
stop?.abort();
|
||||
return new RepoError(false, "too_large");
|
||||
};
|
||||
const len = Number(res.headers.get("Content-Length"));
|
||||
if (Number.isFinite(len) && len > max) {
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw tooLarge();
|
||||
}
|
||||
const reader = res.body && typeof res.body.getReader === "function" ? res.body.getReader() : null;
|
||||
if (!reader) {
|
||||
let buf: ArrayBuffer;
|
||||
try {
|
||||
buf = await within(res.arrayBuffer());
|
||||
} catch (e) {
|
||||
throw e instanceof RepoError ? e : new RepoError(true, `cut off ${url}`);
|
||||
}
|
||||
if (buf.byteLength > max) throw tooLarge();
|
||||
return { ok: true, body: buf };
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await within(reader.read());
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > max) {
|
||||
void reader.cancel().catch(() => {});
|
||||
throw tooLarge();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (e) {
|
||||
void reader.cancel().catch(() => {});
|
||||
throw e instanceof RepoError ? e : new RepoError(true, `cut off ${url}`);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, at);
|
||||
at += c.byteLength;
|
||||
}
|
||||
return { ok: true, body: out.buffer };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchText(f: FetchLike, url: string, timeoutMs: number): Promise<string> {
|
||||
const r = await fetchLimited(f, url, timeoutMs, LISTING_MAX);
|
||||
if (!r.ok) throw new RepoError(false, `http_${r.status}`);
|
||||
return new TextDecoder().decode(r.body);
|
||||
}
|
||||
|
||||
/** One repo's listing: its index files (any that answers counts), or its git tree. */
|
||||
async function listRepo(repo: CheatRepo, f: FetchLike, timeoutMs: number): Promise<Listing> {
|
||||
if (repo.indexFiles.length === 0) {
|
||||
const text = await fetchText(f, repo.treeApi, timeoutMs);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new RepoError(false, "bad_tree");
|
||||
}
|
||||
const nodes = (parsed as { tree?: unknown } | null)?.tree;
|
||||
if (!Array.isArray(nodes)) throw new RepoError(false, "bad_tree");
|
||||
const out: Listing = [];
|
||||
for (const node of nodes as { type?: unknown; path?: unknown }[]) {
|
||||
if (!node || node.type !== "blob" || typeof node.path !== "string") continue;
|
||||
const e = treeEntryFromPath(node.path, repo.treePrefix);
|
||||
if (e) out.push([e[0], "", e[1]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
repo.indexFiles.map(async (idx) => {
|
||||
const text = await fetchText(f, `${repo.rawBase}${idx}`, timeoutMs);
|
||||
const fmt = formatFromIndex(idx);
|
||||
const out: Listing = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const p = parseIndexLine(line);
|
||||
if (p) out.push([p[0], p[1], fmt]);
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
);
|
||||
const ok = results.filter((r): r is PromiseFulfilledResult<Listing> => r.status === "fulfilled");
|
||||
if (ok.length === 0) throw (results[0] as PromiseRejectedResult).reason;
|
||||
return ok.flatMap((r) => r.value);
|
||||
}
|
||||
|
||||
export interface RepoIndex {
|
||||
entries: CheatEntry[];
|
||||
/** Repos that could not be read (their files are missing from entries). */
|
||||
failed: string[];
|
||||
/** Every repo failed and none of them answered at all: the PS5 cannot reach GitHub. */
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every repo's files, de-duplicated by file name across repos (the first repo wins, as in
|
||||
* cheats_repo_search). Repos are read at the same time; one that fails is listed in `failed`.
|
||||
*/
|
||||
export async function readRepoIndex(f: FetchLike, repos: readonly CheatRepo[] = CHEAT_REPOS, timeoutMs = REPO_TIMEOUT_MS): Promise<RepoIndex> {
|
||||
const results = await Promise.allSettled(repos.map((r) => listRepo(r, f, timeoutMs)));
|
||||
const seen = new Set<string>();
|
||||
const entries: CheatEntry[] = [];
|
||||
const failed: string[] = [];
|
||||
let answered = false;
|
||||
results.forEach((r, i) => {
|
||||
const repo = repos[i];
|
||||
if (r.status === "rejected") {
|
||||
failed.push(repo.id);
|
||||
if (!(r.reason instanceof RepoError && r.reason.network)) answered = true;
|
||||
return;
|
||||
}
|
||||
answered = true;
|
||||
for (const [filename, gameTitle, format] of r.value) {
|
||||
if (seen.has(filename)) continue;
|
||||
seen.add(filename);
|
||||
const [titleId, gameVersion] = parseCheatFilename(filename);
|
||||
entries.push({ filename, gameTitle, format, repoId: repo.id, titleId, gameVersion });
|
||||
}
|
||||
});
|
||||
return { entries, failed, offline: !answered };
|
||||
}
|
||||
|
||||
let cached: Promise<RepoIndex> | null = null;
|
||||
|
||||
/**
|
||||
* readRepoIndex once per page session (the git-trees API allows 60 reads an hour). A read where any
|
||||
* repo failed is not kept: the next Find asks again.
|
||||
*/
|
||||
export function repoIndex(f: FetchLike = (u, i) => fetch(u, i)): Promise<RepoIndex> {
|
||||
if (!cached) {
|
||||
const p = readRepoIndex(f);
|
||||
cached = p;
|
||||
p.then(
|
||||
(r) => {
|
||||
if (r.failed.length && cached === p) cached = null;
|
||||
},
|
||||
() => {
|
||||
if (cached === p) cached = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Forget the session's index (tests). */
|
||||
export function resetRepoIndex(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** The entries for one game (by title id, any case). Names the helper cannot store are left out. */
|
||||
export function entriesFor(index: RepoIndex, titleId: string): CheatEntry[] {
|
||||
const id = titleId.toUpperCase();
|
||||
return index.entries.filter((e) => e.titleId === id && storableName(e.filename));
|
||||
}
|
||||
|
||||
/** The helper's limit (cheats.c CHEAT_FILE_MAX): a larger file is never loaded. */
|
||||
export const CHEAT_FILE_MAX = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* One cheat file's bytes from its repo: the content URLs in order (contentUrls), the first that
|
||||
* answers 2xx, read within the timeout and never past CHEAT_FILE_MAX. RepoError(network) when
|
||||
* nothing answered or the body stalled; "too_large"; "not_found" when every URL answered an error;
|
||||
* "bad_name" for a name the helper would refuse (never fetched).
|
||||
*/
|
||||
export async function downloadCheat(entry: CheatEntry, f: FetchLike = (u, i) => fetch(u, i), timeoutMs = REPO_TIMEOUT_MS): Promise<ArrayBuffer> {
|
||||
if (!storableName(entry.filename)) throw new RepoError(false, "bad_name");
|
||||
const repo = CHEAT_REPOS.find((r) => r.id === entry.repoId);
|
||||
if (!repo) throw new RepoError(false, "unknown_repo");
|
||||
let answered = false;
|
||||
for (const url of contentUrls(repo, entry.filename)) {
|
||||
let r: Fetched;
|
||||
try {
|
||||
r = await fetchLimited(f, url, timeoutMs, CHEAT_FILE_MAX);
|
||||
} catch (e) {
|
||||
// Nothing answered at this URL: try the next. A stalled or oversized body ends the download.
|
||||
if (e instanceof RepoError && e.network && /^unreachable /.test(e.message)) continue;
|
||||
throw e instanceof RepoError && e.network ? new RepoError(true, "cut_off") : e;
|
||||
}
|
||||
answered = true;
|
||||
if (!r.ok) continue;
|
||||
if (r.body.byteLength === 0) throw new RepoError(false, "empty");
|
||||
return r.body;
|
||||
}
|
||||
throw new RepoError(!answered, answered ? "not_found" : "unreachable");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/** The PC app's dark-theme tokens, served by vite.console.config.ts (pcTokens). */
|
||||
declare module "virtual:pc-tokens.css";
|
||||
/** The strings' build hash, defined by vite.console.config.ts (consoleStrings); absent under vitest. */
|
||||
declare const __CW_STRINGS_V__: string | undefined;
|
||||
/** The app version the page was built from (vite.console.config.ts); absent under vitest. */
|
||||
declare const __CW_APP_V__: string | undefined;
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Binary,
|
||||
Download,
|
||||
FilePen,
|
||||
FilePlus,
|
||||
ClipboardCopy,
|
||||
FileArchive,
|
||||
FileCheck,
|
||||
Replace,
|
||||
Copy,
|
||||
Eye,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Info,
|
||||
PencilLine,
|
||||
Scissors,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
StarOff,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { t } from "../strings";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { SHOW_ON_PC_ITEM } from "./handoff";
|
||||
|
||||
export type SheetAction =
|
||||
| "open"
|
||||
| "view"
|
||||
| "clipcopy"
|
||||
| "cut"
|
||||
| "copy"
|
||||
| "move"
|
||||
| "rename"
|
||||
| "bulkrename"
|
||||
| "mkdir"
|
||||
| "newfile"
|
||||
| "edit"
|
||||
| "download"
|
||||
| "info"
|
||||
| "pin"
|
||||
| "unpin"
|
||||
| "verify"
|
||||
| "hex"
|
||||
| "unpack"
|
||||
| "showpc"
|
||||
| "fixperm"
|
||||
| "delete";
|
||||
|
||||
const ITEMS: Record<SheetAction, { icon: LucideIcon; key: string }> = {
|
||||
open: { icon: FolderOpen, key: "cw_files_open" },
|
||||
view: { icon: Eye, key: "cw_files_view" },
|
||||
clipcopy: { icon: ClipboardCopy, key: "cw_files_clip_copy" },
|
||||
cut: { icon: Scissors, key: "cw_files_clip_cut" },
|
||||
copy: { icon: Copy, key: "cw_files_copy_to" },
|
||||
move: { icon: FolderInput, key: "cw_files_move_to" },
|
||||
rename: { icon: PencilLine, key: "cw_files_rename" },
|
||||
bulkrename: { icon: Replace, key: "cw_files_bulk_rename" },
|
||||
mkdir: { icon: FolderPlus, key: "cw_files_new_folder" },
|
||||
newfile: { icon: FilePlus, key: "cw_files_new_file" },
|
||||
edit: { icon: FilePen, key: "cw_files_edit_as_text" },
|
||||
download: { icon: Download, key: "cw_files_download" },
|
||||
info: { icon: Info, key: "cw_files_info" },
|
||||
pin: { icon: Star, key: "cw_files_pin" },
|
||||
unpin: { icon: StarOff, key: "cw_files_unpin" },
|
||||
verify: { icon: FileCheck, key: "cw_files_verify" },
|
||||
hex: { icon: Binary, key: "cw_files_vw_as_hex" },
|
||||
unpack: { icon: FileArchive, key: "cw_files_unpack" },
|
||||
showpc: SHOW_ON_PC_ITEM,
|
||||
fixperm: { icon: ShieldCheck, key: "cw_files_fixperm" },
|
||||
delete: { icon: Trash2, key: "cw_files_delete" },
|
||||
};
|
||||
|
||||
/** The ones a running copy/move/delete request holds back (one batch at a time). */
|
||||
const HELD: ReadonlySet<SheetAction> = new Set(["copy", "move", "bulkrename", "unpack", "fixperm", "delete"]);
|
||||
|
||||
/** More actions than this go in two columns. */
|
||||
export const SHEET_ONE_COLUMN = 8;
|
||||
|
||||
/**
|
||||
* △ / "⋯": what can be done with one item or the selection, as a list of
|
||||
* buttons (D-pad + ✕). ○ or △ closes it.
|
||||
*/
|
||||
export function ActionsSheet({
|
||||
title,
|
||||
actions,
|
||||
busy,
|
||||
note,
|
||||
onChoose,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
actions: SheetAction[];
|
||||
/** A copy/move/delete request is running: those are off until it answers. */
|
||||
busy: boolean;
|
||||
/** The running request's progress line. */
|
||||
note?: string | null;
|
||||
onChoose: (a: SheetAction) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// A long list (a file's full menu) in two columns, top to bottom: it fits the TV's 700 px height.
|
||||
const two = actions.length > SHEET_ONE_COLUMN;
|
||||
return (
|
||||
<Modal label={title} className={`cw-sheet${two ? " is-two" : ""}`} onBack={onClose}>
|
||||
<h2 className="cw-sheet-title">{title}</h2>
|
||||
{note ? (
|
||||
<p className="cw-dim" role="status">
|
||||
{note}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className="cw-sheet-items"
|
||||
style={two ? { gridTemplateRows: `repeat(${Math.ceil(actions.length / 2)}, auto)` } : undefined}
|
||||
>
|
||||
{actions.map((a, i) => {
|
||||
const { icon: Icon, key } = ITEMS[a];
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
className={`cw-focusable cw-sheet-item${a === "delete" ? " is-danger" : ""}`}
|
||||
data-sheet={a}
|
||||
data-nav-default={i === 0 ? "" : undefined}
|
||||
disabled={busy && HELD.has(a)}
|
||||
onClick={() => onChoose(a)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
<span>{t(key)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" onClick={onClose}>
|
||||
<span>{t("cw_files_close")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowRight, Square, SquareCheck } from "lucide-react";
|
||||
import { t } from "../strings";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { PhoneHandoff } from "../ui/PhoneHandoff";
|
||||
import { planRenames, type RenameItem, type RenameMode, type RenamePlan, type RenameRow } from "./bulkRename";
|
||||
|
||||
type Kind = RenameMode["kind"];
|
||||
|
||||
const MODES: { kind: Kind; key: string }[] = [
|
||||
{ kind: "replace", key: "cw_files_br_mode_replace" },
|
||||
{ kind: "prefix", key: "cw_files_br_mode_prefix" },
|
||||
{ kind: "suffix", key: "cw_files_br_mode_suffix" },
|
||||
{ kind: "number", key: "cw_files_br_mode_number" },
|
||||
];
|
||||
|
||||
/** Rows drawn in the preview (the plan checks every row; the rest are counted). */
|
||||
export const PREVIEW_ROWS = 60;
|
||||
|
||||
/** Why a row cannot be renamed, as a sentence. */
|
||||
export function problemText(r: RenameRow): string {
|
||||
switch (r.problem) {
|
||||
case "empty":
|
||||
return t("cw_files_br_err_empty", { name: r.from });
|
||||
case "slash":
|
||||
return t("cw_files_br_err_slash", { name: r.from });
|
||||
case "dots":
|
||||
return t("cw_files_br_err_dots", { name: r.from });
|
||||
case "long":
|
||||
return t("cw_files_br_err_long", { name: r.from });
|
||||
case "unchanged":
|
||||
return t("cw_files_br_err_unchanged", { name: r.from });
|
||||
case "exists":
|
||||
return t("cw_files_br_err_exists", { name: r.to });
|
||||
case "twice":
|
||||
return t("cw_files_br_err_twice", { name: r.to });
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** The mode as typed so far. */
|
||||
export interface BulkFields {
|
||||
kind: Kind;
|
||||
find: string;
|
||||
replace: string;
|
||||
caseSensitive: boolean;
|
||||
text: string;
|
||||
template: string;
|
||||
start: string;
|
||||
}
|
||||
|
||||
export const EMPTY_FIELDS: BulkFields = {
|
||||
kind: "replace",
|
||||
find: "",
|
||||
replace: "",
|
||||
caseSensitive: true,
|
||||
text: "",
|
||||
template: "",
|
||||
start: "1",
|
||||
};
|
||||
|
||||
export function modeOf(f: BulkFields): RenameMode {
|
||||
switch (f.kind) {
|
||||
case "replace":
|
||||
return { kind: "replace", find: f.find, replace: f.replace, caseSensitive: f.caseSensitive };
|
||||
case "prefix":
|
||||
return { kind: "prefix", text: f.text };
|
||||
case "suffix":
|
||||
return { kind: "suffix", text: f.text };
|
||||
case "number": {
|
||||
const n = /^\d{1,6}$/.test(f.start.trim()) ? parseInt(f.start.trim(), 10) : 1;
|
||||
return { kind: "number", template: f.template, start: n };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing typed yet for a mode that needs text: the preview waits instead of listing "unchanged". */
|
||||
export function waiting(f: BulkFields): boolean {
|
||||
if (f.kind === "replace") return f.find === "";
|
||||
if (f.kind === "prefix" || f.kind === "suffix") return f.text === "";
|
||||
return false;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
hint,
|
||||
first,
|
||||
numeric,
|
||||
name,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
hint?: string;
|
||||
first?: boolean;
|
||||
numeric?: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="cw-field">
|
||||
<span className="cw-dim">{label}</span>
|
||||
<input
|
||||
className="cw-focusable cw-input"
|
||||
type="text"
|
||||
inputMode={numeric ? "numeric" : undefined}
|
||||
value={value}
|
||||
data-br-field={name}
|
||||
data-nav-default={first ? "" : undefined}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{hint ? <span className="cw-dim cw-br-hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename several items (or one) by a rule: find and replace, add a prefix,
|
||||
* add a suffix (before the extension), or numbering. The preview shows old →
|
||||
* new for each; Rename (✕) is held while any new name cannot be used, and the
|
||||
* first reason shows under the preview.
|
||||
*/
|
||||
export function BulkRenameDialog({
|
||||
items,
|
||||
existing,
|
||||
initial = EMPTY_FIELDS,
|
||||
onRename,
|
||||
onCancel,
|
||||
}: {
|
||||
/** The entries to rename, in the folder's order (numbering follows it). */
|
||||
items: RenameItem[];
|
||||
/** Every name in the folder (the items' own included). */
|
||||
existing: string[];
|
||||
initial?: BulkFields;
|
||||
onRename: (plan: RenamePlan) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [f, setF] = useState<BulkFields>(initial);
|
||||
const set = (patch: Partial<BulkFields>) => setF((x) => ({ ...x, ...patch }));
|
||||
const plan = useMemo(() => planRenames(items, existing, modeOf(f)), [items, existing, f]);
|
||||
const wait = waiting(f);
|
||||
const title = items.length === 1 ? t("cw_files_br_title_one", { name: items[0].name }) : t("cw_files_br_title", { n: items.length });
|
||||
const shown = plan.rows.slice(0, PREVIEW_ROWS);
|
||||
return (
|
||||
<Modal label={title} className="cw-bulk-rename" onBack={onCancel}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (plan.ok && !wait) onRename(plan);
|
||||
}}
|
||||
>
|
||||
<h2 className="cw-sheet-title">{title}</h2>
|
||||
<div className="cw-br-modes" role="radiogroup" aria-label={t("cw_files_br_mode")}>
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.kind}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={f.kind === m.kind}
|
||||
className={`cw-focusable cw-button cw-br-mode${f.kind === m.kind ? " is-on" : ""}`}
|
||||
data-br-mode={m.kind}
|
||||
onClick={() => set({ kind: m.kind })}
|
||||
>
|
||||
<span>{t(m.key)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cw-br-body">
|
||||
<div className="cw-br-fields">
|
||||
{f.kind === "replace" ? (
|
||||
<>
|
||||
<Field name="find" label={t("cw_files_br_find")} value={f.find} first onChange={(v) => set({ find: v })} />
|
||||
<Field name="replace" label={t("cw_files_br_replace")} value={f.replace} onChange={(v) => set({ replace: v })} />
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-conflict-all"
|
||||
data-action="br-case"
|
||||
aria-pressed={f.caseSensitive}
|
||||
onClick={() => set({ caseSensitive: !f.caseSensitive })}
|
||||
>
|
||||
{f.caseSensitive ? <SquareCheck aria-hidden /> : <Square aria-hidden />}
|
||||
<span>{t("cw_files_br_case")}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{f.kind === "prefix" ? (
|
||||
<Field name="text" label={t("cw_files_br_prefix")} value={f.text} first onChange={(v) => set({ text: v })} />
|
||||
) : null}
|
||||
{f.kind === "suffix" ? (
|
||||
<Field
|
||||
name="text"
|
||||
label={t("cw_files_br_suffix")}
|
||||
value={f.text}
|
||||
first
|
||||
hint={t("cw_files_br_suffix_hint")}
|
||||
onChange={(v) => set({ text: v })}
|
||||
/>
|
||||
) : null}
|
||||
{f.kind === "number" ? (
|
||||
<>
|
||||
<Field
|
||||
name="template"
|
||||
label={t("cw_files_br_template")}
|
||||
value={f.template}
|
||||
first
|
||||
hint={t("cw_files_br_template_hint")}
|
||||
onChange={(v) => set({ template: v })}
|
||||
/>
|
||||
<Field name="start" label={t("cw_files_br_start")} value={f.start} numeric onChange={(v) => set({ start: v })} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="cw-br-preview" aria-label={t("cw_files_br_preview")}>
|
||||
<p className="cw-dim">{t("cw_files_br_preview")}</p>
|
||||
{wait ? (
|
||||
<p className="cw-dim">{t("cw_files_br_waiting")}</p>
|
||||
) : (
|
||||
<ul className="cw-br-rows">
|
||||
{shown.map((r) => (
|
||||
<li key={r.from} className={`cw-br-row${r.problem ? " is-bad" : ""}`} data-br-row={r.from}>
|
||||
<span className="cw-br-from">{r.from}</span>
|
||||
<ArrowRight aria-hidden />
|
||||
<span className="cw-br-to">{r.to || "—"}</span>
|
||||
</li>
|
||||
))}
|
||||
{plan.rows.length > shown.length ? (
|
||||
<li className="cw-dim">{t("cw_files_br_more", { n: plan.rows.length - shown.length })}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
<PhoneHandoff />
|
||||
</div>
|
||||
</div>
|
||||
{!wait && plan.first ? (
|
||||
<p className="cw-field-error" role="alert" data-br-problem={plan.first.problem ?? ""}>
|
||||
{problemText(plan.first)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" onClick={onCancel}>
|
||||
<span>{t("cw_dialog_cancel")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="cw-focusable cw-button is-primary"
|
||||
data-action="br-go"
|
||||
disabled={wait || !plan.ok}
|
||||
>
|
||||
<span>{t("cw_files_br_go", { n: items.length })}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { Square, SquareCheck } from "lucide-react";
|
||||
import { t } from "../strings";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { baseName } from "./move";
|
||||
import type { Conflict, Decision } from "./paste";
|
||||
|
||||
/**
|
||||
* A paste met an item that is already there: Skip (the safe choice, focused
|
||||
* first; ○ too), Keep both, or Replace, which asks once more as a danger
|
||||
* confirm (the existing item is deleted). "Apply to all" answers the rest of
|
||||
* this paste's conflicts the same way.
|
||||
*/
|
||||
export function ConflictDialog({ conflict, onAnswer }: { conflict: Conflict; onAnswer: (d: Decision) => void }) {
|
||||
const [all, setAll] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const name = baseName(conflict.dest);
|
||||
if (confirming) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title={t(all ? "cw_files_conflict_replace_all_title" : "cw_files_conflict_replace_title", { name })}
|
||||
body={t(
|
||||
all
|
||||
? "cw_files_conflict_replace_all_body"
|
||||
: conflict.destDir
|
||||
? "cw_files_conflict_replace_dir_body"
|
||||
: "cw_files_conflict_replace_file_body",
|
||||
)}
|
||||
confirmLabel={t("cw_files_conflict_replace")}
|
||||
danger
|
||||
onConfirm={() => onAnswer({ choice: "replace", all })}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const skip = () => onAnswer({ choice: "skip", all });
|
||||
return (
|
||||
<Modal label={t("cw_files_conflict_title", { name })} className="cw-conflict" onBack={skip}>
|
||||
<h2 className="cw-sheet-title">{t("cw_files_conflict_title", { name })}</h2>
|
||||
<p className="cw-dim cw-mono">{conflict.dest}</p>
|
||||
<p>{t(conflict.canReplace ? "cw_files_conflict_body" : "cw_files_conflict_body_self")}</p>
|
||||
{conflict.left > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-conflict-all"
|
||||
data-action="conflict-all"
|
||||
aria-pressed={all}
|
||||
onClick={() => setAll((v) => !v)}
|
||||
>
|
||||
{all ? <SquareCheck aria-hidden /> : <Square aria-hidden />}
|
||||
<span>{t("cw_files_conflict_all", { n: conflict.left })}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" data-conflict="skip" data-nav-default onClick={skip}>
|
||||
<span>{t("cw_files_conflict_skip")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-conflict="keep"
|
||||
onClick={() => onAnswer({ choice: "keep", all })}
|
||||
>
|
||||
<span>{t("cw_files_conflict_keep")}</span>
|
||||
</button>
|
||||
{conflict.canReplace ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button is-danger"
|
||||
data-conflict="replace"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
<span>{t("cw_files_conflict_replace")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Archive,
|
||||
Disc3,
|
||||
File,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Link2,
|
||||
Package,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { t } from "../strings";
|
||||
import { ActionsButton } from "../ui/ActionsButton";
|
||||
import { iconKind, type IconKind } from "./listing";
|
||||
import type { Entry } from "./useDir";
|
||||
|
||||
/** What the viewer can show: images and text (others are not rendered). */
|
||||
export function viewKind(name: string): "image" | "text" | null {
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const ext = name.slice(dot + 1).toLowerCase();
|
||||
if (ext === "png" || ext === "jpg" || ext === "jpeg") return "image";
|
||||
if (ext === "txt" || ext === "log" || ext === "json" || ext === "ini" || ext === "xml") return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
const ICONS: Record<IconKind, LucideIcon> = {
|
||||
folder: Folder,
|
||||
link: Link2,
|
||||
image: FileImage,
|
||||
text: FileText,
|
||||
disk: Disc3,
|
||||
package: Package,
|
||||
exec: FileCode2,
|
||||
archive: Archive,
|
||||
video: FileVideo,
|
||||
other: File,
|
||||
};
|
||||
|
||||
/** Rows drawn at first, and added per "Show more rows" (a 20,000-row folder keeps a small DOM). */
|
||||
export const ROW_WINDOW = 300;
|
||||
|
||||
/**
|
||||
* A folder's entries, one row each: ✕ opens (a folder, a viewable file) or,
|
||||
* in select mode, marks the row; "⋯" (or △) opens its actions. Names are text
|
||||
* nodes only, so any name (quotes, CJK, markup) shows as it is.
|
||||
*/
|
||||
export function DirList({
|
||||
entries,
|
||||
selecting,
|
||||
selected,
|
||||
onEntry,
|
||||
onToggleItem,
|
||||
onActions,
|
||||
label,
|
||||
limit = Infinity,
|
||||
onMoreRows,
|
||||
uploading,
|
||||
}: {
|
||||
entries: Entry[];
|
||||
selecting: boolean;
|
||||
selected: string[];
|
||||
onEntry: (e: Entry) => void;
|
||||
onToggleItem: (name: string) => void;
|
||||
onActions: (name: string) => void;
|
||||
label?: string;
|
||||
/** Draw only the first `limit` rows; the rest wait behind "Show more rows". */
|
||||
limit?: number;
|
||||
onMoreRows?: () => void;
|
||||
/** Entries a running PC upload is writing into: an "Uploading" badge. */
|
||||
uploading?: ReadonlySet<string>;
|
||||
}) {
|
||||
const marked = new Set(selected);
|
||||
const drawn = entries.length > limit ? entries.slice(0, limit) : entries;
|
||||
return (
|
||||
<ul className="cw-files" data-nav-list aria-label={label}>
|
||||
{drawn.map((e) => {
|
||||
const Icon = ICONS[iconKind(e)];
|
||||
const on = marked.has(e.name);
|
||||
return (
|
||||
<li key={e.name} className={`cw-file${on ? " is-selected" : ""}`} data-nav-item data-entry={e.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-file-main"
|
||||
data-entry-main=""
|
||||
aria-pressed={selecting ? on : undefined}
|
||||
onClick={() => (selecting ? onToggleItem(e.name) : onEntry(e))}
|
||||
>
|
||||
<Icon className="cw-file-icon" aria-hidden />
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">{e.name}</span>
|
||||
{uploading?.has(e.name) ? (
|
||||
<span className="cw-tag is-pc" data-uploading="">
|
||||
{t("cw_files_uploading")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="cw-file-meta cw-dim">
|
||||
{e.kind === "dir" ? t("cw_files_kind_dir") : formatBytes(e.size)}
|
||||
</span>
|
||||
</button>
|
||||
<ActionsButton onPress={() => onActions(e.name)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{drawn.length < entries.length && onMoreRows ? (
|
||||
<li className="cw-file is-plain" data-nav-item>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-file-main"
|
||||
data-action="more-rows"
|
||||
onFocus={onMoreRows}
|
||||
onClick={onMoreRows}
|
||||
>
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">{t("cw_files_rows_more", { n: entries.length - drawn.length })}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Disc, Eject, HardDrive, History, RotateCw, Star, TextCursorInput, Usb } from "lucide-react";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { isDrive, volumeLabel, type Volume } from "../home/Home";
|
||||
import { t } from "../strings";
|
||||
import { ActionsButton } from "../ui/ActionsButton";
|
||||
import { baseName, volumeOf } from "./move";
|
||||
import type { FilesPrefs } from "./prefs";
|
||||
|
||||
const isUsb = (v: Volume) => /^\/mnt\/usb\d+$/.test(v.path) || v.mount_from.startsWith("/dev/da");
|
||||
|
||||
/** A USB stick the helper can eject (USB_EJECT takes /mnt/usb0 to /mnt/usb7). */
|
||||
export const canEject = (v: Volume) => /^\/mnt\/usb[0-7]$/.test(v.path);
|
||||
|
||||
/** The console's own volumes: never unmounted from here. */
|
||||
export function isSystemVolume(path: string): boolean {
|
||||
return (
|
||||
path === "/" ||
|
||||
/^\/(data|user)(\/|$)/.test(path) ||
|
||||
/^\/(system|preinst|dev)/.test(path) ||
|
||||
/^\/mnt\/(usb|ext)\d+$/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
/** A disk image PS5Upload mounted (the helper names its source, or it sits under /mnt/ps5upload). */
|
||||
export function isImage(v: Volume): boolean {
|
||||
return !isDrive(v) && (!!v.source_image || v.path.startsWith("/mnt/ps5upload/")) && !isSystemVolume(v.path);
|
||||
}
|
||||
|
||||
/** A pinned or recent folder's name: its drive's name at a drive's top, else its last folder. */
|
||||
export function placeLabel(path: string, volumes: Volume[]): string {
|
||||
const v = volumeOf(path, volumes);
|
||||
if (v && v.path.replace(/\/+$/, "") === path && isDrive(v)) return volumeLabel(v);
|
||||
return baseName(path) || "/";
|
||||
}
|
||||
|
||||
function Places({
|
||||
kind,
|
||||
paths,
|
||||
volumes,
|
||||
onOpen,
|
||||
onActions,
|
||||
}: {
|
||||
kind: "pinned" | "recent";
|
||||
paths: string[];
|
||||
volumes: Volume[];
|
||||
onOpen: (path: string) => void;
|
||||
onActions: (path: string) => void;
|
||||
}) {
|
||||
if (!paths.length) return null;
|
||||
const Icon = kind === "pinned" ? Star : History;
|
||||
const title = t(kind === "pinned" ? "cw_files_pinned" : "cw_files_recent");
|
||||
return (
|
||||
<section className="cw-places">
|
||||
<h2 className="cw-h2">{title}</h2>
|
||||
<ul className="cw-files" data-nav-list aria-label={title}>
|
||||
{paths.map((p) => (
|
||||
<li key={p} className="cw-file" data-nav-item data-place={p} data-place-kind={kind}>
|
||||
<button type="button" className="cw-focusable cw-file-main" data-place-main="" onClick={() => onOpen(p)}>
|
||||
<Icon className={`cw-file-icon${kind === "pinned" ? " is-pinned" : ""}`} aria-hidden />
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">{placeLabel(p, volumes)}</span>
|
||||
<span className="cw-dim cw-mono cw-file-sub">{p}</span>
|
||||
</span>
|
||||
</button>
|
||||
<ActionsButton onPress={() => onActions(p)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files' first level: Pinned folders, one row per drive (internal, extended/M.2,
|
||||
* USB) with its free space and, for a USB drive, Eject; images PS5Upload mounted,
|
||||
* with Unmount; then Recent folders. ✕ opens; "⋯" (or △) on a pinned or recent
|
||||
* folder offers Open and Pin / Unpin.
|
||||
*/
|
||||
export function DriveList({
|
||||
volumes,
|
||||
error,
|
||||
prefs,
|
||||
onOpen,
|
||||
onRetry,
|
||||
onEject,
|
||||
onUnmount,
|
||||
onPlaceActions,
|
||||
onGoTo,
|
||||
unmountHeld,
|
||||
}: {
|
||||
volumes: Volume[] | null;
|
||||
error: boolean;
|
||||
/** Pinned and recent folders; null hides both lists (not read yet, or an older helper). */
|
||||
prefs?: FilesPrefs | null;
|
||||
onOpen: (path: string) => void;
|
||||
/** Read the drives again. */
|
||||
onRetry?: () => void;
|
||||
onEject?: (path: string) => void;
|
||||
onUnmount?: (path: string) => void;
|
||||
onPlaceActions?: (path: string) => void;
|
||||
onGoTo?: () => void;
|
||||
/** Why Unmount is held now (a game is running), shown in its place. */
|
||||
unmountHeld?: string | null;
|
||||
}) {
|
||||
const all = volumes ?? [];
|
||||
const drives = all.filter(isDrive);
|
||||
const images = all.filter(isImage);
|
||||
const placeActions = onPlaceActions ?? (() => undefined);
|
||||
const goTo = onGoTo ? (
|
||||
<div className="cw-toolbar">
|
||||
<button type="button" className="cw-focusable cw-button" data-action="goto" onClick={onGoTo}>
|
||||
<TextCursorInput aria-hidden />
|
||||
<span>{t("cw_files_goto")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
const pinned = prefs ? (
|
||||
<Places kind="pinned" paths={prefs.favorites} volumes={all} onOpen={onOpen} onActions={placeActions} />
|
||||
) : null;
|
||||
const recent = prefs ? (
|
||||
<Places kind="recent" paths={prefs.recent} volumes={all} onOpen={onOpen} onActions={placeActions} />
|
||||
) : null;
|
||||
|
||||
let driveRows;
|
||||
if (!drives.length) {
|
||||
driveRows = !error ? (
|
||||
<p className="cw-dim">{t("cw_home_loading")}</p>
|
||||
) : (
|
||||
<div className="cw-msg is-error" role="alert">
|
||||
{t("cw_home_err_load_storage")}
|
||||
{onRetry ? (
|
||||
<button type="button" className="cw-focusable cw-button cw-msg-action" data-action="retry" onClick={onRetry}>
|
||||
<RotateCw aria-hidden />
|
||||
<span>{t("cw_files_retry")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
driveRows = (
|
||||
<ul className="cw-files" data-nav-list aria-label={t("cw_files_drives")}>
|
||||
{drives.map((v) => {
|
||||
const Icon = isUsb(v) ? Usb : HardDrive;
|
||||
const used = v.total_bytes > 0 ? Math.max(0, Math.min(100, 100 - (v.free_bytes / v.total_bytes) * 100)) : 0;
|
||||
const eject = onEject && canEject(v);
|
||||
return (
|
||||
<li key={v.path} className={`cw-file${eject ? "" : " is-plain"}`} data-nav-item data-volume={v.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-file-main"
|
||||
data-drive={v.path}
|
||||
onClick={() => onOpen(v.path)}
|
||||
>
|
||||
<Icon className="cw-file-icon" aria-hidden />
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">
|
||||
{volumeLabel(v)}
|
||||
{!v.writable ? <span className="cw-tag">{t("cw_home_vol_readonly")}</span> : null}
|
||||
</span>
|
||||
<span className="cw-dim cw-mono">{v.path}</span>
|
||||
</span>
|
||||
<span className="cw-file-meta">
|
||||
<span className="cw-bar" aria-hidden>
|
||||
<span className={`cw-bar-fill${used >= 90 ? " is-warn" : ""}`} style={{ width: `${used}%` }} />
|
||||
</span>
|
||||
{t("cw_home_free_of", { free: formatBytes(v.free_bytes), total: formatBytes(v.total_bytes) })}
|
||||
</span>
|
||||
</button>
|
||||
{eject ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-row-action"
|
||||
data-eject={v.path}
|
||||
onClick={() => onEject(v.path)}
|
||||
>
|
||||
<Eject aria-hidden />
|
||||
<span>{t("cw_files_eject")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{goTo}
|
||||
{pinned}
|
||||
<section className="cw-places">
|
||||
{prefs && (prefs.favorites.length || prefs.recent.length) ? <h2 className="cw-h2">{t("cw_files_drives")}</h2> : null}
|
||||
{driveRows}
|
||||
</section>
|
||||
{images.length ? (
|
||||
<section className="cw-places">
|
||||
<h2 className="cw-h2">{t("cw_files_images")}</h2>
|
||||
<ul className="cw-files" data-nav-list aria-label={t("cw_files_images")}>
|
||||
{images.map((v) => (
|
||||
<li key={v.path} className={`cw-file${onUnmount ? "" : " is-plain"}`} data-nav-item data-volume={v.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-file-main"
|
||||
data-drive={v.path}
|
||||
onClick={() => onOpen(v.path)}
|
||||
>
|
||||
<Disc className="cw-file-icon" aria-hidden />
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">{baseName(v.source_image || v.path)}</span>
|
||||
<span className="cw-dim cw-mono">{v.path}</span>
|
||||
</span>
|
||||
<span className="cw-file-meta cw-dim">{formatBytes(v.total_bytes)}</span>
|
||||
</button>
|
||||
{onUnmount && unmountHeld ? (
|
||||
<span className="cw-dim cw-row-action" data-unmount-held={v.path}>
|
||||
{unmountHeld}
|
||||
</span>
|
||||
) : null}
|
||||
{onUnmount && !unmountHeld ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-row-action"
|
||||
data-unmount={v.path}
|
||||
onClick={() => onUnmount(v.path)}
|
||||
>
|
||||
<Eject aria-hidden />
|
||||
<span>{t("cw_files_unmount")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{recent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../api", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("../api")>();
|
||||
return { ...real, call: vi.fn() };
|
||||
});
|
||||
|
||||
import { ApiError, call } from "../api";
|
||||
import type { Volume } from "../home/Home";
|
||||
import { t } from "../strings";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { ActionsSheet } from "./ActionsSheet";
|
||||
import { DirList } from "./DirList";
|
||||
import { batchMessage, failedNamesIn, filesErrorText, FilesView, pasteMessage, upFrom, viewKind, type FilesViewProps } from "./Files";
|
||||
import { clearClipboard, getClipboard, setClipboard } from "./clipboard";
|
||||
import { ConflictDialog } from "./ConflictDialog";
|
||||
import { IntoItselfError, renameEntry } from "./move";
|
||||
import { TextPrompt } from "./TextPrompt";
|
||||
import type { DirState, Entry } from "./useDir";
|
||||
|
||||
const mockCall = vi.mocked(call);
|
||||
|
||||
const CJK = "完蛋!我被美女包围了!";
|
||||
|
||||
function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
const out: ReactElement<Record<string, unknown>>[] = [];
|
||||
const walk = (n: ReactNode) => {
|
||||
if (Array.isArray(n)) return n.forEach(walk);
|
||||
if (!isValidElement(n)) return;
|
||||
const el = n as ReactElement<Record<string, unknown>>;
|
||||
out.push(el);
|
||||
walk(el.props.children as ReactNode);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
const entry = (name: string, kind: Entry["kind"] = "file", size = 10): Entry => ({ name, kind, size, mtime: 0 });
|
||||
|
||||
const dir = (entries: Entry[], over: Partial<DirState> = {}): DirState => ({
|
||||
entries,
|
||||
more: false,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const vol = (path: string, mount_from = "/dev/x"): Volume => ({
|
||||
path,
|
||||
mount_from,
|
||||
fs_type: "ufs",
|
||||
total_bytes: 1000,
|
||||
free_bytes: 400,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
function props(over: Partial<FilesViewProps> = {}): FilesViewProps {
|
||||
return {
|
||||
path: "/data/games",
|
||||
volumes: [vol("/data"), vol("/mnt/usb0", "/dev/da0s1")],
|
||||
volumesError: false,
|
||||
dir: dir([entry("a.txt"), entry("b"), entry("c")]),
|
||||
selecting: false,
|
||||
selected: [],
|
||||
ask: null,
|
||||
busy: null,
|
||||
message: null,
|
||||
tasks: [],
|
||||
onOpen: vi.fn(),
|
||||
onUp: vi.fn(),
|
||||
onEntry: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
onToggleSelecting: vi.fn(),
|
||||
onToggleItem: vi.fn(),
|
||||
onAsk: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
onConfirmFixPerm: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
onMkdir: vi.fn(),
|
||||
onPicked: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCall.mockReset();
|
||||
mockCall.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("Files", () => {
|
||||
it("delete_confirm_names_item_and_count", () => {
|
||||
// Delete in the actions sheet only asks.
|
||||
const p = props({ ask: { kind: "sheet", names: ["a.txt", "b", "c"] } });
|
||||
const sheet = elements(FilesView(p)).find((e) => e.type === ActionsSheet);
|
||||
expect(sheet).toBeDefined();
|
||||
(sheet!.props.onChoose as (a: string) => void)("delete");
|
||||
expect(p.onAsk).toHaveBeenCalledWith({ kind: "delete", names: ["a.txt", "b", "c"] });
|
||||
expect(mockCall).not.toHaveBeenCalled();
|
||||
|
||||
// Several: the count in the title, the first item named in the body.
|
||||
const many = props({ ask: { kind: "delete", names: ["a.txt", "b", "c"] } });
|
||||
const d = elements(FilesView(many)).find((e) => e.type === ConfirmDialog)!;
|
||||
expect(d.props.title).toBe(t("cw_files_del_many", { n: 3 }));
|
||||
expect(d.props.title).toContain("3");
|
||||
expect(d.props.body).toContain(t("cw_files_del_list_many", { name: "a.txt", more: 2 }));
|
||||
expect(d.props.body).toContain("a.txt");
|
||||
expect(d.props.danger).toBe(true);
|
||||
(d.props.onConfirm as () => void)();
|
||||
expect(many.onConfirmDelete).toHaveBeenCalledWith(["a.txt", "b", "c"]);
|
||||
|
||||
// One: named in the title.
|
||||
const one = props({ ask: { kind: "delete", names: [CJK] } });
|
||||
const d1 = elements(FilesView(one)).find((e) => e.type === ConfirmDialog)!;
|
||||
expect(d1.props.title).toBe(t("cw_files_del_one", { name: CJK }));
|
||||
expect(d1.props.title).toContain(CJK);
|
||||
});
|
||||
|
||||
it("fix_permissions_is_offered_asks_then_reports_done", () => {
|
||||
for (const names of [["b"], ["a.txt", "b", "c"]]) {
|
||||
const p = props({ ask: { kind: "sheet", names } });
|
||||
const sheet = elements(FilesView(p)).find((e) => e.type === ActionsSheet)!;
|
||||
expect(sheet.props.actions).toContain("fixperm");
|
||||
(sheet.props.onChoose as (a: string) => void)("fixperm");
|
||||
expect(p.onAsk).toHaveBeenCalledWith({ kind: "fixperm", names });
|
||||
expect(mockCall).not.toHaveBeenCalled();
|
||||
}
|
||||
const q = props({ ask: { kind: "fixperm", names: ["b", "c"] } });
|
||||
const d = elements(FilesView(q)).find((e) => e.type === ConfirmDialog)!;
|
||||
expect(d.props.body).toBe(t("cw_files_fixperm_body"));
|
||||
expect(d.props.title).toBe(t("cw_files_fixperm_many", { n: 2 })); // the count, as Delete says it
|
||||
expect(d.props.danger).toBeUndefined();
|
||||
(d.props.onConfirm as () => void)();
|
||||
expect(q.onConfirmFixPerm).toHaveBeenCalledWith(["b", "c"]);
|
||||
expect(batchMessage({ started: [], moved: [], failed: [], jobFailed: [], skipped: [] } as never, "chmod")?.text).toBe(
|
||||
t("cw_files_fixperm_done"),
|
||||
);
|
||||
});
|
||||
|
||||
it("unicode_names_render_and_round_trip", async () => {
|
||||
const p = props({ dir: dir([entry(CJK, "dir")]) });
|
||||
expect(renderToStaticMarkup(<FilesView {...p} />)).toContain(CJK);
|
||||
|
||||
// The row carries the name unchanged; ⋯ asks for its sheet with that name.
|
||||
const list = DirList({
|
||||
entries: p.dir.entries,
|
||||
selecting: false,
|
||||
selected: [],
|
||||
onEntry: p.onEntry,
|
||||
onToggleItem: p.onToggleItem,
|
||||
onActions: (name) => p.onAsk({ kind: "sheet", names: [name] }),
|
||||
});
|
||||
const row = elements(list).find((e) => e.props["data-entry"] !== undefined)!;
|
||||
expect(row.props["data-entry"]).toBe(CJK);
|
||||
const html = renderToStaticMarkup(list);
|
||||
expect(html).toContain(CJK);
|
||||
|
||||
// Rename's text box starts with the name; the new name goes back with the old one as-is.
|
||||
const r = props({ ask: { kind: "rename", name: CJK } });
|
||||
const prompt = elements(FilesView(r)).find((e) => e.type === TextPrompt)!;
|
||||
expect(prompt.props.initial).toBe(CJK);
|
||||
(prompt.props.onSubmit as (v: string) => void)(`${CJK} 2`);
|
||||
expect(r.onRename).toHaveBeenCalledWith(CJK, `${CJK} 2`);
|
||||
|
||||
await renameEntry("/data/games", CJK, `${CJK} 2`);
|
||||
expect(mockCall).toHaveBeenCalledWith("/api/fs/rename", {
|
||||
from: `/data/games/${CJK}`,
|
||||
to: `/data/games/${CJK} 2`,
|
||||
});
|
||||
// And it survives JSON both ways (what call() sends).
|
||||
expect(JSON.parse(JSON.stringify(mockCall.mock.calls[0][1])).from).toBe(`/data/games/${CJK}`);
|
||||
});
|
||||
|
||||
it("the drive list shows each drive with its free space", () => {
|
||||
const html = renderToStaticMarkup(<FilesView {...props({ path: null })} />);
|
||||
expect(html).toContain(t("cw_home_vol_internal"));
|
||||
expect(html).toContain(t("cw_home_vol_usb"));
|
||||
expect(html.split("data-drive=").length - 1).toBe(2);
|
||||
});
|
||||
|
||||
it("○ goes up one folder, and from a drive's top to the drive list", () => {
|
||||
const vols = [vol("/data"), vol("/mnt/usb0")];
|
||||
expect(upFrom("/data/a/b", vols)).toBe("/data/a");
|
||||
expect(upFrom("/data", vols)).toBeNull();
|
||||
expect(upFrom("/mnt/usb0/x", vols)).toBe("/mnt/usb0");
|
||||
expect(upFrom("/mnt/usb0", vols)).toBeNull();
|
||||
expect(upFrom(null, vols)).toBeNull();
|
||||
});
|
||||
|
||||
it("copy and move are off while a batch runs; it shows which item and a Stop", () => {
|
||||
const onStopBatch = vi.fn();
|
||||
const p = props({
|
||||
ask: { kind: "sheet", names: ["a.txt"] },
|
||||
busy: { op: "copy", at: 3, total: 40, stopping: false },
|
||||
onStopBatch,
|
||||
});
|
||||
const sheet = elements(FilesView(p)).find((e) => e.type === ActionsSheet)!;
|
||||
expect(sheet.props.busy).toBe(true);
|
||||
const html = renderToStaticMarkup(<FilesView {...p} />);
|
||||
expect(html).toContain(t("cw_files_batch_copy", { at: 3, total: 40 }));
|
||||
const stop = elements(FilesView(p)).find((e) => e.props["data-action"] === "stop-batch")!;
|
||||
(stop.props.onClick as () => void)();
|
||||
expect(onStopBatch).toHaveBeenCalled();
|
||||
// Once stopping, no second Stop.
|
||||
const stopping = props({ busy: { op: "delete", at: 2, total: 5, stopping: true }, onStopBatch });
|
||||
expect(renderToStaticMarkup(<FilesView {...stopping} />)).toContain(t("cw_files_batch_stopping"));
|
||||
expect(elements(FilesView(stopping)).some((e) => e.props["data-action"] === "stop-batch")).toBe(false);
|
||||
});
|
||||
|
||||
it("a finished batch says what failed, what a stop left out, else done", () => {
|
||||
const r = { started: [], moved: [], failed: [], jobFailed: [], skipped: [] };
|
||||
expect(batchMessage(r)).toEqual({ tone: "info", text: t("cw_files_done") });
|
||||
expect(batchMessage({ ...r, jobFailed: ["/a"] })?.text).toBe(t("cw_files_batch_failed", { n: 1 }));
|
||||
expect(batchMessage({ ...r, skipped: ["/a", "/b"] })?.text).toBe(t("cw_files_batch_stopped", { n: 2 }));
|
||||
expect(batchMessage({ ...r, failed: [{ item: "/a", error: new ApiError(403, "x") }] })?.tone).toBe("error");
|
||||
});
|
||||
|
||||
it("only images and text open in the viewer", () => {
|
||||
expect(viewKind("a.PNG")).toBe("image");
|
||||
expect(viewKind("a.jpg")).toBe("image");
|
||||
expect(viewKind("x.log")).toBe("text");
|
||||
expect(viewKind("x.xml")).toBe("text");
|
||||
expect(viewKind("eboot.bin")).toBeNull();
|
||||
expect(viewKind("noext")).toBeNull();
|
||||
});
|
||||
|
||||
it("select mode: ✕ on a row toggles it instead of opening", () => {
|
||||
const onEntry = vi.fn();
|
||||
const onToggleItem = vi.fn();
|
||||
const list = DirList({
|
||||
entries: [entry("a.txt")],
|
||||
selecting: true,
|
||||
selected: ["a.txt"],
|
||||
onEntry,
|
||||
onToggleItem,
|
||||
onActions: () => {},
|
||||
});
|
||||
const main = elements(list).find((e) => e.props["data-entry-main"] !== undefined)!;
|
||||
expect(main.props["aria-pressed"]).toBe(true);
|
||||
(main.props.onClick as () => void)();
|
||||
expect(onToggleItem).toHaveBeenCalledWith("a.txt");
|
||||
expect(onEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("errors name what went wrong", () => {
|
||||
expect(filesErrorText(new ApiError(403, "forbidden"))).toBe(t("cw_files_err_denied"));
|
||||
expect(filesErrorText(new ApiError(409, "exists"))).toBe(t("cw_files_err_exists"));
|
||||
expect(filesErrorText(new ApiError(401, "token"))).toBeNull();
|
||||
expect(filesErrorText(new IntoItselfError("/data/a"))).toBe(t("cw_files_err_into_itself"));
|
||||
});
|
||||
|
||||
it("409_shows_exists_only_for_an_exist_code", () => {
|
||||
expect(filesErrorText(new ApiError(409, "the destination already exists"))).toBe(t("cw_files_err_exists"));
|
||||
expect(filesErrorText(new ApiError(409, "busy"))).toBe(t("cw_home_err_busy"));
|
||||
expect(filesErrorText(new ApiError(409, "job_in_progress"))).toBe(
|
||||
t("cw_home_err_failed", { code: "job_in_progress" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files listing controls", () => {
|
||||
const many = Array.from({ length: 700 }, (_, i) => entry(`f${i}.txt`));
|
||||
it("shows the toolbar, status line and a row window with a Show more rows button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<>
|
||||
{FilesView(
|
||||
props({
|
||||
dir: dir(many),
|
||||
shown: many,
|
||||
status: { items: 700, total: 700, selected: 1, bytes: 10, folders: 0 },
|
||||
rowLimit: 300,
|
||||
onMoreRows: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
selecting: true,
|
||||
selected: ["f1.txt"],
|
||||
}),
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
expect(html).toContain('data-action="select-all"');
|
||||
expect(html).not.toContain('data-action="sortview"'); // the selection bar replaces the right side
|
||||
expect(html).not.toContain('data-action="filter-clear"');
|
||||
const filtered = renderToStaticMarkup(
|
||||
<>
|
||||
{FilesView(
|
||||
props({
|
||||
dir: dir(many),
|
||||
shown: many,
|
||||
status: { items: 700, total: 700, selected: 0, bytes: 0, folders: 0 },
|
||||
view: { sort: "name", dir: "asc", hidden: false, filter: "f1" },
|
||||
onView: vi.fn(),
|
||||
selecting: true,
|
||||
}),
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
expect(filtered).toContain('data-action="filter-clear"');
|
||||
expect(filtered).toContain(t("cw_files_filter_on"));
|
||||
const plain = renderToStaticMarkup(<>{FilesView(props({ onView: vi.fn(), onGoTo: vi.fn(), prefs: { favorites: [], recent: [], last: null }, onPin: vi.fn(), onUnpin: vi.fn() }))}</>);
|
||||
for (const a of ["pin", "goto", "mkdir", "select", "sortview", "filter"]) expect(plain).toContain(`data-action="${a}"`);
|
||||
expect(plain).toContain('data-tip="Sort and view"');
|
||||
expect(html).toContain("700 items · 1 selected (10 B)");
|
||||
expect(html).toContain('data-action="more-rows"');
|
||||
expect(html.match(/data-entry="/g)).toHaveLength(300);
|
||||
});
|
||||
it("offers Load all when more pages wait, and explains an empty filter", () => {
|
||||
const more = renderToStaticMarkup(<>{FilesView(props({ dir: dir([entry("a")], { more: true }), onView: vi.fn(), onLoadAll: vi.fn() }))}</>);
|
||||
expect(more).toContain('data-action="load-all"');
|
||||
const none = renderToStaticMarkup(<>{FilesView(props({ dir: dir([entry("a")]), shown: [], onView: vi.fn() }))}</>);
|
||||
expect(none).toContain(t("cw_files_none_match"));
|
||||
});
|
||||
|
||||
it("clipboard: Copy / Cut in the sheet, the bar in the status line, kept across folders", () => {
|
||||
for (const names of [["b"], ["a.txt", "b", "c"]]) {
|
||||
const p = props({ ask: { kind: "sheet", names }, onClip: vi.fn() });
|
||||
const sheet = elements(FilesView(p)).find((e) => e.type === ActionsSheet)!;
|
||||
expect(sheet.props.actions).toEqual(expect.arrayContaining(["clipcopy", "cut"]));
|
||||
(sheet.props.onChoose as (a: string) => void)("cut");
|
||||
expect(p.onClip).toHaveBeenCalledWith("cut", names);
|
||||
(sheet.props.onChoose as (a: string) => void)("clipcopy");
|
||||
expect(p.onClip).toHaveBeenCalledWith("copy", names);
|
||||
}
|
||||
clearClipboard();
|
||||
setClipboard("cut", ["x", "y", "z"].map((n) => ({ path: `/data/a/${n}`, dir: false })));
|
||||
// Another folder, another render: the clipboard is page state, not the folder's.
|
||||
for (const path of ["/data/games", "/mnt/usb0"]) {
|
||||
const onPaste = vi.fn();
|
||||
const onClipClear = vi.fn();
|
||||
const p = props({
|
||||
path,
|
||||
clip: getClipboard(),
|
||||
onPaste,
|
||||
onClipClear,
|
||||
status: { items: 3, total: 3, selected: 0, bytes: 0, folders: 0 },
|
||||
});
|
||||
const html = renderToStaticMarkup(<>{FilesView(p)}</>);
|
||||
expect(html).toContain("3 items to paste (cut)");
|
||||
expect(html).toContain('data-action="paste"');
|
||||
expect(html).toContain('data-action="clip-clear"');
|
||||
// In the status line's row, beside the item count.
|
||||
expect(html).toMatch(/cw-status-row.*3 items.*cw-clip/);
|
||||
}
|
||||
expect(getClipboard()?.items).toHaveLength(3);
|
||||
const one = renderToStaticMarkup(
|
||||
<>{FilesView(props({ clip: { mode: "copy", items: [{ path: "/data/a/x.txt", dir: false }] }, onPaste: vi.fn() }))}</>,
|
||||
);
|
||||
expect(one).toContain("x.txt to paste (copy)");
|
||||
// A batch running: Paste waits for it.
|
||||
const busy = renderToStaticMarkup(
|
||||
<>{FilesView(props({ clip: getClipboard(), onPaste: vi.fn(), busy: { op: "copy", at: 1, total: 2, stopping: false } }))}</>,
|
||||
);
|
||||
expect(busy).toMatch(/data-action="paste" disabled=""/);
|
||||
clearClipboard();
|
||||
});
|
||||
|
||||
it("a waiting paste conflict shows its dialog over anything else", () => {
|
||||
const answer = vi.fn();
|
||||
const conflict = { item: "/data/a/x.txt", dest: "/data/games/x.txt", destDir: false, canReplace: true, left: 2 };
|
||||
const p = props({ ask: { kind: "sheet", names: ["b"] }, conflict: { conflict, answer } });
|
||||
const els = elements(FilesView(p));
|
||||
const d = els.find((e) => e.type === ConflictDialog)!;
|
||||
expect(els.find((e) => e.type === ActionsSheet)).toBeUndefined();
|
||||
(d.props.onAnswer as (x: unknown) => void)({ choice: "skip", all: false });
|
||||
expect(answer).toHaveBeenCalledWith({ choice: "skip", all: false });
|
||||
|
||||
const html = renderToStaticMarkup(<ConflictDialog conflict={conflict} onAnswer={vi.fn()} />);
|
||||
expect(html).toContain(t("cw_files_conflict_title", { name: "x.txt" }));
|
||||
for (const c of ["skip", "keep", "replace"]) expect(html).toContain(`data-conflict="${c}"`);
|
||||
expect(html).toContain(t("cw_files_conflict_all", { n: 2 }));
|
||||
// Skip is the safe default; Replace is in the danger colour.
|
||||
expect(html).toMatch(/data-conflict="skip" data-nav-default/);
|
||||
expect(html).toMatch(/is-danger" data-conflict="replace"/);
|
||||
// The item itself (copy into its own folder): no Replace; the last one: no Apply to all.
|
||||
const self = renderToStaticMarkup(
|
||||
<ConflictDialog conflict={{ ...conflict, canReplace: false, left: 0 }} onAnswer={vi.fn()} />,
|
||||
);
|
||||
expect(self).not.toContain('data-conflict="replace"');
|
||||
expect(self).not.toContain('data-action="conflict-all"');
|
||||
});
|
||||
|
||||
it("paste messages: skipped, already here, failures", () => {
|
||||
const base = { started: [], moved: [], failed: [], jobFailed: [], skipped: [], done: [], left: [], halted: [], declined: [], here: [] };
|
||||
expect(pasteMessage({ ...base, done: ["/a"] })?.text).toBe(t("cw_files_done"));
|
||||
expect(pasteMessage({ ...base, done: ["/a"], declined: ["/b", "/c"] })?.text).toBe(t("cw_files_paste_done_skipped", { n: 2 }));
|
||||
expect(pasteMessage({ ...base, here: ["/a"] })?.text).toBe(t("cw_files_paste_here_already"));
|
||||
expect(pasteMessage({ ...base, skipped: ["/a"] })?.text).toBe(t("cw_files_batch_stopped", { n: 1 }));
|
||||
expect(pasteMessage({ ...base, failed: [{ item: "/a", error: new IntoItselfError("/a") }] })?.text).toContain(
|
||||
t("cw_files_err_into_itself"),
|
||||
);
|
||||
expect(filesErrorText(new ApiError(500, "replace_delete_failed"))).toBe(t("cw_files_replace_failed"));
|
||||
// Stop during a Replace's delete: that item is named, the rest counted.
|
||||
expect(pasteMessage({ ...base, halted: ["/data/a/G"], skipped: ["/data/a/G"] })?.text).toBe(
|
||||
t("cw_files_paste_halted", { name: "G" }),
|
||||
);
|
||||
expect(pasteMessage({ ...base, halted: ["/data/a/G"], skipped: ["/data/a/G", "/data/a/H"] })?.text).toBe(
|
||||
`${t("cw_files_paste_halted", { name: "G" })} ${t("cw_files_batch_stopped", { n: 1 })}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failed items stay selected after a batch", () => {
|
||||
it("keeps the folder's refused and failed items, once each, and nothing from elsewhere", () => {
|
||||
const r = {
|
||||
started: ["/data/a"],
|
||||
moved: [],
|
||||
failed: [
|
||||
{ item: "/data/b", error: new Error("x") },
|
||||
{ item: "/mnt/usb0/src/c", error: new Error("x") },
|
||||
],
|
||||
jobFailed: ["/data/d", "/data/b"],
|
||||
skipped: ["/data/e"],
|
||||
};
|
||||
expect(failedNamesIn(r, "/data")).toEqual(["b", "d"]);
|
||||
expect(failedNamesIn(r, "/mnt/usb0")).toEqual([]);
|
||||
expect(failedNamesIn({ started: [], moved: [], failed: [], jobFailed: ["/x"], skipped: [] }, "/")).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Calculator, ShieldCheck, Square } from "lucide-react";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { call } from "../api";
|
||||
import { getLanguage, t } from "../strings";
|
||||
import { topModal } from "../nav";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { refocusIfLost } from "../ui/restoreFocus";
|
||||
import { startFolderSize, SIZE_MAX_ENTRIES, SIZE_MAX_MS, type SizeCount, type SizeResult } from "./folderSize";
|
||||
import { LIST_PAGE } from "./useDir";
|
||||
import { modeLetters, modeText } from "./permissions";
|
||||
import { PermissionsDialog } from "./PermissionsDialog";
|
||||
|
||||
interface Stat {
|
||||
kind: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
mode: number;
|
||||
}
|
||||
|
||||
type Info = { stat: Stat; items: string | null } | { error: true } | null;
|
||||
|
||||
function kindText(kind: string): string {
|
||||
if (kind === "dir") return t("cw_files_kind_dir");
|
||||
if (kind === "link") return t("cw_files_kind_link");
|
||||
return t("cw_files_kind_file");
|
||||
}
|
||||
|
||||
type Walk = { state: "running"; count: SizeCount } | { state: "ended"; result: SizeResult } | { state: "held" } | null;
|
||||
|
||||
const num = (n: number) => n.toLocaleString(getLanguage());
|
||||
|
||||
/** "12,345 files · 67 folders · 8.1 GiB". */
|
||||
export function countText(c: SizeCount): string {
|
||||
return t("cw_files_size_count", { files: num(c.files), folders: num(c.folders), size: formatBytes(c.bytes) });
|
||||
}
|
||||
|
||||
/** What a walk that ended says besides the count (partial, cancelled, failed), or null. */
|
||||
export function walkNote(r: SizeResult): string | null {
|
||||
let note: string | null = null;
|
||||
if (r.end === "capped") note = t("cw_files_size_capped", { n: num(SIZE_MAX_ENTRIES) });
|
||||
else if (r.end === "timeout") note = t("cw_files_size_timeout", { s: Math.round(SIZE_MAX_MS / 1000) });
|
||||
else if (r.end === "cancelled") note = t("cw_files_size_cancelled");
|
||||
else if (r.end === "error") note = t("cw_files_err_read");
|
||||
if (r.unreadable > 0) {
|
||||
const u = t("cw_files_size_unreadable", { n: num(r.unreadable) });
|
||||
note = note ? `${note} ${u}` : u;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
/** "Calculate folder size": a bounded walk (folderSize.ts) with its running count, Cancel, and the result. */
|
||||
function FolderSize({ path }: { path: string }) {
|
||||
const [walk, setWalk] = useState<Walk>(null);
|
||||
const cancel = useRef<(() => void) | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Closed while it walks: stop before the next page.
|
||||
cancel.current?.();
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Calculate turns into Cancel and back: focus follows instead of falling out of the dialog.
|
||||
const phase = walk?.state ?? null;
|
||||
useEffect(() => {
|
||||
refocusIfLost(topModal(), phase === "running" ? ['[data-action="size-cancel"]'] : ['[data-action="size-start"]']);
|
||||
}, [phase]);
|
||||
const start = () => {
|
||||
const zero: SizeCount = { files: 0, folders: 0, bytes: 0, entries: 0, unreadable: 0 };
|
||||
const w = startFolderSize(path, (count) => setWalk({ state: "running", count }));
|
||||
if (!w) {
|
||||
setWalk({ state: "held" });
|
||||
return;
|
||||
}
|
||||
cancel.current = w.cancel;
|
||||
setWalk({ state: "running", count: zero });
|
||||
void w.done.then((result) => {
|
||||
cancel.current = null;
|
||||
setWalk({ state: "ended", result });
|
||||
});
|
||||
};
|
||||
if (walk?.state === "running") {
|
||||
return (
|
||||
<div className="cw-msg" role="status" data-size="running">
|
||||
{t("cw_files_size_running", { count: countText(walk.count) })}
|
||||
<button type="button" className="cw-focusable cw-button cw-msg-action" data-action="size-cancel" onClick={() => cancel.current?.()}>
|
||||
<Square aria-hidden />
|
||||
<span>{t("cw_dialog_cancel")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const note = walk?.state === "ended" ? walkNote(walk.result) : null;
|
||||
return (
|
||||
<>
|
||||
{walk?.state === "ended" && walk.result.end !== "error" ? (
|
||||
<dl className="cw-info cw-size-result" data-size={walk.result.end}>
|
||||
<div>
|
||||
<dt className="cw-dim">{t("cw_files_size_total")}</dt>
|
||||
<dd>
|
||||
{formatBytes(walk.result.bytes)} <span className="cw-dim">({t("cw_files_size_bytes", { n: num(walk.result.bytes) })})</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="cw-dim">{t("cw_files_size_files")}</dt>
|
||||
<dd>{num(walk.result.files)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="cw-dim">{t("cw_files_size_folders")}</dt>
|
||||
<dd>{num(walk.result.folders)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{note ? <p className={walk?.state === "ended" && walk.result.end === "error" ? "cw-field-error" : "cw-dim"}>{note}</p> : null}
|
||||
{walk?.state === "held" ? <p className="cw-dim">{t("cw_files_size_busy")}</p> : null}
|
||||
<button type="button" className="cw-focusable cw-button cw-size-go" data-action="size-start" onClick={start}>
|
||||
<Calculator aria-hidden />
|
||||
<span>{t(walk?.state === "ended" ? "cw_files_size_again" : "cw_files_size_start")}</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Info: type, size, modified time, permissions, and for a folder how many
|
||||
* entries it holds (the first page's count; "256+" when there are more), with
|
||||
* "Calculate folder size" walking the whole tree (bounded, cancellable).
|
||||
*/
|
||||
export function InfoDialog({
|
||||
dir,
|
||||
name,
|
||||
path,
|
||||
fsType,
|
||||
onClose,
|
||||
}: {
|
||||
dir: string;
|
||||
name: string;
|
||||
path: string;
|
||||
/** The drive's file system (exFAT/FAT keep no permissions: the editor says so). */
|
||||
fsType?: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [info, setInfo] = useState<Info>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [statTick, setStatTick] = useState(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
const stat = await call<Stat>("/api/fs/stat", { path });
|
||||
let items: string | null = null;
|
||||
if (stat.kind === "dir") {
|
||||
const r = await call<{ entries?: unknown[]; more?: boolean }>("/api/fs/list", { path, offset: 0, limit: LIST_PAGE });
|
||||
const n = Array.isArray(r?.entries) ? r.entries.length : 0;
|
||||
items = r?.more ? `${n}+` : String(n);
|
||||
}
|
||||
if (live) setInfo({ stat, items });
|
||||
})().catch(() => {
|
||||
if (live) setInfo({ error: true });
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path, statTick]);
|
||||
|
||||
const rows: [string, string][] = [[t("cw_files_info_where"), dir]];
|
||||
if (info && "stat" in info) {
|
||||
const s = info.stat;
|
||||
rows.push([t("cw_files_info_type"), kindText(s.kind)]);
|
||||
if (s.kind === "dir") rows.push([t("cw_files_info_items"), info.items ?? "—"]);
|
||||
else rows.push([t("cw_files_info_size"), formatBytes(s.size)]);
|
||||
if (s.mtime > 0) rows.push([t("cw_files_info_modified"), new Date(s.mtime * 1000).toLocaleString(getLanguage())]);
|
||||
rows.push([t("cw_files_info_mode"), `${modeText(s.mode)} ${modeLetters(s.mode)}`]);
|
||||
}
|
||||
const stat = info && "stat" in info ? info.stat : null;
|
||||
if (editing && stat) {
|
||||
return (
|
||||
<PermissionsDialog
|
||||
name={name}
|
||||
path={path}
|
||||
mode={stat.mode}
|
||||
dir={stat.kind === "dir"}
|
||||
fsType={fsType}
|
||||
onDone={() => {
|
||||
setEditing(false);
|
||||
setStatTick((n) => n + 1);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal label={name} onBack={onClose}>
|
||||
<h2 className="cw-file-name">{name}</h2>
|
||||
<dl className="cw-info">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="cw-dim">{k}</dt>
|
||||
<dd className={k === t("cw_files_info_where") ? "cw-mono" : undefined}>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{info && "stat" in info && info.stat.kind === "dir" ? <FolderSize path={path} /> : null}
|
||||
{info == null ? <p className="cw-dim">{t("cw_home_loading")}</p> : null}
|
||||
{info && "error" in info ? <p className="cw-field-error">{t("cw_files_err_read")}</p> : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
{stat && stat.kind !== "link" ? (
|
||||
<button type="button" className="cw-focusable cw-button" data-action="permissions" onClick={() => setEditing(true)}>
|
||||
<ShieldCheck aria-hidden />
|
||||
<span>{t("cw_files_perm_open")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="cw-focusable cw-button" data-nav-default="" onClick={onClose}>
|
||||
<span>{t("cw_files_close")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowUp, ChevronRight } from "lucide-react";
|
||||
import type { Volume } from "../home/Home";
|
||||
import { focusElement } from "../nav";
|
||||
import { t } from "../strings";
|
||||
import { barItems, pathSegments } from "./pathParts";
|
||||
|
||||
/**
|
||||
* Up, then the folder shown as buttons: ✕ on a segment jumps there. A long
|
||||
* path shows its drive, "…" and the last two folders; ✕ on "…" shows them all
|
||||
* (focus moves to the first one it revealed). Keyed by path where it is used,
|
||||
* so a new folder starts collapsed.
|
||||
*/
|
||||
export function PathBar({
|
||||
path,
|
||||
volumes,
|
||||
onOpen,
|
||||
onUp,
|
||||
}: {
|
||||
path: string;
|
||||
volumes: Volume[];
|
||||
onOpen: (path: string) => void;
|
||||
onUp: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const root = useRef<HTMLElement>(null);
|
||||
/** After "…": focus the first segment it revealed. */
|
||||
const revealAt = useRef<number | null>(null);
|
||||
const items = barItems(pathSegments(path, volumes), expanded);
|
||||
|
||||
useEffect(() => {
|
||||
if (revealAt.current == null) return;
|
||||
const el = root.current?.querySelectorAll<HTMLElement>("[data-seg]")[revealAt.current];
|
||||
revealAt.current = null;
|
||||
if (el) focusElement(el);
|
||||
}, [expanded]);
|
||||
|
||||
return (
|
||||
<nav ref={root} className="cw-pathbar" aria-label={t("cw_files_path")}>
|
||||
<button type="button" className="cw-focusable cw-button" data-action="up" onClick={onUp}>
|
||||
<ArrowUp aria-hidden />
|
||||
<span>{t("cw_files_up")}</span>
|
||||
</button>
|
||||
<ol className="cw-crumbs">
|
||||
{items.map((it, i) => (
|
||||
<li key={it.kind === "seg" ? it.seg.path : "more"} className="cw-crumb">
|
||||
{i > 0 ? <ChevronRight className="cw-crumb-sep" aria-hidden /> : null}
|
||||
{it.kind === "more" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-crumb-button"
|
||||
data-action="more-path"
|
||||
aria-label={t("cw_files_path_more", { n: it.hidden.length })}
|
||||
title={it.hidden.map((s) => s.label).join("/")}
|
||||
onClick={() => {
|
||||
revealAt.current = 1;
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`cw-focusable cw-crumb-button${it.last ? " is-current" : ""}`}
|
||||
data-seg={it.seg.path}
|
||||
aria-current={it.last ? "location" : undefined}
|
||||
onClick={() => onOpen(it.seg.path)}
|
||||
>
|
||||
{it.seg.label}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowUp, Check, Folder, HardDrive } from "lucide-react";
|
||||
import { isDrive, volumeLabel, type Volume } from "../home/Home";
|
||||
import { focusElement } from "../nav";
|
||||
import { t } from "../strings";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { joinPath, upFrom } from "./move";
|
||||
import { useDir } from "./useDir";
|
||||
|
||||
/**
|
||||
* Copy to… / Move to…: drives, then folders; "Here" picks the folder shown.
|
||||
* ○ goes up one level, and from the drive list closes the picker.
|
||||
*/
|
||||
export function PathPicker({
|
||||
title,
|
||||
subtitle,
|
||||
volumes,
|
||||
start,
|
||||
onPick,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string;
|
||||
/** What is being copied or moved (a name, or "3 items"). */
|
||||
subtitle: string;
|
||||
volumes: Volume[];
|
||||
/** The folder to open at (the current one). */
|
||||
start: string | null;
|
||||
onPick: (dir: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [path, setPath] = useState<string | null>(start);
|
||||
const dir = useDir(path);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
const drives = volumes.filter((v) => isDrive(v) && v.writable);
|
||||
const folders = dir.entries.filter((e) => e.kind === "dir");
|
||||
|
||||
// On open and after a level change, focus the first row (else "Here").
|
||||
const moved = useRef(true);
|
||||
useEffect(() => {
|
||||
if (!moved.current || (path != null && !dir.loaded)) return;
|
||||
moved.current = false;
|
||||
const el = list.current?.querySelector<HTMLElement>("[data-pick]") ?? list.current?.querySelector<HTMLElement>("[data-pick-here]");
|
||||
if (el) focusElement(el);
|
||||
}, [path, dir.loaded]);
|
||||
|
||||
const go = (p: string | null) => {
|
||||
moved.current = true;
|
||||
setPath(p);
|
||||
};
|
||||
const back = useCallback(() => {
|
||||
if (path == null) onCancel();
|
||||
else {
|
||||
moved.current = true;
|
||||
setPath(upFrom(path, volumes));
|
||||
}
|
||||
}, [path, volumes, onCancel]);
|
||||
|
||||
return (
|
||||
<Modal label={title} className="cw-picker" onBack={back}>
|
||||
<h2>{title}</h2>
|
||||
<p className="cw-dim">{subtitle}</p>
|
||||
<div ref={list}>
|
||||
<div className="cw-picker-bar">
|
||||
<span className="cw-path cw-mono">{path ?? t("cw_files_pick_drive")}</span>
|
||||
{path != null ? (
|
||||
<button type="button" className="cw-focusable cw-button" onClick={back}>
|
||||
<ArrowUp aria-hidden />
|
||||
<span>{t("cw_files_up")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<ul className="cw-picker-list">
|
||||
{path == null
|
||||
? drives.map((v) => (
|
||||
<li key={v.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-sheet-item"
|
||||
data-pick={v.path}
|
||||
data-nav-default=""
|
||||
onClick={() => go(v.path)}
|
||||
>
|
||||
<HardDrive aria-hidden />
|
||||
<span>{volumeLabel(v)}</span>
|
||||
<span className="cw-dim cw-mono">{v.path}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
: folders.map((e) => (
|
||||
<li key={e.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-sheet-item"
|
||||
data-pick={e.name}
|
||||
onClick={() => go(joinPath(path, e.name))}
|
||||
>
|
||||
<Folder aria-hidden />
|
||||
<span>{e.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{path != null && !dir.loaded ? <p className="cw-dim">{t("cw_home_loading")}</p> : null}
|
||||
{path != null && dir.loaded && !folders.length && !dir.more ? (
|
||||
<p className="cw-dim">
|
||||
{t("cw_files_no_folders")} {t("cw_files_no_folders_next")}
|
||||
</p>
|
||||
) : null}
|
||||
{path != null && dir.more ? (
|
||||
<button type="button" className="cw-focusable cw-button" onFocus={dir.loadMore} onClick={dir.loadMore}>
|
||||
<span>{t("cw_files_more")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" onClick={onCancel}>
|
||||
<span>{t("cw_dialog_cancel")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button is-primary"
|
||||
data-pick-here=""
|
||||
disabled={path == null}
|
||||
onClick={() => {
|
||||
if (path != null) onPick(path);
|
||||
}}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
<span>{t("cw_files_here")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Square, SquareCheck } from "lucide-react";
|
||||
import { call } from "../api";
|
||||
import { errorText } from "../home/Home";
|
||||
import { t } from "../strings";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { watchOp } from "./opJob";
|
||||
import { hasPerm, keepsPermissions, modeLetters, modeText, PERM_BITS, PERM_WHO, togglePerm, type PermBit, type PermWho } from "./permissions";
|
||||
|
||||
const WHO_KEY: Record<PermWho, string> = { owner: "cw_files_perm_owner", group: "cw_files_perm_group", other: "cw_files_perm_other" };
|
||||
const BIT_KEY: Record<PermBit, string> = { read: "cw_files_perm_read", write: "cw_files_perm_write", exec: "cw_files_perm_exec" };
|
||||
|
||||
/** Apply `mode` to `path` (and below it with `recursive`): /api/fs/chmod answers {} at once, or a job for a folder's contents. */
|
||||
export async function applyMode(path: string, mode: number, recursive: boolean): Promise<void> {
|
||||
const r = await call<{ job?: string }>("/api/fs/chmod", { path, mode: modeText(mode), recursive });
|
||||
if (r && typeof r.job === "string") {
|
||||
const end = await watchOp(r.job);
|
||||
if (end.end !== "done") throw new Error(end.end === "failed" ? (end.status.message ?? "failed") : end.end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info → Permissions: owner / group / other × read / write / run toggles (one
|
||||
* button each, ✕ flips it) with the octal value, "Apply to everything inside"
|
||||
* for a folder, Apply / Cancel. On an exFAT or FAT drive it says permissions
|
||||
* are not kept there.
|
||||
*/
|
||||
export function PermissionsDialog({
|
||||
name,
|
||||
path,
|
||||
mode: initial,
|
||||
dir,
|
||||
fsType,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
name: string;
|
||||
path: string;
|
||||
mode: number;
|
||||
dir: boolean;
|
||||
fsType?: string | null;
|
||||
/** Applied: the new mode. */
|
||||
onDone: (mode: number) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState(initial & 0o777);
|
||||
const [recursive, setRecursive] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const apply = () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
applyMode(path, mode, dir && recursive).then(
|
||||
() => onDone(mode),
|
||||
(e: unknown) => {
|
||||
setBusy(false);
|
||||
setError(errorText(e));
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Modal label={t("cw_files_perm_title", { name })} className="cw-perm" onBack={onCancel}>
|
||||
<h2 className="cw-file-name">{t("cw_files_perm_title", { name })}</h2>
|
||||
{!keepsPermissions(fsType) ? (
|
||||
<p className="cw-msg is-info" data-perm-note="">
|
||||
{t("cw_files_perm_not_kept", { fs: fsType ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
<table className="cw-perm-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
{PERM_BITS.map((b) => (
|
||||
<th key={b} scope="col">
|
||||
{t(BIT_KEY[b])}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERM_WHO.map((who, wi) => (
|
||||
<tr key={who}>
|
||||
<th scope="row">{t(WHO_KEY[who])}</th>
|
||||
{PERM_BITS.map((bit, bi) => {
|
||||
const on = hasPerm(mode, who, bit);
|
||||
return (
|
||||
<td key={bit}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-perm-bit"
|
||||
data-perm={`${who}-${bit}`}
|
||||
data-nav-default={wi === 0 && bi === 0 ? "" : undefined}
|
||||
aria-pressed={on}
|
||||
aria-label={`${t(WHO_KEY[who])} · ${t(BIT_KEY[bit])}`}
|
||||
disabled={busy}
|
||||
onClick={() => setMode((m) => togglePerm(m, who, bit))}
|
||||
>
|
||||
{on ? <Check aria-hidden /> : <span aria-hidden>–</span>}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="cw-perm-value">
|
||||
<span className="cw-dim">{t("cw_files_perm_value")}</span> <strong className="cw-mono" data-perm-value="">{modeText(mode)}</strong>{" "}
|
||||
<span className="cw-dim cw-mono">{modeLetters(mode)}</span>
|
||||
</p>
|
||||
{dir ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-action="perm-recursive"
|
||||
aria-pressed={recursive}
|
||||
disabled={busy}
|
||||
onClick={() => setRecursive((v) => !v)}
|
||||
>
|
||||
{recursive ? <SquareCheck aria-hidden /> : <Square aria-hidden />}
|
||||
<span>{t("cw_files_perm_recursive")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<p className="cw-dim" role="status">
|
||||
{t("cw_files_perm_applying")}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="cw-field-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" data-action="perm-cancel" onClick={onCancel}>
|
||||
<span>{t("cw_dialog_cancel")}</span>
|
||||
</button>
|
||||
<button type="button" className="cw-focusable cw-button is-primary" data-action="perm-apply" disabled={busy} onClick={apply}>
|
||||
<span>{t("cw_files_perm_apply")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { t } from "../strings";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { PhoneHandoff } from "../ui/PhoneHandoff";
|
||||
import { validName } from "./move";
|
||||
|
||||
/** The default check: a file or folder name ("/", "." and ".." refused). */
|
||||
const checkName = (v: string): string | null => (validName(v) ? null : t("cw_files_bad_name"));
|
||||
|
||||
/**
|
||||
* Rename / New folder / Go to path: one text box (the PS5 shows its keyboard),
|
||||
* OK and Cancel. `check` says why a value cannot be used (shown under the box,
|
||||
* the prompt stays open); by default a name with "/" (or "." / "..") is
|
||||
* refused here, before any call. An async check (Go to path asks the console)
|
||||
* holds OK until it answers. With LAN access on, "Continue on your phone" (a QR
|
||||
* code of this page's LAN address) shows under the box.
|
||||
*/
|
||||
export function TextPrompt({
|
||||
title,
|
||||
initial,
|
||||
fieldLabel,
|
||||
check = checkName,
|
||||
extra,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string;
|
||||
initial: string;
|
||||
/** The box's label; default "Name". */
|
||||
fieldLabel?: string;
|
||||
check?: (value: string) => string | null | Promise<string | null>;
|
||||
/** Shown under the box (Go to path's pinned folders). */
|
||||
extra?: ReactNode;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const [bad, setBad] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
/** False once the prompt is gone: a check that answers late (after Cancel) does nothing. */
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const submit = () => {
|
||||
if (checking) return;
|
||||
const r = check(value);
|
||||
if (r == null || typeof r === "string") {
|
||||
if (r != null) setBad(r);
|
||||
else onSubmit(value);
|
||||
return;
|
||||
}
|
||||
setChecking(true);
|
||||
r.then(
|
||||
(why) => {
|
||||
if (!mounted.current) return;
|
||||
setChecking(false);
|
||||
if (why != null) setBad(why);
|
||||
else onSubmit(value);
|
||||
},
|
||||
() => {
|
||||
if (mounted.current) setChecking(false);
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Modal label={title} onBack={onCancel}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<h2>{title}</h2>
|
||||
<label className="cw-field">
|
||||
<span className="cw-dim">{fieldLabel ?? t("cw_files_name")}</span>
|
||||
<input
|
||||
className="cw-focusable cw-input"
|
||||
type="text"
|
||||
value={value}
|
||||
data-nav-default=""
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setBad(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{bad ? (
|
||||
<p className="cw-field-error" role="alert">
|
||||
{bad}
|
||||
</p>
|
||||
) : null}
|
||||
{extra}
|
||||
<PhoneHandoff />
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" onClick={onCancel}>
|
||||
<span>{t("cw_dialog_cancel")}</span>
|
||||
</button>
|
||||
<button type="submit" className="cw-focusable cw-button is-primary" aria-busy={checking || undefined}>
|
||||
<span>{checking ? t("cw_home_loading") : t("cw_files_ok")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { ApiError } from "../api";
|
||||
import { t } from "../strings";
|
||||
import { topModal } from "../nav";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { refocusIfLost } from "../ui/restoreFocus";
|
||||
import { filesErrorText } from "./Files";
|
||||
import { freeName, statOrNull } from "./paste";
|
||||
import { unpackBlocker, unpackInfo, type UnpackInfo } from "./opJob";
|
||||
|
||||
type Load = { info: UnpackInfo } | { error: string | null; generic: boolean } | null;
|
||||
|
||||
/** A decompress cause token (the unpack's own: fs_unpack_no_space, …) as a sentence, or null when it has none. */
|
||||
export function unpackCauseText(code: string): string | null {
|
||||
if (/not_pfs|not_pfsc|bad_container|bad_directory|bad_inode|bad_name|truncated/.test(code)) return t("cw_files_unpack_not_container");
|
||||
if (/no_space/.test(code)) return t("cw_files_unpack_no_space_short");
|
||||
if (/destination_exists/.test(code)) return t("cw_files_err_exists");
|
||||
if (/unpack_busy/.test(code)) return t("cw_files_unpack_busy");
|
||||
if (/not_exfat/.test(code)) return t("cw_files_unpack_not_image");
|
||||
if (/corrupt_block|inner_missing|size_mismatch/.test(code)) return t("cw_files_unpack_damaged");
|
||||
if (/path_not_allowed/.test(code)) return t("cw_files_err_denied");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why /api/fs/unpack/info or /api/fs/unpack refused the file, as a sentence (null: the reload banner says it). */
|
||||
export function unpackErrorText(err: unknown): string | null {
|
||||
if (err instanceof ApiError) {
|
||||
const why = unpackCauseText(err.code);
|
||||
if (why) return why;
|
||||
}
|
||||
return filesErrorText(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a .ffpfsc: the image it holds is written next to it (the .ffpfsc
|
||||
* stays). Before it starts: what it writes and how big, the free space there
|
||||
* (refused when short: the image plus 1 GiB), and a name already taken
|
||||
* (refused, with Keep both: "name (2).exfat"). Start hands it to Files, which
|
||||
* runs it as the page's one batch (progress, Stop).
|
||||
*/
|
||||
export function UnpackDialog({
|
||||
dir,
|
||||
name,
|
||||
path,
|
||||
busy,
|
||||
onStart,
|
||||
onCancel,
|
||||
load = unpackInfo,
|
||||
taken = async (p: string) => (await statOrNull(p)) != null,
|
||||
initial = null,
|
||||
initialName = null,
|
||||
}: {
|
||||
dir: string;
|
||||
name: string;
|
||||
path: string;
|
||||
/** Another batch runs: Start waits. */
|
||||
busy: boolean;
|
||||
/** Start with the image's own name (null) or the Keep both name. */
|
||||
onStart: (outName: string | null, info: UnpackInfo | null) => void;
|
||||
onCancel: () => void;
|
||||
load?: (path: string) => Promise<UnpackInfo>;
|
||||
taken?: (path: string) => Promise<boolean>;
|
||||
/** Already read (tests): shown at once, not read again. */
|
||||
initial?: UnpackInfo | null;
|
||||
initialName?: string | null;
|
||||
}) {
|
||||
const [state, setState] = useState<Load>(initial ? { info: initial } : null);
|
||||
const [outName, setOutName] = useState<string | null>(initialName);
|
||||
const [naming, setNaming] = useState(false);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const preset = initial != null;
|
||||
useEffect(() => {
|
||||
if (preset) return;
|
||||
let live = true;
|
||||
load(path).then(
|
||||
(info) => {
|
||||
if (live) setState({ info });
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (!live) return;
|
||||
// A helper without the check (or a check that could not run): warn and let the helper decide.
|
||||
const generic = e instanceof ApiError && (e.status === 0 || e.status >= 500 || e.code === "http_404");
|
||||
setState({ error: generic ? null : unpackErrorText(e), generic });
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path, load, preset]);
|
||||
|
||||
const info = state && "info" in state ? state.info : null;
|
||||
const blocker = info ? unpackBlocker(info, outName) : null;
|
||||
const canStart = !busy && !naming && (info ? blocker == null : !!state && "generic" in state && state.generic);
|
||||
// Keep both removes itself once a free name is found: focus moves on to Decompress (else Cancel).
|
||||
const keepShown = blocker === "exists";
|
||||
useEffect(() => {
|
||||
if (!keepShown) refocusIfLost(topModal(), ['[data-action="unpack-go"]', "[data-nav-default]"]);
|
||||
}, [keepShown]);
|
||||
const keepBoth = () => {
|
||||
if (!info) return;
|
||||
setNaming(true);
|
||||
setNameError(null);
|
||||
freeName(dir, info.name, false, taken).then(
|
||||
(n) => {
|
||||
setNaming(false);
|
||||
setOutName(n);
|
||||
},
|
||||
(e: unknown) => {
|
||||
setNaming(false);
|
||||
setNameError(filesErrorText(e));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const title = t("cw_files_unpack_title", { name });
|
||||
return (
|
||||
<Modal label={title} className="cw-unpack" onBack={onCancel}>
|
||||
<h2 className="cw-sheet-title">{title}</h2>
|
||||
{state == null ? <p className="cw-dim">{t("cw_home_loading")}</p> : null}
|
||||
{info ? (
|
||||
<>
|
||||
<p>{t("cw_files_unpack_body", { name: outName ?? info.name, size: formatBytes(info.size) })}</p>
|
||||
<dl className="cw-info">
|
||||
<div>
|
||||
<dt className="cw-dim">{t("cw_files_unpack_writes")}</dt>
|
||||
<dd className="cw-mono" data-unpack-out="">
|
||||
{`${dir.replace(/\/+$/, "")}/${outName ?? info.name}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="cw-dim">{t("cw_files_unpack_needs")}</dt>
|
||||
<dd>{formatBytes(info.need)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="cw-dim">{t("cw_files_unpack_free")}</dt>
|
||||
<dd>{info.free != null ? formatBytes(info.free) : "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{info.free == null ? <p className="cw-dim">{t("cw_files_unpack_free_unknown")}</p> : null}
|
||||
{blocker === "space" ? (
|
||||
<p className="cw-field-error" role="alert" data-unpack-blocked="space">
|
||||
{t("cw_files_unpack_no_space", { need: formatBytes(info.need), free: formatBytes(info.free ?? 0) })}
|
||||
</p>
|
||||
) : null}
|
||||
{blocker === "exists" ? (
|
||||
<p className="cw-field-error" role="alert" data-unpack-blocked="exists">
|
||||
{t("cw_files_unpack_exists", { name: info.name })}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{state && "generic" in state ? (
|
||||
state.generic ? (
|
||||
<p className="cw-dim" data-unpack-generic="">
|
||||
{t("cw_files_unpack_generic")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="cw-field-error" role="alert">
|
||||
{state.error ?? t("cw_files_err_read")}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
{nameError ? (
|
||||
<p className="cw-field-error" role="alert">
|
||||
{nameError}
|
||||
</p>
|
||||
) : null}
|
||||
{busy ? <p className="cw-dim">{t("cw_files_unpack_wait_batch")}</p> : null}
|
||||
<div className="cw-dialog-buttons">
|
||||
<button type="button" className="cw-focusable cw-button" data-nav-default="" onClick={onCancel}>
|
||||
<span>{t("cw_dialog_cancel")}</span>
|
||||
</button>
|
||||
{blocker === "exists" ? (
|
||||
<button type="button" className="cw-focusable cw-button" data-action="unpack-keep" aria-busy={naming || undefined} onClick={keepBoth}>
|
||||
<span>{t("cw_files_conflict_keep")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button is-primary"
|
||||
data-action="unpack-go"
|
||||
disabled={!canStart}
|
||||
onClick={() => onStart(outName, info)}
|
||||
>
|
||||
<span>{t("cw_files_unpack_go")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 696 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user