From b63d1ee55819b5c85f41abe6e74a153f0c06d6ac Mon Sep 17 00:00:00 2001 From: Twice6804 <260297042+Twice6804@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:10:42 -0700 Subject: [PATCH] feat(webui): add engine routes for Saves/Screenshots/Videos/Processes/Power/Users/SMP Phase 2 of browser-accessible ps5upload: thin axum handlers for the PS5-side screens whose Tauri commands previously called ps5upload-core in-process with no HTTP equivalent. Each new route is a direct spawn_blocking wrapper over the existing core function, mirroring the pattern already used by every other GET/POST handler in the engine. - GET /api/ps5/saves/list, /api/ps5/screenshots/list, /api/ps5/videos/list - GET /api/ps5/process/list, POST /api/ps5/process/kill - POST /api/ps5/power/control, GET /api/ps5/power/telemetry - GET /api/ps5/users/list - GET /api/ps5/smp/status The ShadowMount+ status snapshot logic (previously Tauri-crate-local) moved to ps5upload-core::smp so both the desktop command and the new engine route share one implementation instead of two copies drifting apart; the Tauri command is now a 3-line delegation. browserInvoke.ts gained matching cases for saves_list, screenshots_list, videos_list, process_list_get, process_kill_pid, power_reboot/shutdown/ standby/tick, power_telemetry_get, user_list_get, and smp_status. Co-Authored-By: Claude Sonnet 5 --- client/src-tauri/src/commands/smp.rs | 349 +-------------------- client/src/lib/browserInvoke.ts | 67 +++++ engine/crates/ps5upload-core/src/lib.rs | 1 + engine/crates/ps5upload-core/src/smp.rs | 350 ++++++++++++++++++++++ engine/crates/ps5upload-engine/src/lib.rs | 219 ++++++++++++++ 5 files changed, 645 insertions(+), 341 deletions(-) create mode 100644 engine/crates/ps5upload-core/src/smp.rs diff --git a/client/src-tauri/src/commands/smp.rs b/client/src-tauri/src/commands/smp.rs index 55a7ad85..3a945677 100644 --- a/client/src-tauri/src/commands/smp.rs +++ b/client/src-tauri/src/commands/smp.rs @@ -1,104 +1,11 @@ //! ShadowMount+ integration — read-only awareness. //! -//! Detects whether ShadowMountPlus is installed/running on the -//! connected PS5, surfaces its config + state, lists its mounted -//! image dirs. Strictly read-only: we never write SMP's config, -//! never restart it through anything other than the same payload-send -//! flow the user could trigger themselves. -//! -//! Why integrate at all when SMP runs autonomously: the PS5-side -//! state (which images are mounted, which scan paths are configured, -//! what the auto-tuned kstuff delays look like) lives behind a -//! filesystem the user can only browse via FTP today. Lifting it -//! into the Library tab makes the tool stack behave like one product -//! instead of three. -//! -//! Detection signals (any one is sufficient): -//! 1. `/data/shadowmount/debug.log` exists (SMP writes this on -//! first run). Strongest signal — the file is unique to SMP and -//! survives across reboots. -//! 2. A process named `shadowmountplus` appears in PROC_LIST. -//! Definitive proof it's currently running. -//! -//! All Sony API calls go through the engine's existing FTX2 RPCs -//! (FS_LIST_DIR, PROC_LIST, FS_READ); nothing here speaks the wire -//! directly — same separation of concerns as `commands/probes.rs`. - -use std::time::Duration; - -use serde::Serialize; - -use ps5upload_core::fs_ops::{fs_read_with_timeout, list_dir_with_timeout, ListDirOptions}; -use ps5upload_core::hw::proc_list; - -/// Per-call deadline for any single FTX2 RPC. SMP's config is small, -/// the directory listings are small, the proc list is small. 5s is -/// generous and bounds the whole snapshot if any one call hangs. -const RPC_TIMEOUT: Duration = Duration::from_secs(5); - -/// Hard cap on file bytes we'll pull back to the desktop. SMP's -/// config files are typically <8 KiB; debug.log can be larger but -/// we only show the tail. 256 KiB matches the engine's game_meta -/// param.json limit — same "small text file" use case. -const READ_LIMIT_BYTES: u64 = 256 * 1024; - -/// Canonical paths SMP writes/reads. Hardcoded because they're -/// burned into SMP's source (sm_config_mount.c) — not user-configurable. -const SMP_DATA_DIR: &str = "/data/shadowmount"; -const SMP_CONFIG_PATH: &str = "/data/shadowmount/config.ini"; -const SMP_AUTOTUNE_PATH: &str = "/data/shadowmount/autotune.ini"; -const SMP_DEBUG_LOG_PATH: &str = "/data/shadowmount/debug.log"; -const SMP_MOUNT_POINT: &str = "/mnt/shadowmnt"; -/// Name SMP processes appear under in `ps`. Stable since the project's -/// inception (the binary is shadowmountplus.elf and the kernel-side -/// process inherits that basename). -const SMP_PROCESS_NAME_HINT: &str = "shadowmountplus"; +//! Thin Tauri command layer over `ps5upload_core::smp::collect_status`. +//! The snapshot logic itself lives in `ps5upload-core` so the engine's +//! browser-facing HTTP route can share it — see that module's doc +//! comment for detection signals and the read-only design rationale. -/// One mounted-image row, surfaced in the Library tab's SMP panel. -#[derive(Serialize)] -pub struct SmpMountedImage { - /// Mount point under /mnt/shadowmnt (hashed name SMP picks per - /// image — `_`). - mount_point: String, - /// Best-effort image source filename, derived from the mount - /// point name. SMP doesn't expose the original .ffpkg path - /// through any read-only surface, so we strip the trailing - /// "_<8-hex>" hash. Empty if the mount point doesn't match the - /// expected pattern. - derived_name: String, -} - -/// Full SMP status snapshot. Every field is null/empty when SMP -/// isn't present — UI checks `installed` and `running` to decide -/// what to show. -#[derive(Serialize)] -pub struct SmpStatus { - /// True when `/data/shadowmount/debug.log` exists. Implies SMP - /// has been loaded at least once on this console. - installed: bool, - /// True when a process matching `shadowmountplus` is in PROC_LIST. - /// Independent of `installed`: SMP can be installed but not - /// currently running (e.g. user removed it from autoload). - running: bool, - /// Raw config.ini contents (truncated to READ_LIMIT_BYTES). - /// Renderer parses + displays as a key/value table; we don't - /// parse here so the renderer can show the raw file too. - config_ini: Option, - /// Raw autotune.ini — kstuff per-game delays SMP learned from - /// crash patterns + per-image sector-size overrides. - autotune_ini: Option, - /// Tail of debug.log (last READ_LIMIT_BYTES). Useful for "why - /// didn't my game mount?" troubleshooting without making the - /// user FTP into the console. - debug_log_tail: Option, - /// Images currently mounted under /mnt/shadowmnt/. Empty list - /// when no mounts (or when SMP isn't running). UI shows count. - mounted_images: Vec, - /// Per-call error captures. Always present so the renderer can - /// surface "config.ini exists but couldn't be read" without - /// inferring from null fields. - errors: Vec, -} +use ps5upload_core::smp::{collect_status, SmpStatus}; /// One-shot status snapshot. Tauri command — invoked from the /// Library tab's SMP panel mount + refresh button. @@ -106,252 +13,12 @@ pub struct SmpStatus { /// `addr` is the management-port address ("ip:9114"). Renderer is /// expected to construct it via the existing `toMgmtAddr` helper. /// -/// All RPCs run on a `spawn_blocking` so we don't tie up the async -/// reactor; `fs_read` and friends are sync inside `ps5upload-core`. +/// Runs on a `spawn_blocking` so we don't tie up the async reactor; +/// `collect_status` is sync inside `ps5upload-core`. #[tauri::command] pub async fn smp_status(addr: String) -> Result { tokio::task::spawn_blocking(move || collect_status(&addr)) .await .map_err(|e| format!("smp_status task: {e}"))? -} - -/// Collect the snapshot. Pure sync function so unit tests can drive -/// it against a mock; today we only call it from `smp_status` which -/// supplies a real address. -fn collect_status(addr: &str) -> Result { - let mut errors: Vec = Vec::new(); - - // Probe SMP's data dir. Existence ≈ "installed at least once." - let data_dir_listing = list_dir_with_timeout( - addr, - SMP_DATA_DIR, - ListDirOptions::default(), - Some(RPC_TIMEOUT), - ); - let installed = match &data_dir_listing { - Ok(_) => true, - Err(e) => { - // ENOENT-equivalent: dir doesn't exist, which is - // "not installed", not an error. - let s = e.to_string(); - if is_payload_enoent(&s) { - false - } else { - errors.push(format!("list /data/shadowmount: {s}")); - false - } - } - }; - - // PROC_LIST → look for shadowmountplus process. Match is - // substring on lowercased name to handle "shadowmountplus.elf" - // vs "shadowmountplus" depending on how the kernel records it. - let running = match proc_list(addr) { - Ok(list) => { - let needle = SMP_PROCESS_NAME_HINT.to_ascii_lowercase(); - list.procs - .iter() - .any(|p| p.name.to_ascii_lowercase().contains(&needle)) - } - Err(e) => { - errors.push(format!("proc list: {e}")); - false - } - }; - - // Read config.ini + autotune.ini + debug.log tail. Each is - // best-effort — if any fails we still return the others. - let config_ini = read_text_file_or_record(addr, SMP_CONFIG_PATH, &mut errors); - let autotune_ini = read_text_file_or_record(addr, SMP_AUTOTUNE_PATH, &mut errors); - let debug_log_tail = read_text_file_or_record(addr, SMP_DEBUG_LOG_PATH, &mut errors); - - // List mounted images. SMP creates one subdir per mounted image - // under /mnt/shadowmnt/. Empty list when nothing is mounted. - let mounted_images = match list_dir_with_timeout( - addr, - SMP_MOUNT_POINT, - ListDirOptions::default(), - Some(RPC_TIMEOUT), - ) { - Ok(listing) => listing - .entries - .iter() - .filter(|e| e.kind == "dir") - .map(|e| SmpMountedImage { - mount_point: format!("{SMP_MOUNT_POINT}/{}", e.name), - derived_name: derive_image_name(&e.name), - }) - .collect(), - Err(e) => { - // /mnt/shadowmnt may not exist if SMP has never mounted - // anything; that's not an error worth surfacing. See - // is_payload_enoent for why all three of "ENOENT", "No such - // file", and the payload's `_errno_2` numeric form all need - // to be treated as "feature not in use" here. - let s = e.to_string(); - if !is_payload_enoent(&s) { - errors.push(format!("list /mnt/shadowmnt: {s}")); - } - Vec::new() - } - }; - - Ok(SmpStatus { - installed, - running, - config_ini, - autotune_ini, - debug_log_tail, - mounted_images, - errors, - }) -} - -/// Helper: try to read a small text file, recording errors but never -/// failing the whole call. None when the file doesn't exist; Some -/// when readable; None + errors entry when something else went wrong. -fn read_text_file_or_record(addr: &str, path: &str, errors: &mut Vec) -> Option { - match fs_read_with_timeout(addr, path, 0, READ_LIMIT_BYTES, Some(RPC_TIMEOUT)) { - Ok(bytes) => { - // Files we read here are config files / log tails. UTF-8 - // is overwhelmingly the case; lossy decode is the right - // fallback for the rare debug-log line with non-UTF-8 - // bytes (kernel printf output). - Some(String::from_utf8_lossy(&bytes).into_owned()) - } - Err(e) => { - let s = e.to_string(); - if is_missing_optional_file_error(&s) { - // Not an error — file just hasn't been written yet - // (e.g. autotune.ini is created lazily on first - // tuning event). - None - } else { - errors.push(format!("read {path}: {s}")); - None - } - } - } -} - -fn is_missing_optional_file_error(message: &str) -> bool { - is_payload_enoent(message) || message.contains("fs_read_stat_failed") -} - -/// True for any error string that ultimately means "the path you asked -/// for didn't exist." The payload + engine + std::io error chains all -/// surface this differently — std::io::Error gives `ENOENT` or `No such -/// file or directory`, while the payload's FS_LIST_DIR / FS_READ -/// rejections encode errno numerically as `fs_list_dir_opendir_errno_2` -/// or `fs_read_open_errno_2` (errno 2 = ENOENT on FreeBSD/Prospero, same -/// as POSIX). Probe sites need to treat all of them as "not installed / -/// not yet created" instead of pushing them into the user-visible error -/// list. Centralised so a new probe can't drift back to the partial check -/// that surfaced `opendir_errno_2` on the SmpPanel Probe-Errors list. -fn is_payload_enoent(message: &str) -> bool { - if message.contains("ENOENT") || message.contains("No such file") { - return true; - } - // The payload encodes errno as a trailing integer, e.g. `..._errno_2`. - // Match the number EXACTLY so `_errno_20`..`_errno_29` (ENOTDIR, EISDIR, - // EMFILE, ENOSPC, …) are not mis-classified as ENOENT (2) and silently - // swallowed as "not installed / not yet created". - message.split("_errno_").skip(1).any(|rest| { - let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); - digits == "2" - }) -} - -/// Strip SMP's CRC32 hash suffix from a mount-point dir name. -/// "Outlast2_a3c3fd8b" → "Outlast2". -/// -/// SMP's hash suffix is exactly 8 lowercase hex chars (CRC32 in hex). -/// If the name doesn't match that pattern we return it unchanged — -/// future SMP versions might use a different scheme. -fn derive_image_name(dir_name: &str) -> String { - // Operate on chars, not bytes: the suffix is always 9 ASCII chars - // ("_" + 8 hex) but the head can be any UTF-8 (game/image names often - // contain non-ASCII). The old byte-based `split_at(len - 9)` panicked - // when a multi-byte codepoint sat within the last 9 bytes, taking down - // the whole SMP-status snapshot for that one mount. - let chars: Vec = dir_name.chars().collect(); - if chars.len() < 10 { - return dir_name.to_string(); - } - let split = chars.len() - 9; - if chars[split] != '_' { - return dir_name.to_string(); - } - if !chars[split + 1..].iter().all(|c| c.is_ascii_hexdigit()) { - return dir_name.to_string(); - } - chars[..split].iter().collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn derive_strips_hex_suffix() { - assert_eq!(derive_image_name("Outlast2_a3c3fd8b"), "Outlast2"); - assert_eq!(derive_image_name("My Game_deadbeef"), "My Game"); - } - - #[test] - fn derive_handles_non_ascii_without_panicking() { - // Byte-based split_at(len - 9) used to panic when a multi-byte - // codepoint landed in the last 9 bytes. char-based logic is safe. - assert_eq!(derive_image_name("x日a3c3fd8"), "x日a3c3fd8"); // < 10 chars → unchanged - assert_eq!(derive_image_name("日本_a3c3fd8b"), "日本"); // strips ASCII suffix off a UTF-8 head - assert_eq!(derive_image_name("日本語ゲーム_deadbeef"), "日本語ゲーム"); - } - - #[test] - fn derive_keeps_name_without_suffix() { - assert_eq!(derive_image_name("plainname"), "plainname"); - assert_eq!(derive_image_name("foo_notmatching"), "foo_notmatching"); - } - - #[test] - fn derive_handles_short_names() { - assert_eq!(derive_image_name("a"), "a"); - assert_eq!(derive_image_name(""), ""); - } - - #[test] - fn optional_file_missing_detection_handles_payload_stat_failure() { - assert!(is_missing_optional_file_error( - "payload rejected FS_READ(/data/shadowmount/autotune.ini): fs_read_stat_failed" - )); - assert!(is_missing_optional_file_error("No such file or directory")); - assert!(!is_missing_optional_file_error( - "payload rejected FS_READ(/data/shadowmount/autotune.ini): fs_read_path_not_allowed" - )); - } - - #[test] - fn payload_enoent_detection_handles_numeric_errno_form() { - // FS_LIST_DIR on a non-existent path returns the numeric errno - // form — the Probe-Errors panel on the Library SMP card was - // showing this verbatim before the fix because the old check only - // matched the textual "ENOENT" / "No such file" forms. - assert!(is_payload_enoent( - "payload rejected FS_LIST_DIR(/mnt/shadowmnt): fs_list_dir_opendir_errno_2" - )); - // FS_READ on a non-existent path uses the same numeric encoding. - assert!(is_payload_enoent( - "payload rejected FS_READ(/data/shadowmount/missing.ini): fs_read_open_errno_2" - )); - // Textual forms still detected (engine wrapper / std::io::Error). - assert!(is_payload_enoent("ENOENT")); - assert!(is_payload_enoent("No such file or directory (os error 2)")); - // Non-ENOENT errnos must NOT be treated as missing — those are - // real failures that should reach the user. - assert!(!is_payload_enoent( - "payload rejected FS_LIST_DIR(/foo): fs_list_dir_opendir_errno_13" - )); - assert!(!is_payload_enoent("Permission denied")); - assert!(!is_payload_enoent("connection reset")); - } + .map_err(|e| format!("{e:#}")) } diff --git a/client/src/lib/browserInvoke.ts b/client/src/lib/browserInvoke.ts index 8967819d..7d1e69c0 100644 --- a/client/src/lib/browserInvoke.ts +++ b/client/src/lib/browserInvoke.ts @@ -478,6 +478,73 @@ export async function browserInvoke(cmd: string, args: AnyArgs = {}): Promise } } + // ── Process manager ───────────────────────────────────────────────────── + + case "process_list_get": + return getJson(addrUrl("/api/ps5/process/list", args["addr"])); + + case "process_kill_pid": { + const { addr, pid } = args as { addr?: string | null; pid: number }; + return postJson("/api/ps5/process/kill", { addr, pid }); + } + + // ── Power control + telemetry ─────────────────────────────────────────── + + case "power_reboot": + return postJson("/api/ps5/power/control", { + addr: args["addr"], + action: "reboot", + }); + + case "power_shutdown": + return postJson("/api/ps5/power/control", { + addr: args["addr"], + action: "shutdown", + }); + + case "power_standby": + return postJson("/api/ps5/power/control", { + addr: args["addr"], + action: "standby", + }); + + case "power_tick": + return postJson("/api/ps5/power/control", { + addr: args["addr"], + action: "tick", + }); + + case "power_telemetry_get": + return getJson(addrUrl("/api/ps5/power/telemetry", args["addr"])); + + // ── User accounts ──────────────────────────────────────────────────────── + + case "user_list_get": + return getJson(addrUrl("/api/ps5/users/list", args["addr"])); + + // ── Saves / screenshots / videos ──────────────────────────────────────── + + case "saves_list": { + // TS caller: { addr, userId } (Tauri 2 camelCases user_id → userId) + const { addr, userId } = args as { addr?: string | null; userId?: number | null }; + let url = addrUrl("/api/ps5/saves/list", addr); + if (userId != null) { + url += `${url.includes("?") ? "&" : "?"}user_id=${userId}`; + } + return getJson(url); + } + + case "screenshots_list": + return getJson(addrUrl("/api/ps5/screenshots/list", args["addr"])); + + case "videos_list": + return getJson(addrUrl("/api/ps5/videos/list", args["addr"])); + + // ── ShadowMount+ status ────────────────────────────────────────────────── + + case "smp_status": + return getJson(addrUrl("/api/ps5/smp/status", args["addr"])); + // ── Native-only / unsupported ──────────────────────────────────────────── default: diff --git a/engine/crates/ps5upload-core/src/lib.rs b/engine/crates/ps5upload-core/src/lib.rs index bc7121fb..2e51a4e6 100644 --- a/engine/crates/ps5upload-core/src/lib.rs +++ b/engine/crates/ps5upload-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod process_mgr; pub mod profile; pub mod saves; pub mod search_index; +pub mod smp; pub mod smp_meta; pub mod sys_time; pub mod system_control; diff --git a/engine/crates/ps5upload-core/src/smp.rs b/engine/crates/ps5upload-core/src/smp.rs new file mode 100644 index 00000000..6707250d --- /dev/null +++ b/engine/crates/ps5upload-core/src/smp.rs @@ -0,0 +1,350 @@ +//! ShadowMount+ integration — read-only awareness. +//! +//! Detects whether ShadowMountPlus is installed/running on the +//! connected PS5, surfaces its config + state, lists its mounted +//! image dirs. Strictly read-only: we never write SMP's config, +//! never restart it through anything other than the same payload-send +//! flow the user could trigger themselves. +//! +//! Why integrate at all when SMP runs autonomously: the PS5-side +//! state (which images are mounted, which scan paths are configured, +//! what the auto-tuned kstuff delays look like) lives behind a +//! filesystem the user can only browse via FTP today. Lifting it +//! into the Library tab makes the tool stack behave like one product +//! instead of three. +//! +//! Detection signals (any one is sufficient): +//! 1. `/data/shadowmount/debug.log` exists (SMP writes this on +//! first run). Strongest signal — the file is unique to SMP and +//! survives across reboots. +//! 2. A process named `shadowmountplus` appears in PROC_LIST. +//! Definitive proof it's currently running. +//! +//! All Sony API calls go through the engine's existing FTX2 RPCs +//! (FS_LIST_DIR, PROC_LIST, FS_READ); nothing here speaks the wire +//! directly. +//! +//! Lives in `ps5upload-core` (not the Tauri shell) so both the +//! desktop Tauri command and the engine's browser-facing HTTP route +//! can call the same snapshot logic. + +use std::time::Duration; + +use anyhow::Result; +use serde::Serialize; + +use crate::fs_ops::{fs_read_with_timeout, list_dir_with_timeout, ListDirOptions}; +use crate::hw::proc_list; + +/// Per-call deadline for any single FTX2 RPC. SMP's config is small, +/// the directory listings are small, the proc list is small. 5s is +/// generous and bounds the whole snapshot if any one call hangs. +const RPC_TIMEOUT: Duration = Duration::from_secs(5); + +/// Hard cap on file bytes we'll pull back. SMP's config files are +/// typically <8 KiB; debug.log can be larger but we only show the +/// tail. 256 KiB matches the engine's game_meta param.json limit — +/// same "small text file" use case. +const READ_LIMIT_BYTES: u64 = 256 * 1024; + +/// Canonical paths SMP writes/reads. Hardcoded because they're +/// burned into SMP's source (sm_config_mount.c) — not user-configurable. +const SMP_DATA_DIR: &str = "/data/shadowmount"; +const SMP_CONFIG_PATH: &str = "/data/shadowmount/config.ini"; +const SMP_AUTOTUNE_PATH: &str = "/data/shadowmount/autotune.ini"; +const SMP_DEBUG_LOG_PATH: &str = "/data/shadowmount/debug.log"; +const SMP_MOUNT_POINT: &str = "/mnt/shadowmnt"; +/// Name SMP processes appear under in `ps`. Stable since the project's +/// inception (the binary is shadowmountplus.elf and the kernel-side +/// process inherits that basename). +const SMP_PROCESS_NAME_HINT: &str = "shadowmountplus"; + +/// One mounted-image row, surfaced in the Library tab's SMP panel. +#[derive(Serialize)] +pub struct SmpMountedImage { + /// Mount point under /mnt/shadowmnt (hashed name SMP picks per + /// image — `_`). + pub mount_point: String, + /// Best-effort image source filename, derived from the mount + /// point name. SMP doesn't expose the original .ffpkg path + /// through any read-only surface, so we strip the trailing + /// "_<8-hex>" hash. Empty if the mount point doesn't match the + /// expected pattern. + pub derived_name: String, +} + +/// Full SMP status snapshot. Every field is null/empty when SMP +/// isn't present — UI checks `installed` and `running` to decide +/// what to show. +#[derive(Serialize)] +pub struct SmpStatus { + /// True when `/data/shadowmount/debug.log` exists. Implies SMP + /// has been loaded at least once on this console. + pub installed: bool, + /// True when a process matching `shadowmountplus` is in PROC_LIST. + /// Independent of `installed`: SMP can be installed but not + /// currently running (e.g. user removed it from autoload). + pub running: bool, + /// Raw config.ini contents (truncated to READ_LIMIT_BYTES). + /// Renderer parses + displays as a key/value table; we don't + /// parse here so the renderer can show the raw file too. + pub config_ini: Option, + /// Raw autotune.ini — kstuff per-game delays SMP learned from + /// crash patterns + per-image sector-size overrides. + pub autotune_ini: Option, + /// Tail of debug.log (last READ_LIMIT_BYTES). Useful for "why + /// didn't my game mount?" troubleshooting without making the + /// user FTP into the console. + pub debug_log_tail: Option, + /// Images currently mounted under /mnt/shadowmnt/. Empty list + /// when no mounts (or when SMP isn't running). UI shows count. + pub mounted_images: Vec, + /// Per-call error captures. Always present so the renderer can + /// surface "config.ini exists but couldn't be read" without + /// inferring from null fields. + pub errors: Vec, +} + +/// Collect a full SMP status snapshot for the PS5 at `addr` +/// (management-port address, "ip:9114"). +/// +/// Sync — run this on a `spawn_blocking` task from an async caller. +/// Never fails outright: individual probe failures are recorded in +/// `SmpStatus::errors` instead of aborting the whole snapshot. +pub fn collect_status(addr: &str) -> Result { + let mut errors: Vec = Vec::new(); + + // Probe SMP's data dir. Existence ≈ "installed at least once." + let data_dir_listing = list_dir_with_timeout( + addr, + SMP_DATA_DIR, + ListDirOptions::default(), + Some(RPC_TIMEOUT), + ); + let installed = match &data_dir_listing { + Ok(_) => true, + Err(e) => { + // ENOENT-equivalent: dir doesn't exist, which is + // "not installed", not an error. + let s = e.to_string(); + if is_payload_enoent(&s) { + false + } else { + errors.push(format!("list /data/shadowmount: {s}")); + false + } + } + }; + + // PROC_LIST → look for shadowmountplus process. Match is + // substring on lowercased name to handle "shadowmountplus.elf" + // vs "shadowmountplus" depending on how the kernel records it. + let running = match proc_list(addr) { + Ok(list) => { + let needle = SMP_PROCESS_NAME_HINT.to_ascii_lowercase(); + list.procs + .iter() + .any(|p| p.name.to_ascii_lowercase().contains(&needle)) + } + Err(e) => { + errors.push(format!("proc list: {e}")); + false + } + }; + + // Read config.ini + autotune.ini + debug.log tail. Each is + // best-effort — if any fails we still return the others. + let config_ini = read_text_file_or_record(addr, SMP_CONFIG_PATH, &mut errors); + let autotune_ini = read_text_file_or_record(addr, SMP_AUTOTUNE_PATH, &mut errors); + let debug_log_tail = read_text_file_or_record(addr, SMP_DEBUG_LOG_PATH, &mut errors); + + // List mounted images. SMP creates one subdir per mounted image + // under /mnt/shadowmnt/. Empty list when nothing is mounted. + let mounted_images = match list_dir_with_timeout( + addr, + SMP_MOUNT_POINT, + ListDirOptions::default(), + Some(RPC_TIMEOUT), + ) { + Ok(listing) => listing + .entries + .iter() + .filter(|e| e.kind == "dir") + .map(|e| SmpMountedImage { + mount_point: format!("{SMP_MOUNT_POINT}/{}", e.name), + derived_name: derive_image_name(&e.name), + }) + .collect(), + Err(e) => { + // /mnt/shadowmnt may not exist if SMP has never mounted + // anything; that's not an error worth surfacing. See + // is_payload_enoent for why all three of "ENOENT", "No such + // file", and the payload's `_errno_2` numeric form all need + // to be treated as "feature not in use" here. + let s = e.to_string(); + if !is_payload_enoent(&s) { + errors.push(format!("list /mnt/shadowmnt: {s}")); + } + Vec::new() + } + }; + + Ok(SmpStatus { + installed, + running, + config_ini, + autotune_ini, + debug_log_tail, + mounted_images, + errors, + }) +} + +/// Helper: try to read a small text file, recording errors but never +/// failing the whole call. None when the file doesn't exist; Some +/// when readable; None + errors entry when something else went wrong. +fn read_text_file_or_record(addr: &str, path: &str, errors: &mut Vec) -> Option { + match fs_read_with_timeout(addr, path, 0, READ_LIMIT_BYTES, Some(RPC_TIMEOUT)) { + Ok(bytes) => { + // Files we read here are config files / log tails. UTF-8 + // is overwhelmingly the case; lossy decode is the right + // fallback for the rare debug-log line with non-UTF-8 + // bytes (kernel printf output). + Some(String::from_utf8_lossy(&bytes).into_owned()) + } + Err(e) => { + let s = e.to_string(); + if is_missing_optional_file_error(&s) { + // Not an error — file just hasn't been written yet + // (e.g. autotune.ini is created lazily on first + // tuning event). + None + } else { + errors.push(format!("read {path}: {s}")); + None + } + } + } +} + +fn is_missing_optional_file_error(message: &str) -> bool { + is_payload_enoent(message) || message.contains("fs_read_stat_failed") +} + +/// True for any error string that ultimately means "the path you asked +/// for didn't exist." The payload + engine + std::io error chains all +/// surface this differently — std::io::Error gives `ENOENT` or `No such +/// file or directory`, while the payload's FS_LIST_DIR / FS_READ +/// rejections encode errno numerically as `fs_list_dir_opendir_errno_2` +/// or `fs_read_open_errno_2` (errno 2 = ENOENT on FreeBSD/Prospero, same +/// as POSIX). Probe sites need to treat all of them as "not installed / +/// not yet created" instead of pushing them into the user-visible error +/// list. Centralised so a new probe can't drift back to the partial check +/// that surfaced `opendir_errno_2` on the SmpPanel Probe-Errors list. +fn is_payload_enoent(message: &str) -> bool { + if message.contains("ENOENT") || message.contains("No such file") { + return true; + } + // The payload encodes errno as a trailing integer, e.g. `..._errno_2`. + // Match the number EXACTLY so `_errno_20`..`_errno_29` (ENOTDIR, EISDIR, + // EMFILE, ENOSPC, …) are not mis-classified as ENOENT (2) and silently + // swallowed as "not installed / not yet created". + message.split("_errno_").skip(1).any(|rest| { + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits == "2" + }) +} + +/// Strip SMP's CRC32 hash suffix from a mount-point dir name. +/// "Outlast2_a3c3fd8b" → "Outlast2". +/// +/// SMP's hash suffix is exactly 8 lowercase hex chars (CRC32 in hex). +/// If the name doesn't match that pattern we return it unchanged — +/// future SMP versions might use a different scheme. +fn derive_image_name(dir_name: &str) -> String { + // Operate on chars, not bytes: the suffix is always 9 ASCII chars + // ("_" + 8 hex) but the head can be any UTF-8 (game/image names often + // contain non-ASCII). The old byte-based `split_at(len - 9)` panicked + // when a multi-byte codepoint sat within the last 9 bytes, taking down + // the whole SMP-status snapshot for that one mount. + let chars: Vec = dir_name.chars().collect(); + if chars.len() < 10 { + return dir_name.to_string(); + } + let split = chars.len() - 9; + if chars[split] != '_' { + return dir_name.to_string(); + } + if !chars[split + 1..].iter().all(|c| c.is_ascii_hexdigit()) { + return dir_name.to_string(); + } + chars[..split].iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_strips_hex_suffix() { + assert_eq!(derive_image_name("Outlast2_a3c3fd8b"), "Outlast2"); + assert_eq!(derive_image_name("My Game_deadbeef"), "My Game"); + } + + #[test] + fn derive_handles_non_ascii_without_panicking() { + // Byte-based split_at(len - 9) used to panic when a multi-byte + // codepoint landed in the last 9 bytes. char-based logic is safe. + assert_eq!(derive_image_name("x日a3c3fd8"), "x日a3c3fd8"); // < 10 chars → unchanged + assert_eq!(derive_image_name("日本_a3c3fd8b"), "日本"); // strips ASCII suffix off a UTF-8 head + assert_eq!(derive_image_name("日本語ゲーム_deadbeef"), "日本語ゲーム"); + } + + #[test] + fn derive_keeps_name_without_suffix() { + assert_eq!(derive_image_name("plainname"), "plainname"); + assert_eq!(derive_image_name("foo_notmatching"), "foo_notmatching"); + } + + #[test] + fn derive_handles_short_names() { + assert_eq!(derive_image_name("a"), "a"); + assert_eq!(derive_image_name(""), ""); + } + + #[test] + fn optional_file_missing_detection_handles_payload_stat_failure() { + assert!(is_missing_optional_file_error( + "payload rejected FS_READ(/data/shadowmount/autotune.ini): fs_read_stat_failed" + )); + assert!(is_missing_optional_file_error("No such file or directory")); + assert!(!is_missing_optional_file_error( + "payload rejected FS_READ(/data/shadowmount/autotune.ini): fs_read_path_not_allowed" + )); + } + + #[test] + fn payload_enoent_detection_handles_numeric_errno_form() { + // FS_LIST_DIR on a non-existent path returns the numeric errno + // form — the Probe-Errors panel on the Library SMP card was + // showing this verbatim before the fix because the old check only + // matched the textual "ENOENT" / "No such file" forms. + assert!(is_payload_enoent( + "payload rejected FS_LIST_DIR(/mnt/shadowmnt): fs_list_dir_opendir_errno_2" + )); + // FS_READ on a non-existent path uses the same numeric encoding. + assert!(is_payload_enoent( + "payload rejected FS_READ(/data/shadowmount/missing.ini): fs_read_open_errno_2" + )); + // Textual forms still detected (engine wrapper / std::io::Error). + assert!(is_payload_enoent("ENOENT")); + assert!(is_payload_enoent("No such file or directory (os error 2)")); + // Non-ENOENT errnos must NOT be treated as missing — those are + // real failures that should reach the user. + assert!(!is_payload_enoent( + "payload rejected FS_LIST_DIR(/foo): fs_list_dir_opendir_errno_13" + )); + assert!(!is_payload_enoent("Permission denied")); + assert!(!is_payload_enoent("connection reset")); + } +} diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs index 2773b3ba..60fadd1f 100644 --- a/engine/crates/ps5upload-engine/src/lib.rs +++ b/engine/crates/ps5upload-engine/src/lib.rs @@ -67,15 +67,22 @@ use ps5upload_core::{ hw_info, hw_power, hw_set_fan_threshold, hw_storage, hw_temps, proc_list, HwInfo, HwPower, HwStorage, HwTemps, ProcList, }, + process_mgr::{process_kill, process_list, ProcessKillAck, ProcessListResult}, + saves::{list_saves, list_screenshots, list_videos, SaveList, ScreenshotList}, + smp::{collect_status as smp_collect_status, SmpStatus}, sys_time::{ humanize_err as sys_time_humanize, ps5_time_get, ps5_time_set, PsTime, PsTimeSetResult, }, + system_control::{ + power_telemetry, system_control, PowerAction, PowerTelemetry, SystemControlAck, + }, transfer::{ inspect_7z, inspect_zip, sevenz_plan_preview, transfer_7z_resumable, transfer_dir_resumable, transfer_file_list_multistream, transfer_file_list_resumable, transfer_file_path_resumable, transfer_zip_resumable, zip_plan_preview, FileListEntry, TransferConfig, DEFAULT_RESUME_RETRIES, DEFAULT_ZIP_ENTRY_RAM_THRESHOLD, TX_FLAG_RESUME, }, + users::{user_list, UserList}, volumes::{list_volumes, VolumeList}, }; @@ -2098,6 +2105,209 @@ async fn ps5_proc_list( } } +// ── Process manager (Processes screen — distinct from PROC_LIST above: +// this is process_mgr's detailed pid/name/title_id/memory/kind view with +// kill support, not hw's lightweight snapshot) ────────────────────────── + +/// GET /api/ps5/process/list — detailed process enumeration + kill support. +async fn ps5_process_list( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let r: Result = + tokio::task::spawn_blocking(move || process_list(&addr)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +#[derive(Debug, serde::Deserialize)] +struct ProcessKillReq { + addr: Option, + pid: i32, +} + +/// POST /api/ps5/process/kill — SIGKILL a pid. Body: `{ addr, pid }`. +async fn ps5_process_kill( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(req.addr, &state.default_ps5_addr); + let pid = req.pid; + let r: Result = + tokio::task::spawn_blocking(move || process_kill(&addr, pid)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +// ── Power control + telemetry ─────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize)] +struct PowerControlReq { + addr: Option, + /// One of "reboot" | "shutdown" | "standby" | "tick". + action: String, +} + +/// POST /api/ps5/power/control — Body: `{ addr, action }`. +async fn ps5_power_control( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(req.addr, &state.default_ps5_addr); + let action = match req.action.as_str() { + "reboot" => PowerAction::Reboot, + "shutdown" => PowerAction::Shutdown, + "standby" => PowerAction::Standby, + "tick" => PowerAction::Tick, + other => { + return json_err( + StatusCode::BAD_REQUEST, + format!("unknown power action: {other}"), + ) + .into_response() + } + }; + let r: Result = + tokio::task::spawn_blocking(move || system_control(&addr, action)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +/// GET /api/ps5/power/telemetry — lifetime ICC telemetry (operating +/// seconds, boot cycles, thermal alerts, power-up cause). +async fn ps5_power_telemetry( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let r: Result = + tokio::task::spawn_blocking(move || power_telemetry(&addr)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +// ── User accounts ──────────────────────────────────────────────────────── + +/// GET /api/ps5/users/list — enumerate logged-in user accounts. +async fn ps5_users_list( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let r: Result = tokio::task::spawn_blocking(move || user_list(&addr)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +// ── Saves / screenshots / videos ──────────────────────────────────────── + +#[derive(Debug, serde::Deserialize)] +struct SavesListQuery { + addr: Option, + #[serde(default)] + user_id: Option, +} + +/// GET /api/ps5/saves/list?addr=&user_id= — user_id=0 (or absent) lists +/// every user's saves. +async fn ps5_saves_list( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let uid = q.user_id.unwrap_or(0); + let r: Result = + tokio::task::spawn_blocking(move || list_saves(&addr, uid)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +/// GET /api/ps5/screenshots/list?addr= +async fn ps5_screenshots_list( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let r: Result = + tokio::task::spawn_blocking(move || list_screenshots(&addr)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +/// GET /api/ps5/videos/list?addr= — same `{path,size,mtime}` shape as +/// screenshots (payload walks `/user/av_contents/video` instead). +async fn ps5_videos_list( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let r: Result = + tokio::task::spawn_blocking(move || list_videos(&addr)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + +// ── ShadowMount+ status ────────────────────────────────────────────────── + +/// GET /api/ps5/smp/status?addr= — read-only ShadowMount+ snapshot +/// (installed/running + config/autotune/debug.log + mounted images). +async fn ps5_smp_status( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let addr = mgmt_addr_or_default(q.addr, &state.default_ps5_addr); + let r: Result = + tokio::task::spawn_blocking(move || smp_collect_status(&addr)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r); + match r { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + #[derive(Debug, serde::Deserialize)] struct FanThresholdReq { addr: Option, @@ -5832,6 +6042,15 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> { .route("/api/ps5/hw/power", get(ps5_hw_power)) .route("/api/ps5/hw/storage", get(ps5_hw_storage)) .route("/api/ps5/proc/list", get(ps5_proc_list)) + .route("/api/ps5/process/list", get(ps5_process_list)) + .route("/api/ps5/process/kill", post(ps5_process_kill)) + .route("/api/ps5/power/control", post(ps5_power_control)) + .route("/api/ps5/power/telemetry", get(ps5_power_telemetry)) + .route("/api/ps5/users/list", get(ps5_users_list)) + .route("/api/ps5/saves/list", get(ps5_saves_list)) + .route("/api/ps5/screenshots/list", get(ps5_screenshots_list)) + .route("/api/ps5/videos/list", get(ps5_videos_list)) + .route("/api/ps5/smp/status", get(ps5_smp_status)) .route("/api/ps5/hw/fan-threshold", post(ps5_hw_set_fan_threshold)) .route("/api/ps5/fs/chmod", post(ps5_fs_chmod)) .route("/api/ps5/fs/mkdir", post(ps5_fs_mkdir))