diff --git a/client/src-tauri/src/commands/ps5_engine.rs b/client/src-tauri/src/commands/ps5_engine.rs index 26a86aa2..eb5f10a6 100644 --- a/client/src-tauri/src/commands/ps5_engine.rs +++ b/client/src-tauri/src/commands/ps5_engine.rs @@ -1537,6 +1537,10 @@ pub async fn ffpkg_extract( /// Kick off an install. Returns the session_id, the HTTP URL the PS5 /// will fetch from, and the BGFT task_id. Caller polls `pkg_install_status` /// until phase=done|error. +// Tauri command: the parameter list mirrors the JS call site (each becomes a +// key in the invoke args object), so the count is dictated by the install API +// surface, not Rust ergonomics — the standard exception to too_many_arguments. +#[allow(clippy::too_many_arguments)] #[tauri::command] pub async fn pkg_install_start( ps5_addr: String, @@ -1549,6 +1553,10 @@ pub async fn pkg_install_start( // engine keeps the staged pkg instead of deleting it post-install. Optional // so any caller that omits it gets the safe default (true) via serde. delete_staging: Option, + // Serve-only (Stream beta): create the /pkg-host/ serving session but skip + // the in-process install — the caller finishes via dpi-direct-install. See + // InstallStartRequest::serve_only. Optional; defaults false (normal install). + serve_only: Option, ) -> Result { let url = format!("{}/api/pkg/install/start", engine::url()); let body = serde_json::json!({ @@ -1559,6 +1567,7 @@ pub async fn pkg_install_start( "local_ps5_path": local_ps5_path, "content_id": content_id, "delete_staging": delete_staging.unwrap_or(true), + "serve_only": serve_only.unwrap_or(false), }); post_json(&url, &body).await } diff --git a/client/src/state/pkgLibrary.ts b/client/src/state/pkgLibrary.ts index a43d41bd..dc0d2348 100644 --- a/client/src/state/pkgLibrary.ts +++ b/client/src/state/pkgLibrary.ts @@ -1834,6 +1834,12 @@ const makePkgLibraryStore = () => // No staging file is created, so deleteStaging is moot — pass // false so the engine doesn't record a staging_path to clean up. deleteStaging: false, + // Serve-only: create the /pkg-host/ session but DON'T run the + // in-process InstallByPackage. That call, handed our http:// URL, + // hangs the FW<11 payload until its watchdog kills the helper — the + // 3.3.25 "stream install crashed my PS5" bug. The DPI daemon does the + // real install in step 3 (runDpiDirectInstall). + serveOnly: true, })) as { err_code?: number; session_id?: string; diff --git a/engine/crates/ps5upload-engine/src/pkg_install.rs b/engine/crates/ps5upload-engine/src/pkg_install.rs index 474de5f2..bde4d8c0 100644 --- a/engine/crates/ps5upload-engine/src/pkg_install.rs +++ b/engine/crates/ps5upload-engine/src/pkg_install.rs @@ -284,6 +284,24 @@ pub struct InstallStartRequest { /// instead of the engine silently deleting it regardless. #[serde(default = "default_true")] pub delete_staging: bool, + /// Serve-only mode for the streaming-install (Stream beta) path. + /// + /// When true, create + register the /pkg-host/ serving session and + /// return its `session_id`, but DO NOT run the in-process + /// `sceAppInstUtilInstallByPackage` frame. The caller then hands the + /// session to `/api/pkg/dpi-direct-install`, which installs via the + /// DPI daemon (a separate loader process) pulling bytes over HTTP. + /// + /// This exists because the in-process installer, handed an `http://` + /// pkg-host URL on FW < 11, triggers Sony's PlayGo HTTP preflight and + /// HANGS the payload's RPC thread until the helper's watchdog kills it + /// (30 s read-timeout → console unreachable). `installStream`'s whole + /// premise is "register the session, let the DPI daemon do the actual + /// install" — so the in-process install must be skipped here, not just + /// as an optimisation but to avoid crashing the helper. Defaults false + /// so the staged/normal install path is completely unchanged. + #[serde(default)] + pub serve_only: bool, } fn default_true() -> bool { @@ -643,6 +661,38 @@ async fn install_start_handler( sessions.insert(session_id.clone(), session.clone()); } + // Serve-only (Stream beta): the session + its /pkg-host/ listener are now + // live, so the DPI daemon can pull bytes. Return WITHOUT running the + // in-process InstallByPackage — on FW < 11 that call against an http:// URL + // hangs the payload until the watchdog kills the helper. The client's next + // step (`/api/pkg/dpi-direct-install`) performs the real install. + if req.serve_only { + crate::log_info!( + "pkg_install serve-only: addr={} session={} url={} content_id={} title={:?} — skipping in-process install; DPI daemon will pull", + req.ps5_addr, + session_id, + url, + session.content_id, + session.title, + ); + return json_ok(&InstallStartResponse { + session_id, + url, + task_id: 0, + err_code: 0, + err_message: None, + detail: String::new(), + may_not_launch: false, + register_path: "serve-only".to_string(), + intdebug_avail: false, + kernel_rw: false, + shellui_err: None, + appinst_err: None, + via: "serve-only".to_string(), + package_type, + }); + } + let install_req = PkgInstallRequest { url: url.clone(), content_id: session.content_id.clone(), @@ -3075,4 +3125,33 @@ mod tests { assert!(matches!(parse_dpi_reply("error:???"), DpiReply::Unknown(_))); assert!(matches!(parse_dpi_reply(""), DpiReply::Unknown(_))); } + + #[test] + fn install_start_request_serve_only_defaults_false() { + // A normal install request (no serve_only key) must default to a + // real install — serve_only=false — so the staged/normal path is + // untouched. This is the safety default: any caller that forgets the + // field gets the install, not a silent no-op. + let req: InstallStartRequest = serde_json::from_str( + r#"{"ps5_addr":"1.2.3.4:9114","path":"/x.pkg","delete_staging":true}"#, + ) + .expect("parse"); + assert!(!req.serve_only); + assert!(req.delete_staging); + } + + #[test] + fn install_start_request_serve_only_true_parses() { + // The Stream-beta client sends serve_only=true to register the + // pkg-host session WITHOUT the in-process InstallByPackage (which + // hangs the FW<11 helper). Pin that the field round-trips so the + // client↔engine contract can't silently regress to the crashing path. + let req: InstallStartRequest = serde_json::from_str( + r#"{"ps5_addr":"1.2.3.4:9114","path":"/x.pkg","serve_only":true}"#, + ) + .expect("parse"); + assert!(req.serve_only); + // delete_staging still defaults true even when omitted here. + assert!(req.delete_staging); + } }