Compare commits

...
6 Commits
32 changed files with 6891 additions and 288 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Release executables (too large for git, uploaded as release assets)
release/**/PS5Upload.exe
+39
View File
@@ -1,5 +1,27 @@
# Changelog - PS5 Upload Suite
## Version 4.2.1 - Bugfix Release (February 8, 2026)
### 🐛 Bug Fixes
#### UI Scrolling Fix
- **Fixed connection panel eating screen space** - Added `MaxHeight` constraint (250px) and `ScrollViewer` to the connection/payload settings panel
- **Fixed header overflow** - Added `MaxHeight="120"` to header row
- **Fixed content area too small** - Added `MinHeight="200"` to main content area
- When the Auto-send Payload Expander was open, it consumed half the screen leaving no room for file lists
#### File Counting Fix
- **Fixed chunked files never counting as completed** - `fileChunkCounts` was set to the actual number of chunks (e.g., 4 for a 2GB file), but `UploadFileParallelAsync` handles all chunks internally as a single Task. The main loop only saw 1 task completion per file, so chunked files never reached their completion threshold
- Now correctly sets `fileChunkCounts = 1` per file since each file = 1 task
#### Progress Display Fix
- **Fixed progress appearing frozen during heavy uploads** - All UI updates used `DispatcherPriority.Background` (lowest priority), which got starved when 24 parallel connections flooded the dispatcher queue
- Upgraded `UpdateUploadStats` to `DispatcherPriority.Normal`
- Upgraded per-file progress bars to `DispatcherPriority.Render`
- Progress now updates smoothly even at 95%+ network saturation
---
## Version 4.2.0 - Game Mounter Integration (February 7, 2026)
### 🎮 Game Mounter Integration
@@ -36,13 +58,30 @@
- **MainWindow.xaml.cs** - Click handler with progress display and auto-registration
- **Auto Registration** - If new games detected, automatically runs `game_mounter.elf` via shell for home screen registration
### ⚡ Client Improvements
- **Real-Time Speed Display** - New sliding window algorithm (10 samples over 5 seconds) for accurate real-time upload speed
- **Smoother ETA** - Blended speed calculation (60% real-time + 40% average) with better time formatting (hours/minutes/seconds)
- **Speed Display** - Shows both real-time and average speed: `Speed: 95 MB/s (avg 88 MB/s)`
- **Duplicate File Dialog** - New `DuplicateFileDialog` for handling file conflicts during upload
- **Move Dialog Refactor** - Replaced inline move dialog code with reusable `ShowPathPickerDialogAsync()`
- **FormatFileSize Refactor** - Moved to static `FileUtils.FormatFileSize()` class for code reuse
- **ETA Smoothing** - Reduced `ETASmoothingFactor` from 0.3 to 0.15 for less jumpy ETA display
### 🐛 Bug Fixes
#### Payload Fixes
- **Fixed IOVEC_ENTRY(NULL) crash** - `strlen(NULL)` with `-O3` optimization caused segfault in `remount_system_ex()`
- **Fixed libSceAppInstUtil startup crash** - Library cannot be loaded in etaHEN payload context (crashes dynamic linker). Switched to standalone `game_mounter.elf` for registration
- **Fixed dlopen crash** - `dlopen("libSceAppInstUtil.sprx")` crashes payload thread. Removed dynamic loading approach
- **Removed system() calls** - `system("rm -rf ...")` replaced with safe recursive `rmdir_recursive()` to prevent payload hangs
- **Fixed compile warnings** - Removed unused `#include <dlfcn.h>`, re-enabled `auto_unmount_deleted_games()`
#### Client Fixes
- **Fixed Shell Terminal hardcoded IP** - Shell was connecting to hardcoded `192.168.0.160` instead of the configured PS5 IP address
- **Fixed ETA jumping** - ETA no longer jumps erratically during uploads due to improved smoothing
- **Fixed speed dropping to 0** - Sliding window prevents speed display from showing 0 during brief pauses
- **Fixed files remaining counter** - Simplified calculation with `Math.Max(0, ...)` to prevent negative values
### 📝 Technical Notes
- `libSceAppInstUtil.sprx` is **not available** in etaHEN payload context (both static linking and dlopen crash)
- Game registration (`sceAppInstUtilAppInstallTitleDir`) is handled by the standalone `game_mounter.elf` which runs as a separate ELF with its own library dependencies
+172 -28
View File
@@ -1,15 +1,91 @@
# 🚀 PS5 Upload Suite - High-Speed File Transfer
# 🚀 PS5 Upload Suite - Complete PS5 Management Platform
**By Manos**
**Version 4.2.0 - Game Mounter Integration**
**Version 5.0.0 - Complete Management Suite**
Custom high-speed file transfer system for PS5 with etaHEN. Achieves **104+ MB/s** upload speeds for large files using parallel chunked uploads!
Complete all-in-one PS5 management platform with **104+ MB/s** high-speed file transfers, live hardware monitoring, full game/save/screenshot management, and deep PS5 integration — all through a modern desktop client and a robust PS5 payload.
⭐ **NEW in v4.2.0:**
---
⭐ **NEW in v5.0.0 (Complete Management Suite):**
### 🖥️ **NEW: Hardware Monitor Tab**
- 📋 **System Info** — Model (CFI-xxxx), Serial Number, Architecture, OS version
- 💾 **Physical RAM** — With 5-step detection fallback (PS5-specific API + sysctl chain + 16 GB default)
- 🔥 **Live Sensors** — CPU temperature, SoC temperature, CPU frequency, SoC power consumption
- 🔄 **Auto-refresh** — Configurable 5-second polling with automatic disconnect detection
- 🛡️ **Payload Hardened** — Mutex + 1s cache for sensors, 5s throttle for power API, rejection of abnormal readings
### 📷 **NEW: Screenshots Tab**
- 🖼️ **Visual Gallery** — Live thumbnail previews (200px downsampled for fast loading)
- 💾 **Smart Cache** — Local cache in `%TEMP%` keyed by remote path hash — zero re-downloads on refresh
- ⬇️ **Batch Download** — Multi-select and download multiple screenshots
- 🗑️ **Clean Delete** — Removes ALL 3 files per screenshot (`.dat`, `.meta`, `.jpg.jpeg`) — no more ghost entries in PS5 gallery
- 👁️ **Double-click Preview** — Opens in default image viewer
- 🔍 **Full Path Scanning** — 5-level recursive scan of `/user/av_contents/thumbnails/photo/…`
### ▶️ **NEW: Launch Game**
- Right-click any mounted game → **Launch Game**
- Uses `sceLncUtilLaunchApp` (low-level) with fallback to `sceSystemServiceLaunchApp`
- Works on mounted/sideloaded games (bypasses `0x80940005` permission error)
- Triple-strategy launcher with detailed diagnostic error messages
### 💾 **NEW: Save Manager Tab**
- **Full Save Browsing** — Per-game save data with metadata
- **Game Icons** — Fetched from PSN/local param.sfo (cached)
- **Download Saves** — Batch-download multiple saves to local folder
- **Upload Saves** — Drag & drop save files back to PS5
- **Save Details** — Size, modification time, title ID, path
### 🎮 **NEW: Game Details Window**
- **PSN Cover Art** — Official game covers fetched from PlayStation Store
- **Full Metadata** — param.json contents formatted nicely
- **Online PSN Info** — Description, genres, age rating, publisher
- **Alternate Title ID detection** — Auto-fallback for regional variants
- **Browser Search Fallback** — Opens PSN store search if API fails
### 🔧 **Improved Storage Reporting**
- ✅ Matches **PS5 Settings "Console Storage"** formula exactly
- ✅ Total: `/user` effective + `/system_data` + `/system_ex`
- ✅ Free: proper `f_bavail` across all partitions
- ✅ Accounts for reserved space & per-partition blocksize
- Example real-world PS5: **848 GB total / 354 GB free** ✓
### 🎮 **Improved Mount Games**
- ✅ Correct `sceAppInstUtilAppInstallTitleDir(title_id, "/user/app/", 0)` signature
- ✅ Registration BEFORE `mount.lnk` (correct order)
- ✅ Direct in-payload registration (no external `game_mounter.elf` dependency)
- ✅ Proper DRM patching & metadata copy
### 🛡️ **Stability Overhaul**
- ✅ **Thread-safe sensor reads** — `pthread_mutex_t` prevents concurrent API crashes
- ✅ **API call throttling** — SoC power read at most every 5s, sensor cache every 1s
- ✅ **Cached static HW info** — Read once, serve forever (Model, Serial, OS)
- ✅ **Unsafe APIs removed** — `sceKernelIccGet*`, `HwHasWlanBt`, `HwHasOpticalOut` (caused crashes)
- ✅ **Client busy-flag guard** — `Interlocked` prevents overlapping refreshes
- ✅ **Auto-stop timers** — Hardware auto-refresh stops on disconnect
- ✅ **Graceful reconnect** — Connection lost → automatic reconnection attempt
---
⭐ **v4.2.2 (Stability & Mount Fix):**
- 🎮 **Fixed Mount Games** - Games now properly appear on PS5 home screen after mounting
- 🗑️ **Fixed Unmount Games** - Games now properly removed from PS5 home screen when unmounted
- 🖥️ **Fixed Hardware Tab** - Added ScrollViewer for proper scrolling
- 🔧 **Fixed sceAppInstUtil calls** - Correct API usage with proper arguments
- ⚠️ **Disabled auto hardware refresh** - Prevents payload crashes (manual refresh still available)
- 🛡️ **Improved stability** - Fixed delayed payload crashes during folder navigation
⭐ **v4.2.1 (Bugfix):**
- 🖥️ **Fixed UI scrolling** - Connection panel no longer eats half the screen
- 🔢 **Fixed file counting** - Chunked files now count correctly as completed
- 📊 **Fixed frozen progress** - Progress updates no longer freeze during heavy uploads
⭐ **v4.2.0 Features:**
- 🎮 **Mount Games Button** - Mount uploaded games directly from the client
- 🔍 **Multi-Path Scanning** - Scans internal storage, USB drives (0-3), and M.2 SSD
- � **Duplicate Detection** - Skips already-mounted games automatically
- 🔄 **Duplicate Detection** - Skips already-mounted games automatically
- 📊 **Mount Summary** - Detailed results with PS5 notifications
- 🧹 **Auto-Cleanup** - Removes mount entries for deleted games
@@ -33,12 +109,18 @@ Download the latest release from the [Releases](https://github.com/manos555555/P
### 2. Windows GUI Client
- **File:** `PS5Upload.exe`
- Self-contained executable (no .NET installation required)
- Modern dark theme UI
- Self-contained executable (no .NET installation required, **~147 MB**)
- Modern dark theme UI with multi-tab layout
- **Tabs:** Debug Log, Transfer History, Shell, Search, Saves, Games, Screenshots, **Hardware 🆕**
- Drag & drop file/folder upload
- Real-time upload progress with speed tracking
- Transfer History with success/failed tracking
- Privacy-focused (no storage monitoring)
- Real-time upload progress with sliding window speed tracking
- Transfer History with success/failed tracking and retry
- PS5 storage display (free/used/total — matches PS5 Settings)
- Game mounting with one-click Mount Games button
- **NEW:** Launch any mounted game with one click
- **NEW:** Live hardware monitor (CPU/SoC temps, frequency, power)
- **NEW:** Screenshot gallery with thumbnail previews and batch download/delete
- **NEW:** Save Manager with game icons and PSN metadata
### 3. 📱 Android Mobile Client
- **File:** `PS5UploadMobile.apk`
@@ -77,42 +159,94 @@ Download the latest release from the [Releases](https://github.com/manos555555/P
## 🎨 Features
### PS5 Server
✅ **High-Speed Protocol** - Custom binary protocol
✅ **4MB Buffers** - Maximum throughput
✅ **Direct Disk I/O** - No temp files
✅ **Multi-threaded** - Handle multiple clients
✅ **Robust Error Handling** - Graceful failures
### PS5 Server Payload
✅ **High-Speed Protocol** - Custom binary protocol for maximum speed
✅ **8MB Buffer** - Large buffer for high throughput
✅ **16MB Socket Buffers** - Maximum network throughput
✅ **Direct Disk I/O** - No temp files, direct write syscalls
✅ **Multi-threaded** - Handle multiple clients simultaneously
✅ **Per-File Mutex** - Safe parallel uploads without corruption
✅ **Thread-safe Sensor Reads** - `pthread_mutex_t` around all kernel API calls
✅ **Smart Caching** - 1s sensor cache, 5s power-API throttle, permanent HW info cache
✅ **5-Minute Socket Timeout** - No connection drops on large files
✅ **Game Mounting** - nullfs mount, DRM patching, metadata copy, `sceAppInstUtil` registration
✅ **Game Launching** - `sceLncUtilLaunchApp` + fallback chain for pirated/mounted games
✅ **Shell Commands** - Built-in shell with ls, cd, cat, mkdir, rm, etc.
✅ **Filesystem Indexing** - In-memory index for instant search
✅ **Screenshot Management** - Full scan + safe delete of PS5 media files (.dat + .meta + .jpg.jpeg)
✅ **Hardware APIs** - Model, serial, temps, CPU freq, power via verified Sony kernel APIs
✅ **Storage Matching PS5 Settings** - Aggregates /user + /system_data + /system_ex
### Windows Client
✅ **Modern UI** - Dark theme, maximized window for better visibility
✅ **Modern UI** - Dark theme, maximized window, 8+ functional tabs
✅ **Drag & Drop** - Files and folders
✅ **Browse PS5** - Navigate filesystem
✅ **Real-time Progress** - Speed & percentage tracking
✅ **Optimized Upload** - 8 parallel connections for 88-110 MB/s aggregate speed
✅ **Browse PS5** - Navigate filesystem with favorites
✅ **Real-time Speed** - Sliding window algorithm for accurate speed display
✅ **Parallel Uploads** - 4 parallel chunked connections for 104+ MB/s
✅ **Transfer History** - Track all uploads/downloads with success/failed status
✅ **Auto-Clear History** - Optional auto-clear on startup
✅ **Retry Failed Transfers** - Right-click to retry failed uploads
✅ **Duplicate File Dialog** - Handle file conflicts during upload
✅ **Folder Upload** - Recursive directory upload
✅ **Download Files** - Download from PS5 to PC
✅ **🔍 Smart Search** - Full filesystem indexing with instant search (NEW v3.3)
✅ **💾 Storage Display** - Real-time PS5 storage info (matches PS5 Settings exactly)
✅ **🔍 Smart Search** - Full filesystem indexing with instant search
- Wildcard support: `*.pkg`, `*loader*`, `game*.bin`
- Size filters: `size:>1GB`, `size:<100MB`
- Case-insensitive matching
- Search both filename and full path
- Double-click to navigate to folder
✅ **💻 Shell Terminal** - Execute commands directly on PS5 (NEW v3.3)
✅ **💻 Shell Terminal** - Execute commands directly on PS5
- Run system commands remotely
- Real-time output display
- Command history
- Working directory support
✅ **Favorites/Bookmarks** - Quick navigation to saved paths
✅ **Multi-PS5 Support** - Save and switch between multiple PS5 profiles
✅ **🎮 Mount Games** - Mount uploaded games directly from the client (NEW v4.2)
- Scans all game paths: internal, USB drives, M.2 SSD
✅ **⭐ Favorites/Bookmarks** - Quick navigation to saved paths
✅ **👥 Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
✅ **🎮 Mount Games** - Mount uploaded games directly from the client
- Scans all game paths: internal, USB drives (0-3), M.2 SSD
- Duplicate detection across storage locations
- Auto-cleanup of deleted game mounts
- PS5 notifications with mount progress
- Automatic game registration via game_mounter.elf
- In-payload registration (no external binary required)
✅ **▶️ Launch Games** - One-click launch for mounted games
- `sceLncUtilLaunchApp` low-level launcher
- Works on pirated/sideloaded games
- Fallback to SystemService API
- Right-click context menu on any game
✅ **💾 Save Manager Tab** - Full PS5 save file management
- Per-game save browsing with thumbnails
- Game icons fetched and cached from PSN/param.sfo
- Batch download saves to local backup folder
- Upload saves back to PS5
- Size, date, and metadata display
✅ **🎮 Game Details Window** - Rich game information
- PSN cover art (fetched from PlayStation Store)
- Full param.json metadata formatted
- Online PSN description, genres, age rating, publisher
- Alternate title ID detection (regional variants)
- Browser search fallback
✅ **📷 Screenshots Tab** - Complete media gallery
- Visual thumbnail previews (downsampled 200px)
- Local cache in %TEMP% (reuses across refreshes)
- Batch download/delete multi-select
- Double-click preview in default image viewer
- Clean delete removes ALL 3 PS5 files (.dat + .meta + .jpg.jpeg)
- No more ghost entries in PS5 Media Gallery
✅ **🖥️ Hardware Monitor Tab** - Live system stats
- **System Info:** Model (CFI-xxxx), Serial, Architecture, OS, CPU Cores, RAM, Wi-Fi/BT
- **Live Sensors:** CPU temp, SoC temp, CPU frequency, SoC power consumption
- Auto-refresh with configurable 5s interval
- Busy-flag guard prevents overlapping refreshes
- Auto-stops on disconnect
- Progress bars for visual feedback
---
@@ -194,10 +328,20 @@ Download the latest release from the [Releases](https://github.com/manos555555/P
- **sce_sys Copy** - Copies game metadata to `/user/app/` and `/user/appmeta/`
- **mount.lnk Tracking** - Creates mount link files for persistent mount state
### ⚡ Client Improvements
- **Real-Time Speed Display** - Sliding window algorithm for accurate real-time upload speed
- **Smoother ETA** - Blended speed calculation (60% real-time + 40% average) with better formatting
- **Duplicate File Dialog** - New dialog for handling file conflicts during upload
- **Move Dialog Refactor** - Reusable `ShowPathPickerDialogAsync()` replaces inline code
### 🐛 Bug Fixes
- **Fixed IOVEC_ENTRY(NULL) crash** - `strlen(NULL)` with `-O3` optimization caused payload crash
- **Fixed libSceAppInstUtil crash** - Library not available in etaHEN payload context, switched to standalone registration
- **Removed system() calls** - Replaced `system("rm -rf ...")` with safe recursive delete to prevent hangs
- **Fixed Shell Terminal hardcoded IP** - Shell was connecting to hardcoded IP instead of configured PS5 address
- **Fixed ETA jumping** - ETA no longer jumps erratically during uploads
- **Fixed speed dropping to 0** - Sliding window prevents speed display from showing 0 during pauses
- **Fixed files remaining counter** - Prevented negative values in remaining files display
---
+188
View File
@@ -0,0 +1,188 @@
<Window x:Class="PS5Upload.GameDetailsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Game Details"
Height="720" Width="860"
Background="#1E1E1E"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResize">
<Grid>
<!-- Cover Art Background (pic0.png) -->
<Image x:Name="BackgroundCoverImage"
Stretch="UniformToFill"
Opacity="0.25"
RenderOptions.BitmapScalingMode="HighQuality"/>
<!-- Gradient overlay for readability -->
<Rectangle>
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#CC1E1E1E" Offset="0"/>
<GradientStop Color="#E61E1E1E" Offset="0.5"/>
<GradientStop Color="#FF1E1E1E" Offset="1"/>
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle>
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header: icon + title -->
<Border Grid.Row="0" Background="#CC2D2D30" CornerRadius="6" Padding="15">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="128" Height="128" CornerRadius="8"
Background="#3E3E42" ClipToBounds="True" Margin="0,0,20,0">
<Grid>
<TextBlock Text="🎮" FontSize="64"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image x:Name="GameIconImage"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock x:Name="GameNameText"
Text="Game Name"
FontSize="22" FontWeight="Bold"
Foreground="White"
TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock x:Name="TitleIdText"
Text="TITLE_ID"
FontSize="14" FontWeight="SemiBold"
Foreground="#007ACC"/>
<TextBlock Text=" • " Foreground="#666666" FontSize="14"/>
<TextBlock x:Name="RegionText"
Text="Region"
FontSize="14" Foreground="#FFC107"/>
<TextBlock Text=" • " Foreground="#666666" FontSize="14"/>
<TextBlock x:Name="StatusText"
Text="Status"
FontSize="14" Foreground="#28A745"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
<!-- Details panel -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0,15,0,15">
<StackPanel>
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="📊 Size &amp; Storage" FontSize="14" FontWeight="Bold"
Foreground="#9CDCFE" Margin="0,0,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Total size:"
Foreground="#999999" Margin="0,0,0,4"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="TotalSizeText"
Text="-" Foreground="White" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="eboot.bin:"
Foreground="#999999" Margin="0,0,0,4"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="EbootSizeText"
Text="-" Foreground="White"/>
</Grid>
</StackPanel>
</Border>
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="📁 Paths" FontSize="14" FontWeight="Bold"
Foreground="#9CDCFE" Margin="0,0,0,8"/>
<TextBlock Text="Source path:" Foreground="#999999"/>
<TextBlock x:Name="SourcePathText" Text="-"
Foreground="White" TextWrapping="Wrap" Margin="0,2,0,8"/>
<TextBlock Text="Install date:" Foreground="#999999"/>
<TextBlock x:Name="InstallDateText" Text="-"
Foreground="White" Margin="0,2,0,0"/>
</StackPanel>
</Border>
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="🌐 Online Info (PSN Store)" FontSize="14" FontWeight="Bold"
Foreground="#9CDCFE" VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="FetchPsnInfoButton"
Content="🔍 Fetch from Internet"
Click="FetchPsnInfoButton_Click"
Background="#007ACC" Foreground="White"
Padding="10,4" BorderThickness="0"/>
</Grid>
<TextBlock x:Name="PsnStatusText" Text="Click the button above to fetch online info"
Foreground="#888888" FontStyle="Italic" Margin="0,6,0,0"
TextWrapping="Wrap"/>
<StackPanel x:Name="PsnInfoPanel" Visibility="Collapsed" Margin="0,8,0,0">
<TextBlock x:Name="PsnTitleText" FontSize="15" FontWeight="Bold"
Foreground="White" Margin="0,0,0,4" TextWrapping="Wrap"/>
<TextBlock x:Name="PsnPublisherText" Foreground="#9CDCFE" Margin="0,0,0,4"/>
<TextBlock x:Name="PsnReleaseDateText" Foreground="#FFC107" Margin="0,0,0,6"/>
<ScrollViewer MaxHeight="180" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<TextBlock x:Name="PsnDescriptionText" Foreground="#CCCCCC"
TextWrapping="Wrap"/>
</ScrollViewer>
</StackPanel>
<Button x:Name="OpenInBrowserButton"
Content="🌍 Search on PSN Store (browser)"
Click="OpenInBrowserButton_Click"
Background="#3E3E42" Foreground="White"
Padding="10,4" BorderThickness="0"
HorizontalAlignment="Left"
Margin="0,10,0,0"/>
</StackPanel>
</Border>
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15">
<StackPanel>
<TextBlock Text="📜 param.json" FontSize="14" FontWeight="Bold"
Foreground="#9CDCFE" Margin="0,0,0,8"/>
<TextBox x:Name="ParamJsonText"
IsReadOnly="True"
TextWrapping="Wrap"
Background="#1E1E1E"
Foreground="#CCCCCC"
BorderThickness="1"
BorderBrush="#3E3E42"
Padding="8"
FontFamily="Consolas"
FontSize="11"
MinHeight="120"
MaxHeight="240"
VerticalScrollBarVisibility="Auto"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Footer buttons -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Close" Click="CloseButton_Click"
Background="#007ACC" Foreground="White"
Padding="20,8" BorderThickness="0"/>
</StackPanel>
</Grid>
</Grid>
</Window>
+496
View File
@@ -0,0 +1,496 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows;
namespace PS5Upload
{
public partial class GameDetailsWindow : Window
{
private readonly PS5MountedGame _game;
private readonly PS5Protocol? _protocol;
private readonly string _paramJsonRaw;
// Pre-configured HttpClient (properties locked after first use, set once here)
private static readonly HttpClient _http = CreateHttpClient();
private static HttpClient CreateHttpClient()
{
var client = new HttpClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
client.Timeout = TimeSpan.FromSeconds(8);
return client;
}
public GameDetailsWindow(PS5MountedGame game, Dictionary<string, string> details, PS5Protocol? protocol = null)
{
InitializeComponent();
_game = game;
_protocol = protocol;
_paramJsonRaw = details.TryGetValue("param_json", out var pj) ? pj : "";
// Header
if (game.Icon != null)
{
GameIconImage.Source = game.Icon;
}
GameNameText.Text = details.TryGetValue("name", out var n) ? n : game.Name;
TitleIdText.Text = game.TitleId;
RegionText.Text = details.TryGetValue("region", out var r) ? r : game.Region;
bool isActive = details.TryGetValue("is_active", out var act) && act == "1";
StatusText.Text = isActive ? "✓ Mounted" : "✗ Not Mounted";
StatusText.Foreground = isActive
? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x28, 0xA7, 0x45))
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xDC, 0x35, 0x45));
// Sizes
if (details.TryGetValue("total_size", out var ts) && ulong.TryParse(ts, out ulong totalBytes))
{
TotalSizeText.Text = FormatSize(totalBytes);
}
if (details.TryGetValue("eboot_size", out var es) && ulong.TryParse(es, out ulong ebootBytes))
{
EbootSizeText.Text = FormatSize(ebootBytes);
}
// Paths
SourcePathText.Text = details.TryGetValue("path", out var p) ? p : game.Path;
InstallDateText.Text = details.TryGetValue("install_date", out var d) ? d : "Unknown";
// param.json
if (details.TryGetValue("param_json", out var param))
{
ParamJsonText.Text = FormatJson(param);
}
else
{
ParamJsonText.Text = "(not available)";
}
// Load cover art async (pic0.png)
_ = LoadCoverArtAsync();
}
private async Task LoadCoverArtAsync()
{
if (_protocol == null || !_protocol.IsConnected) return;
try
{
var picBytes = await _protocol.GetGamePicAsync(_game.TitleId, 0);
if (picBytes == null || picBytes.Length == 0) return;
Dispatcher.Invoke(() =>
{
try
{
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bitmap.StreamSource = new MemoryStream(picBytes);
bitmap.EndInit();
bitmap.Freeze();
BackgroundCoverImage.Source = bitmap;
}
catch { }
});
}
catch { }
}
private void OpenInBrowserButton_Click(object sender, RoutedEventArgs e)
{
try
{
string query = !string.IsNullOrWhiteSpace(_game.Name) && _game.Name != "Unknown"
? _game.Name
: _game.TitleId;
string url = $"https://store.playstation.com/en-gb/search/{Uri.EscapeDataString(query)}";
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
catch (Exception ex)
{
MessageBox.Show($"Could not open browser: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async void FetchPsnInfoButton_Click(object sender, RoutedEventArgs e)
{
FetchPsnInfoButton.IsEnabled = false;
PsnStatusText.Text = "Fetching from PSN Store...";
PsnInfoPanel.Visibility = Visibility.Collapsed;
try
{
var info = await FetchPsnInfoAsync(_game.TitleId);
if (info == null)
{
PsnStatusText.Text = "❌ Not found on PSN Store (game may be pirated/unreleased/region-locked). Try the browser search button below.";
return;
}
PsnTitleText.Text = info.Title;
PsnPublisherText.Text = string.IsNullOrEmpty(info.Publisher) ? "" : $"🏢 {info.Publisher}";
PsnReleaseDateText.Text = string.IsNullOrEmpty(info.ReleaseDate) ? "" : $"📅 Released: {info.ReleaseDate}";
PsnDescriptionText.Text = info.Description ?? "";
PsnInfoPanel.Visibility = Visibility.Visible;
PsnStatusText.Text = $"✓ Found on PSN Store ({info.Source})";
}
catch (Exception ex)
{
PsnStatusText.Text = $"❌ Error: {ex.Message}";
}
finally
{
FetchPsnInfoButton.IsEnabled = true;
}
}
private class PsnGameInfo
{
public string Title { get; set; } = "";
public string Publisher { get; set; } = "";
public string ReleaseDate { get; set; } = "";
public string Description { get; set; } = "";
public string Source { get; set; } = "";
}
private async Task<PsnGameInfo?> FetchPsnInfoAsync(string titleId)
{
// HttpClient is already configured in the static initializer (UserAgent + Timeout)
// Build list of IDs to try: primary title_id first, then alternates from param.json
var idsToTry = new List<string> { titleId + "_00" };
// Extract alternate IDs from param.json serviceIdForSharing field
// Format examples: "UP4572-PPSA01768_00", "UP4572-CUSA12650_00", "UP4572-PPSA16388_00-00000000000HWOAD"
var alternateIds = ExtractAlternateTitleIds(_paramJsonRaw);
foreach (var altId in alternateIds)
{
if (!idsToTry.Contains(altId)) idsToTry.Add(altId);
}
// Strategy 1: Chihiro API (titlecontainer) - VERIFIED working (returns full game data)
string[] chihiroRegions = { "US/en", "GB/en", "DE/de", "FR/fr", "JP/ja", "IT/it", "ES/es" };
foreach (var tryId in idsToTry)
{
foreach (var region in chihiroRegions)
{
try
{
var parts = region.Split('/');
string url = $"https://store.playstation.com/store/api/chihiro/00_09_000/titlecontainer/{parts[0]}/{parts[1]}/999/{tryId}";
var response = await _http.GetAsync(url);
if (!response.IsSuccessStatusCode) continue;
string json = await response.Content.ReadAsStringAsync();
if (json.Length < 50) continue;
var info = ParsePsnJson(json);
if (info != null)
{
string tag = tryId == titleId + "_00" ? "" : $" via alt ID {tryId}";
info.Source = $"Chihiro API ({region}){tag}";
return info;
}
}
catch { }
}
}
// Strategy 2: Valkyrie API resolve endpoint (newer, for newer games)
string[] valkyrieRegions = { "gb/GB", "us/US", "de/DE", "fr/FR", "ja/JP", "it/IT", "es/ES" };
foreach (var region in valkyrieRegions)
{
try
{
string url = $"https://store.playstation.com/valkyrie-api/{region.Split('/')[0]}/{region.Split('/')[1]}/19/resolve/{titleId}_00";
var response = await _http.GetAsync(url);
if (!response.IsSuccessStatusCode) continue;
string json = await response.Content.ReadAsStringAsync();
if (json.Length < 50) continue;
var info = ParseValkyrieJson(json);
if (info != null)
{
info.Source = $"Valkyrie API ({region})";
return info;
}
}
catch { }
}
// Strategy 3: Search by game name on Valkyrie search API (useful for pirated games with real title)
string gameName = _game.Name;
if (!string.IsNullOrWhiteSpace(gameName) && gameName != "Unknown")
{
try
{
string encoded = Uri.EscapeDataString(gameName);
string url = $"https://store.playstation.com/valkyrie-api/en/US/19/tumbler-search/{encoded}?suggested_size=5&mode=game";
var response = await _http.GetAsync(url);
if (response.IsSuccessStatusCode)
{
string json = await response.Content.ReadAsStringAsync();
var info = ParseValkyrieSearchJson(json, gameName);
if (info != null)
{
info.Source = "Valkyrie Search (by name)";
return info;
}
}
}
catch { }
}
return null;
}
private static PsnGameInfo? ParseValkyrieJson(string json)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
// Valkyrie format: { "included": [ { "attributes": { "name", "long-description", "release-date", ... } } ] }
if (!root.TryGetProperty("included", out var included)) return null;
if (included.ValueKind != System.Text.Json.JsonValueKind.Array) return null;
foreach (var item in included.EnumerateArray())
{
if (!item.TryGetProperty("attributes", out var attrs)) continue;
var info = new PsnGameInfo();
if (attrs.TryGetProperty("name", out var name))
info.Title = name.GetString() ?? "";
if (attrs.TryGetProperty("long-description", out var ld))
info.Description = StripHtml(ld.GetString() ?? "");
else if (attrs.TryGetProperty("description", out var sd))
info.Description = StripHtml(sd.GetString() ?? "");
if (attrs.TryGetProperty("release-date", out var rd))
{
string raw = rd.GetString() ?? "";
if (DateTime.TryParse(raw, out DateTime dt))
info.ReleaseDate = dt.ToString("yyyy-MM-dd");
else
info.ReleaseDate = raw;
}
if (attrs.TryGetProperty("provider-name", out var pub))
info.Publisher = pub.GetString() ?? "";
else if (attrs.TryGetProperty("publisher-name", out var pub2))
info.Publisher = pub2.GetString() ?? "";
if (!string.IsNullOrWhiteSpace(info.Title))
return info;
}
}
catch { }
return null;
}
private static PsnGameInfo? ParseValkyrieSearchJson(string json, string searchName)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("included", out var included)) return null;
if (included.ValueKind != System.Text.Json.JsonValueKind.Array) return null;
PsnGameInfo? bestMatch = null;
foreach (var item in included.EnumerateArray())
{
if (!item.TryGetProperty("type", out var type)) continue;
if (type.GetString() != "game") continue;
if (!item.TryGetProperty("attributes", out var attrs)) continue;
if (!attrs.TryGetProperty("name", out var nameProp)) continue;
string name = nameProp.GetString() ?? "";
if (string.IsNullOrWhiteSpace(name)) continue;
// Find best match by name similarity
if (string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase) ||
name.Contains(searchName, StringComparison.OrdinalIgnoreCase) ||
searchName.Contains(name, StringComparison.OrdinalIgnoreCase))
{
var info = new PsnGameInfo { Title = name };
if (attrs.TryGetProperty("long-description", out var ld))
info.Description = StripHtml(ld.GetString() ?? "");
if (attrs.TryGetProperty("release-date", out var rd))
{
string raw = rd.GetString() ?? "";
if (DateTime.TryParse(raw, out DateTime dt))
info.ReleaseDate = dt.ToString("yyyy-MM-dd");
}
if (attrs.TryGetProperty("provider-name", out var pub))
info.Publisher = pub.GetString() ?? "";
// Exact match wins immediately
if (string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase))
return info;
bestMatch ??= info;
}
}
return bestMatch;
}
catch { }
return null;
}
private static PsnGameInfo? ParsePsnJson(string json)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
var info = new PsnGameInfo();
if (root.TryGetProperty("name", out var name))
info.Title = name.GetString() ?? "";
else if (root.TryGetProperty("title_name", out var tn))
info.Title = tn.GetString() ?? "";
if (root.TryGetProperty("provider_name", out var pub))
info.Publisher = pub.GetString() ?? "";
else if (root.TryGetProperty("publisher_name", out var pub2))
info.Publisher = pub2.GetString() ?? "";
if (root.TryGetProperty("release_date", out var rd))
{
string rawDate = rd.GetString() ?? "";
if (DateTime.TryParse(rawDate, out DateTime dt))
info.ReleaseDate = dt.ToString("yyyy-MM-dd");
else
info.ReleaseDate = rawDate;
}
if (root.TryGetProperty("long_desc", out var ld))
info.Description = StripHtml(ld.GetString() ?? "");
else if (root.TryGetProperty("short_desc", out var sd))
info.Description = StripHtml(sd.GetString() ?? "");
if (!string.IsNullOrWhiteSpace(info.Title))
return info;
}
catch { }
return null;
}
// Extract alternate title IDs from param.json serviceIdForSharing field.
// The payload strips newlines from param.json but keeps structure intact.
// Examples of entries to match:
// "UP4572-PPSA01768_00"
// "UP4572-CUSA12650_00"
// "UP4572-PPSA16388_00-00000000000HWOAD" (full contentId with suffix)
// Returns list of IDs in "PPSAxxxxx_00" or "CUSAxxxxx_00" format ready for API use.
private static List<string> ExtractAlternateTitleIds(string paramJson)
{
var ids = new List<string>();
if (string.IsNullOrWhiteSpace(paramJson)) return ids;
// Match publisher-prefix-free IDs: "PPSA12345_00" or "CUSA12345_00"
// Handles both "UP4572-PPSA01768_00" (short) and "UP4572-PPSA16388_00-SUFFIX" (full contentId)
var regex = new System.Text.RegularExpressions.Regex(@"(?:[A-Z]{2}\d{4}-)?(PPSA\d{5}_\d{2}|CUSA\d{5}_\d{2})");
var matches = regex.Matches(paramJson);
foreach (System.Text.RegularExpressions.Match m in matches)
{
if (m.Groups.Count >= 2)
{
string id = m.Groups[1].Value;
if (!ids.Contains(id)) ids.Add(id);
}
}
return ids;
}
// Strip HTML tags and decode common entities
private static string StripHtml(string html)
{
if (string.IsNullOrWhiteSpace(html)) return "";
// Replace <br>, <br/>, </p> with newlines
html = System.Text.RegularExpressions.Regex.Replace(html, @"<br\s*/?>", "\n", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
html = System.Text.RegularExpressions.Regex.Replace(html, @"</p>", "\n\n", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Strip all remaining tags
html = System.Text.RegularExpressions.Regex.Replace(html, @"<[^>]+>", "");
// Decode common HTML entities
html = System.Net.WebUtility.HtmlDecode(html);
// Collapse excessive whitespace but keep newlines
html = System.Text.RegularExpressions.Regex.Replace(html, @"[ \t]+", " ");
html = System.Text.RegularExpressions.Regex.Replace(html, @"\n{3,}", "\n\n");
return html.Trim();
}
private static string FormatSize(ulong bytes)
{
double mb = bytes / (1024.0 * 1024.0);
if (mb < 1024) return $"{mb:F1} MB ({bytes:N0} bytes)";
return $"{mb / 1024.0:F2} GB ({bytes:N0} bytes)";
}
private static string FormatJson(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "(empty)";
// Basic pretty-print: add newlines after { [ , and before } ]
var sb = new System.Text.StringBuilder();
int depth = 0;
bool inString = false;
foreach (char c in raw)
{
if (c == '"') inString = !inString;
if (!inString)
{
if (c == '{' || c == '[')
{
sb.Append(c);
sb.Append('\n');
depth++;
sb.Append(new string(' ', depth * 2));
continue;
}
if (c == '}' || c == ']')
{
sb.Append('\n');
depth = System.Math.Max(0, depth - 1);
sb.Append(new string(' ', depth * 2));
sb.Append(c);
continue;
}
if (c == ',')
{
sb.Append(c);
sb.Append('\n');
sb.Append(new string(' ', depth * 2));
continue;
}
}
sb.Append(c);
}
return sb.ToString();
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
Close();
}
}
}
+498 -3
View File
@@ -66,10 +66,10 @@
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" MaxHeight="120"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*" MinHeight="200"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
@@ -119,7 +119,9 @@
BorderThickness="1"
CornerRadius="4"
Padding="15"
Margin="0,0,0,15">
Margin="0,0,0,15"
MaxHeight="250">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@@ -323,6 +325,7 @@
</Expander>
</Grid>
</Grid>
</ScrollViewer>
</Border>
<!-- Storage Info Panel -->
@@ -1027,6 +1030,498 @@
</Border>
</Grid>
</TabItem>
<!-- Save Manager Tab -->
<TabItem Header="💾 Saves">
<Grid Background="#1E1E1E">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header with actions -->
<Border Grid.Row="0"
Background="#2D2D30"
Padding="10"
Margin="10,10,10,5"
CornerRadius="4">
<StackPanel Orientation="Horizontal">
<TextBlock Text="💾 PS5 Save Games"
FontSize="14"
FontWeight="Bold"
Foreground="#007ACC"
VerticalAlignment="Center"
Margin="0,0,15,0"/>
<TextBlock x:Name="SaveCountText"
Text=" (0 saves)"
FontSize="12"
Foreground="#999999"
VerticalAlignment="Center"
Margin="0,0,15,0"/>
<Button x:Name="RefreshSavesButton"
Content="🔄 Refresh"
Click="RefreshSavesButton_Click"
Background="#007ACC"
Foreground="White"
BorderThickness="0"
Padding="10,5"/>
<Button Content="📥 Backup Selected"
Click="BackupSaveButton_Click"
Background="#28A745"
Foreground="White"
BorderThickness="0"
Padding="10,5"
Margin="8,0,0,0"/>
<Button Content="📤 Restore to PS5"
Click="RestoreSaveButton_Click"
Background="#FFC107"
Foreground="#1E1E1E"
BorderThickness="0"
Padding="10,5"
Margin="8,0,0,0"/>
</StackPanel>
</Border>
<!-- Saves List -->
<ListBox Grid.Row="1"
x:Name="SavesListBox"
Background="#1E1E1E"
Foreground="White"
BorderThickness="0"
Margin="10,5,10,10"
SelectionMode="Single">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="📥 Backup this save" Click="BackupSaveMenuItem_Click"/>
<MenuItem Header="📤 Restore from backup" Click="RestoreSaveMenuItem_Click"/>
<MenuItem Header="📂 Copy path to clipboard" Click="CopySavePathMenuItem_Click"/>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30"
CornerRadius="4"
Padding="10"
Margin="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Save Icon -->
<Grid Grid.Column="0" Width="48" Height="48" Margin="0,0,12,0">
<Border Background="#3E3E42" CornerRadius="6">
<TextBlock Text="💾"
FontSize="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="6" ClipToBounds="True">
<Image Source="{Binding Icon}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Border>
</Grid>
<!-- Save Info -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding GameName}"
FontSize="13"
FontWeight="Bold"
Foreground="White"
TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Margin="0,3,0,0">
<TextBlock Text="{Binding TitleId}"
FontSize="11"
Foreground="#007ACC"
FontWeight="SemiBold"/>
<TextBlock Text=" • User " Foreground="#666666" FontSize="11"/>
<TextBlock Text="{Binding ShortUserId}"
FontSize="11"
Foreground="#FFC107"/>
<TextBlock Text=" • " Foreground="#666666" FontSize="11"/>
<TextBlock Text="{Binding SizeDisplay}"
FontSize="11"
Foreground="#9CDCFE"/>
</StackPanel>
<TextBlock Text="{Binding ModifiedDisplay}"
FontSize="10"
Foreground="#888888"
Margin="0,3,0,0"/>
</StackPanel>
<!-- Status pill -->
<Border Grid.Column="2"
Background="#007ACC"
CornerRadius="10"
Padding="8,4"
VerticalAlignment="Center">
<TextBlock Text="SAVE"
FontSize="10"
FontWeight="Bold"
Foreground="White"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- Mounted Games Tab -->
<TabItem Header="🎮 Games">
<Grid Background="#1E1E1E">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header with Refresh button -->
<Border Grid.Row="0"
Background="#2D2D30"
Padding="10"
Margin="10,10,10,5"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="🎮 Mounted Games"
FontSize="16"
FontWeight="Bold"
Foreground="#007ACC"
VerticalAlignment="Center"/>
<TextBlock x:Name="GameCountText"
Text=" (0 games)"
FontSize="14"
Foreground="#999999"
VerticalAlignment="Center"/>
</StackPanel>
<Button Grid.Column="1"
x:Name="RefreshGameListButton"
Content="🔄 Refresh"
Click="RefreshGameListButton_Click"
Background="#007ACC"
Padding="15,8"
Margin="0,0,10,0"/>
<Button Grid.Column="2"
x:Name="MountGamesFromListButton"
Content="📥 Mount All"
Click="MountGamesButton_Click"
Background="#28A745"
Padding="15,8"/>
</Grid>
</Border>
<!-- Games List -->
<ListBox Grid.Row="1"
x:Name="MountedGamesListBox"
Background="#1E1E1E"
Foreground="White"
BorderThickness="0"
Margin="10,5,10,10"
MouseDoubleClick="MountedGamesListBox_MouseDoubleClick">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="▶ Launch Game" Click="LaunchGameMenuItem_Click"/>
<MenuItem Header="ℹ️ View Details" Click="ViewGameDetailsMenuItem_Click"/>
<MenuItem Header="🗑️ Unmount Game" Click="UnmountGameMenuItem_Click"/>
<MenuItem Header="📂 Open Path in PS5 Browser" Click="OpenGamePathMenuItem_Click"/>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30"
CornerRadius="4"
Padding="10"
Margin="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Game Icon -->
<Grid Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0">
<Border Background="#3E3E42" CornerRadius="6">
<TextBlock Text="🎮"
FontSize="32"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="6" ClipToBounds="True">
<Image Source="{Binding Icon}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Border>
</Grid>
<!-- Game Info -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}"
FontSize="14"
FontWeight="Bold"
Foreground="White"
TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Margin="0,3,0,0">
<TextBlock Text="{Binding TitleId}"
FontSize="12"
Foreground="#007ACC"
FontWeight="SemiBold"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding Region}"
FontSize="12"
Foreground="#FFC107"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding SizeDisplay}"
FontSize="12"
Foreground="#9CDCFE"/>
</StackPanel>
<TextBlock Text="{Binding Path}"
FontSize="10"
Foreground="#666666"
TextTrimming="CharacterEllipsis"
Margin="0,3,0,0"/>
</StackPanel>
<!-- Status -->
<StackPanel Grid.Column="2"
VerticalAlignment="Center"
HorizontalAlignment="Right">
<Border Background="#28A745"
CornerRadius="10"
Padding="8,4">
<TextBlock Text="Mounted"
FontSize="11"
Foreground="White"/>
</Border>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- Screenshots Tab -->
<TabItem Header="📷 Screenshots">
<Grid Background="#1E1E1E">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Toolbar -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,10,10,5">
<TextBlock Text="📷 PS5 Screenshots" FontWeight="Bold"
Foreground="#0E9CFF" FontSize="14"
VerticalAlignment="Center" Margin="0,0,15,0"/>
<TextBlock x:Name="ScreenshotsCountText" Text="(0 items)"
Foreground="#A0A0A0" FontSize="12"
VerticalAlignment="Center" Margin="0,0,15,0"/>
<Button Content="🔄 Refresh" Padding="10,4"
Background="#0E639C" Foreground="White" BorderThickness="0"
Margin="0,0,8,0" Click="RefreshScreenshotsButton_Click"/>
<Button Content="⬇ Download Selected" Padding="10,4"
Background="#2D7D2D" Foreground="White" BorderThickness="0"
Margin="0,0,8,0" Click="DownloadScreenshotButton_Click"/>
<Button Content="🗑️ Delete Selected" Padding="10,4"
Background="#C44" Foreground="White" BorderThickness="0"
Click="DeleteScreenshotButton_Click"/>
</StackPanel>
<!-- List -->
<ListBox Grid.Row="1"
x:Name="ScreenshotsListBox"
Background="#1E1E1E" Foreground="White"
BorderThickness="0" Margin="10,5,10,10"
SelectionMode="Extended"
MouseDoubleClick="ScreenshotsListBox_MouseDoubleClick">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="⬇ Download" Click="DownloadScreenshotButton_Click"/>
<MenuItem Header="📋 Copy Path" Click="CopyScreenshotPathMenuItem_Click"/>
<MenuItem Header="🗑️ Delete" Click="DeleteScreenshotButton_Click"/>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Width="72" Height="48" Margin="0,0,12,0">
<Border Background="#3E3E42" CornerRadius="6">
<TextBlock Text="🖼️" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="6" ClipToBounds="True">
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"/>
</Border>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding FileName}"
FontWeight="Bold" FontSize="13" Foreground="White"/>
<TextBlock Text="{Binding FullPath}"
FontSize="10" Foreground="#808080"
TextTrimming="CharacterEllipsis" Margin="0,2,0,0"/>
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center"
HorizontalAlignment="Right">
<TextBlock Text="{Binding SizeDisplay}" FontSize="11"
Foreground="#0E9CFF" HorizontalAlignment="Right"/>
<TextBlock Text="{Binding DateDisplay}" FontSize="10"
Foreground="#A0A0A0" HorizontalAlignment="Right"
Margin="0,2,0,0"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- Hardware Tab -->
<TabItem Header="🖥️ Hardware">
<ScrollViewer Background="#1E1E1E" VerticalScrollBarVisibility="Auto">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="🖥️ PS5 Hardware Monitor" FontWeight="Bold"
Foreground="#0E9CFF" FontSize="16"
VerticalAlignment="Center" Margin="0,0,15,0"/>
<Button Content="🔄 Refresh" Padding="10,4"
Background="#0E639C" Foreground="White" BorderThickness="0"
Margin="0,0,8,0" Click="RefreshHardwareButton_Click"/>
<CheckBox x:Name="HwAutoRefreshCheckBox" Content="Auto-refresh (3s)"
Foreground="#DCDCDC" VerticalAlignment="Center"
Checked="HwAutoRefresh_Changed" Unchecked="HwAutoRefresh_Changed"/>
</StackPanel>
<Border Grid.Row="1" Background="#252526" BorderBrush="#3E3E42"
BorderThickness="1" CornerRadius="4" Padding="15" Margin="0,0,0,10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="📋 System Info" FontWeight="Bold"
FontSize="14" Foreground="#0E9CFF" Margin="0,0,0,10"/>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Model:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="HwModelText" Text="—" Foreground="White" FontWeight="Bold" Margin="0,3"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="Serial:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="0" Grid.Column="3" x:Name="HwSerialText" Text="—" Foreground="White" FontWeight="Bold" Margin="0,3"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Architecture:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="HwMachineText" Text="—" Foreground="White" Margin="0,3"/>
<TextBlock Grid.Row="1" Grid.Column="2" Text="OS:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="1" Grid.Column="3" x:Name="HwOsText" Text="—" Foreground="White" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="CPU Cores:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="HwCpuCoresText" Text="—" Foreground="White" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="2" Text="Physical RAM:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="3" x:Name="HwPhysMemText" Text="—" Foreground="White" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Wi-Fi / Bluetooth:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="HwWlanBtText" Text="—" Foreground="White" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="2" Text="Optical Out:" Foreground="#A0A0A0" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="3" x:Name="HwOpticalOutText" Text="—" Foreground="White" Margin="0,3"/>
</Grid>
</Grid>
</Border>
<Border Grid.Row="2" Background="#252526" BorderBrush="#3E3E42"
BorderThickness="1" CornerRadius="4" Padding="15" Margin="0,0,0,10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="🌡️ Live Sensors" FontWeight="Bold"
FontSize="14" Foreground="#0E9CFF" Margin="0,0,0,10"/>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0" Margin="0,0,10,10">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="🔥 CPU Temperature" Foreground="#A0A0A0" FontSize="12"/>
<TextBlock x:Name="HwCpuTempText" Text="— °C" Foreground="#FFA500" FontWeight="Bold"
FontSize="14" Margin="8,0,0,0"/>
</StackPanel>
<ProgressBar x:Name="HwCpuTempBar" Height="18" Minimum="0" Maximum="100" Value="0"
Background="#1E1E1E" Foreground="#FF6B35" BorderBrush="#3E3E42"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Margin="10,0,0,10">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="🌡️ SoC Temperature" Foreground="#A0A0A0" FontSize="12"/>
<TextBlock x:Name="HwSocTempText" Text="— °C" Foreground="#FFA500" FontWeight="Bold"
FontSize="14" Margin="8,0,0,0"/>
</StackPanel>
<ProgressBar x:Name="HwSocTempBar" Height="18" Minimum="0" Maximum="100" Value="0"
Background="#1E1E1E" Foreground="#FF6B35" BorderBrush="#3E3E42"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Margin="0,10,10,0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="⚡ CPU Frequency" Foreground="#A0A0A0" FontSize="12"/>
<TextBlock x:Name="HwCpuFreqText" Text="— MHz" Foreground="#0E9CFF" FontWeight="Bold"
FontSize="14" Margin="8,0,0,0"/>
</StackPanel>
<ProgressBar x:Name="HwCpuFreqBar" Height="18" Minimum="0" Maximum="3500" Value="0"
Background="#1E1E1E" Foreground="#0E9CFF" BorderBrush="#3E3E42"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="10,10,0,0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="🔋 SoC Power" Foreground="#A0A0A0" FontSize="12"/>
<TextBlock x:Name="HwSocPowerText" Text="— W" Foreground="#2D7D2D" FontWeight="Bold"
FontSize="14" Margin="8,0,0,0"/>
</StackPanel>
<ProgressBar x:Name="HwSocPowerBar" Height="18" Minimum="0" Maximum="250" Value="0"
Background="#1E1E1E" Foreground="#2D7D2D" BorderBrush="#3E3E42"/>
</StackPanel>
</Grid>
</Grid>
</Border>
<TextBlock Grid.Row="3" x:Name="HwStatusText" Foreground="#707070" FontSize="11"
HorizontalAlignment="Right" Margin="0,0,0,10"
Text="Not loaded yet — click Refresh"/>
</Grid>
</ScrollViewer>
</TabItem>
</TabControl>
</Border>
</Grid>
+877 -42
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -16,6 +16,7 @@
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<ApplicationIcon>ps5_logo_fixed.ico</ApplicationIcon>
</PropertyGroup>
+1342 -139
View File
File diff suppressed because it is too large. Load diff
+88 -5
View File
@@ -4,11 +4,94 @@
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:PS5UploadMobile"
Shell.FlyoutBehavior="Disabled">
xmlns:pages="clr-namespace:PS5UploadMobile.Pages"
Shell.FlyoutBehavior="Flyout"
Shell.BackgroundColor="#1E1E1E"
Shell.ForegroundColor="White"
Shell.TitleColor="White"
Shell.FlyoutBackgroundColor="#FFFFFF"
Title="PS5 Upload Suite">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
<Shell.Resources>
<ResourceDictionary>
<!-- Flyout item styling: light background, dark text for maximum readability -->
<Style x:Key="FlyoutItemLabelStyle" TargetType="Label">
<Setter Property="TextColor" Value="#1E1E1E" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalOptions" Value="Center" />
</Style>
<!-- Flyout item template: explicit colors for text + selected background -->
<DataTemplate x:Key="FlyoutItemTemplate">
<Grid HeightRequest="54" Padding="16,0" ColumnDefinitions="Auto,*">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup Name="CommonStates">
<VisualState Name="Normal">
<VisualState.Setters>
<Setter TargetName="FlyoutRoot" Property="Grid.BackgroundColor" Value="#FFFFFF" />
<Setter TargetName="FlyoutText" Property="Label.TextColor" Value="#1E1E1E" />
</VisualState.Setters>
</VisualState>
<VisualState Name="Selected">
<VisualState.Setters>
<Setter TargetName="FlyoutRoot" Property="Grid.BackgroundColor" Value="#E3F2FD" />
<Setter TargetName="FlyoutText" Property="Label.TextColor" Value="#0078D4" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Grid x:Name="FlyoutRoot" Grid.ColumnSpan="2" BackgroundColor="#FFFFFF" />
<Image Grid.Column="0" Source="{Binding FlyoutIcon}" Margin="0,0,12,0"
VerticalOptions="Center" HeightRequest="22" WidthRequest="22"/>
<Label x:Name="FlyoutText" Grid.Column="1" Text="{Binding Title}"
FontSize="16" FontAttributes="Bold"
TextColor="#1E1E1E"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
<!-- Apply to all FlyoutItems -->
<Style TargetType="FlyoutItem">
<Setter Property="Shell.ItemTemplate" Value="{StaticResource FlyoutItemTemplate}" />
</Style>
</ResourceDictionary>
</Shell.Resources>
<Shell.FlyoutHeader>
<Grid HeightRequest="130" BackgroundColor="#0078D4">
<VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Spacing="4">
<Label Text="🎮 PS5 Upload Suite" FontSize="22" FontAttributes="Bold" TextColor="White" HorizontalOptions="Center"/>
<Label Text="Complete Management" FontSize="13" TextColor="White" Opacity="0.9" HorizontalOptions="Center"/>
</VerticalStackLayout>
</Grid>
</Shell.FlyoutHeader>
<FlyoutItem Title="Files" Icon="{OnPlatform Default='folder.png'}">
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" Route="files" />
</FlyoutItem>
<FlyoutItem Title="Games">
<ShellContent ContentTemplate="{DataTemplate pages:GamesPage}" Route="games" />
</FlyoutItem>
<FlyoutItem Title="Saves">
<ShellContent ContentTemplate="{DataTemplate pages:SavesPage}" Route="saves" />
</FlyoutItem>
<FlyoutItem Title="Screenshots">
<ShellContent ContentTemplate="{DataTemplate pages:ScreenshotsPage}" Route="screenshots" />
</FlyoutItem>
<FlyoutItem Title="Hardware">
<ShellContent ContentTemplate="{DataTemplate pages:HardwarePage}" Route="hardware" />
</FlyoutItem>
<FlyoutItem Title="Shell">
<ShellContent ContentTemplate="{DataTemplate pages:ShellPage}" Route="shell" />
</FlyoutItem>
<FlyoutItem Title="Mount Games">
<ShellContent ContentTemplate="{DataTemplate pages:MountGamesPage}" Route="mount" />
</FlyoutItem>
</Shell>
+6
View File
@@ -310,6 +310,11 @@ public partial class MainPage : ContentPage
AddLog($"Connecting to {IpEntry.Text}:{PortEntry.Text}...");
bool connected = await _uploadService.ConnectAsync();
// Also establish shared protocol connection for other tabs (Games, Saves, Screenshots, Hardware, Shell, Mount)
int portNum = int.TryParse(PortEntry.Text, out var p) ? p : 9113;
bool sharedOk = await ConnectionManager.Instance.ConnectAsync(IpEntry.Text, portNum);
AddLog(sharedOk ? "Shared protocol connected (all tabs ready)" : "Shared protocol connection failed — other tabs may not work");
if (connected)
{
StatusLabel.Text = "Connected ✓";
@@ -332,6 +337,7 @@ public partial class MainPage : ContentPage
private async void OnDisconnectClicked(object sender, EventArgs e)
{
await _uploadService.DisconnectAsync();
ConnectionManager.Instance.Disconnect();
StatusLabel.Text = "Disconnected";
StatusLabel.TextColor = Colors.Orange;
+5
View File
@@ -23,6 +23,11 @@
<!-- Android specific -->
<AndroidManifest>Platforms\Android\AndroidManifest.xml</AndroidManifest>
<!-- Suppress NETSDK1202 (EOL workload warning for .NET 6 Android) -->
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
<NoWarn>$(NoWarn);NETSDK1202</NoWarn>
</PropertyGroup>
<ItemGroup Condition="$(TargetFramework.StartsWith('net6.0-android'))">
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PS5UploadMobile.Pages.GamesPage"
BackgroundColor="#121212"
Title="Games">
<Grid RowDefinitions="Auto,Auto,*" Padding="10">
<!-- Toolbar -->
<HorizontalStackLayout Grid.Row="0" Spacing="8">
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White" FontSize="14"/>
<Button Text="🎮 Mount All" Clicked="OnMountAllClicked" BackgroundColor="#107C10" TextColor="White" FontSize="14"/>
</HorizontalStackLayout>
<!-- Status Label -->
<Label Grid.Row="1" x:Name="StatusLabel" Text="Tap Refresh to load games"
TextColor="Gray" FontSize="12" Margin="4,6"/>
<!-- Games List -->
<RefreshView Grid.Row="2" x:Name="GamesRefresh" Refreshing="OnRefreshing">
<CollectionView x:Name="GamesListView" SelectionMode="Single"
SelectionChanged="OnGameSelected">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid Padding="10,8" ColumnDefinitions="80,*,Auto" ColumnSpacing="10" BackgroundColor="#1E1E1E" Margin="0,0,0,4">
<!-- Icon -->
<Frame Grid.Column="0" Padding="0" CornerRadius="6" IsClippedToBounds="True"
BackgroundColor="#333" WidthRequest="80" HeightRequest="80" HasShadow="False">
<Image Source="{Binding Icon}" Aspect="AspectFill" WidthRequest="80" HeightRequest="80"/>
</Frame>
<!-- Info -->
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center" Spacing="2">
<Label Text="{Binding Name}" TextColor="White" FontSize="16" FontAttributes="Bold" LineBreakMode="TailTruncation"/>
<Label Text="{Binding TitleId}" TextColor="#80B0FF" FontSize="12"/>
<Label Text="{Binding SizeDisplay}" TextColor="Gray" FontSize="12"/>
<Label Text="{Binding Region}" TextColor="Gray" FontSize="11"/>
</VerticalStackLayout>
<!-- Status -->
<Label Grid.Column="2" Text="{Binding StatusDisplay}" TextColor="LimeGreen" FontSize="11" VerticalOptions="Center"/>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<Label Text="No games found. Pull to refresh." TextColor="Gray" HorizontalOptions="Center" VerticalOptions="Center"/>
</CollectionView.EmptyView>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>
+164
View File
@@ -0,0 +1,164 @@
#nullable enable
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using PS5Upload;
namespace PS5UploadMobile.Pages;
public partial class GamesPage : ContentPage
{
private readonly ObservableCollection<PS5MountedGame> _games = new();
public GamesPage()
{
InitializeComponent();
GamesListView.ItemsSource = _games;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (_games.Count == 0) await LoadGamesAsync();
}
private async void OnRefreshClicked(object sender, EventArgs e) => await LoadGamesAsync();
private async void OnRefreshing(object sender, EventArgs e)
{
await LoadGamesAsync();
GamesRefresh.IsRefreshing = false;
}
private async Task LoadGamesAsync()
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
StatusLabel.Text = "Loading games…";
try
{
var list = await proto.GetGameListAsync();
_games.Clear();
foreach (var g in list) _games.Add(g);
StatusLabel.Text = $"Loaded {_games.Count} games";
// Load icons in background
_ = Task.Run(async () =>
{
foreach (var game in list)
{
try
{
byte[]? iconBytes = await proto.GetGameIconAsync(game.TitleId);
if (iconBytes != null && iconBytes.Length > 0)
{
var ms = new MemoryStream(iconBytes);
await MainThread.InvokeOnMainThreadAsync(() =>
{
game.Icon = ImageSource.FromStream(() => new MemoryStream(iconBytes));
});
}
}
catch { }
}
});
}
catch (Exception ex)
{
StatusLabel.Text = $"Error: {ex.Message}";
}
}
private async void OnGameSelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
if (e.CurrentSelection[0] is not PS5MountedGame game) return;
GamesListView.SelectedItem = null;
string action = await DisplayActionSheet($"{game.Name}", "Cancel", null,
"▶️ Launch Game",
"📖 Game Details",
"⏏️ Unmount Game",
"📋 Copy Title ID");
switch (action)
{
case "▶️ Launch Game":
await LaunchGameAsync(game);
break;
case "📖 Game Details":
await ShowGameDetailsAsync(game);
break;
case "⏏️ Unmount Game":
await UnmountGameAsync(game);
break;
case "📋 Copy Title ID":
await Clipboard.SetTextAsync(game.TitleId);
await DisplayAlert("Copied", $"Copied {game.TitleId}", "OK");
break;
}
}
private async Task LaunchGameAsync(PS5MountedGame game)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
bool confirm = await DisplayAlert("Launch Game", $"Launch '{game.Name}' on your PS5?", "Launch", "Cancel");
if (!confirm) return;
var (ok, msg) = await proto.LaunchGameAsync(game.TitleId);
await DisplayAlert(ok ? "Launching" : "Error", msg, "OK");
}
private async Task UnmountGameAsync(PS5MountedGame game)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
bool confirm = await DisplayAlert("Unmount Game", $"Unmount '{game.Name}' from PS5?", "Unmount", "Cancel");
if (!confirm) return;
var (ok, msg) = await proto.UnmountGameAsync(game.TitleId);
await DisplayAlert(ok ? "Unmounted" : "Error", msg, "OK");
if (ok) await LoadGamesAsync();
}
private async Task ShowGameDetailsAsync(PS5MountedGame game)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
StatusLabel.Text = "Loading game details…";
var details = await proto.GetGameDetailsAsync(game.TitleId);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Title: {game.Name}");
sb.AppendLine($"Title ID: {game.TitleId}");
sb.AppendLine($"Region: {game.Region}");
sb.AppendLine($"Size: {game.SizeDisplay}");
sb.AppendLine($"Path: {game.Path}");
sb.AppendLine($"Status: {game.StatusDisplay}");
if (details != null)
{
sb.AppendLine();
sb.AppendLine("—— param.json ——");
foreach (var kv in details) sb.AppendLine($"{kv.Key}: {kv.Value}");
}
StatusLabel.Text = $"Loaded {_games.Count} games";
await DisplayAlert("Game Details", sb.ToString(), "Close");
}
private async void OnMountAllClicked(object sender, EventArgs e)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
bool confirm = await DisplayAlert("Mount Games",
"This will scan all known locations (internal, USB, M.2) and mount unmounted games. Continue?",
"Mount", "Cancel");
if (!confirm) return;
StatusLabel.Text = "Mounting games (this can take a while)…";
var (ok, summary) = await proto.MountGamesAsync(msg => MainThread.BeginInvokeOnMainThread(() => StatusLabel.Text = msg));
StatusLabel.Text = ok ? "Mount complete" : "Mount failed";
await DisplayAlert(ok ? "Mount Games - Done" : "Mount Games - Error", summary, "OK");
if (ok) await LoadGamesAsync();
}
}
+102
View File
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PS5UploadMobile.Pages.HardwarePage"
BackgroundColor="#121212"
Title="Hardware">
<ScrollView>
<VerticalStackLayout Padding="12" Spacing="14">
<!-- Toolbar -->
<HorizontalStackLayout Spacing="10">
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White"/>
<HorizontalStackLayout Spacing="6" VerticalOptions="Center">
<CheckBox x:Name="AutoRefreshCheck" Color="#0078D4" CheckedChanged="OnAutoRefreshChanged"/>
<Label Text="Auto-refresh (5s)" TextColor="White" VerticalOptions="Center"/>
</HorizontalStackLayout>
</HorizontalStackLayout>
<Label x:Name="StatusLabel" Text="Tap Refresh to load hardware info" TextColor="Gray" FontSize="12"/>
<!-- System Info -->
<Frame BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
<VerticalStackLayout Spacing="8">
<Label Text="🖥️ System Info" TextColor="#80B0FF" FontSize="16" FontAttributes="Bold"/>
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="6">
<Label Grid.Row="0" Grid.Column="0" Text="Model" TextColor="Gray"/>
<Label Grid.Row="0" Grid.Column="1" x:Name="ModelLabel" Text="—" TextColor="White"/>
<Label Grid.Row="1" Grid.Column="0" Text="Serial" TextColor="Gray"/>
<Label Grid.Row="1" Grid.Column="1" x:Name="SerialLabel" Text="—" TextColor="White"/>
<Label Grid.Row="2" Grid.Column="0" Text="Machine" TextColor="Gray"/>
<Label Grid.Row="2" Grid.Column="1" x:Name="MachineLabel" Text="—" TextColor="White"/>
<Label Grid.Row="3" Grid.Column="0" Text="OS" TextColor="Gray"/>
<Label Grid.Row="3" Grid.Column="1" x:Name="OsLabel" Text="—" TextColor="White"/>
<Label Grid.Row="4" Grid.Column="0" Text="CPU Cores" TextColor="Gray"/>
<Label Grid.Row="4" Grid.Column="1" x:Name="CpuCoresLabel" Text="—" TextColor="White"/>
<Label Grid.Row="5" Grid.Column="0" Text="Physical RAM" TextColor="Gray"/>
<Label Grid.Row="5" Grid.Column="1" x:Name="RamLabel" Text="—" TextColor="White"/>
<Label Grid.Row="6" Grid.Column="0" Text="Wi-Fi / BT" TextColor="Gray"/>
<Label Grid.Row="6" Grid.Column="1" x:Name="WlanLabel" Text="—" TextColor="White"/>
<Label Grid.Row="7" Grid.Column="0" Text="Optical Out" TextColor="Gray"/>
<Label Grid.Row="7" Grid.Column="1" x:Name="OpticalLabel" Text="—" TextColor="White"/>
</Grid>
</VerticalStackLayout>
</Frame>
<!-- Live Sensors -->
<Frame BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
<VerticalStackLayout Spacing="12">
<Label Text="🔥 Live Sensors" TextColor="#FF9050" FontSize="16" FontAttributes="Bold"/>
<VerticalStackLayout Spacing="4">
<Grid ColumnDefinitions="*,Auto">
<Label Text="CPU Temperature" TextColor="White"/>
<Label Grid.Column="1" x:Name="CpuTempLabel" Text="—" TextColor="Orange" FontAttributes="Bold"/>
</Grid>
<ProgressBar x:Name="CpuTempBar" Progress="0" ProgressColor="#FF9050"/>
</VerticalStackLayout>
<VerticalStackLayout Spacing="4">
<Grid ColumnDefinitions="*,Auto">
<Label Text="SoC Temperature" TextColor="White"/>
<Label Grid.Column="1" x:Name="SocTempLabel" Text="—" TextColor="Orange" FontAttributes="Bold"/>
</Grid>
<ProgressBar x:Name="SocTempBar" Progress="0" ProgressColor="#FF5050"/>
</VerticalStackLayout>
<VerticalStackLayout Spacing="4">
<Grid ColumnDefinitions="*,Auto">
<Label Text="CPU Frequency" TextColor="White"/>
<Label Grid.Column="1" x:Name="CpuFreqLabel" Text="—" TextColor="LimeGreen" FontAttributes="Bold"/>
</Grid>
<ProgressBar x:Name="CpuFreqBar" Progress="0" ProgressColor="LimeGreen"/>
</VerticalStackLayout>
<VerticalStackLayout Spacing="4">
<Grid ColumnDefinitions="*,Auto">
<Label Text="SoC Power" TextColor="White"/>
<Label Grid.Column="1" x:Name="SocPowerLabel" Text="—" TextColor="Yellow" FontAttributes="Bold"/>
</Grid>
<ProgressBar x:Name="SocPowerBar" Progress="0" ProgressColor="Yellow"/>
</VerticalStackLayout>
</VerticalStackLayout>
</Frame>
<!-- Power Info -->
<Frame BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
<VerticalStackLayout Spacing="8">
<Label Text="⚡ Power Stats" TextColor="#FFD050" FontSize="16" FontAttributes="Bold"/>
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="6">
<Label Grid.Row="0" Grid.Column="0" Text="Operating Time" TextColor="Gray"/>
<Label Grid.Row="0" Grid.Column="1" x:Name="OpTimeLabel" Text="—" TextColor="White"/>
<Label Grid.Row="1" Grid.Column="0" Text="Boot Count" TextColor="Gray"/>
<Label Grid.Row="1" Grid.Column="1" x:Name="BootCountLabel" Text="—" TextColor="White"/>
<Label Grid.Row="2" Grid.Column="0" Text="Total Power" TextColor="Gray"/>
<Label Grid.Row="2" Grid.Column="1" x:Name="PowerConsLabel" Text="—" TextColor="White"/>
</Grid>
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
+129
View File
@@ -0,0 +1,129 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Dispatching;
using PS5Upload;
namespace PS5UploadMobile.Pages;
public partial class HardwarePage : ContentPage
{
private IDispatcherTimer? _autoTimer;
private int _busy; // Interlocked guard
public HardwarePage()
{
InitializeComponent();
}
protected override async void OnAppearing()
{
base.OnAppearing();
await RefreshAllAsync();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
StopAutoTimer();
}
private async void OnRefreshClicked(object sender, EventArgs e) => await RefreshAllAsync();
private void OnAutoRefreshChanged(object sender, CheckedChangedEventArgs e)
{
if (e.Value) StartAutoTimer();
else StopAutoTimer();
}
private void StartAutoTimer()
{
StopAutoTimer();
_autoTimer = Dispatcher.CreateTimer();
_autoTimer.Interval = TimeSpan.FromSeconds(5);
_autoTimer.Tick += async (_, __) => await RefreshSensorsAsync();
_autoTimer.Start();
}
private void StopAutoTimer()
{
if (_autoTimer != null) { _autoTimer.Stop(); _autoTimer = null; }
}
private async Task RefreshAllAsync()
{
if (Interlocked.Exchange(ref _busy, 1) == 1) return;
try
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) { StatusLabel.Text = "Disconnected"; return; }
StatusLabel.Text = "Loading hardware info…";
var hw = await proto.GetHardwareInfoAsync();
if (hw != null)
{
ModelLabel.Text = hw.Model;
SerialLabel.Text = hw.Serial;
MachineLabel.Text = hw.HwMachine;
OsLabel.Text = hw.OsVersion;
CpuCoresLabel.Text = hw.NumCpu > 0 ? hw.NumCpu.ToString() : "—";
RamLabel.Text = hw.PhysMemDisplay;
WlanLabel.Text = hw.WlanBtDisplay;
OpticalLabel.Text = hw.OpticalDisplay;
}
await UpdateSensorsAsync(proto);
await UpdatePowerAsync(proto);
StatusLabel.Text = $"Updated {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex) { StatusLabel.Text = $"Error: {ex.Message}"; }
finally { Interlocked.Exchange(ref _busy, 0); }
}
private async Task RefreshSensorsAsync()
{
if (Interlocked.Exchange(ref _busy, 1) == 1) return;
try
{
var proto = PageHelper.Conn.Protocol;
if (proto == null || !proto.IsConnected) { StopAutoTimer(); return; }
await UpdateSensorsAsync(proto);
StatusLabel.Text = $"Updated {DateTime.Now:HH:mm:ss}";
}
catch { }
finally { Interlocked.Exchange(ref _busy, 0); }
}
private async Task UpdateSensorsAsync(PS5Protocol proto)
{
var temps = await proto.GetTemperatureInfoAsync();
if (temps == null) return;
CpuTempLabel.Text = temps.CpuTempDisplay;
SocTempLabel.Text = temps.SocTempDisplay;
CpuFreqLabel.Text = temps.CpuFreqDisplay;
SocPowerLabel.Text = temps.SocPowerDisplay;
// Progress bars - map sensible ranges
CpuTempBar.Progress = Math.Clamp(temps.CpuTemp / 100.0, 0, 1);
SocTempBar.Progress = Math.Clamp(temps.SocTemp / 100.0, 0, 1);
// PS5 CPU typical 3.5 GHz max
CpuFreqBar.Progress = Math.Clamp(temps.CpuFreqMhz / 3500.0, 0, 1);
// SoC power 0–200W range
SocPowerBar.Progress = Math.Clamp(temps.SocPowerMw / 200000.0, 0, 1);
}
private async Task UpdatePowerAsync(PS5Protocol proto)
{
var power = await proto.GetPowerInfoAsync();
if (power == null) return;
OpTimeLabel.Text = $"{power.OperatingTimeHours}h {power.OperatingTimeMinutes}m";
BootCountLabel.Text = power.BootCount.ToString();
PowerConsLabel.Text = power.PowerConsumptionMw > 0
? $"{power.PowerConsumptionMw / 1000.0:F2} kW·s (lifetime)"
: "—";
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PS5UploadMobile.Pages.MountGamesPage"
BackgroundColor="#121212"
Title="Mount Games">
<Grid RowDefinitions="Auto,Auto,*" Padding="14">
<Frame Grid.Row="0" BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
<VerticalStackLayout Spacing="6">
<Label Text="🎮 Mount Games" TextColor="#80B0FF" FontSize="18" FontAttributes="Bold"/>
<Label Text="Scan all known locations (internal, USB 0–3, M.2 SSD) and automatically mount any unmounted games onto the PS5 home screen."
TextColor="LightGray" FontSize="12"/>
</VerticalStackLayout>
</Frame>
<Button Grid.Row="1" x:Name="StartBtn" Text="🚀 Start Mount Scan"
Clicked="OnStartClicked" BackgroundColor="#107C10" TextColor="White"
FontAttributes="Bold" HeightRequest="55" Margin="0,12,0,12"/>
<Border Grid.Row="2" Stroke="#333" StrokeThickness="1" Background="#000" Padding="8">
<ScrollView x:Name="LogScroll">
<Label x:Name="LogLabel" Text="Ready. Tap 'Start Mount Scan' to begin."
TextColor="#00FF40" FontFamily="Monospace" FontSize="12"/>
</ScrollView>
</Border>
</Grid>
</ContentPage>
+48
View File
@@ -0,0 +1,48 @@
#nullable enable
using System;
using System.Text;
using Microsoft.Maui.Controls;
namespace PS5UploadMobile.Pages;
public partial class MountGamesPage : ContentPage
{
private readonly StringBuilder _buffer = new();
public MountGamesPage()
{
InitializeComponent();
}
private void AppendLine(string s)
{
_buffer.AppendLine(s);
LogLabel.Text = _buffer.ToString();
MainThread.BeginInvokeOnMainThread(async () =>
{
await System.Threading.Tasks.Task.Delay(50);
await LogScroll.ScrollToAsync(0, double.MaxValue, false);
});
}
private async void OnStartClicked(object sender, EventArgs e)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
StartBtn.IsEnabled = false;
_buffer.Clear();
AppendLine("🚀 Starting mount scan…");
AppendLine("Scanning: /data/etaHEN/games, /mnt/usb0-3, /mnt/ext0/games …");
var (ok, summary) = await proto.MountGamesAsync(msg =>
MainThread.BeginInvokeOnMainThread(() => AppendLine(msg)));
AppendLine("");
AppendLine(ok ? "✅ Mount scan complete" : "❌ Mount scan failed");
AppendLine("");
AppendLine("—— Summary ——");
AppendLine(summary);
StartBtn.IsEnabled = true;
}
}
+48
View File
@@ -0,0 +1,48 @@
#nullable enable
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using PS5Upload;
using PS5UploadMobile.Services;
namespace PS5UploadMobile.Pages
{
internal static class PageHelper
{
public static ConnectionManager Conn => ConnectionManager.Instance;
public static async Task<PS5Protocol?> EnsureConnectedAsync(Page page)
{
var proto = await Conn.EnsureConnectedAsync();
if (proto == null)
{
await page.DisplayAlert("Not Connected",
$"Could not connect to {Conn.IpAddress}:{Conn.Port}. Please connect from the Files tab first.",
"OK");
}
return proto;
}
public static string DownloadsDir
{
get
{
string? root = Microsoft.Maui.Storage.FileSystem.AppDataDirectory;
string path = Path.Combine(root, "PS5Downloads");
Directory.CreateDirectory(path);
return path;
}
}
public static string ThumbnailCacheDir
{
get
{
string path = Path.Combine(Microsoft.Maui.Storage.FileSystem.CacheDirectory, "thumbnails");
Directory.CreateDirectory(path);
return path;
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PS5UploadMobile.Pages.SavesPage"
BackgroundColor="#121212"
Title="Saves">
<Grid RowDefinitions="Auto,Auto,*" Padding="10">
<HorizontalStackLayout Grid.Row="0" Spacing="8">
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White" FontSize="14"/>
</HorizontalStackLayout>
<Label Grid.Row="1" x:Name="StatusLabel" Text="Tap Refresh to load save files"
TextColor="Gray" FontSize="12" Margin="4,6"/>
<RefreshView Grid.Row="2" x:Name="SavesRefresh" Refreshing="OnRefreshing">
<CollectionView x:Name="SavesListView" SelectionMode="Single"
SelectionChanged="OnSaveSelected">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid Padding="10,8" ColumnDefinitions="60,*,Auto" ColumnSpacing="10"
BackgroundColor="#1E1E1E" Margin="0,0,0,4">
<Frame Grid.Column="0" Padding="0" CornerRadius="6" IsClippedToBounds="True"
BackgroundColor="#333" WidthRequest="60" HeightRequest="60" HasShadow="False">
<Image Source="{Binding Icon}" Aspect="AspectFill"/>
</Frame>
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center" Spacing="2">
<Label Text="{Binding GameName}" TextColor="White" FontSize="15" FontAttributes="Bold"
LineBreakMode="TailTruncation"/>
<Label Text="{Binding TitleId}" TextColor="#80B0FF" FontSize="11"/>
<Label TextColor="Gray" FontSize="10">
<Label.FormattedText>
<FormattedString>
<Span Text="User: "/>
<Span Text="{Binding ShortUserId}"/>
<Span Text=" • "/>
<Span Text="{Binding ModifiedDisplay}"/>
</FormattedString>
</Label.FormattedText>
</Label>
</VerticalStackLayout>
<Label Grid.Column="2" Text="{Binding SizeDisplay}" TextColor="Orange" FontSize="12"
VerticalOptions="Center"/>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<Label Text="No saves found." TextColor="Gray" HorizontalOptions="Center" VerticalOptions="Center"/>
</CollectionView.EmptyView>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>
+115
View File
@@ -0,0 +1,115 @@
#nullable enable
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using PS5Upload;
namespace PS5UploadMobile.Pages;
public partial class SavesPage : ContentPage
{
private readonly ObservableCollection<PS5SaveGame> _saves = new();
private readonly System.Collections.Generic.Dictionary<string, string> _gameNameCache = new();
public SavesPage()
{
InitializeComponent();
SavesListView.ItemsSource = _saves;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (_saves.Count == 0) await LoadSavesAsync();
}
private async void OnRefreshClicked(object sender, EventArgs e) => await LoadSavesAsync();
private async void OnRefreshing(object sender, EventArgs e)
{
await LoadSavesAsync();
SavesRefresh.IsRefreshing = false;
}
private async Task LoadSavesAsync()
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
StatusLabel.Text = "Loading saves…";
try
{
var list = await proto.ListSavesAsync();
_saves.Clear();
foreach (var s in list)
{
s.GameName = _gameNameCache.TryGetValue(s.TitleId, out var nm) ? nm : s.TitleId;
_saves.Add(s);
}
StatusLabel.Text = $"Loaded {_saves.Count} saves";
// Background: fetch game names from GetGameList + icons
_ = Task.Run(async () =>
{
try
{
var games = await proto.GetGameListAsync();
var gameDict = new System.Collections.Generic.Dictionary<string, string>();
foreach (var g in games) gameDict[g.TitleId] = g.Name;
foreach (var save in list)
{
if (gameDict.TryGetValue(save.TitleId, out var nm))
{
_gameNameCache[save.TitleId] = nm;
await MainThread.InvokeOnMainThreadAsync(() => save.GameName = nm);
}
byte[]? iconBytes = await proto.GetGameIconAsync(save.TitleId);
if (iconBytes != null && iconBytes.Length > 0)
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
save.Icon = ImageSource.FromStream(() => new MemoryStream(iconBytes));
});
}
}
}
catch { }
});
}
catch (Exception ex)
{
StatusLabel.Text = $"Error: {ex.Message}";
}
}
private async void OnSaveSelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
if (e.CurrentSelection[0] is not PS5SaveGame save) return;
SavesListView.SelectedItem = null;
string action = await DisplayActionSheet($"{save.GameName} ({save.TitleId})", "Cancel", null,
"📋 Copy Save Path",
"ℹ️ Save Info");
if (action == "📋 Copy Save Path")
{
await Clipboard.SetTextAsync(save.SavePath);
await DisplayAlert("Copied", $"Copied: {save.SavePath}", "OK");
}
else if (action == "ℹ️ Save Info")
{
string info =
$"Game: {save.GameName}\n" +
$"Title ID: {save.TitleId}\n" +
$"User ID: {save.UserId}\n" +
$"Size: {save.SizeDisplay}\n" +
$"Modified: {save.ModifiedDisplay}\n" +
$"Path: {save.SavePath}";
await DisplayAlert("Save Details", info, "Close");
}
}
}
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PS5UploadMobile.Pages.ScreenshotsPage"
BackgroundColor="#121212"
Title="Screenshots">
<Grid RowDefinitions="Auto,Auto,*" Padding="10">
<HorizontalStackLayout Grid.Row="0" Spacing="8">
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White" FontSize="14"/>
</HorizontalStackLayout>
<Label Grid.Row="1" x:Name="StatusLabel" Text="Tap Refresh to load screenshots"
TextColor="Gray" FontSize="12" Margin="4,6"/>
<RefreshView Grid.Row="2" x:Name="ShotsRefresh" Refreshing="OnRefreshing">
<CollectionView x:Name="ShotsListView" SelectionMode="Single"
SelectionChanged="OnShotSelected">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical" Span="2" VerticalItemSpacing="6" HorizontalItemSpacing="6"/>
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Padding="0" CornerRadius="8" IsClippedToBounds="True"
BackgroundColor="#1E1E1E" HasShadow="False" BorderColor="#333">
<Grid RowDefinitions="*,Auto">
<Image Grid.Row="0" Source="{Binding Thumbnail}" Aspect="AspectFill" HeightRequest="130"
BackgroundColor="#2A2A2A"/>
<VerticalStackLayout Grid.Row="1" Padding="6,4" Spacing="0">
<Label Text="{Binding FileName}" TextColor="White" FontSize="11"
LineBreakMode="TailTruncation"/>
<Label TextColor="Gray" FontSize="10">
<Label.FormattedText>
<FormattedString>
<Span Text="{Binding SizeDisplay}"/>
<Span Text=" • "/>
<Span Text="{Binding DateDisplay}"/>
</FormattedString>
</Label.FormattedText>
</Label>
</VerticalStackLayout>
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<Label Text="No screenshots found. Pull to refresh." TextColor="Gray"
HorizontalOptions="Center" VerticalOptions="Center"/>
</CollectionView.EmptyView>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>
+173
View File
@@ -0,0 +1,173 @@
#nullable enable
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using PS5Upload;
namespace PS5UploadMobile.Pages;
public partial class ScreenshotsPage : ContentPage
{
private readonly ObservableCollection<PS5Screenshot> _shots = new();
public ScreenshotsPage()
{
InitializeComponent();
ShotsListView.ItemsSource = _shots;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (_shots.Count == 0) await LoadShotsAsync();
}
private async void OnRefreshClicked(object sender, EventArgs e) => await LoadShotsAsync();
private async void OnRefreshing(object sender, EventArgs e)
{
await LoadShotsAsync();
ShotsRefresh.IsRefreshing = false;
}
private async Task LoadShotsAsync()
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
StatusLabel.Text = "Loading screenshots…";
try
{
var list = await proto.ListScreenshotsAsync();
_shots.Clear();
foreach (var s in list) _shots.Add(s);
StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
// Load thumbnails in background
_ = Task.Run(() => LoadThumbnailsAsync(list));
}
catch (Exception ex)
{
StatusLabel.Text = $"Error: {ex.Message}";
}
}
private async Task LoadThumbnailsAsync(System.Collections.Generic.List<PS5Screenshot> shots)
{
string cacheDir = PageHelper.ThumbnailCacheDir;
foreach (var shot in shots)
{
try
{
// Cache key
string hash;
using (var md5 = MD5.Create())
{
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(shot.FullPath));
hash = BitConverter.ToString(bytes).Replace("-", "").ToLower();
}
string cacheFile = Path.Combine(cacheDir, hash + ".jpg");
if (!File.Exists(cacheFile))
{
// Need its own protocol - use shared connection
var proto = PageHelper.Conn.Protocol;
if (proto == null || !proto.IsConnected) return;
bool ok = await proto.DownloadFileAsync(shot.FullPath, cacheFile);
if (!ok) continue;
}
await MainThread.InvokeOnMainThreadAsync(() =>
{
shot.Thumbnail = ImageSource.FromFile(cacheFile);
});
}
catch { }
}
}
private async void OnShotSelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
if (e.CurrentSelection[0] is not PS5Screenshot shot) return;
ShotsListView.SelectedItem = null;
string action = await DisplayActionSheet(shot.FileName, "Cancel", null,
"👁️ View",
"⬇️ Download",
"🗑️ Delete",
"📋 Copy Path");
switch (action)
{
case "👁️ View":
await ViewShotAsync(shot);
break;
case "⬇️ Download":
await DownloadShotAsync(shot);
break;
case "🗑️ Delete":
await DeleteShotAsync(shot);
break;
case "📋 Copy Path":
await Clipboard.SetTextAsync(shot.FullPath);
await DisplayAlert("Copied", shot.FullPath, "OK");
break;
}
}
private async Task ViewShotAsync(PS5Screenshot shot)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
StatusLabel.Text = "Downloading for preview…";
string tmp = Path.Combine(PageHelper.ThumbnailCacheDir, "preview_" + Path.GetFileName(shot.FileName));
bool ok = await proto.DownloadFileAsync(shot.FullPath, tmp);
StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
if (!ok) { await DisplayAlert("Error", "Download failed", "OK"); return; }
try
{
await Microsoft.Maui.ApplicationModel.Launcher.OpenAsync(
new Microsoft.Maui.ApplicationModel.OpenFileRequest { File = new ReadOnlyFile(tmp) });
}
catch (Exception ex) { await DisplayAlert("Open failed", ex.Message, "OK"); }
}
private async Task DownloadShotAsync(PS5Screenshot shot)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
string dir = PageHelper.DownloadsDir;
string local = Path.Combine(dir, shot.FileName);
StatusLabel.Text = $"Downloading {shot.FileName}…";
bool ok = await proto.DownloadFileAsync(shot.FullPath, local);
StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
await DisplayAlert(ok ? "Downloaded" : "Error",
ok ? $"Saved to:\n{local}" : "Download failed", "OK");
}
private async Task DeleteShotAsync(PS5Screenshot shot)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
bool confirm = await DisplayAlert("Delete Screenshot",
$"Delete {shot.FileName}? This also removes the thumbnail from PS5 Media Gallery.",
"Delete", "Cancel");
if (!confirm) return;
var (ok, msg) = await proto.DeleteScreenshotAsync(shot.FullPath);
if (ok)
{
_shots.Remove(shot);
StatusLabel.Text = $"Deleted. {_shots.Count} screenshots remaining.";
}
else
{
await DisplayAlert("Delete failed", msg, "OK");
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="PS5UploadMobile.Pages.ShellPage"
BackgroundColor="#121212"
Title="Shell">
<Grid RowDefinitions="Auto,*,Auto" Padding="10">
<HorizontalStackLayout Grid.Row="0" Spacing="8" Margin="0,0,0,6">
<Button Text="🔗 Open Shell" Clicked="OnOpenClicked" BackgroundColor="#107C10" TextColor="White" FontSize="13"/>
<Button Text="❌ Close" Clicked="OnCloseClicked" BackgroundColor="#D13438" TextColor="White" FontSize="13"/>
<Button Text="🧹 Clear" Clicked="OnClearClicked" BackgroundColor="#3E3E3E" TextColor="White" FontSize="13"/>
</HorizontalStackLayout>
<Border Grid.Row="1" Stroke="#333" StrokeThickness="1" Background="#000" Padding="8">
<ScrollView x:Name="OutputScroll">
<Label x:Name="OutputLabel" Text="Shell output will appear here…"
TextColor="#00FF40" FontFamily="Monospace" FontSize="12"/>
</ScrollView>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,6,0,0">
<Entry x:Name="CmdEntry" Placeholder="Enter shell command…"
Text="ls /" TextColor="White" PlaceholderColor="Gray"
Completed="OnCmdCompleted"/>
<Button Grid.Column="1" Text="▶ Run" Clicked="OnRunClicked"
BackgroundColor="#0078D4" TextColor="White"/>
</Grid>
</Grid>
</ContentPage>
+76
View File
@@ -0,0 +1,76 @@
#nullable enable
using System;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
namespace PS5UploadMobile.Pages;
public partial class ShellPage : ContentPage
{
private readonly StringBuilder _buffer = new();
private bool _shellOpen;
public ShellPage()
{
InitializeComponent();
}
private void AppendLine(string s)
{
_buffer.AppendLine(s);
OutputLabel.Text = _buffer.ToString();
MainThread.BeginInvokeOnMainThread(async () =>
{
await Task.Delay(50);
await OutputScroll.ScrollToAsync(0, double.MaxValue, false);
});
}
private async void OnOpenClicked(object sender, EventArgs e)
{
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
bool ok = await proto.OpenShellAsync();
_shellOpen = ok;
AppendLine(ok ? "🟢 Shell opened" : "🔴 Failed to open shell");
}
private async void OnCloseClicked(object sender, EventArgs e)
{
var proto = PageHelper.Conn.Protocol;
if (proto == null) { AppendLine("(no connection)"); return; }
bool ok = await proto.CloseShellAsync();
_shellOpen = false;
AppendLine(ok ? "🟡 Shell closed" : "⚠️ Close failed");
}
private void OnClearClicked(object sender, EventArgs e)
{
_buffer.Clear();
OutputLabel.Text = "";
}
private async void OnCmdCompleted(object sender, EventArgs e) => await RunCmdAsync();
private async void OnRunClicked(object sender, EventArgs e) => await RunCmdAsync();
private async Task RunCmdAsync()
{
string cmd = CmdEntry.Text?.Trim() ?? "";
if (string.IsNullOrEmpty(cmd)) return;
var proto = await PageHelper.EnsureConnectedAsync(this);
if (proto == null) return;
if (!_shellOpen)
{
bool opened = await proto.OpenShellAsync();
_shellOpen = opened;
if (!opened) { AppendLine("🔴 Shell not open"); return; }
}
AppendLine($"$ {cmd}");
string result = await proto.ExecuteShellCommandAsync(cmd);
if (!string.IsNullOrEmpty(result)) AppendLine(result.TrimEnd('\n', '\r'));
}
}
+79
View File
@@ -0,0 +1,79 @@
#nullable enable
using System;
using System.Threading.Tasks;
using PS5Upload;
namespace PS5UploadMobile.Services
{
public class ConnectionManager
{
private static ConnectionManager? _instance;
private static readonly object _lock = new object();
public string IpAddress { get; set; } = "192.168.0.160";
public int Port { get; set; } = 9113;
public MainPage? MainPageReference { get; set; }
// Shared protocol instance used by all pages
private PS5Protocol? _protocol;
public PS5Protocol? Protocol => _protocol;
public bool IsConnected => _protocol?.IsConnected ?? false;
public event Action? ConnectionStateChanged;
private ConnectionManager() { }
public static ConnectionManager Instance
{
get
{
if (_instance == null)
{
lock (_lock) { if (_instance == null) _instance = new ConnectionManager(); }
}
return _instance;
}
}
public void SetConnection(string ipAddress, int port)
{
IpAddress = ipAddress;
Port = port;
}
public void SetMainPage(MainPage mainPage) => MainPageReference = mainPage;
public async Task<bool> ConnectAsync(string? ip = null, int? port = null)
{
if (!string.IsNullOrWhiteSpace(ip)) IpAddress = ip!;
if (port.HasValue) Port = port.Value;
try { _protocol?.Dispose(); } catch { }
_protocol = new PS5Protocol();
bool ok = await _protocol.ConnectAsync(IpAddress, Port);
if (!ok)
{
try { _protocol.Dispose(); } catch { }
_protocol = null;
}
ConnectionStateChanged?.Invoke();
return ok;
}
public void Disconnect()
{
try { _protocol?.Dispose(); } catch { }
_protocol = null;
ConnectionStateChanged?.Invoke();
}
/// <summary>
/// Ensure the shared protocol is connected, attempt reconnect if not.
/// </summary>
public async Task<PS5Protocol?> EnsureConnectedAsync()
{
if (_protocol != null && _protocol.IsConnected) return _protocol;
return await ConnectAsync() ? _protocol : null;
}
}
}
+36 -1
View File
@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Buffers;
using System.IO;
@@ -28,10 +29,29 @@ namespace PS5Upload
ShellExec = 0x21,
ShellInterrupt = 0x22,
ShellClose = 0x23,
MountGames = 0x30,
GetFileInfo = 0x31,
GetSystemInfo = 0x32,
VerifyFile = 0x33,
GetHwInfo = 0x34,
GetTemps = 0x35,
GetRunningApps = 0x36,
KillApp = 0x37,
LaunchBrowser = 0x38,
GetPowerInfo = 0x39,
GetGameList = 0x3A,
UnmountGame = 0x3B,
GetGameIcon = 0x3C,
GetGameDetails = 0x3D,
GetGamePic = 0x3E,
ListSaves = 0x3F,
IndexStart = 0x40,
IndexStatus = 0x41,
SearchIndex = 0x42,
IndexCancel = 0x43,
LaunchGame = 0x44,
ListScreenshots = 0x45,
DeleteScreenshot = 0x46,
Shutdown = 0xFF
}
@@ -45,12 +65,27 @@ namespace PS5Upload
Progress = 0x05
}
public class PS5Protocol : IDisposable
public partial class PS5Protocol : IDisposable
{
private TcpClient? _client;
private NetworkStream? _stream;
private readonly SemaphoreSlim _commandLock = new SemaphoreSlim(1, 1);
private const int BufferSize = 4 * 1024 * 1024; // 4MB buffer - maximum throughput for chunk uploads
// Expose helpers for partial methods in ProtocolExtras.cs
internal Task SendCommandInternalAsync(Command cmd, byte[]? data = null) => SendCommandAsync(cmd, data);
internal Task<(Response response, byte[] data)> ReceiveResponseInternalAsync() => ReceiveResponseAsync();
internal async Task<(Response response, byte[] data)> RunCommandAsync(Command cmd, byte[]? data = null)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(cmd, data);
return await ReceiveResponseAsync();
}
finally { _commandLock.Release(); }
}
public bool IsConnected => _client?.Connected ?? false;
public async Task<bool> ConnectAsync(string ipAddress, int port = 9113)
+461
View File
@@ -0,0 +1,461 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
namespace PS5Upload
{
// ========== Additional commands: Hardware, Games, Saves, Screenshots, Mount, Shell, etc. ==========
public partial class PS5Protocol
{
// ---------------- Hardware info ----------------
public async Task<PS5HardwareInfo?> GetHardwareInfoAsync()
{
try
{
var (response, data) = await RunCommandAsync(Command.GetHwInfo);
if (response != Response.Data) return null;
string text = Encoding.UTF8.GetString(data);
var info = new PS5HardwareInfo();
foreach (var line in text.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var k = parts[0].Trim(); var v = parts[1].Trim();
switch (k)
{
case "model": info.Model = v; break;
case "serial": info.Serial = v; break;
case "has_wlan_bt": info.HasWlanBt = v == "1"; break;
case "has_optical_out": info.HasOpticalOut = v == "1"; break;
case "hw_machine": info.HwMachine = v; break;
case "os": info.OsVersion = v; break;
case "ncpu": if (int.TryParse(v, out var n)) info.NumCpu = n; break;
case "physmem": if (ulong.TryParse(v, out var pm)) info.PhysMem = pm; break;
}
}
return info;
}
catch { return null; }
}
// ---------------- Live sensors ----------------
public async Task<PS5TemperatureInfo?> GetTemperatureInfoAsync()
{
try
{
var (response, data) = await RunCommandAsync(Command.GetTemps);
if (response != Response.Data) return null;
string text = Encoding.UTF8.GetString(data);
var info = new PS5TemperatureInfo();
foreach (var line in text.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var k = parts[0].Trim(); var v = parts[1].Trim();
switch (k)
{
case "cpu_temp": if (int.TryParse(v, out var ct)) info.CpuTemp = ct; break;
case "soc_temp": if (int.TryParse(v, out var st)) info.SocTemp = st; break;
case "cpu_freq_mhz": if (long.TryParse(v, out var cf)) info.CpuFreqMhz = cf; break;
case "soc_power_mw": if (uint.TryParse(v, out var pw)) info.SocPowerMw = pw; break;
}
}
return info;
}
catch { return null; }
}
// ---------------- Power ----------------
public async Task<PS5PowerInfo?> GetPowerInfoAsync()
{
try
{
var (response, data) = await RunCommandAsync(Command.GetPowerInfo);
if (response != Response.Data) return null;
string text = Encoding.UTF8.GetString(data);
var info = new PS5PowerInfo();
foreach (var line in text.Split('\n'))
{
var parts = line.Split('=', 2);
if (parts.Length != 2) continue;
var k = parts[0].Trim(); var v = parts[1].Trim();
switch (k)
{
case "operating_time_sec": if (ulong.TryParse(v, out var a)) info.OperatingTimeSec = a; break;
case "operating_time_hours": if (ulong.TryParse(v, out var b)) info.OperatingTimeHours = b; break;
case "operating_time_minutes": if (ulong.TryParse(v, out var c)) info.OperatingTimeMinutes = c; break;
case "boot_count": if (uint.TryParse(v, out var d)) info.BootCount = d; break;
case "power_consumption_mw": if (uint.TryParse(v, out var e)) info.PowerConsumptionMw = e; break;
}
}
return info;
}
catch { return null; }
}
// ---------------- Mounted games ----------------
public async Task<List<PS5MountedGame>> GetGameListAsync()
{
var result = new List<PS5MountedGame>();
try
{
var (response, data) = await RunCommandAsync(Command.GetGameList);
if (response != Response.Data) return result;
string text = Encoding.UTF8.GetString(data);
if (text.Trim() == "NO_GAMES") return result;
foreach (var line in text.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split('|');
if (parts.Length < 6) continue;
var g = new PS5MountedGame
{
TitleId = parts[0],
Name = parts[1],
Path = parts[2],
Region = parts[4],
IsActive = parts[5] == "1"
};
if (ulong.TryParse(parts[3], out var sz)) g.Size = sz;
result.Add(g);
}
}
catch { }
return result;
}
public async Task<(bool success, string message)> UnmountGameAsync(string titleId)
{
try
{
var data = Encoding.UTF8.GetBytes(titleId + "\0");
var (response, respData) = await RunCommandAsync(Command.UnmountGame, data);
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
}
catch (Exception ex) { return (false, ex.Message); }
}
public async Task<(bool success, string message)> LaunchGameAsync(string titleId)
{
try
{
var data = Encoding.UTF8.GetBytes(titleId + "\0");
var (response, respData) = await RunCommandAsync(Command.LaunchGame, data);
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
}
catch (Exception ex) { return (false, ex.Message); }
}
public async Task<byte[]?> GetGameIconAsync(string titleId)
{
try
{
var data = Encoding.UTF8.GetBytes(titleId + "\0");
var (response, respData) = await RunCommandAsync(Command.GetGameIcon, data);
if (response != Response.Data || respData == null || respData.Length == 0) return null;
return respData;
}
catch { return null; }
}
public async Task<Dictionary<string, string>?> GetGameDetailsAsync(string titleId)
{
try
{
var data = Encoding.UTF8.GetBytes(titleId + "\0");
var (response, respData) = await RunCommandAsync(Command.GetGameDetails, data);
if (response != Response.Data) return null;
var dict = new Dictionary<string, string>();
foreach (var line in Encoding.UTF8.GetString(respData).Split('\n'))
{
var p = line.Split('=', 2);
if (p.Length == 2) dict[p[0].Trim()] = p[1].Trim();
}
return dict;
}
catch { return null; }
}
// ---------------- Saves ----------------
public async Task<List<PS5SaveGame>> ListSavesAsync()
{
var saves = new List<PS5SaveGame>();
try
{
var (response, data) = await RunCommandAsync(Command.ListSaves);
if (response != Response.Data) return saves;
string text = Encoding.UTF8.GetString(data);
if (text.Trim() == "NO_SAVES") return saves;
foreach (var line in text.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split('|');
if (parts.Length < 5) continue;
var s = new PS5SaveGame
{
TitleId = parts[0],
UserId = parts[1],
SavePath = parts[2]
};
if (ulong.TryParse(parts[3], out var sz)) s.Size = sz;
if (long.TryParse(parts[4], out var mt)) s.ModifiedUnixTime = mt;
saves.Add(s);
}
}
catch { }
return saves;
}
// ---------------- Screenshots ----------------
public async Task<List<PS5Screenshot>> ListScreenshotsAsync()
{
var shots = new List<PS5Screenshot>();
try
{
var (response, data) = await RunCommandAsync(Command.ListScreenshots);
if (response != Response.Data) return shots;
string body = Encoding.UTF8.GetString(data);
if (body.StartsWith("NO_SCREENSHOTS")) return shots;
foreach (var line in body.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split('|');
if (parts.Length < 4) continue;
long.TryParse(parts[2], out var size);
long.TryParse(parts[3], out var mtime);
shots.Add(new PS5Screenshot
{
FullPath = parts[0],
FileName = parts[1],
Size = size,
ModifiedTime = DateTimeOffset.FromUnixTimeSeconds(mtime).LocalDateTime
});
}
}
catch { }
return shots.OrderByDescending(s => s.ModifiedTime).ToList();
}
public async Task<(bool success, string message)> DeleteScreenshotAsync(string fullPath)
{
try
{
var data = Encoding.UTF8.GetBytes(fullPath + "\0");
var (response, respData) = await RunCommandAsync(Command.DeleteScreenshot, data);
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
}
catch (Exception ex) { return (false, ex.Message); }
}
// ---------------- Mount all games (long-running, progress) ----------------
public async Task<(bool success, string summary)> MountGamesAsync(Action<string>? onProgress = null)
{
await _commandLock.WaitAsync();
try
{
await SendCommandInternalAsync(Command.MountGames);
// Server streams Progress responses, then final OK or Error
while (true)
{
var (resp, data) = await ReceiveResponseInternalAsync();
if (resp == Response.Progress)
{
onProgress?.Invoke(Encoding.UTF8.GetString(data).TrimEnd('\0'));
}
else if (resp == Response.Ok)
{
return (true, Encoding.UTF8.GetString(data));
}
else if (resp == Response.Error)
{
return (false, Encoding.UTF8.GetString(data));
}
else { return (false, $"Unexpected response {resp}"); }
}
}
catch (Exception ex) { return (false, ex.Message); }
finally { _commandLock.Release(); }
}
// ---------------- Launch Browser ----------------
public async Task<(bool success, string message)> LaunchBrowserAsync(string url)
{
try
{
var data = Encoding.UTF8.GetBytes(url + "\0");
var (response, respData) = await RunCommandAsync(Command.LaunchBrowser, data);
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
}
catch (Exception ex) { return (false, ex.Message); }
}
// Shell methods (OpenShellAsync/ExecuteShellCommandAsync/CloseShellAsync)
// already exist in Protocol.cs — not duplicated here.
}
// ========== Data models ==========
public class PS5HardwareInfo
{
public string Model { get; set; } = "";
public string Serial { get; set; } = "";
public bool HasWlanBt { get; set; }
public bool HasOpticalOut { get; set; }
public string HwMachine { get; set; } = "";
public string OsVersion { get; set; } = "";
public int NumCpu { get; set; }
public ulong PhysMem { get; set; }
public string PhysMemDisplay
{
get
{
if (PhysMem == 0) return "—";
double gb = PhysMem / (1024.0 * 1024.0 * 1024.0);
return $"{gb:F1} GB";
}
}
public string WlanBtDisplay => HasWlanBt ? "✓ Yes" : "✗ None";
public string OpticalDisplay => HasOpticalOut ? "✓ Yes" : "✗ None";
}
public class PS5TemperatureInfo
{
public int CpuTemp { get; set; }
public int SocTemp { get; set; }
public long CpuFreqMhz { get; set; }
public uint SocPowerMw { get; set; }
public string CpuTempDisplay => CpuTemp > 0 ? $"{CpuTemp}°C" : "—";
public string SocTempDisplay => SocTemp > 0 ? $"{SocTemp}°C" : "—";
public string CpuFreqDisplay => CpuFreqMhz > 0 ? $"{CpuFreqMhz / 1000.0:F2} GHz" : "—";
public string SocPowerDisplay => SocPowerMw > 0 ? $"{SocPowerMw / 1000.0:F1} W" : "—";
}
public class PS5PowerInfo
{
public ulong OperatingTimeSec { get; set; }
public ulong OperatingTimeHours { get; set; }
public ulong OperatingTimeMinutes { get; set; }
public uint BootCount { get; set; }
public uint PowerConsumptionMw { get; set; }
}
public class PS5MountedGame : INotifyPropertyChanged
{
public string TitleId { get; set; } = "";
public string Name { get; set; } = "";
public string Path { get; set; } = "";
public ulong Size { get; set; }
public string Region { get; set; } = "";
public bool IsActive { get; set; }
private ImageSource? _icon;
public ImageSource? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
}
public string SizeDisplay
{
get
{
double mb = Size / (1024.0 * 1024.0);
if (mb < 1024) return $"{mb:F1} MB";
return $"{mb / 1024.0:F2} GB";
}
}
public string StatusDisplay => IsActive ? "✓ Mounted" : "✗ Not Mounted";
public event PropertyChangedEventHandler? PropertyChanged;
}
public class PS5SaveGame : INotifyPropertyChanged
{
public string TitleId { get; set; } = "";
public string UserId { get; set; } = "";
public string SavePath { get; set; } = "";
public ulong Size { get; set; }
public long ModifiedUnixTime { get; set; }
public string SizeDisplay
{
get
{
double mb = Size / (1024.0 * 1024.0);
if (mb < 1) return $"{Size / 1024.0:F1} KB";
if (mb < 1024) return $"{mb:F1} MB";
return $"{mb / 1024.0:F2} GB";
}
}
public string ModifiedDisplay
{
get
{
try
{
var dt = DateTimeOffset.FromUnixTimeSeconds(ModifiedUnixTime).LocalDateTime;
return dt.ToString("yyyy-MM-dd HH:mm");
}
catch { return "—"; }
}
}
public string ShortUserId => UserId.Length > 8 ? UserId.Substring(0, 8) + "…" : UserId;
private string _gameName = "";
public string GameName
{
get => _gameName;
set { _gameName = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(GameName))); }
}
private ImageSource? _icon;
public ImageSource? Icon
{
get => _icon;
set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
}
public event PropertyChangedEventHandler? PropertyChanged;
}
public class PS5Screenshot : INotifyPropertyChanged
{
public string FullPath { get; set; } = "";
public string FileName { get; set; } = "";
public long Size { get; set; }
public DateTime ModifiedTime { get; set; }
private ImageSource? _thumbnail;
public ImageSource? Thumbnail
{
get => _thumbnail;
set { _thumbnail = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Thumbnail))); }
}
public event PropertyChangedEventHandler? PropertyChanged;
public string SizeDisplay
{
get
{
if (Size < 1024) return $"{Size} B";
if (Size < 1024 * 1024) return $"{Size / 1024.0:0.0} KB";
return $"{Size / (1024.0 * 1024.0):0.0} MB";
}
}
public string DateDisplay => ModifiedTime.ToString("yyyy-MM-dd HH:mm");
}
}
+23
View File
@@ -0,0 +1,23 @@
# PS5 Upload Server - Makefile
# Uses the maintained ps5-payload-dev/sdk (2026 release)
PS5_HOST ?= 192.168.0.160
PS5_PORT ?= 9021
PS5_PAYLOAD_SDK := /opt/ps5-payload-sdk
CC := $(PS5_PAYLOAD_SDK)/bin/prospero-clang
CXX := $(PS5_PAYLOAD_SDK)/bin/prospero-clang++
LD := $(PS5_PAYLOAD_SDK)/bin/prospero-lld
ELF := ps5_upload_server.elf
CFLAGS := -Wall -O3
all: $(ELF)
$(ELF): main.c
$(CC) $(CFLAGS) -o $@ $^ -lkernel_sys -lSceSystemService -lSceUserService -lSceAppInstUtil
clean:
rm -f $(ELF)
.PHONY: all clean
+1332 -70
View File
File diff suppressed because it is too large. Load diff
+179
View File
@@ -0,0 +1,179 @@
# 🚀 PS5 Upload Suite v5.0.0 — Complete Management Suite
Complete all-in-one PS5 management platform — **104+ MB/s** file transfers, live hardware monitoring, full game/save/screenshot management, and deep PS5 integration through a single desktop client + a robust PS5 payload.
---
## 📦 Downloads
| File | Size | Purpose |
|------|------|---------|
| `PS5Upload.exe` | 147 MB | Windows GUI client (self-contained, no .NET install required) |
| `PS5UploadMobile.apk` | 30 MB | **NEW:** Android client with full feature parity (8+ tabs) |
| `ps5_upload_server.elf` | 187 KB | PS5 payload — load via elfldr, listens on port 9113 |
**Load the payload on your PS5:** copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/` and run it. You will see a notification `PS5 Upload Server: 192.168.0.XXX:9113 - By Manos` confirming it's ready.
**Install the Android app:** download `PS5UploadMobile.apk` and install it on your Android device (enable "Install from unknown sources").
---
## 🆕 What's New in v5.0.0
### 🖥️ NEW: Hardware Monitor Tab
- **System Info** — Model (CFI-xxxx), Serial Number, Architecture, OS version
- **Physical RAM detection** — 5-step fallback chain (PS5-specific API → sysctl chain → 16 GB default)
- **Live Sensors** — CPU temperature, SoC temperature, CPU frequency, SoC power consumption
- **Auto-refresh** — Configurable 5-second polling with automatic disconnect detection
- **Payload hardened** — `pthread_mutex_t` + 1s cache for sensors, 5s throttle for power API, rejection of abnormal readings
### 📷 NEW: Screenshots Tab
- **Visual Gallery** — Live thumbnail previews (200px downsampled for fast loading)
- **Smart Cache** — Local cache in `%TEMP%` keyed by remote path hash — zero re-downloads on refresh
- **Batch Download** — Multi-select and download multiple screenshots
- **Clean Delete** — Removes ALL 3 files per screenshot (`.dat`, `.meta`, `.jpg.jpeg`) — **no more ghost entries in PS5 Media Gallery**
- **Double-click Preview** — Opens in default image viewer
- **Full Path Scanning** — 5-level recursive scan of `/user/av_contents/thumbnails/photo/…`
### ▶️ NEW: Launch Game
- Right-click any mounted game → **Launch Game**
- Uses low-level `sceLncUtilLaunchApp` with fallback to `sceSystemServiceLaunchApp`
- **Works on pirated/sideloaded games** (bypasses `0x80940005` permission error)
- Triple-strategy launcher with detailed diagnostic error messages
### 💾 NEW: Save Manager Tab
- Per-game save browsing with metadata
- Game icons fetched and cached from PSN/param.sfo
- Batch download saves to local backup folder
- Upload saves back to PS5
- Size, date, and title ID display
### 🎮 NEW: Game Details Window
- **PSN Cover Art** fetched from PlayStation Store
- Full `param.json` metadata formatted nicely
- Online PSN info: description, genres, age rating, publisher
- Alternate title ID detection (regional variants)
- Browser search fallback if API fails
### 🔧 Improved Storage Reporting
- ✅ Matches **PS5 Settings "Console Storage"** formula exactly
- ✅ Total: `/user` effective + `/system_data` + `/system_ex`
- ✅ Free: proper `f_bavail` across all partitions
- ✅ Accounts for reserved space & per-partition blocksize
- Example real-world PS5: **848 GB total / 354 GB free** ✓
### 🎮 Improved Mount Games
- ✅ Correct `sceAppInstUtilAppInstallTitleDir(title_id, "/user/app/", 0)` signature
- ✅ Registration BEFORE `mount.lnk` (correct order)
- ✅ Direct in-payload registration — no external `game_mounter.elf` dependency
- ✅ Proper DRM patching & metadata copy
### 🛡️ Stability Overhaul
- ✅ **Thread-safe sensor reads** — `pthread_mutex_t` prevents concurrent API crashes
- ✅ **API call throttling** — SoC power read at most every 5s, sensor cache every 1s
- ✅ **Cached static HW info** — Read once, serve forever (Model, Serial, OS)
- ✅ **Unsafe APIs removed** — `sceKernelIccGet*`, `HwHasWlanBt`, `HwHasOpticalOut` (caused crashes)
- ✅ **Client busy-flag guard** — `Interlocked` prevents overlapping refreshes
- ✅ **Auto-stop timers** — Hardware auto-refresh stops on disconnect
- ✅ **Graceful reconnect** — Connection lost → automatic reconnection attempt
---
## 🐛 Bug Fixes
- **Launch failed with 0x80940005** — Fixed by using low-level `sceLncUtilLaunchApp` instead of `sceSystemServiceLaunchApp` for pirated/mounted games
- **Payload crashes on Hardware Tab refresh** — Fixed with mutex, caching, and removal of unsafe `sceKernelIccGet*` APIs
- **Connection freezes after auto-refresh enabled** — Fixed with busy-flag guard, auto-stop on disconnect, and longer polling interval
- **Ghost screenshots in PS5 Media Gallery after delete** — Fixed by deleting all 3 associated files (`.dat`, `.meta`, `.jpg.jpeg`)
- **Physical RAM showing "—"** — Fixed with 5-step detection chain including PS5-specific `sceKernelGetDirectMemorySize()`
- **Wi-Fi / Bluetooth showing "✗ None"** — Fixed (hardcoded to `true` since all PS5 models have it)
- **Screenshots showing 0 items** — Fixed by scanning correct path `/user/av_contents/thumbnails/photo/` (where viewable `.jpg.jpeg` files live)
- **Storage reporting mismatch with PS5 Settings** — Fixed with correct aggregation formula
---
## 🔧 Technical Details
### New Payload Commands
- `CMD_LAUNCH_GAME (0x44)` — Launch mounted games
- `CMD_LIST_SCREENSHOTS (0x45)` — Recursive scan of screenshot directories
- `CMD_DELETE_SCREENSHOT (0x46)` — Clean delete (original + thumbnail + metadata)
### New Sony Kernel APIs Used (verified safe)
- `sceKernelGetHwModelName` / `sceKernelGetHwSerialNumber`
- `sceKernelGetCpuTemperature` / `sceKernelGetSocSensorTemperature`
- `sceKernelGetCpuFrequency`
- `sceKernelGetDirectMemorySize`
- `sceKernelGetSocPowerConsumption` (throttled to 5s)
- `sceLncUtilLaunchApp` / `sceLncUtilInitialize`
- `sceUserServiceInitialize` / `sceUserServiceGetForegroundUser`
### Removed APIs (destabilize payload)
- `sceKernelIccGetPowerOperatingTime`
- `sceKernelIccGetPowerNumberOfBootShutdown`
- `sceKernelHwHasWlanBt` / `sceKernelHwHasOpticalOut`
- `sceShellCoreUtilGetEffectiveTotalSizeOfUserPartition` (privileges issue)
---
---
## 📱 NEW: Android Mobile Client — Full Feature Parity
The Android app (`PS5UploadMobile.apk`) now has **every feature** of the desktop client, organized via a **hamburger flyout menu (☰)** with a clean light theme for maximum readability:
### 7 Fully-Functional Tabs
- 📂 **Files** — Browse, upload, download, delete, rename, copy/move with drag-friendly UI
- 🎮 **Games** — Mounted games list with cover icons, **Launch Game**, Unmount, Game Details
- 💾 **Saves** — Per-game save browsing with icons, metadata, user IDs
- 📷 **Screenshots** — 2-column gallery with live thumbnails, batch download, clean delete
- 🖥️ **Hardware** — System Info + Live Sensors (CPU/SoC temps, freq, power) + Power Stats + auto-refresh
- 💻 **Shell** — Terminal-style remote command execution with auto-scroll
- 🎮 **Mount Games** — Dedicated page for multi-path scan with streaming progress log
### Mobile-Specific Features
- ✅ **Shared connection** — Connect once in Files tab, all other tabs use it automatically
- ✅ **Thumbnail caching** — Local cache in app cache directory, zero re-downloads
- ✅ **Pull-to-refresh** on games, saves, screenshots
- ✅ **Light flyout menu** — white background, dark text, blue accent on selected item
- ✅ **Dark content pages** — modern readable UI throughout
- ✅ **Interlocked guards** — prevents overlapping refreshes on Hardware tab
- ✅ **Auto-stop timers** — auto-refresh stops on disconnect
- ✅ **0 warnings, 0 errors** build
### Mobile Quick Start
1. Enable **"Install from unknown sources"** in Android settings
2. Download and install `PS5UploadMobile.apk`
3. Load the PS5 payload (`ps5_upload_server.elf`)
4. Open the app, enter your PS5 IP, tap **Connect**
5. Open the **☰ menu** to access all 7 tabs
---
## 🚀 Quick Start (Desktop)
1. **Load payload on PS5:** Copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/` and execute it
2. **Run client on PC:** Launch `PS5Upload.exe` on Windows
3. **Connect:** Enter your PS5 IP (e.g. `192.168.0.160`) and click Connect
4. **Enjoy:** 8 tabs of full PS5 management — uploads, games, saves, screenshots, live hardware monitoring, and more!
---
## 📋 Full Feature List
See the [README](https://github.com/manos555555/PS5-Upload-Suite/blob/main/README.md) for the complete feature matrix.
---
## 🙏 Credits
- PS5 SDK by John Törnblom
- etaHEN team for the kernel runtime
- PS5 homebrew community
- Inspired by `ps5upload` by PhantomPtr
---
**Tested on:** PS5 Slim (CFI-2016 A01Y), firmware with etaHEN — 16 GB RAM, 16 CPU cores, ~848 GB storage
🎮 **Enjoy the complete PS5 management experience!**
Binary file not shown.