mirror of
https://github.com/manos555555/PS5-Suite.git
synced 2026-10-06 17:00:32 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3526f4874b | ||
|
|
0be4325bc5 | ||
|
|
343a5a531e | ||
|
|
fe741b8e9f | ||
|
|
0123a55f52 | ||
|
|
eb6003c8d4 | ||
|
|
5022381eeb | ||
|
|
8cdd392576 | ||
|
|
a837ea9fe1 | ||
|
|
ac7cea2e4b | ||
|
|
abaa028d28 | ||
|
|
45afca892c | ||
|
|
f065b79b33 | ||
|
|
4403d86be5 | ||
|
|
af252efc9a | ||
|
|
2da27aed13 | ||
|
|
264ad6210d | ||
|
|
db48bc6fb2 | ||
|
|
4ac43ebb8b | ||
|
|
20caced5f4 | ||
|
|
285279788f | ||
|
|
70d174cecc | ||
|
|
f128c6a6b4 | ||
|
|
c211c086ac | ||
|
|
4ca30876c7 | ||
|
|
5ae3c1709e | ||
|
|
3374e87bfe |
No files matched your search
+2
-59
@@ -1,60 +1,3 @@
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio cache/options
|
||||
.vs/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Build results
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.cache
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Files built by Visual Studio
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Caches
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
*.bak
|
||||
*.old
|
||||
|
||||
# PowerShell script
|
||||
convert_to_ico.ps1
|
||||
# Release executables (too large for git, uploaded as release assets)
|
||||
release/**/PS5Upload.exe
|
||||
+385
@@ -1,5 +1,390 @@
|
||||
# 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
|
||||
- **Mount Games Button** - New green "🎮 Mount Games" button in the client UI
|
||||
- One-click mounting of all uploaded games directly from the PS5 Upload Suite
|
||||
- Button disabled when disconnected, shows "⏳ Mounting..." during operation
|
||||
|
||||
### 🔍 Multi-Path Game Scanning
|
||||
- Scans **6 locations** for games:
|
||||
- `/data/etaHEN/games` (internal storage)
|
||||
- `/mnt/usb0/games` through `/mnt/usb3/games` (USB drives)
|
||||
- `/mnt/ext0/games` (M.2 SSD)
|
||||
- First pass counts total games, second pass processes them
|
||||
- PS5 notifications show real-time progress: "Mounting 3/10 (30%) - Game Name"
|
||||
|
||||
### 🔄 Smart Mount Features
|
||||
- **Duplicate Detection** - Same title ID across multiple drives mounted only once
|
||||
- **Auto-Cleanup** - `auto_unmount_deleted_games()` removes stale mount entries
|
||||
- **Already Mounted Detection** - Skips games that are already mounted via nullfs
|
||||
- **sce_sys Validation** - Verifies game directory has valid `sce_sys/` before mounting
|
||||
|
||||
### 🔧 Payload Changes
|
||||
- **New Command** - `CMD_MOUNT_GAMES` (0x30) added to protocol
|
||||
- **nullfs Mount** - Games mounted to `/system_ex/app/<title_id>`
|
||||
- **DRM Patching** - Automatic `applicationDrmType` fix in `param.json`
|
||||
- **Metadata Copy** - `sce_sys` copied to `/user/app/` and `/user/appmeta/`
|
||||
- **mount.lnk** - Tracks mount source path for cleanup and remount
|
||||
- **Safe Delete** - Replaced `system("rm -rf ...")` with `rmdir_recursive()` to prevent hangs
|
||||
- **remount_system_ex()** - Remounts `/system_ex` as writable for game installation
|
||||
|
||||
### 🖥️ Client Changes
|
||||
- **Protocol.cs** - Added `MountGames = 0x30` enum and `MountGamesAsync()` method
|
||||
- **MainWindow.xaml** - Green "🎮 Mount Games" button between Upload and Cancel
|
||||
- **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
|
||||
- The upload suite payload handles nullfs mounting, metadata copy, and DRM patching — everything except the final system registration
|
||||
|
||||
---
|
||||
|
||||
## Version 4.1.0 - Memory Optimization & Large File Handling (February 5, 2026)
|
||||
|
||||
### 🧠 Memory & Performance Improvements
|
||||
|
||||
#### 1. Large File Count Support (156K+ Files)
|
||||
- **Fixed crash** with games containing 156,253+ files (e.g., Astrobot)
|
||||
- **Removed massive dictionary** - No longer creates 156K FileInfo objects at once
|
||||
- **Incremental calculation** - Total bytes calculated progressively during scan
|
||||
- **Memory efficient** - Handles unlimited file counts without OutOfMemoryException
|
||||
|
||||
#### 2. Lock-Free Progress Tracking
|
||||
- **ConcurrentDictionary** - Replaced Dictionary with thread-safe collections
|
||||
- **Zero lock contention** - Eliminated `lock (_progressLock)` blocks
|
||||
- **Atomic operations** - Uses `Interlocked.Add()` for progress updates
|
||||
- **Smooth UI** - No stuttering from lock contention
|
||||
|
||||
#### 3. Auto Memory Cleanup
|
||||
- **Completed files removed** - Dictionaries cleaned after file completion
|
||||
- **Reduced memory footprint** - Only active uploads tracked
|
||||
- **Prevents memory overflow** - Long-running uploads stay stable
|
||||
|
||||
#### 4. Reduced UI Stuttering
|
||||
- **Batch logging** - Small files logged every 50 completions (up from 25)
|
||||
- **Less UI pressure** - Fewer `Dispatcher.Invoke()` calls
|
||||
- **Smoother experience** - Especially noticeable with 100K+ files
|
||||
|
||||
### 🔧 Technical Changes
|
||||
- Changed `_fileProgressBytes` from Dictionary to ConcurrentDictionary
|
||||
- Changed `_fileChunkProgressBytes` from Dictionary to ConcurrentDictionary
|
||||
- Changed `_chunkLogLastBytes` from Dictionary to ConcurrentDictionary
|
||||
- Removed `fileSizeLookup` dictionary creation (caused OutOfMemoryException)
|
||||
- Increased `SmallFileLogBatchSize` from 25 to 50
|
||||
- Added dictionary cleanup in file completion handlers
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed application crash when uploading games with 156K+ files
|
||||
- Fixed UI stuttering during small file uploads
|
||||
- Fixed memory overflow from massive dictionary allocations
|
||||
- Fixed lock contention causing UI freezes
|
||||
|
||||
### 📊 Tested With
|
||||
- **Astrobot**: 156,253 files, 1,724 directories - **53 minutes total**
|
||||
- **Result**: Zero crashes, smooth upload, stable memory usage
|
||||
|
||||
---
|
||||
|
||||
## Version 4.0.0 - Retry Feature & Stability Improvements (January 27, 2026)
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
#### 🔄 Retry Failed Transfers
|
||||
- **Right-click context menu** on failed transfers
|
||||
- **Retry Upload** - Re-queue failed files for upload
|
||||
- **Remove from List** - Clear individual failed items
|
||||
- **Persistent paths** - Failed transfers store local and remote paths
|
||||
- **Smart retry** - Validates file existence before retry
|
||||
|
||||
#### 🗑️ Clear All Button Fix
|
||||
- **Transfer History Clear All** now works correctly
|
||||
- **Confirmation dialog** before clearing history
|
||||
- **Clears both** completed and failed transfers
|
||||
|
||||
### 🔧 Performance & Stability
|
||||
|
||||
#### Connection Stability
|
||||
- **Reverted 16MB buffer** - Caused connection drops with 16 parallel connections
|
||||
- **Stable 8MB buffer** - Proven reliable for high-speed transfers
|
||||
- **TCP NoDelay** - Already enabled for optimal performance
|
||||
- **16MB socket buffers** - Maintained for maximum throughput
|
||||
|
||||
#### Upload Performance
|
||||
- **Large files (>100MB)**: 30-36 MB/s per file
|
||||
- **Small files (<100MB)**: 8-12 MB/s per file
|
||||
- **4 parallel large files** - Prevents PS5 memory exhaustion
|
||||
- **16 total connections** - Optimal for mixed workloads
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed Clear All button not clearing transfer history
|
||||
- Fixed connection drops when using oversized buffers
|
||||
- Added proper event handlers for failed transfer context menu
|
||||
- Improved error handling for retry operations
|
||||
|
||||
### 📝 Code Quality
|
||||
- Added comprehensive inline documentation
|
||||
- Improved code organization and readability
|
||||
- Better error messages for failed operations
|
||||
- Enhanced UI feedback for user actions
|
||||
|
||||
---
|
||||
|
||||
## Version 3.3.0 - Smart Search & Enhanced Mobile (January 26, 2026)
|
||||
|
||||
### 🔍 Smart Search Feature
|
||||
- **Full Filesystem Indexing** - Index entire PS5 filesystem (200,000+ files)
|
||||
- **Instant Search** - Lightning-fast search with wildcards (`*.pkg`, `*loader*`)
|
||||
- **Size Filters** - Search by file size (`size:>1GB`, `size:<100MB`)
|
||||
- **Case-Insensitive** - Matches both filename and full path
|
||||
- **Navigate from Search** - Double-click results to jump to folder (Desktop) / Tap to navigate (Mobile)
|
||||
- **Index Status** - Real-time indexing progress with file/directory counts
|
||||
- **Smart Indexing** - Skips problematic directories (`/dev`, `/proc`, `/sys`)
|
||||
|
||||
### 📱 Mobile App v1.2
|
||||
- **🔍 Search Tab** - Full Smart Search functionality on Android
|
||||
- **Navigate to Location** - Tap search results to navigate to folder
|
||||
- **Copy Path** - Copy file paths to clipboard from search results
|
||||
- **Connection Manager** - Singleton pattern for reliable IP/port management
|
||||
- **Improved Navigation** - Fixed MainPage reference for seamless navigation
|
||||
|
||||
### 🖥️ Desktop Client Updates
|
||||
- **Search Tab** - New dedicated tab for Smart Search
|
||||
- **💻 Shell Terminal** - Execute commands directly on PS5
|
||||
- Run system commands remotely
|
||||
- Real-time output display
|
||||
- Command history
|
||||
- Working directory support
|
||||
- **Equal Panel Layout** - Local Files, PS5 Files, and Right panel now equal width
|
||||
- **Improved UI** - Index Status and buttons positioned above search box
|
||||
- **Debounced Search** - Smooth typing experience with 300ms debounce
|
||||
|
||||
### 🔧 Payload Updates
|
||||
- **Indexing Commands** - `CMD_INDEX_START` (0x40), `CMD_INDEX_STATUS` (0x41), `CMD_SEARCH_INDEX` (0x42)
|
||||
- **In-Memory Index** - Linked list structure for fast search
|
||||
- **Wildcard Matching** - Support for `*` and `?` wildcards
|
||||
- **Thread-Safe** - Mutex-protected index operations
|
||||
- **Root Path Support** - Can index from `/` (entire filesystem)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed indexing hangs when scanning root directory
|
||||
- Fixed search protocol compatibility between mobile and desktop
|
||||
- Fixed navigation stack issues in mobile app
|
||||
- Fixed nullable reference warnings in mobile code
|
||||
- Added proper error handling for index operations
|
||||
|
||||
---
|
||||
|
||||
## Version 3.2.1 - Privacy & Bug Fixes (January 24, 2026)
|
||||
|
||||
### 🔒 Privacy Improvements
|
||||
- **Removed disk space display** - No longer shows PS5 storage capacity (Free/Used/Total)
|
||||
- **Fixed storage query bug** - Resolved syntax errors in statvfs code that could cause crashes
|
||||
- **Privacy-focused** - Application no longer queries or displays sensitive storage information
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed potential crash from malformed storage query code
|
||||
- Removed unused protocol busy flag that caused compiler warnings
|
||||
- Cleaned up all storage-related UI elements and backend code
|
||||
|
||||
---
|
||||
|
||||
## Version 3.2.0 - Mobile Improvements & Bug Fixes (January 24, 2026)
|
||||
|
||||
### 🎯 Improvements
|
||||
|
||||
#### 💾 Accurate Storage Display
|
||||
- **Storage now matches PS5 UI** - Shows ~848 GB instead of raw 872 GB
|
||||
- **Accounts for reserved space** - Same calculation as PS5 system
|
||||
- Uses proper calculation: `(total_blocks - reserved) * block_size`
|
||||
|
||||
#### 📱 Mobile App Improvements
|
||||
- **Multi-select mode** - Toggle button (☑️/✅) to select multiple files/folders
|
||||
- **Folder browser** - Visual folder picker for Copy/Move destinations with recursive navigation
|
||||
- **Sorted file list** - Folders always displayed at top, then files (alphabetically)
|
||||
- **Exit confirmation** - Prompt dialog when pressing back button
|
||||
- **Delete folders** - Now supports folder deletion (not just files)
|
||||
- **Batch operations** - Delete/download multiple items at once
|
||||
|
||||
#### 3. 🐛 Bug Fixes
|
||||
- **Fixed empty folder deletion** - No more "Unexpected response" error when deleting empty directories
|
||||
- **Fixed Copy/Move crash** - No longer crashes when selecting destination folder (saved file info before browsing)
|
||||
- **Fixed multi-select count** - Correct item count in delete confirmation dialog
|
||||
- **Fixed folder navigation** - Can enter folders after actions complete (auto-exit multi-select mode)
|
||||
|
||||
---
|
||||
|
||||
## Version 3.1.0 - Parallel Chunked Uploads (January 2026)
|
||||
|
||||
### 🚀 Performance Improvements
|
||||
|
||||
#### 1. ⚡ Parallel Chunked Uploads (104 MB/s!)
|
||||
- **Large files (>100MB)** are now split into 500MB chunks
|
||||
- **4 parallel connections** upload chunks simultaneously
|
||||
- **Result:** ~104 MB/s upload speed (2x faster than before!)
|
||||
- **Fixed race condition:** First chunk creates file before others start
|
||||
- **Fixed closure bug:** Correct offset/size capture for parallel tasks
|
||||
|
||||
#### 2. 📊 Real-time Progress Display
|
||||
- **Speed display** now updates in real-time during chunked uploads
|
||||
- **ETA display** shows accurate time remaining
|
||||
- **Progress bar** correctly shows 0-100% for chunked files
|
||||
- No more speed dropping to 0 during large file uploads
|
||||
|
||||
#### 3. 💾 Storage Display Improvements
|
||||
- **Changed label** to "Data Storage (/data)" for clarity
|
||||
- **Uses statfs with f_bavail** for more accurate available space
|
||||
- **Real-time updates** every 5 seconds when connected
|
||||
|
||||
---
|
||||
|
||||
## Version 3.0.0 - Mobile Client & Path Fixes (January 2026)
|
||||
|
||||
### 🐛 Critical Bug Fixes
|
||||
|
||||
#### 1. 🔧 Path Normalization Fix
|
||||
- **Fixed:** Double-slash paths (`//mnt/ext1/...`) that caused directory creation to fail
|
||||
- **Affected:** All file operations (upload, download, delete, rename, copy, move)
|
||||
- **Solution:** Added `NormalizePath()` function in both client and server
|
||||
- **Result:** 100% reliable directory creation and file uploads
|
||||
|
||||
#### 2. 📱 Android Mobile Client
|
||||
- **NEW:** Full-featured Android app for PS5 file management
|
||||
- **Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
|
||||
- Upload files from phone to PS5
|
||||
- Download files from PS5 with Share option
|
||||
- Browse PS5 filesystem
|
||||
- Create new folders
|
||||
- Rename files/folders
|
||||
- Copy files/folders
|
||||
- Move files/folders
|
||||
- Delete files
|
||||
- Favorites for quick navigation
|
||||
- Debug Log with Copy to clipboard
|
||||
- Transfer History tracking
|
||||
|
||||
#### 3. 🛠️ Server Improvements
|
||||
- Path normalization in all handler functions
|
||||
- More robust error handling
|
||||
- Improved stability for parallel uploads
|
||||
|
||||
---
|
||||
|
||||
## Version 2.1.0 - Performance & History (January 2026)
|
||||
|
||||
### 🚀 Performance Optimizations
|
||||
|
||||
#### 1. ⚡ Massive Upload Speed Boost (88-110 MB/s)
|
||||
- **Server-side:** Replaced `fwrite()` with direct `write()` syscalls
|
||||
- **Client-side:** 8 parallel large file uploads (optimal for PS5 disk)
|
||||
- **Result:** 80-110 MB/s aggregate upload speed on Gigabit Ethernet
|
||||
- **Peak bursts:** Up to 2.05 GB/s when hitting disk cache
|
||||
- **Per-file:** 11-14 MB/s sustained per connection
|
||||
|
||||
#### 2. 📊 Transfer History
|
||||
- Complete history of all uploads and downloads
|
||||
- Success/Failed status tracking with error messages
|
||||
- Speed statistics (average, min, max)
|
||||
- Export to CSV/JSON for analysis
|
||||
- Persistent storage across sessions
|
||||
|
||||
#### 3. 🔄 Auto-Clear History on Startup
|
||||
- Optional checkbox to clear history automatically
|
||||
- Useful for keeping UI clean between sessions
|
||||
- Setting saved in `ps5_upload_settings.json`
|
||||
|
||||
#### 4. 🖥️ Maximized Window UI
|
||||
- Application opens in full-screen mode by default
|
||||
- Better visibility for large file transfers
|
||||
- Can be resized/restored as needed
|
||||
|
||||
### Technical Improvements
|
||||
- ✅ **16MB socket buffers** (up from 4MB) for maximum throughput
|
||||
- ✅ **Per-file mutex locking** - Parallel writes without race conditions
|
||||
- ✅ **File pre-allocation** - Reduces disk fragmentation for large files
|
||||
- ✅ **Direct syscalls** - Bypasses stdio buffering overhead
|
||||
|
||||
---
|
||||
|
||||
## Version 2.0.0 - Download & Multi-PS5 (January 2026)
|
||||
|
||||
### 🎉 4 Major New Features
|
||||
|
||||
#### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination selection
|
||||
- Real-time progress tracking with speed display
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
#### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button to reset
|
||||
|
||||
#### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent storage in JSON
|
||||
|
||||
#### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between different PS5 consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
---
|
||||
|
||||
## Version 1.3.0 - Stable Release (January 13, 2026)
|
||||
|
||||
### 🎯 Major Achievement
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# PS5 Upload Suite v2.0.0 - Feature-Rich Release
|
||||
|
||||
## 🎉 What's New - 4 Major Features Added!
|
||||
|
||||
### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination
|
||||
- Real-time progress with speed tracking
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button
|
||||
|
||||
### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent JSON storage
|
||||
|
||||
### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Server (PS5)
|
||||
1. Copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/`
|
||||
2. Load with elfldr
|
||||
3. Server starts on port 9113
|
||||
|
||||
### Client (Windows)
|
||||
1. Download `PS5Upload.exe`
|
||||
2. Run on Windows 10/11
|
||||
3. Enter PS5 IP and connect
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Performance
|
||||
|
||||
- **Upload Speed:** 100-120 MB/s (Gigabit Ethernet)
|
||||
- **Download Speed:** Same high-speed protocol
|
||||
- **Stability:** Zero connection drops (from v1.3.0)
|
||||
- **Tested:** 42,801+ files uploaded successfully
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### New Protocol Command
|
||||
- `CMD_DOWNLOAD_FILE` (0x13) - Download files from PS5
|
||||
|
||||
### Server Changes
|
||||
- Added sendfile-based download handler
|
||||
- Fixed LIST_DIR to return actual file sizes
|
||||
- Zero-copy file transfer
|
||||
|
||||
### Client Changes
|
||||
- 4 new UI panels (Search, Favorites, Profiles, Download)
|
||||
- JSON-based persistence (ps5_profiles.json, ps5_favorites.json)
|
||||
- Custom dark theme for all ComboBox dropdowns
|
||||
- ~500+ lines of new code
|
||||
|
||||
---
|
||||
|
||||
## ⬆️ Upgrading from v1.3.0
|
||||
|
||||
1. Replace server: Copy new `ps5_upload_server.elf` to PS5
|
||||
2. Replace client: Download new `PS5Upload.exe`
|
||||
3. Your settings will be preserved
|
||||
|
||||
**New files created:**
|
||||
- `ps5_profiles.json` - Saved PS5 profiles
|
||||
- `ps5_favorites.json` - Saved favorite paths
|
||||
|
||||
---
|
||||
|
||||
## 📋 Files in This Release
|
||||
|
||||
- `ps5_upload_server.elf` (129 KB) - PS5 server payload
|
||||
- `PS5Upload.exe` (8.2 MB) - Windows client (self-contained)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
**By Manos**
|
||||
|
||||
Special thanks to the PS5 homebrew community!
|
||||
|
||||
---
|
||||
|
||||
**Enjoy the new features!** 🚀
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
# PS5 Upload Protocol Specification
|
||||
|
||||
## Overview
|
||||
Custom binary protocol for high-speed file transfers between PC and PS5.
|
||||
Port: 9113
|
||||
|
||||
## Message Format
|
||||
|
||||
All messages follow this structure:
|
||||
```
|
||||
[Command (1 byte)][Data Length (4 bytes)][Data (variable)]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Client → Server
|
||||
|
||||
| Command | Value | Description | Data Format |
|
||||
|---------|-------|-------------|-------------|
|
||||
| PING | 0x01 | Test connection | None |
|
||||
| LIST_STORAGE | 0x02 | Get storage info | None |
|
||||
| LIST_DIR | 0x03 | List directory | Path (null-terminated string) |
|
||||
| CREATE_DIR | 0x04 | Create directory | Path (null-terminated string) |
|
||||
| DELETE_FILE | 0x05 | Delete file | Path (null-terminated string) |
|
||||
| DELETE_DIR | 0x06 | Delete directory | Path (null-terminated string) |
|
||||
| START_UPLOAD | 0x10 | Start file upload | Path (null-terminated) + File size (8 bytes) |
|
||||
| UPLOAD_CHUNK | 0x11 | Upload file chunk | Chunk data (raw bytes) |
|
||||
| END_UPLOAD | 0x12 | Finish upload | None |
|
||||
| SHUTDOWN | 0xFF | Shutdown server | None |
|
||||
|
||||
### Server → Client
|
||||
|
||||
| Response | Value | Description | Data Format |
|
||||
|----------|-------|-------------|-------------|
|
||||
| OK | 0x01 | Success | Optional message |
|
||||
| ERROR | 0x02 | Error occurred | Error message (null-terminated) |
|
||||
| DATA | 0x03 | Response data | Variable format |
|
||||
| READY | 0x04 | Ready for next chunk | None |
|
||||
| PROGRESS | 0x05 | Upload progress | Bytes written (8 bytes) |
|
||||
|
||||
## Data Formats
|
||||
|
||||
### LIST_STORAGE Response
|
||||
```
|
||||
[Drive count (4 bytes)]
|
||||
For each drive:
|
||||
[Path length (2 bytes)][Path][Total space (8 bytes)][Free space (8 bytes)]
|
||||
```
|
||||
|
||||
### LIST_DIR Response
|
||||
```
|
||||
[Entry count (4 bytes)]
|
||||
For each entry:
|
||||
[Type (1 byte: 0=file, 1=dir)][Name length (2 bytes)][Name][Size (8 bytes)][Timestamp (8 bytes)]
|
||||
```
|
||||
|
||||
## Flow Examples
|
||||
|
||||
### Upload File
|
||||
```
|
||||
Client: START_UPLOAD "/data/test.bin" size=1048576
|
||||
Server: READY
|
||||
Client: UPLOAD_CHUNK [4MB data]
|
||||
Server: PROGRESS bytes=4194304
|
||||
Client: UPLOAD_CHUNK [remaining data]
|
||||
Server: PROGRESS bytes=1048576
|
||||
Client: END_UPLOAD
|
||||
Server: OK
|
||||
```
|
||||
|
||||
### List Directory
|
||||
```
|
||||
Client: LIST_DIR "/data"
|
||||
Server: DATA [directory listing]
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- 4MB socket buffers
|
||||
- SO_NOSIGPIPE enabled
|
||||
- TCP_NODELAY for low latency
|
||||
- Direct disk writes (no temp files)
|
||||
- Streaming chunks (no full file buffering)
|
||||
Binary file not shown.
@@ -1,42 +1,133 @@
|
||||
# 🚀 PS5 Upload Suite - High-Speed File Transfer
|
||||
# 🚀 PS5 Upload Suite - Complete PS5 Management Platform
|
||||
|
||||
**By Manos**
|
||||
|
||||
**Version 2.0.0 - Feature-Rich Release**
|
||||
**Version 5.0.0 - Complete Management Suite**
|
||||
|
||||
Custom high-speed file transfer system for PS5 with etaHEN. Achieves **100+ MB/s** transfer speeds using a custom binary protocol.
|
||||
|
||||
⭐ **NEW in v2.0.0:** 4 Major Features Added - Download Files, File Search, Favorites/Bookmarks, Multi-PS5 Support!
|
||||
|
||||

|
||||

|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 📦 What's Included
|
||||
⭐ **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
|
||||
- 📊 **Mount Summary** - Detailed results with PS5 notifications
|
||||
- 🧹 **Auto-Cleanup** - Removes mount entries for deleted games
|
||||
|
||||
📱 **Android Mobile Client available!**
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📦 Downloads
|
||||
|
||||
Download the latest release from the [Releases](https://github.com/manos555555/PS5-Upload-Suite/releases) page:
|
||||
|
||||
### 1. PS5 Server Payload
|
||||
- **File:** `payload/ps5_upload_server.elf`
|
||||
- **File:** `ps5_upload_server.elf`
|
||||
- **Port:** 9113
|
||||
- **Protocol:** Custom binary (optimized for speed)
|
||||
- **Features:**
|
||||
- 4MB socket buffers
|
||||
- SO_NOSIGPIPE enabled
|
||||
- TCP_NODELAY for low latency
|
||||
- Direct disk writes (no temp files)
|
||||
- Multi-threaded client handling
|
||||
- High-speed custom binary protocol
|
||||
- 16MB socket buffers for maximum throughput
|
||||
- Multi-threaded client handling
|
||||
|
||||
### 2. Windows GUI Client
|
||||
- **File:** `client/bin/Release/net6.0-windows/win-x64/publish/PS5Upload.exe`
|
||||
- **Framework:** .NET 6.0 WPF
|
||||
- **Features:**
|
||||
- Modern dark theme UI
|
||||
- Drag & drop file/folder upload
|
||||
- Browse PS5 filesystem
|
||||
- Real-time upload progress
|
||||
- Speed indicator (MB/s)
|
||||
- Storage space display
|
||||
- Recursive folder upload
|
||||
- **File:** `PS5Upload.exe`
|
||||
- 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 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`
|
||||
- Upload/download files from your phone
|
||||
- Multi-PS5 Profiles support
|
||||
- Browse PS5 filesystem
|
||||
- Favorites for quick navigation
|
||||
|
||||
---
|
||||
|
||||
@@ -44,17 +135,13 @@ Custom high-speed file transfer system for PS5 with etaHEN. Achieves **100+ MB/s
|
||||
|
||||
### Step 1: Load PS5 Payload
|
||||
|
||||
1. Copy `payload/ps5_upload_server.elf` to your PS5:
|
||||
```
|
||||
/data/etaHEN/payloads/ps5_upload_server.elf
|
||||
```
|
||||
1. Download `ps5_upload_server.elf` from the [Releases](https://github.com/manos555555/PS5-Upload-Suite/releases) page
|
||||
|
||||
2. Load the payload with elfldr
|
||||
2. Copy it to your PS5: `/data/etaHEN/payloads/ps5_upload_server.elf`
|
||||
|
||||
3. You should see notification:
|
||||
```
|
||||
PS5 Upload Server: 192.168.0.XXX:9113 - By Manos
|
||||
```
|
||||
3. Load the payload with elfldr
|
||||
|
||||
4. You should see notification: `PS5 Upload Server: 192.168.0.XXX:9113 - By Manos`
|
||||
|
||||
### Step 2: Run Windows Client
|
||||
|
||||
@@ -72,94 +159,126 @@ Custom high-speed file transfer system for PS5 with etaHEN. Achieves **100+ MB/s
|
||||
|
||||
## 🎨 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, clean design
|
||||
✅ **Modern UI** - Dark theme, maximized window, 8+ functional tabs
|
||||
✅ **Drag & Drop** - Files and folders
|
||||
✅ **Browse PS5** - Navigate filesystem
|
||||
✅ **Real-time Progress** - Speed & percentage
|
||||
✅ **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
|
||||
✅ **Retry Failed Transfers** - Right-click to retry failed uploads
|
||||
✅ **Duplicate File Dialog** - Handle file conflicts during upload
|
||||
✅ **Folder Upload** - Recursive directory upload
|
||||
✅ **Storage Info** - Free space display
|
||||
✅ **Download Files** - Download from PS5 to PC (NEW v2.0)
|
||||
✅ **File Search** - Real-time filtering of PS5 files (NEW v2.0)
|
||||
✅ **Favorites/Bookmarks** - Quick navigation to saved paths (NEW v2.0)
|
||||
✅ **Multi-PS5 Support** - Save and switch between multiple PS5 profiles (NEW v2.0)
|
||||
✅ **Download Files** - Download from PS5 to PC
|
||||
✅ **💾 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
|
||||
- Run system commands remotely
|
||||
- Real-time output display
|
||||
- Command history
|
||||
- Working directory support
|
||||
|
||||
✅ **⭐ 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
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
### v1.3.0 Tested Results:
|
||||
- ✅ **42,801 files** uploaded successfully
|
||||
- ✅ **Zero errors** - 100% success rate
|
||||
- ✅ **60-150 MB/s** sustained throughput
|
||||
- ✅ **Peak:** 190 MB/s per connection
|
||||
### v3.1.0 Optimized Results:
|
||||
- ✅ **104+ MB/s** upload speed for large files (parallel chunked uploads)
|
||||
- ✅ **4 parallel chunk connections** for maximum throughput
|
||||
- ✅ **500MB chunks** - optimal size for PS5 disk performance
|
||||
- ✅ **Real-time progress** - Speed/ETA updates during upload
|
||||
- ✅ **Zero chunk failures** - Fixed race conditions
|
||||
- ✅ **Fully responsive UI** throughout upload
|
||||
- ✅ **No memory leaks** - stable operation
|
||||
|
||||
| Network | Expected Speed |
|
||||
|---------|----------------|
|
||||
| **Gigabit Ethernet** | 100-120 MB/s |
|
||||
| **WiFi 6 (5GHz)** | 60-80 MB/s |
|
||||
| **WiFi 5 (5GHz)** | 40-60 MB/s |
|
||||
### v4.1.0 Real-World Test (Astrobot):
|
||||
- ✅ **156,253 files** uploaded successfully in **53 minutes**
|
||||
- ✅ **1,724 directories** created
|
||||
- ✅ **Zero crashes** - stable memory usage throughout
|
||||
- ✅ **Smooth UI** - no stuttering with massive file counts
|
||||
|
||||
**Note:** Speeds depend on network quality and PS5 disk performance.
|
||||
### Previous Results (v1.3.0):
|
||||
- ✅ **42,801 files** uploaded successfully
|
||||
- ✅ **60-150 MB/s** sustained throughput
|
||||
|
||||
---
|
||||
| Network | Expected Upload Speed | Expected Download Speed |
|
||||
|---------|----------------------|------------------------|
|
||||
| **Gigabit Ethernet** | 88-110 MB/s (aggregate) | 100-120 MB/s |
|
||||
| **WiFi 6 (5GHz)** | 32-70 MB/s (aggregate) | 60-80 MB/s |
|
||||
| **WiFi 5 (5GHz)** | 20-50 MB/s (aggregate) | 40-60 MB/s |
|
||||
|
||||
## 🔧 Building from Source
|
||||
**Note:** Upload speeds depend on network quality and PS5 disk write performance. WiFi has ~40-60% overhead compared to Ethernet.
|
||||
|
||||
### PS5 Payload
|
||||
|
||||
Requirements:
|
||||
- PS5 SDK (prospero-clang)
|
||||
- WSL or Linux
|
||||
|
||||
```bash
|
||||
cd payload
|
||||
bash compile.sh
|
||||
```
|
||||
|
||||
### Windows Client
|
||||
|
||||
Requirements:
|
||||
- .NET 6.0 SDK or later
|
||||
- Windows 10/11
|
||||
|
||||
```cmd
|
||||
cd client
|
||||
build.bat
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```cmd
|
||||
dotnet publish -c Release -r win-x64 --self-contained true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Protocol Specification
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for detailed protocol documentation.
|
||||
|
||||
### Commands
|
||||
- `PING` - Test connection
|
||||
- `LIST_STORAGE` - Get storage info
|
||||
- `LIST_DIR` - List directory contents
|
||||
- `CREATE_DIR` - Create directory
|
||||
- `DELETE_FILE` - Delete file
|
||||
- `DELETE_DIR` - Delete directory
|
||||
- `START_UPLOAD` - Begin file upload
|
||||
- `UPLOAD_CHUNK` - Upload file chunk
|
||||
- `END_UPLOAD` - Finish upload
|
||||
- `DOWNLOAD_FILE` - Download file from PS5 (NEW v2.0)
|
||||
- `SHUTDOWN` - Shutdown server
|
||||
|
||||
---
|
||||
|
||||
@@ -191,6 +310,152 @@ See [PROTOCOL.md](PROTOCOL.md) for detailed protocol documentation.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v4.2.0
|
||||
|
||||
### 🎮 Game Mounter Integration
|
||||
- **Mount Games Button** - New green "🎮 Mount Games" button in the client UI
|
||||
- **Multi-Path Scanning** - Scans 6 locations: `/data/etaHEN/games`, USB drives (0-3), M.2 SSD (`/mnt/ext0/games`)
|
||||
- **Duplicate Detection** - If the same game exists on multiple drives, it's mounted only once
|
||||
- **Auto-Cleanup** - Automatically unmounts entries for games that have been deleted
|
||||
- **PS5 Notifications** - Real-time progress notifications on PS5 screen during mounting
|
||||
- **Detailed Summary** - Shows new mounts, already mounted, duplicates skipped, and failures
|
||||
- **Auto Registration** - Automatically runs `game_mounter.elf` for home screen registration when new games are detected
|
||||
|
||||
### 🔧 Payload Improvements
|
||||
- **New Protocol Command** - `CMD_MOUNT_GAMES` (0x30) for triggering game mount from client
|
||||
- **nullfs Mounting** - Games mounted via nullfs to `/system_ex/app/`
|
||||
- **DRM Patching** - Automatic DRM type fix in param.json
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.2.0
|
||||
|
||||
### 🎯 Improvements:
|
||||
|
||||
#### 1. 💾 Accurate Storage Display
|
||||
- **Storage now matches PS5 UI** - Shows ~848 GB instead of raw 872 GB
|
||||
- **Accounts for reserved space** - Same calculation as PS5 system
|
||||
|
||||
#### 2. 📱 Mobile App Improvements
|
||||
- **Multi-select mode** - Toggle button to select multiple files/folders
|
||||
- **Folder browser** - Visual folder picker for Copy/Move destinations
|
||||
- **Sorted file list** - Folders always displayed at top
|
||||
- **Exit confirmation** - Prompt when pressing back button
|
||||
- **Delete folders** - Now supports folder deletion (not just files)
|
||||
- **Batch operations** - Delete/download multiple items at once
|
||||
|
||||
#### 3. 🐛 Bug Fixes
|
||||
- **Fixed empty folder deletion** - No more "Unexpected response" error
|
||||
- **Fixed Copy/Move crash** - No longer crashes when selecting destination
|
||||
- **Fixed multi-select count** - Correct item count in delete confirmation
|
||||
- **Fixed folder navigation** - Can enter folders after actions complete
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.1.0
|
||||
|
||||
### 🚀 Performance Improvements:
|
||||
|
||||
#### 1. ⚡ Parallel Chunked Uploads (104 MB/s!)
|
||||
- **Large files (>100MB)** are now split into 500MB chunks
|
||||
- **4 parallel connections** upload chunks simultaneously
|
||||
- **Result:** ~104 MB/s upload speed (2x faster than before!)
|
||||
- **Fixed race condition:** First chunk creates file before others start
|
||||
- **Fixed closure bug:** Correct offset/size capture for parallel tasks
|
||||
|
||||
#### 2. 📊 Real-time Progress Display
|
||||
- **Speed display** now updates in real-time during chunked uploads
|
||||
- **ETA display** shows accurate time remaining
|
||||
- **Progress bar** correctly shows 0-100% for chunked files
|
||||
- No more speed dropping to 0 during large file uploads
|
||||
|
||||
#### 3. 💾 Storage Display Improvements
|
||||
- **Changed label** to "Data Storage (/data)" for clarity
|
||||
- **Uses statfs with f_bavail** for more accurate available space
|
||||
- **Real-time updates** every 5 seconds when connected
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.0.0
|
||||
|
||||
### 🐛 Critical Bug Fixes:
|
||||
|
||||
#### 1. 🔧 Path Normalization Fix
|
||||
- **Fixed:** Double-slash paths (`//mnt/ext1/...`) that caused directory creation to fail
|
||||
- **Affected:** All file operations (upload, download, delete, rename, copy, move)
|
||||
- **Solution:** Added `NormalizePath()` function in both client and server
|
||||
- **Result:** 100% reliable directory creation and file uploads
|
||||
|
||||
#### 2. 📱 Android Mobile Client
|
||||
- **NEW:** Full-featured Android app for PS5 file management
|
||||
- **Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
|
||||
- Upload files from phone to PS5
|
||||
- Download files from PS5 with Share option
|
||||
- Browse PS5 filesystem
|
||||
- Favorites for quick navigation
|
||||
- Debug Log with Copy to clipboard
|
||||
- Transfer History tracking
|
||||
|
||||
#### 3. 🛠️ Server Improvements
|
||||
- Path normalization in all handler functions
|
||||
- More robust error handling
|
||||
- Improved stability for parallel uploads
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.1.0
|
||||
|
||||
### 🚀 Performance Optimizations:
|
||||
|
||||
#### 1. ⚡ Massive Upload Speed Boost (88-110 MB/s)
|
||||
- **Server-side:** Replaced `fwrite()` with direct `write()` syscalls
|
||||
- **Client-side:** 8 parallel large file uploads (optimal for PS5 disk)
|
||||
- **Result:** 80-110 MB/s aggregate upload speed on Gigabit Ethernet
|
||||
- **Peak bursts:** Up to 2.05 GB/s when hitting disk cache
|
||||
- **Per-file:** 11-14 MB/s sustained per connection
|
||||
|
||||
#### 2. 📊 Transfer History
|
||||
- Complete history of all uploads and downloads
|
||||
- Success/Failed status tracking with error messages
|
||||
- Speed statistics (average, min, max)
|
||||
- Export to CSV/JSON for analysis
|
||||
- Persistent storage across sessions
|
||||
|
||||
#### 3. 🔄 Auto-Clear History on Startup
|
||||
- Optional checkbox to clear history automatically
|
||||
- Useful for keeping UI clean between sessions
|
||||
- Setting saved in `ps5_upload_settings.json`
|
||||
|
||||
#### 4. 🖥️ Maximized Window UI
|
||||
- Application opens in full-screen mode by default
|
||||
- Better visibility for large file transfers
|
||||
- Can be resized/restored as needed
|
||||
|
||||
### Technical Improvements:
|
||||
- ✅ **16MB socket buffers** (up from 4MB) for maximum throughput
|
||||
- ✅ **Per-file mutex locking** - Parallel writes without race conditions
|
||||
- ✅ **File pre-allocation** - Reduces disk fragmentation for large files
|
||||
- ✅ **Direct syscalls** - Bypasses stdio buffering overhead
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.0.0
|
||||
|
||||
### 🎉 4 Major New Features:
|
||||
@@ -229,7 +494,7 @@ See [PROTOCOL.md](PROTOCOL.md) for detailed protocol documentation.
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - Free to use and modify
|
||||
This software is provided as-is for personal use.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
# 🎉 PS5 Upload Suite v2.0.0 - Feature-Rich Release
|
||||
|
||||
## 🚀 What's New
|
||||
|
||||
This is a **major feature release** adding 4 powerful new capabilities to the PS5 Upload Suite!
|
||||
|
||||
---
|
||||
|
||||
## ✨ New Features
|
||||
|
||||
### 1. 📥 Download Files (PS5 → PC)
|
||||
|
||||
Finally! You can now download files from your PS5 back to your PC.
|
||||
|
||||
**How to use:**
|
||||
- Right-click any file in PS5 Files panel
|
||||
- Select "⬇️ Download to PC"
|
||||
- Choose where to save the file
|
||||
- Watch the progress with real-time speed tracking
|
||||
|
||||
**Technical Details:**
|
||||
- Uses sendfile optimization for maximum speed
|
||||
- Zero-copy transfer on PS5 side
|
||||
- Same high-speed protocol as uploads
|
||||
- Full progress tracking
|
||||
|
||||
---
|
||||
|
||||
### 2. 🔍 File Search
|
||||
|
||||
Quickly find files in large directories without scrolling.
|
||||
|
||||
**How to use:**
|
||||
- Type in the Search box below Current Path
|
||||
- Files are filtered in real-time as you type
|
||||
- Case-insensitive search
|
||||
- Click "✖ Clear" to reset
|
||||
|
||||
**Perfect for:**
|
||||
- Finding specific game saves
|
||||
- Locating config files
|
||||
- Searching through large mod folders
|
||||
|
||||
---
|
||||
|
||||
### 3. ⭐ Favorites/Bookmarks
|
||||
|
||||
Save your frequently used PS5 paths for instant navigation.
|
||||
|
||||
**How to use:**
|
||||
- Navigate to any path (e.g., `/data/games`)
|
||||
- Click "⭐ Add" to save it
|
||||
- Use the Favorites dropdown to quickly jump to saved paths
|
||||
- Click "🗑️ Remove" to delete a favorite
|
||||
|
||||
**Saved paths persist between sessions!**
|
||||
|
||||
---
|
||||
|
||||
### 4. 🎮 Multi-PS5 Support
|
||||
|
||||
Manage multiple PS5 consoles with ease.
|
||||
|
||||
**How to use:**
|
||||
- Enter a PS5 IP address
|
||||
- Click "💾 Save Profile" and give it a name (e.g., "Living Room PS5")
|
||||
- Use the Profile dropdown to quickly switch between PS5s
|
||||
- Click "🗑️ Delete Profile" to remove saved profiles
|
||||
|
||||
**Perfect for:**
|
||||
- Users with multiple PS5 consoles
|
||||
- Testing on different systems
|
||||
- Switching between home and friend's PS5
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
|
||||
### Server-Side (payload)
|
||||
- Added `CMD_DOWNLOAD_FILE` (0x13) command
|
||||
- Implemented sendfile-based download handler
|
||||
- Fixed `LIST_DIR` to return actual file sizes (not 0)
|
||||
- Zero-copy file transfer for downloads
|
||||
|
||||
### Client-Side
|
||||
- Added `DownloadFileAsync()` method to Protocol
|
||||
- Implemented search filtering with `ObservableCollection`
|
||||
- Added JSON-based profile and favorites persistence
|
||||
- Custom ComboBox templates for dark theme consistency
|
||||
- Improved UI responsiveness
|
||||
|
||||
---
|
||||
|
||||
## 📊 Statistics
|
||||
|
||||
**Lines of Code Added:** ~500+ lines
|
||||
**New Protocol Commands:** 1 (DOWNLOAD_FILE)
|
||||
**New UI Elements:** 3 panels (Search, Favorites, Profiles)
|
||||
**Persistent Storage Files:** 2 (ps5_profiles.json, ps5_favorites.json)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Improvements
|
||||
|
||||
- All ComboBox dropdowns now have proper dark theme
|
||||
- All buttons show full text without cutoff
|
||||
- Consistent styling across all new features
|
||||
- Responsive layout that adapts to content
|
||||
|
||||
---
|
||||
|
||||
## 📦 Files Included
|
||||
|
||||
### Server
|
||||
- `payload/ps5_upload_server.elf` - Updated with download support
|
||||
|
||||
### Client
|
||||
- `client/bin/Release/net6.0-windows/win-x64/PS5Upload.exe` - Full v2.0 client
|
||||
|
||||
---
|
||||
|
||||
## ⬆️ Upgrading from v1.3.0
|
||||
|
||||
1. **Replace server payload:**
|
||||
- Copy new `ps5_upload_server.elf` to `/data/etaHEN/payloads/`
|
||||
- Reload with elfldr
|
||||
|
||||
2. **Replace client:**
|
||||
- Close old PS5Upload.exe
|
||||
- Replace with new version
|
||||
- Your settings will be preserved
|
||||
|
||||
**Note:** v2.0 creates two new files:
|
||||
- `ps5_profiles.json` - Saved PS5 profiles
|
||||
- `ps5_favorites.json` - Saved favorite paths
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Issues
|
||||
|
||||
None reported yet! This is a stable release built on top of the rock-solid v1.3.0 foundation.
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Features (Not in v2.0)
|
||||
|
||||
Still on the roadmap:
|
||||
- Disk Space Warning before upload
|
||||
- Duplicate File Detection
|
||||
- File Verification (SHA256)
|
||||
- Resume Interrupted Uploads
|
||||
- Queue Management (reorder, pause)
|
||||
- Transfer History
|
||||
- Scheduled Uploads
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
Special thanks to the PS5 homebrew community for testing and feedback!
|
||||
|
||||
---
|
||||
|
||||
**Enjoy the new features!** 🚀
|
||||
|
||||
**- Manos**
|
||||
@@ -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 & 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1097
-176
File diff suppressed because it is too large.
Load diff
+2694
-791
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,7 @@
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<ApplicationIcon>ps5_logo_fixed.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
+1776
-171
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
<?xml version = "1.0" encoding = "UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:PS5UploadMobile"
|
||||
x:Class="PS5UploadMobile.App">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PS5UploadMobile;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
MainPage = new AppShell();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Shell
|
||||
x:Class="PS5UploadMobile.AppShell"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:PS5UploadMobile"
|
||||
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">
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace PS5UploadMobile;
|
||||
|
||||
public partial class AppShell : Shell
|
||||
{
|
||||
public AppShell()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?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.MainPage"
|
||||
Title="PS5 Upload Suite">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!-- Top Bar with Menu -->
|
||||
<Grid Grid.Row="0" Padding="10" BackgroundColor="#1E1E1E" ColumnDefinitions="Auto,*,Auto">
|
||||
<Button Grid.Column="0" Text="☰" FontSize="24" BackgroundColor="Transparent"
|
||||
Clicked="OnMenuClicked" WidthRequest="50"/>
|
||||
<Label Grid.Column="1" Text="PS5 Upload Suite" FontSize="20" FontAttributes="Bold"
|
||||
TextColor="White" VerticalOptions="Center" HorizontalOptions="Center"/>
|
||||
<Button Grid.Column="2" Text="⭐" FontSize="20" BackgroundColor="Transparent"
|
||||
Clicked="OnAddFavoriteClicked" WidthRequest="50"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Connection Panel -->
|
||||
<Frame Grid.Row="1" Padding="10" Margin="10,5,10,5" BackgroundColor="#1E1E1E" CornerRadius="8">
|
||||
<VerticalStackLayout Spacing="10">
|
||||
<Label Text="PS5 Connection" FontSize="18" FontAttributes="Bold" TextColor="White"/>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="10">
|
||||
<Entry x:Name="IpEntry" Grid.Column="0" Placeholder="PS5 IP (192.168.0.160)"
|
||||
Text="192.168.0.160" TextColor="White" PlaceholderColor="Gray"/>
|
||||
<Entry x:Name="PortEntry" Grid.Column="1" Placeholder="Port (9113)"
|
||||
Text="9113" Keyboard="Numeric" TextColor="White" PlaceholderColor="Gray"/>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="10">
|
||||
<Button x:Name="ConnectBtn" Grid.Column="0" Text="Connect"
|
||||
Clicked="OnConnectClicked" BackgroundColor="#0078D4"/>
|
||||
<Button x:Name="DisconnectBtn" Grid.Column="1" Text="Disconnect"
|
||||
Clicked="OnDisconnectClicked" BackgroundColor="#D13438" IsEnabled="False"/>
|
||||
</Grid>
|
||||
|
||||
<Label x:Name="StatusLabel" Text="Not connected" FontSize="12"
|
||||
TextColor="Orange" HorizontalOptions="Center"/>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
<!-- Path Navigation -->
|
||||
<Frame Grid.Row="2" Padding="10" Margin="10,0,10,5" BackgroundColor="#2D2D2D" CornerRadius="8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Button Grid.Column="0" Text="⬆️" Clicked="OnUpDirectoryClicked"
|
||||
WidthRequest="50" BackgroundColor="#3E3E3E"/>
|
||||
<Entry x:Name="PathEntry" Grid.Column="1" Text="/data/"
|
||||
TextColor="White" Margin="10,0"/>
|
||||
<Button Grid.Column="2" Text="🔄" Clicked="OnRefreshClicked"
|
||||
WidthRequest="50" BackgroundColor="#3E3E3E"/>
|
||||
</Grid>
|
||||
</Frame>
|
||||
|
||||
<!-- File List -->
|
||||
<Frame Grid.Row="3" Padding="0" Margin="10,0,10,5" BackgroundColor="#2D2D2D" CornerRadius="8">
|
||||
<CollectionView x:Name="FileListView" SelectionMode="Single"
|
||||
SelectionChanged="OnFileSelected">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Padding="15,10" ColumnDefinitions="Auto,*,Auto">
|
||||
<Label Grid.Column="0" Text="{Binding Icon}" FontSize="20"
|
||||
VerticalOptions="Center" Margin="0,0,10,0"/>
|
||||
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center">
|
||||
<Label Text="{Binding Name}" TextColor="White" FontSize="16"/>
|
||||
<Label Text="{Binding Size}" TextColor="Gray" FontSize="12"/>
|
||||
</VerticalStackLayout>
|
||||
<Label Grid.Column="2" Text="{Binding Type}" TextColor="Gray"
|
||||
FontSize="12" VerticalOptions="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</Frame>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<Frame Grid.Row="4" Padding="10" Margin="10,0,10,5" BackgroundColor="#2D2D2D"
|
||||
CornerRadius="8" IsVisible="{Binding IsTransferring}">
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label x:Name="ProgressLabel" Text="Uploading..." TextColor="White" FontSize="14"/>
|
||||
<ProgressBar x:Name="ProgressBar" Progress="0" ProgressColor="#0078D4"/>
|
||||
<Label x:Name="ProgressPercentLabel" Text="0%" TextColor="Gray"
|
||||
FontSize="12" HorizontalOptions="Center"/>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<Grid Grid.Row="5" Padding="10" ColumnDefinitions="*,*,*" ColumnSpacing="10">
|
||||
<Button Grid.Column="0" Text="📤 Upload" Clicked="OnUploadClicked"
|
||||
BackgroundColor="#107C10"/>
|
||||
<Button Grid.Column="1" Text="📥 Download" Clicked="OnDownloadClicked"
|
||||
BackgroundColor="#0078D4"/>
|
||||
<Button Grid.Column="2" Text="🗑️ Delete" Clicked="OnDeleteClicked"
|
||||
BackgroundColor="#D13438"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,577 @@
|
||||
#nullable enable
|
||||
using PS5UploadMobile.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace PS5UploadMobile;
|
||||
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
private PS5UploadService _uploadService;
|
||||
private ObservableCollection<FileItem> _files;
|
||||
private string _currentPath = "/data/";
|
||||
private FileItem? _selectedFile;
|
||||
|
||||
// New features
|
||||
private List<string> _favorites = new List<string>();
|
||||
private List<string> _debugLog = new List<string>();
|
||||
private List<string> _transferHistory = new List<string>();
|
||||
private const string FavoritesKey = "ps5_favorites";
|
||||
|
||||
// PS5 Profiles
|
||||
private Dictionary<string, string> _ps5Profiles = new Dictionary<string, string>(); // name -> ip:port
|
||||
private const string ProfilesKey = "ps5_profiles";
|
||||
private string _currentProfileName = "";
|
||||
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uploadService = new PS5UploadService();
|
||||
_files = new ObservableCollection<FileItem>();
|
||||
FileListView.ItemsSource = _files;
|
||||
|
||||
_uploadService.LogMessage += OnLogMessage;
|
||||
_uploadService.ProgressChanged += OnProgressChanged;
|
||||
|
||||
LoadFavorites();
|
||||
LoadProfiles();
|
||||
}
|
||||
|
||||
// Menu button handler
|
||||
private async void OnMenuClicked(object sender, EventArgs e)
|
||||
{
|
||||
string action = await DisplayActionSheet("Menu", "Cancel", null,
|
||||
"🎮 PS5 Profiles",
|
||||
"📋 Debug Log",
|
||||
"📊 Transfer History",
|
||||
"⭐ Favorites",
|
||||
"🗑️ Clear Logs");
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "🎮 PS5 Profiles":
|
||||
await ShowProfiles();
|
||||
break;
|
||||
case "📋 Debug Log":
|
||||
await ShowDebugLog();
|
||||
break;
|
||||
case "📊 Transfer History":
|
||||
await ShowTransferHistory();
|
||||
break;
|
||||
case "⭐ Favorites":
|
||||
await ShowFavorites();
|
||||
break;
|
||||
case "🗑️ Clear Logs":
|
||||
_debugLog.Clear();
|
||||
_transferHistory.Clear();
|
||||
await DisplayAlert("Cleared", "Logs cleared", "OK");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowDebugLog()
|
||||
{
|
||||
string log = _debugLog.Count > 0
|
||||
? string.Join("\n", _debugLog.TakeLast(50))
|
||||
: "No log entries yet";
|
||||
|
||||
string action = await DisplayActionSheet("Debug Log", "Close", null, "📋 Copy Log", "👁️ View Log");
|
||||
|
||||
if (action == "📋 Copy Log")
|
||||
{
|
||||
await Clipboard.SetTextAsync(log);
|
||||
await DisplayAlert("Copied", "Log copied to clipboard", "OK");
|
||||
}
|
||||
else if (action == "👁️ View Log")
|
||||
{
|
||||
await DisplayAlert("Debug Log", log, "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowTransferHistory()
|
||||
{
|
||||
string history = _transferHistory.Count > 0
|
||||
? string.Join("\n", _transferHistory.TakeLast(20))
|
||||
: "No transfers yet";
|
||||
await DisplayAlert("Transfer History", history, "OK");
|
||||
}
|
||||
|
||||
private async Task ShowFavorites()
|
||||
{
|
||||
if (_favorites.Count == 0)
|
||||
{
|
||||
await DisplayAlert("Favorites", "No favorites saved.\nTap ⭐ to add current path.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add options for each favorite plus a delete option
|
||||
var options = new List<string>();
|
||||
foreach (var fav in _favorites)
|
||||
{
|
||||
options.Add($"📁 {fav}");
|
||||
}
|
||||
options.Add("🗑️ Delete a favorite");
|
||||
|
||||
string? selected = await DisplayActionSheet("Favorites", "Cancel", null, options.ToArray());
|
||||
|
||||
if (selected == null || selected == "Cancel")
|
||||
return;
|
||||
|
||||
if (selected == "🗑️ Delete a favorite")
|
||||
{
|
||||
await DeleteFavorite();
|
||||
}
|
||||
else if (selected.StartsWith("📁 "))
|
||||
{
|
||||
string path = selected.Substring(3);
|
||||
if (_favorites.Contains(path))
|
||||
{
|
||||
_currentPath = path;
|
||||
PathEntry.Text = _currentPath;
|
||||
await RefreshFileList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteFavorite()
|
||||
{
|
||||
if (_favorites.Count == 0) return;
|
||||
|
||||
string? toDelete = await DisplayActionSheet("Delete which favorite?", "Cancel", null, _favorites.ToArray());
|
||||
if (toDelete != null && toDelete != "Cancel" && _favorites.Contains(toDelete))
|
||||
{
|
||||
_favorites.Remove(toDelete);
|
||||
SaveFavorites();
|
||||
await DisplayAlert("Deleted", $"'{toDelete}' removed from favorites", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnAddFavoriteClicked(object sender, EventArgs e)
|
||||
{
|
||||
if (_favorites.Contains(_currentPath))
|
||||
{
|
||||
bool remove = await DisplayAlert("Favorite Exists",
|
||||
$"Remove '{_currentPath}' from favorites?", "Remove", "Cancel");
|
||||
if (remove)
|
||||
{
|
||||
_favorites.Remove(_currentPath);
|
||||
SaveFavorites();
|
||||
await DisplayAlert("Removed", "Favorite removed", "OK");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_favorites.Add(_currentPath);
|
||||
SaveFavorites();
|
||||
await DisplayAlert("Added", $"'{_currentPath}' added to favorites", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFavorites()
|
||||
{
|
||||
string? saved = Preferences.Get(FavoritesKey, "");
|
||||
if (!string.IsNullOrEmpty(saved))
|
||||
{
|
||||
_favorites = saved.Split('|').Where(s => !string.IsNullOrEmpty(s)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveFavorites()
|
||||
{
|
||||
Preferences.Set(FavoritesKey, string.Join("|", _favorites));
|
||||
}
|
||||
|
||||
// PS5 Profile Management
|
||||
private void LoadProfiles()
|
||||
{
|
||||
string? saved = Preferences.Get(ProfilesKey, "");
|
||||
if (!string.IsNullOrEmpty(saved))
|
||||
{
|
||||
_ps5Profiles.Clear();
|
||||
var entries = saved.Split('|').Where(s => !string.IsNullOrEmpty(s));
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var parts = entry.Split('=');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
_ps5Profiles[parts[0]] = parts[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveProfiles()
|
||||
{
|
||||
var entries = _ps5Profiles.Select(p => $"{p.Key}={p.Value}");
|
||||
Preferences.Set(ProfilesKey, string.Join("|", entries));
|
||||
}
|
||||
|
||||
private async Task ShowProfiles()
|
||||
{
|
||||
var options = new List<string>();
|
||||
|
||||
foreach (var profile in _ps5Profiles)
|
||||
{
|
||||
string marker = profile.Key == _currentProfileName ? "✓ " : "";
|
||||
options.Add($"{marker}🎮 {profile.Key} ({profile.Value})");
|
||||
}
|
||||
options.Add("➕ Add new profile");
|
||||
if (_ps5Profiles.Count > 0)
|
||||
{
|
||||
options.Add("🗑️ Delete a profile");
|
||||
}
|
||||
|
||||
string? selected = await DisplayActionSheet("PS5 Profiles", "Cancel", null, options.ToArray());
|
||||
|
||||
if (selected == null || selected == "Cancel")
|
||||
return;
|
||||
|
||||
if (selected == "➕ Add new profile")
|
||||
{
|
||||
await AddNewProfile();
|
||||
}
|
||||
else if (selected == "🗑️ Delete a profile")
|
||||
{
|
||||
await DeleteProfile();
|
||||
}
|
||||
else if (selected.Contains("🎮"))
|
||||
{
|
||||
// Extract profile name
|
||||
int start = selected.IndexOf("🎮 ") + 3;
|
||||
int end = selected.IndexOf(" (");
|
||||
if (start > 2 && end > start)
|
||||
{
|
||||
string profileName = selected.Substring(start, end - start);
|
||||
await SelectProfile(profileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddNewProfile()
|
||||
{
|
||||
string? name = await DisplayPromptAsync("New Profile", "Enter profile name:", "Save", "Cancel", "My PS5");
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
|
||||
string? ip = await DisplayPromptAsync("PS5 IP", "Enter PS5 IP address:", "Save", "Cancel", "192.168.0.160");
|
||||
if (string.IsNullOrEmpty(ip)) return;
|
||||
|
||||
string? port = await DisplayPromptAsync("Port", "Enter port (default 9113):", "Save", "Cancel", "9113", keyboard: Keyboard.Numeric);
|
||||
if (string.IsNullOrEmpty(port)) port = "9113";
|
||||
|
||||
_ps5Profiles[name] = $"{ip}:{port}";
|
||||
SaveProfiles();
|
||||
|
||||
await DisplayAlert("Saved", $"Profile '{name}' saved", "OK");
|
||||
await SelectProfile(name);
|
||||
}
|
||||
|
||||
private async Task DeleteProfile()
|
||||
{
|
||||
if (_ps5Profiles.Count == 0) return;
|
||||
|
||||
string? toDelete = await DisplayActionSheet("Delete which profile?", "Cancel", null, _ps5Profiles.Keys.ToArray());
|
||||
if (toDelete != null && toDelete != "Cancel" && _ps5Profiles.ContainsKey(toDelete))
|
||||
{
|
||||
_ps5Profiles.Remove(toDelete);
|
||||
SaveProfiles();
|
||||
if (_currentProfileName == toDelete)
|
||||
{
|
||||
_currentProfileName = "";
|
||||
}
|
||||
await DisplayAlert("Deleted", $"Profile '{toDelete}' deleted", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SelectProfile(string profileName)
|
||||
{
|
||||
if (!_ps5Profiles.ContainsKey(profileName)) return;
|
||||
|
||||
string ipPort = _ps5Profiles[profileName];
|
||||
var parts = ipPort.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
IpEntry.Text = parts[0];
|
||||
PortEntry.Text = parts[1];
|
||||
_currentProfileName = profileName;
|
||||
AddLog($"Selected profile: {profileName} ({ipPort})");
|
||||
await DisplayAlert("Profile Selected", $"Using '{profileName}'\nIP: {parts[0]}\nPort: {parts[1]}", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnConnectClicked(object sender, EventArgs e)
|
||||
{
|
||||
ConnectBtn.IsEnabled = false;
|
||||
StatusLabel.Text = "Connecting...";
|
||||
StatusLabel.TextColor = Colors.Yellow;
|
||||
|
||||
_uploadService.PS5Ip = IpEntry.Text;
|
||||
_uploadService.Port = int.Parse(PortEntry.Text);
|
||||
|
||||
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 ✓";
|
||||
StatusLabel.TextColor = Colors.Green;
|
||||
ConnectBtn.IsEnabled = false;
|
||||
DisconnectBtn.IsEnabled = true;
|
||||
AddLog("Connected successfully");
|
||||
|
||||
await RefreshFileList();
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusLabel.Text = "Connection failed";
|
||||
StatusLabel.TextColor = Colors.Red;
|
||||
ConnectBtn.IsEnabled = true;
|
||||
AddLog("Connection failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDisconnectClicked(object sender, EventArgs e)
|
||||
{
|
||||
await _uploadService.DisconnectAsync();
|
||||
ConnectionManager.Instance.Disconnect();
|
||||
|
||||
StatusLabel.Text = "Disconnected";
|
||||
StatusLabel.TextColor = Colors.Orange;
|
||||
ConnectBtn.IsEnabled = true;
|
||||
DisconnectBtn.IsEnabled = false;
|
||||
|
||||
_files.Clear();
|
||||
AddLog("Disconnected");
|
||||
}
|
||||
|
||||
private async void OnRefreshClicked(object sender, EventArgs e)
|
||||
{
|
||||
await RefreshFileList();
|
||||
}
|
||||
|
||||
private async void OnUpDirectoryClicked(object sender, EventArgs e)
|
||||
{
|
||||
if (_currentPath != "/")
|
||||
{
|
||||
int lastSlash = _currentPath.TrimEnd('/').LastIndexOf('/');
|
||||
_currentPath = _currentPath.Substring(0, lastSlash + 1);
|
||||
PathEntry.Text = _currentPath;
|
||||
await RefreshFileList();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnFileSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.CurrentSelection.Count > 0)
|
||||
{
|
||||
_selectedFile = e.CurrentSelection[0] as FileItem;
|
||||
|
||||
if (_selectedFile?.IsDirectory == true)
|
||||
{
|
||||
_currentPath = _selectedFile.FullPath;
|
||||
PathEntry.Text = _currentPath;
|
||||
await RefreshFileList();
|
||||
FileListView.SelectedItem = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnUploadClicked(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await FilePicker.PickAsync();
|
||||
if (result != null)
|
||||
{
|
||||
ProgressLabel.Text = $"Uploading {result.FileName}...";
|
||||
ProgressBar.Progress = 0;
|
||||
ProgressPercentLabel.Text = "0%";
|
||||
|
||||
// Copy file to temp location for reliable access on Android
|
||||
string tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName);
|
||||
using (var sourceStream = await result.OpenReadAsync())
|
||||
using (var destStream = File.Create(tempPath))
|
||||
{
|
||||
await sourceStream.CopyToAsync(destStream);
|
||||
}
|
||||
|
||||
string remotePath = _currentPath.TrimEnd('/') + "/" + result.FileName;
|
||||
AddLog($"Uploading: {result.FileName} -> {remotePath}");
|
||||
|
||||
bool success = await _uploadService.UploadFileAsync(tempPath, remotePath);
|
||||
|
||||
// Clean up temp file
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
|
||||
if (success)
|
||||
{
|
||||
AddTransfer($"⬆️ {result.FileName}");
|
||||
await DisplayAlert("Success", "File uploaded successfully!", "OK");
|
||||
await RefreshFileList();
|
||||
}
|
||||
else
|
||||
{
|
||||
await DisplayAlert("Error", "Upload failed", "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"Upload error: {ex.Message}");
|
||||
await DisplayAlert("Error", $"Upload failed: {ex.Message}", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDownloadClicked(object sender, EventArgs e)
|
||||
{
|
||||
if (_selectedFile == null || _selectedFile.IsDirectory)
|
||||
{
|
||||
await DisplayAlert("Error", "Please select a file to download", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// First download to cache, then let user save via Share
|
||||
string cachePath = Path.Combine(FileSystem.CacheDirectory, _selectedFile.Name);
|
||||
|
||||
ProgressLabel.Text = $"Downloading {_selectedFile.Name}...";
|
||||
ProgressBar.Progress = 0;
|
||||
ProgressPercentLabel.Text = "0%";
|
||||
|
||||
AddLog($"Downloading: {_selectedFile.FullPath} -> {cachePath}");
|
||||
bool success = await _uploadService.DownloadFileAsync(_selectedFile.FullPath, cachePath);
|
||||
|
||||
if (success)
|
||||
{
|
||||
AddTransfer($"⬇️ {_selectedFile.Name}");
|
||||
|
||||
// Use Share to let user choose where to save
|
||||
// This opens the Android share sheet where user can:
|
||||
// - Save to Files app (choose location)
|
||||
// - Save to Downloads
|
||||
// - Send via other apps
|
||||
string? action = await DisplayActionSheet(
|
||||
$"'{_selectedFile.Name}' downloaded!",
|
||||
"Close", null,
|
||||
"💾 Save to location...",
|
||||
"📂 Open file");
|
||||
|
||||
if (action == "💾 Save to location...")
|
||||
{
|
||||
// Share opens Android's save/share dialog where user can choose location
|
||||
await Share.RequestAsync(new ShareFileRequest
|
||||
{
|
||||
Title = $"Save {_selectedFile.Name}",
|
||||
File = new ShareFile(cachePath)
|
||||
});
|
||||
}
|
||||
else if (action == "📂 Open file")
|
||||
{
|
||||
try
|
||||
{
|
||||
await Launcher.OpenAsync(new OpenFileRequest
|
||||
{
|
||||
File = new ReadOnlyFile(cachePath)
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DisplayAlert("Error", "No app found to open this file type", "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await DisplayAlert("Error", "Download failed", "OK");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"Download error: {ex.Message}");
|
||||
await DisplayAlert("Error", $"Download failed: {ex.Message}", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDeleteClicked(object sender, EventArgs e)
|
||||
{
|
||||
if (_selectedFile == null)
|
||||
{
|
||||
await DisplayAlert("Error", "Please select a file to delete", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
bool confirm = await DisplayAlert("Confirm",
|
||||
$"Delete {_selectedFile.Name}?", "Yes", "No");
|
||||
|
||||
if (confirm)
|
||||
{
|
||||
AddLog($"Deleting: {_selectedFile.FullPath}");
|
||||
bool success = await _uploadService.DeleteFileAsync(_selectedFile.FullPath);
|
||||
if (success)
|
||||
{
|
||||
AddTransfer($"🗑️ {_selectedFile.Name}");
|
||||
await RefreshFileList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshFileList()
|
||||
{
|
||||
try
|
||||
{
|
||||
_files.Clear();
|
||||
AddLog($"Listing: {_currentPath}");
|
||||
|
||||
var fileItems = await _uploadService.ListDirectoryAsync(_currentPath);
|
||||
|
||||
foreach (var fileItem in fileItems)
|
||||
{
|
||||
_files.Add(fileItem);
|
||||
}
|
||||
|
||||
AddLog($"Found {fileItems.Count} items");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"List error: {ex.Message}");
|
||||
await DisplayAlert("Error", $"Failed to list directory: {ex.Message}", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLog(string message)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("HH:mm:ss");
|
||||
_debugLog.Add($"[{timestamp}] {message}");
|
||||
if (_debugLog.Count > 100) _debugLog.RemoveAt(0);
|
||||
}
|
||||
|
||||
private void AddTransfer(string message)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("HH:mm:ss");
|
||||
_transferHistory.Add($"[{timestamp}] {message}");
|
||||
if (_transferHistory.Count > 50) _transferHistory.RemoveAt(0);
|
||||
}
|
||||
|
||||
private void OnLogMessage(object? sender, string message)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
AddLog(message);
|
||||
});
|
||||
}
|
||||
|
||||
private void OnProgressChanged(object? sender, int progress)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
ProgressBar.Progress = progress / 100.0;
|
||||
ProgressPercentLabel.Text = $"{progress}%";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace PS5UploadMobile;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.ConfigureFonts(fonts =>
|
||||
{
|
||||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
|
||||
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0-android</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>PS5UploadMobile</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- Display name -->
|
||||
<ApplicationTitle>PS5 Upload Suite</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.ps5tools.uploadsuite</ApplicationId>
|
||||
<ApplicationIdGuid>0cb2ca56-8fa9-400b-8669-c219279a7aad</ApplicationIdGuid>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||
|
||||
<!-- 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'))">
|
||||
<PackageReference Include="System.Net.Sockets" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- App Icon - PS5 Logo from Windows client -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
<MauiImage Update="Resources\Images\dotnet_bot.svg" BaseSize="168,208" />
|
||||
|
||||
<!-- Custom Fonts -->
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
|
||||
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)"
|
||||
: "—";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
#nullable enable
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace PS5UploadMobile.Services
|
||||
{
|
||||
public class FtpService
|
||||
{
|
||||
private string _ps5Ip = "192.168.0.160";
|
||||
private int _ftpPort = 2121;
|
||||
private TcpClient? _controlClient;
|
||||
private NetworkStream? _controlStream;
|
||||
private StreamReader? _controlReader;
|
||||
|
||||
public event EventHandler<string>? LogMessage;
|
||||
public event EventHandler<int>? ProgressChanged;
|
||||
|
||||
public string PS5Ip
|
||||
{
|
||||
get => _ps5Ip;
|
||||
set => _ps5Ip = value;
|
||||
}
|
||||
|
||||
public int FtpPort
|
||||
{
|
||||
get => _ftpPort;
|
||||
set => _ftpPort = value;
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_controlClient = new TcpClient();
|
||||
await _controlClient.ConnectAsync(_ps5Ip, _ftpPort);
|
||||
_controlStream = _controlClient.GetStream();
|
||||
_controlReader = new StreamReader(_controlStream, Encoding.ASCII);
|
||||
|
||||
// Read welcome message
|
||||
string? response = await _controlReader.ReadLineAsync();
|
||||
LogMessage?.Invoke(this, $"Connected: {response}");
|
||||
|
||||
// Login as anonymous
|
||||
await SendCommandAsync("USER anonymous");
|
||||
await SendCommandAsync("PASS anonymous");
|
||||
await SendCommandAsync("TYPE I"); // Binary mode
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Connection failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_controlClient?.Connected == true)
|
||||
{
|
||||
await SendCommandAsync("QUIT");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
_controlReader?.Dispose();
|
||||
_controlStream?.Dispose();
|
||||
_controlClient?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListDirectoryAsync(string path)
|
||||
{
|
||||
var files = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Change to directory
|
||||
await SendCommandAsync($"CWD {path}");
|
||||
|
||||
// Enter passive mode
|
||||
string pasvResponse = await SendCommandAsync("PASV");
|
||||
var dataEndpoint = ParsePasvResponse(pasvResponse);
|
||||
|
||||
// Connect to data channel
|
||||
using var dataClient = new TcpClient();
|
||||
await dataClient.ConnectAsync(dataEndpoint.Address, dataEndpoint.Port);
|
||||
using var dataStream = dataClient.GetStream();
|
||||
using var dataReader = new StreamReader(dataStream, Encoding.ASCII);
|
||||
|
||||
// Send LIST command
|
||||
await SendCommandAsync("LIST");
|
||||
|
||||
// Read directory listing
|
||||
string? line;
|
||||
while ((line = await dataReader.ReadLineAsync()) != null)
|
||||
{
|
||||
files.Add(line);
|
||||
}
|
||||
|
||||
dataClient.Close();
|
||||
await ReadResponseAsync(); // Read final response
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"List failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public async Task<bool> UploadFileAsync(string localPath, string remotePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(localPath);
|
||||
long totalBytes = fileInfo.Length;
|
||||
long uploadedBytes = 0;
|
||||
|
||||
// Enter passive mode
|
||||
string pasvResponse = await SendCommandAsync("PASV");
|
||||
var dataEndpoint = ParsePasvResponse(pasvResponse);
|
||||
|
||||
// Connect to data channel
|
||||
using var dataClient = new TcpClient();
|
||||
await dataClient.ConnectAsync(dataEndpoint.Address, dataEndpoint.Port);
|
||||
using var dataStream = dataClient.GetStream();
|
||||
|
||||
// Send STOR command
|
||||
await SendCommandAsync($"STOR {remotePath}");
|
||||
|
||||
// Upload file
|
||||
using var fileStream = File.OpenRead(localPath);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await dataStream.WriteAsync(buffer, 0, bytesRead);
|
||||
uploadedBytes += bytesRead;
|
||||
|
||||
int progress = (int)((uploadedBytes * 100) / totalBytes);
|
||||
ProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
|
||||
dataClient.Close();
|
||||
await ReadResponseAsync(); // Read final response
|
||||
|
||||
LogMessage?.Invoke(this, $"Upload complete: {Path.GetFileName(localPath)}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Upload failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DownloadFileAsync(string remotePath, string localPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get file size
|
||||
string sizeResponse = await SendCommandAsync($"SIZE {remotePath}");
|
||||
long totalBytes = ParseSizeResponse(sizeResponse);
|
||||
long downloadedBytes = 0;
|
||||
|
||||
// Enter passive mode
|
||||
string pasvResponse = await SendCommandAsync("PASV");
|
||||
var dataEndpoint = ParsePasvResponse(pasvResponse);
|
||||
|
||||
// Connect to data channel
|
||||
using var dataClient = new TcpClient();
|
||||
await dataClient.ConnectAsync(dataEndpoint.Address, dataEndpoint.Port);
|
||||
using var dataStream = dataClient.GetStream();
|
||||
|
||||
// Send RETR command
|
||||
await SendCommandAsync($"RETR {remotePath}");
|
||||
|
||||
// Download file
|
||||
using var fileStream = File.Create(localPath);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await dataStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
downloadedBytes += bytesRead;
|
||||
|
||||
if (totalBytes > 0)
|
||||
{
|
||||
int progress = (int)((downloadedBytes * 100) / totalBytes);
|
||||
ProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
}
|
||||
|
||||
dataClient.Close();
|
||||
await ReadResponseAsync(); // Read final response
|
||||
|
||||
LogMessage?.Invoke(this, $"Download complete: {Path.GetFileName(remotePath)}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Download failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFileAsync(string remotePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendCommandAsync($"DELE {remotePath}");
|
||||
LogMessage?.Invoke(this, $"Deleted: {remotePath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Delete failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> SendCommandAsync(string command)
|
||||
{
|
||||
if (_controlStream == null || _controlReader == null)
|
||||
throw new InvalidOperationException("Not connected");
|
||||
|
||||
byte[] commandBytes = Encoding.ASCII.GetBytes(command + "\r\n");
|
||||
await _controlStream.WriteAsync(commandBytes, 0, commandBytes.Length);
|
||||
|
||||
return await ReadResponseAsync();
|
||||
}
|
||||
|
||||
private async Task<string> ReadResponseAsync()
|
||||
{
|
||||
if (_controlReader == null)
|
||||
throw new InvalidOperationException("Not connected");
|
||||
|
||||
string? response = await _controlReader.ReadLineAsync();
|
||||
LogMessage?.Invoke(this, $"FTP: {response}");
|
||||
return response ?? "";
|
||||
}
|
||||
|
||||
private IPEndPoint ParsePasvResponse(string response)
|
||||
{
|
||||
// Parse PASV response: 227 Entering Passive Mode (192,168,0,160,8,19)
|
||||
int start = response.IndexOf('(');
|
||||
int end = response.IndexOf(')');
|
||||
string[] parts = response.Substring(start + 1, end - start - 1).Split(',');
|
||||
|
||||
string ip = $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}";
|
||||
int port = int.Parse(parts[4]) * 256 + int.Parse(parts[5]);
|
||||
|
||||
return new IPEndPoint(IPAddress.Parse(ip), port);
|
||||
}
|
||||
|
||||
private long ParseSizeResponse(string response)
|
||||
{
|
||||
// Parse SIZE response: 213 12345
|
||||
string[] parts = response.Split(' ');
|
||||
return parts.Length > 1 ? long.Parse(parts[1]) : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#nullable enable
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5UploadMobile.Services
|
||||
{
|
||||
public class PS5UploadService
|
||||
{
|
||||
private string _ps5Ip = "192.168.0.160";
|
||||
private int _port = 9113;
|
||||
private PS5Protocol? _protocol;
|
||||
|
||||
public event EventHandler<string>? LogMessage;
|
||||
public event EventHandler<int>? ProgressChanged;
|
||||
|
||||
public PS5UploadService()
|
||||
{
|
||||
_protocol = new PS5Protocol();
|
||||
}
|
||||
|
||||
public string PS5Ip
|
||||
{
|
||||
get => _ps5Ip;
|
||||
set => _ps5Ip = value;
|
||||
}
|
||||
|
||||
public int Port
|
||||
{
|
||||
get => _port;
|
||||
set => _port = value;
|
||||
}
|
||||
|
||||
public bool IsConnected => _protocol?.IsConnected ?? false;
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_protocol = new PS5Protocol();
|
||||
bool connected = await _protocol.ConnectAsync(_ps5Ip, _port);
|
||||
|
||||
if (connected)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Connected to PS5 at {_ps5Ip}:{_port}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMessage?.Invoke(this, "Failed to connect to PS5");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Connection error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
if (_protocol != null)
|
||||
{
|
||||
_protocol.Disconnect();
|
||||
LogMessage?.Invoke(this, "Disconnected from PS5");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<List<FileItem>> ListDirectoryAsync(string path)
|
||||
{
|
||||
var files = new List<FileItem>();
|
||||
|
||||
if (_protocol == null || !_protocol.IsConnected)
|
||||
{
|
||||
LogMessage?.Invoke(this, "Not connected to PS5");
|
||||
return files;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entries = await _protocol.ListDirAsync(path);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
string fullPath = path.TrimEnd('/') + "/" + entry.Name;
|
||||
if (entry.IsDirectory) fullPath += "/";
|
||||
|
||||
files.Add(new FileItem
|
||||
{
|
||||
Name = entry.Name,
|
||||
Size = entry.IsDirectory ? "" : FormatFileSize(entry.Size),
|
||||
Type = entry.IsDirectory ? "DIR" : "FILE",
|
||||
Icon = entry.IsDirectory ? "📁" : "📄",
|
||||
IsDirectory = entry.IsDirectory,
|
||||
FullPath = fullPath
|
||||
});
|
||||
}
|
||||
|
||||
LogMessage?.Invoke(this, $"Listed {files.Count} items in {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"List error: {ex.Message}");
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public async Task<bool> UploadFileAsync(string localPath, string remotePath)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected)
|
||||
{
|
||||
LogMessage?.Invoke(this, "Not connected to PS5");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(localPath);
|
||||
LogMessage?.Invoke(this, $"Uploading {fileInfo.Name} ({FormatFileSize(fileInfo.Length)})...");
|
||||
|
||||
var progress = new Progress<UploadProgress>(p =>
|
||||
{
|
||||
int percent = p.TotalBytes > 0 ? (int)((p.BytesSent * 100) / p.TotalBytes) : 0;
|
||||
ProgressChanged?.Invoke(this, percent);
|
||||
});
|
||||
|
||||
bool success = await _protocol.UploadFileAsync(localPath, remotePath, progress);
|
||||
|
||||
if (success)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Upload complete: {fileInfo.Name}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMessage?.Invoke(this, "Upload failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Upload error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DownloadFileAsync(string remotePath, string localPath)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected)
|
||||
{
|
||||
LogMessage?.Invoke(this, "Not connected to PS5");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Downloading {Path.GetFileName(remotePath)}...");
|
||||
|
||||
var progress = new Progress<UploadProgress>(p =>
|
||||
{
|
||||
int percent = p.TotalBytes > 0 ? (int)((p.BytesSent * 100) / p.TotalBytes) : 0;
|
||||
ProgressChanged?.Invoke(this, percent);
|
||||
});
|
||||
|
||||
bool success = await _protocol.DownloadFileAsync(remotePath, localPath, progress);
|
||||
|
||||
if (success)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Download complete: {Path.GetFileName(remotePath)}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMessage?.Invoke(this, "Download failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Download error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFileAsync(string remotePath)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected)
|
||||
{
|
||||
LogMessage?.Invoke(this, "Not connected to PS5");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bool success = await _protocol.DeleteFileAsync(remotePath);
|
||||
|
||||
if (success)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Deleted: {remotePath}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMessage?.Invoke(this, "Delete failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(this, $"Delete error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len /= 1024;
|
||||
}
|
||||
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
|
||||
public class FileItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Size { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string Icon { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public string FullPath { get; set; } = "";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+5
-1
@@ -8,7 +8,11 @@ echo ""
|
||||
|
||||
echo "[+] Compiling..."
|
||||
rm -f ps5_upload_server.elf
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -Wall -O3 -pthread -o ps5_upload_server.elf main.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -Wall -O3 -pthread \
|
||||
-I/opt/ps5-payload-sdk/target/include_bsd \
|
||||
-I/opt/ps5-payload-sdk/target/include \
|
||||
-L/opt/ps5-payload-sdk/target/lib \
|
||||
-o ps5_upload_server.elf main.c
|
||||
|
||||
if [ -f "ps5_upload_server.elf" ]; then
|
||||
echo ""
|
||||
|
||||
+3292
-172
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,343 @@
|
||||
# 🚀 PS5 Upload Suite - High-Speed File Transfer
|
||||
|
||||
**By Manos**
|
||||
|
||||
**Version 4.1.0 - Memory Optimization & Large File Handling**
|
||||
|
||||
Custom high-speed file transfer system for PS5 with etaHEN. Achieves **104+ MB/s** upload speeds for large files using parallel chunked uploads!
|
||||
|
||||
⭐ **NEW in v4.1.0:**
|
||||
- 🧠 **Memory Optimization** - Handles 156K+ files without crashes
|
||||
- ⚡ **Reduced UI Stuttering** - Batch logging for smoother experience
|
||||
- 🔧 **Lock-Free Operations** - ConcurrentDictionary for thread-safety
|
||||
- 🧹 **Auto Memory Cleanup** - Completed files removed from tracking
|
||||
- � **Incremental Processing** - No massive dictionary allocations
|
||||
|
||||
📱 **Android Mobile Client available!**
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📦 Downloads
|
||||
|
||||
Download the latest release from the [Releases](https://github.com/manos555555/PS5-Upload-Suite/releases) page:
|
||||
|
||||
### 1. PS5 Server Payload
|
||||
- **File:** `ps5_upload_server.elf`
|
||||
- **Port:** 9113
|
||||
- High-speed custom binary protocol
|
||||
- 16MB socket buffers for maximum throughput
|
||||
- Multi-threaded client handling
|
||||
|
||||
### 2. Windows GUI Client
|
||||
- **File:** `PS5Upload.exe`
|
||||
- Self-contained executable (no .NET installation required)
|
||||
- Modern dark theme UI
|
||||
- Drag & drop file/folder upload
|
||||
- Real-time upload progress with speed tracking
|
||||
- Transfer History with success/failed tracking
|
||||
- Privacy-focused (no storage monitoring)
|
||||
|
||||
### 3. 📱 Android Mobile Client
|
||||
- **File:** `PS5UploadMobile.apk`
|
||||
- Upload/download files from your phone
|
||||
- Multi-PS5 Profiles support
|
||||
- Browse PS5 filesystem
|
||||
- Favorites for quick navigation
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Step 1: Load PS5 Payload
|
||||
|
||||
1. Download `ps5_upload_server.elf` from the [Releases](https://github.com/manos555555/PS5-Upload-Suite/releases) page
|
||||
|
||||
2. Copy it to your PS5: `/data/etaHEN/payloads/ps5_upload_server.elf`
|
||||
|
||||
3. Load the payload with elfldr
|
||||
|
||||
4. You should see notification: `PS5 Upload Server: 192.168.0.XXX:9113 - By Manos`
|
||||
|
||||
### Step 2: Run Windows Client
|
||||
|
||||
1. Run `PS5Upload.exe` on your PC
|
||||
|
||||
2. Enter your PS5's IP address (e.g., `192.168.0.160`)
|
||||
|
||||
3. Click **Connect**
|
||||
|
||||
4. Browse files/folders or drag & drop
|
||||
|
||||
5. Click **Upload to PS5**
|
||||
|
||||
---
|
||||
|
||||
## 🎨 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
|
||||
|
||||
### Windows Client
|
||||
✅ **Modern UI** - Dark theme, maximized window for better visibility
|
||||
✅ **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
|
||||
✅ **Transfer History** - Track all uploads/downloads with success/failed status
|
||||
✅ **Auto-Clear History** - Optional auto-clear on startup
|
||||
✅ **Folder Upload** - Recursive directory upload
|
||||
✅ **Download Files** - Download from PS5 to PC
|
||||
✅ **🔍 Smart Search** - Full filesystem indexing with instant search (NEW v3.3)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
### v3.1.0 Optimized Results:
|
||||
- ✅ **104+ MB/s** upload speed for large files (parallel chunked uploads)
|
||||
- ✅ **4 parallel chunk connections** for maximum throughput
|
||||
- ✅ **500MB chunks** - optimal size for PS5 disk performance
|
||||
- ✅ **Real-time progress** - Speed/ETA updates during upload
|
||||
- ✅ **Zero chunk failures** - Fixed race conditions
|
||||
- ✅ **Fully responsive UI** throughout upload
|
||||
- ✅ **No memory leaks** - stable operation
|
||||
|
||||
### Previous Results (v1.3.0):
|
||||
- ✅ **42,801 files** uploaded successfully
|
||||
- ✅ **60-150 MB/s** sustained throughput
|
||||
|
||||
| Network | Expected Upload Speed | Expected Download Speed |
|
||||
|---------|----------------------|------------------------|
|
||||
| **Gigabit Ethernet** | 88-110 MB/s (aggregate) | 100-120 MB/s |
|
||||
| **WiFi 6 (5GHz)** | 32-70 MB/s (aggregate) | 60-80 MB/s |
|
||||
| **WiFi 5 (5GHz)** | 20-50 MB/s (aggregate) | 40-60 MB/s |
|
||||
|
||||
**Note:** Upload speeds depend on network quality and PS5 disk write performance. WiFi has ~40-60% overhead compared to Ethernet.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Connection Failed
|
||||
- Make sure PS5 payload is running
|
||||
- Check PS5 IP address is correct
|
||||
- Verify both devices are on same network
|
||||
- Check firewall settings
|
||||
|
||||
### Slow Upload Speed
|
||||
- Use wired Ethernet connection (not WiFi)
|
||||
- Close other network applications
|
||||
- Check PS5 disk health
|
||||
|
||||
### Upload Fails
|
||||
- Check PS5 has enough free space
|
||||
- Verify destination path exists
|
||||
- Check file permissions
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Notes
|
||||
|
||||
- Server only accepts connections from local network
|
||||
- No authentication required (local network only)
|
||||
- SHUTDOWN command only works from localhost
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.2.0
|
||||
|
||||
### 🎯 Improvements:
|
||||
|
||||
#### 1. 💾 Accurate Storage Display
|
||||
- **Storage now matches PS5 UI** - Shows ~848 GB instead of raw 872 GB
|
||||
- **Accounts for reserved space** - Same calculation as PS5 system
|
||||
|
||||
#### 2. 📱 Mobile App Improvements
|
||||
- **Multi-select mode** - Toggle button to select multiple files/folders
|
||||
- **Folder browser** - Visual folder picker for Copy/Move destinations
|
||||
- **Sorted file list** - Folders always displayed at top
|
||||
- **Exit confirmation** - Prompt when pressing back button
|
||||
- **Delete folders** - Now supports folder deletion (not just files)
|
||||
- **Batch operations** - Delete/download multiple items at once
|
||||
|
||||
#### 3. 🐛 Bug Fixes
|
||||
- **Fixed empty folder deletion** - No more "Unexpected response" error
|
||||
- **Fixed Copy/Move crash** - No longer crashes when selecting destination
|
||||
- **Fixed multi-select count** - Correct item count in delete confirmation
|
||||
- **Fixed folder navigation** - Can enter folders after actions complete
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.1.0
|
||||
|
||||
### 🚀 Performance Improvements:
|
||||
|
||||
#### 1. ⚡ Parallel Chunked Uploads (104 MB/s!)
|
||||
- **Large files (>100MB)** are now split into 500MB chunks
|
||||
- **4 parallel connections** upload chunks simultaneously
|
||||
- **Result:** ~104 MB/s upload speed (2x faster than before!)
|
||||
- **Fixed race condition:** First chunk creates file before others start
|
||||
- **Fixed closure bug:** Correct offset/size capture for parallel tasks
|
||||
|
||||
#### 2. 📊 Real-time Progress Display
|
||||
- **Speed display** now updates in real-time during chunked uploads
|
||||
- **ETA display** shows accurate time remaining
|
||||
- **Progress bar** correctly shows 0-100% for chunked files
|
||||
- No more speed dropping to 0 during large file uploads
|
||||
|
||||
#### 3. 💾 Storage Display Improvements
|
||||
- **Changed label** to "Data Storage (/data)" for clarity
|
||||
- **Uses statfs with f_bavail** for more accurate available space
|
||||
- **Real-time updates** every 5 seconds when connected
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v3.0.0
|
||||
|
||||
### 🐛 Critical Bug Fixes:
|
||||
|
||||
#### 1. 🔧 Path Normalization Fix
|
||||
- **Fixed:** Double-slash paths (`//mnt/ext1/...`) that caused directory creation to fail
|
||||
- **Affected:** All file operations (upload, download, delete, rename, copy, move)
|
||||
- **Solution:** Added `NormalizePath()` function in both client and server
|
||||
- **Result:** 100% reliable directory creation and file uploads
|
||||
|
||||
#### 2. 📱 Android Mobile Client
|
||||
- **NEW:** Full-featured Android app for PS5 file management
|
||||
- **Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
|
||||
- Upload files from phone to PS5
|
||||
- Download files from PS5 with Share option
|
||||
- Browse PS5 filesystem
|
||||
- Favorites for quick navigation
|
||||
- Debug Log with Copy to clipboard
|
||||
- Transfer History tracking
|
||||
|
||||
#### 3. 🛠️ Server Improvements
|
||||
- Path normalization in all handler functions
|
||||
- More robust error handling
|
||||
- Improved stability for parallel uploads
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.1.0
|
||||
|
||||
### 🚀 Performance Optimizations:
|
||||
|
||||
#### 1. ⚡ Massive Upload Speed Boost (88-110 MB/s)
|
||||
- **Server-side:** Replaced `fwrite()` with direct `write()` syscalls
|
||||
- **Client-side:** 8 parallel large file uploads (optimal for PS5 disk)
|
||||
- **Result:** 80-110 MB/s aggregate upload speed on Gigabit Ethernet
|
||||
- **Peak bursts:** Up to 2.05 GB/s when hitting disk cache
|
||||
- **Per-file:** 11-14 MB/s sustained per connection
|
||||
|
||||
#### 2. 📊 Transfer History
|
||||
- Complete history of all uploads and downloads
|
||||
- Success/Failed status tracking with error messages
|
||||
- Speed statistics (average, min, max)
|
||||
- Export to CSV/JSON for analysis
|
||||
- Persistent storage across sessions
|
||||
|
||||
#### 3. 🔄 Auto-Clear History on Startup
|
||||
- Optional checkbox to clear history automatically
|
||||
- Useful for keeping UI clean between sessions
|
||||
- Setting saved in `ps5_upload_settings.json`
|
||||
|
||||
#### 4. 🖥️ Maximized Window UI
|
||||
- Application opens in full-screen mode by default
|
||||
- Better visibility for large file transfers
|
||||
- Can be resized/restored as needed
|
||||
|
||||
### Technical Improvements:
|
||||
- ✅ **16MB socket buffers** (up from 4MB) for maximum throughput
|
||||
- ✅ **Per-file mutex locking** - Parallel writes without race conditions
|
||||
- ✅ **File pre-allocation** - Reduces disk fragmentation for large files
|
||||
- ✅ **Direct syscalls** - Bypasses stdio buffering overhead
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.0.0
|
||||
|
||||
### 🎉 4 Major New Features:
|
||||
|
||||
#### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination selection
|
||||
- Real-time progress tracking with speed display
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
#### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button to reset
|
||||
|
||||
#### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent storage in JSON
|
||||
|
||||
#### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between different PS5 consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
### Previous Stability (v1.3.0):
|
||||
- ✅ **Zero connection drops** - 5 minute socket timeout + aggressive keepalive
|
||||
- ✅ **Fully responsive UI** - Async updates + throttled logging
|
||||
- ✅ **No memory leaks** - Proper resource disposal
|
||||
- ✅ **Tested:** 42,801 files uploaded with 100% success rate
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
This software is provided as-is for personal use.
|
||||
|
||||
---
|
||||
|
||||
## 👤 Author
|
||||
|
||||
**Manos**
|
||||
|
||||
Created with ❤️ for the PS5 homebrew community
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
- PS5 SDK
|
||||
- etaHEN
|
||||
- Inspired by ps5upload by PhantomPtr
|
||||
- PS5 homebrew community
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions, please open an issue on GitHub.
|
||||
|
||||
---
|
||||
|
||||
**Enjoy blazing-fast file transfers on your PS5!** 🚀
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Reference in new issue
Block a user