Compare commits

...
621 Commits
Author SHA1 Message Date
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
phantomptr c7fa4d068a Release v7.0.3 2026-10-10 22:49:21 -07:00
phantomptrandClaude Opus 5.5 6b16f3789d Changelog: ship these as 7.0.3 (the v7.0.2 tag can't be moved)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 22:49:19 -07:00
phantomptrandClaude Opus 5.5 6ce24568ee Upload: the "exfat" destination says it is a folder, not a conversion
A user picked the "exfat" destination preset for a game folder expecting
an .exfat image and got the folder, unchanged, in an exfat folder. The
preset's hint ("Disk images") was only a tooltip, so on a phone it was
never seen.

The selected preset's hint now shows as text, the exfat one says it does
not turn a folder into an image, and a game folder headed for an exfat
folder gets a note pointing to "Or send it as one game image", which is
what builds the .exfat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 22:49:03 -07:00
phantomptr 72fe7f84d8 Release v7.0.2 2026-10-10 22:33:31 -07:00
phantomptrandClaude Opus 5.5 3d0a8b6d21 Changelog for 7.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 22:33:29 -07:00
phantomptrandClaude Opus 5.5 279ebe97cc Themes: OLED is back, and Peach joins it (#438)
7.0.0 cut the themes to light and dark and quietly turned OLED users'
setting into dark. Now there are four:
- OLED: dark on pure black (window and sidebar #000, no glow), for OLED
  screens. Dark's text, accent and status colours.
- Peach: a warmer light, cream window, brown text, burnt-peach accent.
  Text 14.9:1, muted 6.5:1, accent 5.2:1, white on the fill 5.2:1.
Every light-only rule (high contrast, reduced transparency, inputs)
matches Peach too, and OLED keeps a black window under high contrast.

Settings shows the four with a swatch each. The sun/moon toggle flips
between the last light and last dark you picked, so an OLED user goes
OLED <-> Light, not back to Dark. A stored 6.x "rose" reads as Peach.

Also: Remote Play's "Someone is signed in" said "user 7" for slot 7,
which read as a different account from Profile's "Slot 7 - User1"; it
says "Slot 7" now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 22:31:20 -07:00
phantomptrandClaude Opus 5.5 ae16d01d50 Remote Play: the pxplay number is the console's account id again
The pxplay number was read from the account id's bytes high byte first,
but the console stores them low byte first (as offact, ps5-account-tools
and our Profile screen read them), so pxplay was given another account's
number and answered 403. A typed hex id was worse: it was decoded as if
it were base64.

The manual account id now accepts hex (with or without 0x), base64 or the
pxplay number, is sent as the console's base64, and anything else is
refused with a message. Pinned against the reported id
(0x7ed3586971779f76 = dp93cWlY034= = 9138745278763343734) and the test
consoles' id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 22:26:28 -07:00
phantomptrandClaude Opus 5.5 5c840f9ec9 make release-post: a second run replaces the images folder instead of failing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:51:38 -07:00
phantomptrandClaude Opus 5.5 adc97eb203 Posts: link the website, credit phantomptr.com, attach website screenshots
Release and introduction posts send downloads to ps5upload.phantomptr.com (it
picks the right file), keep the GitHub release for full notes, and the
introduction posts say who made it. scripts/post-images.js screenshots the live
website with the client's Playwright into .release_posts/website/; each post
command copies the ones it needs into its images/ and says where to attach them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:49:52 -07:00
phantomptrandClaude Opus 5.5 096afd9436 Website: the footer links to phantomptr.com
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:45:25 -07:00
phantomptrandClaude Opus 5.5 f41d7c1b94 Website: link previews use ps5upload.phantomptr.com
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:28:19 -07:00
phantomptrandClaude Opus 5.5 9ff93772b6 Intro posts: a higher-level Reddit title
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:04:30 -07:00
phantomptrandClaude Opus 5.5 fb7f5cf8df Intro posts: a plain Reddit title
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:03:15 -07:00
phantomptrandClaude Opus 5.5 d07afdd211 Discord posts: the coffee emoji outside the link text, so Discord makes it a link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 21:00:36 -07:00
phantomptrandClaude Opus 5.5 6862b8c522 make promo-post: introduction posts for new users
scripts/promo-posts.js writes .release_posts/about/: what PS5Upload is, its
features, how to start and where to download, shaped for X (a thread that
fits 280 per post, checked), Discord and Reddit, plus app screenshots to
attach. The version and link come from VERSION; release-posts.js stays the
per-release notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 20:53:04 -07:00
phantomptrandClaude Opus 5.5 d9f73c8bca Release posts: phrase-like labels stand alone, a thread pointer when there's no summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 20:47:51 -07:00
phantomptrandClaude Opus 5.5 011d6d9623 Buy me a coffee in the release posts and the website header
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 19:38:31 -07:00
phantomptrandClaude Opus 5.5 76baa41e16 Website: a PS5Upload page for GitHub Pages, kept current from the releases
site/ is a static page in the app's own look (light and dark, the app's
screenshots): features, downloads for every platform with the visitor's
system first, the Docker command, news from the release notes, community
links. It reads the releases from GitHub's API live, falling back on
site/releases.json (scripts/site-releases.mjs), which the pages workflow
rewrites on every deploy: after each publish run, on release edits (promoting a
pre-release), on changes to site/, and daily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 19:32:54 -07:00
phantomptrandClaude Opus 5.5 7026ed9bdb Release posts: one file per post in .release_posts/, shaped for X, Discord and Reddit
make release-post now writes .release_posts/v<version>/ (git-ignored): each X post
in its own file (plain text, 280 with links as 23, link in the main post), a
Discord announcement in Discord's markdown (named links without embeds, small
print), and the Reddit title and body as separate files, plus a README saying
what goes where with each post's length. Every platform gets the issues link.
Short lines keep the first clause after a label, so X no longer reads
'Convert', 'Captures'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 19:27:26 -07:00
phantomptr bc973486ad Release v7.0.1 2026-10-10 19:19:38 -07:00
phantomptrandClaude Opus 5.5 f8906c3271 Collection: show a scan's progress, and join a running scan instead of failing
The engine reports what a scan is doing (finding, reading, naming, saving),
which folder and item, and how many games are named; the screen shows it as a
progress card with the item being read and the time so far, and a last-scanned
line afterwards. A second scan request joins the running one: the Collection is
one per computer, and pressing Scan during the automatic refresh used to show
'The collection could not be read: a scan is already running'.

Also removes Import from PS Game Library (the engine route, its code and the
menu items): that tool was never public.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 19:15:50 -07:00
phantomptrandClaude Opus 5.5 e377d3f1d4 Changelog: 7.0.0 as one short list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 17:32:37 -07:00
phantomptrandClaude Opus 5.5 11827039d3 Android: rounded split buttons, Home tiles that fit a phone, a Payloads search box
- The build rewrote start/end corners and positions with :lang(ar,fa,…) language
  lists, which Chromium before 141 (Android's WebView) drops: split buttons were
  square and start/end-placed controls sat wrong. Each list is now split into
  one language per check (postcss/langLists.mjs, run on the built CSS).
- Home's action tiles stack the icon above the label on a phone; the labels broke
  mid-word.
- Payloads' search box takes its own row on a phone instead of shrinking to nothing.
- After Resend helper, pairing keeps checking for a minute, not 16 s.
- Reverts the Android blur change: the doubled title was the emulator's software
  rendering, gone with its GPU on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 17:05:48 -07:00
phantomptrandClaude Opus 5.5 510759b4d4 Android: no glass blur, and a closed pairing dialog stays closed
Android WebView drew a copy of what sits behind a frosted card above it (the
Connection title showed twice); without the blur the page draws right, and
scrolling is cheaper. Closing the pairing dialog used to last a minute, then
it opened again over and over; now it stays closed for that console until
Pair... is pressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:52:14 -07:00
phantomptrandClaude Opus 5.5 b65b1efe01 Convert: the compression and LZ4 options are full-size checkboxes with their text stacked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:19:06 -07:00
phantomptr 16ae68bf61 Merge branch 'convert-q' 2026-10-10 16:17:43 -07:00
phantomptrandClaude Opus 5.5 9cb4387b5a Convert: one set of make/after choices for Convert and the queue; queue only games; pick several folders
Build & install now holds the only Make (package, or the image format and
compression, with LZ4 asset packs) and After converting (keep, stream or
upload & install, delete after) controls; the single Convert button and the
queue both use them, so the queue still builds the image picked. Add games
from a folder queues the folder itself when it is a game, else only its
games, images and archives, and says how many folders were skipped. The
queue can pick several game folders as well as several images or archives
(#435).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:16:57 -07:00
phantomptrandClaude Opus 5.5 389fd8d599 Convert: a single run can delete its package once installed, and after-choices follow what is made
start() takes deleteAfter, like the queue's handoff already did; thenFor
says what can happen after converting (an image is uploaded, never
streamed; nothing is sent while no console answers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:15:08 -07:00
phantomptrandClaude Opus 5.5 84b40a9b8a Convert: tell game folders apart for the queue's folder scan, and pick several folders
A folder is a game when its sce_sys holds param.json or param.sfo (the
engine's own rule). gamesInFolder takes the picked folder itself when it is
a game, else only its child games, images and archives, counting the
folders it skipped; pickPaths can now pick several folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:15:08 -07:00
phantomptrandClaude Opus 5.5 8ae0279031 Changelog: the Android app does the internet features the desktop app does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:07:55 -07:00
phantomptrandClaude Opus 5.5 90489a84a2 Android: install and download from a link, install from a saved server, and the online title lookup work in the Android build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:07:54 -07:00
phantomptrandClaude Opus 5.5 493fec8e7a Android: the community cheat search and download work in the Android build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:07:54 -07:00
phantomptrandClaude Opus 5.5 6489b716c2 Android: the launcher shows the PS5Upload logo on every build
The logo was only applied when the Android project was first created, so an
existing one kept Tauri's placeholder, and the round icon was a small tile
inside a circle. scripts/release/android-icons.py draws the set from the icon
source (adaptive logo in the safe zone over white; full-bleed white tile and
disc for older launchers), and the post-init patch copies it on every build,
local and CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 16:02:02 -07:00
phantomptrandClaude Opus 5.5 7019470231 Bug report: the app version is always the running one and can't be edited
A draft saved by an older version kept its number (6.6.0 in a 7.0.0 report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 15:18:34 -07:00
phantomptrandClaude Opus 5.5 14ad5b17bf Captures: view the app's screenshots large, page through them, delete them
The Capture button saved screenshots only the Bug report could pick from,
as thumbnails. A viewer now shows them large with previous/next (buttons,
arrow keys, a thumbnail strip), Delete and Show in folder. It opens from a
button beside Capture, from the "Screenshot saved" note, and from the Bug
report, which also gains Delete all. Deleting anywhere drops the capture
from every list and from the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:54:06 -07:00
phantomptr f2cc3ff94f Merge branch 'fix-ui2' 2026-10-10 14:42:19 -07:00
phantomptrandClaude Opus 5.5 5f984432bd Polish: spell Refresh "Odśwież" (was "Odswież")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:40:28 -07:00
phantomptrandClaude Opus 5.5 08cd2e1aa4 Disk usage: translate the "(+N more)" treemap block
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:40:22 -07:00
phantomptrandClaude Opus 5.5 581da21152 RTL: mirror the directional arrows and chevrons (back/forward, sidebar collapse, next, disclosures)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:39:43 -07:00
phantomptrandClaude Opus 5.5 22a982c2e5 Game activity error has Retry; Hardware rows line up with their hints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:39:25 -07:00
phantomptrandClaude Opus 5.5 17a181e5ad Files right-click menu works from the keyboard: focus, arrows, Escape/Tab return focus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:38:37 -07:00
phantomptrandClaude Opus 5.5 f4702f6648 Shortcuts overlay is a real dialog: role, label, focus trap and restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:37:03 -07:00
phantomptrandClaude Opus 5.5 3da372c4f0 Escape closes only the topmost dialog, not the Modal under a confirm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 14:37:03 -07:00
phantomptrandClaude Opus 5.5 d89eabe0bb Drop-downs draw their own chevron; Bug report fills the page; no Android build warnings
Also restores the lockfile's libc fields lost when the bundled font was removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:18:55 -07:00
phantomptr ddfefc7a42 Merge branch 'fix-payload' 2026-10-10 13:47:03 -07:00
phantomptrandClaude Opus 5.5 5a3321f763 Changelog: 7.0.0 look and fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:43:43 -07:00
phantomptrandClaude Opus 5.5 a72912a547 ava1: a job cancelled on the console ends its sender, and a sender never waits forever
job.cancel stopped a job on the console without telling the session it
was attached to: the session stayed up, so the sender waited for a
JobDone that never came. The console now tells that session first
(JobDone with ERR_CANCELLED for a job it receives, JobCancel for one it
sends).

The engine's sender also bounds the wait: a receiver that says nothing
about the job for 10 minutes on a live session (no Status, no other
frame) gets JobCancel{ERR_STALLED} and the job fails. The wait for the
map is bounded the same way and honours the cancel flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:43:32 -07:00
phantomptrandClaude Opus 5.5 38271875e5 Layout: page content lines up with its header; Home's console panel stays beside the page
Screens that centred a narrow column under a left-aligned header now fill
the page frame. On Home the tall greeting block is gone, so the console
panel is short enough to stay pinned while the page scrolls and never
leaves an empty column under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:43:30 -07:00
phantomptr 490b53612a Merge branch 'fix-ui'
# Conflicts:
#	client/src/index.css
2026-10-10 13:40:20 -07:00
phantomptrandClaude Opus 5.5 0eee814ab8 Look: follow macOS 27 — neutral window, edge-to-edge sidebar, system font, blue accent
The app's logo is back in the sidebar, the top bar and the Home greeting.
Home's main actions are plain tiles instead of gradient photos with round
arrow buttons, and the coral ball is gone from Home and from empty states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:39:22 -07:00
phantomptrandClaude Opus 5.5 001e51abec Files: the right-click menu is drawn on the page, so its last items are not cut off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:33:16 -07:00
phantomptrandClaude Opus 5.5 b73df6b0a9 Bottom tabs: long translated labels shorten instead of running into each other
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:31:04 -07:00
phantomptrandClaude Opus 5.5 b9a4f03133 Audit log: the empty state is the same card as on other screens, not a bare line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:30:46 -07:00
phantomptrandClaude Opus 5.5 098c5e639d Arabic and Persian: a named UI font stack, and no letter-spacing or tiny caps labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:30:46 -07:00
phantomptrandClaude Opus 5.5 203bb3294d Leftover square panels, inputs and solid buttons now use the glass primitives
Settings cards and its Add daily / Export / Import buttons, the ShadowMount+
panel and its rows, the Upload destination fields and choice cards, the queue's
per-console box, the Health junk list, the Cheats game header (which also
stacks below 1280 now, where it was crushed), the Send payload form, and a
handful of dialogs' and lists' square boxes: surface-panel, Button, .input /
.select and the card and field radii, as everywhere else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:33 -07:00
phantomptrandClaude Opus 5.5 84d68d6389 Health and Cheats: the speed test and the cheat filters fit a narrow column
Speed test: the size picker is the app's own select, and on a phone the
controls drop under the description instead of squeezing it to three words a
line. Cheats: the three filters wrap, so their choices are no longer cut to
"All gam" / "Any stc".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:33 -07:00
phantomptrandClaude Opus 5.5 243d60655d Running apps: an app with no name reads "Unknown app", not its app_id twice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:33 -07:00
phantomptrandClaude Opus 5.5 2d6677e0de Disk usage: the map's last row stays inside its panel, and small blocks show their size
A row had a fixed height, so the cells it wrapped onto a second line were
drawn below the panel's border. Blocks are at least 96px wide, enough for
"57.3 GiB" instead of "57.…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:33 -07:00
phantomptrandClaude Opus 5.5 f7783bbc18 Sidebar: the hide buttons are no longer a Tab stop after every link
Forty Tab presses never left the sidebar. The buttons stay for the pointer,
are reached from a focused link with the arrow key toward them, and More keeps
the same switch for every screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:33 -07:00
phantomptrandClaude Opus 5.5 573e250e96 Console picker and activity panel: opening one moves keyboard focus into it
The sidebar's console picker is a real menu: it announces itself, opens with
the focus on the console on show, moves with the arrow keys, and Escape or Tab
closes it back to its button. The activity panel above the status strip takes
focus when opened (it comes before its button in tab order) and Escape closes
it. Its progress bar gets round ends like the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:33 -07:00
phantomptrandClaude Opus 5.5 afea1b8ade Volumes: the package-drive badge wraps under the path instead of leaving the card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:18:49 -07:00
phantomptrandClaude Opus 5.5 cce542c042 Collection: the title, the totals and the error state hold up in a narrow window
Page header: actions drop under the title when both don't fit, instead of
squeezing the title to "C…". Totals: 3 + 2 tiles below 1280, so words and
numbers no longer break mid-way. A collection that failed to load shows the
error with Retry, not the "add your first folder" prompt. On a phone, Folders
moves into the … menu so the menu is not left alone on a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:18:49 -07:00
phantomptrandClaude Opus 5.5 2d2c02066d Console tabs: no more pill cut in half beside the search field
With several consoles, the search field folds to its round button below 1280
(it left the tab strip room for less than one tab at 1024). The strip fades at
an edge with more tabs past it and keeps the console on show scrolled into view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:18:49 -07:00
phantomptr f2cddc0a28 Merge branch 'fix-payload' 2026-10-10 13:15:04 -07:00
phantomptrandClaude Opus 5.5 e02bd615ca Web UI: a copy that should merge into an existing folder says so to the engine
The browser proxy dropped the overwrite flag the desktop one forwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:15:04 -07:00
phantomptr 769195f496 Merge branch 'fix-engine' 2026-10-10 13:14:53 -07:00
phantomptrandClaude Opus 5.5 d88d330870 payload/ava1: console-to-console audit fixes
- A JobCancel reaches a job only when it is parked or attached to the
  session the cancel came on, so a late cancel from an old direct send
  no longer stops the upload the relay re-attached. The sending
  console's watcher only hangs up when its job was replaced.
- c2c.send replaces only an earlier send the same device started;
  any other job under that id is left alone (ERR_BUSY, before dialling).
- A live ticket decides even for a paired key: the session is the
  ticket's restricted one, owned by the ticket's owner.
- A session's terms (restricted/outbound/r_job/r_owner/r_root) are
  written in the locked section that publishes it.
- FileRetry is de-duplicated per file and capped at the manifest size.
- c2c.send checks the read policy before stat; a dial that succeeds
  with some lanes failing no longer leaves a stale reason; a stale
  comment and an unused include are gone.

SPEC.md §11 and §18 say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:14:38 -07:00
phantomptrandClaude Opus 5.5 3d89a27854 engine: test that a path named twice is previewed for the trash once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:14:13 -07:00
phantomptrandClaude Opus 5.5 5142c3b623 Files: the right-click menu is opaque glass like the other menus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:13:58 -07:00
phantomptrandClaude Opus 5.5 ae74e0c54a Toggle: the knob stays inside the track in right-to-left languages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:13:58 -07:00
phantomptrandClaude Opus 5.5 31e4c9a948 Tasks: item counts are not printed as bytes, and the status rail follows the reading direction
A batch install counts packages; its row read "31 B / 100 B". The coloured
rail was a physical left border, so it sat on the wrong side in Arabic and Persian.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:13:58 -07:00
phantomptrandClaude Opus 5.5 8c0ec8cd29 Processes: say the list is waiting for a transfer instead of showing a blank page
The shared poller stands aside while data moves to the console, so a screen
opened mid-transfer never got its first read and showed only the toolbar.
It now explains that, and shows skeleton rows while a normal first read is out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:13:58 -07:00
phantomptrandClaude Opus 5.5 82360fac3d Dialogs: Tab stays inside the open dialog, and the command palette is a real dialog
Modal, Drawer, the confirm/alert/prompt dialogs, the folder picker and the
command palette now share one focus trap (lib/focusTrap). Before, Tab walked
out of an open dialog into the controls under the scrim.

Command palette: role=dialog with a label, Escape and the arrow keys work
from anywhere in the panel, result rows are no longer Tab stops, and focus
returns to where it was on close.

Confirm dialog: Enter on a focused Cancel button cancels instead of confirming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:11:00 -07:00
phantomptrandClaude Opus 5.5 d421f84262 engine: clippy fixes for the new code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:09:19 -07:00
phantomptrandClaude Opus 5.5 94c4217f23 engine: job file lists are shared, not copied under the lock; finalize I/O off async workers
/api/jobs/{id}/files clones an Arc under the jobs lock and serializes
after releasing it. The install history write, the source-copy removal and
the organizer's folder stats run off the async workers. The icon cache
recounts its size every 256 writes so the running total cannot drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:05:46 -07:00
phantomptrandClaude Opus 5.5 373bb1cf88 engine: bound the failure-log map and the helper log carry; journal prunes on a clock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:03:14 -07:00
phantomptrandClaude Opus 5.5 f7af95c050 fpkg: a homebrew with a minimal param.json is a game, not DLC
The DLC rule (no applicationCategoryType and no applicationDrmType) also
matched a homebrew's minimal param.json. A DLC carries no eboot.bin, so
the rule now requires that too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:02:05 -07:00
phantomptrandClaude Opus 5.5 04e522f71b engine: job events are sent under the jobs lock, so 'running' never follows the end
The progress ticker built its snapshot under the lock but sent it after;
a job finishing in between let a stale 'running' reach clients after
'done'. Both the ticker and set_job now send while holding the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:59:53 -07:00
phantomptrandClaude Opus 5.5 6d3cedeaf5 engine: install slots ignore name case, ended installs are pruned, sizes promote
One console is one install slot whatever the case of its host name. An
install that ended without reaching Done or Failed is now pruned like a
finished one instead of being kept until restart. iec_bytes no longer
prints "1024.0 KiB": a value that rounds to 1024 of a unit moves up one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:58:35 -07:00
phantomptrandClaude Opus 5.5 67f52766d4 fpkg: an AMPR pack manifest whose pack count overflows its layout is refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:56:47 -07:00
phantomptrandClaude Opus 5.5 920c0d788c engine: no panic on a non-ASCII app folder or a huge pack field width
An app folder name of nine bytes with a multi-byte character was sliced
mid-character; title ids are now checked by bytes. A pack_pattern field
wider than 64 ("{id:065536}") is a profile error (400) instead of a
format! panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:56:05 -07:00
phantomptrandClaude Opus 5.5 9b67a7c4a6 engine: a remote fetch refuses sizes that overflow instead of wrapping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:54:47 -07:00
phantomptrandClaude Opus 5.5 c1701d091d engine: the bandwidth cap reaches relays and is never lost to rounding
A console-to-console relay now honours the outbound cap (the request's
bandwidth_cap_mbps, else the environment's) like an upload; the direct
route is not paced since its bytes never cross this computer. A tiny cap
no longer rounds to 0 (= no cap), and a lane cancelled while waiting on
the pacer gives its reserved budget back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:54:17 -07:00
phantomptrandClaude Opus 5.5 961f2a4674 ava1: a direct console-to-console send always gives up, and frees the receiver
An unreadable job status skipped both the give-up and the stall checks, so
a direct send could poll forever. After falling back to the relay, the
engine now also cancels the receiving console's half of the direct job (it
owns it; the journal stays for the resume), and a relay still refused as
busy says why the direct route was given up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:51:34 -07:00
phantomptrandClaude Opus 5.5 89a8445973 Collection covers: a cover read out again shows, instead of the one cached before
A deep rescan can read a new cover out of a game whose files did not
change, and the cover's URL was versioned only by those files, so the old
image stayed (and the data-URL cache, keyed by game only, answered with the
old bytes too). The engine now records the saved cover's size and modified
time (cover_stamp, an optional index field) and the game page's cover URL
carries it; the client folds it into the cover version and keys the
data-URL cache by version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:50:48 -07:00
phantomptrandClaude Opus 5.5 97e9cb108d Upload: Retry sends the upload that failed, not whatever the form holds now
Retry re-ran the current form, which may have moved on to another source,
destination or console since. The failed phase now keeps the arguments it
was started with, and Retry starts those again (falling back to the form
only for a failure that has none).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:50:48 -07:00
phantomptrandClaude Opus 5.5 69d2d4280d Link download and PS5 to PS5 start: give the start request a 60 s limit
Both started their engine job with a bare fetch, so an engine that never
answered left the button spinning forever. The start request now times
out after 60 s with an error that says what was being started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:50:48 -07:00
phantomptrandClaude Opus 5.5 f78cb43dfb engine: a second build of the same output is refused, not raced
Builds claim their .partial (and LZ4 spool) before the job exists; a second
build of the same output gets 409 instead of deleting and overwriting the
first one's file. The image writer no longer removes a .partial blindly,
never removes one it could not create, and a cancel that lands after the
image was finished no longer reports it cancelled. The handlers' file
checks run off the async workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:50:10 -07:00
phantomptrandClaude Opus 5.5 f35455afe8 engine: a link download's partial belongs to its worker and its own link
Cancel no longer deletes the .partial under a running worker or a live
retry: the worker removes its own partial on exit, and a cancelled entry
stays live until then. A partial is resumed only when its sidecar
(<name>.partial.json: host, path, size, ETag/Last-Modified) names the same
link and both its head and tail bytes match. Stopped downloads are pruned
from the registry after 30 minutes or beyond 64, and the start route's
file work runs off the async workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:47:35 -07:00
phantomptrandClaude Opus 5.5 fe3e088381 engine: destructive routes and pairing require the console they act on
Twelve routes (write-bytes, app lifecycle/register, sdk patch/restore,
unmount, chmod, copy, cleanup, health fix, SMP image-rw, profile activate)
and four pairing routes still fell back to the default console when addr
was missing. They now answer 400. An addr with an empty host (":1234")
also counts as missing. Every client caller already sends addr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:47:35 -07:00
phantomptrandClaude Opus 5.5 5d50108823 engine: keep collection ids, covers, trash and organizer moves inside their folders
A package's titleId and an imported library index both became file paths
unchecked. Ids must now be a bare Game ID, an import drops games with a bad
id and re-derives covers, a cover is served only by a bare file name, trash
takes only paths strictly inside a current local root (and de-duplicates),
and organizer moves refuse any path that leaves their folder. The undo log
is rewritten as a run goes and undo replays moves a crash left unrecorded;
same-named folders get their own undo logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:44:16 -07:00
phantomptrandClaude Opus 5.5 0365905819 Game page: Close game keeps to its own console, and old finished jobs don't trigger re-reads
Close game rebuilt the running set from whatever the store held, which can
be another console's, and published it under this console. It now starts
from this console's set only. The finished-jobs baseline was taken while
the view was still loading, so jobs that matched only once the game's
copies were known looked new and re-read the consoles; the baseline is now
every job already done when the page opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:43:41 -07:00
phantomptrandClaude Opus 5.5 f2fe77e9fe Helper send: a vanished send record is not a success unless the helper answers
sendHelperAndWait treated a missing record as "the helper came up", but a
dismissed failure leaves no record either, so setup went on as if the
helper were running. It now asks the helper when there is no record. The
setup wizard also stops after the send when it was cancelled meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:42:32 -07:00
phantomptrandClaude Opus 5.5 70297e6e0d Processes: load once on mount, console switch and return to the window with Auto refresh off
With Auto refresh off the poll was disabled outright, so the screen sat
empty on open, and after a console switch kept showing the previous
console's processes with Kill buttons aimed at the new one. A poll with no
interval now runs once whenever a poll would start; the screen clears on a
switch and a probe still out to the old console no longer blocks the new
console's first load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:41:39 -07:00
phantomptrandClaude Opus 5.5 aba9cab57d Upload: Check again can't double the poll loop, and an unreadable file list is not refetched every poll
Pressing Check again while a status read was still out started a second
read beside it, and each rescheduled itself, so the poll ran twice from
then on. A poll in flight now makes Check again a no-op. A job file list
the engine can't give was asked for again on every 500 ms poll; it now
backs off (2 s, 4 s, 8 s...) and stops after MAX_FILES_FETCH_FAILURES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:40:37 -07:00
phantomptrandClaude Opus 5.5 6e84083abe Convert queue: keep the AMPR LZ4 asset-pack choice of the game added from the screen
The queue built every image without LZ4 packs even when the checked game
had them turned on. The image build now carries the choice (only for the
game the screen inspected, as an .exfat of a libSceAmpr title, the same
condition as the screen's own button) and the runner passes it to the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:39:23 -07:00
phantomptrandClaude Opus 5.5 da4797bcdc Running engine jobs: stop offering this tab's own jobs as orphans with Cancel
In the browser the banner only knew the upload queue and the one-shot
upload, so a Files download or upload, a Convert or image build, a RAR
extract or a link download running in this same tab showed up as an
orphan. Every follower polls through jobStatus, so a poll now claims the
job for the tab; Files downloads and the Convert pipeline are also claimed
from their stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:38:38 -07:00
phantomptrandClaude Opus 5.5 6f8cea0ed6 Files download: a failed status read no longer ends the download in the UI
The engine keeps downloading whether or not this tab can read the job, so
one dropped poll used to leave a running job with no banner and no Cancel.
Failed reads are now counted like the upload poller does: after
MAX_POLL_FAILURES the banner says contact is lost and polling continues at
the slower pace; only an engine that no longer knows the job ends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:37:48 -07:00
phantomptrandClaude Opus 5.5 d8cac94000 Running apps: never publish an empty running set on a failed read, and drop a reply for the previous console
The browser build has no app.db route, so every 5 s poll joined against
nothing and cleared every Playing badge and Close game button. Titles now
come from app.db when it answers, else from the process list, and nothing
is published when neither does. A reply for a console the user has since
switched away from is dropped instead of showing its rows with buttons that
act on the new console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:36:07 -07:00
phantomptrandClaude Opus 5.5 7efd3b994a i18n: translate the 120 texts that were allowlisted as English, and empty the allowlist
Every key is now translated in all 21 languages, and a missing translation fails the gate again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 12:31:48 -07:00
phantomptrandClaude Opus 5.5 1d47c778f5 7.0.0: a new look for the whole app, and fixes that make it faster and sturdier
Not tagged or published yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:36:53 -07:00
phantomptrandClaude Opus 5.5 8868bf7111 i18n: drop the 15 keys the redesign left unused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:29:19 -07:00
phantomptrandClaude Opus 5.5 bf21821123 i18n: translate the 7.0 texts into every language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:28:21 -07:00
phantomptr 0c09ce1664 Merge branch 'sdk-v044' into makeover 2026-10-10 11:26:14 -07:00
phantomptrandClaude Opus 5.5 6ed3a701fb Pollers and dates: Processes and power status use the shared poller, dates in the app's language, a first-run queue loads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:24:14 -07:00
phantomptr a289ea3f0c Merge branch 'p2-files' into makeover 2026-10-10 11:20:28 -07:00
phantomptrandClaude Opus 5.5 f0409a28b0 Install link: the chosen method's dot uses the accent fill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:17:44 -07:00
phantomptrandClaude Opus 5.5 da4abf91af Sense restyle: Upload, Files, Install, Convert, Disk usage, Volumes, Search, Servers, Connection
- Upload: drop zone is a soft dashed glass well with an icon orb; queue rows
  are glass rows with tinted edges for active/failed; a failed upload row says
  what happened in plain words and has its own Retry.
- Files: drive and sort chips use the shared chip style (no solid accent
  block); roomier glass file rows in the sans face; pill breadcrumb bar.
- Selectable cards (Convert compression, image format, link install method,
  Move to / Send to destinations, Upload choices) share one glass card style
  with an accent ring when chosen; compression levels use icons, not emoji.
- Disk usage treemap uses tints of the theme's palette instead of fixed blue.
- Volumes cards are glass with the shared progress bar; Connection steps no
  longer wear a green frame when done.
- Raw inputs/selects use the shared .input/.select fields; hard tone frames
  become tinted edges; buttons are pills.
- Error cards on Files, Volumes, Search, Disk usage, Servers, Local image and
  Upload preflight get a plain-language message and Retry where it applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:15:46 -07:00
phantomptrandClaude Opus 5.5 8d05f60a7e PS5 Payload SDK v0.44, and scripts/update-ps5-sdk.sh to bump it
Pin v0.44 (sha256 4d06eab7...). Its crt/ and prospero-nid are identical
to v0.43, so the vendored sdk-crt (+ ps5upload.patch) and the portable
prospero-nid stay as they are; the helper, installer and elfldr build to
the same code (only the build-id differs). v0.44 adds LLVM 23 to the
candidate list, so the Makefile comment and Homebrew fallbacks move too.

scripts/update-ps5-sdk.sh (make update-sdk): finds the latest release
(or --tag), checks the zip, rewrites the pin, re-vendors prospero-nid
and crt only when upstream changed them (stops if our crt patch no
longer applies), moves the "SDK vX.Y" doc mentions and installs with
install-ps5-sdk.sh, keeping the old SDK as a backup. --check only
reports. The installer names its backups after the release they hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:14:44 -07:00
phantomptr 2777d0e6b8 Merge branch 'p2-tools' into makeover 2026-10-10 11:14:14 -07:00
phantomptrandClaude Opus 5.5 7c03d393ad Games screens: glass modals and pill controls in the remaining panels
Game files' detail/move/mount dialogs stand on the float surface (they
were translucent surface-2 over the scrim), and the notes, inputs and
small buttons in Game files, ShadowMount+, the game page copy rows and
the Collection modals move to the rounded glass/pill language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:11:12 -07:00
phantomptrandClaude Opus 5.5 d1d7060005 Chips for the remaining toggles; phone fixes for Tasks, Captures, Bug report
- Bug report's chips, Hardware's fan presets, Settings' retention picker
  use the shared chip; Send payload's path field is a standard input.
- Bordered rows built in template strings get the field radius.
- Phone: Tasks history rows are glass with only running/failed edges
  coloured; missed reminders and screenshot rows wrap their actions under
  the text; Bug report's action bar sits just above the tab bar.
- Hardware metric values no longer wrap mid-number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:10:28 -07:00
phantomptrandClaude Opus 5.5 64fc9d5d59 Games makeover: framed covers, chip tabs, game page hero, Home recent games
- components/GameIconFrame: the reference's white cover frame (glass rim at
  night), caption (title over a muted meta line) and tags that sit on art.
- Collection: framed cover grid, chip filters, glass stats; Ready to play:
  framed cards, chips for the play-time sort/filter, pill search, a glass
  kstuff line (soft token instead of an opacity modifier) and Now playing.
- Games: chip tabs through the shared Tabs, same ?tab= contract.
- Game page: glass hero with the framed cover, big title and fact chips;
  coral-circle section headings; pill controls in the copy rows.
- Game files: glass rows with framed thumbnails, pill search; the running
  apps panel humanises its error.
- Game Activity: shared Tabs, framed icons; Processes: glass rows.
- Home: a Recent games row from the Collection (newest first, chips per
  console that was read, Not installed), hidden when the Collection is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:06:32 -07:00
phantomptrandClaude Opus 5.5 b41f76240a Makeover sweep across the tools screens: glass panels, pills, chips
- Hardware: every panel is a glass card with an icon disc header, metric
  rows with the hint under the value, and more room between cards.
- Notifications: unread rows are raised glass with a coral dot instead of a
  solid accent tint; the unread bar matches.
- Settings: text size and theme pickers are chips/glass pills; Backup
  presets and Profile's Crop/Fit are chips; the roster's Save/Add are pills.
- Captures: the count sits beside the description.
- Changelog/FAQ: a wrapped bullet stays one list item (was split into a
  stray paragraph).
- Sweep of my screens, components and layout: old rounded-md/lg bordered
  boxes become glass panels or radius tokens, small buttons become pills,
  single-line fields are glass pills; undefined colour tokens
  (--color-error/-danger/-surface-1) and Tailwind palette colours replaced
  with theme tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 11:03:21 -07:00
phantomptrandClaude Opus 5.5 fc67febd7f Link downloads: follow a duplicate, Resume/Replace a taken name; Convert shows its target
- Engine: /api/pkg/remote/download/start takes `existing: resume|replace` for a
  different file already under the download's name (resume moves it to the
  .partial, whose tail is checked against the link; replace deletes it), and
  its 409 carries the file's path and sizes. A live download of the same name
  is answered before anything is taken over.
- Client starts link downloads over the engine API (lib/linkDownload) so the
  409s keep their fields: a duplicate attaches to the running download, and
  Install Package asks Resume (when the file is shorter than the link) or
  Replace instead of the old 'Move or delete it first'.
- Convert & install shows 'Will install on {console}' while it runs; 'Show in
  folder' is hidden where no file manager can open it and says so when
  opening fails.
- Install Package says a refresh is held for the running install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:59:27 -07:00
phantomptrandClaude Opus 5.5 b5f5e6dfc1 Shared look: chip tabs everywhere, readable status callouts, quieter focus, frosted scrims
- Callout: tinted glass with normal-colour text and a tone-coloured icon
  disc; ErrorCard/SuccessCard/WarningCard are now Callout wrappers, and
  ErrorCard humanizes engine strings and takes onRetry.
- TabbedShell (Logs, Payloads) and the package viewer use chip tabs; Tasks'
  Tasks/History/Stats switch is the shared Tabs instead of a solid accent bar.
- Focus: .input/.select keep their own focus style without the extra ring;
  the command palette's search row lights up instead of a heavy outline.
- Overlays share a frosted .scrim; Cheats' repo browser and the capture
  previews use the shared Modal; Bug report's action bar and the audit log
  header are frosted glass.
- Tasks lists missed scheduled reminders with Run now / Dismiss.
- Retry on list errors across Hardware, Health, Cheats, Saves, Profile,
  FW spoof, Backup, Remote Play; Logs' Open logs folder reports failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:58:39 -07:00
phantomptrandClaude Opus 5.5 fc16bf47c1 Upload/Convert/Queue: survive missed polls, honest cancels and notices, one IEC size unit
- One-shot upload: a failed status poll no longer fails a transfer the engine
  is still sending. After several in a row it shows lost contact (phase stays
  running, so nothing starts a competing upload) and keeps polling the same
  job; Check again re-attaches now. A failed upload gets a Retry.
- Upload screen subscribes to the phase kind only; the live numbers re-render
  just the status card. QueuePanel and RunningEngineJobs are memoized, and the
  latter no longer re-renders on every progress tick.
- RunningEngineJobs Cancel waits for the engine and keeps the banner with the
  error when the cancel failed.
- Queue Clear says what it did: a running upload was stopped; a running
  install keeps going.
- Delete-source-after-upload failures raise a warning instead of vanishing.
- Convert & install installs on the console chosen at start (or stops with the
  package kept when that console left the roster); giving up on polling stops
  the engine job, or says it may still be running.
- Pkg library Refresh during an install is held and run when it ends.
- Sizes in Upload/Files/Install/Convert/Volumes/First run go through
  formatBytes (IEC); the bandwidth cap is labelled MiB/s like the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:51:29 -07:00
phantomptrandClaude Opus 5.5 6f823594c3 Client: shared usePoll, batched engine log, schedule catch-up, capture delete, formatDate, kernel log windowing
- lib/usePoll: one poll primitive that never overlaps itself and pauses
  while the window is hidden or a transfer to that console runs; Hardware
  (power/storage, PS5 clock, SMP meta), Notifications and the kernel log use it.
- Engine log bridge appends a whole tick in one store update; the log store
  keeps an error counter so Sidebar/More stop re-filtering 500 lines.
- Schedules fire whatever came due since the last tick (15 min catch-up),
  and record later ones as missed instead of silently skipping the day.
- Screenshots / Video clips: Delete for the selection (confirm, engine
  delete like Files, thumbnail too), Retry on list errors, previews in the
  shared Modal.
- formatDate: dates follow the app language.
- Kernel log renders only the rows on screen (fixed-height windowing).
- Logs: Open logs folder shows its error. Settings and Connection load lazily;
  the vendor-react chunk matches exact package names.
- Modal reads onClose through a ref so an inline arrow doesn't re-grab focus
  on every render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:50:55 -07:00
phantomptrandClaude Opus 5.5 a288865814 Games: lighter polling, one running-games key, stale-page guard, staged pkg install
- Ready to play: the running-games poll skips while the window is hidden
  (and catches up when shown), keeps the old map when nothing changed, looks
  backport rows up in a map, and memoises the cards.
- Running-apps store: every writer is keyed by the bare host (the panel wrote
  host:port, the rest bare host); readers go through runningOn/useRunningTitleIds.
- Game files: a row's param.json read is cached per console + path for two
  minutes, so showing the screen again does not re-read every folder.
- Game page: the view is tagged with its title id; a late reply for a game the
  page already left is dropped.
- Game page Updates/Add-ons: staged packages get an Install/Reinstall button
  that queues through the package library, asking first when the base game is
  not on the console.
- Collection: narrow store selectors (the scan counters live in their own
  line), memoised cards, and covers versioned per game instead of per scan.
- Scan/read error cards on both Games tabs get Try again and a readable reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:50:20 -07:00
phantomptr c238d04b45 Merge branch 'main' into makeover 2026-10-10 10:36:48 -07:00
phantomptrandClaude Opus 5.5 0c4329e662 Dark tiles: a deeper fade under the label; dyslexia font reaches the app
The dark photo tiles' status line measured 2.1-3.5:1 over the brightest
photos; the fade under the label now keeps it above 5.9:1. The dyslexia
font was set on body while #root set the app font itself, so it never
showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:33:40 -07:00
phantomptrandClaude Opus 5.5 5f93bd74ec Makeover polish: banners, palette, colour fixes, phone Home
Banners (engine down, pairing, helper version, Android) and the update bar
float as tinted glass instead of full-width strips. The command palette
gets pill rows with a soft accent highlight. Text on the warn/good game
badges and the selected bug-report capture uses the theme's contrast colour
instead of fixed black or white. On a phone the Recent activity heading
keeps its width and the chips wrap under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:31:13 -07:00
phantomptrandClaude Opus 5.5 154b195c5c Destructive routes: the addr check returns an Option (clippy result_large_err)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:28:01 -07:00
phantomptrandClaude Opus 5.5 d509f83de2 CSS build: keep Tailwind's nested @supports through the layer flattening
Tailwind nests the colour-mix() of every colour-with-opacity utility
(bg-[var(--color-good)]/5 and the like) in an @supports block inside the
rule, and postcss-cascade-layers drops at-rules nested that way. Only the
full-strength fallback was left. While the colour tokens sat in @theme
Tailwind had resolved those at build time (to the dark theme's colours);
on plain custom properties they painted solid. A small PostCSS step now
hoists such at-rules out beside their rule first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:26:57 -07:00
phantomptrandClaude Opus 5.5 77f5364069 Engine sizes in IEC units from one formatter
The link-copy space check said 'GB' for 10^9 bytes while the collection export and
the health checks said 'GB' for 2^30. ps5upload_core::units::iec_bytes is now the
one formatter (KiB, MiB, GiB), used by all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:25:02 -07:00
phantomptrandClaude Opus 5.5 abda7b524c Engine: disk I/O off async workers, quieter log tail, per-console failure dedup, addr required for destructive routes
- Artwork cache reads/writes run on the blocking pool; the cache size is tracked
  instead of walking the folder on every write; concurrent writes of one entry use
  unique temp names.
- Server-folder copies write on the blocking pool and fail on a failed sync.
- Debug/trace lines keep their own 500-line ring, so per-request and per-range
  lines no longer push warnings out of the log tail; the pkg-host per-range line is
  debug.
- Repeated-failure collapsing is keyed by route and console.
- Delete, move, unregister, process kill, power, user delete, backup restore/delete
  and cheat delete answer 400 without an addr instead of acting on the default
  console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:23:27 -07:00
phantomptrandClaude Opus 5.5 78b3fb7fbf Home: framed photo tiles and a greeting panel
The main actions (Upload, Install, Games, Files, Saves, Convert) are framed
photo tiles: a thick white frame, a soft generated photo in the reference
palette (CSS only, distinct per tile), the label bottom-left and a white
round arrow. Beside them, a greeting panel with the orb and a time-of-day
hello carries the console status, metrics and live sensors that were in
the hero and the Console status card. Recent activity gets filter chips;
power, health, notifications and servers follow as glass cards. Nothing
that Home showed before is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:22:10 -07:00
phantomptrandClaude Opus 5.5 00234f8541 Shell: one glass canvas with pill navigation
Desktop: the app is a large rounded glass canvas floating on the
atmosphere, the sidebar inside it: the orb and wordmark on top, the console
picker as a glass pill, small uppercase section captions, and pill nav
items (the active one a white pill with a coral icon). The content's top
bar holds Back/Forward and the console tabs as pills, and on the right a
search pill that opens the command palette plus white round buttons for
notifications and the theme. The status line sits quietly under the canvas.

Phones: the tab bar is a floating glass pill and More is restyled to match.

Every function, test id, aria label and shortcut is kept; notifications
moved from the sidebar footer to the top bar (the inbox gained an opening
direction and a round-button variant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:22:10 -07:00
phantomptrandClaude Opus 5.5 39e4e19458 Design system: the Sense look (atmosphere, glass, Poppins, pills)
New light and dark token sets: every surface is translucent glass over a
fixed atmospheric wash (peach, rose and lavender by day; aubergine and
indigo with ember and violet glows by night), with bright white edges,
large radii, warm shadows and one coral accent. Body text keeps >= 4.5:1
on the glass in both modes (numbers in index.css).

The colour tokens moved out of Tailwind's @theme onto :root: Tailwind
inlined @theme values into arbitrary color-mix() classes at build time,
which is what painted dark bands into the light theme.

High contrast makes the glass opaque and drops the blur; Reduced motion and
prefers-reduced-transparency drop the blur too.

Poppins 400-700 is bundled (@fontsource/poppins), with the system stack
behind it so scripts it lacks fall back per glyph.

Primitives follow: pill buttons (coral primary with a glow, white glass
secondary), glass cards and fields, chip-row tabs and segmented controls,
coral toggle, tinted-edge callouts, coral-to-pink progress, glass modals,
drawers, menus and toasts, rows-on-glass tables, a big bold page title, and
the Orb brand mark (also in empty states).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:22:01 -07:00
phantomptrandClaude Opus 5.5 99e7db4a56 Theme: two modes, light and dark
OLED and Rose are gone. A stored or mirrored "oled" reads as dark and
"rose" as light (normalizeTheme), and the stored key is rewritten once so
it only ever holds one of the two. The theme toggle flips between them and
the Settings picker shows two options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:22:01 -07:00
phantomptrandClaude Opus 5.5 f3cc4175d9 Collection console read: list update/DLC folders once, keep details a failed read missed
Each title cost about nine console calls (two listings per drive plus an
unfiltered appinfo query). The drives' user/patch and user/addcont folders are now
listed once per read and shared; a title's DLC folder is listed only when it has
one; appinfo asks for APP_VER and CONTENT_VERSION only. A listing or version query
that fails (not one that is absent) marks the detail unread, and the snapshot keeps
what the last read found instead of recording 'no update, no DLC'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:18:37 -07:00
phantomptrandClaude Opus 5.5 7f62056adf Link downloads write <name>.partial, resume, and refuse a duplicate
A killed download left a short file under the real name, so every retry got 409
'Move or delete it first', and two downloads of one link wrote one file. Bytes now
land in <name>.partial and are renamed when complete; a retry resumes from the
partial after re-checking its tail against the origin (a mismatch starts over); a
file already complete is reported done; a second live download of the same file is
refused with the first one's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:15:53 -07:00
phantomptrandClaude Opus 5.5 e204b9805f Install slot: released on a panic, poison-safe locks, pruned maps, IPv6 key
A panic in an install task left its console 'busy' until the engine restarted, and
a poisoned lock broke installs on every console. The task now holds a guard that
fails the job and frees the slot; every JobStore lock recovers from poisoning;
finish forgets the stop request and keeps the newest 200 finished statuses. The
per-console key uses the shared host normalisation, so IPv6 addresses no longer
collapse to a prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:14:09 -07:00
phantomptrandClaude Opus 5.5 9840b00180 FPKG build, compress, exFAT build and unpack jobs fail instead of hanging on a panic
Each job's blocking work now holds the drop guard transfers use (a panic marks the
job failed) plus guards that stop its ticker and free the build's partial file or
the unpack's folder and active-set entry. The tickers skip building events nobody
listens to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:12:01 -07:00
phantomptrandClaude Opus 5.5 7ccd963956 Job snapshots carry a file count; the file list moves to GET /api/jobs/{id}/files
The 500 ms poll, the 5 s job list and every 200 ms progress event carried the
whole planned file list (megabytes on a big folder), cloned under the jobs lock
each time. The snapshot now has files_count; the client reads the list once per
job. Events are not built when nobody subscribes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 10:09:55 -07:00
phantomptrandClaude Opus 5.5 ac6c704da9 Servers list: an unexpected engine reply shows no servers instead of breaking Home
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 09:57:26 -07:00
phantomptrandClaude Opus 5.5 3ad3063790 i18n: translate the AMPR LZ4 option
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 09:55:13 -07:00
phantomptr 380184e104 Merge branch 'worktree-agent-a1e55c07f9a697874' 2026-10-10 09:50:37 -07:00
phantomptrandClaude Opus 5.5 7f724d3399 6.8.2: every screen gone through, dead code removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 09:50:01 -07:00
phantomptrandClaude Opus 5.5 d42f60b936 i18n: translate the clean-up's new texts into every language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 09:37:32 -07:00
phantomptrandClaude Opus 5.5 92ccc4d559 Engine: drop routes the app no longer calls
clear-slot, users/list, remoteplay/devices, tmdb/fetch, bps inspect/apply, pkg install cancel,
appinfo/set, time/state get/set, pkg/parse and the fan-curve routes lost their last caller with
today's client clean-up; their handlers, types and the core code only they used go too, as does
the search-index module only tests used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 09:34:10 -07:00
phantomptrandClaude Opus 5.5 15e0889ada i18n: drop 415 keys nothing uses any more, from every language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:54:21 -07:00
phantomptrandClaude Opus 5.5 b05d12da95 fpkg: builder fixes from the review
- DLC content class: a DLC's param.json names neither applicationCategoryType
  nor applicationDrmType (all seven DLC packages read, four publishers), and
  its container is 0x21 / 0x0a020000. content_class gave it the game pair.
- The flat layout refuses a u2c delta past a byte instead of clamping it at
  255, which sent the console's read to the wrong record (the block layout
  already refused). Two scale tests built such trees with the flat writer;
  they now use larger files, or the block layout.
- A build's .partial is removed on every way out but success: an I/O error in
  the in-memory writer, a failed rename, a panic. A separate Kraken spool is
  removed the same way, and its name carries the process and a count, so two
  builds of one content id into different folders no longer share (and
  truncate) one spool.
- The in-memory writer has the flat layout only; a block-layout request (the
  default) goes to the streaming writer instead of being written flat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:52:47 -07:00
phantomptrandClaude Opus 5.5 f7c578ebcc Client: delete superseded helpers and exports only their tests used
The engine's unified install now owns DPI bring-up and the unreachable-
stream diagnosis (REASON_GUIDANCE plus the engine's hint), so
lib/dpiUnavailable.ts, streamUnreachableMessage/originOf and the
engine-blind hint were no longer reachable. redactDiagnosticText is
replaced by lib/redaction.ts, effectiveMount is inlined where the
Library reads mounts, and the per-console queue reorders within its
group. Also drops test-only exports (loaderAddr, portOf, rarFolderName,
resolveCheatName, fetchJobSummaries, firmwareMajor, recentProblems,
fwToSdkHex/PS5_FIRMWARES, normalizeTitleId, onTaskStatusChange) and
store members only tests read. useStaleHostGuard now reuses
captureHostProbe instead of duplicating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:48:06 -07:00
phantomptrandClaude Opus 5.5 69c51489e2 Client: declare postcss as a dev dependency
cssLayers.test.ts imports it directly; it was only installed as a
transitive dependency. Same version as already locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:48:06 -07:00
phantomptrandClaude Opus 5.5 57ddd70b20 Game page: link another console's queue to /tasks directly
/activity now redirects to /tasks, so the link took a redirect hop and
the e2e check for the /activity URL raced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:48:05 -07:00
phantomptrandClaude Opus 5.5 798669435d Client: remove unused files, components and exports
Unused files (src/types.ts, state/backStack.ts, lib/pollUntilReady.ts,
scripts/split-i18n.mjs); components only the barrel re-exported
(ContextMenu, Spotlight, Sheet, PullToRefresh, Tooltip, RadioGroup,
IconButton, Breadcrumb), the desktop TabRail and ActiveTaskCount; unused
default exports; and exports or store actions nothing reads
(convert queue cancelCurrent, activity runningEntries, among others).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:47:58 -07:00
phantomptrandClaude Opus 5.5 4039168353 Desktop: drop API wrappers and Tauri commands nothing calls; remove shell and fs plugins
Wrappers with no caller (profile clear-slot, user list, fs search index,
USB port power, direct pkg mount, UFS fsck, LWFS mount, Remote Play
devices, BPS inspect/apply, install history/cancel, TMDB fetch, fan
curve, net speed test) go with their Tauri commands, browser shim cases
and coverage entries, plus never-invoked pkg_metadata, ps5_appinfo_set
and ps5_time_state_get/set. The Android multicast lock is internal to
discovery and no longer registered as a command.

Links open through the opener plugin and file I/O goes through our own
commands, so tauri-plugin-shell and tauri-plugin-fs (and their
capabilities) are unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:47:51 -07:00
phantomptrandClaude Opus 5.5 afd7b8417a Convert: AMPR LZ4 asset packs option for a game image
For a game whose check reports a libSceAmpr title (inspection.ampr_packs),
the .exfat image choice offers 'AMPR LZ4 asset packs (smaller, for
ShadowMount+)'. It turns .ffpfsc compression off, can take a profile .toml in
drakmor's format, and is shown disabled with the engine's reason when the
game's own ampr_emu cannot serve packs. The run shows a 'Pack LZ4 assets'
stage before the image's own. The option is per game, so the queue does not
carry it.

The Tauri command and the browser mapping pass ampr_lz4 through to
/api/exfat/build; client/src-tauri/Cargo.lock follows the fpkg crate's new
dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:41:00 -07:00
phantomptrandClaude Opus 5.5 b88c638abc Convert: refuse LZ4 packs a game's ampr_emu cannot serve
Packs are read only by ampr_emu 0.4+ built with pack support; an older one
would find every packed file missing. The game's fakelib/libSceAmpr.sprx is
recognised by its own strings (the AMPRPAK4 magic, ampr_assets.index and its
'(c) Drakmor' version), and a title that does not import libSceAmpr, ships no
runtime, or ships one without pack support is refused before a job starts
and again in the build. No runtime binary is shipped or swapped in.

The inspection reports it as ampr_packs {runtime_version, refusal} for
libSceAmpr titles, which the Convert screen uses to offer the option.

Checked by hand against real modules: Lazy_AMPR's bundled 0.4.2.1 is
recognised as pack-capable; a dump's older ampr_emu (AMPRIDX3 only) is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:34:13 -07:00
phantomptrandClaude Opus 5.5 09722e4546 Convert: LZ4 asset packs inside an exFAT game image
POST /api/exfat/build takes ampr_lz4 {level, block_kib, profile_toml}. The
game's files are packed first (ampr_pack::image::prepare) into a spool beside
the output, and the unchanged image writer is handed an overlay of the folder:
the loose files, a regenerated ampr_emu.index listing every original file,
the manifest with its CRC and runtime sidecars, and the volumes, without the
packed originals. Folders packing empties stay as empty folders. The image is
read back and hash-checked as before; the spool is removed afterwards, and a
stale one is swept like a stale .partial.

exFAT only and not with .ffpfsc: the packs are already compressed. A test
writes such an image, reads it back through the exFAT reader and decodes every
packed file through the pack reader to its source bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:31:46 -07:00
phantomptrandClaude Opus 5.5 4314fd630e fpkg: cross-check the AMPR pack port against drakmor's tool
Fixtures written by drakmor's ampr_pack.py and build_ampr_index.py for a small
tree (regenerated in the test from the same formulas), with the two profiles
they were given. The tests check that our ampr_emu.index is byte-identical,
that our reader decodes both of their pack sets through their CRC sidecars,
that an all-store profile gives their exact bytes, and that a compress profile
plans the same records, codecs and volumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:25:17 -07:00
phantomptr 4cfa010816 Merge branch 'worktree-agent-aad05e9c2bd87f77a' 2026-10-10 06:24:37 -07:00
phantomptrandClaude Opus 5.5 7a5608c6a9 Remove dead engine/payload code and the FTX2 migration shims
Engine: drop the unused /api/fakelibs/manifest, /api/collection/summary and
/api/ps5/content-db/backup routes, the payload FTP client (core ftp.rs, FTP_START/STATUS)
and NOTIF_SEND, and pub items only their own tests called. The DPI reason strings now come
from the payload_lifecycle constants everywhere.

Shims: the legacy helper replace flow (engine legacy_helper/legacy_guard, the helper/state
and helper/replace routes, the status-error fold), the client's helper_old banner/state and
the payload's legacy takeover and retired-folder removal are gone. An old helper that does
not speak AVA1 now reads as no helper, and the normal send-the-helper flow replaces it. The
6.0 console folder cleanup now starts from a successful status probe.

Payload: remove the FTP server and its self-tests, notif.send, ftp.start/ftp.status
(ids 119, 120, 123 retired in the schema), the unused ShellUI sensor/install RPCs,
runtime_reconcile_mounts, register_services_init, hw_temps_get_text and
mgmt_call_fs_mkdir. MGMT_METHODS.md describes the current method table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:24:09 -07:00
phantomptrandClaude Opus 5.5 7199b2fae2 Remove unused repo files and stale FTX2 doc notes
Deletes tests/install-fallback-hw.mjs, the mobile audit probe and its allowlist,
scripts/requirements.txt, tests/lab/verify-pending.sh, bench/baselines/ and client/icons/
(nothing references them; the bundle icons live in client/src-tauri/icons). audit-scripts
lists fpkg-diff.py as a manual research tool and no longer counts a script naming itself as
a reference. SPEC.md no longer says zip/7z/RAR sources fall back to FTX2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:24:09 -07:00
phantomptrandClaude Opus 5.5 821b5b5695 Desktop: drop ten Tauri commands nothing invokes
screenshot_{delete,clear,open_dir}, crash_reports_{stats,dir_resolved,clear,open_dir} and
diag_log_{read_window,stats,clear} were registered but never called from the renderer (no
invoke, template string or browserInvoke mapping). The multicast lock pair stays: it is used
internally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:24:09 -07:00
phantomptrandClaude Opus 5.5 2648010d09 fpkg: AMPR seekable LZ4 asset packs (ampr_pack)
A port of drakmor/ampr_emu's packer (tools/ampr_pack.py and
ampr_pack_format.py, GPL-3): the AMPRPAK4 manifest, AMPRDAT3 volumes,
AMPRCRC1 chunk-CRC sidecar and AMPRCFG1 runtime settings, with a reader that
validates what ampr_emu validates and decodes files chunk by chunk.

The planner follows the original: drakmor's TOML profile with the same keys,
defaults and checks, fnmatch-style rules where the last match wins, auto-loose
sampling of large incompressible files, balanced/hash/round-robin lanes,
page-safe placement (small chunks share an I/O page but never cross one),
raw fallback, per-lane block dedup, volume rollover, and the same build id, so
an all-store profile gives byte-identical output to drakmor's tool.

LZ4 is raw blocks: lz4_flex for fast mode, and a hash-chain encoder written
here for the hc levels, since lz4_flex has none. A built-in profile packs
everything inside a folder and keeps executables, system and backport folders,
AMPR's own files, text configuration and already-compressed media loose; the
exclusion list was informed by Nazky's Lazy_AMPR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:23:51 -07:00
phantomptrandClaude Opus 5.5 aabb6bfba9 Convert: always regenerate ampr_emu.index from the packaged files
A dump's own ampr_emu.index described the folder as dumped, so a package
carried it stale after the param.json rewrite, executable repairs, left-out
files and the icons the container takes. ampr_emu trusts its sizes. The
builder now drops the dump's copy and generates one from the final file list
(keystone included), and two paths differing only in case stop the build with
both named instead of a progress line.

Readiness parses the folder's index and reports a stale or unreadable one
instead of passing it unchecked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:08:42 -07:00
phantomptr c1cd88d3e8 Merge branch 'worktree-agent-adea6a47913d6f0a2'
# Conflicts:
#	client/src/layout/TabNav.tsx
2026-10-10 06:04:37 -07:00
phantomptrandClaude Opus 5.5 830cd43322 Payloads: three different 'autoloader' things get three names
The playlist run on connect is 'Playlist on connect', the wizard that
writes payloads to a USB drive makes a 'USB payload stick', and the
catalogue's WebKit Autoloader installer is the 'Browser payload menu'
(upstream name kept in brackets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 06:02:48 -07:00
phantomptrandClaude Opus 5.5 901fa0aec8 Payloads: ShadowMount+ and nanoDNS tabs in the browser build
Those two tabs only read and write console files through the engine
(smp_status, fs_read_preview, fs_write_bytes_run), so the web UI now
gets them instead of a redirect to Connection; Catalog and Send stay
desktop-only, as does the palette's Send entry. Drops the unreachable
standalone nanoDNS layout and SmpPanel's never-used hide default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:58:58 -07:00
phantomptr 0a03ca963a Merge branch 'worktree-agent-a0e31cac0c1936d24' 2026-10-10 05:58:05 -07:00
phantomptrandClaude Opus 5.5 caa6341706 Payload catalogue: translated notifications that name the route taken
The cached/sent/failed notifications were hardcoded English, and 'sent'
always said :9021 even when payload_send fell back to Payload Manager on
:8084; sendPayload now reports that. The curated count leaves out repos
the user added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:55:49 -07:00
phantomptrandClaude Opus 5.5 5169c5f285 Error advice: no FTP, today's screen names
err_appinst_not_initialized, err_bgft_not_loadable and
err_install_enoent_generic told people to FTP the package and register it
from the Library; err_launch_unknown and err_appinst_drm_type pointed at
the Library too. They now name the helper update, Stream & install, the
PS5's own Package Installer, and Games -> Game files. The Upload done
card's Open Library button opens Games -> Game files and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:55:05 -07:00
phantomptrandClaude Opus 5.5 325255dfd4 Disk usage says what it measures; Volumes fallback matches en
There is no recursive size op (no du in the engine, payload or the AVA1
mgmt methods), and the screen sums only the files directly inside each
folder, so the description now says that instead of promising a folder is
sized by everything it holds. The Volumes header's inline fallback still
named Library -> Mount; it now matches the en.ts text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:52:31 -07:00
phantomptrandClaude Opus 5.5 85c2f56041 Send payload: block unreadable or empty files, one handler per drop
payload_probe_read_error and payload_probe_too_small fell through to
'Payload file looks OK' with Send enabled; they now block with the
reason. A drop on the playlists' zone no longer also replaces the Send
form's file. The tab text says the port follows the file type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:52:04 -07:00
phantomptrandClaude Opus 5.5 9405535e54 Edit image on this computer: honest file types, macOS/Linux desktop only
The subtitle promised .ffpkg, which no desktop OS mounts; the picker takes
.exfat/.img/.image/.raw (the engine's looks_like_image), and now the
subtitle, empty state and picker share one list. The engine attaches with
hdiutil/udisksctl, so the screen is hidden (nav, palette, More) and its
route redirects on Windows, Android and in the browser build, where it
would attach on the engine's server. NavItem gains macOrLinuxOnly and a
navItemOffered helper. Renamed to say it works on this computer, and the
subtitle points on-console edits at Games -> Game files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:51:29 -07:00
phantomptrandClaude Opus 5.5 f078573361 Tests: Shell is desktop-only, Fan Curve redirects to Console
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:48:35 -07:00
phantomptr df135d610f Merge branch 'worktree-agent-a9db31e037ff8e82d' 2026-10-10 05:48:34 -07:00
phantomptr bc61cbf508 Merge branch 'worktree-agent-ae78a2ba2c14c296e' 2026-10-10 05:48:33 -07:00
phantomptrandClaude Opus 5.5 118c0185df Firmware spoof check: one name in the sidebar and on the screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:48:21 -07:00
phantomptrandClaude Opus 5.5 321a432734 Profile and Remote Play: browser-safe confirms, one place to sign in
Profile used plugin-dialog's confirm, which throws in the browser; it now
uses the app's dialog. 'Open Payloads' only shows where Payloads opens.
Remote Play's account check points to Profile (which activates accounts)
instead of saying ps5upload can't, and its np-fake-signin card links to
Profile instead of repeating the manual recipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:47:55 -07:00
phantomptrandClaude Opus 5.5 a963e7e7b7 Settings: honest wording, platform gating, and regrouped controls
- Reset names everything it deletes (pairings, Collection, saved servers
  and passwords, backport libraries, install history, jobs, events, logs);
  the browser build says it only clears this browser's storage.
- Remove Color blind palette, Screen reader hints and Density: nothing
  read them. Old stored keys are ignored on load. Motion and dyslexia
  hints say what the CSS actually does.
- Browser build hides Engine URL, keep computer awake, the settings file,
  OS notifications and rest-mode reconnect, and replaces the update
  panel with a note to update the engine/Docker image. Android hides the
  engine URL.
- Drop the PS5 power tick reminder; stored ones are dropped on load.
- New Console, Installs, Files & saves and Backporting groups; Beta only
  when something is in beta; no Bug report card (pinned in the sidebar).
- Backport library Remove asks first; the scan dialog from Settings no
  longer claims 0 titles on this console.
- Upload speed limit: same value and wording in Settings and on the
  Upload screen, decimals in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:47:49 -07:00
phantomptrandClaude Opus 5.5 f45879d329 Upload speed limit reaches Files uploads and single-file uploads
Files-screen uploads never passed the limit, and no single-file upload did
(transfer_file had no field for it, though the engine accepts one). Pass
the stored limit to every job start, read as each job starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:47:42 -07:00
phantomptr 3f72a73cf4 Merge branch 'worktree-agent-abcb549b06adb0295'
# Conflicts:
#	client/src/App.tsx
#	client/src/layout/TabNav.tsx
2026-10-10 05:47:40 -07:00
phantomptrandClaude Opus 5.5 1eb332a3da Game Activity: drop Recently Played, say where the numbers come from
Recently Played was the installed apps from app.db in title-id order,
with no time data at all. The subtitle claimed everything came from the
console's own records while the default Tracked tab is the helper's
process watcher. Reset play time clears only that tracked table, so it
now shows only on that tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:46:29 -07:00
phantomptrandClaude Opus 5.5 506eb33a3c FAQ, README: drop screens that are gone, one prerequisite list
Remove the FTP Server, SMB Browser and Game Metadata answers and the
"optional FTP server" claims (no such screen; the README said the same).
Rewrite the cross-device rename answer around Move to… and Shell, and
describe the Connections screen in place of the SMB Browser. Library and
Installed references now name the Games screen tabs. The two
before-install payload lists said different things; there is now one,
matching Install Package's own list. Mention the Tasks screen's History
and Stats tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:46:12 -07:00
phantomptrandClaude Opus 5.5 8b0463707b Install/Upload: drop the 'install replaces the payload' block
An install no longer touches the helper: the installer daemon loads beside
it as a companion. The Upload screen's extra install check was redundant
(installs run as console-queue items, so queueRunning already covers them)
and its comment wrong. The Install Package row's installDisabled was
hard-wired to false, so its busy tooltip (pkglib.install.busyHint) could
never show; the dead prop and tooltip are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:46:03 -07:00
phantomptrandClaude Opus 5.5 a391ff4b92 Processes: confirm before killing a payload
A payload kill went through on one click; killing kstuff or etaHEN takes
away what they provide until they are sent again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:44:21 -07:00
phantomptrandClaude Opus 5.5 b7b1521f31 Cheats: Refresh only re-reads the list; re-apply is its own button
Refresh called cheats_reload, which re-applies patches and posts on the
TV, and always toasted 'reloaded for the running game'. Refresh now just
lists; 'Re-apply to running game' shows while a game runs, and its toast
follows the status read back. Search examples use a PS5 title id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:43:52 -07:00
phantomptrandClaude Opus 5.5 41d6e10c37 Tasks, Stats, Audit log, Logs, Notifications, About, FAQ: say what they do
Tasks (/tasks): drop the Telemetry view (a copy of Hardware, mostly blank
on retail firmware); fold Timeline into History (same data); Stats becomes
a tab (?tab=stats, /stats redirects, nav item removed). The History tab has
its own label instead of the changelog's "Full history". Clear history and
Clear running act on the console being shown, not every console. A running
row's button says what it really does: Cancel for uploads (queue too), FS
ops and op-id ops; Stop watching for downloads; none when nothing can stop.

Stats: count upload-queue bytes as uploaded, rank only network transfers as
fastest (no on-console copies or installs), scope to the shown console, and
say the totals cover the last 100 operations.

Audit log: also record File System / library deletes, unregisters,
uninstalls and installs; describe it truthfully (a 256-entry local ring).

Logs: hide the recording level in the browser build (no disk log there);
the kernel category explainer described a default-hidden set that is empty.
Health: label the engine's network category. Notifications: both texts now
say it lists ps5upload's own TV messages. Changelog: the whole file is
bundled. About: tiles for the current features, payload sending only where
it works. FAQ: Copy link only in the browser build, under the served base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:45 -07:00
phantomptrandClaude Opus 5.5 49a7017a50 Save data: truthful USB tooltips, one USB menu per row, browser-aware text
The USB actions copy the save to this computer, zip it and send it back,
not 'without leaving the PS5'. The two USB buttons per row are one menu,
and the browser build no longer promises zip backups it cannot make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:34 -07:00
phantomptrandClaude Opus 5.5 02b2ee8e85 Games: tell the two disk-image sections apart
Game files lists image files; Ready to play lists registered games that
run from an image. Both were titled Disk images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:25 -07:00
phantomptrandClaude Opus 5.5 cfc8ec468c Home, Search, Servers and tab navigation follow the real screens
- Home: the servers card renders once, as its own grid row (it sat
  inside the empty-state box, so it showed 0-2 times); the duplicate
  "Connect a server" tile is gone; tile status text is translated;
  Recent activity links /tasks.
- /activity redirects to /tasks keeping query and hash, so the sidebar
  highlights it; ActivitySummary and job rows link real routes, not the
  /file-system, /installed and /library redirects.
- Search: each result opens File System at its folder (?path=), and the
  export notifications are translated.
- TabNav builds its groups from navItems' sections (plus redirects), so
  Search, Backup and Edit Game Image light the tab their sidebar section
  belongs to.
- "Connections" is now "Servers" in the nav and the screen title, with
  FTPS named in the subtitle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:06 -07:00
phantomptrandClaude Opus 5.5 d0d46aa571 Bring-up and setup wizard send the helper through sendHelperTo
Quick bring-up and the first-run wizard sent the helper with a raw
sendPayload and polled payloadCheck, skipping the shared send that joins
a send already under way and opens pairing for an unpaired console, so a
first run on an unpaired console timed out instead of pairing. Both now
use sendHelperAndWait (sendHelperTo, waiting out a joined send).

Connection: Quick bring-up is hidden in the browser (its playlists send
ELFs from this machine); power controls show whenever an address is set
so Wake is there when the console does not answer; the companion button
says "Open Payloads", opens the Catalog and is hidden in the browser;
step 3 and the three helper-sending entry points describe the current
screens and when to use which. First-run notes are translated, its done
notification opens /games, and its text no longer points at a Settings
entry that does not exist. en.ts also carries the new keys for Home,
Search and Servers (next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:06 -07:00
phantomptrandClaude Opus 5.5 fb7cc97c72 Game page: Close game on the console row, fewer tabs, one play-time source
- Media, Storage and Play Time tabs only repeated Overview or linked to
  Captures; Overview now carries a Screenshots & clips link instead.
- The header Launch did not say which console it started the game on;
  the per-console Play stays, and turns into Close game (killGame, with
  the SIGKILL fallback) while the game runs on the connected console.
- The game page, Ready to play and Game files read play time from the
  helper's tracker (what Game Activity's Tracked tab shows) through
  lib/trackedPlay, falling back to the app's own count only when the
  helper has no tracker. Stored formats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:04 -07:00
phantomptrandClaude Opus 5.5 3e5fb36a2c Files: download Stop cancels the engine job
The engine already registered a cancel flag for downloads and the AVA1
download honours it, but the Stop button only stopped polling. Stop now
calls /api/jobs/{id}/cancel; the button and tooltip say Cancel. The
download job's outcome handling is split into run_download_job so a test
can drive a real AVA1 download against a folder host and check that a
cancelled job ends failed with transfer_cancelled. Also: the strings this
and the Upload commit changed in en.ts, and the install hint now points at
Games instead of Installed Apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:03 -07:00
phantomptrandClaude Opus 5.5 6846c5194e Upload: Stop cancels the job; drop the typed-path From another PS5 card
The running upload's Stop only stopped watching (reset); it now calls the
store's cancel, and the tooltip says so. The done hint and register-after
text point at Games -> Game files instead of the retired Library. The
typed-path PS5-to-PS5 card is gone from Upload: File System's Send to
another console plans and checks the copy properly; a one-line pointer
replaces the button. The queue's ps5 source and the engine route stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:42:03 -07:00
phantomptrandClaude Opus 5.5 44629498d8 Console snapshots: say they stay on the PS5, and show the format's limits
The Backup screen said files were copied to this computer; snapshots are
written to /data/ps5upload/backups on the console. Renamed so it isn't
confused with save backups, and the keep-5, depth-8 and path-flattening
limits of payload/src/backup.c are stated (pinned to the source by a test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:40:43 -07:00
phantomptrandClaude Opus 5.5 ea1cda588f Console tools: drop Fan Curve, speed test and kernel-log copy; honest Hardware text
Fan Curve redirects to /console: the console takes one fan target, so the
curve's other points did nothing. Hardware's fan presets were named
backwards (55 C is the loud one) and Balanced claimed to match Sony's
default (91 C on 13.60); applying now asks first. The speed test was
latency only and repeated Health; the kernel log links to Logs > Kernel.
Disc controls hide on the original Digital Edition. Captures has one
header, and the browser build can download captures (the engine writes
them to a folder on its machine). Shell is desktop-only in nav and route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:39:25 -07:00
phantomptrandClaude Opus 5.5 fc931de814 AVA1 upload speed cap applies to the whole job, not each lane
lane_task paced each lane to the cap on its own, so a job on N lanes (up
to 8) sent N times the cap. One Pacer per job now hands out the byte
budget to every lane, and an idle stretch no longer banks a burst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:36:56 -07:00
phantomptrandClaude Opus 5.5 80cf659de1 Running apps: Kill uses the same close as Close game
RunningAppsPanel called appKill alone, which throws on FW 12.20, so Kill
did nothing there. killGame now looks the pid up by app id when the
caller has none and SIGKILLs it, and both screens use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:35:51 -07:00
phantomptrandClaude Opus 5.5 fcdd24832d Games: drop the dead embedded prop from Library and InstalledApps
Games is their only importer and always passed embedded, so the
standalone page-header branches never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:33:58 -07:00
phantomptrandClaude Opus 5.5 39bace23a2 Route /dashboard to Home and build the palette from the nav (completes 7a9d2767)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:30:32 -07:00
phantomptrandClaude Opus 5.5 7a9d2767dd Remove the Dashboard screen; command palette lists the sidebar's screens
Dashboard was a smaller copy of Home with no nav entry; /dashboard now goes to Home. The
palette's hand-written list had drifted (a dead Dashboard entry, "Library", "Hardware",
"Transfer Log" for renamed screens, many screens missing): it now lists the same screens as the
sidebar, with the same names and visibility, plus old names as search words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:30:22 -07:00
phantomptrandClaude Opus 5.5 f3be9602ac Sidebar: Bug report pinned under Home, outside the sections, never hidden
Moved out of Diagnostics: it sits right under Home in the sidebar and first in More, and has
no hide switch. A copy hidden by an older version still shows and isn't counted as hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 05:07:29 -07:00
phantomptrandClaude Opus 5.5 7a9afd3615 6.8.1: Convert's queue builds the picked image
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 04:38:46 -07:00
phantomptrandClaude Opus 5.5 24fb0a33b6 Convert queue: build the image that was picked, not always a package
Picking a game image (.ffpkg/.exfat/.ffpfs, or compressed .ffpfsc) and queueing
the game still built a .pkg: queue items had no notion of an image. Each item now
carries what to build. The queue has a Make choice (package or game image), and
touching the image options under ③ switches it to that image. Rows show the
file each game becomes. A queued image is kept or sent through the Upload
queue; a source that isn't a local game folder is skipped with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 04:38:45 -07:00
phantomptrandClaude Opus 5.5 df204a4208 6.8.0: send files from one console to another
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 23:13:55 -07:00
phantomptrandClaude Opus 5.5 784765cd2a Console to console: straight between the consoles, else through this computer (#433, step 2)
The sending console now dials the receiving one and pushes the job itself.
The receiving console hands out a one-job ticket (c2c.allow) for the sender's
key, and the sender shows it in its handshake (c2c.send). The receiver takes it
as an ordinary upload into the ticket's one root. Each side holds the other to
that one job: no calls, no other job, no other root.

The engine tries the direct route first and falls back to its relay when the
consoles can't connect, a helper is older, or the direct send stops. The job on
the receiving console is the engine's own under the same id, so either route
resumes what the other left. The queue row shows the route ("Straight between
the consoles", Beta) or "Through this computer" and why.

Verified on two PS5s (FW 13.60): 842 MB / 401 files direct in about 11 s, both
directions; a cancel then resume; checksums identical. Spec: SPEC.md §18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 23:13:55 -07:00
phantomptrandClaude Opus 5.5 8cd95f2d12 Files: Send to another console (#433, step 1)
Select files and folders in Files and choose Send to another console…
(toolbar or right-click; shown when the roster has another console). The
dialog picks the console and a folder on it (its drives, a browser that
starts at the source's folder), and says before anything runs whether it
fits and which names it replaces. Each item becomes a queue item on the
receiving console (new source kind ps5: progress, Stop, Retry, resume by
its tx id), its row reading From <console>. It runs on the engine's
console-to-console relay; the direct route follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 22:28:43 -07:00
phantomptrandClaude Opus 5.5 7ecb99d53c 6.7.4: one helper send per console
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 21:54:28 -07:00
phantomptrandClaude Opus 5.5 bb3b690fb6 Send helper: one send per console, so a click no longer races the automatic redeploy
The Send helper button and the automatic redeploy (queues, recovery) each
guarded only against themselves. A click that met a redeploy sent two
helpers within milliseconds (the engine log shows their loader checks
13 ms apart); the two then fight over the takeover, the new one waiting
out the old one's ports before killing it, and the app sat on "Waiting
for payload to boot" for 20-40 s while the console had shown the ELF
arrive at once. Both now go through one per-console gate: a send under
way is waited for, and the button waits for a helper sent in the last
15 s instead of sending another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 21:54:18 -07:00
phantomptrandClaude Opus 5.5 1d1f018bd6 6.7.3: no leftovers from failed conversions (#432)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 13:13:34 -07:00
phantomptrandClaude Opus 5.5 76e83568c2 Convert: a failed or interrupted conversion leaves no leftovers (#432)
Image builds and compressions wrote <name>.partial into the output folder
and nothing tracked them: one interrupted (app closed, engine restarted)
stayed for good, and on Windows the single delete after a failure often
lost to an antivirus or the indexer holding the just-written file, so the
whole image stayed on the system drive. Every build now marks its partial
as being written; each conversion sweeps the output folder of build
partials and unpack folders no running job owns, before it writes and
after it fails, retrying a delete for a few seconds. Only the app's own
.pkg/.ffpfsc/.ffpfs/.exfat/.ffpkg partials are touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 12:58:19 -07:00
phantomptrandClaude Opus 5.5 106baa2d6d 6.7.2: Collection covers in the desktop app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 12:25:09 -07:00
phantomptrandClaude Opus 5.5 d116b740b1 Collection covers come over the IPC when the window's own load is refused
6.7.1 allowed the cover route through the engine's cross-site guard only
for an image request with a trusted Referer, but the desktop window's
WebKit sends neither Origin nor Referer for it, so every cover was still
refused (the engine log shows the 403s). Covers now use useImageRetry
with an IPC fallback like the console icons: a collection_cover_data
command reads the cover from the engine natively and hands it back as a
data: URL, kept for the session. The game page's header uses the same
cover component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 12:25:02 -07:00
phantomptrandClaude Opus 5.5 beb4e6583c 6.7.1: Collection covers, multi-part RAR resume (#429), a full drive's partial file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 11:33:05 -07:00
phantomptrandClaude Opus 5.5 d98065d6c9 Helper: a new helper leaves no other copy of itself running
A new helper already asked the running one to exit, killed its recorded
pid if it would not, and swept by name only when both failed. A copy the
handshake never sees (one with no ownership record, or a crashed one that
kept no port) went on running beside the new one. After a clean takeover
the helper now waits out the predecessor's exit window (65 s, past its
60 s watchdog ceiling, so a journal flush is never cut short) and then
ends any other process of ours, unless it was itself asked to exit
meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 11:31:18 -07:00
phantomptrandClaude Opus 5.5 5f93259bf5 Fixes from issues and Discord: a full drive's partial file, stopping an install early, 0x80B21104, VPN
- A single-file upload that fills the drive keeps <file>.ava-part so
  Retry resumes it, but nothing said so: the failed row now names it and
  offers Delete partial file (the next attempt starts fresh).
- Stopping a stream install before the engine had its job dropped the
  row and left the install running unseen; Stop now waits for the job
  and stops it.
- 0x80B21104 explains that the console will not take this package and
  links its FAQ entry, instead of the generic PlayGo text.
- The Connection screen's "port not open" step suggests turning a VPN
  off (reported twice on Discord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 11:29:37 -07:00
phantomptrandClaude Opus 5.5 7fd748cb03 RAR: resuming past a file split across volumes no longer fails as reordered (#429)
A resume skips the entries already uploaded. Skipping a file that
continues in the next volume left its continuation to be read as one
more entry, so the walk fell out of step with the listing and the upload
failed with "the archive lists its entries in a different order than it
extracts them". A continuation header is now skipped with the file it
belongs to. Reproduced with a two-volume RAR5 set in the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 11:23:50 -07:00
phantomptrandClaude Opus 5.5 0b3b565f14 Fix Collection covers refused by the engine, and "no cheat files found" shown as an error
The engine's cross-site guard let the app's image requests through only
for the console icon routes, so every Collection cover
(/api/collection/games/<id>/cover) was refused: a game with no online
cover showed its initials and the game page a broken image. That
read-only route is allowed now, and the game page's cover falls back to
the console's icon if it still fails.

A title with no cheats is an empty list, not a red error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 11:19:40 -07:00
phantomptrandClaude Opus 5.5 105357afc6 Keep port numbers out of the snapshot tests (check-no-ftx2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 10:08:45 -07:00
phantomptrandClaude Opus 5.5 d5f3f2f206 6.7.0: one page per game, every console and every copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:30:33 -07:00
phantomptrandClaude Opus 5.5 6a0e97b529 Game page review fixes: act on the fresh read, forget removed consoles, re-read after a job
- An install after a re-read queues what the console still lacks, or
  says it has it already (it queued the stale choice).
- The installed list counts as complete only when the internal app
  folder listing answered in full; a failed listing no longer marks
  app.db-only titles not installed.
- Both engine reads count a title's app folder on any drive, so a game on
  the M.2 drive no longer flips between installed and not.
- The summary shows only consoles the app has; the engine is told on
  every game page open, and when the last console is removed.
- A finished send or install re-reads the console it ran on.
- Snapshot writes in refresh and keep run off the async workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:30:19 -07:00
phantomptrandClaude Opus 5.5 a3c0d32728 client: browser tests for the game page; tabs say which section is current
e2e: open a game from the Collection (consoles with their age, copies,
Back), install on the non-connected console (its row shows it, Open goes
to that console's Activity), and Send to PS5 from the game's page. The
tab bar uses Link: a NavLink's own path match dropped aria-current for
Collection, Saves and the other screens a tab covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:22:44 -07:00
phantomptrandClaude Opus 5.5 0d7c61b743 client: Activity for another console, clearer names, translations
Activity takes ?console=<ip> (where Open queue goes for a job on another
console). "Open library" says Open Games; Install Package calls sidebar
screens screens, not tabs. The Games tab stays lit in Collection on a
phone. Every new string is in all 21 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:14:54 -07:00
phantomptrandClaude Opus 5.5 ee879e2127 client: every game links to its page
Ready to play and Game files titles, Install Package's package menu,
Upload's picked game, and the command palette (Collection games, when
searching) open /games/:title_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:10:24 -07:00
phantomptrandClaude Opus 5.5 98946c9701 client: the game's tabs say which console they show; updates and DLC from the drives
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:07:50 -07:00
phantomptrandClaude Opus 5.5 42f2cb34de client: the game page shows every console and every copy
Overview gains a Consoles card (each saved console's state, its age, a
refresh, and Install / Update / DLC / Send / Play for that console with
its own progress) and an On your drives card (the Collection's copy rows).
Collection opens the game page instead of its own detail panel, whose
parts moved to screens/GameHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:07:01 -07:00
phantomptrandClaude Opus 5.5 cc9fc56557 client: game view API and the game page's rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 09:00:10 -07:00
phantomptrandClaude Opus 5.5 e648b7c30a engine: remember what each console has; one game across consoles and drives
console_snapshot keeps, per console IP and title, what the last read found
(installed, version, update, DLC) and when, in state/console-snapshots.json.
The Collection's console check and the installed-apps list fill it; a
complete installed list marks titles that left as not installed.

GET /api/games/{id} joins it with the Collection (copies, and per console
what the drives could bring it); POST /api/games/{id}/refresh reads one
title now; POST /api/console-snapshots/keep forgets removed consoles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 08:58:30 -07:00
phantomptrandClaude Opus 5.5 3ff7723da5 Collection: progress links go to Install Package, not Home
"Progress shows in Install Package" and the queued notification linked to
/install, which is not a page, so they landed on Home. A test now checks
every fixed in-app link against the routes App.tsx declares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 07:46:46 -07:00
phantomptrandClaude Opus 5.5 60bb7d2a21 6.6.3: test housekeeping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 03:35:45 -07:00
phantomptrandClaude Opus 5.5 c6bb6a9a66 ava1-ctest: scratch dirs remove themselves
Every test's temp dir is a TempDir that is removed on drop, whether the
test passed, failed or panicked, so a run leaves nothing in $TMPDIR.
AVA1_KEEP_TMP=1 keeps them for a post-mortem. Rigs hold the dir after
the server so the server stops first. 284 tests pass on macOS and Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 03:35:40 -07:00
phantomptrandClaude Opus 5.5 6425f0685b 6.6.2: Windows engine start (#427), one game to two consoles, desktop bug reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 02:54:16 -07:00
phantomptrandClaude Opus 5.5 c030d6647b Fix desktop bug reports, and the same package or game for two consoles
- Every desktop report failed with "Duplicate filename: README.txt": the
  bundle skips a name it already wrote, and screenshots are numbered.
- The engine refused a stream install of a package another console was
  still pulling; the rival check is now per console.
- The Convert queue refused the same game for a second console, and an
  image built for one console showed as busy on the others.
- Bug report: a console switch no longer writes one console's draft over
  another's, a created report's draft stays cleared, and a failed
  Download zip says so.
- Engine: a browser upload that goes away mid-way no longer leaves the
  partial package for a week; a failed link download no longer blocks its
  retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 02:53:52 -07:00
phantomptrandClaude Opus 5.5 2711a25521 Desktop: report an engine that dies at startup, and fix the Windows orphan reaper
A crash during startup was taken from the slot by the stderr watcher, so
the readiness loop waited out 30 s and reported a timeout. The cmd.exe
reaper's quotes were escaped into a broken findstr line, and a cold
PowerShell often needed more than its 2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 02:47:19 -07:00
phantomptrandClaude Opus 5.5 db68e3ef16 Fix sending one game to two consoles, and the engine not starting on Windows (#427)
The Collection treated a game on its way to one PS5 as busy for every
console, so it could not be sent to a second one. Activity is now per
console.

On Windows, an older engine still running (or an antivirus scan) locked
ps5upload-engine.exe, so the new engine could not be written and the app
never connected. It now falls back to a hash-named copy, and the startup
error keeps its cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 02:44:54 -07:00
phantomptrandClaude Opus 5.5 d4dab2bdc5 6.6.1: Bug report page, event timeline, multi-console fixes, dependency updates
- Bug report: one page, everything in the chosen time frame, captures to
  pick from, always redacted; GitHub / Discord / zip.
- Engine and app keep a timeline; helper log mirrored from each PS5.
- Console switches no longer land results on the wrong console.
- Buy me a coffee on Home.
- Dependency updates (#420-#425), chacha20poly1305 0.11 port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 00:29:49 -07:00
phantomptr 234881ea4e chore(deps): Bump chacha20poly1305 from 0.10.1 to 0.11.0 in /engine (#424) 2026-10-09 00:29:27 -07:00
phantomptr 05cc0476bf chore(deps): Bump blake2 from 0.10.6 to 0.11.0 in /engine (#421) 2026-10-09 00:29:26 -07:00
phantomptr d031e9bf89 chore(deps): Bump russh from 0.63.3 to 0.64.1 in /engine (#423) 2026-10-09 00:29:24 -07:00
phantomptr 0102b1a2c3 chore(deps): Bump the frontend group in /client with 13 updates (#422) 2026-10-09 00:29:23 -07:00
phantomptrandClaude Opus 5.5 aae5ed4104 home: Buy me a coffee under the connection badge
Right-aligned under the badge on a wide window, under the title on a
phone. The ko-fi link now lives in one place for Home and About.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 00:18:46 -07:00
dependabot[bot] 516a56ccfe chore(deps): Bump the engine group in /engine with 4 updates (#420)
Bumps the engine group in /engine with 4 updates: [tokio](https://github.com/tokio-rs/tokio), [uuid](https://github.com/uuid-rs/uuid), [cc](https://github.com/rust-lang/cc-rs) and [libc](https://github.com/rust-lang/libc).


Updates `tokio` from 1.53.1 to 1.53.2
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.1...tokio-1.53.2)

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

Updates `cc` from 1.4.0 to 1.6.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.4.0...cc-v1.6.0)

Updates `libc` from 0.2.189 to 0.2.190
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.190/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.189...0.2.190)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.53.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: engine
- dependency-name: uuid
  dependency-version: 1.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: engine
- dependency-name: cc
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: engine
- dependency-name: libc
  dependency-version: 0.2.190
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: engine
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 00:18:37 -07:00
phantomptrandClaude Opus 5.5 6a3e7ffa3a bug report: one page, everything in the time frame, captures to pick from
The four-step wizard hid its Next button off-screen (no page gutter, a long
problem line widened the page) and asked people to tick log sources and
problems. Now one page: what happened, screenshots, the setup (filled in),
and only how far back the logs go; everything recorded in that window goes
in, always redacted. A sticky bar says what still blocks the report and
holds Create report. A panel up top says where reports go, with GitHub and
Discord buttons.

Screenshots: the desktop page says where the Capture button is (status
bar, bottom right), lists every capture to pick from, and adds a capture
taken while the report is open. Any other image can be added, dropped or
pasted. Checkboxes no longer shrink beside a long label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-09 00:12:34 -07:00
PhantomPtrandClaude Opus 5.5 9a6b5571a7 6.6.0: Bug report wizard, event timeline, multi-console fixes, faster builds
- Bug report wizard (4 steps, time range, GitHub/Discord/zip), redacted on both sides.
- Engine event journal + app journal merged into one timeline; helper stderr mirrored from each PS5.
- Results that finish after a console switch land on the console they started on.
- Faster local gate and CI (nextest, parallel lanes, sharded C interop/ASan, cache restore/save).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-08 23:31:25 -07:00
phantomptrandClaude Opus 5.5 62a87f6161 6.5.2: helper crash on the Hardware screen fixed (#417), New folder in Files' right-click menu
- Power telemetry reads the four ICC getters under the hw fault guard with
  oversized zeroed buffers; a crash now _exit()s so the AVA1 port is freed
  instead of the process wedging in the core dump.
- Files: New folder on a row's menu and a folder menu on the list's empty
  space (New folder, Paste here, Refresh).
- Payloads: "Send file" tab is "Send payload".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-08 19:35:38 -07:00
phantomptrandClaude Opus 5.5 ad933f4e56 6.5.1: Collection Send to PS5, stop a running install, Let another device pair
- Collection: Send to PS5 queues and starts the copy (folder, image or
  archive), with progress on the card and in the details.
- Queue: a running install can be stopped; a limited run starts only its items.
- Connection: Let another device pair; a refused :9021 says to load elfldr.
- Console screens no longer freeze each other's scrolling.
- Docker: map /data so pairing survives a container recreate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM5Gzyt1uxKyJiWZAgd1w9
2026-10-08 11:38:58 -07:00
phantomptrandClaude Opus 5.5 0d254e5433 6.5.0: Collection, Move to, Android on Android 17, helper on FW 5.50, web UI pairing
New:
- Collection: every game in your folders (local or on a NAS, scanned in
  place), grouped with updates and DLC, what each PS5 has, one-click
  installs, install links, organize / de-duplicate / clean up. Replaces PS
  Game Library (with an importer).
- Send a game folder as one image (.ffpfsc / .ffpkg / .exfat); Convert
  writes .ffpkg and .ffpfs too.
- Files: Move to... (instant or cross-drive, with a fit check) and Set
  permissions (777).
- Offline PSN sign-in from Profile.

Fixed:
- Android 17: local network permission (6.x could not connect).
- FW 5.50: own build of the SDK startup code, so the helper, installer and
  elfldr start.
- Web UI: Send helper (pairs it); no "ready" while unpaired.
- Screenshots decode in a child process (Linux crash loop).
- Installs no longer start queued uploads.
- Cross-drive moves check the copy before removing the original.
- Finished installs show as done after a reload; engine-unreachable
  explained with Restart engine; Health starts the installer.
- Dev: make run-client renders on macOS 27.

Better: "declined" installs explain Sony's code and how far it got;
0x80B21104 explained; Connection failures readable; keep-awake for all
installs; Games cards. Translations, FAQ, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM5Gzyt1uxKyJiWZAgd1w9
2026-10-08 09:31:54 -07:00
phantomptrandClaude Opus 5.5 95e1e1aea7 6.4.0: Install tabs, named links, archives, game images, Health on Home, speed test, FAQ viewer
- Install: Stream & install and Upload & install are separate tabs; the link card shows what
  each way asks, with its own confirmation; links can be named; Skip the certificate check
  only where this computer connects, and it now takes effect.
- Links: "Stream through this computer" proxies through the engine; PS5 packages from a
  link are recognised as packages.
- Archives: packages from .zip, .7z and .rar (multi-part, password), from files or links.
- Convert Games: a game folder can be written as an .exfat or .ffpfsc game image.
- Health: network and port checks, speed test, wider clean-up, health line on Home.
- FAQ: section tabs, topics, ranked search, links to an answer. FAQ and README updated.
- Captures: screenshots and videos in one screen. Payloads under Setup. Plainer wording.
- Helper: the takeover wait no longer ends up to 1 ms early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:51:22 -07:00
phantomptrandClaude Opus 5.5 95f09e3189 6.3.1: 0x80B2116F fixed, console tabs keep their screens, Kept by ps5upload
- Fix 0x80B2116F ("The PS5 declined the install"): Sony reads 8 bytes past
  the six-pointer MetaInfo we handed sceAppInstUtilInstallByPackage; ours
  held stack leftovers there, which sent full packages down the patch path.
  Every Upload & install was refused and Stream installs failed 3 in 5 once
  the daemon had made a few on-console attempts. Installer daemon 1.3.9.
  Measured on FW 13.60 on two consoles: refused every time before, accepted
  every time after (3.6 GiB base over itself, and its patch).
- Each console keeps its own tree of screens across a console switch.
- Volumes: "Kept by ps5upload" with clean-up for temp and test files.
- Every Play button closes a running game first.
- Games: resume a failed Download; try a stopped or failed Move again.
- Pairing: after Resend helper, wait for the console to accept.
- Remote Play: unlocking the PS5's own setting with np-fake-signin v1.4.
- The scroll lock takes the scroll root that is on show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:24:13 -07:00
phantomptrandClaude Opus 5.5 6ea0f6d315 6.3.0: screens keep their state, resumable Files copies, storage that adds up
- Screens keep their state across view switches (last six kept alive, hidden,
  with no timers or console traffic while hidden).
- Files copies, link downloads, Games download/move and Send helper survive
  view and console-tab switches and show in Tasks.
- A Files copy resumes after Stop, a failure, or an app close or crash;
  dropped connections retry, and it carries on after rest mode.
- Internal storage is charged about a fifth more than is written (measured);
  Volumes and Upload show what is likely to fit and warn without blocking.
  The fixed learned gap is removed.
- Play closes a running game first instead of launching over it.
- Clearer messages for ShadowMount+ launch refusals, Mount on SMP images,
  macOS Local Network, and closed pairing (with Resend helper).
- Connections viewer returns to its folder; ps5debug-NG in Payloads; FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017efezs2aAwuUAtbVazCaUc
2026-10-07 04:10:40 -07:00
phantomptr 48901863cc 6.2.3: stabilize parallel AVA1 test 2026-10-07 00:01:37 -07:00
phantomptr 90c1a0f058 6.2.2: fan, install, convert and connection fixes 2026-10-06 23:43:06 -07:00
phantomptrandClaude Opus 5.5 f26c8f66a6 6.2.1: single-file retry resumes, honest fan setting, Sony code on a refusal
- #401: a single-file upload started again (Upload screen Start, or re-added to
  the queue) ran under a new job id, so the console's kept .ava-part was not
  credited and the space check asked for the whole file while the partial still
  took that room. The id is now remembered per (console, source, destination)
  like folders, reused on the next attempt, and forgotten on success.
- #400: the console takes one turbo temperature, not a curve. The Fan Curve
  screen says so and shows the temperature the points map to (same rule and
  bounds as payload/src/fan_map.c).
- #399: "The PS5 declined the install" dropped Sony's error code; it is shown.
- Version 6.2.1 and its changelog entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6otJV6kLjX99ZS9GpoCS1
2026-10-06 12:00:14 -07:00
PhantomPtrandClaude 79842024be ci: release-tag workflow to cut a release from GitHub (#402)
Cuts the vX.Y.Z tag scripts/release.sh would push, at the dispatched commit, for when a
release has to be cut without a local clone that can push tags. A tag pushed with
GITHUB_TOKEN does not trigger publish.yml's tag push, so the workflow dispatches it.


Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-06 11:43:26 -07:00
e62e0c4d30 6.2.0: games launch again, installs stop giving up early (#398)
* chore(deps): Bump rustls from 0.23.43 to 0.23.45 in /client/src-tauri

Bumps [rustls](https://github.com/rustls/rustls) from 0.23.43 to 0.23.45.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.43...v/0.23.45)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.45
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Install: drop the staged-refusal retry guard and the stream reach pre-check

After the PS5 refused a package from its own storage (0x80b2116f), the app
would no longer run Install on that row again and blocked Retry with Stream
unless the engine had offered it. Install and Retry with Stream now always
go out.

The engine no longer probes whether the console can reach it before handing
over a stream URL. A console that never fetches still gets the firewall /
PS5UPLOAD_PKG_HOST_IP explanation from the post-failure path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* Helper: everything written on the console is 0777

Since 6.0 an upload kept the computer's file modes, so a game folder sent
from Windows landed as 0644 files. The PS5's app loader refuses game files
without world-execute, so those games would not launch until the user ran
chmod 777 by hand. The 5.x helper forced 0777 for this reason.

Uploads, on-console copies and the file manager's writes and new folders
now land as 0777. fs_chmod still sets exactly the mode it is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* Fix launch refusals, folder permissions, abandoned upload space and overestimated free space

- A launch refused with 0x80940033 now says whether kstuff is missing; with kstuff running,
  a folder game is made 0777 and launched again. Folder games get a Fix permissions button.
- The helper's own ps5upload folders are 0777, and older ones are opened up on start.
- The helper's job cleanup also deletes the half-written copy of an abandoned upload.
- An upload that runs out of room part-way teaches the engine how much of that drive the
  console never allocates; later space checks and Volumes' safe figure leave it out.
- Queue rows that failed because the PS5 could not reach this computer offer the
  Windows firewall fix; FAQ lists the ports and the 0x80B2116F firmwares.
- lf_slots_wedge: a bound that a healthy ASan run fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* 6.2.0: games launch again, installs stop giving up early

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* i18n: add the launch-refusal and fix-permissions strings to en, allowlist them for other locales

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* ava1: a finished job counts the bytes still awaiting their Received

bytes_sent grew only on a Received the send loop read, and the loop stops at JobDone. A frame
still in flight then went uncounted although the job holds it: a list upload reported 19000 of
26000 bytes under the coverage build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* ava1: a lane stopped by its LaneDown never picks a frame afterwards

A lane task checked its stop flag only at the top of its loop. One that passed the check just
before LaneDown, and reached its pick after lane_death had swept the lane, charged a frame to
the dead lane: the frame sat in inflight with no lane to carry it, nothing queued, so the stall
rule never fired and the job waited forever (the soak test's resume hung under the coverage
build). The flag is now read again under the sched lock, which lane_death's sweeps take, so a
pick is either swept or sees the stop. The pick moves into pick_for_lane for a deterministic test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

* ci(coverage): run the engine tests once for both reports, 60 min limit

scripts/coverage.mjs ran `cargo llvm-cov --lcov` and then `--html`, each a full instrumented
test run. One pass now takes ~34 min, so the second ran into the 40 min limit and the job was
cancelled (it was red on main too). The tests now run once (--no-report) and both reports come
from that run's profiles via `cargo llvm-cov report`; the limit is 60 min for headroom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq9BzVoith72tgD87LGG2z

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-06 10:17:35 -07:00
dependabot[bot] c215f2796d chore(deps-dev): Bump source-map-js from 1.2.1 to 1.2.2 in /client (#396)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 04:12:02 -07:00
PhantomPtrandClaude Opus 5.5 4eaa6af456 6.1.2: game-folder uploads no longer freeze on the console (#395)
* fix(test): live console tests use a per-run job id

A console remembers a finished job under its id; the live perf test reused a fixed id for a
new tagged destination each run and was refused ("this job was opened for another
destination") from the second run on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkU45KbpqSUxuUeeq6L9rG

* fix(ava1): a peer's close reason wins over our racing write failure

A peer that refuses us (not paired, refused join) writes an Error and closes. Our next
write, often a ping, then fails with a broken pipe before the reader has read that Error,
and the session ended as "write failed: broken pipe" instead of the real reason. A failed
write now marks the writer dead at once and gives the reader 200 ms to deliver the peer's
reason; the first close wins. Seen as a sanitizer-only CI failure
(a_data_frame_before_pairing_is_answered_not_paired_and_closes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkU45KbpqSUxuUeeq6L9rG

* fix(payload): a large file with its root but missing chunks no longer pins a descriptor slot

Uploads of game folders froze on the console (35k-file tree: ~13k files in, then nothing).
A large file holds a descriptor slot until it commits; a job gets ~61 slots on the console
(fd budget ~490). Workers waiting for a slot close idle files to make room, but skipped any
file that had its root, assuming its commit was about to run. The root comes on the control
connection and can arrive before the file's last chunks are applied; those chunks sit in
the queue behind the waiting workers. Such a file could neither commit nor be closed, and
once a job's whole share was files like it every worker waited forever, holding the
sender's whole credit window. Only a file with its root AND every byte durable is kept open
now. Regression test freezes without the fix (2/2) and passes with it; the real folder now
uploads on both consoles (FW 13.60): 35,254 files in ~3.5 min each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 6.1.2: version and changelog; keep the fs_ops test building on both toolchains

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:22:41 -07:00
PhantomPtrandClaude Opus 5.5 1f214747dc 6.1.1: version and changelog (#392)
Claude-Session: https://claude.ai/code/session_01NkU45KbpqSUxuUeeq6L9rG

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:30:05 -07:00
77040f75bb Combined: CI toolchain fixes + lane-scaling guard + WebUI Base URL + stalled-upload recovery (#391)
* Implement PS5UPLOAD_BASE_URL environment variable

* Format engine Rust sources

* Check and remove trailing slash on VITE_BASE_URL

* Add base path to logo-square.png

* Fix Base URL 404 error

* Update Docs

* Comment Backend code

* fix(engine): clear two latent toolchain-gate failures on current stable

Both reproduce on a clean checkout with the engine CI's own commands on
current stable Rust; CI only passes today because its cargo cache carries
forward lint/compile results from an earlier toolchain, so any cache miss
or contributor build fails.

- ps5upload-fpkg `free_bytes`: `st.f_bavail as u64 * st.f_frsize as u64`
  trips clippy `unnecessary_cast` (`-D warnings`) because the statvfs fields
  are already u64 on Linux. The cast is still needed on macOS, where they
  are u32, so keep it, silence the lint on the targets where it is a no-op,
  and use `saturating_mul` so the 32-bit path cannot overflow (the bare `*`
  would panic in debug).

- ps5upload-core fs_ops test: `AtomicU8::try_update` is an unstable library
  feature (E0658) on current stable, so `cargo test -p ps5upload-core` fails
  to compile. Switch to the stable `fetch_update`; the call only inspects
  `.is_ok()`, which is true for both exactly when the closure returns `Some`,
  so behaviour is unchanged.

Verified: `cargo clippy --workspace -- -D warnings` and
`cargo test -p ps5upload-core` both pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtgLppKgEWNbr47WHg1tsu

* test(ava1): lane-scaling measurement is checked locally, not on CI

`lanes_grow_on_a_link_that_scales_with_them` asserts the governor opens a
fourth lane when extra lanes raise throughput through a per-lane cap. That
only holds at native speed: a shared CI runner cannot always give the fourth
lane the headroom to pay off (3 lanes seen on main, 4+ on the same commit in
its PR), and under coverage the instrumented sender stalls the capped 160 MiB
transfer outright. Skip the coverage run entirely and the lane-count assertion
on CI; the transfer-succeeds assertion still runs everywhere.

This is the same fix as PR #389 (identical diff), ported here so this PR's
`rust (engine workspace)` job goes green on its own; it no-ops once the base
carries either copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtgLppKgEWNbr47WHg1tsu

* fix(ava1): a stalled upload cancels its job so the destination frees for a resume

When an upload stalls fatally (no frame fits the receiver's window and nothing
is sent, received or credited for the stall limit), the sender broke with a
Protocol error but, unlike every other fatal exit (ERR_IO, ERR_VERIFY,
ERR_CANCELLED), sent the console no JobCancel. The console was never told the
job died, so it kept the job alive holding the destination. The next attempt's
JobOpen was then refused BUSY ("another transfer is writing to this
destination") until its retries ran out — the two failures a user sees back to
back: a 600 s credit stall on one attempt, then "stayed busy for 12 retries" on
the next.

Send a JobCancel (reason ERR_STALLED) before giving up, matching the other
fatal paths. The journal and the .ava-part stay, so this frees the destination
for a clean resume rather than a conflict. Extends the existing
`a_grant_smaller_than_one_group_fails_loudly_instead_of_hanging` test to assert
the receiver sees the cancel.

This does not change why a console grants a sub-group window and stops
returning credit (the stall's root cause is in the console-side AVA1 receiver);
it makes the stall tear down cleanly so a retry can recover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtgLppKgEWNbr47WHg1tsu

* test(ava1): de-flake two C-interop tests that failed CI on timing, not on bugs

The gating `rust (engine workspace)` ctest step flaked on a rotating set of
timing-sensitive tests under CI load (all pre-existing on main, none caused by
this PR). Two of them are now robust:

- `a_rate_capped_link_is_reported_as_the_network` (governor.rs): a bottleneck
  *classification* assertion, meaningful only at native speed. On a shared CI
  runner the debug sender is slower than the 8 MiB/s cap, so the link is not the
  sole bottleneck and the label describes the runner, not the code. Add the
  `CI` arm to the skip guard that already covers the sanitizers and coverage —
  the same guard its sibling `a_slow_disk_is_reported_as_the_bottleneck` already
  carries. The transfer-succeeds assertion still runs everywhere.

- `lane_killed_mid_chunk_requeues_and_completes` (upload.rs): asserts a data-lane
  kill never costs the session (`sessions == 1`). It killed "the newest
  connection" via ChaosProxy, which races onto the control connection right
  after a reconnect or before the first lane opens — dropping the session and
  failing the assertion (reproduced on clean main, ~1 in 3 locally). Add
  `ChaosProxy::kill_newest_lane`, which never targets the control connection
  (the oldest live one), and use it here. The session now survives the kill by
  construction; the assertion is unchanged and passed 8/8 locally after the fix.

Not a weakening of either test: the first keeps its transfer check and only
drops an environment-dependent label on CI (matching the existing sibling); the
second still proves a lane kill is absorbed, now without the control-connection
race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtgLppKgEWNbr47WHg1tsu

---------

Co-authored-by: GosDev <39804434+GospelBG@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 16:29:40 -07:00
PhantomPtrandClaude Opus 5.5 05684fd0c0 6.1.0: Files works like FileZilla, per-console queue, fixes (#388)
* fix(connection): a console whose key changed opens the pairing dialog, not a 20 s retry

The helper check (step 2) carried the engine's sentence "a different device answered at
this address", not the ava1_wrong_console token, so it never opened the pairing dialog:
it polled for 20 s and told the user to send the helper again, which cannot help. The
sentence now counts as a pairing problem; step 2 stops polling and opens the dialog, which
offers "Forget the old one and pair this one".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(zip): a resumed whole file is finished when the next file arrives before its commit

After a resume, a file with every byte durable but no Done is in flight with
written == size; its commit writes the descriptor. When the next file's data reached
the sink first, append refused it ("file 6 ended at 1048576 of 1048576 bytes") and the
Stored-zip download failed instead of resuming (seen on main CI). The sink now finishes
that file itself, as commit would; a late commit is a no-op. Regression test added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ci): transfer tests lift their throttle once interrupted; optimize crypto deps in dev

ava1_transfer_integration ran past the 25-minute CI step: resumes after a planned
interruption kept crawling at the test's throttle, and the crypto/hash/compression
crates are 10-50x slower unoptimized. ChaosProxy gets a live set_bytes_per_sec; the
cancel-midway and mid-stream-drop tests lift the cap once the interruption has landed.
Only those dependency crates build at opt-level 3 in dev (our own code stays
debuggable). post_commit_failure_is_not_a_resend is capped so the destination is
created while data still flows. Locally: 251 s -> 98 s for the transfer suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the slow-disk label is not checked on CI runners, where the debug sender is slower than the disk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* feat(files): add folders and drag-and-drop uploads; queue and activity follow the selected console

- Files: Add folder (uploads it whole into the open folder) and drag-and-drop of files and
  folders from the file manager, with a drop highlight. A dropped .pkg is copied there, not
  offered for install (/files now owns its drops).
- Upload's queue shows only the selected console's items (it showed every console's);
  Activity likewise, plus local-only work.
- New strings translated in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* revert(ci): drop the dev opt-level overrides for crypto crates

Lifting the throttle after the planned interruption gives most of the speedup alone
(transfer suite 251 s -> 112 s locally; 98 s with the overrides). With the overrides the
resume-after-cancel tests failed under ASan and coverage on CI only (progress never
crossed the cancel threshold before the upload ended); main passes them. Not worth the
risk for 14 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the dest-ends relay test caps its source so the destination ends it mid-transfer

On a fast runner the uncapped 128 MiB relay finished before the destination's first
durable report (which is what ends its job). The source is now read at 16 MiB/s (~8 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* feat(files): right-click menu, keyboard shortcuts, column sorting, typed path

FileZilla-style browsing on the Files screen:
- right-click a row: Open, Download, Cut, Copy, Paste into (folders), Rename, Copy path, Delete
- double-click opens; Ctrl/Cmd+C/X/V/A, Delete, F2 rename, F5 refresh, Backspace up, Enter, Esc
- sort by Name, Size or Modified (folders first)
- the path bar is editable: type a path and press Enter
Paste can target any folder now (the row menu's Paste into). Strings in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ava1): a lane that joins right after Welcome is no longer refused as an unknown session

Both servers sent Welcome before registering the session, and a client opens its lanes the
moment it is welcomed: under load a Join could beat the registration and be refused
("unknown session"), dropping the connection. The console's C server now registers the
session before Welcome goes out; the Rust server's lane waits briefly (2 s, within the join
deadline) for the registration. Seen in CI coverage (close_race, 48 concurrent sessions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* 6.1.0: version and changelog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ava1): a dropped upload job wakes its readers instead of leaking a parked thread

The sender's readers park on the read-ahead budget inside spawn_blocking; only closing it
wakes them, and that ran only on a normal exit. A job future dropped mid-job (a relay whose
destination ended, a torn-down session task) left a reader parked for good: a leaked
blocking thread that also kept its runtime from shutting down. That was the relay test that
hung CI on small runners (reproduced 3/4 on 2 CPUs in Docker; 8/8 pass with the fix). A drop
guard now stops and wakes the readers however the job ends. Regression test added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* docs(changelog): 6.1.0 lists the stopped-copy fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the launches test waits for a launch to start instead of a fixed 80 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:40:53 -07:00
PhantomPtrandClaude Opus 5.5 fd2b1269d0 docs(changelog): plain '## 6.0.0' heading so release-posts.js and the notes step find it (#382)
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:33:28 -07:00
PhantomPtrandClaude Opus 5.5 e9c377d416 fix(engine): every data path follows the engine's data folder (#379) (#381)
The artwork cache, pkg-host sessions, backport library and link-download folder
found their folder through HOME and ignored PS5UPLOAD_DATA_DIR, so on Android (no
home folder) they were disabled or unsaved. They now use the engine's data_dir()
(PS5UPLOAD_DATA_DIR, else ~/.ps5upload); each keeps its own override variable, and
desktop paths are unchanged.


Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:09:41 -07:00
PhantomPtrandClaude Opus 5.5 b55d8d35a7 fix(android): give the in-process engine a data folder (#379) (#380)
A phone has no home folder, so the engine's data directory resolved to nothing:
saved connections failed with "no home folder to keep saved connections in"
(#379), and on 6.0 the AVA1 pool would report itself unavailable, breaking every
transfer. The Android engine now gets PS5UPLOAD_DATA_DIR = the app's private data
folder before it starts (an explicit value wins).


Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:07:46 -07:00
PhantomPtrandClaude Opus 5.5 9e5c258ad8 6.0.0: CI fixes, upgrade clean-up, docs and version (#377)
* docs: 6.0.0 changelog, README, FAQ and contributor docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* Release prep: version 6.0.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ci): keep the leaked test pools reachable for LSan; skip the tiny-file floor under coverage

- ava1-ctest diag/mgmt_fs: the &'static Pool each test leaks for
  AvaTransport::with_pool is now held in a static list, so LeakSanitizer sees
  it (and the sessions on its threads) as reachable instead of reporting
  ~140 KB of indirect leaks the frame-name suppression could never match.
  Verified under clang ASan+LSan in Linux Docker: diag 13/13, mgmt_fs 40/40.
- download.rs tiny-file throughput floor is skipped when cargo-llvm-cov runs
  (CARGO_LLVM_COV set): instrumentation slows every call several times over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): data_rust servers use the clients' fast timing

Five folder-host tests gave the client fast timing (dead after 500 ms) but
left the server on the default (ping every 2 s). On a slow runner a half-second
gap made the client drop healthy lanes and the job stalled until the 36 s
progress limit (both CI runs on #374/#377). The ordered-download tests already
set both ends; these now do too. The app uses one timing on both ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the C lane socket-buffer test accepts Linux's wmem_max cap

Same cap as the Rust lane test: Linux CI gives the send side 416 KiB, so the
1 MiB floor failed under ASan in CI. 128 KiB is still far above a default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ci): gcc unused-result in the FTP selftest, serial ctest in make test, sender state on failure

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ava1): flush a partial bundle after any reader message, not only a record

The last small-file record could arrive while a large file's chunk or root
was still queued behind it: the flush saw a non-empty channel, the next
message was not a record, and the records sat unsent until the receiver
cut the job as stalled (CI-only flake; 7/36 under local parallel load, 0/48 after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(validate): run the C interop tests serially in make ci too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ava1): a resumed file goes in one-group pieces so a flapping link still makes progress

With kills every 2 s, the 4 MiB tail piece was cut before it was durable on every
attempt and the upload retried the same 28 MiB mark until it timed out (1 in 3
locally, CI coverage). Resumed files now send 1 MiB pieces: 5/5 in ~6 s.

Also: make docker-engine uses the repo root as context (as CI); governor
classification asserts are skipped under the sanitizers, where the C is the bottleneck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): single-instance gate tests hold the C-server lock; relay test cap leaves margin

Coverage runs the C tests in parallel; the two gate tests stop the one global C server.
The relay order test's 4 MiB/s cap finished in 5.9 s on CI against its >6 s precondition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): periodic-kill bound widened under coverage; tiny-file floor relaxed on CI runners

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): lane-kill test kills until a resend; periodic-kill liveness race skipped under coverage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): a hung relay fails its test at the bound instead of hanging the CI job

The dest-ends relay test ran the relay under spawn_blocking: on a hang the timeout
panicked but the runtime then waited forever for the blocking thread (27 min on CI).
It now runs on a plain thread. The engine test steps get a 25-minute bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): dest-ends relay test in its own binary on small files; relay logs its silent retries

The test hung a CI job twice inside the shared relay binary (behind the 384 MiB relays'
lock) and never reproduced locally or in Linux Docker. It now runs alone, on 4x32 MiB
(still twice the relay buffer), ~3 s. The relay's open-dropped and retry paths now say
so on stderr instead of looping silently. Governor classification asserts also skip
under coverage, where the instrumented sender is the bottleneck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the partly-durable relay test caps its first attempt so it can be interrupted

On a fast runner the uncapped relay finished all 160 MiB before the first durable
report was observed. The first attempt now reads A through a 32 MiB/s proxy (~5 s);
the resume runs uncapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* feat: one-time 6.0 upgrade clean-up that keeps every setting

Host (engine, once per data dir): pre-5.x files no 5.41/6.0 code reads (api_token,
ps5upload.ini, ps5upload_profiles.ini/.json, app-config.json) move to ~/.ps5upload/legacy-5x/.
App (desktop, once): the 1.x/2.x config.json/profiles.json move to legacy-5x/; the cached
ezremote-dpi.elf nothing loads is deleted. Client: the retired upload-streams key is dropped.
Console (once per console, on its first 6.0 helper answer): the FTX2 /data/ps5upload/tx and
/spool folders are deleted; a failure retries later. The health check no longer lists those
folders (its fix would have recreated them). Nothing current is moved or reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the download periodic-kill race is skipped under coverage, like its upload twin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): the tiny-zip floor only catches a stall on CI runners

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(test): 'other calls still answer during launches' is a state check, not a wall-clock bound

Both mgmt_t6 tests asserted a sub-300 ms answer while a slow Sony call ran; on a loaded
runner that separates nothing. They now assert the answer arrived while the slow call
was still in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* fix(ci): reword the 6.0 migration comments for check-no-ftx2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:04:11 -07:00
PhantomPtrandClaude Opus 5.5 9371ddd906 fix(ci): Windows build, Linux socket-buffer test, LSan suppression; shorter Thanks (#375)
* fix(ci): Windows build of ava1, a Linux-safe socket-buffer test, the LSan suppression for the leaked test pool

- ava1/src/verify.rs: the Windows-only read_exact_at/write_all_at reassigned
  an immutable `buf` (E0384); rebind it inside the windows block so Unix
  keeps no unused-mut. First Windows build of ava1 in CI (#374).
- session.rs sockbuf test asserted >= 1 MiB lane buffers; Linux CI caps the
  send side at wmem_max (416 KiB reported). Assert >= 128 KiB, still far above
  a stock default.
- ava1-ctest/lsan.supp: the intentionally leaked test Pool's sessions are
  allocated on its own threads, so the existing frame-name suppression never
  matched them. No payload/csrc frame leaked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

* docs(readme): shorter Thanks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8fQMhp7Rdgq2e4U9oDff

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:29:45 -07:00
PhantomPtr 5954c8adde 6.0.0: AVA1 replaces FTX2 — one encrypted connection for everything (#374)
See CHANGELOG.md (6.0.0). Full history of the work: tag ftx2-last and branch history archived in ps5upload-docs.
2026-10-05 00:25:05 -07:00
phantomptr a364f7a473 Release v5.41.0 2026-09-30 16:39:05 -07:00
PhantomPtrandClaude Opus 5.5 534772d55a 5.41.0: installs the PS5 declined, working Docker images, Payload Manager fallback (#347)
* fix: installs the PS5 declined, Docker images that work, Payload Manager as a backup loader (5.41.0)

Installs
- A package the console serves itself (Upload & install, USB, the phone app)
  is refused on FW 13.60 with 0x80B2116F (Sony's DbgGetPatchInfo/DbgCancelPatch
  path, especially over an installed app). The unified install now re-serves
  the console's copy from the engine (ps5:// read back through the helper) and
  a refused link through the engine's link proxy. HW-verified on a 13.60 Pro:
  stream, Upload & install fresh and over an installed app, USB.
- Installer 1.2.0: fixed past Last-Modified, logs to /data/ps5upload/installer.log.
- NET_REACH (frame 148/149): the console checks it can reach the engine before
  a stream install and names a firewall/isolation cause in seconds.
- Staged-route refusals get their own reason and message (staged_refused).
- Folder-game register on 13.x: sceAppInstUtilAppInstallAll fallback,
  /system_ex rw remount on EROFS, repeated uninstall for its two records.
- Removed the incorrect "no fake packages above FW 11.60" warnings.

Loaders
- itsPLK Payload Manager (:8084) fallback: starts the installer, recovers a
  wedged elfldr (HW-verified) and puts the helper back when :9021 is dead.
  Only ever uses its own ps5upload-* folders.
- Setup wizard skips payloads already running; "Send only ps5upload".

Engine / self-hosting
- Engine clients ignore proxy env vars; errors carry the real cause.
- Docker: writable /tmp and /data (also under a user: override), HEALTHCHECK
  via --healthcheck, PS5UPLOAD_ALLOW_IP takes CIDR ranges, actionable 403,
  bridge-network warning, engine/compose.yaml, scripts/docker-smoke.sh run in
  the PR gate and before every release push.
- Desktop app with a remote engine browses the engine's disk; From this device
  uploads first. Android engine listens on the LAN so the phone can stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: accept Windows' timeout as the probe's underlying cause

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: rustfmt the probe test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:38:55 -07:00
phantomptr 3de53d2fd9 Release v5.40.0 2026-09-29 22:13:19 -07:00
phantomptrandClaude Opus 5.5 ebe54d96f5 docs(changelog): 5.40.0 — FW 13.60 refresh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:47:56 -07:00
phantomptrandClaude Opus 5.5 02fa12a2de fix: zip names keep UTF-8, failed snapshots leave nothing, reopen last screen
Found during the FW 13.60 hardware sweep:

- Non-ASCII names inside a .zip were garbled on upload (名前 é → σÉìσëì ├⌐):
  zips from macOS/Linux store UTF-8 without the UTF-8 flag and the zip
  crate decodes those as CP437. Use the raw bytes when they are valid
  UTF-8 (as Info-ZIP/7-Zip do), for uploads and Convert. Verified on both
  consoles: 3001/3001 files byte- and name-identical.
- A backup snapshot that copied nothing (an empty save) still left a
  0-file entry in the backup list; the directory is now removed.
- "Reopen where you left off" never worked since the landing moved from
  What's New to Home: the landing redirect beat the restore effect. And
  the leftover /whats-new trigger hijacked opening What's New by its
  address (this is what failed the bundled-docs e2e). The landing
  redirect now reads the saved route (lib/lastRoute, tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:30:05 -07:00
phantomptrandClaude Opus 5.5 8bf98cdfdc fix(fw13.60): installed games listed, honest launch result, FPKG limits stated
Swept both consoles after the upgrade to FW 13.60 (Phat CFI-1115A,
Pro CFI-7019). Found and fixed:

- Games was empty on both consoles. The installed list came only from
  /user/appmeta/<id>, and on 13.60 apps installed by homebrew installers
  (Payload Manager, Shadow Mount+, WebKit Autoloader) have no such folder.
  The engine now adds titles app.db knows are installed, counted only when
  /user/app/<id> (or the ext0/ext1 equivalent) exists: app.db also holds
  shell entries (NPXS) and preinstalled "push resource" tiles like ASTRO's
  PLAYROOM that were never downloaded. Names and icons fall back to
  /user/app/<id>/sce_sys. Pure filter extracted + unit-tested.
- A launch the console refused (0x80940033, a homebrew app on 13.60)
  was reported as ok: the ShellUI fallback returns "dispatched". When the
  primary launcher already refused, the payload now checks the title is
  running (~6 s) and otherwise reports launch_sony_error_0x80940033.
  Verified on the Pro: before {"ok":true}, after the refusal.
- Fake-package support (kstuff FPKG, a53_ppr) stops at FW 11.60, so on
  newer firmware Install Package says so instead of listing payloads that
  don't exist for it, Convert warns before a build, Games no longer says
  "kstuff active — installs and launches are good to go", and a failed
  launch names the likely cause. fpkgUnsupportedFirmware() tested against
  the real 13.60 kernel string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:30:05 -07:00
phantomptr 3762b92ba4 Release v5.39.1 2026-09-29 01:35:37 -07:00
phantomptrandClaude Opus 5.5 52caaa0d22 fix(android): no dead-code warning, an APK that installs, honest deploy result
- stream_metadata is only called from desktop-only code, so the Android
  build warned it was never used; it now carries the same cfg.
- Debug Android builds keep line tables only (CARGO_PROFILE_DEV_DEBUG in
  ANDROID_ENV, so the desktop dev build is untouched). The app library
  was 558 MB with full debug info, stored uncompressed, making a 1.1 GB
  APK that failed to install on an emulator with
  INSTALL_FAILED_INSUFFICIENT_STORAGE. Now 231 MB / 566 MB APK; verified
  installing and starting on emulator-5554.
- On INSUFFICIENT_STORAGE on an emulator, remove the old install and
  retry; on a real device, say to free space.
- android-deploy printed "✓ deployed" unconditionally, even right after a
  failed install. The install step now reports the real outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:16:19 -07:00
phantomptrandClaude Opus 5.5 960fecf345 docs(changelog): 5.39.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:03:37 -07:00
phantomptrandClaude Opus 5.5 98388c268c fix(desktop): profile_activate accepts the account id as a string
The client sends the 64-bit account id as a string ("0x…" or decimal)
because a JS number cannot hold it exactly, and the engine accepts both
forms — but the Tauri command still declared `id: Option<u64>`, so every
activation from the desktop app failed with "invalid type: string
\"0x7a356e99a9e2205c\", expected u64". The command now passes the value
through and lets the engine parse it. Browser build unaffected (it posts
straight to the engine); its arg type is corrected to string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:03:09 -07:00
phantomptrandClaude Opus 5.5 1934dc8e9d fix(api): appInfoDetails checks ok through assertOk
The require-assert-ok lint rule failed CI on main: the function tested
res.ok by hand. A refusal now throws inside the existing try and still
yields null (no details), so behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:13:06 -07:00
phantomptr 5a28819d25 Release v5.39.0 2026-09-28 21:45:11 -07:00
phantomptrandClaude Opus 5.5 2cad667901 feat(cheats): game-first Cheats screen with covers, details and version matching; mark PS5 messages read
Cheats: the screen lists the console's games (cover, name, installed
version) grouped as playing / ready / to download / none, using the full
repo index (fetched once) to show availability per game. The detail pane
shows appinfo details (version, size, install and last-opened dates,
content id), the downloaded cheats as switches (disabled with an
explanation while the game isn't running, since the payload refuses a
toggle then), and every repo file ranked with the installed version first
and a prominent Download button. appInfoDetails() wraps /api/ps5/appinfo;
the browser shim now accepts Tauri's camelCase titleId.

Notifications: the payload ring carries no read state, so every entry was
unread forever. Read state now lives client-side per console (watermark +
exceptions) with Mark read / Mark unread / Mark all as read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:45:07 -07:00
phantomptr 05fa3178db Release v5.38.0 2026-09-28 18:13:57 -07:00
phantomptrandClaude Opus 5.5 15dd1a0c44 feat(install): each queue row carries its install's full status
The running install's phase, bytes, speed, time left and status notes now
live on its queue row instead of a page-level banner. Installers report
structured progress through InstallHooks (phase/current/total/rate) and
status notes through onStatus, which the queue stores on the item.

The queue is grouped into Now / Up next (run order) / Finished, with
summary chips, compact waiting rows, and reordering that swaps only with
the neighbour of the same install tier. The Install page drops the
duplicate stream-result card, reorders its sections, folds the FPKG
checklist, and gives link download-first its own notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:13:03 -07:00
PhantomPtrandClaude Opus 5.5 105e4aae96 chore(android): run-android reuses and foregrounds the emulator; software rendering by default (#339)
* chore(android): run-android foregrounds and reuses the emulator; faster emulator

- ensure reuses a running or still-booting emulator instead of booting a
  second; run-android brings its window to the front and reopens a headless
  one with a window; run-android-background leaves it headless.
- A stopped emulator is waited on until it has really exited (forced after
  60s), so a restart no longer mistakes it for one that is booting.
- Windowed emulators render on the host GPU instead of software, with 4 GB
  RAM and up to 6 cores.
- Android dev serves Vite on 1430/1431 (PS5UPLOAD_VITE_PORT), so it can run
  alongside desktop dev on 1420.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(android): software rendering by default; host GPU is opt-in

-gpu host froze the Android 16 image within minutes on an M1 Max (the
emulator warns its guest GL layer is unstable above API 35), with or
without Guest ANGLE. PS5UPLOAD_EMU_GPU=host opts back in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:31:42 -07:00
phantomptr 3190458798 Release v5.37.2 2026-09-28 16:47:58 -07:00
PhantomPtrandClaude Opus 5.5 e6e8601363 Files crash: reload on a missing screen chunk; make keeps node_modules; dev FAQ/changelog (#340)
* fix(app): a screen whose code is gone reloads the page instead of crashing

Screens load on demand; when a screen's file can't be fetched the
import rejects ('Importing a module script failed') and the route showed
the crash screen. That happens to a web UI tab left open across an
update (it asks for file names the new build doesn't have) and to a dev
server whose dependency cache was rebuilt under it. Reproduced by
swapping the served build under an open tab: main showed the crash
screen for Files; now the page reloads once and opens Files from the
new build. A second failure within 30 s shows the error, so an outage
can't reload forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(make): don't reinstall client dependencies that already match the lockfile

setup-client ran npm ci on every make (run-client, run-android, emu-test,
dist-*). npm ci deletes node_modules first — Vite's dependency cache
with it — so a dev server already running lost its cache and its
screens failed ('Importing a module script failed' on Files). It now
reinstalls only when package-lock.json or Node changed since the last
install (a stamp in node_modules). Checked: a make during a running dev
server leaves it working; a stale stamp still reinstalls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dev): the FAQ and What's New screens load on the dev server

They imported the repo-root FAQ.md and CHANGELOG.md with ?raw, and the
dev server served those (outside the client folder) as raw markdown
instead of modules. A small Vite plugin exposes them as virtual modules,
which go through the normal pipeline in dev and in builds (each is its
own on-demand chunk, locally and in the Docker web UI build). New e2e
test: both screens show their document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): 5.37.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:47:36 -07:00
phantomptr d8e62af42d Release v5.37.1 2026-09-28 14:55:47 -07:00
PhantomPtrandClaude Opus 5.5 6f5abad2b5 elfldr that recovers by itself; 75-minute publish builds; 5.37.1 notes (#338)
* feat(elfldr): vendor elfldr with a read deadline, embedded in the engine

elfldr serves one connection at a time and its reads had no deadline,
so a client that went silent without closing (a dropped link, a machine
or console asleep mid-send) left it running but deaf on :9021 — users
had to load elfldr again. Measured on the Phat (FW 5.10): the stock
build answered nothing while a silent connection stayed open; with a
15 s SO_RCVTIMEO it dropped that connection and answered the next
request at once, and a payload sent with 10 s pauses still loaded.

third_party/elfldr is upstream 02cfe91 plus ps5upload.patch (GPLv3,
credited), with the tested ELF checked in so every engine build embeds
it; `make elfldr` rebuilds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(elfldr): the engine swaps a console's stock elfldr for the patched one

GET /api/ps5/elfldr/health tells healthy / stuck / absent from a request
elfldr answers without running anything. POST /api/ps5/elfldr/ensure
replaces the standard elfldr.elf with the embedded patched build when it
answers and isn't the one this engine installed (a reboot or autoloader
brings the stock one back), leaves any other loader on :9021 alone, and
returns once the new one answers. On the Phat: upgraded (pid 685,
answering at once), then 'current'; the helper was untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(publish): give each client build 75 minutes

The Intel macOS build ran past the 45-minute limit for v5.37.0 (about
33 minutes for v5.35.0 and v5.36.0), which cancelled it and skipped
create-release, so v5.37.0 was never published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(engine): history tests get their own temp dirs

TmpDir named directories by pid and nanoseconds, but macOS time ticks in
microseconds: two tests starting together shared a directory and one
deleted it under the other (a_legacy_host_port_file_is_still_read read
nothing). A counter makes each name unique. The elfldr embed test reads
the embedded bytes directly instead of clearing an env var mid-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(elfldr): the app keeps the patched elfldr in place and never sends into a stuck one

Before sending the helper, the app checks the console's loader: a stuck
one (took the connection, never answered) is waited out for 20 s — the
patched build clears in 15 — and one that stays stuck is reported
plainly ('load elfldr again') instead of a socket error. Once the helper
is up, the engine swaps the stock elfldr for the patched build: at once
after a fresh helper send (a reboot or wake brings the stock one back),
else at most every 10 minutes per console. Desktop, browser and Android
share the engine routes; strings in all 20 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): 5.37.1 — elfldr recovers by itself; 5.37.0 folded in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(elfldr): swap it in when the helper is already up at launch

Only ensurePayloadCurrent asked for the patched elfldr, and a helper
that is already up when the app opens never goes through it — on the
Phat the stock elfldr stayed. The status poll's 'arrives at up' edge
(first sighting included) now asks too, after the same settle as the
log capture and never during a transfer.

Hardware (Phat FW 5.10, web build and Android app): stock elfldr
replaced ~10 s after opening; a silent :9021 connection held while the
helper was shut down — the helper came back through the patched
elfldr; with the stock elfldr wedged, the Android app sent nothing for
150 s (health: stuck), then redeployed and swapped elfldr once released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(convert): the compress stage pays for reading and writing on one thread

kraken_image::compress runs the encoder on a pool, but one thread reads
the source and writes each compressed block, so the stage takes the
longer of encoding and read+write — not the slower of reading and
encoding, as the estimate assumed. With the Windows Battlefield 6 run's
speeds the model now gives 75 min for a 71-min build.

The estimate also reports the speeds it measured (rates), because the
small read/write samples can be fooled by caches: on a source read an
hour earlier the read sample hit the file cache at 8.6 GB/s and the
estimate came out at 25 min for a 54-min build. Calibrating from past
builds is the follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): build-time estimate, stated as measured

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(docker): 25 minutes for the PR image checks

Both PR gates build the whole image (engine, and the client for the web
UI one) in about 7-10 minutes; the engine gate took 9m55s and the web UI
gate was cancelled at its 10-minute limit on PR #338.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:55:05 -07:00
PhantomPtrandClaude Opus 5.5 832e2afcd2 fix(docker): the web UI image bundles the FAQ and Changelog (#337)
* fix(docker): the web UI image bundles the FAQ and Changelog

Since the FAQ and Changelog screens bundle ../FAQ.md and ../CHANGELOG.md,
the web UI image's frontend stage failed: it built the client at /src
and .dockerignore excluded every *.md. The stage now keeps the repo
layout (/repo/client) and copies the two files in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scripts): the audit counts a script's node:test file as used

scripts:check runs every *.test.mjs by pattern, so the test beside
stale-client-lib.mjs is named nowhere and the strict audit called it an
orphan (repo scripts failed on all three OSes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:04:23 -07:00
phantomptrandClaude Opus 5.5 0bba2cbab8 fix(scripts): make run-client stops only the engine on the app's port
kill-stale-client did `pkill -f ps5upload-engine`, which SIGTERMed
every engine on the machine — a standalone one on another port died 70
minutes into a package build. It now stops only an engine listening on
19113 (an engine whose app is gone exits by itself). The decision is a
small pure module with node:test cases, which scripts:check now runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:23:54 -07:00
phantomptr ce76e9dec5 Release v5.37.0 2026-09-28 12:16:16 -07:00
phantomptrandClaude Opus 5.5 e338b29973 test(e2e): the sidebar lists every screen from the start
The browser smoke test still expected the Favorites sidebar (Home only,
"Star screens in More"); links now appear in both the sidebar and
More, so More's are looked up inside the page body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:46:34 -07:00
phantomptrandClaude Opus 5.5 92fd104830 docs(changelog): sidebar, build-time estimate, button sizes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:41:31 -07:00
phantomptrandClaude Opus 5.5 c155c08c88 fix(convert): the time estimate covers the whole build, at the speed the drives allow
A 234 GiB game estimated at 17 min took 71: the estimate timed only
compression, of blocks already in memory, one thread multiplied by the
thread count. It now times the source's sequential reads and the
output drive's writes, runs the sample on every thread at once, and
adds the package's write and verify to compression bounded by the
slower of reading and encoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:41:06 -07:00
phantomptrandClaude Opus 5.5 ca84bb2b61 feat(nav): the sidebar shows every screen; hide what you don't use
The desktop sidebar held Home, your starred screens and About, so a new
user saw nothing else — Convert and File System lived behind the More
icon. It now lists every screen in its sections (each folds away,
remembered); hover a row to hide it, and More's eye switch brings it
back. Hidden screens and folded sections are mirrored to settings.json.
Favorites are retired; nothing is hidden at first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:32:56 -07:00
phantomptrandClaude Opus 5.5 fedaba42e8 fix(css): raw buttons honour their text and font classes
An unlayered `button { font: inherit }` outranked every Tailwind
utility (they live in @layer), so each raw <button> ignored its
text-/font- classes: sidebar headers, tabs, Refresh and the Install
Package header actions rendered at body size. Tailwind's own base layer
already sets it. Checked before/after on 16 screens at desktop and
phone widths; nothing overflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:32:56 -07:00
phantomptrandClaude Opus 5.5 05d6fa3db1 docs(changelog): 5.37.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:44:55 -07:00
phantomptrandClaude Opus 5.5 071487f955 fix(engine): listing a folder that isn't there is a 404, not a 502
Install Package lists every drive's package folder; a drive without one
logged a gateway error. The body is unchanged, which is what the app
reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:31:50 -07:00
phantomptrandClaude Opus 5.5 8b2758290f fix(icons): a suffixed save folder shows its game's icon and name
Saves listed a console folder named PPSA17221.bak and asked the engine
for the icon of "PPSA17221.bak" (400, then a retry). GameIcon now
looks up the leading title id and skips names that aren't one; the
Saves header names the game too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:31:50 -07:00
phantomptrandClaude Opus 5.5 31dc0d9182 fix(engine): a recovery line counts every failure of the outage
It reported only the unlogged repeats, so one logged 502 followed by a
200 read "recovered after 0 failure(s)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:14:18 -07:00
phantomptrandClaude Opus 5.5 cf7ddbccab fix(engine): exit when the desktop shell dies, even with nowhere to print
The parent-watch thread's first eprintln! panicked on the closed stderr
pipe a dead shell leaves behind, so the engine never shut down and held
its port (reproduced with the v5.36.0 app: killed shell, engine alive
25 s later). Shutdown is now triggered before any message, messages use
a write that cannot panic, and the notify keeps a permit so a parent
that dies during start-up isn't missed (it fell to the 10 s hard exit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:12:53 -07:00
phantomptrandClaude Opus 5.5 d75a5adf4b fix(picker): with folders ticked, Add is the one action
"Use this folder" resolved only the open folder and dropped the ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:06:48 -07:00
phantomptrandClaude Opus 5.5 d0bbf5da74 fix(remote): a RAR volume already in the folder is never overwritten
The copy itself got a 409 when its name was taken, but a set's other
volumes were written over, and cleanup would then delete them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:06:24 -07:00
phantomptrandClaude Opus 5.5 f9fbfd5734 chore: untrack a client/node_modules symlink committed by mistake
59440903 committed a worktree's node_modules symlink, pointing at an
absolute path on the author's machine. `node_modules/` only ignores
directories; `node_modules` covers a symlink too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:59:04 -07:00
phantomptrandClaude Opus 5.5 ba68a1910a fix(picker): let the multi-pick hint wrap on a phone
Seen on the Android emulator: "add them toget…" was cut off beside
the Add button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:58:38 -07:00
phantomptrandClaude Opus 5.5 93e02d8969 feat(convert): Game language — declare one PlayGo language in the package
Battlefield 6's eboot imports scePlayGoGetLanguageMask: it asks the
install which language it has, and every converted package answered
"all 31, en-US first". Convert's Options card now takes one language;
the package's playgo-scenario.json then lists it as the default and
only language (the shape Sony's own scenarios take). Carried through a
single run and the Convert queue; reset per game.

Checked on the Phat (FW 5.10): a fr-FR Minecraft build installs and
plays like the all-language control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:49:40 -07:00
phantomptrandClaude Opus 5.5 8d128ad520 feat(picker): tick several files or folders in the in-app browser
The browser build and Android browse in-app, which picked one path at a
time. pickLocalPaths() opens it with checkboxes kept across folders and
an Add N button; Upload's Choose file/folder feed every pick to the
batch list, pickPaths() uses it off-desktop, and Convert's queue gains
Pick several….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:11:50 -07:00
phantomptrandClaude Opus 5.5 9b21f5a2e8 feat(remote): a multi-part .rar on a saved server is copied with every volume
UnRAR finds the rest of a set beside its first volume, so copying only
game.part1.rar (or game.rar of an .r00/.r01 set) could never extract. The
server copy now brings the set's other volumes into the same folder and
cleanup removes them too - for Upload, a batch item's turn, and Convert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:01:54 -07:00
phantomptrandClaude Opus 5.5 c1b03a9910 test(engine): hand-run throughput test for the console reader Convert uses
Measured 2026-09-28 on the Phat over this Mac's Wi-Fi: 7.8 MB/s for 2 GiB,
the same as curl on the same file (7.9 MB/s) — the reader adds no overhead;
the link is the limit (117 MB/s wired in the spike).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:56:39 -07:00
phantomptrandClaude Opus 5.5 79d49c08a8 fix(layout): buttons never shrink below their label; the Games grid fills by room, not window width
Buttons in tight rows ellipsized to "Ref…" / "Connect a NAS…"; they keep their
label now (rows wrap). The game cover grid chose columns by window width, so
beside the sidebar at 768 px it squeezed four 99 px cards; it now fits as
many ≥8 rem columns as the space holds (two on a phone, more on wide windows).
Scanned at 390/768/1280: no overflow, no cut labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:14:48 -07:00
phantomptrandClaude Opus 5.5 07c7f1de00 fix(mobile): header actions and the Upload picker row wrap instead of squeezing labels; titles wrap on phones
On a phone, header action rows shrank their buttons to "Ref…" / "New f…" /
"From this d…" (File System, Install Package) and Upload's picker row
crushed three buttons into fragments. Rows wrap now; a long page title wraps
to two lines on a phone instead of truncating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:02:39 -07:00
phantomptrandClaude Opus 5.5 c40d70ce7c fix(button): an icon passed as a child stays on the label's line
The label span is a block (for truncation) and a block-level svg sat above
the text: Refresh / Add a server / Add point / Reset play time… rendered as an
icon over a word on every screen that passes the icon as a child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:00:27 -07:00
phantomptrandClaude Opus 5.5 0f296fdfcb style(icons): one icon per screen, the same in the menu and the page header
Audit log, Connection, Game activity and Processes showed a different icon in
their header than in the menu; Cheats shared the Games gamepad, Edit game image
shared the Volumes drive, Processes shared the Console CPU. Now: audit log
shield-check, connection cable, game activity clock, processes layers, cheats
wand, edit game image file-pen, games gamepad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:58:19 -07:00
phantomptrandClaude Opus 5.5 ea5b7700d7 style: every screen in the same page frame; Convert uses two columns on wide windows
Screens sat in a mix of centered 3xl/4xl/5xl columns with 16 or 24 px padding,
leaving half a wide window empty on some and not others. All screen roots (and
the tabbed shell) now use the shared app-page frame: one max width, 24 px
padding, 16 px on phones. Convert puts the game and its options beside
building and the queue at xl widths. The queue's Start button only shows when
something is waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:56:34 -07:00
phantomptrandClaude Opus 5.5 fb1d384154 fix(web): FAQ and Changelog load in the browser build; Keep awake and Capture don't offer what a web page can't do
The browser build showed developer errors ("'faq_load' requires the Tauri desktop
client… Guard the call site…") on FAQ and in Settings → Keep awake, and its
Capture button could only fail. FAQ/CHANGELOG are bundled into the web app as
lazy chunks; Keep awake reports unsupported there; Capture is desktop-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:48:11 -07:00
phantomptrandClaude Opus 5.5 bb75c14224 fix(payload): uninstalling a folder we registered removes its /user/appmeta copy
Registration copies sce_sys into /user/appmeta/<id>; Sony's uninstall doesn't
know about that copy, and the app lists every appmeta folder as installed, so
an uninstalled folder game stayed in Games. Once Sony confirms the uninstall
of our own registration, the flat folder is removed (files only, no recursion).
Verified on the Phat: register → unregister → appmeta gone, not listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:45:40 -07:00
phantomptrandClaude Opus 5.5 7872b0fc4f feat(convert): a queue - several games converted one after another, installs handed to the console queue
Add this game / Add games from a folder… queue builds on this computer, one at
a time, each shown by the Convert screen's own stage view. After converting:
keep the package, or Stream & install / Upload & install - the package is
handed to the console queue and the next build starts while it installs
(a console dump still swaps inside its build). Optional delete after a
verified install. Reorder, remove, stop after the current build; a build
interrupted by a restart is built again, and the engine now clears an
interrupted build's stale .pkg.partial instead of refusing the rebuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:28:22 -07:00
phantomptrandClaude Opus 5.5 9eab028449 feat(upload): several sources at once - review list, multi-select, drops, Add games from a folder
Two or more sources (multi-select pickers, a multi drop, or Add games from a
folder…, which lists a folder's children with games checked) switch Upload to
a review list: each row is inspected in the background (four at a time) and
shows its kind, size and where it lands; an encrypted .rar asks for its
password inline. Before adding: rows landing in the same place block, rows
already queued are skipped, and space per drive blocks only when it clearly
won't fit (warns when it can't tell). Shared destination, resume/overwrite and
only the options the list needs; Add N to queue / Upload N now. One source
still behaves exactly as before; a package dropped on Upload is uploaded and
installed (it no longer jumps to Install Package).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:13:20 -07:00
phantomptrandClaude Opus 5.5 4f28b1d52e feat(upload): shared queue-item builder; a server archive is copied when its turn comes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:01:57 -07:00
phantomptrandClaude Opus 5.5 d0970f0de3 fix(pkg): name the container's own entries (digests, entry_keys, image_key, metas…)
The viewer's Files tab listed them as bare ids (#0001, #0010, …).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:57:43 -07:00
phantomptrandClaude Opus 5.5 cd57a668a7 feat(viewer): open it from anywhere - drops, Convert, the queue, File System, server browser
One app-wide viewer. A package, image or folder dropped on a screen without
its own drop zone opens it with Install… / Convert… (a drop no longer jumps
straight to Install Package); Convert views its game and its package; queue
rows view their package or source; File System and Connections → Browse get
View on packages, images and game folders (read on the console / server).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:54:38 -07:00
phantomptrandClaude Opus 5.5 104b08f9ac test(installer-client): the fake daemons read the whole request line
The client writes a request and its newline separately; a fake that answered
after the first segment closed over unread bytes, and the reset beat the
reply to the client (hello_over_socket flaked under load).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:54:37 -07:00
phantomptrandClaude Opus 5.5 21aee22efa feat(viewer): Files, Images and Related tabs; packages and games on a server or the PS5
The viewer reads remote:// and ps5:// sources through the engine's opener, and
.pkg parsing now works over any reader, so a staged package opens straight
from the console: every Install Package row gets View details, reading the
local original when there is one. New GET /api/game/inspect/files lists a
package's entries or a tree's files (a windowed list for 200k-file dumps);
Images shows and saves every artwork; Related groups the title's other
library and queued packages base → update → DLC, each viewable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:45:36 -07:00
phantomptrandClaude Opus 5.5 bd14603614 feat(convert): Convert & replace swaps a console dump for its package
A dump on the PS5 and its package share a title id, and ShadowMountPlus
re-registers a dump over an install, so a console source is never installed
beside its dump. The swap journals each step to /data/ps5upload/swap first,
parks the dump on its own volume (outside every scan root), waits for SMP to
let go, removes the leftover registration, installs through the queue, and
moves the dump back if anything fails. A running game stops it untouched.
The result offers Delete the old dump / Keep it parked (dropping its
manual.lst line); an interrupted swap is listed on Convert to put back or
finish.

Verified on the Phat (5.10) with a disposable fixture title: a failed install
rolled back (dump restored, SMP re-registered); a good one parked the dump and
left a real app.pkg install; Delete the old dump removed it and the journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:36:17 -07:00
phantomptrandClaude Opus 5.5 3c558ba0ed feat(convert): read a game in place on a saved server or the PS5
A server folder or image is built straight off the server through the
engine's server file system (8 MiB read-ahead, retries) - no copy stage;
only a server archive is still copied, then unpacked. 'On the PS5' browses
the console and reads a dump through ftpsrv (:2121) as ps5://host/path;
installed games under /user/app are refused as encrypted. source::open
resolves remote:// and ps5:// through an opener the engine registers, so
build, estimate, inspect and the viewer all read them. Compression still
copies a server image first (it reads local disk).

Verified on the Phat (5.10): inspect + build of an image on the console
over ps5://, same package size as the local build; /user/app refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:08:22 -07:00
phantomptrandClaude Opus 5.5 ebcc541173 feat(convert): build from a .zip, .7z or .rar archive
An archive is unpacked into .ps5upload-extract-<job> in the output folder as
its own Convert stage (progress, Cancel), the game found inside - a folder
however wrapped, or a single .exfat/.ffpkg/.ffpfsc image - is built, and the
unpack is removed when the run ends. Space for the files plus the package is
checked first; unpack folders left by an earlier run are swept. RAR takes a
password (never stored); a wrong one on a content-encrypted RAR now says so
instead of 'File CRC error', for uploads too. zip/7z are built without their
encryption features, so a protected one is refused with that reason. A server
archive is copied, then unpacked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:53:45 -07:00
phantomptrandClaude Opus 5.5 da44b7193f feat(fpkg): convert and inspect a .ffpfsc through the image inside it
PfscReader is Read + Seek over a .ffpfsc's inner file, decoding 64 KiB zlib
blocks on demand with a small cache; the single-file layout parser is shared
with verify and bounds-checked for images users bring. exFAT and UFS2 sources
now open from any reader. A package built from a .ffpfsc is byte-identical
to the one built from its image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:38:32 -07:00
phantomptrandClaude Opus 5.5 67335e2c50 fix(settings): hide the Beta features switch while nothing is in beta; README drops Convert's beta note
Convert graduated; the switch revealed nothing and the README still told users
to enable Convert there. Stream install is Stream & install everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:30:48 -07:00
phantomptrandClaude Opus 5.5 808780f2e3 fix(viewer): cache sees folder PARAM and split-part edits; safer reads and labels
- Cache key covers a folder's PARAM files and a package's split parts, and a
  hit keeps its token alive.
- Content-id slicing can't panic; text read from an image or folder is capped;
  a failing OS random source is an error, never a guessable token.
- SFO counts and levels show in decimal (flags and versions stay hex).
- "Entries" for a package, "Parental level" for PS4; Copy uses the shared
  clipboard helper; a reply for an earlier path or console is ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:29:19 -07:00
phantomptrandClaude Opus 5.5 ead8079a6e test(installer-client): fix the fake daemon's macOS read race
An accepted socket inherits the listener's O_NONBLOCK on BSD/macOS, so a read
before the client wrote returned WouldBlock and the request went unrecorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:29:19 -07:00
phantomptrandClaude Opus 5.5 4784996be2 fix(queue): a failed Install Package upload or browser stream leaves no dead queue row
The library row already shows an upload failure; its queue row's Retry would
stage a copy nothing waits for. A browser-staged stream copy is deleted after
a failure, so its row (and the Retry via upload hint) could never work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:20:24 -07:00
phantomptrandClaude Opus 5.5 4598183dcc fix(pkg-library): install() returns its own result; stream callbacks can't leak or be overwritten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:18:19 -07:00
phantomptrandClaude Opus 5.5 5944090346 fix(queue): duplicate installs join the queued one; library rows show queue state
A second identical install now awaits the queued one instead of failing, and
Install Package rows show Queued/Installing and block Delete while their
install waits in the console queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:16:13 -07:00
phantomptrandClaude Opus 5.5 0b6901fa53 fix(payload): a resumed single-file upload writes where the acknowledged bytes end
A fresh single-file upload pre-sizes its .ps5up2-tmp to the full transfer
(posix_fallocate); a resume reopened it with O_APPEND, so the resumed
shards were written past the pre-sized end. A 1 GiB upload that dropped
and resumed grew to 1 GiB + the unsent remainder and COMMIT refused it
with size_mismatch (seen on the Android emulator).

Resume now opens without appending and seeks to entry->bytes_received
(the journal's acknowledged byte count). direct_open.h is header-only so
the new host self-test drives the same code on a real file.

Hardware (Phat 5.10): 1 GiB upload, engine killed at ~300 MB, resumed
with the same tx id — before: size_mismatch at 1,879,048,192 bytes;
after: committed at 1,073,741,824, SHA-256 identical to the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:11:20 -07:00
phantomptrandClaude Opus 5.5 628e8c4a13 feat(upload): a package's footer button reads Upload & install and yields to Stream & install
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:57:57 -07:00
phantomptrandClaude Opus 5.5 6a11e21259 feat(install): Stream & install first — renamed buttons, stream-first copy, drops stream
Stream install → Stream & install (primary, first); Add package → Upload &
install (secondary). Install Package's description and empty state lead
with streaming; dropping a package streams it; Upload's .pkg card offers
Stream & install as the recommended route; Convert uses the same label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:55:49 -07:00
phantomptrandClaude Opus 5.5 1ebbd62ffa fix(viewer): no PS4-vs-PS5 firmware verdict; honest installed check; space on the roomiest drive
- A PS4 package's minimum firmware is PS4 firmware: never compared with the
  PS5's (was a false red "can't run").
- Installed check comes from the preflight: unknown is no verdict (never
  "not installed"), installed-without-version reads as installed, DLC and
  updates are checked as themselves and say when the base game is missing,
  an unknown package version is not compared.
- Space is checked against the roomiest single drive, not the sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:49:01 -07:00
phantomptrandClaude Opus 5.5 dfbdca9a5b fix(viewer): keep CDATA update notes, hide empty ones, pad the panel; View details always on the package card; changelog
Hardware (web UI, Playwright): Star Wars patch (PS4), Star Wars base, Spider-Man 2 (PS5 fake package) and a PPSA30528 .exfat inspected — titles, ids, versions, firmware, SDK, covers and update notes correct; checks match the console (installed 01.02, FW 5.10 meets 3.55).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:40:18 -07:00
phantomptrandClaude Opus 5.5 c0ada35d3e fix(inspect): PS5 sources are games by applicationCategoryType, with their pubtools build date
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:33:49 -07:00
phantomptrandClaude Opus 5.5 d101fc287e feat(viewer): open the package panel from Install Package and Upload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:28:01 -07:00
phantomptrandClaude Opus 5.5 c280409756 feat(viewer): PackagePanel — cover, badges, console checks, Overview and Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:23:59 -07:00
phantomptrandClaude Opus 5.5 29052d27cd feat(viewer): firmware, installed-version and space checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:20:37 -07:00
phantomptrandClaude Opus 5.5 2ed82f8554 feat(inspect): /api/game/inspect with a token cache, its image route, Tauri commands and browser mapping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:19:56 -07:00
phantomptrandClaude Opus 5.5 4af627a092 feat(engine): GameInspection — one description of any local package, image or game folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:15:44 -07:00
phantomptrandClaude Opus 5.5 9a3d19d34e fix(pkg): clippy — read the entry table with as_chunks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:11:59 -07:00
phantomptrandClaude Opus 5.5 51adbe60cd feat(pkg): typed PARAM.SFO values and the package entry table
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:11:45 -07:00
phantomptrandClaude Opus 5.5 7228fa9d33 fix(queue): review fixes — an install is never run twice, the saved queue loads first, installs never auto-rerun
- Stop and Clear leave a running install running (Sony's install can't be
  stopped); a new drain waits for it instead of re-queueing and running it
  a second time, and its outcome is recorded even after Stop.
- The saved queue loads at app start; nothing is saved before it loads,
  and anything queued before the load is kept.
- Reconnect-resume never re-runs a failed install; Retry failed resets
  install rows cleanly.
- A failed install never stops the installs queued after it.
- Retry via upload installs after uploading and wins over a staged
  variant (the old dialog's options), and drops the row only once the
  upload is accepted.
- Rest mode after uploads ignores installs and an Install Package upload
  whose install is queued next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:10:45 -07:00
phantomptrandClaude Opus 5.5 ff18bd2b2b docs(changelog): one queue per console (#335 step 1)
Hardware (Phat, FW 5.10, web UI via Playwright):
1. Jak X base (3.6 GB) stream install running; its patch and a third
   package queued behind it as Queued (#1)/(#2) — no refusal. Base then
   patch installed; the game launched patched. PASS
2. Star Wars base + patch stream installs in sequence; launched. PASS
3. The third package's file was renamed before its turn: only that row
   failed, with the reason and Retry; the others finished. PASS
4. Page reload mid-queue: not exercised on hardware (covered by the
   browser-build unit test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:53 -07:00
phantomptrandClaude Opus 5.5 c40b0951b4 fix(queue): show the queue at the top of Install Package; installs appear once in the activity bar
The Queue panel sat below the link box and two info cards, out of view.
The activity bar and task list also mirrored install items as "Upload"
rows next to the install's own task, so every queued install showed twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:56:47 -07:00
phantomptrandClaude Opus 5.5 aacc098b13 fix(install): a running install no longer locks the Install Package buttons
Stream install, Add package, Install all, Install link and External
Packages' Install used to disable while any install ran, so a second
stream install could not be queued from the screen. Installs queue per
console now; only deleting staged packages still waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:48:20 -07:00
phantomptrandClaude Opus 5.5 1f7cdb1a8e feat(queue): Queue panel on Install Package, install rows with Retry via upload, queue count on the console tab
Install rows say where the package comes from and their place in line,
show install progress, and offer Retry (and Retry via upload when the PS5
couldn't reach this computer). A running install has no Cancel: Sony's
installer can't be stopped halfway. The panel is now titled Queue in all
20 languages (several old titles read "Upload the queue").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:45:50 -07:00
phantomptrandClaude Opus 5.5 c025dfef3a feat(queue): Retry via upload on a stream install the PS5 couldn't reach; drop the staging dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:40:40 -07:00
phantomptrandClaude Opus 5.5 25f838d2d1 refactor(install): Install Package uploads run as console-queue items — one upload engine
addAndUpload keeps its checks (split sets, duplicates, variants, package
drive) and hands the transfer to the queue, mirroring the item's progress
onto the library row; its own port wait, transfer loop and watchdog are
gone. The install after upload is queued like any other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:37:56 -07:00
phantomptrandClaude Opus 5.5 5e45a08f18 feat(install): every install entry point runs through the console queue
A second install is queued instead of refused, and installs from any
source order base → update → DLC together. Each entry point keeps its
signature: it queues the install and resolves when that item finishes.
Download-first links download right away and queue the finished file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:33:27 -07:00
phantomptrandClaude Opus 5.5 c9a36a4e9e feat(queue): install items resolve on removal, retry with a fresh waiter, never persist links, and are not re-run after a restart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:22:32 -07:00
phantomptrandClaude Opus 5.5 6a98fac98b feat(queue): install items — any install can run as a console-queue item
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:20:57 -07:00
PhantomPtrandClaude Opus 5.5 76016015c7 PS5Upload v5.36.0 (#336)
* feat(pkg): choose which drive install packages are kept on

Uploaded packages always went to internal storage
(/user/data/ps5upload/pkg_library), which fills the console's own drive.
The Volumes screen now lets you mark a drive "Use for packages"; new
packages are staged under <drive>/ps5upload/pkg_library on it.

- Per console, remembered on this device. Internal stays the default.
- Falls back to internal storage, with a notice, when the chosen drive
  isn't connected.
- The package library lists packages on every drive; rows show the drive.
- The folder chain on the drive is created one level at a time.
- The External Packages scan skips a drive's ps5upload/ folder, so staged
  packages aren't listed twice.

Hardware (Phat, FW 5.10): Star Wars Racer Revenge base+patch staged on
/mnt/ext0 installed, patched 01.00->01.02 and launched in 2 of 3 trials
(80 MB/s); the first stalled after the header read and is covered by the
daemon-recycle fix that follows. Internal control: 1/1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(install): recycle the installer daemon after a stalled delivery

A console-path install that stalls leaves the daemon's loopback job
"serving" until Sony reads every byte, which never happens, so every later
install was refused as "an install is already in progress" until the
daemon restarted, although the failure told the user to retry.
Measured on the Phat (FW 5.10). A stalled daemon-served install now stops
the daemon the same way a wedged stream refusal already did, and the next
install's ensure brings up a fresh one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PS5Upload v5.36.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:18:45 -07:00
PhantomPtrandClaude Opus 5.5 c7bb83d04d chore(scripts): rewrite release posts, drop outdated scripts; README highlights (#334)
release-posts.js
- Reads whole changelog groups (bold heading + wrapped bullets), not just
  the first line of each bullet.
- X: link counted as 23 chars, replies packed to fit 280, markdown stripped.
- Discord: full bullets when they fit 2000 chars, short ones otherwise;
  Reddit: real markdown sections instead of a code block.
- Says "pre-release" when GitHub does (gh), --prerelease/--stable to
  override; --only and --out to pick a platform and write files.
- make release-post calls it directly; the .sh wrapper is gone.

Removed (outdated)
- ps5-install-trace.py, ps5-install-watch.py, fpkg-mount-test.py: drove
  /api/pkg/install/start and the dpi-* routes, which no longer exist.
- gather-fakelibs.py: superseded by the app's Backport scan.
- pull_ps5_debug.py: pulled a debug dir the helper no longer writes to.

hw-test-loop.sh ran cargo from the repo root (no workspace there); it now
points at engine/Cargo.toml.

README: a Highlights section up top (unified install, Convert to FPKG,
sleep and wake), Android/web in the platforms badge, tested firmware 5.10 &
9.60.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 06:10:10 -07:00
PhantomPtrandClaude Opus 5.5 3f34434287 ci(docker): read the installer ELF from the artifact's installer/ dir (#333)
The payload artifact keeps payload/installer/ps5upload-installer.elf as
installer/ps5upload-installer.elf; the image jobs still copied it from the
old dpi/ path and failed on the v5.35.0 tag.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 05:27:27 -07:00
1932 changed files with 409421 additions and 74996 deletions

No files matched your search

+3
View File
@@ -32,6 +32,9 @@ client/node_modules/
# Docs, tests, scripts (not needed at runtime)
docs/
*.md
# …except the two the web UI bundles (its FAQ and Changelog screens).
!FAQ.md
!CHANGELOG.md
LICENSE*
# ...but keep the vendored crate whole: its licences must travel with the
# source we build from, and excluding them here would quietly drop the
+14
View File
@@ -6,6 +6,7 @@ body:
attributes:
value: |
Thanks for taking the time to file a bug report!
The app's **More → Bug report** fills this in for you and saves a zip with the logs: drag that zip into this issue.
- type: textarea
id: what-happened
attributes:
@@ -41,6 +42,11 @@ body:
attributes:
label: PS5 firmware
placeholder: "e.g. 9.60, 5.10"
- type: textarea
id: payloads
attributes:
label: Other payloads loaded
description: Anything else running on the console (kstuff, ShadowMount+, etaHEN, ftpsrv, Payload Manager, ...), with versions if you know them.
- type: textarea
id: logs
attributes:
@@ -53,3 +59,11 @@ body:
label: Steps to reproduce
description: If the bug is reproducible, list the exact steps.
placeholder: "1. ...\n2. ...\n3. ..."
- type: dropdown
id: frequency
attributes:
label: How often does it happen?
options:
- Once
- Sometimes
- Every time
+3 -2
View File
@@ -27,6 +27,7 @@
If yes, which files:
- [ ] README.md
- [ ] CHANGELOG.md
- [ ] TESTING.md
- [ ] FAQ.md
- [ ] bench/README.md
@@ -38,8 +39,8 @@ If yes, which files:
## Testing
<!-- Describe the tests you ran -->
- [ ] `npm run validate`
- [ ] `npm run coverage`
- [ ] `npm run validate` (CI also needs `cargo fmt --check` in `engine/` AND `client/src-tauri`)
- [ ] `cargo test -p ava1-ctest -- --test-threads=1` (in `engine/`), `make test-payload`, and the retired-protocol check (see CONTRIBUTING.md)
- [ ] Built PS5 payload successfully
- [ ] Cross-platform CI target checks passed
- [ ] Tested on real PS5 hardware
+48
View File
@@ -0,0 +1,48 @@
name: ava1-soak
# Weekly host soak (review 009 #2c): whole jobs (mixed small and large uploads with cancel and resume,
# zip downloads, parked jobs reaped) against the real console C, built with ASan + UBSan + LSan, for
# an hour. After every round the data layer's counters must be back at baseline: job table empty,
# nothing unswept, no pack segments on disk, the open-descriptor count unchanged, memory bounded.
# Not part of the PR gate.
on:
schedule:
- cron: '17 3 * * 0'
workflow_dispatch:
inputs:
minutes:
description: 'Minutes to soak'
default: '60'
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
soak:
name: soak (ASan + UBSan)
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- uses: actions/checkout@v7
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install clang + sanitizer runtimes
run: sudo apt-get update && sudo apt-get install -y clang libclang-rt-18-dev
- name: Soak
working-directory: engine
env:
CC: clang
AVA1_CTEST_SANITIZE: '1'
RUSTFLAGS: -Clinker=clang
ASAN_OPTIONS: detect_leaks=1
LSAN_OPTIONS: suppressions=${{ github.workspace }}/engine/crates/ava1-ctest/lsan.supp
AVA1_SOAK_MINUTES: ${{ github.event.inputs.minutes || '60' }}
AVA1_SOAK_RSS_MB: '512'
run: cargo test -p ava1-ctest --test soak -- --ignored --test-threads=1 --nocapture
+71 -11
View File
@@ -27,7 +27,7 @@ on:
push:
tags: ['v*']
pull_request:
paths: ['engine/**', 'client/**', '.github/workflows/docker-engine.yml']
paths: ['engine/**', 'client/**', 'payload/**', 'scripts/docker-smoke.sh', '.github/workflows/docker-engine.yml']
workflow_dispatch:
inputs:
tag:
@@ -50,7 +50,9 @@ jobs:
verify:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 10
# A full engine (and, for the web UI, client) build: ~7-10 min on a good
# runner, so 10 cancelled ordinary runs (PR #338).
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
@@ -72,14 +74,25 @@ jobs:
file: engine/Dockerfile
platforms: linux/amd64
push: false
# Load it so the smoke test below can run it.
load: true
tags: ps5upload-engine:smoke
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=min
# Building proved nothing about whether the image works: 5.40.0 built
# fine and refused every request, had no /tmp for uploads and no
# writable home. Run it and check those.
- name: Smoke-test the image
run: scripts/docker-smoke.sh ps5upload-engine:smoke
# ── PR gate for the webui image: same idea, root context + Dockerfile.webui ─
verify-webui:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 10
# A full engine (and, for the web UI, client) build: ~7-10 min on a good
# runner, so 10 cancelled ordinary runs (PR #338).
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
@@ -101,8 +114,13 @@ jobs:
file: engine/Dockerfile.webui
platforms: linux/amd64
push: false
load: true
tags: ps5upload-engine-webui:smoke
cache-from: type=gha,scope=webui
cache-to: type=gha,mode=max,scope=webui
cache-to: type=gha,mode=min,scope=webui
- name: Smoke-test the image
run: scripts/docker-smoke.sh ps5upload-engine-webui:smoke --webui
# ── Resolve + validate the tag ONCE, hand the trusted values to build/merge ─
# Centralizing this means the tag is regex-validated before it ever reaches a
@@ -157,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: |
@@ -250,7 +280,7 @@ jobs:
set -euo pipefail
mkdir -p engine/crates/ps5upload-engine/payload
cp -v /tmp/payload-elves/ps5upload.elf \
/tmp/payload-elves/dpi/ps5upload-installer.elf \
/tmp/payload-elves/installer/ps5upload-installer.elf \
engine/crates/ps5upload-engine/payload/
ls -la engine/crates/ps5upload-engine/payload/
@@ -267,6 +297,21 @@ jobs:
with:
images: ${{ env.IMAGE }}
# Same build, loaded locally and run before anything is pushed: a
# release tag can't be moved, so a broken image would need a new release.
- name: Build for the smoke test
uses: docker/build-push-action@v7
with:
context: .
file: engine/Dockerfile
platforms: ${{ matrix.platform }}
load: true
tags: ps5upload-smoke:local
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }}
- name: Smoke-test the image
run: scripts/docker-smoke.sh ps5upload-smoke:local
- name: Build and push by digest
id: push
uses: docker/build-push-action@v7
@@ -276,8 +321,8 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }}
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
env:
@@ -338,7 +383,7 @@ jobs:
set -euo pipefail
mkdir -p engine/crates/ps5upload-engine/payload
cp -v /tmp/payload-elves/ps5upload.elf \
/tmp/payload-elves/dpi/ps5upload-installer.elf \
/tmp/payload-elves/installer/ps5upload-installer.elf \
engine/crates/ps5upload-engine/payload/
ls -la engine/crates/ps5upload-engine/payload/
@@ -355,6 +400,21 @@ jobs:
with:
images: ${{ env.IMAGE }}
# Same build, loaded locally and run before anything is pushed: a
# release tag can't be moved, so a broken image would need a new release.
- name: Build for the smoke test
uses: docker/build-push-action@v7
with:
context: .
file: engine/Dockerfile.webui
platforms: ${{ matrix.platform }}
load: true
tags: ps5upload-smoke:local
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }}
- name: Smoke-test the image
run: scripts/docker-smoke.sh ps5upload-smoke:local --webui
- name: Build and push by digest
id: push
uses: docker/build-push-action@v7
@@ -364,8 +424,8 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=webui-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=webui-${{ env.PLATFORM_PAIR }}
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }}
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
env:
+327 -33
View File
@@ -51,13 +51,13 @@ jobs:
run: node scripts/update-version.js --check
# ─────────────────────────────────────────────────────────────────────
# Rust workspace (engine + core + ftx2-proto). Format, clippy, tests.
# Rust workspace (engine, core, AVA1 crates). Format, clippy, tests.
# Gates PRs that touch any Rust code.
# ─────────────────────────────────────────────────────────────────────
rust-engine:
name: rust (engine workspace)
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
@@ -66,15 +66,19 @@ jobs:
with:
components: rustfmt, clippy
- name: Cache cargo registry + target
uses: actions/cache@v6
# Restored on every run; saved (under a new key per run, so it stays current) only from
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
- name: Restore cargo cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-engine-cargo-${{ hashFiles('engine/Cargo.lock') }}
restore-keys: ${{ runner.os }}-engine-cargo-
key: ${{ runner.os }}-engine-lint-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-engine-lint-${{ hashFiles('engine/Cargo.lock') }}-
${{ runner.os }}-engine-lint-
- name: cargo fmt
working-directory: engine
@@ -104,11 +108,253 @@ jobs:
working-directory: engine
run: cargo check -p ps5upload-engine --features webui
- name: cargo test
- name: Save cargo cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-engine-lint-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
# The workspace's tests, in two legs that run side by side: the engine crate (and the
# integration suite built on it) is about half of the time, everything else the other half.
# Together they are `cargo test --workspace --exclude ava1-ctest`; a crate added later lands
# in `libs` without anyone listing it.
rust-engine-tests:
name: rust tests (${{ matrix.part }})
runs-on: ubuntu-24.04
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
include:
- part: engine
select: -p ps5upload-engine -p ps5upload-tests
- part: libs
select: --workspace --exclude ava1-ctest --exclude ps5upload-engine --exclude ps5upload-tests
steps:
- uses: actions/checkout@v7
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
# Restored on every run; saved (under a new key per run, so it stays current) only from
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
- name: Restore cargo cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-engine-test-${{ matrix.part }}-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-engine-test-${{ matrix.part }}-${{ hashFiles('engine/Cargo.lock') }}-
${{ runner.os }}-engine-test-${{ matrix.part }}-
- name: Install cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
# nextest runs the test binaries side by side (cargo test runs them one at a time, and most
# of these sit in network sleeps); doctests, which nextest does not run, follow separately.
# Timing-sensitive binaries are serialized by engine/.config/nextest.toml.
# Bounded per step: a hung test fails here in minutes instead of holding the job.
- name: cargo nextest
working-directory: engine
timeout-minutes: 25
env:
RUST_BACKTRACE: 1
run: cargo test --workspace -- --nocapture
run: cargo nextest run --profile ci ${{ matrix.select }}
- name: cargo test --doc
working-directory: engine
timeout-minutes: 10
run: cargo test --doc ${{ matrix.select }}
- name: Save cargo cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-engine-test-${{ matrix.part }}-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
# The C interop suite. The C server and the data layer are process-wide singletons, so the
# suite runs one test at a time: a test that holds the server cannot starve the others. That
# rule holds inside one machine, so the test files are dealt across four machines, each still
# running its share one at a time.
rust-ctest:
name: rust (ava1 C interop ${{ matrix.shard }}/4)
runs-on: ubuntu-24.04
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
# Restored on every run; saved (under a new key per run, so it stays current) only from
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
- name: Restore cargo cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-engine-ctest-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-engine-ctest-${{ hashFiles('engine/Cargo.lock') }}-
${{ runner.os }}-engine-ctest-
- name: cargo test (ava1 C interop, serial)
working-directory: engine
timeout-minutes: 25
env:
RUST_BACKTRACE: 1
SHARD: ${{ matrix.shard }}
run: |
# Every test file, sorted, dealt round-robin: file N goes to shard (N mod 4) + 1.
# A file added later is picked up by one of the four without being listed anywhere.
mapfile -t all < <(ls crates/ava1-ctest/tests/*.rs | sort)
args=()
for i in "${!all[@]}"; do
if [ $(( i % 4 + 1 )) -eq "$SHARD" ]; then
args+=(--test "$(basename "${all[$i]}" .rs)")
fi
done
[ "${#args[@]}" -gt 0 ] || { echo "::error::shard $SHARD got no test files"; exit 1; }
echo "shard $SHARD of 4: ${#args[@]} of ${#all[@]} test files"
cargo test -p ava1-ctest "${args[@]}" -- --test-threads=1
- name: Save cargo cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-engine-ctest-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
# ─────────────────────────────────────────────────────────────────────
# AVA1: C/Rust conformance + interop runs in rust-engine-tests and rust-ctest
# above. This job adds what those cannot:
# fuzzing the C decoders and data-plane parsers under ASan/UBSan and the
# Rust decoders with cargo-fuzz.
# ─────────────────────────────────────────────────────────────────────
ava1:
name: ava1 (fuzz C + Rust decoders)
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- name: Install clang + libFuzzer
run: sudo apt-get update && sudo apt-get install -y clang libclang-rt-18-dev
- name: C frame AEAD (RFC 8439 + Monocypher differential, AVX2 and portable paths)
run: scripts/ava1-aead-test.sh
- name: No FTX2 or ports 9113/9114 in the repository
run: make check-no-ftx2
- name: Fuzz C decoders + data-plane parsers (60 s each)
env:
AVA1_FUZZ_SECONDS: 60
run: make ava1-fuzz-c
- name: Install Rust nightly + cargo-fuzz
run: |
rustup toolchain install nightly --profile minimal
cargo install cargo-fuzz --locked
- name: Fuzz Rust decoders (60 s + 30 s)
working-directory: engine/crates/ava1/fuzz
run: |
cargo +nightly fuzz run decode -- -max_total_time=60
cargo +nightly fuzz run frame_header -- -max_total_time=30
# ─────────────────────────────────────────────────────────────────────
# AVA1 sanitizers (review 009 #2a): the whole ctest suite, i.e. every console C path the host can run,
# with ASan + UBSan (+ LSan on Linux) on every C unit. A leak, use-after-free, overflow or UB in the
# payload C fails here instead of on a console with no debugger. Slow on purpose.
# engine/crates/ava1-ctest/lsan.supp lists the tests that exit on purpose, each with its reason.
# ─────────────────────────────────────────────────────────────────────
ava1-ctest-sanitize:
name: ava1-ctest (ASan + UBSan)
runs-on: ubuntu-24.04
timeout-minutes: 60
# Four shards, test files dealt round-robin exactly like rust-ctest: the serial sanitized run
# was ~15 min on one runner.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install clang + sanitizer runtimes
run: sudo apt-get update && sudo apt-get install -y clang libclang-rt-18-dev
# Restored on every run; saved (under a new key per run, so it stays current) only from
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
- name: Restore cargo cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-ava1-san-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-ava1-san-${{ hashFiles('engine/Cargo.lock') }}-
${{ runner.os }}-ava1-san-
- name: cargo test -p ava1-ctest (sanitized, serial)
working-directory: engine
env:
CC: clang
AVA1_CTEST_SANITIZE: '1'
RUSTFLAGS: -Clinker=clang
ASAN_OPTIONS: detect_leaks=1
LSAN_OPTIONS: suppressions=${{ github.workspace }}/engine/crates/ava1-ctest/lsan.supp:print_suppressions=0
RUST_BACKTRACE: 1
SHARD: ${{ matrix.shard }}
run: |
mapfile -t all < <(ls crates/ava1-ctest/tests/*.rs | sort)
args=()
for i in "${!all[@]}"; do
if [ $(( i % 4 + 1 )) -eq "$SHARD" ]; then
args+=(--test "$(basename "${all[$i]}" .rs)")
fi
done
[ "${#args[@]}" -gt 0 ] || { echo "::error::shard $SHARD got no test files"; exit 1; }
echo "sanitized shard $SHARD of 4: ${#args[@]} of ${#all[@]} test files"
cargo test -p ava1-ctest "${args[@]}" -- --test-threads=1
- name: Save cargo cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
engine/target
key: ${{ runner.os }}-ava1-san-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
# ─────────────────────────────────────────────────────────────────────
# Engine/core target portability. This is intentionally separate from
@@ -231,22 +477,25 @@ jobs:
clang \
libclang-dev
- name: Cache cargo target
uses: actions/cache@v6
# Restored on every run; saved (under a new key per run, so it stays current) only from
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
- name: Restore cargo cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
client/src-tauri/target
key: ${{ matrix.os }}-desktop-cargo-${{ hashFiles('client/src-tauri/Cargo.lock') }}
restore-keys: ${{ matrix.os }}-desktop-cargo-
key: ${{ runner.os }}-desktop-${{ matrix.os }}-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-desktop-${{ matrix.os }}-${{ hashFiles('client/src-tauri/Cargo.lock') }}-
${{ runner.os }}-desktop-${{ matrix.os }}-
# tauri.conf.json bundles two out-of-tree resources: the PS5
# payload (ps5upload.elf.gz) and the engine sidecar binary.
# cargo check parses those resource paths and errors out if
# anything is missing, long before any code compiles. Stub the
# payload .gz and build the engine binary for real so its target
# path exists.
# payload .gz and the engine binary (see the next step).
#
# The stub is a *fake ELF* (ELF magic + 200 KB of zeros, gzipped),
# not an empty gzip: `cargo test` runs probes.rs::
@@ -263,14 +512,19 @@ jobs:
{ printf '\x7fELF'; head -c 204800 /dev/zero; } > payload/ps5upload.elf
gzip -nf payload/ps5upload.elf # -> payload/ps5upload.elf.gz
- name: Build engine sidecar (needed as a Tauri resource)
working-directory: engine
run: cargo build --release -p ps5upload-engine
- name: cargo check
working-directory: client/src-tauri
run: cargo check --all-targets
# The desktop shell only include_bytes! the engine binary (it is extracted and started at
# run time, which no test here does), so a placeholder at the same path is enough. The real
# engine is built and checked by the rust-engine jobs; releases build it in publish.yml.
# Building it here cost 4-9 min per OS.
- name: Stub engine sidecar (needed as a Tauri resource)
shell: bash
run: |
mkdir -p engine/target/release
exe=engine/target/release/ps5upload-engine
[ "$RUNNER_OS" = "Windows" ] && exe="$exe.exe"
printf 'engine stub for CI\n' > "$exe"
# No separate `cargo check --all-targets`: clippy --all-targets type-checks the same code.
- name: cargo clippy
working-directory: client/src-tauri
run: cargo clippy --all-targets -- -D warnings
@@ -281,6 +535,16 @@ jobs:
RUST_BACKTRACE: 1
run: cargo test
- name: Save cargo cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
client/src-tauri/target
key: ${{ runner.os }}-desktop-${{ matrix.os }}-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
# ─────────────────────────────────────────────────────────────────────
# Client frontend — TypeScript typecheck, ESLint, Vitest, Vite build.
# ─────────────────────────────────────────────────────────────────────
@@ -368,14 +632,18 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
coverage:
name: coverage reports
# A report, not a gate (pr-gate does not need it): ~35 min of instrumented tests is not worth
# holding a runner on every PR. Pushes to main and manual runs still produce it.
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
# `continue-on-error` below does NOT cover the timeout: a job that hits
# `timeout-minutes` is reported as *cancelled*, which cancels the whole
# run and leaves main red over a job that is explicitly not a gate. It
# reached 20m17s on 2026-09-16 against a 16m02s green run the day
# before, so the headroom here is deliberate — keep it well clear of
# the real runtime as the test suite grows.
timeout-minutes: 40
# the real runtime as the test suite grows. On 2026-10-06 one test pass took
# ~34 min under instrumentation (scripts/coverage.mjs now runs the suite once).
timeout-minutes: 60
# Reporting job — not a gate. cargo-llvm-cov occasionally trails
# rustc's profraw format version (we hit "raw profile version
# mismatch: ... expected version = 9" when rustc bumped to v10),
@@ -428,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: |
@@ -493,6 +773,8 @@ jobs:
echo "::error::payload ELF is smaller than expected — build may have failed silently"
exit 1
fi
# AVA1: the engine stamps its key into exactly one trust slot (SPEC.md §5.1).
python3 -c "d=open('payload/ps5upload.elf','rb').read(); n=d.count(b'AVA1TRUST'); assert n==1, n"
dpi_size_bytes=$(stat -c '%s' payload/installer/ps5upload-installer.elf)
echo "ps5upload-installer.elf: ${dpi_size_bytes} bytes"
if [ "$dpi_size_bytes" -lt 50000 ]; then
@@ -533,15 +815,19 @@ jobs:
with:
targets: aarch64-linux-android,armv7-linux-androideabi
- name: Cache cargo target
uses: actions/cache@v6
# Restored on every run; saved (under a new key per run, so it stays current) only from
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
- name: Restore cargo cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
client/src-tauri/target
key: ${{ runner.os }}-android-cargo-${{ hashFiles('client/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-android-cargo-
key: ${{ runner.os }}-android-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-android-${{ hashFiles('client/src-tauri/Cargo.lock') }}-
${{ runner.os }}-android-
# Gradle's dependency cache.
#
@@ -617,12 +903,6 @@ jobs:
working-directory: client
run: npx tauri android init
# `android init` seeds placeholder launcher icons; re-apply ours so
# the Android app icon matches the desktop app icon.
- name: Apply app icon
working-directory: client
run: npx tauri icon src-tauri/icons/sources/macos_squircle_1024.png
# Keep the manifest patch in the build-verification path too, so a
# regression in android-postinit-patch.sh is caught by CI (release
# parity; the loopback + backup config is harmless for debug).
@@ -641,6 +921,16 @@ jobs:
if-no-files-found: error
retention-days: 7
- name: Save cargo cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
client/src-tauri/target
key: ${{ runner.os }}-android-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
# ─────────────────────────────────────────────────────────────────────
# Aggregate gate. One stable check that turns green only when every
# required job above succeeded. Branch protection on `main` requires
@@ -662,6 +952,10 @@ jobs:
needs:
- version-sync
- rust-engine
- rust-engine-tests
- rust-ctest
- ava1
- ava1-ctest-sanitize
- rust-engine-targets
- rust-desktop
- client-frontend
+61
View File
@@ -0,0 +1,61 @@
name: pages
# The website (site/) on GitHub Pages: https://phantomptr.github.io/ps5upload/
#
# The page reads GitHub's releases API live in the visitor's browser, so a new release shows
# up without a deploy. Each deploy also writes site/releases.json, the copy the page falls
# back on when the API can't be reached (offline, or GitHub's anonymous rate limit).
#
# When it deploys:
# - a change to site/ (or this file) on main;
# - 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:
push:
branches: [main]
paths: ["site/**", "scripts/site-releases.mjs", ".github/workflows/pages.yml"]
workflow_run:
workflows: [publish]
types: [completed]
# 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:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
with:
ref: main
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Snapshot the releases (fallback for the page)
run: node scripts/site-releases.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: site
- id: deployment
uses: actions/deploy-pages@v5
+14 -17
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
@@ -136,7 +144,9 @@ jobs:
name: build-client-${{ matrix.platform }}-${{ matrix.arch }}
needs: build-payload
runs-on: ${{ matrix.os }}
timeout-minutes: 45
# The Intel macOS runner took ~33 min for v5.35/5.36 and ran past 45 on
# v5.37.0, cancelling the whole release (create-release needs every build).
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
@@ -186,16 +196,9 @@ jobs:
with:
targets: ${{ matrix.rust_target }}
- name: Cache cargo target
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
client/src-tauri/target
key: ${{ runner.os }}-${{ matrix.arch }}-tauri-${{ hashFiles('client/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.arch }}-tauri-
# No cargo cache here: release runs are tag runs, and a cache a tag run saves is visible to
# that tag only, so saving never helped a later release (it filled the shared 10 GiB and
# evicted CI's caches); and CI's debug clippy/test caches don't match this release build.
# Bounded and retried on purpose. During the v5.3.3 publish this step
# stalled inside apt for 45 minutes, ate the whole job budget, and took
@@ -574,12 +577,6 @@ jobs:
working-directory: client
run: npx tauri android init
# `android init` seeds placeholder launcher icons; re-apply ours so
# the released Android app icon matches the desktop app icon.
- name: Apply app icon
working-directory: client
run: npx tauri icon src-tauri/icons/sources/macos_squircle_1024.png
# Post-init manifest patches: (1) loopback-only cleartext so the
# renderer can reach the in-process engine in release builds
# (otherwise the engine shows "red"), and (2) backup rules so user
+55
View File
@@ -0,0 +1,55 @@
# Cuts a release from GitHub instead of a local clone: creates the annotated
# `vX.Y.Z` tag that scripts/release.sh would push, at the commit this run was
# dispatched on, then dispatches publish.yml for it.
#
# gh workflow run release-tag.yml --ref main
#
# A tag pushed with GITHUB_TOKEN does not trigger `push: tags` workflows, so
# publish.yml is dispatched explicitly (workflow_dispatch is exempt from that
# rule). The tag is VERSION's; a run whose VERSION already has a tag stops.
name: release-tag
on:
workflow_dispatch:
permissions:
contents: write
actions: write
concurrency:
group: release-tag
cancel-in-progress: false
jobs:
tag:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Create and push the tag
id: tag
run: |
version="$(tr -d '[:space:]' < VERSION)"
if ! echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::VERSION '$version' is not X.Y.Z"
exit 1
fi
tag="v$version"
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then
echo "::error::$tag already exists"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$tag" -m "$tag" "$GITHUB_SHA"
git push origin "refs/tags/$tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Tagged $GITHUB_SHA as $tag"
- name: Dispatch publish
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.tag }}
run: gh workflow run publish.yml --ref "$TAG" -f tag="$TAG"
+18 -7
View File
@@ -12,7 +12,7 @@ desktop.ini
.aider*
# ─── Node / pnpm / yarn ──────────────────────────────────────────────────
node_modules/
node_modules
.pnp
.pnp.*
.yarn/*
@@ -53,6 +53,7 @@ scripts/.translate-cache.json
# ─── Client (Vite / Tauri) ───────────────────────────────────────────────
client/dist/
client/dist-console/
client/.vite/
client/out/
client/.cache/
@@ -72,9 +73,7 @@ tests/lab/elev_probe/*.elf
# ─── Bench artefacts (generated on demand) ───────────────────────────────
bench/fixtures/
bench/results/
bench/reports/
bench/baselines/*.tmp
# ─── Logs / debug dumps ──────────────────────────────────────────────────
# Anchor `logs/` and `debug/` to the repo root so the patterns don't
@@ -148,6 +147,7 @@ playwright-report/
test-results/
/output/playwright/
/output/research-*/
/output/validate/
/.playwright-cli/
# ─── Dev screenshots / debug captures ────────────────────────────────────
@@ -160,7 +160,7 @@ test-results/
#
# So ignore image/video files AT THE ROOT by default and name the
# exceptions. Anchored with a leading slash, so real assets under
# client/icons/, client/public/ and client/src-tauri/icons/ are untouched.
# client/public/ and client/src-tauri/icons/ are untouched.
/*.png
/*.jpg
/*.jpeg
@@ -195,7 +195,7 @@ ps5upload.elf.gz.blake3.tmp
# ─── Built payload artifacts ────────────────────────────────────────────
# CI rebuilds these from C source on every release (see
# .github/workflows/release.yml: "make payload" step). Tracking them
# .github/workflows/publish.yml: "make payload" step). Tracking them
# caused two recurring problems:
# 1. Binary churn: contributors who run `make payload` locally get a
# diff-noise commit they have to revert (hit twice in 2.8.0 dev).
@@ -248,8 +248,8 @@ payload/sqlite3.o
# developer's own console for backport work. NEVER committed: these are Sony
# binaries, and every comparable project (BackPork, Backport Kitchen,
# Auto-Backpork, MacPork) refuses to redistribute them for the same reason.
# Populate with scripts/gather-fakelibs.py; the tool sources them from the
# user's own games at runtime.
# The app gathers them itself (Backport > Scan this console) from the user's
# own games, into ~/.ps5upload/fakelibs.
fakelibs/
# Brainstorming mockups (visual companion)
@@ -257,3 +257,14 @@ fakelibs/
# ─── Local-only docs (specs, plans, research — kept off GitHub) ──────────
docs/
# Intermediate objects of the vendored elfldr build (its ELF is tracked).
third_party/elfldr/*.o
third_party/elfldr/socksrv.elf
third_party/elfldr/bootstrap.elf
# Our build of the PS5 SDK startup code (payload/sdk-crt.mk)
payload/third_party/sdk-crt/build/
# ─── Release announcement drafts (make release-post) ─────────────────────
.release_posts/
+1579
View File
File diff suppressed because it is too large. Load diff
+23 -12
View File
@@ -12,7 +12,7 @@ same way.
3. Make your change, keeping it focused — one logical change per PR.
4. **Run the quality gate locally** (see below) until it's green.
5. Open a **pull request** against `phantomptr/ps5upload:main` and fill out
the PR template (`.github/PULL_REQUEST_TEMPLATE.md`).
the PR template.
Every PR is gated by CI (the **`PR gate`** check) and reviewed by the code
owner before it can merge. PRs are squash-merged, so your branch becomes a
@@ -29,27 +29,38 @@ make run-client # launch the Tauri dev app
See the [README](README.md) and [TESTING.md](TESTING.md) for the full
toolchain (PS5 Payload SDK, Rust, Node 22, Tauri prerequisites).
## Quality gate — run before opening a PR
The PS5 Payload SDK release is pinned in `scripts/ps5-sdk.env`. `make update-sdk`
moves the pin to the latest upstream release and installs it (`SDK_TAG=vX.Y` for a
specific one, `SDK_CHECK=1` to only check); see TESTING.md, Layer 2.
CI runs the same checks; running them locally first saves a round-trip:
## Quality gate: run before opening a PR
CI runs the same checks; running them locally first saves a round-trip.
`npm run validate` runs most of it in one command (version sync, script checks, i18n,
engine and desktop fmt/clippy/tests, client typecheck/lint/tests/build). The complete gate is
these, and `cargo fmt --check` must pass in BOTH `engine/` and `client/src-tauri`:
```bash
npm run validate # lints + unit/integration tests + typecheck + i18n + build
( cd engine && cargo fmt --all -- --check ) # fmt in BOTH workspaces
( cd client/src-tauri && cargo fmt --all -- --check )
( cd engine && cargo clippy --workspace -- -D warnings )
( cd engine && cargo test --workspace )
( cd engine && cargo test -p ava1-ctest -- --test-threads=1 )
make test-payload # needs PS5_PAYLOAD_SDK
make check-no-ftx2 # the retired protocol must stay gone
( cd client && npm run lint && npm run typecheck && npm test && npm run build:vite )
npm run i18n:check # from the repo root
```
`npm run validate` covers, across all three layers:
- **Lints** — `cargo fmt`/`clippy` (engine + Tauri shell), ESLint, `tsc`,
script syntax/audit, and the i18n coverage gate.
- **Unit + integration tests** — `cargo test --workspace` (protocol, core,
engine, pkg, and the mock-FTX2 integration tests — no PS5 required),
the Tauri shell tests, and the client Vitest suite.
`ava1-ctest` builds the payload's AVA1 C on the host and tests it against the Rust side, so no
PS5 is needed. `check-no-ftx2` fails if the old protocol name or ports 9113/9114 reappear
outside the changelog and a short list of exceptions in the Makefile.
Other useful targets:
```bash
make test # script + engine + payload + client checks
npm run validate:full # adds the payload ELF build (needs PS5_PAYLOAD_SDK)
npm run validate:full # adds the payload ELF build and self-tests
npm run coverage # frontend + Rust coverage reports
```
+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
+1103 -951
View File
File diff suppressed because it is too large. Load diff
+309 -69
View File
@@ -2,17 +2,12 @@
#
# Tree layout (current):
# payload/ — PS5 C payload (FreeBSD 11)
# engine/ — Rust workspace: ftx2-proto, ps5upload-core, -engine HTTP service,
# -lab CLI, -tests mock server, -bench, -pkg
# engine/ — Rust workspace: ava1, ps5upload-core, -ava1, -engine HTTP service,
# -lab CLI, -tests, -pkg
# client/ — Tauri 2 desktop app, cross-platform (Linux/macOS/Windows, x64+arm64)
# tests/ — root integration smoke + tests/lab/ (real-hardware shell scripts)
# bench/ — golden workloads, baselines, perf-gate helpers
# bench/ — hardware sweep, golden workloads and profiles
# scripts/ — install + dev helpers (one per OS, plus shared mjs utilities)
#
# Retired pre-2.1: app/ (browser server), shared/ (legacy JS modules),
# ui/ (empty scaffold), client/electron/ (Tauri replaces Electron),
# specs/ (consolidated into in-tree doc comments + CHANGELOG), tools/
# (folded into scripts/). The 1.x C payload was already gone pre-rename.
JOBS ?= $(shell getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 4)
# `npm ci` — NOT `npm install`. The lockfile carries `libc` fields that select
@@ -35,14 +30,14 @@ export PS5_PAYLOAD_SDK
PS5_SDK_TAG := $(shell sed -n 's/^PS5_SDK_TAG=//p' scripts/ps5-sdk.env 2>/dev/null)
# On macOS the payload SDK's `prospero-clang` wrapper resolves `ld.lld`
# and `clang` through `prospero-llvm-config`. SDK v0.42 supports LLVM 16–22.
# Homebrew exposes the current major as `llvm` (not `llvm@22`), so discover
# and `clang` through `prospero-llvm-config`. SDK v0.44 supports LLVM 16–23.
# Homebrew exposes the current major as `llvm` (not `llvm@23`), so discover
# its real prefix and retain versioned fallbacks for older installations.
# Linux/WSL picks up `llvm-config-<N>` from apt naturally.
ifeq ($(shell uname -s),Darwin)
MACOS_LLVM_CONFIG := $(shell \
if command -v brew >/dev/null 2>&1; then \
for formula in llvm llvm@22 llvm@18; do \
for formula in llvm llvm@23 llvm@22 llvm@18; do \
prefix="$$(brew --prefix "$$formula" 2>/dev/null || true)"; \
if [ -n "$$prefix" ] && [ -x "$$prefix/bin/llvm-config" ]; then \
printf '%s\n' "$$prefix/bin/llvm-config"; \
@@ -80,10 +75,6 @@ ANDROID_APK := $(ANDROID_GEN_DIR)/app/build/outputs/apk/universal/debug/a
# Package id (matches tauri.conf.json `identifier`). Used to recover from a
# signature-mismatched prior install during the convenience deploy step.
ANDROID_APP_ID := com.phantomptr.ps5upload
# App-icon source (relative to CLIENT_DIR). `tauri android init` seeds
# placeholder launcher icons; we re-apply ours so the Android icon matches
# the desktop app icon. gen/ is gitignored, so this runs at init time.
ANDROID_ICON_SRC := src-tauri/icons/sources/macos_squircle_1024.png
# rustup's *real* toolchain bin (not the ~/.cargo/bin shim). The Android std
# lives only here; Homebrew `rust` ships only the host std. We pin PATH to this
# dir for the android recipes so cross-compilation works even when Homebrew's
@@ -91,12 +82,17 @@ ANDROID_ICON_SRC := src-tauri/icons/sources/macos_squircle_1024.png
# picks Homebrew's cargo and fails with "can't find crate for `core`/`std`").
RUSTUP_CARGO_BIN := $(shell dirname "$$(rustup which cargo 2>/dev/null)" 2>/dev/null)
# Env preamble shared by the android recipes.
ANDROID_ENV = JAVA_HOME="$(ANDROID_JAVA_HOME)" ANDROID_HOME="$(ANDROID_HOME)" NDK_HOME="$(ANDROID_NDK_HOME)" $(if $(RUSTUP_CARGO_BIN),PATH="$(RUSTUP_CARGO_BIN):$$PATH")
# Debug APKs keep line tables only. With full debug info the app library was
# 558 MB, stored uncompressed, so the APK passed 1.1 GB and `adb install`
# failed on an emulator with INSTALL_FAILED_INSUFFICIENT_STORAGE. Line tables
# keep function names and file:line in backtraces. Set here, not in
# Cargo.toml, so the desktop `tauri dev` build keeps its full debug info.
ANDROID_ENV = JAVA_HOME="$(ANDROID_JAVA_HOME)" ANDROID_HOME="$(ANDROID_HOME)" NDK_HOME="$(ANDROID_NDK_HOME)" CARGO_PROFILE_DEV_DEBUG=line-tables-only $(if $(RUSTUP_CARGO_BIN),PATH="$(RUSTUP_CARGO_BIN):$$PATH")
# adb from the SDK (falls back to PATH if a device-only adb is installed).
ADB ?= $(ANDROID_HOME)/platform-tools/adb
.PHONY: all help
.PHONY: install install-ubuntu install-macos install-windows
.PHONY: install install-ubuntu install-macos install-windows update-sdk
.PHONY: setup setup-engine setup-payload setup-client
.PHONY: build payload installer engine client _engine-release _payload-if-ready _android-build-if-ready
.PHONY: test test-root test-engine test-engine-coverage test-desktop test-payload test-client test-client-coverage
@@ -104,6 +100,7 @@ ADB ?= $(ANDROID_HOME)/platform-tools/adb
.PHONY: quality quality-full quality-hardware ci ci-full
.PHONY: clean clean-payload clean-engine clean-client
.PHONY: verify info install-hooks
.PHONY: test-ava1 test-ava1-sanitize ava1-fuzz-c ava1-soak check-no-ftx2
.PHONY: run-engine run-client dev start _check-tauri-system-deps
.PHONY: install-engine uninstall-engine
.PHONY: dist dist-win dist-win-arm dist-mac dist-mac-x64 dist-linux dist-linux-arm
@@ -128,6 +125,8 @@ help:
@echo " make install-ubuntu - Ubuntu/Debian/WSL2: apt + rustup + node + PS5 SDK $(PS5_SDK_TAG)"
@echo " make install-macos - macOS: brew + rustup + LLVM + PS5 SDK $(PS5_SDK_TAG)"
@echo " make install-windows - Windows 11: winget + rustup + VS Build Tools + PS5 SDK $(PS5_SDK_TAG)"
@echo " make update-sdk - Move the PS5 SDK pin to the latest release and install it"
@echo " (SDK_TAG=vX.Y for a specific one; SDK_CHECK=1 to only check)"
@echo ""
@echo "Quick start (after install):"
@echo " 1. make setup - Check toolchains, install client deps"
@@ -155,8 +154,9 @@ help:
@echo " make coverage - Rust + frontend coverage reports"
@echo " make coverage-engine - Rust coverage report only"
@echo " make coverage-client - Frontend coverage report only"
@echo " make test-engine - cargo test --workspace"
@echo " make test-engine - cargo test --workspace (C interop serial)"
@echo " make test-desktop - Tauri Rust cargo check/clippy/test"
@echo " make test-ava1 - AVA1 Rust + C conformance and interop tests"
@echo " make test-payload - Validate $(PAYLOAD_ELF)"
@echo " make test-client - Type-check + lint + unit tests + build client UI"
@echo ""
@@ -171,8 +171,10 @@ help:
@echo " make dist-linux|dist-linux-arm"
@echo ""
@echo "Android (Tauri mobile — needs Android SDK + JDK 17 + NDK + rustup):"
@echo " make run-android - Build + run with live reload; boots the emulator WITH its window"
@echo " make run-android-background - Same, but the emulator boots headless (no window)"
@echo " make run-android - Build + run with live reload; emulator in the FOREGROUND (window)"
@echo " make run-android-background - Same, but the emulator runs headless (no window)"
@echo " Both reuse a running emulator instead of booting another; run-android"
@echo " brings its window to the front (and reopens a headless one with a window)."
@echo " make android-deploy - Build APK + install/update it on connected device(s)"
@echo " make android-build - Build a debug APK (no device needed)"
@echo " make android-init - One-time: scaffold src-tauri/gen/android"
@@ -180,7 +182,6 @@ help:
@echo " make emu-test - Build + run the app on a headless emulator, with a screenshot"
@echo " make emu-start - Boot the test emulator (headless); emu-stop to shut it down"
@echo " make emu-status - Show AVDs, whether one is running, and where artifacts go"
@echo " make run-android - Boots the emulator (windowed) only if nothing is attached"
@echo " emu-test leaves the emulator up for fast re-runs; PS5UPLOAD_EMU_TEARDOWN=1 stops it"
@echo ""
@echo "Auto-launch (engine starts at OS login):"
@@ -240,6 +241,13 @@ install-windows:
exit 1; \
fi
# Bump the PS5 Payload SDK pin (scripts/ps5-sdk.env) to the latest upstream
# release, or SDK_TAG, and install it into $(PS5_PAYLOAD_SDK); the old SDK is
# kept as a backup. SDK_CHECK=1 only reports whether a newer release exists.
update-sdk:
@PS5_SDK_INSTALL_DIR="$(PS5_PAYLOAD_SDK)" bash scripts/update-ps5-sdk.sh \
$(if $(SDK_TAG),--tag $(SDK_TAG)) $(if $(SDK_CHECK),--check)
#──────────────────────────────────────────────────────────────────────────────
# Setup
#──────────────────────────────────────────────────────────────────────────────
@@ -300,9 +308,17 @@ setup-client:
@command -v node >/dev/null 2>&1 || { echo "ERROR: Node.js is not installed."; exit 1; }
@command -v npm >/dev/null 2>&1 || { echo "ERROR: npm is not installed."; exit 1; }
@echo "✓ Node.js toolchain found: node $$(node --version), npm $$(npm --version)"
@echo "Installing client dependencies..."
@cd $(CLIENT_DIR) && $(NPM_INSTALL)
@echo "✓ Client dependencies installed"
@# Reinstall only when the lockfile (or Node) changed since the last install. npm ci
@# deletes node_modules first — including Vite's dependency cache — so running it on
@# every make broke any dev server already running (make run-client beside
@# run-android or emu-test): its screens failed with "Importing a module script failed".
@cd $(CLIENT_DIR) && want="$$(node -e "const c=require('crypto'),f=require('fs');process.stdout.write(c.createHash('sha256').update(f.readFileSync('package-lock.json')).update(process.version).digest('hex'))")"; \
if [ -f node_modules/.ps5upload-installed ] && [ "$$(cat node_modules/.ps5upload-installed)" = "$$want" ]; then \
echo "✓ Client dependencies already match package-lock.json"; \
else \
echo "Installing client dependencies..." && $(NPM_INSTALL) && printf '%s' "$$want" > node_modules/.ps5upload-installed && \
echo "✓ Client dependencies installed"; \
fi
#──────────────────────────────────────────────────────────────────────────────
# Build
@@ -337,6 +353,7 @@ _android-install-if-device:
echo "⚠ No Android device connected — APK is at $$apk."; \
echo " Plug in a device (USB debugging on), then: $$adb install -r \"$$apk\""; \
else \
failed=0; \
for d in $$devs; do \
echo "Updating the app on device $$d (adb install -r)..."; \
out=$$("$$adb" -s "$$d" install -r "$$apk" 2>&1); \
@@ -349,18 +366,36 @@ _android-install-if-device:
echo "✓ App reinstalled on $$d"; \
else \
echo "⚠ Could not install on $$d — APK is at $$apk (install manually: $$adb -s $$d install -r \"$$apk\")."; \
failed=1; \
fi; \
elif printf '%s' "$$out" | grep -qi 'INSUFFICIENT_STORAGE' && case "$$d" in emulator-*) true;; *) false;; esac; then \
echo "⚠ $$d is out of space — removing the old install (an emulator's app data is disposable) and retrying..."; \
"$$adb" -s "$$d" uninstall "$(ANDROID_APP_ID)" >/dev/null 2>&1 || true; \
if out=$$("$$adb" -s "$$d" install "$$apk" 2>&1) && ! printf '%s' "$$out" | grep -qi 'Failure'; then \
echo "✓ App reinstalled on $$d"; \
else \
echo "⚠ Still could not install on $$d — free space on it (or wipe it: emulator -avd <name> -wipe-data):"; \
printf '%s\n' "$$out" | sed 's/^/ /'; \
failed=1; \
fi; \
else \
echo "⚠ adb install failed on $$d (build is fine — APK is at $$apk):"; \
printf '%s\n' "$$out" | sed 's/^/ /'; \
if printf '%s' "$$out" | grep -qi 'INSUFFICIENT_STORAGE'; then \
echo " The device is out of space. Free some, then: $$adb -s $$d install -r \"$$apk\""; \
fi; \
failed=1; \
fi; \
done; \
if [ $$failed -eq 0 ]; then echo "✓ Android build deployed to connected device(s)."; \
else echo "⚠ Android build NOT deployed to every device — see above."; fi; \
fi
# Build the Android APK and install/update it on the connected device(s) in
# one step — the simplest "push my latest changes to the phone".
# The install step reports its own outcome: it used to be followed by an
# unconditional "✓ deployed", even straight after a failed install.
android-deploy: android-build _android-install-if-device
@echo "✓ Android build deployed to connected device(s)."
# Rebuild the payload only when the PS5 SDK is configured; otherwise skip so a
# frontend-only `make run-client` keeps working without the SDK. The shell
@@ -410,6 +445,16 @@ installer: setup-payload
@$(MAKE) -C $(INSTALLER_DIR) -j$(JOBS)
@echo "✓ Built $(INSTALLER_ELF)"
# The patched elfldr the engine sends to replace a console's stock one (see
# third_party/elfldr/README.md). Its ELF is checked in, so a build without the
# SDK still embeds it; rebuild after changing the vendored sources.
.PHONY: elfldr
elfldr: setup-payload
@echo "Building the patched elfldr..."
@$(MAKE) -C third_party/elfldr
@rm -f third_party/elfldr/*.o
@echo "✓ Built third_party/elfldr/elfldr-ps5.elf"
send-payload: payload
@echo "Sending payload to $(PS5_HOST):$(PS5_LOADER_PORT) ..."
@if ! command -v python3 >/dev/null 2>&1; then \
@@ -457,28 +502,83 @@ gen-fixtures:
@node scripts/gen-fixtures.mjs
sweep:
@echo "Running FTX2 sweep against live PS5 at $(PS5_HOST):9113 ..."
@node bench/run-ftx2-sweep.mjs --spawn-engine --gen-fixtures
@echo "Running the upload sweep against live PS5 at $(PS5_HOST) ..."
@node bench/run-sweep.mjs --spawn-engine --gen-fixtures
# Wait for the payload's runtime port to accept connections after send.
# Retries 15×/2s = 30s ceiling; exits non-zero if the port never opens.
_wait-payload-ready:
@echo "Waiting for PS5 runtime port 9113 ..."
@echo "Waiting for the helper's AVA1 port 9120 ..."
@i=0; while [ $$i -lt 15 ]; do \
if nc -z -w 1 "$(PS5_HOST)" 9113 >/dev/null 2>&1; then \
if nc -z -w 1 "$(PS5_HOST)" 9120 >/dev/null 2>&1; then \
echo "✓ runtime port open"; exit 0; \
fi; \
i=$$((i+1)); sleep 2; \
done; \
echo "ERROR: PS5 runtime port 9113 did not open within 30s"; exit 1
echo "ERROR: PS5 AVA1 port 9120 did not open within 30s"; exit 1
validate: send-payload _wait-payload-ready
#──────────────────────────────────────────────────────────────────────────────
# check-no-ftx2: the retired protocol (FTX2) and its ports (9113 transfer, 9114
# management) must not reappear anywhere in the repository.
#
# The scope is the whole tree, payload and the host C tests included, except the history the
# project keeps on purpose and the files that must name the legacy protocol (listed below). The
# pattern is case-insensitive "ftx2" or a bare port 9113/9114. Digits on either side are excluded
# so the engine's own 19113 is not a match. Binary files and lockfiles are not searched.
#
# 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
# name this check and its pattern.
# .../ava1-ctest/tests/payload_cutover.rs
# asserts the payload no longer says FTX2 or 9113/9114.
# .../ps5upload-engine/src/ava1_only_tests.rs
# the tests that pin the engine free of the retired symbols.
# client/src/lib/addr.ts,
# client/src/lib/humanizeError.ts tolerate a stale host:9113 / host:9114 a user or an
# older engine message still carries.
# *.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' ':!site/releases.json' \
':!CONTRIBUTING.md' ':!TESTING.md' \
':!protocol/ava1' \
':!Makefile' \
':!.github/workflows/engine-ci.yml' \
':!*Cargo.lock' ':!*package-lock.json' \
':!engine/crates/ps5upload-engine/src/ava1_only_tests.rs' \
':!engine/crates/ps5upload-engine/src/install' \
':!engine/crates/ps5upload-engine/src/fakelibs_api.rs' \
':!engine/crates/ps5upload-engine/src/icon_cache.rs' \
':!engine/crates/ps5upload-ava1/src/pool.rs' \
':!engine/crates/ps5upload-lab/src/main.rs' \
':!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' \
':!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 \
echo "ERROR: FTX2 or port 9113/9114 found above (see the check-no-ftx2 notes in the Makefile)"; exit 1; \
else echo "✓ check-no-ftx2: clean"; fi
validate: check-no-ftx2 send-payload _wait-payload-ready
@echo ""
@echo "── Running smoke suite ────────────────────────────────"
@npm run --silent smoke:hardware
@echo ""
@echo "── Running sweep (default profiles) ────────────────────"
@node bench/run-ftx2-sweep.mjs --spawn-engine --gen-fixtures
@node bench/run-sweep.mjs --spawn-engine --gen-fixtures
@echo ""
@echo "✓ validate complete — see bench/reports/ for the full report"
@@ -488,7 +588,7 @@ validate-xl: send-payload _wait-payload-ready
@npm run --silent smoke:hardware
@echo ""
@echo "── Running sweep (INCLUDING XL 200k-file stress) ───────"
@node bench/run-ftx2-sweep.mjs --spawn-engine --gen-fixtures --xl
@node bench/run-sweep.mjs --spawn-engine --gen-fixtures --xl
@echo ""
@echo "✓ validate-xl complete — see bench/reports/ for the full report"
@@ -587,8 +687,6 @@ emu-test: android-build
android-init: android-deps setup-client
@echo "Scaffolding Android project (tauri android init)..."
@cd $(CLIENT_DIR) && $(ANDROID_ENV) npx tauri android init
@echo "Applying app icon from $(ANDROID_ICON_SRC) (matches the desktop icon)..."
@cd $(CLIENT_DIR) && $(ANDROID_ENV) npx tauri icon $(ANDROID_ICON_SRC) >/dev/null
@echo "Allowing loopback cleartext so the in-process engine is reachable..."
@bash scripts/release/android-postinit-patch.sh
@@ -610,6 +708,11 @@ android-build: android-deps payload setup-client
# run-android: the emulator boots WITH its window (foreground) so the app can be
# seen and used. run-android-background: it boots headless, as emu-test does.
# Either way the dev server stays attached here for live reload.
# Android dev's Vite ports (live reload on the next one up), separate from
# desktop dev's 1420/1421 so `make run-client` and `make run-android` can run
# at the same time.
ANDROID_VITE_PORT ?= 1430
run-android: EMU_WINDOW := 1
run-android-background: EMU_WINDOW := 0
run-android run-android-background: android-deps payload setup-client
@@ -621,7 +724,8 @@ run-android run-android-background: android-deps payload setup-client
echo " (leaving the existing device/emulator running)"; \
fi; \
echo "Launching on the attached Android device/emulator (tauri android dev)..."; \
cd $(CLIENT_DIR) && $(ANDROID_ENV) npx tauri android dev
cd $(CLIENT_DIR) && PS5UPLOAD_VITE_PORT=$(ANDROID_VITE_PORT) $(ANDROID_ENV) npx tauri android dev \
--config '{"build":{"devUrl":"http://localhost:$(ANDROID_VITE_PORT)"}}'
#──────────────────────────────────────────────────────────────────────────────
# Docker — self-hosted engine image. Mirrors what .github/workflows/
@@ -639,15 +743,15 @@ DOCKER_ENGINE_IMAGE ?= ps5upload-engine
docker-engine:
@command -v $(DOCKER) >/dev/null 2>&1 || { echo "ERROR: docker not found on PATH."; exit 1; }
@echo "Building $(DOCKER_ENGINE_IMAGE) image (context: $(ENGINE_DIR)/)..."
@$(DOCKER) build -t $(DOCKER_ENGINE_IMAGE) $(ENGINE_DIR)
@echo "Building $(DOCKER_ENGINE_IMAGE) image (context: repo root, as in CI)..."
@$(DOCKER) build -t $(DOCKER_ENGINE_IMAGE) -f $(ENGINE_DIR)/Dockerfile .
@echo "✓ Built image $(DOCKER_ENGINE_IMAGE) — run with: make docker-engine-run"
# Run the locally-built engine image. Binds the published port and points it at
# the PS5's transfer port. Override PS5_HOST / the bind address as needed.
# the PS5 (AVA1, port 9120). Override PS5_HOST / the bind address as needed.
docker-engine-run: docker-engine
@echo "Running $(DOCKER_ENGINE_IMAGE) — engine on :19113, PS5 at $(PS5_HOST):9113 ..."
@$(DOCKER) run --rm -p 19113:19113 -e PS5_ADDR=$(PS5_HOST):9113 $(DOCKER_ENGINE_IMAGE)
@echo "Running $(DOCKER_ENGINE_IMAGE) — engine on :19113, PS5 at $(PS5_HOST) ..."
@$(DOCKER) run --rm -p 19113:19113 -e PS5_ADDR=$(PS5_HOST) $(DOCKER_ENGINE_IMAGE)
#──────────────────────────────────────────────────────────────────────────────
# Testing
@@ -696,9 +800,7 @@ ci-full: quality-full
test-root:
@echo "Syntax-checking root node scripts..."
@node --check tests/smoke-hardware.mjs
@node --check bench/run-ftx2-sweep.mjs
@node --check bench/run-ftx2-upload.mjs
@node --check bench/check-ftx2-baseline.mjs
@node --check bench/run-sweep.mjs
@node --check scripts/gen-fixtures.mjs
@node --check scripts/check-lockfile.mjs
@node scripts/check-lockfile.mjs --self-test
@@ -708,10 +810,12 @@ test-root:
test-engine: setup-engine
@echo "Running Rust engine tests..."
@cd $(ENGINE_DIR) && $(CARGO) test --workspace
@cd $(ENGINE_DIR) && $(CARGO) test --workspace --exclude ava1-ctest
@# The C interop tests share one C server and must run serially (as in CI).
@cd $(ENGINE_DIR) && $(CARGO) test -p ava1-ctest -- --test-threads=1
@echo "✓ Engine tests passed"
@# Android cross-compile check. The engine cfg's some modules out on
@# Android (remote_pkg, which needs an HTTP client), so a call site added
@# Android cross-compile check. The engine cfg's some code out on
@# Android (RAR, which needs the UnRAR C++ dep), so a call site added
@# without the matching cfg builds fine on every desktop target and fails
@# ONLY here. That is exactly how v5.31.0 shipped with no APK: every local
@# gate was green on macOS while the Android build was broken, and it was
@@ -759,16 +863,6 @@ test-payload: payload
done
@echo "✓ Main payload and PS5Upload installer are PS5 ELFs with gzip resources"
@echo "Running play-time launch/resume self-test (host build)..."
@echo "Running accept-recovery self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -o /tmp/ps5upload-accept-recovery-selftest \
$(PAYLOAD_DIR)/tests/accept_recovery_selftest.c
@/tmp/ps5upload-accept-recovery-selftest
@echo "✓ accept() failures keep the helper serving until its network is gone"
@echo "Running direct-commit apply self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -o /tmp/ps5upload-commit-apply-selftest \
$(PAYLOAD_DIR)/tests/commit_apply_selftest.c
@/tmp/ps5upload-commit-apply-selftest
@echo "✓ a repeat COMMIT never unlinks a destination it cannot replace"
@cc -O2 -Wall -Wextra -Werror -o /tmp/ps5upload-activity-selftest \
$(PAYLOAD_DIR)/tests/activity_launch_selftest.c
@/tmp/ps5upload-activity-selftest
@@ -776,16 +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"
@@ -837,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 \
@@ -860,17 +1066,6 @@ test-payload: payload
/tmp/ps5upload-cdb/sqlite3.o
@/tmp/ps5upload-actjson/selftest
@echo "✓ every Game Activity tab names its titles, in the keys the engine reads"
@echo "Running FTP wire-format self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -o /tmp/ps5upload-ftp-format-selftest \
$(PAYLOAD_DIR)/tests/ftp_format_selftest.c
@/tmp/ps5upload-ftp-format-selftest
@echo "✓ FTP PASV/EPSV/LIST replies match the shapes clients parse"
@echo "Running FTP lifecycle self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include \
-o /tmp/ps5upload-ftp-lifecycle-selftest \
$(PAYLOAD_DIR)/tests/ftp_lifecycle_selftest.c
@/tmp/ps5upload-ftp-lifecycle-selftest
@echo "✓ FTP stop/start drains sessions without stale listeners or fd reuse"
@echo "Running timed initializer serialization self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include \
-o /tmp/ps5upload-timed-init-selftest \
@@ -1181,4 +1376,49 @@ dev: run-client
start: run-client
release-post:
@./scripts/release-posts.sh
@node scripts/release-posts.js $(ARGS)
promo-post:
@node scripts/promo-posts.js $(ARGS)
AVA1_C := payload/ava1
AVA1_C_CODEC := $(AVA1_C)/ava1_wire.c $(AVA1_C)/ava1_frame.c $(AVA1_C)/gen/ava1_gen.c \
$(AVA1_C)/ava1_keys.c $(AVA1_C)/ava1_noise.c $(AVA1_C)/ava1_aead.c $(AVA1_C)/platform_posix.c \
payload/third_party/monocypher/monocypher.c
# scripts/ava1-aead-test.sh: the C AEAD against RFC 8439 and Monocypher, including the
# AVX2 path (x86-64 natively, or under Rosetta 2 on an arm64 Mac).
test-ava1:
scripts/ava1-aead-test.sh
cd engine && cargo test -p ava1 -p ava1-gen -p ava1-chaos
# The C server and the data layer are process-wide singletons
# (one CServer at a time, one ava1_data_start): serial, not two shells.
cd engine && cargo test -p ava1-ctest -- --test-threads=1
# The whole ctest suite with ASan + UBSan on every C unit (review 009 #2a), LSan included on Linux.
# Run it on Linux (CI, or `docker run rust:latest` with clang + libclang-rt-dev installed): the Rust link
# of the sanitized C did not link with Homebrew's clang on macOS when tried (not investigated further).
# engine/crates/ava1-ctest/lsan.supp lists the intentional leaks (test-side Box::leak), each with its reason.
CLANG ?= clang
test-ava1-sanitize:
cd engine && CC=$(CLANG) AVA1_CTEST_SANITIZE=1 RUSTFLAGS="-Clinker=$(CLANG) $$RUSTFLAGS" \
ASAN_OPTIONS=$${ASAN_OPTIONS:-detect_leaks=$$([ "$$(uname)" = Linux ] && echo 1 || echo 0)} \
LSAN_OPTIONS=suppressions=$(CURDIR)/engine/crates/ava1-ctest/lsan.supp \
cargo test -p ava1-ctest -- --test-threads=1
# The host soak (review 009 #2c): AVA1_SOAK_MINUTES=60 make ava1-soak. Add AVA1_CTEST_SANITIZE=1 (and
# CC/RUSTFLAGS as above) to run it under ASan.
ava1-soak:
cd engine && AVA1_SOAK_MINUTES=$${AVA1_SOAK_MINUTES:-5} cargo test -p ava1-ctest --test soak -- --ignored --test-threads=1 --nocapture
ava1-fuzz-c:
$${CC:-clang} -g -O1 -fsanitize=fuzzer,address,undefined -DAVA1_AEAD_PORTABLE -I$(AVA1_C) -I$(AVA1_C)/gen -Ipayload/third_party/monocypher \
-o /tmp/ava1-fuzz-decode $(AVA1_C)/fuzz/fuzz_decode.c $(AVA1_C_CODEC)
/tmp/ava1-fuzz-decode -max_total_time=$${AVA1_FUZZ_SECONDS:-60} -max_len=65536
$${CC:-clang} -g -O1 -fsanitize=fuzzer,address,undefined -I$(AVA1_C) -I$(AVA1_C)/gen -Ipayload/third_party/blake3 \
-DBLAKE3_NO_SSE2 -DBLAKE3_NO_SSE41 -DBLAKE3_NO_AVX2 -DBLAKE3_NO_AVX512 -DBLAKE3_USE_NEON=0 \
-o /tmp/ava1-fuzz-data $(AVA1_C)/fuzz/fuzz_data.c $(AVA1_C)/ava1_manifest.c $(AVA1_C)/ava1_journal.c \
$(AVA1_C)/ava1_ranges.c $(AVA1_C)/ava1_wire.c $(AVA1_C)/ava1_frame.c $(AVA1_C)/gen/ava1_gen.c \
payload/third_party/blake3/blake3.c payload/third_party/blake3/blake3_dispatch.c \
payload/third_party/blake3/blake3_portable.c
/tmp/ava1-fuzz-data -max_total_time=$${AVA1_FUZZ_SECONDS:-60} -max_len=65536
+191 -408
View File
@@ -5,15 +5,14 @@
</p>
<p align="center">
<strong>Fast, reliable transfers from your computer to your PS5.</strong><br/>
Transfer · Mount · Browse — designed to live alongside your PS5-side tools.
PS5 Upload gets your games, apps and homebrew onto a jailbroken PS5 quickly and reliably: uploads resume after a dropped connection, installs pick the route that works, and you can browse files, manage the console and run cheats from your computer, phone or browser.
</p>
<p align="center">
<a href="https://github.com/phantomptr/ps5upload/releases"><img alt="release" src="https://img.shields.io/github/v/release/phantomptr/ps5upload?display_name=tag&sort=semver&color=blue" /></a>
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-GPL--3-green" /></a>
<img alt="platforms" src="https://img.shields.io/badge/platforms-macOS_·_Linux_·_Windows-lightgrey" />
<img alt="firmware" src="https://img.shields.io/badge/PS5_firmware-1.00_–_13.60_supported_•_9.x–12.x_tested-brightgreen" />
<img alt="platforms" src="https://img.shields.io/badge/platforms-macOS_·_Linux_·_Windows_·_Android_·_Web-lightgrey" />
<img alt="firmware" src="https://img.shields.io/badge/PS5_firmware-1.00_–_13.60_supported_•_5.10,_9.60_&_13.60_tested-brightgreen" />
<a href="https://discord.gg/fzK3xddtrM"><img alt="discord" src="https://img.shields.io/badge/discord-join-5865F2" /></a>
</p>
@@ -21,131 +20,36 @@
## What it does
- **Fast transfer** — FTX2 binary protocol with BLAKE3 per-shard
verification, small-file packing, and resume on disconnect.
Uses your LAN flat-out. Pack worker absorbs transient
`EIO`/`EMFILE` hiccups so a 200k-file game upload doesn't get
killed by one unlucky syscall.
- **Upload queue** — line up multiple games or images, hit Start,
walk away. Every running row shows live MiB/s and ETA; done
rows show the wall-clock-average rate so you can spot a slow
destination. Queue state survives app restarts.
- **Compressed archive uploads (`.zip` / `.7z` / `.rar`)** — keep a game
dump as a single archive on your PC (less disk, easier to move) and
upload it directly. ps5upload decompresses on the host and streams
the files into the same FTX2 pipeline, so they land **already
extracted** on the PS5 — no manual unpack, no temp copy of the whole
game. The Upload screen previews the expansion (`zipped → extracted`,
file count, space saved) and detects the embedded game. Decompresses
one file at a time, so a 100 GB archive doesn't need 100 GB of RAM.
`.7z` and `.rar` stream straight into the pipeline and write nothing to
your disk at all; `.zip` spills large entries to a temp file because its
format needs random access. `.rar` is desktop-only (the UnRAR C dep is
excluded from the Android build).
- **Native image mount** — attach `.exfat` and `.ffpkg` images on
the PS5 (MDIOCATTACH + nmount) with no third-party helper. Every
mount survives payload restarts and auto-reconciles on startup.
Mount read-write to **edit an image in place** — add DLC, swap
assets, or apply a backport patch to a game that ships as an image
rather than a folder. Edits go straight into the image file, so the
app warns before you start.
When ShadowMount+ owns the image (it mounts everything read-only and
re-adopts anything it finds), **Edit files…** checks the image out —
moving it out of ShadowMount+'s scan folder, mounting it read-write
where you choose, and putting it back when you finish. The checkout is
journalled on the console, so an edit interrupted by a crash or a
reboot can still be finished later, from any machine.
- **Browse everything** — list games anywhere on the PS5 (including
inside mounted images), disk images, files, and volumes. Run FS
ops (add files, replace a file, chmod, delete, move, copy, mkdir)
with a real directory tree. Bulk delete of a 200k-file folder
shows live progress with a working Stop button.
- **NAS / SMB upload** — browse a Windows share or Samba NAS from the
app, download to this computer, or **upload a file or folder
straight to the PS5** in one step (streamed to a host temp dir, then
FTX2 — no 2 GiB memory cap on that path).
- **FTP server on the PS5** — optional built-in FTP for FileZilla and
other clients (default port **2122**, so it coexists with ftpsrv on
2121). Not a replacement for FTX2 Upload.
- **Backport helpers** — Fakelib folder manager, BPS patch apply, and
SDK Version Changer for folder dumps that need newer system libs.
Runtime mount is still BackPork or ShadowMount+ (don’t run both).
- **Stay-in-the-loop notifications** — in-app alerts mirror to your
computer's notification center (macOS / Windows / Linux, plus the
Android shade) when a transfer finishes or fails in the background,
and the PS5 itself shows a toast when an upload starts and completes.
Your machine is kept awake automatically while a transfer runs.
- **Hardware view** — model, serial, uptime, storage and RAM, refreshed
live; plus a fan-threshold control that rings through to
`/dev/icc_fan` for quieter operation. Temperatures (CPU, SoC, M.2) and
CPU frequency are read **on demand** (a button), not auto-polled — each
read briefly pauses the system UI, so polling them could destabilize the
console. Some readings on that panel are simply not available on retail
firmware and show `—`: SoC clock, SoC power, CPU usage, fan duty, and
the per-drive sensor list. Storage totals and temperatures do work.
- **Power control & wake** — reboot, rest mode, or shut down the PS5 from
the app, and **wake it from standby** over the network (Sony's discovery
protocol — a PS5 does not respond to Wake-on-LAN). With the console's
Remote Play session keys stored, **Wake & sign in** brings it up straight
on your user's home screen instead of the user-select screen, the way the
PS Remote Play app does. Waking needs three console settings enabled
(Remote Play, plus *Stay Connected to the Internet* and *Enable Turning On
PS5 from Network* under Rest Mode) — the app spells them out, since without
them the wake fails silently.
- **Send any payload** — push `.elf`, `.bin`, `.js`, `.lua`, or
`.jar` files to the PS5's loader port (typical defaults: `.elf` →
9021 elfldr, `.js` → 50000 WebKit-stage, `.lua` → 9026, `.jar` →
9025 BD-JB / BDJ; custom loaders may listen anywhere). Recent-sends
history with click-to-replay and per-row success/fail badges. Curated
payload catalogue (kstuff, ShadowMount+, etaHEN, …) fetches releases on
demand — or **add your own GitHub/Gitea repo** and it tracks + caches
that too. **Payload playlists** run a scripted boot sequence, and a
playlist step can pull straight from a repo at run time so you don't
keep a pile of `.elf` files on your PC.
- **Install packages** — install a PS4 `.pkg` or PS5 fake package two ways:
**Stream install** sends it straight from your PC (nothing is copied to
the PS5 first — handy when console storage is tight), and **Upload &
install** copies it to the PS5's package library first, where it stays so
you can **Install**, **Reinstall** or **Delete** it later without
re-uploading. **Install all** does a whole set in base → update → DLC order.
Packages on a NAS/SMB share or behind an HTTP(S) link install the same way.
Everything goes through ps5upload's own on-console installer, and an
install is only marked done once the console has pulled the whole package
and the result checks out — an update that didn't raise the game's version
is reported, not called a success. Base, update and DLC installs verified
on FW 5.10.
- **Convert to FPKG (beta)** — turn a decrypted game folder or an `.exfat` /
`.ffpkg` image into an installable fake package on your computer, with the
same compression Sony's packages use, then install it with Stream install
or Upload & install. Enable it under Settings → Beta features.
- **Web browser access** — run the engine (or the official Docker image)
and manage your PS5 from any browser on the LAN, the full app served
over HTTP. No desktop install needed on that machine. Unauthenticated —
keep it on a trusted LAN. See the self-hosted-engine FAQ below.
- **Screenshots & video clips** — browse the PS5's Capture Gallery,
preview, and download screenshots (HDR `.jxr` auto-converted to PNG) or
gameplay video clips to your computer.
- **Register + launch** — Library row's Play button always registers
first (idempotent if already registered), retries with DRM-type
patch on rejection, then launches. Unmount unregisters every
title inside the image first so the dashboard stays clean —
no ghost tiles after unmount.
- **Speaks your language** — the whole UI, including error messages
and troubleshooting hints, is available in 20 languages: English,
Simplified & Traditional Chinese, Spanish, Hindi, Arabic, Bengali,
Brazilian Portuguese, Russian, Japanese, German, French, Korean,
Turkish, Vietnamese, Indonesian, Italian, Thai, Polish, and Hungarian.
- **Fast, resumable uploads** of games, folders, disk images and `.zip` / `.7z` / `.rar`
archives (unpacked on the way, nothing extracted to your disk). Everything runs over
**AVA1**, one encrypted connection on port 9120. Every file is verified, and an upload
continues after a dropped connection, a helper restart or rest mode. A job that will not
fit is refused up front, counting what is already on the console. Queue, live speed and ETA.
- **Install any package.** PS4 `.pkg` and PS5 fake packages (base, update, DLC) from your
computer (**Stream & install** or **Upload & install**), a NAS/SMB share, a USB drive or a
link. If the PS5 refuses one route, the app offers another.
- **Collection.** Every game in your folders (including folders on a NAS, scanned in place),
grouped with its updates and DLC, with what each PS5 already has and one click to install the
rest. Organize, de-duplicate and clean up the drives. Replaces PS Game Library.
- **Convert Games.** Build an installable package from a decrypted game folder or an
image, or write a game folder as a game image (`.exfat`, `.ffpkg`, `.ffpfs`, or the smaller
`.ffpfsc`) that ShadowMount+ mounts, and upload it in one step.
- **Browse and manage.** Files, games, disk images (mount, edit in place), saves, screenshots
and video clips; register, launch, stop and uninstall games; copy, move (Move to…),
delete, set permissions.
- **Cheats, fan curve, hardware view,** Remote Play pairing, payload sender with a catalogue
and playlists, and backport tools.
- **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 ([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
- **System pkg patches.** `sceAppInstUtilInstallByPackage` is built
for game pkgs; NPXS-prefix system pkgs (Store updates, Settings
app patches) register but the install path freezes Sony's mgmt
service mid-flight on most firmwares. Use the on-PS5 Settings →
Debug Settings → Game → Package Installer for those.
- **Insecure pkg signing keys.** The engine only installs pkgs signed
with keys already in the PS5's trust store. It does not generate or
inject fake signing keys.
- **System package patches** (NPXS Store or Settings updates). Use the PS5's own
Settings → Debug Settings → Game → Package Installer.
- **Sign packages with fake keys.** It only installs packages the console already trusts.
## A quick look
@@ -161,9 +65,72 @@ 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. |
| 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. |
| 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. |
### NixOS
No NixOS artifact is published on the Releases page, but ps5upload is
packaged in [GriefNorth's NUR](https://github.com/GriefNorth/nur-packages) as
`ps5upload` (x64 and ARM64) — it wraps the same `.AppImage` and needs no
FUSE. With flakes:
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nur.url = "github:GriefNorth/nur-packages";
};
outputs = { nixpkgs, nur, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
({ pkgs, ... }: {
environment.systemPackages = [
nur.packages.${pkgs.system}.ps5upload
];
})
];
};
};
}
```
Then `nixos-rebuild switch --flake .#myhost` and launch **ps5upload** from
your app menu. Without flakes, call the package straight out of the repo —
and note that ps5upload bundles UnRAR for `.rar` support, so nixpkgs needs
`config.allowUnfree = true`:
```nix
{ pkgs, ... }:
{
nixpkgs.config.allowUnfree = true;
environment.systemPackages = [
(pkgs.callPackage
((builtins.fetchTarball
"https://github.com/GriefNorth/nur-packages/archive/refs/heads/main.tar.gz")
+ "/pkgs/ps5upload")
{ })
];
}
```
Or try it without installing anything:
```bash
nix run github:GriefNorth/nur-packages#ps5upload
```
The Nix expression pins one release version, so bump it there when a new
ps5upload comes out. Prefer to run the AppImage yourself? Prefer the
`./PS5Upload.sh` wrapper from the zip above — on NixOS the bare
`.AppImage` usually renders an empty window, because its bundled
`libwayland-client` shadows the host one and WebKitGTK then fails to create
an EGL display. The Nix package works around that for you.
### First-launch warnings (and why they're there)
@@ -217,7 +184,7 @@ only needs to be done once per install.
The app checks GitHub for updates once per launch (Settings → Updates)
and downloads a fresh archive to your Downloads folder when you click
Download — replace the old app manually and relaunch.
Download. Replace the old app and relaunch; the app then offers to update the helper.
Building from source:
@@ -235,11 +202,11 @@ make run-client # launch the Tauri dev app
(`libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `librsvg2-dev`,
`libayatana-appindicator3-dev`, `libxdo-dev`, `libssl-dev`,
`build-essential`), Node.js 22 LTS via NodeSource (only if missing),
Rust via rustup, and checksum-verified PS5 Payload SDK v0.42 →
Rust via rustup, and checksum-verified PS5 Payload SDK v0.44 →
`~/ps5-payload-sdk`.
- **`make install-macos`** — macOS: Xcode CLT, Homebrew, `node`, current
`llvm` (LLVM 22 at this release), Rust via rustup, and checksum-verified
PS5 Payload SDK v0.42.
`llvm` (LLVM 23 at this release), Rust via rustup, and checksum-verified
PS5 Payload SDK v0.44.
- **`make install-windows`** — Windows 11: Node.js LTS, Rust, VS 2022 Build
Tools (C++ workload), WebView2 Runtime, 7-Zip, and PS5 Payload SDK
via `winget`. Run from an elevated PowerShell (or any shell with
@@ -257,314 +224,112 @@ For per-platform bundles only (no full dev env): `make dist-mac`,
## Quick start
1. Launch ps5upload on your computer.
2. Open the **Connection** tab and enter your PS5's IP address.
3. Click **Check**, then **Send payload**.
4. Once the third step turns green, go to any other tab — you're
connected.
1. Jailbreak the PS5 and keep an ELF loader running on port **9021**.
2. Launch ps5upload and open **Connection**. Enter the PS5's IP address.
3. Click **Check**, then **Send payload**. The app launches the helper and pairs with it by
itself.
4. If you loaded the helper another way, the console shows a **6-digit code**. Enter it in the
app once; the computer is remembered.
The payload stays loaded until the PS5 reboots or goes into rest mode.
The helper stays until the PS5 reboots or enters rest mode. The app only needs ports **9120**
(the helper) and **9021** (the loader); allow both in your firewall. 6.0 cannot talk to a
v5.x helper: the app offers **Update the helper** and replaces it in one click.
## Architecture
```
client/ (Tauri 2 · React · TypeScript)
client/ (Tauri 2 · React · TypeScript) Android app · Docker web UI
│
└── spawns ── ps5upload-engine (HTTP :19113)
│
▼ FTX2 binary framing
payload/ps5upload.elf (PS5 C payload, ports 9113 + 9114)
▼ AVA1 (Noise-encrypted, port 9120)
payload/ps5upload.elf (PS5 C payload)
```
Three layers:
- **`payload/`**: C payload on the PS5 (FreeBSD 11). Transfers, BLAKE3 verification, mounts,
file operations and every management call. Spec: [`protocol/ava1/SPEC.md`](protocol/ava1/SPEC.md).
- **`engine/`**: Rust workspace with the AVA1 client, transfer logic, HTTP API and lab tools.
- **`client/`**: Tauri 2 desktop app (and the Android and web builds), talking to the engine.
- **`payload/`** — C payload that runs on the PS5 (FreeBSD 11).
Ports 9113 (transfer) + 9114 (management). Handles FTX2 framing,
BLAKE3 verification, mount pipelines, and FS ops.
- **`engine/`** — Rust workspace with the protocol types, transfer
logic, HTTP service, lab CLI, mock server, and benchmarks.
- **`client/`** — Tauri 2 desktop app. Tauri IPC commands proxy to the
sidecar HTTP engine, keeping the engine usable from CLI / CI too.
## Build and test
## Build
All workflows go through the root `Makefile` (`make help`).
All workflows go through the root `Makefile` (see `make help`).
| Target | What it does |
| Command | What it does |
|---|---|
| `make build` | Payload + engine + client |
| `make payload` | PS5 ELF at `payload/ps5upload.elf` (requires `PS5_PAYLOAD_SDK` env var) |
| `make engine` | `cargo build --workspace` |
| `make client` | `vite build` in `client/` |
| `make run-client` | Tauri dev (vite + Rust main process) |
| `make run-engine` | `ps5upload-engine` on `localhost:19113` |
| `make send-payload` | Send the built ELF to `PS5_HOST:PS5_LOADER_PORT` |
| `npm run validate` | Full non-hardware quality gate |
| `make quality` | Same full non-hardware quality gate via Make |
| `npm run coverage` | Generate frontend + Rust coverage reports |
| `make coverage` | Same coverage reports via Make |
| `make test` | Script syntax + engine tests + payload validation + client build |
| `make test-engine` | `cargo test --workspace` (no hardware needed) |
| `make dist` | Tauri bundle under `client/src-tauri/target/release/bundle/` |
| `make run-client` | Tauri dev app |
| `make run-engine` | Engine on `localhost:19113` |
| `make test-engine` | Rust tests, no PS5 needed |
| `make test-payload` | Console C code compiled and run on the host |
| `npm run validate` | Full non-hardware gate |
| `make dist` | Tauri bundle |
## Test
Unit and integration tests run entirely against an in-process mock
FTX2 server — no PS5 needed:
```bash
make test-engine
```
Full local quality gate and coverage reports:
```bash
npm run validate
npm run coverage
```
Real-hardware smoke test (requires payload already loaded):
```bash
npm run smoke:hardware
```
See [`TESTING.md`](TESTING.md) for the complete mock-test, coverage,
cross-platform, and live-PS5 validation workflow.
See [`TESTING.md`](TESTING.md) for the full workflow, and [`CONTRIBUTING.md`](CONTRIBUTING.md).
## Tech stack
- **Payload** — C (FreeBSD 11), prospero-clang toolchain
- **Engine** — Rust (edition 2021), tokio + axum 0.8
- **Desktop client** — Tauri 2, React, TypeScript, Zustand,
Tailwind CSS v4, Vite
- **Protocol** — FTX2 (custom binary framing, BLAKE3 shard
verification)
- **Payload**: C (FreeBSD 11), prospero-clang, PS5 Payload SDK v0.44
- **Engine**: Rust, tokio + axum
- **Client**: Tauri 2, React, TypeScript, Zustand, Tailwind CSS v4, Vite
- **Protocol**: AVA1 (Noise XX handshake, ChaCha20-Poly1305, BLAKE3 verification)
## Supported platforms
**Desktop client**
- **Desktop:** macOS, Linux and Windows, x64 and arm64.
- **Android:** the `.apk` from Releases.
- **Web UI:** the self-hosted Docker image, in any browser on your LAN (see below).
- **PS5 firmware:** 1.00 through 13.60 on every model; one payload binary for all. Tested on
5.10, 9.60 and 13.60. In practice the limit is your jailbreak's ELF loader on port 9021.
| | x64 | arm64 |
|---|---|---|
| **macOS** | ✓ | ✓ |
| **Linux** | ✓ | ✓ |
| **Windows** | ✓ | ✓ |
Fake package install needs live kernel patches (kstuff / fpkg-enable) on the console; where
they are missing the app says so instead of reporting a false success.
**PS5 payload** — every firmware the PS5 Payload SDK supports,
currently **1.00 through 13.60** on every console model (original
CFI-1xxx, Slim CFI-2xxx, Pro CFI-7xxx, Digital). Built against SDK
v0.42, which ships per-firmware kernel offsets and resolves them at
payload startup via `kernel_get_fw_version()` — the same binary
runs on every supported firmware without per-release rebuilds.
## Self-hosted engine and web UI
| Ceiling | Range | Meaning |
|---|---|---|
| **Payload binary** | **1.00 – 13.60** | Same ELF runs on every FW the SDK has offsets for |
| **Practical jailbreak** | **~1.00 – 12.70** | Limited by the public ELF loader / exploit chain, not by our payload |
| Hardware-tested here | **5.10** and **9.60** | Core transfer, mount, browse, install, FTP, metadata |
Run the engine on a NAS or server and use it from a browser, or point the desktop app at it
with **Settings → Engine URL**.
> Fake/debug package install depends on the console's jailbreak having live kernel
> patches (kstuff / fpkg-enable). On builds where those aren't active the
> installer reports it honestly rather than claiming a false success — see
> the FAQ on install firmware support. `.ffpkg` / `.ffpfs` are UFS images for
> the mount flow, not install-package filename variants.
The process-list feature (Hardware tab's process snapshot) reads
`kinfo_proc` via `sysctl(KERN_PROC_PROC)` with field offsets that
have been stable across every SDK-supported firmware — pid at
byte 72, thread name at byte 447. No firmware-specific fallback
table required; real command names appear across the full 1.00 –
13.60 range. Transfer, mount, file browse, hardware monitor and FS
ops work identically across all supported firmwares. The Hardware
tab's blank readings (SoC clock, SoC power, CPU usage, fan duty,
date/time) are values retail firmware doesn't expose at all, not a
firmware-version difference — temperatures and CPU frequency read
fine on 5.10 and 9.60 alike.
**What actually gates users in practice is the ELF loader** on
port 9021 — a third-party component, not part of ps5upload.
- Images: `ghcr.io/phantomptr/ps5upload-engine-webui` (full web UI) and
`ghcr.io/phantomptr/ps5upload-engine` (engine only), tagged `:latest` or `:<version>`.
- Start from [`engine/compose.yaml`](engine/compose.yaml). It uses host networking (stream
installs need the PS5 to reach the engine). Set `PS5_ADDR` and `PS5UPLOAD_ALLOW_IP`.
- Open `http://<host>:19113`. The file picker browses the engine's disk; mount your games
folder into the container and set `PS5UPLOAD_BROWSE_ROOTS`.
- Map `/data` to a host folder (on Unraid, `/mnt/user/appdata/ps5upload`): it holds the pairing
key, so the PS5 keeps trusting the web UI after the container is recreated.
- An upload keeps running when you close the tab.
- **No password.** Anyone allowed can read, write and delete on your PS5. Keep it on a trusted
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
**Q: "Connection Refused" or it won't connect?**
* Did you load the payload first? The PS5 stops listening after a
reboot or rest-mode cycle — send the payload again from the
**Connection** tab.
* Is your computer's firewall blocking outbound connections to
port 9113 / 9114 / 9021 on your PS5?
* Your computer and PS5 don't have to be on the same subnet, but
there has to be a route to the IP.
**Q: It connects, then drops within seconds?**
* If you load ps5upload with an autoloader or PLDMGR, put **elfldr first**
in its list, before `ps5upload.elf`. Without elfldr loaded first,
ps5upload connects and then drops after a few seconds. Loading elfldr
once from PLDMGR and then sending ps5upload again also fixes it.
**Q: Stream install fails before the PS5 downloads anything?**
* The PS5 pulls the package from your computer, so it has to be able to
reach it. Allow ps5upload through your computer's firewall (on Windows,
for both Private and Public networks), keep the computer and the PS5 on
the same network with any VPN off, and set the PS5's Proxy Server to
"Do Not Use". **Upload & install** works without this connection.
**Q: Resume gets stuck on "Checking what's already on your PS5…"?**
* Reconcile scoped to the *local* tree's parent directories, so a
single-file upload into a folder that already holds other games
is now one `FS_LIST_DIR` call instead of a recursive walk of the
whole destination. Update to the latest build.
* Safe-mode reconcile still hashes every same-size remote file via
BLAKE3 — that's ~2–3 s per GiB on PS5 UFS. Use Fast mode for
single-file or large-file transfers.
**Q: Do I need a LAN cable?**
* Not strictly, but Wi-Fi caps throughput well below what the PS5
NIC can actually do. Plug in an Ethernet cable for the best
experience.
**Q: Can I use this over the Internet?**
* Yes, technically. If you forward ports 9113 / 9114 to your PS5
it will work. However, the FTX2 protocol is optimised for speed,
not for authentication — we don't recommend exposing an
exploited PS5 to the open Internet.
**Q: How do I install / launch a game from the Library tab?**
* The Library row exposes **Mount** for `.exfat` / `.ffpkg` /
`.ffpfs` images plus **Register** / **Register (patch DRM)** /
**Launch** / **Unregister** buttons on the games inside.
* **Mount** is hardware-validated on FW 9.60 — a 76 GiB UFS
`.ffpkg` mounts on `/dev/lvd1`, appears in Volumes with the
correct `source_image`, and unmounts cleanly. The new round's
payload uses compile-time `-lSce*` linkage so the rtld
initialises the Sony sprx state via `DT_NEEDED` before main()
runs.
* **Register** is hardware-validated: pointing it at a folder
game with `eboot.bin` + `sce_sys/param.json` succeeds end to
end — `sceAppInstUtilAppInstallTitleDir` returns 0, the title
appears in `app.db` with the correct title name, and a nullfs
bind is installed at `/system_ex/app/<title_id>`. Idempotent —
re-registering the same path returns the same result.
* **Register (patch DRM)** rewrites the source's
`sce_sys/param.json`'s `applicationDrmType` to `"standard"`
before staging — needed for PSN-extracted dumps that ship with
`"PSN"` or `"disc"`. Modifies the source file in place; only
use when a normal Register fails with a DRM error.
* **Launch** routes `sceLncUtilLaunchApp` through a ptrace RPC
into `SceShellUI` so Sony's `getpid() == SceShellUI.pid`
caller-context check passes natively. Hardware-validated on
FW 9.60: hitting Launch on a registered title actually starts
the game (SoC power draw confirms the title comes up). Falls
back to a direct call when ShellUI RPC isn't available.
* **Listing inside an active mount** (`/mnt/ps5upload/<name>/...`)
is gated by the PS5 sandbox / LVD mount permission set rather
than payload privilege. We re-check after every credential
elevation; treat "ENOTDIR descending into a mount" as
expected for now. Other PS5-side tools see the mount via
`/mnt/ps5upload/`, and the source path is recorded in our
tracker so reconcile-on-next-boot keeps state consistent.
* Sensors are read **on demand** (the Hardware tab's "Read sensors"
button), not on a timer. Each read briefly ptrace-pauses the system UI,
and doing that on a loop could destabilize the console — it powered
some consoles off — so the auto-refresh covers only the ptrace-free
data (info, uptime, storage).
* Which readings actually work, measured on FW 9.60 and 5.10:
* **Work:** CPU / SoC / M.2 temperature, CPU frequency, storage totals.
* **Show `—`:** SoC clock, SoC power, CPU usage, fan duty, per-drive
sensors, and the console date/time. These aren't broken payload code
— retail firmware doesn't expose them to us. A persistent `—` there
is the expected result, not a fault to report.
**Q: "No writable storage found"?**
* The tool blocks writes to read-only system partitions. If you
want to write to a USB drive, make sure it's formatted (exFAT is
best) and plugged in *before* you load the payload.
**Q: macOS: "App is damaged" or "Unidentified Developer"?**
* This is normal for unsigned apps. Right-click the app, select
**Open**, then click **Open** in the dialog.
* If macOS still blocks it, **System Settings → Privacy &
Security → Open Anyway**.
* Last resort — remove the quarantine flag:
```bash
xattr -dr com.apple.quarantine /Applications/ps5upload.app
```
* No Apple Developer account is required; the app is intentionally
unsigned.
**Q: Where are config and logs saved?**
* The desktop client uses the OS app-data directory:
* **Windows:** `%APPDATA%\com.phantomptr.ps5upload`
* **macOS:** `~/Library/Application Support/com.phantomptr.ps5upload`
* **Linux:** `~/.local/share/com.phantomptr.ps5upload`
**Q: Does this work on PS4?**
* No. The payload is compiled specifically for the PS5 (FreeBSD
11, Zen 2) and calls PS5-only kernel entry points.
**Q: What about older firmware (≤ 9.00)?**
* The FTX2 payload itself doesn't call firmware-gated APIs, but
the ELF loader workflow on port 9021 depends on what your
jailbreak exposes. Patches welcome.
**Q: Can I run ps5upload headless / over SSH?**
* Not the GUI. The `ps5upload-engine` binary speaks HTTP on
`:19113` and exposes the full transfer / reconcile / FS API, so
you can script transfers from a terminal or CI job without ever
opening the desktop client.
**Q: Can the engine run on a different machine (remote / self-hosted)?**
* Yes (3.3.7+). Host the engine elsewhere and point the desktop app at it via
**Settings → Engine URL**. An official multi-arch image is published at
`ghcr.io/phantomptr/ps5upload-engine` (`:latest` or `:<version>`); you can
also build from `engine/Dockerfile`. To let a remote box reach it, set
`PS5UPLOAD_ALLOW_IP` to that box's IP. **Security:** the engine's API is
unauthenticated (it can read/write/delete PS5 files), so only do this on a
trusted LAN — never expose the engine to the internet.
**Q: Can I run ps5upload from a browser, with no desktop app at all (self-hosted web UI)?**
* Yes (3.3.25+). A separate `webui` build of the engine serves the **full
React app over HTTP**, so you can manage your PS5 from any browser on the
LAN — handy for a NAS or headless box you don't want to install the desktop
client on. An official multi-arch image is published at
`ghcr.io/phantomptr/ps5upload-engine-webui` (`:latest` or `:<version>`); or
build it yourself with
`docker build -f engine/Dockerfile.webui -t ps5upload-engine-webui .`
(build context is the **repo root**, not `engine/` — it needs `client/` to
build the frontend first). Run it the same way as the plain engine image
(`PS5_ADDR`, `PS5UPLOAD_ALLOW_IP`) and open `http://<host>:19113` in a
browser on an allowlisted IP. Same security model as the remote engine
above: **unauthenticated**, LAN-only, never expose it to the internet.
**Upload works in the browser** too, operating on files already
present on the **engine's own machine** — e.g. mount a folder into the
container with `-v /host/games:/data/games`, then browse to `/data/games`
in the in-app file picker. That picker browses the *engine's* filesystem,
not the browser's own machine — a browser tab has no way to reach a
remote engine's disk except through what the engine itself can already
read. A few things are still desktop-only and hidden in the browser UI:
archive uploads (`.zip`/`.7z`/`.rar`), Payloads (sending a `.elf`/etc. from
disk), installing a `.pkg` you pick from your PC, and saving a save-data
backup to your computer — everything else that operates on the PS5 itself
(browse, transfer, install from a PS5-connected USB drive, hardware
monitor, saves list, …) works the same as the desktop app. The upload
queue also doesn't persist across a full page reload in browser mode
(queue persistence is desktop-only).
* Installing packages from the browser needs an engine that carries
ps5upload's on-console installer (`ps5upload-installer.elf`): every
install goes through it, and the engine sends it to the console when it
isn't already running there (it runs alongside the ps5upload helper and
never replaces it). The released engine binaries and the official Docker
images include it, and so does an engine built from a full checkout — the
ELF is in the repository. You can also point the engine at your own copies
with `-e PS5UPLOAD_PAYLOAD_DIR=/path/to/elves`.
Common questions (connection problems, install errors, USB drives, firmware) are in
[`FAQ.md`](FAQ.md), also available inside the app.
## Contributing
- Report bugs:
[GitHub Issues](https://github.com/phantomptr/ps5upload/issues)
- Pull requests welcome — please read
`.github/PULL_REQUEST_TEMPLATE.md` and run `make test` locally
before opening.
- 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
@@ -648,11 +413,23 @@ This software builds on the following open-source projects:
* [unrar / unrar_sys](https://crates.io/crates/unrar) — `.rar` extraction on **desktop** (bundles the **UnRAR** source by Alexander Roshal; used to *extract only*, never to compress). See the required notice and the GPLv3 §7 linking exception in [`LICENSES/`](LICENSES/). The `unrar` wrapper (MIT OR Apache-2.0) is **vendored** at [`third_party/unrar`](third_party/unrar) carrying one local fix for an out-of-bounds read on multi-volume archives — upstream 0.5.8 has no fix; see that directory's README.
**Payload (PS5):**
* [PS5 Payload SDK](https://github.com/ps5-payload-dev/sdk) — Open-source SDK for PS5 payload development
* [BLAKE3](https://github.com/BLAKE3-team/BLAKE3) — Fast cryptographic hashing (per-shard verification)
* [PS5 Payload SDK](https://github.com/ps5-payload-dev/sdk) — Open-source SDK for PS5 payload development; its startup code is **vendored** at [`payload/third_party/sdk-crt`](payload/third_party/sdk-crt) with one fix so payloads start on firmware where two kernel lookups fail (FW 5.50)
* [elfldr](https://github.com/ps5-payload-dev/elfldr) (GPLv3) — the ELF loader; a patched copy is **vendored** at [`third_party/elfldr`](third_party/elfldr) (fix for a loader that hangs on a silent client)
* [Monocypher](https://github.com/LoupVaillant/Monocypher) (CC0 / BSD-2) — AVA1's cryptography on the console (X25519, ChaCha20-Poly1305, BLAKE2b)
* [BLAKE3](https://github.com/BLAKE3-team/BLAKE3) (CC0 / Apache-2.0) — fast hashing for AVA1 file verification
* [SQLite](https://sqlite.org/) (public domain) — reading the console's app database
* [tiny-AES-c](https://github.com/kokke/tiny-AES-c) (Unlicense) — MC4 cheat decryption
## Thanks
* [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
ps5upload stands on the shoulders of the **PS5 homebrew scene**. Huge
thanks to everyone who makes this ecosystem possible — the exploit and
kernel researchers, the ELF/payload loader authors, the PS5 Payload SDK
@@ -660,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).
+5 -3
View File
@@ -32,9 +32,11 @@ reports are taken seriously and prioritized.
## Scope
ps5upload talks to a **jailbroken PS5 over a trusted LAN**. The FTX2
transfer protocol is optimized for speed, not authentication, and is not
designed to be exposed to untrusted networks — see the README's FAQ. The
ps5upload talks to a **jailbroken PS5 over a LAN**. The AVA1 protocol
(port 9120) uses the Noise XX handshake and encrypts and authenticates
every byte; the console only accepts devices that were paired by comparing
a six-digit code, or the engine that launched it. Weaknesses in pairing,
the handshake, or AVA1's message parsers are squarely in scope. The
local engine binds loopback (`127.0.0.1:19113`) and rejects non-loopback
callers except the PS5-facing `/pkg-host/` route. Reports most in scope:
+30 -19
View File
@@ -37,6 +37,15 @@ caught real regressions:
Running a narrower check and assuming the wider one follows is the most
common way to burn a cycle here.
`npm run validate` is not the whole gate. Before a PR also run:
```sh
( cd client/src-tauri && cargo fmt --all -- --check ) # validate only checks fmt in engine/
( cd engine && cargo test -p ava1-ctest -- --test-threads=1 )
make test-payload # needs PS5_PAYLOAD_SDK
make check-no-ftx2 # the retired protocol must stay gone
```
## Layer 2 — payload build and self-tests
```sh
@@ -48,21 +57,24 @@ Builds `payload/ps5upload.elf` and the DPI daemon, checks both are PS5
ELFs with valid gzip resources, then runs every host self-test with
`-Wall -Wextra -Werror`:
| Self-test | Pins |
|---|---|
| `hw_guard` | Recovering from a faulting Sony getter without losing the helper |
| `ptrace_recovery` | Timeout recovery never resuming injected registers |
| `timed_init` | Bounded one-time init never starting a second initializer |
| `appdb_scan` | Reading `app.db` as real SQLite records, not printable runs |
| `ftp_format` | PASV/EPSV/LIST reply shapes clients parse strictly |
| `ftp_lifecycle` | Session registration, generations, stop-to-kill |
| `sdk_param` | `param.json` rewrite, and reporting when nothing changed |
| `elf_param` | Finding SDK fields via program headers, not stray magic bytes |
| `sdk_changer_file` | Patching only tracked sources; durable backups |
Each self-test lives in `payload/tests/*_selftest.c` (hardware guards, ptrace recovery,
`app.db` reading, FTP, SDK param rewriting, installer, cheats, wake watchdog and more). The
AVA1 C itself is tested by `ava1-ctest`, which compiles it on the host and checks it against the
Rust side (see the full gate below).
The SDK version is pinned in `scripts/ps5-sdk.env` (currently **v0.42**)
The SDK version is pinned in `scripts/ps5-sdk.env` (currently **v0.44**)
and verified by checksum. Local installers and CI read the same file.
To move to a newer SDK release, run `make update-sdk` (or
`scripts/update-ps5-sdk.sh [--tag vX.Y] [--check]`). It downloads the release,
rewrites the pin and its checksum, refreshes the vendored `prospero-nid` and
`payload/third_party/sdk-crt` if upstream changed them at that tag, and installs
the SDK into `$PS5_PAYLOAD_SDK` (default `/opt/ps5-payload-sdk`), keeping the old
one as a `.backup-<old tag>-<time>` directory. If `/opt` needs root it uses
`sudo` from a terminal; without it, the backup goes to your home directory.
Then `rm -rf payload/build payload/installer/build payload/third_party/sdk-crt/build`,
run `make test-payload`, and commit the changed pin.
**Adding payload logic?** If it can be separated from the console, put it
header-only in `payload/include/` and give it a self-test here. That is
the established convention and the reason the list above exists.
@@ -73,7 +85,7 @@ Nothing here is tied to one network. Every entry point takes an address:
```sh
export PS5_HOST=192.168.1.50 # console IP (loader + payload)
export PS5_ADDR=192.168.1.50:9113 # transfer port, for scripts/bench
export PS5_ADDR=192.168.1.50 # console IP; the engine owns the port (9120)
make send-payload PS5_HOST=$PS5_HOST
npm run smoke:hardware # or: node tests/smoke-hardware.mjs
@@ -85,7 +97,7 @@ committed defaults — those stay generic on purpose.
| Command | Does |
|---|---|
| `make validate` | Build, send, wait for `:9113`, smoke, sweep, write `bench/reports/<ts>-sweep.{json,md}` |
| `make validate` | Build, send, wait for `:9120`, smoke, sweep, write `bench/reports/<ts>-sweep.{json,md}` |
| `make validate-xl` | Adds the 200k-file stress profile |
| `node tests/smoke-hardware.mjs --no-spawn-engine` | Against an engine already running |
@@ -94,11 +106,11 @@ Clean up test uploads afterwards:
```sh
curl -X POST http://127.0.0.1:19113/api/ps5/cleanup \
-H 'content-type: application/json' \
-d '{"addr":"'"$PS5_HOST"':9114","path":"/data/ps5upload/tests/manual"}'
-d '{"addr":"'"$PS5_HOST"'","path":"/data/ps5upload/tests/manual"}'
```
**Use a live console when touching:** the payload C runtime, FTX2
framing, transfer/reconcile/resume, storage, mount, cleanup, the file
**Use a live console when touching:** the payload C runtime, AVA1
(`payload/ava1/`), transfer/reconcile/resume, storage, mount, cleanup, the file
browser, volume commands, or anything performance-sensitive.
### Hardware testing has teeth
@@ -220,8 +232,7 @@ out-of-bounds read in release. `0.5.8` is the latest release, so the crate
is vendored at `third_party/unrar` with the fix and both workspaces
redirect to it via `[patch.crates-io]`.
See `third_party/unrar/README.md` for the patch and
`docs/unrar-upstream-bug.md` for the report to send upstream. If you bump
See `third_party/unrar/README.md` for the patch. If you bump
the crate, re-apply the hunk or drop the vendoring once upstream ships the
fix.
+1 -1
View File
@@ -1 +1 @@
5.35.0
7.1.8
+12 -23
View File
@@ -1,37 +1,26 @@
# Bench
This directory now contains the first repo-native benchmark tooling for the `2.0` FTX2 path.
Repo-native benchmark and hardware-sweep tooling that drives a running `ps5upload-engine` over its HTTP API.
Current contents:
- `run-ftx2-upload.mjs`
Runs a real upload benchmark against `ps5upload-engine` and stores a JSON result snapshot under `bench/results/`.
- `check-ftx2-baseline.mjs`
Compares one result JSON against a baseline result JSON and fails on throughput or elapsed-time regression.
- `baselines/`
Commit known-good result snapshots here when you want a stable comparison target.
- `results/`
Local benchmark outputs written by `run-ftx2-upload.mjs`.
- `run-sweep.mjs`
Runs every profile in `profiles.mjs` against a live PS5 through the engine (`make validate`, `make validate-xl`) and writes a report under `bench/reports/`.
- `profiles.mjs`
The shared profile definitions. `scripts/gen-fixtures.mjs` builds the local trees they upload.
- `resume-test.mjs`, `edge-case-sweep.mjs`
Hardware checks for resume and edge cases.
Protocol benchmarks (AVA1 upload, download, copy, resume, relay and the drop-and-resend run) are `ps5upload-lab bench` scenarios; see `cargo run -p ps5upload-lab -- bench --help` from `engine/`.
Example flow:
```bash
node bench/run-ftx2-upload.mjs \
--spawn-engine \
--ps5-addr=192.168.137.2:9113 \
--source=/path/to/workload
cp bench/results/<new-result>.json bench/baselines/ftx2-upload-main.json
node bench/check-ftx2-baseline.mjs \
--result=bench/results/<new-result>.json \
--baseline=bench/baselines/ftx2-upload-main.json
node bench/run-sweep.mjs --spawn-engine --ps5-addr=192.168.137.2
```
Notes:
- The benchmark targets the `2.0` engine HTTP API, not the legacy app/desktop transfer path.
- `run-ftx2-upload.mjs` accepts either a file or a directory source.
- The sweep targets the engine HTTP API. The console address is a bare host; a `:port` suffix is ignored.
- If the engine is already running, omit `--spawn-engine`.
- Baselines are intentionally captured from real hardware; this repo does not invent threshold numbers ahead of measurement.
- The baseline JSON files in `bench/baselines/` were captured against the pre-2.2.0 engine which bound port `9114`. The current engine binds `19113`. The captured `engine_url` field in the older JSONs is a historical record of where the run actually executed, not a current configuration knob — `check-ftx2-baseline.mjs` compares throughput and elapsed-time, not URLs, so the drift is harmless. When refreshing a baseline against the current engine, generate a new run and overwrite the file rather than patching the URL in place. The throughput numbers themselves remain valid as a regression target across the port change.
- Results are captured from real hardware; this repo does not invent threshold numbers ahead of measurement.
-1
View File
@@ -1 +0,0 @@
-29
View File
@@ -1,29 +0,0 @@
{
"schema_version": 1,
"benchmark": "ftx2_upload",
"created_at": "2026-04-17T08:24:54.610Z",
"engine_url": "http://127.0.0.1:9114",
"ps5_addr": "192.168.137.2:9113",
"source": {
"path": "/tmp/bench-dir-32x4m",
"kind": "dir",
"bytes": 4096
},
"destination": {
"root": "/data/ps5upload-bench",
"final_path": "/data/ps5upload-bench"
},
"job": {
"job_id": "4c95a227-c2b6-4edd-82a8-194918c2bb36",
"tx_id_hex": "f2f61953e76b41bba9306ef50967fae5",
"shards_sent": 32,
"bytes_sent": 134217728,
"started_at_ms": 1776414290591,
"completed_at_ms": 1776414294259,
"elapsed_ms": 3668
},
"metrics": {
"bytes_per_sec": 36591528.89858233,
"mib_per_sec": 34.89640130861505
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"schema_version": 1,
"benchmark": "ftx2_upload",
"created_at": "2026-04-17T08:24:50.510Z",
"engine_url": "http://127.0.0.1:9114",
"ps5_addr": "192.168.137.2:9113",
"source": {
"path": "/tmp/bench-128m.bin",
"kind": "file",
"bytes": 134217728
},
"destination": {
"root": "/data/ps5upload-bench",
"final_path": "/data/ps5upload-bench/bench-128m.bin"
},
"job": {
"job_id": "7d35bac6-cea8-4dda-ac19-02caf63dbb83",
"tx_id_hex": "20f7c34a255248879375509d1fab858c",
"shards_sent": 4,
"bytes_sent": 134217728,
"started_at_ms": 1776414287499,
"completed_at_ms": 1776414290094,
"elapsed_ms": 2595
},
"metrics": {
"bytes_per_sec": 51721667.82273603,
"mib_per_sec": 49.32562620423892
}
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
function log(message) {
process.stdout.write(`[bench-ftx2-gate] ${message}\n`);
}
function parseArgs(argv) {
const out = {
result: '',
baseline: '',
minThroughputRatio: 0.9,
maxElapsedRatio: 1.15,
};
for (const arg of argv) {
if (arg.startsWith('--result=')) out.result = path.resolve(repoRoot, arg.slice('--result='.length));
else if (arg.startsWith('--baseline=')) out.baseline = path.resolve(repoRoot, arg.slice('--baseline='.length));
else if (arg.startsWith('--min-throughput-ratio=')) out.minThroughputRatio = Number(arg.slice('--min-throughput-ratio='.length)) || out.minThroughputRatio;
else if (arg.startsWith('--max-elapsed-ratio=')) out.maxElapsedRatio = Number(arg.slice('--max-elapsed-ratio='.length)) || out.maxElapsedRatio;
}
if (!out.result || !out.baseline) {
throw new Error('usage: node bench/check-ftx2-baseline.mjs --result=... --baseline=...');
}
return out;
}
async function readJson(filePath) {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
}
function formatMiBPerSec(value) {
return `${Number(value || 0).toFixed(2)} MiB/s`;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const result = await readJson(options.result);
const baseline = await readJson(options.baseline);
const resultBps = Number(result?.metrics?.bytes_per_sec || 0);
const baselineBps = Number(baseline?.metrics?.bytes_per_sec || 0);
const resultElapsedMs = Number(result?.job?.elapsed_ms || 0);
const baselineElapsedMs = Number(baseline?.job?.elapsed_ms || 0);
if (resultBps <= 0 || baselineBps <= 0) {
throw new Error('result and baseline must both have positive metrics.bytes_per_sec');
}
if (resultElapsedMs <= 0 || baselineElapsedMs <= 0) {
throw new Error('result and baseline must both have positive job.elapsed_ms');
}
const throughputRatio = resultBps / baselineBps;
const elapsedRatio = resultElapsedMs / baselineElapsedMs;
log(`result throughput: ${formatMiBPerSec(result?.metrics?.mib_per_sec)}`);
log(`baseline throughput: ${formatMiBPerSec(baseline?.metrics?.mib_per_sec)}`);
log(`throughput ratio: ${throughputRatio.toFixed(3)}`);
log(`elapsed ratio: ${elapsedRatio.toFixed(3)}`);
const failures = [];
if (throughputRatio < options.minThroughputRatio) {
failures.push(
`throughput ratio ${throughputRatio.toFixed(3)} is below threshold ${options.minThroughputRatio.toFixed(3)}`,
);
}
if (elapsedRatio > options.maxElapsedRatio) {
failures.push(
`elapsed ratio ${elapsedRatio.toFixed(3)} exceeds threshold ${options.maxElapsedRatio.toFixed(3)}`,
);
}
if (failures.length) {
for (const failure of failures) {
log(`FAIL: ${failure}`);
}
process.exitCode = 1;
return;
}
log('PASS');
}
main().catch((error) => {
process.stderr.write(`[bench-ftx2-gate] error: ${error.message}\n`);
process.exitCode = 1;
});
+9 -10
View File
@@ -3,8 +3,8 @@
* Upload edge-case sweep (manual hardware test, not CI).
*
* Exercises the tricky upload paths the smoke test doesn't: special-char
* filenames, empty files, nested dirs, reconcile-skip, zip upload, multistream
* with odd names, and concurrent uploads — and VERIFIES correctness by
* filenames, empty files, nested dirs, reconcile-skip, zip upload and
* concurrent uploads — and VERIFIES correctness by
* downloading each upload back and byte-comparing (catches silent corruption,
* not just "job said ok").
*
@@ -20,7 +20,7 @@ import { execFileSync } from "node:child_process";
const ENGINE = process.env.ENGINE_URL || "http://127.0.0.1:19113";
const HOST = process.argv[2];
if (!HOST) { console.error("usage: edge-case-sweep.mjs <ps5-ip>"); process.exit(2); }
const ADDR = HOST.includes(":") ? HOST : `${HOST}:9113`;
const ADDR = HOST;
const DEST = "/data/ps5upload/tests/edge";
let pass = 0, fail = 0;
@@ -55,13 +55,13 @@ async function pollJob(id, timeoutMs = 120000) {
async function del(p) { await jpost("/api/ps5/fs/delete", { path: p, addr: ADDR }); }
// Upload a dir, download it back to a fresh local dir, return map name->sha of downloaded files.
async function uploadDirAndVerify(label, localDir, ps5Sub, { streams = 1, reconcile = false } = {}) {
async function uploadDirAndVerify(label, localDir, ps5Sub, { reconcile = false } = {}) {
const destRoot = `${DEST}/${ps5Sub}`;
await del(destRoot);
await new Promise((x) => setTimeout(x, 400));
const ep = reconcile ? "/api/transfer/dir-reconcile" : "/api/transfer/dir";
const body = reconcile
? { src_dir: localDir, dest_root: destRoot, addr: ADDR, mode: "fast", tx_id: null, excludes: [], streams }
? { src_dir: localDir, dest_root: destRoot, addr: ADDR, mode: "fast", tx_id: null, excludes: [] }
: { src_dir: localDir, dest_root: destRoot, addr: ADDR };
const st = await jpost(ep, body);
if (!st.ok || !st.json.job_id) return bad(label, `start HTTP ${st.status} ${JSON.stringify(st.json)}`);
@@ -88,7 +88,7 @@ async function uploadDirAndVerify(label, localDir, ps5Sub, { streams = 1, reconc
await fs.rm(dlDir, { recursive: true, force: true });
if (missing.length) return bad(label, `${missing.length} file(s) missing after round-trip, e.g. ${JSON.stringify(missing.slice(0,3))}`);
if (mism) return bad(label, `${mism}/${checked} file(s) byte-mismatch after round-trip`);
ok(`${label} (${checked} files round-tripped, streams=${streams}${reconcile ? ", reconcile" : ""})`);
ok(`${label} (${checked} files round-tripped${reconcile ? ", reconcile" : ""})`);
return res.j;
}
@@ -120,8 +120,7 @@ async function main() {
for (let i = 0; i < trickyNames.length; i++) {
await fs.writeFile(path.join(specialDir, trickyNames[i]), crypto.randomBytes(1024 + i * 37));
}
await uploadDirAndVerify("special-chars (dir, single stream)", specialDir, "special", { streams: 1 });
await uploadDirAndVerify("special-chars (dir, 4 streams)", specialDir, "special4", { streams: 4 });
await uploadDirAndVerify("special-chars (dir)", specialDir, "special");
// ---- 2. Empty file + empty-ish mix ----
const emptyDir = path.join(work, "empties");
@@ -148,11 +147,11 @@ async function main() {
for (let i = 0; i < 6; i++) await fs.writeFile(path.join(recDir, `rec_${i}.bin`), crypto.randomBytes(64 * 1024));
const destRec = `${DEST}/rec`;
await del(destRec); await new Promise((x) => setTimeout(x, 400));
const r1 = await jpost("/api/transfer/dir-reconcile", { src_dir: recDir, dest_root: destRec, addr: ADDR, mode: "fast", tx_id: null, excludes: [], streams: 1 });
const r1 = await jpost("/api/transfer/dir-reconcile", { src_dir: recDir, dest_root: destRec, addr: ADDR, mode: "fast", tx_id: null, excludes: [] });
const r1res = await pollJob(r1.json.job_id);
if (!r1res.ok) bad("reconcile run-1", r1res.error);
else {
const r2 = await jpost("/api/transfer/dir-reconcile", { src_dir: recDir, dest_root: destRec, addr: ADDR, mode: "fast", tx_id: null, excludes: [], streams: 1 });
const r2 = await jpost("/api/transfer/dir-reconcile", { src_dir: recDir, dest_root: destRec, addr: ADDR, mode: "fast", tx_id: null, excludes: [] });
const r2res = await pollJob(r2.json.job_id);
if (!r2res.ok) bad("reconcile run-2", r2res.error);
else {
-190
View File
@@ -1,190 +0,0 @@
#!/usr/bin/env node
/*
* Multi-stream hardware test harness (manual, not CI).
*
* Drives the ps5upload engine HTTP API against a live PS5 to measure upload
* throughput at varying parallel-stream counts. Assumes the engine is already
* running (spawn it separately so it survives across subcommands and its logs
* are visible). Sequential by design — one upload at a time.
*
* Subcommands:
* status <host> print STATUS (incl max_transfer_streams)
* delete <host> <ps5path> rm -rf a PS5 path (best effort)
* dir <host> <src> <destRoot> <csv> folder upload at each stream count in <csv>
* file <host> <src> <ps5dest> single-file upload (single stream)
*
* Env: ENGINE_URL (default http://127.0.0.1:19113)
*/
const ENGINE = process.env.ENGINE_URL || "http://127.0.0.1:19113";
const MGMT = (h) => (h.includes(":") ? h : `${h}:9113`); // engine derives mgmt from this
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const mib = (b) => b / (1024 * 1024);
async function jpost(path, body) {
const r = await fetch(`${ENGINE}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const text = await r.text();
let json;
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
return { ok: r.ok, status: r.status, json };
}
async function jget(path) {
const r = await fetch(`${ENGINE}${path}`);
const text = await r.text();
let json;
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
return { ok: r.ok, status: r.status, json };
}
async function cmdStatus(host) {
const r = await jget(`/api/ps5/status?addr=${encodeURIComponent(MGMT(host))}`);
console.log(`STATUS ${host}: HTTP ${r.status}`);
console.log(JSON.stringify(r.json, null, 2));
}
async function cmdDelete(host, ps5path) {
const r = await jpost("/api/ps5/fs/delete", { addr: MGMT(host), path: ps5path, op_id: 0 });
console.log(`delete ${ps5path}: HTTP ${r.status} ${JSON.stringify(r.json)}`);
}
// Poll a job to completion, sampling bytes_sent to estimate peak instantaneous
// rate. Returns { ok, elapsedMs, bytes, shards, files, peakMiBs, error }.
async function pollJob(jobId, { sampleMs = 500 } = {}) {
let last = { t: Date.now(), bytes: 0 };
let peak = 0;
for (;;) {
const r = await jget(`/api/jobs/${jobId}`);
const j = r.json || {};
const status = j.status;
if (status === "running") {
const now = Date.now();
const bytes = j.bytes_sent || 0;
const dt = (now - last.t) / 1000;
if (dt >= 0.4) {
const inst = mib(bytes - last.bytes) / dt;
if (inst > peak) peak = inst;
last = { t: now, bytes };
}
await sleep(sampleMs);
continue;
}
if (status === "done") {
return {
ok: true,
elapsedMs: j.elapsed_ms || 0,
bytes: j.bytes_sent || 0,
shards: j.shards_sent || 0,
files: j.files_sent || 0,
peakMiBs: peak,
};
}
if (status === "failed") {
return { ok: false, error: j.error || JSON.stringify(j) };
}
// Unknown / queued — keep polling.
await sleep(sampleMs);
}
}
async function cmdDir(host, src, destRoot, csv) {
const streamsList = csv.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => n >= 1);
const results = [];
for (const streams of streamsList) {
// Clean the dest so every run re-sends the full folder (reconcile would
// otherwise skip already-present files and report ~0 bytes on run 2+).
await cmdDelete(host, destRoot);
await sleep(1500); // let the payload settle after the delete
console.log(`\n=== dir upload: streams=${streams} → ${destRoot} ===`);
const t0 = Date.now();
const start = await jpost("/api/transfer/dir-reconcile", {
src_dir: src,
dest_root: destRoot,
addr: MGMT(host),
mode: "fast",
tx_id: null,
excludes: [],
streams,
});
if (!start.ok || !start.json.job_id) {
console.log(` START FAILED: HTTP ${start.status} ${JSON.stringify(start.json)}`);
results.push({ streams, ok: false, error: `start ${start.status}` });
continue;
}
const res = await pollJob(start.json.job_id);
const wallMs = Date.now() - t0;
if (!res.ok) {
console.log(` FAILED: ${res.error}`);
results.push({ streams, ok: false, error: res.error });
continue;
}
const avg = mib(res.bytes) / (res.elapsedMs / 1000);
const avgWall = mib(res.bytes) / (wallMs / 1000);
console.log(
` OK ${(mib(res.bytes)).toFixed(0)} MiB / ${(res.elapsedMs / 1000).toFixed(1)}s` +
` | avg ${avg.toFixed(1)} MiB/s (job) ${avgWall.toFixed(1)} MiB/s (wall)` +
` | peak ${res.peakMiBs.toFixed(1)} MiB/s | files=${res.files} shards=${res.shards}`,
);
results.push({ streams, ok: true, bytes: res.bytes, elapsedMs: res.elapsedMs, avg, peak: res.peakMiBs });
await sleep(2000);
}
console.log(`\n── summary (${host}) ──`);
for (const r of results) {
if (r.ok) console.log(` streams=${r.streams}: avg ${r.avg.toFixed(1)} MiB/s, peak ${r.peak.toFixed(1)} MiB/s`);
else console.log(` streams=${r.streams}: FAILED ${r.error}`);
}
}
async function cmdFile(host, src, dest) {
console.log(`\n=== single-file upload → ${dest} ===`);
const t0 = Date.now();
const start = await jpost("/api/transfer/file", {
src,
dest,
addr: MGMT(host),
tx_id: null,
});
if (!start.ok || !start.json.job_id) {
console.log(` START FAILED: HTTP ${start.status} ${JSON.stringify(start.json)}`);
return;
}
const res = await pollJob(start.json.job_id);
const wallMs = Date.now() - t0;
if (!res.ok) {
console.log(` FAILED: ${res.error}`);
return;
}
const avg = mib(res.bytes) / (res.elapsedMs / 1000);
console.log(
` OK ${(mib(res.bytes)).toFixed(0)} MiB / ${(res.elapsedMs / 1000).toFixed(1)}s` +
` | avg ${avg.toFixed(1)} MiB/s | peak ${res.peakMiBs.toFixed(1)} MiB/s | wall ${(wallMs / 1000).toFixed(1)}s`,
);
}
const [cmd, ...args] = process.argv.slice(2);
const run = {
status: () => cmdStatus(args[0]),
delete: () => cmdDelete(args[0], args[1]),
dir: () => cmdDir(args[0], args[1], args[2], args[3]),
file: () => cmdFile(args[0], args[1], args[2]),
};
if (!run[cmd]) {
console.error("usage: status|delete|dir|file (see header)");
process.exit(2);
}
run[cmd]().catch((e) => {
console.error(e);
process.exit(1);
});
+6 -6
View File
@@ -1,6 +1,6 @@
// Shared profile definitions for the FTX2 sweep.
// Shared profile definitions for the upload sweep.
//
// Kept in one file so gen-fixtures and run-ftx2-sweep stay in lockstep.
// Kept in one file so gen-fixtures and run-sweep stay in lockstep.
// Add a profile by appending to PROFILES; give it a unique `name` and at most
// one tag in the opt-in tag set below. `bytesTotal` is derived (not declared)
// so sizes stay correct if the raw numbers change.
@@ -33,25 +33,25 @@ export const PROFILES = [
name: 'small-file',
kind: 'file',
size: 1 * MIB,
note: 'small single-shard direct-mode',
note: 'small file',
},
{
name: 'medium-file',
kind: 'file',
size: 32 * MIB,
note: 'exactly one shard at current shard size',
note: 'medium file',
},
{
name: 'large-file',
kind: 'file',
size: 128 * MIB,
note: 'previous single-file baseline; 4 shards; persistent-fd win lives here',
note: 'previous single-file baseline; persistent-fd win lives here',
},
{
name: 'huge-file',
kind: 'file',
size: 1 * GIB,
note: 'deep persistent-writer test, 32 shards',
note: 'deep persistent-writer test',
},
// ── Directory workloads ───────────────────────────────────────────────────
+7 -6
View File
@@ -4,8 +4,8 @@
*
* 1. Start a 1 GiB single-file upload with a fixed tx_id.
* 2. Kill the engine mid-transfer (simulates a crash / hard network drop).
* 3. Restart the engine, re-issue the SAME tx_id → payload resumes from its
* journaled last_acked_shard (BeginTxAck), engine sends only the remainder.
* 3. Restart the engine, re-issue the SAME tx_id → the payload resumes from the
* progress it journaled (AVA1 job journal), and the engine sends only the remainder.
* 4. Verify: run-2 sent < full size (proves resume, not re-send) AND the
* final file on the PS5 byte-matches the source (download-back sha256).
*
@@ -23,7 +23,7 @@ const ENGINE_BIN = path.join(repoRoot, "engine", "target", "release", "ps5upload
const ENGINE = "http://127.0.0.1:19113";
const HOST = process.argv[2];
if (!HOST) { console.error("usage: resume-test.mjs <ps5-ip>"); process.exit(2); }
const ADDR = `${HOST}:9113`;
const ADDR = HOST;
const SRC = path.join(repoRoot, "bench", "fixtures", "huge-file", "huge-file.bin");
const TXID = "aabbccddeeff00112233445566778899";
const DEST = "/data/ps5upload/tests/resume/huge.bin";
@@ -98,11 +98,12 @@ async function main() {
console.log(" run-2: re-issuing SAME tx_id (resume)...");
const r2 = await jpost("/api/transfer/file", { src: SRC, dest: DEST, addr: ADDR, tx_id: TXID });
if (!r2.job_id) { console.log(" ✗ run-2 start failed", JSON.stringify(r2)); process.exit(1); }
// Diagnostic: log run-2 progress + payload active-tx every 5s so a stall is visible.
// Diagnostic: log run-2 progress + the payload's command count every 5s so a stall is visible
// (AVA1's node.status has no transaction fields).
const diag = setInterval(async () => {
const j = await jget(`/api/jobs/${r2.job_id}`).catch(() => ({}));
const st = await jget(`/api/ps5/status?addr=${encodeURIComponent(ADDR)}`).catch(() => ({}));
console.log(` [diag] job=${j.status} sent=${((j.bytes_sent||0)/1048576).toFixed(0)}MiB | payload active_tx=${st.active_transactions} last_seq=${st.last_tx_seq}`);
console.log(` [diag] job=${j.status} sent=${((j.bytes_sent||0)/1048576).toFixed(0)}MiB | payload commands=${st.command_count}`);
}, 5000);
const res2 = await pollJob(r2.job_id, 150000);
clearInterval(diag);
@@ -126,7 +127,7 @@ async function main() {
if (back.length !== fullSize) { console.log(` ✗ size mismatch: ${back.length} vs ${fullSize}`); okAll = false; }
if (backSha !== srcSha) { console.log(` ✗ SHA MISMATCH — resumed file is corrupt`); okAll = false; }
else console.log(` ✓ final file byte-identical to source (sha match)`);
if (partialSeen && sent2 >= fullSize) console.log(` ⚠ run-2 re-sent the whole file (${(sent2/1048576).toFixed(0)} MiB) — resume did not skip acked shards`);
if (partialSeen && sent2 >= fullSize) console.log(` ⚠ run-2 re-sent the whole file (${(sent2/1048576).toFixed(0)} MiB) — resume did not skip the data the console already had`);
else if (partialSeen) console.log(` ✓ resume skipped already-acked data (run-2 sent only ${(sent2 / 1048576).toFixed(0)}/${(fullSize/1048576).toFixed(0)} MiB)`);
await jpost("/api/ps5/fs/delete", { path: DESTDIR, addr: ADDR });
-318
View File
@@ -1,318 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const DEFAULT_ENGINE_URL = 'http://127.0.0.1:19113';
const DEFAULT_PS5_ADDR = '192.168.137.2:9113';
// Unified test sandbox — matches smoke/sweep. Wipe with a single CLEANUP:
// POST /api/ps5/cleanup {"path":"/data/ps5upload/tests"}
const DEFAULT_DEST_ROOT = '/data/ps5upload/tests/bench';
const DEFAULT_RESULTS_DIR = path.join(repoRoot, 'bench', 'results');
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
function log(message) {
process.stdout.write(`[bench-ftx2-upload] ${message}\n`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let scaled = value;
let index = 0;
while (scaled >= 1024 && index < units.length - 1) {
scaled /= 1024;
index += 1;
}
return `${scaled.toFixed(index === 0 ? 0 : 2)} ${units[index]}`;
}
async function sourceStats(sourcePath, stat) {
if (stat.isFile()) {
return { bytes: stat.size, fileCount: 1 };
}
if (!stat.isDirectory()) {
return { bytes: null, fileCount: 0 };
}
let bytes = 0;
let fileCount = 0;
const stack = [sourcePath];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(p);
} else if (entry.isFile()) {
try {
const s = await fs.stat(p);
bytes += s.size;
fileCount += 1;
} catch {}
}
}
}
return { bytes, fileCount };
}
function parseArgs(argv) {
const out = {
engineUrl: DEFAULT_ENGINE_URL,
ps5Addr: process.env.PS5_ADDR || DEFAULT_PS5_ADDR,
source: '',
destRoot: DEFAULT_DEST_ROOT,
timeoutMs: DEFAULT_TIMEOUT_MS,
spawnEngine: false,
resultsDir: DEFAULT_RESULTS_DIR,
writeResult: true,
};
for (const arg of argv) {
if (arg.startsWith('--engine-url=')) out.engineUrl = arg.slice('--engine-url='.length);
else if (arg.startsWith('--ps5-addr=')) out.ps5Addr = arg.slice('--ps5-addr='.length);
else if (arg.startsWith('--source=')) out.source = arg.slice('--source='.length);
else if (arg.startsWith('--dest-root=')) out.destRoot = arg.slice('--dest-root='.length);
else if (arg.startsWith('--timeout-ms=')) out.timeoutMs = Number(arg.slice('--timeout-ms='.length)) || out.timeoutMs;
else if (arg.startsWith('--results-dir=')) out.resultsDir = path.resolve(repoRoot, arg.slice('--results-dir='.length));
else if (arg === '--spawn-engine') out.spawnEngine = true;
else if (arg === '--no-write-result') out.writeResult = false;
}
if (!out.source) {
throw new Error('missing required --source=/path/to/file-or-dir');
}
return out;
}
async function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {}
await sleep(250);
}
throw new Error(`timed out waiting for ${url}`);
}
function spawnEngine(ps5Addr) {
// Prefer the pre-built release binary — `cargo run` defaults to debug which
// is dramatically slower for high-throughput transfers (the engine allocates
// per-shard in the hot path). Fall back to release cargo build if the
// binary isn't present.
const releaseBin = path.join(repoRoot, 'engine', 'target', 'release', 'ps5upload-engine');
let cmd, args;
if (existsSync(releaseBin)) {
cmd = releaseBin;
args = [];
} else {
cmd = 'cargo';
args = ['run', '--release', '-p', 'ps5upload-engine'];
}
const child = spawn(cmd, args, {
cwd: path.join(repoRoot, 'engine'),
env: {
...process.env,
PS5_ADDR: ps5Addr,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout?.on('data', (chunk) => process.stdout.write(String(chunk)));
child.stderr?.on('data', (chunk) => process.stderr.write(String(chunk)));
return child;
}
async function stopChild(child, name) {
if (!child || child.exitCode != null || child.killed) return;
child.kill('SIGTERM');
await new Promise((resolve) => {
const timer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
log(`${name} stopped`);
}
async function postJson(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!response.ok) {
throw new Error(`POST ${url} failed (${response.status}): ${typeof parsed === 'string' ? parsed : JSON.stringify(parsed)}`);
}
return parsed;
}
async function getJson(url) {
const response = await fetch(url);
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!response.ok) {
throw new Error(`GET ${url} failed (${response.status}): ${typeof parsed === 'string' ? parsed : JSON.stringify(parsed)}`);
}
return parsed;
}
function nowIsoCompact() {
return new Date().toISOString().replaceAll(':', '').replace(/\.\d{3}Z$/, 'Z');
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sourcePath = path.resolve(options.source);
const stat = await fs.stat(sourcePath);
const sourceKind = stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other';
if (sourceKind === 'other') {
throw new Error(`source must be a regular file or directory: ${sourcePath}`);
}
const sourceSummary = await sourceStats(sourcePath, stat);
let engineChild = null;
try {
if (options.spawnEngine) {
log(`spawning engine with PS5_ADDR=${options.ps5Addr}`);
engineChild = spawnEngine(options.ps5Addr);
}
await waitForHttpOk(`${options.engineUrl}/api/jobs`, 30_000);
log(`engine ready at ${options.engineUrl}`);
const destName = path.basename(sourcePath);
const reqBody = sourceKind === 'dir'
? {
addr: options.ps5Addr,
dest_root: options.destRoot,
src_dir: sourcePath,
}
: {
addr: options.ps5Addr,
dest: `${options.destRoot}/${destName}`,
src: sourcePath,
};
const route = sourceKind === 'dir' ? '/api/transfer/dir' : '/api/transfer/file';
log(`starting ${sourceKind} benchmark from ${sourcePath} to ${options.destRoot}`);
const startedAt = Date.now();
const created = await postJson(`${options.engineUrl}${route}`, reqBody);
const jobId = created?.job_id;
if (!jobId) {
throw new Error(`engine did not return job_id: ${JSON.stringify(created)}`);
}
let job = null;
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
job = await getJson(`${options.engineUrl}/api/jobs/${jobId}`);
if (job?.status === 'done' || job?.status === 'failed') break;
await sleep(1000);
}
if (!job || (job.status !== 'done' && job.status !== 'failed')) {
throw new Error(`job ${jobId} did not complete within ${options.timeoutMs} ms`);
}
if (job.status !== 'done') {
throw new Error(`job ${jobId} failed: ${job.error || JSON.stringify(job)}`);
}
const finishedAt = Date.now();
const elapsedMs = Number(job.elapsed_ms || (finishedAt - startedAt));
const bytesSent = Number(job.bytes_sent || 0);
const shardsSent = Number(job.shards_sent || 0);
const bytesPerSec = elapsedMs > 0 ? (bytesSent * 1000) / elapsedMs : 0;
const mibPerSec = bytesPerSec / (1024 * 1024);
const result = {
schema_version: 1,
benchmark: 'ftx2_upload',
created_at: new Date().toISOString(),
engine_url: options.engineUrl,
ps5_addr: options.ps5Addr,
source: {
path: sourcePath,
kind: sourceKind,
bytes: sourceSummary.bytes,
file_count: sourceSummary.fileCount,
},
destination: {
root: options.destRoot,
final_path: job.dest,
},
job: {
job_id: jobId,
tx_id_hex: job.tx_id_hex,
shards_sent: shardsSent,
bytes_sent: bytesSent,
started_at_ms: Number(job.started_at_ms || 0),
completed_at_ms: Number(job.completed_at_ms || 0),
elapsed_ms: elapsedMs,
},
metrics: {
bytes_per_sec: bytesPerSec,
mib_per_sec: mibPerSec,
},
};
log(`job ${jobId} complete`);
log(`bytes sent: ${formatBytes(bytesSent)} in ${(elapsedMs / 1000).toFixed(2)} s`);
log(`throughput: ${mibPerSec.toFixed(2)} MiB/s`);
if (options.writeResult) {
await fs.mkdir(options.resultsDir, { recursive: true });
const outputPath = path.join(
options.resultsDir,
`${nowIsoCompact()}-${sourceKind}-${path.basename(sourcePath)}.json`,
);
await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
log(`wrote result ${outputPath}`);
} else {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
} finally {
await stopChild(engineChild, 'engine');
}
}
main().catch((error) => {
process.stderr.write(`[bench-ftx2-upload] error: ${error.message}\n`);
process.exitCode = 1;
});
@@ -1,23 +1,23 @@
#!/usr/bin/env node
/**
* bench/run-ftx2-sweep.mjs
* bench/run-sweep.mjs
*
* Runs the FTX2 upload benchmark across every profile in bench/profiles.mjs,
* Runs the upload benchmark across every profile in bench/profiles.mjs,
* with PS5-side cleanup before each profile and after the whole run. Writes
* one timestamped JSON + one Markdown report under bench/reports/, and prints
* a summary table to stdout.
*
* Prerequisites:
* - PS5 payload loaded and listening at PS5_ADDR (default 192.168.137.2:9113).
* - PS5 payload loaded and listening at PS5_ADDR (default 192.168.137.2).
* - Fixture trees present under bench/fixtures/ (run scripts/gen-fixtures.mjs
* first, or pass --gen-fixtures).
* - Either engine already running, or pass --spawn-engine to launch it.
*
* Usage:
* node bench/run-ftx2-sweep.mjs --spawn-engine
* node bench/run-ftx2-sweep.mjs --xl --spawn-engine
* node bench/run-ftx2-sweep.mjs --only=large-file,medium-dir --spawn-engine
* node bench/run-ftx2-sweep.mjs --no-cleanup # for debugging: leave PS5 state
* node bench/run-sweep.mjs --spawn-engine
* node bench/run-sweep.mjs --xl --spawn-engine
* node bench/run-sweep.mjs --only=large-file,medium-dir --spawn-engine
* node bench/run-sweep.mjs --no-cleanup # for debugging: leave PS5 state
*/
import fs from 'node:fs/promises';
@@ -34,7 +34,7 @@ const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const DEFAULT_ENGINE_URL = 'http://127.0.0.1:19113';
const DEFAULT_PS5_ADDR = '192.168.137.2:9113';
const DEFAULT_PS5_ADDR = '192.168.137.2';
// Unified test sandbox — see tests/smoke-hardware.mjs for the shape.
// All sweep dest_root entries land under this root so `rm -rf
// /data/ps5upload/tests` wipes every test artifact in one shot.
@@ -102,11 +102,11 @@ function parseArgs(argv) {
}
function printHelp() {
process.stdout.write(`ftx2 sweep — run every bench profile against a live PS5
process.stdout.write(`sweep — run every bench profile against a live PS5
Options:
--engine-url=URL engine HTTP base URL (default: http://127.0.0.1:19113)
--ps5-addr=HOST:PORT PS5 FTX2 address (default: 192.168.137.2:9113)
--ps5-addr=HOST PS5 address (a :port suffix is ignored) (default: 192.168.137.2)
--dest-root=PATH dest root on PS5 (default: /data/ps5upload-sweep)
--fixtures-dir=PATH fixture source root (default: bench/fixtures)
--reports-dir=PATH where to write report (default: bench/reports)
@@ -143,10 +143,6 @@ async function postJson(url, body) {
function tryParse(t) { try { return JSON.parse(t); } catch { return t; } }
function mgmtAddrFor(transferAddr) {
const i = transferAddr.lastIndexOf(':');
return i < 0 ? `${transferAddr}:9114` : `${transferAddr.slice(0, i)}:9114`;
}
async function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
@@ -267,7 +263,7 @@ async function runProfile(opts, profile) {
}
const bytesSent = Number(job.bytes_sent || 0);
const shardsSent = Number(job.shards_sent || 0);
const filesSent = Number(job.files_sent || 0);
const elapsedMs = Number(job.elapsed_ms || wallMs);
const mibPerSec = elapsedMs > 0 ? (bytesSent * 1000) / elapsedMs / (1024 * 1024) : 0;
@@ -278,7 +274,7 @@ async function runProfile(opts, profile) {
file_count: profileFileCount(profile),
bytes_total: profileBytesTotal(profile),
bytes_sent: bytesSent,
shards_sent: shardsSent,
files_sent: filesSent,
job_id: jobId,
tx_id_hex: job.tx_id_hex,
elapsed_ms: elapsedMs,
@@ -346,7 +342,7 @@ function renderMarkdown(report, previous) {
for (const r of previous.results) if (r.status === 'ok') prevByName.set(r.profile, r);
}
const lines = [];
lines.push(`# FTX2 sweep report`);
lines.push(`# Upload sweep report`);
lines.push('');
lines.push(`- **Timestamp**: ${report.created_at}`);
lines.push(`- **PS5 address**: ${report.ps5_addr}`);
@@ -354,14 +350,14 @@ function renderMarkdown(report, previous) {
lines.push(`- **Host**: ${report.host.platform}/${report.host.arch} (node ${report.host.node})`);
if (previous?.created_at) lines.push(`- **Previous report**: ${previous.created_at}`);
lines.push('');
lines.push('| Profile | Kind | Files | Bytes | Time | Shards | Throughput | Δ vs prev |');
lines.push('| Profile | Kind | Files | Bytes | Time | Files sent | Throughput | Δ vs prev |');
lines.push('| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |');
for (const r of report.results) {
if (r.status === 'ok') {
const prev = prevByName.get(r.profile);
const delta = prev ? fmtDelta(r.mib_per_sec, prev.mib_per_sec) : '';
lines.push(
`| \`${r.profile}\` | ${r.kind} | ${r.file_count} | ${formatBytes(r.bytes_total)} | ${(r.elapsed_ms / 1000).toFixed(2)}s | ${r.shards_sent} | **${r.mib_per_sec.toFixed(2)} MiB/s** |${delta} |`
`| \`${r.profile}\` | ${r.kind} | ${r.file_count} | ${formatBytes(r.bytes_total)} | ${(r.elapsed_ms / 1000).toFixed(2)}s | ${r.files_sent} | **${r.mib_per_sec.toFixed(2)} MiB/s** |${delta} |`
);
} else {
lines.push(`| \`${r.profile}\` | — | — | — | — | — | **${r.status.toUpperCase()}** | ${r.error ?? ''} |`);
@@ -419,7 +415,7 @@ async function main() {
log(`engine: ${opts.engineUrl}`);
log(`ps5: ${opts.ps5Addr}`);
opts.ps5MgmtAddr = mgmtAddrFor(opts.ps5Addr);
opts.ps5MgmtAddr = opts.ps5Addr;
log(`ps5 fs: ${opts.ps5MgmtAddr}`);
log(`dest root: ${opts.destRoot}`);
log(`profiles: ${selected.map((p) => p.name).join(', ')}`);
@@ -462,7 +458,7 @@ async function main() {
const r = await runProfile(opts, profile);
results.push(r);
if (r.status === 'ok') {
log(` ${profile.name}: ${formatBytes(r.bytes_sent)} in ${(r.elapsed_ms / 1000).toFixed(2)}s = ${r.mib_per_sec.toFixed(2)} MiB/s (${r.shards_sent} shards)`);
log(` ${profile.name}: ${formatBytes(r.bytes_sent)} in ${(r.elapsed_ms / 1000).toFixed(2)}s = ${r.mib_per_sec.toFixed(2)} MiB/s (${r.files_sent} files)`);
} else {
logErr(` ${profile.name}: ${r.status.toUpperCase()} — ${r.error ?? ''}`);
}
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from "@playwright/test";
// The desktop app's Capture button (status bar, bottom right) saves screenshots for bug reports.
// The Bug report page says where that button is, lists every capture to pick from, and a capture
// taken while a report is being written is added to it.
const PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const shot = (n: number) => ({
name: `screenshot-2026100${n}-120000-000.png`,
path: `/shots/screenshot-2026100${n}-120000-000.png`,
bytes: 70,
data_url: `data:image/png;base64,${PNG}`,
});
test("captures are listed to pick from, and a new capture joins the report", async ({ page }) => {
await page.addInitScript(
([a, b, saved]) => {
const w = window as unknown as Record<string, unknown>;
let id = 0;
const list = [a, b];
w.__TAURI_INTERNALS__ = {
invoke: async (cmd: string) => {
if (cmd === "screenshot_list") return list;
if (cmd === "screenshot_save") {
list.unshift(saved);
return saved;
}
return null;
},
transformCallback: () => ++id,
unregisterCallback: () => {},
convertFileSrc: (p: string) => p,
metadata: { currentWindow: { label: "main" }, currentWebview: { windowLabel: "main", label: "main" } },
plugins: {},
};
w.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
},
[shot(1), shot(2), shot(3)],
);
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
);
await page.goto("/bug-report", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
await expect(main.getByTestId("br-capture-hint")).toContainText("bottom right", { timeout: 30_000 });
// The include toggles (each capture also has a Preview button).
const captures = main.getByTestId("br-captures").locator("button[aria-pressed]");
await expect(captures).toHaveCount(2);
await expect(captures.first()).toHaveAttribute("aria-pressed", "false");
await captures.first().click();
await expect(captures.first()).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "Capture", exact: true }).click();
await expect(captures).toHaveCount(3);
await expect(main.getByRole("button", { name: "screenshot-20261003-120000-000.png", exact: true })).toHaveAttribute("aria-pressed", "true");
});
test("captures open large, page with the arrow keys, and delete from this computer", async ({ page }) => {
await page.addInitScript(
([a, b, c]) => {
const w = window as unknown as Record<string, unknown>;
let id = 0;
let list = [a, b, c];
w.__TAURI_INTERNALS__ = {
invoke: async (cmd: string, args: { names?: string[] }) => {
if (cmd === "screenshot_list") return list;
if (cmd === "screenshot_delete") {
const names = args?.names ?? [];
list = list.filter((s) => !names.includes(s.name));
return names;
}
return null;
},
transformCallback: () => ++id,
unregisterCallback: () => {},
convertFileSrc: (p: string) => p,
metadata: { currentWindow: { label: "main" }, currentWebview: { windowLabel: "main", label: "main" } },
plugins: {},
};
w.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
},
[shot(3), shot(2), shot(1)],
);
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
);
await page.goto("/bug-report", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
await expect(main.getByTestId("br-captures").locator("button[aria-pressed]")).toHaveCount(3, {
timeout: 30_000,
});
// The status bar opens the viewer on the newest capture.
await page.getByRole("button", { name: "View captures" }).first().click();
const viewer = page.getByRole("dialog");
await expect(viewer).toContainText("1 of 3");
await page.keyboard.press("ArrowRight");
await expect(viewer).toContainText("2 of 3");
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
await expect(viewer).toContainText("3 of 3");
// Delete asks first, then the capture is gone from the viewer and the Bug report.
await viewer.getByRole("button", { name: "Delete", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Delete", exact: true }).click();
await expect(viewer).toContainText("2 of 2");
await page.keyboard.press("Escape");
await expect(main.getByTestId("br-captures").locator("button[aria-pressed]")).toHaveCount(2);
});
+90
View File
@@ -0,0 +1,90 @@
import { expect, test, type Route } from "@playwright/test";
// The bug report page end to end in the web UI: describe it, pick how far back, create the report
// (everything recorded in that window, redacted, with MISSING.txt), then Post to GitHub / Discord.
const HOST = "192.168.86.100";
test("one page builds a redacted report of the whole time frame and pre-fills GitHub", async ({ page }) => {
await page.addInitScript((host) => {
window.localStorage.setItem("ps5upload.host", host);
// Record what the page asks the browser to open instead of opening it.
(window as unknown as { __opened: string[] }).__opened = [];
window.open = ((url: string) => {
(window as unknown as { __opened: string[] }).__opened.push(url);
return null;
}) as typeof window.open;
}, HOST);
const now = Date.now();
let bundle: { filename: string; entries: { path: string; text?: string }[] } | null = null;
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
await page.route(
(u) => u.pathname.startsWith("/api/"),
async (route) => {
const req = route.request();
const p = new URL(req.url()).pathname;
if (p === "/api/version") return json(route, { caps: {}, version: "6.5.2" });
if (p === "/api/event-journal")
return json(route, {
dropped: 0,
events: [
{ ts: now - 60_000, src: "helper", cat: "helper", level: "error", code: "helper_fatal", console: HOST, msg: "[fatal] signal 11 while serving frame 88" },
{ ts: now - 50_000, src: "engine", cat: "connection", level: "warn", code: "reconnecting", console: HOST, msg: `reconnecting to ${HOST}`, count: 3, last_ts: now - 40_000 },
],
});
if (p === "/api/ps5/helper-log-ftp") return json(route, { error: "connect: refused" }, 502);
if (p === "/api/bug-report/bundle" && req.method() === "POST") {
bundle = req.postDataJSON();
return route.fulfill({ status: 200, contentType: "application/zip", body: "PK\u0005\u0006" + "\0".repeat(18) });
}
if (p === "/api/jobs") return json(route, []);
return json(route, {});
},
);
await page.goto("/bug-report", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
// Nothing to build until there's a description; the bar says why.
const create = main.getByRole("button", { name: "Create report" });
await expect(create).toBeVisible({ timeout: 30_000 });
await expect(create).toBeDisabled();
await expect(main.getByTestId("br-blocked")).toContainText("Describe what went wrong");
await main.getByLabel("What went wrong?").fill("The helper crashed while I had the Hardware screen open.");
// No log checkboxes: only how far back.
await expect(main.getByRole("checkbox")).toHaveCount(0);
await main.getByRole("radio", { name: "Last 6 hours" }).click();
await expect(main.getByTestId("br-blocked")).toHaveCount(0);
await create.click();
await expect(main.getByTestId("br-saved")).toBeVisible({ timeout: 30_000 });
expect(bundle).not.toBeNull();
const b = bundle as unknown as { filename: string; entries: { path: string; text?: string }[] };
const paths = b.entries.map((e) => e.path);
expect(paths).toEqual(expect.arrayContaining(["README.txt", "report.md", "report.json", "timeline.txt", "timeline.jsonl", "MISSING.txt"]));
const timeline = b.entries.find((e) => e.path === "timeline.txt")?.text ?? "";
expect(timeline).toContain("signal 11");
expect(timeline).toContain("<ip-1>");
expect(JSON.stringify(b.entries)).not.toContain(HOST);
expect(b.entries.find((e) => e.path === "MISSING.txt")?.text).toContain("helper_ftp: connect: refused");
expect(b.entries.find((e) => e.path === "report.md")?.text).toContain("signal 11");
// GitHub, then Discord
await main.getByRole("button", { name: "Post to GitHub" }).click();
await main.getByRole("button", { name: "Post to Discord" }).click();
// Discord copies the text first, then opens the channel (a page that lost focus cannot write the clipboard).
await expect
.poll(() => page.evaluate(() => (window as unknown as { __opened: string[] }).__opened.length))
.toBe(2);
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
const gh = new URL(opened[0]);
expect(gh.pathname).toBe("/phantomptr/ps5upload/issues/new");
expect(gh.searchParams.get("template")).toBe("bug_report.yml");
expect(gh.searchParams.get("title")).toContain("The helper crashed");
expect(gh.searchParams.get("what-happened")).toContain("The helper crashed");
// Everything recorded in the window, no picking: the crash reaches the issue's logs field.
expect(gh.searchParams.get("logs")).toContain("signal 11");
expect(gh.searchParams.get("platform")).toBe("Browser / self-hosted web UI");
expect(opened[1]).toBe("https://discord.com/channels/1464735724434624524/1465533832953462794");
});
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
// The FAQ and What's New screens bundle the repo-root FAQ.md and CHANGELOG.md. On the dev server
// those sit outside the client folder, and Vite served them as raw markdown instead of modules,
// so both screens failed to load their document.
test("the FAQ and What's New screens load their documents", async ({ page }) => {
await page.goto("/faq", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "What ps5upload does (and doesn't)" })).toBeVisible({
timeout: 20_000,
});
await page.goto("/whats-new", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "5.37.1" }).first()).toBeVisible({ timeout: 20_000 });
});
+139
View File
@@ -0,0 +1,139 @@
import { expect, test, type Route } from "@playwright/test";
// A game kept as a folder used to offer "Copy it with Upload", which only opened the Upload
// screen. Send to PS5 now queues the copy and starts it from the game's page (opened from the
// Collection), and the console's row says how it is going. The engine here is a fake that
// answers the same routes.
const HOST = "192.168.0.5";
const FOLDER = "/games/app/PPSA11386-app";
test("Send to PS5 queues a game folder from the Collection and shows its progress", async ({
page,
}) => {
await page.addInitScript((host) => {
window.localStorage.setItem("ps5upload.host", host);
}, HOST);
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
const started: { path?: string; dest?: string } = {};
const game = {
game_id: "PPSA11386",
title: "007 First Light",
platform: "PS5",
sources: ["/games"],
locations: [
{
root: "/games",
container: "app",
name: "PPSA11386-app",
type: "folder",
path: "app/PPSA11386-app",
absolute_path: FOLDER,
size_bytes: 80_900_000_000,
added_ts: 0,
pkg: { kind: "base", version: "1.18.0", region: "Europe", content_id: "EP3969-PPSA11386_00-007FIRSTLIGHT000" },
},
],
total_size_bytes: 80_900_000_000,
copies: 1,
is_duplicate: false,
added_ts: 0,
};
await page.route(
(u) => u.pathname.startsWith("/api/"),
async (route) => {
const req = route.request();
const url = new URL(req.url());
const p = url.pathname;
if (p === "/api/version") return json(route, { caps: { rar: true }, version: "6.5.0" });
if (p === "/api/ps5/status")
return json(route, {
command_count: 1,
instance_id: 1,
prior_instance: "clean",
ps5_kernel: "r229358/releases/13.60",
ucred_elevated: true,
version: "6.5.0",
});
if (p === "/api/ps5/volumes")
return json(route, {
volumes: [
{
path: "/data",
fs_type: "nullfs",
total_bytes: 900_000_000_000,
free_bytes: 400_000_000_000,
allocatable_bytes: 398_000_000_000,
writable: true,
is_placeholder: false,
},
],
});
if (p === "/api/collection/settings")
return json(route, { roots: ["/games"], refresh_secs: null, sweep_sidecars: false, trash_available: true });
if (p === "/api/collection/scan")
return json(route, { running: false, deep: false, found: 1, done: 1, started_ms: 0, finished_ms: 1, error: null, games: 1, locations: 1 });
if (p === "/api/collection/library")
return json(route, {
roots: ["/games"],
generated_at: "2026-10-08T00:00:00Z",
summary: { total_games: 1, total_locations: 1, total_size_bytes: game.total_size_bytes, duplicates_count: 0, reclaimable_bytes: 0 },
games: { [game.game_id]: game },
});
if (p === "/api/collection/console")
return json(route, {
games: [{ game_id: game.game_id, installed: false, dlc_missing: [], non_package_copy: true }],
});
if (p === `/api/games/${game.game_id}`)
return json(route, {
title_id: game.game_id,
title: game.title,
platform: "PS5",
cover: null,
copies: game.locations,
consoles: [{ host: HOST, read_at: Math.floor(Date.now() / 1000), installed: false, dlc_missing: [] }],
});
if (p === `/api/games/${game.game_id}/refresh`)
return json(route, { host: HOST, read_at: Math.floor(Date.now() / 1000), installed: false, dlc_missing: [] });
if (p === "/api/transfer/dir" || p === "/api/transfer/dir-reconcile") {
const body = req.postDataJSON() as { src_dir?: string; dest_root?: string; src?: string; dest?: string };
started.path = body.src_dir ?? body.src;
started.dest = body.dest_root ?? body.dest;
return json(route, { job_id: "j1" });
}
if (p === "/api/pkg/links") return json(route, []);
if (p.startsWith("/api/jobs"))
return json(route, { status: "running", bytes_sent: 20_000_000_000, total_bytes: 80_900_000_000 });
if (p === "/api/ps5/list-dir")
return json(route, {
path: url.searchParams.get("path") ?? "/data",
entries: [],
truncated: false,
total_scanned: 0,
returned: 0,
});
if (p === "/api/ps5/process/list") return json(route, { processes: [] });
if (p === "/api/engine-logs") return json(route, { lines: [] });
if (p === "/api/ps5/readiness") return json(route, { ready: true, detail: "" });
return json(route, {});
},
);
await page.goto("/collection", { waitUntil: "domcontentloaded" });
await page.getByTestId("collection-card").first().click();
await expect(page).toHaveURL(/\/games\/PPSA11386$/);
const row = page.getByTestId(`game-console-${HOST}`);
await expect(row).toContainText("Not installed", { timeout: 30_000 });
// The old button only navigated away; this one opens the send panel in place.
await row.getByRole("button", { name: "Send to PS5" }).click();
await expect(row.getByText(/To \/data\/homebrew\/PPSA11386-app/)).toBeVisible();
// Folder as is: the default for a game folder.
await expect(row.getByRole("radio", { name: "As the folder" })).toBeChecked();
await row.getByRole("button", { name: "Send", exact: true }).click();
// Queued and started from here; the console's row follows it.
await expect.poll(() => started.path, { timeout: 20_000 }).toBe(FOLDER);
await expect(row.getByText(/Sending|Waiting in the queue/).first()).toBeVisible({ timeout: 20_000 });
});
@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test";
// Switching console tabs used to rebuild every screen, so anything typed or loaded for one
// console was gone when you came back to it. Each console now keeps its own screens.
const A = { id: "console-a", name: "Living room", host: "192.168.0.5" };
const B = { id: "console-b", name: "Office", host: "192.168.0.6" };
test("each console keeps its own screens across a console switch", async ({
page,
}) => {
await page.addInitScript(
([a, b]) => {
// Only on the first load: a reload must find what the app itself saved.
if (window.localStorage.getItem("ps5upload.roster.v1")) return;
window.localStorage.setItem("ps5upload.host", a.host);
window.localStorage.setItem(
"ps5upload.roster.v1",
JSON.stringify({ profiles: [a, b], active_id: a.id }),
);
},
[A, B],
);
// No engine here: every engine route answers with nothing.
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
);
await page.goto("/faq", { waitUntil: "domcontentloaded" });
// Each console's tree has its own tab strip; only the one on show is visible.
const tabA = page.locator(`[data-console-id="${A.id}"]:visible`);
const tabB = page.locator(`[data-console-id="${B.id}"]:visible`);
await expect(tabA).toHaveAttribute("aria-current", "page", { timeout: 30_000 });
// The screen on show, whichever console's it is.
const search = page
.locator("[data-scroll-root]:visible")
.getByPlaceholder("Search the FAQ…");
await search.fill("rest mode");
await tabB.click();
await expect(tabB).toHaveAttribute("aria-current", "page");
// B has its own screen: nothing of A's typing is in it.
await expect(search).toHaveValue("");
await search.fill("firewall");
// One scroll root is on show: the selected console's.
await expect(page.locator("[data-scroll-root]:visible")).toHaveCount(1);
await tabA.click();
await expect(tabA).toHaveAttribute("aria-current", "page");
await expect(search).toHaveValue("rest mode");
await tabB.click();
await expect(tabB).toHaveAttribute("aria-current", "page");
await expect(search).toHaveValue("firewall");
});
+15 -13
View File
@@ -8,15 +8,12 @@ test("guides a disconnected user through primary navigation and recovery", async
// serves any same-origin /api probe as inert app HTML in this test setup.
await page.goto("/home", { waitUntil: "domcontentloaded" });
// The sidebar pins Home and nothing else until the user stars screens in
// More, so a fresh profile sees exactly one favourite plus the More
// escape hatch. (The nav landmark keeps its "Primary" name deliberately —
// "Favorites" is the visible heading, not the landmark's accessible name.)
// A fresh profile's sidebar lists every screen: nothing is hidden behind More
// until the user hides it.
const primary = page.getByRole("navigation", { name: "Primary" });
await expect(primary.getByRole("link", { name: "Home" })).toBeVisible();
await expect(primary.getByText("Star screens in More")).toBeVisible();
for (const notPinned of ["Games", "Files", "Console", "Tasks"]) {
await expect(primary.getByRole("link", { name: notPinned })).toHaveCount(0);
for (const shown of ["Games", "Files", "Console", "Tasks", "Convert Games"]) {
await expect(primary.getByRole("link", { name: shown, exact: true })).toBeVisible();
}
await expect(
@@ -33,18 +30,23 @@ test("guides a disconnected user through primary navigation and recovery", async
page.locator('[aria-disabled="true"]').filter({ hasText: /^Upload/ }),
).toBeVisible();
// Everything unpinned stays one click away through More.
// More lists every screen too (and is where hidden ones come back). Scoped to the
// page body: the sidebar carries the same links.
const body = page.getByRole("main");
await page.getByRole("link", { name: "More" }).click();
await page.getByRole("link", { name: "Tasks" }).click();
// Wait for More itself: Home also has a "Tasks" link, and Home stays in the page (hidden)
// after the switch, so a click aimed at it would wait on a link that never shows again.
await expect(page.getByRole("heading", { name: "More" })).toBeVisible();
await body.getByRole("link", { name: "Tasks", exact: true }).click();
await expect(page.getByRole("heading", { name: "Tasks" })).toBeVisible();
await expect(page.getByRole("button", { name: "Tasks", pressed: true })).toBeVisible();
await expect(page.getByRole("button", { name: /history/i })).toBeVisible();
await expect(page.getByRole("tab", { name: "Tasks", selected: true })).toBeVisible();
await expect(page.getByRole("tab", { name: /history/i })).toBeVisible();
await expect(page.getByText("No activity yet")).toBeVisible();
await page.getByRole("link", { name: "More" }).click();
await expect(page.getByRole("heading", { name: "More" })).toBeVisible();
await expect(page.getByRole("link", { name: "Install Package" })).toBeVisible();
await expect(page.getByRole("link", { name: "Settings" })).toBeVisible();
await expect(body.getByRole("link", { name: "Install Package" })).toBeVisible();
await expect(body.getByRole("link", { name: "Settings" })).toBeVisible();
// Regression: this screen used to draw its entire staging UI with no console
// connected — a small "No PS5 host set" warning, and then the full body
+104
View File
@@ -0,0 +1,104 @@
import { expect, test, type Route } from "@playwright/test";
// The Files right-click menu offers New folder: on a row (with the row's own actions) and on
// the list's empty space (a folder menu: New folder, Refresh). The engine here is a fake.
const HOST = "192.168.0.5";
test("right-click offers New folder on a row and on empty space", 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: "6.5.2" });
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: "6.5.2",
});
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 },
],
truncated: false,
total_scanned: 1,
returned: 1,
});
if (p === "/api/jobs") return json(route, []);
return json(route, {});
},
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
const row = main.getByText("homebrew", { exact: true });
await expect(row).toBeVisible({ timeout: 30_000 });
// On a row: the row's actions, with New folder among them.
await row.click({ button: "right" });
const menu = page.getByTestId("fs-row-menu");
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Open" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "New folder" })).toBeVisible();
// The keyboard works in it: focus starts on the first item, the arrows move
// and wrap, Home/End jump, Escape closes.
const items = menu.getByRole("menuitem");
const enabled = menu.locator('[role="menuitem"]:not([disabled])');
await expect(enabled.first()).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(enabled.nth(1)).toBeFocused();
await page.keyboard.press("ArrowUp");
await page.keyboard.press("ArrowUp");
await expect(enabled.last()).toBeFocused();
await page.keyboard.press("Home");
await expect(enabled.first()).toBeFocused();
expect(await items.count()).toBeGreaterThan(1);
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
// Tab closes it too rather than walking out into the page.
await row.click({ button: "right" });
await expect(enabled.first()).toBeFocused();
await page.keyboard.press("Tab");
await expect(menu).toHaveCount(0);
// On the empty space below the rows: the folder menu, without row actions.
const area = page.getByTestId("fs-list-area");
const box = await area.boundingBox();
if (!box) throw new Error("list area has no box");
await page.mouse.click(box.x + box.width / 2, box.y + box.height - 10, {
button: "right",
});
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Open" })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: "Refresh" })).toBeVisible();
// New folder opens the name field.
await menu.getByRole("menuitem", { name: "New folder" }).click();
await expect(menu).toHaveCount(0);
await expect(main.getByPlaceholder("folder name")).toBeVisible();
});
+91
View File
@@ -0,0 +1,91 @@
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(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);
});
+101
View File
@@ -0,0 +1,101 @@
import { expect, test, type Route } from "@playwright/test";
// #433: select files in Files, "Send to another console…", pick the console and folder, and each
// item is queued on that console as a copy from this one. The engine here is a fake.
const PRO = "192.168.0.5";
const PHAT = "192.168.0.6";
test("Files sends the selection to another console, one queued copy per item", async ({ page }) => {
await page.addInitScript(
([pro, phat]) => {
window.localStorage.setItem("ps5upload.host", pro);
window.localStorage.setItem(
"ps5upload.roster.v1",
JSON.stringify({
profiles: [
{ id: "pro", name: "Pro", host: pro },
{ id: "phat", name: "Phat", host: phat },
],
active_id: "pro",
}),
);
},
[PRO, PHAT],
);
const json = (route: Route, body: unknown) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
const relays: { from: string; src: string; to: string; dest: string }[] = [];
await page.route(
(u) => u.pathname.startsWith("/api/"),
async (route) => {
const req = route.request();
const url = new URL(req.url());
const p = url.pathname;
const addr = url.searchParams.get("addr") ?? "";
if (p === "/api/version") return json(route, { caps: { rar: true }, version: "6.7.4" });
if (p === "/api/ps5/status")
return json(route, {
command_count: 1,
instance_id: 1,
prior_instance: "clean",
ps5_kernel: "r229358/releases/13.60",
ucred_elevated: true,
version: "6.7.4",
});
if (p === "/api/ps5/volumes")
return json(route, {
volumes: [
{
path: "/data",
fs_type: "nullfs",
total_bytes: 900e9,
free_bytes: 400e9,
allocatable_bytes: 398e9,
writable: true,
is_placeholder: false,
},
],
});
if (p === "/api/ps5/list-dir") {
const onPhat = addr.startsWith(PHAT);
return json(route, {
path: url.searchParams.get("path") ?? "/data",
entries: onPhat
? [{ name: "b.ffpfsc", kind: "file", size: 1, mtime: 1 }]
: [
{ name: "GameA-app", kind: "dir", size: 0, mtime: 1 },
{ name: "b.ffpfsc", kind: "file", size: 4_000_000, mtime: 1 },
],
truncated: false,
total_scanned: 2,
returned: 2,
});
}
if (p === "/api/transfer/ps5-to-ps5") {
relays.push(req.postDataJSON());
return json(route, { job_id: `j${relays.length}` });
}
// Each copy finishes at once, so the second (run after the first, on Phat's queue) starts.
if (p.startsWith("/api/jobs")) return json(route, { status: "done", bytes_sent: 10, total_bytes: 10, elapsed_ms: 5 });
return json(route, {});
},
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
await expect(main.getByText("GameA-app", { exact: true })).toBeVisible({ timeout: 30_000 });
await main.getByTitle("Select all").check();
await main.getByTestId("fs-send-console").click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("radio", { name: /Phat/ })).toHaveAttribute("aria-checked", "true");
// What is already there on Phat is named before anything runs.
await expect(dialog.getByTestId("send-console-summary")).toContainText("Already there and replaced: b.ffpfsc");
await dialog.getByTestId("send-console-confirm").click();
await expect(dialog).toHaveCount(0);
await expect.poll(() => relays.length, { timeout: 20_000 }).toBe(2);
expect(relays.map((r) => r.dest).sort()).toEqual(["/data/GameA-app", "/data/b.ffpfsc"]);
expect(relays.every((r) => r.from.startsWith(PRO) && r.to.startsWith(PHAT))).toBe(true);
});
@@ -0,0 +1,267 @@
import { expect, test, type Route } from "@playwright/test";
// Regression: an "Add files" upload on the Files screen used to live inside the screen, so
// opening another screen and coming back showed no progress and an armed drop zone, as if
// the upload had been cancelled. The engine here is a fake that answers the same routes.
const HOST = "192.168.0.5";
const PC_DIR = "/pc/games";
const PC_FILE = `${PC_DIR}/big.exfat`;
const TOTAL = 10_000_000_000;
test("an Add files upload keeps its progress across a screen change", async ({
page,
}) => {
await page.addInitScript((host) => {
window.localStorage.setItem("ps5upload.host", host);
}, HOST);
// The fake engine's one upload job: running until the test lets it finish.
const job = { started: 0, cancelled: 0, finished: false };
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
// Only the engine's routes: the dev server also serves the app's own /src/api/ modules.
await page.route(
(u) => u.pathname.startsWith("/api/"),
async (route) => {
const req = route.request();
const url = new URL(req.url());
const p = url.pathname;
if (p === "/api/version")
return json(route, { caps: { rar: true }, version: "6.2.3" });
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: "6.2.3",
});
if (p === "/api/ps5/volumes")
return json(route, {
volumes: [
{
path: "/data",
mount_from: "/user/data",
fs_type: "nullfs",
total_bytes: 670_000_000_000,
free_bytes: 400_000_000_000,
writable: true,
is_placeholder: false,
source_image: "",
safety_reserve_bytes: 1_073_741_824,
allocatable_bytes: 398_000_000_000,
},
],
});
if (p === "/api/ps5/list-dir")
return json(route, {
path: url.searchParams.get("path") ?? "/data",
entries: [
{ name: "homebrew", kind: "dir", size: 0, mtime: 1791360199 },
],
truncated: false,
total_scanned: 1,
returned: 1,
});
if (p === "/api/local/storage-roots") return json(route, [PC_DIR]);
if (p === "/api/local/list-dir")
return json(route, [
{ name: "big.exfat", path: PC_FILE, is_dir: false, size: TOTAL },
]);
if (p === "/api/local/path-kind") return json(route, { kind: "file" });
if (p === "/api/transfer/file" && req.method() === "POST") {
job.started += 1;
return json(route, { job_id: "job-1" });
}
if (p === "/api/jobs/job-1/cancel") {
job.cancelled += 1;
return json(route, {});
}
if (p === "/api/jobs/job-1")
return json(
route,
job.finished
? {
status: "done",
bytes_sent: TOTAL,
total_bytes: TOTAL,
dest: "/data/big.exfat",
}
: {
status: "running",
bytes_sent: 2_500_000_000,
total_bytes: TOTAL,
},
);
if (p === "/api/jobs") return json(route, []);
// Anything else the screens poll: an empty answer is enough here.
return json(route, {});
},
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
await main
.getByRole("button", { name: "Add files" })
.click({ timeout: 30_000 });
// The browser build picks from the engine's machine: choose the file there.
await page.getByText("big.exfat").first().click();
await page.getByRole("button", { name: "Add 1" }).click();
const progress = page.getByTestId("fs-upload-progress");
await expect(progress).toBeVisible({ timeout: 15_000 });
await expect(progress).toContainText("25%");
// Leave Files and come back: the same upload, still running, still cancellable.
const primary = page.getByRole("navigation", { name: "Primary" });
await primary.getByRole("link", { name: "Volumes", exact: true }).click();
await expect(page.getByRole("heading", { name: "Volumes" })).toBeVisible();
// The Files screen is kept alive behind this one, so its progress exists but is hidden.
await expect(page.getByTestId("fs-upload-progress")).toBeHidden();
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();
// From another screen it is visible as a running task.
await primary.getByRole("link", { name: "Tasks", exact: true }).click();
// Only the screen on show: the hidden Files screen also names the file.
await expect(
page.locator("[data-scroll-root]:visible").getByText("big.exfat").first(),
).toBeVisible();
await primary.getByRole("link", { name: "Files", exact: true }).click();
await expect(progress).toBeVisible({ timeout: 15_000 });
// Nothing restarted or cancelled it along the way.
expect(job.started).toBe(1);
expect(job.cancelled).toBe(0);
job.finished = true;
await expect(progress).toHaveCount(0, { timeout: 15_000 });
await expect(main.getByRole("button", { name: "Add files" })).toBeEnabled();
expect(job.started).toBe(1);
});
test("a stopped or interrupted Add files upload can be resumed", async ({
page,
}) => {
await page.addInitScript((host) => {
window.localStorage.setItem("ps5upload.host", host);
}, HOST);
// The fake engine: every start is a new job that runs until cancelled.
const jobs: Record<string, "running" | "cancelled"> = {};
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
await page.route(
(u) => u.pathname.startsWith("/api/"),
async (route) => {
const req = route.request();
const url = new URL(req.url());
const p = url.pathname;
if (p === "/api/version")
return json(route, { caps: { rar: true }, version: "6.2.3" });
if (p === "/api/ps5/status")
return json(route, {
instance_id: 1,
prior_instance: "clean",
ps5_kernel: "r229358/releases/13.60 Jul 17 2026 02:16:29",
ucred_elevated: true,
version: "6.2.3",
});
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: [],
truncated: false,
total_scanned: 0,
returned: 0,
});
if (p === "/api/local/storage-roots") return json(route, [PC_DIR]);
if (p === "/api/local/list-dir")
return json(route, [
{ name: "big.exfat", path: PC_FILE, is_dir: false, size: TOTAL },
]);
if (p === "/api/local/path-kind") return json(route, { kind: "file" });
if (p === "/api/transfer/file" && req.method() === "POST") {
const id = `job-${Object.keys(jobs).length + 1}`;
jobs[id] = "running";
return json(route, { job_id: id });
}
const m = /^\/api\/jobs\/(job-\d+)(\/cancel)?$/.exec(p);
if (m) {
if (m[2]) {
jobs[m[1]] = "cancelled";
return json(route, {});
}
return json(
route,
jobs[m[1]] === "running"
? {
status: "running",
bytes_sent: 2_500_000_000,
total_bytes: TOTAL,
}
: { status: "failed", error: "transfer_cancelled" },
);
}
if (p === "/api/jobs") return json(route, []);
return json(route, {});
},
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
await main
.getByRole("button", { name: "Add files" })
.click({ timeout: 30_000 });
await page.getByText("big.exfat").first().click();
await page.getByRole("button", { name: "Add 1" }).click();
const progress = page.getByTestId("fs-upload-progress");
await expect(progress).toBeVisible({ timeout: 15_000 });
// Stopped on purpose: offered again, with no error.
await progress.getByRole("button", { name: /cancel|stop/i }).click();
const stopped = page.getByTestId("fs-upload-stopped");
await expect(stopped).toContainText("You stopped this copy at big.exfat");
await stopped.getByRole("button", { name: "Resume" }).click();
await expect(progress).toBeVisible({ timeout: 15_000 });
await expect(stopped).toHaveCount(0);
expect(Object.keys(jobs)).toEqual(["job-1", "job-2"]);
// The app goes away mid-upload and comes back: the run was saved.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(stopped).toContainText("interrupted when the app closed", {
timeout: 30_000,
});
await stopped.getByRole("button", { name: "Resume" }).click();
await expect(progress).toBeVisible({ timeout: 15_000 });
// Its engine job was still running, so it was picked back up, not started a third time.
expect(Object.keys(jobs)).toEqual(["job-1", "job-2"]);
// And it can be let go.
await progress.getByRole("button", { name: /cancel|stop/i }).click();
await stopped.getByRole("button", { name: "Dismiss" }).click();
await expect(stopped).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(main.getByRole("button", { name: "Add files" })).toBeEnabled({
timeout: 30_000,
});
await expect(stopped).toHaveCount(0);
});
+131
View File
@@ -0,0 +1,131 @@
import { expect, test, type Page } from "@playwright/test";
// One game page: opened from the Collection, it shows every saved console (with how old that
// knowledge is) and every copy on the drives; Back returns to the Collection; an install on a
// console other than the connected one shows on its row and Open queue goes to its Activity.
const ID = "PPSA01234";
const loc = (name: string, kind: string, version: string) => ({
root: "/games",
container: "/games",
name,
type: "pkg",
path: name,
absolute_path: `/games/${name}`,
size_bytes: 1024 * 1024 * 1024,
added_ts: 1,
pkg: { kind, version, content_id: `UP0000-${ID}_00-ASTRO0000000000`, complete: true, title: "Astro" },
});
const offer = (name: string, category: string, version: string) => ({
path: `/games/${name}`,
name,
version,
content_id: `UP0000-${ID}_00-ASTRO0000000000`,
title: "Astro",
size_bytes: 1024,
category,
});
const game = {
game_id: ID,
title: "Astro",
platform: "PS5",
sources: [],
locations: [loc("astro.pkg", "base", "01.000"), loc("astro-patch.pkg", "patch", "01.004")],
total_size_bytes: 2 * 1024 * 1024 * 1024,
copies: 1,
is_duplicate: false,
added_ts: 1,
};
async function fakeEngine(page: Page) {
const now = Math.floor(Date.now() / 1000);
const view = {
title_id: ID,
title: "Astro",
platform: "PS5",
cover: null,
copies: game.locations,
consoles: [
{
host: "192.168.1.99",
read_at: now - 2 * 3600,
installed: true,
version: "01.002",
update: offer("astro-patch.pkg", "gp", "01.004"),
dlc_missing: [],
},
],
};
await page.addInitScript(() => {
window.localStorage.setItem("ps5upload.host", "192.168.1.100");
window.localStorage.setItem(
"ps5upload.roster.v1",
JSON.stringify({
profiles: [
{ id: "pro", name: "Pro", host: "192.168.1.100" },
{ id: "phat", name: "Phat", host: "192.168.1.99" },
],
active_id: "pro",
}),
);
});
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) => {
const path = new URL(route.request().url()).pathname;
const json = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
if (path === "/api/collection/library")
return json({ summary: { total_games: 1, total_locations: 2, total_size_bytes: 1, duplicates_count: 0, reclaimable_bytes: 0 }, games: { [ID]: game } });
if (path === "/api/collection/settings") return json({ roots: ["/games"], refresh_secs: null, sweep_sidecars: false });
if (path === "/api/collection/scan") return json({ running: false, phase: "idle", done: 0, total: 0 });
if (path === `/api/games/${ID}`) return json(view);
return json({ error: "down" }, 503);
},
);
}
test("a game opened from the Collection shows every console and every copy, and Back returns", async ({ page }) => {
await fakeEngine(page);
await page.goto("/collection", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Astro/ }).first().click({ timeout: 30_000 });
await expect(page).toHaveURL(new RegExp(`/games/${ID}$`));
const consoles = page.getByTestId("game-consoles");
await expect(consoles).toBeVisible({ timeout: 30_000 });
const phat = page.getByTestId("game-console-192.168.1.99");
await expect(phat).toContainText("Installed · v01.002");
await expect(phat).toContainText("as of 2 h ago");
await expect(phat.getByRole("button", { name: "Install update 01.004" })).toBeVisible();
// The Pro has never been read for this game.
await expect(page.getByTestId("game-console-192.168.1.100")).toContainText("Not checked yet");
await expect(page.getByTestId("game-copies").locator("li")).toHaveCount(2);
await expect(page.getByTestId("game-summary")).toContainText("Installed on Phat (01.002)");
await page.getByTestId("game-back").click();
await expect(page).toHaveURL(/\/collection$/);
});
test("installing on a console that is not the connected one shows on its row and opens its activity", async ({ page }) => {
await fakeEngine(page);
await page.goto(`/games/${ID}`, { waitUntil: "domcontentloaded" });
const phat = page.getByTestId("game-console-192.168.1.99");
await phat.getByRole("button", { name: "Install update 01.004" }).click({ timeout: 30_000 });
// The engine is down here, so the item may fail; either way the row says what happened to it
// and links to that console's queue.
const open = phat.getByRole("button", { name: "Open", exact: true });
await expect(open).toBeVisible({ timeout: 30_000 });
await open.click();
await expect(page).toHaveURL(/\/tasks\?console=192\.168\.1\.99$/);
await expect(page.getByTestId("activity-console")).toContainText("Showing Phat");
});
test("Collection keeps the Games tab lit on a phone", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await fakeEngine(page);
await page.goto("/collection", { waitUntil: "domcontentloaded" });
const bottom = page.getByRole("navigation", { name: "Primary" }).last();
await expect(bottom.getByRole("link", { name: /^Games/ })).toHaveAttribute("aria-current", "page", {
timeout: 30_000,
});
});
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
// 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 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");
window.localStorage.setItem(
"ps5upload.roster.v1",
JSON.stringify({ profiles: [{ id: "a", name: "PS5", host: "192.168.86.100" }], active_id: "a" }),
);
(window as unknown as { __opened: string[] }).__opened = [];
window.open = ((url: string) => {
(window as unknown as { __opened: string[] }).__opened.push(url);
return null;
}) as typeof window.open;
});
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) => route.fulfill({ status: 503, contentType: "application/json", body: "{\"error\":\"down\"}" }),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
const badge = main.getByTestId("home-connection-badge");
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);
await coffee.click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __opened: string[] }).__opened))
.toEqual(["https://ko-fi.com/B0B81S0WUA"]);
});
@@ -0,0 +1,63 @@
import { expect, test } from "@playwright/test";
// Switching screens used to throw each screen away, so anything typed, opened or scrolled
// was gone on return. Screens now stay alive, hidden, while another one is shown.
test("a screen keeps what was typed across a screen change", async ({
page,
}) => {
await page.goto("/faq", { waitUntil: "domcontentloaded" });
const main = page.getByRole("main");
// Something typed that lives only in the screen.
const search = main.getByPlaceholder("Search the FAQ…");
await expect(search).toBeVisible({ timeout: 30_000 });
await search.fill("rest mode");
const primary = page.getByRole("navigation", { name: "Primary" });
await primary.getByRole("link", { name: "Tasks", exact: true }).click();
await expect(page.getByRole("heading", { name: "Tasks" })).toBeVisible();
// The screen left behind is out of the way: nothing of it shows.
await expect(main.getByPlaceholder("Search the FAQ…")).toBeHidden();
await page.goBack();
await expect(main.getByPlaceholder("Search the FAQ…")).toHaveValue(
"rest mode",
);
});
test("each screen scrolls by itself and comes back where it was left", async ({
page,
}) => {
// What's New is one long page; the FAQ opens on a short list of questions.
await page.goto("/whats-new", { waitUntil: "domcontentloaded" });
const scroller = page.locator("[data-scroll-root]:visible");
await expect(scroller).toHaveCount(1, { timeout: 30_000 });
await page.getByRole("main").getByRole("heading").first().waitFor();
// Scroll once the page is tall enough to scroll: its content arrives after the heading.
await expect
.poll(() =>
scroller.evaluate((el) => {
if (el.scrollTop < 300) el.scrollTop = 600;
return el.scrollTop;
}),
)
.toBeGreaterThan(300);
const primary = page.getByRole("navigation", { name: "Primary" });
await primary.getByRole("link", { name: "Tasks", exact: true }).click();
await expect(page.getByRole("heading", { name: "Tasks" })).toBeVisible();
// One scroll root is on show (a hidden screen may carry the marker a moment longer; the
// scroll lock takes the visible one).
await expect(page.locator("[data-scroll-root]:visible")).toHaveCount(1);
expect(
await page.locator("[data-scroll-root]:visible").evaluate((el) => el.scrollTop),
).toBe(0);
await page.goBack();
await expect
.poll(() =>
page.locator("[data-scroll-root]:visible").evaluate((el) => el.scrollTop),
)
.toBeGreaterThan(300);
});
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "@playwright/test";
// The Send payload IP box used to switch the selected console on every keystroke: each half-typed
// address became the "current console", the box snapped back, and the other consoles' kept screens
// were pushed out. Typing a different address there now only sets where this send goes.
const A = { id: "console-a", name: "Living room", host: "192.168.0.5" };
const B = { id: "console-b", name: "Office", host: "192.168.0.6" };
test("typing an address in Send payload does not switch consoles", async ({ page }) => {
// Payloads is a desktop-only screen: stand in for the Tauri runtime, every native call
// answering with nothing, which is all this screen needs to render.
await page.addInitScript(() => {
const w = window as unknown as Record<string, unknown>;
let id = 0;
w.__TAURI_INTERNALS__ = {
invoke: async () => null,
transformCallback: () => ++id,
unregisterCallback: () => {},
convertFileSrc: (p: string) => p,
metadata: { currentWindow: { label: "main" }, currentWebview: { windowLabel: "main", label: "main" } },
plugins: {},
};
w.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
});
await page.addInitScript(
([a, b]) => {
if (window.localStorage.getItem("ps5upload.roster.v1")) return;
window.localStorage.setItem("ps5upload.host", a.host);
window.localStorage.setItem(
"ps5upload.roster.v1",
JSON.stringify({ profiles: [a, b], active_id: a.id }),
);
},
[A, B],
);
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
);
await page.goto("/payloads?tab=send", { waitUntil: "domcontentloaded" });
const tabA = page.locator(`[data-console-id="${A.id}"]:visible`);
await expect(tabA).toHaveAttribute("aria-current", "page", { timeout: 30_000 });
const box = page.locator("[data-scroll-root]:visible").getByLabel("PS5 IP address");
await expect(box).toHaveValue(A.host);
await box.fill("");
await box.pressSequentially("10.0.0.77", { delay: 20 });
// Every keystroke kept, the box still focused, and the selected console unchanged.
await expect(box).toHaveValue("10.0.0.77");
await expect(box).toBeFocused();
await expect(tabA).toHaveAttribute("aria-current", "page");
expect(await page.evaluate(() => window.localStorage.getItem("ps5upload.host"))).toBe(A.host);
await expect(page.locator("[data-scroll-root]:visible").getByText("10.0.0.77:9021")).toBeVisible();
});
test("typing an address in First run keeps every keystroke and the console", async ({ page }) => {
await page.addInitScript(() => {
const w = window as unknown as Record<string, unknown>;
let id = 0;
w.__TAURI_INTERNALS__ = {
invoke: async () => null,
transformCallback: () => ++id,
unregisterCallback: () => {},
convertFileSrc: (p: string) => p,
metadata: { currentWindow: { label: "main" }, currentWebview: { windowLabel: "main", label: "main" } },
plugins: {},
};
w.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
});
await page.addInitScript(
([a, b]) => {
if (window.localStorage.getItem("ps5upload.roster.v1")) return;
window.localStorage.setItem("ps5upload.host", a.host);
window.localStorage.setItem(
"ps5upload.roster.v1",
JSON.stringify({ profiles: [a, b], active_id: a.id }),
);
},
[A, B],
);
await page.route(
(u) => u.pathname.startsWith("/api/"),
(route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
);
await page.goto("/first-run", { waitUntil: "domcontentloaded" });
const tabA = page.locator(`[data-console-id="${A.id}"]:visible`);
await expect(tabA).toHaveAttribute("aria-current", "page", { timeout: 30_000 });
const box = page.locator("[data-scroll-root]:visible").getByPlaceholder("192.168.1.50");
await box.fill("");
await box.pressSequentially("10.0.0.77", { delay: 20 });
await expect(box).toHaveValue("10.0.0.77");
await expect(box).toBeFocused();
await expect(tabA).toHaveAttribute("aria-current", "page");
});
+1 -1
View File
@@ -52,7 +52,7 @@ const ALLOWED_STRINGS = new Set([
// Regex allowlist for code-like literals that vary (paths, env lines,
// glob examples). Tested against the trimmed text.
const ALLOWED_PATTERNS = [
/^[A-Z0-9_]+=.*/, // FTX2_BANDWIDTH_MBPS=10 # …
/^[A-Z0-9_]+=.*/, // PS5UPLOAD_BANDWIDTH_MBPS=10 # …
/^[*./\s]*\*\.[a-z]+/i, // *.pkg / eboot.bin / PPSA* / *.log globs
/\.(ini|log|gz|elf|bin|pkg|txt)\b/i, // filenames
/^\/[\w./-]+$/, // absolute paths
@@ -39,7 +39,6 @@ const STATUS_ENDPOINTS = new Set([
"discoverPs5",
"toastPush",
"cheatsStatus",
"ftpStatus",
"remoteplayStatus",
"smpStatus",
"activityGet",
@@ -49,7 +48,6 @@ const STATUS_ENDPOINTS = new Set([
"sdkScan",
"procModulesGet",
"netInterfacesGet",
"userListGet",
]);
/** Only the API modules — this is a rule about where guards belong. */
+3
View File
@@ -38,6 +38,7 @@ export default tseslint.config(
{
ignores: [
"dist/**",
"dist-console/**",
"node_modules/**",
"src-tauri/**",
"coverage/**",
@@ -46,6 +47,8 @@ export default tseslint.config(
// browser-globals config here gives false positives ("console"
// unknown). They're tooling, not shipped code.
"scripts/**",
// Plain-ES5 boot guard served as-is, outside the TS/ESM toolchain.
"public/**",
],
},
js.configs.recommended,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

+2
View File
@@ -17,6 +17,8 @@
</head>
<body>
<div id="root"></div>
<!-- Plain ES5, before the bundle: a notice instead of a white window on an old WebView (#352). -->
<script src="./boot-guard.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+415 -341
View File
File diff suppressed because it is too large. Load diff
+16 -13
View File
@@ -2,7 +2,7 @@
"name": "ps5upload-client",
"private": true,
"type": "module",
"version": "5.35.0",
"version": "7.1.8",
"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,33 +30,35 @@
"dist:android": "tauri android build --apk --target aarch64 --target armv7"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-notification": "^2.4.0",
"@tauri-apps/plugin-opener": "^2.5.5",
"@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.47.0",
"lucide-react": "^1.56.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0",
"zustand": "^5.0.15"
},
"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.11.5",
"@tauri-apps/cli": "^2.12.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^5.0.1",
"eslint": "^10.11.0",
"@vitejs/plugin-react": "^6.1.2",
"@vitest/coverage-v8": "^5.0.4",
"eslint": "^10.12.0",
"eslint-plugin-react-hooks": "^7.1.1",
"postcss": "^8.5.30",
"tailwindcss": "^4.0.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.0",
"typescript-eslint": "^8.71.2",
"vite": "^8.3.4",
"vitest": "^5.0.0"
},
"overrides": {
+16
View File
@@ -0,0 +1,16 @@
// Flatten CSS cascade layers (#352).
//
// Tailwind v4 wraps every rule it emits in `@layer theme/base/components/utilities`. A WebView
// without `@layer` (Safari before 15.4: macOS 11 whose system WebKit was never updated, iOS 15.3
// and older) drops each of those blocks, which leaves the app completely unstyled: the React tree
// renders, but the page is plain HTML. Flattening keeps the layer order as specificity, so the
// cascade is the same in a modern engine and the sheet still applies in an old one.
//
// Tailwind nests some at-rules inside utility rules (the @supports that carries colour-mix() for
// `bg-[var(--color-x)]/10`), and the flattening drops those; they are hoisted out first.
import cascadeLayers from "@csstools/postcss-cascade-layers";
import hoistNestedAtRules from "./postcss/hoistNestedAtRules.mjs";
export default {
plugins: [hoistNestedAtRules(), cascadeLayers()],
};
+43
View File
@@ -0,0 +1,43 @@
// Hoist at-rules nested directly in a style rule out to the top level.
//
// Tailwind v4 writes some utilities with CSS nesting, the colour-with-opacity
// ones most of all:
//
// .bg-\[var\(--color-good\)\]\/5 {
// background-color: var(--color-good);
// @supports (color: color-mix(in lab, red, red)) {
// background-color: color-mix(in oklab, var(--color-good) 5%, transparent);
// }
// }
//
// postcss-cascade-layers (see postcss.config.mjs) drops an at-rule nested like
// that, which left only the fallback: every `bg-[var(--color-x)]/5` painted at
// full strength. Rewritten as
//
// .sel { background-color: var(--color-good); }
// @supports (…) { .sel { background-color: color-mix(…); } }
//
// it means the same and survives the flattening. Only at-rules whose body is
// plain declarations are moved; anything more involved is left as it was.
export default function hoistNestedAtRules() {
return {
postcssPlugin: "ps5upload-hoist-nested-at-rules",
Once(root) {
root.walkRules((rule) => {
let after = rule;
for (const child of [...(rule.nodes ?? [])]) {
if (child.type !== "atrule" || !child.nodes) continue;
if (!child.nodes.every((n) => n.type === "decl" || n.type === "comment")) continue;
const inner = rule.clone({ nodes: [] });
for (const n of child.nodes) inner.append(n.clone());
const hoisted = child.clone({ nodes: [] });
hoisted.append(inner);
child.remove();
after.parent.insertAfter(after, hoisted);
after = hoisted;
}
});
},
};
}
hoistNestedAtRules.postcss = true;
+38
View File
@@ -0,0 +1,38 @@
import postcss from "postcss";
import cascadeLayers from "@csstools/postcss-cascade-layers";
import { describe, expect, it } from "vitest";
import hoistNestedAtRules from "./hoistNestedAtRules.mjs";
const run = (css, plugins) => postcss(plugins).process(css, { from: undefined }).css;
const NESTED = `@layer utilities {
.tint {
background-color: var(--color-good);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-good) 5%, transparent);
}
}
}`;
describe("hoistNestedAtRules", () => {
it("moves a nested @supports out beside its rule", () => {
const out = run(".a { color: red; @supports (x: y) { color: blue; } }", [hoistNestedAtRules()]);
const squash = (css) => css.replace(/[\s;]+/g, "");
expect(squash(out)).toBe(squash(".a { color: red } @supports (x: y) { .a { color: blue } }"));
});
it("keeps the colour-mix through the layer flattening", () => {
// Without the hoist the flattening drops the nested block: only the
// full-strength fallback is left.
expect(run(NESTED, [cascadeLayers()])).not.toContain("color-mix");
expect(run(NESTED, [hoistNestedAtRules(), cascadeLayers()])).toContain(
"color-mix(in oklab, var(--color-good) 5%, transparent)",
);
});
it("leaves an at-rule with nested rules alone", () => {
const css = ".a { @media (hover: hover) { &:hover { color: blue; } } }";
expect(run(css, [hoistNestedAtRules()])).toBe(css);
});
});
+54
View File
@@ -0,0 +1,54 @@
// Split `:lang(a,b,c)` language lists in the built stylesheet.
//
// The build targets old WebKit (safari13), so the minifier rewrites logical properties
// (`rounded-s-full`, `start-3`, `border-s`…) into left/right pairs picked by language:
//
// .rounded-s-full:not(:lang(ae,ar,…,yi)) { border-top-left-radius: … }
// .rounded-s-full:lang(ae,ar,…,yi) { border-top-right-radius: … }
//
// A language LIST inside :lang() is newer than the rest of that output: Chromium before
// 141 (Android System WebView on most phones, older WebView2) drops both rules, which
// left every split button square-cornered and every start/end-positioned control
// misplaced on Android. One language per :lang() works everywhere:
//
// :not(:lang(ae,ar,fa)) -> :not(:lang(ar)):not(:lang(fa))
// .x:lang(ae,ar,fa) -> .x:lang(ar), .x:lang(fa) (only the right-to-left
// languages the app ships)
//
// Run on the final CSS (vite.config.ts, generateBundle), after the minifier wrote it.
import postcss from "postcss";
/** Right-to-left languages the app has a translation for (client/src/i18n/locales). */
export const SHIPPED_RTL = ["ar", "fa"];
const NOT_LIST = /:not\(:lang\(([a-z-]+(?:,[a-z-]+)+)\)\)/gi;
const LIST = /:lang\(([a-z-]+(?:,[a-z-]+)+)\)/i;
function splitSelector(sel) {
// Only the languages the app can be set to need excluding; the rest never occur, and
// each extra :not() would add to the rule's specificity.
const negated = sel.replace(NOT_LIST, (_, list) =>
list
.split(",")
.filter((l) => SHIPPED_RTL.includes(l))
.map((l) => `:not(:lang(${l}))`)
.join(""),
);
const m = LIST.exec(negated);
if (!m) return [negated];
const langs = m[1].split(",").filter((l) => SHIPPED_RTL.includes(l));
// Expand one list at a time; a selector with two lists is expanded again.
return langs.flatMap((l) => splitSelector(negated.replace(m[0], `:lang(${l})`)));
}
export function splitLangLists(css) {
if (!css.includes(":lang(") || !/:lang\([a-z-]+,/i.test(css)) return css;
const root = postcss.parse(css);
root.walkRules((rule) => {
if (!/:lang\([a-z-]+,/i.test(rule.selector)) return;
const selectors = rule.selectors.flatMap(splitSelector);
if (selectors.length === 0) rule.remove();
else rule.selectors = selectors;
});
return root.toResult({ map: false }).css.replace(/,\s*\n\s*/g, ",");
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { splitLangLists } from "./langLists.mjs";
const LIST = "ae,ar,arc,fa,he";
describe("splitLangLists", () => {
it("turns a negated language list into one check per shipped right-to-left language", () => {
const out = splitLangLists(`.rounded-s-full:not(:lang(${LIST})){border-top-left-radius:9px}`);
expect(out).toBe(
".rounded-s-full:not(:lang(ar)):not(:lang(fa)){border-top-left-radius:9px}",
);
});
it("gives a matching language list one selector per right-to-left language the app ships", () => {
const out = splitLangLists(`.end-3:lang(${LIST}){left:1rem}`);
expect(out).toBe(".end-3:lang(ar),.end-3:lang(fa){left:1rem}");
});
it("keeps the other selectors of the rule and leaves single languages alone", () => {
const out = splitLangLists(`.a:lang(${LIST}),.b{x:1}:root:lang(ar) .c{y:2}`);
expect(out).toBe(".a:lang(ar),.a:lang(fa),.b{x:1}:root:lang(ar) .c{y:2}");
});
it("drops a matching rule whose list holds none of the shipped languages", () => {
expect(splitLangLists(".x:lang(he,yi){a:1}.y{b:2}")).toBe(".y{b:2}");
});
});
describe("SHIPPED_RTL", () => {
it("names every right-to-left language the app can be set to", async () => {
const { SHIPPED_RTL } = await import("./langLists.mjs");
const { RTL_LANGS } = await import("../src/state/lang");
expect([...SHIPPED_RTL].sort()).toEqual([...RTL_LANGS].sort());
});
});
+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);
});
});
+115
View File
@@ -0,0 +1,115 @@
/*
* Boot guard: plain ES5, loaded as a classic script BEFORE the app bundle.
*
* An old WebView (macOS 11 / Safari, an old Android System WebView, ...) can fail to parse or run
* the bundle, and the user is then left with a blank white window and no clue why (#352). This file
* must therefore stay free of any syntax newer than ES5 (no const/let, arrow functions, template
* strings, ?. or ??): if it failed to parse it would be no better than the bundle. A test parses
* it and runs it against a stub DOM.
*
* It shows a plain-DOM notice when:
* - a basic platform feature the app needs is missing, or
* - a script fails to parse/load (a SyntaxError, or a <script> that errors), or
* - nothing has rendered into #root after BOOT_TIMEOUT_MS.
* The notice removes itself if the app does start after all.
*/
(function (w, d) {
"use strict";
var BOOT_TIMEOUT_MS = 12000;
var shown = false;
var box = null;
function mounted() {
var r = d.getElementById("root");
return !!(r && r.firstChild);
}
function missingFeatures() {
var m = [];
if (typeof w.Promise !== "function") m.push("Promise");
if (typeof w.fetch !== "function") m.push("fetch");
if (typeof w.Map !== "function" || typeof w.Set !== "function") m.push("Map/Set");
if (typeof Object.assign !== "function") m.push("Object.assign");
if (typeof Object.entries !== "function") m.push("Object.entries");
if (typeof Array.prototype.includes !== "function") m.push("Array.includes");
if (w.CSS && typeof w.CSS.supports === "function" && !w.CSS.supports("display", "grid")) m.push("CSS grid");
return m;
}
// The app's stylesheet sets margin:0 on <body>; a WebView that dropped the sheet leaves the
// default 8px. The React tree still renders then, so mounted() alone cannot see it (#352).
function stylesMissing() {
try {
if (typeof w.getComputedStyle !== "function" || !d.body) return false;
var m = w.getComputedStyle(d.body).marginTop;
return typeof m === "string" && m !== "" && m !== "0px" && m !== "0";
} catch (err) {
return false;
}
}
function show(reason, force) {
if (shown || (!force && mounted())) return;
shown = true;
box = d.createElement("div");
box.id = "ps5u-boot-notice";
box.setAttribute("role", "alert");
box.style.cssText =
"font:16px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;max-width:560px;margin:10vh auto;padding:24px;color:#111;background:#fff;border:1px solid #ccc;border-radius:8px";
var h = d.createElement("h1");
h.style.cssText = "font-size:20px;margin:0 0 12px";
h.appendChild(d.createTextNode("PS5 Upload cannot start in this web view"));
var p = d.createElement("p");
p.appendChild(
d.createTextNode(
"The system component that draws this app is too old or blocked part of it. Update your operating system (on a Mac, to a newer macOS than Big Sur 11) or the browser, then open PS5 Upload again.",
),
);
var q = d.createElement("p");
q.style.cssText = "font-size:13px;color:#555;word-break:break-word";
var ua = (w.navigator && w.navigator.userAgent) || "unknown";
q.appendChild(d.createTextNode("Reason: " + reason + " | " + ua + " | Include this when you report the problem."));
box.appendChild(h);
box.appendChild(p);
box.appendChild(q);
(d.body || d.documentElement).appendChild(box);
}
function hide() {
if (box && box.parentNode) box.parentNode.removeChild(box);
box = null;
shown = false;
}
var missing = missingFeatures();
if (missing.length) show("missing " + missing.join(", "));
w.addEventListener(
"error",
function (e) {
var t = e && e.target;
if (t && t !== w && t.tagName === "SCRIPT") {
show("script failed to load");
return;
}
var msg = (e && e.message) || "";
if (/SyntaxError|Unexpected (token|identifier|number|end)|BigInt|not a function|is not defined/i.test(msg)) {
show(msg);
}
},
true,
);
w.setTimeout(function check() {
if (mounted()) {
if (stylesMissing()) {
show("the stylesheet was not applied", true);
} else if (shown) {
hide();
}
return;
}
show("the app did not start within " + BOOT_TIMEOUT_MS / 1000 + " seconds");
w.setTimeout(check, 1000);
}, BOOT_TIMEOUT_MS);
})(window, document);
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env node
// Splits the monolithic `src/i18n.ts` into one file per locale under
// `src/i18n/locales/<code>.ts`, so each locale can be lazy-loaded as
// its own Vite chunk. Run once; the main `src/i18n.ts` becomes a thin
// loader. See the comment in `src/i18n.ts` for the lazy-load design.
import fs from "node:fs";
import path from "node:path";
const SRC = path.resolve("src/i18n.ts");
const OUT_DIR = path.resolve("src/i18n/locales");
const text = fs.readFileSync(SRC, "utf8");
const lines = text.split("\n");
// Locate each locale's start line (" en: {" or " \"zh-CN\": {").
const headerRe = /^\s\s"?([a-zA-Z][a-zA-Z0-9_-]*)"?:\s*\{\s*$/;
const headers = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(headerRe);
if (m) headers.push({ code: m[1], lineIdx: i });
}
// Find each block's end by tracking braces from the header's `{`.
// Returns { lineIdx, col } — col is the byte offset of the matching `}`
// within that line. Some locales have `,},` on the same line as the
// last content entry (no own line for the closing brace).
function findBlockEnd(startLine) {
let depth = 0;
for (let i = startLine; i < lines.length; i++) {
const line = lines[i];
for (let c = 0; c < line.length; c++) {
const ch = line[c];
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return { lineIdx: i, col: c };
}
}
}
throw new Error(`unterminated block at line ${startLine}`);
}
for (const { code, lineIdx } of headers) {
const { lineIdx: endLine, col: endCol } = findBlockEnd(lineIdx);
// Inner body lines: everything strictly between the `{` line and the
// closing `}` line, plus any content on the closing line up to the
// `}` itself (17 of 18 locales pack the last entry + `},` on one line).
const bodyLines = lines.slice(lineIdx + 1, endLine);
const tail = lines[endLine].slice(0, endCol).replace(/,\s*$/, "");
if (tail.trim().length > 0) bodyLines.push(tail);
// De-indent by 4 spaces if every non-empty line starts with that;
// monolithic file uses 4-space indent inside each locale.
const dedented = bodyLines.map((l) =>
l.startsWith(" ") ? l.slice(4) : l,
);
const safe = code.replace("-", "_");
const out = [
`// Auto-extracted from src/i18n.ts by scripts/split-i18n.mjs.`,
`// Locale: ${code}. Lazy-loaded via dynamic import in src/i18n.ts.`,
`import type { Translations } from "../types";`,
``,
`const ${safe}: Translations = {`,
...dedented,
`};`,
``,
`export default ${safe};`,
``,
].join("\n");
const target = path.join(OUT_DIR, `${code}.ts`);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, out);
console.log(`wrote ${target} (${bodyLines.length} lines)`);
}
console.log(`\nDone. ${headers.length} locales extracted.`);
console.log(`Now manually rewrite src/i18n.ts to lazy-load these chunks.`);
+716 -397
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "ps5upload-desktop"
version = "5.35.0"
version = "7.1.8"
description = "The all-in-one PS5 companion app."
edition = "2021"
rust-version = "1.77"
@@ -13,11 +13,12 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
# Tauri core + the built-in plugins we rely on for dialogs, fs and OS shell
ava1 = { path = "../../engine/crates/ava1" }
# Tauri core + the native file dialogs. File reads/writes go through our
# own commands (see commands/save_text_file.rs), and links open through
# tauri-plugin-opener below.
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
# Native OS notifications (system tray/center) — mirrors the in-app
# notification inbox to the desktop notification center and the Android
# notification shade. Cross-platform (macOS/Windows/Linux/Android/iOS).
@@ -117,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
@@ -162,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" }
+4 -4
View File
@@ -1,8 +1,10 @@
{
"$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, fs, shell, core window controls).",
"windows": ["main"],
"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"
],
"permissions": [
"core:default",
"core:window:allow-minimize",
@@ -17,8 +19,6 @@
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"fs:default",
"shell:allow-open",
"opener:default",
"opener:allow-open-url",
"opener:allow-open-path",
@@ -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"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Loaded 100 of 1932 files, more files were not shown because too many files have changed in this diff. Show more