Compare commits

...
276 Commits
Author SHA1 Message Date
phantomptr 7fba660ba0 Release v7.1.11 2026-10-20 20:41:34 -07:00
PhantomPtr 16eaf85552 Fix browser avatar uploads and remote conversion preflight timeouts (#488)
* test: reproduce browser avatar and conversion preflight failures

* fix: support browser avatar images and slow remote conversion inspection

* test: select the avatar confirmation alert dialog
2026-10-20 20:41:28 -07:00
PhantomPtr 6421e8035f Fix collection organization for images and extracted games (#487)
* test(collection): cover root-relative mixed-format organization

* test(collection): cover folder conflicts and mixed preview

* fix(collection): organize images and intact games from collection roots

* fix(collection): report unreadable extracted game metadata

* fix(collection): restrict organizer fixture helpers to tests
2026-10-20 20:12:31 -07:00
PhantomPtr e83a09a7e4 Fix compressed Game files, tile feedback and PS5Upload batch intake (#483)
* test: cover nested package intake and duplicate Back requests

* test: reproduce missing compressed images and paginated library entries

* fix: address PS5Upload triage and include compressed library images

* fix: preserve queued copy recovery and validate compressed image actions

* fix: remove retired single-package picker import

* fix: keep skipped-source feedback accurate across conversion modes

* test: correct scan mock lifecycle and package picker assertion

* fix: label compressed image folder navigation clearly
2026-10-20 12:09:11 -07:00
phantomptr 912fd7c412 7.1.10: preserve file menu order and fix instrumented throughput checks 2026-10-19 14:45:39 -07:00
phantomptr 1ea78946d9 7.1.9: file properties, Minion controls and local cheat import 2026-10-19 12:42:31 -07:00
phantomptr 59fdad5281 Release v7.1.8 2026-10-18 23:14:40 -07:00
phantomptrandClaude Opus 5.5 c85f18221c Kraken: a small first half of a block is valid (#477)
The reference decoder needs 13 bytes for the whole chunk, seed
included; we asked for 13 after the 8-byte seed and refused valid
level-7 chunks with "chunk too short".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:14:32 -07:00
phantomptr 41971ae538 Release v7.1.7 2026-10-18 22:53:13 -07:00
phantomptrandClaude Opus 5.5 d92d4fa032 7.1.7 notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:52:57 -07:00
phantomptrandClaude Opus 5.5 744f34ebbb Merge Organize on SMB, FTP, SFTP and WebDAV servers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:52:57 -07:00
phantomptrandClaude Opus 5.5 cc02f7b40b Organize on servers: odd names, FTP target checks, undo fingerprint
- Names that look like escapes or separators: the scanner built remote://
  paths from raw server names, which parse() then percent-decoded, so
  "a%20b.pkg" read "a b.pkg" and "100% Orange.pkg" could not be read. Each
  name is now encoded (%, \) into the path and the relative path decoded
  back; parse() accepts an encoded backslash (never one hiding ".."). The
  organizer's server tree keeps raw server paths and never decodes them;
  only on Windows is a "\" turned into "/" (there a name holding one is
  left alone). Container strings go through one path_string everywhere.
- FTP no-overwrite: a rename target counts as free only when its folder
  could be listed (550 is not "absent"), no entry has the name in any case,
  and SIZE/MDTM on the exact path do not find it (LIST hides dot-files).
  stat() probes the same way, so apply's destination check sees hidden
  files too.
- Undo fingerprint: a run's log records protocol, host, port, share and
  folder. Undo is refused when the saved connection now points at another
  server; an older log is undone on the same connection id with a note.
- redact(): a short secret right after a %XX escape, and the secret's
  percent-encoded form, are masked too.
- Tests: odd names through the scanner and the organizer, case-variant
  collisions, FTP unlistable parent / hidden target / case twin, the undo
  fingerprint, URL-encoded redaction. Re-checked plan/apply/undo on Samba,
  vsftpd, OpenSSH and Apache WebDAV with such names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:52:40 -07:00
phantomptrandClaude Opus 5.5 a88541b11b Organize: never delete a WebDAV folder; redact short secrets as tokens only
WebDAV's DELETE of a collection is recursive, so the empty-check before it
left a moment in which a file landing in the folder would be deleted with
it. WebDAV now removes no folder: a folder the run emptied stays in place
and is listed in the result's kept_dirs (now "<folder>/<dir>", so the same
name in two containers is told apart). SMB, FTP RMD and SFTP rmdir refuse a
non-empty folder by protocol and keep removing. The write contract accepts a
backend that never removes folders; MemFs can play one.

Error redaction replaced every occurrence of the secret, so a one-letter
password turned "Connection" into "Connec***ion". A secret of four
characters or more is still replaced everywhere; a shorter one only where it
stands as a whole token (user:pw@, password=pw, quoted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:20:14 -07:00
phantomptrandClaude Opus 5.5 ce65412879 Collection: Organize packages on saved servers (SMB, FTP, SFTP, WebDAV)
RemoteFs gains three non-destructive writes, for every backend and MemFs:
create_dir (ok if the folder is there), rename that never replaces (SMB
ReplaceIfExists=false, SFTP v3 RENAME plus a look first, WebDAV MOVE with
Overwrite: F, FTP looks up the target before RNFR/RNTO), and remove_dir
of an empty folder. contract::check_writes covers them, including a rename
onto an existing file failing with both files intact; it passes against
real Samba, vsftpd, OpenSSH and Apache WebDAV servers.

The organizer now works through a Tree (this computer, or a server over the
pool), so scan, plan, apply and undo are one code path. On a server: the
index's cached package facts spare reading headers over the network, a
rename whose reply was lost is checked rather than repeated, a dropped
connection stops the run with the in-flight move logged as uncertain, and
Undo puts back whatever actually moved. Plans and runs carry a
"Server: /path" label.

WebDAV PROPFIND and writes no longer follow redirects through ureq (which
dropped the Authorization header): a same-server redirect, as Apache sends
for a folder without its trailing slash, is followed by hand. Stat of a
folder on such a server failed with a 401 before.

The Folders hint, the Organize dialog and the FAQ drop "this computer's
folders only" for Organize; the hint is updated in all 21 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:16:57 -07:00
phantomptrandClaude Opus 5.5 21d0677450 ava1-ctest: serialize jobs_any_owner's tests under cargo test
The coverage job runs tests as threads of one process; the 32-job
test could evict the grace test's finished PC operation. nextest
was unaffected (one process per test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 21:11:10 -07:00
phantomptr 6e9aba056a Release v7.1.6 2026-10-18 20:25:20 -07:00
phantomptrandClaude Opus 5.5 53dea0e35c 7.1.6 notes: faster multi-file uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 20:24:33 -07:00
phantomptrandClaude Opus 5.5 6eb4ac565e Merge Files: a multi-file pick uploads as one job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 20:24:33 -07:00
phantomptrandClaude Opus 5.5 61e930f620 Files upload: review fixes — never overwrite unasked
- Conflicts compare names by NFC + lower case on every drive: a case-only or
  Unicode-form twin is a conflict, Keep both looks for a name free under the
  same rule, two picks differing only in case clash, and Replace lands under
  the spelling already there.
- A failed fresh listing aborts the pick with its error instead of planning
  against a stale or empty one.
- The conflict dialog always focuses Keep both (never Replace or Merge).
- The pick is dropped when the console changes during the listing or the
  questions; an open question answers Cancel on unmount or console change.
- A run already going on that console is reported before any question (and
  the store's refusal is surfaced too).
- listUnsupported only takes engine HTTP 404/405 or a missing shell command.
- Resume keeps a list's Keep both names as planned (documented why).
- Tests: ConflictPanel focus/scrim/buttons/apply-all, conflictDecision clamp,
  case/Unicode clashes; one new string in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 20:24:17 -07:00
phantomptrandClaude Opus 5.5 47023ee256 Files: send a multi-file pick as one file-list job
The Files screen started one AVA1 job per picked file (~0.46 s each in the
engine; 5,000 small files took 44 min). Two or more local files of a pick now
go up as ONE POST /api/transfer/file-list job under a client-minted tx_id.

- state/fsUploadPlan: settles name conflicts before the job (Skip / Keep both
  / Replace, Merge for a folder onto a folder, "do the same for the other N")
  against a fresh listing, then groups the pick into steps. A single file and
  each folder keep their own job (folder jobs already are one job and carry
  excludes, empty dirs, module renames, remote sources); remote:// files stay
  per-file.
- state/fsUpload: runs steps. A list shows overall bytes, finished files and
  the file in flight; cancel ends the one job; a stop names the files not yet
  on the PS5; retry/resume/restart re-send the same list under the same id so
  the console journal skips finished files. An engine/shell without the route
  falls back to per-file jobs.
- ConflictAsk dialog replaces the all-or-nothing Overwrite confirm.
- startTransferFileList, Tauri transfer_file_list, browserInvoke mapping.
- 10 new strings in all 21 locales; retired fs_add_files_conflict_*.

Phat 13.60, release engine: 240 files/348 MB 137.7 s -> 4.6 s; 5,000 small
files 44 min -> 27.7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 20:15:34 -07:00
phantomptrandClaude Opus 5.5 342ab8e72b 7.1.6 notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 19:06:37 -07:00
phantomptrandClaude Opus 5.5 e0270dedb1 Merge folder picker above dialogs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 19:06:29 -07:00
phantomptrandClaude Opus 5.5 f5a38446fd Merge Organize: loose packages in a collection folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 19:06:29 -07:00
phantomptrandClaude Opus 5.5 f730d9e39b Merge #476: uninstall reports what it achieved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 19:06:28 -07:00
phantomptrandClaude Opus 5.5 e04641b04e uninstall: #476 review fixes
- Our folder/SMP registration (mount.lnk) whose teardown worked is done
  even when Sony refuses, unless the title is still listed; the ACK
  carries Sony's code for logging and the engine's
  sony_uninstall_refused now means "the refusal stands". The appmeta
  copy is removed in that case too.
- Presence follows the engine's installed list exactly: an appmeta
  folder, or an app.db row whose folder exists under /user/app,
  /mnt/ext0/user/app or /mnt/ext1/user/app. A folder registration's
  kept /user/app/<id>/sce_sys is no longer a false "still present".
- remove_our_appmeta removes subfolders (bounded depth, lstat, never
  follows a link, unlink/rmdir only).
- Library unregister note and the dump swap honour stillPresent; the
  Installed confirm text change is reverted.
- Tests: ours + Sony non-zero, presence cases, appmeta with a subfolder
  and a symlink (host self-test); refusal_stands (Rust); vitest.

Hardware (Phat, FW 13.60): registered a small folder game through
AppInstallAll, added an appmeta subfolder, unregistered: still_present
false in 1.4 s, no /user/app/<id>, no appmeta, no app.db row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 19:06:06 -07:00
phantomptrandClaude Opus 5.5 3eb5f74ef1 Uninstall reports what it achieved instead of always "Unregistered" (#476)
The Installed screen's Uninstall ran Sony's AppUnInstall and the helper
toasted "Unregistered <id>" whatever Sony answered; the client ignored
the reply, dropped the game from its list, and the next refresh listed
it again. A refusal (sony_uninstall_refused) was carried in an ok reply
that no caller checked.

Sony's return code alone proves nothing either: on FW 13.60 it is 0 for
a title id that was never installed (measured on the Phat).

- helper: after the uninstall, check the places the app lists a title
  from (/user/appmeta/<id>, app.pkg on internal/ext0/ext1), waiting up
  to 10 s. A refusal is an ERROR frame (uninstall_refused:0x...), so
  every engine version reports it; a title still present is an ACK with
  still_present:true. The toast says Uninstalled / Unregistered /
  refused / still there. Decision logic in unregister_outcome.h with a
  host self-test.
- engine: pass still_present through and log it at WARN so bug reports
  carry it.
- client: appUnregister rejects a refusal (also an older helper's ok
  reply with sony_uninstall_refused) and returns stillPresent; the
  Installed screen keeps the game listed and says so. Plain-language
  errors for uninstall_refused / uninstall_unavailable, translated.
  The confirm dialog no longer calls a package install a folder
  registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 18:42:26 -07:00
phantomptrandClaude Opus 5.5 4eeaeef331 Collection Organize: packages loose in a collection folder count too
A .pkg lying directly in a collection root had no top-level folder,
so Organize skipped it and, with every package loose, reported that
no folder holds packages. A root is now a container for its loose
packages only (its subfolders are planned on their own).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 18:17:41 -07:00
phantomptrandClaude Opus 5.5 9bc73173c0 Folder picker opens above the dialog that opened it
Collection's folders dialog opened the server folder picker behind
itself: both sat at z-50. The picker now sits at z-[55], under the
confirm layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 17:54:44 -07:00
phantomptr 6c97eb0977 Release v7.1.5 2026-10-18 14:43:29 -07:00
phantomptrandClaude Opus 5.5 cf7f5884b1 Merge #475 review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 14:35:03 -07:00
phantomptrandClaude Opus 5.5 1d56e09c19 helper: #475 review fixes
- A durable-by-log job is not ended for the table until it has settled
  (no pack segments, or `settled`): between unswept_n reaching 0 and the
  settle, its last Status{unswept=0} and the segments' removal are still to
  come, and an eviction or the 10 s reap there left the sender's settle wait
  to time out and the segments behind. C test for that window.
- create() checks the retiring ids first and evicts only when the create it
  makes room for will go through (the id is not retiring, and the retiring
  list has room for the victim's id too), so no victim is destroyed for
  nothing. The retiring list holds 2 x AVA1_MAX_JOBS ids, which makes its
  "full is unreachable" comment true with evictions.
- The victim is destroyed on a thread of its own (ava1_job_put_nowait), off
  the JobOpen path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 14:16:06 -07:00
phantomptrandClaude Opus 5.5 b82cd65803 7.1.5 notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 14:08:15 -07:00
phantomptrandClaude Opus 5.5 e24cfed18b Merge #475: finished uploads leave the job table
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 14:08:06 -07:00
phantomptrandClaude Opus 5.5 6ce864c0f6 Merge rename to PS5Upload Minion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 14:08:05 -07:00
phantomptrandClaude Opus 5.5 5d0ff05913 Payloads catalog: releases with empty notes, gone repos, readable cache age
websrv v0.35 has "body": null, which failed the whole release list.
A repo that returns 404 now says it can't be found and that the saved
version still works; the cache age reads "saved 57 days ago".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 14:07:39 -07:00
phantomptrandClaude Opus 5.5 38f992f78e helper: ended uploads leave the job table on a live session (#475)
The Files screen uploads a pick of files one AVA1 job per file, all on the
engine's one shared session. The console's job table holds 32 jobs, and an
upload that ended stayed listed (with its job thread and workers) until its
session ended: only parked jobs were ever reaped, and a session is parked only
when it ends. So the 33rd file of a pick got ERR_BUSY "too many jobs, or this
job is still closing" for 12 retries and failed, and every later file did the
same until the helper was replaced (a new session). Reproduced on the Phat
(FW 13.60): file 24 of the Switch-like set failed busy, and the table never
drained while the session lived.

- A finished upload or download (not a copy, op or console-to-console send)
  stamps concluded_ms once its JobDone is out (or the peer's came in).
- The reaper lets such a job go 10 s after that, attached or not, unless it
  still has files settling (unswept), as SPEC §11.7 intended.
- A JobOpen that finds the table full takes the slot of the concluded
  transfer that ended longest ago and nobody else holds, instead of BUSY.

Tests: C unit test of the eviction and reap rules (wire_upload), and a host
test of 40 single-file jobs in a row on one session (failed at file 32 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 13:46:29 -07:00
phantomptrandClaude Opus 5.5 433139d18e Rename PS5Upload Files to PS5Upload Minion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 13:25:23 -07:00
phantomptr e763303a63 Release v7.1.4 2026-10-18 12:23:30 -07:00
phantomptrandClaude Opus 5.5 91969a15f1 7.1.4 notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:56:22 -07:00
phantomptrandClaude Opus 5.5 12a61d6199 Merge console cheat downloads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:56:12 -07:00
phantomptrandClaude Opus 5.5 5c9c4aeffd Merge cheats memory-write fix (Wolverine crash)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:56:12 -07:00
phantomptrandClaude Opus 5.5 c149a6ffe1 cheats: drop SIGSTOP, lock fixes, private-memory rule
- No SIGSTOP/SIGCONT: stopping the game froze games for CheatRunner and
  SIGSTOP also reaches SceSysCore as SIGCHLD. The game keeps running;
  the write order (caves before hooks on, the reverse off) is what keeps
  it from running half a cheat.
- Physical writes (FW > 8.20), as CheatRunner does them: a range inside
  a loaded module must lie wholly inside the game's eboot or an /app0
  module (system libraries and module edges are refused); a range in no
  module (heap, direct memory: absolute/data cheats) is gated only by a
  canonical user address and a present, readable page, with no vm_map
  entry walk (that walk panicked kernels on PS4-BC and special entries).
- The other enabled cheats' ranges are collected under g_apply_lock, and
  an incomplete list (alloc, unreadable file or state, unresolvable mod,
  too many ranges) refuses the turn-off with nothing written.
- A short read of the state file is a failed read, so state_set never
  rewrites it with keys missing.
- Self-test: the keep range now really overlaps the hook, and the hook
  is checked restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:55:55 -07:00
phantomptrandClaude Opus 5.5 ef0d59b681 console cheats: download review fixes
- One deadline covers the reply and its body, and the body is read in
  chunks (getReader; arrayBuffer when there is no stream) and abandoned
  past 4 MiB: a stalled or oversized body can no longer hang the finder.
- The helper reports "replaced" when a file of that name was already
  stored; the page says so (cw_cheats_find_replaced, 21 locales).
- A stored file closes only the finder that asked for it, and only while
  its game is running.
- The finder stays open through a transient empty status: it closes when
  another game is reported or no game on two reads in a row.
- downloadCheat refuses a name the helper would refuse before fetching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:48:06 -07:00
phantomptrandClaude Opus 5.5 fd9dce6479 cheats: review fixes — rollback, private writes, FW split, shared caves
- Rollback restores the failing location too (a write torn across two
  pages, or a read-back mismatch), then the earlier ones; undo failures
  are reported in the error. The write loop, checks and rollback move to
  cheat_plan_apply() with a memory-ops backend.
- FW split as in CheatRunner: <= 8.20 mdbg only (copy-on-write, private);
  > 8.20 physical page only, no mdbg write fallback.
- A physical write must land inside the game's own module (eboot, handle
  0, or a module under /app0); Section / module_name / absolute addresses
  in a system library are refused.
- Turning a cheat off leaves alone a code cave that overlaps another
  enabled cheat of the same title.
- The game is held with SIGSTOP (not ptrace) while the writes land;
  SIGCONT on every path. kill(pid, 0) before every write.
- state_set runs under g_apply_lock; the sidecar is read whole (no 4 KiB
  cut) and an unreadable one is not rewritten.
- Self-test: fake memory backend failing on every Nth write, on the second
  page of a page-straddling write (turning on and off), on the undo
  itself, a mismatch, the game exiting mid-apply, and a shared cave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:47:20 -07:00
phantomptrandClaude Opus 5.5 dd16c7c47f console cheats: find and download cheats on the console
The console page's Cheats section only listed cheat files already on the
console. It can now find the running game's cheats in the community repos
(read by the PS5 browser itself; a port of cheats.rs's repo list, filename
parsing and content URLs), mark the ones for the installed version, and
download one: the browser fetches it and POSTs it to the helper, which
stores it atomically where the cheat engine looks.

Helper: CSP connect-src allows raw.githubusercontent.com and
api.github.com; new /api/cheats/version (appinfo CONTENT_VERSION/APP_VER)
and streamed /api/cheats/store (strict name check, <= 4 MiB, same-folder
temp + rename via wf_receive_file). cheats.c unchanged: the engine rescans
its folders on every list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:40:49 -07:00
phantomptrandClaude Opus 5.5 02469c725b cheats: write game memory through its physical pages, all-or-nothing
Every Marvel's Wolverine cheat crashed the console; CheatRunner's worked.
Our engine PT_ATTACHed the game, set the protection byte of the vm_map
entry holding the address to RWX with kernel_mprotect, wrote with PT_IO,
and "restored" it to R-X. Measured on the Pro (FW 13.60): the game's
.text is one 95 MiB entry (0x400000-0x6324000), execute-only (0x04), so
each toggle made the whole executable RWX under a copy-on-write PT_IO
write and left it R-X. kernel_mprotect edits the entry byte directly (no
pmap update, no split); CheatRunner traced two kernel panics to that
call and moved to the page-table walk this commit uses.

- Read/write through kernel_proc_getpaddr + the direct map (mdbg as a
  fallback), inside kernel_rw_lock; read back after every write. No
  ptrace stop, no protection change.
- Apply a cheat as a whole: every location is read and checked against
  its cheat and original bytes before anything is written; on a write
  failure the earlier writes are undone. The old engine wrote whichever
  locations matched, so a jump could land without its code cave.
- Write code caves before the jumps into them when turning on, and the
  reverse when turning off (cheats_plan.c, host-tested on the real
  Wolverine pack).
- Refuse a cheat the parser had to truncate, and a base of 0.
- Toggle state is keyed by the flat index across all of a title's files;
  a toggle in the second file used to mark the first file's mods, which
  the watcher then re-applied at the next launch.

Verified on the Pro with Wolverine running: God Mode, Max Resources/XP
(20 locations), Infinite Item Charges and an MC4 Infinite Health on and
off, 60 s each, game and console stayed up; a clashing MC4 cheat was
refused with nothing written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 11:27:08 -07:00
phantomptrandClaude Opus 5.5 d11e2c1631 Save backups: put the game's name in the zip name
PPSA01234-Game Name-User.zip, when the game's name is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 10:53:46 -07:00
phantomptrandClaude Opus 5.5 a90ea16b4b path_policy: match path_protected_forms' header so GCC builds the C tests
GCC's -Werror=array-parameter rejected the definition's char[PATH_MAX]
parameters against the header's char * (clang accepts it), breaking
the Linux engine-workspace, C interop and coverage CI jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 09:59:14 -07:00
phantomptr 7ab0b4f485 Release v7.1.3 2026-10-18 09:08:27 -07:00
phantomptrandClaude Opus 5.5 2fee3c2c25 7.1.3 notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 08:51:54 -07:00
phantomptrandClaude Opus 5.5 a2d0aa8886 Merge Payloads hub, HearBridge and Processes & ports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 08:51:53 -07:00
phantomptrandClaude Opus 5.5 9297147661 console files: transfer review fixes
- A text save is refused (409 busy) while an upload and a download both run,
  so at least two of the four web workers stay free.
- Throughput floor: uploads, downloads and .tar streams are dropped below
  1 KiB/s averaged over 60 s, on top of the 20 s idle timeout.
- Early refusals on streamed routes read off up to 64 KiB / 2 s first, so
  the page gets the real error code.
- Leftover .ps5u-*.part files older than 24 h in the target folder are swept
  at upload begin and text save (that folder only, lstat, links untouched).
- A Range header too long to keep is ignored (200, whole file), not a 400.
- The editor saves with the file's dominant line ending and says when lone
  CRs will be normalised (new string in all 21 locales).
- Upload dialog free-space separator; Upload/Download hidden also when the
  page is opened by the console's own LAN address (from /api/settings).
- The .tar walk starts from the canonical root and resolves the trust
  store's forms once per archive instead of a realpath per entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 08:51:30 -07:00
phantomptrandClaude Opus 5.5 da8220d7e7 console files: upload and download from another device
When PS5Upload Files is opened from a phone or PC on the LAN (not from the
console itself), Files offers Upload here and Download.

Upload: POST /api/upload/begin checks path policy, the folder, an existing
name (409 exists / not_a_file) and free space (507) before any byte is sent
and reserves the one upload slot (409 busy; forgotten after 30 s); the bytes
follow as POST /api/upload/data?id= (streamed, idle timeout 20 s), written
to a temp file beside the target and renamed only when complete, so a cancel
or a dropped client leaves nothing. /api/upload/cancel frees a reservation.
The page sends files (or a folder, where the browser can pick one) one at a
time with progress, Skip this file / Stop, and Skip / Keep both / Replace
for names already there.

Download: /api/download/begin hands out a /dl/<ticket>/<name> link (128-bit,
for that item only, 60 s after last use) that the browser saves with a plain
navigation: a file with Content-Length and Range, a folder as a .tar streamed
as it is read (pax for long names and > 8 GiB sizes, links and other mounts
left out). One download at a time; trust store and its ancestors refused.

Measured on the Phat (13.60, LAN), 1 GiB: upload 66-75 MiB/s, download
111 MiB/s, folder .tar 101 MiB/s. Strings in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 08:28:40 -07:00
phantomptrandClaude Opus 5.5 064392d6a8 console files: permissions, new file and text editing
Info gets a Permissions editor (owner/group/other x read/write/run, octal,
"apply to everything inside" for folders; exFAT/FAT drives say permissions
are not kept). /api/fs/chmod takes an optional mode (<= 0777) for the item
alone (FS_CHMOD) or recursively (CHMOD_R job); without it, Fix permissions
is unchanged.

New file (toolbar + sheet) makes an empty file and opens it in the new text
editor; Edit / Edit as text opens any file up to 1 MiB that is valid UTF-8
without NUL. Saves go through POST /api/fs/save-text: the web server can now
hand a request to a streamed route after the token check (body read by the
route with an idle timeout, not the 5 s header deadline); the save is
refused over 1 MiB or with a NUL, checks the mtime/size (and mtime_ns, now in
/api/fs/stat) the editor read before writing and again before the rename,
and writes a same-folder temp file that keeps the old mode and is renamed
onto the file after a same-device check (webui_files.c, host selftest;
cross_device.h audit updated). CRLF files stay CRLF; a changed file asks
Save anyway / Discard; ↑/↓ stay inside the text box until its first/last
line. Phone width: tools wrap, rows tighten, dialogs fit (no sideways
scroll). Strings in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 08:28:00 -07:00
phantomptrandClaude Opus 5.5 c0c08d9c21 Payloads: hub review fixes
- HearBridge discovery has an 8 s overall deadline, tries the remembered
  port first and stops after the first connect timeout; reads can't
  outlast the deadline.
- The HearBridge routes refuse a console address with whitespace,
  control characters or URL syntax before any request; routes are
  tested at the axum level (unknown action and bad host are 400s).
- HearBridge is read every 15 s while its card is collapsed, 5 s with
  Manage open; the User-Agent comment now says what it does and doesn't
  avoid. A failed scan no longer waits for results.
- Marker checks wait for the catalogue and stand aside during transfers.
- HearBridge's card has its own loading line; summaries re-read after
  an Update.
- Removed the seven translations the old tabs used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 07:59:00 -07:00
phantomptrandClaude Opus 5.5 c5a239238c Payloads: HearBridge status and controls
HearBridge (Bluetooth headset audio) gets an integration card on On your
PS5. The engine talks to it for the app (its page sends no CORS
headers): new /api/ps5/hearbridge/{status,saved,devices,action} routes
find it on 8090..8095, read its status with the token stripped, and
pass only volume, mute, night mode, latency, reconnect, scan, select
and stop, each checked against the ranges HearBridge takes. The token
is read fresh per change and never leaves the engine.

The card shows the stream state, headset, battery, link quality,
delay, codec, volume and game profile; Manage has the volume and
latency sliders, mute, night mode, reconnect, scan and connect, saved
headsets, recent events and a link to HearBridge's own page for
pairing, EQ and backups. Stop uses HearBridge's clean stop. An absent
or older HearBridge shows "HearBridge didn't answer" with the page
link. A card opened by a link now scrolls into view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 07:38:10 -07:00
phantomptrandClaude Opus 5.5 d6c47afd9e Payloads: On your PS5 hub; Processes & ports
Payloads now has three tabs: Catalog, Send and On your PS5 (the default
once a console is connected, and the only tab in the web UI). On your
PS5 shows a card for every payload running or installed on the console,
found from the process list and each payload's marker file, plus
running payloads the catalogue doesn't know. A card opens the payload's
web page, offers an update when the catalogue has a newer release than
the running version, stops it (the existing kill confirm), and for
ShadowMount+, nanoDNS and Payload Manager expands to the panel that used
to be its own tab.

Ports moved next to Processes: the screen is now Processes & ports with
two tabs. Old links (?tab=manager|shadowmount|nanodns|ports,
/payloads/<integration>, /nanodns, /shadowmount) land on the card or on
the Ports tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 07:23:58 -07:00
phantomptrandClaude Opus 5.5 bb53c4f928 Payloads: add HearBridge (Bluetooth audio) to the catalogue
Also name ports 8090 (HearBridge) and 9180 (PS5Upload Files) in Ports
and mark both as web pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 06:53:01 -07:00
phantomptrandClaude Opus 5.5 530c63f28f ava1 soak: let the open-descriptor count settle before failing
The weekly ASan soak failed once at round 582 with 14 -> 16 descriptors
after 581 flat rounds: a connection teardown still finishing after the
fixed 300 ms settle. Poll for up to 10 s like the job and pack checks;
a real leak never settles back and still fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 06:18:17 -07:00
phantomptr ac1fa9f8ef Release v7.1.2 2026-10-18 05:54:06 -07:00
phantomptrandClaude Opus 5.5 11d12102c0 Save data: per-user names, user filter and per-user USB backups
On a shared console every user's saves were listed together under raw
user ids, and USB backups of the same game shared one folder, so
Restore all from USB could restore one user's backup into another's
save. Label saves with the owner's name (profile info), start on the
signed-in user with a Whose saves picker, name local backup zips after
the game and user, and keep USB backups in a per-user folder (legacy
backups used only when one user owns the title).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-18 05:54:03 -07:00
phantomptr d7169cc7e1 Release v7.1.1 2026-10-17 22:53:28 -07:00
phantomptrandClaude Opus 5.5 a54b04fbc4 Big Sur: old-WebKit fallbacks so the app scrolls again
macOS Big Sur's WKWebView (Safari 14) lacks dvh units and the
padding/margin-inline/block and inset shorthands. The minifier dropped
the hand-written height:100% fallback, so the app shell had no height
and nothing scrolled. Write an older equivalent before each such
declaration in the built CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 22:53:26 -07:00
phantomptr 23e33a21ca Release v7.1.0 2026-10-17 16:26:43 -07:00
phantomptrandClaude Opus 5.5 4432ea1459 7.1.0 notes: don't promise the offline copy on every browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 16:20:10 -07:00
phantomptrandClaude Opus 5.5 13a61f5c1e 7.1.0 notes and FAQ for PS5Upload Files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 16:19:35 -07:00
phantomptrandClaude Opus 5.5 712ff87d6a console page: final hardware-pass fixes
Web threads get the 512 KiB mgmt stack (they run mgmt handlers); a recent
folder that is gone (404) is dropped via a new prefs "forget" op; a non-exFAT
image inside a .ffpfsc reads as unsupported, not damaged, and is logged to
stderr; text find shows its count after a pause in typing; free-name probes
use /api/fs/stat missing_ok (200 {"missing":true}) so no 404 noise. hw_info
comment updated for the page's 10 s HW_TEMPS poll on a web worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 16:19:35 -07:00
phantomptrandClaude Opus 5.5 a507c9b035 Merge console page robustness and polish (F8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 15:46:42 -07:00
phantomptrandClaude Opus 5.5 831018b04d console page: F8 review fixes
- cheats: "attached to the game" (busy) only for EBUSY; other attach
  failures stay a plain "pt_attach failed (errno=N)".
- Continue on your phone shows only in the console's own browser
  (127.0.0.1/localhost); a gap above the dialog buttons.
- The token read gives up after 4 s (offline screen); a small splash
  instead of a blank page while it is asked.
- No silent reload: an appcache update, or a helper version newer than
  the page's build, shows a one-press "updated: Reload" banner (Later
  hides it). cw_update_* in 21 locales.
- own_addrs reports a getifaddrs failure and the list is kept (no
  flapping); EMFILE/ENFILE/ENOBUFS/ENOMEM never count toward a broken
  listener.
- backHistory header: Escape/○ at Home closes the browser on the PS5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 15:34:03 -07:00
phantomptrandClaude Opus 5.5 f77c33e579 console page: rebind, marquee, continue on phone, deferred fixes
- Helper listener heals itself on the accept thread (no new thread): every
  5 s it rebinds after a resume (10 s clock gap) or, with LAN access on,
  when the console's addresses change (Host allowlist refreshed); a
  listener whose poll/accept keeps failing for 5 s is rebound too.
- Long file names slide while their row is focused (text-indent marquee,
  prefers-reduced-motion respected; one focusin/focusout pair).
- Continue on your phone: /api/settings carries the console's LAN address;
  rename, new folder, go to path and bulk rename show a QR code and
  http://<addr>:9180/ while LAN access is on. Tiny dependency-free QR
  encoder (byte mode, v1-10, L/M), checked against segno symbols.
- Deferred: FS stat ids from stat() (a symlink to the destination is
  itself); clipboard header; language switch keeps the shown language
  until the new one loads, strings fetch times out after 10 s; own
  Cache-Control detected in any case; cheats toggle right after the game
  exits says closed at once (helper drops its 2 s cache, dead pid reads as
  no game), a racing PC/page toggle says "another app is changing";
  failed batch items stay selected; Fix permissions title counts items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 15:18:01 -07:00
phantomptrandClaude Opus 5.5 4ff889720d console page: offline page, error boundary, close on ○ at Home
- Offline copy: a service worker (/sw.js, network first for / and
  /strings/*, never /api/* or POSTs) plus the appcache fallback
  (<html manifest="/cache.appcache">) the PS5 browser honours for Payload
  Manager. Both are emitted by the console build with the page's hash and
  embedded in the helper (webui_offline.c), served with their own types and
  Cache-Control: no-cache; the page is now no-cache (storable) instead of
  no-store.
- Boot gate: the page reads /api/token first; no answer (or not the
  helper's) shows "PS5Upload Files isn't running" with Try again / Close,
  re-checked every 5 s, reloading once the helper answers.
- Error boundary: Reload, or Reset settings & reload (this page's
  ps5upload.* keys, the tour mark kept), instead of a blank page.
- ○ on the first screen (PS5 browser only) calls window.close(); a refused
  close leaves the next ○ to the browser. A popstate that pops nothing is
  ignored, and within 1.5 s of load before any key/click a pop only puts
  the guard back (WebKit's load-time popstate, tour-at-load).
- cw_* strings for F8 in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 14:58:11 -07:00
phantomptrandClaude Opus 5.5 7d510c0f06 Merge #473 app.db pagination fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 14:53:37 -07:00
phantomptrandClaude Opus 5.5 17716f912b Merge console files viewer, search and disk usage (F5+F6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 14:38:44 -07:00
phantomptrandClaude Opus 5.5 bf7157a5ff console files: search and viewer hardening
Index name match is linear (iterative, one backtrack point; selftest bounds
the pathological pattern); star runs collapsed. INDEX_SEARCH filters an
optional "under" before its limit; This folder says when matches elsewhere
filled the reply. Text viewer draws a window around a find target with
memoized blocks and one scan per query. Hex reads only 64 KiB; end-of-list
buttons keep focus (hex First/Last added); D-pad left/right on the picture
changes image (nav hands arrows to [data-nav-arrows]); SFO key scan bounded;
folder size and disk usage share one walk; Search waits for the drives;
Disk usage starts at the folder shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 14:37:10 -07:00
phantomptrandClaude Opus 5.5 ec2c8f3a6b console files: search and disk usage
Helper: /api/index/start|status|search|cancel over INDEX_START / STATUS /
SEARCH / CANCEL (handlers unchanged; the engine never calls them). Their
bodies are scanned quote to quote, so every root and pattern is checked
for no '"', '\' or control character and < 256 bytes (codec, selftested).
Roots: 1-8, absolute, never the trust store (403). The page's last start
(roots, started_at, cancelled) is remembered so status says whether the
index there is the page's and what it covers. Search answers at most 500
results, filters by `under` (asking the helper's 5,000 for a folder) and
never returns a trust-store path; truncated when the helper's limit or
the page's is reached. JSON buffers on the heap.

Page: Search (toolbar) — name or * / ? pattern (a word matches anywhere),
all drives or this folder; the first search builds the index for that
scope with a running count and Cancel, then searches; a covering index
is reused; results (name, folder, size) open in Files at the item. An
older helper (404) is told. Disk usage (toolbar) — the drive's
subfolders as bars, biggest first, measured one after another with
folderSize's walker under one budget (500k entries, 3 min, 2,000
subfolders), cancellable, one at a time page-wide (refuses while a
folder-size walk runs); "›" looks inside (○ comes back up), ✕ opens in
Files. Strings cw_files_search_* / cw_files_usage_* in 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 14:11:33 -07:00
phantomptrandClaude Opus 5.5 3a1e8019b6 console files: viewer — text find, JSON, hex, image browsing, game banner
Text: the first 1 MiB as numbered rows (long lines cut every 2,000
characters, numbered once), drawn 1,500 rows at a time; find with
Previous / Next on-screen buttons (case-insensitive, wraps, counts up to
10,000), a wrap toggle, and "View as hex". .json files are pretty
printed (shown as written when they do not parse); sce_sys/param.json
gets a summary card: title, title id, content id, version, required
system software.

Hex: the first 64 KiB of any file (sheet "View as hex"), offset | hex |
ASCII, 1 KiB pages with Previous / Next page.

Images: previous / next through the folder's images in list order
(on-screen arrows, disabled at the ends; ←/→ on the picture when the
browser delivers them); the 16 MiB cap message stays.

Game banner: a folder with sce_sys shows title, id, version, firmware and
sce_sys/icon0.png, from param.json (PS5) or param.sfo (PS4: PSF parser,
strings and integers, every offset bounds-checked), each read ≤ 64 KiB.

New code under files/viewer/; Files.tsx and ActionsSheet only wire it.
Strings cw_files_vw_* / cw_files_game_* in 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 14:06:39 -07:00
phantomptrandClaude Opus 5.5 d46a966990 Merge console files PC integration (F7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:53:30 -07:00
phantomptrandClaude Opus 5.5 0ffeba67f8 console files: handoff and upload-badge hardening
- The Uploading badge reads upload roots through ava1_data_upload_roots,
  under the open lock (where roots are written) and the table lock, and
  leaves out parked, finished, stopping and recovery-placeholder jobs.
- files_handoff_take_json checks its buffer before taking, so a too-small
  buffer can no longer empty the slot and lose the handoff.
- PC app: the Show on PC poll skips its ticks while a transfer runs on
  that console (like the other background management pollers); away from
  File System a handoff becomes a toast with "Open" instead of navigating.
- Console "Show on PC" now says "Sent to your PC." (no promise it opens),
  in all 21 languages; new PC strings for the Open offer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:52:35 -07:00
phantomptrandClaude Opus 5.5 1f16fb97fc console files: decompress progress, case-only rename, unpack name hardening
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:48:00 -07:00
phantomptrandClaude Opus 5.5 4b51f802d3 console files: shared favourites and Show on TV / Show on PC
The console page takes the PC's "Show on console" handoff every 3 s while
visible (any section) and opens Files there: a folder as it is, a file in
its folder with the file focused, a path that is gone opens nothing and
says so. "Show on PC" in the actions sheet (once the helper is known to
have handoffs) leaves the item for the PC app. Folders a PC upload is
writing into (/api/jobs "uploading") show an "Uploading" badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:36:15 -07:00
phantomptrandClaude Opus 5.5 c12787d7e0 console files: bulk rename, folder size, verify, decompress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:22:35 -07:00
phantomptrandClaude Opus 5.5 8c86887b97 console files: Pinned on console, Show on console and Show on PC in the PC app
File System's path bar dropdown gets a "Pinned on console" group (the
console page's pinned folders, a click opens one; pin / unpin the folder
shown). The row and folder context menus get "Show on console": the
console page opens that place. The app takes the console's "Show on PC"
handoff every 3 s on File System (10 s elsewhere, never while hidden) and
opens it in File System with a toast. All of it is hidden against an
older helper (the engine says supported:false).

Strings for both sides in all 21 languages, in one block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:20:58 -07:00
phantomptrandClaude Opus 5.5 8d188eda1d console files: files.* methods and engine routes for shared prefs and handoffs
Helper: files.prefs_get/prefs_set (148/149) reach files_prefs.c, the same
file the console page's /api/files/prefs uses; files.handoff_put/take
(150/151) keep one Show on TV / Show on PC slot per direction in helper
memory, gone after two minutes, emptied by a take (files_handoff.c, host
self-test). The page gets /api/files/handoff/take {for:"console"} and
/api/files/handoff/put {to:"pc"}; /api/jobs adds "uploading", the roots
of up to 8 running uploads, for the Files badge.

Engine: GET /api/ps5/files/prefs, POST .../prefs/set, .../handoff/put,
.../handoff/take. An older helper answers unknown method, reported as
{"supported":false} so the PC app hides the features.

console files: shared favourites and Show on TV / Show on PC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 13:09:27 -07:00
phantomptrandClaude Opus 5.5 dccc77f38b console files: paste decides "the item itself" by dev+ino, Stop/close skip a waiting conflict
/api/fs/stat returns dev and ino (lstat of the accepted path) so a
case-insensitive exFAT spelling of the same folder or item is never offered
Replace (which deleted the item first) and a cut there is a no-op. Jobs Stop
left running stay in the cut clipboard; Stop during a Replace delete says so;
Stop and closing Files answer a pending conflict with Skip; one combined
replace-all body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 12:49:19 -07:00
phantomptrandClaude Opus 5.5 910321cb4a console files: copy, cut and paste with conflict choices
Copy / Cut in the actions sheet put items in a page clipboard (kept across
folders and sections); a bar in the status line shows "3 items to paste
(cut)" with Paste here and Clear. Paste stats each destination first; a
conflict asks Skip / Keep both ("name (2).ext", first free number) /
Replace (danger confirm), with Apply to all. Replace of a file uses the
copy job's overwrite flag (new optional "overwrite" on /api/fs/copy, off by
default); a folder (or a kind change, or a same-drive cut, which is a
rename) deletes the existing one first and waits, since overwrite merges
folders. Cut = rename on the same drive, copy job with move across drives;
one batch at a time with progress and Stop; a successful cut empties the
clipboard, a copy keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 12:41:26 -07:00
phantomptrandClaude Sonnet 5.5 099b941871 console files: deterministic 20k test, accent-folded sort, select-bar filter clear
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 12:22:00 -07:00
phantomptrandClaude Sonnet 5.5 4fcb9e913a console files: one-row toolbar, more rows on screen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 12:18:29 -07:00
phantomptrandClaude Sonnet 5.5 f7daeec308 console files: sort, hidden files, filter, icons, select all
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 11:48:31 -07:00
phantomptrandClaude Opus 5.5 a1d1692ef0 console files: hold unmount while a game runs, hold eject during transfers, pinned in Go to path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 11:31:45 -07:00
phantomptrandClaude Opus 5.5 5fa4afeb55 console files: path bar, favourites, recent, eject and unmount
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 11:22:19 -07:00
phantomptrandClaude Opus 5.5 264b8096f8 Review fixes for #473: reconcile folders only on a partial app.db read
- Registered-only titles are added only when app.db could not be read in
  full; the helper's registered list is every /user/app folder, so with a
  complete read a folder app.db does not list is a leftover, not a game.
  NPXS ids are excluded, and a candidate needs a mount.lnk source or a
  param.json/param.sfo in its sce_sys.
- app.db pages carry the table's row count (SQL reader only); the engine
  marks the list truncated when it changes between pages.
- A SQL walk that ends on anything but SQLITE_DONE reports truncated.
- FAQ: the phone guide is phrased as a question; references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 11:13:58 -07:00
phantomptrandClaude Opus 5.5 61178d7526 FAQ: use PS5Upload from your phone or another computer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 11:07:03 -07:00
phantomptrandClaude Opus 5.5 b7b1ccaf58 Page through the app database so no installed game is hidden (#473)
APP_DB_QUERY answered at most 256 rows, and fewer when its 64 KiB reply
filled, with nothing saying the list was cut. A title installed by a
homebrew installer (no /user/appmeta folder) that sorted past that point
disappeared from the installed list.

Payload: app.db.query takes an optional {"offset","limit"} window and
answers offset/next/more/truncated; a full buffer now ends the page with
more=true instead of closing the list. An empty request is page one, so
older engines keep working.

Engine: appdb_query follows the pages (at most 32) and marks the list
truncated when it could not finish (or an older helper returned a full
256). The installed list reads app.db once per request, adds registered
titles whose app folder exists, reports incomplete, and a truncated list
never proves a title absent for install verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 11:07:03 -07:00
phantomptrandClaude Sonnet 5.5 3b4338d284 console page: name it PS5Upload Files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 10:53:29 -07:00
phantomptrandClaude Opus 5.5 d435ac27db console page: load only the active language's strings
The page embedded all 21 languages' cw_* strings (174 KiB of the 220 KiB gzip
budget). Now it embeds English only; every other language is a separate
build artifact (dist-console/strings/<lang>.json) gzip'd into the helper and
served at GET /strings/<lang>.json (Host/Origin checked, no token, immutable
year-long cache; the page asks with ?v=<hash of the strings>). The page shows
English until the chosen language arrives, falls back to English per key, and
stays in English (logged once) when a load fails.

Page: 607.1 KiB raw / 174.4 KiB gzip -> 357.5 KiB raw / 112.5 KiB gzip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 10:49:30 -07:00
phantomptrandClaude Opus 5.5 4d7575ec3b console page: the input log's last batch gets past the helper's rate gate
Fix round 2. The helper refused a request under 500 ms after the last one (429),
which caught the final batch sent as Controls closes, right after a regular one,
so ○ was still lost. The gate is now a pure, host-tested function: one request
marked {"final":true} is admitted inside the window, once per window, each body
still at most 4 KiB. The page marks its final batch (keepalive, cut to fit with
the flag) and sends it only after a regular send still on the wire, so the two
never arrive out of order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 10:29:15 -07:00
phantomptrandClaude Sonnet 5.5 f220b63e58 console page: Fix permissions in Files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 10:25:10 -07:00
phantomptrandClaude Opus 5.5 66a321abb9 console page: Controls says Received for unused keys, keeps the last log batch
Fix round 1 for the controls guide. Controls shows "Works" only for input the page
acts on today (nav.ts's always-live keys, verified bindings, the pointer) and
"Received" for keys that arrive but are not used yet. Stopping the input log, or
the page being hidden while it runs, sends the queued events at once with fetch
keepalive, so ○ closing Controls is not lost. Legend labels wrap to two lines and
fit in all 21 languages; the drive list's Try again shows Loading; the focused
drawing keeps its ring without the washed-out fill; unused cw_settings_display
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 10:20:54 -07:00
phantomptrandClaude Opus 5.5 f9ffbe4df9 console page: controls guide, first-run tips, help
A Controls screen (Settings, the rail's Help, or OPTIONS once measured) draws the
DualSense with each button's job on the page and lights every button the page
receives ("Works"); PS and Mute are marked as used by the PS5, the rest "not
received yet". While it is open the input log is on, and keys do nothing else
(no click, no focus move, no browser reload) except ○, which closes it.

Three first-run tip cards (sections; ✕ selects, ○ goes back, everything has an
on-screen button; the PS5 pointer and full screen) show once, skip with ○ or
Skip, and come back from Settings › Show tips again. A help overlay lists the
buttons that work on the current screen and those not confirmed yet. The hint
bar shows ✕ and ○ plus only verified buttons with a job on the screen in front.

Settings order: Theme · Language · Full screen · Controls · Show tips again ·
Allow other devices · home screen tile · About. Empty and failed states say what
happens next (drive list and settings get Try again). CHANGELOG 7.1.0 describes
the final page. Gzip budget 220 KiB (ruling R19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 10:09:24 -07:00
phantomptrandClaude Opus 5.5 e4d069a3f0 console page: Cheats for the running game
The console page gets a Cheats section (rail: Home, Files, Cheats, Settings; Home's
Cheats shortcut now shows). It lists the running game's cheats as switches: X or the
square button switches the focused one, rows are inert while a switch runs, and the
helper's state is read again after every switch. No game, no cheats, the game closing
while the page is open, and helper errors each say what to do next, with Refresh.

The helper adapts the cheat methods exactly as the PC engine calls them:
/api/cheats/status (CHEATS_STATUS), /api/cheats/list {title_id} (CHEATS_GET, every
entry re-escaped, a cut reply keeps its whole entries) and /api/cheats/toggle
{title_id,index,on} (CHEATS_TOGGLE, its free-text refusals mapped to tokens).
Downloads and deletes stay in the PC app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 09:39:58 -07:00
phantomptrandClaude Sonnet 5.5 297a76efd2 console page: every running task keeps its Cancel, honest empty state, cover fallback
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 09:14:44 -07:00
phantomptrandClaude Sonnet 5.5 8db3aeab3c console page: stored theme applied before first paint, stricter token guard, visible danger focus
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 09:11:16 -07:00
phantomptrandClaude Sonnet 5.5 d286b72deb console page: Home without power actions or repeats
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 09:08:27 -07:00
phantomptrandClaude Sonnet 5.5 9274e78afe console page: themes matching the PC app
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 08:59:38 -07:00
phantomptrandClaude Opus 5.5 33f1851cdc console page R1 fixes: text fields never take hover focus, unmeasured buttons stay inert, honest full screen
- Hover never focuses a text field; a field holds focus against hover only for
  1.5 s after a key typed into it, so the ring cannot stay on an input while
  the pointer (and the native ✕ click) sits on OK.
- Ruling R18: only verified bindings act; the always-live set is the arrows,
  Enter, Escape and Backspace (and the old gamepad buttons). F5, Home,
  PageUp/PageDown, ContextMenu, F1, F11 keep their browser defaults. PageUp/
  PageDown are left to text fields once verified.
- Full screen: a refusal (error event or rejected promise) hides it for the
  session; a webkit request counts only when the page really went full
  screen; automatic only in the PS5 browser; Escape spends no try; the
  Settings switch follows fullscreenchange.
- cw_hint_filter removed from all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 08:52:14 -07:00
phantomptrandClaude Opus 5.5 c1c74dece8 console page: the pointer is the focus, the whole DualSense in one table, an input log, full screen, TV scale
The PS5 browser drives an on-screen pointer and ✕ clicks at it, so the page
now treats hover as focus: the ring and what ✕ activates are the same
element. After a key move the pointer is parked (drift under 6 px is
ignored) and a click there activates the focused control instead.

nav.ts's button table covers every DualSense button with its action, the
keys/keyCodes it may arrive as and verified:false until measured: L2/R2 page,
L3 top, R3 rail, OPTIONS help, Create refresh, touchpad full screen, PS/Mute
system. The hint bar shows ✕/○ always, the rest once verified.

POST /api/input-log appends compact, privacy-neutral event lines to a 64 KiB
ring at /data/ps5upload/webui-input.log (same-directory rewrite, bounded
body, 429 under 500 ms); the page sends only while setInputLogEnabled(true).

Full screen on the first activation (standard, then webkit-prefixed), with a
Settings switch hidden where unsupported. Type scales with the viewport
(26 px at 1540x700). Raw page budget 640 KiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 08:25:31 -07:00
phantomptrandClaude Sonnet 5.5 20f8809415 Hide the console page tile from the PC library; settings fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 07:52:03 -07:00
phantomptrandClaude Opus 5.5 8550d3380d CHANGELOG 7.1.0: say what the console page was verified to do
Made for the controller (D-pad and ✕) and works with the PS5's Back
button; no "controller alone" claim. Batches run one item at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 07:35:15 -07:00
phantomptrandClaude Opus 5.5 c51c38d919 console page: one job at a time, browser Back steps back in the page, polish
- Copy/move/delete batches send the next item only after the previous
  item's job left "running" (files/batch.ts: page-wide batch, survives
  section switches, "Copying 3 of 40", Stop ends it before its next item).
- The PS5 browser's ○ is history back: one history guard sits on top
  while the page has somewhere to go back to (modal, folder, a section
  other than Home); popstate runs the page's back action. Escape,
  Backspace and a gamepad ○ call history.back() through the same path.
- Home's confirm dialog keeps 409/503 errors inside it; the status poll
  pauses while the page is hidden; images over 16 MiB say "too large to
  preview"; storage cards are role=button with Enter/Space; the tile
  switch waits for the helper's real result (tile_busy/tile_failed).
- New cw_ strings in all 21 locales; three unused ones removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 07:35:15 -07:00
phantomptrandClaude Opus 5.5 284d22c6e5 helper web UI: free seen jobs, hide the tile from the PC library, games-only Close
- /api/jobs releases the page's finished jobs after replying
  (ava1_jobs_release_seen), so batches cannot fill the shared table.
- list_registered_titles_json leaves out WEBUI_TILE_TITLE_ID; a PC's
  APP_UNREGISTER of the tile is Settings -> tile off (saved tile:false,
  tile thread removes it) so the next start does not re-register it.
- Now running: the focused app is never the tile; the fallback names
  only a game title id (PPSA/CUSA/... prefixes), never the browser, the
  tile or homebrew. Codec selftest covers it.
- /api/settings carries tile_busy/tile_failed (webui_tile_state); the
  tile request is made before the reply.
- put_str/r_status clamp `at` after every escape/printf; send_error and
  r_fs_delete clamp too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 07:35:02 -07:00
phantomptrandClaude Opus 5.5 9d2625850e ava1: release the console page's finished jobs once it has seen them
ava1_jobs_release_seen(me): every finished operation or copy owned by
the console page keeps only the grace (AVA1_OP_GRACE_MS) after the page
first read it finished, instead of a copy holding one of the 32 shared
job slots for the full 10-minute park age. Operations reuse
ava1_op_status_delivered; copies are restamped and the reaper keeps a
delivered copy only for the grace. A paired PC's jobs are never touched.

ava1-ctest: the 32-job cap with list_all (full table refuses a 33rd for
the page and a PC; releasing the page's own makes room, PC jobs stay)
and the grace (page jobs gone after ~10 s, PC op and copy still listed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 07:35:02 -07:00
phantomptrandClaude Opus 5.5 ffa85203ec payload: square, full-bleed PS5Upload tile icon
The PS5 shows home-screen tile art edge to edge. The macOS squircle icon (transparent rounded
corners and margin) sat as a small rounded square inside a grey frame; this is the same logo
flattened onto a white square, centred, filling ~70% of the tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:59:50 -07:00
phantomptrandClaude Sonnet 5.5 76928943b9 console page: Home lists finished jobs only from this console
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:54:23 -07:00
phantomptrandClaude Sonnet 5.5 81a6e0d5ff console page: show the name as PS5Upload
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:46:47 -07:00
phantomptrandClaude Opus 5.5 97345a6514 console page: rail/content navigation regions, storage cards open Files
The rail and the content are nav regions ([data-nav-region]). Up/Down stay
in the focused region and stop at its ends. Left/Right move within the
region while something lies straight that way, else enter the neighbouring
region at its entry: [data-nav-entry] (the rail's active section), else the
control last focused there if it is still shown, else its first control.
Modals keep their own scoping. ✕ on a Home storage card opens Files at
that drive's path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:42:46 -07:00
phantomptrandClaude Sonnet 5.5 81654f8793 console page: Settings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:33:08 -07:00
phantomptrandClaude Opus 5.5 902d6cef65 console page: Files review fixes
Closing the viewer aborts its reads (no more 1 MiB requests holding a
helper web worker); copying or moving a folder into itself or a subfolder
is refused on the page with its own message; a 409 says "already exists"
only for an exists code; opening Files at a path (Browse USB) resets the
selection and focuses the first row like any folder change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:22:45 -07:00
phantomptrandClaude Opus 5.5 79a14d5731 console page: Files
Drives -> folders (folders first, natural name order, sizes, free space),
paged 256 at a time through /api/fs/list's next offset; "Show more" loads
the next page when the D-pad reaches it. The actions sheet (triangle or the
on-screen "..."): View, Copy to..., Move to..., Rename, New folder, Info,
Delete (confirm names the item and the count). Select (square or the
toggle) applies Copy/Move/Delete to several items, sent one request at a
time; Copy/Move/Delete are off while a batch is being started. Moves rename
on the same drive and fall back to a move copy job on 409 cross_mount; copy
and move send each item's full destination path, as JOB_COPY and the
engine use it. Viewer for png/jpg (blob URL, revoked on close) and
txt/log/json/ini/xml (first 1 MiB as text nodes, D-pad steps through it).
Circle goes up one folder or closes the open modal.

ConfirmDialog (and every new modal) restores focus to the row now at the
opener's place when the opener is gone; Files also refocuses that place
when a refresh removes the focused row. App's circle handler is now a
nav fallback so a section's own circle runs first. Home's Browse USB
appears, since Files opens at ctx.params.path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 06:16:58 -07:00
phantomptrandClaude Opus 5.5 f91e0f9b67 console page: Home
Running game with Close, one storage card per drive, every job with its
owner (this console / a PC) and Cancel, quick actions Browse USB (hidden
until Files opens at a path), Rest mode and Restart. Every destructive
action goes through the shared ConfirmDialog (nav modal, focus on the safe
choice, ○ closes). useJobs polls /api/jobs every 1 s while a job runs,
every 5 s otherwise, stops on unmount and on 401. Sections get a context
(status, refreshStatus, openSection with params). Strings in 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 05:49:08 -07:00
phantomptrandClaude Opus 5.5 8f7233827f console page: modal-scoped navigation, anti-framing headers, D-pad echo guard
- nav.ts: while a [data-nav-modal] is open only handlers scoped inside it
  run (onNavAction's new scope getter), so the reload banner (and later
  dialogs) keep ○/L1/R1 from moving focus to the page behind; confirm
  with focus outside the modal brings focus in instead of clicking.
- R12: an Arrow keydown within 100 ms after the gamepad fired the same
  direction is dropped as the browser's echo.
- GET / adds frame-ancestors 'none' and X-Frame-Options: DENY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 05:29:45 -07:00
phantomptrandClaude Opus 5.5 99dda9acb8 console page: build, shell, controller navigation
The page the helper serves on the PS5 itself: client/src/console/ built by
vite.console.config.ts into one self-contained dist-console/index.html
(script and styles inlined, safari13, 253 KiB raw / 84.5 KiB gzip; the
build fails over 450 KiB raw or 150 KiB gzip). payload/Makefile gzips it
into build/webui_page.c and the helper serves it at GET /, with
GET /api/token for the per-start token.

- Shell: top bar (logo, console chip from /api/status), rail of the
  implemented sections (Home, Files, Settings placeholders), hint bar
  showing only verified bindings, reload banner on a 401.
- nav.ts: spatial focus (aligned neighbours first), the R11 default input
  table (keyboard + Gamepad "standard", all unverified), D-pad repeat.
- api.ts: token fetched once, X-PS5U-Token on every POST, 401 → expired.
- strings.ts: cw_* keys from all 21 locales via build-console-strings.mjs
  (vm-eval like the i18n gate), English fallback.
- CI: client npm ci before every make payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 05:23:26 -07:00
phantomptrandClaude Opus 5.5 87880bf0db payload: webui tile re-mounts only after a reboot and removes its staged /user/app copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 05:01:25 -07:00
phantomptrandClaude Opus 5.5 7a12127b67 payload: webui settings and ps5upload home-screen tile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 04:55:15 -07:00
phantomptrandClaude Opus 5.5 1bbeb52fa3 payload: webui read replies sandboxed, web calls counted in the exit's in-flight RPCs, list next offset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 04:32:30 -07:00
phantomptrandClaude Opus 5.5 da9cf4e49e payload: webui API routes for home, files and settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 04:22:17 -07:00
phantomptrandClaude Opus 5.5 2803f01d19 payload: bounded webui server inside the helper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 03:59:04 -07:00
phantomptrandClaude Sonnet 5.5 180309354f payload: webui token and host/origin checks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 03:25:24 -07:00
phantomptrandClaude Sonnet 5.5 8004ed1a38 payload: webui HTTP parser: segmentation-independent line cap, reject duplicate identity headers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 03:23:25 -07:00
phantomptrandClaude Sonnet 5.5 f1fea5f7c8 payload: webui HTTP request parser and response head
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 03:20:51 -07:00
phantomptrandClaude Opus 5.5 68de5e8b19 ava1: list and cancel jobs across owners for the console page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 03:17:42 -07:00
phantomptr e7f200db70 Release v7.0.34 2026-10-17 00:56:04 -07:00
phantomptrandClaude Opus 5.5 ebf6c2f946 Give Unity game modules the letter case their eboot loads them by
Some dumps ship Media/Modules/Il2cppUserAssemblies.prx while the eboot loads
%s/Modules/Il2CppUserAssemblies.prx. exFAT and PFS ignore case, so the game
plays from an image; /data and UFS2 do not, and the game crashed (SIGSEGV,
RIP 0) a second after starting as a folder.

Folder uploads and Convert's game-folder and .ffpkg outputs now rename such
files to the eboot's spelling (only path-shaped references in the eboot count);
the source is left alone, and Convert's checks list the renames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-17 00:56:00 -07:00
phantomptr 68b56f12ab Release v7.0.33 2026-10-16 21:50:44 -07:00
phantomptrandClaude Opus 5.5 374d094678 PS5 updates install without their base; exact bytes count as installed; About links the website
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 21:50:41 -07:00
phantomptr 99523745b4 Release v7.0.32 2026-10-16 18:48:46 -07:00
phantomptrandClaude Opus 5.5 638a1cec15 Find rest mode over every network; offer Wake when not awake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 18:48:33 -07:00
phantomptr c16e5ab264 Release v7.0.31 2026-10-16 15:17:10 -07:00
phantomptrandClaude Opus 5.5 538fb60df8 Home tiles say rest mode; CHANGELOG 7.0.31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 14:55:54 -07:00
phantomptr f81909bca1 Merge branch 'worktree-agent-af3ef2a6d0e919531' into rel-7031 2026-10-16 14:49:27 -07:00
phantomptrandClaude Opus 5.5 4ee9e08c09 Client: tell rest mode apart from off or broken
Per the 2026-10-16 rest-mode detection spec (client side). The engine's
status verdict (power: awake/rest/unreachable, reason console_rest_mode)
is read defensively, so this builds alone and lights up once the engine
side lands.

- HostRuntime.power, set by the status poller; rest flips the console down
  at once (no miss debounce) and is logged on the edge.
- Status bar, console tabs, Home badge/metric, game page console rows:
  "In rest mode" in the calm accent, "Not answering" for unreachable.
- ConnectionGate: rest-mode notice with Wake / Wake & sign in from that
  console's own profile (shared useConsoleWake), and a not-answering notice
  naming Settings > System > Power Saving > Features Available in Rest Mode.
- Connection Check: a closed :9021 on a console DDP says is asleep reads
  "Your PS5 is in rest mode. Wake it, then check again", no VPN hints.
- PowerControl reads the shown console's credentials and seeds its power
  state from the poller.
- Auto-redeploy holds while the console rests (no TCP probes); keep-awake
  skips resting consoles.
- Upload queue parks an upload that meets rest mode as "Waiting for the
  PS5 to wake" (pending, no spent recovery attempt) and resumes it on the
  helper-up edge, desktop and browser; Files uploads stop instead of
  retrying into a sleeping console and resume the same way.
- 16 new strings (joberr.console_rest_mode among them) in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 14:48:51 -07:00
phantomptrandClaude Opus 5.5 3916d73051 Engine: tell rest mode apart from off or broken
When the helper's port and the loader both refuse, or the session connect
gets no TCP answer, require_in asks DDP (2 SRCH within ~800 ms). A 620
reply is the new token console_rest_mode ("The PS5 is in rest mode. Wake
it, and the helper can be sent again."). No reply after a timeout stays
ava1_unreachable with a plainer message. DDP 200 or silence after a
refusal keeps helper_not_ava1 as before. A verdict DDP explained is
cached with the pool refusal for 12 s (PROBED_REFUSAL_TTL, past the
client's 10 s poll); other refusals keep their 5 s / 30 s TTLs. The probe
is injectable (Pool::with_power_probe) and skips loopback hosts.

GET /api/ps5/status failures answer {error, reason, power}. Upload,
download and relay stalls on a console DDP calls asleep fail with
console_rest_mode instead of the bare "no durable progress".

payload_check (Tauri and the browser twin) passes reason and power
through; payloadCheck returns them as optional fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 14:44:42 -07:00
phantomptr 8a79431093 Release v7.0.30 2026-10-16 13:19:29 -07:00
phantomptrandClaude Opus 5.5 2ade96f096 CHANGELOG: 7.0.30; Connection names the right Settings section for reconnect
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 12:46:22 -07:00
phantomptr 70193bb46e Merge branch 'worktree-agent-a10551a6718792ae5' into rel-7030
# Conflicts:
#	client/src/i18n/locales/ar.ts
#	client/src/i18n/locales/bn.ts
#	client/src/i18n/locales/de.ts
#	client/src/i18n/locales/en.ts
#	client/src/i18n/locales/es.ts
#	client/src/i18n/locales/fa.ts
#	client/src/i18n/locales/fr.ts
#	client/src/i18n/locales/hi.ts
#	client/src/i18n/locales/hu.ts
#	client/src/i18n/locales/id.ts
#	client/src/i18n/locales/it.ts
#	client/src/i18n/locales/ja.ts
#	client/src/i18n/locales/ko.ts
#	client/src/i18n/locales/pl.ts
#	client/src/i18n/locales/pt-BR.ts
#	client/src/i18n/locales/ru.ts
#	client/src/i18n/locales/th.ts
#	client/src/i18n/locales/tr.ts
#	client/src/i18n/locales/vi.ts
#	client/src/i18n/locales/zh-CN.ts
#	client/src/i18n/locales/zh-TW.ts
2026-10-16 12:34:57 -07:00
phantomptrandClaude Opus 5.5 47bb893488 Stop helper: turn the console's helper off and keep it off (#471)
Engine: POST /api/ps5/helper/stop { addr } sends node.shutdown over the
paired AVA1 session (payload_lifecycle::shutdown_running_payload) and waits
for the helper's port to go quiet before answering {state: "stopped"};
{state: "not_running"} when nothing answered. Refused with 409
{reason: "busy"} while this engine runs a transfer, download, relay,
console file operation or install against that console (a per-console busy
count held by each job, plus the install slot); 502 {reason: "refused" |
"still_running"} otherwise.

Client: "Stop helper" beside Send/Resend on the Connection screen, behind a
confirm. A stop marks the console "stopped by you" (persisted in
localStorage, so desktop and browser builds keep it across restarts). The
reconnect loop, a queue's mid-run recovery and unattended queue runs (wake
resume, re-attach after restart) leave a marked console alone. Send helper,
Check, the setup wizard, Quick bring-up and starting an upload clear the
mark, as does a helper seen answering a minute after the stop. Connection
and the screen gate show "Stopped" rather than a missing helper. Strings in
all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 12:34:18 -07:00
phantomptrandClaude Opus 5.5 051eb0ac89 Files: a compact phone layout (#470)
Below the sm breakpoint a Files row is checkbox, icon, name (ellipsized,
full name in title), size and one ⋮ that opens the row's actions in the
desktop order with the same labels and icons, Delete last in red. The
desktop buttons and the menu come from one list (buildRowActions), so
both run the same handlers. The menu is the row's existing right-click
menu: portaled, keyboard-navigable, closed by outside tap and Escape,
placed against the ⋮ and flipped above it near the bottom of the screen.

The selection bar keeps "N selected" on one line and scrolls its actions
sideways on a phone; the Recent list sits on the opaque floating-menu
surface above the rows at every width (it was the 4% page tint, so the
rows read through it). Toolbar, drive chips, path bar, sort chips and
rows are denser at phone width; Add folder / New folder / Refresh show
their icon alone there. The desktop layout is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 12:32:11 -07:00
phantomptr 67f2201a89 Release v7.0.29 2026-10-16 11:26:59 -07:00
phantomptrandClaude Opus 5.5 7b5f2b9d83 CHANGELOG: 7.0.29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 11:26:53 -07:00
phantomptr c10100a719 Merge branch 'worktree-agent-a2ae9b624051a3566' into fix-469 2026-10-16 10:58:38 -07:00
phantomptrandClaude Opus 5.5 9fc954e5e5 Convert: make a game folder from an image or debug package (#469)
Engine: POST /api/folder/build unpacks a game image (or, once the pkg
reader lands, a debug-key .pkg) into <out>/<name>. It runs as a job with
Write and Verify stages; every path is checked before anything is written
(.., absolute, empty components, names this OS cannot hold, file/folder
clashes, case clashes on a case-insensitive drive); data streams in 8 MiB
chunks with cancel per chunk; the folder is written as <name>.partial/
(marked so the output-folder sweep can tell it from a user's own) and
renamed only after the read-back: every size and empty folder, and the
SHA-256 of a seeded sample (or every file) re-read from a freshly opened
source. An existing target, a plain folder source and a shortfall of
free space (files + 64 MiB) are refused before the job starts.

Client: a third "What to make" tile, A game folder. A folder source
disables Make with "This is already a folder"; .pkg is accepted as a
source; then is keep only; the finished card shows the folder and Show in
folder. The queue, prefs, pipeline and stage list know the folder kind.
Strings in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 10:58:12 -07:00
phantomptrandClaude Opus 5.5 9e808ac5c2 fpkg: name the passcode case in the package refusal; tree_extract example
A fake package built with another passcode is refused the same way as a retail one, so the
message says both. tree_extract writes any source tree out as a folder, for checking real
packages and images by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 10:53:10 -07:00
phantomptrandClaude Opus 5.5 3d3fcb6a94 fpkg: read a debug .pkg back as a source tree (#469)
pkg_source opens a package built with the debug key as a SourceTree,
registered in source::open (and saved servers) for the pkg extension. It
streams: outer blocks are decrypted on demand and checked against the
container's digests, the inner image is read through its layout
descriptor (the Kraken/stored block layout and the flat layout with
stored or zlib metadata), and only the metadata region is expanded
whole. A small LRU keeps sequential reads from decoding a block twice.
The artwork the container carries in the image's place is restored
under sce_sys/.

Retail packages, native packages made with another passcode, PS4
packages, unknown layouts, truncated and damaged packages are refused
with the reason in plain words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 10:49:53 -07:00
phantomptr d040883261 Release v7.0.28 2026-10-16 08:27:37 -07:00
phantomptrandClaude Opus 5.5 e3f5c1c2b9 CHANGELOG: 7.0.28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
2026-10-16 08:27:23 -07:00
phantomptrandClaude Opus 5.5 1260d73094 7z: a failed read of the archive is a read error, not a corrupt archive (#446)
lzma-rust2's range coder reads a failed byte as 1, so any read error on the archive file
surfaces as "dist overflow", the same as a damaged stream. Since 7.0.22 that is classed as
corruption and never retried, so a read that failed once (a phone's storage under load) ended
a 70 GB upload as "the archive is damaged". The archive file is now read through a tap that
keeps the first real read error, and when the decoder then reports corruption, that read
error is what the job sees: an ordinary I/O error the upload retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 08:21:58 -07:00
phantomptrandClaude Opus 5.5 a61954b5fe ffpfsc unpack: host stand-in for the C tests, rename audit row
The C interop tests build fs_jobs.c, which registers the unpack op; its LVD mount has no
host counterpart, so the host gets a refusal. Its .part -> image rename is a sibling
rename and goes in cross_device.h's audit table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 08:10:58 -07:00
phantomptr 72da996e8b Merge branch 'worktree-agent-a461a6c996d73ec3c' into release-prep
# Conflicts:
#	client/src/i18n/locales/ar.ts
#	client/src/i18n/locales/bn.ts
#	client/src/i18n/locales/de.ts
#	client/src/i18n/locales/en.ts
#	client/src/i18n/locales/es.ts
#	client/src/i18n/locales/fa.ts
#	client/src/i18n/locales/fr.ts
#	client/src/i18n/locales/hi.ts
#	client/src/i18n/locales/hu.ts
#	client/src/i18n/locales/id.ts
#	client/src/i18n/locales/it.ts
#	client/src/i18n/locales/ja.ts
#	client/src/i18n/locales/ko.ts
#	client/src/i18n/locales/pl.ts
#	client/src/i18n/locales/pt-BR.ts
#	client/src/i18n/locales/ru.ts
#	client/src/i18n/locales/th.ts
#	client/src/i18n/locales/tr.ts
#	client/src/i18n/locales/vi.ts
#	client/src/i18n/locales/zh-CN.ts
#	client/src/i18n/locales/zh-TW.ts
2026-10-16 07:47:51 -07:00
phantomptr 140bd26f0f Merge branch 'worktree-agent-a271b63e8e1248a07' into release-prep 2026-10-16 07:47:27 -07:00
phantomptrandClaude Opus 5.5 1af1a1afa9 Console to console: take over from another device, Test connection (#444)
Pausing a routed send now ends both consoles' halves, so its journal is free
to resume from any device with any helper. The driving engine keeps a shared
record of each send on the receiving console (/data/ps5upload/c2c/<tx>.json)
with a 45 s lease refreshed every 15 s; another device lists those sends
(GET /api/transfer/ps5-to-ps5/remote), takes one over or watches a direct one
(POST /api/transfer/ps5-to-ps5 with adopt: take | watch, same tx_id), and the
engine that had it stops driving without ending anything ("taken over by").

Helper: a new caps bit, CAP_SHARED_JOBS (4). job.status / job.cancel answer
any paired device for uploads and downloads (operations and local copies stay
owner-only), and a JobOpen moves a parked upload of another device to the
opener; an attached one is still refused. SPEC §18.5-18.6.

Test connection in Send to another console runs the console's net.reach probe
from the sending console to the dial target (plus the relay legs, and an
optional real 4 KiB direct send). Tasks shows "Sends from other devices" with
Watch / Take over / Resume here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 07:46:28 -07:00
phantomptrandClaude Opus 5.5 d1e938749c Background watcher: renderer mirrors until the engine answers /api/activity; gate fixes (#457)
native_job_notify turns true only after a successful /api/activity poll, so a
remote engine on an older version leaves job notifications with the page. The
BgStrings parity test reads the Rust file through Vite (no node types in the
client tsconfig); a clippy fix in the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 07:16:16 -07:00
phantomptrandClaude Opus 5.5 e90dd6e4b4 Decompress a .ffpfsc on the console, decoded by the kernel (#453)
Files gets "Decompress to .exfat…" on a .ffpfsc again. The first build (pulled
before 7.0.23) inflated the blocks in the helper with vendored zlib, and the helper
died with SIGBUS within minutes of loading it; once the console froze. This one has
no decoder: the helper mounts the container read-only the way ShadowMount+ mounts a
.ffpfsc (LVD image type 9, read-only single-save flags, 4096-byte sectors, pfs with
mkeymode=AC), so the kernel decodes it, and copies the file inside to <name>.part,
then renames it in the same folder. The kernel lists that file rounded up to whole
64 KiB blocks; only the inode's real length is copied.

The checks from the first build stay: the container's block table is read before the
kernel sees it, a destination that exists and a drive without room for the image plus
1 GiB are refused, the size and exFAT boot sector are checked before the rename, and
Stop removes the .part and the mount. One unpack runs at a time. The mount is not in
the mount table the helper sees, so it is recorded on the /mnt tmpfs (owner pid, LVD
unit, .part) and a helper that starts after one died mid-copy releases it; a record of
a live instance is left alone.

Measured on the FW 13.60 Phat: a 315 MB image (127 MB container) in 3.5 s and a 4.5 GB
one (3.0 GB container) in 65 s, both byte-identical to the source; Stop, a takeover
mid-copy and a stale record all left no mount, LVD unit or .part behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 07:09:42 -07:00
phantomptrandClaude Opus 5.5 f436661bf9 Renderer: name jobs for the host, let it announce endings, wake the queue (#457)
- lib/bgWork.ts names the engine jobs the page starts (queue and Upload screen
  uploads, installs, File System downloads), tells the host whether the page is
  showing, the notification setting and the notification texts in the person's
  language, and passes the host's bg-tick / bg-job-finished to the queue.
- lib/wakeableSleep.ts: the upload queue's and install polling's sleeps end on
  the host's clock when the hidden window's timers are throttled; a finished job
  ends the status-poll sleeps at once. Settle delays still wait their full time.
- Job-outcome notifications are tagged jobOutcome; the OS mirror skips them
  when the host reports native_job_notify (false in the browser build: the old
  behaviour).
- keepAwakeHold.ts holds the host's background-work hold, refreshed every
  minute, while a transfer runs or the upload queue is draining.
- 17 bg_* strings in all 21 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 07:04:17 -07:00
phantomptrandClaude Opus 5.5 413703be89 Host: background watcher and Android transfer service (#457)
bg_activity.rs polls the engine's /api/activity every second from Rust, so a
hidden or frozen WebView no longer decides whether work is running or has
ended. A pure step() turns each snapshot into start/update/finished/stop
actions: the first poll only records seq, an engine restart resets it, a
renderer hold refreshed within 5 minutes counts as work, and nothing active for
30 s stops. Finished jobs are emitted to the page (bg-job-finished, plus a
bg-tick heartbeat while work runs) and announced through the OS when the app is
in the background, in the language the page sends.

Android: a connectedDevice foreground service (dataSync as fallback) with a
partial wake lock and a Wi-Fi lock keeps the process and its in-process engine
alive, with an ongoing progress notification and a separate result channel. The
Kotlin class, manifest entries and R8 keep rules are written by
android-postinit-patch.sh. JNI_OnLoad and the context helpers move to
android_jni.rs so both callers share them; app classes load through the
context's class loader. The mobile engine start is now once per process, as
Activity recreation can run setup again.

Desktop: notifications through the notification plugin's Rust API when the
main window is hidden, minimised or unfocused; backgroundThrottling disabled on
the window (macOS 14+).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 07:04:16 -07:00
phantomptrandClaude Opus 5.5 cdf46c2e87 Engine: /api/activity reports running jobs and recent endings (#457)
A process-wide hub fed from set_job's terminal write and the unified install's
finish path: running transfers and installs with their progress, and a ring of
the last 64 endings with a monotonic seq so the app's background watcher sees
each one once. POST /api/activity/label lets the client name a job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 06:45:12 -07:00
phantomptrandClaude Opus 5.5 e0d3bd7851 Status checks name the next step when the helper is down
The management path dropped the job path's specific message (helper port closed with the
loader up, or no loader at all, or a helper too old: #447) and always said "not running or
is an old version". Status, process list and every other management call now carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 06:29:07 -07:00
phantomptrandClaude Opus 5.5 3ba4656c68 Desktop Cargo.lock: follow the engine's lz4_flex 0.14
The lz4_flex bump only updated engine/Cargo.lock; the desktop crate builds the same
engine crates and failed --locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 06:24:31 -07:00
phantomptr 8c5552ecd5 Merge remote-tracking branch 'origin/dependabot/npm_and_yarn/client/frontend-117a2bf004' into deps-2026-10-16 2026-10-16 06:02:17 -07:00
phantomptr bffbd984fc Merge remote-tracking branch 'origin/dependabot/cargo/client/src-tauri/tauri-shell-196865a7e0' into deps-2026-10-16 2026-10-16 06:02:17 -07:00
phantomptr c547e450c1 Merge remote-tracking branch 'origin/dependabot/github_actions/actions-3a264e1184' into deps-2026-10-16 2026-10-16 06:02:17 -07:00
phantomptr 4933f91033 Merge remote-tracking branch 'origin/dependabot/cargo/engine/engine-eb34cac1a2' into deps-2026-10-16 2026-10-16 06:02:17 -07:00
phantomptr bb0e3b6d6b Merge remote-tracking branch 'origin/dependabot/cargo/engine/lz4_flex-0.14.0' into deps-2026-10-16 2026-10-16 06:02:17 -07:00
phantomptr 4e7db556f2 Merge remote-tracking branch 'origin/dependabot/cargo/client/src-tauri/png-0.18.1' into deps-2026-10-16 2026-10-16 06:02:17 -07:00
dependabot[bot] b6951dcc86 Bump the frontend group across 1 directory with 12 updates
Bumps the frontend group with 12 updates in the /client directory:

| Package | From | To |
| --- | --- | --- |
| [@tauri-apps/api](https://github.com/tauri-apps/tauri) | `2.12.1` | `2.12.3` |
| [@tauri-apps/plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.8.1` | `2.8.2` |
| [@tauri-apps/plugin-notification](https://github.com/tauri-apps/plugins-workspace) | `2.5.1` | `2.6.0` |
| [@tauri-apps/plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.7.0` | `2.7.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.52.0` | `1.56.0` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.63.0` | `1.64.0` |
| [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.12.1` | `2.12.2` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `5.0.3` | `5.0.4` |
| [postcss](https://github.com/postcss/postcss) | `8.5.28` | `8.5.30` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.3` | `8.3.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |



Updates `@tauri-apps/api` from 2.12.1 to 2.12.3
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/api-v2.12.1...@tauri-apps/api-v2.12.3)

Updates `@tauri-apps/plugin-dialog` from 2.8.1 to 2.8.2
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.8.1...http-v2.8.2)

Updates `@tauri-apps/plugin-notification` from 2.5.1 to 2.6.0
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.5.1...fs-v2.6.0)

Updates `@tauri-apps/plugin-opener` from 2.7.0 to 2.7.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.7.0...fs-v2.7.1)

Updates `lucide-react` from 1.52.0 to 1.56.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.56.0/packages/lucide-react)

Updates `@playwright/test` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

Updates `@tauri-apps/cli` from 2.12.1 to 2.12.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.12.1...@tauri-apps/cli-v2.12.2)

Updates `@vitest/coverage-v8` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/coverage-v8)

Updates `postcss` from 8.5.28 to 8.5.30
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.28...8.5.30)

Updates `typescript-eslint` from 8.71.1 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

Updates `vite` from 8.3.3 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend
- dependency-name: "@tauri-apps/api"
  dependency-version: 2.12.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.12.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: "@tauri-apps/plugin-dialog"
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: "@tauri-apps/plugin-notification"
  dependency-version: 2.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend
- dependency-name: "@tauri-apps/plugin-opener"
  dependency-version: 2.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: lucide-react
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend
- dependency-name: postcss
  dependency-version: 8.5.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 08:13:32 +00:00
phantomptr 80e68028a6 Release v7.0.27 2026-10-16 01:09:45 -07:00
phantomptrandClaude Opus 5.5 817bd52213 Stream install: finish on byte coverage, wait out an elfldr swap
A stream install was declared done when the bytes served added up to the
package size. The PS5 re-reads ranges, so on a 37.6 MB package ten 64 KiB
header re-reads equalled a 640 KiB range it had not fetched yet. The
session was released, the console's request for that range got 410, and
the PS5 download failed after ps5upload reported it installed. Coverage is
now an exact interval set, and delivery completes only when every byte
has been served.

"Nothing answered on the ELF loader port :9021" at install start was the
client's background elfldr swap: :9021 is closed for about a second while
the new elfldr replaces the old. The swap, the installer bring-up and the
payload restore now share a per-console lock, and a refused loader port is
retried for 8 s (no bare pre-probe).

Verified on FW 13.60, Phat and Pro: the engine held until the gap was
fetched, with no 410s; an install started inside a swap waited and
succeeded; an 820 MB package installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3cPwhp3UrzayBfyy9PDcN
2026-10-16 01:09:40 -07:00
dependabot[bot] 407b08700e Bump png from 0.17.16 to 0.18.1 in /client/src-tauri
Bumps [png](https://github.com/image-rs/image-png) from 0.17.16 to 0.18.1.
- [Changelog](https://github.com/image-rs/image-png/blob/master/CHANGES.md)
- [Commits](https://github.com/image-rs/image-png/commits)

---
updated-dependencies:
- dependency-name: png
  dependency-version: 0.18.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 06:45:10 +00:00
dependabot[bot] 3160ed2b89 Bump the tauri-shell group in /client/src-tauri with 8 updates
Bumps the tauri-shell group in /client/src-tauri with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.12.1` | `2.12.2` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.8.1` | `2.8.2` |
| [tauri-plugin-notification](https://github.com/tauri-apps/plugins-workspace) | `2.5.1` | `2.6.0` |
| [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) | `2.5.2` | `2.5.3` |
| [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` |
| [tauri-plugin-opener](https://github.com/tauri-apps/plugins-workspace) | `2.7.0` | `2.7.1` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.7.1` | `2.7.2` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.27.0` | `1.28.0` |


Updates `tauri` from 2.12.1 to 2.12.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.12.1...tauri-v2.12.2)

Updates `tauri-plugin-dialog` from 2.8.1 to 2.8.2
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/http-v2.8.1...http-v2.8.2)

Updates `tauri-plugin-notification` from 2.5.1 to 2.6.0
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.5.1...fs-v2.6.0)

Updates `tauri-plugin-single-instance` from 2.5.2 to 2.5.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.5.2...http-v2.5.3)

Updates `serde_json` from 1.0.151 to 1.0.154
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154)

Updates `tauri-plugin-opener` from 2.7.0 to 2.7.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.7.0...fs-v2.7.1)

Updates `tauri-build` from 2.7.1 to 2.7.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.7.1...tauri-build-v2.7.2)

Updates `uuid` from 1.27.0 to 1.28.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.27.0...v1.28.0)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.12.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: tauri-shell
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: tauri-shell
- dependency-name: tauri-plugin-notification
  dependency-version: 2.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tauri-shell
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: tauri-shell
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: tauri-shell
- dependency-name: tauri-plugin-opener
  dependency-version: 2.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: tauri-shell
- dependency-name: tauri-build
  dependency-version: 2.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: tauri-shell
- dependency-name: uuid
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tauri-shell
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 06:45:05 +00:00
dependabot[bot] e5fbd22f8a Bump the actions group with 3 updates
Bumps the actions group with 3 updates: [actions/configure-pages](https://github.com/actions/configure-pages), [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) and [actions/deploy-pages](https://github.com/actions/deploy-pages).


Updates `actions/configure-pages` from 5 to 6
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

Updates `actions/upload-pages-artifact` from 4 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 06:45:03 +00:00
dependabot[bot] 671437e42c Bump lz4_flex from 0.13.1 to 0.14.0 in /engine
Bumps [lz4_flex](https://github.com/pseitz/lz4_flex) from 0.13.1 to 0.14.0.
- [Release notes](https://github.com/pseitz/lz4_flex/releases)
- [Changelog](https://github.com/PSeitz/lz4_flex/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pseitz/lz4_flex/compare/0.13.1...0.14.0)

---
updated-dependencies:
- dependency-name: lz4_flex
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 06:44:52 +00:00
dependabot[bot] 409a6a8792 Bump the engine group in /engine with 7 updates
Bumps the engine group in /engine with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.27.0` | `1.28.0` |
| [zeroize](https://github.com/RustCrypto/utils) | `1.9.0` | `1.9.1` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.6.0` | `1.8.0` |
| [sha2](https://github.com/RustCrypto/hashes) | `0.11.0` | `0.11.1` |
| rust-embed | `8.12.0` | `8.13.0` |
| [suppaftp](https://github.com/veeso/suppaftp) | `12.1.1` | `12.2.0` |


Updates `serde_json` from 1.0.151 to 1.0.154
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154)

Updates `uuid` from 1.27.0 to 1.28.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.27.0...v1.28.0)

Updates `zeroize` from 1.9.0 to 1.9.1
- [Commits](https://github.com/RustCrypto/utils/compare/zeroize-v1.9.0...zeroize-v1.9.1)

Updates `cc` from 1.6.0 to 1.8.0
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.6.0...cc-v1.8.0)

Updates `sha2` from 0.11.0 to 0.11.1
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.11.0...sha2-v0.11.1)

Updates `rust-embed` from 8.12.0 to 8.13.0

Updates `suppaftp` from 12.1.1 to 12.2.0
- [Release notes](https://github.com/veeso/suppaftp/releases)
- [Changelog](https://github.com/veeso/suppaftp/blob/main/CHANGELOG.md)
- [Commits](https://github.com/veeso/suppaftp/compare/v12.1.1...v12.2.0)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: engine
- dependency-name: uuid
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: engine
- dependency-name: zeroize
  dependency-version: 1.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: engine
- dependency-name: cc
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: engine
- dependency-name: sha2
  dependency-version: 0.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: engine
- dependency-name: rust-embed
  dependency-version: 8.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: engine
- dependency-name: suppaftp
  dependency-version: 12.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: engine
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 06:44:47 +00:00
phantomptr d74824c886 Release v7.0.26 2026-10-15 18:13:07 -07:00
phantomptrandClaude Opus 5.5 f61f5629e7 Reddit fixes: folder games register with ShadowMount+, resumable stalls, solid-rar retries, PM updates, drag to move
- Receiver writes sce_sys/param.json and param.sfo as .ava-hold and puts them in
  place when the job finishes: ShadowMount+ registered a folder whose eboot.bin
  was still arriving, failed twice and skipped the title for good (repro on
  13.60).
- AVA1 SkipProgress (0x30): a sender reading past data the receiver holds (a
  resumed solid .rar/.7z) keeps the job alive; the console ended it as stalled
  after 900 s on every retry.
- RAR5 unknown-size entries no longer wrap the unpacked total.
- Upload queue: stalled rows offer Restart transfer, stopped/interrupted rows
  offer Resume, "Retry failed uploads" actually runs them and is translated as
  an action.
- Payload Manager tab: Update for payloads stored from the Catalog, keeping the
  boot-list place; repository installs are left to Payload Manager.
- Files: drag folders (and, without drag-out, files) onto a folder or the
  breadcrumb to move them.
- README links the compose file from the feature list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 18:05:34 -07:00
phantomptrandClaude Opus 5.5 42403e4892 Collection: a deleted folder no longer breaks saving or scanning (#461)
Saving settings re-checked every root still existed, so once one was
gone nothing could be saved, removing that root included. Only roots
being added are checked now. A scan skips a root it can't open instead
of failing, keeps its caches, and lists it in missing_roots; Collection
names it and Folders marks it "Not found, skipped".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 17:06:14 -07:00
phantomptr 08e32214af Release v7.0.25 2026-10-15 12:12:35 -07:00
phantomptrandClaude Opus 5.5 139de00ce3 Profile: drop Create User, it could never work (#460)
13.60's libSceUserService exports no create call, and the old path
(GetInitialUser + SetUserName) only renamed the initial user; in a
payload GetInitialUser fails with 0x80940004. user.create now answers
with "add the user on the PS5" and touches nothing; Profile says so
instead of showing the button.

Also fix the worker-commit crash tests' race: the sender now waits for
credit and stops once the job stopped or finished on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 12:12:30 -07:00
phantomptr 0b6fb9bfb3 Release v7.0.24 2026-10-15 10:05:18 -07:00
phantomptrandClaude Opus 5.5 0c1aeae1ef Payloads → Ports: the ports in use on the PS5 (Discord ask)
New helper method net.ports (147, frame 265/266): binds each TCP and UDP
port and closes it at once (never connects, so no loader sees a silent
client), then binds 127.0.0.1 and the LAN address to tell network,
console-only and outgoing sockets apart. The socket sysctls
(net.inet.*.pcblist, kern.file) answer ENOENT on FW 13.60. A full pass
takes ~890 ms on the Phat. The engine names well-known ports and the
Payloads screen lists them, with Open only for web UIs and unknown ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 10:05:11 -07:00
phantomptrandClaude Opus 5.5 98bda5a1c0 Folder homebrew hint: folders can be on a USB or external drive (#452)
The Installed section said every folder game was registered from
/data/homebrew/<id>-app, also for folders on USB drives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 08:17:50 -07:00
phantomptr 61f6d0e9e8 Release v7.0.23 2026-10-15 00:18:50 -07:00
phantomptrandClaude Opus 5.5 f67b50ee6f Changelog for 7.0.23
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 00:18:42 -07:00
phantomptrandClaude Opus 5.5 98e19ae62a Rename audit: list icon_keeper.c's tmp -> final rename
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-15 00:02:33 -07:00
phantomptrandClaude Opus 5.5 d055239ddb Revert "Decompress a .ffpfsc on the console (#453)"
This reverts commit 1f27c1a560.

Every helper build that linked the vendored zlib died within minutes on a
FW 13.60 Phat with SIGBUS in a system library touching helper code at
zlib's inflateEnd (logged by the new fault detail: addr=0x200161159,
rip=0x88007fe60), most often right after an app kill. One such build was
running when the console went down during a game launch and did not come
back. The same helper without zlib ran through repeated launches and kills.
The decompress op goes back out until it can run without linking zlib into
the helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 23:51:53 -07:00
phantomptr 58f5535427 Revert "Pin the FFPFSC_UNPACK job op in the schema drift test (#453)"
This reverts commit 742f4cbbc9.
2026-10-14 23:51:43 -07:00
phantomptrandClaude Opus 5.5 8751da2ffb Helper: a fault logs its address and instruction
"[fatal] signal 10 outside any request" said what died but not where. Faults
(SIGSEGV, SIGBUS, SIGILL) now go through a SA_SIGINFO handler that first lets
the hw guard recover a Sony getter, then writes the faulting address, si_code,
the instruction pointer and where main() loaded, so the instruction can be
found in the ELF. Everything stays async-signal-safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 23:25:23 -07:00
phantomptrandClaude Opus 5.5 742f4cbbc9 Pin the FFPFSC_UNPACK job op in the schema drift test (#453)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:54:57 -07:00
phantomptrandClaude Opus 5.5 252cfc4d69 check-no-ftx2: skip vendored payload/third_party (zlib's CRC table)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:48:43 -07:00
phantomptrandClaude Opus 5.5 d92cf3cba2 Tasks view shows a transfer's speed and ETA (#444)
The Files queue row showed speed and time left, but the task the same
transfer drives in the Tasks view had only its bar: the bridge passed bytes
and never the rate the queue and the single-file transfer already compute.
Both now pass the rate and, when the total is known, the time left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:36:45 -07:00
phantomptrandClaude Opus 5.5 f534ab020f Updates: give packaged Linux installs the command that installs them (#456)
The updater downloads the new package and opens its folder. On Bazzite and
other rpm-ostree systems that left people to work out `rpm-ostree uninstall`
plus `rpm-ostree install` by hand. A packaged Linux copy now gets the one
command to run, with a Copy button: `rpm-ostree uninstall <pkg> --install
<rpm> && systemctl reboot` on rpm-ostree (the package name read from the rpm
database), `sudo dnf install` for rpm, `sudo apt install` for deb. The README's
Bazzite row says the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:35:14 -07:00
phantomptrandClaude Opus 5.5 ccdd5b1e28 Payloads: export and import playlists with their custom repos
Asked on Discord: share a working autoload setup with friends. The Playlists
panel gets Export… and Import…: one JSON file with every playlist, the
auto-loader's choice and the custom repos the catalogue knows. Import adds the
repos first (their ids come from host/owner/name, so repo steps resolve with
no remapping), then the playlists beside the ones already here with new ids
and names kept apart; the auto-loader choice is taken only when none is set.
Strings in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:32:08 -07:00
phantomptrandClaude Opus 5.5 1f27c1a560 Decompress a .ffpfsc on the console (#453)
Files gets "Decompress to .exfat…" on a .ffpfsc. The helper writes the image
the container holds beside it, under the image's own name, and keeps the
source; nothing crosses the network, where the round trip through a computer
was 191 GB of Wi-Fi for a 56 GB container in the report.

The helper (new job op FFPFSC_UNPACK, id 10 in the AVA1 schema) reads the
single-file PFS the way the engine's reader does, inflates each 64 KiB block
with vendored zlib 1.3.1 (inflate side only, third_party/zlib), and writes
<name>.part. It refuses a drive without room for the image plus 1 GiB and a
destination that exists, checks the size and, for an .exfat, the boot
sector, then renames within the folder. Progress and Stop go through the
op-status/op-cancel routes; the engine route is POST /api/ps5/fs/unpack. The
Files screen shows a progress line with Stop and a task. Strings in all 21
locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:32:07 -07:00
phantomptrandClaude Opus 5.5 010f064d4c Convert: say "backported" only for a backport; point LZ4 at the runtime checkbox
"Runs on FW x and later (backported)" showed whenever every module's SDK was
below the firmware param.json declares, which an ordinary dump built with an
older SDK also is (asked on Discord). Backports stamp the FW 4.00 pair; other
cases now read "its modules allow it; it declares y".

When the game's ampr_emu cannot serve packs and putting 0.4.2.1 into the image
would fix that, the LZ4 line said "Not available for this game", which read as
a dead end (#449). It now says to tick the checkbox above it. New strings in
all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:11:52 -07:00
phantomptrandClaude Opus 5.5 7e8ab16bf5 Android save backup: no empty zip at the picked place (#451)
Android's save dialog returns a content:// file the zip writer cannot open, so
the backup was redirected to Downloads while the file the person picked stayed
at 0 bytes, and the success message named that empty file. On Android the
backup now skips the dialog, goes to Downloads as <title>-<date-time>.zip (a
second backup no longer replaces the first), and the message names the real
file. The zip command returns the path it wrote on every platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:08:52 -07:00
phantomptrandClaude Opus 5.5 9a620c6026 Hidden home-screen icons stay hidden after their app starts (#450)
The system sets an app's icon visible again whenever the app starts, from
ps5upload, the home screen or a forwarder (measured on a FW 13.60 Phat: visible
again within 5 s of launch). The helper now remembers the titles hidden through
ps5upload (/data/ps5upload/hidden_icons.txt, seeded once from what app.db has
hidden) and a thread hides them again when app.db changes, writing only rows
that came back. On the Phat a relaunched hidden game was hidden again 2 s
after the system showed it, with no repeat writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:06:55 -07:00
phantomptrandClaude Opus 5.5 37f3da7f80 Screenshot thumbnails: two at a time, no full-size fallback (#450)
Each thumbnail is a transfer job. A screen of rows started them all at once,
filled every data lane ("all 8 lanes are open") and kept going after the
screen was left, so the Games tab stalled behind them. A missing thumbnail also
fell back to downloading the full-size shot. Thumbnails now run two at a time
across the app, a queued one is dropped once the screen closes, and a missing
thumbnail keeps its glyph (Preview still opens the shot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:06:47 -07:00
phantomptrandClaude Opus 5.5 916ac32883 Grids load scaled covers, not 400 KiB PNGs (#450)
A console's icon0.png is 300-700 KiB at 512 px. The Games tab drew hundreds of
them at card size, and through the macOS data-URL fallback each was a base64
string of the whole file: scrolling stuttered and typing in search stalled.

The icon routes take size=128|256|384. The engine scales the cover once
(JPEG when opaque, PNG when it has transparency) and caches it beside the full
one; on a FW 13.60 Phat a 256 px cover is 15-22 KiB, served from cache in a
few ms. Games cards ask for 384, Library rows and GameIcon for twice their
drawn size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 20:06:47 -07:00
phantomptrandClaude Opus 5.5 8b24e27e53 Helper: a prior that served nothing gets 3 s, not 15 s, to exit
Sending the helper could spin for 20-40 s after the console showed it loaded
(reported on Discord). On a FW 13.60 Phat, 3 of the last 4 helper starts sat in
the 15 s reap grace: the recorded prior was a ps5upload.elf alive for hours with
no startup trace and no AVA1 port, so the takeover never asked it to exit and it
never would. The full grace is now kept for a prior that was serving and was
asked to hand over; one that served nothing is killed after 3 s.

A normal replacement on the Phat still hands over cleanly; AVA1 was up 1.9 s
after the new helper entered main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 19:49:59 -07:00
phantomptrandClaude Opus 5.5 606950753d Read the ampr_emu 0.3 banner's version (#449)
The 0.3 releases say "0.3.6 (public beta) (c) Drakmor", so the version
reader, which expected digits right before the marker, reported "unknown
version". It now takes the first version token of the whole banner string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 19:43:17 -07:00
phantomptrandClaude Opus 5.5 35d66b4ecf Game page shows the copy the console runs, not a stale folder (#452)
The library scan keeps one row per title, the alphabetically first unless one is
under /mnt/ps5upload, so a game ShadowMount+ runs from an image on an external
drive showed a leftover /data/homebrew folder. The page now prefers the
registered source: for an image-backed title, the image behind its mount (by
the mount map, then by the stem ShadowMount+ names its mount after), else the
mount point; the library row only when the title is not registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 19:43:17 -07:00
phantomptrandClaude Opus 5.5 b7dd979198 Payload Manager 0.6 support: request marker, POST actions, slots (#458)
Payload Manager 0.6.0 refuses a mutation without a same-origin Origin/Referer
unless it carries X-PLDMGR-Request: 1 (403 "Mutation origin or request marker
rejected"), and answers 405 to GET on /loadpayload and /manage:delete. Every
request now carries the marker, and launch/delete are POSTs; 0.5.2 ignores both.

0.6 also moved storage to slots (repo/<id>@<source>, local/<stem>) and lists
them in "items"; its autoload list names slots. The list parser reads items
(with name/version/source) and the Payloads tab keys "Load at boot" by slot,
falling back to the file name on 0.5.

Verified on a FW 13.60 Phat with 0.6.1: overview, store, launch, autoload
add/remove and delete through the engine, and the live installer launch test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-14 19:36:37 -07:00
quer3q f37508feb5 Fast verification by default (#459)
For all type of conversions: packages, images and "Compress and image"
is now using new fast verification.

Toggle can be used to disable this behaviour and enabled full-scan
verification.
2026-10-14 19:36:34 -07:00
Enrique Wood 16e48cb243 Installed apps: localize the "last seen" label (today / yesterday / N days ago) (#454)
formatLastSeen returned hard-coded English words ("today", "yesterday",
"N days ago") that were then substituted into the translated line
installed_playtime_line, so every non-English UI showed e.g. Spanish
"visto por ultima vez yesterday".

Format the day count with Intl.RelativeTimeFormat in the app language
(numeric: "auto"), so 0 / 1 days read as today / yesterday and their
equivalents in each locale (hoy / ayer / hace 5 dias, heute / gestern, ...).
This covers every shipped locale without new i18n keys.

- formatLastSeen takes an optional `locale` (default "en"), so existing
  callers and the English output are unchanged. An invalid tag falls back
  to the previous English strings.
- AppCard passes the active language from useLangStore.
- Tests: Spanish and German labels, and the invalid-tag fallback.

The "never" label (a title with play time but no last-seen timestamp) is
still English; it has no Intl equivalent and is left out of this change.

Not run locally: npm run validate / vitest / tsc / eslint (no Node toolchain
on the machine used). Intl outputs were checked in a browser console.
2026-10-14 19:33:36 -07:00
phantomptr 01afdf8926 Release v7.0.22 2026-10-12 02:17:36 -07:00
phantomptrandClaude Fable 5.1 4a1bd939a4 HDR PNG carries the capture's ICC profile as iCCP beside cICP (#448)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-12 02:17:33 -07:00
phantomptr f7b411c6db Release v7.0.21 2026-10-12 02:07:18 -07:00
phantomptrandClaude Fable 5.1 8e87800be9 Fix: image format snapped back to .ffpkg; an image can be rewritten as another image format
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-12 02:07:15 -07:00
phantomptr cdda1e0e11 Release v7.0.20 2026-10-12 00:25:25 -07:00
phantomptrandClaude Fable 5.1 956ec87251 Convert remembers what it makes across navigation and follows a running build; image stages are named as such
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-12 00:25:22 -07:00
phantomptr 0b9659b436 Release v7.0.19 2026-10-12 00:17:59 -07:00
phantomptrandClaude Fable 5.1 7f6047a332 Fix: the game page crashed when the measuring status reply had no state; refusal test follows the no-AMPR wording
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-12 00:17:56 -07:00
phantomptr 10dd8e8520 Release v7.0.18 2026-10-11 23:38:54 -07:00
phantomptrandClaude Fable 5.1 2be9b2b986 LZ4 packs: install ampr_emu 0.4.2.1 into the image when the game's is missing or old (own copy kept as .orig); a non-AMPR game is pointed at .ffpfsc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 23:38:51 -07:00
phantomptr 6b646d818b Release v7.0.17 2026-10-11 22:47:06 -07:00
phantomptrandClaude Fable 5.1 679d005958 Open a save decrypted and edit its files (save.mount / save.unmount in the helper, pfsmgr + sceFsMountSaveData from a /data copy, write-back only when changed); verified on FW 13.60
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 22:47:04 -07:00
phantomptr b74365dbc0 Release v7.0.16 2026-10-11 22:30:52 -07:00
phantomptrandClaude Fable 5.1 40bcd3a9f1 Measure how a game plays and pack it that way: ampr_emu debug build in fakelib2, sessions collected, a profile generated (port of drakmor's ampr_pack_profile.py); i18n gate fix for 7.0.15
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 22:30:50 -07:00
phantomptr a0a5edb7a0 Release v7.0.15 2026-10-11 21:58:11 -07:00
phantomptrandClaude Fable 5.1 0036298778 Images regenerate a stale ampr_emu.index (#449), connection says which of three helper states it found (#447), HDR screenshots as 16-bit PQ PNG with cICP (#448)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 21:58:08 -07:00
phantomptr b18c7de5a6 Release v7.0.14 2026-10-11 21:16:26 -07:00
phantomptrandClaude Fable 5.1 6eb4b4f619 Convert walks through four steps: game, what to make (with a recommendation for the console's firmware), the path's own options, build & install
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 21:16:23 -07:00
phantomptrandClaude Fable 5.1 4d3086d82f Convert: a game on the PS5 or a saved server can become a game image; Options hide package-only choices for an image
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 21:08:00 -07:00
phantomptr 774577a499 Release v7.0.13 2026-10-11 20:07:49 -07:00
phantomptrandClaude Fable 5.1 564e757bbe Image read-back check at drive speed, a lighter game page, 7z corruption stops retrying (#446), console-to-console route and dial address (#444)
exFAT reader: FAT held in memory, a cursor so a big file's chain is not re-walked per read,
runs of consecutive clusters read in one call; hashing on all cores; verify_chunk_kib on the
image build request with a Convert preference (8 MiB measured fastest, 169 MiB/s -> 3.7 GiB/s).

Game page: the Overview card that repeated the hero is gone; path, last played and captures
live in the hero; each copy is one compact row with a primary action and an overflow menu.

7z: lzma-rust2 raises "dist overflow" as ErrorKind::Other; it is now a corrupt-archive fault
that names the entry, so the queue stops instead of retrying the same bytes.

Console to console: route auto/direct/relay and an optional dial address per pair, in the
send dialog and the engine request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 20:07:43 -07:00
phantomptr b390165ae1 Release v7.0.12 2026-10-11 13:12:51 -07:00
phantomptrandClaude Fable 5.1 ab8cbcdd44 Convert: Stored (uncompressed) package option as the fallback for consoles that refuse a compressed package
Compressed packages are verified to play on FW 5.10 only; stored blocks are the other form
that has played. Level::Stored writes the block layout with raw blocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 13:03:18 -07:00
phantomptrandClaude Fable 5.1 28b12bf5d1 Helper mismatch popup says there is nothing to do on the PS5: Reload helper sends it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 12:58:59 -07:00
phantomptr 004d7becbb Release v7.0.11 2026-10-11 12:47:02 -07:00
phantomptrandClaude Fable 5.1 c091a2f127 RAR errors keep the library's reason after the advice, so a report can be diagnosed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 12:41:32 -07:00
phantomptrandClaude Fable 5.1 979dac2562 Game page: Install on a package card under On your drives
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 12:39:05 -07:00
phantomptrandClaude Fable 5.1 7555b8d154 Settings: Reduce transparency switch (#443)
The frosted-glass backdrop blur is what keeps the GPU busy while an upload redraws its
progress. Motion → Reduced already zeroed it; this names the switch and follows the OS
transparency setting by default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 12:31:40 -07:00
phantomptrandClaude Fable 5.1 07a7166abc e2e: the Home support button is "Coffee or tokens"
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 12:23:50 -07:00
phantomptr 48fb4ee1c1 Release v7.0.10 2026-10-11 11:19:16 -07:00
phantomptrandClaude Fable 5.1 db03f58e08 Android build: the drag plugin's permission lives in a desktop-only capability
tauri-plugin-drag is not compiled for Android/iOS, and a capability naming drag:default
failed the Android build of v7.0.9 before it was published. Changelog: 7.0.9 → 7.0.10.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 11:19:10 -07:00
phantomptr 2d062c6b60 Release v7.0.9 2026-10-11 10:46:23 -07:00
phantomptrandClaude Fable 5.1 e079e30e4d Home: grids and action tiles answer to their own width, so a large Text size drops columns instead of wrapping labels one letter per line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 10:43:39 -07:00
phantomptrandClaude Fable 5.1 e3a1afd15b Uploads: resume ids live 7 days like the console's journal; Stop buttons say what is kept (#440)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 10:27:59 -07:00
phantomptrandClaude Fable 5.1 44e0aba609 Connections: WebDAV and WebDAV (HTTPS) servers (#442)
PROPFIND listings, ranged GET reads, Basic auth, optional self-signed certificate per
connection. Same read-only RemoteFs as SMB/FTP/SFTP, so uploads and installs stream from
the server. Loopback-server test covers sign-in, listing, walk and ranged reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 10:26:11 -07:00
phantomptr a0d95ad72d Merge branch 'pr-441' 2026-10-11 10:15:45 -07:00
phantomptr d14718960d Merge branch 'main' into pr-441 2026-10-11 10:14:36 -07:00
phantomptrandClaude Fable 5.1 0a925af070 Credit and support the wider scene: README Thanks, About credits, site support card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 10:13:10 -07:00
phantomptrandClaude Fable 5.1 5d41f564e2 Volumes: Eject a USB drive (usb.eject, method 144)
A plain unmount first; FW 13.60's shell keeps /mnt/usbN open, so the app offers
"Eject anyway", which syncs and force-unmounts. Verified on the Phat: /mnt/usb0
gone and stayed gone, helper unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 10:02:51 -07:00
phantomptrandClaude Fable 5.1 051ea1cc81 Payloads: a Payload Manager tab (list, launch, delete, boot list) and "Store in Payload Manager"; catalogue + scene-tool entries for Payload Manager and LZ4 Compression
Engine routes /api/ps5/pldmgr{,/launch,/delete,/autoload,/store} over Payload Manager's
own HTTP API (:8084). Storing into an existing folder is confirmed first, since Payload
Manager's import removes that folder's other files. Scene-tool pills with a web page
(Payload Manager :8084, LZ4 Compression :5920) open it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 09:52:30 -07:00
phantomptrandClaude Fable 5.1 5163c8f66e Files: drag a file out of the window onto this computer (desktop); "Coffee or tokens"
The OS drag can only carry a local file, so the first drag fetches the PS5 file into
a staging folder under app data (pruned after a day) and starts the drag if the mouse
is still held; otherwise it says to drag again. Files over 4 GB keep using Download.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 09:37:13 -07:00
phantomptrandClaude Fable 5.1 07c8d64211 Auto-redeploy waits for a booting console; README/DISCLAIMER note how the project is built
The unattended helper restore pushed the helper as soon as :9021 answered, which on a
cold boot is before kstuff loads (FW 11.40 report: crash 3/3). It now waits 90 s after
the loader first appears. SPEC.md: fs.write/fs.mkdir ignore mode (both are 0777).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
2026-10-11 09:27:07 -07:00
Aleksey Zelenskiy 5dac982774 fix(webui): serve local game cover (sce_sys/icon0.png) from the engine 2026-10-11 12:19:41 +02:00
phantomptr 82e86c63b5 Release v7.0.8 2026-10-11 02:44:28 -07:00
phantomptrandClaude Opus 5.5 e62fe3d877 Changelog: 7.0.7 was never published; its changes ship as 7.0.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 02:44:25 -07:00
phantomptrandClaude Opus 5.5 92ed5c2af1 Upload: the "network" label for an idle console no longer changes how lanes grow (#437)
cea5c5c2 made a credit wait with an idle console report BN_NETWORK, and
the governor grows lanes (and caps the chunk) on BN_NETWORK, so it began
trying an extra lane where 7.0.6 held. Unintended: more lanes cannot
fetch credit the console has not granted. Control is back to 7.0.6's
rule; only the reported label says "network". A test pins it: a credit
wait with an idle console steers lanes and chunks tick for tick like a
credit wait with no word from the console. The throughput gates measure
the same with the old and new governor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 02:15:49 -07:00
phantomptr fed3cf2146 Release v7.0.7 2026-10-11 01:32:20 -07:00
phantomptrandClaude Opus 5.5 d4d181df49 Changelog for 7.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 01:32:18 -07:00
phantomptrandClaude Opus 5.5 0346309c14 Version sync: leave the vendored sevenz-rust2 at its own version
The sync treats every lockfile crate without a source as ours and set
the vendored sevenz-rust2 to the app version (7.0.5, 7.0.6), the same
trap unrar fell into; cargo then wrote 0.23.0 back and CI's version
check failed. It joins unrar on the vendored list, and the desktop
lockfile is back to 0.23.0. Builds were unaffected: cargo always built
the vendored source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 01:08:32 -07:00
phantomptrandClaude Opus 5.5 ece147d720 Games: hide a title's icon from the PS5 home screen, and show it again (#407)
For shortcuts and loaders that, once set up, only clutter the home
screen. ⋯ → "Hide from home screen" on a Games card; a "Hidden" badge
marks it, "N hidden from the home screen" at the top filters to them,
and ⋯ → "Show on home screen" puts it back.

How: every user's icon table in app.db (tbl_iconinfo_<user>) has a
`visible` flag; the system hides some of its own apps with it. The
helper (app.icon_set, app.hidden_list) flips only that flag: nothing is
deleted, the title stays installed and playable. Guards: title ids only,
system apps (NPXS) refused, tables found by shape (titleId + visible),
app.db backed up once to /data/ps5upload/app.db.before-hide before the
first change, BEGIN IMMEDIATE (fails before any change if the system
holds the lock), and rolled back unless at least one and at most one
row per table changed. The hidden list is read live from app.db, so it
cannot drift from the console, and leaves out the system's own hidden
apps. The home screen redraws when an app is next opened and closed;
the console shows a notification saying so.

Verified on the Phat (FW 13.60): WebKit Autoloader hidden, listed, shown
again; a system app and an unknown id refused; the backup written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 00:36:44 -07:00
phantomptrandClaude Opus 5.5 b83d3b3159 Pages: drop the release trigger, which the environment always rejected
A release event runs on the tag's ref, and the github-pages environment
only deploys from main: every release showed a failed deploy ('Tag
v7.0.6 is not allowed to deploy to github-pages'). The deploy after
publish (from main) already covers new releases, and the daily run
picks up a hand-promoted one; the page reads releases live anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 00:22:06 -07:00
phantomptrandClaude Opus 5.5 ba48655988 Lab: the late-listener port test reserves its ports instead of releasing them
wait_for_ports_waits_for_listeners_that_appear_late picked ports by
binding and dropping them, so on CI (1,500 tests in parallel) another
test could take one and answer on it before the test listened: CI saw
'ready' after 452 ms when the second listener only opened at 800 ms.
The ports are now bound and held without listening (socket2, already in
the lockfile) and start listening on cue. 12 parallel copies pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 00:21:49 -07:00
phantomptrandClaude Opus 5.5 cea5c5c264 Upload: "Limited by: console memory" when the console was idle was really the network (#437)
The sender called any wait for the console's credit "console memory"
unless the console reported its disk or workers as the limit. When the
console reports its queue ran dry, it is idle, waiting for data: the
credit is slow in transit (here a Windows Internet Connection Sharing
bridge on a CPU busy converting), so the cause is the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 00:18:56 -07:00
phantomptrandClaude Opus 5.5 35fece053b i18n: "Slot" is the same word in de, id, it and pt-BR
remotePlay_slot now reads "Slot {slot}" like Profile's label, which in
those four languages is identical to English, so the coverage check
called it untranslated and failed CI. Allowlisted as the same word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-11 00:05:13 -07:00
phantomptrandClaude Opus 5.5 69b15b56a7 Scripts: name post-images.js with its extension, so the script audit sees it used
The audit counts a script as used when its file name appears elsewhere;
require("./post-images") left the .js off, and CI's repo-scripts job
failed with post-images.js as unreferenced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:44:20 -07:00
phantomptr 2ace14e90f Release v7.0.6 2026-10-10 23:26:42 -07:00
phantomptrandClaude Opus 5.5 eac9f670a4 Changelog for 7.0.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:21:56 -07:00
phantomptrandClaude Opus 5.5 516bfee468 A console whose address changed is found and offered (#430)
When the router hands the PS5 a new address, the saved one stops
answering and the app said the helper was not running. Now, while the
active console is down, the desktop and Android apps scan the network
(at most every five minutes) and ask the engine about each PS5 running
our helper: POST /api/ava1/same-console {old, new} connects to the new
address expecting the key pinned for the old one and answers same,
different or unknown. Only "same" shows a banner: "Your PS5 now answers
at X", with Switch (re-points the console, keeping its name and history)
and Dismiss. Another PS5 is never offered; the check pins nothing.

Tested against the C server (same at a new address, another console, no
pin, nothing answering) and against the real consoles: the Pro's key
pinned under a made-up old address matches the Pro and not the Phat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:21:44 -07:00
phantomptrandClaude Opus 5.5 6ce4cafec4 README: link ps5upctl, a community CLI for the engine API (#430)
Also note the upstream report for the vendored sevenz-rust2 fix
(hasenbanck/sevenz-rust2#148).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:12:36 -07:00
phantomptr 6cd4f021d8 Release v7.0.5 2026-10-10 23:11:20 -07:00
phantomptrandClaude Opus 5.5 fabd8c830c Upload: one blue button, the next step (#439)
- The confirm's "Start upload" was a grey card with a faint outline while
  Resume was filled; it is filled again, and the three choices share one
  shape (Override stays plain: with files there it is the risky one).
- "Cancel" before anything started only cleared the source: it says
  "Change source" now.
- "Build the image, then upload it" was a second filled button next to
  Upload; it is plain, the alternative path.
- Add to queue is filled while the queue runs (Upload is disabled then),
  and Retry is filled after a failed upload or destination check.

7.0.2-7.0.4 were never published; the changelog ships it all as 7.0.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:11:17 -07:00
phantomptr 9e17809797 Release v7.0.4 2026-10-10 23:05:52 -07:00
phantomptrandClaude Opus 5.5 8f229e4046 Changelog: 7.0.2 and 7.0.3 were never published; their changes ship as 7.0.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:05:50 -07:00
phantomptrandClaude Opus 5.5 d2dc3f703c 7z on 32-bit Android: archives over 4 GiB no longer fail with "dist overflow" (#434)
sevenz-rust2 turns 7z sizes (u64 in the format) into usize before using
them as read limits: a block's packed size caps the decoder's input, and
block and file unpacked sizes cap the output and the CRC check. On the
32-bit Android build (armv7, in the APK next to arm64) usize is 32 bits,
so a limit past 4 GiB wraps: a 4.7 GB block was cut to 405 MB of input,
the LZMA decoder ran out inside a chunk and said "dist overflow", and any
file over 4 GiB was cut short. Game backups are tens of GB in one block.

sevenz-rust2 0.23.0 is vendored in third_party/ (as unrar is) with those
sizes kept u64 end to end; nothing changes on 64-bit. 0.24.0 still has
the casts. Verified on linux/arm/v7: a 4.7 GB single-block archive failed
after 405 MB before and decodes in full with its CRC after, and 6 GiB of
LZMA-coded output decodes correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:03:10 -07:00
phantomptrandClaude Opus 5.5 d0c02ced52 check-no-ftx2: skip the website's copy of the release notes
site/releases.json repeats past release notes, which name FTX2, so the
check failed engine CI on every commit since the site was added. It is
history, like CHANGELOG.md, which is already exempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:03:10 -07:00
696 changed files with 126532 additions and 4618 deletions

No files matched your search

+12
View File
@@ -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: |
+12
View File
@@ -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 -9
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -53,6 +53,7 @@ scripts/.translate-cache.json
# ─── Client (Vite / Tauri) ───────────────────────────────────────────────
client/dist/
client/dist-console/
client/.vite/
client/out/
client/.cache/
+671 -1
View File
@@ -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
View File
@@ -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
+212 -35
View File
@@ -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?**
+111 -2
View File
@@ -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 \
+26 -2
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
7.0.3
7.1.11
+44
View File
@@ -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();
});
}
+92
View File
@@ -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();
+3 -3
View File
@@ -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);
+25
View File
@@ -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");
});
+62
View File
@@ -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);
});
+1
View File
@@ -38,6 +38,7 @@ export default tseslint.config(
{
ignores: [
"dist/**",
"dist-console/**",
"node_modules/**",
"src-tauri/**",
"coverage/**",
+295 -295
View File
File diff suppressed because it is too large. Load diff
+13 -12
View File
@@ -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": {
+64
View File
@@ -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;
}
+38
View File
@@ -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);
});
});
+244 -37
View File
@@ -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"
+10 -1
View File
@@ -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" }
+3 -1
View File
@@ -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"
]
}
+138
View File
@@ -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(())
});
}
+88
View File
@@ -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))
}
+860
View File
@@ -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}")),
}),
+66
View File
@@ -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
}
+2 -54
View File
@@ -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")
+2
View File
@@ -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::*;
+117 -9
View File
@@ -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]
+8 -6
View File
@@ -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 {
+58 -10
View File
@@ -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
+153 -9
View File
@@ -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))
+85 -3
View File
@@ -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,
})
}
+7
View File
@@ -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 {
+24
View File
@@ -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");
+3 -2
View File
@@ -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
View File
@@ -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
+87
View File
@@ -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;
}
+25
View File
@@ -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");
});
});
+183
View File
@@ -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";
}
}
+7 -1
View File
@@ -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 }) },
),
+66
View File
@@ -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",
);
});
});
+153
View File
@@ -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 };
}
+41
View File
@@ -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);
});
});
+50
View File
@@ -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
View File
@@ -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 }),
};
+104
View File
@@ -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");
});
});
+168
View File
@@ -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;
}
+43
View File
@@ -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");
});
});
+41
View File
@@ -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);
}
+130
View File
@@ -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;
}
+34
View File
@@ -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 }));
});
+61
View File
@@ -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
View File
@@ -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,
+111
View File
@@ -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",
);
});
});
+4 -1
View File
@@ -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 {
+2
View File
@@ -17,6 +17,8 @@ const PROTOCOL: Record<Connection["protocol"], string> = {
ftp: "FTP",
ftps: "FTPS",
sftp: "SFTP",
webdav: "WebDAV",
webdavs: "WebDAV (HTTPS)",
};
export interface BrowseButtonProps {
+2 -1
View File
@@ -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"],
+146 -2
View File
@@ -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 -1
View File
@@ -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&amp;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");
+10 -6
View File
@@ -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]);
+3 -1
View File
@@ -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}
>
+4 -2
View File
@@ -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&#x27;t running");
});
it("offers Wake & sign in when the session keys are stored", () => {
expect(view({ power: "rest", canWake: true, canSignIn: true })).toContain("Wake &amp; 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&#x27;t answering");
expect(html).toContain("Features Available in Rest Mode");
});
});
+1
View File
@@ -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,
+182
View File
@@ -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>
);
}
+137
View File
@@ -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();
}
});
});
+198
View File
@@ -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);
});
}
+271
View File
@@ -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);
});
+248
View File
@@ -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]);
}
+322
View File
@@ -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);
});
});
+316
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+293
View File
@@ -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" });
});
});
+184
View File
@@ -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,
};
}
+238
View File
@@ -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,
};
}
+342
View File
@@ -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 });
});
});
+437
View File
@@ -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
+6
View File
@@ -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;
+141
View File
@@ -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>
);
}
+126
View File
@@ -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>
);
}
+238
View File
@@ -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}
</>
);
}
+420
View File
@@ -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
+219
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+134
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+191
View File
@@ -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