Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ What's new in ps5upload, written for humans.

---

## 3.3.20

- **Installs wait for the PS5 to be ready — fewer "couldn't be applied" errors.**
Right after an install the PS5 has a brief recovery moment (the screen-black
blink); starting the next install during it was getting rejected with a
transient error, so you'd have to wait and retry by hand. ps5upload now checks
the console is settled before it installs, and if the PS5 says it's busy it
waits and retries automatically instead of failing — so back-to-back updates
and DLC just work. (Shows "Waiting for the PS5 to be ready…" while it waits.)

## 3.3.19

- **"Installed" no longer leaks between consoles.** If you staged the same .pkg
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.3.19
3.3.20
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ps5upload-client",
"private": true,
"version": "3.3.19",
"version": "3.3.20",
"description": "The all-in-one PS5 companion app.",
"homepage": "https://github.com/phantomptr/ps5upload",
"author": "PhantomPtr <phantomptr@gmail.com>",
Expand Down
10 changes: 5 additions & 5 deletions client/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ps5upload-desktop"
version = "3.3.19"
version = "3.3.20"
description = "The all-in-one PS5 companion app."
edition = "2021"
rust-version = "1.77"
Expand Down
14 changes: 14 additions & 0 deletions client/src-tauri/src/commands/ps5_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ pub async fn ps5_apps_installed(addr: Option<String>) -> Result<JsonValue, Strin
get_json(&url).await
}

/// Probe whether the console is settled enough to take a .pkg install (the
/// AppListRegistered frame round-trips cleanly). Proxies GET
/// /api/ps5/readiness. Used as a pre-install + post-install gate so we don't
/// fire an install into the post-install SceShellUI recovery window.
#[tauri::command]
pub async fn ps5_readiness(addr: Option<String>) -> Result<JsonValue, String> {
let base = engine::url();
let url = match addr {
Some(a) => format!("{base}/api/ps5/readiness?addr={}", urlencoding(&a)),
None => format!("{base}/api/ps5/readiness"),
};
get_json(&url).await
}

