Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4005c1c00 | ||
|
|
6501452103 | ||
|
|
367e57ca1f | ||
|
|
80b75d30eb | ||
|
|
1f80816b5a | ||
|
|
fa4635cadb | ||
|
|
49faaf1ba6 | ||
|
|
b2261ee5f8 | ||
|
|
6dce564861 | ||
|
|
05315d6966 | ||
|
|
33d44f429d | ||
|
|
714d3f2a70 | ||
|
|
9f0b725566 | ||
|
|
c721be0956 | ||
|
|
92732f34f2 | ||
|
|
6cf0fdd114 | ||
|
|
7d48ae610b | ||
|
|
5b65f0dd32 |
No files matched your search
@@ -40,7 +40,13 @@ payload/appdb_*.db
|
||||
payload/port9113_killer.*
|
||||
lib-prospero-pkg/test-stream/realbuild-out/
|
||||
|
||||
# legacy clients (WPF v5/v6, old MAUI app, empty stub template)
|
||||
# legacy clients (WPF v5/v6, old MAUI app, empty stub template, old Android WIP)
|
||||
client/
|
||||
mobile/
|
||||
client-android/
|
||||
client-avalonia-android/
|
||||
|
||||
# dev scratch: homebrew pkg builds, extracted binaries — not for the repo
|
||||
homebrew/
|
||||
|
||||
# vendored library source — the client uses the compiled dll (client-avalonia/libs/)
|
||||
lib-prospero-pkg/
|
||||
@@ -1,640 +1,77 @@
|
||||
# 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
|
||||
**Complete stability overhaul** - Fixed all critical bugs causing connection drops, UI freezes, and upload failures. System now handles **42,801+ files** with **zero errors** and **full gigabit speeds**.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bug Fixes
|
||||
|
||||
### **PAYLOAD (Server-Side) - 7 Critical Fixes**
|
||||
|
||||
#### 1. **Per-File Mutex Implementation**
|
||||
- **Issue:** Multiple threads could write to the same file simultaneously causing data corruption
|
||||
- **Fix:** Implemented per-file mutex system using hash map for file path locking
|
||||
- **Impact:** Prevents race conditions during chunked uploads
|
||||
|
||||
#### 2. **Memory/Mutex Cleanup on Disconnect**
|
||||
- **Issue:** Mutexes and memory not properly released when client disconnected
|
||||
- **Fix:** Added proper cleanup in client disconnect handler
|
||||
- **Impact:** Prevents memory leaks and mutex deadlocks
|
||||
|
||||
#### 3. **fflush() Race Condition**
|
||||
- **Issue:** `fflush()` called outside file mutex, causing potential data corruption
|
||||
- **Fix:** Moved `fflush()` inside mutex lock in `handle_upload_chunk`
|
||||
- **Location:** `main.c:869-890`
|
||||
- **Impact:** Ensures data integrity during concurrent writes
|
||||
|
||||
#### 4. **File Creation Race Condition**
|
||||
- **Issue:** Multiple threads could attempt to create/open same file simultaneously
|
||||
- **Fix:** Added `pthread_mutex_lock` around file creation and `fseeko` in `handle_start_upload`
|
||||
- **Location:** `main.c:814-844`
|
||||
- **Impact:** Prevents file corruption during parallel uploads
|
||||
|
||||
#### 5. **Global Scan Counter Issues**
|
||||
- **Issue:** Static variables in recursive `count_files_recursive` caused stale values
|
||||
- **Fix:** Moved `scan_count` and `last_scan_notify` to global scope
|
||||
- **Location:** `main.c:324-366`
|
||||
- **Impact:** Accurate progress reporting during folder deletion
|
||||
|
||||
#### 6. **Directory Counting Bug**
|
||||
- **Issue:** `count_files_recursive` only counted files, not directories
|
||||
- **Fix:** Added directory counting in recursive function
|
||||
- **Location:** `main.c:350-351`
|
||||
- **Impact:** Correct file count for folders with only subdirectories
|
||||
|
||||
#### 7. **Socket Timeout for Large Files** ⭐ **CRITICAL FIX**
|
||||
- **Issue:** PS5 closed connections during large file uploads (100MB+) due to default timeout
|
||||
- **Fix:** Added 5-minute socket timeout (`SO_RCVTIMEO`/`SO_SNDTIMEO`)
|
||||
- **Location:** `main.c:1142-1148`
|
||||
- **Impact:** **Eliminates all connection drops** - enables stable upload of 15GB+ files
|
||||
- **Additional:** Aggressive TCP keepalive (10s idle, 5s interval, 3 probes)
|
||||
|
||||
---
|
||||
|
||||
### **CLIENT (Windows App) - 8 Critical Fixes**
|
||||
|
||||
#### 1. **ConnectAsync Timeout Memory Leak**
|
||||
- **Issue:** `TcpClient` not disposed on connection timeout
|
||||
- **Fix:** Added proper `Dispose()` call in timeout exception handler
|
||||
- **Location:** `Protocol.cs:62-67`
|
||||
- **Impact:** Prevents memory leaks during connection failures
|
||||
|
||||
#### 2. **ConnectAsync Exception Memory Leak**
|
||||
- **Issue:** `TcpClient` not disposed on general connection exceptions
|
||||
- **Fix:** Added proper `Dispose()` call in exception handler
|
||||
- **Location:** `Protocol.cs:81-84`
|
||||
- **Impact:** Prevents memory leaks during network errors
|
||||
|
||||
#### 3. **Disconnect Memory Leak**
|
||||
- **Issue:** `NetworkStream` and `TcpClient` not properly disposed
|
||||
- **Fix:** Added explicit `Dispose()` calls for both objects
|
||||
- **Location:** `Protocol.cs:91-92`
|
||||
- **Impact:** Prevents memory leaks during normal disconnection
|
||||
|
||||
#### 4. **Upload Deadlock During Chunked Uploads**
|
||||
- **Issue:** Tasks never removed from `activeTasks` when connection index mismatched
|
||||
- **Fix:** Always remove completed task regardless of connection index validity
|
||||
- **Location:** `MainWindow.xaml.cs:692-734`
|
||||
- **Impact:** Prevents infinite wait during parallel chunked uploads
|
||||
|
||||
#### 5. **Chunking Disabled for Stability** ⭐ **CRITICAL FIX**
|
||||
- **Issue:** PS5 cannot handle concurrent writes to same file, even with per-file mutex
|
||||
- **Fix:** Disabled chunking completely - use single connection per file
|
||||
- **Location:** `MainWindow.xaml.cs:611-614`
|
||||
- **Impact:** **Eliminates all "connection aborted" errors**
|
||||
- **Rationale:** Even with 2 chunks, PS5 actively closes connections during concurrent writes
|
||||
|
||||
#### 6. **UI Freeze During Uploads**
|
||||
- **Issue:** `Dispatcher.Invoke()` blocked UI thread when uploading thousands of files
|
||||
- **Fix:** Replaced with `Dispatcher.InvokeAsync()` in `UpdateUploadStats`
|
||||
- **Location:** `MainWindow.xaml.cs:115-158`
|
||||
- **Impact:** **Fully responsive UI** during uploads
|
||||
|
||||
#### 7. **Log Flooding Causing UI Freeze**
|
||||
- **Issue:** LogTextBox filled with hundreds of thousands of entries, causing UI slowdown
|
||||
- **Fix:**
|
||||
- Throttle verbose messages (log every 50th message)
|
||||
- Limit log size to 1000 lines (remove oldest 200 when limit reached)
|
||||
- Use `InvokeAsync` with `Background` priority
|
||||
- **Location:** `MainWindow.xaml.cs:165-206`
|
||||
- **Impact:** **Prevents UI freeze** and memory issues
|
||||
|
||||
#### 8. **Optimal Parallel Connection Strategy**
|
||||
- **Issue:** Need to balance speed vs stability
|
||||
- **Fix:** Use **6 parallel single-connection uploads** (no chunking)
|
||||
- **Impact:**
|
||||
- **Full gigabit speeds** (60-150 MB/s total throughput)
|
||||
- **Zero connection drops**
|
||||
- **Maximum stability**
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Improvements
|
||||
|
||||
### Before Fixes:
|
||||
- ❌ Connection drops every 100-200 files
|
||||
- ❌ UI completely frozen during uploads
|
||||
- ❌ Chunked uploads failed with "connection aborted" errors
|
||||
- ❌ Memory leaks causing application slowdown
|
||||
- ❌ Upload deadlocks requiring application restart
|
||||
|
||||
### After Fixes:
|
||||
- ✅ **42,801+ files uploaded with ZERO errors**
|
||||
- ✅ **Fully responsive UI** throughout entire upload
|
||||
- ✅ **Consistent speeds:** 11-190 MB/s per connection
|
||||
- ✅ **Total throughput:** 60-150 MB/s (full gigabit)
|
||||
- ✅ **No memory leaks** - stable memory usage
|
||||
- ✅ **No deadlocks** - perfect task management
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Technical Architecture
|
||||
|
||||
### Payload (PS5 Server):
|
||||
```c
|
||||
// 5 minute socket timeout for large files
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 300;
|
||||
setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
setsockopt(client_sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
|
||||
// Aggressive TCP keepalive
|
||||
int keepidle = 10; // Start after 10s idle
|
||||
int keepintvl = 5; // Send probe every 5s
|
||||
int keepcnt = 3; // Drop after 3 failures
|
||||
setsockopt(client_sock, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
|
||||
setsockopt(client_sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
|
||||
setsockopt(client_sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
|
||||
|
||||
// Per-file mutex for concurrent uploads
|
||||
pthread_mutex_t* file_mutex = get_file_mutex(file_path);
|
||||
pthread_mutex_lock(file_mutex);
|
||||
fwrite(buffer, 1, bytes_received, fp);
|
||||
fflush(fp);
|
||||
pthread_mutex_unlock(file_mutex);
|
||||
```
|
||||
|
||||
### Client (Windows):
|
||||
```csharp
|
||||
// 6 parallel single-connection uploads
|
||||
const int MaxParallelUploads = 6;
|
||||
|
||||
// Chunking disabled for stability
|
||||
if (false) // Chunking permanently disabled
|
||||
{
|
||||
// PS5 cannot handle concurrent writes to same file
|
||||
}
|
||||
|
||||
// Throttled logging for responsive UI
|
||||
if (message.Contains("📊") || message.Contains("⬆️ Uploading:"))
|
||||
{
|
||||
_logCounter++;
|
||||
if (_logCounter % 50 != 0)
|
||||
return; // Skip verbose messages
|
||||
}
|
||||
|
||||
// Async UI updates
|
||||
Dispatcher.InvokeAsync(() => {
|
||||
// Update UI without blocking
|
||||
}, DispatcherPriority.Background);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Tested Configuration
|
||||
|
||||
### Test Case:
|
||||
- **Files:** 42,801 files
|
||||
- **Total Size:** ~7.52 GB
|
||||
- **File Types:** .xpps, .pak, .mp4, .bk2, .bank, .json, .prx, .sprx
|
||||
- **Largest File:** 15.96 GB (ac2-ps5.pak)
|
||||
|
||||
### Results:
|
||||
- **Success Rate:** 100% (zero errors)
|
||||
- **Average Speed:** 60-150 MB/s
|
||||
- **Peak Speed:** 190 MB/s per connection
|
||||
- **UI Responsiveness:** Fully responsive throughout
|
||||
- **Memory Usage:** Stable (no leaks)
|
||||
- **Completion Time:** ~1.7 hours for 42,801 files
|
||||
|
||||
---
|
||||
|
||||
## 📝 Known Limitations
|
||||
|
||||
1. **Chunking Disabled:** While this reduces speed for individual large files, it's necessary for PS5 stability
|
||||
2. **6 Connection Limit:** More connections cause overhead without speed benefit
|
||||
3. **PS5 Hardware Constraint:** PS5 cannot handle concurrent writes to same file, even with proper locking
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migration Guide
|
||||
|
||||
### From v1.2 to v1.3:
|
||||
1. **Upload new payload** (`ps5_upload_server.elf`) to PS5
|
||||
2. **Restart payload** with elfldr
|
||||
3. **Use new client** (`PS5Upload.exe`)
|
||||
4. **No configuration changes needed** - works out of the box
|
||||
|
||||
### Breaking Changes:
|
||||
- None - fully backward compatible
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 Developer Notes
|
||||
|
||||
### Why Chunking Was Disabled:
|
||||
Multiple attempts were made to enable chunking with various configurations:
|
||||
- 4 chunks: Connection drops
|
||||
- 3 chunks: Connection drops
|
||||
- 2 chunks: Connection drops
|
||||
|
||||
**Root Cause:** PS5 FreeBSD kernel actively closes TCP connections when multiple threads perform concurrent `fwrite()` operations on the same file, even with proper per-file mutex locking. The 5-minute socket timeout helps, but doesn't solve the fundamental issue of concurrent writes.
|
||||
|
||||
**Solution:** Single connection per file ensures only one thread writes to each file, eliminating the issue entirely.
|
||||
|
||||
### Why 6 Parallel Connections:
|
||||
- **Less than 6:** Underutilizes network bandwidth
|
||||
- **More than 6:** Increases CPU/memory overhead without speed benefit
|
||||
- **6 connections:** Perfect balance for PS5 hardware
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
**Developed by:** Manos
|
||||
**Testing:** Extensive real-world testing with 42,801+ files
|
||||
**Platform:** PS5 (FreeBSD) + Windows 10/11
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This software is provided as-is for educational and personal use.
|
||||
# Changelog - PS5 Suite
|
||||
|
||||
All notable changes to **PS5 Suite** (client app + on-console payload).
|
||||
|
||||
---
|
||||
|
||||
## v7.3.0 (October 2026)
|
||||
|
||||
### New Features
|
||||
- **Unmount All button** — removes every mounted game in one action, with confirmation dialog and per-title progress. Only suite-mounted games are touched (verified by `mount.lnk` marker or active `/system_ex/app` overlay) — real installed titles are never affected.
|
||||
|
||||
### Major Improvements
|
||||
- **Full k-stuff compatibility** — every feature previously gated behind etaHEN now works on k-stuff too: mount/unmount, launch game, controller info + lightbar, PKG installs, suspend/resume/kill.
|
||||
- **Mount/unmount no longer kills the home screen** — on kstuff firmwares 12.00+, game registration now runs through SceShellCore's own `installTitleDir` via an injected code-cave bridge (offsets verified per-firmware). The daemon writes the registry rows and notifies the UI itself, so icons appear and disappear instantly with zero renderer restarts.
|
||||
- **Unregister via `sceAppInstUtilAppUnInstall`** — official daemon IPC removes the icon live; direct SQLite writes remain only as a watchdog-guarded fallback.
|
||||
- **Launch Game works on k-stuff** — removed the etaHEN-only gate. `sceLncUtilLaunchApp` error codes are now handled correctly: the daemon launches asynchronously, so the payload polls for the target's `eboot.bin` process (up to ~8s) and reports real success with the actual app_id. If another game holds the foreground, it is closed via `sceLncUtilKillApp` and the launch is retried — same as the PS5 UI's "close game?" flow.
|
||||
- **Power actions use official daemon paths** — reboot/shutdown no longer call the raw `reboot()` syscall (which halted the kernel but left the console in a zombie state that needed a power-plug pull). Reboot now goes through `sceSystemServiceRequestReboot`; shutdown through `sceSystemStateMgrTurnOff` with `sceShellCoreUtilRequestShutdown`/`RequestPowerOff` fallbacks. Raw syscall kept only as last resort.
|
||||
- **Controller info + lightbar on k-stuff** — removed the etaHEN gate; the ShellCore/ShellUI remote pad bridge (ptrace + remote calls inside a process that owns a real pad session) works on both loaders. Verified live with real analog/button data.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed mounted-game icons landing past the visible edge of the home row (`lastAccessIndex` now uses MAX+1 like real installs; stale index-0 rows are healed automatically).
|
||||
- Fixed `proc_find_name` ENOMEM race that could fail process lookups.
|
||||
- Stale ShellCore bridge hooks are detected and recovered instead of failing the registration.
|
||||
- Fixed launch kill-heuristic matching system daemons with numeric title IDs — a running game is now identified strictly by `comm == "eboot.bin"`; system processes (SceCdlgApp, ShellUI) are never touched.
|
||||
- Trophy unlock/re-lock fixes from v7.2.x verified under k-stuff (fixes issue #11).
|
||||
|
||||
### Removed
|
||||
- **Capture Screen** — removed from payload and both clients. `sceScreenShotCapture` requires a registered app-thread context the payload cannot satisfy: local calls return `0x80C1B001` and remote calls inside ShellUI/ShellCore/capture daemons/`eboot.bin` all return `0x80BE0001`. Rather than a button that can never work, the feature is gone; browsing/downloading/deleting existing captures in the Screenshots tab is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## v7.2.4 (October 2026)
|
||||
|
||||
### Bug Fixes
|
||||
- **Fixed Windows startup crash** — the self-contained single-file build failed on some systems with `System.DllNotFoundException` (`SkiaSharp.SKImageInfo` / `libSkiaSharp.dll`). `IncludeNativeLibrariesForSelfExtract` is now set permanently in `client-avalonia/PS5Suite.csproj`, so every future publish extracts the native Skia/HarfBuzz/ANGLE libraries correctly. (Fixes issue #10)
|
||||
|
||||
### New Features
|
||||
- **Automatic update checker on startup** — desktop and Android now query the GitHub releases API once per launch and prompt **only** when a strictly newer release exists. *Yes* opens the release page in the browser; offline/rate-limit failures stay silent; no prompt when already on the latest version.
|
||||
- Android app now reads its version from the assembly (`<Version>7.2.4</Version>`) so the update checker reports correctly.
|
||||
|
||||
---
|
||||
|
||||
## v7.2.3 (October 2026)
|
||||
|
||||
### New Features
|
||||
- **Android client — full feature parity with desktop**: Files toolbar (upload/download/delete/rename/new folder), SAF-based local browser, Games, Saves, Screenshots, Trophies, Prospero Store, dialogs, live search & sorting, mount/stop game, cover icons, trophy unlock/re-lock.
|
||||
- **Trophy re-lock** — per-trophy lock via payload-side `TRPTITLE.DAT` rewriting, verified live.
|
||||
- **Prospero Store** — browse the `homebrew.page` catalogue by category, download + SHA-256 verify, extract, install to `/data/homebrew/<TITLEID>/`, auto-mount.
|
||||
- **PKG-Zone Homebrew Store** — integrated catalogue with one-tap install.
|
||||
- **macOS `.app` packaging** — releases ship `PS5Suite.app` inside a ZIP that preserves the Unix executable bit (`0o755`), so no `chmod +x` needed after download. (Fixes issue #8)
|
||||
- Live search + sorting in Games and Trophies tabs.
|
||||
- System Info tab shows real values read from the console, with graceful "unavailable" for unsupported fields.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed false-positive trophy mask detection (tested on Astro Bot).
|
||||
- Fixed Windows single-file publish missing macOS/Linux packaging conventions.
|
||||
|
||||
### Documentation
|
||||
- Removed all Blu-ray disc-dump references from README and release notes.
|
||||
- Added trophy-safety disclaimer: unlocking many trophies with unrealistically short/identical timestamps can look suspicious if the console ever syncs to PSN — use offline, at your own risk.
|
||||
|
||||
---
|
||||
|
||||
## v7.2.x and earlier
|
||||
|
||||
Earlier development history (the project evolved from *PS5 Upload Suite* — a file uploader — into the full management suite). Highlights carried forward:
|
||||
|
||||
- Parallel high-speed file transfer engine (~280 MB/s aggregate)
|
||||
- Game mount / unmount / launch / stop with `nullfs` + `sce_sys` handling
|
||||
- Decrypted save backup/restore and screenshots gallery
|
||||
- Live hardware monitoring (temps, CPU, RAM), fan control, console LED, DualSense lightbar
|
||||
- Remote shell, memory read/write tools, kernel log
|
||||
- PKG streaming install, FPKG conversion, archive upload
|
||||
- Full filesystem indexing + instant search on console
|
||||
- Payload self-update from GitHub releases
|
||||
|
||||
Full granular history is available in the git log.
|
||||
@@ -1,217 +1,295 @@
|
||||
# 🎮 PS5 Suite v7.1.5 — Complete PS5 Management Platform
|
||||
|
||||
**By Manos**
|
||||
|
||||
All-in-one management suite for jailbroken PS5 consoles. One app — on Windows, Linux or macOS (Android build in progress) — that does everything: high-speed file transfers, game mount/launch, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, memory tools, fan control, kernel log and a remote shell.
|
||||
|
||||

|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **File Transfer** — dual PC/PS5 browser, storage bar | **Games** — list, mount/launch context menu |
|
||||
|  |  |
|
||||
| **Saves & Media** — decrypted mount/backup/restore | **System Info** — live sensors, per-core CPU, RAM |
|
||||
|  |  |
|
||||
| **Shell** — remote terminal on the console | **Debug Log** — every operation, timestamped |
|
||||
|
||||
---
|
||||
|
||||
## 📥 What's in this release
|
||||
|
||||
| Asset | Platform |
|
||||
|---|---|
|
||||
| `PS5Suite-Windows-x64.exe` | Windows 10/11 64-bit — self-contained, no .NET install needed |
|
||||
| `PS5Suite-Linux-x64` | Linux 64-bit — `chmod +x` and run |
|
||||
| `PS5Suite-Linux-ARM64` | ARM64 Linux (Raspberry Pi, etc.) |
|
||||
| `PS5Suite-macOS-x64` | Intel Macs — `chmod +x`, Gatekeeper: right-click → Open |
|
||||
| `PS5Suite-macOS-ARM64` | Apple Silicon (M1/M2/M3/M4) |
|
||||
| `ps5_suite_server.elf` | **Required** on-console server component |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Setup (2 minutes)
|
||||
|
||||
1. Load a jailbreak / payload loader on the PS5 (e.g. GoldHEN, or etaHEN itemzflow).
|
||||
2. Open the app → expand **⚙️ Payload Settings** → **📂 Browse** → pick `payload/ps5_suite_server.elf` → **📤 Send Payload** (default port 9020).
|
||||
3. The payload installs itself and starts the suite server (ports 9113–9116).
|
||||
4. Press **🔍** to auto-discover the console on your LAN, or type its IP → **🔌 Connect**.
|
||||
5. Done. Future payload updates go through **🔄 Self-Update Payload** (pushes the new ELF through the running server) or **⬆️ Update from GitHub**.
|
||||
|
||||
💾 Save your console(s) as named **connection profiles** for one-click reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 📂 EVERYTHING the program does — feature by feature
|
||||
|
||||
### 🔌 Top bar / Connection
|
||||
|
||||
- **Auto-discover (🔍)** — broadcasts on the LAN and finds the PS5 automatically.
|
||||
- **Profiles** — save multiple consoles by name, delete them, reconnect instantly.
|
||||
- **Storage Info panel** — free/total space on the console's drive, **🔄 Refresh Storage** button.
|
||||
- **Payload Settings** — pick the ELF, set the injection port (default 9020), **Send Payload**, **Self-Update Payload** (updates the running server in place), **Update from GitHub** (checks for new suite releases).
|
||||
- **Buy Me a Coffee** link in the header. ❤️
|
||||
|
||||
---
|
||||
|
||||
### 📁 FILE TRANSFER (main screen — dual browser)
|
||||
|
||||
Two side-by-side panes: your PC on the left, the PS5 on the right.
|
||||
|
||||
**Local PC pane**
|
||||
- Browse all local drives/folders like a file manager.
|
||||
- **📄 Files** — multi-select files to queue. **📁 Folder** — queue an entire folder (recursive).
|
||||
- **🌐 NAS** — add a network/SMB path (`\\server\share\folder`) directly — uploads straight from your NAS to the PS5.
|
||||
- Right-click a local `.pkg` → **📦 Install on PS5** — *streams the package from the PC to the console's installer over HTTP; no 20 GB copy to the PS5 drive needed.*
|
||||
- **🗑️ Clear** empties the upload queue.
|
||||
|
||||
**PS5 remote pane**
|
||||
- Full filesystem browser: path bar + **Go**, ⭐ **favorites** (saved per console), live **search/filter** box.
|
||||
- Right-click menu: **⬇️ Download**, **📝 Rename**, **📋 Copy**, **✂️ Move**, **🗑️ Delete**, **🗑️ Delete Selected** (multi-select).
|
||||
- Downloads ask where to save on the PC; folders download recursively.
|
||||
|
||||
**Transfer engine**
|
||||
- **📤 Upload** — parallel chunked transfers; ~**280 MB/s** on fast LANs (2.5GbE), saturates gigabit easily.
|
||||
- Duplicate files on the PS5 trigger a dialog: skip / overwrite / rename.
|
||||
- Live progress bar with per-file %, overall speed (MB/s) and ETA. **⏹ STOP** cancels cleanly.
|
||||
- **🎮 Mount Games** — mounts uploaded game dumps so they appear in the PS5's game list.
|
||||
- **Transfer History** — every completed/failed transfer logged; **🔄 Retry** failed items, **🗑️ Remove**/**Clear All**.
|
||||
|
||||
---
|
||||
|
||||
### 🎮 GAMES (sidebar)
|
||||
|
||||
- Lists every installed + mounted title: name, title ID, region, version, size, path.
|
||||
- **▶ Launch Game** — boots the title directly.
|
||||
- **ℹ️ View Details** — full info window (param.sfo metadata, content ID, category, versions).
|
||||
- **📥 Mount Game** / **🗑️ Unmount Game** — mount/unmount game images on the fly.
|
||||
- **📂 Open Path** — jumps the PS5 browser straight into that game's folder.
|
||||
- **📥 Mount All** batch-mounts everything in the list.
|
||||
|
||||
---
|
||||
|
||||
### 💾 SAVES & MEDIA → Saves
|
||||
|
||||
- Enumerates all save data on the console (per user, per title) with size/type badges.
|
||||
- **🔓 Mount (decrypt)** — mounts the save decrypted on the PS5 so its files are readable.
|
||||
- **📂 Browse** — opens the Save Browser window into the mounted save's files.
|
||||
- **🔒 Unmount & save** — unmounts and re-seals the save.
|
||||
- **📥 Backup (decrypted)** — downloads the decrypted save contents to PC.
|
||||
- **📥 Backup (raw image)** — downloads the raw encrypted save image 1:1.
|
||||
- **📤 Restore** — pushes a backup back onto the console.
|
||||
- **🗑️ Delete save** / **📂 Copy path**.
|
||||
|
||||
---
|
||||
|
||||
### 📷 SAVES & MEDIA → Screenshots
|
||||
|
||||
- Grid of all captured screenshots & video clips on the PS5 with **thumbnails**, filename, size, date.
|
||||
- **⬇ Download** selected (or batch to a chosen folder), **🗑️ Delete**, **📋 Copy Path**.
|
||||
- Double-click/preview to view the image full-size.
|
||||
|
||||
---
|
||||
|
||||
### 🖥️ SYSTEM INFO (live hardware monitor)
|
||||
|
||||
Everything is read **live from the console** — nothing hardcoded; values the console can't report show as unavailable.
|
||||
|
||||
- **System Information** — model (CFI-xxxx), serial number, architecture, OS version, CPU cores, physical RAM.
|
||||
- **Extended Info** — firmware version, product code, uptime this boot, power-cycle count, BD drive presence, thermal alert status.
|
||||
- **Live Sensors** (● LIVE, auto-refresh) — 🔥 CPU temp, 🌡️ SoC temp, ⚡ CPU frequency, 🔋 SoC power draw (watts).
|
||||
- **CPU Usage** — per-core usage bars (all 8 cores) + total.
|
||||
- **RAM** — used/free memory.
|
||||
- **Storage** — free/total per mount point.
|
||||
- **Modules** — **🔄 Load** lists all loaded kernel/user modules.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Shell
|
||||
|
||||
- Run shell commands directly on the PS5 — type, Enter, output streams back.
|
||||
- Scrollable session history, **🗑️ Clear**, **💾 Save Log** to a PC file.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Search
|
||||
|
||||
- **🔄 Start Index** — the payload crawls the PS5 filesystem and builds a searchable index.
|
||||
- Instant search across **every** file/folder on the console — results open straight in the browser.
|
||||
- **🔃 Refresh** rebuilds, **🗑️ Clear** wipes the index.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Fan Control
|
||||
|
||||
- Reads the current fan threshold + live temperature.
|
||||
- **🌡️ Set Threshold** — custom temperature target.
|
||||
- Presets: **❄️ Cool (50 °C)**, **⚖️ Balanced (60 °C)**, **🔇 Quiet (70 °C)**.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Memory
|
||||
|
||||
- **Process list** of running PS5 processes (**↻ refresh**).
|
||||
- **🔍 First Scan / 🔎 Next Scan** — cheat-style value scanning in a process's memory.
|
||||
- **📖 Read** (hex dump, 256 B) and **✏️ Write** at arbitrary addresses — full debugging/patching toolkit.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Kernel Log
|
||||
|
||||
- Live kernel log viewer — choose **Last 32 KB / Last 128 KB / Full buffer**.
|
||||
- **Auto (3s)** checkbox for continuous refresh, **🔄 Refresh** manual.
|
||||
- **📋 Copy** to clipboard, **💾 Save…** to file.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → PKG
|
||||
|
||||
- **Install PKG** — three ways:
|
||||
- **📋 Browse PS5** — pick a `.pkg` already on the console.
|
||||
- **💻 Browse PC** — pick a local `.pkg`; it's **streamed over HTTP** (ranged requests, persistent keep-alive connections, 512 KB relay) straight into the PS5 installer. Verified with a 22.7 GB package.
|
||||
- Via the local-pane right-click "Install on PS5".
|
||||
- **🔄 Check Status** — queries the console's installer for progress/result.
|
||||
- Staged `.pkg` files on the PS5 are **auto-deleted** after successful install.
|
||||
- PS5 notifications show the real package name (not a generic label).
|
||||
|
||||
- **Convert to FPKG** — turn an extracted game folder into an installable fake package **entirely on the PC**:
|
||||
- Point at the source folder (with `sce_sys`), choose output, done.
|
||||
- Auto-fills title ID / content ID / version from `param.json`.
|
||||
- Optional **fake-signing** of `eboot.bin`, `*.elf`, `*.prx`, `*.sprx` (originals restored automatically after the build).
|
||||
- Builds the full PS5 container: inner PFS image, encrypted+signed outer PFS, CNT entry table, all SHA3-256 digests, RSA-3072 signatures, FIH finalization — powered by the bundled **LibProsperoPkg** engine.
|
||||
- Output verified against real package structure (field-accurate, validator-tested).
|
||||
|
||||
---
|
||||
|
||||
### 🧾 DEBUG LOG (sidebar)
|
||||
|
||||
- Timestamped log of **every** operation the app performs — connections, transfers, errors, PS5 responses.
|
||||
- **📋 Copy Log**, **🗑️ Clear Log**; a `ps5suite_*.log` file is also written automatically next to the executable for post-mortem debugging.
|
||||
- **Failed transfers** section with per-item **🔄 Retry** and **🗑️ Remove**.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical details
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| Client (Windows/Linux/macOS/Android) | C# / .NET 10, **Avalonia UI** — one shared codebase everywhere |
|
||||
| On-console payload | C, built with the **PS5 Payload SDK** (`prospero-clang`) |
|
||||
| FPKG engine | **LibProsperoPkg** (bundled) — CNT/PFS/PFSC/PFSv3/FIH build, read & verify |
|
||||
| Transfer protocol | Raw TCP, chunked + parallel streams |
|
||||
| Suite server ports | 9113–9116 |
|
||||
| Local PKG streamer ports | 18990–19009 |
|
||||
| Payload proxy port | 13801+ |
|
||||
|
||||
- All heavy work (transfers, FPKG builds) runs on background threads — the UI never freezes.
|
||||
- The Android app shares **the same UI + protocol code as the desktop EXE** (source included in the repo — APK build in progress).
|
||||
-
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
Requires a jailbroken PS5 with a payload loader. Save backup/restore and memory write features modify console state — **use at your own risk**. For personal/educational use; no copyrighted content included.
|
||||
|
||||
**Enjoy. — Manos**
|
||||
# 🎮 PS5 Suite v7.2.4 — Complete PS5 Management Platform
|
||||
|
||||
**By Manos**
|
||||
|
||||
All-in-one management suite for jailbroken PS5 consoles. One app — on **Windows, Linux, macOS and Android** — that does everything: high-speed file transfers, game mount/launch, **trophy unlock & re-lock**, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, an integrated Homebrew Store + Prospero Store, memory tools, fan control, kernel log, a remote shell — plus console LED control and DualSense lightbar support.
|
||||
|
||||
<div align="center">
|
||||
|
||||
### ☕ Enjoying PS5 Suite?
|
||||
|
||||
**This project is built for the PS5 scene, for free — if it saved you time or you just like what we do, consider buying us a coffee. Every cup goes straight back into new features, fixes and payload wizardry.** 🍻
|
||||
|
||||
**Got questions, ideas or found a bug? Join the community on Discord — we hang out there!**
|
||||
|
||||
[](https://ko-fi.com/manosmourtzakis)
|
||||
[](https://x.com/manos_moyrtzis)
|
||||
[](https://discord.gg/eRXG2Csmpy)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **File Transfer** — dual PC/PS5 browser, storage bar | **Games** — list, mount/launch context menu |
|
||||
|  |  |
|
||||
| **Saves & Media** — decrypted mount/backup/restore | **System Info** — live sensors, per-core CPU, RAM |
|
||||
|  |  |
|
||||
| **Shell** — remote terminal on the console | **Debug Log** — every operation, timestamped |
|
||||
|
||||
---
|
||||
|
||||
## 📥 What's in this release
|
||||
|
||||
| Asset | Platform |
|
||||
|---|---|
|
||||
| `PS5Suite-Windows-x64.exe` | Windows 10/11 64-bit — self-contained, no .NET install needed |
|
||||
| `PS5Suite-Linux-x64` | Linux 64-bit — `chmod +x` and run |
|
||||
| `PS5Suite-Linux-ARM64` | ARM64 Linux (Raspberry Pi, etc.) |
|
||||
| `PS5Suite-macOS-x64.zip` | Intel Macs — unzip → double-click `PS5Suite.app` (Gatekeeper: right-click → Open) |
|
||||
| `PS5Suite-macOS-ARM64.zip` | Apple Silicon (M1/M2/M3/M4) — same, exec permissions preserved |
|
||||
| `PS5Suite.apk` | **Android** — signed APK, full feature parity with desktop |
|
||||
| `ps5_suite_server.elf` | **Required** on-console server component |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Setup (2 minutes)
|
||||
|
||||
1. Load a jailbreak / payload loader on the PS5 — **fully compatible with both etaHEN and Kstuff** (any payload loader that accepts ELFs works). All features including mount/unmount, launch game and DualSense control work on k-stuff.
|
||||
2. Open the app → expand **⚙️ Payload Settings** → **📂 Browse** → pick `payload/ps5_suite_server.elf` → **📤 Send Payload** (default port 9021).
|
||||
3. The payload installs itself and starts the suite server (ports 9113–9116).
|
||||
4. Press **🔍** to auto-discover the console on your LAN, or type its IP → **🔌 Connect**.
|
||||
5. Done. Future payload updates go through **🔄 Self-Update Payload** (pushes the new ELF through the running server) or **⬆️ Update from GitHub**.
|
||||
|
||||
💾 Save your console(s) as named **connection profiles** for one-click reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 📂 EVERYTHING the program does — feature by feature
|
||||
|
||||
### 🔌 Top bar / Connection
|
||||
|
||||
- **Auto-discover (🔍)** — broadcasts on the LAN and finds the PS5 automatically.
|
||||
- **Profiles** — save multiple consoles by name, delete them, reconnect instantly.
|
||||
- **Storage Info panel** — free/total space on the console's drive, **🔄 Refresh Storage** button.
|
||||
- **Payload Settings** — pick the ELF, set the injection port (default 9021), **Send Payload**, **Self-Update Payload** (updates the running server in place), **Update from GitHub** (checks for new suite releases).
|
||||
- **Ko-fi support link** in the header. ❤️
|
||||
|
||||
---
|
||||
|
||||
### 📁 FILE TRANSFER (main screen — dual browser)
|
||||
|
||||
Two side-by-side panes: your PC on the left, the PS5 on the right.
|
||||
|
||||
**Local PC pane**
|
||||
- Browse all local drives/folders like a file manager.
|
||||
- **📄 Files** — multi-select files to queue. **📁 Folder** — queue an entire folder (recursive).
|
||||
- **🌐 NAS** — add a network/SMB path (`\\server\share\folder`) with **username/password login** — managed SMB client (SMBLibrary) so it works on every OS, not just Windows; credentials are kept in memory only. Guest/anonymous shares work too. Uploads stream straight from your NAS to the PS5.
|
||||
- Right-click a local or NAS `.pkg` → **📦 Install on PS5** — *streams the package to the console's installer over HTTP; no 20 GB copy to the PS5 drive needed.*
|
||||
- **🗑️ Clear** empties the upload queue.
|
||||
|
||||
**PS5 remote pane**
|
||||
- Full filesystem browser: path bar + **Go**, ⭐ **favorites** (saved per console), live **search/filter** box.
|
||||
- Right-click menu: **⬇️ Download**, **📝 Rename**, **📋 Copy**, **✂️ Move**, **🗑️ Delete**, **🗑️ Delete Selected** (multi-select).
|
||||
- Downloads ask where to save on the PC; folders download recursively.
|
||||
|
||||
**Transfer engine**
|
||||
- **📤 Upload** — parallel chunked transfers; ~**280 MB/s over Ethernet** on fast LANs (2.5GbE), saturates gigabit wired links easily. Wi-Fi will be significantly slower — use a wired connection on both PC and PS5 for full speed.
|
||||
- Duplicate files on the PS5 trigger a dialog: skip / overwrite / rename.
|
||||
- Live progress bar with per-file %, overall speed (MB/s) and ETA. **⏹ STOP** cancels cleanly.
|
||||
- **🎮 Mount Games** — mounts uploaded game dumps so they appear in the PS5's game list.
|
||||
- **Transfer History** — every completed/failed transfer logged; **🔄 Retry** failed items, **🗑️ Remove**/**Clear All**.
|
||||
|
||||
---
|
||||
|
||||
### 🎮 GAMES (sidebar)
|
||||
|
||||
- Lists every installed + mounted title: name, title ID, region, version, size, path.
|
||||
- **▶ Launch Game** — boots the title directly.
|
||||
- **ℹ️ View Details** — full info window (param.sfo metadata, content ID, category, versions).
|
||||
- **📥 Mount Game** / **🗑️ Unmount Game** — mount/unmount game images on the fly.
|
||||
- **📂 Open Path** — jumps the PS5 browser straight into that game's folder.
|
||||
- **📥 Mount All** batch-mounts everything in the list.
|
||||
|
||||
---
|
||||
|
||||
### 🛍️ GAMES → Homebrew Store
|
||||
|
||||
- Built-in **pkg-zone.com** catalog: browse/search every public PS5 homebrew package.
|
||||
- Covers, version + author info, one-tap **📥 Install** — the package streams straight to the console installer.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 GAMES → Prospero Store
|
||||
|
||||
- Built-in **homebrew.page** catalog: homebrew apps, games and tools with covers, categories and search.
|
||||
- **📥 Install** — downloads the app ZIP, verifies SHA-256, extracts, uploads to /data/homebrew/ with parallel connections and auto-mounts it on the PS5 home screen.
|
||||
|
||||
---
|
||||
|
||||
### 🏆 TROPHIES
|
||||
|
||||
- Lists every trophy set on the console (per game) with real data parsed from TRPTITLE.DAT — names, descriptions, grades (bronze/silver/gold/platinum), icons, hidden status and earned timestamps.
|
||||
- **🔓 Unlock** — awards a trophy live through the running game (the matching title must be running — the app tells you if it isn't).
|
||||
- **🔒 Lock** — re-locks a trophy by rewriting the set's unlock bitmasks in place (group masks, row flags and timestamps cleared) — as if it was never earned.
|
||||
- Search/filter across sets, live refresh after every change.
|
||||
|
||||
> ⚠️ **Warning:** unlocking many trophies with unrealistically short/identical timestamps can look suspicious if the console ever syncs to PSN. **I take no responsibility for any ban on your account or console** — use this feature offline and at your own risk.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 💾 SAVES & MEDIA → Saves
|
||||
|
||||
- Enumerates all save data on the console (per user, per title) with size/type badges.
|
||||
- **🔓 Mount (decrypt)** — mounts the save decrypted on the PS5 so its files are readable.
|
||||
- **📂 Browse** — opens the Save Browser window into the mounted save's files.
|
||||
- **🔒 Unmount & save** — unmounts and re-seals the save.
|
||||
- **📥 Backup (decrypted)** — downloads the decrypted save contents to PC.
|
||||
- **📥 Backup (raw image)** — downloads the raw encrypted save image 1:1.
|
||||
- **📤 Restore** — pushes a backup back onto the console.
|
||||
- **🗑️ Delete save** / **📂 Copy path**.
|
||||
|
||||
---
|
||||
|
||||
### 📷 SAVES & MEDIA → Screenshots
|
||||
|
||||
- Grid of all captured screenshots & video clips on the PS5 with **thumbnails**, filename, size, date.
|
||||
- **⬇ Download** selected (or batch to a chosen folder), **🗑️ Delete**, **📋 Copy Path**.
|
||||
- Double-click/preview to view the image full-size.
|
||||
|
||||
---
|
||||
|
||||
### 🖥️ SYSTEM INFO (live hardware monitor)
|
||||
|
||||
Everything is read **live from the console** — nothing hardcoded; values the console can't report show as unavailable.
|
||||
|
||||
- **System Information** — model (CFI-xxxx), serial number, architecture, OS version, CPU cores, physical RAM.
|
||||
- **Extended Info** — firmware version, product code, uptime this boot, power-cycle count, BD drive presence, thermal alert status.
|
||||
- **Live Sensors** (● LIVE, auto-refresh) — 🔥 CPU temp, 🌡️ SoC temp, ⚡ CPU frequency, 🔋 SoC power draw (watts).
|
||||
- **CPU Usage** — per-core usage bars (all 8 cores) + total.
|
||||
- **RAM** — used/free memory.
|
||||
- **Network** — interface info + ⚡ **LAN speed test** (payload streams 16 MB, shows real Mbps).
|
||||
- **Storage** — free/total per mount point.
|
||||
- **Modules** — **🔄 Load** lists all loaded kernel/user modules.
|
||||
|
||||
---
|
||||
|
||||
### 🎯 APPS (App Manager v2)
|
||||
|
||||
- Lists every running app/process with real data from `sceKernelGetAppInfo`: pid, app_id, title_id, name, type, per-process **CPU%** (kernel `ki_runtime` deltas) and suspended state (`ki_stat`, no IPC stall).
|
||||
- **⏸ Suspend / ▶ Resume** — SIGSTOP/SIGCONT freeze, works on any app.
|
||||
- **🗑️ Kill** — ForceKillApp → KillApp → SIGKILL fallback chain.
|
||||
- **🧠 Coredump** — triggers a process memory dump (needs etaHEN).
|
||||
|
||||
---
|
||||
|
||||
### ⚡ POWER & DEVICES
|
||||
|
||||
- **Power** — reboot / shutdown / rest mode from the app.
|
||||
- **USB drives** — list mounted USB storage.
|
||||
- **🎮 Controller (DualSense)** — live pad state + controller info read through a remote bridge into `SceShellUI` (the payload gets no pad session of its own, so it resolves the real logged-in user remotely — nothing hardcoded). **Lightbar**: set any RGB color.
|
||||
- **💡 Console LED** — real `/dev/icc_indicator` interface with three hardware channels (blue `0x01` / white `0x11` / amber `0x21` — verified on hardware): pick a color, run effects (breathe, sunrise, blink, chase…), `auto` hands control back to the system.
|
||||
- **🔔 Notify** — push a custom notification to the PS5 screen.
|
||||
- **🔊 Beeper** — console beep / mute.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Shell
|
||||
|
||||
- Run shell commands directly on the PS5 — type, Enter, output streams back.
|
||||
- Scrollable session history, **🗑️ Clear**, **💾 Save Log** to a PC file.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Search
|
||||
|
||||
- **🔄 Start Index** — the payload crawls the PS5 filesystem and builds a searchable index.
|
||||
- Instant search across **every** file/folder on the console — results open straight in the browser.
|
||||
- **🔃 Refresh** rebuilds, **🗑️ Clear** wipes the index.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Fan Control
|
||||
|
||||
- Reads the current fan threshold + live temperature.
|
||||
- **🌡️ Set Threshold** — custom temperature target.
|
||||
- Presets: **❄️ Cool (50 °C)**, **⚖️ Balanced (60 °C)**, **🔇 Quiet (70 °C)**.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Memory
|
||||
|
||||
- **Process list** of running PS5 processes (**↻ refresh**).
|
||||
- **🔍 First Scan / 🔎 Next Scan** — cheat-style value scanning in a process's memory.
|
||||
- **📖 Read** (hex dump, 256 B) and **✏️ Write** at arbitrary addresses — full debugging/patching toolkit.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → Kernel Log
|
||||
|
||||
- Live kernel log viewer — choose **Last 32 KB / Last 128 KB / Full buffer**.
|
||||
- **Auto (3s)** checkbox for continuous refresh, **🔄 Refresh** manual.
|
||||
- **📋 Copy** to clipboard, **💾 Save…** to file.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 TOOLS → PKG
|
||||
|
||||
- **Install PKG** — three ways:
|
||||
- **📋 Browse PS5** — pick a `.pkg` already on the console.
|
||||
- **💻 Browse PC** — pick a local `.pkg`; it's **streamed over HTTP** (ranged requests, persistent keep-alive connections, 512 KB relay) straight into the PS5 installer. Verified with a 22.7 GB package.
|
||||
- Via the local-pane right-click "Install on PS5".
|
||||
- **🔄 Check Status** — queries the console's installer for progress/result.
|
||||
- Staged `.pkg` files on the PS5 are **auto-deleted** after successful install.
|
||||
- PS5 notifications show the real package name (not a generic label).
|
||||
|
||||
- **Convert to FPKG** — turn an extracted game folder into an installable fake package **entirely on the PC**:
|
||||
- Point at the source folder (with `sce_sys`), choose output, done.
|
||||
- Auto-fills title ID / content ID / version from `param.json`.
|
||||
- Optional **fake-signing** of `eboot.bin`, `*.elf`, `*.prx`, `*.sprx` (originals restored automatically after the build).
|
||||
- Builds the full PS5 container: inner PFS image, encrypted+signed outer PFS, CNT entry table, all SHA3-256 digests, RSA-3072 signatures, FIH finalization — powered by the bundled **LibProsperoPkg** engine.
|
||||
- Output verified against real package structure (field-accurate, validator-tested).
|
||||
|
||||
---
|
||||
|
||||
### 🧾 DEBUG LOG (sidebar)
|
||||
|
||||
- Timestamped log of **every** operation the app performs — connections, transfers, errors, PS5 responses.
|
||||
- **📋 Copy Log**, **🗑️ Clear Log**; a `ps5suite_*.log` file is also written automatically next to the executable for post-mortem debugging.
|
||||
- **Failed transfers** section with per-item **🔄 Retry** and **🗑️ Remove**.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical details
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| Client (Windows/Linux/macOS/Android) | C# / .NET 10, **Avalonia UI** — one shared codebase everywhere |
|
||||
| On-console payload | C, built with the **PS5 Payload SDK** (`prospero-clang`) |
|
||||
| FPKG engine | **LibProsperoPkg** (bundled) — CNT/PFS/PFSC/PFSv3/FIH build, read & verify |
|
||||
| Transfer protocol | Raw TCP, chunked + parallel streams |
|
||||
| Suite server ports | 9113–9118 (scans until it finds the live server) |
|
||||
| Local PKG streamer ports | 18990–19009 |
|
||||
| Payload proxy port | 13801+ |
|
||||
|
||||
- All heavy work (transfers, FPKG builds) runs on background threads — the UI never freezes.
|
||||
- **The Android app is the same Avalonia codebase** — shared protocol layer, same dark theme and the same feature set as the desktop build, wrapped in a hamburger-navigation layout for touch. Source is in `client-android/`; a signed `PS5Suite.apk` ships in every release.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Android app
|
||||
|
||||
- Same Avalonia UI/protocol as desktop — every feature above works on Android: transfers, games (mount/launch/stop), both stores, trophies, saves, system info, tools, devices, debug log.
|
||||
- Side **☰ hamburger menu** navigation, signed APK, version synced with desktop releases.
|
||||
- Install → enter PS5 IP → Connect. Same payload, same ports.
|
||||
|
||||
---
|
||||
|
||||
## 🐞 Found a bug?
|
||||
|
||||
Please report it in the **[Issues](https://github.com/manos555555/PS5-Suite/issues)** section — include your firmware, what you were doing, and any error message or log output (the app's Debug Log tab + the `ps5suite_*.log` file next to the executable help a lot). Every report gets looked at — the last two fixes came straight from user reports. 🙏
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
Requires a jailbroken PS5 with a payload loader. Save backup/restore and memory write features modify console state — **use at your own risk**. For personal/educational use; no copyrighted content included.
|
||||
|
||||
**🏆 Trophy features:** unlocking trophies — especially many at once or with unrealistic timestamps — may look suspicious to Sony if your console or account ever syncs with PSN. **The author takes no responsibility for any ban of your account or console.** Use offline, at your own risk.
|
||||
|
||||
**Enjoy. — Manos**
|
||||
@@ -0,0 +1,454 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# 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 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# Mac bundle stuff
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
##
|
||||
## Visual Studio Code
|
||||
##
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project>
|
||||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Avalonia packages -->
|
||||
<PackageVersion Include="Avalonia" Version="11.0.6" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.0.6" />
|
||||
<PackageVersion Include="Avalonia.iOS" Version="11.0.6" />
|
||||
<PackageVersion Include="Avalonia.Browser" Version="11.0.6" />
|
||||
<PackageVersion Include="Avalonia.Android" Version="11.0.6" />
|
||||
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,42 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
|
||||
namespace PS5SuiteAndroid.Android;
|
||||
|
||||
[Activity(
|
||||
Label = "PS5 Suite",
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
Icon = "@drawable/icon",
|
||||
MainLauncher = true,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
public class MainActivity : AvaloniaMainActivity<App>
|
||||
{
|
||||
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
|
||||
{
|
||||
// Expose user-browsable writable dirs to the shared library before
|
||||
// Avalonia starts (Android/data/<pkg>/files — no permission needed).
|
||||
PS5SuiteAndroid.AppPaths.DownloadsDirProvider =
|
||||
() => GetExternalFilesDir(null)?.AbsolutePath ?? FilesDir!.AbsolutePath;
|
||||
PS5SuiteAndroid.AppPaths.CacheDirProvider =
|
||||
() => CacheDir!.AbsolutePath;
|
||||
PS5SuiteAndroid.AppPaths.UrlOpener = url =>
|
||||
{
|
||||
try
|
||||
{
|
||||
StartActivity(new global::Android.Content.Intent(
|
||||
global::Android.Content.Intent.ActionView,
|
||||
global::Android.Net.Uri.Parse(url)));
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
base.OnCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
{
|
||||
return base.CustomizeAppBuilder(builder)
|
||||
.WithInterFont();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-android</TargetFramework>
|
||||
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>com.manos.ps5suite</ApplicationId>
|
||||
<ApplicationVersion>10</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>7.4.0</ApplicationDisplayVersion>
|
||||
<ApplicationTitle>PS5 Suite</ApplicationTitle>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AndroidResource Include="Resources\drawable\icon.jpg" />
|
||||
<AndroidResource Include="Resources\drawable\splash_screen.xml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application android:label="PS5 Suite" android:icon="@drawable/icon" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,44 @@
|
||||
Images, layout descriptions, binary blobs and string dictionaries can be included
|
||||
in your application as resource files. Various Android APIs are designed to
|
||||
operate on the resource IDs instead of dealing with images, strings or binary blobs
|
||||
directly.
|
||||
|
||||
For example, a sample Android app that contains a user interface layout (main.axml),
|
||||
an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png)
|
||||
would keep its resources in the "Resources" directory of the application:
|
||||
|
||||
Resources/
|
||||
drawable/
|
||||
icon.png
|
||||
|
||||
layout/
|
||||
main.axml
|
||||
|
||||
values/
|
||||
strings.xml
|
||||
|
||||
In order to get the build system to recognize Android resources, set the build action to
|
||||
"AndroidResource". The native Android APIs do not operate directly with filenames, but
|
||||
instead operate on resource IDs. When you compile an Android application that uses resources,
|
||||
the build system will package the resources for distribution and generate a class called "R"
|
||||
(this is an Android convention) that contains the tokens for each one of the resources
|
||||
included. For example, for the above Resources layout, this is what the R class would expose:
|
||||
|
||||
public class R {
|
||||
public class drawable {
|
||||
public const int icon = 0x123;
|
||||
}
|
||||
|
||||
public class layout {
|
||||
public const int main = 0x456;
|
||||
}
|
||||
|
||||
public class strings {
|
||||
public const int first_string = 0xabc;
|
||||
public const int second_string = 0xbcd;
|
||||
}
|
||||
}
|
||||
|
||||
You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main
|
||||
to reference the layout/main.axml file, or R.strings.first_string to reference the first
|
||||
string in the dictionary file values/strings.xml.
|
||||
@@ -0,0 +1,66 @@
|
||||
<animated-vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt">
|
||||
<aapt:attr name="android:drawable">
|
||||
<vector
|
||||
android:name="vector"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<group
|
||||
android:name="wrapper"
|
||||
android:translateX="21"
|
||||
android:translateY="21">
|
||||
<group android:name="group">
|
||||
<path
|
||||
android:name="path"
|
||||
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:name="path_1"
|
||||
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:name="path_2"
|
||||
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
|
||||
android:strokeWidth="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
</aapt:attr>
|
||||
<target android:name="path">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:propertyName="fillColor"
|
||||
android:duration="1000"
|
||||
android:valueFrom="#00ffffff"
|
||||
android:valueTo="#161c2d"
|
||||
android:valueType="colorType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="path_1">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:propertyName="fillColor"
|
||||
android:duration="1000"
|
||||
android:valueFrom="#00ffffff"
|
||||
android:valueTo="#f9f9fb"
|
||||
android:valueType="colorType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="path_2">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:propertyName="fillColor"
|
||||
android:duration="1000"
|
||||
android:valueFrom="#00ffffff"
|
||||
android:valueTo="#f9f9fb"
|
||||
android:valueType="colorType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
</animated-vector>
|
||||
@@ -0,0 +1,71 @@
|
||||
<animated-vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt">
|
||||
<aapt:attr name="android:drawable">
|
||||
<vector
|
||||
android:name="vector"
|
||||
android:width="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<group
|
||||
android:name="wrapper"
|
||||
android:translateX="21"
|
||||
android:translateY="21">
|
||||
<group android:name="group">
|
||||
<path
|
||||
android:name="path"
|
||||
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
|
||||
android:fillColor="#00ffffff"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:name="path_1"
|
||||
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
|
||||
android:fillColor="#00ffffff"
|
||||
android:strokeWidth="1"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:name="path_2"
|
||||
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
|
||||
android:fillColor="#00ffffff"
|
||||
android:strokeWidth="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
</aapt:attr>
|
||||
<target android:name="path_2">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:propertyName="fillColor"
|
||||
android:startOffset="100"
|
||||
android:duration="900"
|
||||
android:valueFrom="#00ffffff"
|
||||
android:valueTo="#161c2d"
|
||||
android:valueType="colorType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="path">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:propertyName="fillColor"
|
||||
android:duration="500"
|
||||
android:valueFrom="#00ffffff"
|
||||
android:valueTo="#f9f9fb"
|
||||
android:valueType="colorType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="path_1">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:propertyName="fillColor"
|
||||
android:startOffset="100"
|
||||
android:duration="900"
|
||||
android:valueFrom="#00ffffff"
|
||||
android:valueTo="#161c2d"
|
||||
android:valueType="colorType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
</animated-vector>
|
||||
|
After Width: | Height: | Size: 155 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item>
|
||||
<color android:color="@color/splash_background"/>
|
||||
</item>
|
||||
|
||||
<item android:drawable="@drawable/avalonia_anim"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:gravity="center" />
|
||||
|
||||
</layer-list>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="splash_background">#212121</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
|
||||
<style name="MyTheme" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
<style name="MyTheme.Main" parent="MyTheme">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="splash_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
|
||||
<style name="MyTheme">
|
||||
</style>
|
||||
|
||||
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowBackground">@drawable/splash_screen</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-browser</TargetFramework>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" Expression="#[.{fingerprint}]!" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Browser;
|
||||
using PS5SuiteAndroid;
|
||||
|
||||
internal sealed partial class Program
|
||||
{
|
||||
private static Task Main(string[] args) => BuildAvaloniaApp()
|
||||
.WithInterFont()
|
||||
.StartBrowserAppAsync("out");
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[assembly:System.Runtime.Versioning.SupportedOSPlatform("browser")]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"profiles": {
|
||||
"PS5SuiteAndroid.Browser": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:7169;http://localhost:5235",
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* HTML styles for the splash screen */
|
||||
.avalonia-splash {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: white;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Light theme styles */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.avalonia-splash {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.avalonia-splash h2 {
|
||||
color: #1b2a4e;
|
||||
}
|
||||
|
||||
.avalonia-splash a {
|
||||
color: #0D6EFD;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.avalonia-splash {
|
||||
background: #1b2a4e;
|
||||
}
|
||||
|
||||
.avalonia-splash h2 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.avalonia-splash a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.avalonia-splash h2 {
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.avalonia-splash a {
|
||||
text-decoration: none;
|
||||
font-size: 2.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avalonia-splash.splash-close {
|
||||
transition: opacity 200ms, display 200ms;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>PS5SuiteAndroid.Browser</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preload" id="webassembly" />
|
||||
<script type="importmap"></script>
|
||||
<script type='module' src="main#[.{fingerprint}].js"></script>
|
||||
<link rel="stylesheet" href="./app.css" />
|
||||
</head>
|
||||
|
||||
<body style="margin: 0; overflow: hidden">
|
||||
<div id="out">
|
||||
<div class="avalonia-splash">
|
||||
<h2>
|
||||
Powered by
|
||||
<a href="https://www.avaloniaui.net/" target="_blank">
|
||||
<svg width="266" height="52" viewBox="0 0 266 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M55.8592 47.3941C54.9035 47.3941 54.1184 47.1723 53.504 46.7285C52.9237 46.2848 52.5483 45.6875 52.3776 44.9365C52.2411 44.1856 52.3947 43.3493 52.8384 42.4277L65.9456 13.7045C66.4917 12.544 67.1403 11.7077 67.8912 11.1957C68.6421 10.6496 69.5125 10.3765 70.5024 10.3765C71.4923 10.3765 72.3627 10.6496 73.1136 11.1957C73.8645 11.7077 74.496 12.544 75.008 13.7045L88.2176 42.4277C88.6613 43.3493 88.8149 44.2027 88.6784 44.9877C88.576 45.7387 88.2347 46.336 87.6544 46.7797C87.0741 47.1893 86.3232 47.3941 85.4016 47.3941C84.2411 47.3941 83.3365 47.1211 82.688 46.5749C82.0736 46.0288 81.5275 45.1755 81.0496 44.0149L78.9279 39.0997H62.0415L59.9552 44.0149C59.4432 45.2096 58.8971 46.08 58.3168 46.6261C57.7707 47.1381 56.9515 47.3941 55.8592 47.3941ZM70.4 19.2853L64.6844 32.9045H76.2627L70.5024 19.2853H70.4Z" fill="currentColor"/>
|
||||
<path d="M101.869 47.3941C100.879 47.3941 100.009 47.1381 99.258 46.6261C98.5071 46.08 97.9096 45.2779 97.4659 44.2197L89.7348 26.4021C89.3593 25.5147 89.2228 24.6955 89.3252 23.9445C89.4276 23.1595 89.786 22.5451 90.4004 22.1013C91.0489 21.6235 91.9364 21.3845 93.0628 21.3845C93.9844 21.3845 94.7353 21.6064 95.3156 22.0501C95.8959 22.4597 96.4079 23.2619 96.8516 24.4565L102.018 37.95L107.552 24.4053C108.03 23.2448 108.559 22.4597 109.14 22.0501C109.72 21.6064 110.522 21.3845 111.546 21.3845C112.433 21.3845 113.133 21.6235 113.645 22.1013C114.191 22.5451 114.516 23.1424 114.618 23.8933C114.755 24.6443 114.618 25.4635 114.208 26.3509L106.324 44.2197C105.88 45.312 105.283 46.1141 104.532 46.6261C103.815 47.1381 102.927 47.3941 101.869 47.3941Z" fill="currentColor"/>
|
||||
<path d="M126.569 47.4965C124.726 47.4965 123.07 47.1381 121.602 46.4213C120.135 45.7045 118.991 44.7317 118.172 43.5029C117.353 42.2741 116.943 40.8917 116.943 39.3557C116.943 37.5125 117.421 36.0619 118.377 35.0037C119.333 33.9115 120.886 33.1435 123.036 32.6997C125.186 32.2219 128.037 31.9829 131.586 31.9829H133.43V35.9765H131.638C129.897 35.9765 128.48 36.0789 127.388 36.2837C126.33 36.4544 125.562 36.7616 125.084 37.2053C124.64 37.6491 124.418 38.2635 124.418 39.0485C124.418 40.0043 124.743 40.7893 125.391 41.4037C126.074 42.0181 127.047 42.3253 128.31 42.3253C129.299 42.3253 130.17 42.1035 130.921 41.6597C131.706 41.1819 132.32 40.5504 132.764 39.7653C133.208 38.9461 133.43 38.0245 133.43 37.0005V31.1125C133.43 29.6107 133.088 28.5525 132.406 27.9381C131.723 27.2896 130.562 26.9653 128.924 26.9653C128.002 26.9653 126.995 27.0848 125.903 27.3237C124.845 27.5285 123.667 27.8869 122.37 28.3989C121.619 28.7403 120.954 28.8256 120.374 28.6549C119.793 28.4501 119.35 28.1088 119.042 27.6309C118.735 27.1189 118.582 26.5728 118.582 25.9925C118.582 25.3781 118.752 24.7979 119.094 24.2517C119.435 23.6715 119.998 23.2448 120.783 22.9717C122.387 22.3232 123.889 21.8795 125.289 21.6405C126.722 21.4016 128.037 21.2821 129.231 21.2821C131.859 21.2821 134.01 21.6747 135.682 22.4597C137.389 23.2107 138.669 24.3883 139.522 25.9925C140.376 27.5627 140.802 29.5936 140.802 32.0853V43.4517C140.802 44.7147 140.495 45.6875 139.881 46.3701C139.266 47.0528 138.379 47.3941 137.218 47.3941C136.058 47.3941 135.153 47.0528 134.505 46.3701C133.89 45.6875 133.583 44.7147 133.583 43.4517L133.594 43.15C133.594 43.15 133.293 44.032 132.61 44.8853C131.962 45.7045 131.126 46.3531 130.102 46.8309C129.078 47.2747 127.9 47.4965 126.569 47.4965Z" fill="currentColor"/>
|
||||
<path d="M155.632 47.4965C152.594 47.4965 150.324 46.6603 148.822 44.9877C147.321 43.2811 146.57 40.7552 146.57 37.4101V14.3189C146.57 13.0219 146.894 12.0491 147.542 11.4005C148.225 10.7179 149.198 10.3765 150.461 10.3765C151.69 10.3765 152.628 10.7179 153.277 11.4005C153.959 12.0491 154.301 13.0219 154.301 14.3189V37.1029C154.301 38.5024 154.591 39.5435 155.171 40.2261C155.786 40.8747 156.588 41.1989 157.578 41.1989C157.851 41.1989 158.107 41.1819 158.346 41.1477C158.585 41.1136 158.841 41.0965 159.114 41.0965C159.66 41.0283 160.035 41.1989 160.24 41.6085C160.479 41.984 160.598 42.752 160.598 43.9125C160.598 44.9365 160.394 45.7216 159.984 46.2677C159.574 46.7797 158.943 47.1211 158.09 47.2917C157.748 47.3259 157.356 47.36 156.912 47.3941C156.468 47.4624 156.042 47.4965 155.632 47.4965Z" fill="currentColor"/>
|
||||
<path d="M175.453 47.4965C172.756 47.4965 170.401 46.9675 168.387 45.9093C166.407 44.8512 164.871 43.3323 163.779 41.3525C162.687 39.3728 162.141 37.0347 162.141 34.3381C162.141 32.3243 162.448 30.5152 163.062 28.9109C163.677 27.3067 164.564 25.9413 165.725 24.8149C166.919 23.6544 168.336 22.784 169.974 22.2037C171.613 21.5893 173.439 21.2821 175.453 21.2821C178.149 21.2821 180.487 21.8112 182.467 22.8693C184.481 23.9275 186.034 25.4293 187.126 27.3749C188.253 29.3205 188.816 31.6416 188.816 34.3381C188.816 36.3861 188.492 38.2123 187.843 39.8165C187.229 41.4208 186.341 42.8032 185.181 43.9637C184.02 45.1243 182.604 46.0117 180.931 46.6261C179.293 47.2064 177.467 47.4965 175.453 47.4965ZM175.453 41.7109C176.579 41.7109 177.552 41.4379 178.371 40.8917C179.19 40.3456 179.839 39.5435 180.317 38.4853C180.795 37.3931 181.034 36.0107 181.034 34.3381C181.034 31.8464 180.522 30.0203 179.498 28.8597C178.474 27.6651 177.125 27.0677 175.453 27.0677C174.361 27.0677 173.388 27.3237 172.534 27.8357C171.715 28.3477 171.067 29.1499 170.589 30.2421C170.145 31.3003 169.923 32.6656 169.923 34.3381C169.923 36.8299 170.435 38.6901 171.459 39.9189C172.483 41.1136 173.814 41.7109 175.453 41.7109Z" fill="currentColor"/>
|
||||
<path d="M197.411 47.3941C196.148 47.3941 195.175 47.0528 194.492 46.3701C193.844 45.6875 193.52 44.7147 193.52 43.4517V25.2757C193.52 24.0128 193.844 23.0571 194.492 22.4085C195.175 21.7259 196.114 21.3845 197.308 21.3845C198.537 21.3845 199.476 21.7259 200.124 22.4085C200.773 23.0571 201.112 24.1871 201.112 25.45C201.141 25.3955 202.48 23.552 204.016 22.6645C205.586 21.7429 207.361 21.2821 209.34 21.2821C211.354 21.2821 213.01 21.6747 214.307 22.4597C215.604 23.2107 216.577 24.3712 217.225 25.9413C217.874 27.4773 218.198 29.44 218.198 31.8293V43.4517C218.198 44.7147 217.857 45.6875 217.174 46.3701C216.525 47.0528 215.57 47.3941 214.307 47.3941C213.078 47.3941 212.122 47.0528 211.44 46.3701C210.791 45.6875 210.467 44.7147 210.467 43.4517V32.1877C210.467 30.4469 210.143 29.2011 209.494 28.4501C208.88 27.6651 207.924 27.2725 206.627 27.2725C204.988 27.2725 203.674 27.7845 202.684 28.8085C201.729 29.8325 201.251 31.1979 201.251 32.9045V43.4517C201.251 46.08 199.971 47.3941 197.411 47.3941Z" fill="currentColor"/>
|
||||
<path d="M227.861 47.3429C226.598 47.3429 225.625 46.9845 224.942 46.2677C224.294 45.5168 223.97 44.4757 223.97 43.1445V25.6341C223.97 24.2688 224.294 23.2277 224.942 22.5109C225.625 21.76 226.598 21.3845 227.861 21.3845C229.09 21.3845 230.028 21.76 230.677 22.5109C231.359 23.2277 231.701 24.2688 231.701 25.6341V43.1445C231.701 44.4757 231.377 45.5168 230.728 46.2677C230.079 46.9845 229.124 47.3429 227.861 47.3429ZM227.861 17.1861C226.427 17.1861 225.318 16.8619 224.533 16.2133C223.782 15.5307 223.406 14.5749 223.406 13.3461C223.406 12.0832 223.782 11.1275 224.533 10.4789C225.318 9.79629 226.427 9.45496 227.861 9.45496C229.294 9.45496 230.387 9.79629 231.138 10.4789C231.889 11.1275 232.264 12.0832 232.264 13.3461C232.264 14.5749 231.889 15.5307 231.138 16.2133C230.387 16.8619 229.294 17.1861 227.861 17.1861Z" fill="currentColor"/>
|
||||
<path d="M246.169 47.4965C244.326 47.4965 242.67 47.1381 241.202 46.4213C239.735 45.7045 238.591 44.7317 237.772 43.5029C236.953 42.2741 236.543 40.8917 236.543 39.3557C236.543 37.5125 237.021 36.0619 237.977 35.0037C238.933 33.9115 240.486 33.1435 242.636 32.6997C244.786 32.2219 247.637 31.9829 251.186 31.9829H253.03V35.9765H251.238C249.497 35.9765 248.08 36.0789 246.988 36.2837C245.93 36.4544 245.162 36.7616 244.684 37.2053C244.24 37.6491 244.018 38.2635 244.018 39.0485C244.018 40.0043 244.343 40.7893 244.991 41.4037C245.674 42.0181 246.647 42.3253 247.91 42.3253C248.899 42.3253 249.77 42.1035 250.521 41.6597C251.306 41.1819 251.92 40.5504 252.364 39.7653C252.808 38.9461 253.03 38.0245 253.03 37.0005V31.1125C253.03 29.6107 252.688 28.5525 252.006 27.9381C251.323 27.2896 250.162 26.9653 248.524 26.9653C247.602 26.9653 246.595 27.0848 245.503 27.3237C244.445 27.5285 243.267 27.8869 241.97 28.3989C241.219 28.7403 240.554 28.8256 239.974 28.6549C239.393 28.4501 238.95 28.1088 238.642 27.6309C238.335 27.1189 238.182 26.5728 238.182 25.9925C238.182 25.3781 238.352 24.7979 238.694 24.2517C239.035 23.6715 239.598 23.2448 240.383 22.9717C241.987 22.3232 243.489 21.8795 244.889 21.6405C246.322 21.4016 247.637 21.2821 248.831 21.2821C251.459 21.2821 253.61 21.6747 255.282 22.4597C256.989 23.2107 258.269 24.3883 259.122 25.9925C259.976 27.5627 260.402 29.5936 260.402 32.0853V43.4517C260.402 44.7147 260.095 45.6875 259.481 46.3701C258.866 47.0528 257.979 47.3941 256.818 47.3941C255.658 47.3941 254.753 47.0528 254.105 46.3701C253.49 45.6875 253.183 44.7147 253.183 43.4517V43.1789C253.183 43.3144 252.893 44.032 252.21 44.8853C251.562 45.7045 250.726 46.3531 249.702 46.8309C248.678 47.2747 247.5 47.4965 246.169 47.4965Z" fill="currentColor"/>
|
||||
<path d="M22.3444 20.9916C18.7895 20.9916 15.9077 24.0073 15.9077 27.7274C15.9077 31.4475 18.7895 34.4632 22.3444 34.4632C25.8993 34.4632 28.7811 31.4475 28.7811 27.7274C28.7811 24.0073 25.8993 20.9916 22.3444 20.9916Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.6937 49.0667H21.9271C10.8346 48.8653 1.90173 39.3893 1.90173 27.7333C1.90173 15.9513 11.0289 6.40002 22.2878 6.40002C33.3638 6.40002 42.3768 15.6435 42.6667 27.161L42.6314 44.4824C42.338 47.0679 40.2434 49.0667 37.6937 49.0667ZM9.22617 24.667C10.5612 18.3725 15.9275 13.6656 22.3444 13.6656C29.7657 13.6656 35.7818 19.9613 35.7818 27.7274C35.7818 27.7857 35.7825 27.8488 35.7831 27.9136C35.7846 28.0483 35.7861 28.1901 35.7818 28.3103V41.6939H28.7907V40.0685C26.877 41.1655 24.6803 41.7892 22.3444 41.7892C15.9275 41.7892 10.5612 37.0823 9.22617 30.7878C10.5043 30.4129 11.4416 29.1847 11.4416 27.7274C11.4416 26.2701 10.5043 25.0419 9.22617 24.667ZM8.33937 29.9683C9.52696 29.9683 10.4897 28.9609 10.4897 27.7181C10.4897 26.4753 9.52696 25.4678 8.33937 25.4678C7.15178 25.4678 6.18904 26.4753 6.18904 27.7181C6.18904 28.9609 7.15178 29.9683 8.33937 29.9683Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { dotnet } from './_framework/dotnet.js'
|
||||
|
||||
const is_browser = typeof window != "undefined";
|
||||
if (!is_browser) throw new Error(`Expected to be running in a browser`);
|
||||
|
||||
const dotnetRuntime = await dotnet
|
||||
.withDiagnosticTracing(false)
|
||||
.withApplicationArgumentsFromQuery()
|
||||
.create();
|
||||
|
||||
const config = dotnetRuntime.getConfig();
|
||||
|
||||
await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]);
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<!--If you are willing to use platform-specific APIs, use conditional compilation.
|
||||
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace PS5SuiteAndroid.Desktop;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PS5SuiteAndroid.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,23 @@
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.iOS;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace PS5SuiteAndroid.iOS;
|
||||
|
||||
// The UIApplicationDelegate for the application. This class is responsible for launching the
|
||||
// User Interface of the application, as well as listening (and optionally responding) to
|
||||
// application events from iOS.
|
||||
[Register("AppDelegate")]
|
||||
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
|
||||
public partial class AppDelegate : AvaloniaAppDelegate<App>
|
||||
#pragma warning restore CA1711 // Identifiers should not have incorrect suffix
|
||||
{
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
{
|
||||
return base.CustomizeAppBuilder(builder)
|
||||
.WithInterFont();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PS5SuiteAndroid</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>companyName.PS5SuiteAndroid</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
using UIKit;
|
||||
|
||||
namespace PS5SuiteAndroid.iOS;
|
||||
|
||||
public class Application
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-ios</TargetFramework>
|
||||
<SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.iOS" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="6214" systemVersion="14A314h" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="6207" />
|
||||
<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1" />
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" />
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder" />
|
||||
<view contentMode="scaleToFill" id="iN0-l3-epB">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="480" />
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES" />
|
||||
<subviews>
|
||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" Copyright (c) 2022 " textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines"
|
||||
minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
|
||||
<rect key="frame" x="20" y="439" width="441" height="21" />
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17" />
|
||||
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
|
||||
<nil key="highlightedColor" />
|
||||
</label>
|
||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="PS5SuiteAndroid" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines"
|
||||
minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="kId-c2-rCX">
|
||||
<rect key="frame" x="20" y="140" width="441" height="43" />
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="36" />
|
||||
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
|
||||
<nil key="highlightedColor" />
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite" />
|
||||
<constraints>
|
||||
<constraint firstItem="kId-c2-rCX" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="bottom" multiplier="1/3" constant="1" id="5cJ-9S-tgC" />
|
||||
<constraint firstAttribute="centerX" secondItem="kId-c2-rCX" secondAttribute="centerX" id="Koa-jz-hwk" />
|
||||
<constraint firstAttribute="bottom" secondItem="8ie-xW-0ye" secondAttribute="bottom" constant="20" id="Kzo-t9-V3l" />
|
||||
<constraint firstItem="8ie-xW-0ye" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="MfP-vx-nX0" />
|
||||
<constraint firstAttribute="centerX" secondItem="8ie-xW-0ye" secondAttribute="centerX" id="ZEH-qu-HZ9" />
|
||||
<constraint firstItem="kId-c2-rCX" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="fvb-Df-36g" />
|
||||
</constraints>
|
||||
<nil key="simulatedStatusBarMetrics" />
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics" />
|
||||
<point key="canvasLocation" x="548" y="455" />
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Project Path="PS5SuiteAndroid.Android/PS5SuiteAndroid.Android.csproj">
|
||||
<Deploy />
|
||||
</Project>
|
||||
<Project Path="PS5SuiteAndroid.Browser/PS5SuiteAndroid.Browser.csproj" />
|
||||
<Project Path="PS5SuiteAndroid.Desktop/PS5SuiteAndroid.Desktop.csproj" />
|
||||
<Project Path="PS5SuiteAndroid.iOS/PS5SuiteAndroid.iOS.csproj">
|
||||
<Deploy />
|
||||
</Project>
|
||||
<Project Path="PS5SuiteAndroid/PS5SuiteAndroid.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:PS5SuiteAndroid"
|
||||
x:Class="PS5SuiteAndroid.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator />
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,35 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PS5SuiteAndroid.ViewModels;
|
||||
using PS5SuiteAndroid.Views;
|
||||
|
||||
namespace PS5SuiteAndroid;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainViewModel()
|
||||
};
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
singleViewPlatform.MainView = new MainView
|
||||
{
|
||||
DataContext = new MainViewModel()
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace PS5SuiteAndroid;
|
||||
|
||||
// Platform-provided writable locations. The Android head fills these in
|
||||
// during startup (external files dir needs no storage permission and is
|
||||
// browsable by the user at Android/data/<pkg>/files).
|
||||
public static class AppPaths
|
||||
{
|
||||
public static Func<string>? DownloadsDirProvider;
|
||||
public static Func<string>? CacheDirProvider;
|
||||
|
||||
// Opens a URL in the platform browser (Android head wires an Intent).
|
||||
public static Action<string>? UrlOpener;
|
||||
|
||||
public static string DownloadsDir =>
|
||||
DownloadsDirProvider?.Invoke() ?? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
|
||||
public static string CacheDir =>
|
||||
CacheDirProvider?.Invoke() ?? System.IO.Path.GetTempPath();
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>7.4.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using PS5SuiteAndroid.ViewModels;
|
||||
|
||||
namespace PS5SuiteAndroid;
|
||||
|
||||
/// <summary>
|
||||
/// Given a view model, returns the corresponding view if possible.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public Control? Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
return null;
|
||||
|
||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PS5SuiteAndroid.ViewModels;
|
||||
|
||||
public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
public string Greeting { get; set; } = "PS5 Suite";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PS5SuiteAndroid.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5SuiteAndroid.Views;
|
||||
|
||||
// File Transfer actions — upload / download / mkdir / rename / delete.
|
||||
// Downloads land in AppPaths.DownloadsDir (Android/data/<pkg>/files on
|
||||
// Android, user-browsable without permissions).
|
||||
public partial class MainView
|
||||
{
|
||||
private async void FilesRefresh_Click(object? sender, RoutedEventArgs e)
|
||||
=> await RefreshFilesAsync();
|
||||
|
||||
private async void FileUpload_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
|
||||
var picked = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select file to upload",
|
||||
AllowMultiple = false
|
||||
});
|
||||
if (picked.Count == 0) return;
|
||||
|
||||
var file = picked[0];
|
||||
string remotePath = _currentPath.TrimEnd('/') + "/" + file.Name;
|
||||
UpdateStatus($"Uploading {file.Name}...");
|
||||
|
||||
// On Android the picker may return a content:// stream without a
|
||||
// local path — stage it through the cache dir.
|
||||
string? localPath = file.TryGetLocalPath();
|
||||
string? staged = null;
|
||||
try
|
||||
{
|
||||
if (localPath == null || !File.Exists(localPath))
|
||||
{
|
||||
staged = Path.Combine(AppPaths.CacheDir, file.Name);
|
||||
await using var src = await file.OpenReadAsync();
|
||||
await using var dst = File.Create(staged);
|
||||
await src.CopyToAsync(dst);
|
||||
localPath = staged;
|
||||
}
|
||||
|
||||
var progress = new Progress<UploadProgress>(p =>
|
||||
{
|
||||
if (p.TotalBytes > 0)
|
||||
UpdateStatus($"Uploading {file.Name}: {p.BytesSent * 100 / p.TotalBytes}%");
|
||||
});
|
||||
|
||||
bool ok = await _protocol!.UploadFileAsync(localPath, remotePath, progress);
|
||||
UpdateStatus(ok ? $"Uploaded: {remotePath}" : $"Upload failed: {_protocol.LastError}");
|
||||
if (ok) await RefreshFilesAsync();
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Upload error: {ex.Message}"); }
|
||||
finally { if (staged != null) try { File.Delete(staged); } catch { } }
|
||||
}
|
||||
|
||||
private async void FileDownload_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (FilesListBox.SelectedItem is not FileItem item) { UpdateStatus("Select a file first"); return; }
|
||||
if (item.IsDirectory) { UpdateStatus("Folder download: use the Backup flow in Saves"); return; }
|
||||
|
||||
string localDir = Path.Combine(AppPaths.DownloadsDir, "PS5Suite");
|
||||
Directory.CreateDirectory(localDir);
|
||||
string localPath = Path.Combine(localDir, item.Name);
|
||||
UpdateStatus($"Downloading {item.Name}...");
|
||||
try
|
||||
{
|
||||
var progress = new Progress<UploadProgress>(p =>
|
||||
{
|
||||
if (p.TotalBytes > 0)
|
||||
UpdateStatus($"Downloading {item.Name}: {p.BytesSent * 100 / p.TotalBytes}%");
|
||||
});
|
||||
bool ok = await _protocol!.DownloadFileAsync(item.FullPath, localPath, progress);
|
||||
UpdateStatus(ok ? $"Saved to {localPath}" : $"Download failed: {_protocol.LastError}");
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Download error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void FileNewFolder_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
var name = await ShowInputAsync("New Folder", $"Create folder inside {_currentPath}");
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
string path = _currentPath.TrimEnd('/') + "/" + name.Trim();
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol!.CreateDirAsync(path);
|
||||
UpdateStatus(ok ? $"Created {path}" : $"Failed: {_protocol.LastError}");
|
||||
if (ok) await RefreshFilesAsync();
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Mkdir error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void FileRename_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (FilesListBox.SelectedItem is not FileItem item) { UpdateStatus("Select an item first"); return; }
|
||||
var name = await ShowInputAsync("Rename", $"Rename \"{item.Name}\" to:", item.Name);
|
||||
if (string.IsNullOrWhiteSpace(name) || name == item.Name) return;
|
||||
string newPath = _currentPath.TrimEnd('/') + "/" + name.Trim();
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol!.RenameAsync(item.FullPath, newPath);
|
||||
UpdateStatus(ok ? $"Renamed to {name}" : $"Rename failed: {_protocol.LastError}");
|
||||
if (ok) await RefreshFilesAsync();
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Rename error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void FileDelete_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (FilesListBox.SelectedItem is not FileItem item) { UpdateStatus("Select an item first"); return; }
|
||||
string kind = item.IsDirectory ? "folder (recursively)" : "file";
|
||||
if (!await ShowConfirmAsync($"Delete {kind} \"{item.Name}\"?\n\n{item.FullPath}", "Confirm delete")) return;
|
||||
try
|
||||
{
|
||||
bool ok = item.IsDirectory
|
||||
? await _protocol!.DeleteDirAsync(item.FullPath)
|
||||
: await _protocol!.DeleteFileAsync(item.FullPath);
|
||||
UpdateStatus(ok ? $"Deleted {item.Name}" : $"Delete failed: {_protocol.LastError}");
|
||||
if (ok) await RefreshFilesAsync();
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Delete error: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5SuiteAndroid.Views;
|
||||
|
||||
// Games — search/sort, per-game mount, stop running title, icons.
|
||||
public partial class MainView
|
||||
{
|
||||
private readonly List<GameItem> _allGames = new();
|
||||
|
||||
private void GameSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyGameFilter();
|
||||
private void GameSort_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyGameFilter();
|
||||
|
||||
private void ApplyGameFilter()
|
||||
{
|
||||
if (GamesListBox == null || GameSearchBox == null || GameSortCombo == null) return;
|
||||
var prev = (GamesListBox.SelectedItem as GameItem)?.TitleId;
|
||||
|
||||
var q = GameSearchBox.Text?.Trim() ?? "";
|
||||
IEnumerable<GameItem> view = string.IsNullOrEmpty(q)
|
||||
? _allGames
|
||||
: _allGames.Where(g =>
|
||||
g.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
g.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
view = GameSortCombo.SelectedIndex switch
|
||||
{
|
||||
1 => view.OrderByDescending(g => g.Name, StringComparer.OrdinalIgnoreCase),
|
||||
2 => view.OrderBy(g => g.Size),
|
||||
3 => view.OrderByDescending(g => g.Size),
|
||||
_ => view.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
var list = view.ToList();
|
||||
_games.Clear();
|
||||
foreach (var g in list) _games.Add(g);
|
||||
if (GameCountText != null)
|
||||
GameCountText.Text = string.IsNullOrEmpty(q) || list.Count == _allGames.Count
|
||||
? $"Games ({_allGames.Count})"
|
||||
: $"Games ({list.Count}/{_allGames.Count})";
|
||||
if (prev != null)
|
||||
GamesListBox.SelectedItem = list.FirstOrDefault(g => g.TitleId == prev);
|
||||
}
|
||||
|
||||
private async void MountGameButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { UpdateStatus("Not connected"); return; }
|
||||
if (GamesListBox.SelectedItem is not GameItem game) { UpdateStatus("Select a game first"); return; }
|
||||
UpdateStatus($"Mounting {game.TitleId}...");
|
||||
try
|
||||
{
|
||||
var result = await _protocol.MountGameAsync(game.TitleId, m => UpdateStatus(m));
|
||||
bool failed = result == null || result.StartsWith("ERROR");
|
||||
UpdateStatus(failed ? $"Mount failed: {result ?? _protocol.LastError}" : $"Mounted {game.TitleId}");
|
||||
if (!failed) await RefreshGamesAsync();
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Mount error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Stop = kill the running app whose title id matches the selected game.
|
||||
private async void StopGameButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { UpdateStatus("Not connected"); return; }
|
||||
if (GamesListBox.SelectedItem is not GameItem game) { UpdateStatus("Select a game first"); return; }
|
||||
try
|
||||
{
|
||||
var running = await _protocol.GetRunningAppsAsync();
|
||||
var match = running.FirstOrDefault(a =>
|
||||
string.Equals(a.TitleId, game.TitleId, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
{
|
||||
UpdateStatus($"{game.Name} is not running");
|
||||
await ShowMessageAsync($"\"{game.Name}\" is not currently running on the PS5.", "Not running");
|
||||
return;
|
||||
}
|
||||
if (!await ShowConfirmAsync($"Stop \"{game.Name}\" on the PS5?", "Stop game")) return;
|
||||
var (ok, msg) = await _protocol.KillAppAsync(game.TitleId);
|
||||
UpdateStatus(ok ? $"Stopped {game.Name}" : $"Stop failed: {msg}");
|
||||
await RefreshGamesAsync();
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Stop error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Mark running titles and lazy-load icons on a side connection.
|
||||
private async Task LoadGameExtrasAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var running = await _protocol!.GetRunningAppsAsync();
|
||||
var runningIds = new HashSet<string>(running.Select(a => a.TitleId), StringComparer.OrdinalIgnoreCase);
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
foreach (var g in _allGames)
|
||||
g.IsRunning = runningIds.Contains(g.TitleId);
|
||||
if (RunningAppsText != null)
|
||||
{
|
||||
RunningAppsText.IsVisible = running.Count > 0;
|
||||
RunningAppsText.Text = running.Count == 0 ? ""
|
||||
: "Running: " + string.Join(", ", running.Select(a => string.IsNullOrEmpty(a.Name) ? a.TitleId : a.Name));
|
||||
}
|
||||
});
|
||||
|
||||
string? ip = _ps5IpAddress;
|
||||
if (ip == null) return;
|
||||
using var iconProto = new PS5Protocol();
|
||||
if (!await iconProto.ConnectAsync(ip)) return;
|
||||
foreach (var g in _allGames)
|
||||
{
|
||||
if (g.Icon != null) continue;
|
||||
var bytes = await iconProto.GetGameIconAsync(g.TitleId);
|
||||
if (bytes == null || bytes.Length == 0) continue;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { g.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
|
||||
});
|
||||
if (_protocol?.IsConnected != true) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5SuiteAndroid.Views;
|
||||
|
||||
// Prospero Store — homebrew.page/api/v1 catalog (same as desktop).
|
||||
// zip artifacts → download → SHA-256 verify → extract → upload tree to
|
||||
// /data/homebrew/<TITLEID>/ → MountGame registers it on the home screen.
|
||||
public partial class MainView
|
||||
{
|
||||
public class ProsperoItem : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public string TitleId { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Author { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public string Kind { get; set; } = "";
|
||||
public string Format { get; set; } = "";
|
||||
public long Size { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public string IconSmall { get; set; } = "";
|
||||
public string Updated { get; set; } = "";
|
||||
|
||||
[JsonIgnore]
|
||||
private Bitmap? _icon;
|
||||
[JsonIgnore]
|
||||
public Bitmap? IconImage
|
||||
{
|
||||
get => _icon;
|
||||
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(IconImage))); }
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool CanInstall => Status == "available" && Format == "zip";
|
||||
|
||||
[JsonIgnore]
|
||||
public string ButtonLabel => CanInstall ? "📥" : "—";
|
||||
|
||||
[JsonIgnore]
|
||||
public string MetaLine
|
||||
{
|
||||
get
|
||||
{
|
||||
string kind = Kind switch { "game" => "🎮", "tool" => "🔧", "app" => "🧩", _ => "📦" };
|
||||
string size = Size > 0 ? $" · {Size / 1048576.0:0.0} MB" : "";
|
||||
string st = Status == "coming_soon" ? " · coming soon"
|
||||
: Format == "ffpfsc" ? " · ffpfsc (unsupported)" : "";
|
||||
return $"{kind} {Version}{size} · {Author}{st}";
|
||||
}
|
||||
}
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
private const string ProsperoApi = "https://homebrew.page/api/v1";
|
||||
private readonly List<ProsperoItem> _prosperoItems = new();
|
||||
private bool _prosperoFetching;
|
||||
private bool _prosperoInstalling;
|
||||
private static readonly HttpClient _prosperoHttp = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
private static readonly HttpClient _prosperoDl = new() { Timeout = TimeSpan.FromMinutes(15) };
|
||||
|
||||
private string ProsperoCacheFile => Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "prospero_cache.json");
|
||||
private string ProsperoIconDir => Path.Combine(AppPaths.CacheDir, "prospero_icons");
|
||||
private string ProsperoDlDir => Path.Combine(AppPaths.CacheDir, "prospero_dl");
|
||||
|
||||
private static List<ProsperoItem> ParseProsperoIndex(string json)
|
||||
{
|
||||
var list = new List<ProsperoItem>();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("apps", out var apps)) return list;
|
||||
foreach (var a in apps.EnumerateArray())
|
||||
{
|
||||
static string S(JsonElement e, string k) =>
|
||||
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? "" : "";
|
||||
list.Add(new ProsperoItem
|
||||
{
|
||||
TitleId = S(a, "titleid"),
|
||||
Name = S(a, "name") is { Length: > 0 } n ? n : S(a, "titleid"),
|
||||
Author = S(a, "author"),
|
||||
Version = S(a, "version"),
|
||||
Kind = S(a, "kind"),
|
||||
Format = S(a, "format"),
|
||||
Status = S(a, "status"),
|
||||
IconSmall = S(a, "icon_small"),
|
||||
Updated = S(a, "updated"),
|
||||
Size = a.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private async void ProsperoRefresh_Click(object? sender, RoutedEventArgs e) => await RefreshProsperoAsync();
|
||||
private void ProsperoSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyProsperoFilter();
|
||||
private void ProsperoCategory_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyProsperoFilter();
|
||||
|
||||
private async Task RefreshProsperoAsync()
|
||||
{
|
||||
if (_prosperoFetching) return;
|
||||
_prosperoFetching = true;
|
||||
try
|
||||
{
|
||||
// show cache first if present
|
||||
try
|
||||
{
|
||||
if (File.Exists(ProsperoCacheFile))
|
||||
{
|
||||
var items = JsonSerializer.Deserialize<List<ProsperoItem>>(File.ReadAllText(ProsperoCacheFile));
|
||||
if (items is { Count: > 0 })
|
||||
{
|
||||
_prosperoItems.Clear();
|
||||
_prosperoItems.AddRange(items);
|
||||
ApplyProsperoFilter();
|
||||
ProsperoStatusText.Text = $"{items.Count} apps (cached) — refreshing…";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
UpdateStatus("Fetching Prospero catalog...");
|
||||
var fresh = ParseProsperoIndex(await _prosperoHttp.GetStringAsync($"{ProsperoApi}/index.json"));
|
||||
if (fresh.Count == 0) throw new Exception("catalog returned no apps");
|
||||
|
||||
_prosperoItems.Clear();
|
||||
_prosperoItems.AddRange(fresh);
|
||||
ApplyProsperoFilter();
|
||||
int avail = fresh.Count(i => i.CanInstall);
|
||||
ProsperoStatusText.Text = $"{fresh.Count} apps ({avail} installable)";
|
||||
UpdateStatus($"Prospero catalog: {fresh.Count} apps ({avail} installable)");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(ProsperoCacheFile)!);
|
||||
File.WriteAllText(ProsperoCacheFile, JsonSerializer.Serialize(fresh));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProsperoStatusText.Text = _prosperoItems.Count > 0
|
||||
? $"{_prosperoItems.Count} apps (cached — catalog unreachable)"
|
||||
: "Fetch failed";
|
||||
UpdateStatus($"Prospero catalog error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_prosperoFetching = false;
|
||||
if (_prosperoItems.Count > 0) _ = Task.Run(FetchProsperoIconsAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyProsperoFilter()
|
||||
{
|
||||
if (ProsperoListBox == null || ProsperoSearchBox == null || ProsperoCategoryCombo == null) return;
|
||||
|
||||
IEnumerable<ProsperoItem> view = ProsperoCategoryCombo.SelectedIndex switch
|
||||
{
|
||||
1 => _prosperoItems.Where(i => i.Kind == "app"),
|
||||
2 => _prosperoItems.Where(i => i.Kind == "game"),
|
||||
3 => _prosperoItems.Where(i => i.Kind == "tool"),
|
||||
4 => _prosperoItems.Where(i => i.Status == "coming_soon"),
|
||||
_ => _prosperoItems,
|
||||
};
|
||||
|
||||
string q = ProsperoSearchBox.Text?.Trim() ?? "";
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
view = view.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.Author.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var list = view.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
ProsperoListBox.ItemsSource = null;
|
||||
ProsperoListBox.ItemsSource = list;
|
||||
if (_prosperoItems.Count > 0)
|
||||
ProsperoStatusText.Text = string.IsNullOrEmpty(q) && ProsperoCategoryCombo.SelectedIndex == 0
|
||||
? $"{_prosperoItems.Count} apps ({_prosperoItems.Count(i => i.CanInstall)} installable)"
|
||||
: $"{list.Count}/{_prosperoItems.Count} apps";
|
||||
}
|
||||
|
||||
private async Task FetchProsperoIconsAsync()
|
||||
{
|
||||
try { Directory.CreateDirectory(ProsperoIconDir); } catch { }
|
||||
var items = _prosperoItems.ToList();
|
||||
await Task.WhenAll(items.Select(async it =>
|
||||
{
|
||||
if (it.IconImage != null) return;
|
||||
string cached = Path.Combine(ProsperoIconDir, it.TitleId + ".png");
|
||||
try
|
||||
{
|
||||
if (File.Exists(cached))
|
||||
{
|
||||
var bmp = new Bitmap(cached);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { it.IconImage = bmp; });
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
string url = !string.IsNullOrEmpty(it.IconSmall)
|
||||
? it.IconSmall
|
||||
: $"{ProsperoApi}/icons/{it.TitleId}-256.png";
|
||||
for (int a = 0; a < 3 && it.IconImage == null; a++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await _prosperoHttp.GetByteArrayAsync(url);
|
||||
if (bytes.Length < 100 || bytes[0] != 0x89 || bytes[1] != 0x50) break;
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bmp = new Bitmap(ms);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { it.IconImage = bmp; });
|
||||
try { File.WriteAllBytes(cached, bytes); } catch { }
|
||||
}
|
||||
catch { await Task.Delay(500 * (a + 1)); }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- install ----------
|
||||
|
||||
private record ProsperoDetail(string Url, string Sha256, string Format, string Status, long Size);
|
||||
|
||||
private static ProsperoDetail ParseProsperoDetail(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var r = doc.RootElement;
|
||||
static string S(JsonElement e, string k) =>
|
||||
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? "" : "";
|
||||
return new ProsperoDetail(
|
||||
S(r, "artifact_url"), S(r, "sha256"), S(r, "format"), S(r, "status"),
|
||||
r.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0);
|
||||
}
|
||||
|
||||
private void ProsperoUi(string text)
|
||||
=> Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ProsperoStatusText.Text = text;
|
||||
UpdateStatus(text);
|
||||
});
|
||||
|
||||
private async Task<bool> DownloadProsperoZipAsync(string url, string name, long expected, string destPath)
|
||||
{
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
|
||||
using var resp = await _prosperoDl.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
long total = resp.Content.Headers.ContentLength ?? expected;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(cts.Token);
|
||||
await using var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20);
|
||||
var buf = new byte[512 * 1024];
|
||||
long done = 0; int rd; int lastBucket = -1;
|
||||
while ((rd = await src.ReadAsync(buf, cts.Token)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buf.AsMemory(0, rd), cts.Token);
|
||||
done += rd;
|
||||
if (total > 0)
|
||||
{
|
||||
int bucket = (int)(done * 10 / total);
|
||||
if (bucket != lastBucket)
|
||||
{
|
||||
lastBucket = bucket;
|
||||
int pct = (int)(done * 100 / total);
|
||||
ProsperoUi($"Downloading {name} — {done / 1048576}/{total / 1048576} MB");
|
||||
Dispatcher.UIThread.Post(() => { ProsperoProgress.Value = pct; });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && done < total) throw new IOException($"short read {done}/{total}");
|
||||
|
||||
// sanity: must be a zip
|
||||
await using (var chk = new FileStream(destPath, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
var magic = new byte[4];
|
||||
if (await chk.ReadAsync(magic) < 4 || magic[0] != 'P' || magic[1] != 'K')
|
||||
throw new IOException("downloaded file is not a ZIP");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { File.Delete(destPath); } catch { }
|
||||
ProsperoUi($"⚠️ Download failed ({ex.Message}){(attempt < 2 ? $" — retry {attempt + 2}/3" : "")}");
|
||||
if (attempt < 2) await Task.Delay(1200);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? FindAppFolder(string extRoot, string titleId)
|
||||
{
|
||||
var cand = Path.Combine(extRoot, titleId);
|
||||
if (Directory.Exists(cand) && File.Exists(Path.Combine(cand, "eboot.bin")))
|
||||
return cand;
|
||||
foreach (var dir in Directory.EnumerateDirectories(extRoot))
|
||||
if (File.Exists(Path.Combine(dir, "eboot.bin")))
|
||||
return dir;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async void ProsperoInstall_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_prosperoInstalling) return;
|
||||
if (_protocol?.IsConnected != true)
|
||||
{ await ShowMessageAsync("Not connected to PS5", "Error"); return; }
|
||||
string? tid = (sender as Control)?.Tag as string;
|
||||
if (string.IsNullOrEmpty(tid)) return;
|
||||
var item = _prosperoItems.FirstOrDefault(i => i.TitleId == tid);
|
||||
if (item == null || !item.CanInstall) return;
|
||||
string name = item.Name;
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Install \"{name}\" ({tid}) on the PS5?\n\n" +
|
||||
$"The app folder goes to /data/homebrew/{tid} and is mounted to the home screen.",
|
||||
"Install")) return;
|
||||
|
||||
_prosperoInstalling = true;
|
||||
string? zipPath = null, extDir = null;
|
||||
try
|
||||
{
|
||||
ProsperoProgress.IsVisible = true;
|
||||
ProsperoProgress.Value = 0;
|
||||
|
||||
// 1. detail record
|
||||
ProsperoUi($"Resolving {name}...");
|
||||
var det = ParseProsperoDetail(await _prosperoHttp.GetStringAsync($"{ProsperoApi}/apps/{tid}.json"));
|
||||
if (det.Format != "zip" || det.Status != "available" || det.Url.Length == 0)
|
||||
{ ProsperoUi($"❌ {name}: not installable ({det.Format}/{det.Status})"); return; }
|
||||
|
||||
// 2. download
|
||||
Directory.CreateDirectory(ProsperoDlDir);
|
||||
zipPath = Path.Combine(ProsperoDlDir, tid + ".zip");
|
||||
ProsperoUi($"📥 {name}: downloading {(det.Size > 0 ? det.Size : item.Size) / 1048576.0:0.0} MB...");
|
||||
if (!await DownloadProsperoZipAsync(det.Url, name, det.Size, zipPath))
|
||||
{ ProsperoUi("download failed"); return; }
|
||||
|
||||
// 3. SHA-256 verify (thread pool)
|
||||
if (det.Sha256.Length == 64)
|
||||
{
|
||||
ProsperoUi($"Verifying {name}...");
|
||||
string hex = await Task.Run(async () =>
|
||||
Convert.ToHexString(await SHA256.HashDataAsync(File.OpenRead(zipPath))));
|
||||
if (!hex.Equals(det.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{ ProsperoUi($"❌ {name}: SHA-256 mismatch — rejected"); return; }
|
||||
}
|
||||
|
||||
// 4. extract (thread pool)
|
||||
string? appDir = null;
|
||||
await Task.Run(() =>
|
||||
{
|
||||
extDir = Path.Combine(ProsperoDlDir, tid + "_ext");
|
||||
if (Directory.Exists(extDir)) Directory.Delete(extDir, true);
|
||||
ZipFile.ExtractToDirectory(zipPath, extDir);
|
||||
appDir = FindAppFolder(extDir, tid);
|
||||
});
|
||||
if (appDir == null)
|
||||
{ ProsperoUi($"❌ {name}: no eboot.bin inside the zip"); return; }
|
||||
string folderName = Path.GetFileName(appDir);
|
||||
|
||||
// 5. upload — parallel connections like the desktop lanes
|
||||
var files = Directory.GetFiles(appDir, "*", SearchOption.AllDirectories);
|
||||
string remoteBase = $"/data/homebrew/{folderName}";
|
||||
ProsperoUi($"📤 {name}: uploading {files.Length} files...");
|
||||
ProsperoProgress.Value = 0;
|
||||
|
||||
var rels = files.Select(f => $"{remoteBase}/{Path.GetRelativePath(appDir, f).Replace('\\', '/')}").ToArray();
|
||||
Exception? uploadError = null;
|
||||
int doneFiles = 0;
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
await _protocol.CreateDirAsync(remoteBase);
|
||||
foreach (var dir in Directory.GetDirectories(appDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string rel = Path.GetRelativePath(appDir, dir).Replace('\\', '/');
|
||||
await _protocol.CreateDirAsync($"{remoteBase}/{rel}");
|
||||
}
|
||||
|
||||
int lanes = Math.Min(8, files.Length);
|
||||
int nextFile = -1;
|
||||
long lastUi = 0;
|
||||
var laneTasks = Enumerable.Range(0, lanes).Select(async _ =>
|
||||
{
|
||||
PS5Protocol? lane = null;
|
||||
try
|
||||
{
|
||||
if (_ps5IpAddress == null) throw new Exception("no PS5 IP");
|
||||
lane = new PS5Protocol();
|
||||
if (!await lane.ConnectAsync(_ps5IpAddress)) throw new Exception("lane connect failed");
|
||||
while (Volatile.Read(ref uploadError) == null)
|
||||
{
|
||||
int i = Interlocked.Increment(ref nextFile);
|
||||
if (i >= files.Length) break;
|
||||
if (!await lane.UploadFileAsync(files[i], rels[i]))
|
||||
{
|
||||
Interlocked.CompareExchange(ref uploadError,
|
||||
new Exception($"upload failed: {rels[i]} — {lane.LastError}"), null);
|
||||
break;
|
||||
}
|
||||
int d = Interlocked.Increment(ref doneFiles);
|
||||
long now = Environment.TickCount64;
|
||||
if (d == files.Length || now - lastUi > 150)
|
||||
{
|
||||
Interlocked.Exchange(ref lastUi, now);
|
||||
ProsperoUi($"Uploading {name} — {d}/{files.Length} files");
|
||||
int pct = d * 100 / files.Length;
|
||||
Dispatcher.UIThread.Post(() => { ProsperoProgress.Value = pct; });
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { lane?.Dispose(); }
|
||||
}).ToList();
|
||||
await Task.WhenAll(laneTasks);
|
||||
});
|
||||
if (uploadError != null)
|
||||
{ ProsperoUi($"❌ {name}: {uploadError.Message}"); return; }
|
||||
|
||||
// clean temp — nothing stored twice
|
||||
try { File.Delete(zipPath); zipPath = null; } catch { }
|
||||
try { if (extDir != null) { Directory.Delete(extDir, true); extDir = null; } } catch { }
|
||||
|
||||
// 6. mount
|
||||
ProsperoUi($"🗂️ {name}: mounting to home screen...");
|
||||
var mountRes = await _protocol.MountGameAsync(folderName, msg =>
|
||||
ProsperoUi($"Mounting {name} — {msg}"));
|
||||
ProsperoUi(mountRes == null
|
||||
? $"{name} installed (use Mount All in Games to show it)"
|
||||
: $"✅ {name} installed — on the home screen");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProsperoUi($"❌ Install error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_prosperoInstalling = false;
|
||||
Dispatcher.UIThread.Post(() => { ProsperoProgress.IsVisible = false; ProsperoProgress.Value = 0; });
|
||||
try { if (zipPath != null) File.Delete(zipPath); } catch { }
|
||||
try { if (extDir != null && Directory.Exists(extDir)) Directory.Delete(extDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5SuiteAndroid.Views;
|
||||
|
||||
// Saves: browse mounted, backup (decrypted + raw), restore. Screenshots:
|
||||
// download to the app's downloads dir.
|
||||
public partial class MainView
|
||||
{
|
||||
// ---------- Browse mounted ----------
|
||||
|
||||
private async void SaveBrowseButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
var (mounted, _, _, _) = await _protocol!.SaveMountStatusAsync();
|
||||
if (!mounted)
|
||||
{
|
||||
await ShowMessageAsync("No save is mounted.\nMount a save image first.", "Saves");
|
||||
return;
|
||||
}
|
||||
_currentPath = PS5Protocol.SaveMountPoint;
|
||||
NavigateTo(1); // File Transfer page
|
||||
await RefreshFilesAsync();
|
||||
}
|
||||
|
||||
// ---------- Mount helper ----------
|
||||
|
||||
private async Task<(bool mountedOk, bool weMounted)> EnsureSaveMountedAsync(PS5SaveFile save)
|
||||
{
|
||||
var (mounted, src, _, _) = await _protocol!.SaveMountStatusAsync();
|
||||
if (mounted && string.Equals(src, save.Path, StringComparison.OrdinalIgnoreCase))
|
||||
return (true, false);
|
||||
if (mounted)
|
||||
await _protocol.SaveUnmountAsync(m => UpdateStatus(m));
|
||||
UpdateStatus($"Mounting {save.SaveName}...");
|
||||
var (ok, msg) = await _protocol.SaveMountAsync(save.Path, m => UpdateStatus(m));
|
||||
if (!ok) UpdateStatus($"Mount failed: {msg}");
|
||||
return (ok, ok);
|
||||
}
|
||||
|
||||
private async Task UnmountSaveAsync()
|
||||
{
|
||||
var (ok, msg) = await _protocol!.SaveUnmountAsync(m => UpdateStatus(m));
|
||||
UpdateStatus(ok ? "Save unmounted — image written back" : $"Unmount failed: {msg}");
|
||||
if (ok) SaveMountStatusText.Text = "No save mounted";
|
||||
}
|
||||
|
||||
// ---------- Backup (decrypted) ----------
|
||||
|
||||
private async void SaveBackupButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (SavesListBox.SelectedItem is not PS5SaveFile save)
|
||||
{ UpdateStatus("Select a save first"); return; }
|
||||
|
||||
string backupRoot = Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "save_backups");
|
||||
string backupDir = Path.Combine(backupRoot, $"{save.SaveName}_{save.TitleId}");
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Backup decrypted contents of {save.SaveName}?\n\nDestination:\n{backupDir}",
|
||||
"Backup save")) return;
|
||||
|
||||
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
|
||||
if (!mountedOk) return;
|
||||
|
||||
try
|
||||
{
|
||||
UpdateStatus("Downloading decrypted save contents...");
|
||||
var progress = new Progress<DownloadFolderProgress>(p =>
|
||||
{
|
||||
if (p.TotalFiles > 0)
|
||||
UpdateStatus($"Backup {save.SaveName}: {p.FilesCompleted}/{p.TotalFiles} files");
|
||||
});
|
||||
var result = await _protocol!.DownloadFolderAsync(
|
||||
PS5Protocol.SaveMountPoint, backupDir, _ps5IpAddress ?? "",
|
||||
progress, System.Threading.CancellationToken.None);
|
||||
await ShowMessageAsync(
|
||||
$"Backup complete!\n\nFiles: {result.filesDownloaded} (failed: {result.filesFailed})\nSize: {FormatSize(result.totalBytes)}\n\nLocation:\n{backupDir}",
|
||||
"Backup complete");
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Backup error: {ex.Message}"); }
|
||||
|
||||
if (weMounted) await UnmountSaveAsync();
|
||||
}
|
||||
|
||||
// ---------- Backup (raw image) ----------
|
||||
|
||||
private async void SaveBackupRaw_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (SavesListBox.SelectedItem is not PS5SaveFile save)
|
||||
{ UpdateStatus("Select a save first"); return; }
|
||||
|
||||
string dir = Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "save_backups_raw");
|
||||
Directory.CreateDirectory(dir);
|
||||
string local = Path.Combine(dir, Path.GetFileName(save.Path));
|
||||
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Download the raw (encrypted) save image?\n\n{save.SaveName}\n→ {local}",
|
||||
"Raw backup")) return;
|
||||
|
||||
UpdateStatus($"Downloading {save.SaveName} (raw)...");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol!.DownloadFileAsync(save.Path, local,
|
||||
new Progress<UploadProgress>(p =>
|
||||
{
|
||||
if (p.TotalBytes > 0)
|
||||
UpdateStatus($"Downloading: {p.BytesSent * 100 / p.TotalBytes}%");
|
||||
}));
|
||||
UpdateStatus(ok ? $"Raw image saved: {local}" : $"Download failed: {_protocol.LastError}");
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Raw backup error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ---------- Restore (decrypted) ----------
|
||||
|
||||
private async void SaveRestoreButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (SavesListBox.SelectedItem is not PS5SaveFile save)
|
||||
{ UpdateStatus("Select a save first"); return; }
|
||||
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Select backup folder containing decrypted save files"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
string stageRoot;
|
||||
string? localPath = folders[0].TryGetLocalPath();
|
||||
if (localPath != null && Directory.Exists(localPath))
|
||||
{
|
||||
stageRoot = localPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
// SAF folder — stage it through the cache so we can upload by path.
|
||||
stageRoot = Path.Combine(AppPaths.CacheDir, "save_restore_stage");
|
||||
if (Directory.Exists(stageRoot)) Directory.Delete(stageRoot, true);
|
||||
Directory.CreateDirectory(stageRoot);
|
||||
UpdateStatus("Copying backup folder to staging...");
|
||||
try { await StageFolderAsync(folders[0], stageRoot); }
|
||||
catch (Exception ex) { UpdateStatus($"Stage failed: {ex.Message}"); return; }
|
||||
}
|
||||
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Restore decrypted files into {save.SaveName} ({save.TitleId})?\n\nFrom: {stageRoot}\n\nThe save is mounted, files are overwritten, then the image is written back.",
|
||||
"Restore save")) return;
|
||||
|
||||
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
|
||||
if (!mountedOk) return;
|
||||
|
||||
string mnt = PS5Protocol.SaveMountPoint;
|
||||
int uploaded = 0, failed = 0; long totalBytes = 0;
|
||||
try
|
||||
{
|
||||
var remoteDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = Directory.EnumerateFiles(stageRoot, "*", SearchOption.AllDirectories).ToList();
|
||||
foreach (var localFile in files)
|
||||
{
|
||||
string rel = Path.GetRelativePath(stageRoot, localFile).Replace('\\', '/');
|
||||
string? remoteDir = Path.GetDirectoryName(rel)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(remoteDir)) continue;
|
||||
string cur = mnt;
|
||||
foreach (var seg in remoteDir.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
cur += "/" + seg;
|
||||
if (remoteDirs.Add(cur))
|
||||
try { await _protocol!.CreateDirAsync(cur); } catch { }
|
||||
}
|
||||
}
|
||||
foreach (var localFile in files)
|
||||
{
|
||||
string rel = Path.GetRelativePath(stageRoot, localFile).Replace('\\', '/');
|
||||
string remote = mnt + "/" + rel;
|
||||
try
|
||||
{
|
||||
if (await _protocol!.UploadFileAsync(localFile, remote))
|
||||
{ uploaded++; totalBytes += new FileInfo(localFile).Length; }
|
||||
else failed++;
|
||||
}
|
||||
catch { failed++; }
|
||||
UpdateStatus($"Restoring: {uploaded + failed}/{files.Count} files");
|
||||
}
|
||||
UpdateStatus($"Restore upload done: {uploaded} ok, {failed} failed, {FormatSize(totalBytes)} — writing back...");
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Restore error: {ex.Message}"); }
|
||||
|
||||
if (weMounted) await UnmountSaveAsync();
|
||||
}
|
||||
|
||||
// Recursively copy a picked SAF folder to a real local dir.
|
||||
private static async Task StageFolderAsync(IStorageFolder folder, string localDir)
|
||||
{
|
||||
Directory.CreateDirectory(localDir);
|
||||
await foreach (var item in folder.GetItemsAsync())
|
||||
{
|
||||
if (item is IStorageFile f)
|
||||
{
|
||||
string dest = Path.Combine(localDir, f.Name);
|
||||
await using var src = await f.OpenReadAsync();
|
||||
await using var dst = File.Create(dest);
|
||||
await src.CopyToAsync(dst);
|
||||
}
|
||||
else if (item is IStorageFolder sub)
|
||||
{
|
||||
await StageFolderAsync(sub, Path.Combine(localDir, sub.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Screenshot download ----------
|
||||
|
||||
private async void ScreenshotDownload_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (ScreenshotsListBox.SelectedItem is not PS5Screenshot shot)
|
||||
{ UpdateStatus("Select a screenshot first"); return; }
|
||||
string dir = Path.Combine(AppPaths.DownloadsDir, "PS5Suite", "screenshots");
|
||||
Directory.CreateDirectory(dir);
|
||||
string local = Path.Combine(dir, shot.FileName);
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol!.DownloadFileAsync(shot.FullPath, local);
|
||||
UpdateStatus(ok ? $"Saved to {local}" : $"Download failed: {_protocol.LastError}");
|
||||
}
|
||||
catch (Exception ex) { UpdateStatus($"Download error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ---------- Power extras ----------
|
||||
|
||||
private async void RestartUi_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!RequireConnection()) return;
|
||||
if (!await ShowConfirmAsync("Restart the PS5 shell UI (SceShellUI)?", "Restart UI")) return;
|
||||
var (ok, msg) = await _protocol!.SendTextCommandAsync(PS5Upload.Command.RestartUI, "");
|
||||
UpdateStatus(ok ? "Shell UI restarted" : $"Restart failed: {msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5SuiteAndroid.Views;
|
||||
|
||||
// Trophies — two-level view: trophy sets list → per-set trophy list.
|
||||
// Parsing logic is a direct port of the desktop client (verified T2PD parse).
|
||||
public partial class MainView
|
||||
{
|
||||
private List<PS5TrophySet> _trophySets = new();
|
||||
private PS5TrophySet? _selectedTrophySet;
|
||||
private bool _trophyLoading;
|
||||
|
||||
private void TrophySearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyTrophySetFilter();
|
||||
|
||||
private void ApplyTrophySetFilter()
|
||||
{
|
||||
if (TrophySetsListBox == null || TrophySearchBox == null) return;
|
||||
var prevNpwr = _selectedTrophySet?.NpCommunicationId;
|
||||
|
||||
var q = TrophySearchBox.Text?.Trim() ?? "";
|
||||
var view = string.IsNullOrEmpty(q)
|
||||
? _trophySets
|
||||
: _trophySets.Where(s =>
|
||||
s.GameName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.NpCommunicationId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var list = view.OrderBy(s => s.GameName, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
TrophySetsListBox.ItemsSource = null;
|
||||
TrophySetsListBox.ItemsSource = list;
|
||||
if (prevNpwr != null && TrophySetsListBox.IsVisible)
|
||||
TrophySetsListBox.SelectedItem = list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr);
|
||||
}
|
||||
|
||||
private async void TrophyRefresh_Click(object? sender, RoutedEventArgs e)
|
||||
=> await RefreshTrophiesAsync();
|
||||
|
||||
private async Task RefreshTrophiesAsync()
|
||||
{
|
||||
if (_trophyLoading) return;
|
||||
if (_protocol == null || !_protocol.IsConnected) { UpdateStatus("Not connected"); return; }
|
||||
_trophyLoading = true;
|
||||
TrophyStatusText.Text = "loading…";
|
||||
UpdateStatus("Loading trophy sets...");
|
||||
try
|
||||
{
|
||||
var sets = await _protocol.GetTrophyListAsync();
|
||||
foreach (var set in sets)
|
||||
ParseTrophySet(set);
|
||||
|
||||
// Real game names from tropmeta; fall back to mounted-games list.
|
||||
var mounted = await _protocol.GetGameListAsync();
|
||||
var nameByTitle = mounted.ToDictionary(g => g.TitleId, g => g.Name, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var set in sets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(set.GameName))
|
||||
set.GameName = !string.IsNullOrEmpty(set.TitleId)
|
||||
? (nameByTitle.TryGetValue(set.TitleId, out var gn) ? gn : set.TitleId)
|
||||
: set.NpCommunicationId;
|
||||
}
|
||||
|
||||
_trophySets = sets;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => ApplyTrophySetFilter());
|
||||
TrophyStatusText.Text = $"{sets.Count} sets, {sets.Sum(s => s.TotalCount)} trophies";
|
||||
UpdateStatus($"🏆 {sets.Count} trophy sets, {sets.Sum(s => s.TotalCount)} trophies");
|
||||
|
||||
_ = Task.Run(() => LoadTrophySetIconsAsync(sets));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TrophyStatusText.Text = "error";
|
||||
UpdateStatus($"Trophy load failed: {ex.Message}");
|
||||
}
|
||||
finally { _trophyLoading = false; }
|
||||
}
|
||||
|
||||
private void TrophySet_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
|
||||
_selectedTrophySet = set;
|
||||
TrophyItemsListBox.ItemsSource = null;
|
||||
TrophyItemsListBox.ItemsSource = set.Trophies;
|
||||
TrophySetsListBox.IsVisible = false;
|
||||
TrophyItemsListBox.IsVisible = true;
|
||||
TrophyBackBar.IsVisible = true;
|
||||
TrophyStatusText.Text = $"{set.GameName} — {set.TotalCount} trophies";
|
||||
|
||||
if (set.Trophies.Any(t => t.Icon == null))
|
||||
_ = Task.Run(() => LoadTrophyItemIconsAsync(set));
|
||||
}
|
||||
|
||||
private void TrophyBack_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
TrophyItemsListBox.IsVisible = false;
|
||||
TrophyBackBar.IsVisible = false;
|
||||
TrophySetsListBox.IsVisible = true;
|
||||
_selectedTrophySet = null;
|
||||
if (TrophySetsListBox.ItemsSource == null) ApplyTrophySetFilter();
|
||||
TrophyStatusText.Text = $"{_trophySets.Count} sets, {_trophySets.Sum(s => s.TotalCount)} trophies";
|
||||
}
|
||||
|
||||
// ---------- parsing (ported 1:1 from desktop MainWindow.Trophies.cs) ----------
|
||||
|
||||
private static void ParseTrophySet(PS5TrophySet set)
|
||||
{
|
||||
try
|
||||
{
|
||||
var confGrades = new Dictionary<string, (string grade, bool hidden, string group)>();
|
||||
using (var doc = JsonDocument.Parse(set.TropConfJson))
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("trophies", out var trs))
|
||||
foreach (var t in trs.EnumerateArray())
|
||||
{
|
||||
string id = t.TryGetProperty("id", out var idp) ? idp.GetString() ?? "" : "";
|
||||
if (id.Length == 0) continue;
|
||||
string grade = t.TryGetProperty("grade", out var g) ? g.GetString() ?? "" : "";
|
||||
bool hidden = t.TryGetProperty("hidden", out var h) && h.GetBoolean();
|
||||
string group = t.TryGetProperty("groupId", out var gr) ? gr.GetString() ?? "" : "";
|
||||
confGrades[id] = (grade, hidden, group);
|
||||
}
|
||||
}
|
||||
|
||||
var names = new Dictionary<string, (string name, string detail)>();
|
||||
var groupNames = new Dictionary<string, string>();
|
||||
string? titleName = null;
|
||||
if (set.TropMetaJson.Length > 0)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(set.TropMetaJson);
|
||||
if (doc.RootElement.TryGetProperty("metadata", out var meta))
|
||||
{
|
||||
if (meta.TryGetProperty("titleMetadata", out var tm) &&
|
||||
tm.TryGetProperty("name", out var tn))
|
||||
titleName = tn.GetString();
|
||||
if (meta.TryGetProperty("groupMetadata", out var gms))
|
||||
foreach (var g in gms.EnumerateArray())
|
||||
{
|
||||
string id = g.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
string name = g.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
if (id.Length > 0) groupNames[id] = name;
|
||||
}
|
||||
if (meta.TryGetProperty("trophyMetadata", out var tms))
|
||||
foreach (var t in tms.EnumerateArray())
|
||||
{
|
||||
string id = t.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
string name = t.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
string det = t.TryGetProperty("detail", out var d) ? d.GetString() ?? "" : "";
|
||||
if (id.Length > 0) names[id] = (name, det);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(titleName)) set.GameName = titleName;
|
||||
|
||||
set.Trophies.Clear();
|
||||
foreach (var kv in confGrades.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var (grade, hidden, group) = kv.Value;
|
||||
names.TryGetValue(kv.Key, out var nd);
|
||||
set.Trophies.Add(new PS5Trophy
|
||||
{
|
||||
Id = int.TryParse(kv.Key, out int idNum) ? idNum : 0,
|
||||
Name = string.IsNullOrEmpty(nd.name) ? $"Trophy {kv.Key}" : nd.name,
|
||||
Detail = nd.detail ?? "",
|
||||
Grade = grade,
|
||||
Hidden = hidden,
|
||||
GroupId = groupNames.TryGetValue(group, out var gn) ? gn : group,
|
||||
StateKnown = false
|
||||
});
|
||||
}
|
||||
|
||||
ApplyTrpTitleState(set);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Trophy] parse failed for {set.NpCommunicationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// TRPTITLE.DAT (T2PD) per-trophy state — verified layout, same as desktop.
|
||||
private static void ApplyTrpTitleState(PS5TrophySet set)
|
||||
{
|
||||
var d = set.TrpTitleData;
|
||||
if (d.Length < 0x200 || d[0] != 'T' || d[1] != '2' || d[2] != 'P' || d[3] != 'D')
|
||||
return;
|
||||
|
||||
static bool IsRec(byte[] d, int o, byte typeHi, byte szLo)
|
||||
=> o + 8 <= d.Length && d[o] == 0 && d[o + 1] == 0 &&
|
||||
d[o + 2] == typeHi && d[o + 3] == 0 &&
|
||||
d[o + 4] == 0 && d[o + 5] == 0 && d[o + 6] == 0 && d[o + 7] == szLo;
|
||||
|
||||
int maxId = -1;
|
||||
for (int o = 0x200; o + 0x14 <= d.Length; o += 4)
|
||||
{
|
||||
if (!IsRec(d, o, 5, 0xC0)) continue;
|
||||
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
|
||||
if (id < 512 && id > maxId) maxId = id;
|
||||
}
|
||||
if (maxId < 0) return;
|
||||
int nbits = maxId + 1, nb = (nbits + 7) / 8;
|
||||
|
||||
var unionMask = new byte[nb];
|
||||
int groupRecs = 0;
|
||||
for (int o = 0x800; o + 0x10 + 0x30 + nb <= d.Length; o += 4)
|
||||
{
|
||||
if (!IsRec(d, o, 7, 0xB0)) continue;
|
||||
groupRecs++;
|
||||
int mo = o + 0x10 + 0x30;
|
||||
for (int j = 0; j < nb; j++) unionMask[j] |= d[mo + j];
|
||||
}
|
||||
if (groupRecs == 0) return;
|
||||
|
||||
var rowState = new Dictionary<int, (bool unlocked, DateTime when)>();
|
||||
var epoch = new DateTime(1, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
for (int o = 0x800; o + 0x10 + 0x18 <= d.Length; o += 4)
|
||||
{
|
||||
if (!IsRec(d, o, 8, 0x50)) continue;
|
||||
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
|
||||
if (id < 0 || id >= nbits) continue;
|
||||
int fl = (d[o + 0x14] << 24) | (d[o + 0x15] << 16) | (d[o + 0x16] << 8) | d[o + 0x17];
|
||||
long ts = 0;
|
||||
for (int j = 0; j < 8; j++) ts = (ts << 8) | d[o + 0x20 + j];
|
||||
bool unlocked = (fl & 1) != 0;
|
||||
DateTime when = default;
|
||||
if (unlocked && ts > 0)
|
||||
{
|
||||
try { when = epoch + TimeSpan.FromTicks(ts * 10); } catch { }
|
||||
}
|
||||
rowState[id] = (unlocked, when);
|
||||
}
|
||||
|
||||
int unionPop = 0;
|
||||
for (int i = 0; i < nb * 8; i++)
|
||||
{
|
||||
if (((unionMask[i >> 3] >> (i & 7)) & 1) == 0) continue;
|
||||
if (i >= nbits) return;
|
||||
unionPop++;
|
||||
}
|
||||
|
||||
byte[] mask;
|
||||
if (unionPop == 0)
|
||||
{
|
||||
mask = new byte[nb];
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[]? found = null;
|
||||
for (int o = 0x200; o + nb + 4 <= d.Length && found == null; o += 8)
|
||||
{
|
||||
int pop = 0; bool ok = true;
|
||||
for (int j = 0; j < nb; j++)
|
||||
{
|
||||
int b = d[o + j];
|
||||
for (int bit = 0; bit < 8; bit++)
|
||||
if (((b >> bit) & 1) != 0)
|
||||
{
|
||||
if (j * 8 + bit >= nbits) { ok = false; break; }
|
||||
pop++;
|
||||
}
|
||||
if (!ok) break;
|
||||
}
|
||||
if (!ok || pop != unionPop) continue;
|
||||
if (d[o + nb] != 0 || d[o + nb + 1] != 0 || d[o + nb + 2] != 0 || d[o + nb + 3] != 0)
|
||||
continue;
|
||||
found = new byte[nb];
|
||||
Array.Copy(d, o, found, 0, nb);
|
||||
}
|
||||
if (found == null)
|
||||
{
|
||||
// Multi-group sets: no single window carries the union, so the
|
||||
// popcount scan misses. The union of the 0x700 group masks IS
|
||||
// the authoritative per-trophy state — apply it directly
|
||||
// instead of rows-only (which made list disagree with sidebar).
|
||||
mask = unionMask;
|
||||
}
|
||||
else
|
||||
{
|
||||
mask = found;
|
||||
}
|
||||
}
|
||||
|
||||
set.UnlockMask = mask;
|
||||
set.StateKnown = true;
|
||||
foreach (var t in set.Trophies)
|
||||
{
|
||||
if (t.Id < 0 || t.Id >= nbits) continue;
|
||||
bool unlocked = ((mask[t.Id >> 3] >> (t.Id & 7)) & 1) != 0;
|
||||
DateTime? when = null;
|
||||
if (rowState.TryGetValue(t.Id, out var rs))
|
||||
{
|
||||
unlocked |= rs.unlocked;
|
||||
if (rs.when != default) when = rs.when;
|
||||
}
|
||||
t.StateKnown = true;
|
||||
t.IsUnlocked = unlocked;
|
||||
t.UnlockedTime = when;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- unlock / lock ----------
|
||||
|
||||
private async Task<bool> EnsureTrophyGameRunningAsync(PS5TrophySet set)
|
||||
{
|
||||
var apps = await _protocol!.GetRunningAppsAsync();
|
||||
if (!string.IsNullOrEmpty(set.TitleId) &&
|
||||
apps.Any(a => string.Equals(a.TitleId, set.TitleId, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
if (apps.Count == 0)
|
||||
{
|
||||
UpdateStatus($"⚠️ No game running — launch '{set.GameName}' first");
|
||||
await ShowMessageAsync(
|
||||
$"The game is not running on the PS5.\n\nLaunch \"{set.GameName}\" first — trophies can only be unlocked while the matching game is running.",
|
||||
"Launch the game first");
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateStatus($"⚠️ Running title is '{apps[0].Name}', need '{set.TitleId}'");
|
||||
await ShowMessageAsync(
|
||||
$"A different title is running: \"{apps[0].Name}\".\n\nLaunch \"{set.GameName}\" ({set.TitleId}) first.",
|
||||
"Launch the game first");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task RunTrophyUnlockAsync(string spec)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var (ok, msg) = await _protocol!.TrophyUnlockAsync(spec);
|
||||
if (ok)
|
||||
{
|
||||
UpdateStatus($"🏆 {msg} ({sw.ElapsedMilliseconds} ms)");
|
||||
await Task.Delay(1200);
|
||||
await RefreshTrophiesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateStatus($"❌ Trophy unlock failed: {msg}");
|
||||
await ShowMessageAsync(msg, "Unlock failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async void TrophyUnlock_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { DataContext: PS5Trophy t }) return;
|
||||
if (_selectedTrophySet is not PS5TrophySet set) return;
|
||||
if (!await EnsureTrophyGameRunningAsync(set)) return;
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Unlock trophy \"{t.Name}\" ({t.GradeDisplay}) on the PS5?\n\n" +
|
||||
"The unlock goes through the running game's trophy pipeline — it is permanent and fires a real notification.",
|
||||
"Unlock trophy")) return;
|
||||
await RunTrophyUnlockAsync($"unlock:{t.Id}");
|
||||
}
|
||||
|
||||
private async void TrophyLock_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { DataContext: PS5Trophy t }) return;
|
||||
if (_selectedTrophySet is not PS5TrophySet set) return;
|
||||
if (_protocol?.IsConnected != true) { UpdateStatus("Not connected"); return; }
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Re-lock trophy \"{t.Name}\" on the PS5?\n\n" +
|
||||
"This rewrites TRPTITLE.DAT on the console — the unlock bit and timestamp are cleared.\n\n" +
|
||||
"Close the game first if it's running, so the trophy daemon doesn't overwrite the edit.",
|
||||
"Re-lock trophy")) return;
|
||||
|
||||
var (ok, msg) = await _protocol.TrophyUnlockAsync($"lock:{set.NpCommunicationId}:{t.Id}");
|
||||
if (ok)
|
||||
{
|
||||
UpdateStatus($"🔒 {msg}");
|
||||
await Task.Delay(600);
|
||||
await RefreshTrophiesAsync();
|
||||
// stay inside the set's trophy list after refresh
|
||||
if (_selectedTrophySet != null && TrophyItemsListBox.IsVisible)
|
||||
{
|
||||
var same = _trophySets.FirstOrDefault(s => s.NpCommunicationId == _selectedTrophySet.NpCommunicationId);
|
||||
if (same != null)
|
||||
{
|
||||
_selectedTrophySet = same;
|
||||
TrophyItemsListBox.ItemsSource = same.Trophies;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateStatus($"❌ Trophy lock failed: {msg}");
|
||||
await ShowMessageAsync(msg, "Lock failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- icons (side connection) ----------
|
||||
|
||||
private async Task LoadTrophySetIconsAsync(List<PS5TrophySet> sets)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_ps5IpAddress == null) return;
|
||||
using var proto = new PS5Protocol();
|
||||
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
|
||||
foreach (var set in sets)
|
||||
{
|
||||
foreach (var cand in new[] { "icon0_en-US.png", "icon0.png" })
|
||||
{
|
||||
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, cand);
|
||||
if (bytes == null || bytes.Length == 0) continue;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { set.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task LoadTrophyItemIconsAsync(PS5TrophySet set)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_ps5IpAddress == null) return;
|
||||
using var proto = new PS5Protocol();
|
||||
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
|
||||
foreach (var t in set.Trophies)
|
||||
{
|
||||
if (t.Icon != null) continue;
|
||||
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, $"trop{t.Id:0000}.png");
|
||||
if (bytes == null || bytes.Length == 0) continue;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { t.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
|
||||
});
|
||||
if (_protocol?.IsConnected != true) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
|
||||
x:Class="PS5SuiteAndroid.Views.MainView"
|
||||
Background="#1E1E1E">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- Header -->
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10,15,15,15">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Button Grid.Column="0" x:Name="MenuButton" Content="☰" FontSize="18"
|
||||
Background="Transparent" Foreground="#0E9CFF" Padding="8,0"
|
||||
VerticalAlignment="Center" Click="MenuButton_Click"/>
|
||||
<StackPanel Grid.Column="1" Margin="8,0,0,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="StatusIndicator" Width="10" Height="10" Fill="#FF6B35" Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="PS5 Suite" FontSize="20" FontWeight="Bold" Foreground="#0E9CFF"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="ConnectionStatusText" Text="Disconnected" FontSize="11" Foreground="#A0A0A0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Navigation drawer + content -->
|
||||
<SplitView Grid.Row="1" x:Name="NavDrawer" DisplayMode="Overlay"
|
||||
CompactPaneLength="0" OpenPaneLength="250"
|
||||
PaneBackground="#252526" IsPaneOpen="False">
|
||||
<SplitView.Pane>
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Background="#2D2D30" Padding="15,12">
|
||||
<TextBlock Text="Menu" FontSize="14" FontWeight="Bold" Foreground="#0E9CFF"/>
|
||||
</Border>
|
||||
<ListBox Grid.Row="1" x:Name="NavList" Background="Transparent"
|
||||
SelectionChanged="NavList_SelectionChanged">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="15,13"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="Foreground" Value="#E0E0E0"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected">
|
||||
<Setter Property="Background" Value="#0E639C"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover">
|
||||
<Setter Property="Background" Value="#3E3E42"/>
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBoxItem>🔌 Connect</ListBoxItem>
|
||||
<ListBoxItem>📁 File Transfer</ListBoxItem>
|
||||
<ListBoxItem>🎮 Games</ListBoxItem>
|
||||
<ListBoxItem>🌐 Prospero Store</ListBoxItem>
|
||||
<ListBoxItem>🏆 Trophies</ListBoxItem>
|
||||
<ListBoxItem>💾 Saves & Media</ListBoxItem>
|
||||
<ListBoxItem>🖥️ System Info</ListBoxItem>
|
||||
<ListBoxItem>🛠️ Tools</ListBoxItem>
|
||||
<ListBoxItem>⚡ Devices</ListBoxItem>
|
||||
<ListBoxItem>📋 Debug Log</ListBoxItem>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</SplitView.Pane>
|
||||
<SplitView.Content>
|
||||
<Panel x:Name="ContentHost" Background="#1E1E1E">
|
||||
<!-- Connect Tab -->
|
||||
<Panel x:Name="PageConnect">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="15" Spacing="15">
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="PS5 IP Address" Foreground="#A0A0A0" FontSize="14"/>
|
||||
<TextBox x:Name="IpAddressInput" Text="192.168.1.100" Background="#2D2D30"
|
||||
BorderBrush="#3E3E42" Padding="12" FontSize="16"/>
|
||||
<Button x:Name="ConnectButton" Content="Connect" Background="#007ACC"
|
||||
Foreground="White" Padding="15" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center" FontSize="16" Click="ConnectButton_Click"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Quick Actions" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Content="📁 Browse Files" Background="#3E3E42" Foreground="White"
|
||||
Padding="12" HorizontalAlignment="Stretch" Click="BrowseFilesButton_Click"/>
|
||||
<Button Content="🎮 View Games" Background="#3E3E42" Foreground="White"
|
||||
Padding="12" HorizontalAlignment="Stretch" Click="ViewGamesButton_Click"/>
|
||||
<Button Content="🖥️ System Info" Background="#3E3E42" Foreground="White"
|
||||
Padding="12" HorizontalAlignment="Stretch" Click="SystemInfoButton_Click"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- Files Tab -->
|
||||
<Panel x:Name="PageFiles" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="CurrentPathText" Text="/data" Foreground="#A0A0A0"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
<Button Grid.Column="1" Content="⬆️" Background="#3E3E42" Padding="10" Click="GoUpButton_Click"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<WrapPanel Grid.Row="1" Margin="10,6" Orientation="Horizontal">
|
||||
<Button Content="🔄 Refresh" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FilesRefresh_Click"/>
|
||||
<Button Content="📤 Upload" Background="#007ACC" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileUpload_Click"/>
|
||||
<Button Content="📁 New Folder" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileNewFolder_Click"/>
|
||||
<Button Content="⬇️ Download" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileDownload_Click"/>
|
||||
<Button Content="📝 Rename" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileRename_Click"/>
|
||||
<Button Content="🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FileDelete_Click"/>
|
||||
</WrapPanel>
|
||||
<ListBox Grid.Row="2" x:Name="FilesListBox" Background="#1E1E1E"
|
||||
SelectionMode="Single" DoubleTapped="FilesListBox_DoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="4" Padding="12" Margin="4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Icon}" FontSize="24" Margin="0,0,12,0"/>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SizeText}" FontSize="11" Foreground="#808080"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" FontSize="20" Foreground="#808080"
|
||||
VerticalAlignment="Center" IsVisible="{Binding IsDirectory}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Games Tab -->
|
||||
<Panel x:Name="PageGames" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="GameSearchBox" Watermark="Search games..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
|
||||
TextChanged="GameSearch_TextChanged"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="RefreshGamesButton_Click"/>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="GameCountText" Text="Games" Foreground="#0E9CFF" FontWeight="Bold" FontSize="13" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="GameSortCombo" Grid.Column="1" Background="#2D2D30" SelectedIndex="0"
|
||||
SelectionChanged="GameSort_SelectionChanged">
|
||||
<ComboBoxItem>Name A→Z</ComboBoxItem>
|
||||
<ComboBoxItem>Name Z→A</ComboBoxItem>
|
||||
<ComboBoxItem>Size ↑</ComboBoxItem>
|
||||
<ComboBoxItem>Size ↓</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<WrapPanel Grid.Row="1" Margin="10,6" Orientation="Horizontal">
|
||||
<Button Content="🎮 Mount All" Background="#007ACC" Foreground="White" Padding="10" Margin="0,4,6,4" Click="MountGamesButton_Click"/>
|
||||
<Button Content="📥 Mount" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="MountGameButton_Click"/>
|
||||
<Button Content="▶️ Launch" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="LaunchGameButton_Click"/>
|
||||
<Button Content="⏹ Stop" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="StopGameButton_Click"/>
|
||||
<Button Content="🗑️ Unmount" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="UnmountGameButton_Click"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Grid.Row="2" x:Name="RunningAppsText" Text="" Foreground="#28A745" FontSize="11" Margin="12,0" TextWrapping="Wrap" IsVisible="False"/>
|
||||
<ListBox Grid.Row="3" x:Name="GamesListBox" Background="#1E1E1E">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Width="60" Height="60" Background="#3E3E42"
|
||||
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
|
||||
<Panel>
|
||||
<TextBlock Text="🎮" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
|
||||
</Panel>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock FontSize="12" Foreground="#0E9CFF">
|
||||
<Run Text="{Binding TitleId}"/><Run Text="{Binding RunningBadge}" Foreground="#28A745"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding SizeText}" FontSize="11" Foreground="#808080"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Prospero Store Tab (homebrew.page native PS5 apps) -->
|
||||
<Panel x:Name="PageProspero" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="ProsperoSearchBox" Watermark="Search Prospero store..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
|
||||
TextChanged="ProsperoSearch_TextChanged"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="ProsperoRefresh_Click"/>
|
||||
</Grid>
|
||||
<ComboBox x:Name="ProsperoCategoryCombo" Background="#2D2D30" SelectedIndex="0"
|
||||
HorizontalAlignment="Stretch" SelectionChanged="ProsperoCategory_SelectionChanged">
|
||||
<ComboBoxItem>📦 All</ComboBoxItem>
|
||||
<ComboBoxItem>🧩 Apps</ComboBoxItem>
|
||||
<ComboBoxItem>🎮 Games</ComboBoxItem>
|
||||
<ComboBoxItem>🔧 Tools</ComboBoxItem>
|
||||
<ComboBoxItem>⏳ Coming soon</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="ProsperoStatusText" Text="Tap 🔄 to fetch homebrew.page catalog" Foreground="#808080" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ProgressBar Grid.Row="1" x:Name="ProsperoProgress" Height="6" Minimum="0" Maximum="100" Value="0"
|
||||
Background="#1E1E1E" Foreground="#0E9CFF" IsVisible="False"/>
|
||||
<ListBox Grid.Row="2" x:Name="ProsperoListBox" Background="#1E1E1E">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Width="52" Height="52" Background="#3E3E42"
|
||||
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
|
||||
<Panel>
|
||||
<TextBlock Text="📦" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Image Source="{Binding IconImage}" Stretch="UniformToFill"/>
|
||||
</Panel>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="11" Foreground="#0E9CFF"/>
|
||||
<TextBlock FontSize="10" Foreground="#808080">
|
||||
<Run Text="{Binding MetaLine}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="{Binding ButtonLabel}" Background="#007ACC" Foreground="White"
|
||||
Padding="8" VerticalAlignment="Center" Tag="{Binding TitleId}"
|
||||
IsEnabled="{Binding CanInstall}" Click="ProsperoInstall_Click"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Trophies Tab -->
|
||||
<Panel x:Name="PageTrophies" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="TrophySearchBox" Watermark="Search trophy sets..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
|
||||
TextChanged="TrophySearch_TextChanged"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="TrophyRefresh_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="TrophyStatusText" Text="Tap 🔄 to load trophy sets" Foreground="#808080" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Row="1" Background="#252526" Padding="6,0" x:Name="TrophyBackBar" IsVisible="False">
|
||||
<Button Content="← Back to sets" Background="Transparent" Foreground="#0E9CFF"
|
||||
Padding="10" Click="TrophyBack_Click"/>
|
||||
</Border>
|
||||
<ListBox Grid.Row="2" x:Name="TrophySetsListBox" Background="#1E1E1E"
|
||||
SelectionChanged="TrophySet_SelectionChanged">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Width="52" Height="52" Background="#3E3E42"
|
||||
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
|
||||
<Panel>
|
||||
<TextBlock Text="🏆" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
|
||||
</Panel>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding GameName}" FontSize="14" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding NpCommunicationId}" FontSize="11" Foreground="#0E9CFF"/>
|
||||
<TextBlock FontSize="11" Foreground="#808080">
|
||||
<Run Text="{Binding ProgressDisplay}"/><Run Text=" earned"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<ListBox Grid.Row="2" x:Name="TrophyItemsListBox" Background="#1E1E1E" IsVisible="False">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="8" Padding="10" Margin="4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Width="44" Height="44" Background="#3E3E42"
|
||||
CornerRadius="6" Margin="0,0,10,0" ClipToBounds="True">
|
||||
<Panel>
|
||||
<TextBlock Text="🏆" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
|
||||
</Panel>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding HiddenDisplay}" FontSize="11"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding GradeDisplay}" FontSize="10" FontWeight="Bold" Foreground="{Binding GradeBrush}"/>
|
||||
<TextBlock Text="{Binding StateDisplay}" FontSize="10" Foreground="#808080"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Detail}" FontSize="10" Foreground="#606060" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Center" Spacing="4">
|
||||
<Button Content="🔓" Background="#28A745" Foreground="White" Padding="8"
|
||||
IsVisible="{Binding CanUnlock}" Click="TrophyUnlock_Click"/>
|
||||
<Button Content="🔒" Background="#DC3545" Foreground="White" Padding="8"
|
||||
IsVisible="{Binding CanLock}" Click="TrophyLock_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Saves & Media Tab -->
|
||||
<Panel x:Name="PageSaves" IsVisible="False">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="15" Spacing="15">
|
||||
<!-- Save Images Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="💾 Save Images" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="🔄 Scan" Background="#3E3E42" Padding="8" Click="SaveScanButton_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="SaveMountStatusText" Text="No save mounted" Foreground="#808080" FontSize="11"/>
|
||||
<ListBox x:Name="SavesListBox" Background="#1E1E1E" MaxHeight="300">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding SaveName}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="11" Foreground="#0E9CFF"/>
|
||||
<TextBlock Text="{Binding TypeBadge}" FontSize="11" Foreground="#9C27B0"/>
|
||||
<TextBlock Text="{Binding SizeDisplay}" FontSize="11" Foreground="#808080"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Content="🔓 Mount" Background="#007ACC" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveMountButton_Click"/>
|
||||
<Button Content="🔒 Unmount" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveUnmountButton_Click"/>
|
||||
<Button Content="📂 Browse" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveBrowseButton_Click"/>
|
||||
<Button Content="� Backup" Background="#28A745" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveBackupButton_Click"/>
|
||||
<Button Content="📥 Raw" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveBackupRaw_Click"/>
|
||||
<Button Content="📤 Restore" Background="#FFC107" Foreground="#1E1E1E" Padding="10" Margin="0,4,6,4" Click="SaveRestoreButton_Click"/>
|
||||
<Button Content="�🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="SaveDeleteButton_Click"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Mounted saves are browsable at /data/save_mnt" Foreground="#606060" FontSize="10"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Screenshots Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="📸 Screenshots" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="ScreenshotsRefreshButton_Click"/>
|
||||
</Grid>
|
||||
<ListBox x:Name="ScreenshotsListBox" Background="#1E1E1E" MaxHeight="260">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding FileName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="10" Foreground="#808080">
|
||||
<Run Text="{Binding DateDisplay}"/><Run Text=" · "/><Run Text="{Binding SizeDisplay}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Image Grid.Column="1" Source="{Binding Thumbnail}" Width="72" Height="40" Stretch="Uniform"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Content="⬇️ Download" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="ScreenshotDownload_Click"/>
|
||||
<Button Content="🗑️ Delete Selected" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="ScreenshotDeleteButton_Click"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- System Tab -->
|
||||
<Panel x:Name="PageSystem" IsVisible="False">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="15" Spacing="15">
|
||||
<!-- Sensors Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🌡️ Live Sensors" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="RefreshSensorsButton_Click"/>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Margin="0,0,8,8">
|
||||
<TextBlock Text="CPU Temp" Foreground="#808080" FontSize="11"/>
|
||||
<TextBlock x:Name="CpuTempText" Text="—°C" Foreground="#FFA500" FontSize="20" FontWeight="Bold"/>
|
||||
<ProgressBar x:Name="CpuTempBar" Height="8" Minimum="0" Maximum="100" Value="0" Background="#1E1E1E" Foreground="#FF6B35"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Margin="8,0,0,8">
|
||||
<TextBlock Text="SoC Temp" Foreground="#808080" FontSize="11"/>
|
||||
<TextBlock x:Name="SocTempText" Text="—°C" Foreground="#FFA500" FontSize="20" FontWeight="Bold"/>
|
||||
<ProgressBar x:Name="SocTempBar" Height="8" Minimum="0" Maximum="100" Value="0" Background="#1E1E1E" Foreground="#FF6B35"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Margin="0,8,8,0">
|
||||
<TextBlock Text="CPU Freq" Foreground="#808080" FontSize="11"/>
|
||||
<TextBlock x:Name="CpuFreqText" Text="— MHz" Foreground="#0E9CFF" FontSize="20" FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Margin="8,8,0,0">
|
||||
<TextBlock Text="Power" Foreground="#808080" FontSize="11"/>
|
||||
<TextBlock x:Name="PowerText" Text="— W" Foreground="#28A745" FontSize="20" FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- CPU Usage Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="📊 CPU Usage" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 0:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu0Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu0Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 4:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu4Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu4Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 1:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu1Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu1Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 5:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu5Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu5Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 2:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu2Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu2Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 6:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu6Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu6Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 3:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu3Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu3Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="Core 7:" Foreground="#808080" Width="50" FontSize="12"/>
|
||||
<TextBlock x:Name="Cpu7Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
|
||||
<ProgressBar x:Name="Cpu7Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Border Background="#1E1E1E" CornerRadius="4" Padding="8" Margin="0,8,0,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<TextBlock Text="Average:" Foreground="#808080" FontSize="12"/>
|
||||
<TextBlock x:Name="CpuAvgText" Text="—%" Foreground="#28A745" FontWeight="Bold" FontSize="14" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Memory Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="💾 Memory" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Direct Total:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="MemTotalText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Available:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="MemAvailText" Text="—" Foreground="#28A745" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Used:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="MemUsedText" Text="—" Foreground="#FFC107" FontSize="12" Margin="10,2,0,2"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,8,0,0">
|
||||
<ProgressBar x:Name="MemUsageBar" Height="20" Minimum="0" Maximum="100" Value="0" Background="#1E1E1E" Foreground="#007ACC"/>
|
||||
<TextBlock x:Name="MemUsageText" Text="—%" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="11" FontWeight="Bold"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- System Info Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="📋 System Info" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Model:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="ModelText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Firmware:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="FirmwareText" Text="—" Foreground="#28A745" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Total Uptime:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="UptimeText" Text="—" Foreground="#FFC107" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Boot Count:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="BootCountText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="BD Drive:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="BdDriveText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Extended Info Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🔧 Extended Info" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="ExtInfoRefresh_Click"/>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Prospero:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="ProsperoText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Product:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="ProductText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Shutdowns:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="ShutdownCountText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Thermal Alert:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="ThermalAlertText" Text="—" Foreground="#FFA500" FontSize="12" Margin="10,2,0,2"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="BD Power:" Foreground="#808080" FontSize="12" Margin="0,2"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="BdPowerText" Text="—" FontSize="12" Margin="10,2,0,2"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Network Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🌐 Network" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="NetRefresh_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="NetInfoText" Text="Not loaded" Foreground="#A0A0A0" FontSize="12" TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Content="⚡ Speed Test" Background="#007ACC" Foreground="White"
|
||||
Padding="10" HorizontalAlignment="Stretch" Click="NetSpeed_Click"/>
|
||||
<TextBlock Grid.Column="1" x:Name="NetSpeedText" Text="" Foreground="#28A745" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Modules Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="📦 Loaded Modules" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="ModulesRefresh_Click"/>
|
||||
</Grid>
|
||||
<ListBox x:Name="ModulesListBox" Background="#1E1E1E" MaxHeight="260">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="4" Padding="8" Margin="3">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}" Foreground="#9CDCFE" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Info}" Foreground="#808080" FontSize="10"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- Tools Tab -->
|
||||
<Panel x:Name="PageTools" IsVisible="False">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="15" Spacing="15">
|
||||
<!-- PKG Install Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="📦 Install PKG" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<TextBlock Text="PKG path on PS5 or HTTP URL:" Foreground="#A0A0A0" FontSize="12"/>
|
||||
<TextBox x:Name="PkgPathInput" Watermark="/user/data/pkg/game.pkg or http://..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="12" FontSize="13"/>
|
||||
<Button Content="📦 Install PKG" Background="#007ACC" Foreground="White"
|
||||
Padding="12" HorizontalAlignment="Stretch" Click="InstallPkgButton_Click"/>
|
||||
<ProgressBar x:Name="PkgProgressBar" Height="8" Minimum="0" Maximum="100" Value="0"
|
||||
Background="#1E1E1E" Foreground="#0E9CFF"/>
|
||||
<TextBlock x:Name="PkgStatusText" Text="Idle" Foreground="#808080" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Fan Control Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="🌀 Fan Control" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="Current threshold:" Foreground="#A0A0A0" FontSize="12" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="FanCurrentText" Text="—" Foreground="#FFA500" FontSize="14" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<Button Content="🔄" Background="#3E3E42" Padding="8" Click="FanRefreshButton_Click"/>
|
||||
</StackPanel>
|
||||
<Slider x:Name="FanSlider" Minimum="30" Maximum="90" Value="60"
|
||||
TickFrequency="1" IsSnapToTickEnabled="True"
|
||||
ValueChanged="FanSlider_ValueChanged"/>
|
||||
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="Cool 50°" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FanPreset_Click"/>
|
||||
<TextBlock x:Name="FanSliderText" Text="60°C" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#0E9CFF" VerticalAlignment="Center" MinWidth="60" Margin="0,4"/>
|
||||
<Button Content="Quiet 70°" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="FanPreset_Click"/>
|
||||
</WrapPanel>
|
||||
<Button Content="🌀 Set Threshold" Background="#007ACC" Foreground="White"
|
||||
Padding="12" HorizontalAlignment="Stretch" Click="FanSetButton_Click"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Shell Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="💻 Shell" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<TextBox x:Name="ShellOutputBox" IsReadOnly="True" Height="180" FontFamily="Consolas" FontSize="11"
|
||||
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="ShellInputBox" Watermark="command..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"
|
||||
KeyDown="ShellInput_KeyDown"/>
|
||||
<Button Grid.Column="1" Content="▶" Background="#007ACC" Foreground="White" Padding="12" Click="ShellExec_Click"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Search Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="🔍 File Search" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="📇 Index" Background="#3E3E42" Padding="8" Click="IndexStart_Click"/>
|
||||
<TextBlock x:Name="IndexStatusText" Text="index not built" Foreground="#808080" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="SearchBox" Watermark="search pattern..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<Button Grid.Column="1" Content="🔍" Background="#007ACC" Foreground="White" Padding="10" Click="SearchRun_Click"/>
|
||||
</Grid>
|
||||
<ListBox x:Name="SearchResultsBox" Background="#1E1E1E" MaxHeight="200">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" Foreground="#9CDCFE" FontSize="11" Margin="4,2"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Memory Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="🧠 Memory" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBox x:Name="MemPidBox" Watermark="pid" Width="100" Margin="0,4,6,4"
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<Button Content="📋 Regions" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="MemRegions_Click"/>
|
||||
</WrapPanel>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBox x:Name="MemAddrBox" Watermark="0xaddr" Width="150" Margin="0,4,6,4"
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<TextBox x:Name="MemLenBox" Watermark="len" Width="80" Margin="0,4,6,4"
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<Button Content="📖 Read" Background="#007ACC" Foreground="White" Padding="8" Margin="0,4,6,4" Click="MemRead_Click"/>
|
||||
</WrapPanel>
|
||||
<TextBox x:Name="MemOutputBox" IsReadOnly="True" Height="160" FontFamily="Consolas" FontSize="10"
|
||||
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Apps Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🎯 Running Apps" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="AppsRefresh_Click"/>
|
||||
</Grid>
|
||||
<ListBox x:Name="AppsListBox" Background="#1E1E1E" MaxHeight="260"
|
||||
SelectionChanged="AppsListBox_SelectionChanged">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="10" Foreground="#0E9CFF"/>
|
||||
<TextBlock Text="{Binding Cpu}" FontSize="10" Foreground="#9CDCFE"/>
|
||||
<TextBlock Text="{Binding Status}" FontSize="10" Foreground="#FFA500"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="⏸ Suspend" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="AppSuspend_Click"/>
|
||||
<Button Content="▶ Resume" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="AppResume_Click"/>
|
||||
<Button Content="⏹ Kill" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="AppKill_Click"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Kernel Log Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🧾 Kernel Log" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="KlogRefresh_Click"/>
|
||||
</Grid>
|
||||
<TextBox x:Name="KlogBox" IsReadOnly="True" Height="220" FontFamily="Consolas" FontSize="10"
|
||||
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- Devices Tab -->
|
||||
<Panel x:Name="PageDevices" IsVisible="False">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="15" Spacing="15">
|
||||
<!-- Power Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="⚡ Power" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Content="🔄 Reboot" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="PowerReboot_Click"/>
|
||||
<Button Content="⏻ Shutdown" Background="#DC3545" Foreground="White" Padding="10" Margin="0,4,6,4" Click="PowerShutdown_Click"/>
|
||||
<Button Content="📺 Restart Shell UI" Background="#3E3E42" Foreground="White" Padding="10" Margin="0,4,6,4" Click="RestartUi_Click"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- USB Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🔌 USB Drives" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="UsbRefresh_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="UsbListText" Text="No USB drives scanned" Foreground="#A0A0A0" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Controller Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="🎮 Controller" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="🔄 Info" Background="#3E3E42" Padding="8" Click="PadRefresh_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="PadInfoText" Text="—" Foreground="#A0A0A0" FontSize="11" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="Light Bar" Foreground="#A0A0A0" FontSize="12"/>
|
||||
<WrapPanel>
|
||||
<Button Content="🔵" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="0,0,255"/>
|
||||
<Button Content="🔴" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="255,0,0"/>
|
||||
<Button Content="🟢" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="0,255,0"/>
|
||||
<Button Content="🟣" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="128,0,255"/>
|
||||
<Button Content="⚪" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="255,255,255"/>
|
||||
<Button Content="⚫" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="0,0,0"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Console LED Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="💡 Console LED" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Content="Bright" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="LedDim_Click" Tag="0"/>
|
||||
<Button Content="Medium" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="LedDim_Click" Tag="1"/>
|
||||
<Button Content="Dim" Background="#3E3E42" Foreground="White" Padding="8" Margin="0,4,6,4" Click="LedDim_Click" Tag="2"/>
|
||||
</WrapPanel>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<ComboBox x:Name="LedEffectCombo" Grid.Column="0" Background="#2D2D30" SelectedIndex="1">
|
||||
<ComboBoxItem>off</ComboBoxItem>
|
||||
<ComboBoxItem>white</ComboBoxItem>
|
||||
<ComboBoxItem>white-dim</ComboBoxItem>
|
||||
<ComboBoxItem>blue</ComboBoxItem>
|
||||
<ComboBoxItem>orange</ComboBoxItem>
|
||||
<ComboBoxItem>purple</ComboBoxItem>
|
||||
<ComboBoxItem>pink</ComboBoxItem>
|
||||
<ComboBoxItem>sunrise</ComboBoxItem>
|
||||
<ComboBoxItem>blue-breathe</ComboBoxItem>
|
||||
<ComboBoxItem>white-breathe</ComboBoxItem>
|
||||
<ComboBoxItem>orange-breathe</ComboBoxItem>
|
||||
<ComboBoxItem>pink-breathe</ComboBoxItem>
|
||||
<ComboBoxItem>pink-breathe-fast</ComboBoxItem>
|
||||
<ComboBoxItem>blue-to-richblue</ComboBoxItem>
|
||||
<ComboBoxItem>blue-white-anim</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<Button Grid.Column="1" Content="✨ Apply" Background="#007ACC" Foreground="White" Padding="10" Click="LedEffect_Click"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Beeper Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="🔊 Beeper" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<WrapPanel>
|
||||
<Button Content="1×" Padding="10" Margin="2" Background="#3E3E42" Click="Beep_Click" Tag="1"/>
|
||||
<Button Content="2×" Padding="10" Margin="2" Background="#3E3E42" Click="Beep_Click" Tag="2"/>
|
||||
<Button Content="3×" Padding="10" Margin="2" Background="#3E3E42" Click="Beep_Click" Tag="3"/>
|
||||
<Button Content="Mute" Padding="10" Margin="2" Background="#DC3545" Click="BeepMute_Click" Tag="1"/>
|
||||
<Button Content="Unmute" Padding="10" Margin="2" Background="#3E3E42" Click="BeepMute_Click" Tag="0"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Notification Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="🔔 Notification" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<TextBox x:Name="NotifyTextBox" Watermark="Message to show on PS5..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="12" FontSize="13"/>
|
||||
<Button Content="🔔 Send" Background="#007ACC" Foreground="White"
|
||||
Padding="10" HorizontalAlignment="Stretch" Click="NotifySend_Click"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- Debug Log Tab -->
|
||||
<Panel x:Name="PageLog" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<CheckBox x:Name="LogAutoScroll" Content="Auto-scroll" Foreground="#A0A0A0" IsChecked="True"/>
|
||||
<Button Content="🗑️ Clear" Background="#3E3E42" Padding="8" Click="LogClear_Click"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ScrollViewer Grid.Row="1" x:Name="LogScroller" Background="#1E1E1E">
|
||||
<ItemsControl x:Name="LogItems" Margin="8"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Panel>
|
||||
</SplitView.Content>
|
||||
</SplitView>
|
||||
|
||||
<!-- Bottom Status Bar -->
|
||||
<Border Grid.Row="2" Background="#252526" Padding="10">
|
||||
<TextBlock x:Name="StatusText" Text="Ready" Foreground="#808080" FontSize="11" HorizontalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- Modal dialog overlay (message / confirm / input) -->
|
||||
<Grid Grid.Row="0" Grid.RowSpan="3" x:Name="DialogOverlay" IsVisible="False"
|
||||
Background="#AA000000">
|
||||
<Border Background="#252526" CornerRadius="12" Padding="20" Margin="24"
|
||||
MaxWidth="420" VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
BorderBrush="#3E3E42" BorderThickness="1">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="DialogTitle" Text="Title" FontSize="16" FontWeight="Bold" Foreground="#0E9CFF"/>
|
||||
<ScrollViewer MaxHeight="300">
|
||||
<TextBlock x:Name="DialogMessage" Text="" FontSize="13" Foreground="#E0E0E0" TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
<TextBox x:Name="DialogInput" Watermark="" Background="#2D2D30" BorderBrush="#3E3E42"
|
||||
Padding="10" FontSize="14" IsVisible="False"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
||||
<Button x:Name="DialogCancelButton" Content="Cancel" Background="#3E3E42" Foreground="White"
|
||||
Padding="16,10" Click="DialogCancel_Click"/>
|
||||
<Button x:Name="DialogOkButton" Content="OK" Background="#007ACC" Foreground="White"
|
||||
Padding="16,10" Click="DialogOk_Click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PS5SuiteAndroid.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="using:PS5SuiteAndroid.Views"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="PS5SuiteAndroid.Views.MainWindow"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="PS5SuiteAndroid">
|
||||
<views:MainView />
|
||||
</Window>
|
||||
@@ -1,11 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LibProsperoPkg.Gui.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace PS5SuiteAndroid.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.425",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
|
||||
namespace PS5Upload.Android;
|
||||
|
||||
[Application]
|
||||
public class Application : AvaloniaAndroidApplication<App>
|
||||
{
|
||||
public Application(nint javaReference, global::Android.Runtime.JniHandleOwnership transfer)
|
||||
: base(javaReference, transfer)
|
||||
{
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
{
|
||||
return base.CustomizeAppBuilder(builder)
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
|
||||
[Activity(
|
||||
Label = "PS5 Suite",
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
Icon = "@mipmap/ic_launcher",
|
||||
MainLauncher = true,
|
||||
LaunchMode = LaunchMode.SingleTop,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize)]
|
||||
public class MainActivity : AvaloniaMainActivity
|
||||
{
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-android</TargetFramework>
|
||||
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>14</LangVersion>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationId>com.manos.ps5suite</ApplicationId>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>7.1.5</ApplicationDisplayVersion>
|
||||
<ApplicationTitle>PS5 Suite</ApplicationTitle>
|
||||
<AssemblyName>PS5Suite</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
<!-- The UI relies on runtime reflection bindings ({Binding ...} without x:DataType);
|
||||
trimming would strip bound properties and blank out the lists. -->
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<RunAOTCompilation>false</RunAOTCompilation>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the full desktop UI/codebase so the Android app has feature parity -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\client-avalonia\**\*.cs"
|
||||
Exclude="..\client-avalonia\Program.cs;..\client-avalonia\bin\**;..\client-avalonia\obj\**" />
|
||||
<AvaloniaXaml Include="..\client-avalonia\**\*.axaml"
|
||||
Exclude="..\client-avalonia\bin\**;..\client-avalonia\obj\**" />
|
||||
<AvaloniaResource Include="..\client-avalonia\ps5_logo.png" Link="ps5_logo.png" />
|
||||
<AvaloniaResource Include="..\client-avalonia\ps5_logo.ico" Link="ps5_logo.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Android" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||
<Reference Include="LibProsperoPkg">
|
||||
<HintPath>..\client-avalonia\libs\LibProsperoPkg.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AndroidResource Include="Resources\**\*.xml" />
|
||||
<AndroidResource Include="Resources\**\*.png" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<application android:label="PS5 Suite" android:icon="@mipmap/ic_launcher" android:theme="@style/MyTheme.Main" android:usesCleartextTraffic="true" />
|
||||
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
||||
</manifest>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 183 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1E1E1E</color>
|
||||
</resources>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<style name="MyTheme.Main" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">#1E1E1E</item>
|
||||
<item name="android:navigationBarColor">#1E1E1E</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
<style name="MyTheme.NoActionBar" parent="MyTheme.Main" />
|
||||
</resources>
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// App Manager v2 — real per-app data from sceKernelGetAppInfo +
|
||||
// suspend/resume/kill/coredump through LncUtil (daemon IPC, needs etaHEN).
|
||||
public partial class MainWindow
|
||||
{
|
||||
private class AppEntry
|
||||
{
|
||||
public int Pid;
|
||||
public uint AppId;
|
||||
public string TitleId = "";
|
||||
public string Name = "";
|
||||
public uint AppType;
|
||||
public double CpuPct;
|
||||
public int Suspended; // -1 unknown, 0 running, 1 suspended
|
||||
public string Display =>
|
||||
$"pid={Pid,-6} app={AppId,-4} {TitleId,-10} {Name,-20} cpu={CpuPct,6:0.00}% " +
|
||||
(Suspended < 0 ? "?" : Suspended == 1 ? "suspended" : "running");
|
||||
}
|
||||
|
||||
private readonly List<AppEntry> _apps = new();
|
||||
private AppEntry? _appSel;
|
||||
private DispatcherTimer? _appTimer;
|
||||
|
||||
private async Task AppMgrLoadAsync()
|
||||
{
|
||||
var txt = await _protocol.AppListV2Async();
|
||||
if (txt == null)
|
||||
{
|
||||
AppMgrStatus.Text = $"app list failed: {_protocol.LastError}";
|
||||
return;
|
||||
}
|
||||
_apps.Clear();
|
||||
foreach (var line in txt.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (line.StartsWith("appinfo="))
|
||||
{
|
||||
if (!line.EndsWith("ok")) AppMgrStatus.Text = "sceKernelGetAppInfo unavailable — app fields empty";
|
||||
continue;
|
||||
}
|
||||
var f = line.Split('|');
|
||||
if (f.Length < 7) continue;
|
||||
_apps.Add(new AppEntry
|
||||
{
|
||||
Pid = int.Parse(f[0]),
|
||||
AppId = uint.Parse(f[1]),
|
||||
TitleId = f[2],
|
||||
Name = f[3],
|
||||
AppType = uint.Parse(f[4]),
|
||||
CpuPct = uint.Parse(f[5]) / 100.0,
|
||||
Suspended = int.Parse(f[6]),
|
||||
});
|
||||
}
|
||||
AppMgrList.ItemsSource = _apps.Select(a => a.Display).ToList();
|
||||
if (!(AppMgrStatus.Text ?? "").StartsWith("sceKernelGetAppInfo"))
|
||||
AppMgrStatus.Text = $"{_apps.Count} app(s) — select one";
|
||||
}
|
||||
|
||||
private async void AppMgrRefresh_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_protocol.IsConnected) { AppMgrStatus.Text = "Not connected"; return; }
|
||||
await AppMgrLoadAsync();
|
||||
}
|
||||
|
||||
private void AppMgrAutoRefresh_Changed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (AppMgrAutoRefresh.IsChecked == true)
|
||||
{
|
||||
_appTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||||
_appTimer.Tick -= AppTimer_Tick;
|
||||
_appTimer.Tick += AppTimer_Tick;
|
||||
_appTimer.Start();
|
||||
}
|
||||
else _appTimer?.Stop();
|
||||
}
|
||||
|
||||
private async void AppTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_protocol.IsConnected) await AppMgrLoadAsync();
|
||||
}
|
||||
|
||||
private void AppMgrList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = AppMgrList.SelectedItem?.ToString();
|
||||
_appSel = s == null ? null : _apps.FirstOrDefault(a => a.Display == s);
|
||||
}
|
||||
|
||||
private async Task AppAction(Func<int, int, Task<(bool ok, string msg)>> fn)
|
||||
{
|
||||
if (_appSel == null) { AppMgrStatus.Text = "Select an app first"; return; }
|
||||
var (ok, msg) = await fn((int)_appSel.AppId, _appSel.Pid);
|
||||
AppMgrStatus.Text = ok ? $"✅ {msg}" : $"❌ {msg}";
|
||||
await AppMgrLoadAsync();
|
||||
}
|
||||
|
||||
private async void AppMgrSuspend_Click(object? sender, RoutedEventArgs e)
|
||||
=> await AppAction(_protocol.AppSuspendAsync);
|
||||
private async void AppMgrResume_Click(object? sender, RoutedEventArgs e)
|
||||
=> await AppAction(_protocol.AppResumeAsync);
|
||||
private async void AppMgrKill_Click(object? sender, RoutedEventArgs e)
|
||||
=> await AppAction(_protocol.AppKillAsync);
|
||||
private async void AppMgrCoredump_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_appSel == null) { AppMgrStatus.Text = "Select an app first"; return; }
|
||||
var (ok, msg) = await _protocol.AppCoredumpAsync((int)_appSel.AppId);
|
||||
AppMgrStatus.Text = ok ? $"✅ {msg}" : $"❌ {msg}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
@@ -169,7 +170,6 @@ namespace PS5Upload
|
||||
{
|
||||
ConnectButton.IsEnabled = false;
|
||||
Log("🔌 Disconnecting from PS5...");
|
||||
Log("🔌 Disconnecting from PS5...");
|
||||
StopHwAutoRefresh();
|
||||
_protocol.Disconnect();
|
||||
_shellActive = false;
|
||||
@@ -357,6 +357,8 @@ namespace PS5Upload
|
||||
}
|
||||
}
|
||||
}
|
||||
// Files were queued while browsing a folder — surface the queue.
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
private async void BrowseFolderButton_Click(object? sender, RoutedEventArgs e)
|
||||
@@ -373,9 +375,94 @@ namespace PS5Upload
|
||||
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = path, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
}
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
private void ClearLocalFiles_Click(object? sender, RoutedEventArgs e) => _localFiles.Clear();
|
||||
// ============================================================
|
||||
// LOCAL PANE BROWSE MODE — double-click a folder in the queue
|
||||
// to navigate into it (local + NAS); files double-clicked in
|
||||
// browse mode are added to the upload queue.
|
||||
// ============================================================
|
||||
private string? _localBrowsePath; // null = queue view
|
||||
private readonly ObservableCollection<LocalFileItem> _localBrowse = new();
|
||||
private const string BrowseBackSentinel = "\0back";
|
||||
|
||||
private void LocalFilesListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (LocalFilesListBox.SelectedItem is not LocalFileItem item) return;
|
||||
if (_localBrowsePath == null)
|
||||
{
|
||||
if (item.IsDirectory) EnterLocalBrowse(item.FullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (item.FullPath == BrowseBackSentinel) { LocalBrowseUp(); return; }
|
||||
if (item.IsDirectory) EnterLocalBrowse(item.FullPath);
|
||||
else
|
||||
{
|
||||
// Queue the file — keep the browse view so more can be picked.
|
||||
_localFiles.Add(new LocalFileItem { Name = item.Name, FullPath = item.FullPath, Icon = "📄", IsDirectory = false, Size = item.Size });
|
||||
Log($"➕ Queued from browse: {item.Name} ({FormatFileSize(item.Size)})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnterLocalBrowse(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = LocalIo.Enumerate(path);
|
||||
_localBrowse.Clear();
|
||||
_localBrowse.Add(new LocalFileItem { Name = "⬆ .. (Back)", FullPath = BrowseBackSentinel, Icon = "⬆", IsDirectory = false });
|
||||
foreach (var en in entries.OrderByDescending(x => x.IsDirectory).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
|
||||
_localBrowse.Add(new LocalFileItem { Name = en.Name, FullPath = en.FullPath, IsDirectory = en.IsDirectory, Size = en.Size, Icon = en.IsDirectory ? "📁" : "📄" });
|
||||
_localBrowsePath = path;
|
||||
LocalFilesListBox.ItemsSource = _localBrowse;
|
||||
LocalFilesHeader.Text = "📂 " + path;
|
||||
LocalBrowseBackBtn.IsVisible = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Cannot browse {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void LocalBrowseUp()
|
||||
{
|
||||
if (_localBrowsePath == null) { ExitLocalBrowse(); return; }
|
||||
string? parent = GetLocalParentPath(_localBrowsePath);
|
||||
if (parent == null) ExitLocalBrowse();
|
||||
else EnterLocalBrowse(parent);
|
||||
}
|
||||
|
||||
private void ExitLocalBrowse()
|
||||
{
|
||||
_localBrowsePath = null;
|
||||
LocalFilesListBox.ItemsSource = _localFiles;
|
||||
LocalFilesHeader.Text = "📁 Local Files";
|
||||
LocalBrowseBackBtn.IsVisible = false;
|
||||
}
|
||||
|
||||
private void LocalBrowseBack_Click(object? sender, RoutedEventArgs e) => LocalBrowseUp();
|
||||
|
||||
private static string? GetLocalParentPath(string path)
|
||||
{
|
||||
if (LocalIo.IsUnc(path))
|
||||
{
|
||||
string p = NasManager.Normalize(path).TrimEnd('\\');
|
||||
string root = NasManager.ShareRoot(p);
|
||||
if (p.Length <= root.Length) return null; // at share root → exit
|
||||
return p.Substring(0, p.LastIndexOf('\\'));
|
||||
}
|
||||
string? parent = Path.GetDirectoryName(path.TrimEnd('\\', '/'));
|
||||
return string.IsNullOrEmpty(parent) ? null : parent;
|
||||
}
|
||||
|
||||
private void ClearLocalFiles_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_localFiles.Clear();
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
private void LocalFilesListBox_DragOver(object? sender, DragEventArgs e) => e.DragEffects = DragDropEffects.Copy;
|
||||
|
||||
@@ -410,6 +497,7 @@ namespace PS5Upload
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -426,8 +514,20 @@ namespace PS5Upload
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "ps5suite_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
// Extract ZIP
|
||||
await Task.Run(() => System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir));
|
||||
// Extract ZIP — stream-based so NAS-sourced archives work too
|
||||
await Task.Run(() =>
|
||||
{
|
||||
if (LocalIo.IsUnc(zipPath) && NasManager.HasSession(zipPath))
|
||||
{
|
||||
using var zs = LocalIo.OpenRead(zipPath);
|
||||
using var za = new System.IO.Compression.ZipArchive(zs, System.IO.Compression.ZipArchiveMode.Read);
|
||||
za.ExtractToDirectory(tempDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Add all extracted files
|
||||
var files = Directory.GetFiles(tempDir, "*", SearchOption.AllDirectories);
|
||||
@@ -452,8 +552,8 @@ namespace PS5Upload
|
||||
{
|
||||
Log($"❌ Failed to extract ZIP: {ex.Message}");
|
||||
// Fall back to adding the zip file itself
|
||||
FileInfo info = new FileInfo(zipPath);
|
||||
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = zipPath, Icon = "📄", IsDirectory = false, Size = info.Length });
|
||||
long sz = LocalIo.GetLength(zipPath);
|
||||
_localFiles.Add(new LocalFileItem { Name = LocalIo.GetName(zipPath), FullPath = zipPath, Icon = "📄", IsDirectory = false, Size = sz });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,25 +580,74 @@ namespace PS5Upload
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NAS/SMB SUPPORT (ps5upload-style)
|
||||
// Adds network paths (\\server\share\folder) to upload queue
|
||||
// NAS/SMB SUPPORT (managed SMB via SMBLibrary — works on
|
||||
// Windows/Linux/macOS/Android with explicit credentials)
|
||||
// ============================================================
|
||||
private async void AddNasPath_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? nasPath = await ShowInputDialogAsync(
|
||||
"Add NAS/Network Path",
|
||||
"Enter UNC path (\\\\server\\share\\folder):",
|
||||
"\\\\NAS\\games");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nasPath)) return;
|
||||
|
||||
nasPath = nasPath.Trim();
|
||||
|
||||
// Validate UNC path format
|
||||
if (!nasPath.StartsWith("\\\\") && !nasPath.StartsWith("//"))
|
||||
// Saved connections → quick picker (or "New" for the full dialog).
|
||||
if (_nasSaved.Count > 0)
|
||||
{
|
||||
var pick = await ShowNasPickerAsync();
|
||||
if (pick == null) return;
|
||||
if (!ReferenceEquals(pick, _nasNewSentinel))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!NasManager.HasSession(pick.Path))
|
||||
{
|
||||
Log($"🔐 Reconnecting to \\\\{pick.Server}\\{pick.Share}...");
|
||||
string pass = NasManager.DecodePass(pick.PassEnc);
|
||||
await Task.Run(() => NasManager.Connect(pick.Server, pick.Share, pick.User, pass));
|
||||
Log($"✅ Connected to \\\\{pick.Server}\\{pick.Share}");
|
||||
}
|
||||
AddNasItem(NasManager.Normalize(pick.Path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Saved NAS connection failed: {ex.Message}");
|
||||
await ShowMessageAsync($"Saved connection failed:\n{ex.Message}\n\nUse ➕ New to enter different credentials.", "Connection Failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var input = await ShowNasDialogAsync();
|
||||
if (input == null || string.IsNullOrWhiteSpace(input.Value.path)) return;
|
||||
|
||||
string nasPath = NasManager.Normalize(input.Value.path);
|
||||
|
||||
if (!NasManager.TrySplit(nasPath, out string server, out string share, out _))
|
||||
{
|
||||
// "\\host" with no share — try to list the host's shares
|
||||
// via OS tools so the user sees what's available.
|
||||
string bare = nasPath.TrimEnd('\\');
|
||||
if (NasManager.IsUncPath(bare) && bare.Count(c => c == '\\') == 2)
|
||||
{
|
||||
string host = bare.TrimStart('\\');
|
||||
Log($"🌐 Querying shares on \\\\{host}...");
|
||||
var shares = await Task.Run(() => NasManager.EnumerateSharesOs(host, input.Value.user, input.Value.pass));
|
||||
if (shares != null)
|
||||
{
|
||||
await ShowMessageAsync(
|
||||
$"\\\\{host} is reachable — pick a share and add it to the path.\n\n" +
|
||||
"Shares found:\n " + string.Join("\n ", shares.Select(s => $"\\\\{host}\\{s}")) +
|
||||
"\n\nExample: \\\\" + host + "\\" + shares[0],
|
||||
"Select a Share");
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowMessageAsync(
|
||||
$"The path \\\\{host} is missing the share name.\n\n" +
|
||||
"SMB connects to a share, not a host:\n" +
|
||||
" \\\\host\\share\\folder\n\n" +
|
||||
"Couldn't enumerate shares automatically — check your NAS admin page for share names.",
|
||||
"Share Name Required");
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ShowMessageAsync(
|
||||
"Invalid path format.\n\n" +
|
||||
"Use UNC format: \\\\server\\share\\folder\n" +
|
||||
@@ -506,49 +655,104 @@ namespace PS5Upload
|
||||
"Invalid Path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path (convert // to \\ for Windows)
|
||||
if (nasPath.StartsWith("//"))
|
||||
|
||||
// Establish/reuse the authenticated session for this share.
|
||||
if (!NasManager.HasSession(nasPath))
|
||||
{
|
||||
nasPath = "\\\\" + nasPath.Substring(2).Replace("/", "\\");
|
||||
}
|
||||
|
||||
Log($"🌐 Checking NAS path: {nasPath}");
|
||||
|
||||
// Check if path exists
|
||||
if (Directory.Exists(nasPath))
|
||||
{
|
||||
// It's a directory - add it
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(nasPath);
|
||||
_localFiles.Add(new LocalFileItem {
|
||||
Name = dirInfo.Name,
|
||||
FullPath = nasPath,
|
||||
Icon = "🌐",
|
||||
IsDirectory = true,
|
||||
Size = 0
|
||||
});
|
||||
Log($"✅ Added NAS folder: {dirInfo.Name} ({nasPath})");
|
||||
}
|
||||
else if (File.Exists(nasPath))
|
||||
{
|
||||
// It's a file - add it
|
||||
FileInfo fileInfo = new FileInfo(nasPath);
|
||||
|
||||
// Check if it's a ZIP file
|
||||
if (fileInfo.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
// No creds entered → a Windows-authenticated share may work
|
||||
// via System.IO; otherwise try guest, then ask to retry.
|
||||
if (string.IsNullOrEmpty(input.Value.user))
|
||||
{
|
||||
await ExtractAndAddZipContents(nasPath, fileInfo.Name);
|
||||
if (Directory.Exists(nasPath) || File.Exists(nasPath))
|
||||
{
|
||||
Log($"🌐 NAS path accessible via OS session (no SMB login needed)");
|
||||
AddNasItem(nasPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log($"🔐 Connecting to \\\\{server}\\{share}...");
|
||||
try
|
||||
{
|
||||
await Task.Run(() => NasManager.Connect(server, share, input.Value.user, input.Value.pass));
|
||||
Log(input.Value.user.Length > 0
|
||||
? $"✅ Connected to \\\\{server}\\{share} as {input.Value.user}"
|
||||
: $"✅ Connected to \\\\{server}\\{share} (guest)");
|
||||
|
||||
// Optional persistence — path + creds for next launch.
|
||||
if (input.Value.remember)
|
||||
{
|
||||
var existing = _nasSaved.FindIndex(c => c.Path.Equals(nasPath, StringComparison.OrdinalIgnoreCase));
|
||||
var saved = new NasManager.SavedConnection
|
||||
{
|
||||
Path = nasPath, Server = server, Share = share,
|
||||
User = input.Value.user, PassEnc = NasManager.EncodePass(input.Value.pass)
|
||||
};
|
||||
if (existing >= 0) _nasSaved[existing] = saved; else _nasSaved.Add(saved);
|
||||
SaveNasConnections();
|
||||
Log($"💾 Saved NAS connection: {nasPath}");
|
||||
}
|
||||
}
|
||||
catch (NasManager.NasAuthException ex)
|
||||
{
|
||||
Log($"🔒 NAS auth failed: {ex.Message}");
|
||||
await ShowMessageAsync($"{ex.Message}\n\nRe-open the NAS dialog and enter the share's username/password.", "Authentication Failed");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ NAS connect failed: {ex.Message}");
|
||||
await ShowMessageAsync($"Cannot connect to \\\\{server}\\{share}:\n{ex.Message}", "Connection Failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log($"🌐 Checking NAS path: {nasPath}");
|
||||
AddNasItem(nasPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ NAS error: {ex.Message}");
|
||||
await ShowMessageAsync($"Error accessing NAS path: {ex.Message}", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the NAS path (file or folder) to the upload queue — works via
|
||||
/// the SMB session when present, or the OS's own share session.
|
||||
private async void AddNasItem(string nasPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool isDir, isFile;
|
||||
long size = 0;
|
||||
if (NasManager.HasSession(nasPath))
|
||||
{
|
||||
var kind = NasManager.ExistsAsDir(nasPath);
|
||||
isDir = kind == true; isFile = kind == false;
|
||||
if (isFile) size = NasManager.GetLength(nasPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
isDir = Directory.Exists(nasPath); isFile = File.Exists(nasPath);
|
||||
if (isFile) size = new FileInfo(nasPath).Length;
|
||||
}
|
||||
|
||||
string name = LocalIo.GetName(nasPath);
|
||||
if (isDir)
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem { Name = name, FullPath = nasPath, Icon = "🌐", IsDirectory = true, Size = 0 });
|
||||
Log($"✅ Added NAS folder: {name} ({nasPath})");
|
||||
}
|
||||
else if (isFile)
|
||||
{
|
||||
if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ExtractAndAddZipContents(nasPath, name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem {
|
||||
Name = fileInfo.Name,
|
||||
FullPath = nasPath,
|
||||
Icon = "🌐",
|
||||
IsDirectory = false,
|
||||
Size = fileInfo.Length
|
||||
});
|
||||
Log($"✅ Added NAS file: {fileInfo.Name} ({FormatFileSize(fileInfo.Length)})");
|
||||
_localFiles.Add(new LocalFileItem { Name = name, FullPath = nasPath, Icon = "🌐", IsDirectory = false, Size = size });
|
||||
Log($"✅ Added NAS file: {name} ({FormatFileSize(size)})");
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -617,24 +821,28 @@ namespace PS5Upload
|
||||
|
||||
private async void DeleteMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isDeleting) return;
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
if (await ShowConfirmAsync($"Delete {item.Name}?"))
|
||||
{
|
||||
_isDeleting = true;
|
||||
try
|
||||
{
|
||||
if (item.IsDirectory) { Log($"🗑️ Deleting folder: {item.Name}"); await _protocol.DeleteDirAsync(item.FullPath); Log($"✅ Folder deletion complete: {item.Name}"); }
|
||||
if (item.IsDirectory) { Log($"🗑️ Deleting folder: {item.Name}"); bool ok = await _protocol.DeleteDirAsync(item.FullPath); Log(ok ? $"✅ Folder deletion complete: {item.Name}" : $"❌ Folder deletion failed: {item.Name}"); }
|
||||
else { Log($"🗑️ Deleting file: {item.Name}"); await _protocol.DeleteFileAsync(item.FullPath); Log($"✅ File deletion complete: {item.Name}"); }
|
||||
await Task.Delay(1500);
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
catch (Exception ex) { await ShowMessageAsync($"Delete failed: {ex.Message}", "Error"); Log($"❌ Delete failed: {ex.Message}"); }
|
||||
finally { _isDeleting = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteSelectedMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isDeleting) return;
|
||||
var selectedItems = (PS5FilesListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5FileItem>().ToList();
|
||||
if (selectedItems.Count == 0) { await ShowMessageAsync("No items selected."); return; }
|
||||
|
||||
@@ -646,24 +854,30 @@ namespace PS5Upload
|
||||
|
||||
if (!await ShowConfirmAsync(message, "Confirm Bulk Delete")) return;
|
||||
|
||||
Log($"🗑️ Starting bulk delete of {selectedItems.Count} items...");
|
||||
int successCount = 0, failCount = 0;
|
||||
foreach (var item in selectedItems)
|
||||
_isDeleting = true;
|
||||
try
|
||||
{
|
||||
try
|
||||
Log($"🗑️ Starting bulk delete of {selectedItems.Count} items...");
|
||||
int successCount = 0, failCount = 0;
|
||||
foreach (var item in selectedItems)
|
||||
{
|
||||
if (item.IsDirectory) await _protocol.DeleteDirAsync(item.FullPath);
|
||||
else await _protocol.DeleteFileAsync(item.FullPath);
|
||||
successCount++;
|
||||
Log($"✅ Deleted: {item.Name}");
|
||||
await Task.Delay(300);
|
||||
try
|
||||
{
|
||||
bool ok;
|
||||
if (item.IsDirectory) ok = await _protocol.DeleteDirAsync(item.FullPath);
|
||||
else ok = await _protocol.DeleteFileAsync(item.FullPath);
|
||||
if (ok) { successCount++; Log($"✅ Deleted: {item.Name}"); }
|
||||
else { failCount++; Log($"❌ Failed to delete {item.Name}: server error"); }
|
||||
await Task.Delay(300);
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Failed to delete {item.Name}: {ex.Message}"); failCount++; }
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Failed to delete {item.Name}: {ex.Message}"); failCount++; }
|
||||
Log($"🗑️ Bulk delete complete: {successCount} succeeded, {failCount} failed");
|
||||
await Task.Delay(500);
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
await ShowMessageAsync($"Bulk delete complete!\n\n✅ {successCount} deleted\n❌ {failCount} failed");
|
||||
}
|
||||
Log($"🗑️ Bulk delete complete: {successCount} succeeded, {failCount} failed");
|
||||
await Task.Delay(500);
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
await ShowMessageAsync($"Bulk delete complete!\n\n✅ {successCount} deleted\n❌ {failCount} failed");
|
||||
finally { _isDeleting = false; }
|
||||
}
|
||||
|
||||
private async void DownloadMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -35,6 +35,8 @@ namespace PS5Upload
|
||||
return;
|
||||
}
|
||||
KlogText.Text = txt;
|
||||
if (KlogAutoScroll.IsChecked == true)
|
||||
KlogText.CaretIndex = txt.Length;
|
||||
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ namespace PS5Upload
|
||||
if (txt != null)
|
||||
{
|
||||
KlogText.Text = txt;
|
||||
if (KlogAutoScroll.IsChecked == true)
|
||||
KlogText.CaretIndex = txt.Length;
|
||||
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss} (auto)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Prospero Store Site tab — homebrew.page/api/v1 catalog.
|
||||
// Signed static JSON API (Cloudflare CDN, fast + reliable):
|
||||
// index.json → app list (titleid/name/version/format/size/…)
|
||||
// apps/<TID>.json → per-app record (artifact_url, sha256, size)
|
||||
// icons/<TID>-256.png → store icon
|
||||
//
|
||||
// Artifacts are NOT PKGs — they are ZIP archives holding the app folder
|
||||
// (<TITLEID>/ with eboot.bin + sce_sys/), hosted on the dev's GitHub
|
||||
// Releases. Install path per catalog spec:
|
||||
// download → SHA-256 verify → extract → upload tree to /data/homebrew/
|
||||
// → our own Mount pipeline registers it on the PS5 home screen
|
||||
// (/data/homebrew is in the payload's GAME_SCAN_PATHS).
|
||||
public partial class MainWindow
|
||||
{
|
||||
public class ProsperoItem : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public string TitleId { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Author { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public string Kind { get; set; } = ""; // "app" | "game" | "tool"
|
||||
public string Format { get; set; } = ""; // "zip" | "ffpfsc" | ""
|
||||
public long Size { get; set; }
|
||||
public string Status { get; set; } = ""; // "available" | "coming_soon"
|
||||
public string IconSmall { get; set; } = "";
|
||||
public string Updated { get; set; } = ""; // ISO date
|
||||
|
||||
[JsonIgnore]
|
||||
private Bitmap? _icon;
|
||||
[JsonIgnore]
|
||||
public Bitmap? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string SizeDisplay => Size > 0 ? PS5StorageInfo.FormatBytes((ulong)Size) : "—";
|
||||
|
||||
[JsonIgnore]
|
||||
public string KindDisplay => Kind switch
|
||||
{
|
||||
"game" => "🎮 game",
|
||||
"tool" => "🔧 tool",
|
||||
"app" => "🧩 app",
|
||||
_ => Kind,
|
||||
};
|
||||
|
||||
[JsonIgnore]
|
||||
public bool CanInstall => Status == "available" && Format == "zip";
|
||||
|
||||
[JsonIgnore]
|
||||
public string InstallLabel => CanInstall ? "📥 Install"
|
||||
: Status == "coming_soon" ? "Coming soon"
|
||||
: Format == "ffpfsc" ? "ffpfsc — unsupported" : "Unavailable";
|
||||
|
||||
[JsonIgnore]
|
||||
public string StatusDisplay => Status == "coming_soon" ? "🕒 coming soon"
|
||||
: Format == "ffpfsc" ? "⚠️ ffpfsc image — install via Prospero Store on console"
|
||||
: Format == "zip" ? "zip — installs to /data/homebrew" : "";
|
||||
|
||||
[JsonIgnore]
|
||||
public IBrush StatusColor => Status == "coming_soon"
|
||||
? new SolidColorBrush(Color.Parse("#888888"))
|
||||
: Format == "ffpfsc"
|
||||
? new SolidColorBrush(Color.Parse("#FFC107"))
|
||||
: new SolidColorBrush(Color.Parse("#6A9955"));
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
private const string ProsperoApi = "https://homebrew.page/api/v1";
|
||||
private const string ProsperoCacheFile = "prospero_cache.json";
|
||||
private const string ProsperoIconDir = "prospero_icons";
|
||||
private const string ProsperoDlDir = "prospero_dl";
|
||||
|
||||
private readonly List<ProsperoItem> _prosperoItems = new();
|
||||
private bool _prosperoFetching;
|
||||
private bool _prosperoInstalling;
|
||||
private bool _prosperoLoadedOnce;
|
||||
|
||||
// ---------- catalog fetch / cache ----------
|
||||
|
||||
private static List<ProsperoItem> ParseProsperoIndex(string json)
|
||||
{
|
||||
var list = new List<ProsperoItem>();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("apps", out var apps)) return list;
|
||||
foreach (var a in apps.EnumerateArray())
|
||||
{
|
||||
static string S(JsonElement e, string k) =>
|
||||
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? "" : "";
|
||||
list.Add(new ProsperoItem
|
||||
{
|
||||
TitleId = S(a, "titleid"),
|
||||
Name = S(a, "name") is { Length: > 0 } n ? n : S(a, "titleid"),
|
||||
Author = S(a, "author"),
|
||||
Version = S(a, "version"),
|
||||
Kind = S(a, "kind"),
|
||||
Format = S(a, "format"),
|
||||
Status = S(a, "status"),
|
||||
IconSmall = S(a, "icon_small"),
|
||||
Updated = S(a, "updated"),
|
||||
Size = a.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private int LoadProsperoCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ProsperoCacheFile)) return 0;
|
||||
var items = JsonSerializer.Deserialize<List<ProsperoItem>>(File.ReadAllText(ProsperoCacheFile));
|
||||
if (items == null || items.Count == 0) return 0;
|
||||
_prosperoItems.Clear();
|
||||
_prosperoItems.AddRange(items);
|
||||
ApplyProsperoFilter();
|
||||
ProsperoStatusText.Text = $"{items.Count} apps (cached)";
|
||||
foreach (var it in items) TryLoadProsperoIcon(it);
|
||||
return items.Count;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private void TryLoadProsperoIcon(ProsperoItem it)
|
||||
{
|
||||
try
|
||||
{
|
||||
string p = Path.Combine(ProsperoIconDir, it.TitleId + ".png");
|
||||
if (File.Exists(p)) it.Icon = new Bitmap(p);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void FetchProsperoIconsAsync()
|
||||
{
|
||||
var items = _prosperoItems.ToList();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { Directory.CreateDirectory(ProsperoIconDir); } catch { }
|
||||
await Task.WhenAll(items.Select(async it =>
|
||||
{
|
||||
if (it.Icon != null) return;
|
||||
TryLoadProsperoIcon(it);
|
||||
if (it.Icon != null) return;
|
||||
string url = !string.IsNullOrEmpty(it.IconSmall)
|
||||
? it.IconSmall
|
||||
: $"{ProsperoApi}/icons/{it.TitleId}-256.png";
|
||||
for (int a = 0; a < 4 && it.Icon == null; a++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await _storeHttp.GetByteArrayAsync(url);
|
||||
if (bytes.Length < 100 || bytes[0] != 0x89 || bytes[1] != 0x50)
|
||||
throw new Exception("not a PNG");
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bmp = new Bitmap(ms);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { it.Icon = bmp; });
|
||||
try { File.WriteAllBytes(Path.Combine(ProsperoIconDir, it.TitleId + ".png"), bytes); } catch { }
|
||||
}
|
||||
catch { await Task.Delay(600 * (a + 1)); }
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private async void ProsperoRefresh_Click(object? sender, RoutedEventArgs? e) => await RefreshProsperoAsync();
|
||||
|
||||
private async Task RefreshProsperoAsync()
|
||||
{
|
||||
if (_prosperoFetching) return;
|
||||
_prosperoFetching = true;
|
||||
try
|
||||
{
|
||||
int cached = LoadProsperoCache();
|
||||
ProsperoStatusText.Text = cached > 0
|
||||
? $"{cached} apps (cached) — refreshing…"
|
||||
: "Fetching homebrew.page catalog...";
|
||||
Log("🌐 Fetching Prospero catalog...");
|
||||
|
||||
var items = ParseProsperoIndex(await _storeHttp.GetStringAsync($"{ProsperoApi}/index.json"));
|
||||
if (items.Count == 0) throw new Exception("catalog returned no apps");
|
||||
|
||||
_prosperoItems.Clear();
|
||||
_prosperoItems.AddRange(items);
|
||||
ApplyProsperoFilter();
|
||||
foreach (var it in items) TryLoadProsperoIcon(it);
|
||||
int avail = items.Count(i => i.CanInstall);
|
||||
ProsperoStatusText.Text = $"{items.Count} apps ({avail} installable)";
|
||||
Log($"🌐 Prospero catalog: {items.Count} apps ({avail} installable)");
|
||||
try { File.WriteAllText(ProsperoCacheFile, JsonSerializer.Serialize(items)); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProsperoStatusText.Text = _prosperoItems.Count > 0
|
||||
? $"{_prosperoItems.Count} apps (cached — homebrew.page unreachable)"
|
||||
: "Fetch failed";
|
||||
Log($"⚠️ Prospero catalog: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_prosperoFetching = false;
|
||||
if (_prosperoItems.Count > 0) FetchProsperoIconsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- search / sort ----------
|
||||
|
||||
// Site sections: apps / games / tools / coming soon — kind field +
|
||||
// status, same split as homebrew.page. Search applies inside the
|
||||
// selected category.
|
||||
private void ApplyProsperoFilter()
|
||||
{
|
||||
if (ProsperoListBox == null || ProsperoSearchBox == null || ProsperoSortComboBox == null
|
||||
|| ProsperoCategoryComboBox == null) return;
|
||||
|
||||
IEnumerable<ProsperoItem> view = ProsperoCategoryComboBox.SelectedIndex switch
|
||||
{
|
||||
1 => _prosperoItems.Where(i => i.Kind == "app"),
|
||||
2 => _prosperoItems.Where(i => i.Kind == "game"),
|
||||
3 => _prosperoItems.Where(i => i.Kind == "tool"),
|
||||
4 => _prosperoItems.Where(i => i.Status == "coming_soon"),
|
||||
_ => _prosperoItems,
|
||||
};
|
||||
|
||||
string q = ProsperoSearchBox.Text?.Trim() ?? "";
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
view = view.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.Author.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
view = ProsperoSortComboBox.SelectedIndex switch
|
||||
{
|
||||
1 => view.OrderByDescending(i => i.Name, StringComparer.OrdinalIgnoreCase),
|
||||
2 => view.OrderBy(i => i.Size),
|
||||
3 => view.OrderByDescending(i => i.Size),
|
||||
4 => view.OrderByDescending(i => i.Updated, StringComparer.Ordinal),
|
||||
5 => view.OrderBy(i => i.Author, StringComparer.OrdinalIgnoreCase),
|
||||
_ => view.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
var list = view.ToList();
|
||||
ProsperoListBox.ItemsSource = null;
|
||||
ProsperoListBox.ItemsSource = list;
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
ProsperoStatusText.Text = $"{list.Count}/{_prosperoItems.Count} apps";
|
||||
else if (_prosperoItems.Count > 0)
|
||||
ProsperoStatusText.Text = $"{_prosperoItems.Count} apps ({_prosperoItems.Count(i => i.CanInstall)} installable)";
|
||||
}
|
||||
|
||||
private void ProsperoSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyProsperoFilter();
|
||||
private void ProsperoSort_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyProsperoFilter();
|
||||
private void ProsperoCategory_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyProsperoFilter();
|
||||
|
||||
// ---------- install: zip → verify → /data/homebrew → mount ----------
|
||||
|
||||
private record ProsperoDetail(string Url, string Sha256, string Format, string Status, long Size);
|
||||
|
||||
private static ProsperoDetail ParseProsperoDetail(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var r = doc.RootElement;
|
||||
static string S(JsonElement e, string k) =>
|
||||
e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? "" : "";
|
||||
return new ProsperoDetail(
|
||||
S(r, "artifact_url"), S(r, "sha256"), S(r, "format"), S(r, "status"),
|
||||
r.TryGetProperty("size", out var sz) && sz.ValueKind == JsonValueKind.Number ? sz.GetInt64() : 0);
|
||||
}
|
||||
|
||||
// GitHub release URLs redirect to objects.githubusercontent.com —
|
||||
// HttpClient follows redirects automatically.
|
||||
private async Task<bool> DownloadProsperoZipAsync(string url, string name, long expected, string destPath)
|
||||
{
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
|
||||
using var resp = await _storeDl.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
long total = resp.Content.Headers.ContentLength ?? expected;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(cts.Token);
|
||||
// Fresh download targets get scanned by AV/indexers and may
|
||||
// stay briefly locked — retry the open instead of failing
|
||||
// the whole attempt on "file in use".
|
||||
FileStream? dst = null;
|
||||
for (int o = 0; o < 8 && dst == null; o++)
|
||||
{
|
||||
try { dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20); }
|
||||
catch (IOException) { if (o == 7) throw; await Task.Delay(500); }
|
||||
}
|
||||
await using (var outFs = dst!) {
|
||||
var buf = new byte[512 * 1024];
|
||||
long done = 0; int rd; int lastBucket = -1;
|
||||
while ((rd = await src.ReadAsync(buf, cts.Token)) > 0)
|
||||
{
|
||||
await outFs.WriteAsync(buf.AsMemory(0, rd), cts.Token);
|
||||
done += rd;
|
||||
if (total > 0)
|
||||
{
|
||||
int bucket = (int)(done * 10 / total);
|
||||
if (bucket != lastBucket)
|
||||
{
|
||||
lastBucket = bucket;
|
||||
ProsperoStatusText.Text = $"Downloading {name} — {done / 1048576}/{total / 1048576} MB";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && done < total) throw new IOException($"short read {done}/{total}");
|
||||
}
|
||||
|
||||
await using (var chk = new FileStream(destPath, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
var magic = new byte[4];
|
||||
if (await chk.ReadAsync(magic) < 4 || magic[0] != 'P' || magic[1] != 'K')
|
||||
throw new IOException("downloaded file is not a ZIP");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { File.Delete(destPath); } catch { }
|
||||
Log($"⚠️ Download failed ({ex.Message}){(attempt < 2 ? $" — retry {attempt + 2}/3" : "")}");
|
||||
if (attempt < 2) await Task.Delay(1200);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Catalog spec: zip root holds the app folder <TITLEID>/ containing
|
||||
// eboot.bin + sce_sys/. Locate it defensively — name it what the
|
||||
// archive says, fall back to the folder holding eboot.bin.
|
||||
private static string? FindAppFolder(string extRoot, string titleId)
|
||||
{
|
||||
var cand = Path.Combine(extRoot, titleId);
|
||||
if (Directory.Exists(cand) && File.Exists(Path.Combine(cand, "eboot.bin")))
|
||||
return cand;
|
||||
foreach (var dir in Directory.EnumerateDirectories(extRoot))
|
||||
if (File.Exists(Path.Combine(dir, "eboot.bin")))
|
||||
return dir;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async void ProsperoInstall_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_prosperoInstalling) return;
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
|
||||
string? tid = (sender as Control)?.Tag as string;
|
||||
if (string.IsNullOrEmpty(tid)) return;
|
||||
var item = _prosperoItems.FirstOrDefault(i => i.TitleId == tid);
|
||||
if (item == null || !item.CanInstall) return;
|
||||
string name = item.Name;
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Install \"{name}\" ({tid}) on the PS5?\n\n" +
|
||||
$"The app folder will be placed in /data/homebrew/{tid} and mounted to the home screen.")) return;
|
||||
|
||||
_prosperoInstalling = true;
|
||||
string? zipPath = null, extDir = null;
|
||||
try
|
||||
{
|
||||
// 1. per-app record: artifact_url + sha256 + format
|
||||
ProsperoStatusText.Text = $"Resolving {name}...";
|
||||
var det = ParseProsperoDetail(await _storeHttp.GetStringAsync($"{ProsperoApi}/apps/{tid}.json"));
|
||||
if (det.Format != "zip" || det.Status != "available" || det.Url.Length == 0)
|
||||
{ Log($"❌ {name}: not installable (format={det.Format}, status={det.Status})"); return; }
|
||||
|
||||
// 2. download zip (redirects followed by HttpClient)
|
||||
string dlDir = Path.Combine(AppContext.BaseDirectory, ProsperoDlDir);
|
||||
Directory.CreateDirectory(dlDir);
|
||||
zipPath = Path.Combine(dlDir, tid + ".zip");
|
||||
Log($"📥 {name}: downloading {PS5StorageInfo.FormatBytes((ulong)(det.Size > 0 ? det.Size : item.Size))}...");
|
||||
if (!await DownloadProsperoZipAsync(det.Url, name, det.Size, zipPath))
|
||||
{ ProsperoStatusText.Text = "download failed"; return; }
|
||||
|
||||
// 3. SHA-256 verify — mandatory per catalog spec. Runs on the
|
||||
// thread pool: File.OpenRead is a sync stream and hashing a
|
||||
// few hundred MB on the UI thread stalls repaints.
|
||||
if (det.Sha256.Length == 64)
|
||||
{
|
||||
ProsperoStatusText.Text = $"Verifying {name}...";
|
||||
string hex = await Task.Run(async () =>
|
||||
Convert.ToHexString(await SHA256.HashDataAsync(File.OpenRead(zipPath))));
|
||||
if (!hex.Equals(det.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{ Log($"❌ {name}: SHA-256 mismatch — archive rejected (got {hex.Substring(0, 16)}…, catalog says {det.Sha256.Substring(0, 16)}…)"); return; }
|
||||
}
|
||||
else Log($"⚠️ {name}: catalog has no sha256 — installing unverified");
|
||||
|
||||
// 4. extract → locate <TITLEID>/ with eboot.bin. Extraction of
|
||||
// 10k+ entry zips is sync CPU+disk — off the UI thread.
|
||||
string? appDir = null; string extLocal = "";
|
||||
await Task.Run(() =>
|
||||
{
|
||||
extLocal = Path.Combine(dlDir, tid + "_ext");
|
||||
extDir = extLocal;
|
||||
if (Directory.Exists(extLocal)) Directory.Delete(extLocal, true);
|
||||
ZipFile.ExtractToDirectory(zipPath, extLocal);
|
||||
appDir = FindAppFolder(extLocal, tid);
|
||||
});
|
||||
if (appDir == null)
|
||||
{ Log($"❌ {name}: no app folder with eboot.bin inside the zip"); return; }
|
||||
string folderName = Path.GetFileName(appDir);
|
||||
|
||||
// 5. upload tree → /data/homebrew/<FOLDER>/ — same pooled-lane
|
||||
// model the File Transfer tab uses: N parallel connections each
|
||||
// pulling files off a shared index. The whole fan-out runs on
|
||||
// the thread pool so the 13k+ await continuations don't flood
|
||||
// the UI dispatcher (that was the tab-switch freeze).
|
||||
var files = Directory.GetFiles(appDir, "*", SearchOption.AllDirectories);
|
||||
string remoteBase = $"/data/homebrew/{folderName}";
|
||||
Log($"📤 {name}: uploading {files.Length} files → {remoteBase}");
|
||||
ProsperoStatusText.Text = $"Uploading {name} — 0/{files.Length} files";
|
||||
|
||||
var rels = files.Select(f => $"{remoteBase}/{Path.GetRelativePath(appDir, f).Replace('\\', '/')}").ToArray();
|
||||
Exception? uploadError = null;
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
await _protocol.CreateDirAsync(remoteBase);
|
||||
foreach (var dir in Directory.GetDirectories(appDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string rel = Path.GetRelativePath(appDir, dir).Replace('\\', '/');
|
||||
await _protocol.CreateDirAsync($"{remoteBase}/{rel}");
|
||||
}
|
||||
|
||||
int lanes = Math.Min(16, files.Length);
|
||||
int nextFile = -1, doneFiles = 0;
|
||||
long lastUiPost = 0;
|
||||
var laneTasks = Enumerable.Range(0, lanes).Select(async _ =>
|
||||
{
|
||||
PS5Protocol? lane = null;
|
||||
try
|
||||
{
|
||||
lane = await AcquireUploadConnectionAsync();
|
||||
while (Volatile.Read(ref uploadError) == null)
|
||||
{
|
||||
int i = Interlocked.Increment(ref nextFile);
|
||||
if (i >= files.Length) break;
|
||||
if (!await lane.UploadFileAsync(files[i], rels[i]))
|
||||
{
|
||||
Interlocked.CompareExchange(ref uploadError,
|
||||
new Exception($"upload failed: {rels[i]} — {lane.LastError}"), null);
|
||||
break;
|
||||
}
|
||||
int d = Interlocked.Increment(ref doneFiles);
|
||||
long now = Environment.TickCount64;
|
||||
if (d == files.Length || now - lastUiPost > 150)
|
||||
{
|
||||
Interlocked.Exchange(ref lastUiPost, now);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
ProsperoStatusText.Text = $"Uploading {name} — {d}/{files.Length} files");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (lane != null)
|
||||
{
|
||||
if (lane.IsConnected) ReleaseUploadConnection(lane);
|
||||
else DestroyConnection(lane);
|
||||
}
|
||||
}
|
||||
}).ToList();
|
||||
await Task.WhenAll(laneTasks);
|
||||
});
|
||||
if (uploadError != null)
|
||||
{ Log($"❌ {name}: {uploadError.Message}"); ProsperoStatusText.Text = "upload failed"; return; }
|
||||
Log($"📤 {name}: {files.Length} files uploaded");
|
||||
|
||||
// zip + temp no longer needed — delete so nothing is stored twice
|
||||
try { File.Delete(zipPath); zipPath = null; } catch { }
|
||||
try { if (extDir != null) { Directory.Delete(extDir, true); extDir = null; } } catch { }
|
||||
|
||||
// 6. mount via our own Mount pipeline → home screen entry
|
||||
ProsperoStatusText.Text = $"Mounting {name}...";
|
||||
Log($"🗂️ {name}: mounting {folderName} to home screen...");
|
||||
var mountRes = await _protocol.MountGameAsync(folderName, msg =>
|
||||
Dispatcher.UIThread.Post(() => ProsperoStatusText.Text = $"Mounting {name} — {msg}"));
|
||||
if (mountRes == null)
|
||||
{
|
||||
Log($"⚠️ {name}: uploaded to {remoteBase} but mount gave no response — try Mount Games in the Games tab");
|
||||
ProsperoStatusText.Text = $"{name} installed (mount manually)";
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"✅ {name}: installed to {remoteBase} — {mountRes.Split('\n')[0]}");
|
||||
ProsperoStatusText.Text = $"{name} installed";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Install error: {ex.Message}");
|
||||
ProsperoStatusText.Text = "install failed";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_prosperoInstalling = false;
|
||||
try { if (zipPath != null) File.Delete(zipPath); } catch { }
|
||||
try { if (extDir != null && Directory.Exists(extDir)) Directory.Delete(extDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,8 +380,43 @@ namespace PS5Upload
|
||||
// ============================================================
|
||||
// GAMES TAB
|
||||
// ============================================================
|
||||
private List<PS5MountedGame> _allGames = new();
|
||||
|
||||
private async void RefreshGameListButton_Click(object? sender, RoutedEventArgs e) => await RefreshGameListAsync();
|
||||
|
||||
private void GameSearchBox_TextChanged(object? sender, Avalonia.Controls.TextChangedEventArgs e) => ApplyGameFilter();
|
||||
|
||||
private void GameSortComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyGameFilter();
|
||||
|
||||
private void ApplyGameFilter()
|
||||
{
|
||||
if (MountedGamesListBox == null || GameSearchBox == null || GameSortComboBox == null) return;
|
||||
var q = GameSearchBox.Text?.Trim() ?? "";
|
||||
IEnumerable<PS5MountedGame> view = string.IsNullOrEmpty(q)
|
||||
? _allGames
|
||||
: _allGames.Where(g =>
|
||||
g.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
g.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
g.Region.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
g.Path.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
view = GameSortComboBox.SelectedIndex switch
|
||||
{
|
||||
1 => view.OrderByDescending(g => g.Name, StringComparer.OrdinalIgnoreCase),
|
||||
2 => view.OrderBy(g => g.Size),
|
||||
3 => view.OrderByDescending(g => g.Size),
|
||||
4 => view.OrderBy(g => g.TitleId, StringComparer.OrdinalIgnoreCase),
|
||||
5 => view.OrderByDescending(g => g.IsActive),
|
||||
_ => view.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
var list = view.ToList();
|
||||
MountedGamesListBox.ItemsSource = list;
|
||||
GameCountText.Text = string.IsNullOrEmpty(q) || list.Count == _allGames.Count
|
||||
? $" ({_allGames.Count} games)"
|
||||
: $" ({list.Count}/{_allGames.Count} games)";
|
||||
}
|
||||
|
||||
private async Task RefreshGameListAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
@@ -389,11 +424,8 @@ namespace PS5Upload
|
||||
try
|
||||
{
|
||||
var games = await _protocol.GetGameListAsync();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
MountedGamesListBox.ItemsSource = games;
|
||||
GameCountText.Text = $" ({games.Count} games)";
|
||||
});
|
||||
_allGames = games;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => ApplyGameFilter());
|
||||
|
||||
if (games.Count > 0)
|
||||
{
|
||||
@@ -465,6 +497,28 @@ namespace PS5Upload
|
||||
}
|
||||
}
|
||||
|
||||
private async void UnmountAllGamesButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
|
||||
if (!await ShowConfirmAsync("Unmount ALL games?\n\nEvery mounted game will be removed from the PS5 home screen.")) return;
|
||||
|
||||
UnmountAllGamesButton.IsEnabled = false;
|
||||
UnmountAllGamesButton.Content = "⏳ Unmounting...";
|
||||
Log("🗑️ Unmount All: Starting...");
|
||||
try
|
||||
{
|
||||
var (success, message) = await _protocol.UnmountAllGamesAsync(msg =>
|
||||
Dispatcher.UIThread.Post(() => Log($" {msg}")));
|
||||
if (success) { Log($"✅ {message}"); await RefreshGameListAsync(); }
|
||||
else { Log($"❌ Failed: {message}"); await ShowMessageAsync($"Unmount all failed:\n{message}", "Error"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnmountAllGamesButton.Content = "🗑️ Unmount All";
|
||||
UnmountAllGamesButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenGamePathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game)
|
||||
@@ -549,6 +603,9 @@ namespace PS5Upload
|
||||
else HwThermalAlertText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#28A745"));
|
||||
}
|
||||
|
||||
var net = await _protocol.GetNetInfoAsync();
|
||||
if (net != null) ApplyNetInfo(net, fillStatic: true);
|
||||
|
||||
_hwStaticLoaded = true;
|
||||
}
|
||||
|
||||
@@ -618,11 +675,105 @@ namespace PS5Upload
|
||||
HwCpuAvgText.Text = $"{cpu.Average}%";
|
||||
}
|
||||
|
||||
// Live traffic rates — refresh counters every tick
|
||||
var net = await _protocol.GetNetInfoAsync();
|
||||
if (net != null) ApplyNetInfo(net, fillStatic: false);
|
||||
|
||||
HwStatusText.Text = $"Last updated: {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex) { HwStatusText.Text = $"Error: {ex.Message}"; }
|
||||
}
|
||||
|
||||
// ---------------- network info ----------------
|
||||
private ulong _netPrevRx, _netPrevTx;
|
||||
private DateTime _netPrevT = DateTime.MinValue;
|
||||
|
||||
private void ApplyNetInfo(string txt, bool fillStatic)
|
||||
{
|
||||
var kv = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
ulong rx = 0, tx = 0;
|
||||
string? activeIf = null; ulong activeBytes = 0;
|
||||
foreach (var raw in txt.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
if (line.StartsWith("if="))
|
||||
{
|
||||
var f = line.Split('|');
|
||||
if (f.Length == 3 && ulong.TryParse(f[1].AsSpan(3), out ulong r) && ulong.TryParse(f[2].AsSpan(3), out ulong t))
|
||||
{
|
||||
rx += r; tx += t;
|
||||
if (r + t > activeBytes) { activeBytes = r + t; activeIf = f[0].Substring(3); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int eq = line.IndexOf('=');
|
||||
if (eq > 0) kv[line[..eq]] = line[(eq + 1)..];
|
||||
}
|
||||
|
||||
if (fillStatic)
|
||||
{
|
||||
if (kv.TryGetValue("state", out var st))
|
||||
NetStateText.Text = st switch { "3" => "🟢 Online", "2" => "🟡 IP obtained", "1" => "🟡 Connecting…", "0" => "🔴 Offline", _ => st };
|
||||
if (kv.TryGetValue("ssid", out var ssid)) NetSsidText.Text = ssid;
|
||||
if (kv.TryGetValue("rssi", out var rssi)) NetRssiText.Text = $"{rssi}%";
|
||||
if (kv.TryGetValue("ip", out var ip)) NetIpText.Text = ip;
|
||||
// Classify the raw fN= IPv4 fields: netmask starts with 255,
|
||||
// gateway is the first remaining non-IP address, DNS are rest.
|
||||
var fvals = kv.Where(p => p.Key.StartsWith("f") && System.Net.IPAddress.TryParse(p.Value, out _))
|
||||
.Select(p => p.Value).Where(v => v != "0.0.0.0").ToList();
|
||||
var netmask = fvals.FirstOrDefault(v => v.StartsWith("255."));
|
||||
var rest = fvals.Where(v => v != netmask && v != ip).ToList();
|
||||
if (rest.Count > 0) NetGwText.Text = rest[0];
|
||||
if (rest.Count > 1) NetDnsText.Text = string.Join(" ", rest.Skip(1));
|
||||
if (kv.TryGetValue("mac", out var mac)) NetMacText.Text = mac;
|
||||
var mtu = kv.TryGetValue("mtu", out var m) ? m : "—";
|
||||
var link = kv.TryGetValue("link", out var l) ? (l == "0" ? "down" : "up") : "?";
|
||||
var dev = kv.TryGetValue("device", out var d) ? (d == "1" ? "WiFi" : "Ethernet") : "";
|
||||
NetMtuText.Text = $"{mtu} / {link}{(dev.Length > 0 ? $" / {dev}" : "")}";
|
||||
if (kv.TryGetValue("nat_type", out var nt))
|
||||
NetNatText.Text = nt switch { "1" => "Type 1 (Open)", "2" => "Type 2 (Moderate)", "3" => "Type 3 (Strict)", _ => $"Type {nt}" };
|
||||
if (kv.TryGetValue("nat_ip", out var nip))
|
||||
NetNatIpText.Text = kv.TryGetValue("nat_port", out var np) ? $"{nip}:{np}" : nip;
|
||||
}
|
||||
|
||||
// rates from counter deltas
|
||||
var now = DateTime.UtcNow;
|
||||
if (_netPrevT != DateTime.MinValue)
|
||||
{
|
||||
double dt = (now - _netPrevT).TotalSeconds;
|
||||
if (dt > 0.3)
|
||||
{
|
||||
NetRxRateText.Text = $"{FormatRate((ulong)Math.Max(0, (double)(rx - _netPrevRx)) / dt)}";
|
||||
NetTxRateText.Text = $"{FormatRate((ulong)Math.Max(0, (double)(tx - _netPrevTx)) / dt)}";
|
||||
if (activeIf != null) NetIfText.Text = activeIf;
|
||||
NetTotalsText.Text = $"↓{FormatBytes(rx)} ↑{FormatBytes(tx)}";
|
||||
}
|
||||
}
|
||||
_netPrevRx = rx; _netPrevTx = tx; _netPrevT = now;
|
||||
}
|
||||
|
||||
private static string FormatRate(double bps)
|
||||
{
|
||||
if (bps >= 1024.0 * 1024 * 1024) return $"{bps / (1024.0 * 1024 * 1024):0.00} GB/s";
|
||||
if (bps >= 1024.0 * 1024) return $"{bps / (1024.0 * 1024):0.0} MB/s";
|
||||
if (bps >= 1024.0) return $"{bps / 1024.0:0.0} KB/s";
|
||||
return $"{bps:0} B/s";
|
||||
}
|
||||
|
||||
private async void NetSpeedTestButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null) return;
|
||||
NetSpeedTestButton.IsEnabled = false;
|
||||
NetSpeedText.Text = "Running…";
|
||||
try
|
||||
{
|
||||
var mbps = await _protocol.NetSpeedTestAsync();
|
||||
NetSpeedText.Text = mbps.HasValue ? $"Link: {mbps.Value:0} Mbps ↓" : $"Failed: {_protocol.LastError}";
|
||||
}
|
||||
catch (Exception ex) { NetSpeedText.Text = $"Error: {ex.Message}"; }
|
||||
finally { NetSpeedTestButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private async void LoadModulesButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Trophies tab — read-only NpTrophy V2 viewer.
|
||||
// Payload ships raw tropconf.json + tropmeta_<lang>.json + TRPTITLE.DAT
|
||||
// per registered set; we parse the JSON here and lazy-load PNG icons
|
||||
// through CMD_TROPHY_ICON on a dedicated connection.
|
||||
public partial class MainWindow
|
||||
{
|
||||
private List<PS5TrophySet> _trophySets = new();
|
||||
|
||||
private void TrophySetSearchBox_TextChanged(object? sender, Avalonia.Controls.TextChangedEventArgs e) => ApplyTrophySetFilter();
|
||||
|
||||
private void TrophySetSortComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyTrophySetFilter();
|
||||
|
||||
private void TrophyUserComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyTrophySetFilter();
|
||||
|
||||
private void ApplyTrophySetFilter()
|
||||
{
|
||||
if (TrophySetsListBox == null || TrophySetSearchBox == null || TrophySetSortComboBox == null) return;
|
||||
var prevNpwr = (TrophySetsListBox.SelectedItem as PS5TrophySet)?.NpCommunicationId;
|
||||
var prevUid = (TrophySetsListBox.SelectedItem as PS5TrophySet)?.UserId;
|
||||
|
||||
string? filterUser = null;
|
||||
if (TrophyUserComboBox?.SelectedItem is string sel && sel.StartsWith("Profile "))
|
||||
filterUser = sel.Substring(8);
|
||||
|
||||
var q = TrophySetSearchBox.Text?.Trim() ?? "";
|
||||
IEnumerable<PS5TrophySet> view = _trophySets;
|
||||
|
||||
if (filterUser != null)
|
||||
view = view.Where(s => s.UserId == filterUser);
|
||||
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
view = view.Where(s =>
|
||||
s.GameName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.NpCommunicationId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
view = TrophySetSortComboBox.SelectedIndex switch
|
||||
{
|
||||
1 => view.OrderByDescending(s => s.GameName, StringComparer.OrdinalIgnoreCase),
|
||||
2 => view.OrderBy(s => s.EarnedCount).ThenByDescending(s => s.TotalCount),
|
||||
3 => view.OrderByDescending(s => s.EarnedCount).ThenByDescending(s => s.TotalCount),
|
||||
4 => view.OrderByDescending(s => s.TotalCount),
|
||||
_ => view.OrderBy(s => s.GameName, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
var list = view.ToList();
|
||||
TrophySetsListBox.ItemsSource = null;
|
||||
TrophySetsListBox.ItemsSource = list;
|
||||
TrophyStatusText.Text = string.IsNullOrEmpty(q) || list.Count == _trophySets.Count
|
||||
? $"({_trophySets.Count} sets)"
|
||||
: $"({list.Count}/{_trophySets.Count} sets)";
|
||||
if (prevNpwr != null)
|
||||
TrophySetsListBox.SelectedItem =
|
||||
list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr && s.UserId == prevUid)
|
||||
?? list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr);
|
||||
}
|
||||
|
||||
private bool _trophyLoading;
|
||||
|
||||
// Auto-load the catalog every time the Trophies tab gets focus —
|
||||
// no manual Refresh button needed.
|
||||
private async void GamesTabControl_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Source is not TabControl tc) return;
|
||||
if (tc.SelectedItem is TabItem { Header: "🏆 Trophies" })
|
||||
await RefreshTrophiesAsync();
|
||||
else if (tc.SelectedItem is TabItem { Header: "🌐 Prospero Store Site" } && !_prosperoLoadedOnce)
|
||||
{
|
||||
// Remote JSON catalog — no PS5 connection needed to browse.
|
||||
_prosperoLoadedOnce = true;
|
||||
await RefreshProsperoAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshTrophiesAsync()
|
||||
{
|
||||
if (_trophyLoading) return; // auto-load + unlock-refresh can overlap
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
_trophyLoading = true;
|
||||
Log("🏆 Loading trophy sets...");
|
||||
TrophyStatusText.Text = "loading…";
|
||||
try
|
||||
{
|
||||
var sets = await _protocol.GetTrophyListAsync();
|
||||
foreach (var set in sets)
|
||||
ParseTrophySet(set);
|
||||
|
||||
// Game names come from tropmeta titleMetadata — the real title,
|
||||
// not a guess. Fall back to the mounted-games list or the TID.
|
||||
var mounted = await _protocol.GetGameListAsync();
|
||||
var nameByTitle = mounted.ToDictionary(g => g.TitleId, g => g.Name, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var set in sets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(set.GameName))
|
||||
set.GameName = !string.IsNullOrEmpty(set.TitleId)
|
||||
? (nameByTitle.TryGetValue(set.TitleId, out var gn) ? gn : set.TitleId)
|
||||
: set.NpCommunicationId;
|
||||
}
|
||||
|
||||
_trophySets = sets;
|
||||
|
||||
var users = sets.Select(s => s.UserId)
|
||||
.Where(u => !string.IsNullOrEmpty(u))
|
||||
.Distinct()
|
||||
.OrderBy(u => u)
|
||||
.ToList();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var prevSel = TrophyUserComboBox.SelectedItem as string;
|
||||
var items = new List<string>();
|
||||
if (users.Count > 1)
|
||||
items.Add("All Profiles");
|
||||
foreach (var u in users)
|
||||
items.Add($"Profile {u}");
|
||||
if (items.Count == 0)
|
||||
items.Add("No profiles");
|
||||
TrophyUserComboBox.ItemsSource = items;
|
||||
int idx = prevSel != null ? items.IndexOf(prevSel) : -1;
|
||||
TrophyUserComboBox.SelectedIndex = idx >= 0 ? idx : 0;
|
||||
});
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => ApplyTrophySetFilter());
|
||||
Log($"🏆 {sets.Count} trophy sets, {sets.Sum(s => s.TotalCount)} trophies total");
|
||||
|
||||
// Lazy-load set icons + trophy icons on a side connection so the
|
||||
// main command channel stays responsive.
|
||||
_ = Task.Run(() => LoadTrophyIconsAsync(sets));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TrophyStatusText.Text = "error";
|
||||
Log($"❌ Trophy load failed: {ex.Message}");
|
||||
}
|
||||
finally { _trophyLoading = false; }
|
||||
}
|
||||
|
||||
// tropconf.json → ids/grades/hidden/groups; tropmeta_<lang>.json →
|
||||
// title name + per-trophy name/detail. Id strings ("0000") map to
|
||||
// icon files trop0000.png.
|
||||
private static void ParseTrophySet(PS5TrophySet set)
|
||||
{
|
||||
try
|
||||
{
|
||||
var confGrades = new Dictionary<string, (string grade, bool hidden, string group)>();
|
||||
using (var doc = JsonDocument.Parse(set.TropConfJson))
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("trophies", out var trs))
|
||||
foreach (var t in trs.EnumerateArray())
|
||||
{
|
||||
string id = t.TryGetProperty("id", out var idp) ? idp.GetString() ?? "" : "";
|
||||
if (id.Length == 0) continue;
|
||||
string grade = t.TryGetProperty("grade", out var g) ? g.GetString() ?? "" : "";
|
||||
bool hidden = t.TryGetProperty("hidden", out var h) && h.GetBoolean();
|
||||
string group = t.TryGetProperty("groupId", out var gr) ? gr.GetString() ?? "" : "";
|
||||
confGrades[id] = (grade, hidden, group);
|
||||
}
|
||||
}
|
||||
|
||||
var names = new Dictionary<string, (string name, string detail)>();
|
||||
var groupNames = new Dictionary<string, string>();
|
||||
string? titleName = null;
|
||||
if (set.TropMetaJson.Length > 0)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(set.TropMetaJson);
|
||||
if (doc.RootElement.TryGetProperty("metadata", out var meta))
|
||||
{
|
||||
if (meta.TryGetProperty("titleMetadata", out var tm) &&
|
||||
tm.TryGetProperty("name", out var tn))
|
||||
titleName = tn.GetString();
|
||||
if (meta.TryGetProperty("groupMetadata", out var gms))
|
||||
foreach (var g in gms.EnumerateArray())
|
||||
{
|
||||
string id = g.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
string name = g.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
if (id.Length > 0) groupNames[id] = name;
|
||||
}
|
||||
if (meta.TryGetProperty("trophyMetadata", out var tms))
|
||||
foreach (var t in tms.EnumerateArray())
|
||||
{
|
||||
string id = t.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
string name = t.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
string det = t.TryGetProperty("detail", out var d) ? d.GetString() ?? "" : "";
|
||||
if (id.Length > 0) names[id] = (name, det);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(titleName)) set.GameName = titleName;
|
||||
|
||||
set.Trophies.Clear();
|
||||
foreach (var kv in confGrades.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var (grade, hidden, group) = kv.Value;
|
||||
names.TryGetValue(kv.Key, out var nd);
|
||||
set.Trophies.Add(new PS5Trophy
|
||||
{
|
||||
Id = int.TryParse(kv.Key, out int idNum) ? idNum : 0,
|
||||
Name = string.IsNullOrEmpty(nd.name) ? $"Trophy {kv.Key}" : nd.name,
|
||||
Detail = nd.detail ?? "",
|
||||
Grade = grade,
|
||||
Hidden = hidden,
|
||||
GroupId = groupNames.TryGetValue(group, out var gn) ? gn : group,
|
||||
StateKnown = false // TRPTITLE.DAT parse decides below — honest "—" if unknown
|
||||
});
|
||||
}
|
||||
|
||||
ApplyTrpTitleState(set);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Trophy] parse failed for {set.NpCommunicationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// TRPTITLE.DAT (T2PD) — per-trophy unlock state, reversed from a real
|
||||
// user dump (verified: Witcher 3 set, 76/79 unlocked, missing trophies
|
||||
// 0=P/9=S/10=G match PSN progress exactly).
|
||||
//
|
||||
// Layout essentials:
|
||||
// record stream of typed entries: u32be type | u32be size | 8B | body[size]
|
||||
// type 0x500 (size 0xC0): trophy def record — body+0x00 u32be trophy id
|
||||
// type 0x700 (size 0xB0): group state record — body+0x00 u32be group idx,
|
||||
// body+0x30 unlock bitmask for that group
|
||||
// A global unlock bitmask (bit i = trophy id i, LSB-first) exists
|
||||
// elsewhere in the file; we locate it by scanning for a zero-padded
|
||||
// window whose set bits ⊆ [0,total) and whose popcount equals the
|
||||
// union of the group masks (self-validating — no trust without match).
|
||||
private static void ApplyTrpTitleState(PS5TrophySet set)
|
||||
{
|
||||
var d = set.TrpTitleData;
|
||||
if (d.Length < 0x200 || d[0] != 'T' || d[1] != '2' || d[2] != 'P' || d[3] != 'D')
|
||||
return;
|
||||
|
||||
static bool IsRec(byte[] d, int o, byte typeHi, byte szLo)
|
||||
=> o + 8 <= d.Length && d[o] == 0 && d[o + 1] == 0 &&
|
||||
d[o + 2] == typeHi && d[o + 3] == 0 &&
|
||||
d[o + 4] == 0 && d[o + 5] == 0 && d[o + 6] == 0 && d[o + 7] == szLo;
|
||||
|
||||
// 1) trophy count from the 0x500 definition records (max id + 1)
|
||||
int maxId = -1;
|
||||
for (int o = 0x200; o + 0x14 <= d.Length; o += 4)
|
||||
{
|
||||
if (!IsRec(d, o, 5, 0xC0)) continue;
|
||||
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
|
||||
if (id < 512 && id > maxId) maxId = id;
|
||||
}
|
||||
if (maxId < 0) return;
|
||||
int nbits = maxId + 1, nb = (nbits + 7) / 8;
|
||||
|
||||
// 2) union of the per-group unlock masks (0x700 records, past headers)
|
||||
var unionMask = new byte[nb];
|
||||
int groupRecs = 0;
|
||||
for (int o = 0x800; o + 0x10 + 0x30 + nb <= d.Length; o += 4)
|
||||
{
|
||||
if (!IsRec(d, o, 7, 0xB0)) continue;
|
||||
groupRecs++;
|
||||
int mo = o + 0x10 + 0x30;
|
||||
for (int j = 0; j < nb; j++) unionMask[j] |= d[mo + j];
|
||||
}
|
||||
if (groupRecs == 0) return;
|
||||
|
||||
// 2.5) per-trophy state rows (0x800 records):
|
||||
// body+0x00 u32be trophy id, +0x04 u32be unlocked flag,
|
||||
// +0x10 u64be unlock time — microseconds since 0001-01-01 UTC
|
||||
// (verified against trophy_tracker.db: rec with ts 0xE31C37B254CD80
|
||||
// decodes to 2026-09-24T11:14:14Z, the file's last_update).
|
||||
var rowState = new Dictionary<int, (bool unlocked, DateTime when)>();
|
||||
var epoch = new DateTime(1, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
for (int o = 0x800; o + 0x10 + 0x18 <= d.Length; o += 4)
|
||||
{
|
||||
if (!IsRec(d, o, 8, 0x50)) continue;
|
||||
int id = (d[o + 0x10] << 24) | (d[o + 0x11] << 16) | (d[o + 0x12] << 8) | d[o + 0x13];
|
||||
if (id < 0 || id >= nbits) continue;
|
||||
int fl = (d[o + 0x14] << 24) | (d[o + 0x15] << 16) | (d[o + 0x16] << 8) | d[o + 0x17];
|
||||
long ts = 0;
|
||||
for (int j = 0; j < 8; j++) ts = (ts << 8) | d[o + 0x20 + j];
|
||||
// bit0 = earned; upper bits are per-game flags (LNEE rows use
|
||||
// 0x11 = earned|notified — a bare "== 1" misses them entirely)
|
||||
bool unlocked = (fl & 1) != 0;
|
||||
DateTime when = default;
|
||||
if (unlocked && ts > 0)
|
||||
{
|
||||
try { when = epoch + TimeSpan.FromTicks(ts * 10); } catch { }
|
||||
}
|
||||
rowState[id] = (unlocked, when);
|
||||
}
|
||||
|
||||
int unionPop = 0;
|
||||
for (int i = 0; i < nb * 8; i++)
|
||||
{
|
||||
if (((unionMask[i >> 3] >> (i & 7)) & 1) == 0) continue;
|
||||
if (i >= nbits) return; // stray bits → not a mask field here
|
||||
unionPop++;
|
||||
}
|
||||
|
||||
byte[] mask;
|
||||
if (unionPop == 0)
|
||||
{
|
||||
mask = new byte[nb]; // state records exist → truly all locked
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3) global mask: bits ⊆ [0,nbits), popcount == unionPop, zero tail
|
||||
byte[]? found = null;
|
||||
for (int o = 0x200; o + nb + 4 <= d.Length && found == null; o += 8)
|
||||
{
|
||||
int pop = 0; bool ok = true;
|
||||
for (int j = 0; j < nb; j++)
|
||||
{
|
||||
int b = d[o + j];
|
||||
for (int bit = 0; bit < 8; bit++)
|
||||
if (((b >> bit) & 1) != 0)
|
||||
{
|
||||
if (j * 8 + bit >= nbits) { ok = false; break; }
|
||||
pop++;
|
||||
}
|
||||
if (!ok) break;
|
||||
}
|
||||
if (!ok || pop != unionPop) continue;
|
||||
if (d[o + nb] != 0 || d[o + nb + 1] != 0 || d[o + nb + 2] != 0 || d[o + nb + 3] != 0)
|
||||
continue;
|
||||
found = new byte[nb];
|
||||
Array.Copy(d, o, found, 0, nb);
|
||||
}
|
||||
if (found == null)
|
||||
{
|
||||
// Multi-group sets: no single window carries the union, so
|
||||
// the popcount scan misses. The union of the 0x700 group
|
||||
// masks IS the authoritative per-trophy state (it is what
|
||||
// the console reads) — apply it directly instead of
|
||||
// falling back to rows-only, which made the list disagree
|
||||
// with the sidebar count (sidebar=unionPop, list=rows).
|
||||
mask = unionMask;
|
||||
}
|
||||
else
|
||||
{
|
||||
mask = found;
|
||||
}
|
||||
}
|
||||
|
||||
set.UnlockMask = mask;
|
||||
set.StateKnown = true;
|
||||
foreach (var t in set.Trophies)
|
||||
{
|
||||
if (t.Id < 0 || t.Id >= nbits) continue;
|
||||
bool unlocked = ((mask[t.Id >> 3] >> (t.Id & 7)) & 1) != 0;
|
||||
DateTime? when = null;
|
||||
if (rowState.TryGetValue(t.Id, out var rs))
|
||||
{
|
||||
unlocked |= rs.unlocked;
|
||||
if (rs.when != default) when = rs.when;
|
||||
}
|
||||
t.StateKnown = true;
|
||||
t.IsUnlocked = unlocked;
|
||||
t.UnlockedTime = when;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug-unlock path: the daemon binds the trophy commId from the RUNNING
|
||||
// game process, so we verify the matching title is actually running first.
|
||||
private async Task<bool> EnsureTrophyGameRunningAsync(PS5TrophySet set)
|
||||
{
|
||||
var apps = await _protocol.GetRunningAppsAsync();
|
||||
if (!string.IsNullOrEmpty(set.TitleId) &&
|
||||
apps.Any(a => string.Equals(a.TitleId, set.TitleId, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
if (apps.Count == 0)
|
||||
{
|
||||
Log($"⚠️ No game running on the PS5 — launch '{set.GameName}' first (the trophy API binds to the running game).");
|
||||
await ShowMessageAsync(
|
||||
$"The game is not running on the PS5.\n\nLaunch \"{set.GameName}\" first — trophies can only be unlocked while the matching game is running (the unlock goes through the game's own trophy pipeline).",
|
||||
"Launch the game first");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"⚠️ Running title is '{apps[0].Name}' but the selected set belongs to '{set.TitleId}'. Launch the matching game first.");
|
||||
await ShowMessageAsync(
|
||||
$"A different title is running: \"{apps[0].Name}\".\n\nLaunch \"{set.GameName}\" ({set.TitleId}) first — trophies can only be unlocked while the matching game is running.",
|
||||
"Launch the game first");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task RunTrophyUnlockAsync(string spec)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var (ok, msg) = await _protocol.TrophyUnlockAsync(spec);
|
||||
if (ok)
|
||||
{
|
||||
Log($"🏆 {msg} ({sw.ElapsedMilliseconds} ms)");
|
||||
// The daemon writes TRPTITLE.DAT asynchronously — give it a moment
|
||||
// then refresh so the new state/timestamp is re-read from disk.
|
||||
await Task.Delay(1200);
|
||||
await RefreshTrophiesAsync();
|
||||
}
|
||||
else
|
||||
Log($"❌ Trophy unlock failed: {msg}");
|
||||
}
|
||||
|
||||
private async void TrophyUnlock_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { DataContext: PS5Trophy t }) return;
|
||||
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
|
||||
if (!await EnsureTrophyGameRunningAsync(set)) return;
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Unlock trophy \"{t.Name}\" ({t.GradeDisplay}) on the PS5?\n\n" +
|
||||
"The unlock goes through the running game's own UDS event pipeline — it is permanent and fires a real trophy notification.")) return;
|
||||
await RunTrophyUnlockAsync($"unlock:{t.Id}");
|
||||
}
|
||||
|
||||
// Re-lock: pure file rewrite on the console (TRPTITLE.DAT) — no running
|
||||
// game needed, and in fact best done with the game closed so the trophy
|
||||
// daemon can't flush a cached copy over our edit.
|
||||
private async void TrophyLock_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { DataContext: PS5Trophy t }) return;
|
||||
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
if (!await ShowConfirmAsync(
|
||||
$"Re-lock trophy \"{t.Name}\" on the PS5?\n\n" +
|
||||
"This rewrites TRPTITLE.DAT on the console — the unlock bit and timestamp are cleared.\n\n" +
|
||||
"Close the game first if it's running, so the trophy daemon doesn't overwrite the edit.")) return;
|
||||
|
||||
var (ok, msg) = await _protocol.TrophyUnlockAsync($"lock:{set.NpCommunicationId}:{t.Id}");
|
||||
if (ok)
|
||||
{
|
||||
Log($"🔒 {msg}");
|
||||
await Task.Delay(600);
|
||||
await RefreshTrophiesAsync();
|
||||
}
|
||||
else Log($"❌ Trophy lock failed: {msg}");
|
||||
}
|
||||
|
||||
private async void TrophySetsListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (TrophySetsListBox.SelectedItem is not PS5TrophySet set) return;
|
||||
TrophyItemsListBox.ItemsSource = null;
|
||||
TrophyItemsListBox.ItemsSource = set.Trophies;
|
||||
TrophyStatusText.Text = $"{set.GameName} — {set.TotalCount} trophies";
|
||||
|
||||
// Load this set's trophy icons if not already fetched.
|
||||
if (set.Trophies.Any(t => t.Icon == null))
|
||||
_ = Task.Run(() => LoadTrophyItemIconsAsync(set));
|
||||
}
|
||||
|
||||
private async Task LoadTrophyIconsAsync(List<PS5TrophySet> sets)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var proto = new PS5Protocol();
|
||||
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
|
||||
foreach (var set in sets)
|
||||
{
|
||||
// icon0_<lang>.png is the set cover; fall back to plain icon0.png.
|
||||
foreach (var cand in new[] { "icon0_en-US.png", "icon0.png" })
|
||||
{
|
||||
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, cand);
|
||||
if (bytes == null || bytes.Length == 0) continue;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { set.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task LoadTrophyItemIconsAsync(PS5TrophySet set)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var proto = new PS5Protocol();
|
||||
if (!await proto.ConnectAsync(_ps5IpAddress)) return;
|
||||
foreach (var t in set.Trophies)
|
||||
{
|
||||
if (t.Icon != null) continue;
|
||||
var bytes = await proto.GetTrophyIconAsync(set.NpCommunicationId, $"trop{t.Id:0000}.png");
|
||||
if (bytes == null || bytes.Length == 0) continue;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { t.Icon = new Bitmap(new MemoryStream(bytes)); } catch { }
|
||||
});
|
||||
if (!_protocol.IsConnected) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,19 @@ namespace PS5Upload
|
||||
if (_localFiles.Count == 0) { await ShowMessageAsync("No files selected for upload"); return; }
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
|
||||
|
||||
// Queued a WHOLE NAS share? Enumerating it recursively can mean
|
||||
// thousands of files over SMB — confirm before burning the time.
|
||||
var shareRoots = _localFiles.Where(f => f.IsDirectory && LocalIo.IsUnc(f.FullPath)
|
||||
&& NasManager.Normalize(f.FullPath).TrimEnd('\\').Equals(NasManager.ShareRoot(f.FullPath), StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (shareRoots.Count > 0)
|
||||
{
|
||||
if (!await ShowConfirmAsync(
|
||||
"You're uploading ENTIRE share(s):\n " + string.Join("\n ", shareRoots.Select(f => f.FullPath)) +
|
||||
"\n\nThis will enumerate every file on the share (can take a while).\nTip: double-click it and pick the files/folders you actually want.\n\nContinue anyway?",
|
||||
"Upload Entire Share?"))
|
||||
return;
|
||||
}
|
||||
|
||||
UploadButton.IsEnabled = false;
|
||||
CancelButton.IsEnabled = true;
|
||||
ProgressPanel.IsVisible = true;
|
||||
@@ -134,17 +147,18 @@ namespace PS5Upload
|
||||
TotalProgressText.Text = "Collecting files...";
|
||||
|
||||
Log("Collecting files...");
|
||||
var ct = _uploadCancellation.Token;
|
||||
var allFiles = await Task.Run(() =>
|
||||
{
|
||||
var files = new List<(string localPath, string remotePath)>();
|
||||
foreach (var item in localFilesCopy)
|
||||
{
|
||||
string targetBasePath = item.RemotePathOverride ?? (currentPath + "/" + item.Name);
|
||||
if (item.IsDirectory) CollectFilesFromDirectory(item.FullPath, targetBasePath, files);
|
||||
if (item.IsDirectory) CollectFilesFromDirectory(item.FullPath, targetBasePath, files, ct);
|
||||
else files.Add((item.FullPath, targetBasePath));
|
||||
}
|
||||
return files;
|
||||
});
|
||||
}, ct);
|
||||
|
||||
Log("Checking for existing files...");
|
||||
var filesToUpload = await FilterDuplicateFilesAsync(allFiles);
|
||||
@@ -158,7 +172,7 @@ namespace PS5Upload
|
||||
allFiles = filesToUpload;
|
||||
|
||||
_totalFilesToUpload = allFiles.Count;
|
||||
_totalBytesToUpload = await Task.Run(() => { long total = 0; foreach (var f in allFiles) { try { total += new FileInfo(f.localPath).Length; } catch { } } return total; });
|
||||
_totalBytesToUpload = await Task.Run(() => { long total = 0; foreach (var f in allFiles) { try { total += LocalIo.GetLength(f.localPath); } catch { } } return total; });
|
||||
_totalBytesUploaded = 0; _completedFiles = 0; _uploadStartTime = DateTime.Now;
|
||||
_fileProgressBytes.Clear(); _fileChunkProgressBytes.Clear(); _chunkLogLastBytes.Clear();
|
||||
_smoothedETA = TimeSpan.Zero; _speedWindowIndex = 0; _speedWindowCount = 0; _currentSpeed = 0;
|
||||
@@ -251,7 +265,7 @@ namespace PS5Upload
|
||||
while (fileQueue.Count > 0 && activeTasks.Count < MaxParallelUploads)
|
||||
{
|
||||
var (localPath, remotePath) = fileQueue.Dequeue();
|
||||
long fileSize = new FileInfo(localPath).Length;
|
||||
long fileSize = LocalIo.GetLength(localPath);
|
||||
bool isLargeFile = fileSize > LargeFileThresholdBytes;
|
||||
bool isHugeFile = fileSize >= HugeFileThresholdBytes;
|
||||
|
||||
@@ -267,6 +281,7 @@ namespace PS5Upload
|
||||
|
||||
var task = UploadFileParallelAsync(connection, localPath, remotePath, _uploadCancellation.Token);
|
||||
activeTasks.Add(task); taskToConnection[task] = connection; taskToFilePath[task] = localPath; taskToRemotePath[task] = remotePath;
|
||||
_activeTaskCount = activeTasks.Count;
|
||||
taskIsLargeFile[task] = isLargeFile; taskIsHugeFile[task] = isHugeFile;
|
||||
fileChunkCounts[localPath] = 1; fileChunksCompleted[localPath] = 0;
|
||||
}
|
||||
@@ -367,24 +382,24 @@ namespace PS5Upload
|
||||
try
|
||||
{
|
||||
string fileName = Path.GetFileName(localPath);
|
||||
FileInfo fileInfo = new FileInfo(localPath);
|
||||
long fileLen = LocalIo.GetLength(localPath);
|
||||
|
||||
if (fileInfo.Length > ChunkThresholdBytes)
|
||||
if (fileLen > ChunkThresholdBytes)
|
||||
{
|
||||
int maxParallelChunks = fileInfo.Length >= HugeFileThresholdBytes ? MaxParallelChunksForHugeFile : MaxParallelChunksForLargeFile;
|
||||
long chunkSize = fileInfo.Length >= HugeFileThresholdBytes ? HugeFileChunkSizeBytes : DefaultChunkSizeBytes;
|
||||
int maxParallelChunks = fileLen >= HugeFileThresholdBytes ? MaxParallelChunksForHugeFile : MaxParallelChunksForLargeFile;
|
||||
long chunkSize = fileLen >= HugeFileThresholdBytes ? HugeFileChunkSizeBytes : DefaultChunkSizeBytes;
|
||||
|
||||
// Scale chunk size so medium files still get parallel lanes:
|
||||
// a 140MB file shouldn't fall back to 1 chunk / 1 lane while
|
||||
// a 10GB file grabs 10 workers.
|
||||
long scaledChunk = fileInfo.Length / maxParallelChunks;
|
||||
long scaledChunk = fileLen / maxParallelChunks;
|
||||
long minChunk = 64L * 1024 * 1024;
|
||||
if (scaledChunk < minChunk) scaledChunk = minChunk;
|
||||
if (scaledChunk < chunkSize) chunkSize = scaledChunk;
|
||||
|
||||
long totalChunks = (fileInfo.Length + chunkSize - 1) / chunkSize;
|
||||
long totalChunks = (fileLen + chunkSize - 1) / chunkSize;
|
||||
int workerCount = (int)Math.Min(totalChunks, Math.Max(1, maxParallelChunks));
|
||||
Log($"⬆️ Uploading (chunked): {fileName} ({FormatFileSize(fileInfo.Length)}) {workerCount} lanes");
|
||||
Log($"⬆️ Uploading (chunked): {fileName} ({FormatFileSize(fileLen)}) {workerCount} lanes");
|
||||
_fileProgressBytes[localPath] = 0;
|
||||
_fileChunkProgressBytes[localPath] = new ConcurrentDictionary<long, long>();
|
||||
_chunkLogLastBytes[localPath] = 0;
|
||||
@@ -443,12 +458,12 @@ namespace PS5Upload
|
||||
async Task DoChunk(PS5Protocol wConn, int chunkIndex, Action? readyCallback)
|
||||
{
|
||||
long offset = chunkIndex * chunkSize;
|
||||
long size = Math.Min(chunkSize, fileInfo.Length - offset);
|
||||
long size = Math.Min(chunkSize, fileLen - offset);
|
||||
long humanIdx = chunkIndex + 1;
|
||||
var prog = MakeChunkProgress(offset, size, humanIdx, totalChunks);
|
||||
bool ok = await wConn.UploadFileAsync(localPath, remotePath, prog, cancellationToken, offset, size, readyCallback);
|
||||
if (!ok) throw new Exception($"Chunk {humanIdx}/{totalChunks} failed for {fileName}");
|
||||
if (_fileChunkProgressBytes.TryGetValue(localPath, out var cm)) { cm[offset] = size; long agg = cm.Values.Sum(); long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long d = agg - prev; if (d > 0) { Interlocked.Add(ref _totalBytesUploaded, d); _fileProgressBytes[localPath] = agg; } _currentFileBytes = agg; _currentFileTotalBytes = fileInfo.Length; }
|
||||
if (_fileChunkProgressBytes.TryGetValue(localPath, out var cm)) { cm[offset] = size; long agg = cm.Values.Sum(); long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long d = agg - prev; if (d > 0) { Interlocked.Add(ref _totalBytesUploaded, d); _fileProgressBytes[localPath] = agg; } _currentFileBytes = agg; _currentFileTotalBytes = fileLen; }
|
||||
}
|
||||
|
||||
IProgress<UploadProgress> MakeChunkProgress(long chunkOffset, long chunkLength, long chunkNumber, long totalChunkCount)
|
||||
@@ -462,13 +477,13 @@ namespace PS5Upload
|
||||
long sent = p.BytesSent - chunkOffset; if (sent < 0) sent = 0; if (sent > chunkLength) sent = chunkLength;
|
||||
var map = _fileChunkProgressBytes.GetOrAdd(localPath, _ => new ConcurrentDictionary<long, long>());
|
||||
map[chunkOffset] = sent;
|
||||
if (callCount % 10 != 0 && sent != chunkLength) return;
|
||||
if (callCount % 2 != 0 && sent != chunkLength) return;
|
||||
long agg = map.Values.Sum();
|
||||
long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long delta = agg - prev;
|
||||
if (delta != 0) { Interlocked.Add(ref _totalBytesUploaded, delta); _fileProgressBytes[localPath] = agg; }
|
||||
_currentFileName = fileName;
|
||||
Interlocked.Exchange(ref _currentFileBytes, agg);
|
||||
Interlocked.Exchange(ref _currentFileTotalBytes, fileInfo.Length);
|
||||
Interlocked.Exchange(ref _currentFileTotalBytes, fileLen);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -478,10 +493,10 @@ namespace PS5Upload
|
||||
var progress = new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount % 10 != 0 && p.BytesSent != p.TotalBytes) return;
|
||||
if (callCount % 2 != 0 && p.BytesSent != p.TotalBytes) return;
|
||||
_currentFileName = fileName;
|
||||
Interlocked.Exchange(ref _currentFileBytes, p.BytesSent);
|
||||
Interlocked.Exchange(ref _currentFileTotalBytes, fileInfo.Length);
|
||||
Interlocked.Exchange(ref _currentFileTotalBytes, fileLen);
|
||||
long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long add = p.BytesSent - prev;
|
||||
if (add > 0) { Interlocked.Add(ref _totalBytesUploaded, add); _fileProgressBytes[localPath] = p.BytesSent; }
|
||||
});
|
||||
@@ -491,25 +506,26 @@ namespace PS5Upload
|
||||
string detail = connection.LastError;
|
||||
throw new Exception(string.IsNullOrEmpty(detail) ? $"Upload failed for {fileName}" : $"Upload failed for {fileName}: {detail}");
|
||||
}
|
||||
long prevB = _fileProgressBytes.GetOrAdd(localPath, 0); long d2 = fileInfo.Length - prevB;
|
||||
if (d2 > 0) { Interlocked.Add(ref _totalBytesUploaded, d2); _fileProgressBytes[localPath] = fileInfo.Length; }
|
||||
long prevB = _fileProgressBytes.GetOrAdd(localPath, 0); long d2 = fileLen - prevB;
|
||||
if (d2 > 0) { Interlocked.Add(ref _totalBytesUploaded, d2); _fileProgressBytes[localPath] = fileLen; }
|
||||
}
|
||||
|
||||
_fileProgressBytes.TryRemove(localPath, out _); _fileChunkProgressBytes.TryRemove(localPath, out _); _chunkLogLastBytes.TryRemove(localPath, out _);
|
||||
if (fileInfo.Length < 10 * 1024 * 1024) TrackSmallFileCompletion(fileName, fileInfo.Length); else Log($"✅ Upload complete: {fileName}");
|
||||
if (fileLen < 10 * 1024 * 1024) TrackSmallFileCompletion(fileName, fileLen); else Log($"✅ Upload complete: {fileName}");
|
||||
// Batch into pending list — the UI timer drains them. A post +
|
||||
// collection-change + layout pass per file froze the window
|
||||
// on 100k-file uploads.
|
||||
lock (_pendingHistoryLock)
|
||||
_pendingHistory.Add(new TransferHistoryItem { FileName = fileName, Status = "✅ Completed", Size = FormatFileSize(fileInfo.Length), Timestamp = DateTime.Now });
|
||||
_pendingHistory.Add(new TransferHistoryItem { FileName = fileName, Status = "✅ Completed", Size = FormatFileSize(fileLen), Timestamp = DateTime.Now });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string fn = Path.GetFileName(localPath);
|
||||
Log($"❌ Exception uploading {fn}: {ex.Message}");
|
||||
_fileProgressBytes.TryRemove(localPath, out _); _fileChunkProgressBytes.TryRemove(localPath, out _); _chunkLogLastBytes.TryRemove(localPath, out _);
|
||||
long failSize = 0; try { failSize = LocalIo.GetLength(localPath); } catch { }
|
||||
lock (_pendingHistoryLock)
|
||||
_pendingHistory.Add(new TransferHistoryItem { FileName = fn, Status = "❌ Failed", Size = FormatFileSize(new FileInfo(localPath).Length), Timestamp = DateTime.Now, LocalPath = localPath, RemotePath = remotePath });
|
||||
_pendingHistory.Add(new TransferHistoryItem { FileName = fn, Status = "❌ Failed", Size = FormatFileSize(failSize), Timestamp = DateTime.Now, LocalPath = localPath, RemotePath = remotePath });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -540,10 +556,29 @@ namespace PS5Upload
|
||||
private void DestroyConnection(PS5Protocol c) { try { c.Disconnect(); c.Dispose(); } catch { } finally { Interlocked.Decrement(ref _currentPoolConnections); } }
|
||||
private void DrainConnectionPool() { while (_connectionPool.TryDequeue(out var c)) DestroyConnection(c); }
|
||||
|
||||
private void CollectFilesFromDirectory(string localDir, string remoteDir, List<(string localPath, string remotePath)> files)
|
||||
private void CollectFilesFromDirectory(string localDir, string remoteDir, List<(string localPath, string remotePath)> files, CancellationToken ct = default)
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(localDir)) { FileInfo info = new(file); files.Add((file, remoteDir + "/" + info.Name)); }
|
||||
foreach (string dir in Directory.GetDirectories(localDir)) { DirectoryInfo info = new(dir); CollectFilesFromDirectory(dir, remoteDir + "/" + info.Name, files); }
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var e in LocalIo.Enumerate(localDir))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (e.IsDirectory) CollectFilesFromDirectory(e.FullPath, remoteDir + "/" + e.Name, files, ct);
|
||||
else
|
||||
{
|
||||
files.Add((e.FullPath, remoteDir + "/" + e.Name));
|
||||
// Live collect counter — recursive NAS enumeration over
|
||||
// thousands of files must not look like a freeze.
|
||||
if (files.Count % 200 == 0)
|
||||
{
|
||||
int n = files.Count;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
TotalProgressText.Text = $"Collecting files... ({n} found)";
|
||||
UploadFileNameText.Text = e.Name;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackSmallFileCompletion(string fileName, long fileSize)
|
||||
@@ -590,7 +625,7 @@ namespace PS5Upload
|
||||
else if (_duplicateAction == DuplicateAction.SkipAll) { /* skip */ }
|
||||
else
|
||||
{
|
||||
long localSize = new FileInfo(file.localPath).Length;
|
||||
long localSize = LocalIo.GetLength(file.localPath);
|
||||
long remoteSize = existingFiles[fn!];
|
||||
var dlg = new DuplicateFileDialog(fn!, localSize, remoteSize);
|
||||
await dlg.ShowDialog(this);
|
||||
|
||||
@@ -161,11 +161,11 @@
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<StackPanel Grid.Row="3" Margin="10,10,10,15">
|
||||
<Button x:Name="BuyMeCoffeeButton" Background="#FFDD00" Foreground="#000000"
|
||||
<Button x:Name="BuyMeCoffeeButton" Background="#FF5E5B" Foreground="#FFFFFF"
|
||||
Click="BuyMeCoffeeButton_Click" HorizontalAlignment="Stretch" Padding="8,6" FontSize="12">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<TextBlock Text="☕" FontSize="14" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="Buy me a coffee" FontSize="12" Foreground="#000000"/>
|
||||
<TextBlock Text="Support me on Ko-fi" FontSize="12" Foreground="#FFFFFF"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="Mounted Games:" Width="110" Foreground="#999999"/>
|
||||
<TextBlock Text="Games & Apps:" Width="110" Foreground="#999999"/>
|
||||
<TextBlock x:Name="MountedGamesText" Text="---" FontWeight="Bold" Foreground="#007ACC"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
@@ -281,12 +281,13 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Border Grid.Row="0" Background="#2D2D30" Padding="10" CornerRadius="4,4,0,0">
|
||||
<TextBlock Text="📁 Local Files" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="LocalFilesHeader" Text="📁 Local Files" FontSize="14" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
<ListBox Grid.Row="1" x:Name="LocalFilesListBox" BorderThickness="0" DragDrop.AllowDrop="True">
|
||||
<ListBox Grid.Row="1" x:Name="LocalFilesListBox" BorderThickness="0" DragDrop.AllowDrop="True" DoubleTapped="LocalFilesListBox_DoubleTapped">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="📦 Install on PS5 (.pkg)" Click="InstallLocalPkgMenuItem_Click"/>
|
||||
<MenuItem Header="🔌 Disconnect NAS session" Click="DisconnectNasMenuItem_Click"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="🗑️ Remove from list" Click="RemoveLocalFileMenuItem_Click"/>
|
||||
</ContextMenu>
|
||||
@@ -301,6 +302,7 @@
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<WrapPanel Grid.Row="2" Margin="10" HorizontalAlignment="Center">
|
||||
<Button x:Name="LocalBrowseBackBtn" Content="⬆ Back" Click="LocalBrowseBack_Click" Padding="10,5" Margin="0,0,5,0" Background="#6C757D" IsVisible="False" ToolTip.Tip="Leave browse mode / go up one level"/>
|
||||
<Button Content="📄 Files" Click="BrowseFilesButton_Click" Padding="10,5" Margin="0,0,5,0"/>
|
||||
<Button Content="📁 Folder" Click="BrowseFolderButton_Click" Padding="10,5" Margin="0,0,5,0"/>
|
||||
<Button Content="🌐 NAS" Click="AddNasPath_Click" Padding="10,5" Background="#17A2B8" Margin="0,0,5,0" ToolTip.Tip="Add network/NAS path (\\server\share)"/>
|
||||
@@ -373,10 +375,14 @@
|
||||
|
||||
<!-- PAGE 2: Games -->
|
||||
<Grid x:Name="PageGames" IsVisible="False">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TabControl x:Name="GamesTabControl" Background="#1E1E1E" SelectionChanged="GamesTabControl_SelectionChanged">
|
||||
<TabItem Header="🎮 Games">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" Background="#252526" CornerRadius="4" Padding="12" Margin="0,0,0,15">
|
||||
<WrapPanel>
|
||||
@@ -384,10 +390,29 @@
|
||||
<TextBlock x:Name="GameCountText" Text="(0 games)" FontSize="12" Foreground="#999999" VerticalAlignment="Center" Margin="0,0,15,0"/>
|
||||
<Button x:Name="RefreshGameListButton" Content="🔄 Refresh" Click="RefreshGameListButton_Click" Padding="10,5" Margin="0,0,8,0"/>
|
||||
<Button x:Name="MountGamesFromListButton" Content="📥 Mount All" Click="MountGamesButton_Click" Padding="10,5" Background="#28A745"/>
|
||||
<Button x:Name="UnmountAllGamesButton" Content="🗑️ Unmount All" Click="UnmountAllGamesButton_Click" Padding="10,5" Margin="8,0,0,0" Background="#8B0000"/>
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="MountedGamesListBox" Background="#1E1E1E" BorderThickness="0" DoubleTapped="MountedGamesListBox_DoubleTapped">
|
||||
<Border Grid.Row="1" Background="#252526" CornerRadius="4" Padding="8" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="GameSearchBox" PlaceholderText="🔍 Search games..." TextChanged="GameSearchBox_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" x:Name="GameSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="GameSortComboBox_SelectionChanged" VerticalContentAlignment="Center">
|
||||
<ComboBoxItem>Name A→Z</ComboBoxItem>
|
||||
<ComboBoxItem>Name Z→A</ComboBoxItem>
|
||||
<ComboBoxItem>Size ↑</ComboBoxItem>
|
||||
<ComboBoxItem>Size ↓</ComboBoxItem>
|
||||
<ComboBoxItem>Title ID</ComboBoxItem>
|
||||
<ComboBoxItem>Mounted first</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="MountedGamesListBox" Background="#1E1E1E" BorderThickness="0" DoubleTapped="MountedGamesListBox_DoubleTapped">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="▶ Launch Game" Click="LaunchGameMenuItem_Click"/>
|
||||
@@ -433,6 +458,198 @@
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Prospero Store Site (homebrew.page native PS5 catalog) -->
|
||||
<TabItem Header="🌐 Prospero Store Site">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Border Grid.Row="0" Background="#252526" CornerRadius="4" Padding="12" Margin="0,0,0,15">
|
||||
<WrapPanel>
|
||||
<TextBlock Text="🌐 Prospero Store Site" FontSize="16" FontWeight="Bold" Foreground="#007ACC" VerticalAlignment="Center" Margin="0,0,15,0"/>
|
||||
<Button Content="🔄 Refresh Catalog" Click="ProsperoRefresh_Click" Padding="10,5" Margin="0,0,8,0"/>
|
||||
<TextBlock x:Name="ProsperoStatusText" Text="" Foreground="#999999" VerticalAlignment="Center"/>
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
<Border Grid.Row="1" Background="#252526" CornerRadius="4" Padding="8" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="ProsperoSearchBox" PlaceholderText="🔍 Search apps..." TextChanged="ProsperoSearch_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" x:Name="ProsperoCategoryComboBox" Width="140" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="ProsperoCategory_SelectionChanged" VerticalContentAlignment="Center">
|
||||
<ComboBoxItem>All</ComboBoxItem>
|
||||
<ComboBoxItem>Apps</ComboBoxItem>
|
||||
<ComboBoxItem>Games</ComboBoxItem>
|
||||
<ComboBoxItem>Tools</ComboBoxItem>
|
||||
<ComboBoxItem>Coming soon</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<ComboBox Grid.Column="2" x:Name="ProsperoSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="ProsperoSort_SelectionChanged" VerticalContentAlignment="Center">
|
||||
<ComboBoxItem>Name A→Z</ComboBoxItem>
|
||||
<ComboBoxItem>Name Z→A</ComboBoxItem>
|
||||
<ComboBoxItem>Size ↑</ComboBoxItem>
|
||||
<ComboBoxItem>Size ↓</ComboBoxItem>
|
||||
<ComboBoxItem>Newest first</ComboBoxItem>
|
||||
<ComboBoxItem>Author</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ListBox Grid.Row="2" x:Name="ProsperoListBox" Background="#1E1E1E" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="6" Padding="12" Margin="3">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0">
|
||||
<Border Background="#3E3E42" CornerRadius="8">
|
||||
<TextBlock Text="🧩" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<Border CornerRadius="8" ClipToBounds="True">
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="15" FontWeight="Bold"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="12" Foreground="#007ACC" FontWeight="SemiBold"/>
|
||||
<TextBlock Text=" • " Foreground="#666666"/>
|
||||
<TextBlock Text="{Binding Version}" FontSize="12" Foreground="#FFC107"/>
|
||||
<TextBlock Text=" • " Foreground="#666666"/>
|
||||
<TextBlock Text="{Binding Author}" FontSize="12" Foreground="#9CDCFE"/>
|
||||
<TextBlock Text=" • " Foreground="#666666"/>
|
||||
<TextBlock Text="{Binding KindDisplay}" FontSize="12" Foreground="#CE9178"/>
|
||||
<TextBlock Text=" • " Foreground="#666666"/>
|
||||
<TextBlock Text="{Binding SizeDisplay}" FontSize="12" Foreground="#888888"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StatusDisplay}" FontSize="11" Foreground="{Binding StatusColor}" Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="{Binding InstallLabel}" Click="ProsperoInstall_Click" Tag="{Binding TitleId}" Padding="12,6" Background="#28A745" VerticalAlignment="Center" IsEnabled="{Binding CanInstall}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Trophies (NpTrophy V2 viewer) -->
|
||||
<TabItem Header="🏆 Trophies">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Border Grid.Row="0" Background="#252526" CornerRadius="4" Padding="12" Margin="0,0,0,15">
|
||||
<WrapPanel>
|
||||
<TextBlock Text="🏆 Trophies" FontSize="16" FontWeight="Bold" Foreground="#007ACC" VerticalAlignment="Center" Margin="0,0,15,0"/>
|
||||
<TextBlock x:Name="TrophyStatusText" Text="" Foreground="#999999" VerticalAlignment="Center"/>
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
<Border Grid.Row="1" Background="#252526" CornerRadius="4" Padding="8" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="TrophySetSearchBox" PlaceholderText="🔍 Search games..." TextChanged="TrophySetSearchBox_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
|
||||
<ComboBox Grid.Column="1" x:Name="TrophyUserComboBox" MinWidth="160" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="TrophyUserComboBox_SelectionChanged" VerticalContentAlignment="Center"/>
|
||||
<ComboBox Grid.Column="2" x:Name="TrophySetSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="TrophySetSortComboBox_SelectionChanged" VerticalContentAlignment="Center">
|
||||
<ComboBoxItem>Name A→Z</ComboBoxItem>
|
||||
<ComboBoxItem>Name Z→A</ComboBoxItem>
|
||||
<ComboBoxItem>Progress ↑</ComboBoxItem>
|
||||
<ComboBoxItem>Progress ↓</ComboBoxItem>
|
||||
<ComboBoxItem>Trophy count</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="320"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<ListBox Grid.Column="0" x:Name="TrophySetsListBox" Background="#1E1E1E" BorderThickness="0" SelectionChanged="TrophySetsListBox_SelectionChanged" Margin="0,0,8,0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="6" Padding="10" Margin="3">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Width="48" Height="48" Margin="0,0,10,0" Background="#3E3E42" CornerRadius="6" ClipToBounds="True">
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding GameName}" FontSize="13" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,3,0,0">
|
||||
<TextBlock Text="{Binding NpCommunicationId}" FontSize="11" Foreground="#007ACC"/>
|
||||
<TextBlock Text=" • " Foreground="#666666"/>
|
||||
<TextBlock Text="{Binding ProgressDisplay}" FontSize="11" Foreground="#9CDCFE"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding UserIdDisplay}" FontSize="10" Foreground="#888888" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<ListBox Grid.Column="1" x:Name="TrophyItemsListBox" Background="#1E1E1E" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="6" Padding="10" Margin="3">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Width="48" Height="48" Margin="0,0,10,0" Background="#3E3E42" CornerRadius="6" ClipToBounds="True">
|
||||
<Image Source="{Binding Icon}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding HiddenDisplay}" Margin="6,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="#AAAAAA" TextWrapping="Wrap" Margin="0,3,0,0"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<TextBlock Text="{Binding GroupDisplay}" FontSize="10" Foreground="#888888"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
|
||||
<Border Background="{Binding GradeBrush}" CornerRadius="10" Padding="10,4" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding GradeDisplay}" FontSize="11" Foreground="#202020" FontWeight="Bold"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding StateDisplay}" FontSize="10" Foreground="#999999" Margin="0,5,0,0" HorizontalAlignment="Right"/>
|
||||
<Button Content="🔓 Unlock" FontSize="10" Padding="10,2" Margin="0,5,0,0" HorizontalAlignment="Right"
|
||||
IsVisible="{Binding CanUnlock}" Click="TrophyUnlock_Click"
|
||||
ToolTip.Tip="Debug-unlock this trophy — requires the game to be RUNNING on the PS5"/>
|
||||
<Button Content="🔒 Lock" FontSize="10" Padding="10,2" Margin="0,5,0,0" HorizontalAlignment="Right"
|
||||
IsVisible="{Binding CanLock}" Click="TrophyLock_Click"
|
||||
ToolTip.Tip="Re-lock this trophy — rewrites TRPTITLE.DAT on the console (close the game first)"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
|
||||
<!-- PAGE 3: Saves & Media -->
|
||||
@@ -812,6 +1029,78 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Row 3b: Network -->
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="20"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="🌐 Network" FontWeight="Bold" FontSize="14" Foreground="#0E9CFF" Margin="0,0,0,12"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="110"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Status:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="NetStateText" Text="—" Margin="0,3" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="SSID:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="NetSsidText" Text="—" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Signal (RSSI):" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="NetRssiText" Text="—" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="IP Address:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="NetIpText" Text="—" Margin="0,3" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="Gateway:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="NetGwText" Text="—" Margin="0,3" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="DNS:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" x:Name="NetDnsText" Text="—" Margin="0,3" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="6" Grid.Column="0" Text="MAC:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="6" Grid.Column="1" x:Name="NetMacText" Text="—" Margin="0,3" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="7" Grid.Column="0" Text="MTU / Link:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="7" Grid.Column="1" x:Name="NetMtuText" Text="—" Margin="0,3"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Text="📊 Live Traffic" FontWeight="Bold" FontSize="14" Foreground="#0E9CFF" Margin="0,0,0,12"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="110"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="NAT Type:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="NetNatText" Text="—" Margin="0,3" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Public IP:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="NetNatIpText" Text="—" Margin="0,3" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="⬇ Download:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="NetRxRateText" Text="—" Margin="0,3" Foreground="#28A745" FontWeight="Bold" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="⬆ Upload:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="NetTxRateText" Text="—" Margin="0,3" Foreground="#0E9CFF" FontWeight="Bold" FontFamily="Consolas"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="Interface:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="NetIfText" Text="—" Margin="0,3" FontFamily="Consolas" FontSize="11"/>
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="Totals:" Foreground="#808080" Margin="0,3"/>
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" x:Name="NetTotalsText" Text="—" Margin="0,3" FontFamily="Consolas" FontSize="11"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
|
||||
<Button x:Name="NetSpeedTestButton" Content="⚡ Speed Test" Background="#0E9CFF" Foreground="White" Padding="14,7" CornerRadius="4" Click="NetSpeedTestButton_Click"/>
|
||||
<TextBlock x:Name="NetSpeedText" Text="" VerticalAlignment="Center" Margin="12,0,0,0" Foreground="#FFAA00" FontWeight="Bold" FontFamily="Consolas"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Row 4: Loaded Modules -->
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
@@ -933,6 +1222,123 @@
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Power & Devices Tab -->
|
||||
<TabItem Header="⚡ Power & Devices">
|
||||
<ScrollViewer Background="#1E1E1E">
|
||||
<StackPanel Margin="15">
|
||||
<TextBlock Text="⚡ Console Power" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<WrapPanel>
|
||||
<Button Content="🔄 Reboot" Click="PowerReboot_Click" Padding="15,8" Background="#007ACC" FontWeight="SemiBold" Margin="0,0,10,0"/>
|
||||
<Button Content="⏻ Shutdown" Click="PowerShutdown_Click" Padding="15,8" Background="#DC3545" FontWeight="SemiBold"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="⚠️ The console acts immediately after confirmation — the connection drops." Foreground="#707070" FontSize="11" Margin="0,10,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="🔌 USB Drives" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<WrapPanel Margin="0,0,0,10">
|
||||
<Button Content="🔄 Refresh" Click="RefreshUsbDrives_Click" Padding="10,5" Background="#3E3E42" Margin="0,0,10,0"/>
|
||||
<Button Content="📂 Browse Selected" Click="BrowseUsbDrive_Click" Padding="10,5" Background="#007ACC"/>
|
||||
</WrapPanel>
|
||||
<ListBox x:Name="UsbDrivesList" Height="110" FontFamily="Consolas" FontSize="12" Background="#1A1A1B"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="🎮 Controllers" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<WrapPanel Margin="0,0,0,10">
|
||||
<Button Content="🔄 Refresh" Click="RefreshPadInfo_Click" Padding="10,5" Background="#3E3E42"/>
|
||||
</WrapPanel>
|
||||
<TextBlock x:Name="PadInfoText" Text="—" FontFamily="Consolas" FontSize="12" Foreground="#CCCCCC" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="🎨 Light Bar" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<WrapPanel>
|
||||
<Button Content="🔵 Blue" Click="LightBar_Click" Tag="0,0,255" Padding="10,5" Background="#1E3A5F" Margin="0,0,6,0"/>
|
||||
<Button Content="🔴 Red" Click="LightBar_Click" Tag="255,0,0" Padding="10,5" Background="#5F1E1E" Margin="0,0,6,0"/>
|
||||
<Button Content="🟢 Green" Click="LightBar_Click" Tag="0,255,0" Padding="10,5" Background="#1E5F1E" Margin="0,0,6,0"/>
|
||||
<Button Content="🟣 Purple" Click="LightBar_Click" Tag="160,32,240" Padding="10,5" Background="#3E1E5F" Margin="0,0,6,0"/>
|
||||
<Button Content="⚪ White" Click="LightBar_Click" Tag="255,255,255" Padding="10,5" Background="#4E4E4E" Margin="0,0,6,0"/>
|
||||
<Button Content="⚫ Off" Click="LightBar_Click" Tag="0,0,0" Padding="10,5" Background="#3E3E42"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="🔔 Notification" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="NotifyTextBox" PlaceholderText="Notification text..." Padding="8,6" Margin="0,0,10,0"/>
|
||||
<Button Grid.Column="1" Content="🔔 Send" Click="SendNotify_Click" Padding="12,6" Background="#28A745"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="💡 Console LED & 🔊 Beeper" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
|
||||
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<WrapPanel Margin="0,0,0,8">
|
||||
<TextBlock Text="LED:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Button Content="Bright" Click="LedDim_Click" Tag="0" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Medium" Click="LedDim_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Dim" Click="LedDim_Click" Tag="2" Padding="10,5" Background="#3E3E42"/>
|
||||
</WrapPanel>
|
||||
<WrapPanel Margin="0,0,0,8">
|
||||
<TextBlock Text="Color:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Button Content="White" Click="LedColor_Click" Tag="0,255,0" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Blue" Click="LedColor_Click" Tag="255,0,0" Padding="10,5" Background="#1E3A5F" Margin="0,0,6,0"/>
|
||||
<Button Content="Orange" Click="LedColor_Click" Tag="0,0,255" Padding="10,5" Background="#5F3A1E" Margin="0,0,6,0"/>
|
||||
<Button Content="Purple" Click="LedColor_Click" Tag="64,0,255" Padding="10,5" Background="#3E1E5F" Margin="0,0,6,0"/>
|
||||
<Button Content="Pink" Click="LedColor_Click" Tag="32,48,255" Padding="10,5" Background="#5F1E3E" Margin="0,0,6,0"/>
|
||||
<Button Content="Off" Click="LedColor_Click" Tag="0,0,0" Padding="10,5" Background="#3E3E42"/>
|
||||
</WrapPanel>
|
||||
<WrapPanel Margin="0,0,0,8">
|
||||
<TextBlock Text="Effect:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<ComboBox x:Name="LedEffectCombo" Width="180" SelectedIndex="1" Margin="0,0,8,0">
|
||||
<ComboBoxItem Content="off"/>
|
||||
<ComboBoxItem Content="white"/>
|
||||
<ComboBoxItem Content="white-dim"/>
|
||||
<ComboBoxItem Content="blue"/>
|
||||
<ComboBoxItem Content="orange"/>
|
||||
<ComboBoxItem Content="purple"/>
|
||||
<ComboBoxItem Content="pink"/>
|
||||
<ComboBoxItem Content="sunrise (white breathe + orange)"/>
|
||||
<ComboBoxItem Content="blue-breathe"/>
|
||||
<ComboBoxItem Content="white-breathe"/>
|
||||
<ComboBoxItem Content="orange-breathe"/>
|
||||
<ComboBoxItem Content="pink-breathe"/>
|
||||
<ComboBoxItem Content="pink-breathe-fast"/>
|
||||
<ComboBoxItem Content="blue-to-richblue"/>
|
||||
<ComboBoxItem Content="blue-white-anim"/>
|
||||
</ComboBox>
|
||||
<Button Content="✨ Apply" Click="LedEffect_Click" Padding="10,5" Background="#007ACC"/>
|
||||
</WrapPanel>
|
||||
<WrapPanel>
|
||||
<TextBlock Text="Beeper:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Button Content="Beep" Click="Buzzer_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Error" Click="Buzzer_Click" Tag="2" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Long" Click="Buzzer_Click" Tag="3" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Vol High" Click="BuzzerVol_Click" Tag="0" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Vol Med" Click="BuzzerVol_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Vol Low" Click="BuzzerVol_Click" Tag="2" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Mute" Click="BuzzerMute_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
|
||||
<Button Content="Unmute" Click="BuzzerMute_Click" Tag="0" Padding="10,5" Background="#3E3E42"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- Memory Editor Tab -->
|
||||
<TabItem Header="🧠 Memory">
|
||||
<Grid Background="#1E1E1E" Margin="15">
|
||||
@@ -1014,6 +1420,36 @@
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- App Manager v2 Tab -->
|
||||
<TabItem Header="🎯 Apps">
|
||||
<Grid Background="#1E1E1E" Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Text="🎯 App Manager" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,10"/>
|
||||
<WrapPanel Grid.Row="1" Margin="0,0,0,8">
|
||||
<Button Content="🔄 Refresh" Click="AppMgrRefresh_Click" Padding="10,5" Background="#3E3E42" Margin="0,0,8,0"/>
|
||||
<CheckBox x:Name="AppMgrAutoRefresh" Content="Auto (3s)" Foreground="#CCCCCC" Margin="0,0,10,0" VerticalAlignment="Center" IsCheckedChanged="AppMgrAutoRefresh_Changed"/>
|
||||
<Button x:Name="AppMgrSuspendBtn" Content="⏸ Freeze" Click="AppMgrSuspend_Click" Padding="10,5" Background="#8A6D00" Margin="0,0,8,0" ToolTip.Tip="Freeze all app threads (SIGSTOP) — game pauses completely"/>
|
||||
<Button x:Name="AppMgrResumeBtn" Content="▶ Resume" Click="AppMgrResume_Click" Padding="10,5" Background="#28A745" Margin="0,0,8,0" ToolTip.Tip="Resume a frozen app (SIGCONT)"/>
|
||||
<Button x:Name="AppMgrKillBtn" Content="❌ Kill" Click="AppMgrKill_Click" Padding="10,5" Background="#B03030" Margin="0,0,8,0" ToolTip.Tip="Force-kill (LncUtil, SIGKILL fallback)"/>
|
||||
<Button x:Name="AppMgrCoredumpBtn" Content="💥 Coredump" Click="AppMgrCoredump_Click" Padding="10,5" Background="#6F42C1" Margin="0,0,8,0" ToolTip.Tip="Ask ShellCore to dump the app's memory (for debugging)"/>
|
||||
<TextBlock x:Name="AppMgrStatus" Foreground="#808080" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
</WrapPanel>
|
||||
<ListBox Grid.Row="2" x:Name="AppMgrList" FontFamily="Consolas" FontSize="12" Background="#252526" SelectionChanged="AppMgrList_SelectionChanged">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Grid.Row="3" Text="Real app info via sceKernelGetAppInfo — suspend/resume/kill via LncUtil (needs etaHEN). CPU% from kernel scheduler stats." Foreground="#606060" FontSize="11" Margin="0,6,0,0"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Kernel Log Tab -->
|
||||
<TabItem Header="🧾 Kernel Log">
|
||||
<Grid Background="#1E1E1E" Margin="15">
|
||||
@@ -1027,6 +1463,7 @@
|
||||
<WrapPanel Grid.Row="1" Margin="0,0,0,10">
|
||||
<Button Content="🔄 Refresh" Click="KlogRefresh_Click" Padding="10,5" Background="#3E3E42" Margin="0,0,8,0"/>
|
||||
<CheckBox x:Name="KlogAutoRefresh" Content="Auto (3s)" Foreground="#CCCCCC" Margin="0,0,10,0" VerticalAlignment="Center" IsCheckedChanged="KlogAutoRefresh_Changed"/>
|
||||
<CheckBox x:Name="KlogAutoScroll" Content="Auto-scroll" Foreground="#CCCCCC" IsChecked="True" Margin="0,0,10,0" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="KlogTailSelect" Width="120" SelectedIndex="0" Margin="0,0,8,0">
|
||||
<ComboBoxItem Content="Last 32 KB"/>
|
||||
<ComboBoxItem Content="Last 128 KB"/>
|
||||
@@ -1169,6 +1606,7 @@
|
||||
<TextBox Grid.Row="1" x:Name="LogTextBox" Text="" FontFamily="Consolas" FontSize="11" Foreground="#CCCCCC" Background="#1E1E1E" BorderThickness="0" Padding="10" TextWrapping="Wrap" IsReadOnly="True" AcceptsReturn="True"/>
|
||||
|
||||
<WrapPanel Grid.Row="2" Margin="10" HorizontalAlignment="Center">
|
||||
<CheckBox x:Name="LogAutoScroll" Content="Auto-scroll" Foreground="#CCCCCC" IsChecked="True" VerticalAlignment="Center" Margin="0,0,15,0"/>
|
||||
<Button Content="📋 Copy Log" Click="CopyLogButton_Click" Padding="8,5" Margin="0,0,5,0" Background="#28A745"/>
|
||||
<Button Content="🗑️ Clear Log" Click="ClearLogButton_Click" Padding="8,5" Background="#3E3E42"/>
|
||||
</WrapPanel>
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace PS5Upload
|
||||
private ObservableCollection<PS5FileItem> _ps5Files = new();
|
||||
private ObservableCollection<PS5FileItem> _ps5FilesFiltered = new();
|
||||
private string _currentPS5Path = "/data";
|
||||
private bool _isDeleting;
|
||||
private CancellationTokenSource? _uploadCancellation;
|
||||
private string _ps5IpAddress = "";
|
||||
private string _searchQuery = "";
|
||||
@@ -37,6 +38,10 @@ namespace PS5Upload
|
||||
private List<string> _favoritePaths = new();
|
||||
private const string FavoritesFileName = "ps5_favorites.json";
|
||||
|
||||
// Saved NAS connections
|
||||
private List<NasManager.SavedConnection> _nasSaved = new();
|
||||
private const string NasFileName = "ps5_nas_connections.json";
|
||||
|
||||
// Transfer History
|
||||
private ObservableCollection<TransferHistoryItem> _completedTransfers = new();
|
||||
private ObservableCollection<TransferHistoryItem> _failedTransfers = new();
|
||||
@@ -171,6 +176,7 @@ namespace PS5Upload
|
||||
Log("Application started");
|
||||
LoadProfiles();
|
||||
LoadFavorites();
|
||||
LoadNasConnections();
|
||||
LoadSettings();
|
||||
|
||||
if (AutoSendPayloadCheckBox != null) AutoSendPayloadCheckBox.IsChecked = _autoSendPayload;
|
||||
@@ -179,6 +185,8 @@ namespace PS5Upload
|
||||
|
||||
if (_autoSendPayload && !string.IsNullOrEmpty(_payloadPath) && !string.IsNullOrEmpty(_ps5IpAddress))
|
||||
_ = AutoSendPayloadOnStartup();
|
||||
|
||||
Opened += (_, _) => _ = CheckForAppUpdateAsync();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -258,6 +266,155 @@ namespace PS5Upload
|
||||
return result;
|
||||
}
|
||||
|
||||
/// NAS connect dialog: UNC path + optional credentials + LAN discovery
|
||||
/// + optional "remember this connection" save.
|
||||
/// Returns null on cancel; empty user = guest/anonymous attempt.
|
||||
private async Task<(string path, string user, string pass, bool remember)?> ShowNasDialogAsync()
|
||||
{
|
||||
(string, string, string, bool)? result = null;
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = "Add NAS / Network Share", Width = 440, Height = 510,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(20), Spacing = 6 };
|
||||
var pathBox = new TextBox { Text = "\\\\NAS\\share\\folder", Margin = new Thickness(0, 0, 0, 8) };
|
||||
|
||||
// --- Discovery row ---
|
||||
var scanRow = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 8 };
|
||||
var scanBtn = new Button { Content = "🔍 Find NAS on LAN", Padding = new Thickness(10, 4) };
|
||||
var scanStatus = new TextBlock { Text = "", Foreground = new SolidColorBrush(Color.FromRgb(150, 150, 150)), FontSize = 11, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center };
|
||||
scanRow.Children.Add(scanBtn);
|
||||
scanRow.Children.Add(scanStatus);
|
||||
panel.Children.Add(scanRow);
|
||||
|
||||
var hostList = new ListBox
|
||||
{
|
||||
Height = 80, IsVisible = false,
|
||||
Background = new SolidColorBrush(Color.FromRgb(20, 20, 20)),
|
||||
Margin = new Thickness(0, 0, 0, 6)
|
||||
};
|
||||
var foundHosts = new List<NasManager.NasHost>();
|
||||
hostList.SelectionChanged += (s, ev) =>
|
||||
{
|
||||
if (hostList.SelectedIndex >= 0 && hostList.SelectedIndex < foundHosts.Count)
|
||||
pathBox.Text = "\\\\" + foundHosts[hostList.SelectedIndex].Ip + "\\";
|
||||
};
|
||||
panel.Children.Add(hostList);
|
||||
|
||||
scanBtn.Click += async (s, ev) =>
|
||||
{
|
||||
scanBtn.IsEnabled = false;
|
||||
scanStatus.Text = "Scanning LAN…";
|
||||
hostList.IsVisible = false;
|
||||
try
|
||||
{
|
||||
foundHosts.Clear();
|
||||
foundHosts.AddRange(await NasManager.DiscoverAsync());
|
||||
var items = new Avalonia.Controls.ItemsControl();
|
||||
var strings = new System.Collections.ObjectModel.ObservableCollection<string>(foundHosts.Select(h => h.Display));
|
||||
hostList.ItemsSource = strings;
|
||||
hostList.IsVisible = strings.Count > 0;
|
||||
scanStatus.Text = strings.Count == 0 ? "No SMB hosts found" : $"{strings.Count} host(s) — pick one:";
|
||||
}
|
||||
catch (Exception ex) { scanStatus.Text = "Scan failed: " + ex.Message; }
|
||||
scanBtn.IsEnabled = true;
|
||||
};
|
||||
|
||||
panel.Children.Add(new TextBlock { Text = "UNC path:", Foreground = Brushes.White });
|
||||
panel.Children.Add(pathBox);
|
||||
|
||||
panel.Children.Add(new TextBlock { Text = "Username (leave empty for guest):", Foreground = Brushes.White });
|
||||
var userBox = new TextBox { PlaceholderText = "e.g. admin or WORKGROUP\\user", Margin = new Thickness(0, 0, 0, 8) };
|
||||
panel.Children.Add(userBox);
|
||||
|
||||
panel.Children.Add(new TextBlock { Text = "Password:", Foreground = Brushes.White });
|
||||
var passBox = new TextBox { PasswordChar = '●', Margin = new Thickness(0, 0, 0, 8) };
|
||||
panel.Children.Add(passBox);
|
||||
|
||||
var rememberBox = new CheckBox { Content = "💾 Remember this connection", Foreground = Brushes.White, Margin = new Thickness(0, 0, 0, 6) };
|
||||
panel.Children.Add(rememberBox);
|
||||
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Saved connections reappear when you press NAS.\nPassword is stored locally on this PC only (obfuscated, not encrypted).",
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(150, 150, 150)),
|
||||
FontSize = 11, TextWrapping = Avalonia.Media.TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 0, 0, 10)
|
||||
});
|
||||
|
||||
var btnPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Spacing = 10 };
|
||||
var okBtn = new Button { Content = "Connect", Width = 90, IsDefault = true };
|
||||
okBtn.Click += (s, ev) => { result = (pathBox.Text ?? "", userBox.Text ?? "", passBox.Text ?? "", rememberBox.IsChecked == true); dlg.Close(); };
|
||||
var cancelBtn = new Button { Content = "Cancel", Width = 90, IsCancel = true };
|
||||
cancelBtn.Click += (s, ev) => dlg.Close();
|
||||
btnPanel.Children.Add(okBtn);
|
||||
btnPanel.Children.Add(cancelBtn);
|
||||
panel.Children.Add(btnPanel);
|
||||
|
||||
dlg.Content = panel;
|
||||
await dlg.ShowDialog(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Picker shown when saved NAS connections exist.
|
||||
/// Returns the chosen connection, the sentinel for "new connection", or null.
|
||||
private static readonly NasManager.SavedConnection _nasNewSentinel = new() { Path = "__new__" };
|
||||
private async Task<NasManager.SavedConnection?> ShowNasPickerAsync()
|
||||
{
|
||||
NasManager.SavedConnection? result = null;
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = "NAS Connections", Width = 460, Height = 330,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(15), Spacing = 8 };
|
||||
|
||||
panel.Children.Add(new TextBlock { Text = "Saved connections:", Foreground = Brushes.White, Margin = new Thickness(0, 0, 0, 4) });
|
||||
var list = new ListBox
|
||||
{
|
||||
Height = 170,
|
||||
Background = new SolidColorBrush(Color.FromRgb(20, 20, 20)),
|
||||
ItemsSource = new ObservableCollection<string>(_nasSaved.Select(c => c.Display))
|
||||
};
|
||||
list.SelectedIndex = 0;
|
||||
panel.Children.Add(list);
|
||||
|
||||
var btnPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
|
||||
var connBtn = new Button { Content = "🔌 Connect", Width = 100, IsDefault = true };
|
||||
var newBtn = new Button { Content = "➕ New…", Width = 90 };
|
||||
var delBtn = new Button { Content = "🗑️ Delete", Width = 90 };
|
||||
var cancelBtn = new Button { Content = "Cancel", Width = 90, IsCancel = true };
|
||||
|
||||
connBtn.Click += (s, ev) => { if (list.SelectedIndex >= 0 && list.SelectedIndex < _nasSaved.Count) result = _nasSaved[list.SelectedIndex]; dlg.Close(); };
|
||||
newBtn.Click += (s, ev) => { result = _nasNewSentinel; dlg.Close(); };
|
||||
delBtn.Click += (s, ev) =>
|
||||
{
|
||||
if (list.SelectedIndex >= 0 && list.SelectedIndex < _nasSaved.Count)
|
||||
{
|
||||
_nasSaved.RemoveAt(list.SelectedIndex);
|
||||
SaveNasConnections();
|
||||
list.ItemsSource = new ObservableCollection<string>(_nasSaved.Select(c => c.Display));
|
||||
if (_nasSaved.Count == 0) { result = _nasNewSentinel; dlg.Close(); }
|
||||
}
|
||||
};
|
||||
cancelBtn.Click += (s, ev) => dlg.Close();
|
||||
|
||||
// Double-click a saved entry = Connect
|
||||
list.DoubleTapped += (s, ev) => { if (list.SelectedIndex >= 0 && list.SelectedIndex < _nasSaved.Count) { result = _nasSaved[list.SelectedIndex]; dlg.Close(); } };
|
||||
|
||||
btnPanel.Children.Add(connBtn);
|
||||
btnPanel.Children.Add(newBtn);
|
||||
btnPanel.Children.Add(delBtn);
|
||||
btnPanel.Children.Add(cancelBtn);
|
||||
panel.Children.Add(btnPanel);
|
||||
dlg.Content = panel;
|
||||
await dlg.ShowDialog(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NAVIGATION
|
||||
// ============================================================
|
||||
@@ -360,11 +517,20 @@ namespace PS5Upload
|
||||
Interlocked.Exchange(ref _lastLogFlushTicks, DateTime.Now.Ticks);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
int caret = LogTextBox.CaretIndex;
|
||||
string t = (LogTextBox.Text ?? "") + chunk;
|
||||
int trimmed = 0;
|
||||
if (t.Length > MaxLogChars)
|
||||
t = t.Substring(t.Length - MaxLogChars);
|
||||
{
|
||||
trimmed = t.Length - MaxLogChars;
|
||||
t = t.Substring(trimmed);
|
||||
}
|
||||
LogTextBox.Text = t;
|
||||
LogTextBox.CaretIndex = t.Length;
|
||||
// Auto-scroll: jump to the end; otherwise keep the user's
|
||||
// scroll position (caret tracks what they're reading).
|
||||
LogTextBox.CaretIndex = (LogAutoScroll.IsChecked == true)
|
||||
? t.Length
|
||||
: Math.Clamp(caret - trimmed, 0, t.Length);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SMBLibrary;
|
||||
using SMBLibrary.Client;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
/// <summary>
|
||||
/// Managed SMB (NAS) access via SMBLibrary. One authenticated session per
|
||||
/// share root (\\server\share) is kept for the app lifetime; every file
|
||||
/// under that share reuses it. All store ops run under a per-session lock —
|
||||
/// SMBLibrary isn't thread-safe, so parallel upload lanes interleave their
|
||||
/// reads on the single session.
|
||||
/// </summary>
|
||||
public static class NasManager
|
||||
{
|
||||
public sealed class NasAuthException : IOException { public NasAuthException(string m) : base(m) { } }
|
||||
|
||||
private sealed class Session
|
||||
{
|
||||
public string Server = "", Share = "";
|
||||
public string Domain = "", User = "", Pass = "";
|
||||
public SMB2Client? Client;
|
||||
public ISMBFileStore? Store;
|
||||
public readonly object Lock = new();
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Session> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// A NAS connection the user chose to keep between app launches.
|
||||
/// Password is stored obfuscated (DPAPI on Windows, Base64 elsewhere)
|
||||
/// — convenient, not secure; never treat as a real credential vault.
|
||||
public sealed class SavedConnection
|
||||
{
|
||||
public string Path { get; set; } = ""; // e.g. \\192.168.0.5\G\games
|
||||
public string Server { get; set; } = "";
|
||||
public string Share { get; set; } = "";
|
||||
public string User { get; set; } = "";
|
||||
public string PassEnc { get; set; } = ""; // encoded, see Encode/DecodePass
|
||||
|
||||
public string Display => string.IsNullOrEmpty(User) ? Path : $"{Path} ({User})";
|
||||
}
|
||||
|
||||
public static string EncodePass(string plain)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var enc = System.Security.Cryptography.ProtectedData.Protect(
|
||||
System.Text.Encoding.UTF8.GetBytes(plain), null,
|
||||
System.Security.Cryptography.DataProtectionScope.CurrentUser);
|
||||
return "dpapi:" + Convert.ToBase64String(enc);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "b64:" + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(plain));
|
||||
}
|
||||
|
||||
public static string DecodePass(string enc)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (enc.StartsWith("dpapi:") && OperatingSystem.IsWindows())
|
||||
{
|
||||
var raw = System.Security.Cryptography.ProtectedData.Unprotect(
|
||||
Convert.FromBase64String(enc.Substring(6)), null,
|
||||
System.Security.Cryptography.DataProtectionScope.CurrentUser);
|
||||
return System.Text.Encoding.UTF8.GetString(raw);
|
||||
}
|
||||
if (enc.StartsWith("b64:"))
|
||||
return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(enc.Substring(4)));
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LAN DISCOVERY — scan local subnets for hosts listening on
|
||||
// TCP 445 (SMB). ~2s for a /24 with parallel probes.
|
||||
// ============================================================
|
||||
public sealed class NasHost
|
||||
{
|
||||
public string Ip = "";
|
||||
public string Name = "";
|
||||
public string Display => string.IsNullOrEmpty(Name) ? Ip : $"{Ip} ({Name})";
|
||||
}
|
||||
|
||||
public static async Task<List<NasHost>> DiscoverAsync(CancellationToken ct = default)
|
||||
{
|
||||
var ranges = GetLocalScanRanges();
|
||||
var found = new ConcurrentBag<string>();
|
||||
|
||||
var probes = new List<Task>();
|
||||
using var sem = new SemaphoreSlim(220);
|
||||
foreach (var (network, mask) in ranges)
|
||||
{
|
||||
uint count = ~mask + 1;
|
||||
if (count > 2048) count = 2048; // sanity cap
|
||||
for (uint i = 1; i < count - 1; i++)
|
||||
{
|
||||
uint ipN = network + i;
|
||||
string ip = $"{(ipN >> 24) & 0xFF}.{(ipN >> 16) & 0xFF}.{(ipN >> 8) & 0xFF}.{ipN & 0xFF}";
|
||||
probes.Add(Task.Run(async () =>
|
||||
{
|
||||
await sem.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
using var c = new System.Net.Sockets.TcpClient();
|
||||
var t = c.ConnectAsync(ip, 445);
|
||||
if (await Task.WhenAny(t, Task.Delay(350, ct)).ConfigureAwait(false) == t && c.Connected)
|
||||
found.Add(ip);
|
||||
}
|
||||
catch { }
|
||||
finally { sem.Release(); }
|
||||
}, ct));
|
||||
}
|
||||
}
|
||||
try { await Task.WhenAll(probes).ConfigureAwait(false); } catch { }
|
||||
|
||||
// Reverse-DNS the hits (parallel, short timeout, tolerate failures).
|
||||
var results = new ConcurrentBag<NasHost>();
|
||||
await Task.WhenAll(found.Select(async ip =>
|
||||
{
|
||||
string name = "";
|
||||
try
|
||||
{
|
||||
var t = System.Net.Dns.GetHostEntryAsync(ip);
|
||||
if (await Task.WhenAny(t, Task.Delay(1200, ct)).ConfigureAwait(false) == t)
|
||||
{
|
||||
var e = await t;
|
||||
if (!string.IsNullOrEmpty(e.HostName) && e.HostName != ip) name = e.HostName;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
results.Add(new NasHost { Ip = ip, Name = name });
|
||||
})).ConfigureAwait(false);
|
||||
|
||||
return results.OrderBy(h => h.Ip).ToList();
|
||||
}
|
||||
|
||||
/// Try to list a host's shares via OS tools: `net view` on Windows,
|
||||
/// `smbclient -L` elsewhere (if installed). Returns null when the
|
||||
/// tool isn't available or the output can't be parsed.
|
||||
public static List<string>? EnumerateSharesOs(string host, string user = "", string pass = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
string exe, args;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
exe = "net"; args = $"view \\\\{host} /all";
|
||||
}
|
||||
else
|
||||
{
|
||||
exe = "smbclient";
|
||||
string u = string.IsNullOrEmpty(user) ? "-N" : $"-U {user}%{pass}";
|
||||
args = $"-L //{host} {u} -g"; // -g = parseable output
|
||||
}
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(exe, args)
|
||||
{
|
||||
RedirectStandardOutput = true, RedirectStandardError = true,
|
||||
UseShellExecute = false, CreateNoWindow = true
|
||||
};
|
||||
using var p = System.Diagnostics.Process.Start(psi);
|
||||
if (p == null) return null;
|
||||
string outp = p.StandardOutput.ReadToEnd();
|
||||
if (!p.WaitForExit(8000)) { try { p.Kill(); } catch { } return null; }
|
||||
|
||||
var shares = new List<string>();
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// "Share name Type Used as Comment" — names in col 0, e.g. "games Disk"
|
||||
foreach (var line in outp.Split('\n'))
|
||||
{
|
||||
var t = line.TrimEnd('\r');
|
||||
if (t.Contains(" Disk") && t.TrimStart().Length > 0)
|
||||
{
|
||||
int i = t.IndexOf(" ");
|
||||
string name = (i > 0 ? t.Substring(0, i) : t).Trim();
|
||||
if (name.Length > 0 && !name.EndsWith("$") && !name.Equals("Share name", StringComparison.OrdinalIgnoreCase))
|
||||
shares.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// smbclient -g lines: "Disk|sharename|comment"
|
||||
foreach (var line in outp.Split('\n'))
|
||||
{
|
||||
var parts = line.Trim().Split('|');
|
||||
if (parts.Length >= 2 && parts[0] == "Disk" && !parts[1].EndsWith("$"))
|
||||
shares.Add(parts[1]);
|
||||
}
|
||||
}
|
||||
return shares.Count > 0 ? shares : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// Local IPv4 subnets to scan — (network, mask) as uints, big-endian.
|
||||
private static List<(uint network, uint mask)> GetLocalScanRanges()
|
||||
{
|
||||
var ranges = new List<(uint, uint)>();
|
||||
try
|
||||
{
|
||||
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
||||
if (nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
|
||||
foreach (var ua in nic.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
||||
var ipB = ua.Address.GetAddressBytes();
|
||||
var mB = ua.IPv4Mask?.GetAddressBytes();
|
||||
if (mB == null) continue;
|
||||
uint ip = ((uint)ipB[0] << 24) | ((uint)ipB[1] << 16) | ((uint)ipB[2] << 8) | ipB[3];
|
||||
uint mask = ((uint)mB[0] << 24) | ((uint)mB[1] << 16) | ((uint)mB[2] << 8) | mB[3];
|
||||
ranges.Add((ip & mask, mask));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
// Fallback: if NIC enumeration gave nothing (odd platforms), guess common /24s is pointless — return empty.
|
||||
return ranges.Distinct().ToList();
|
||||
}
|
||||
|
||||
public static bool IsUncPath(string? path) =>
|
||||
!string.IsNullOrEmpty(path) && (path.StartsWith("\\\\") || path.StartsWith("//"));
|
||||
|
||||
public static string Normalize(string path)
|
||||
{
|
||||
path = path.Trim();
|
||||
if (path.StartsWith("//")) path = "\\\\" + path.Substring(2).Replace('/', '\\');
|
||||
return path;
|
||||
}
|
||||
|
||||
/// "\\server\share" portion of a UNC path ("" if not UNC).
|
||||
public static string ShareRoot(string uncPath)
|
||||
{
|
||||
string p = Normalize(uncPath).TrimEnd('\\');
|
||||
int i = p.IndexOf('\\', 2); // end of server
|
||||
if (i < 0) return p;
|
||||
int j = p.IndexOf('\\', i + 1); // end of share
|
||||
return j < 0 ? p : p.Substring(0, j);
|
||||
}
|
||||
|
||||
/// Split \\server\share\a\b → (server, share, "a\b").
|
||||
public static bool TrySplit(string uncPath, out string server, out string share, out string rel)
|
||||
{
|
||||
server = share = rel = "";
|
||||
if (!IsUncPath(uncPath)) return false;
|
||||
string p = Normalize(uncPath);
|
||||
string rest = p.Substring(2);
|
||||
int i = rest.IndexOf('\\');
|
||||
if (i < 0) return false;
|
||||
server = rest.Substring(0, i);
|
||||
string rest2 = rest.Substring(i + 1);
|
||||
int j = rest2.IndexOf('\\');
|
||||
if (j < 0) { share = rest2; }
|
||||
else { share = rest2.Substring(0, j); rel = rest2.Substring(j + 1); }
|
||||
return server.Length > 0 && share.Length > 0;
|
||||
}
|
||||
|
||||
public static bool HasSession(string uncPath) => _sessions.ContainsKey(ShareRoot(uncPath));
|
||||
|
||||
/// Connect + authenticate + tree-connect to \\server\share. Throws
|
||||
/// NasAuthException on logon failure, IOException on other errors.
|
||||
public static void Connect(string server, string share, string user, string password, string domain = "")
|
||||
{
|
||||
var s = new Session { Server = server, Share = share, User = user, Pass = password, Domain = domain };
|
||||
Establish(s);
|
||||
_sessions["\\\\" + server + "\\" + share] = s;
|
||||
}
|
||||
|
||||
public static void DisconnectAll()
|
||||
{
|
||||
foreach (var s in _sessions.Values) { try { s.Client?.Logoff(); s.Client?.Disconnect(); } catch { } }
|
||||
_sessions.Clear();
|
||||
}
|
||||
|
||||
/// Drop the session for the share that contains uncPath (if any).
|
||||
public static bool Disconnect(string uncPath)
|
||||
{
|
||||
string root = ShareRoot(uncPath);
|
||||
if (_sessions.TryRemove(root, out var s))
|
||||
{
|
||||
try { s.Client?.Logoff(); s.Client?.Disconnect(); } catch { }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Establish(Session s)
|
||||
{
|
||||
var client = new SMB2Client();
|
||||
bool ok;
|
||||
try { ok = client.Connect(s.Server, SMBTransportType.DirectTCPTransport); }
|
||||
catch (Exception ex) { throw new IOException($"Cannot reach {s.Server}:445 — {ex.Message}"); }
|
||||
if (!ok) throw new IOException($"Cannot reach {s.Server}:445 (connection timeout)");
|
||||
|
||||
NTStatus st = client.Login(s.Domain, s.User, s.Pass);
|
||||
if (st != NTStatus.STATUS_SUCCESS)
|
||||
{
|
||||
try { client.Disconnect(); } catch { }
|
||||
if (st == NTStatus.STATUS_LOGON_FAILURE || st == NTStatus.STATUS_ACCESS_DENIED)
|
||||
throw new NasAuthException($"Login failed on {s.Server} — check username/password ({st})");
|
||||
throw new IOException($"Login failed on {s.Server}: {st}");
|
||||
}
|
||||
|
||||
ISMBFileStore? store = client.TreeConnect(s.Share, out st);
|
||||
if (st != NTStatus.STATUS_SUCCESS || store == null)
|
||||
{
|
||||
try { client.Logoff(); client.Disconnect(); } catch { }
|
||||
if (st == NTStatus.STATUS_ACCESS_DENIED) throw new NasAuthException($"Access denied to \\\\{s.Server}\\{s.Share} — insufficient permissions");
|
||||
throw new IOException($"Cannot mount share \\\\{s.Server}\\{s.Share}: {st}");
|
||||
}
|
||||
s.Client = client; s.Store = store;
|
||||
}
|
||||
|
||||
private static Session SessionFor(string uncPath)
|
||||
{
|
||||
if (_sessions.TryGetValue(ShareRoot(uncPath), out var s)) return s;
|
||||
throw new IOException($"No NAS session for {ShareRoot(uncPath)} — add the path via the 🌐 NAS button first.");
|
||||
}
|
||||
|
||||
private static string RelPath(Session s, string uncPath)
|
||||
{
|
||||
string root = "\\\\" + s.Server + "\\" + s.Share;
|
||||
return Normalize(uncPath).Substring(root.Length).TrimStart('\\');
|
||||
}
|
||||
|
||||
/// Run an op on the session's store; on stale connection reconnect once and retry.
|
||||
private static T WithStore<T>(string uncPath, Func<ISMBFileStore, Session, T> op)
|
||||
{
|
||||
var s = SessionFor(uncPath);
|
||||
lock (s.Lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
return op(s.Store ?? throw new IOException("NAS session not connected"), s);
|
||||
}
|
||||
catch (NasAuthException) { throw; }
|
||||
catch (IOException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Dead socket / broken pipe → one reconnect attempt.
|
||||
try { Establish(s); return op(s.Store!, s); }
|
||||
catch (IOException) { throw; }
|
||||
catch (Exception ex2) { throw new IOException($"NAS error on {s.Server}: {ex2.Message}", ex); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static NTStatus OpenFile(ISMBFileStore store, string rel, bool isDir, out object handle)
|
||||
{
|
||||
handle = null!;
|
||||
return store.CreateFile(out handle, out _,
|
||||
rel,
|
||||
AccessMask.MAXIMUM_ALLOWED,
|
||||
SMBLibrary.FileAttributes.Normal,
|
||||
ShareAccess.Read | ShareAccess.Write,
|
||||
CreateDisposition.FILE_OPEN,
|
||||
isDir ? CreateOptions.FILE_DIRECTORY_FILE : CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
null);
|
||||
}
|
||||
|
||||
/// true=dir, false=file, null=doesn't exist / not accessible.
|
||||
public static bool? ExistsAsDir(string uncPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WithStore(uncPath, (store, s) =>
|
||||
{
|
||||
string rel = RelPath(s, uncPath);
|
||||
if (rel.Length == 0) return (bool?)true; // share root = directory
|
||||
NTStatus st = OpenFile(store, rel, true, out object h);
|
||||
if (st == NTStatus.STATUS_SUCCESS) { store.CloseFile(h); return (bool?)true; }
|
||||
st = OpenFile(store, rel, false, out h);
|
||||
if (st == NTStatus.STATUS_SUCCESS) { store.CloseFile(h); return (bool?)false; }
|
||||
if (st == NTStatus.STATUS_ACCESS_DENIED) throw new NasAuthException($"Access denied: {uncPath}");
|
||||
return (bool?)null;
|
||||
});
|
||||
}
|
||||
catch (IOException) { throw; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public static bool IsFile(string uncPath) => ExistsAsDir(uncPath) == false;
|
||||
public static bool IsDirectory(string uncPath) => ExistsAsDir(uncPath) == true;
|
||||
|
||||
public static long GetLength(string uncPath)
|
||||
{
|
||||
return WithStore(uncPath, (store, s) =>
|
||||
{
|
||||
string rel = RelPath(s, uncPath);
|
||||
NTStatus st = OpenFile(store, rel, false, out object h);
|
||||
if (st != NTStatus.STATUS_SUCCESS)
|
||||
throw new IOException($"Cannot open {uncPath}: {st}");
|
||||
try
|
||||
{
|
||||
st = store.GetFileInformation(out FileInformation? info, h, FileInformationClass.FileStandardInformation);
|
||||
if (st != NTStatus.STATUS_SUCCESS || info is not FileStandardInformation fsi)
|
||||
throw new IOException($"Cannot stat {uncPath}: {st}");
|
||||
return fsi.EndOfFile;
|
||||
}
|
||||
finally { store.CloseFile(h); }
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class NasEntry
|
||||
{
|
||||
public string FullPath = "";
|
||||
public string Name = "";
|
||||
public bool IsDirectory;
|
||||
public long Size;
|
||||
}
|
||||
|
||||
/// Direct children of a UNC directory (skips . and ..).
|
||||
public static List<NasEntry> Enumerate(string uncDir)
|
||||
{
|
||||
return WithStore(uncDir, (store, s) =>
|
||||
{
|
||||
string rel = RelPath(s, uncDir);
|
||||
NTStatus st = OpenFile(store, rel, true, out object h);
|
||||
if (st != NTStatus.STATUS_SUCCESS && rel.Length == 0)
|
||||
st = OpenFile(store, ".", true, out h); // some servers want "." for share root
|
||||
if (st != NTStatus.STATUS_SUCCESS)
|
||||
throw new IOException($"Cannot open directory {uncDir}: {st}");
|
||||
try
|
||||
{
|
||||
st = store.QueryDirectory(out List<QueryDirectoryFileInformation> entries, h, "*", FileInformationClass.FileDirectoryInformation);
|
||||
if (st != NTStatus.STATUS_SUCCESS && st != NTStatus.STATUS_NO_MORE_FILES)
|
||||
throw new IOException($"Cannot list {uncDir}: {st}");
|
||||
var result = new List<NasEntry>();
|
||||
string basePath = Normalize(uncDir).TrimEnd('\\');
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e is not FileDirectoryInformation fdi) continue;
|
||||
if (fdi.FileName is "." or "..") continue;
|
||||
bool isDir = (fdi.FileAttributes & SMBLibrary.FileAttributes.Directory) != 0;
|
||||
result.Add(new NasEntry
|
||||
{
|
||||
Name = fdi.FileName,
|
||||
FullPath = basePath + "\\" + fdi.FileName,
|
||||
IsDirectory = isDir,
|
||||
Size = isDir ? 0 : fdi.EndOfFile
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
finally { store.CloseFile(h); }
|
||||
});
|
||||
}
|
||||
|
||||
/// Seekable read stream over an SMB file. Multiple streams can be open
|
||||
/// concurrently on one session (each owns its file handle).
|
||||
public static Stream OpenRead(string uncPath)
|
||||
{
|
||||
var s = SessionFor(uncPath);
|
||||
lock (s.Lock)
|
||||
{
|
||||
if (s.Store == null) Establish(s);
|
||||
string rel = RelPath(s, uncPath);
|
||||
NTStatus st = OpenFile(s.Store!, rel, false, out object h);
|
||||
if (st == NTStatus.STATUS_ACCESS_DENIED) throw new NasAuthException($"Access denied: {uncPath}");
|
||||
if (st != NTStatus.STATUS_SUCCESS)
|
||||
throw new IOException($"Cannot open {uncPath}: {st}");
|
||||
return new SmbReadStream(s, h, uncPath);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SmbReadStream : Stream
|
||||
{
|
||||
private readonly Session _s;
|
||||
private object _handle;
|
||||
private readonly string _path;
|
||||
private long _pos = -1; // lazily resolved
|
||||
private long _len = -1;
|
||||
private bool _disposed;
|
||||
|
||||
public SmbReadStream(Session s, object handle, string path) { _s = s; _handle = handle; _path = path; }
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_len < 0)
|
||||
{
|
||||
lock (_s.Lock)
|
||||
{
|
||||
var st = _s.Store!.GetFileInformation(out FileInformation? info, _handle, FileInformationClass.FileStandardInformation);
|
||||
_len = (st == NTStatus.STATUS_SUCCESS && info is FileStandardInformation fsi) ? fsi.EndOfFile : 0;
|
||||
}
|
||||
}
|
||||
return _len;
|
||||
}
|
||||
}
|
||||
public override long Position { get => _pos < 0 ? 0 : _pos; set => _pos = value; }
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long basePos = origin == SeekOrigin.Begin ? 0 : origin == SeekOrigin.Current ? Position : Length;
|
||||
_pos = basePos + offset;
|
||||
if (_pos < 0) _pos = 0;
|
||||
return _pos;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(SmbReadStream));
|
||||
lock (_s.Lock)
|
||||
{
|
||||
EnsureFresh();
|
||||
int total = 0;
|
||||
while (total < count)
|
||||
{
|
||||
int want = Math.Min(count - total, 1024 * 1024); // 1MB per SMB read
|
||||
var st = _s.Store!.ReadFile(out byte[]? data, _handle, _pos, want);
|
||||
if (st == NTStatus.STATUS_END_OF_FILE || data == null || data.Length == 0) break;
|
||||
if (st != NTStatus.STATUS_SUCCESS) break;
|
||||
Buffer.BlockCopy(data, 0, buffer, offset + total, data.Length);
|
||||
_pos += data.Length;
|
||||
total += data.Length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> Task.Run(() => Read(buffer, offset, count), cancellationToken);
|
||||
|
||||
private void EnsureFresh()
|
||||
{
|
||||
if (_pos < 0) _pos = 0;
|
||||
if (_s.Store == null)
|
||||
{
|
||||
Establish(_s);
|
||||
string rel = RelPath(_s, _path);
|
||||
NTStatus st = OpenFile(_s.Store!, rel, false, out object h);
|
||||
if (st != NTStatus.STATUS_SUCCESS) throw new IOException($"Cannot reopen {_path}: {st}");
|
||||
_handle = h;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
lock (_s.Lock) { try { _s.Store?.CloseFile(_handle); } catch { } }
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uniform file access for the upload queue: UNC paths go through the
|
||||
/// NAS session (SMBLibrary) when one exists — otherwise System.IO so
|
||||
/// already-authenticated Windows shares keep working; local paths always
|
||||
/// use System.IO.
|
||||
/// </summary>
|
||||
public static class LocalIo
|
||||
{
|
||||
public static bool IsUnc(string? path) => NasManager.IsUncPath(path);
|
||||
|
||||
/// File exists? (either kind)
|
||||
public static bool FileExists(string path)
|
||||
=> IsUnc(path) && NasManager.HasSession(path) ? NasManager.IsFile(path) : File.Exists(path);
|
||||
|
||||
public static bool DirExists(string path)
|
||||
=> IsUnc(path) && NasManager.HasSession(path) ? NasManager.IsDirectory(path) : Directory.Exists(path);
|
||||
|
||||
public static long GetLength(string path)
|
||||
{
|
||||
if (IsUnc(path) && NasManager.HasSession(path)) return NasManager.GetLength(path);
|
||||
return new FileInfo(path).Length;
|
||||
}
|
||||
|
||||
public static string GetName(string path)
|
||||
{
|
||||
if (IsUnc(path)) return NasManager.Normalize(path).TrimEnd('\\').Split('\\')[^1];
|
||||
return Path.GetFileName(path.TrimEnd('\\', '/'));
|
||||
}
|
||||
|
||||
public static Stream OpenRead(string path, bool randomAccess = false)
|
||||
{
|
||||
if (IsUnc(path) && NasManager.HasSession(path)) return NasManager.OpenRead(path);
|
||||
var opts = randomAccess ? FileOptions.RandomAccess : FileOptions.SequentialScan;
|
||||
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, opts);
|
||||
}
|
||||
|
||||
public sealed class Entry
|
||||
{
|
||||
public string FullPath = "";
|
||||
public string Name = "";
|
||||
public bool IsDirectory;
|
||||
public long Size;
|
||||
}
|
||||
|
||||
/// Direct children of a local or NAS directory.
|
||||
public static List<Entry> Enumerate(string dir)
|
||||
{
|
||||
if (IsUnc(dir) && NasManager.HasSession(dir))
|
||||
{
|
||||
var l = new List<Entry>();
|
||||
foreach (var e in NasManager.Enumerate(dir))
|
||||
l.Add(new Entry { FullPath = e.FullPath, Name = e.Name, IsDirectory = e.IsDirectory, Size = e.Size });
|
||||
return l;
|
||||
}
|
||||
var result = new List<Entry>();
|
||||
var di = new DirectoryInfo(dir);
|
||||
foreach (var f in di.GetFiles()) result.Add(new Entry { FullPath = f.FullName, Name = f.Name, IsDirectory = false, Size = f.Length });
|
||||
foreach (var d in di.GetDirectories()) result.Add(new Entry { FullPath = d.FullName, Name = d.Name, IsDirectory = true, Size = 0 });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,11 @@
|
||||
<AssemblyName>PS5Suite</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<ApplicationIcon>ps5_logo.ico</ApplicationIcon>
|
||||
<Version>7.0.0</Version>
|
||||
<Version>7.4.0</Version>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
|
||||
<Copyright>Copyright © 2026 Manos</Copyright>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,6 +29,8 @@
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.22" />
|
||||
<PackageReference Include="SMBLibrary" Version="1.5.8.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.12" />
|
||||
<Reference Include="LibProsperoPkg">
|
||||
<HintPath>libs\LibProsperoPkg.dll</HintPath>
|
||||
</Reference>
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace PS5Upload
|
||||
/// Keys are stored UNescaped because requests are unescaped before lookup.</summary>
|
||||
public string AddFile(string localPath)
|
||||
{
|
||||
var name = Path.GetFileName(localPath);
|
||||
var name = LocalIo.GetName(localPath);
|
||||
_files["/" + name] = localPath;
|
||||
return "/" + Uri.EscapeDataString(name);
|
||||
}
|
||||
@@ -124,8 +124,7 @@ namespace PS5Upload
|
||||
return;
|
||||
}
|
||||
|
||||
var fi = new FileInfo(localPath);
|
||||
long total = fi.Length, start = 0, end = total - 1;
|
||||
long total = LocalIo.GetLength(localPath), start = 0, end = total - 1;
|
||||
bool partial = false;
|
||||
|
||||
// Parse "Range: bytes=a-b" / "bytes=a-" / "bytes=-n".
|
||||
@@ -174,7 +173,7 @@ namespace PS5Upload
|
||||
|
||||
if (method == "GET")
|
||||
{
|
||||
using var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var fs = LocalIo.OpenRead(localPath, randomAccess: true);
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
var buf = new byte[1024 * 1024];
|
||||
long remain = len;
|
||||
|
||||
@@ -80,7 +80,27 @@ namespace PS5Upload
|
||||
MemRegions = 0x6B,
|
||||
MemSearch = 0x6C,
|
||||
KlogRead = 0x6D,
|
||||
UnmountAll = 0x6E,
|
||||
MountGame = 0x6F,
|
||||
// App Manager v2
|
||||
AppListV2 = 0x70,
|
||||
AppSuspend = 0x71,
|
||||
AppResume = 0x72,
|
||||
AppKill = 0x73,
|
||||
AppCoredump = 0x74,
|
||||
NetInfo = 0x75,
|
||||
NetSpeedTest = 0x76,
|
||||
PowerAction = 0x77,
|
||||
UsbList = 0x78,
|
||||
PadInfo = 0x79,
|
||||
Notify = 0x7C,
|
||||
PadAction = 0x7D,
|
||||
IccControl = 0x7E,
|
||||
|
||||
// Trophy viewer (NpTrophy V2) — read-only listing + icons
|
||||
TrophyList = 0x80,
|
||||
TrophyIcon = 0x81,
|
||||
TrophyUnlock = 0x82,
|
||||
|
||||
Shutdown = 0xFF
|
||||
}
|
||||
@@ -139,19 +159,82 @@ namespace PS5Upload
|
||||
for (int p = 9113; p <= 9118; p++)
|
||||
if (!ports.Contains(p)) ports.Add(p);
|
||||
|
||||
foreach (var p in ports)
|
||||
// Probe ALL ports in parallel — a wedged payload accepts TCP but
|
||||
// never answers PING, and probing serially wastes ~3s per dead port.
|
||||
var probes = ports.Select(p => ProbePortAsync(ipAddress, p, ct)).ToList();
|
||||
(int port, TcpClient? client)[] results;
|
||||
try { results = await Task.WhenAll(probes); }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var r = await ConnectSingleAsync(ipAddress, p, ct);
|
||||
if (r == ConnectResult.Timeout) { Disconnect(); return false; }
|
||||
if (r == ConnectResult.Ok && await VerifyAliveAsync())
|
||||
foreach (var pr in probes)
|
||||
try { var r = pr.Result; r.client?.Dispose(); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
TcpClient? winner = null; int winnerPort = 0;
|
||||
foreach (var (p, client) in results)
|
||||
{
|
||||
if (client != null && winner == null) { winner = client; winnerPort = p; }
|
||||
else if (client != null) { client.Close(); client.Dispose(); }
|
||||
}
|
||||
if (winner == null) { Disconnect(); return false; }
|
||||
|
||||
_client = winner;
|
||||
TrySetSocketBuffers(_client, 16 * 1024 * 1024);
|
||||
_stream = winner.GetStream();
|
||||
Port = winnerPort;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>TCP-connect + one bounded PING on a single port. Returns the
|
||||
/// live socket on success so the caller can keep using it.</summary>
|
||||
private static async Task<(int port, TcpClient? client)> ProbePortAsync(
|
||||
string ipAddress, int port, CancellationToken ct)
|
||||
{
|
||||
TcpClient? c = null;
|
||||
try
|
||||
{
|
||||
c = new TcpClient { NoDelay = true, LingerState = new System.Net.Sockets.LingerOption(false, 0) };
|
||||
var connectTask = c.ConnectAsync(ipAddress, port);
|
||||
if (await Task.WhenAny(connectTask, Task.Delay(2000, ct)) != connectTask)
|
||||
{ c.Dispose(); return (port, null); }
|
||||
await connectTask;
|
||||
if (!c.Connected) { c.Dispose(); return (port, null); }
|
||||
|
||||
var s = c.GetStream();
|
||||
byte[] ping = { (byte)Command.Ping, 0, 0, 0, 0 };
|
||||
using var probe = new CancellationTokenSource(2000);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(probe.Token, ct);
|
||||
await s.WriteAsync(ping, linked.Token);
|
||||
// Read the FULL framed response (5-byte header + payload) —
|
||||
// PING answers "PONG" (4 bytes); leaving them in the stream
|
||||
// desyncs every subsequent command.
|
||||
var hdr = new byte[5];
|
||||
int got = 0;
|
||||
while (got < 5)
|
||||
{
|
||||
Port = p;
|
||||
return true;
|
||||
int n = await s.ReadAsync(hdr, got, 5 - got, linked.Token);
|
||||
if (n == 0) break;
|
||||
got += n;
|
||||
}
|
||||
Disconnect();
|
||||
uint payloadLen = got == 5 ? BitConverter.ToUInt32(hdr, 1) : 0;
|
||||
var body = new byte[payloadLen];
|
||||
got = 0;
|
||||
while (got < payloadLen)
|
||||
{
|
||||
int n = await s.ReadAsync(body, got, (int)payloadLen - got, linked.Token);
|
||||
if (n == 0) break;
|
||||
got += n;
|
||||
}
|
||||
if (hdr[0] == (byte)Response.Ok && got == payloadLen) return (port, c);
|
||||
c.Close(); c.Dispose();
|
||||
return (port, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
c?.Dispose();
|
||||
return (port, null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -243,8 +326,7 @@ namespace PS5Upload
|
||||
try
|
||||
{
|
||||
_client = new TcpClient();
|
||||
_client.ReceiveBufferSize = 16 * 1024 * 1024; // 16MB - matches payload SO_RCVBUF setting
|
||||
_client.SendBufferSize = 16 * 1024 * 1024; // 16MB - matches payload SO_RCVBUF setting
|
||||
TrySetSocketBuffers(_client, 16 * 1024 * 1024); // 16MB - matches payload SO_RCVBUF setting
|
||||
_client.NoDelay = true;
|
||||
_client.LingerState = new System.Net.Sockets.LingerOption(false, 0);
|
||||
|
||||
@@ -318,7 +400,34 @@ namespace PS5Upload
|
||||
await _stream.WriteAsync(header, 0, 5);
|
||||
if (data != null && data.Length > 0)
|
||||
{
|
||||
await _stream.WriteAsync(data, 0, data.Length);
|
||||
await WriteSocketChunkedAsync(_stream, data, 0, data.Length, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// macOS caps SO_SNDBUF/SO_RCVBUF at kern.ipc.maxsockbuf (8MB default).
|
||||
// setsockopt above the cap returns ENOBUFS — clamp down until it sticks.
|
||||
private static void TrySetSocketBuffers(TcpClient client, int desired)
|
||||
{
|
||||
for (int sz = desired; sz >= 1 * 1024 * 1024; sz >>= 1)
|
||||
{
|
||||
try { client.ReceiveBufferSize = sz; client.SendBufferSize = sz; return; }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
}
|
||||
|
||||
// macOS kern.ipc.maxsockbuf defaults to 8MB — a single send() larger
|
||||
// than that fails with ENOBUFS. Slice every socket write to 4MB;
|
||||
// invisible overhead on Windows/Linux, fixes Darwin transfers.
|
||||
private const int MaxSocketWriteBytes = 4 * 1024 * 1024;
|
||||
|
||||
private static async Task WriteSocketChunkedAsync(NetworkStream stream, byte[] buffer, int offset, int count, CancellationToken ct)
|
||||
{
|
||||
while (count > 0)
|
||||
{
|
||||
int n = Math.Min(count, MaxSocketWriteBytes);
|
||||
await stream.WriteAsync(buffer, offset, n, ct);
|
||||
offset += n;
|
||||
count -= n;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +529,8 @@ namespace PS5Upload
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get path if available (part 8)
|
||||
// Get path if available (part 8); part 9 was a dev-only mount
|
||||
// dump — intentionally ignored now.
|
||||
string storagePath = (parts.Length >= 8) ? parts[7].Trim() : "unknown";
|
||||
|
||||
return new PS5StorageInfo
|
||||
@@ -1047,6 +1157,115 @@ namespace PS5Upload
|
||||
}
|
||||
}
|
||||
|
||||
// Power action: "reboot" or "shutdown" — the console goes down right
|
||||
// after the ACK, so callers should expect the connection to drop.
|
||||
public async Task<(bool success, string message)> PowerActionAsync(string action)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.PowerAction, Encoding.UTF8.GetBytes(action));
|
||||
var (response, data) = await ReceiveResponseAsync(10000);
|
||||
string msg = data.Length > 0 ? Encoding.UTF8.GetString(data) : response.ToString();
|
||||
return (response == Response.Ok, msg.TrimEnd('\0', '\n', '\r'));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<PS5UsbDrive>> ListUsbDrivesAsync()
|
||||
{
|
||||
var drives = new List<PS5UsbDrive>();
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.UsbList);
|
||||
var (response, data) = await ReceiveResponseAsync();
|
||||
if (response != Response.Data) return drives;
|
||||
|
||||
foreach (var line in Encoding.UTF8.GetString(data).Split('\n'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line == "NONE") continue;
|
||||
var p = line.Trim().Split('|');
|
||||
if (p.Length < 5) continue;
|
||||
drives.Add(new PS5UsbDrive
|
||||
{
|
||||
MountPath = p[0],
|
||||
FsType = p[1],
|
||||
Device = p[2],
|
||||
TotalBytes = ulong.TryParse(p[3], out var t) ? t : 0,
|
||||
FreeBytes = ulong.TryParse(p[4], out var f) ? f : 0
|
||||
});
|
||||
}
|
||||
return drives;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return drives;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetPadInfoAsync()
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.PadInfo);
|
||||
var (response, data) = await ReceiveResponseAsync(15000);
|
||||
if (response != Response.Data) return null;
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// Generic simple command that takes a string arg and returns a short
|
||||
// status string — used by the devices/fun features.
|
||||
public async Task<(bool success, string message)> SendTextCommandAsync(Command cmd, string arg, int timeoutMs = 15000)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(cmd, Encoding.UTF8.GetBytes(arg));
|
||||
var (response, data) = await ReceiveResponseAsync(timeoutMs);
|
||||
string msg = data.Length > 0 ? Encoding.UTF8.GetString(data).TrimEnd('\0', '\n', '\r') : response.ToString();
|
||||
return (response == Response.Ok, msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<(bool success, string message)> NotifyAsync(string text)
|
||||
=> SendTextCommandAsync(Command.Notify, text);
|
||||
|
||||
public Task<(bool success, string message)> PadActionAsync(string cmd)
|
||||
=> SendTextCommandAsync(Command.PadAction, cmd);
|
||||
|
||||
public Task<(bool success, string message)> IccControlAsync(string cmd)
|
||||
=> SendTextCommandAsync(Command.IccControl, cmd);
|
||||
|
||||
// NEW: Get list of mounted games
|
||||
public async Task<List<PS5MountedGame>> GetGameListAsync()
|
||||
{
|
||||
@@ -1312,6 +1531,43 @@ namespace PS5Upload
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Unmount every mounted game (same async worker pipeline).</summary>
|
||||
public async Task<(bool success, string message)> UnmountAllGamesAsync(Action<string>? onProgress = null)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.UnmountAll);
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(10);
|
||||
while (true)
|
||||
{
|
||||
int remainingMs = (int)(deadline - DateTime.UtcNow).TotalMilliseconds;
|
||||
if (remainingMs <= 0) return (false, "Unmount-all timed out after 10 minutes");
|
||||
|
||||
var (response, respData) = await ReceiveResponseAsync(remainingMs);
|
||||
string message = respData != null ? Encoding.UTF8.GetString(respData).TrimEnd('\0') : "";
|
||||
|
||||
if (response == Response.Progress)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(message)) onProgress?.Invoke(message);
|
||||
continue;
|
||||
}
|
||||
if (response == Response.Ok) return (true, message);
|
||||
if (response == Response.Error) return (false, message);
|
||||
return (false, $"Unexpected response: {response}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Launch a game by title ID
|
||||
public async Task<(bool success, string message)> LaunchGameAsync(string titleId)
|
||||
{
|
||||
@@ -1446,6 +1702,108 @@ namespace PS5Upload
|
||||
}
|
||||
}
|
||||
|
||||
// Trophy viewer — returns every registered trophy set with its raw
|
||||
// tropconf.json / tropmeta.json payloads and the per-user TRPTITLE.DAT
|
||||
// blob (empty when the user never earned anything in that set).
|
||||
public async Task<List<PS5TrophySet>> GetTrophyListAsync()
|
||||
{
|
||||
var sets = new List<PS5TrophySet>();
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.TrophyList);
|
||||
var (response, data) = await ReceiveResponseAsync(180000);
|
||||
if (response != Response.Data || data == null || data.Length < 2)
|
||||
return sets;
|
||||
|
||||
int off = 0;
|
||||
ushort count = BitConverter.ToUInt16(data, off); off += 2;
|
||||
|
||||
string rdStr()
|
||||
{
|
||||
int l = data[off]; off++;
|
||||
string s = Encoding.UTF8.GetString(data, off, l); off += l;
|
||||
return s;
|
||||
}
|
||||
byte[] rdBlob()
|
||||
{
|
||||
uint l = BitConverter.ToUInt32(data, off); off += 4;
|
||||
var b = new byte[l];
|
||||
Array.Copy(data, off, b, 0, l); off += (int)l;
|
||||
return b;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var set = new PS5TrophySet
|
||||
{
|
||||
NpCommunicationId = rdStr(),
|
||||
TitleId = rdStr(),
|
||||
UserId = rdStr(),
|
||||
TropConfJson = rdBlob(),
|
||||
TropMetaJson = rdBlob(),
|
||||
TrpTitleData = rdBlob()
|
||||
};
|
||||
sets.Add(set);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = $"GetTrophyListAsync: {ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
|
||||
// Fetch a trophy PNG ("trop0000.png", "icon0_en-US.png", …) from a set's UCP.
|
||||
public async Task<byte[]?> GetTrophyIconAsync(string npwr, string entry)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
byte[] req = Encoding.UTF8.GetBytes($"{npwr}|{entry}");
|
||||
await SendCommandAsync(Command.TrophyIcon, req);
|
||||
var (response, data) = await ReceiveResponseAsync();
|
||||
if (response != Response.Data || data == null || data.Length == 0)
|
||||
return null;
|
||||
return data;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// Trophy unlock — routed through the trophy daemon's debug API inside
|
||||
// the RUNNING game process (the daemon binds the commId from it).
|
||||
// spec: "unlock:<id|all>" or "lock:<id>". Returns daemon diagnostics.
|
||||
public async Task<(bool ok, string msg)> TrophyUnlockAsync(string spec)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.TrophyUnlock, Encoding.UTF8.GetBytes(spec + "\0"));
|
||||
var (response, data) = await ReceiveResponseAsync(120000);
|
||||
var msg = Encoding.UTF8.GetString(data ?? Array.Empty<byte>());
|
||||
return (response == Response.Data || response == Response.Ok, msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"TrophyUnlockAsync: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_commandLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Get detailed info about a game
|
||||
public async Task<Dictionary<string, string>?> GetGameDetailsAsync(string titleId)
|
||||
{
|
||||
@@ -1594,52 +1952,45 @@ namespace PS5Upload
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
|
||||
await SendCommandAsync(Command.DeleteDir, pathBytes);
|
||||
|
||||
// Payload does NOT send initial OK - goes straight to progress messages
|
||||
// Keep reading until we get the final OK/ERROR response
|
||||
bool deletionComplete = false;
|
||||
|
||||
bool success = false;
|
||||
bool done = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (!deletionComplete)
|
||||
while (!done)
|
||||
{
|
||||
var (progressResponse, progressData) = await ReceiveResponseAsync();
|
||||
|
||||
if (progressResponse == Response.Progress)
|
||||
var (resp, respData) = await ReceiveResponseAsync();
|
||||
|
||||
if (resp == Response.Progress)
|
||||
{
|
||||
string message = Encoding.UTF8.GetString(progressData).TrimEnd('\0');
|
||||
string message = Encoding.UTF8.GetString(respData).TrimEnd('\0');
|
||||
OnProgressMessage?.Invoke(message);
|
||||
}
|
||||
else if (progressResponse == Response.Ok)
|
||||
else if (resp == Response.Ok)
|
||||
{
|
||||
// Final OK response received - deletion complete
|
||||
OnProgressMessage?.Invoke("🔚 Received final OK - deletion complete");
|
||||
deletionComplete = true;
|
||||
success = true;
|
||||
done = true;
|
||||
}
|
||||
else if (progressResponse == Response.Error)
|
||||
else if (resp == Response.Error)
|
||||
{
|
||||
// Error response received
|
||||
OnProgressMessage?.Invoke("❌ Received error response");
|
||||
deletionComplete = true;
|
||||
done = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unexpected response - log it
|
||||
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {progressResponse}");
|
||||
deletionComplete = true;
|
||||
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {resp}");
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Connection closed - log it
|
||||
OnProgressMessage?.Invoke($"⚠️ Connection closed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Give server a moment to fully close the deletion thread
|
||||
// CRITICAL: Increased delay to prevent race condition with next delete
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
return true;
|
||||
return success;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1729,17 +2080,17 @@ namespace PS5Upload
|
||||
|
||||
private async Task<bool> UploadFileCoreAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress, CancellationToken cancellationToken, long chunkOffset, long chunkSize, Action? onReadyCallback)
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(localPath);
|
||||
if (!fileInfo.Exists) return false;
|
||||
if (!LocalIo.FileExists(localPath)) return false;
|
||||
long fileLen = LocalIo.GetLength(localPath);
|
||||
|
||||
// Determine actual upload size
|
||||
long uploadSize = chunkSize > 0 ? chunkSize : fileInfo.Length;
|
||||
long uploadSize = chunkSize > 0 ? chunkSize : fileLen;
|
||||
|
||||
// Send START_UPLOAD with optional chunk offset
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(remotePath);
|
||||
byte[] startData = new byte[pathBytes.Length + 1 + 8 + 8]; // path + null + size + offset
|
||||
Array.Copy(pathBytes, 0, startData, 0, pathBytes.Length);
|
||||
BitConverter.GetBytes(fileInfo.Length).CopyTo(startData, pathBytes.Length + 1);
|
||||
BitConverter.GetBytes(fileLen).CopyTo(startData, pathBytes.Length + 1);
|
||||
BitConverter.GetBytes(chunkOffset).CopyTo(startData, pathBytes.Length + 9);
|
||||
|
||||
await SendCommandAsync(Command.StartUpload, startData);
|
||||
@@ -1771,9 +2122,10 @@ namespace PS5Upload
|
||||
|
||||
try
|
||||
{
|
||||
// Use RandomAccess for chunked uploads to avoid disk I/O contention when multiple workers read same file
|
||||
FileOptions fileOptions = chunkOffset > 0 ? FileOptions.RandomAccess : FileOptions.SequentialScan;
|
||||
using (FileStream fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, fileOptions))
|
||||
// RandomAccess for chunked uploads to avoid disk I/O contention
|
||||
// when multiple workers read the same file. UNC paths go
|
||||
// through the NAS session via a seekable SMB stream.
|
||||
using (Stream fs = LocalIo.OpenRead(localPath, randomAccess: chunkOffset > 0))
|
||||
{
|
||||
if (chunkOffset > 0)
|
||||
{
|
||||
@@ -1809,7 +2161,7 @@ namespace PS5Upload
|
||||
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(writeBuffer, 0, 5 + bytesRead, linkedCts.Token);
|
||||
await WriteSocketChunkedAsync(_stream, writeBuffer, 0, 5 + bytesRead, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (writeTimeout.IsCancellationRequested)
|
||||
{
|
||||
@@ -1837,12 +2189,12 @@ namespace PS5Upload
|
||||
progress?.Report(new UploadProgress
|
||||
{
|
||||
BytesSent = chunkOffset + totalSent,
|
||||
TotalBytes = fileInfo.Length,
|
||||
TotalBytes = fileLen,
|
||||
SpeedBytesPerSecond = currentSpeed,
|
||||
AverageSpeedBytesPerSecond = avgSpeed,
|
||||
ElapsedTime = elapsed,
|
||||
EstimatedTimeRemaining = eta,
|
||||
CurrentFileName = fileInfo.Name
|
||||
CurrentFileName = LocalIo.GetName(localPath)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2436,6 +2788,83 @@ namespace PS5Upload
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
// ================= App Manager v2 =================
|
||||
|
||||
/// <summary>pid|appid|title|comm|apptype|cpu_x100|suspended per line (+ appinfo=ok line)</summary>
|
||||
public async Task<string?> AppListV2Async()
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.AppListV2);
|
||||
var (response, data) = await ReceiveResponseAsync(30000);
|
||||
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
catch (Exception ex) { LastError = $"AppListV2Async: {ex.Message}"; return null; }
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task<(bool ok, string msg)> AppActionAsync(Command cmd, int appId, int pid = 0)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(cmd, Encoding.UTF8.GetBytes($"{appId}|{pid}"));
|
||||
var (response, data) = await ReceiveResponseAsync(30000);
|
||||
return (response == Response.Ok, Encoding.UTF8.GetString(data));
|
||||
}
|
||||
catch (Exception ex) { LastError = $"AppAction: {ex.Message}"; return (false, LastError); }
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
public Task<(bool ok, string msg)> AppSuspendAsync(int appId, int pid) => AppActionAsync(Command.AppSuspend, appId, pid);
|
||||
public Task<(bool ok, string msg)> AppResumeAsync(int appId, int pid) => AppActionAsync(Command.AppResume, appId, pid);
|
||||
public Task<(bool ok, string msg)> AppKillAsync(int appId, int pid) => AppActionAsync(Command.AppKill, appId, pid);
|
||||
public Task<(bool ok, string msg)> AppCoredumpAsync(int appId) => AppActionAsync(Command.AppCoredump, appId);
|
||||
|
||||
/// <summary>
|
||||
/// Network info: key=value lines plus per-interface byte counters
|
||||
/// ("if=name|rx=N|tx=N"). Returns the raw text for the caller to parse.
|
||||
/// </summary>
|
||||
public async Task<string?> GetNetInfoAsync()
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.NetInfo);
|
||||
var (response, data) = await ReceiveResponseAsync();
|
||||
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
catch (Exception ex) { LastError = $"GetNetInfoAsync: {ex.Message}"; return null; }
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LAN link speed test: payload streams 16MB; returns receive throughput in Mbps.
|
||||
/// </summary>
|
||||
public async Task<double?> NetSpeedTestAsync()
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await SendCommandAsync(Command.NetSpeedTest);
|
||||
var (response, data) = await ReceiveResponseAsync();
|
||||
sw.Stop();
|
||||
if (response != Response.Data || data.Length == 0)
|
||||
{
|
||||
LastError = data.Length > 0 ? Encoding.UTF8.GetString(data) : "no data";
|
||||
return null;
|
||||
}
|
||||
double mbps = data.Length * 8.0 / sw.Elapsed.TotalSeconds / 1_000_000.0;
|
||||
return mbps;
|
||||
}
|
||||
catch (Exception ex) { LastError = $"NetSpeedTestAsync: {ex.Message}"; return null; }
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the kernel message buffer (kern.msgbuf). tailBytes > 0 limits to the tail.
|
||||
/// </summary>
|
||||
@@ -3159,6 +3588,25 @@ namespace PS5Upload
|
||||
public string Path { get; set; } = "";
|
||||
}
|
||||
|
||||
public class PS5UsbDrive
|
||||
{
|
||||
public string MountPath { get; set; } = "";
|
||||
public string FsType { get; set; } = "";
|
||||
public string Device { get; set; } = "";
|
||||
public ulong TotalBytes { get; set; }
|
||||
public ulong FreeBytes { get; set; }
|
||||
public string TotalGB => FormatBytes(TotalBytes);
|
||||
public string FreeGB => FormatBytes(FreeBytes);
|
||||
|
||||
private static string FormatBytes(ulong bytes)
|
||||
{
|
||||
double b = bytes;
|
||||
if (b >= 1000.0 * 1000 * 1000) return $"{b / (1000.0 * 1000 * 1000):F1} GB";
|
||||
if (b >= 1000.0 * 1000) return $"{b / (1000.0 * 1000):F1} MB";
|
||||
return $"{b:F0} B";
|
||||
}
|
||||
}
|
||||
|
||||
// Save game info
|
||||
public class PS5SaveGame : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
@@ -3295,4 +3743,101 @@ namespace PS5Upload
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
// ============ Trophy viewer (NpTrophy V2) ============
|
||||
|
||||
// One registered trophy set: raw UCP payloads + per-user TRPTITLE.DAT.
|
||||
public class PS5TrophySet : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public string NpCommunicationId { get; set; } = "";
|
||||
public string TitleId { get; set; } = ""; // empty when npbind map misses
|
||||
public string UserId { get; set; } = ""; // uid that owns TRPTITLE.DAT
|
||||
public byte[] TropConfJson { get; set; } = Array.Empty<byte>();
|
||||
public byte[] TropMetaJson { get; set; } = Array.Empty<byte>();
|
||||
public byte[] TrpTitleData { get; set; } = Array.Empty<byte>();
|
||||
|
||||
public List<PS5Trophy> Trophies { get; } = new();
|
||||
|
||||
// Parsed from TRPTITLE.DAT (client-side T2PD parse). StateKnown=false
|
||||
// → show honest "—"; UnlockMask bit i = trophy id i (LSB-first).
|
||||
public byte[] UnlockMask { get; set; } = Array.Empty<byte>();
|
||||
public bool StateKnown { get; set; }
|
||||
public int EarnedFallback { get; set; } = -1; // count known, flags unknown
|
||||
|
||||
private string _gameName = "";
|
||||
public string GameName
|
||||
{
|
||||
get => _gameName;
|
||||
set { _gameName = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(GameName))); }
|
||||
}
|
||||
|
||||
private Avalonia.Media.IImage? _icon;
|
||||
public Avalonia.Media.IImage? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
|
||||
}
|
||||
|
||||
public int EarnedCount => StateKnown
|
||||
? Trophies.Count(t => t.IsUnlocked)
|
||||
: EarnedFallback >= 0 ? EarnedFallback : Trophies.Count(t => t.IsUnlocked);
|
||||
public int TotalCount => Trophies.Count;
|
||||
public string UserIdDisplay => string.IsNullOrEmpty(UserId) ? "" : $"👤 {UserId}";
|
||||
public string ProgressDisplay => TotalCount == 0
|
||||
? "—"
|
||||
: (StateKnown || EarnedFallback >= 0)
|
||||
? $"{EarnedCount}/{TotalCount}"
|
||||
: $"—/{TotalCount}";
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
// One trophy definition merged with (optional) per-user unlock state.
|
||||
public class PS5Trophy : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public int Id { get; set; } // index → icon "trop%04d.png"
|
||||
public string Name { get; set; } = "";
|
||||
public string Detail { get; set; } = "";
|
||||
public string Grade { get; set; } = ""; // B/S/G/P
|
||||
public bool Hidden { get; set; }
|
||||
public string GroupId { get; set; } = ""; // "default" or dlc group
|
||||
public bool IsUnlocked { get; set; }
|
||||
public bool StateKnown { get; set; } // false until TRPTITLE.DAT format is parsed
|
||||
public DateTime? UnlockedTime { get; set; }
|
||||
|
||||
public string GradeDisplay => Grade switch
|
||||
{
|
||||
"P" => "Platinum",
|
||||
"G" => "Gold",
|
||||
"S" => "Silver",
|
||||
"B" => "Bronze",
|
||||
_ => "?"
|
||||
};
|
||||
public Avalonia.Media.IBrush GradeBrush => Grade switch
|
||||
{
|
||||
"P" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#7FD4FF")),
|
||||
"G" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#FFD24A")),
|
||||
"S" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#C0C0C0")),
|
||||
"B" => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#CD7F32")),
|
||||
_ => new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#6C757D"))
|
||||
};
|
||||
public string StateDisplay => !StateKnown
|
||||
? "—"
|
||||
: IsUnlocked
|
||||
? (UnlockedTime?.ToString("yyyy-MM-dd HH:mm") ?? "Unlocked")
|
||||
: "Locked";
|
||||
public string HiddenDisplay => Hidden ? "🙈" : "";
|
||||
public string GroupDisplay => string.IsNullOrEmpty(GroupId) || GroupId == "default" ? "Base" : GroupId;
|
||||
public bool CanUnlock => StateKnown && !IsUnlocked && Grade != "P";
|
||||
public bool CanLock => StateKnown && IsUnlocked && Grade != "P";
|
||||
|
||||
private Avalonia.Media.IImage? _icon;
|
||||
public Avalonia.Media.IImage? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set { _icon = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Icon))); }
|
||||
}
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds standalone single-file executables of PS5Suite for all major platforms.
|
||||
|
||||
.DESCRIPTION
|
||||
Produces self-contained, single-file binaries (no .NET runtime required on target machine) for:
|
||||
- Windows x64 -> dist/win-x64/PS5Suite.exe
|
||||
- Linux x64 -> dist/linux-x64/PS5Suite
|
||||
- Linux ARM64 -> dist/linux-arm64/PS5Suite
|
||||
- macOS x64 (Intel) -> dist/osx-x64/PS5Suite
|
||||
- macOS ARM64 (M1+) -> dist/osx-arm64/PS5Suite
|
||||
|
||||
.PARAMETER Targets
|
||||
Optional list of RIDs to build. Defaults to all 5.
|
||||
Example: .\publish.ps1 -Targets win-x64,linux-x64
|
||||
|
||||
.PARAMETER Clean
|
||||
Wipe the dist/ folder before building.
|
||||
|
||||
.EXAMPLE
|
||||
.\publish.ps1
|
||||
.\publish.ps1 -Targets win-x64
|
||||
.\publish.ps1 -Clean
|
||||
#>
|
||||
|
||||
param(
|
||||
[string[]] $Targets = @('win-x64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'),
|
||||
[switch] $Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Project = Join-Path $ProjectRoot 'PS5Suite.csproj'
|
||||
$DistRoot = Join-Path $ProjectRoot 'dist'
|
||||
|
||||
if (-not (Test-Path $Project)) {
|
||||
Write-Error "Project file not found: $Project"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Clean -and (Test-Path $DistRoot)) {
|
||||
Write-Host "Cleaning $DistRoot ..." -ForegroundColor Yellow
|
||||
Remove-Item $DistRoot -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DistRoot -Force | Out-Null
|
||||
|
||||
$summary = @()
|
||||
|
||||
foreach ($rid in $Targets) {
|
||||
$outDir = Join-Path $DistRoot $rid
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Publishing $rid" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
|
||||
$args = @(
|
||||
'publish', $Project,
|
||||
'-c', 'Release',
|
||||
'-r', $rid,
|
||||
'--self-contained', 'true',
|
||||
'-p:PublishSingleFile=true',
|
||||
'-p:IncludeNativeLibrariesForSelfExtract=true',
|
||||
'-p:EnableCompressionInSingleFile=true',
|
||||
'-p:DebugType=None',
|
||||
'-p:DebugSymbols=false',
|
||||
'-o', $outDir
|
||||
)
|
||||
|
||||
& dotnet @args
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Publish failed for $rid"
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'FAILED'; Path = '-'; SizeMB = '-' }
|
||||
continue
|
||||
}
|
||||
|
||||
# Locate the produced binary
|
||||
$exeName = if ($rid -like 'win-*') { 'PS5Suite.exe' } else { 'PS5Suite' }
|
||||
$exePath = Join-Path $outDir $exeName
|
||||
|
||||
if (Test-Path $exePath) {
|
||||
$sizeMB = [math]::Round((Get-Item $exePath).Length / 1MB, 2)
|
||||
Write-Host (" -> {0} ({1} MB)" -f $exePath, $sizeMB) -ForegroundColor Green
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'OK'; Path = $exePath; SizeMB = $sizeMB }
|
||||
} else {
|
||||
Write-Warning "Binary not found at expected path: $exePath"
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'NO BINARY'; Path = $outDir; SizeMB = '-' }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Build summary" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
$summary | Format-Table -AutoSize
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Notes:" -ForegroundColor Yellow
|
||||
Write-Host " - Linux/macOS users may need: chmod +x ./PS5Suite"
|
||||
Write-Host " - macOS may also need: xattr -dr com.apple.quarantine ./PS5Suite"
|
||||
Write-Host " - All builds are self-contained; no .NET runtime needed on the target system."
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds standalone single-file executables of PS5Suite for all major platforms.
|
||||
|
||||
.DESCRIPTION
|
||||
Produces self-contained, single-file binaries (no .NET runtime required on target machine) for:
|
||||
- Windows x64 -> dist/win-x64/PS5Suite.exe
|
||||
- Linux x64 -> dist/linux-x64/PS5Suite
|
||||
- Linux ARM64 -> dist/linux-arm64/PS5Suite
|
||||
- macOS x64 (Intel) -> dist/osx-x64/PS5Suite
|
||||
- macOS ARM64 (M1+) -> dist/osx-arm64/PS5Suite
|
||||
|
||||
.PARAMETER Targets
|
||||
Optional list of RIDs to build. Defaults to all 5.
|
||||
Example: .\publish.ps1 -Targets win-x64,linux-x64
|
||||
|
||||
.PARAMETER Clean
|
||||
Wipe the dist/ folder before building.
|
||||
|
||||
.EXAMPLE
|
||||
.\publish.ps1
|
||||
.\publish.ps1 -Targets win-x64
|
||||
.\publish.ps1 -Clean
|
||||
#>
|
||||
|
||||
param(
|
||||
[string[]] $Targets = @('win-x64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'),
|
||||
[switch] $Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Project = Join-Path $ProjectRoot 'PS5Suite.csproj'
|
||||
$DistRoot = Join-Path $ProjectRoot 'dist'
|
||||
|
||||
if (-not (Test-Path $Project)) {
|
||||
Write-Error "Project file not found: $Project"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Clean -and (Test-Path $DistRoot)) {
|
||||
Write-Host "Cleaning $DistRoot ..." -ForegroundColor Yellow
|
||||
Remove-Item $DistRoot -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DistRoot -Force | Out-Null
|
||||
|
||||
$summary = @()
|
||||
|
||||
foreach ($rid in $Targets) {
|
||||
$outDir = Join-Path $DistRoot $rid
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Publishing $rid" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
|
||||
$args = @(
|
||||
'publish', $Project,
|
||||
'-c', 'Release',
|
||||
'-r', $rid,
|
||||
'--self-contained', 'true',
|
||||
'-p:PublishSingleFile=true',
|
||||
'-p:IncludeNativeLibrariesForSelfExtract=true',
|
||||
'-p:EnableCompressionInSingleFile=true',
|
||||
'-p:DebugType=None',
|
||||
'-p:DebugSymbols=false',
|
||||
'-o', $outDir
|
||||
)
|
||||
|
||||
& dotnet @args
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Publish failed for $rid"
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'FAILED'; Path = '-'; SizeMB = '-' }
|
||||
continue
|
||||
}
|
||||
|
||||
# Locate the produced binary
|
||||
$exeName = if ($rid -like 'win-*') { 'PS5Suite.exe' } else { 'PS5Suite' }
|
||||
$exePath = Join-Path $outDir $exeName
|
||||
|
||||
if (Test-Path $exePath) {
|
||||
$sizeMB = [math]::Round((Get-Item $exePath).Length / 1MB, 2)
|
||||
Write-Host (" -> {0} ({1} MB)" -f $exePath, $sizeMB) -ForegroundColor Green
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'OK'; Path = $exePath; SizeMB = $sizeMB }
|
||||
} else {
|
||||
Write-Warning "Binary not found at expected path: $exePath"
|
||||
$summary += [pscustomobject]@{ RID = $rid; Status = 'NO BINARY'; Path = $outDir; SizeMB = '-' }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Build summary" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
$summary | Format-Table -AutoSize
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Notes:" -ForegroundColor Yellow
|
||||
Write-Host " - Linux/macOS users may need: chmod +x ./PS5Suite"
|
||||
Write-Host " - macOS may also need: xattr -dr com.apple.quarantine ./PS5Suite"
|
||||
Write-Host " - All builds are self-contained; no .NET runtime needed on the target system."
|
||||
@@ -1,68 +0,0 @@
|
||||
lpp_version
|
||||
lpp_abi_version
|
||||
lpp_keys_available
|
||||
lpp_last_error
|
||||
lpp_is_valid_content_id
|
||||
lpp_is_valid_title_id
|
||||
lpp_compose_content_id
|
||||
lpp_build_package
|
||||
lpp_build_package_ex
|
||||
lpp_detect_package_type
|
||||
lpp_build_inner_image
|
||||
lpp_encrypt_pfs_image
|
||||
lpp_pack_pfs_image
|
||||
lpp_unpack_pfs_image
|
||||
lpp_is_self
|
||||
lpp_is_elf
|
||||
lpp_is_ucp
|
||||
lpp_read_self_info
|
||||
lpp_make_fself
|
||||
lpp_make_fself_ex
|
||||
lpp_make_fself_file
|
||||
lpp_fake_sign_folder
|
||||
lpp_application_type_name
|
||||
lpp_application_drm_type
|
||||
lpp_parse_application_type
|
||||
lpp_read_auth_info
|
||||
lpp_write_auth_info
|
||||
lpp_read_npdrm_content_info
|
||||
lpp_inspect_package
|
||||
lpp_extract_package
|
||||
lpp_extract_package_ekpfs
|
||||
lpp_rif_record_count
|
||||
lpp_read_rif_content_id
|
||||
lpp_read_rif_summary
|
||||
lpp_validate_package
|
||||
lpp_validate_package_report
|
||||
lpp_disc_backup_reassemble
|
||||
lpp_disc_backup_verify
|
||||
lpp_convert_backup
|
||||
lpp_convert_backup_ex
|
||||
lpp_read_package_summary
|
||||
lpp_package_entry_count
|
||||
lpp_read_package_entry
|
||||
lpp_list_package_files
|
||||
lpp_list_package_files_ekpfs
|
||||
lpp_compare_containers
|
||||
lpp_merge_split_package_dir
|
||||
lpp_package_homebrew
|
||||
lpp_inspect_launch_readiness
|
||||
lpp_launch_readiness_issues
|
||||
lpp_build_pfs_layout
|
||||
lpp_pfs_image_is_encrypted
|
||||
lpp_decrypt_pfs_image
|
||||
lpp_read_elf_header
|
||||
lpp_normalize_elf_module
|
||||
lpp_ucp_validate_file
|
||||
lpp_ucp_verify_digest_file
|
||||
lpp_ucp_build_from_directory
|
||||
lpp_ucp_repair_digest_file
|
||||
lpp_rif_create
|
||||
lpp_derive_image_key
|
||||
lpp_entitlement_key_validate
|
||||
lpp_disc_backup_content_info
|
||||
lpp_disc_backup_verify_chunk_crcs
|
||||
lpp_encode_png_to_dds
|
||||
lpp_build_playgo_chunk_dat
|
||||
lpp_create_param_json
|
||||
lpp_create_gp5
|
||||
@@ -1,767 +0,0 @@
|
||||
/*
|
||||
* libprosperopkg.h - C interface for the LibProsperoPkg shared library.
|
||||
*
|
||||
* The shared library (libprosperopkg.so / libprosperopkg.dylib) is produced by the build
|
||||
* workflows. All strings are UTF-8 and NUL-terminated. Output strings are written into
|
||||
* caller-provided buffers; the library allocates no memory the caller must free.
|
||||
*
|
||||
* String-output functions return the number of bytes written (excluding the terminator) on
|
||||
* success. When the buffer is too small they return the negative of the required size
|
||||
* (including the terminator), so a caller can size a buffer and retry. Status functions return
|
||||
* 0 on success and a negative value on failure; call lpp_last_error for a description. Detection
|
||||
* predicates (lpp_is_*) return 1 or 0 and never fail. Struct-output functions fill a
|
||||
* caller-provided struct (fixed char arrays inside are UTF-8 and NUL-terminated) and return 0 on
|
||||
* success or a negative value on failure.
|
||||
*/
|
||||
|
||||
#ifndef LIBPROSPEROPKG_H
|
||||
#define LIBPROSPEROPKG_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ABI version of the exported surface (see lpp_abi_version). Bumped when exports change. */
|
||||
#define LPP_ABI_VERSION 7
|
||||
|
||||
/* Build mode (build `mode`). */
|
||||
#define LPP_MODE_APPLICATION 0
|
||||
#define LPP_MODE_HOMEBREW 1
|
||||
#define LPP_MODE_ADDITIONAL_CONTENT_DATA 2
|
||||
#define LPP_MODE_ADDITIONAL_CONTENT_NO_DATA 3
|
||||
|
||||
/* Output container format (build `output_format`). */
|
||||
#define LPP_OUTPUT_METADATA_CONTAINER 0
|
||||
#define LPP_OUTPUT_DEBUG_IMAGE 1
|
||||
|
||||
/* Inner-image codec (build `inner_compression`). */
|
||||
#define LPP_INNER_NONE 0
|
||||
#define LPP_INNER_ZLIB 1
|
||||
#define LPP_INNER_KRAKEN 2
|
||||
/* Installable data-first inner image: raw-concatenated per-file payloads described by a generated
|
||||
* naps_pkg_layout.dat. This is the recommended codec for installable homebrew packages. */
|
||||
#define LPP_INNER_NWONLY_DATA_FIRST 3
|
||||
|
||||
/* Inner-image form (lpp_build_inner_image `form`). */
|
||||
#define LPP_FORM_PLAINTEXT 0
|
||||
#define LPP_FORM_ENCRYPTED 1
|
||||
#define LPP_FORM_COMPRESSED 2
|
||||
#define LPP_FORM_KRAKEN_COMPRESSED 3
|
||||
|
||||
/* Package type (lpp_detect_package_type return value). */
|
||||
#define LPP_TYPE_META 0
|
||||
#define LPP_TYPE_FULL_RETAIL 1
|
||||
#define LPP_TYPE_FULL_DEBUG 2
|
||||
|
||||
/* Application type (build `application_type`, and the app-type helper functions). */
|
||||
#define LPP_APP_TYPE_NOT_SPECIFIED 0
|
||||
#define LPP_APP_TYPE_PAID_STANDALONE_FULL 1
|
||||
#define LPP_APP_TYPE_UPGRADABLE 2
|
||||
#define LPP_APP_TYPE_DEMO 3
|
||||
#define LPP_APP_TYPE_FREEMIUM 4
|
||||
|
||||
/* Patch kind (lpp_npdrm_content_info.patch_kind). */
|
||||
#define LPP_PATCH_NONE 0
|
||||
#define LPP_PATCH_FIRST 1
|
||||
#define LPP_PATCH_SUBSEQUENT 2
|
||||
#define LPP_PATCH_DELTA 3
|
||||
#define LPP_PATCH_CUMULATIVE 4
|
||||
|
||||
/* Authentication-info authority category (lpp_read_auth_info `category`). */
|
||||
#define LPP_AUTH_CATEGORY_UNKNOWN 0x00
|
||||
#define LPP_AUTH_CATEGORY_FAKE 0x31
|
||||
#define LPP_AUTH_CATEGORY_GENUINE 0x45
|
||||
#define LPP_AUTH_CATEGORY_PRIVILEGED 0x48
|
||||
|
||||
/* param.json DRM type (lpp_create_param_json `drm_type`). */
|
||||
#define LPP_PARAM_DRM_STANDARD 0
|
||||
#define LPP_PARAM_DRM_FREE 1
|
||||
#define LPP_PARAM_DRM_FREEMIUM 2
|
||||
|
||||
/* GP5 volume type (lpp_create_gp5 `volume_type`). */
|
||||
#define LPP_GP5_VOLUME_APP 0
|
||||
#define LPP_GP5_VOLUME_PATCH 1
|
||||
#define LPP_GP5_VOLUME_AC 2
|
||||
#define LPP_GP5_VOLUME_AC_NODATA 3
|
||||
|
||||
/* ELF machine (lpp_elf_info.machine). */
|
||||
#define LPP_ELF_MACHINE_X86_64 0x3E
|
||||
#define LPP_ELF_MACHINE_AARCH64 0xB7
|
||||
|
||||
/* Package entry id (lpp_package_entry.id). */
|
||||
#define LPP_ENTRY_UNKNOWN 0x0000
|
||||
#define LPP_ENTRY_DIGESTS 0x0001
|
||||
#define LPP_ENTRY_ENTRY_KEYS 0x0010
|
||||
#define LPP_ENTRY_IMAGE_KEY 0x0020
|
||||
#define LPP_ENTRY_GENERAL_DIGESTS 0x0080
|
||||
#define LPP_ENTRY_METAS 0x0100
|
||||
#define LPP_ENTRY_ENTRY_NAMES 0x0200
|
||||
#define LPP_ENTRY_LICENSE_DAT 0x0400
|
||||
#define LPP_ENTRY_LICENSE_INFO 0x0401
|
||||
#define LPP_ENTRY_PARAM_JSON 0x1000
|
||||
#define LPP_ENTRY_PARAM_SFO 0x1001
|
||||
#define LPP_ENTRY_ICON0_PNG 0x1200
|
||||
#define LPP_ENTRY_PIC0_PNG 0x1220
|
||||
#define LPP_ENTRY_SND0_AT9 0x1240
|
||||
#define LPP_ENTRY_ICON0_DDS 0x1280
|
||||
#define LPP_ENTRY_PIC0_DDS 0x12A0
|
||||
#define LPP_ENTRY_PIC1_DDS 0x12C0
|
||||
#define LPP_ENTRY_PLAYGO_CHUNK_DAT 0x1300
|
||||
#define LPP_ENTRY_PLAYGO_CHUNK_SHA 0x1301
|
||||
#define LPP_ENTRY_PLAYGO_MANIFEST_XML 0x1302
|
||||
#define LPP_ENTRY_PIC2_DDS 0x2060
|
||||
|
||||
/*
|
||||
* Full option set for lpp_build_package_ex. Zero-initialize the whole struct, set struct_size to
|
||||
* sizeof(lpp_build_options), then fill the fields you need. Any string pointer may be NULL; a NULL
|
||||
* or empty `passcode` uses the 32-zero default, and a NULL or empty `version` uses "01.00". Set
|
||||
* `content_badge_type` to a negative value to omit it. Set `has_authority_id` to 1 to apply
|
||||
* `authority_id`; otherwise it is derived from the ELF during fake-signing. Set `license_free`
|
||||
* to 1 to produce a DRM/license-free debug package (fake-signs modules and forces the free DRM
|
||||
* bucket). `license_free` is an appended field: a smaller `struct_size` leaves it disabled.
|
||||
*/
|
||||
typedef struct lpp_build_options {
|
||||
int32_t struct_size; /* sizeof(lpp_build_options) */
|
||||
int32_t mode; /* LPP_MODE_* */
|
||||
int32_t output_format; /* LPP_OUTPUT_* */
|
||||
int32_t inner_compression; /* LPP_INNER_* */
|
||||
int32_t application_type; /* LPP_APP_TYPE_* */
|
||||
int32_t content_badge_type; /* < 0 to omit */
|
||||
int32_t generate_param_json; /* 0/1 (generate param.json when the source lacks one) */
|
||||
int32_t compress_inner_image; /* 0/1 (zlib PFSC inner image) */
|
||||
int32_t fake_sign_self; /* 0/1 (fake-sign raw ELF modules before packing) */
|
||||
int32_t has_authority_id; /* 0/1 (apply authority_id below) */
|
||||
|
||||
uint64_t app_version; /* fake-self application version */
|
||||
uint64_t firmware_version; /* fake-self firmware version */
|
||||
uint64_t authority_id; /* fake-self authority-id override (see has_authority_id) */
|
||||
|
||||
const char* source_folder;
|
||||
const char* output_folder;
|
||||
const char* content_id;
|
||||
const char* passcode; /* NULL/empty -> 32 zeroes */
|
||||
const char* title;
|
||||
const char* title_id;
|
||||
const char* version; /* NULL/empty -> "01.00" */
|
||||
const char* application_drm_type; /* NULL -> derived from application_type */
|
||||
|
||||
int32_t license_free; /* 0/1 (DRM/license-free debug package: fake-sign + free bucket) */
|
||||
} lpp_build_options;
|
||||
|
||||
/*
|
||||
* Projected NpDrm content-info filled by lpp_read_npdrm_content_info. Zero-initialize before the
|
||||
* call; on return content_id and title_id are UTF-8, NUL-terminated.
|
||||
*/
|
||||
typedef struct lpp_npdrm_content_info {
|
||||
int32_t struct_size; /* sizeof(lpp_npdrm_content_info) */
|
||||
uint32_t drm_type;
|
||||
uint32_t content_type;
|
||||
uint32_t content_flags;
|
||||
int32_t patch_kind; /* LPP_PATCH_* */
|
||||
int32_t is_patch; /* 0/1 */
|
||||
int32_t is_nested; /* 0/1 */
|
||||
int32_t is_finalized; /* 0/1 */
|
||||
int64_t container_offset;
|
||||
char content_id[64];
|
||||
char title_id[16];
|
||||
} lpp_npdrm_content_info;
|
||||
|
||||
/*
|
||||
* Package inspection result filled by lpp_inspect_package. Zero-initialize before the call; on
|
||||
* return content_id is UTF-8, NUL-terminated (empty when the package carries no content id).
|
||||
*/
|
||||
typedef struct lpp_package_info {
|
||||
int32_t struct_size; /* sizeof(lpp_package_info) */
|
||||
int32_t package_type; /* LPP_TYPE_* */
|
||||
int32_t is_retail; /* 0/1 */
|
||||
int32_t outer_encrypted; /* 0/1 */
|
||||
int32_t requires_key; /* 0/1 (a supplied key is required to extract) */
|
||||
int32_t reserved;
|
||||
int64_t pfs_image_offset;
|
||||
int64_t pfs_image_size;
|
||||
char content_id[64];
|
||||
} lpp_package_info;
|
||||
|
||||
/*
|
||||
* Multi-content RIF summary filled by lpp_read_rif_summary. Zero-initialize before the call; on
|
||||
* return app_content_id and service_id are UTF-8, NUL-terminated.
|
||||
*/
|
||||
typedef struct lpp_rif_summary {
|
||||
int32_t struct_size; /* sizeof(lpp_rif_summary) */
|
||||
int32_t record_count; /* n_rif */
|
||||
int32_t has_app; /* 0/1 */
|
||||
int32_t additional_count; /* n_ac */
|
||||
int64_t expected_size;
|
||||
int64_t actual_size;
|
||||
char app_content_id[64];
|
||||
char service_id[16];
|
||||
} lpp_rif_summary;
|
||||
|
||||
/*
|
||||
* Package header/image-header summary filled by lpp_read_package_summary. Zero-initialize before
|
||||
* the call; on return content_id is UTF-8, NUL-terminated.
|
||||
*/
|
||||
typedef struct lpp_package_summary {
|
||||
int32_t struct_size; /* sizeof(lpp_package_summary) */
|
||||
int32_t package_type; /* LPP_TYPE_* */
|
||||
int32_t is_official; /* 0/1 */
|
||||
int32_t fih_format_version;
|
||||
uint32_t flags;
|
||||
uint32_t entry_count;
|
||||
uint32_t sc_entry_count;
|
||||
uint32_t drm_type;
|
||||
uint32_t content_type;
|
||||
uint32_t content_flags;
|
||||
int64_t pfs_image_offset;
|
||||
int64_t pfs_image_size;
|
||||
int64_t embedded_cnt_offset;
|
||||
char content_id[64];
|
||||
} lpp_package_summary;
|
||||
|
||||
/*
|
||||
* Single package entry filled by lpp_read_package_entry. Zero-initialize before the call; on
|
||||
* return name is UTF-8, NUL-terminated (empty when the entry is unnamed).
|
||||
*/
|
||||
typedef struct lpp_package_entry {
|
||||
int32_t struct_size; /* sizeof(lpp_package_entry) */
|
||||
uint32_t raw_id;
|
||||
int32_t id; /* LPP_ENTRY_* */
|
||||
uint32_t flags1;
|
||||
uint32_t flags2;
|
||||
uint32_t data_offset;
|
||||
uint32_t data_size;
|
||||
int32_t encrypted; /* 0/1 */
|
||||
uint32_t key_index;
|
||||
char name[64];
|
||||
} lpp_package_entry;
|
||||
|
||||
/*
|
||||
* ELF header fields filled by lpp_read_elf_header. Zero-initialize before the call.
|
||||
*/
|
||||
typedef struct lpp_elf_info {
|
||||
int32_t struct_size; /* sizeof(lpp_elf_info) */
|
||||
int32_t elf_class;
|
||||
int32_t data;
|
||||
int32_t os_abi;
|
||||
int32_t abi_version;
|
||||
int32_t type;
|
||||
int32_t machine; /* LPP_ELF_MACHINE_* */
|
||||
uint64_t entry;
|
||||
uint32_t flags;
|
||||
int32_t program_header_count;
|
||||
int32_t is_executable; /* 0/1 */
|
||||
int32_t is_dynamic; /* 0/1 */
|
||||
int32_t is_module_type; /* 0/1 */
|
||||
int32_t is_module_ready; /* 0/1 */
|
||||
} lpp_elf_info;
|
||||
|
||||
/*
|
||||
* Launch-readiness summary filled by lpp_inspect_launch_readiness and, optionally, by
|
||||
* lpp_package_homebrew. Zero-initialize before the call.
|
||||
*/
|
||||
typedef struct lpp_launch_readiness {
|
||||
int32_t struct_size; /* sizeof(lpp_launch_readiness) */
|
||||
int32_t has_eboot; /* 0/1 */
|
||||
int32_t has_param_json; /* 0/1 */
|
||||
int32_t has_param_sfo; /* 0/1 */
|
||||
int32_t requires_debug_console; /* 0/1 */
|
||||
int32_t is_launch_ready; /* 0/1 */
|
||||
int32_t module_count;
|
||||
int32_t issue_count;
|
||||
} lpp_launch_readiness;
|
||||
|
||||
/* Returns a pointer to a static, NUL-terminated version string. */
|
||||
const char* lpp_version(void);
|
||||
|
||||
/* Returns the numeric ABI version of this library (compare against LPP_ABI_VERSION). */
|
||||
int lpp_abi_version(void);
|
||||
|
||||
/*
|
||||
* Returns 1 when the wired-in publishing key material is present (the build path can sign the
|
||||
* package), or 0 when it is absent and signing is skipped. Never fails.
|
||||
*/
|
||||
int lpp_keys_available(void);
|
||||
|
||||
/* Copies the current thread's most recent error message into `buffer`. */
|
||||
int lpp_last_error(char* buffer, int capacity);
|
||||
|
||||
/* Returns 1 when `content_id` is a valid 36-character content id, otherwise 0. */
|
||||
int lpp_is_valid_content_id(const char* content_id);
|
||||
|
||||
/* Returns 1 when `title_id` looks like a PPSAxxxxx title id, otherwise 0. */
|
||||
int lpp_is_valid_title_id(const char* title_id);
|
||||
|
||||
/*
|
||||
* Composes a 36-character content id from a publisher prefix, a title id and a label.
|
||||
* Any argument may be NULL to accept the default for that field. Writes the result into
|
||||
* `out_buffer` as UTF-8.
|
||||
*/
|
||||
int lpp_compose_content_id(const char* publisher, const char* title_id, const char* label,
|
||||
char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Builds a package from a prepared source folder. `passcode` and `version` may be NULL/empty
|
||||
* to accept their defaults (a 32-zero passcode and "01.00"). On success the output path is
|
||||
* written to `out_path` and the function returns 0. On failure it returns a negative value;
|
||||
* call lpp_last_error for a description.
|
||||
*/
|
||||
int lpp_build_package(const char* source_folder,
|
||||
const char* output_folder,
|
||||
const char* content_id,
|
||||
const char* passcode,
|
||||
const char* title,
|
||||
const char* title_id,
|
||||
const char* version,
|
||||
int mode,
|
||||
int output_format,
|
||||
int inner_compression,
|
||||
char* out_path,
|
||||
int out_path_capacity);
|
||||
|
||||
/*
|
||||
* Builds a package using the full option set in `options` (application type, fake-signing,
|
||||
* param.json generation, inner compression, badge and DRM overrides). Zero-initialize the struct
|
||||
* and set struct_size = sizeof(lpp_build_options). On success writes the output path to `out_path`
|
||||
* and returns 0; on failure returns a negative value (call lpp_last_error).
|
||||
*/
|
||||
int lpp_build_package_ex(const lpp_build_options* options,
|
||||
char* out_path, int out_path_capacity);
|
||||
|
||||
/*
|
||||
* Detects the package type of the file at `path`. Returns the package type (LPP_TYPE_*), or -1
|
||||
* when the file is not a recognized package or cannot be read (call lpp_last_error).
|
||||
*/
|
||||
int lpp_detect_package_type(const char* path);
|
||||
|
||||
/*
|
||||
* Lays a prepared folder out into an inner-PFS image. `form` selects the image form (LPP_FORM_*).
|
||||
* `passcode` may be NULL/empty to accept the 32-zero default. The written image path is copied
|
||||
* into `out_path`. Returns 0 on success; a negative value on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_build_inner_image(const char* source_folder,
|
||||
const char* output_path,
|
||||
const char* content_id,
|
||||
const char* passcode,
|
||||
int form,
|
||||
char* out_path,
|
||||
int out_path_capacity);
|
||||
|
||||
/*
|
||||
* AES-XTS-encrypts a plaintext inner-PFS image in place, using keys derived from the content id
|
||||
* and passcode. `passcode` may be NULL/empty to accept the 32-zero default. Returns 0 on success;
|
||||
* a negative value on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_encrypt_pfs_image(const char* pfs_image_path, const char* content_id, const char* passcode);
|
||||
|
||||
/*
|
||||
* Packs a plaintext PFS image into a PFSv3 PFSC (Kraken) container. A non-positive `level` or
|
||||
* `block_size` selects the default (7 / 262144). Returns 0 on success; a negative value on
|
||||
* failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_pack_pfs_image(const char* input_image_path, const char* output_path,
|
||||
int level, int block_size);
|
||||
|
||||
/*
|
||||
* Unpacks a PFSv3 PFSC container back into a plaintext PFS image. Returns the number of bytes
|
||||
* written on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_unpack_pfs_image(const char* input_path, const char* output_path);
|
||||
|
||||
/* Returns 1 when `data` (`length` bytes) holds a SELF container, otherwise 0. */
|
||||
int lpp_is_self(const unsigned char* data, int length);
|
||||
|
||||
/* Returns 1 when `data` (`length` bytes) holds a 64-bit ELF, otherwise 0. */
|
||||
int lpp_is_elf(const unsigned char* data, int length);
|
||||
|
||||
/* Returns 1 when `data` (`length` bytes) holds a UCP archive, otherwise 0. */
|
||||
int lpp_is_ucp(const unsigned char* data, int length);
|
||||
|
||||
/*
|
||||
* Reads the SELF extended-info and segment count from an in-memory SELF module. Any output
|
||||
* pointer may be NULL to skip that field; `digest32` must point to 32 bytes when non-NULL. When
|
||||
* the module carries no extended-info block the ext-info outputs are zero-filled and the call
|
||||
* still returns 0. Returns -1 when the buffer is not a valid SELF (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_self_info(const unsigned char* data, int length,
|
||||
uint64_t* authority_id, uint64_t* program_type,
|
||||
uint64_t* app_version, uint64_t* firmware_version,
|
||||
unsigned char* digest32, int* segment_count);
|
||||
|
||||
/*
|
||||
* Generates a fake-self from a 64-bit ELF. Pass out_buffer=NULL or capacity=0 to query the
|
||||
* required size (returned positive, nothing written). On success returns the number of bytes
|
||||
* written. Returns -1 and sets lpp_last_error on failure or when a non-zero buffer is too small.
|
||||
*/
|
||||
int lpp_make_fself(const unsigned char* elf, int elf_length,
|
||||
unsigned char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Like lpp_make_fself, with explicit fake-self options: `app_version` and `firmware_version` are
|
||||
* written to the extended info, and `authority_id` overrides the derived id when `has_authority_id`
|
||||
* is non-zero. The size-query and buffer semantics match lpp_make_fself.
|
||||
*/
|
||||
int lpp_make_fself_ex(const unsigned char* elf, int elf_length,
|
||||
uint64_t app_version, uint64_t firmware_version,
|
||||
uint64_t authority_id, int has_authority_id,
|
||||
unsigned char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Reads a 64-bit ELF from `elf_path`, generates a fake-self and writes it to `out_path`. The
|
||||
* version/authority arguments match lpp_make_fself_ex. Returns the number of bytes written on
|
||||
* success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_make_fself_file(const char* elf_path, const char* out_path,
|
||||
uint64_t app_version, uint64_t firmware_version,
|
||||
uint64_t authority_id, int has_authority_id);
|
||||
|
||||
/*
|
||||
* Fake-signs every raw ELF module under `source_folder` in place (eboot.bin, *.elf, *.prx,
|
||||
* *.sprx). Files already SELF, or that are not a 64-bit ELF, are skipped. The version/authority
|
||||
* arguments match lpp_make_fself_ex. Returns the number of modules converted, or -1 on failure
|
||||
* (call lpp_last_error).
|
||||
*/
|
||||
int lpp_fake_sign_folder(const char* source_folder,
|
||||
uint64_t app_version, uint64_t firmware_version,
|
||||
uint64_t authority_id, int has_authority_id);
|
||||
|
||||
/*
|
||||
* Copies the display name of an application type (LPP_APP_TYPE_*) into `out_buffer` as UTF-8.
|
||||
* Returns the number of bytes written, or a negative value (call lpp_last_error).
|
||||
*/
|
||||
int lpp_application_type_name(int application_type, char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Copies the generated param.json applicationDrmType token ("free" / "standard" / "freemium") for
|
||||
* an application type (LPP_APP_TYPE_*) into `out_buffer`. Returns the number of bytes written, or a
|
||||
* negative value (call lpp_last_error).
|
||||
*/
|
||||
int lpp_application_drm_type(int application_type, char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Parses an application-type display name (case-insensitive) into its code (LPP_APP_TYPE_*).
|
||||
* Unknown or empty input yields LPP_APP_TYPE_NOT_SPECIFIED (0).
|
||||
*/
|
||||
int lpp_parse_application_type(const char* name);
|
||||
|
||||
/*
|
||||
* Reads a SELF authentication-info sidecar (a 0x88-byte *.auth_info record). Any output pointer
|
||||
* may be NULL; `capabilities4` and `attributes4` each point to four 64-bit words, and `category`
|
||||
* receives the authority category (LPP_AUTH_CATEGORY_*). Returns 0 on success, or -1 on failure
|
||||
* (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_auth_info(const char* path, uint64_t* paid,
|
||||
uint64_t* capabilities4, uint64_t* attributes4, int* category);
|
||||
|
||||
/*
|
||||
* Builds a SELF authentication-info sidecar from supplied fields and writes it to `path`.
|
||||
* `capabilities4` and `attributes4` each point to four 64-bit words, or may be NULL to write
|
||||
* zeroes. Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_write_auth_info(const char* path, uint64_t paid,
|
||||
const uint64_t* capabilities4, const uint64_t* attributes4);
|
||||
|
||||
/*
|
||||
* Reads and projects the NpDrm content-info of the package at `path` into `out_info`. Returns 0
|
||||
* on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_npdrm_content_info(const char* path, lpp_npdrm_content_info* out_info);
|
||||
|
||||
/*
|
||||
* Inspects the package at `path` without a key, filling `out_info` (package type, retail flag,
|
||||
* outer-PFS offset and size, encryption state, whether a supplied key is required, and the content
|
||||
* id when present). Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_inspect_package(const char* path, lpp_package_info* out_info);
|
||||
|
||||
/*
|
||||
* Extracts a package to `output_directory`, deriving the outer key from `passcode` (and the
|
||||
* package's own content id). A NULL or empty passcode uses the 32-zero default. Set `extract_outer`
|
||||
* non-zero to also write the outer metadata files. Returns the number of extracted files on
|
||||
* success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_extract_package(const char* path, const char* output_directory,
|
||||
const char* passcode, int extract_outer);
|
||||
|
||||
/*
|
||||
* Extracts a package to `output_directory` using a supplied 32-byte outer key (`ekpfs32`). Set
|
||||
* `extract_outer` non-zero to also write the outer metadata files. Returns the number of extracted
|
||||
* files on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_extract_package_ekpfs(const char* path, const char* output_directory,
|
||||
const unsigned char* ekpfs32, int extract_outer);
|
||||
|
||||
/*
|
||||
* Counts the records in a RIF license file (n_rif). Returns the record count on success, or -1 on
|
||||
* failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_rif_record_count(const char* path);
|
||||
|
||||
/*
|
||||
* Copies the content id of the first record in a RIF file into `out_buffer` as UTF-8. Returns the
|
||||
* number of bytes written, or a negative value (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_rif_content_id(const char* path, char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Summarizes a multi-content RIF file into `out_summary`. `app_title_id` may be NULL to skip the
|
||||
* application match. Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_rif_summary(const char* path, const char* app_title_id, lpp_rif_summary* out_summary);
|
||||
|
||||
/*
|
||||
* Runs the structural acceptance-gate checks on the package at `path`. `expected_content_id` may be
|
||||
* NULL to skip the content-id match. The pass, warning and fail counts are written to the output
|
||||
* pointers when non-NULL. Returns 1 when accepted (no failing check), 0 when rejected, or -1 on
|
||||
* error (call lpp_last_error).
|
||||
*/
|
||||
int lpp_validate_package(const char* path, const char* expected_content_id,
|
||||
int* pass_count, int* warn_count, int* fail_count);
|
||||
|
||||
/*
|
||||
* Runs the structural acceptance-gate checks on the package at `path` and writes each check as one
|
||||
* line ("[Status] Name: Detail") into `out_buffer`. `expected_content_id` may be NULL to skip the
|
||||
* content-id match. Follows the string-output convention (bytes written, or the negative required
|
||||
* size).
|
||||
*/
|
||||
int lpp_validate_package_report(const char* path, const char* expected_content_id,
|
||||
char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Reassembles a split disc-backup package (from an app.json path or a directory that contains one)
|
||||
* into a single package file at `output_path`. Returns the number of bytes written on success, or
|
||||
* -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_disc_backup_reassemble(const char* manifest_path, const char* output_path);
|
||||
|
||||
/*
|
||||
* Verifies a split disc-backup package. The package-digest and chunk-CRC results are written to the
|
||||
* output pointers (1 = match, 0 = mismatch) when non-NULL. Returns 0 on success, or -1 on failure
|
||||
* (call lpp_last_error).
|
||||
*/
|
||||
int lpp_disc_backup_verify(const char* manifest_path, int* digest_ok, int* chunk_crc_ok);
|
||||
|
||||
/*
|
||||
* Converts a decrypted application backup into a debug package. Substitutes each signed executable
|
||||
* with its raw ELF from the decrypted subtree, fake-signs the modules, and builds a debug image whose
|
||||
* mount key derives from the content id and passcode. `content_id` and `version` may be NULL/empty to
|
||||
* take them from the backup's param.json; `passcode` may be NULL/empty for the all-zero default. The
|
||||
* substituted-module count is written to `substituted_count` when non-NULL. The output path is written
|
||||
* into `out_path` as UTF-8. Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_convert_backup(const char* backup_folder, const char* output_folder, const char* content_id,
|
||||
const char* passcode, const char* version, int* substituted_count,
|
||||
char* out_path, int out_path_capacity);
|
||||
|
||||
/*
|
||||
* Converts a decrypted application backup into a debug package with the full option set. Extends
|
||||
* lpp_convert_backup with the name of the decrypted-module subtree (`decrypted_subfolder`, NULL/empty
|
||||
* for "decrypted"), a flag to drop the backup's own right.sprx so the embedded debug module is
|
||||
* injected instead (`use_embedded_right_sprx`), and the inner-image codec (`inner_compression`,
|
||||
* LPP_INNER_*). The substituted, plaintext and unresolved module counts are written to the output
|
||||
* pointers when non-NULL. The output path is written to `out_path`. Returns 0 on success, or -1 on
|
||||
* failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_convert_backup_ex(const char* backup_folder, const char* output_folder, const char* content_id,
|
||||
const char* passcode, const char* version, const char* decrypted_subfolder,
|
||||
int use_embedded_right_sprx, int inner_compression, int* substituted_count,
|
||||
int* plaintext_count, int* unresolved_count,
|
||||
char* out_path, int out_path_capacity);
|
||||
|
||||
/*
|
||||
* Reads the package at `path` and fills `out_info` with header and image-header fields (package
|
||||
* type, entry counts, DRM/content type and flags, content id, PFS image offset/size, embedded
|
||||
* container offset). Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_package_summary(const char* path, lpp_package_summary* out_info);
|
||||
|
||||
/*
|
||||
* Returns the entry count of the package at `path`, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_package_entry_count(const char* path);
|
||||
|
||||
/*
|
||||
* Fills `out_entry` with the entry at zero-based `index` in the package at `path`. Returns 0 on
|
||||
* success, or -1 on failure or an out-of-range index (call lpp_last_error).
|
||||
*/
|
||||
int lpp_read_package_entry(const char* path, int index, lpp_package_entry* out_entry);
|
||||
|
||||
/*
|
||||
* Lists the inner files of the package at `path` (newline-separated relative paths) using a
|
||||
* passcode. `passcode` may be NULL/empty for the 32-zero default. Follows the string-output
|
||||
* convention (bytes written, or the negative required size).
|
||||
*/
|
||||
int lpp_list_package_files(const char* path, const char* passcode, char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Lists the inner files of the package at `path` (newline-separated relative paths) using a
|
||||
* supplied 32-byte image key (`ekpfs32`). Follows the string-output convention.
|
||||
*/
|
||||
int lpp_list_package_files_ekpfs(const char* path, const unsigned char* ekpfs32,
|
||||
char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Compares two metadata containers and writes the differences (newline-separated) into
|
||||
* `out_buffer`; an empty result means the containers match. Follows the string-output convention.
|
||||
*/
|
||||
int lpp_compare_containers(const char* reference_path, const char* candidate_path,
|
||||
char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Merges every split package found in `input_dir` and writes the resulting output paths
|
||||
* (newline-separated) into `out_buffer`. `output_dir` may be NULL to write beside the input; set
|
||||
* `compute_digest` non-zero to compute a SHA-256 per merged package. Follows the string-output
|
||||
* convention.
|
||||
*/
|
||||
int lpp_merge_split_package_dir(const char* input_dir, const char* output_dir, int compute_digest,
|
||||
char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Builds a package from a homebrew folder. `content_id`, `title` and `version` may be NULL/empty to
|
||||
* take defaults; `passcode` may be NULL/empty for the 32-zero default; `module_name` may be
|
||||
* NULL/empty for "eboot.bin". When non-NULL, `out_readiness` receives the launch-readiness summary.
|
||||
* Writes the output path to `out_path`. Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_package_homebrew(const char* homebrew_folder, const char* output_folder, const char* content_id,
|
||||
const char* passcode, const char* title, const char* version,
|
||||
const char* module_name, int inner_compression,
|
||||
lpp_launch_readiness* out_readiness, char* out_path, int out_path_capacity);
|
||||
|
||||
/*
|
||||
* Inspects an application root and fills `out_readiness` with its launch-readiness summary. Returns
|
||||
* 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_inspect_launch_readiness(const char* app_root, lpp_launch_readiness* out_readiness);
|
||||
|
||||
/*
|
||||
* Inspects an application root and writes its blocking launch-readiness reasons (newline-separated)
|
||||
* into `out_buffer`; an empty result means the tree is launch-ready. Pairs with
|
||||
* lpp_inspect_launch_readiness, which reports the issue count. Follows the string-output convention.
|
||||
*/
|
||||
int lpp_launch_readiness_issues(const char* app_root, char* out_buffer, int capacity);
|
||||
|
||||
/*
|
||||
* Lays a source folder out into a plaintext inner-PFS image. The file and directory counts are
|
||||
* written to `file_count` and `directory_count` when non-NULL; the output path is written to
|
||||
* `out_path`. Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_build_pfs_layout(const char* source_folder, const char* output_path, int* file_count,
|
||||
int* directory_count, char* out_path, int out_path_capacity);
|
||||
|
||||
/*
|
||||
* Reports whether the inner image at `path` is encrypted. Returns 1 when encrypted, 0 when
|
||||
* plaintext, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_pfs_image_is_encrypted(const char* path);
|
||||
|
||||
/*
|
||||
* Decrypts the inner image at `pfs_image_path` in place, deriving the image key from `content_id`
|
||||
* and `passcode` (NULL/empty passcode uses the 32-zero default). Returns 0 on success, or -1 on
|
||||
* failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_decrypt_pfs_image(const char* pfs_image_path, const char* content_id, const char* passcode);
|
||||
|
||||
/*
|
||||
* Reads the ELF header at `path` into `out_info`. Returns 0 on success, or -1 on failure (call
|
||||
* lpp_last_error).
|
||||
*/
|
||||
int lpp_read_elf_header(const char* path, lpp_elf_info* out_info);
|
||||
|
||||
/*
|
||||
* Normalizes the ELF at `in_path` for use as a module and writes the result to `out_path`. When
|
||||
* non-NULL, `changed` receives 1 if any header field changed, otherwise 0. Returns 0 on success, or
|
||||
* -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_normalize_elf_module(const char* in_path, const char* out_path, int* changed);
|
||||
|
||||
/*
|
||||
* Validates the content protection file at `path`. When non-NULL, `error_buffer` receives a
|
||||
* description when the file is invalid. Returns 1 when valid, 0 when invalid, or -1 on failure
|
||||
* (call lpp_last_error).
|
||||
*/
|
||||
int lpp_ucp_validate_file(const char* path, char* error_buffer, int error_capacity);
|
||||
|
||||
/*
|
||||
* Verifies the digest of the content protection file at `path`. Returns 1 when the digest matches,
|
||||
* 0 when it does not, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_ucp_verify_digest_file(const char* path);
|
||||
|
||||
/*
|
||||
* Builds a content protection file from `directory` and writes it to `output_path`. Returns the
|
||||
* number of bytes written, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_ucp_build_from_directory(const char* directory, const char* output_path);
|
||||
|
||||
/*
|
||||
* Reads the content protection file at `in_path`, repairs its digest, and writes it to `out_path`.
|
||||
* Returns the number of bytes written, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_ucp_repair_digest_file(const char* in_path, const char* out_path);
|
||||
|
||||
/*
|
||||
* Creates a structural license record for `content_id` and writes it to `out_path`. Pass 0 for
|
||||
* `expiry` to create a non-expiring record. Returns the number of bytes written, or -1 on failure
|
||||
* (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_rif_create(const char* content_id, long long expiry, const char* out_path);
|
||||
|
||||
/*
|
||||
* Derives the 32-byte image key from `content_id` and `passcode` (NULL/empty passcode uses the
|
||||
* 32-zero default) and writes it to `out32`. Returns 0 on success, or -1 on failure (call
|
||||
* lpp_last_error).
|
||||
*/
|
||||
int lpp_derive_image_key(const char* content_id, const char* passcode, unsigned char* out32);
|
||||
|
||||
/*
|
||||
* Validates a 32-hex-character entitlement key. Returns 1 when valid, 0 when invalid, or -1 on
|
||||
* failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_entitlement_key_validate(const char* hex);
|
||||
|
||||
/*
|
||||
* Opens a disc backup from its manifest and fills `out_info` with the content info of the
|
||||
* reassembled package. Returns 0 on success, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_disc_backup_content_info(const char* manifest_path, lpp_npdrm_content_info* out_info);
|
||||
|
||||
/*
|
||||
* Verifies the chunk CRCs of a disc backup. When non-NULL, `mismatch_chunk` receives the index of
|
||||
* the first mismatch (or -1 when all match). Returns 1 when all chunks match, 0 on mismatch, or -1
|
||||
* on failure (call lpp_last_error).
|
||||
*/
|
||||
int lpp_disc_backup_verify_chunk_crcs(const char* manifest_path, int* mismatch_chunk);
|
||||
|
||||
/*
|
||||
* Encodes the PNG file at `png_path` to a BC7 DDS texture and writes it to `dds_path`. Returns the
|
||||
* number of bytes written, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_encode_png_to_dds(const char* png_path, const char* dds_path);
|
||||
|
||||
/*
|
||||
* Builds a chunk descriptor file for `content_id` and writes it to `out_path`. Returns the number
|
||||
* of bytes written, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_build_playgo_chunk_dat(const char* content_id, const char* out_path);
|
||||
|
||||
/*
|
||||
* Creates a default param.json for the given ids (`drm_type` is LPP_PARAM_DRM_*) and writes it to
|
||||
* `out_path`. Returns the number of bytes written, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_create_param_json(const char* content_id, const char* title_id, const char* title_name,
|
||||
int drm_type, const char* out_path);
|
||||
|
||||
/*
|
||||
* Builds a GP5 project referencing `source_folder` and writes it to `out_path`. `volume_type` is one of
|
||||
* LPP_GP5_VOLUME_* and `passcode` may be NULL/empty for the all-zero default. Returns the number of bytes
|
||||
* written, or -1 on failure (call lpp_last_error).
|
||||
*/
|
||||
long long lpp_create_gp5(const char* source_folder, const char* out_path, int volume_type,
|
||||
const char* passcode);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* LIBPROSPEROPKG_H */
|
||||
@@ -1,124 +0,0 @@
|
||||
name: Native library (Linux)
|
||||
|
||||
# Builds the LibProsperoPkg shared library (.so) with NativeAOT for linux-x64 and
|
||||
# linux-arm64. The AOT and shared-library settings are injected into the project at build
|
||||
# time; the committed project is never modified. Each architecture builds on its own runner,
|
||||
# so no cross-compilation toolchain is required. After the build the workflow verifies the
|
||||
# exported symbol table and packages the library, header, license text and a checksum manifest
|
||||
# as an artifact. The workflow also publishes the desktop GUI application for the same
|
||||
# architecture into a separate GUI folder within the artifact.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-linux-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- rid: linux-x64
|
||||
runner: ubuntu-latest
|
||||
- rid: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
PROJECT: src/LibProsperoPkg/LibProsperoPkg.csproj
|
||||
PROJECT_DIR: src/LibProsperoPkg
|
||||
NATIVE_DIR: .github/native
|
||||
LICENSE_FILE: LICENSE
|
||||
NOTICE_FILE: NOTICE
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Install build prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang zlib1g-dev
|
||||
|
||||
- name: Inject AOT settings and export surface
|
||||
shell: pwsh
|
||||
run: |
|
||||
$csproj = $env:PROJECT
|
||||
$text = Get-Content -Raw $csproj
|
||||
if ($text -notmatch '<PublishAot>') {
|
||||
$inject = " <PublishAot>true</PublishAot>`n <NativeLib>Shared</NativeLib>`n </PropertyGroup>"
|
||||
$idx = $text.IndexOf('</PropertyGroup>')
|
||||
$text = $text.Substring(0, $idx) + $inject + $text.Substring($idx + '</PropertyGroup>'.Length)
|
||||
Set-Content -NoNewline -Path $csproj -Value $text
|
||||
}
|
||||
Copy-Item (Join-Path $env:NATIVE_DIR 'NativeExports.cs') (Join-Path $env:PROJECT_DIR 'NativeExports.cs') -Force
|
||||
if (-not (Select-String -Path $csproj -Pattern '<PublishAot>true</PublishAot>' -Quiet)) {
|
||||
Write-Error 'csproj patch did not apply'
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Publish shared library
|
||||
run: dotnet publish "$PROJECT" -c Release -r ${{ matrix.rid }} --nologo
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pub = Join-Path $env:PROJECT_DIR "bin/Release/net10.0/${{ matrix.rid }}/publish"
|
||||
$mine = Get-ChildItem -Path $pub -Recurse -Include '*LibProsperoPkg.so'
|
||||
if (-not $mine) {
|
||||
Write-Error "LibProsperoPkg shared library (.so) was not produced in $pub"
|
||||
Get-ChildItem -Path $pub -Recurse -Include *.so | ForEach-Object { Write-Host $_.FullName }
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$mine | Copy-Item -Destination artifacts
|
||||
# A lib-prefixed alias so downstream consumers can link with -lprosperopkg.
|
||||
Copy-Item $mine[0].FullName (Join-Path 'artifacts' 'libprosperopkg.so') -Force
|
||||
Copy-Item (Join-Path $env:NATIVE_DIR 'libprosperopkg.h') artifacts
|
||||
Copy-Item $env:LICENSE_FILE artifacts
|
||||
Copy-Item $env:NOTICE_FILE artifacts
|
||||
|
||||
- name: Verify exported symbols
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
lib=$(ls artifacts/*LibProsperoPkg.so 2>/dev/null | head -n 1)
|
||||
if [ -z "$lib" ]; then
|
||||
echo "LibProsperoPkg shared library (.so) not found in artifacts:"
|
||||
ls -la artifacts
|
||||
exit 1
|
||||
fi
|
||||
echo "Inspecting $lib"
|
||||
syms=$(nm -D --defined-only "$lib" | awk '{print $NF}' | sed -e 's/^_//' -e 's/@.*//')
|
||||
missing=0
|
||||
for s in $(cat "$NATIVE_DIR/exports.txt"); do
|
||||
if ! grep -Fxq "$s" <<< "$syms"; then echo "MISSING: $s"; missing=1; fi
|
||||
done
|
||||
if [ "$missing" -ne 0 ]; then echo "Expected C exports are missing"; exit 1; fi
|
||||
echo "All expected exports present"
|
||||
|
||||
- name: Checksums
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
( cd artifacts && sha256sum \
|
||||
*LibProsperoPkg.so libprosperopkg.so libprosperopkg.h \
|
||||
"$(basename "$LICENSE_FILE")" "$(basename "$NOTICE_FILE")" > SHA256SUMS )
|
||||
cat artifacts/SHA256SUMS
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: libprosperopkg-${{ matrix.rid }}
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
@@ -1,114 +0,0 @@
|
||||
name: Native library (macOS)
|
||||
|
||||
# Builds the LibProsperoPkg shared library (.dylib) with NativeAOT for osx-x64 and
|
||||
# osx-arm64. The AOT and shared-library settings are injected into the project at build
|
||||
# time; the committed project is never modified. Each architecture builds on its own runner
|
||||
# (Intel and Apple Silicon), so no cross-compilation toolchain is required. After the build
|
||||
# the workflow verifies the exported symbol table and packages the library, header, license
|
||||
# text and a checksum manifest as an artifact. The workflow also publishes the desktop GUI
|
||||
# application for the same architecture into a separate GUI folder within the artifact.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-macos-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- rid: osx-arm64
|
||||
runner: macos-14
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
PROJECT: src/LibProsperoPkg/LibProsperoPkg.csproj
|
||||
PROJECT_DIR: src/LibProsperoPkg
|
||||
NATIVE_DIR: .github/native
|
||||
LICENSE_FILE: LICENSE
|
||||
NOTICE_FILE: NOTICE
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Inject AOT settings and export surface
|
||||
shell: pwsh
|
||||
run: |
|
||||
$csproj = $env:PROJECT
|
||||
$text = Get-Content -Raw $csproj
|
||||
if ($text -notmatch '<PublishAot>') {
|
||||
$inject = " <PublishAot>true</PublishAot>`n <NativeLib>Shared</NativeLib>`n </PropertyGroup>"
|
||||
$idx = $text.IndexOf('</PropertyGroup>')
|
||||
$text = $text.Substring(0, $idx) + $inject + $text.Substring($idx + '</PropertyGroup>'.Length)
|
||||
Set-Content -NoNewline -Path $csproj -Value $text
|
||||
}
|
||||
Copy-Item (Join-Path $env:NATIVE_DIR 'NativeExports.cs') (Join-Path $env:PROJECT_DIR 'NativeExports.cs') -Force
|
||||
if (-not (Select-String -Path $csproj -Pattern '<PublishAot>true</PublishAot>' -Quiet)) {
|
||||
Write-Error 'csproj patch did not apply'
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Publish shared library
|
||||
run: dotnet publish "$PROJECT" -c Release -r ${{ matrix.rid }} --nologo
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pub = Join-Path $env:PROJECT_DIR "bin/Release/net10.0/${{ matrix.rid }}/publish"
|
||||
$mine = Get-ChildItem -Path $pub -Recurse -Include '*LibProsperoPkg.dylib'
|
||||
if (-not $mine) {
|
||||
Write-Error "LibProsperoPkg shared library (.dylib) was not produced in $pub"
|
||||
Get-ChildItem -Path $pub -Recurse -Include *.dylib | ForEach-Object { Write-Host $_.FullName }
|
||||
exit 1
|
||||
}
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$mine | Copy-Item -Destination artifacts
|
||||
Copy-Item (Join-Path $env:NATIVE_DIR 'libprosperopkg.h') artifacts
|
||||
Copy-Item $env:LICENSE_FILE artifacts
|
||||
Copy-Item $env:NOTICE_FILE artifacts
|
||||
|
||||
- name: Verify exported symbols
|
||||
shell: bash
|
||||
run: |
|
||||
lib=$(ls artifacts/*LibProsperoPkg.dylib 2>/dev/null | head -n 1)
|
||||
if [ -z "$lib" ]; then
|
||||
echo "LibProsperoPkg shared library (.dylib) not found in artifacts:"
|
||||
ls -la artifacts
|
||||
exit 1
|
||||
fi
|
||||
echo "Inspecting $lib"
|
||||
syms=$(nm -gU "$lib" | awk '{print $NF}' | sed -e 's/^_//' -e 's/@.*//')
|
||||
missing=0
|
||||
for s in $(cat "$NATIVE_DIR/exports.txt"); do
|
||||
if ! grep -Fxq "$s" <<< "$syms"; then echo "MISSING: $s"; missing=1; fi
|
||||
done
|
||||
if [ "$missing" -ne 0 ]; then echo "Expected C exports are missing"; exit 1; fi
|
||||
echo "All expected exports present"
|
||||
|
||||
- name: Checksums
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
( cd artifacts && shasum -a 256 \
|
||||
*LibProsperoPkg.dylib libprosperopkg.h \
|
||||
"$(basename "$LICENSE_FILE")" "$(basename "$NOTICE_FILE")" > SHA256SUMS )
|
||||
cat artifacts/SHA256SUMS
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: libprosperopkg-${{ matrix.rid }}
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
@@ -1,5 +0,0 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,48 +0,0 @@
|
||||
LibProsperoPkg
|
||||
Copyright (C) 2026 SvenGDK
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software Foundation,
|
||||
either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this
|
||||
program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Derived code
|
||||
------------------------------------------------------------------------
|
||||
|
||||
The following components are derived from LibOrbisPkg by maxton, licensed
|
||||
under the GNU General Public License v3.0:
|
||||
<https://github.com/maxton/LibOrbisPkg>.
|
||||
|
||||
* General-purpose utilities in src/LibProsperoPkg/Util/: Crypto.cs,
|
||||
Keys.cs, MemoryMapped.cs, OffsetStream.cs, StreamExtensions.cs,
|
||||
SubStream.cs, WriterBase.cs and XtsBlockTransform.cs.
|
||||
|
||||
* The PFS filesystem model, builder and reader in src/LibProsperoPkg/PFS/:
|
||||
ProsperoPfsStructs.cs, ProsperoPfsBuilder.cs, ProsperoPfsReader.cs,
|
||||
ProsperoPfsProperties.cs, ProsperoFlatPathTable.cs, ProsperoFsTree.cs
|
||||
and ProsperoXtsDecryptReader.cs.
|
||||
|
||||
* The container model and writer in src/LibProsperoPkg/PKG/:
|
||||
ProsperoCnt.cs, ProsperoCntEntry.cs, ProsperoCntEnums.cs and
|
||||
ProsperoCntWriter.cs.
|
||||
|
||||
These files were adapted for the PS5 package and PFSv3 filesystem formats;
|
||||
their public types carry the Prospero prefix in this library.
|
||||
|
||||
The Kraken (newLZ) decode and bitstream logic in
|
||||
src/LibProsperoPkg/PFS/Compression/Oodle/ (KrakenDecoder.cs,
|
||||
KrakenBitWriter.cs and the array-entropy helpers) is an index-based
|
||||
translation of the decompression reference implementation "ooz" by
|
||||
Powzix (kraken.cpp), licensed under the GNU General Public License v3.0:
|
||||
<https://github.com/powzix/ooz>.
|
||||
|
||||
GPL-3.0 section 13 expressly permits conveying a combined work that
|
||||
links or combines GPL-covered code, which this library relies on for the
|
||||
combination above.
|
||||
@@ -1,219 +0,0 @@
|
||||
# LibProsperoPkg
|
||||
|
||||
A .NET class library for building and inspecting **PS5** packages. It turns a prepared application
|
||||
folder into a complete, installable debug package end to end, in-process, without an external
|
||||
command-line tool.
|
||||
|
||||
The library is written in C# 14 and targets .NET 10. It is self-contained and exposes a small,
|
||||
documented public API any .NET application can consume.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **End-to-end pipeline.** Folder -> inner PFS layout -> AES-XTS encryption -> outer PFS ->
|
||||
`\x7FCNT` metadata container -> finalized `\x7FFIH` debug image, in one process.
|
||||
- **Self-contained.** The GP5 project model, the PFS image builder, AES-XTS encryption,
|
||||
RSA-3072 metadata signing and the finalized debug image are produced by the library itself.
|
||||
- **Reader and writer.** Parse and inspect existing PS5 packages (`\x7FCNT` / `\x7FFIH`) and build
|
||||
new ones.
|
||||
- **Extraction.** Extract the application filesystem from a finalized debug/keyed image with
|
||||
`ProsperoPackageExtractor`, or from any image whose 32-byte image key is supplied.
|
||||
- **Disc-backup packages.** Open a split disc backup (the ordered `app_0` / `app_sc` pieces)
|
||||
through its `app.json` manifest, reassemble it on the fly, and verify the package digest and
|
||||
64 KiB chunk CRCs.
|
||||
- **License (`rif`).** Read, write and create the per-title license record — including multi-title
|
||||
files — through `LibProsperoPkg.License`.
|
||||
- **Acceptance checks.** Validate a package against the structural gate the console mount path
|
||||
enforces (`ProsperoPkgValidator`).
|
||||
- **Fake-signed packages (fPKG).** Optionally fake-sign raw ELF modules (`eboot.bin`, `*.elf`,
|
||||
`*.prx`, `*.sprx`) to fake-self before packing, producing an installable fake package. The
|
||||
conversion is non-destructive: source modules are restored after the build.
|
||||
- **Backup conversion.** `ProsperoBackupConverter` repackages a decrypted application backup into a
|
||||
debug fPKG, substituting each executable with its decrypted ELF and fake-signing it, so the image
|
||||
mounts from the content id and passcode with no rif.
|
||||
- **Launch-readiness inspection.** `ProsperoLaunchReadiness` inspects an assembled application root
|
||||
and reports whether it meets the debug-launch conditions: every executable module is a plaintext
|
||||
module the loader accepts, `eboot.bin` is present, and the metadata is a `param.json` rather than a
|
||||
PS4 `param.sfo`.
|
||||
- **Homebrew packaging.** `ProsperoHomebrewPackager` turns a compiled homebrew folder into an
|
||||
installable debug fPKG: it assembles a clean source tree, builds a license-free debug image whose
|
||||
mount key derives from the content id and passcode, and checks launch-readiness. A worked sample
|
||||
lives in `src/HomebrewTest`.
|
||||
- **Application type.** `ProsperoApplicationType` selects the generated `param.json`
|
||||
`applicationDrmType` (`free` / `standard` / `freemium`), covering paid, upgradable, demo and
|
||||
freemium apps.
|
||||
- **Texture generation.** The `sce_sys` icon/picture DDS (BC7) encoder decodes the source PNG and
|
||||
block-compresses it to a DX10 BC7 texture in-process.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Toolchain | .NET 10 SDK or newer |
|
||||
| Language | C# 14 |
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd LibProsperoPKG/src/LibProsperoPkg
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
This produces `LibProsperoPkg.dll`.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Add the project (or the built `LibProsperoPkg.dll`) to your build and create a package from a
|
||||
prepared application folder:
|
||||
|
||||
```csharp
|
||||
using LibProsperoPkg;
|
||||
|
||||
var options = new ProsperoBuildOptions
|
||||
{
|
||||
Mode = ProsperoPackageMode.Application,
|
||||
OutputFormat = ProsperoOutputFormat.DebugImage, // installable on a debug-mode console
|
||||
SourceFolder = @"/path/to/prepared/app", // must contain sce_sys/
|
||||
OutputFolder = @"/path/to/output",
|
||||
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
|
||||
TitleId = "PPSA00000",
|
||||
Title = "My PS5 Application",
|
||||
Version = "01.00",
|
||||
};
|
||||
|
||||
ProsperoBuildResult result = ProsperoPackageBuilder.Build(options, Console.WriteLine);
|
||||
|
||||
Console.WriteLine($"Package written to: {result.OutputPath}");
|
||||
foreach (var warning in result.Warnings)
|
||||
Console.WriteLine($"Warning: {warning}");
|
||||
```
|
||||
|
||||
### Fake-signing modules (fPKG)
|
||||
|
||||
Set `FakeSignSelfModules` to convert raw ELF modules in the source folder to fake-self before
|
||||
packing. Use `ApplicationType` to control the generated `param.json` `applicationDrmType`:
|
||||
|
||||
```csharp
|
||||
var options = new ProsperoBuildOptions
|
||||
{
|
||||
Mode = ProsperoPackageMode.Application,
|
||||
OutputFormat = ProsperoOutputFormat.DebugImage,
|
||||
SourceFolder = @"/path/to/prepared/app",
|
||||
OutputFolder = @"/path/to/output",
|
||||
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
|
||||
TitleId = "PPSA00000",
|
||||
Title = "My PS5 Application",
|
||||
Version = "01.00",
|
||||
ApplicationType = ProsperoApplicationType.FreemiumApp,
|
||||
FakeSignSelfModules = true,
|
||||
};
|
||||
```
|
||||
|
||||
Files that are already SELF are left untouched, and the original module bytes are restored once
|
||||
packing completes.
|
||||
|
||||
To build a DRM-free, license-free package in one step, set `LicenseFree`. It fake-signs modules and
|
||||
derives the debug mount key from the content id and passcode, so no rif is written.
|
||||
`ProsperoBuildResult.DebugLicense` reports the grant and `ProsperoBuildResult.LicenseFree` echoes the
|
||||
option. The output is a debug finalized image for a debug-enabled console.
|
||||
|
||||
To repackage a decrypted application backup into a debug fPKG, call `ProsperoBackupConverter.Convert`.
|
||||
It substitutes each signed executable with its decrypted raw ELF, fake-signs the modules, and builds
|
||||
a debug image that mounts from the content id and passcode. The backup stays untouched; the converter
|
||||
works from a staging copy.
|
||||
|
||||
```csharp
|
||||
var result = ProsperoBackupConverter.Convert(new ProsperoBackupConversionOptions
|
||||
{
|
||||
BackupFolder = @"/path/to/backup/PPSA00000-app0",
|
||||
OutputFolder = @"/path/to/output",
|
||||
});
|
||||
```
|
||||
|
||||
### Packaging a homebrew module
|
||||
|
||||
To package a compiled homebrew folder (a raw ELF `eboot.bin` plus an optional `sce_sys/` tree) into
|
||||
an installable debug fPKG, call `ProsperoHomebrewPackager.Package`. It fake-signs the module and
|
||||
derives the mount key from the content id and passcode, then checks launch-readiness. A worked sample
|
||||
lives in `src/HomebrewTest`.
|
||||
|
||||
```csharp
|
||||
var result = ProsperoHomebrewPackager.Package(new ProsperoHomebrewPackageOptions
|
||||
{
|
||||
HomebrewFolder = @"/path/to/HomebrewTest",
|
||||
OutputFolder = @"/path/to/output",
|
||||
ContentId = "UP9000-PPSA99099_00-PROSPERO00000000",
|
||||
Title = "LibProsperoPKG",
|
||||
});
|
||||
Console.WriteLine($"Launch ready: {result.LaunchReadiness.IsLaunchReady}");
|
||||
```
|
||||
|
||||
Every build path stores the inner image as the data-first image.
|
||||
|
||||
### Inspecting an existing package
|
||||
|
||||
```csharp
|
||||
using LibProsperoPkg.PKG;
|
||||
|
||||
ProsperoPkg pkg = ProsperoPkgReader.Read(@"/path/to/package.pkg");
|
||||
Console.WriteLine($"Type: {pkg.Type}");
|
||||
Console.WriteLine($"Content ID: {pkg.Header?.ContentId}");
|
||||
Console.WriteLine($"Entries: {pkg.Entries.Count}");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public surface, at a glance
|
||||
|
||||
| Namespace | Key types |
|
||||
|---|---|
|
||||
| `LibProsperoPkg` | `ProsperoPackageBuilder`, `ProsperoBackupConverter`, `ProsperoHomebrewPackager`, `ProsperoBuildOptions`, `ProsperoBuildResult`, `ProsperoBackupConversionOptions`, `ProsperoBackupConversionResult`, `ProsperoHomebrewPackageOptions`, `ProsperoHomebrewPackageResult`, `ProsperoPackageMode`, `ProsperoOutputFormat`, `InnerImageForm`, `ProsperoApplicationType` |
|
||||
| `LibProsperoPkg.PKG` | `ProsperoPkgBuilder`, `ProsperoPkgReader`, `ProsperoCntWriter`, `ProsperoFihBuilder`, `ProsperoPkgSigner`, `ProsperoDdsEncoder`, `ProsperoPackageExtractor`, `ProsperoExtractionKey`, `ProsperoPkgValidator`, `ProsperoPkg`, `ProsperoPkgHeader` |
|
||||
| `LibProsperoPkg.PFS` | `ProsperoPfsLayout`, `ProsperoPfsImage`, `ProsperoPfsc`, `ProsperoPfsExtractor` |
|
||||
| `LibProsperoPkg.License` | `ProsperoRif`, `ProsperoRifSet`, `ProsperoEntitlementKey` |
|
||||
| `LibProsperoPkg.NpDrm` | `ProsperoNpDrmContentInfo` |
|
||||
| `LibProsperoPkg.DiscBackup` | `ProsperoDiscBackup`, `ProsperoDiscBackupManifest`, `ProsperoPlaygoChunkCrc` |
|
||||
| `LibProsperoPkg.GP5` | `Gp5Creator`, `Gp5Project` and its element model |
|
||||
| `LibProsperoPkg.Keys` | `ProsperoKeys` |
|
||||
| `LibProsperoPkg.PlayGo` | `ProsperoPlayGo` |
|
||||
| `LibProsperoPkg.Content` | `ProsperoUcp`, `ProsperoFself`, `ProsperoSelfAuthInfo`, `ProsperoElfHeader`, `ProsperoLaunchReadiness` |
|
||||
|
||||
See **[docs/](docs/)** for the full feature status and the PS5 package technical write-up.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[docs/README.md](docs/README.md)** - documentation index.
|
||||
- **[docs/getting-started.md](docs/getting-started.md)** - install, build and first package.
|
||||
- **[docs/api-overview.md](docs/api-overview.md)** - public API by namespace.
|
||||
- **[docs/implementation-status.md](docs/implementation-status.md)** - what is implemented and what
|
||||
is still missing.
|
||||
- **[docs/ps5-pkg-format.md](docs/ps5-pkg-format.md)** - technical write-up of the PS5 package
|
||||
format and the creation process.
|
||||
|
||||
---
|
||||
|
||||
## Coverage
|
||||
|
||||
LibProsperoPkg produces a complete debug package end to end: the finalized `\x7FFIH` image, the
|
||||
`\x7FCNT` metadata container, the inner-image assembly, the layout/metric metadata and the trailing
|
||||
install-metadata archive, all of which round-trip through the reader. A console running in **debug
|
||||
mode**, which relaxes finalized-image verification, is the intended target. The retail (submitted)
|
||||
image path and the per-console license and key material are gated on console-side material the
|
||||
library does not have. On-console acceptance depends on the console's mode and firmware. See
|
||||
[docs/implementation-status.md](docs/implementation-status.md) for the precise breakdown.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
LibProsperoPkg is licensed under the GNU General Public License v3.0 or later
|
||||
(GPL-3.0-or-later). See [LICENSE](LICENSE). Third-party attributions are listed in [NOTICE](NOTICE).
|
||||
@@ -1,43 +0,0 @@
|
||||
# LibProsperoPkg Documentation
|
||||
|
||||
This folder contains the technical documentation for **LibProsperoPkg**, a
|
||||
.NET 10 / C# 14 library for building and inspecting PS5 packages.
|
||||
|
||||
## Contents
|
||||
|
||||
| Document | Description |
|
||||
|---|---|
|
||||
| [getting-started.md](getting-started.md) | Install the SDK, build the library, and produce your first package. |
|
||||
| [api-overview.md](api-overview.md) | The public API, organized by namespace, with usage notes. |
|
||||
| [implementation-status.md](implementation-status.md) | A precise breakdown of what is implemented and what is still missing. |
|
||||
| [ps5-pkg-format.md](ps5-pkg-format.md) | A technical write-up of the PS5 package format and the end-to-end creation process. |
|
||||
|
||||
## At a glance
|
||||
|
||||
LibProsperoPkg turns a prepared PS5 application folder into a complete, installable package
|
||||
in-process. The pipeline is:
|
||||
|
||||
```
|
||||
prepared folder (sce_sys/ + eboot + data)
|
||||
│
|
||||
▼ optional fake-sign of raw ELF modules (FakeSignSelfModules / LicenseFree)
|
||||
│
|
||||
▼ inner PFS layout (ProsperoPfsLayout)
|
||||
plaintext inner PFS image
|
||||
│
|
||||
▼ data-first inner image (raw-concatenated per-file payloads + naps_pkg_layout.dat)
|
||||
│
|
||||
▼ outer PFS + metadata (ProsperoPkgBuilder)
|
||||
\x7FCNT metadata container
|
||||
│
|
||||
▼ finalize + install-metadata archive (ProsperoFihBuilder)
|
||||
\x7FFIH debug image ──► installable on a debug-mode console
|
||||
```
|
||||
|
||||
The library also has a read side. `ProsperoPackageExtractor` inspects and unpacks debug packages,
|
||||
`ProsperoDiscBackup` reassembles split `app_0` / `app_sc` backups from an `app.json` manifest, and
|
||||
the `License` and `NpDrm` namespaces read `rif` licences and package content-info.
|
||||
`ProsperoBackupConverter` closes the loop from the read side back to the build side: it turns a
|
||||
decrypted backup into a debug fPKG by substituting each executable with its decrypted ELF and
|
||||
fake-signing it. See [api-overview.md](api-overview.md) for the full surface and
|
||||
[ps5-pkg-format.md](ps5-pkg-format.md) §9–§11 for the RIF, disc-backup and extraction formats.
|
||||
@@ -1,382 +0,0 @@
|
||||
# API Overview
|
||||
|
||||
This document summarizes the public surface of LibProsperoPkg, grouped by namespace. Every
|
||||
public type and member carries XML documentation, so IntelliSense and the generated
|
||||
documentation file give the authoritative detail; this page is the orientation map.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg` — high-level builder
|
||||
|
||||
### `ProsperoPackageBuilder` (static)
|
||||
|
||||
The primary entry point.
|
||||
|
||||
| Member | Purpose |
|
||||
|---|---|
|
||||
| `Build(ProsperoBuildOptions, Action<string>?)` | Build a package from a prepared folder. Returns `ProsperoBuildResult`. |
|
||||
| `BuildInnerPfsLayout(...)` | Lay a folder out into a plaintext inner-PFS image. Returns `ProsperoPfsLayoutResult`. |
|
||||
| `BuildInnerImage(...)` | Run the full inner-image pipeline (plaintext / encrypted / zlib-compressed / Kraken-compressed). Returns the written image path. |
|
||||
| `EncryptPfsImage(...)` | AES-XTS-encrypt a prepared plaintext inner-PFS image in place. Returns `ProsperoPfsImageResult`. |
|
||||
| `CompareContainers(...)` | Compare two packages field by field. Returns the list of differences. |
|
||||
| `ComposeContentId(publisher, titleId, label)` | Build a well-formed 36-char content id. |
|
||||
| `IsValidContentId` / `IsValidTitleId` | Validate identifiers. |
|
||||
| `VolumeTypeForMode(mode)` | Map a `ProsperoPackageMode` to the GP5 `Gp5VolumeType`. |
|
||||
| `ProsperoVolumeTypeForMode(mode)` | Map a `ProsperoPackageMode` to the container `ProsperoVolumeType`. |
|
||||
| `IsDlcMode(mode)` | Report whether a mode is additional-content. |
|
||||
| `KeysAvailable` | Reports whether the built-in signing key material is available. |
|
||||
|
||||
### Supporting types
|
||||
|
||||
- **`ProsperoBuildOptions`** — the build description: `Mode`, `OutputFormat`, `SourceFolder`,
|
||||
`OutputFolder`, `ContentId`, `Passcode`, `Title`, `TitleId`, `Version`,
|
||||
`GenerateParamJsonIfMissing`, `ApplicationType`, `ApplicationDrmType`, `ContentBadgeType`,
|
||||
`FakeSignSelfModules`, `FselfOptions`, `LicenseFree`.
|
||||
- **`ProsperoApplicationType`** — the application type: `NotSpecified`,
|
||||
`PaidStandaloneFullApp`, `UpgradableApp`, `DemoApp`, `FreemiumApp`. `ProsperoApplicationTypes`
|
||||
maps it to the generated `param.json` `applicationDrmType` (`free` / `standard` / `freemium`)
|
||||
and provides `DisplayName` and `Parse`.
|
||||
- **`ProsperoBuildResult`** — `OutputPath`, a list of non-fatal `Warnings`, `LicenseFree` (echoes
|
||||
the option), and `DebugLicense` (the constructed `ProsperoDebugLicense` when `LicenseFree` is set,
|
||||
otherwise null).
|
||||
- **`ProsperoPackageMode`** — `Application`, `Homebrew`, `AdditionalContentData`,
|
||||
`AdditionalContentNoData`.
|
||||
- **`ProsperoOutputFormat`** — `MetadataContainer` (`\x7FCNT` only, not installable) or
|
||||
`DebugImage` (`\x7FFIH`, the default, installable on a debug-mode console).
|
||||
- **`InnerImageForm`** — `Plaintext`, `Encrypted`, `Compressed` (zlib PFSC),
|
||||
`KrakenCompressed` (PFSv3 Kraken). Selects how `BuildInnerImage` renders a laid-out inner image.
|
||||
|
||||
The package build path always stores the inner `pfs_image.dat` as the data-first image: a raw
|
||||
concatenation of per-file payloads (raw or headerless Kraken) with the geometry described by a
|
||||
generated `naps_pkg_layout.dat`.
|
||||
|
||||
### `ProsperoBackupConverter` (static)
|
||||
|
||||
Repackages a decrypted application backup into a debug fPKG that mounts and launches without a rif
|
||||
or a console secret.
|
||||
|
||||
| Member | Purpose |
|
||||
|---|---|
|
||||
| `Convert(ProsperoBackupConversionOptions, Action<string>?)` | Assemble a staging tree from the backup, substitute each signed executable with its decrypted counterpart, fake-sign, and build a debug image. Returns `ProsperoBackupConversionResult`. |
|
||||
|
||||
- **`ProsperoBackupConversionOptions`** — `BackupFolder`, `OutputFolder`, `DecryptedSubfolder`
|
||||
(default `decrypted`), `ContentId`, `Passcode`, `Version`, `StagingFolder`, `KeepStaging`,
|
||||
`UseEmbeddedRightSprx`, `FselfOptions`. Content id and version fall back to the backup's
|
||||
`param.json` when not supplied.
|
||||
- **`ProsperoBackupConversionResult`** — `OutputPath`, `DebugLicense`, `SubstitutedModules`,
|
||||
`PlaintextModules`, `UnresolvedModules`, `Warnings`, `LaunchReadiness`, `StagingFolder`.
|
||||
`LaunchReadiness` is a `ProsperoLaunchReadinessReport` (below) over the assembled tree.
|
||||
|
||||
### `ProsperoHomebrewPackager` (static)
|
||||
|
||||
Packages a compiled homebrew module into an installable debug fPKG. It assembles a clean source tree
|
||||
from the module folder (the module lands as `eboot.bin`, the `sce_sys/` tree is copied) and builds a
|
||||
finalized debug image with the license-free path enabled, so the module is fake-signed and the mount
|
||||
key derives from the content id and passcode.
|
||||
|
||||
| Member | Purpose |
|
||||
|---|---|
|
||||
| `Package(ProsperoHomebrewPackageOptions, Action<string>?)` | Assemble the source tree and build a debug image. Returns `ProsperoHomebrewPackageResult`. |
|
||||
|
||||
- **`ProsperoHomebrewPackageOptions`** — `HomebrewFolder`, `OutputFolder` (both required), `ModuleName`
|
||||
(default `eboot.bin`), `ContentId`, `Passcode`, `Title`, `Version`, `StagingFolder`, `KeepStaging`,
|
||||
`FselfOptions`. Content id and version fall back to the homebrew's `param.json` when not supplied.
|
||||
- **`ProsperoHomebrewPackageResult`** — `OutputPath`, `DebugLicense` (`RequiresRif` always false),
|
||||
`ModulePath` (`eboot.bin`), `LaunchReadiness`, `Warnings`, `StagingFolder`.
|
||||
|
||||
### `ProsperoLaunchReadiness` (static, `LibProsperoPkg.Content`)
|
||||
|
||||
Inspects an assembled application root and reports whether it meets the debug-launch conditions:
|
||||
every executable module is a plaintext module the loader accepts, `eboot.bin` is present, and the
|
||||
metadata is a `param.json` rather than the older `param.sfo`. It inspects only — it never signs, mounts,
|
||||
or launches.
|
||||
|
||||
| Member | Purpose |
|
||||
|---|---|
|
||||
| `InspectModule(string path, ReadOnlySpan<byte> data)` | Classify one module. Returns `ModuleLaunchReadiness`. |
|
||||
| `InspectAppRoot(string root)` | Scan `eboot.bin` + `*.prx` / `*.sprx`, check `param.json` / `param.sfo`, aggregate issues. Returns `ProsperoLaunchReadinessReport`. |
|
||||
|
||||
- **`ModuleAuthorityKind`** — `NotExecutable`, `RawElf`, `FakeAuthoritySelf`, `GenuineAuthoritySelf`,
|
||||
`UnknownAuthoritySelf`, `SignedEncrypted`.
|
||||
- **`ModuleLaunchReadiness`** — `Path`, `Kind`, `AuthorityId`, `WillRunOnDebugConsole`, `Note`.
|
||||
- **`ProsperoLaunchReadinessReport`** — `AppRoot`, `Modules`, `HasEboot`, `HasParamJson`,
|
||||
`HasParamSfo`, `RequiresDebugConsole`, `Issues`, `IsLaunchReady`.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.PKG` — container, signing, finalization
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoPkgBuilder` | Build the outer PFS + `\x7FCNT` metadata container. |
|
||||
| `ProsperoPkgReader` | `DetectType(path/stream)` and `Read(path/stream)` for existing packages. |
|
||||
| `ProsperoCntWriter` | Low-level `\x7FCNT` container writer over the `ProsperoCnt` model (`ProsperoCntEntry`, `ProsperoCntHeader`, `ProsperoCntEntryNames`, entry-id enums). |
|
||||
| `ProsperoFihBuilder` | Wrap a `\x7FCNT` into a finalized `\x7FFIH` image. `BuildFromCnt(cntPath, fihOutputPath, ProsperoFihVariant)`. |
|
||||
| `ProsperoPkgSigner` | RSA-3072 metadata signing and EKPFS derivation. |
|
||||
| `ProsperoNapsLayout` | PS5 `naps_pkg_layout.dat` decoder and serializer for the `nwonly` streaming layout. `Parse`/`DecodeHeader` (returning a `NapsLayoutDocument` over the `Naps*` record types), `BuildLayout` (decoder and serializer are mutually consistent, including zero padding), the per-section `Encode*`/`Decode*` helpers, `SectionMap`. Record values are data-dependent on the inner-image compression run. The data-first build path generates a valid layout for its assembled inner image through `ProsperoNwonlyNapsGenerator`. |
|
||||
| `ProsperoImageDigests` | PS5 finalized-image / CNT digest algorithms (single primitive: **SHA3-256**). Computes digests for all documented formulas. `ComputeSblockDigest`/`ComputeGameDigest` (`SHA3-256(plaintext outer superblock, 0x10000)` = FIH `0x30/0x70/0xD0`), `ComputeFixedInfoDigest` (`SHA3-256(FIH block)`), `ComputeBodyDigest` (`SHA3-256(CNT body)`), `ComputeEntryDigest` + `BuildEntryDigestTable` (CNT entry `0x0001`; self-slot zeroed), `ComputePackageDigest` (`SHA3-256(CNT[0:0xFE0])` = CNT `+0xFE0` = `<package-digest>`), `ComputeCntHeaderRollupDigest` (`SHA3-256(CNT[off:off+size])` = CNT `+0x100`), `ComputeContentDigest` / `ComputeHeaderDigest` / `ComputeConcatDigest` / `ForceFihRelativeImageOffset` (the GeneralDigests block — content/header/system/playgo/target, wired via `ProsperoPkgBuilder.ComputeGeneralDigests`), `LocateSuperblock`/`ComputeSblockDigestFromImage` (scan `version 2` + magic `0x0b2a3301`), `Sha3_256`. The FIH `0xB0` nested-image-content slot is computed from the uncompressed inner PFS image during finalization. |
|
||||
| `ProsperoDdsEncoder` | Re-encode `sce_sys` icon/picture images to BC7 DDS. |
|
||||
| `ProsperoFihVariant` | Finalized-image variant for `ProsperoFihBuilder`: `Debug`, `Official`. |
|
||||
| `ProsperoNapsMeta` | Build the `naps_meta_*` install-metadata descriptors. `BuildMeta300` / `BuildMeta300FromInnerImageSize` produce the 48-byte `naps_meta_300/301/302/308` descriptor from the inner-image geometry; `BuildMeta18` builds the AES-128-XTS TLV metric blob (`naps_meta_18.dat`) over the finalized image and its content-file table. |
|
||||
| `ProsperoSystemFiles` | Validate backend-signed `sce_sys` files before packing. `Validate`, `ValidateNpbind`, `ValidateNptitle`, `ValidateLicenseDat`, `ValidateLicenseInfo`. |
|
||||
| `ProsperoSiArchive` | Build the trailing `sce_suppl` install archive: `pfsimage.xml`, the `naps_meta_*` descriptors, and the copied PlayGo files. `BuildDebugSiSegment`, `BuildPfsImageXml`, `WriteZip`, with `ProsperoSiMember` and `ProsperoPfsImageXmlOptions`. |
|
||||
| `ProsperoChunkInfoModel` | Chunk/scenario model threaded from the GP5 project into the install archive. |
|
||||
|
||||
### Read model
|
||||
|
||||
- **`ProsperoPkg`** — `Type` (`ProsperoPkgType`), `Header` (`ProsperoPkgHeader?`), `Entries`
|
||||
(`IReadOnlyList<ProsperoPkgEntry>`), `Fih` (`ProsperoFihHeader?`).
|
||||
- **`ProsperoPkgHeader`** — `Magic`, `Flags`, `EntryCount`, `EntryTableOffset`, `BodyOffset`,
|
||||
`BodySize`, `ContentId`, `DrmType`, `ContentType`.
|
||||
- **`ProsperoPkgEntry`** — `Id` (`ProsperoEntryId`), `DataOffset`, `DataSize`, `Name`, and the
|
||||
raw header fields.
|
||||
- **`ProsperoFihHeader`** — `SignedByte` (0x00 debug / 0x80 retail), `PfsImageOffset`,
|
||||
`PfsImageSize`, `EmbeddedCntOffset`.
|
||||
|
||||
### Build properties
|
||||
|
||||
- **`ProsperoPkgBuildProperties`** and **`ProsperoVolumeType`** drive the low-level builder.
|
||||
- **`ProsperoPkgLayout`** and **`ProsperoEntryId`** describe the container layout and entry ids.
|
||||
|
||||
### Reading and extracting existing packages
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoPackageExtractor` (static) | Extract the application filesystem from a finalized image end to end (finalized image → outer PFS → `pfs_image.dat` PFSC → inner PFS → files). `Inspect(path)` reports type, retail flag, outer-PFS offset/size, whether the outer PFS is encrypted, and whether a supplied key is required — without a key. `ListFiles(path, key)` enumerates the inner filesystem without writing. `Extract(...)` (two overloads) writes the files and returns a `ProsperoPackageManifest`. Supporting records: `ProsperoExtractionOptions`, `ProsperoPackageExtractionInfo`, `ProsperoPackageManifest`. |
|
||||
| `ProsperoExtractionKey` (sealed) | Key material for the outer PFS. `FromPasscode(passcode)` / `FromPasscode(contentId, passcode)` derive the outer EKPFS from public inputs (SHA-256 and SHA3-256 candidates; the extractor auto-selects whichever opens the image), `FromEkpfs(bytes)` takes a supplied 32-byte image key, `None` attempts a plaintext outer PFS. `ResolveEkpfsCandidates`, `Kind` (`ProsperoExtractionKeyKind`), `ContentId`, `Passcode`, `Ekpfs`. |
|
||||
| `ProsperoPkgValidator` (static) | Check a package against the structural acceptance gate the mount path enforces. `Validate(path, expectedContentId=null)` / `Validate(ProsperoPkg, expectedContentId=null)` return a `ProsperoAcceptanceReport` of named `Pass` / `Warning` / `Fail` checks (`ProsperoAcceptanceCheck`, `ProsperoCheckStatus`) with `Accepted` and `HasWarnings` roll-ups. |
|
||||
|
||||
A finalized retail image (signed byte `0x80`) encrypts the whole outer PFS including its
|
||||
superblock; its image key arrives through the console entitlement path and is not derivable from
|
||||
public inputs. `Inspect` reports this (`RequiresSuppliedKey = true`) and `Extract` refuses with a
|
||||
clear message rather than returning a fabricated result.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.PFS` — filesystem image
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoPfsLayout` | Build a plaintext inner-PFS image from a folder. `BuildFromFolder`, `VerifyRoundTrip`. |
|
||||
| `ProsperoPfsImage` | AES-XTS encrypt/decrypt a PFS image. `EncryptInPlace`, `VerifyRoundTrip`. |
|
||||
| `ProsperoOuterPfsImage` | AES-XTS encrypt/decrypt the PS5 nwonly **outer** finalized-image PFS (whole 0x10000 block = one XTS unit; sector = block index, or `0x800000000000 | index` for signed blocks; superblock block left plaintext). `Transform` (block-index or `ProsperoOuterBlockKind[]` overload), `EncryptInPlace`/`DecryptInPlace` (key- or content-id/passcode-driven), `MetadataBlockIndex`. Decrypt and re-encrypt round-trips. |
|
||||
| `ProsperoOuterPfsSignature` | PS5 nwonly outer-PFS signing primitives. `ComputeBlockHash` (plain SHA3-256 per-block/dinode hash), `ComputeSuperblockIcv`/`WriteSuperblockIcv` (`SHA3-256(superblock[0:0x5a0])` with the `icv` field zeroed), `BlockSector(index, signed)` (the bit-47 signed-block sector flag). |
|
||||
| `ProsperoOuterPfsBuilder` | PS5 nwonly outer-PFS **structure generator**: assembles the data-first 11-block plaintext outer image from its outer files (`pfs_image.dat`, `naps_pkg_layout.dat`) — inode table with per-block SHA3 hashes, super-root/uroot dirents, the `\x7fFLT` inode_flat_path_table (custom reduced-Keccak path hash), and the signed superblock (+`icv`). `BuildPlaintext`, `Encrypt`, `BuildEncrypted`. Types: `ProsperoOuterFile`, `ProsperoOuterPfsBuildParameters`, `ProsperoOuterPfsBuildResult`. |
|
||||
| `ProsperoPfsKeys` | PFS-image key derivation using SHA3-256. `DeriveEkpfs(contentId, passcode)`, `DeriveImageEncryptionKeys(ekpfs, seed)` → `(tweakKey, dataKey)`, overload `DeriveImageEncryptionKeys(contentId, passcode, seed)` → `(tweakKey, dataKey)`, `DeriveImageSignKey(ekpfs, seed)`. |
|
||||
| `ProsperoPfsc` | High-level PFSC block compression. `PackFile`, `Unpack`, `IsPfsc`. |
|
||||
| `ProsperoPfscEncoder` | Lower-level PFSC container encoder. `Encode` (buffer or stream), `HeaderSize`, `ShouldSkipExecutableCompression`, with `ProsperoPfscEncoderOptions` / `ProsperoPfscEncodeStats`. |
|
||||
| `ProsperoPfscReader` | Random-access reader over a PFSC container. `Read`, `ReadSector`, `SectorSize`, `DataLength`. |
|
||||
| `ProsperoPfsExtractor` (static) | Write every file of an opened (and, if encrypted, decrypted) `ProsperoPfsReader` to a directory, PFSC-decompressing per file, confined to the output directory. `Extract(reader, outputDirectory, logger=null)`, `ListEntries(reader)`; owns `ProsperoExtractedEntry` and `ProsperoExtractionException`. This is the single-image half that `ProsperoPackageExtractor` composes twice around the middle PFSC decode. |
|
||||
|
||||
Each high-level entry carries an options/result record pair (`ProsperoPfsLayoutOptions`/`Result`,
|
||||
`ProsperoPfsImageOptions`/`Result`, `ProsperoPfscOptions`/`Result`).
|
||||
|
||||
The namespace also exposes the low-level filesystem model that the builder and reader operate on:
|
||||
`ProsperoPfsBuilder`, `ProsperoPfsReader`, `ProsperoPfsHeader`, `ProsperoInode`, the on-disk dinode
|
||||
records (`ProsperoDinodeD32`, `ProsperoDinodeS32`, `ProsperoDinodeS64`), `ProsperoFlatPathTable`,
|
||||
`ProsperoPfsDirent`, the filesystem-tree nodes (`ProsperoFsNode`, `ProsperoFsDir`, `ProsperoFsFile`),
|
||||
`ProsperoXtsDecryptReader`, `ProsperoPfscWriter` (low-level PFSC header writer), and the supporting
|
||||
enums (`ProsperoDirentType`, `ProsperoInodeFlags`,
|
||||
`ProsperoInodeMode`, `ProsperoPfsMode`, `ProsperoOuterBlockKind`).
|
||||
|
||||
### `LibProsperoPkg.PFS.Compression` — PS5 PFSv3 Kraken codec
|
||||
|
||||
The PS5 compression-file (`PFSC` v3) codec used by the `nwonly` path.
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoCompressedPfsImage` | Public façade for the inner-image use of the codec — packs/unpacks a whole PFS image as a self-describing `PFSC` v3 container. `Pack`/`PackStored`/`PackFile`, `Unpack`/`UnpackFile`, detection helpers, `ValidateRoundTrip`; returns `ProsperoCompressedPfsImageResult` (raw/encoded sizes, block + stored counts, gain %). Used to compress the inner metadata block and by the standalone PFSC pack/unpack tool. |
|
||||
| `ProsperoCompressedPfsFileWriter` | Produce a PFSv3 `PFSC` container. `WriteCompressed(payload, level, blockSize, useHuffmanArrays=true)` (Kraken with default-on Huffman entropy arrays, per-block stored fallback) / `WriteStored(payload)`. |
|
||||
| `ProsperoCompressedPfsFile` | Parse a PFSv3 `PFSC` container. `Parse`, detection helpers, `VerifyFileDigest`, and `Decompress()` for a full decode. |
|
||||
| `ProsperoPfsDigest` | SHA3-256 helpers for the per-block hashes and the `@0x28` file digest. |
|
||||
| `ProsperoPfsCompressionConstants` | The Kraken window-bits constant for the codec. |
|
||||
| `ProsperoCompressionAlgorithm` / `ProsperoPfsCompressionFormat` | The codec (`QuickZ`, `Zlib`, `Kraken`) and container-format version (`Version0`..`Version3`) enums. `ProsperoPfsShufflePattern` names the pre-compression shuffle patterns. |
|
||||
|
||||
The newLZ (Kraken) decoder and the Huffman entropy-array encoder are internal implementation
|
||||
details of these types and are not part of the public surface. `PfsBlock` and
|
||||
`ProsperoCompressedPfsImageResult` describe a single block and the pack result.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.GP5` — project model
|
||||
|
||||
- **`Gp5Creator`** — `FromFolder(...)` / `FromFolderExplicit(...)` build a `Gp5Project` from a
|
||||
folder.
|
||||
- **`Gp5Project`** — the GP5 document model, with both the "normal" (`rootdir`-walked) and
|
||||
"flat" (`files`/`folders`-listed) layouts represented via `Gp5Layout`. Elements:
|
||||
`Gp5Volume`, `Gp5Package`, `Gp5ChunkInfo`, `Gp5Chunk`, `Gp5Scenarios`, `Gp5Scenario`,
|
||||
`Gp5RootDir`, `Gp5File`, `Gp5Dir`. `Gp5VolumeType` names the volume kind
|
||||
(`prospero_app`, `prospero_patch`, `prospero_ac`, `prospero_ac_nodata`).
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.Keys` — signing key access
|
||||
|
||||
- **`ProsperoKeys`** — exposes the wired-in PS5 signing key material (`IsAvailable` and the
|
||||
individual key accessors). Used by the signer and the package builder.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.PlayGo` — auxiliary file generators
|
||||
|
||||
- **`ProsperoPlayGo`** — generates the auxiliary `sce_sys` files (`about/right.sprx`,
|
||||
`playgo-chunk.dat`, `playgo-manifest.xml`) that the builder injects into the inner PFS so the
|
||||
produced file set is complete.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.Content` — content file codecs
|
||||
|
||||
- **`ProsperoUcp`** — reads, builds, validates, verifies, and repairs UCP archives
|
||||
(`trophy2/*.ucp`, `uds/*.ucp`). `IsUcp`, `Read`, `Build`, `BuildFromDirectory`, `Validate`,
|
||||
`VerifyDigest`, and `WithRepairedDigest`.
|
||||
- **`ProsperoFself`** — parses SELF containers and generates a fake-self from a 64-bit ELF.
|
||||
`IsSelf`, `IsElf`, `Parse`, `Validate`, and `MakeFself` (with `FselfOptions` for app and firmware
|
||||
version and an optional authority id). The read model exposes `SelfImage`, `SelfSegment`, and
|
||||
`SelfExtInfo`. `MakeFself` normalizes the input module header by default (see
|
||||
`FselfOptions.NormalizeHeader`), working on a private copy so the caller's buffer is untouched. The
|
||||
high-level builder wires this in through `ProsperoBuildOptions.FakeSignSelfModules`,
|
||||
which fake-signs raw ELF modules in the source tree before packing (producing an fPKG) and restores
|
||||
the originals afterward.
|
||||
- **`ProsperoSelfAuthInfo`** — reads, validates, builds, and round-trips the SELF authentication-info
|
||||
sidecar (`*.auth_info`), a fixed 0x88-byte record: program authority id (`paid`) at `0x00`, four
|
||||
64-bit capability words at `0x08`, four 64-bit attribute words at `0x28`, and a 0x40-byte reserved
|
||||
tail, all little-endian. `IsAuthInfo`, `Parse`, `Read`, `ReadFile`, `Create`, `ToBytes`, `Write`,
|
||||
`WriteFile`; `Paid` / `AuthorityId`, `Capabilities`, `Attributes`, `Reserved`, and `Category`
|
||||
(`ProsperoAuthorityCategory`, with `IsFakeAuthority` / `IsGenuineAuthority` / `IsPrivilegedSystem`).
|
||||
Grant words are copied verbatim.
|
||||
- **`ProsperoElfHeader`** — reads and edits the 64-bit ELF header. `Read`, `ReadFile`, the typed
|
||||
header properties, and the `IsExecutable` / `IsDynamic` / `IsModuleType` / `IsModuleReady`
|
||||
predicates report its shape. `SetOsAbi`, `SetAbiVersion`, `SetType`, and `SetMachine` rewrite
|
||||
single fields in place; `NormalizeForModule` retargets a module ELF (machine to x86-64, OS/ABI to
|
||||
FreeBSD, placeholder type to executable) before `MakeFself`, returning an `ElfNormalizeResult`.
|
||||
`ElfType`, `ElfClass`, `ElfData`, `ElfOsAbi`, and `ElfMachine` name the value spaces.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.License` — per-title license (`rif`)
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoRif` (sealed) | The per-title license record (`license/rif`): a fixed `0x400`-byte structure with a big-endian header (magic `RIF\0`, version, flags, the `QPaC` format tag, expiry, a 36-byte content id) and a 448-byte encrypted key blob at `0x240`. `Parse` / `Read` decode one record; `ReadAll` / `WriteAll` handle a multi-title file (one record per sub-title); `Create(contentId, keyBlob=null, expiry)` builds a structural record (copying a supplied key blob verbatim or leaving it zero); `ToBytes` / `Write` serialize; `Validate`. Exposes `ContentId`, `TitleId`, `ServiceLabel`, `Expiry` / `IsNonExpiring`, `HasKeyBlob`. |
|
||||
| `ProsperoRifSet` (sealed) | A whole license file as the ordered set of records. `ReadFile` / `Read` / `FromRecords`, `Validate` (non-empty, whole-file size a positive multiple of `0x400`, per-record validity), `Summarize(appTitleId)` → `ProsperoRifSetSummary` (`n_rif`, per-record `ServiceID`, `has_app`, `n_ac`, size), `Describe`. |
|
||||
| `ProsperoEntitlementKey` (sealed) | The 128-bit content key (`entitlement_key`) the builder accepts as an alternative to a passcode. `FromBytes`, `ParseHex` / `ToHex`, `Value`, `IsZero`, `Validate`, and `ResolveMode(passcode, entitlementKey, out mode, out error)` (`ProsperoKeyMode`) enforcing the mutual-exclusivity rule. A validated carrier for supplied material; it never derives or forges a license key body. |
|
||||
| `ProsperoDebugLicense` (sealed) | The debug grant for a content id + passcode, where the mount key is recomputed from public inputs rather than granted. `Create(contentId, passcode=null)` (passcode defaults to 32 zeros), `RequiresRif` (always false), `DeriveEkpfs`, `DeriveImageEncryptionKeys(seed)`, `DeriveImageSignKey(seed)`, and `DeriveKeySet(seed)` → `ProsperoDebugKeySet` (EKPFS + AES-XTS tweak/data + sign key) delegate to `ProsperoPfsKeys`; `ToStructuralRif(expiry)` emits a zero-blob record for pipelines that expect a license file present; `Validate`. Holds no device secret. |
|
||||
|
||||
The 448-byte key blob at `0x240` is encrypted with per-console material and cannot be produced
|
||||
off-console. `Create` covers the structural / templated path only.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.NpDrm` — content-info projection
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoNpDrmContentInfo` (sealed) | Projects a package header into the compact classification the mount path consumes before it accepts an image: container offset, content id, derived title id, `DrmType` (`0x70`), `ContentType` (`0x74`), `ContentFlags` (`0x78`), `IsNestedImage`, `IsFinalized`, and the decoded `PatchKind` (`ProsperoPatchKind`: `None` / `First` / `Subsequent` / `Delta` / `Cumulative`) with an `IsPatch` roll-up. `Read(path)` / `Read(stream)` parse and project; `FromPackage` projects a parsed `ProsperoPkg`; `ResolveContainerOffset` mirrors the container-offset switch on magic and version; `DeriveTitleId(contentId)`. |
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.DiscBackup` — split disc-backup packages
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `ProsperoDiscBackup` (sealed) | Opens a split disc-backup package (the ordered `app_0` / `app_sc` pieces) described by an `app.json` manifest and presents it as one package. `Open(path)`, `OpenPackageStream` / `ReassembleTo(stream/path, progress)`, `ReadPackage`, `ReadContentInfo`, `ComputePackageDigest` / `VerifyPackageDigest`, `ReadChunkCrc` / `VerifyChunkCrcHash` / `VerifyChunkCrcs(out mismatchChunk, progress)`, `FindImageKeyEntry`, `ExtractEntry` / `ExtractEntryBytes`. Props: `Directory`, `Manifest`, `OriginalFileSize`. |
|
||||
| `ProsperoDiscBackupManifest` (sealed) | Parses `app.json`: `NumberOfSplitFiles`, `OriginalFileSize`, `PackageDigest`, the ordered `Pieces` (`ProsperoDiscBackupPiece`: `DiscNumber`, `FileOffset`, `FileSize`, `Url`), `PlaygoChunkCrcHashValue`, `PlaygoChunkCrcUrl`. `Parse(json)`, `Read(path)`. |
|
||||
| `ProsperoConcatStream` (sealed) | A read-only, seekable stream that concatenates the piece windows without materializing a temporary file. |
|
||||
| `ProsperoPlaygoChunkCrc` (sealed) | Parses the headerless little-endian CRC-32C array (one value per 64 KiB chunk, from `app.crc`). `Parse`, `Read(path)`, `VerifyPackage(stream, out mismatchChunk, progress)`. |
|
||||
|
||||
The EEKPFS key entry (id `0x20`) is returned as stored (encrypted); off-console key derivation from
|
||||
it is not implemented.
|
||||
|
||||
---
|
||||
|
||||
## `LibProsperoPkg.Util` — low-level helpers
|
||||
|
||||
Building blocks shared across the library. Most consumers use the higher-level types above; these
|
||||
are exposed for advanced use.
|
||||
|
||||
- **`Crypto`** — SHA-256 / SHA3-256, HMAC-SHA-256, AES-CBC/CFB, RSA (PKCS#1) key wrapping, the PFS
|
||||
key-generation primitives (`PfsGenCryptoKey`, `PfsGenEncKey`, `PfsGenSignKey`), `ComputeKeys`,
|
||||
`CreateKeystone`, and `Xor`.
|
||||
- **`CryptoKeys`** — the key constants the signer and mount-key derivation use.
|
||||
- **`ProsperoCrc32C`** — CRC-32C (`Compute`, `Update`).
|
||||
- **`XtsBlockTransform`** — AES-XTS sector encrypt/decrypt.
|
||||
- Stream helpers: `OffsetStream`, `SubStream`, `StreamReader`, `WriterBase`, and the
|
||||
`IMemoryReader` / `IMemoryAccessor` accessors with `MemoryMappedViewAccessor_`.
|
||||
|
||||
---
|
||||
|
||||
## Shared library (C ABI)
|
||||
|
||||
The library can be built with a flat C export surface and a matching `libprosperopkg.h` header.
|
||||
The committed project stays unchanged; build properties are injected at build time. Each build
|
||||
checks the exported symbol table and packages the library, header, license text, and a
|
||||
`SHA256SUMS` manifest as an artifact.
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `lpp_version` | Return the library version string. |
|
||||
| `lpp_abi_version` | Return the numeric ABI version of the export surface (`LPP_ABI_VERSION`). |
|
||||
| `lpp_last_error` | Return the last error message on the calling thread. |
|
||||
| `lpp_is_valid_content_id` / `lpp_is_valid_title_id` | Validate identifiers. |
|
||||
| `lpp_compose_content_id` | Compose a 36-char content id. |
|
||||
| `lpp_build_package` | Run the full build from a prepared folder. |
|
||||
| `lpp_build_package_ex` | Run the full build with the complete option set (`lpp_build_options`). |
|
||||
| `lpp_detect_package_type` | Return the package type of a file (`LPP_TYPE_*`). |
|
||||
| `lpp_build_inner_image` | Lay a folder out into an inner-PFS image (`LPP_FORM_*`). |
|
||||
| `lpp_encrypt_pfs_image` | AES-XTS-encrypt a plaintext inner-PFS image in place. |
|
||||
| `lpp_pack_pfs_image` / `lpp_unpack_pfs_image` | Pack / unpack a PFSv3 PFSC container. |
|
||||
| `lpp_is_self` / `lpp_is_elf` / `lpp_is_ucp` | Detect a SELF, a 64-bit ELF, or a UCP archive. |
|
||||
| `lpp_read_self_info` | Read the extended info and segment count from a SELF module. |
|
||||
| `lpp_make_fself` | Generate a fake-self from a 64-bit ELF. |
|
||||
| `lpp_make_fself_ex` | Generate a fake-self with explicit version and authority-id options. |
|
||||
| `lpp_make_fself_file` | Read an ELF from a path, fake-sign it, and write the result to a path. |
|
||||
| `lpp_fake_sign_folder` | Fake-sign every raw ELF module under a folder in place. |
|
||||
| `lpp_application_type_name` | Return the display name of an application type (`LPP_APP_TYPE_*`). |
|
||||
| `lpp_application_drm_type` | Return the generated `applicationDrmType` token for an application type. |
|
||||
| `lpp_parse_application_type` | Parse an application-type display name into its code. |
|
||||
| `lpp_read_auth_info` / `lpp_write_auth_info` | Read / write a SELF authentication-info sidecar. |
|
||||
| `lpp_read_npdrm_content_info` | Read the NpDrm content-info of a package (`lpp_npdrm_content_info`). |
|
||||
| `lpp_inspect_package` | Inspect a package without a key (`lpp_package_info`). |
|
||||
| `lpp_read_package_summary` | Read header and image-header fields (`lpp_package_summary`). |
|
||||
| `lpp_package_entry_count` / `lpp_read_package_entry` | Count entries / read one entry (`lpp_package_entry`). |
|
||||
| `lpp_list_package_files` / `lpp_list_package_files_ekpfs` | List the inner files by passcode or image key. |
|
||||
| `lpp_compare_containers` | List the differences between two metadata containers. |
|
||||
| `lpp_extract_package` / `lpp_extract_package_ekpfs` | Extract a package by passcode or image key. |
|
||||
| `lpp_validate_package` | Run the acceptance checks on a package. |
|
||||
| `lpp_merge_split_package_dir` | Merge split packages found in a directory. |
|
||||
| `lpp_package_homebrew` | Build a package from a homebrew folder. |
|
||||
| `lpp_inspect_launch_readiness` | Summarize the launch readiness of an application root (`lpp_launch_readiness`). |
|
||||
| `lpp_build_pfs_layout` | Lay a folder out into a plaintext inner-PFS image. |
|
||||
| `lpp_pfs_image_is_encrypted` / `lpp_decrypt_pfs_image` | Report encryption state / decrypt an inner image in place. |
|
||||
| `lpp_read_elf_header` | Read an ELF header (`lpp_elf_info`). |
|
||||
| `lpp_normalize_elf_module` | Normalize an ELF for use as a module. |
|
||||
| `lpp_ucp_validate_file` / `lpp_ucp_verify_digest_file` | Validate a content protection file / verify its digest. |
|
||||
| `lpp_ucp_build_from_directory` / `lpp_ucp_repair_digest_file` | Build a content protection file / repair its digest. |
|
||||
| `lpp_rif_record_count` / `lpp_read_rif_content_id` / `lpp_read_rif_summary` | Read the record count, first content id, or a summary (`lpp_rif_summary`). |
|
||||
| `lpp_rif_create` | Create a structural license record. |
|
||||
| `lpp_derive_image_key` | Derive the 32-byte image key from a content id and passcode. |
|
||||
| `lpp_entitlement_key_validate` | Validate a 32-hex-character entitlement key. |
|
||||
| `lpp_disc_backup_reassemble` / `lpp_disc_backup_verify` | Reassemble / verify a split disc backup. |
|
||||
| `lpp_disc_backup_content_info` / `lpp_disc_backup_verify_chunk_crcs` | Read a disc backup's content info / verify its chunk CRCs. |
|
||||
| `lpp_convert_backup` | Convert a decrypted application backup into a debug package. |
|
||||
| `lpp_encode_png_to_dds` | Encode a PNG file to a BC7 DDS texture. |
|
||||
| `lpp_build_playgo_chunk_dat` | Build a chunk descriptor file for a content id. |
|
||||
| `lpp_create_param_json` | Create a default param.json for a set of ids. |
|
||||
|
||||
`lpp_build_package_ex` takes a pointer to `lpp_build_options`, a zero-initialized struct whose
|
||||
`struct_size` field is set to `sizeof(lpp_build_options)` before use. It carries the application
|
||||
type, badge and DRM overrides, param.json generation, inner compression, and the fake-self settings
|
||||
(version, firmware and authority-id) applied to raw ELF modules before packing. The trailing
|
||||
`license_free` field builds a DRM/license-free debug package; it is appended, so a smaller
|
||||
`struct_size` from an older caller leaves it disabled.
|
||||
|
||||
Strings cross the boundary as UTF-8. String-output functions return the number of bytes written, or
|
||||
the negative of the required size (including the terminator) when the caller buffer is too small.
|
||||
Status functions return 0 on success and a negative value on failure, with the message available
|
||||
through `lpp_last_error`. Struct-output functions fill a caller-provided struct — `lpp_build_options`,
|
||||
`lpp_npdrm_content_info`, `lpp_package_info`, `lpp_rif_summary`, `lpp_package_summary`,
|
||||
`lpp_package_entry`, `lpp_elf_info`, and `lpp_launch_readiness` — whose `struct_size` field is set
|
||||
before use. Enums pass as ints; the header defines the `LPP_ABI_VERSION`, `LPP_MODE_*`,
|
||||
`LPP_OUTPUT_*`, `LPP_INNER_*`, `LPP_FORM_*`, `LPP_TYPE_*`, `LPP_APP_TYPE_*`, `LPP_PATCH_*`,
|
||||
`LPP_AUTH_CATEGORY_*`, `LPP_PARAM_DRM_*`, `LPP_ELF_MACHINE_*`, and `LPP_ENTRY_*` values.
|
||||
@@ -1,224 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **.NET 10 SDK** or newer. Verify with:
|
||||
|
||||
```bash
|
||||
dotnet --version
|
||||
```
|
||||
|
||||
- A C# 14 capable toolchain (included with the .NET 10 SDK).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
LibProsperoPKG/
|
||||
├── README.md
|
||||
├── NOTICE
|
||||
├── docs/
|
||||
└── src/
|
||||
└── LibProsperoPkg/
|
||||
├── LibProsperoPkg.csproj
|
||||
├── ProsperoPackageBuilder.cs high-level entry point
|
||||
├── PKG/ container build/read/write, signing, DDS, FIH, extraction
|
||||
├── PFS/ inner PFS layout, AES-XTS, PFSC/data-first compression, extraction
|
||||
├── Content/ UCP, fake-self, and auth-info content codecs
|
||||
├── License/ per-title license (rif) read/write/create
|
||||
├── NpDrm/ package content-info projection
|
||||
├── DiscBackup/ split disc-backup (app_0 / app_sc) open and verify
|
||||
├── GP5/ GP5 project model
|
||||
├── Keys/ signing key access
|
||||
├── PlayGo/ PlayGo / "about" helper file generators
|
||||
└── Util/ crypto, keys, and shared helpers
|
||||
```
|
||||
|
||||
## Building the library
|
||||
|
||||
```bash
|
||||
cd LibProsperoPKG/src/LibProsperoPkg
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
The Release build is written under `bin/Release/net10.0/`.
|
||||
|
||||
## Using the library from another project
|
||||
|
||||
Point another project at either the compiled assembly or the `.csproj` directly:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LibProsperoPKG\src\LibProsperoPkg\LibProsperoPkg.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
## Preparing an application folder
|
||||
|
||||
The builder consumes a folder that already contains the standard PS5 layout:
|
||||
|
||||
- `sce_sys/` — system metadata directory (must be present). When `param.json` is missing and
|
||||
`GenerateParamJsonIfMissing` is left `true`, a minimal one is generated from the build options.
|
||||
- The application executable (`eboot.bin`) and any data files.
|
||||
|
||||
## Building your first package
|
||||
|
||||
```csharp
|
||||
using LibProsperoPkg;
|
||||
|
||||
var options = new ProsperoBuildOptions
|
||||
{
|
||||
Mode = ProsperoPackageMode.Application,
|
||||
OutputFormat = ProsperoOutputFormat.DebugImage,
|
||||
SourceFolder = "/path/to/prepared/app",
|
||||
OutputFolder = "/path/to/output",
|
||||
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
|
||||
TitleId = "PPSA00000",
|
||||
Title = "My PS5 Application",
|
||||
Version = "01.00",
|
||||
};
|
||||
|
||||
var result = ProsperoPackageBuilder.Build(options, Console.WriteLine);
|
||||
Console.WriteLine(result.OutputPath);
|
||||
```
|
||||
|
||||
## Fake-signing modules and application type
|
||||
|
||||
To pack raw ELF modules that are not yet SELF, set `FakeSignSelfModules`. The builder converts
|
||||
`eboot.bin` and any `*.elf` / `*.prx` / `*.sprx` in the source folder to fake-self before layout
|
||||
and restores the original files afterward. Modules that are already SELF are skipped.
|
||||
|
||||
`ApplicationType` selects the `applicationDrmType` written to a generated `param.json`:
|
||||
|
||||
| `ProsperoApplicationType` | `applicationDrmType` |
|
||||
|---|---|
|
||||
| `PaidStandaloneFullApp` | `standard` |
|
||||
| `UpgradableApp` | `standard` |
|
||||
| `FreemiumApp` | `freemium` |
|
||||
| `DemoApp` | `free` |
|
||||
| `NotSpecified` | `free` |
|
||||
|
||||
```csharp
|
||||
var options = new ProsperoBuildOptions
|
||||
{
|
||||
Mode = ProsperoPackageMode.Application,
|
||||
OutputFormat = ProsperoOutputFormat.DebugImage,
|
||||
SourceFolder = "/path/to/prepared/app",
|
||||
OutputFolder = "/path/to/output",
|
||||
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
|
||||
TitleId = "PPSA00000",
|
||||
Title = "My PS5 Application",
|
||||
Version = "01.00",
|
||||
ApplicationType = ProsperoApplicationType.FreemiumApp,
|
||||
FakeSignSelfModules = true,
|
||||
};
|
||||
```
|
||||
|
||||
## DRM/license-free debug package
|
||||
|
||||
Set `LicenseFree` to build a single package that is DRM-free, license-free, fake-signed, and
|
||||
runnable on a debug-enabled console. The flag fake-signs raw ELF modules (the same conversion as
|
||||
`FakeSignSelfModules`) and constructs a debug grant whose mount key is recomputed from the content
|
||||
id and passcode, so no rif or license file is written. The output stays a debug finalized image.
|
||||
`param.json` `applicationDrmType` is descriptive metadata read by the installer, not a boot gate; a
|
||||
generated `param.json` already carries the derived value and is not rewritten.
|
||||
|
||||
```csharp
|
||||
var options = new ProsperoBuildOptions
|
||||
{
|
||||
Mode = ProsperoPackageMode.Application,
|
||||
OutputFormat = ProsperoOutputFormat.DebugImage,
|
||||
SourceFolder = "/path/to/prepared/app",
|
||||
OutputFolder = "/path/to/output",
|
||||
ContentId = "UP9000-PPSA00000_00-PROSPERO00000000",
|
||||
TitleId = "PPSA00000",
|
||||
Title = "My PS5 Application",
|
||||
Version = "01.00",
|
||||
LicenseFree = true,
|
||||
};
|
||||
|
||||
ProsperoBuildResult result = ProsperoPackageBuilder.Build(options);
|
||||
// result.LicenseFree == true
|
||||
// result.DebugLicense holds the content id + passcode grant (RequiresRif == false)
|
||||
```
|
||||
|
||||
The source tree is unchanged after the build: the fake-sign step restores the original bytes when
|
||||
the build finishes or throws.
|
||||
|
||||
Read an existing package's header and entries:
|
||||
|
||||
```csharp
|
||||
using LibProsperoPkg.PKG;
|
||||
|
||||
ProsperoPkg pkg = ProsperoPkgReader.Read("/path/to/package.pkg");
|
||||
Console.WriteLine($"{pkg.Type} {pkg.Header?.ContentId}");
|
||||
```
|
||||
|
||||
Check a package against the structural acceptance gate the console mount path enforces:
|
||||
|
||||
```csharp
|
||||
ProsperoAcceptanceReport report = ProsperoPkgValidator.Validate("/path/to/package.pkg");
|
||||
Console.WriteLine(report.Accepted);
|
||||
```
|
||||
|
||||
Extract the application filesystem from a finalized debug/keyed image. `Inspect` first reports
|
||||
whether a supplied key is required, without needing one:
|
||||
|
||||
```csharp
|
||||
ProsperoPackageExtractionInfo info = ProsperoPackageExtractor.Inspect("/path/to/package.pkg");
|
||||
if (!info.RequiresSuppliedKey)
|
||||
{
|
||||
ProsperoPackageManifest manifest = ProsperoPackageExtractor.Extract(
|
||||
"/path/to/package.pkg", "/path/to/output", new string('0', 32));
|
||||
Console.WriteLine(manifest.ExtractedFileCount);
|
||||
}
|
||||
```
|
||||
|
||||
A finalized retail image (signed byte `0x80`) reports `RequiresSuppliedKey = true`; its image key
|
||||
is not derivable from public inputs, so extraction needs a supplied 32-byte key through
|
||||
`ProsperoExtractionKey.FromEkpfs`.
|
||||
|
||||
## Opening a disc-backup
|
||||
|
||||
A split disc backup is a set of `app_0` / `app_sc` pieces described by an `app.json` manifest. Open
|
||||
the directory, verify integrity, and read the reassembled package:
|
||||
|
||||
```csharp
|
||||
using LibProsperoPkg.DiscBackup;
|
||||
|
||||
ProsperoDiscBackup backup = ProsperoDiscBackup.Open("/path/to/backup/dir");
|
||||
Console.WriteLine(backup.VerifyPackageDigest());
|
||||
ProsperoPkg pkg = backup.ReadPackage();
|
||||
```
|
||||
|
||||
## Converting a decrypted backup to a debug fPKG
|
||||
|
||||
A decrypted backup carries the app tree plus a `decrypted/` subfolder that mirrors every executable
|
||||
as raw ELF. `ProsperoBackupConverter` substitutes each signed executable with its decrypted
|
||||
counterpart, fake-signs the modules, and builds a debug image that mounts from the content id and
|
||||
passcode alone. No executable byte-patching and no `param.json` edit are involved.
|
||||
|
||||
```csharp
|
||||
var options = new ProsperoBackupConversionOptions
|
||||
{
|
||||
BackupFolder = "/path/to/backup/PPSA00000-app0",
|
||||
OutputFolder = "/path/to/output",
|
||||
// ContentId and Version fall back to the backup's param.json when omitted.
|
||||
};
|
||||
|
||||
ProsperoBackupConversionResult result = ProsperoBackupConverter.Convert(options);
|
||||
Console.WriteLine(result.OutputPath);
|
||||
Console.WriteLine($"substituted {result.SubstitutedModules.Count} modules");
|
||||
// result.DebugLicense.RequiresRif == false
|
||||
```
|
||||
|
||||
The backup is never modified; the converter works from a staging copy. Every module in the output
|
||||
is a fake-self, the image is debug (not retail), and extraction round-trips every file.
|
||||
|
||||
## Notes on content identifiers
|
||||
|
||||
- **Content ID** is 36 characters: `XXYYYY-XXXXYYYYY_00-ZZZZZZZZZZZZZZZZ`.
|
||||
Validate with `ProsperoPackageBuilder.IsValidContentId` or compose one with
|
||||
`ProsperoPackageBuilder.ComposeContentId(publisher, titleId, label)`.
|
||||
- **Title ID** is 9 characters (for example `PPSA00000`). Validate with
|
||||
`ProsperoPackageBuilder.IsValidTitleId`.
|
||||
- **Passcode** is exactly 32 characters and defaults to all zeroes.
|
||||