From c40d404fa7c4a4405a8623978a25c677f2169c44 Mon Sep 17 00:00:00 2001 From: phantomptr Date: Mon, 6 Jul 2026 04:37:11 -0700 Subject: [PATCH] fix(install): make Stream (beta) correct on FW 11+ and actually verify it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the stream-install crash fix (#184), from the FW assessment. Two gaps that made "stream install is great" false, especially on FW 12: 1. FW-11 guard (client). The Stream path installs via the standalone DPI daemon, which has no kernel R/W and so can't acquire the SYSTEM install authid that FW 11+ requires for the content-copy — on FW 11+ it registers the title but lands NO content ("hollow" tile). The in-process installer (normal Upload→Install) DOES escalate correctly. So before a stream install on a console we KNOW is FW 11+, warn and point the user at the normal path (they can still proceed). Below FW 11 / unknown FW is unaffected. New `firmwareMajor()` helper (+3 tests) off the existing kernel-string parser. 2. Verify actually verifies (engine). install_status_handler returned CONFLICT for a session with no BGFT task_id — which is EVERY stream (serve_only) session — so the client's verifyInstallCompleted fell through to its optimistic "can't verify → assume done" branch and reported success without checking. A stream session has no task_id (the DPI daemon did the install in its own process), but completion is verifiable the SAME way the normal path verifies a Done: the on-disk launch-check (verify_launchable → is /user/app//app.pkg present) + byte observation, both filesystem-based and task_id-free. Synthesize a Done phase for the no-task_id case so the existing adaptive tracker runs (Registered ⇒ complete, Absent ⇒ still installing, flatline ⇒ stall). This also catches a hollow tile if a FW-11+ user proceeds past the guard. Engine build+fmt+clippy+80 tests, desktop clippy, client typecheck+lint+782 tests (+3), i18n 18 langs — all green. Still pending HW verification on the Pro (FW 9.60) once its payload is reloaded. Co-Authored-By: Claude Opus 4.8 --- client/src/i18n/locales/en.ts | 4 ++ client/src/lib/ps5Firmware.test.ts | 26 +++++++- client/src/lib/ps5Firmware.ts | 16 +++++ client/src/screens/InstallPackage/index.tsx | 30 +++++++++ .../ps5upload-engine/src/pkg_install.rs | 62 +++++++++++++------ scripts/i18n-known-missing.json | 51 +++++++++++++++ 6 files changed, 168 insertions(+), 21 deletions(-) diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index b39ea428..56bcef94 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -2185,6 +2185,10 @@ shortcuts_activate: "Open / activate", "pkglib.baseMissing.title": "Base game isn't installed", "pkglib.stream.hint": "Install a .pkg straight from this PC over HTTP — no staging upload (beta)", + "pkglib.stream.fw11.title": "Stream install isn't reliable on this firmware", + "pkglib.stream.fw11.body": + 'Your PS5 is on firmware {fw}.x. Stream (beta) installs through a path that can\'t get the credentials firmware 11 and up require, so it may register the game but install no data (a "hollow" tile that won\'t launch). Use the normal Upload → Install instead — it handles firmware {fw} correctly. Continue with Stream anyway?', + "pkglib.stream.fw11.confirm": "Stream anyway", roster_remove_aria: "Remove {name}", schedule_daily_at: "daily at {time}", schedule_once_at: "once at {time}", diff --git a/client/src/lib/ps5Firmware.test.ts b/client/src/lib/ps5Firmware.test.ts index 7eec9e89..0bfa3da8 100644 --- a/client/src/lib/ps5Firmware.test.ts +++ b/client/src/lib/ps5Firmware.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parsePS5Firmware } from "./ps5Firmware"; +import { parsePS5Firmware, firmwareMajor } from "./ps5Firmware"; describe("parsePS5Firmware", () => { it("extracts from 'releases/09.60' kernel string", () => { @@ -37,3 +37,27 @@ describe("parsePS5Firmware", () => { ).toBe("9.60"); }); }); + +describe("firmwareMajor (Stream FW-11 guard)", () => { + it("returns the integer major below the FW-11 cliff", () => { + expect( + firmwareMajor("FreeBSD 11.0 r218215/releases/09.60 Jul 18 2023") + ).toBe(9); + expect(firmwareMajor("r/releases/05.00")).toBe(5); + expect(firmwareMajor("r/releases/10.40")).toBe(10); + }); + + it("returns >= 11 at and above the cliff (the guard trigger)", () => { + expect(firmwareMajor("r/releases/11.00")).toBe(11); + expect(firmwareMajor("r/releases/12.40")).toBe(12); + // The guard is `fw !== null && fw >= 11`. + expect(firmwareMajor("r/releases/12.40")! >= 11).toBe(true); + expect(firmwareMajor("r/releases/09.60")! >= 11).toBe(false); + }); + + it("returns null when the firmware can't be parsed (guard does NOT block)", () => { + expect(firmwareMajor(null)).toBeNull(); + expect(firmwareMajor("")).toBeNull(); + expect(firmwareMajor("unknown build")).toBeNull(); + }); +}); diff --git a/client/src/lib/ps5Firmware.ts b/client/src/lib/ps5Firmware.ts index e7e490a5..1a2e027f 100644 --- a/client/src/lib/ps5Firmware.ts +++ b/client/src/lib/ps5Firmware.ts @@ -35,3 +35,19 @@ export function parsePS5Firmware(kernel: string | null | undefined): string | nu } return null; } + +/** + * The firmware MAJOR number (9, 10, 11, 12, …) from the kernel string, or + * null when it can't be parsed. Used for the FW-11 "authority cliff": at and + * above FW 11, Sony gates the package content-copy behind the SYSTEM install + * authid, which the standalone DPI daemon (Stream beta) can't acquire — so a + * stream install there registers a hollow tile with no content. The reliable + * path on FW 11+ is the normal upload-then-install (its in-process installer + * DOES escalate). Callers use `firmwareMajor(kernel) >= 11` to steer users. + */ +export function firmwareMajor(kernel: string | null | undefined): number | null { + const fw = parsePS5Firmware(kernel); + if (!fw) return null; + const major = Number(fw.split(".")[0]); + return Number.isFinite(major) ? major : null; +} diff --git a/client/src/screens/InstallPackage/index.tsx b/client/src/screens/InstallPackage/index.tsx index c43c7286..679c6763 100644 --- a/client/src/screens/InstallPackage/index.tsx +++ b/client/src/screens/InstallPackage/index.tsx @@ -53,6 +53,7 @@ import { type PkgConsoleMetadata, } from "../../api/ps5"; import { transferAddr, hostOf } from "../../lib/addr"; +import { firmwareMajor } from "../../lib/ps5Firmware"; import { formatBytes } from "../../lib/format"; /* ─── Cover art ──────────────────────────────────────────────────────── @@ -292,6 +293,9 @@ const installedIdsCache = new Map>(); export default function InstallPackageScreen() { const tr = useTr(); const host = useConnectionStore((s) => s.host); + // This console's runtime (kernel string → firmware major for the Stream + // FW-11 guard). Scoped to the active host like every other selector. + const runtime = useConnectionStore((s) => s.runtimeByHost[hostOf(host)]); // Per-console store: every selector is scoped to THIS console's host, so the // Install Package view is fully isolated per PS5 (parallel installs). const entries = usePkgLibrary(host, (s) => s.entries); @@ -467,6 +471,32 @@ export default function InstallPackageScreen() { ); return; } + // FW-11 authority cliff: the Stream (beta) path installs via the standalone + // DPI daemon, which can't acquire the SYSTEM install authid that FW 11+ + // requires for the content-copy — so a stream install there registers a + // hollow tile with no content. Steer the user to the normal + // upload-then-install (whose in-process installer DOES escalate) before we + // waste a transfer on an install that won't land. Only a hard block when we + // KNOW it's FW 11+; unknown/<11 proceeds. + const fwMajor = firmwareMajor(runtime?.ps5Kernel); + if (fwMajor !== null && fwMajor >= 11) { + const proceed = await confirm({ + title: tr( + "pkglib.stream.fw11.title", + undefined, + "Stream install isn't reliable on this firmware", + ), + message: tr( + "pkglib.stream.fw11.body", + { fw: String(fwMajor) }, + `Your PS5 is on firmware ${fwMajor}.x. Stream (beta) installs through a path that can't get the credentials firmware 11 and up require, so it may register the game but install no data (a "hollow" tile that won't launch). Use the normal Upload → Install instead — it handles firmware ${fwMajor} correctly. Continue with Stream anyway?`, + ), + confirmLabel: tr("pkglib.stream.fw11.confirm", undefined, "Stream anyway"), + cancelLabel: tr("cancel", undefined, "Cancel"), + destructive: true, + }); + if (!proceed) return; + } setStreaming(true); try { const sel = isAndroid() diff --git a/engine/crates/ps5upload-engine/src/pkg_install.rs b/engine/crates/ps5upload-engine/src/pkg_install.rs index bde4d8c0..911103c2 100644 --- a/engine/crates/ps5upload-engine/src/pkg_install.rs +++ b/engine/crates/ps5upload-engine/src/pkg_install.rs @@ -1307,31 +1307,51 @@ async fn install_status_handler( cached_stalled, )); } - let task_id = match task_id { - Some(t) => t, - None => return json_err(StatusCode::CONFLICT, "session has no BGFT task_id yet"), - }; // Off the reactor: this handler is polled ~1/s per active install, and the // blocking STATUS frame exchange against a slow/wedged console would // otherwise park a worker thread per poll — with several installs that // starves the whole engine. (See install_start_handler.) - let mut status: PkgInstallStatus = { - let addr = ps5_addr.clone(); - match tokio::task::spawn_blocking(move || pkg_install_status(&addr, task_id)).await { - Ok(Ok(s)) => s, - Ok(Err(e)) => { - return json_err( - StatusCode::BAD_GATEWAY, - &format!("payload PKG_INSTALL_STATUS failed: {e}"), - ) - } - Err(e) => { - return json_err( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("status task panicked/cancelled: {e}"), - ) + // + // A serve-only (Stream beta) session has NO BGFT task_id — the in-process + // installer never ran; the DPI daemon did, in its own process. We can't + // query BGFT phase, but completion is verifiable the SAME way the normal + // path verifies a Done: the on-disk launch-check (`verify_launchable`) plus + // byte observation, both filesystem-based and task_id-free. Synthesize a + // Done phase so the progress-driven tracker below runs (Registered ⇒ + // complete, Absent ⇒ still installing, flatline ⇒ stall). This replaces the + // old CONFLICT that made the client's stream-install verify a silent no-op + // (and thus couldn't catch a FW-11+ hollow tile). + let mut status: PkgInstallStatus = match task_id { + Some(task_id) => { + let addr = ps5_addr.clone(); + match tokio::task::spawn_blocking(move || pkg_install_status(&addr, task_id)).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + return json_err( + StatusCode::BAD_GATEWAY, + &format!("payload PKG_INSTALL_STATUS failed: {e}"), + ) + } + Err(e) => { + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("status task panicked/cancelled: {e}"), + ) + } } } + None => PkgInstallStatus { + phase: InstallPhase::Done, + downloaded: 0, + total, + err_code: 0, + detail: String::new(), + register_path: String::new(), + intdebug_avail: false, + kernel_rw: false, + shellui_err: None, + appinst_err: None, + }, }; // (`total` from the session is the fallback for build_status_response, @@ -1546,7 +1566,9 @@ async fn install_status_handler( status, total, cancelled, - task_id, + // Serve-only sessions have no BGFT task_id; 0 makes via_tier() report + // "direct-bgft", the honest "no synthetic tier flags" fallback. + task_id.unwrap_or(0), launchable, installed_bytes, stalled, diff --git a/scripts/i18n-known-missing.json b/scripts/i18n-known-missing.json index a2958c8a..52014fef 100644 --- a/scripts/i18n-known-missing.json +++ b/scripts/i18n-known-missing.json @@ -134,6 +134,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -471,6 +474,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -808,6 +814,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -1145,6 +1154,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -1482,6 +1494,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -1819,6 +1834,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -2156,6 +2174,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -2493,6 +2514,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -2830,6 +2854,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -3167,6 +3194,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -3504,6 +3534,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -3841,6 +3874,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -4178,6 +4214,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -4515,6 +4554,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -4852,6 +4894,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -5189,6 +5234,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step", @@ -5526,6 +5574,9 @@ "pkglib.menu.openFolder", "pkglib.options.heading", "pkglib.stream", + "pkglib.stream.fw11.body", + "pkglib.stream.fw11.confirm", + "pkglib.stream.fw11.title", "pkglib.stream.hint", "pkglib.version.title", "playlist_add_repo_step",