#[tauri::command]
pub async fn ps5_list_dir(
path: String,
Expand Down
1 change: 1 addition & 0 deletions client/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ pub fn run() {
commands::pkg_metadata_console,
commands::ps5_list_dir,
commands::ps5_apps_installed,
commands::ps5_readiness,
commands::transfer_file,
commands::transfer_dir,
commands::transfer_zip,
Expand Down
2 changes: 1 addition & 1 deletion client/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "PS5Upload",
"version": "3.3.19",
"version": "3.3.20",
"identifier": "com.phantomptr.ps5upload",
"build": {
"beforeDevCommand": "npm run dev:vite",
Expand Down
16 changes: 16 additions & 0 deletions client/src/api/ps5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2942,6 +2942,22 @@ export async function appsInstalled(
return { titles, registeredUnavailable: !!res?.registered_unavailable };
}

/** Whether the console is settled enough to take a .pkg install — the engine
* round-trips the AppListRegistered frame, which goes unanswered while the
* console is recovering from a prior install (the post-install SceShellUI
* black-screen blip). Returns false on any error (treat "can't tell" as
* "not ready" so the caller waits rather than firing into the blip). */
export async function consoleReadiness(host: string): Promise<boolean> {
try {
const res = await invoke<{ ready?: boolean }>("ps5_readiness", {
addr: toMgmtAddr(host),
});
return !!res?.ready;
} catch {
return false;
}
}

/** One `.pkg` found on a connected external/USB drive. */
export interface ExternalPkg {
/** Absolute on-console path, e.g. `/mnt/usb0/games/foo.pkg`. */
Expand Down
55 changes: 54 additions & 1 deletion client/src/state/pkgLibrary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,21 @@ vi.mock("../api/ps5", () => ({
// Pre-install space check; default to "plenty free" so store tests aren't
// blocked. (null would also be fine — it means "couldn't read, don't block".)
installFreeBytes: vi.fn(async () => 1_000_000_000_000),
// Console-readiness probe; default to "ready" so install tests proceed past
// the pre-install gate immediately. Readiness-specific tests override it.
consoleReadiness: vi.fn(async () => true),
}));
// No active transfer in tests → installs proceed immediately.
vi.mock("../lib/ps5Transfers", () => ({ transferScreenBusy: () => false }));

import { invoke } from "@tauri-apps/api/core";
import { fsDelete, fsListDir, fsCopy, pkgMetadataConsole } from "../api/ps5";
import {
fsDelete,
fsListDir,
fsCopy,
pkgMetadataConsole,
consoleReadiness,
} from "../api/ps5";
import {
titleIdFromContentId,
platformFromTitleId,
Expand All @@ -38,6 +47,7 @@ import {
installSpaceWarning,
installedLastResult,
runPkgInstall,
waitForConsoleReady,
recordPkgInstalled,
isPkgInstalledHere,
PKG_MAY_NOT_LAUNCH_MESSAGE,
Expand Down Expand Up @@ -232,6 +242,49 @@ describe("pkgRowInstalled (installed/Reinstall badge)", () => {
});
});

describe("waitForConsoleReady (install readiness gate)", () => {
const mockedReady = vi.mocked(consoleReadiness);
beforeEach(() => {
mockedReady.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
// Restore the module default ("ready") so later install suites sail through
// the pre-install gate instead of inheriting this suite's not-ready stub.
mockedReady.mockReset();
mockedReady.mockResolvedValue(true);
});

it("returns true immediately when the console is already ready (no wait)", async () => {
mockedReady.mockResolvedValue(true);
const p = waitForConsoleReady("192.168.1.10");
await expect(p).resolves.toBe(true);
expect(mockedReady).toHaveBeenCalledTimes(1); // first probe, no delay
});

it("polls until the console becomes ready", async () => {
// Not ready twice, then ready.
mockedReady
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
const p = waitForConsoleReady("192.168.1.10");
// Let the polling timers + awaits flush.
await vi.advanceTimersByTimeAsync(5_000);
await expect(p).resolves.toBe(true);
expect(mockedReady.mock.calls.length).toBeGreaterThanOrEqual(3);
});

it("gives up (false) after the timeout when never ready", async () => {
mockedReady.mockResolvedValue(false);
const p = waitForConsoleReady("192.168.1.10", { timeoutMs: 4_500 });
await vi.advanceTimersByTimeAsync(10_000);
// timeoutMs/POLL(1500) = 3 attempts, then false.
await expect(p).resolves.toBe(false);
});
});

describe("recordPkgInstalled / isPkgInstalledHere (per-console isolation)", () => {
// The reported bug: the same .pkg staged on multiple consoles lands at an
// identical path, and installing on ONE console used to mark it installed on
Expand Down
99 changes: 90 additions & 9 deletions client/src/state/pkgLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
pkgMetadataConsole,
toastPush,
installFreeBytes,
consoleReadiness,
} from "../api/ps5";
import type { ExternalPkg } from "../api/ps5";
import { formatBytes } from "../lib/format";
Expand Down Expand Up @@ -536,6 +537,52 @@ const PKG_VERIFY_POLL_MS = 2_500;
* assume success — `completed` stays false so the pkg is KEPT (never delete on
* uncertainty). */
const PKG_VERIFY_MAX_POLL_ERRORS = 5;

// ── Console-readiness gate ───────────────────────────────────────────────────
// A console goes unresponsive on the AppListRegistered frame while it recovers
// from a prior install (the post-install SceShellUI black-screen blip). Firing
// an install into that window is what produces the transient rejections seen on
// hardware — DPI rc=0x80020002 and appinst 0x80b21106 — which clear once the
// console settles (the user's logs: rejected twice, then ok minutes later after
// they waited). We gate installs on a readiness probe and retry the transient.
const READY_POLL_MS = 1_500;
/** Cap on each readiness wait. We never hard-block forever — if the probe can't
* clear (e.g. an older payload that can't report registered apps), attempting
* the install is better than refusing to try, and the DPI transient-retry is
* the real safety net for a genuinely-busy console. Kept modest (30s) so a
* console whose probe never clears adds a bounded delay, not a 90s stall; the
* real post-install blip settles well within this. */
const READY_WAIT_TIMEOUT_MS = 30_000;
/** The DPI daemon's "console not in an installable state" rejection — transient
* (clears on settle). Retried with a readiness gate rather than failed outright. */
const DPI_TRANSIENT_BUSY_RC = 0x80020002;
/** How many times to (re)attempt a DPI install that keeps hitting the transient
* busy rc, each gated on the console becoming ready again. */
const DPI_MAX_ATTEMPTS = 4;

/**
* Poll the console-readiness probe (the AppListRegistered round-trip) until it
* reports ready, or `timeoutMs` elapses. Returns true once ready, false on
* timeout. The first probe runs with no delay — a settled console proceeds
* instantly. `onWait` fires before each subsequent poll so the caller can
* surface a "waiting for the PS5…" notice. Exported for the queue's pre-install
* gate and for tests.
*/
export async function waitForConsoleReady(
host: string,
opts: { timeoutMs?: number; onWait?: (elapsedMs: number) => void } = {},
): Promise<boolean> {
const timeoutMs = opts.timeoutMs ?? READY_WAIT_TIMEOUT_MS;
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / READY_POLL_MS));
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (await consoleReadiness(host)) return true;
if (attempt < maxAttempts - 1) {
opts.onWait?.(attempt * READY_POLL_MS);
await sleep(READY_POLL_MS);
}
}
return false;
}
/** Absolute backstop so a pathological engine that keeps returning "install"
* forever can't block the queue indefinitely. Generously past the engine's
* own 2h session GC — under normal operation the engine reaches a terminal
Expand Down Expand Up @@ -690,7 +737,8 @@ async function verifyInstallCompleted(
async function runDpiInstall(
host: string,
localPs5Path: string,
): Promise<{ ok: boolean; errMessage: string; daemonFailed: boolean }> {
onStatus?: (msg: string) => void,
): Promise<{ ok: boolean; errMessage: string; daemonFailed: boolean; rc: number }> {
const ip = hostOf(host);
const ens = (await invoke("dpi_ensure", { ip })) as {
ok?: boolean;
Expand All @@ -700,17 +748,34 @@ async function runDpiInstall(
return {
ok: false,
daemonFailed: true,
rc: 0,
errMessage: ens.error || "the DPI daemon didn't come up on :9040",
};
}
// Send the install, retrying the transient "console busy" rejection
// (0x80020002) — it clears once the console settles, so we gate each retry on
// the readiness probe instead of failing the way a single attempt used to.
let resp: { ok?: boolean; rc?: number; err_message?: string } = {};
try {
resp = (await invoke("pkg_dpi_install", {
ps5Addr: mgmtAddr(host),
localPs5Path,
})) as typeof resp;
} catch (e) {
resp = { ok: false, rc: 0, err_message: pkgError(e) };
for (let attempt = 1; attempt <= DPI_MAX_ATTEMPTS; attempt++) {
try {
resp = (await invoke("pkg_dpi_install", {
ps5Addr: mgmtAddr(host),
localPs5Path,
})) as typeof resp;
} catch (e) {
resp = { ok: false, rc: 0, err_message: pkgError(e) };
}
const rcNow = (resp.rc ?? 0) >>> 0;
if (resp.ok || rcNow !== DPI_TRANSIENT_BUSY_RC || attempt === DPI_MAX_ATTEMPTS) {
break;
}
// Transient busy → wait for the console to settle, then retry.
onStatus?.(
`PS5 is busy finishing the last install — waiting for it to be ready (attempt ${attempt}/${DPI_MAX_ATTEMPTS})…`,
);
await waitForConsoleReady(host, {
onWait: () => onStatus?.("Waiting for the PS5 to be ready…"),
});
}
// Restore our main payload — the daemon replaced it. Best-effort.
try {
Expand All @@ -729,6 +794,7 @@ async function runDpiInstall(
return {
ok,
daemonFailed: false,
rc,
errMessage: ok
? ""
: resp.err_message ||
Expand Down Expand Up @@ -796,7 +862,20 @@ export async function runPkgInstall(
// can render a real % for large titles (Sony's BGFT progress isn't
// meaningful on the file:// staging path). Optional — no-op if omitted.
onProgress?: (installedBytes: number, total: number) => void,
// Called with a human-readable status line while the install waits on the
// console-readiness gate (pre-install + DPI transient retry). Lets the caller
// surface "Waiting for the PS5 to be ready…" instead of a frozen UI.
onStatus?: (msg: string) => void,
): Promise<PkgInstallOutcome> {
// PRE-INSTALL GATE: don't fire an install into the post-install SceShellUI
// recovery window — that's what produces the transient rejections. Wait for
// the console to answer the readiness probe cleanly first. Best-effort: on
// timeout we proceed anyway (an older payload may never report ready, and the
// transient-retry below still rescues a genuinely-busy console).
await waitForConsoleReady(host, {
onWait: () => onStatus?.("Waiting for the PS5 to be ready…"),
});

let installed = false;
// True when the install accepted but stalled (flatlined) before completing —
// the pkg is KEPT and the caller shows a retry message, not a hard failure.
Expand Down Expand Up @@ -878,7 +957,7 @@ export async function runPkgInstall(
// it's safe for patches. HW-proven on the Phat — a Jak X v01.04 patch landed
// in /user/patch/CUSA07842/patch.pkg with the 3.8 GB base in
// /user/app/CUSA07842/app.pkg fully intact.
const dpi = await runDpiInstall(host, localPs5Path);
const dpi = await runDpiInstall(host, localPs5Path, onStatus);
if (dpi.daemonFailed) {
// DPI couldn't even come up. For a patch, give update-specific guidance
// (base is safe; try the PS5's Package Installer) rather than a raw
Expand Down Expand Up @@ -1359,6 +1438,8 @@ const makePkgLibraryStore = () =>
set({ busyNotice: `Installing on the PS5… ${pct}%` });
}
},
// Readiness-gate status (pre-install wait / DPI transient retry).
(msg) => set({ busyNotice: msg }),
);
useActivityHistoryStore
.getState()
Expand Down
Loading
Loading