mirror of
https://github.com/manos555555/PS5-Suite.git
synced 2026-10-06 14:00:26 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d48ae610b | ||
|
|
5b65f0dd32 | ||
|
|
a6199c8d51 |
No files matched your search
+41
-52
@@ -1,60 +1,49 @@
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio cache/options
|
||||
.vs/
|
||||
*.suo
|
||||
# Build outputs
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
publish/
|
||||
publish_*/
|
||||
out/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.suo
|
||||
msbuild.binlog
|
||||
|
||||
# Build results
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.cache
|
||||
# Release binaries (uploaded as GitHub Release assets, not tracked in git)
|
||||
release/
|
||||
*.apk
|
||||
*.aab
|
||||
PS5UploadSuite-*.exe
|
||||
PS5Upload_Standalone_*/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
# Runtime settings/logs (machine-local)
|
||||
ps5suite_settings.json
|
||||
ps5upload_settings.json
|
||||
ps5_favorites.json
|
||||
ps5_profiles.json
|
||||
ps5suite_*.log
|
||||
*.tmp.png
|
||||
|
||||
# Files built by Visual Studio
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
# AI/tooling junk
|
||||
.aider*
|
||||
.freebuff/
|
||||
.aider.tags.cache.v4/
|
||||
|
||||
# Caches
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
# OS files
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
*.bak
|
||||
*.old
|
||||
# internal test/debug artifacts
|
||||
publish-*/
|
||||
payload/appdb_*.db
|
||||
payload/port9113_killer.*
|
||||
lib-prospero-pkg/test-stream/realbuild-out/
|
||||
|
||||
# PowerShell script
|
||||
convert_to_ico.ps1
|
||||
# legacy clients (WPF v5/v6, old MAUI app, empty stub template, old Android WIP)
|
||||
client/
|
||||
mobile/
|
||||
client-avalonia-android/
|
||||
|
||||
# dev scratch: homebrew pkg builds, extracted binaries — not for the repo
|
||||
homebrew/
|
||||
+385
@@ -1,5 +1,390 @@
|
||||
# Changelog - PS5 Upload Suite
|
||||
|
||||
## Version 4.2.1 - Bugfix Release (February 8, 2026)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
#### UI Scrolling Fix
|
||||
- **Fixed connection panel eating screen space** - Added `MaxHeight` constraint (250px) and `ScrollViewer` to the connection/payload settings panel
|
||||
- **Fixed header overflow** - Added `MaxHeight="120"` to header row
|
||||
- **Fixed content area too small** - Added `MinHeight="200"` to main content area
|
||||
- When the Auto-send Payload Expander was open, it consumed half the screen leaving no room for file lists
|
||||
|
||||
#### File Counting Fix
|
||||
- **Fixed chunked files never counting as completed** - `fileChunkCounts` was set to the actual number of chunks (e.g., 4 for a 2GB file), but `UploadFileParallelAsync` handles all chunks internally as a single Task. The main loop only saw 1 task completion per file, so chunked files never reached their completion threshold
|
||||
- Now correctly sets `fileChunkCounts = 1` per file since each file = 1 task
|
||||
|
||||
#### Progress Display Fix
|
||||
- **Fixed progress appearing frozen during heavy uploads** - All UI updates used `DispatcherPriority.Background` (lowest priority), which got starved when 24 parallel connections flooded the dispatcher queue
|
||||
- Upgraded `UpdateUploadStats` to `DispatcherPriority.Normal`
|
||||
- Upgraded per-file progress bars to `DispatcherPriority.Render`
|
||||
- Progress now updates smoothly even at 95%+ network saturation
|
||||
|
||||
---
|
||||
|
||||
## Version 4.2.0 - Game Mounter Integration (February 7, 2026)
|
||||
|
||||
### 🎮 Game Mounter Integration
|
||||
- **Mount Games Button** - New green "🎮 Mount Games" button in the client UI
|
||||
- One-click mounting of all uploaded games directly from the PS5 Upload Suite
|
||||
- Button disabled when disconnected, shows "⏳ Mounting..." during operation
|
||||
|
||||
### 🔍 Multi-Path Game Scanning
|
||||
- Scans **6 locations** for games:
|
||||
- `/data/etaHEN/games` (internal storage)
|
||||
- `/mnt/usb0/games` through `/mnt/usb3/games` (USB drives)
|
||||
- `/mnt/ext0/games` (M.2 SSD)
|
||||
- First pass counts total games, second pass processes them
|
||||
- PS5 notifications show real-time progress: "Mounting 3/10 (30%) - Game Name"
|
||||
|
||||
### 🔄 Smart Mount Features
|
||||
- **Duplicate Detection** - Same title ID across multiple drives mounted only once
|
||||
- **Auto-Cleanup** - `auto_unmount_deleted_games()` removes stale mount entries
|
||||
- **Already Mounted Detection** - Skips games that are already mounted via nullfs
|
||||
- **sce_sys Validation** - Verifies game directory has valid `sce_sys/` before mounting
|
||||
|
||||
### 🔧 Payload Changes
|
||||
- **New Command** - `CMD_MOUNT_GAMES` (0x30) added to protocol
|
||||
- **nullfs Mount** - Games mounted to `/system_ex/app/<title_id>`
|
||||
- **DRM Patching** - Automatic `applicationDrmType` fix in `param.json`
|
||||
- **Metadata Copy** - `sce_sys` copied to `/user/app/` and `/user/appmeta/`
|
||||
- **mount.lnk** - Tracks mount source path for cleanup and remount
|
||||
- **Safe Delete** - Replaced `system("rm -rf ...")` with `rmdir_recursive()` to prevent hangs
|
||||
- **remount_system_ex()** - Remounts `/system_ex` as writable for game installation
|
||||
|
||||
### 🖥️ Client Changes
|
||||
- **Protocol.cs** - Added `MountGames = 0x30` enum and `MountGamesAsync()` method
|
||||
- **MainWindow.xaml** - Green "🎮 Mount Games" button between Upload and Cancel
|
||||
- **MainWindow.xaml.cs** - Click handler with progress display and auto-registration
|
||||
- **Auto Registration** - If new games detected, automatically runs `game_mounter.elf` via shell for home screen registration
|
||||
|
||||
### ⚡ Client Improvements
|
||||
- **Real-Time Speed Display** - New sliding window algorithm (10 samples over 5 seconds) for accurate real-time upload speed
|
||||
- **Smoother ETA** - Blended speed calculation (60% real-time + 40% average) with better time formatting (hours/minutes/seconds)
|
||||
- **Speed Display** - Shows both real-time and average speed: `Speed: 95 MB/s (avg 88 MB/s)`
|
||||
- **Duplicate File Dialog** - New `DuplicateFileDialog` for handling file conflicts during upload
|
||||
- **Move Dialog Refactor** - Replaced inline move dialog code with reusable `ShowPathPickerDialogAsync()`
|
||||
- **FormatFileSize Refactor** - Moved to static `FileUtils.FormatFileSize()` class for code reuse
|
||||
- **ETA Smoothing** - Reduced `ETASmoothingFactor` from 0.3 to 0.15 for less jumpy ETA display
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
#### Payload Fixes
|
||||
- **Fixed IOVEC_ENTRY(NULL) crash** - `strlen(NULL)` with `-O3` optimization caused segfault in `remount_system_ex()`
|
||||
- **Fixed libSceAppInstUtil startup crash** - Library cannot be loaded in etaHEN payload context (crashes dynamic linker). Switched to standalone `game_mounter.elf` for registration
|
||||
- **Fixed dlopen crash** - `dlopen("libSceAppInstUtil.sprx")` crashes payload thread. Removed dynamic loading approach
|
||||
- **Removed system() calls** - `system("rm -rf ...")` replaced with safe recursive `rmdir_recursive()` to prevent payload hangs
|
||||
- **Fixed compile warnings** - Removed unused `#include <dlfcn.h>`, re-enabled `auto_unmount_deleted_games()`
|
||||
|
||||
#### Client Fixes
|
||||
- **Fixed Shell Terminal hardcoded IP** - Shell was connecting to hardcoded `192.168.0.160` instead of the configured PS5 IP address
|
||||
- **Fixed ETA jumping** - ETA no longer jumps erratically during uploads due to improved smoothing
|
||||
- **Fixed speed dropping to 0** - Sliding window prevents speed display from showing 0 during brief pauses
|
||||
- **Fixed files remaining counter** - Simplified calculation with `Math.Max(0, ...)` to prevent negative values
|
||||
|
||||
### 📝 Technical Notes
|
||||
- `libSceAppInstUtil.sprx` is **not available** in etaHEN payload context (both static linking and dlopen crash)
|
||||
- Game registration (`sceAppInstUtilAppInstallTitleDir`) is handled by the standalone `game_mounter.elf` which runs as a separate ELF with its own library dependencies
|
||||
- The upload suite payload handles nullfs mounting, metadata copy, and DRM patching — everything except the final system registration
|
||||
|
||||
---
|
||||
|
||||
## Version 4.1.0 - Memory Optimization & Large File Handling (February 5, 2026)
|
||||
|
||||
### 🧠 Memory & Performance Improvements
|
||||
|
||||
#### 1. Large File Count Support (156K+ Files)
|
||||
- **Fixed crash** with games containing 156,253+ files (e.g., Astrobot)
|
||||
- **Removed massive dictionary** - No longer creates 156K FileInfo objects at once
|
||||
- **Incremental calculation** - Total bytes calculated progressively during scan
|
||||
- **Memory efficient** - Handles unlimited file counts without OutOfMemoryException
|
||||
|
||||
#### 2. Lock-Free Progress Tracking
|
||||
- **ConcurrentDictionary** - Replaced Dictionary with thread-safe collections
|
||||
- **Zero lock contention** - Eliminated `lock (_progressLock)` blocks
|
||||
- **Atomic operations** - Uses `Interlocked.Add()` for progress updates
|
||||
- **Smooth UI** - No stuttering from lock contention
|
||||
|
||||
#### 3. Auto Memory Cleanup
|
||||
- **Completed files removed** - Dictionaries cleaned after file completion
|
||||
- **Reduced memory footprint** - Only active uploads tracked
|
||||
- **Prevents memory overflow** - Long-running uploads stay stable
|
||||
|
||||
#### 4. Reduced UI Stuttering
|
||||
- **Batch logging** - Small files logged every 50 completions (up from 25)
|
||||
- **Less UI pressure** - Fewer `Dispatcher.Invoke()` calls
|
||||
- **Smoother experience** - Especially noticeable with 100K+ files
|
||||
|
||||
### 🔧 Technical Changes
|
||||
- Changed `_fileProgressBytes` from Dictionary to ConcurrentDictionary
|
||||
- Changed `_fileChunkProgressBytes` from Dictionary to ConcurrentDictionary
|
||||
- Changed `_chunkLogLastBytes` from Dictionary to ConcurrentDictionary
|
||||
- Removed `fileSizeLookup` dictionary creation (caused OutOfMemoryException)
|
||||
- Increased `SmallFileLogBatchSize` from 25 to 50
|
||||
- Added dictionary cleanup in file completion handlers
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed application crash when uploading games with 156K+ files
|
||||
- Fixed UI stuttering during small file uploads
|
||||
- Fixed memory overflow from massive dictionary allocations
|
||||
- Fixed lock contention causing UI freezes
|
||||
|
||||
### 📊 Tested With
|
||||
- **Astrobot**: 156,253 files, 1,724 directories - **53 minutes total**
|
||||
- **Result**: Zero crashes, smooth upload, stable memory usage
|
||||
|
||||
---
|
||||
|
||||
## Version 4.0.0 - Retry Feature & Stability Improvements (January 27, 2026)
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
#### 🔄 Retry Failed Transfers
|
||||
- **Right-click context menu** on failed transfers
|
||||
- **Retry Upload** - Re-queue failed files for upload
|
||||
- **Remove from List** - Clear individual failed items
|
||||
- **Persistent paths** - Failed transfers store local and remote paths
|
||||
- **Smart retry** - Validates file existence before retry
|
||||
|
||||
#### 🗑️ Clear All Button Fix
|
||||
- **Transfer History Clear All** now works correctly
|
||||
- **Confirmation dialog** before clearing history
|
||||
- **Clears both** completed and failed transfers
|
||||
|
||||
### 🔧 Performance & Stability
|
||||
|
||||
#### Connection Stability
|
||||
- **Reverted 16MB buffer** - Caused connection drops with 16 parallel connections
|
||||
- **Stable 8MB buffer** - Proven reliable for high-speed transfers
|
||||
- **TCP NoDelay** - Already enabled for optimal performance
|
||||
- **16MB socket buffers** - Maintained for maximum throughput
|
||||
|
||||
#### Upload Performance
|
||||
- **Large files (>100MB)**: 30-36 MB/s per file
|
||||
- **Small files (<100MB)**: 8-12 MB/s per file
|
||||
- **4 parallel large files** - Prevents PS5 memory exhaustion
|
||||
- **16 total connections** - Optimal for mixed workloads
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed Clear All button not clearing transfer history
|
||||
- Fixed connection drops when using oversized buffers
|
||||
- Added proper event handlers for failed transfer context menu
|
||||
- Improved error handling for retry operations
|
||||
|
||||
### 📝 Code Quality
|
||||
- Added comprehensive inline documentation
|
||||
- Improved code organization and readability
|
||||
- Better error messages for failed operations
|
||||
- Enhanced UI feedback for user actions
|
||||
|
||||
---
|
||||
|
||||
## Version 3.3.0 - Smart Search & Enhanced Mobile (January 26, 2026)
|
||||
|
||||
### 🔍 Smart Search Feature
|
||||
- **Full Filesystem Indexing** - Index entire PS5 filesystem (200,000+ files)
|
||||
- **Instant Search** - Lightning-fast search with wildcards (`*.pkg`, `*loader*`)
|
||||
- **Size Filters** - Search by file size (`size:>1GB`, `size:<100MB`)
|
||||
- **Case-Insensitive** - Matches both filename and full path
|
||||
- **Navigate from Search** - Double-click results to jump to folder (Desktop) / Tap to navigate (Mobile)
|
||||
- **Index Status** - Real-time indexing progress with file/directory counts
|
||||
- **Smart Indexing** - Skips problematic directories (`/dev`, `/proc`, `/sys`)
|
||||
|
||||
### 📱 Mobile App v1.2
|
||||
- **🔍 Search Tab** - Full Smart Search functionality on Android
|
||||
- **Navigate to Location** - Tap search results to navigate to folder
|
||||
- **Copy Path** - Copy file paths to clipboard from search results
|
||||
- **Connection Manager** - Singleton pattern for reliable IP/port management
|
||||
- **Improved Navigation** - Fixed MainPage reference for seamless navigation
|
||||
|
||||
### 🖥️ Desktop Client Updates
|
||||
- **Search Tab** - New dedicated tab for Smart Search
|
||||
- **💻 Shell Terminal** - Execute commands directly on PS5
|
||||
- Run system commands remotely
|
||||
- Real-time output display
|
||||
- Command history
|
||||
- Working directory support
|
||||
- **Equal Panel Layout** - Local Files, PS5 Files, and Right panel now equal width
|
||||
- **Improved UI** - Index Status and buttons positioned above search box
|
||||
- **Debounced Search** - Smooth typing experience with 300ms debounce
|
||||
|
||||
### 🔧 Payload Updates
|
||||
- **Indexing Commands** - `CMD_INDEX_START` (0x40), `CMD_INDEX_STATUS` (0x41), `CMD_SEARCH_INDEX` (0x42)
|
||||
- **In-Memory Index** - Linked list structure for fast search
|
||||
- **Wildcard Matching** - Support for `*` and `?` wildcards
|
||||
- **Thread-Safe** - Mutex-protected index operations
|
||||
- **Root Path Support** - Can index from `/` (entire filesystem)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed indexing hangs when scanning root directory
|
||||
- Fixed search protocol compatibility between mobile and desktop
|
||||
- Fixed navigation stack issues in mobile app
|
||||
- Fixed nullable reference warnings in mobile code
|
||||
- Added proper error handling for index operations
|
||||
|
||||
---
|
||||
|
||||
## Version 3.2.1 - Privacy & Bug Fixes (January 24, 2026)
|
||||
|
||||
### 🔒 Privacy Improvements
|
||||
- **Removed disk space display** - No longer shows PS5 storage capacity (Free/Used/Total)
|
||||
- **Fixed storage query bug** - Resolved syntax errors in statvfs code that could cause crashes
|
||||
- **Privacy-focused** - Application no longer queries or displays sensitive storage information
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed potential crash from malformed storage query code
|
||||
- Removed unused protocol busy flag that caused compiler warnings
|
||||
- Cleaned up all storage-related UI elements and backend code
|
||||
|
||||
---
|
||||
|
||||
## Version 3.2.0 - Mobile Improvements & Bug Fixes (January 24, 2026)
|
||||
|
||||
### 🎯 Improvements
|
||||
|
||||
#### 💾 Accurate Storage Display
|
||||
- **Storage now matches PS5 UI** - Shows ~848 GB instead of raw 872 GB
|
||||
- **Accounts for reserved space** - Same calculation as PS5 system
|
||||
- Uses proper calculation: `(total_blocks - reserved) * block_size`
|
||||
|
||||
#### 📱 Mobile App Improvements
|
||||
- **Multi-select mode** - Toggle button (☑️/✅) to select multiple files/folders
|
||||
- **Folder browser** - Visual folder picker for Copy/Move destinations with recursive navigation
|
||||
- **Sorted file list** - Folders always displayed at top, then files (alphabetically)
|
||||
- **Exit confirmation** - Prompt dialog when pressing back button
|
||||
- **Delete folders** - Now supports folder deletion (not just files)
|
||||
- **Batch operations** - Delete/download multiple items at once
|
||||
|
||||
#### 3. 🐛 Bug Fixes
|
||||
- **Fixed empty folder deletion** - No more "Unexpected response" error when deleting empty directories
|
||||
- **Fixed Copy/Move crash** - No longer crashes when selecting destination folder (saved file info before browsing)
|
||||
- **Fixed multi-select count** - Correct item count in delete confirmation dialog
|
||||
- **Fixed folder navigation** - Can enter folders after actions complete (auto-exit multi-select mode)
|
||||
|
||||
---
|
||||
|
||||
## Version 3.1.0 - Parallel Chunked Uploads (January 2026)
|
||||
|
||||
### 🚀 Performance Improvements
|
||||
|
||||
#### 1. ⚡ Parallel Chunked Uploads (104 MB/s!)
|
||||
- **Large files (>100MB)** are now split into 500MB chunks
|
||||
- **4 parallel connections** upload chunks simultaneously
|
||||
- **Result:** ~104 MB/s upload speed (2x faster than before!)
|
||||
- **Fixed race condition:** First chunk creates file before others start
|
||||
- **Fixed closure bug:** Correct offset/size capture for parallel tasks
|
||||
|
||||
#### 2. 📊 Real-time Progress Display
|
||||
- **Speed display** now updates in real-time during chunked uploads
|
||||
- **ETA display** shows accurate time remaining
|
||||
- **Progress bar** correctly shows 0-100% for chunked files
|
||||
- No more speed dropping to 0 during large file uploads
|
||||
|
||||
#### 3. 💾 Storage Display Improvements
|
||||
- **Changed label** to "Data Storage (/data)" for clarity
|
||||
- **Uses statfs with f_bavail** for more accurate available space
|
||||
- **Real-time updates** every 5 seconds when connected
|
||||
|
||||
---
|
||||
|
||||
## Version 3.0.0 - Mobile Client & Path Fixes (January 2026)
|
||||
|
||||
### 🐛 Critical Bug Fixes
|
||||
|
||||
#### 1. 🔧 Path Normalization Fix
|
||||
- **Fixed:** Double-slash paths (`//mnt/ext1/...`) that caused directory creation to fail
|
||||
- **Affected:** All file operations (upload, download, delete, rename, copy, move)
|
||||
- **Solution:** Added `NormalizePath()` function in both client and server
|
||||
- **Result:** 100% reliable directory creation and file uploads
|
||||
|
||||
#### 2. 📱 Android Mobile Client
|
||||
- **NEW:** Full-featured Android app for PS5 file management
|
||||
- **Multi-PS5 Profiles** - Save and switch between multiple PS5 consoles
|
||||
- Upload files from phone to PS5
|
||||
- Download files from PS5 with Share option
|
||||
- Browse PS5 filesystem
|
||||
- Create new folders
|
||||
- Rename files/folders
|
||||
- Copy files/folders
|
||||
- Move files/folders
|
||||
- Delete files
|
||||
- Favorites for quick navigation
|
||||
- Debug Log with Copy to clipboard
|
||||
- Transfer History tracking
|
||||
|
||||
#### 3. 🛠️ Server Improvements
|
||||
- Path normalization in all handler functions
|
||||
- More robust error handling
|
||||
- Improved stability for parallel uploads
|
||||
|
||||
---
|
||||
|
||||
## Version 2.1.0 - Performance & History (January 2026)
|
||||
|
||||
### 🚀 Performance Optimizations
|
||||
|
||||
#### 1. ⚡ Massive Upload Speed Boost (88-110 MB/s)
|
||||
- **Server-side:** Replaced `fwrite()` with direct `write()` syscalls
|
||||
- **Client-side:** 8 parallel large file uploads (optimal for PS5 disk)
|
||||
- **Result:** 80-110 MB/s aggregate upload speed on Gigabit Ethernet
|
||||
- **Peak bursts:** Up to 2.05 GB/s when hitting disk cache
|
||||
- **Per-file:** 11-14 MB/s sustained per connection
|
||||
|
||||
#### 2. 📊 Transfer History
|
||||
- Complete history of all uploads and downloads
|
||||
- Success/Failed status tracking with error messages
|
||||
- Speed statistics (average, min, max)
|
||||
- Export to CSV/JSON for analysis
|
||||
- Persistent storage across sessions
|
||||
|
||||
#### 3. 🔄 Auto-Clear History on Startup
|
||||
- Optional checkbox to clear history automatically
|
||||
- Useful for keeping UI clean between sessions
|
||||
- Setting saved in `ps5_upload_settings.json`
|
||||
|
||||
#### 4. 🖥️ Maximized Window UI
|
||||
- Application opens in full-screen mode by default
|
||||
- Better visibility for large file transfers
|
||||
- Can be resized/restored as needed
|
||||
|
||||
### Technical Improvements
|
||||
- ✅ **16MB socket buffers** (up from 4MB) for maximum throughput
|
||||
- ✅ **Per-file mutex locking** - Parallel writes without race conditions
|
||||
- ✅ **File pre-allocation** - Reduces disk fragmentation for large files
|
||||
- ✅ **Direct syscalls** - Bypasses stdio buffering overhead
|
||||
|
||||
---
|
||||
|
||||
## Version 2.0.0 - Download & Multi-PS5 (January 2026)
|
||||
|
||||
### 🎉 4 Major New Features
|
||||
|
||||
#### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination selection
|
||||
- Real-time progress tracking with speed display
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
#### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button to reset
|
||||
|
||||
#### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent storage in JSON
|
||||
|
||||
#### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between different PS5 consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
---
|
||||
|
||||
## Version 1.3.0 - Stable Release (January 13, 2026)
|
||||
|
||||
### 🎯 Major Achievement
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# PS5 Upload Suite v2.0.0 - Feature-Rich Release
|
||||
|
||||
## 🎉 What's New - 4 Major Features Added!
|
||||
|
||||
### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination
|
||||
- Real-time progress with speed tracking
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button
|
||||
|
||||
### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent JSON storage
|
||||
|
||||
### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Server (PS5)
|
||||
1. Copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/`
|
||||
2. Load with elfldr
|
||||
3. Server starts on port 9113
|
||||
|
||||
### Client (Windows)
|
||||
1. Download `PS5Upload.exe`
|
||||
2. Run on Windows 10/11
|
||||
3. Enter PS5 IP and connect
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Performance
|
||||
|
||||
- **Upload Speed:** 100-120 MB/s (Gigabit Ethernet)
|
||||
- **Download Speed:** Same high-speed protocol
|
||||
- **Stability:** Zero connection drops (from v1.3.0)
|
||||
- **Tested:** 42,801+ files uploaded successfully
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### New Protocol Command
|
||||
- `CMD_DOWNLOAD_FILE` (0x13) - Download files from PS5
|
||||
|
||||
### Server Changes
|
||||
- Added sendfile-based download handler
|
||||
- Fixed LIST_DIR to return actual file sizes
|
||||
- Zero-copy file transfer
|
||||
|
||||
### Client Changes
|
||||
- 4 new UI panels (Search, Favorites, Profiles, Download)
|
||||
- JSON-based persistence (ps5_profiles.json, ps5_favorites.json)
|
||||
- Custom dark theme for all ComboBox dropdowns
|
||||
- ~500+ lines of new code
|
||||
|
||||
---
|
||||
|
||||
## ⬆️ Upgrading from v1.3.0
|
||||
|
||||
1. Replace server: Copy new `ps5_upload_server.elf` to PS5
|
||||
2. Replace client: Download new `PS5Upload.exe`
|
||||
3. Your settings will be preserved
|
||||
|
||||
**New files created:**
|
||||
- `ps5_profiles.json` - Saved PS5 profiles
|
||||
- `ps5_favorites.json` - Saved favorite paths
|
||||
|
||||
---
|
||||
|
||||
## 📋 Files in This Release
|
||||
|
||||
- `ps5_upload_server.elf` (129 KB) - PS5 server payload
|
||||
- `PS5Upload.exe` (8.2 MB) - Windows client (self-contained)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
**By Manos**
|
||||
|
||||
Special thanks to the PS5 homebrew community!
|
||||
|
||||
---
|
||||
|
||||
**Enjoy the new features!** 🚀
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
# PS5 Upload Protocol Specification
|
||||
|
||||
## Overview
|
||||
Custom binary protocol for high-speed file transfers between PC and PS5.
|
||||
Port: 9113
|
||||
|
||||
## Message Format
|
||||
|
||||
All messages follow this structure:
|
||||
```
|
||||
[Command (1 byte)][Data Length (4 bytes)][Data (variable)]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Client → Server
|
||||
|
||||
| Command | Value | Description | Data Format |
|
||||
|---------|-------|-------------|-------------|
|
||||
| PING | 0x01 | Test connection | None |
|
||||
| LIST_STORAGE | 0x02 | Get storage info | None |
|
||||
| LIST_DIR | 0x03 | List directory | Path (null-terminated string) |
|
||||
| CREATE_DIR | 0x04 | Create directory | Path (null-terminated string) |
|
||||
| DELETE_FILE | 0x05 | Delete file | Path (null-terminated string) |
|
||||
| DELETE_DIR | 0x06 | Delete directory | Path (null-terminated string) |
|
||||
| START_UPLOAD | 0x10 | Start file upload | Path (null-terminated) + File size (8 bytes) |
|
||||
| UPLOAD_CHUNK | 0x11 | Upload file chunk | Chunk data (raw bytes) |
|
||||
| END_UPLOAD | 0x12 | Finish upload | None |
|
||||
| SHUTDOWN | 0xFF | Shutdown server | None |
|
||||
|
||||
### Server → Client
|
||||
|
||||
| Response | Value | Description | Data Format |
|
||||
|----------|-------|-------------|-------------|
|
||||
| OK | 0x01 | Success | Optional message |
|
||||
| ERROR | 0x02 | Error occurred | Error message (null-terminated) |
|
||||
| DATA | 0x03 | Response data | Variable format |
|
||||
| READY | 0x04 | Ready for next chunk | None |
|
||||
| PROGRESS | 0x05 | Upload progress | Bytes written (8 bytes) |
|
||||
|
||||
## Data Formats
|
||||
|
||||
### LIST_STORAGE Response
|
||||
```
|
||||
[Drive count (4 bytes)]
|
||||
For each drive:
|
||||
[Path length (2 bytes)][Path][Total space (8 bytes)][Free space (8 bytes)]
|
||||
```
|
||||
|
||||
### LIST_DIR Response
|
||||
```
|
||||
[Entry count (4 bytes)]
|
||||
For each entry:
|
||||
[Type (1 byte: 0=file, 1=dir)][Name length (2 bytes)][Name][Size (8 bytes)][Timestamp (8 bytes)]
|
||||
```
|
||||
|
||||
## Flow Examples
|
||||
|
||||
### Upload File
|
||||
```
|
||||
Client: START_UPLOAD "/data/test.bin" size=1048576
|
||||
Server: READY
|
||||
Client: UPLOAD_CHUNK [4MB data]
|
||||
Server: PROGRESS bytes=4194304
|
||||
Client: UPLOAD_CHUNK [remaining data]
|
||||
Server: PROGRESS bytes=1048576
|
||||
Client: END_UPLOAD
|
||||
Server: OK
|
||||
```
|
||||
|
||||
### List Directory
|
||||
```
|
||||
Client: LIST_DIR "/data"
|
||||
Server: DATA [directory listing]
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- 4MB socket buffers
|
||||
- SO_NOSIGPIPE enabled
|
||||
- TCP_NODELAY for low latency
|
||||
- Direct disk writes (no temp files)
|
||||
- Streaming chunks (no full file buffering)
|
||||
Binary file not shown.
@@ -1,259 +1,261 @@
|
||||
# 🚀 PS5 Upload Suite - High-Speed File Transfer
|
||||
# 🎮 PS5 Suite v7.2.3 — Complete PS5 Management Platform
|
||||
|
||||
**By Manos**
|
||||
|
||||
**Version 2.0.0 - Feature-Rich Release**
|
||||
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, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, an integrated Homebrew Store, memory tools, fan control, kernel log, a remote shell — plus console LED control and DualSense lightbar support.
|
||||
|
||||
Custom high-speed file transfer system for PS5 with etaHEN. Achieves **100+ MB/s** transfer speeds using a custom binary protocol.
|
||||

|
||||
|
||||
⭐ **NEW in v2.0.0:** 4 Major Features Added - Download Files, File Search, Favorites/Bookmarks, Multi-PS5 Support!
|
||||
## 📸 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 Included
|
||||
## 📥 What's in this release
|
||||
|
||||
### 1. PS5 Server Payload
|
||||
- **File:** `payload/ps5_upload_server.elf`
|
||||
- **Port:** 9113
|
||||
- **Protocol:** Custom binary (optimized for speed)
|
||||
- **Features:**
|
||||
- 4MB socket buffers
|
||||
- SO_NOSIGPIPE enabled
|
||||
- TCP_NODELAY for low latency
|
||||
- Direct disk writes (no temp files)
|
||||
- Multi-threaded client handling
|
||||
|
||||
### 2. Windows GUI Client
|
||||
- **File:** `client/bin/Release/net6.0-windows/win-x64/publish/PS5Upload.exe`
|
||||
- **Framework:** .NET 6.0 WPF
|
||||
- **Features:**
|
||||
- Modern dark theme UI
|
||||
- Drag & drop file/folder upload
|
||||
- Browse PS5 filesystem
|
||||
- Real-time upload progress
|
||||
- Speed indicator (MB/s)
|
||||
- Storage space display
|
||||
- Recursive folder upload
|
||||
| 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) |
|
||||
| `PS5Suite.apk` | **Android** — signed APK, full feature parity with desktop |
|
||||
| `ps5_suite_server.elf` | **Required** on-console server component |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## 🚀 Setup (2 minutes)
|
||||
|
||||
### Step 1: Load PS5 Payload
|
||||
1. Load a jailbreak / payload loader on the PS5 — tested with **etaHEN** and **Kstuff** (any payload loader that accepts ELFs works).
|
||||
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**.
|
||||
|
||||
1. Copy `payload/ps5_upload_server.elf` to your PS5:
|
||||
```
|
||||
/data/etaHEN/payloads/ps5_upload_server.elf
|
||||
```
|
||||
|
||||
2. Load the payload with elfldr
|
||||
|
||||
3. You should see notification:
|
||||
```
|
||||
PS5 Upload Server: 192.168.0.XXX:9113 - By Manos
|
||||
```
|
||||
|
||||
### Step 2: Run Windows Client
|
||||
|
||||
1. Run `PS5Upload.exe` on your PC
|
||||
|
||||
2. Enter your PS5's IP address (e.g., `192.168.0.160`)
|
||||
|
||||
3. Click **Connect**
|
||||
|
||||
4. Browse files/folders or drag & drop
|
||||
|
||||
5. Click **Upload to PS5**
|
||||
💾 Save your console(s) as named **connection profiles** for one-click reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Features
|
||||
## 📂 EVERYTHING the program does — feature by feature
|
||||
|
||||
### PS5 Server
|
||||
✅ **High-Speed Protocol** - Custom binary protocol
|
||||
✅ **4MB Buffers** - Maximum throughput
|
||||
✅ **Direct Disk I/O** - No temp files
|
||||
✅ **Multi-threaded** - Handle multiple clients
|
||||
✅ **Robust Error Handling** - Graceful failures
|
||||
### 🔌 Top bar / Connection
|
||||
|
||||
### Windows Client
|
||||
✅ **Modern UI** - Dark theme, clean design
|
||||
✅ **Drag & Drop** - Files and folders
|
||||
✅ **Browse PS5** - Navigate filesystem
|
||||
✅ **Real-time Progress** - Speed & percentage
|
||||
✅ **Folder Upload** - Recursive directory upload
|
||||
✅ **Storage Info** - Free space display
|
||||
✅ **Download Files** - Download from PS5 to PC (NEW v2.0)
|
||||
✅ **File Search** - Real-time filtering of PS5 files (NEW v2.0)
|
||||
✅ **Favorites/Bookmarks** - Quick navigation to saved paths (NEW v2.0)
|
||||
✅ **Multi-PS5 Support** - Save and switch between multiple PS5 profiles (NEW v2.0)
|
||||
- **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. ❤️
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance
|
||||
### 📁 FILE TRANSFER (main screen — dual browser)
|
||||
|
||||
### v1.3.0 Tested Results:
|
||||
- ✅ **42,801 files** uploaded successfully
|
||||
- ✅ **Zero errors** - 100% success rate
|
||||
- ✅ **60-150 MB/s** sustained throughput
|
||||
- ✅ **Peak:** 190 MB/s per connection
|
||||
- ✅ **Fully responsive UI** throughout upload
|
||||
- ✅ **No memory leaks** - stable operation
|
||||
Two side-by-side panes: your PC on the left, the PS5 on the right.
|
||||
|
||||
| Network | Expected Speed |
|
||||
|---------|----------------|
|
||||
| **Gigabit Ethernet** | 100-120 MB/s |
|
||||
| **WiFi 6 (5GHz)** | 60-80 MB/s |
|
||||
| **WiFi 5 (5GHz)** | 40-60 MB/s |
|
||||
**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.
|
||||
|
||||
**Note:** Speeds depend on network quality and PS5 disk performance.
|
||||
**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**.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Building from Source
|
||||
### 🎮 GAMES (sidebar)
|
||||
|
||||
### PS5 Payload
|
||||
|
||||
Requirements:
|
||||
- PS5 SDK (prospero-clang)
|
||||
- WSL or Linux
|
||||
|
||||
```bash
|
||||
cd payload
|
||||
bash compile.sh
|
||||
```
|
||||
|
||||
### Windows Client
|
||||
|
||||
Requirements:
|
||||
- .NET 6.0 SDK or later
|
||||
- Windows 10/11
|
||||
|
||||
```cmd
|
||||
cd client
|
||||
build.bat
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```cmd
|
||||
dotnet publish -c Release -r win-x64 --self-contained true
|
||||
```
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Protocol Specification
|
||||
### � GAMES → Homebrew Store
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for detailed protocol documentation.
|
||||
|
||||
### Commands
|
||||
- `PING` - Test connection
|
||||
- `LIST_STORAGE` - Get storage info
|
||||
- `LIST_DIR` - List directory contents
|
||||
- `CREATE_DIR` - Create directory
|
||||
- `DELETE_FILE` - Delete file
|
||||
- `DELETE_DIR` - Delete directory
|
||||
- `START_UPLOAD` - Begin file upload
|
||||
- `UPLOAD_CHUNK` - Upload file chunk
|
||||
- `END_UPLOAD` - Finish upload
|
||||
- `DOWNLOAD_FILE` - Download file from PS5 (NEW v2.0)
|
||||
- `SHUTDOWN` - Shutdown server
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
### �💾 SAVES & MEDIA → Saves
|
||||
|
||||
### Connection Failed
|
||||
- Make sure PS5 payload is running
|
||||
- Check PS5 IP address is correct
|
||||
- Verify both devices are on same network
|
||||
- Check firewall settings
|
||||
|
||||
### Slow Upload Speed
|
||||
- Use wired Ethernet connection (not WiFi)
|
||||
- Close other network applications
|
||||
- Check PS5 disk health
|
||||
|
||||
### Upload Fails
|
||||
- Check PS5 has enough free space
|
||||
- Verify destination path exists
|
||||
- Check file permissions
|
||||
- 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**.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Notes
|
||||
### 📷 SAVES & MEDIA → Screenshots
|
||||
|
||||
- Server only accepts connections from local network
|
||||
- No authentication required (local network only)
|
||||
- SHUTDOWN command only works from localhost
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 📝 What's New in v2.0.0
|
||||
### 🖥️ SYSTEM INFO (live hardware monitor)
|
||||
|
||||
### 🎉 4 Major New Features:
|
||||
Everything is read **live from the console** — nothing hardcoded; values the console can't report show as unavailable.
|
||||
|
||||
#### 1. 📥 Download Files (PS5 → PC)
|
||||
- Right-click any file → "⬇️ Download to PC"
|
||||
- Save file dialog for destination selection
|
||||
- Real-time progress tracking with speed display
|
||||
- Optimized with sendfile for maximum speed
|
||||
|
||||
#### 2. 🔍 File Search
|
||||
- Search box in PS5 Files panel
|
||||
- Real-time filtering as you type
|
||||
- Case-insensitive search
|
||||
- Quick "Clear" button to reset
|
||||
|
||||
#### 3. ⭐ Favorites/Bookmarks
|
||||
- Save frequently used PS5 paths
|
||||
- Quick dropdown navigation
|
||||
- Add/Remove favorite paths
|
||||
- Persistent storage in JSON
|
||||
|
||||
#### 4. 🎮 Multi-PS5 Support
|
||||
- Save multiple PS5 profiles (IP + name)
|
||||
- Quick switch between different PS5 consoles
|
||||
- Dropdown profile selector
|
||||
- Persistent profile storage
|
||||
|
||||
### Previous Stability (v1.3.0):
|
||||
- ✅ **Zero connection drops** - 5 minute socket timeout + aggressive keepalive
|
||||
- ✅ **Fully responsive UI** - Async updates + throttled logging
|
||||
- ✅ **No memory leaks** - Proper resource disposal
|
||||
- ✅ **Tested:** 42,801 files uploaded with 100% success rate
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
### 🎯 APPS (App Manager v2)
|
||||
|
||||
MIT License - Free to use and modify
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## 👤 Author
|
||||
### ⚡ POWER & DEVICES
|
||||
|
||||
**Manos**
|
||||
|
||||
Created with ❤️ for the PS5 homebrew community
|
||||
- **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.
|
||||
- **📀 Disc Dump** — dump the inserted BD disc to `/user/disc` with live progress + cancel.
|
||||
- **🔊 Beeper** — console beep / mute.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
### 🔧 TOOLS → Shell
|
||||
|
||||
- PS5 SDK
|
||||
- etaHEN
|
||||
- Inspired by ps5upload by PhantomPtr
|
||||
- PS5 homebrew community
|
||||
- Run shell commands directly on the PS5 — type, Enter, output streams back.
|
||||
- Scrollable session history, **🗑️ Clear**, **💾 Save Log** to a PC file.
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
### 🔧 TOOLS → Search
|
||||
|
||||
For issues or questions, please open an issue on GitHub.
|
||||
- **🔄 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.
|
||||
|
||||
---
|
||||
|
||||
**Enjoy blazing-fast file transfers on your PS5!** 🚀
|
||||
### 🔧 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 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, store, 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.
|
||||
|
||||
**Enjoy. — Manos**
|
||||
@@ -1,166 +0,0 @@
|
||||
# 🎉 PS5 Upload Suite v2.0.0 - Feature-Rich Release
|
||||
|
||||
## 🚀 What's New
|
||||
|
||||
This is a **major feature release** adding 4 powerful new capabilities to the PS5 Upload Suite!
|
||||
|
||||
---
|
||||
|
||||
## ✨ New Features
|
||||
|
||||
### 1. 📥 Download Files (PS5 → PC)
|
||||
|
||||
Finally! You can now download files from your PS5 back to your PC.
|
||||
|
||||
**How to use:**
|
||||
- Right-click any file in PS5 Files panel
|
||||
- Select "⬇️ Download to PC"
|
||||
- Choose where to save the file
|
||||
- Watch the progress with real-time speed tracking
|
||||
|
||||
**Technical Details:**
|
||||
- Uses sendfile optimization for maximum speed
|
||||
- Zero-copy transfer on PS5 side
|
||||
- Same high-speed protocol as uploads
|
||||
- Full progress tracking
|
||||
|
||||
---
|
||||
|
||||
### 2. 🔍 File Search
|
||||
|
||||
Quickly find files in large directories without scrolling.
|
||||
|
||||
**How to use:**
|
||||
- Type in the Search box below Current Path
|
||||
- Files are filtered in real-time as you type
|
||||
- Case-insensitive search
|
||||
- Click "✖ Clear" to reset
|
||||
|
||||
**Perfect for:**
|
||||
- Finding specific game saves
|
||||
- Locating config files
|
||||
- Searching through large mod folders
|
||||
|
||||
---
|
||||
|
||||
### 3. ⭐ Favorites/Bookmarks
|
||||
|
||||
Save your frequently used PS5 paths for instant navigation.
|
||||
|
||||
**How to use:**
|
||||
- Navigate to any path (e.g., `/data/games`)
|
||||
- Click "⭐ Add" to save it
|
||||
- Use the Favorites dropdown to quickly jump to saved paths
|
||||
- Click "🗑️ Remove" to delete a favorite
|
||||
|
||||
**Saved paths persist between sessions!**
|
||||
|
||||
---
|
||||
|
||||
### 4. 🎮 Multi-PS5 Support
|
||||
|
||||
Manage multiple PS5 consoles with ease.
|
||||
|
||||
**How to use:**
|
||||
- Enter a PS5 IP address
|
||||
- Click "💾 Save Profile" and give it a name (e.g., "Living Room PS5")
|
||||
- Use the Profile dropdown to quickly switch between PS5s
|
||||
- Click "🗑️ Delete Profile" to remove saved profiles
|
||||
|
||||
**Perfect for:**
|
||||
- Users with multiple PS5 consoles
|
||||
- Testing on different systems
|
||||
- Switching between home and friend's PS5
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
|
||||
### Server-Side (payload)
|
||||
- Added `CMD_DOWNLOAD_FILE` (0x13) command
|
||||
- Implemented sendfile-based download handler
|
||||
- Fixed `LIST_DIR` to return actual file sizes (not 0)
|
||||
- Zero-copy file transfer for downloads
|
||||
|
||||
### Client-Side
|
||||
- Added `DownloadFileAsync()` method to Protocol
|
||||
- Implemented search filtering with `ObservableCollection`
|
||||
- Added JSON-based profile and favorites persistence
|
||||
- Custom ComboBox templates for dark theme consistency
|
||||
- Improved UI responsiveness
|
||||
|
||||
---
|
||||
|
||||
## 📊 Statistics
|
||||
|
||||
**Lines of Code Added:** ~500+ lines
|
||||
**New Protocol Commands:** 1 (DOWNLOAD_FILE)
|
||||
**New UI Elements:** 3 panels (Search, Favorites, Profiles)
|
||||
**Persistent Storage Files:** 2 (ps5_profiles.json, ps5_favorites.json)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Improvements
|
||||
|
||||
- All ComboBox dropdowns now have proper dark theme
|
||||
- All buttons show full text without cutoff
|
||||
- Consistent styling across all new features
|
||||
- Responsive layout that adapts to content
|
||||
|
||||
---
|
||||
|
||||
## 📦 Files Included
|
||||
|
||||
### Server
|
||||
- `payload/ps5_upload_server.elf` - Updated with download support
|
||||
|
||||
### Client
|
||||
- `client/bin/Release/net6.0-windows/win-x64/PS5Upload.exe` - Full v2.0 client
|
||||
|
||||
---
|
||||
|
||||
## ⬆️ Upgrading from v1.3.0
|
||||
|
||||
1. **Replace server payload:**
|
||||
- Copy new `ps5_upload_server.elf` to `/data/etaHEN/payloads/`
|
||||
- Reload with elfldr
|
||||
|
||||
2. **Replace client:**
|
||||
- Close old PS5Upload.exe
|
||||
- Replace with new version
|
||||
- Your settings will be preserved
|
||||
|
||||
**Note:** v2.0 creates two new files:
|
||||
- `ps5_profiles.json` - Saved PS5 profiles
|
||||
- `ps5_favorites.json` - Saved favorite paths
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Issues
|
||||
|
||||
None reported yet! This is a stable release built on top of the rock-solid v1.3.0 foundation.
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Features (Not in v2.0)
|
||||
|
||||
Still on the roadmap:
|
||||
- Disk Space Warning before upload
|
||||
- Duplicate File Detection
|
||||
- File Verification (SHA256)
|
||||
- Resume Interrupted Uploads
|
||||
- Queue Management (reorder, pause)
|
||||
- Transfer History
|
||||
- Scheduled Uploads
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
Special thanks to the PS5 homebrew community for testing and feedback!
|
||||
|
||||
---
|
||||
|
||||
**Enjoy the new features!** 🚀
|
||||
|
||||
**- Manos**
|
||||
@@ -0,0 +1,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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,21 @@
|
||||
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 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>7</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>7.2.3</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>
|
||||
File renamed without changes.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
|
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();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,754 @@
|
||||
<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>🛒 Homebrew Store</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,*">
|
||||
<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>
|
||||
<ListBox Grid.Row="1" 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,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="Mounted Games" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="RefreshGamesButton_Click"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="10,6">
|
||||
<Button Content="🎮 Mount All" Background="#007ACC" Foreground="White" Padding="12" Click="MountGamesButton_Click"/>
|
||||
<Button Content="▶️ Launch" Background="#3E3E42" Foreground="White" Padding="12" Click="LaunchGameButton_Click"/>
|
||||
<Button Content="🗑️ Unmount" Background="#3E3E42" Foreground="White" Padding="12" Click="UnmountGameButton_Click"/>
|
||||
</StackPanel>
|
||||
<ListBox Grid.Row="2" 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">
|
||||
<TextBlock Text="🎮" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="12" Foreground="#0E9CFF"/>
|
||||
<TextBlock Text="{Binding SizeText}" FontSize="11" Foreground="#808080"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Store Tab -->
|
||||
<Panel x:Name="PageStore" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Background="#252526" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="StoreSearchBox" Watermark="Search homebrew..."
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
|
||||
TextChanged="StoreSearch_TextChanged"/>
|
||||
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="StoreRefresh_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="StoreStatusText" Text="Tap 🔄 to fetch pkg-zone catalog" Foreground="#808080" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ListBox Grid.Row="1" x:Name="StoreListBox" 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 Cover}" 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 Id}" FontSize="11" Foreground="#0E9CFF"/>
|
||||
<TextBlock FontSize="10" Foreground="#808080">
|
||||
<Run Text="{Binding Version}"/><Run Text=" · "/><Run Text="{Binding Author}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="📥" Background="#007ACC" Foreground="White"
|
||||
Padding="8" VerticalAlignment="Center" Tag="{Binding Id}" Click="StoreInstall_Click"/>
|
||||
</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>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="🔓 Mount" Background="#007ACC" Foreground="White" Padding="10" Click="SaveMountButton_Click"/>
|
||||
<Button Content="🔒 Unmount" Background="#3E3E42" Foreground="White" Padding="10" Click="SaveUnmountButton_Click"/>
|
||||
<Button Content="🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Click="SaveDeleteButton_Click"/>
|
||||
</StackPanel>
|
||||
<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>
|
||||
<Button Content="📸 Capture Screenshot" Background="#007ACC" Foreground="White"
|
||||
Padding="12" HorizontalAlignment="Stretch" Click="CaptureScreenshotButton_Click"/>
|
||||
<ListBox x:Name="ScreenshotsListBox" Background="#1E1E1E" MaxHeight="260">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
|
||||
<StackPanel>
|
||||
<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>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<Button Content="🗑️ Delete Selected" Background="#DC3545" Foreground="White"
|
||||
Padding="10" HorizontalAlignment="Stretch" Click="ScreenshotDeleteButton_Click"/>
|
||||
</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"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
|
||||
<Button Content="Cool 50°" Background="#3E3E42" Padding="10" Click="FanPreset_Click"/>
|
||||
<TextBlock x:Name="FanSliderText" Text="60°C" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#0E9CFF" VerticalAlignment="Center" MinWidth="60"/>
|
||||
<Button Content="Quiet 70°" Background="#3E3E42" Padding="10" Click="FanPreset_Click"/>
|
||||
</StackPanel>
|
||||
<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"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox x:Name="MemPidBox" Watermark="pid" Width="100"
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<Button Content="📋 Regions" Background="#3E3E42" Padding="8" Click="MemRegions_Click"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox x:Name="MemAddrBox" Watermark="0xaddr" Width="150"
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<TextBox x:Name="MemLenBox" Watermark="len" Width="80"
|
||||
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
|
||||
<Button Content="📖 Read" Background="#007ACC" Padding="8" Click="MemRead_Click"/>
|
||||
</StackPanel>
|
||||
<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>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
|
||||
<Button Content="⏸ Suspend" Background="#3E3E42" Padding="8" Click="AppSuspend_Click"/>
|
||||
<Button Content="▶ Resume" Background="#3E3E42" Padding="8" Click="AppResume_Click"/>
|
||||
<Button Content="🗑 Kill" Background="#DC3545" Foreground="White" Padding="8" Click="AppKill_Click"/>
|
||||
</StackPanel>
|
||||
</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"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="🔄 Reboot" Background="#3E3E42" Foreground="White" Padding="10" Click="PowerReboot_Click"/>
|
||||
<Button Content="⏻ Shutdown" Background="#DC3545" Foreground="White" Padding="10" Click="PowerShutdown_Click"/>
|
||||
</StackPanel>
|
||||
</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"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Bright" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="0"/>
|
||||
<Button Content="Medium" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="1"/>
|
||||
<Button Content="Dim" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="2"/>
|
||||
</StackPanel>
|
||||
<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>
|
||||
|
||||
<!-- Disc Dump Card -->
|
||||
<Border Background="#252526" CornerRadius="8" Padding="15">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="💿 Disc Dump" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="▶ Start" Background="#007ACC" Foreground="White" Padding="10" Click="DiscDumpStart_Click"/>
|
||||
<Button Content="⏹ Cancel" Background="#DC3545" Foreground="White" Padding="10" Click="DiscDumpCancel_Click"/>
|
||||
</StackPanel>
|
||||
<ProgressBar x:Name="DiscDumpBar" Height="8" Minimum="0" Maximum="100" Value="0"
|
||||
Background="#1E1E1E" Foreground="#0E9CFF"/>
|
||||
<TextBlock x:Name="DiscDumpStatus" Text="Idle — dumps /mnt/disc to /user/data/disc_dumps"
|
||||
Foreground="#808080" FontSize="11" TextWrapping="Wrap"/>
|
||||
</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>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
private static readonly string LogFilePath = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
$"ps5suite_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
|
||||
|
||||
private static readonly object _logLock = new object();
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
LogToFile("=== Application Started ===");
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||
{
|
||||
#if ANDROID
|
||||
// On Android there is no real Window host — the single view embeds controls.
|
||||
// Build the MainWindow (for its code-behind + dialog ownership) but host its
|
||||
// content directly in the single view; a TopLevel can't be a child control.
|
||||
var window = new MainWindow();
|
||||
if (window.Content is Avalonia.Controls.Control content)
|
||||
{
|
||||
window.Content = null;
|
||||
singleView.MainView = content;
|
||||
}
|
||||
#else
|
||||
singleView.MainView = new MainWindow();
|
||||
#endif
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
public static void LogToFile(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
File.AppendAllText(LogFilePath, $"[{timestamp}] {message}\n");
|
||||
}
|
||||
}
|
||||
catch { /* Ignore logging errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.DuplicateFileDialog"
|
||||
Title="Duplicate File Found"
|
||||
Width="500" Height="280"
|
||||
Icon="/ps5_logo.ico"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="#1E1E1E"
|
||||
CanResize="False">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="⚠️ A file with this name already exists on the PS5:"
|
||||
Foreground="White" FontSize="14" Margin="0,0,0,10"/>
|
||||
|
||||
<TextBlock Grid.Row="1" x:Name="FileNameText" Text=""
|
||||
Foreground="#FFD700" FontWeight="Bold" FontSize="13" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Margin="0,0,0,15">
|
||||
<TextBlock x:Name="LocalSizeText" Foreground="#AAAAAA" FontSize="12"/>
|
||||
<TextBlock x:Name="RemoteSizeText" Foreground="#AAAAAA" FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="What would you like to do?"
|
||||
Foreground="White" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
|
||||
<Button Content="Replace" Width="90" Click="ReplaceButton_Click"/>
|
||||
<Button Content="Skip" Width="90" Click="SkipButton_Click"/>
|
||||
<Button Content="Replace All" Width="100" Click="ReplaceAllButton_Click"/>
|
||||
<Button Content="Skip All" Width="90" Click="SkipAllButton_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,47 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class DuplicateFileDialog : Window
|
||||
{
|
||||
public enum FileAction { Replace, Skip, ReplaceAll, SkipAll }
|
||||
public FileAction UserAction { get; private set; } = FileAction.Skip;
|
||||
|
||||
public DuplicateFileDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public DuplicateFileDialog(string fileName, long localSize, long remoteSize) : this()
|
||||
{
|
||||
FileNameText.Text = fileName;
|
||||
LocalSizeText.Text = $"Local file size: {FileUtils.FormatFileSize(localSize)}";
|
||||
RemoteSizeText.Text = $"Remote file size: {FileUtils.FormatFileSize(remoteSize)}";
|
||||
}
|
||||
|
||||
private void ReplaceButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Replace;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SkipButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Skip;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ReplaceAllButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.ReplaceAll;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SkipAllButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.SkipAll;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.GameDetailsWindow"
|
||||
Title="Game Details"
|
||||
Height="720" Width="860"
|
||||
Icon="/ps5_logo.ico"
|
||||
Background="#1E1E1E"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="True">
|
||||
<Grid>
|
||||
<!-- Cover Art Background -->
|
||||
<Image x:Name="BackgroundCoverImage"
|
||||
Stretch="UniformToFill"
|
||||
Opacity="0.25"/>
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<Border>
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Color="#CC1E1E1E" Offset="0"/>
|
||||
<GradientStop Color="#E61E1E1E" Offset="0.5"/>
|
||||
<GradientStop Color="#FF1E1E1E" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header: icon + title -->
|
||||
<Border Grid.Row="0" Background="#CC2D2D30" CornerRadius="6" Padding="15">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Width="128" Height="128" CornerRadius="8"
|
||||
Background="#3E3E42" ClipToBounds="True" Margin="0,0,20,0">
|
||||
<Grid>
|
||||
<TextBlock Text="🎮" FontSize="64"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Image x:Name="GameIconImage" Stretch="UniformToFill"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GameNameText" Text="Game Name"
|
||||
FontSize="22" FontWeight="Bold" Foreground="White"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<TextBlock x:Name="TitleIdText" Text="TITLE_ID"
|
||||
FontSize="14" FontWeight="SemiBold" Foreground="#007ACC"/>
|
||||
<TextBlock Text=" • " Foreground="#666666" FontSize="14"/>
|
||||
<TextBlock x:Name="RegionText" Text="Region"
|
||||
FontSize="14" Foreground="#FFC107"/>
|
||||
<TextBlock Text=" • " Foreground="#666666" FontSize="14"/>
|
||||
<TextBlock x:Name="StatusText" Text="Status"
|
||||
FontSize="14" Foreground="#28A745"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Details panel -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0,15,0,15">
|
||||
<StackPanel>
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="📊 Size & Storage" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#9CDCFE" Margin="0,0,0,8"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="140"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Total size:" Foreground="#999999" Margin="0,0,0,4"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="TotalSizeText" Text="-" Foreground="White" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="eboot.bin:" Foreground="#999999" Margin="0,0,0,4"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="EbootSizeText" Text="-" Foreground="White"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="📁 Paths" FontSize="14" FontWeight="Bold" Foreground="#9CDCFE" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Source path:" Foreground="#999999"/>
|
||||
<TextBlock x:Name="SourcePathText" Text="-" Foreground="White" TextWrapping="Wrap" Margin="0,2,0,8"/>
|
||||
<TextBlock Text="Install date:" Foreground="#999999"/>
|
||||
<TextBlock x:Name="InstallDateText" Text="-" Foreground="White" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="🌐 Online Info (PSN Store)" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#9CDCFE" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" x:Name="FetchPsnInfoButton"
|
||||
Content="🔍 Fetch from Internet"
|
||||
Click="FetchPsnInfoButton_Click"
|
||||
Background="#007ACC" Foreground="White"
|
||||
Padding="10,4" BorderThickness="0"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="PsnStatusText" Text="Click the button above to fetch online info"
|
||||
Foreground="#888888" FontStyle="Italic" Margin="0,6,0,0" TextWrapping="Wrap"/>
|
||||
<StackPanel x:Name="PsnInfoPanel" IsVisible="False" Margin="0,8,0,0">
|
||||
<TextBlock x:Name="PsnTitleText" FontSize="15" FontWeight="Bold"
|
||||
Foreground="White" Margin="0,0,0,4" TextWrapping="Wrap"/>
|
||||
<TextBlock x:Name="PsnPublisherText" Foreground="#9CDCFE" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="PsnReleaseDateText" Foreground="#FFC107" Margin="0,0,0,6"/>
|
||||
<ScrollViewer MaxHeight="180" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<TextBlock x:Name="PsnDescriptionText" Foreground="#CCCCCC" TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
<Button x:Name="OpenInBrowserButton"
|
||||
Content="🌍 Search on PSN Store (browser)"
|
||||
Click="OpenInBrowserButton_Click"
|
||||
Background="#3E3E42" Foreground="White"
|
||||
Padding="10,4" BorderThickness="0"
|
||||
HorizontalAlignment="Left" Margin="0,10,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="#CC2D2D30" CornerRadius="6" Padding="15">
|
||||
<StackPanel>
|
||||
<TextBlock Text="📜 param.json" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#9CDCFE" Margin="0,0,0,8"/>
|
||||
<TextBox x:Name="ParamJsonText"
|
||||
IsReadOnly="True"
|
||||
TextWrapping="Wrap"
|
||||
Background="#1E1E1E"
|
||||
Foreground="#CCCCCC"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#3E3E42"
|
||||
Padding="8"
|
||||
FontFamily="Consolas"
|
||||
FontSize="11"
|
||||
MinHeight="120"
|
||||
MaxHeight="240"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Close" Click="CloseButton_Click"
|
||||
Background="#007ACC" Foreground="White"
|
||||
Padding="20,8" BorderThickness="0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class GameDetailsWindow : Window
|
||||
{
|
||||
private readonly PS5MountedGame _game;
|
||||
private readonly PS5Protocol? _protocol;
|
||||
private readonly string _paramJsonRaw;
|
||||
private static readonly HttpClient _http = CreateHttpClient();
|
||||
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
return client;
|
||||
}
|
||||
|
||||
public GameDetailsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_game = new PS5MountedGame();
|
||||
_paramJsonRaw = "";
|
||||
}
|
||||
|
||||
public GameDetailsWindow(PS5MountedGame game, Dictionary<string, string> details, PS5Protocol? protocol = null) : this()
|
||||
{
|
||||
_game = game;
|
||||
_protocol = protocol;
|
||||
_paramJsonRaw = details.TryGetValue("param_json", out var pj) ? pj : "";
|
||||
|
||||
if (game.Icon != null) GameIconImage.Source = game.Icon;
|
||||
GameNameText.Text = details.TryGetValue("name", out var n) ? n : game.Name;
|
||||
TitleIdText.Text = game.TitleId;
|
||||
RegionText.Text = details.TryGetValue("region", out var r) ? r : game.Region;
|
||||
bool isActive = details.TryGetValue("is_active", out var act) && act == "1";
|
||||
StatusText.Text = isActive ? "✓ Mounted" : "✗ Not Mounted";
|
||||
StatusText.Foreground = isActive
|
||||
? new SolidColorBrush(Color.FromRgb(0x28, 0xA7, 0x45))
|
||||
: new SolidColorBrush(Color.FromRgb(0xDC, 0x35, 0x45));
|
||||
|
||||
if (details.TryGetValue("total_size", out var ts) && ulong.TryParse(ts, out ulong totalBytes))
|
||||
TotalSizeText.Text = FormatSize(totalBytes);
|
||||
if (details.TryGetValue("eboot_size", out var es) && ulong.TryParse(es, out ulong ebootBytes))
|
||||
EbootSizeText.Text = FormatSize(ebootBytes);
|
||||
|
||||
SourcePathText.Text = details.TryGetValue("path", out var p) ? p : game.Path;
|
||||
InstallDateText.Text = details.TryGetValue("install_date", out var d) ? d : "Unknown";
|
||||
|
||||
ParamJsonText.Text = details.TryGetValue("param_json", out var param) ? FormatJson(param) : "(not available)";
|
||||
|
||||
_ = LoadCoverArtAsync();
|
||||
}
|
||||
|
||||
private async Task LoadCoverArtAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) return;
|
||||
try
|
||||
{
|
||||
var picBytes = await _protocol.GetGamePicAsync(_game.TitleId, 0);
|
||||
if (picBytes == null || picBytes.Length == 0) return;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream(picBytes);
|
||||
var bitmap = new Bitmap(ms);
|
||||
BackgroundCoverImage.Source = bitmap;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void OpenInBrowserButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
string query = !string.IsNullOrWhiteSpace(_game.Name) && _game.Name != "Unknown" ? _game.Name : _game.TitleId;
|
||||
string url = $"https://store.playstation.com/en-gb/search/{Uri.EscapeDataString(query)}";
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async void FetchPsnInfoButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
FetchPsnInfoButton.IsEnabled = false;
|
||||
PsnStatusText.Text = "Fetching from PSN Store...";
|
||||
PsnInfoPanel.IsVisible = false;
|
||||
|
||||
try
|
||||
{
|
||||
var info = await FetchPsnInfoAsync(_game.TitleId);
|
||||
if (info == null)
|
||||
{
|
||||
PsnStatusText.Text = "❌ Not found on PSN Store. Try the browser search button below.";
|
||||
return;
|
||||
}
|
||||
PsnTitleText.Text = info.Title;
|
||||
PsnPublisherText.Text = string.IsNullOrEmpty(info.Publisher) ? "" : $"🏢 {info.Publisher}";
|
||||
PsnReleaseDateText.Text = string.IsNullOrEmpty(info.ReleaseDate) ? "" : $"📅 Released: {info.ReleaseDate}";
|
||||
PsnDescriptionText.Text = info.Description ?? "";
|
||||
PsnInfoPanel.IsVisible = true;
|
||||
PsnStatusText.Text = $"✓ Found on PSN Store ({info.Source})";
|
||||
}
|
||||
catch (Exception ex) { PsnStatusText.Text = $"❌ Error: {ex.Message}"; }
|
||||
finally { FetchPsnInfoButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private class PsnGameInfo
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Publisher { get; set; } = "";
|
||||
public string ReleaseDate { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Source { get; set; } = "";
|
||||
}
|
||||
|
||||
private async Task<PsnGameInfo?> FetchPsnInfoAsync(string titleId)
|
||||
{
|
||||
var idsToTry = new List<string> { titleId + "_00" };
|
||||
var alternateIds = ExtractAlternateTitleIds(_paramJsonRaw);
|
||||
foreach (var altId in alternateIds)
|
||||
if (!idsToTry.Contains(altId)) idsToTry.Add(altId);
|
||||
|
||||
string[] chihiroRegions = { "US/en", "GB/en", "DE/de", "FR/fr", "JP/ja", "IT/it", "ES/es" };
|
||||
foreach (var tryId in idsToTry)
|
||||
{
|
||||
foreach (var region in chihiroRegions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = region.Split('/');
|
||||
string url = $"https://store.playstation.com/store/api/chihiro/00_09_000/titlecontainer/{parts[0]}/{parts[1]}/999/{tryId}";
|
||||
var response = await _http.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) continue;
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
if (json.Length < 50) continue;
|
||||
var info = ParsePsnJson(json);
|
||||
if (info != null) { string tag = tryId == titleId + "_00" ? "" : $" via alt ID {tryId}"; info.Source = $"Chihiro API ({region}){tag}"; return info; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
string[] valkyrieRegions = { "gb/GB", "us/US", "de/DE", "fr/FR", "ja/JP", "it/IT", "es/ES" };
|
||||
foreach (var region in valkyrieRegions)
|
||||
{
|
||||
try
|
||||
{
|
||||
string url = $"https://store.playstation.com/valkyrie-api/{region.Split('/')[0]}/{region.Split('/')[1]}/19/resolve/{titleId}_00";
|
||||
var response = await _http.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) continue;
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
if (json.Length < 50) continue;
|
||||
var info = ParseValkyrieJson(json);
|
||||
if (info != null) { info.Source = $"Valkyrie API ({region})"; return info; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
string gameName = _game.Name;
|
||||
if (!string.IsNullOrWhiteSpace(gameName) && gameName != "Unknown")
|
||||
{
|
||||
try
|
||||
{
|
||||
string encoded = Uri.EscapeDataString(gameName);
|
||||
string url = $"https://store.playstation.com/valkyrie-api/en/US/19/tumbler-search/{encoded}?suggested_size=5&mode=game";
|
||||
var response = await _http.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
var info = ParseValkyrieSearchJson(json, gameName);
|
||||
if (info != null) { info.Source = "Valkyrie Search (by name)"; return info; }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PsnGameInfo? ParseValkyrieJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("included", out var included) || included.ValueKind != System.Text.Json.JsonValueKind.Array) return null;
|
||||
foreach (var item in included.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("attributes", out var attrs)) continue;
|
||||
var info = new PsnGameInfo();
|
||||
if (attrs.TryGetProperty("name", out var name)) info.Title = name.GetString() ?? "";
|
||||
if (attrs.TryGetProperty("long-description", out var ld)) info.Description = StripHtml(ld.GetString() ?? "");
|
||||
else if (attrs.TryGetProperty("description", out var sd)) info.Description = StripHtml(sd.GetString() ?? "");
|
||||
if (attrs.TryGetProperty("release-date", out var rd)) { string raw = rd.GetString() ?? ""; info.ReleaseDate = DateTime.TryParse(raw, out DateTime dt) ? dt.ToString("yyyy-MM-dd") : raw; }
|
||||
if (attrs.TryGetProperty("provider-name", out var pub)) info.Publisher = pub.GetString() ?? "";
|
||||
else if (attrs.TryGetProperty("publisher-name", out var pub2)) info.Publisher = pub2.GetString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(info.Title)) return info;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PsnGameInfo? ParseValkyrieSearchJson(string json, string searchName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("included", out var included) || included.ValueKind != System.Text.Json.JsonValueKind.Array) return null;
|
||||
PsnGameInfo? bestMatch = null;
|
||||
foreach (var item in included.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("type", out var type) || type.GetString() != "game") continue;
|
||||
if (!item.TryGetProperty("attributes", out var attrs) || !attrs.TryGetProperty("name", out var nameProp)) continue;
|
||||
string name = nameProp.GetString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase) || name.Contains(searchName, StringComparison.OrdinalIgnoreCase) || searchName.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var info = new PsnGameInfo { Title = name };
|
||||
if (attrs.TryGetProperty("long-description", out var ld)) info.Description = StripHtml(ld.GetString() ?? "");
|
||||
if (attrs.TryGetProperty("release-date", out var rd)) { string raw = rd.GetString() ?? ""; if (DateTime.TryParse(raw, out DateTime dt)) info.ReleaseDate = dt.ToString("yyyy-MM-dd"); }
|
||||
if (attrs.TryGetProperty("provider-name", out var pub)) info.Publisher = pub.GetString() ?? "";
|
||||
if (string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase)) return info;
|
||||
bestMatch ??= info;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PsnGameInfo? ParsePsnJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var info = new PsnGameInfo();
|
||||
if (root.TryGetProperty("name", out var name)) info.Title = name.GetString() ?? "";
|
||||
else if (root.TryGetProperty("title_name", out var tn)) info.Title = tn.GetString() ?? "";
|
||||
if (root.TryGetProperty("provider_name", out var pub)) info.Publisher = pub.GetString() ?? "";
|
||||
else if (root.TryGetProperty("publisher_name", out var pub2)) info.Publisher = pub2.GetString() ?? "";
|
||||
if (root.TryGetProperty("release_date", out var rd)) { string rawDate = rd.GetString() ?? ""; info.ReleaseDate = DateTime.TryParse(rawDate, out DateTime dt) ? dt.ToString("yyyy-MM-dd") : rawDate; }
|
||||
if (root.TryGetProperty("long_desc", out var ld)) info.Description = StripHtml(ld.GetString() ?? "");
|
||||
else if (root.TryGetProperty("short_desc", out var sd)) info.Description = StripHtml(sd.GetString() ?? "");
|
||||
if (!string.IsNullOrWhiteSpace(info.Title)) return info;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> ExtractAlternateTitleIds(string paramJson)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(paramJson)) return ids;
|
||||
var regex = new System.Text.RegularExpressions.Regex(@"(?:[A-Z]{2}\d{4}-)?(PPSA\d{5}_\d{2}|CUSA\d{5}_\d{2})");
|
||||
var matches = regex.Matches(paramJson);
|
||||
foreach (System.Text.RegularExpressions.Match m in matches)
|
||||
if (m.Groups.Count >= 2) { string id = m.Groups[1].Value; if (!ids.Contains(id)) ids.Add(id); }
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static string StripHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "";
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"<br\s*/?>", "\n", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"</p>", "\n\n", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"<[^>]+>", "");
|
||||
html = System.Net.WebUtility.HtmlDecode(html);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"[ \t]+", " ");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\n{3,}", "\n\n");
|
||||
return html.Trim();
|
||||
}
|
||||
|
||||
private static string FormatSize(ulong bytes)
|
||||
{
|
||||
double mb = bytes / (1024.0 * 1024.0);
|
||||
return mb < 1024 ? $"{mb:F1} MB ({bytes:N0} bytes)" : $"{mb / 1024.0:F2} GB ({bytes:N0} bytes)";
|
||||
}
|
||||
|
||||
private static string FormatJson(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "(empty)";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
int depth = 0; bool inString = false;
|
||||
foreach (char c in raw)
|
||||
{
|
||||
if (c == '"') inString = !inString;
|
||||
if (!inString)
|
||||
{
|
||||
if (c == '{' || c == '[') { sb.Append(c); sb.Append('\n'); depth++; sb.Append(new string(' ', depth * 2)); continue; }
|
||||
if (c == '}' || c == ']') { sb.Append('\n'); depth = Math.Max(0, depth - 1); sb.Append(new string(' ', depth * 2)); sb.Append(c); continue; }
|
||||
if (c == ',') { sb.Append(c); sb.Append('\n'); sb.Append(new string(' ', depth * 2)); continue; }
|
||||
}
|
||||
sb.Append(c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
// ============================================================
|
||||
// SETTINGS
|
||||
// ============================================================
|
||||
private void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Renamed with the app (PS5 Suite); fall back to the legacy file.
|
||||
const string settingsFile = "ps5suite_settings.json";
|
||||
string effective = File.Exists(settingsFile) ? settingsFile
|
||||
: File.Exists("ps5upload_settings.json") ? "ps5upload_settings.json" : settingsFile;
|
||||
if (File.Exists(effective))
|
||||
{
|
||||
string json = File.ReadAllText(effective);
|
||||
var settings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json);
|
||||
if (settings != null)
|
||||
{
|
||||
if (settings.ContainsKey("AutoSendPayload"))
|
||||
_autoSendPayload = settings["AutoSendPayload"].ToString() == "True";
|
||||
if (settings.ContainsKey("PayloadPath"))
|
||||
_payloadPath = settings["PayloadPath"].ToString() ?? "";
|
||||
if (settings.ContainsKey("PayloadPort"))
|
||||
int.TryParse(settings["PayloadPort"].ToString(), out _payloadPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Log($"⚠️ Failed to load settings: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string settingsFile = "ps5suite_settings.json";
|
||||
var settings = new Dictionary<string, object>
|
||||
{
|
||||
["AutoSendPayload"] = _autoSendPayload,
|
||||
["PayloadPath"] = _payloadPath,
|
||||
["PayloadPort"] = _payloadPort
|
||||
};
|
||||
string json = System.Text.Json.JsonSerializer.Serialize(settings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(settingsFile, json);
|
||||
}
|
||||
catch (Exception ex) { Log($"⚠️ Failed to save settings: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONNECTION
|
||||
// ============================================================
|
||||
private async Task AutoSendPayloadOnStartup()
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
if (string.IsNullOrEmpty(_ps5IpAddress)) { Log("⚠️ Auto-send payload: No PS5 IP address configured"); return; }
|
||||
if (string.IsNullOrEmpty(_payloadPath) || !File.Exists(_payloadPath)) { Log("⚠️ Auto-send payload: Payload file not found"); return; }
|
||||
|
||||
Log($"📤 Auto-sending payload to {_ps5IpAddress}:{_payloadPort}...");
|
||||
var progress = new InlineProgress<long>(bytes => { });
|
||||
bool success = await PS5Protocol.SendPayloadAsync(_ps5IpAddress, _payloadPath, _payloadPort, progress);
|
||||
if (success)
|
||||
{
|
||||
Log($"✅ Payload sent successfully ({new FileInfo(_payloadPath).Length} bytes)");
|
||||
await Task.Delay(2000);
|
||||
await ConnectToPS5Async();
|
||||
}
|
||||
else
|
||||
Log("❌ Failed to send payload");
|
||||
}
|
||||
|
||||
private async Task ConnectToPS5Async()
|
||||
{
|
||||
if (await _protocol.ConnectAsync(_ps5IpAddress))
|
||||
{
|
||||
Log("✅ Connected to PS5 successfully");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🟢 Disconnect";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.Green);
|
||||
UploadButton.IsEnabled = true;
|
||||
MountGamesButton.IsEnabled = true;
|
||||
});
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
else
|
||||
Log("❌ Failed to connect to PS5");
|
||||
}
|
||||
|
||||
private CancellationTokenSource? _connectCts;
|
||||
private bool _connecting;
|
||||
|
||||
private async void AutoDetectButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Log("🔍 Auto-discovering PS5 on the network...");
|
||||
AutoDetectButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var found = await PS5Protocol.DiscoverPS5Async(3000);
|
||||
if (found == null)
|
||||
{
|
||||
Log("❌ No PS5 answered — is the payload running?");
|
||||
await ShowMessageAsync("No PS5 found on the network.\nMake sure the payload is running.", "Auto-Discovery");
|
||||
return;
|
||||
}
|
||||
IpAddressTextBox.Text = found.Value.ip;
|
||||
Log($"🔍 Found PS5 at {found.Value.ip}:{found.Value.port}");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Discovery failed: {ex.Message}"); }
|
||||
finally { AutoDetectButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// While a connect attempt is running the button becomes Cancel
|
||||
if (_connecting)
|
||||
{
|
||||
Log("⏹ Connect cancelled by user");
|
||||
_connectCts?.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
string ipAddress = IpAddressTextBox.Text?.Trim() ?? "";
|
||||
|
||||
// Empty IP => auto-discover: broadcast a UDP probe; the running
|
||||
// payload answers with its IP + actual bound port.
|
||||
if (string.IsNullOrEmpty(ipAddress))
|
||||
{
|
||||
Log("🔍 Auto-discovering PS5 on the network...");
|
||||
try
|
||||
{
|
||||
var found = await PS5Protocol.DiscoverPS5Async(2500);
|
||||
if (found == null)
|
||||
{
|
||||
Log("❌ No PS5 answered the discovery probe");
|
||||
await ShowMessageAsync("No PS5 found on the network.\nMake sure the payload is running, or enter the IP manually.", "Auto-Discovery");
|
||||
return;
|
||||
}
|
||||
ipAddress = found.Value.ip;
|
||||
Log($"🔍 Found PS5 at {ipAddress}:{found.Value.port}");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => IpAddressTextBox.Text = ipAddress);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Discovery failed: {ex.Message}");
|
||||
await ShowMessageAsync($"Discovery failed:\n{ex.Message}", "Auto-Discovery");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_ps5IpAddress = ipAddress;
|
||||
|
||||
if (_protocol.IsConnected)
|
||||
{
|
||||
ConnectButton.IsEnabled = false;
|
||||
Log("🔌 Disconnecting from PS5...");
|
||||
Log("🔌 Disconnecting from PS5...");
|
||||
StopHwAutoRefresh();
|
||||
_protocol.Disconnect();
|
||||
_shellActive = false;
|
||||
_shellCurrentDir = "/data";
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🔴 Disconnected";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.DarkRed);
|
||||
UploadButton.IsEnabled = false;
|
||||
MountGamesButton.IsEnabled = false;
|
||||
_ps5Files.Clear();
|
||||
});
|
||||
Log("✅ Disconnected from PS5");
|
||||
|
||||
await Task.Delay(1000);
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🔵 Connect";
|
||||
ConnectButton.Background = new SolidColorBrush(Color.FromRgb(0, 122, 204));
|
||||
});
|
||||
ConnectButton.IsEnabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"Connecting to PS5 at {ipAddress}...");
|
||||
_connecting = true;
|
||||
_connectCts = new CancellationTokenSource();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "⏹ Cancel";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.DarkOrange);
|
||||
});
|
||||
try
|
||||
{
|
||||
if (await _protocol.ConnectAsync(ipAddress, 9113, _connectCts.Token))
|
||||
{
|
||||
Log("✅ Connected to PS5 successfully");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ConnectButton.Content = "🟢 Disconnect";
|
||||
ConnectButton.Background = new SolidColorBrush(Colors.DarkGreen);
|
||||
UploadButton.IsEnabled = true;
|
||||
MountGamesButton.IsEnabled = true;
|
||||
});
|
||||
await OpenShellAsync();
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
await RefreshStorageInfoAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("❌ Failed to connect to PS5");
|
||||
await ShowMessageAsync("Failed to connect to PS5. Make sure the payload is running.", "Connection Error");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log("⏹ Connect cancelled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connecting = false;
|
||||
_connectCts?.Dispose();
|
||||
_connectCts = null;
|
||||
bool stillConnected = _protocol.IsConnected;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!stillConnected)
|
||||
{
|
||||
ConnectButton.Content = "🔵 Connect";
|
||||
ConnectButton.Background = new SolidColorBrush(Color.FromRgb(0, 122, 204));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PS5 DIRECTORY BROWSING
|
||||
// ============================================================
|
||||
private async Task LoadPS5DirectoryAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_protocol.IsConnected)
|
||||
{
|
||||
Log($"⚠️ Connection lost (LastError: {_protocol.LastError}), reconnecting...");
|
||||
if (!await _protocol.ConnectAsync(_ps5IpAddress))
|
||||
{
|
||||
await ShowMessageAsync("Failed to reconnect to PS5", "Connection Error");
|
||||
return;
|
||||
}
|
||||
Log("✅ Reconnected successfully");
|
||||
}
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
_currentPS5Path = path;
|
||||
CurrentPathTextBox.Text = path;
|
||||
|
||||
var entries = await _protocol.ListDirAsync(path);
|
||||
Log($"🔍 DEBUG: ListDir returned {entries.Count()} entries for path: {path}");
|
||||
foreach (var entry in entries.Take(5))
|
||||
Log($" - {(entry.IsDirectory ? "📁" : "📄")} {entry.Name} ({entry.Size} bytes)");
|
||||
if (entries.Count() > 5)
|
||||
Log($" ... and {entries.Count() - 5} more");
|
||||
|
||||
var items = new List<PS5FileItem>();
|
||||
if (path != "/")
|
||||
{
|
||||
string? parentPath = Path.GetDirectoryName(path);
|
||||
if (string.IsNullOrEmpty(parentPath)) parentPath = "/";
|
||||
else parentPath = parentPath.Replace("\\", "/");
|
||||
items.Add(new PS5FileItem { Name = "..", FullPath = parentPath, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
items.Add(new PS5FileItem
|
||||
{
|
||||
Name = entry.Name,
|
||||
IsDirectory = entry.IsDirectory,
|
||||
Size = entry.Size,
|
||||
FullPath = $"{path}/{entry.Name}".Replace("//", "/"),
|
||||
Icon = entry.IsDirectory ? "📁" : "📄"
|
||||
});
|
||||
}
|
||||
|
||||
var sortedItems = items.OrderBy(i => i.IsDirectory ? 0 : 1).ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
_ps5Files.Clear();
|
||||
foreach (var item in sortedItems) _ps5Files.Add(item);
|
||||
|
||||
var t4 = sw.ElapsedMilliseconds;
|
||||
Log("📂 Loaded " + entries.Count().ToString() + " items (Total: " + t4.ToString() + "ms)");
|
||||
ApplySearchFilter();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_protocol.IsConnected)
|
||||
{
|
||||
Log($"❌ Failed to load directory: {ex.Message}");
|
||||
await ShowMessageAsync($"Failed to load directory: {ex.Message}", "Error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySearchFilter()
|
||||
{
|
||||
_ps5FilesFiltered.Clear();
|
||||
if (string.IsNullOrWhiteSpace(_searchQuery))
|
||||
{
|
||||
foreach (var file in _ps5Files) _ps5FilesFiltered.Add(file);
|
||||
}
|
||||
else
|
||||
{
|
||||
string query = _searchQuery.ToLower();
|
||||
foreach (var file in _ps5Files)
|
||||
if (file.Name.ToLower().Contains(query))
|
||||
_ps5FilesFiltered.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOCAL FILES
|
||||
// ============================================================
|
||||
private async void BrowseFilesButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Select files to upload", AllowMultiple = true });
|
||||
foreach (var file in files)
|
||||
{
|
||||
var path = file.TryGetLocalPath();
|
||||
if (path != null)
|
||||
{
|
||||
FileInfo info = new FileInfo(path);
|
||||
|
||||
// Check if it's a ZIP file - extract and add contents
|
||||
if (info.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ExtractAndAddZipContents(path, info.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = path, Icon = "📄", IsDirectory = false, Size = info.Length });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Files were queued while browsing a folder — surface the queue.
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
private async void BrowseFolderButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select folder to upload", AllowMultiple = false });
|
||||
if (folders.Count > 0)
|
||||
{
|
||||
var path = folders[0].TryGetLocalPath();
|
||||
if (path != null)
|
||||
{
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(path);
|
||||
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = path, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
}
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
|
||||
private void LocalFilesListBox_Drop(object? sender, DragEventArgs e)
|
||||
{
|
||||
// Avalonia 12: Use TryGetFiles() on DataTransfer
|
||||
if (e.DataTransfer.TryGetFiles() is { } files)
|
||||
{
|
||||
foreach (var item in files)
|
||||
{
|
||||
var path = item.Path.LocalPath;
|
||||
if (path == null) continue;
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
FileInfo info = new FileInfo(path);
|
||||
|
||||
// Check if it's a ZIP file - extract and add contents
|
||||
if (info.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_ = ExtractAndAddZipContents(path, info.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = path, Icon = "📄", IsDirectory = false, Size = info.Length });
|
||||
}
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(path);
|
||||
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = path, Icon = "📁", IsDirectory = true, Size = 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_localBrowsePath != null) ExitLocalBrowse();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ZIP ARCHIVE SUPPORT (ps5upload-style)
|
||||
// Extracts .zip files and adds contents to upload queue
|
||||
// ============================================================
|
||||
private async Task ExtractAndAddZipContents(string zipPath, string zipName)
|
||||
{
|
||||
try
|
||||
{
|
||||
Log($"📦 Extracting ZIP: {zipName}");
|
||||
|
||||
// Create temp extraction directory
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "ps5suite_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(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);
|
||||
var dirs = Directory.GetDirectories(tempDir, "*", SearchOption.AllDirectories);
|
||||
|
||||
Log($"📦 Found {files.Length} files, {dirs.Length} folders in ZIP");
|
||||
|
||||
// Add root directory as a folder item
|
||||
string zipBaseName = Path.GetFileNameWithoutExtension(zipName);
|
||||
_localFiles.Add(new LocalFileItem {
|
||||
Name = zipBaseName,
|
||||
FullPath = tempDir,
|
||||
Icon = "📁",
|
||||
IsDirectory = true,
|
||||
Size = 0
|
||||
});
|
||||
|
||||
Log($"✅ ZIP extracted to temp folder - added '{zipBaseName}' to upload queue");
|
||||
Log($"⚠️ Will upload extracted contents, not the .zip file");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Failed to extract ZIP: {ex.Message}");
|
||||
// Fall back to adding the zip file itself
|
||||
long sz = LocalIo.GetLength(zipPath);
|
||||
_localFiles.Add(new LocalFileItem { Name = LocalIo.GetName(zipPath), FullPath = zipPath, Icon = "📄", IsDirectory = false, Size = sz });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PS5 FILE OPERATIONS
|
||||
// ============================================================
|
||||
private void PS5FilesListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item && item.IsDirectory)
|
||||
_ = LoadPS5DirectoryAsync(item.FullPath);
|
||||
}
|
||||
|
||||
private void ClearSearchButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SearchTextBox.Text = "";
|
||||
_searchQuery = "";
|
||||
ApplySearchFilter();
|
||||
}
|
||||
|
||||
private void GoButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
string path = CurrentPathTextBox.Text?.Trim() ?? "";
|
||||
if (!string.IsNullOrEmpty(path)) _ = LoadPS5DirectoryAsync(path);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
{
|
||||
// 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" +
|
||||
"Example: \\\\NAS\\games\\PS4\\MyGame",
|
||||
"Invalid Path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Establish/reuse the authenticated session for this share.
|
||||
if (!NasManager.HasSession(nasPath))
|
||||
{
|
||||
// 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))
|
||||
{
|
||||
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 = name, FullPath = nasPath, Icon = "🌐", IsDirectory = false, Size = size });
|
||||
Log($"✅ Added NAS file: {name} ({FormatFileSize(size)})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"❌ NAS path not found: {nasPath}");
|
||||
await ShowMessageAsync(
|
||||
$"Cannot access: {nasPath}\n\n" +
|
||||
"Make sure:\n" +
|
||||
"• The network share is accessible\n" +
|
||||
"• You have permission to read it\n" +
|
||||
"• The path is correct",
|
||||
"NAS Path Not Found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ NAS error: {ex.Message}");
|
||||
await ShowMessageAsync($"Error accessing NAS path: {ex.Message}", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
private async void RenameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
string? newName = await ShowInputDialogAsync("Rename", "New name:", item.Name);
|
||||
if (!string.IsNullOrWhiteSpace(newName))
|
||||
{
|
||||
try
|
||||
{
|
||||
string newPath = Path.Combine(Path.GetDirectoryName(item.FullPath)?.Replace("\\", "/") ?? "", newName).Replace("\\", "/");
|
||||
await _protocol.RenameAsync(item.FullPath, newPath);
|
||||
Log($"✅ Renamed {item.Name} to {newName}");
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
catch (Exception ex) { await ShowMessageAsync($"Rename failed: {ex.Message}", "Error"); Log($"❌ Rename failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void CopyMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
string? destPath = await ShowInputDialogAsync("Copy To", "Destination path:", _currentPS5Path + "/" + item.Name);
|
||||
if (destPath != null)
|
||||
{
|
||||
try { await _protocol.CopyFileAsync(item.FullPath, destPath); Log($"✅ Copied {item.Name} to {destPath}"); await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
catch (Exception ex) { await ShowMessageAsync($"Copy failed: {ex.Message}", "Error"); Log($"❌ Copy failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void MoveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
string? destPath = await ShowInputDialogAsync("Move To", "Destination path:", _currentPS5Path + "/" + item.Name);
|
||||
if (destPath != null)
|
||||
{
|
||||
try { await _protocol.RenameAsync(item.FullPath, destPath); Log($"✅ Moved {item.Name} to {destPath}"); await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
catch (Exception ex) { await ShowMessageAsync($"Move failed: {ex.Message}", "Error"); Log($"❌ Move failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
if (await ShowConfirmAsync($"Delete {item.Name}?"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.IsDirectory) { Log($"🗑️ Deleting folder: {item.Name}"); await _protocol.DeleteDirAsync(item.FullPath); Log($"✅ Folder deletion complete: {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}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteSelectedMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var selectedItems = (PS5FilesListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5FileItem>().ToList();
|
||||
if (selectedItems.Count == 0) { await ShowMessageAsync("No items selected."); return; }
|
||||
|
||||
int folderCount = selectedItems.Count(i => i.IsDirectory);
|
||||
int fileCount = selectedItems.Count(i => !i.IsDirectory);
|
||||
string message = $"Delete {selectedItems.Count} items?\n\n";
|
||||
if (folderCount > 0) message += $"📁 {folderCount} folders\n";
|
||||
if (fileCount > 0) message += $"📄 {fileCount} files";
|
||||
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.IsDirectory) await _protocol.DeleteDirAsync(item.FullPath);
|
||||
else await _protocol.DeleteFileAsync(item.FullPath);
|
||||
successCount++;
|
||||
Log($"✅ Deleted: {item.Name}");
|
||||
await Task.Delay(300);
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
private async void DownloadMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = $"Select destination for '{item.Name}' folder" });
|
||||
if (folders.Count == 0) return;
|
||||
var folderPath = folders[0].TryGetLocalPath();
|
||||
if (folderPath == null) return;
|
||||
string localBasePath = Path.Combine(folderPath, item.Name);
|
||||
|
||||
try
|
||||
{
|
||||
Log($"⬇️ Downloading folder: {item.FullPath} → {localBasePath}");
|
||||
_uploadCancellation = new CancellationTokenSource();
|
||||
var ct = _uploadCancellation.Token;
|
||||
int dlCallCount = 0;
|
||||
var progress = new InlineProgress<DownloadFolderProgress>(p =>
|
||||
{
|
||||
if (++dlCallCount % 8 != 0 && p.Phase != "Complete") return;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (p.Phase == "Scanning")
|
||||
TotalProgressText.Text = $"Scanning: {p.FilesCompleted} files found... ({p.CurrentFile})";
|
||||
else if (p.Phase == "Downloading")
|
||||
{
|
||||
double percent = p.TotalBytes > 0 ? (double)p.BytesDownloaded / p.TotalBytes * 100 : 0;
|
||||
TotalProgressBar.Value = percent;
|
||||
TotalProgressText.Text = $"Downloading: {p.FilesCompleted}/{p.TotalFiles} files ({FormatFileSize(p.BytesDownloaded)}/{FormatFileSize(p.TotalBytes)}) [{percent:F1}%]";
|
||||
if (!string.IsNullOrEmpty(p.CurrentFile)) UploadFileNameText.Text = $"⬇️ {p.CurrentFile}";
|
||||
}
|
||||
else if (p.Phase == "Complete")
|
||||
{
|
||||
TotalProgressBar.Value = 100;
|
||||
TotalProgressText.Text = $"Complete: {p.FilesCompleted}/{p.TotalFiles} files ({FormatFileSize(p.BytesDownloaded)})";
|
||||
}
|
||||
});
|
||||
});
|
||||
var (downloaded, failed, totalBytes) = await _protocol.DownloadFolderAsync(item.FullPath, localBasePath, _ps5IpAddress, progress, ct);
|
||||
Log($"✅ Folder download complete: {downloaded} files ({FormatFileSize(totalBytes)}), {failed} failed");
|
||||
await ShowMessageAsync($"Folder downloaded!\n\nFiles: {downloaded} downloaded, {failed} failed\nSize: {FormatFileSize(totalBytes)}\nSaved to: {localBasePath}", "Download Complete");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Folder download error: {ex.Message}"); await ShowMessageAsync($"Folder download error: {ex.Message}", "Error"); }
|
||||
finally { _uploadCancellation = null; TotalProgressBar.Value = 0; TotalProgressText.Text = ""; UploadFileNameText.Text = ""; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Single file download
|
||||
var saveFile = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Save Downloaded File", SuggestedFileName = item.Name });
|
||||
if (saveFile != null)
|
||||
{
|
||||
var savePath = saveFile.TryGetLocalPath();
|
||||
if (savePath == null) return;
|
||||
try
|
||||
{
|
||||
Log($"⬇️ Downloading: {item.Name} ({FormatFileSize(item.Size)})");
|
||||
int sfCallCount = 0;
|
||||
var progress = new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
if (++sfCallCount % 8 != 0 && p.BytesSent != p.TotalBytes) return;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
double percent = p.TotalBytes > 0 ? (double)p.BytesSent / p.TotalBytes * 100 : 0;
|
||||
TotalProgressBar.Value = percent;
|
||||
TotalProgressText.Text = $"Downloading: {FormatFileSize(p.BytesSent)}/{FormatFileSize(p.TotalBytes)} ({percent:F1}%) @ {FormatFileSize((long)p.SpeedBytesPerSecond)}/s";
|
||||
});
|
||||
});
|
||||
bool success = await _protocol.DownloadFileAsync(item.FullPath, savePath, progress);
|
||||
if (success) { Log($"✅ Downloaded: {item.Name} → {savePath}"); await ShowMessageAsync($"File downloaded successfully!\n\nSaved to: {savePath}", "Success"); }
|
||||
else { Log($"❌ Download failed: {item.Name}"); await ShowMessageAsync("Download failed", "Error"); }
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Download error: {ex.Message}"); await ShowMessageAsync($"Download error: {ex.Message}", "Error"); }
|
||||
finally { TotalProgressBar.Value = 0; TotalProgressText.Text = ""; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOG BUTTONS
|
||||
// ============================================================
|
||||
private void ClearLogButton_Click(object? sender, RoutedEventArgs e) { LogTextBox.Text = ""; Log("Log cleared"); }
|
||||
|
||||
private async void CopyLogButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard != null)
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, LogTextBox.Text ?? "");
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await clipboard.SetDataAsync(data);
|
||||
Log("📋 Log copied to clipboard!");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Log($"⚠️ Clipboard error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async Task<PS5StorageInfo?> FetchStorageInfoWithFallbackAsync()
|
||||
{
|
||||
bool uploadsIdle = (_uploadCancellation == null || _uploadCancellation.IsCancellationRequested) && _activeTaskCount == 0;
|
||||
if (_protocol.IsConnected && uploadsIdle)
|
||||
return await _protocol.ListStorageAsync();
|
||||
|
||||
var tempProtocol = new PS5Protocol();
|
||||
try
|
||||
{
|
||||
if (!await tempProtocol.ConnectAsync(_ps5IpAddress)) { Log("❌ Storage refresh: failed to open temporary connection"); return null; }
|
||||
return await tempProtocol.ListStorageAsync();
|
||||
}
|
||||
finally { tempProtocol.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Kernel log viewer (sysctl kern.msgbuf).
|
||||
public partial class MainWindow
|
||||
{
|
||||
private DispatcherTimer? _klogTimer;
|
||||
private bool _klogBusy;
|
||||
|
||||
private int KlogTailBytes => KlogTailSelect.SelectedIndex switch
|
||||
{
|
||||
0 => 32 * 1024,
|
||||
1 => 128 * 1024,
|
||||
_ => 0 // full buffer
|
||||
};
|
||||
|
||||
private async void KlogRefresh_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_klogBusy) return;
|
||||
if (!_protocol.IsConnected) { KlogStatus.Text = "Not connected"; return; }
|
||||
_klogBusy = true;
|
||||
KlogStatus.Text = "Reading kern.msgbuf…";
|
||||
var txt = await _protocol.GetKernelLogAsync(KlogTailBytes);
|
||||
_klogBusy = false;
|
||||
if (txt == null)
|
||||
{
|
||||
KlogStatus.Text = $"❌ {_protocol.LastError}";
|
||||
return;
|
||||
}
|
||||
KlogText.Text = txt;
|
||||
if (KlogAutoScroll.IsChecked == true)
|
||||
KlogText.CaretIndex = txt.Length;
|
||||
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
|
||||
private void KlogAutoRefresh_Changed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (KlogAutoRefresh.IsChecked == true)
|
||||
{
|
||||
_klogTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||||
_klogTimer.Tick -= KlogTimer_Tick;
|
||||
_klogTimer.Tick += KlogTimer_Tick;
|
||||
_klogTimer.Start();
|
||||
KlogRefresh_Click(sender, e);
|
||||
}
|
||||
else
|
||||
{
|
||||
_klogTimer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async void KlogTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_klogBusy || !_protocol.IsConnected) return;
|
||||
_klogBusy = true;
|
||||
var txt = await _protocol.GetKernelLogAsync(KlogTailBytes);
|
||||
_klogBusy = false;
|
||||
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)";
|
||||
}
|
||||
}
|
||||
|
||||
private async void KlogCopy_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb != null && !string.IsNullOrEmpty(KlogText.Text))
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, KlogText.Text);
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await cb.SetDataAsync(data);
|
||||
KlogStatus.Text = "Copied to clipboard";
|
||||
}
|
||||
}
|
||||
|
||||
private async void KlogSave_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrEmpty(KlogText.Text)) { KlogStatus.Text = "Nothing to save — refresh first"; return; }
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save kernel log",
|
||||
SuggestedFileName = $"klog_{DateTime.Now:yyyyMMdd_HHmmss}.txt"
|
||||
});
|
||||
var dest = file?.TryGetLocalPath();
|
||||
if (dest == null) return;
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(dest, KlogText.Text);
|
||||
KlogStatus.Text = $"Saved to {Path.GetFileName(dest)}";
|
||||
}
|
||||
catch (Exception ex) { KlogStatus.Text = $"Save failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Memory editor — ptrace/PT_IO read-write-search on PS5 processes.
|
||||
public partial class MainWindow
|
||||
{
|
||||
private List<(int pid, string name)> _memProcs = new();
|
||||
private readonly List<ulong> _memResults = new();
|
||||
private int _memPid = -1;
|
||||
private int _memPatternLen = 4;
|
||||
private bool _memScanning;
|
||||
|
||||
// ---------- process list ----------
|
||||
|
||||
private async void MemRefreshProcesses_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_protocol.IsConnected) { MemStatus.Text = "Not connected"; return; }
|
||||
MemStatus.Text = "Loading processes...";
|
||||
_memProcs = await _protocol.GetProcessListAsync();
|
||||
ApplyMemProcFilter();
|
||||
MemStatus.Text = _memProcs.Count > 0
|
||||
? $"{_memProcs.Count} processes — select one"
|
||||
: $"Process list failed: {_protocol.LastError}";
|
||||
}
|
||||
|
||||
private void MemProcessFilter_KeyUp(object? sender, Avalonia.Input.KeyEventArgs e) => ApplyMemProcFilter();
|
||||
|
||||
private void ApplyMemProcFilter()
|
||||
{
|
||||
var f = (MemProcessFilter.Text ?? "").Trim();
|
||||
MemProcessList.ItemsSource = _memProcs
|
||||
.Where(p => f.Length == 0 || p.name.Contains(f, StringComparison.OrdinalIgnoreCase) || p.pid.ToString().Contains(f))
|
||||
.Select(p => $"{p.pid,6} {p.name}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async void MemProcessList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = MemProcessList.SelectedItem?.ToString();
|
||||
if (s == null) return;
|
||||
var first = s.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
|
||||
if (!int.TryParse(first, out int pid)) return;
|
||||
|
||||
_memPid = pid;
|
||||
_memResults.Clear();
|
||||
MemResultsList.ItemsSource = null;
|
||||
MemNextScanButton.IsEnabled = false;
|
||||
MemStatus.Text = $"Loading regions for pid {pid}...";
|
||||
|
||||
var txt = await _protocol.MemRegionsAsync(pid);
|
||||
if (txt == null) { MemStatus.Text = $"vmmap failed: {_protocol.LastError}"; return; }
|
||||
var regions = txt.Split('\n', StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
MemRegionsList.ItemsSource = regions;
|
||||
MemStatus.Text = $"pid {pid}: {regions.Count} regions — pick one or scan a range";
|
||||
}
|
||||
|
||||
private void MemRegionsList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = MemRegionsList.SelectedItem?.ToString();
|
||||
if (s == null) return;
|
||||
// "start-end|prot|size|path"
|
||||
var range = s.Split('|')[0].Split('-');
|
||||
if (range.Length == 2)
|
||||
{
|
||||
MemScanStart.Text = "0x" + range[0];
|
||||
MemScanEnd.Text = "0x" + range[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- search ----------
|
||||
|
||||
private byte[]? EncodeSearchValue()
|
||||
{
|
||||
var type = (MemValueType.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "u32";
|
||||
var v = (MemValueInput.Text ?? "").Trim();
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "u8": return new[] { ParseMemNumber<byte>(v, byte.TryParse) };
|
||||
case "u16": return BitConverter.GetBytes(ParseMemNumber<ushort>(v, ushort.TryParse));
|
||||
case "u32": return BitConverter.GetBytes(ParseMemNumber<uint>(v, uint.TryParse));
|
||||
case "u64": return BitConverter.GetBytes(ParseMemNumber<ulong>(v, ulong.TryParse));
|
||||
case "float": return BitConverter.GetBytes(float.Parse(v, CultureInfo.InvariantCulture));
|
||||
case "bytes": return Convert.FromHexString(v.Replace(" ", "").Replace("0x", ""));
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private delegate bool TryNum<T>(string s, NumberStyles st, IFormatProvider fp, out T val);
|
||||
private static T ParseMemNumber<T>(string v, TryNum<T> tryParse) where T : struct
|
||||
{
|
||||
v = v.Trim();
|
||||
if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tryParse(v[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out T r)) return r;
|
||||
}
|
||||
if (tryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out T d)) return d;
|
||||
if (tryParse(v, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out T h)) return h;
|
||||
throw new FormatException(v);
|
||||
}
|
||||
|
||||
private static ulong ParseMemAddr(string? text)
|
||||
{
|
||||
var v = (text ?? "").Trim();
|
||||
if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) v = v[2..];
|
||||
if (ulong.TryParse(v, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong hex)) return hex;
|
||||
return ulong.TryParse(v, out ulong dec) ? dec : 0;
|
||||
}
|
||||
|
||||
private async void MemFirstScan_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memScanning) return;
|
||||
if (_memPid <= 0) { MemStatus.Text = "Select a process first"; return; }
|
||||
var pattern = EncodeSearchValue();
|
||||
if (pattern == null || pattern.Length == 0) { MemStatus.Text = "Invalid value — check type/input"; return; }
|
||||
|
||||
ulong start = ParseMemAddr(MemScanStart.Text), end = ParseMemAddr(MemScanEnd.Text);
|
||||
if (end <= start) { MemStatus.Text = "Bad range — set start/end or pick a region"; return; }
|
||||
|
||||
_memPatternLen = pattern.Length;
|
||||
_memScanning = true;
|
||||
MemFirstScanButton.IsEnabled = false;
|
||||
MemStatus.Text = $"Scanning 0x{start:X}–0x{end:X} ({(end - start) / 1024 / 1024}MB)…";
|
||||
|
||||
var txt = await _protocol.MemSearchAsync(_memPid, start, end, pattern);
|
||||
_memScanning = false;
|
||||
MemFirstScanButton.IsEnabled = true;
|
||||
|
||||
if (txt == null) { MemStatus.Text = $"Scan failed: {_protocol.LastError}"; return; }
|
||||
var lines = txt.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
_memResults.Clear();
|
||||
bool truncated = false, timedOut = false;
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (l == "TRUNCATED") { truncated = true; continue; }
|
||||
if (l == "TIMEOUT") { timedOut = true; continue; }
|
||||
if (l == "NO_MATCHES") break;
|
||||
var a = ParseMemAddr(l);
|
||||
if (a != 0) _memResults.Add(a);
|
||||
}
|
||||
MemResultsList.ItemsSource = _memResults.Select(a => $"0x{a:X}").ToList();
|
||||
MemStatus.Text = $"{_memResults.Count} match(es)" +
|
||||
(truncated ? " — hit 1024 cap, narrow with Next Scan" : "") +
|
||||
(timedOut ? " — scan timed out, partial results" : "");
|
||||
MemNextScanButton.IsEnabled = _memResults.Count > 0;
|
||||
}
|
||||
|
||||
// Next Scan = client-side re-read of each candidate against the new value
|
||||
private async void MemNextScan_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memScanning || _memResults.Count == 0) return;
|
||||
var pattern = EncodeSearchValue();
|
||||
if (pattern == null || pattern.Length != _memPatternLen)
|
||||
{
|
||||
MemStatus.Text = $"Next Scan needs the same type ({_memPatternLen} bytes)";
|
||||
return;
|
||||
}
|
||||
|
||||
_memScanning = true;
|
||||
MemNextScanButton.IsEnabled = false;
|
||||
var kept = new List<ulong>();
|
||||
for (int i = 0; i < _memResults.Count; i++)
|
||||
{
|
||||
var bytes = await _protocol.MemReadAsync(_memPid, _memResults[i], pattern.Length);
|
||||
if (bytes != null && bytes.Length >= pattern.Length && bytes.AsSpan(0, pattern.Length).SequenceEqual(pattern))
|
||||
kept.Add(_memResults[i]);
|
||||
if (i % 64 == 0) MemStatus.Text = $"Re-checking {i}/{_memResults.Count}…";
|
||||
}
|
||||
_memResults.Clear();
|
||||
_memResults.AddRange(kept);
|
||||
MemResultsList.ItemsSource = _memResults.Select(a => $"0x{a:X}").ToList();
|
||||
MemStatus.Text = $"{_memResults.Count} match(es) after filter";
|
||||
_memScanning = false;
|
||||
MemNextScanButton.IsEnabled = _memResults.Count > 0;
|
||||
}
|
||||
|
||||
// ---------- read / write ----------
|
||||
|
||||
private void MemResultsList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var s = MemResultsList.SelectedItem?.ToString();
|
||||
if (s == null) return;
|
||||
MemAddrInput.Text = s;
|
||||
MemRead_Click(sender, e);
|
||||
}
|
||||
|
||||
private async void MemRead_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memPid <= 0) { MemStatus.Text = "Select a process first"; return; }
|
||||
ulong addr = ParseMemAddr(MemAddrInput.Text);
|
||||
if (addr == 0) { MemStatus.Text = "Enter an address (0x…)"; return; }
|
||||
var data = await _protocol.MemReadAsync(_memPid, addr, 256);
|
||||
if (data == null) { MemStatus.Text = $"Read failed: {_protocol.LastError}"; return; }
|
||||
MemDumpText.Text = MemHexDump(data, addr);
|
||||
MemStatus.Text = $"Read {data.Length}B @ 0x{addr:X}";
|
||||
}
|
||||
|
||||
private async void MemWrite_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_memPid <= 0) { MemStatus.Text = "Select a process first"; return; }
|
||||
ulong addr = ParseMemAddr(MemAddrInput.Text);
|
||||
if (addr == 0) { MemStatus.Text = "Enter an address (0x…)"; return; }
|
||||
byte[] bytes;
|
||||
try { bytes = Convert.FromHexString((MemWriteHex.Text ?? "").Replace(" ", "").Replace("0x", "")); }
|
||||
catch { MemStatus.Text = "Invalid hex data"; return; }
|
||||
if (bytes.Length == 0) { MemStatus.Text = "Nothing to write"; return; }
|
||||
|
||||
var (ok, msg) = await _protocol.MemWriteAsync(_memPid, addr, bytes);
|
||||
MemStatus.Text = ok ? $"✅ {msg}" : $"❌ {msg}";
|
||||
if (ok) MemRead_Click(sender, e); // refresh the dump
|
||||
}
|
||||
|
||||
private static string MemHexDump(byte[] data, ulong baseAddr)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < data.Length; i += 16)
|
||||
{
|
||||
sb.Append($"{baseAddr + (ulong)i:X8} ");
|
||||
for (int j = 0; j < 16; j++)
|
||||
sb.Append(i + j < data.Length ? $"{data[i + j]:X2} " : " ");
|
||||
sb.Append(' ');
|
||||
for (int j = 0; j < 16 && i + j < data.Length; j++)
|
||||
{
|
||||
byte b = data[i + j];
|
||||
sb.Append(b >= 32 && b < 127 ? (char)b : '.');
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Games, Saves, Screenshots, Hardware tabs
|
||||
public partial class MainWindow
|
||||
{
|
||||
// ============================================================
|
||||
// SAVES TAB
|
||||
// ============================================================
|
||||
private async void RefreshSavesButton_Click(object? sender, RoutedEventArgs e) => await RefreshSavesAsync();
|
||||
|
||||
private async Task RefreshSavesAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log("💾 Scanning save images...");
|
||||
try
|
||||
{
|
||||
var saves = await _protocol.ListSaveFilesAsync();
|
||||
_currentSaves = saves;
|
||||
var mounted = await _protocol.GetGameListAsync();
|
||||
var nameByTitle = mounted.ToDictionary(g => g.TitleId, g => g.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var save in saves)
|
||||
{
|
||||
save.GameName = nameByTitle.TryGetValue(save.TitleId, out var gn) ? gn : save.TitleId;
|
||||
if (_iconCache.TryGetValue(save.TitleId, out var cached)) save.Icon = cached;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
SavesListBox.ItemsSource = saves;
|
||||
SaveCountText.Text = $" ({saves.Count} saves)";
|
||||
});
|
||||
Log($"💾 Found {saves.Count} save images");
|
||||
|
||||
// Fetch icons in background on a dedicated connection so bulk
|
||||
// icon downloads never serialize behind the main command lock.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var iconProto = new PS5Protocol();
|
||||
if (!await iconProto.ConnectAsync(_ps5IpAddress)) return;
|
||||
foreach (var s2 in saves)
|
||||
{
|
||||
if (s2.Icon != null) continue;
|
||||
try
|
||||
{
|
||||
var bytes = await iconProto.GetGameIconAsync(s2.TitleId);
|
||||
if (bytes != null && bytes.Length > 0)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bmp = new Bitmap(ms);
|
||||
_iconCache[s2.TitleId] = bmp;
|
||||
s2.Icon = bmp;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (!_protocol.IsConnected) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
|
||||
await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Error fetching saves: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async Task UpdateSaveMountStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (mounted, src, local, isPs4) = await _protocol.SaveMountStatusAsync();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
SaveMountStatusText.Text = mounted
|
||||
? $"🔓 Mounted (decrypted): {src} → /data/save_mnt{(isPs4 ? " [PS4 save]" : "")}"
|
||||
: "";
|
||||
BrowseSaveButton.IsEnabled = mounted;
|
||||
UnmountSaveButton.IsEnabled = mounted;
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ---------- Mount / Browse / Unmount ----------
|
||||
|
||||
private async void MountSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await MountSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void MountSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await MountSaveAsync(save);
|
||||
}
|
||||
|
||||
private async Task MountSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
Log($"🔓 Mounting {save.SaveName} ({save.TitleId}, {(save.IsPs4 ? "PS4" : "PS5")})...");
|
||||
var (ok, msg) = await _protocol.SaveMountAsync(save.Path, m => Log($" {m}"));
|
||||
if (ok)
|
||||
{
|
||||
Log($"✅ Save mounted decrypted at /data/save_mnt");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log($"❌ Mount failed: {msg}");
|
||||
await ShowMessageAsync($"Mount failed:\n{msg}", "Error");
|
||||
}
|
||||
await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
private async void BrowseSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
var (mounted, src, local, isPs4) = await _protocol.SaveMountStatusAsync();
|
||||
if (!mounted)
|
||||
{
|
||||
await ShowMessageAsync("No save is mounted.\nSelect a save and press Mount first.", "Save Manager");
|
||||
return;
|
||||
}
|
||||
var win = new SaveBrowserWindow(_protocol, PS5Protocol.SaveMountPoint, _ps5IpAddress);
|
||||
bool unmount = await win.ShowDialog<bool>(this);
|
||||
if (unmount) await UnmountSaveAsync();
|
||||
else await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
private async void UnmountSaveButton_Click(object? sender, RoutedEventArgs e) => await UnmountSaveAsync();
|
||||
|
||||
private async Task UnmountSaveAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) return;
|
||||
Log("🔒 Unmounting save (writing changes back to the image)...");
|
||||
var (ok, msg) = await _protocol.SaveUnmountAsync(m => Log($" {m}"));
|
||||
if (ok) Log($"✅ Save unmounted. {msg}");
|
||||
else
|
||||
{
|
||||
Log($"❌ Unmount failed: {msg}");
|
||||
await ShowMessageAsync($"Unmount failed:\n{msg}", "Error");
|
||||
}
|
||||
await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
// ---------- Backup / Restore (decrypted via mount) ----------
|
||||
|
||||
// Ensures the given save image is mounted. Returns (ok, weMountedIt).
|
||||
private async Task<(bool ok, bool weMounted)> EnsureSaveMountedAsync(PS5SaveFile save)
|
||||
{
|
||||
var (mounted, src, _, _) = await _protocol.SaveMountStatusAsync();
|
||||
if (mounted && src == save.Path) return (true, false);
|
||||
if (mounted)
|
||||
{
|
||||
await ShowMessageAsync($"A different save is mounted:\n{src}\n\nUnmount it first.", "Save Manager");
|
||||
return (false, false);
|
||||
}
|
||||
Log($"🔓 Mounting {save.SaveName} for transfer...");
|
||||
var (ok, msg) = await _protocol.SaveMountAsync(save.Path, m => Log($" {m}"));
|
||||
if (!ok)
|
||||
{
|
||||
Log($"❌ Mount failed: {msg}");
|
||||
await ShowMessageAsync($"Mount failed:\n{msg}", "Error");
|
||||
}
|
||||
await UpdateSaveMountStatusAsync();
|
||||
return (ok, ok);
|
||||
}
|
||||
|
||||
private async void BackupSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await BackupSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void BackupSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await BackupSaveAsync(save);
|
||||
}
|
||||
|
||||
// Decrypted backup: mount -> download plaintext contents -> unmount.
|
||||
private async Task BackupSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose backup destination folder"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
var parentDir = folders[0].TryGetLocalPath();
|
||||
if (parentDir == null) return;
|
||||
|
||||
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
|
||||
if (!mountedOk) return;
|
||||
|
||||
string backupDir = Path.Combine(parentDir,
|
||||
$"{save.TitleId}_{save.SaveName}_{DateTime.Now:yyyyMMdd_HHmmss}_decrypted");
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
Log($"📥 Backing up decrypted save contents to {backupDir}...");
|
||||
try
|
||||
{
|
||||
var result = await _protocol.DownloadFolderAsync(PS5Protocol.SaveMountPoint, backupDir, _ps5IpAddress, null, CancellationToken.None);
|
||||
Log($"✅ Backup complete: {result.filesDownloaded} files, {result.filesFailed} failed, {FormatFileSize(result.totalBytes)} total");
|
||||
await ShowMessageAsync($"Backup complete (decrypted)!\n\nLocation: {backupDir}\nFiles: {result.filesDownloaded}\nFailed: {result.filesFailed}\nSize: {FormatFileSize(result.totalBytes)}", "Backup Complete");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Backup failed: {ex.Message}"); await ShowMessageAsync($"Backup failed:\n{ex.Message}", "Error"); }
|
||||
|
||||
if (weMounted) await UnmountSaveAsync();
|
||||
}
|
||||
|
||||
// Raw backup: copy the encrypted image file (+ .bin companion) as-is.
|
||||
private async void BackupSaveRawMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is not PS5SaveFile save) return;
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose destination for raw save image"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
var parentDir = folders[0].TryGetLocalPath();
|
||||
if (parentDir == null) return;
|
||||
|
||||
string local = Path.Combine(parentDir, save.SaveName);
|
||||
Log($"📥 Downloading raw save image {save.SaveName}...");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DownloadFileAsync(save.Path, local);
|
||||
if (save.HasBinKey)
|
||||
{
|
||||
try { await _protocol.DownloadFileAsync(save.Path + ".bin", local + ".bin"); } catch { }
|
||||
}
|
||||
Log(ok ? $"✅ Raw image saved: {local}" : "❌ Raw download failed");
|
||||
if (ok) await ShowMessageAsync($"Raw save image saved:\n{local}\n\n(encrypted - usable for same-console restore)", "Backup Complete");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Raw backup failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void RestoreSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await RestoreSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void RestoreSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await RestoreSaveAsync(save);
|
||||
}
|
||||
|
||||
// Decrypted restore: mount -> upload plaintext files into the mounted
|
||||
// save -> unmount (the payload writes the image back automatically).
|
||||
private async Task RestoreSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Select the backup folder containing the save files"
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
var backupFolder = folders[0].TryGetLocalPath();
|
||||
if (backupFolder == null || !Directory.Exists(backupFolder)) { await ShowMessageAsync("Invalid folder.", "Error"); return; }
|
||||
|
||||
if (!await ShowConfirmAsync($"Restore decrypted files into save {save.SaveName} ({save.TitleId})?\n\nFrom: {backupFolder}\n\nThe save will be mounted, files overwritten, then written back."))
|
||||
return;
|
||||
|
||||
var (mountedOk, weMounted) = await EnsureSaveMountedAsync(save);
|
||||
if (!mountedOk) return;
|
||||
|
||||
string mnt = PS5Protocol.SaveMountPoint;
|
||||
Log("📤 Restoring files into mounted save...");
|
||||
int uploaded = 0, failed = 0; long totalBytes = 0;
|
||||
try
|
||||
{
|
||||
var remoteDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var localFile in Directory.EnumerateFiles(backupFolder, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string rel = Path.GetRelativePath(backupFolder, localFile).Replace('\\', '/');
|
||||
string? remoteDir = Path.GetDirectoryName(rel)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(remoteDir)) continue;
|
||||
string parts = mnt;
|
||||
foreach (var seg in remoteDir.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
parts += "/" + seg;
|
||||
if (remoteDirs.Add(parts))
|
||||
{
|
||||
try { await _protocol.CreateDirAsync(parts); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var localFile in Directory.EnumerateFiles(backupFolder, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string rel = Path.GetRelativePath(backupFolder, localFile).Replace('\\', '/');
|
||||
string remote = mnt + "/" + rel;
|
||||
try { bool ok = await _protocol.UploadFileAsync(localFile, remote); if (ok) { uploaded++; totalBytes += new FileInfo(localFile).Length; } else failed++; }
|
||||
catch { failed++; }
|
||||
}
|
||||
Log($"✅ Restore upload done: {uploaded} uploaded, {failed} failed, {FormatFileSize(totalBytes)} - writing image back...");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Restore failed: {ex.Message}"); await ShowMessageAsync($"Restore failed:\n{ex.Message}", "Error"); }
|
||||
|
||||
if (weMounted) await UnmountSaveAsync();
|
||||
else await UpdateSaveMountStatusAsync();
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
|
||||
private async void DeleteSaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await DeleteSaveAsync(save);
|
||||
else await ShowMessageAsync("Select a save first.");
|
||||
}
|
||||
|
||||
private async void DeleteSaveMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save) await DeleteSaveAsync(save);
|
||||
}
|
||||
|
||||
private async Task DeleteSaveAsync(PS5SaveFile save)
|
||||
{
|
||||
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5.", "Error"); return; }
|
||||
if (!await ShowConfirmAsync($"Delete save {save.SaveName} ({save.TitleId}) from the console?\n\n{save.Path}\n\nThis cannot be undone."))
|
||||
return;
|
||||
var (ok, msg) = await _protocol.SaveDeleteAsync(save.Path);
|
||||
if (ok) { Log($"🗑 Deleted {save.SaveName}"); await RefreshSavesAsync(); }
|
||||
else { Log($"❌ Delete failed: {msg}"); await ShowMessageAsync($"Delete failed:\n{msg}", "Error"); }
|
||||
}
|
||||
|
||||
private async void CopySavePathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SavesListBox.SelectedItem is PS5SaveFile save)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb != null)
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, save.Path);
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await cb.SetDataAsync(data);
|
||||
Log($"📋 Copied: {save.Path}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Clipboard: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GAMES TAB
|
||||
// ============================================================
|
||||
private async void RefreshGameListButton_Click(object? sender, RoutedEventArgs e) => await RefreshGameListAsync();
|
||||
|
||||
private async Task RefreshGameListAsync()
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log("🎮 Fetching mounted games list...");
|
||||
try
|
||||
{
|
||||
var games = await _protocol.GetGameListAsync();
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
MountedGamesListBox.ItemsSource = games;
|
||||
GameCountText.Text = $" ({games.Count} games)";
|
||||
});
|
||||
|
||||
if (games.Count > 0)
|
||||
{
|
||||
Log($"🎮 Found {games.Count} mounted games:");
|
||||
foreach (var game in games) Log($" • {game.TitleId} - {game.Name} [{game.Region}]");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
// Dedicated connection: keeps bulk icon fetches off the
|
||||
// shared main connection (no more serialized UI stalls).
|
||||
using var iconProto = new PS5Protocol();
|
||||
if (!await iconProto.ConnectAsync(_ps5IpAddress)) return;
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
if (_iconCache.TryGetValue(game.TitleId, out var cached)) { await Dispatcher.UIThread.InvokeAsync(() => game.Icon = cached); continue; }
|
||||
try
|
||||
{
|
||||
var iconBytes = await iconProto.GetGameIconAsync(game.TitleId);
|
||||
if (iconBytes != null && iconBytes.Length > 0)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try { using var ms = new MemoryStream(iconBytes); var bmp = new Bitmap(ms); _iconCache[game.TitleId] = bmp; game.Icon = bmp; } catch { }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (!_protocol.IsConnected) break;
|
||||
}
|
||||
});
|
||||
}
|
||||
else Log("🎮 No mounted games found");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Error fetching game list: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void MountedGamesListBox_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game) await ShowGameDetailsAsync(game);
|
||||
}
|
||||
|
||||
private async void ViewGameDetailsMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game) await ShowGameDetailsAsync(game);
|
||||
}
|
||||
|
||||
private async Task ShowGameDetailsAsync(PS5MountedGame game)
|
||||
{
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log($"ℹ️ Fetching details for {game.TitleId}...");
|
||||
var details = await _protocol.GetGameDetailsAsync(game.TitleId);
|
||||
if (details == null) { await ShowMessageAsync("Failed to fetch game details.", "Error"); return; }
|
||||
var dlg = new GameDetailsWindow(game, details, _protocol);
|
||||
await dlg.ShowDialog(this);
|
||||
}
|
||||
|
||||
private async void UnmountGameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game)
|
||||
{
|
||||
if (await ShowConfirmAsync($"Unmount game {game.TitleId}?\n\n{game.Name}\n\nThis will remove the game from the PS5 home screen."))
|
||||
{
|
||||
Log($"🗑️ Unmounting {game.TitleId}...");
|
||||
var (success, message) = await _protocol.UnmountGameAsync(game.TitleId, msg => Log($" {msg}"));
|
||||
if (success) { Log($"✅ {message}"); await RefreshGameListAsync(); }
|
||||
else Log($"❌ Failed: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenGamePathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is PS5MountedGame game)
|
||||
{
|
||||
string gamePath = game.Path;
|
||||
if (!string.IsNullOrEmpty(gamePath))
|
||||
{
|
||||
Log($"📂 Navigating to {gamePath}");
|
||||
_currentPS5Path = gamePath;
|
||||
NavFiles.IsChecked = true; // sidebar selection state
|
||||
NavButton_Click(NavFiles, e); // switch to the File Transfer page
|
||||
await LoadPS5DirectoryAsync(gamePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void LaunchGameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is not PS5MountedGame game) return;
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log($"▶ Launching {game.TitleId} ({game.Name})...");
|
||||
var (success, message) = await _protocol.LaunchGameAsync(game.TitleId);
|
||||
if (success) Log($"✅ {message}"); else Log($"❌ Launch failed: {message}");
|
||||
}
|
||||
|
||||
private async void MountGameMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (MountedGamesListBox.SelectedItem is not PS5MountedGame game) return;
|
||||
if (!_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log($"📥 Mounting {game.TitleId} ({game.Name})...");
|
||||
var result = await _protocol.MountGameAsync(game.TitleId,
|
||||
onProgress: msg => Dispatcher.UIThread.Post(() => Log($" {msg}")));
|
||||
Log(result ?? $"❌ Mount failed: {_protocol.LastError}");
|
||||
await RefreshGameListAsync();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HARDWARE TAB
|
||||
// ============================================================
|
||||
private async void HwAutoTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { StopHwAutoRefresh(); return; }
|
||||
if (!PageSystem.IsVisible) { StopHwAutoRefresh(); return; }
|
||||
if (_activeTaskCount > 0) return;
|
||||
try { await RefreshHardwareAsync(); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task RefreshHardwareAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
if (Interlocked.CompareExchange(ref _hwBusyFlag, 1, 0) != 0) { Log("⏳ Hardware refresh already in progress"); return; }
|
||||
try
|
||||
{
|
||||
// Static info — fetched once per session; none of it can change
|
||||
// while the console is running, so it stays out of the refresh loop.
|
||||
if (!_hwStaticLoaded)
|
||||
{
|
||||
var hw = await _protocol.GetHardwareInfoAsync();
|
||||
if (hw != null)
|
||||
{
|
||||
HwModelText.Text = string.IsNullOrWhiteSpace(hw.Model) ? "PlayStation 5" : hw.Model;
|
||||
HwSerialText.Text = string.IsNullOrWhiteSpace(hw.Serial) ? "—" : hw.Serial;
|
||||
HwMachineText.Text = string.IsNullOrWhiteSpace(hw.HwMachine) ? "—" : hw.HwMachine;
|
||||
HwOsText.Text = string.IsNullOrWhiteSpace(hw.OsVersion) ? "—" : hw.OsVersion;
|
||||
HwCpuCoresText.Text = hw.NumCpu > 0 ? $"{hw.NumCpu} cores" : "—";
|
||||
HwPhysMemText.Text = hw.PhysMem > 0 ? $"{hw.PhysMem / (1024.0 * 1024.0 * 1024.0):0.0} GB" : "—";
|
||||
}
|
||||
|
||||
var ext = await _protocol.GetExtendedInfoAsync();
|
||||
if (ext != null)
|
||||
{
|
||||
HwFirmwareText.Text = !string.IsNullOrWhiteSpace(ext.FirmwareVersion) ? ext.FirmwareVersion : "—";
|
||||
HwProductCodeText.Text = !string.IsNullOrWhiteSpace(ext.ProductCode) ? ext.ProductCode : "—";
|
||||
HwTotalUptimeText.Text = ext.TotalOperatingTimeSec > 0
|
||||
? $"{ext.TotalOperatingTimeSec / 3600}h {(ext.TotalOperatingTimeSec % 3600) / 60}m"
|
||||
: "—";
|
||||
HwBootCountText.Text = ext.BootCount > 0 ? $"{ext.BootCount} cycles" : "—";
|
||||
HwBdDriveText.Text = ext.BdDrivePower >= 0 ? (ext.BdDrivePower == 1 ? "On" : "Off") : "—";
|
||||
HwThermalAlertText.Text = ext.ThermalAlert == 0 ? "Normal" : $"Alert ({ext.ThermalAlert})";
|
||||
if (ext.ThermalAlert != 0) HwThermalAlertText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#FF6B35"));
|
||||
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;
|
||||
}
|
||||
|
||||
// Memory info — dynamic, refreshed every tick
|
||||
var mem = await _protocol.GetMemoryInfoAsync();
|
||||
if (mem != null)
|
||||
{
|
||||
// Physical RAM is static — fill it once from this (more reliable) source
|
||||
if (mem.PhysicalTotal > 0 && HwPhysMemText.Text == "—")
|
||||
HwPhysMemText.Text = FormatBytes(mem.PhysicalTotal);
|
||||
HwMemDirectTotalText.Text = FormatBytes(mem.PhysicalTotal > 0 ? mem.PhysicalTotal : mem.DirectTotal);
|
||||
HwMemDirectAvailText.Text = FormatBytes(mem.DirectUsed);
|
||||
HwMemDirectUsedText.Text = FormatBytes(mem.FreeMemory);
|
||||
HwMemFlexibleText.Text = FormatBytes(mem.FlexibleAvailable);
|
||||
HwMemFreeText.Text = FormatBytes(mem.GpuPoolFree);
|
||||
HwMemUsageBar.Value = Math.Clamp(mem.DirectUsedPercent, 0, 100);
|
||||
HwMemUsageText.Text = $"{mem.DirectUsedPercent}%";
|
||||
}
|
||||
|
||||
await RefreshHardwareSensorsAsync();
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Hardware refresh failed: {ex.Message}"); HwStatusText.Text = $"Error: {ex.Message}"; }
|
||||
finally { Interlocked.Exchange(ref _hwBusyFlag, 0); }
|
||||
}
|
||||
|
||||
private string FormatBytes(ulong bytes)
|
||||
{
|
||||
if (bytes == 0) return "—";
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB";
|
||||
if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):0.0} MB";
|
||||
return $"{bytes / (1024.0 * 1024.0 * 1024.0):0.00} GB";
|
||||
}
|
||||
|
||||
private async Task RefreshHardwareSensorsAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) return;
|
||||
try
|
||||
{
|
||||
// Temperature sensors
|
||||
var t = await _protocol.GetTemperatureInfoAsync();
|
||||
if (t != null)
|
||||
{
|
||||
HwCpuTempText.Text = $"{t.CpuTemp}°C";
|
||||
HwCpuTempBar.Value = Math.Clamp(t.CpuTemp, 0, 100);
|
||||
HwSocTempText.Text = $"{t.SocTemp}°C";
|
||||
HwSocTempBar.Value = Math.Clamp(t.SocTemp, 0, 100);
|
||||
HwCpuFreqText.Text = t.CpuFreqMhz > 0 ? $"{t.CpuFreqMhz} MHz" : "—";
|
||||
HwCpuFreqBar.Value = Math.Clamp(t.CpuFreqMhz, 0, 3500);
|
||||
double watts = t.SocPowerMw / 1000.0;
|
||||
HwSocPowerText.Text = t.SocPowerMw > 0 ? $"{watts:0.0} W" : "—";
|
||||
HwSocPowerBar.Value = Math.Clamp(watts, 0, 250);
|
||||
}
|
||||
|
||||
// CPU usage per core
|
||||
var cpu = await _protocol.GetCpuUsageAsync();
|
||||
if (cpu != null)
|
||||
{
|
||||
HwCpu0Text.Text = $"{cpu.CoreUsage[0]}%"; HwCpu0Bar.Value = cpu.CoreUsage[0];
|
||||
HwCpu1Text.Text = $"{cpu.CoreUsage[1]}%"; HwCpu1Bar.Value = cpu.CoreUsage[1];
|
||||
HwCpu2Text.Text = $"{cpu.CoreUsage[2]}%"; HwCpu2Bar.Value = cpu.CoreUsage[2];
|
||||
HwCpu3Text.Text = $"{cpu.CoreUsage[3]}%"; HwCpu3Bar.Value = cpu.CoreUsage[3];
|
||||
HwCpu4Text.Text = $"{cpu.CoreUsage[4]}%"; HwCpu4Bar.Value = cpu.CoreUsage[4];
|
||||
HwCpu5Text.Text = $"{cpu.CoreUsage[5]}%"; HwCpu5Bar.Value = cpu.CoreUsage[5];
|
||||
HwCpu6Text.Text = $"{cpu.CoreUsage[6]}%"; HwCpu6Bar.Value = cpu.CoreUsage[6];
|
||||
HwCpu7Text.Text = $"{cpu.CoreUsage[7]}%"; HwCpu7Bar.Value = cpu.CoreUsage[7];
|
||||
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; }
|
||||
try
|
||||
{
|
||||
Log("📦 Loading modules list...");
|
||||
var modules = await _protocol.GetModuleListAsync();
|
||||
ModulesListBox.ItemsSource = modules;
|
||||
HwModuleCountText.Text = $" ({modules.Count})";
|
||||
Log($"✅ Found {modules.Count} modules");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Failed to load modules: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SCREENSHOTS TAB
|
||||
// ============================================================
|
||||
private async void RefreshScreenshotsButton_Click(object? sender, RoutedEventArgs e) => await RefreshScreenshotsAsync();
|
||||
|
||||
private async Task RefreshScreenshotsAsync()
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
Log("📷 Fetching screenshots list...");
|
||||
try
|
||||
{
|
||||
var shots = await _protocol.ListScreenshotsAsync();
|
||||
_currentScreenshots = shots;
|
||||
foreach (var s in shots)
|
||||
if (_screenshotThumbCache.TryGetValue(s.FullPath, out var cached)) s.Thumbnail = cached;
|
||||
|
||||
ScreenshotsListBox.ItemsSource = null;
|
||||
ScreenshotsListBox.ItemsSource = shots;
|
||||
ScreenshotsCountText.Text = $"({shots.Count} items)";
|
||||
Log($"✅ Found {shots.Count} screenshots");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
string cacheDir = Path.Combine(Path.GetTempPath(), "PS5SuiteCache", "ss_thumbs");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
// Dedicated connection: thumbnail downloads used to run on
|
||||
// the main connection and block every other UI operation.
|
||||
using var thumbProto = new PS5Protocol();
|
||||
if (!await thumbProto.ConnectAsync(_ps5IpAddress)) return;
|
||||
|
||||
foreach (var shot in shots)
|
||||
{
|
||||
if (shot.Thumbnail != null) continue;
|
||||
if (!_protocol.IsConnected) break;
|
||||
// Stable 64-bit FNV-1a hash instead of GetHashCode():
|
||||
// string hashing is randomized per process, so cache
|
||||
// names changed between runs (and could collide).
|
||||
string hash = FnvHash64(shot.FullPath).ToString("X16");
|
||||
string ext = Path.GetExtension(shot.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
|
||||
string localCache = Path.Combine(cacheDir, hash + ext);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(localCache) || new FileInfo(localCache).Length == 0)
|
||||
{
|
||||
bool ok = await thumbProto.DownloadFileAsync(shot.FullPath, localCache);
|
||||
if (!ok) continue;
|
||||
}
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = new Bitmap(localCache);
|
||||
_screenshotThumbCache[shot.FullPath] = bmp;
|
||||
shot.Thumbnail = bmp;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Screenshots fetch failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async void DownloadScreenshotButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
var selected = (ScreenshotsListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5Screenshot>().ToList();
|
||||
if (selected.Count == 0) { Log("⚠️ No screenshots selected"); return; }
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select folder to save screenshots" });
|
||||
if (folders.Count == 0) return;
|
||||
var targetFolder = folders[0].TryGetLocalPath();
|
||||
if (targetFolder == null) return;
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
foreach (var shot in selected)
|
||||
{
|
||||
string localPath = Path.Combine(targetFolder, shot.FileName);
|
||||
Log($"⬇ Downloading {shot.FileName} ({shot.SizeDisplay})...");
|
||||
try { bool success = await _protocol.DownloadFileAsync(shot.FullPath, localPath); if (success) ok++; else { fail++; Log($"❌ Failed: {shot.FileName}"); } }
|
||||
catch (Exception ex) { fail++; Log($"❌ {shot.FileName}: {ex.Message}"); }
|
||||
}
|
||||
Log($"✅ Downloaded {ok} screenshot(s), {fail} failed → {targetFolder}");
|
||||
}
|
||||
|
||||
private async void DeleteScreenshotButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
|
||||
var selected = (ScreenshotsListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5Screenshot>().ToList();
|
||||
if (selected.Count == 0) { Log("⚠️ No screenshots selected"); return; }
|
||||
|
||||
if (!await ShowConfirmAsync($"Delete {selected.Count} screenshot(s) from PS5?\n\nThis cannot be undone."))
|
||||
return;
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
foreach (var shot in selected)
|
||||
{
|
||||
try { var (success, _) = await _protocol.DeleteScreenshotAsync(shot.FullPath); if (success) ok++; else fail++; }
|
||||
catch { fail++; }
|
||||
}
|
||||
Log($"🗑️ Deleted {ok} screenshot(s), {fail} failed");
|
||||
await RefreshScreenshotsAsync();
|
||||
}
|
||||
|
||||
private async void ScreenshotsListBox_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
if (ScreenshotsListBox.SelectedItem is not PS5Screenshot shot) return;
|
||||
if (_protocol == null || !_protocol.IsConnected) return;
|
||||
string tempPath = Path.Combine(Path.GetTempPath(), shot.FileName);
|
||||
Log($"⬇ Opening preview: {shot.FileName}...");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DownloadFileAsync(shot.FullPath, tempPath);
|
||||
if (ok) System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = tempPath, UseShellExecute = true });
|
||||
else Log($"❌ Failed to download preview");
|
||||
}
|
||||
catch (Exception ex) { Log($"❌ Preview failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Stable 64-bit FNV-1a hash for cache file names
|
||||
private static long FnvHash64(string s)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
ulong hash = 14695981039346656037UL;
|
||||
foreach (char c in s)
|
||||
{
|
||||
hash ^= c;
|
||||
hash *= 1099511628211UL;
|
||||
}
|
||||
return (long)hash;
|
||||
}
|
||||
}
|
||||
|
||||
private async void CopyScreenshotPathMenuItem_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ScreenshotsListBox.SelectedItem is PS5Screenshot shot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb != null)
|
||||
{
|
||||
var item = new Avalonia.Input.DataTransferItem();
|
||||
item.Set(Avalonia.Input.DataFormat.Text, shot.FullPath);
|
||||
var data = new Avalonia.Input.DataTransfer();
|
||||
data.Add(item);
|
||||
await cb.SetDataAsync(data);
|
||||
}
|
||||
Log($"📋 Copied path: {shot.FullPath}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
// IProgress that runs Report() inline on the caller's thread instead
|
||||
// of marshaling to the captured SynchronizationContext. Progress<T>
|
||||
// was flooding the UI thread with a callback per send-buffer flush ×
|
||||
// 27+ parallel lanes — the window froze (wait cursor, dead tabs).
|
||||
private sealed class InlineProgress<T> : IProgress<T>
|
||||
{
|
||||
private readonly Action<T> _action;
|
||||
public InlineProgress(Action<T> action) => _action = action;
|
||||
public void Report(T value) => _action(value);
|
||||
}
|
||||
|
||||
private void UiUpdateTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
int completed;
|
||||
lock (_progressLock) { completed = _completedFiles; }
|
||||
UpdateUploadStats(completed, _activeTaskCount);
|
||||
FlushLog();
|
||||
FlushPendingHistory();
|
||||
}
|
||||
|
||||
// Move queued transfer-history items into the bound collections in
|
||||
// one batch (bounded at 2000 entries so the list can't grow forever).
|
||||
private readonly List<TransferHistoryItem> _pendingHistory = new();
|
||||
private readonly object _pendingHistoryLock = new();
|
||||
private void FlushPendingHistory()
|
||||
{
|
||||
List<TransferHistoryItem>? pending = null;
|
||||
lock (_pendingHistoryLock)
|
||||
{
|
||||
if (_pendingHistory.Count > 0)
|
||||
{
|
||||
pending = new List<TransferHistoryItem>(_pendingHistory);
|
||||
_pendingHistory.Clear();
|
||||
}
|
||||
}
|
||||
if (pending == null) return;
|
||||
foreach (var it in pending)
|
||||
{
|
||||
var target = it.Status.StartsWith("❌") ? _failedTransfers : _completedTransfers;
|
||||
target.Add(it);
|
||||
while (target.Count > 2000) target.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUploadStats(int completedFiles, int activeTaskCount)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
int remainingFiles = Math.Max(0, _totalFilesToUpload - completedFiles);
|
||||
FilesRemainingText.Text = $"Files: {completedFiles} / {_totalFilesToUpload} ({remainingFiles} remaining)";
|
||||
|
||||
double totalPercent = _totalBytesToUpload > 0 ? (double)_totalBytesUploaded / _totalBytesToUpload * 100 : 0;
|
||||
TotalProgressBar.Value = totalPercent;
|
||||
TotalProgressText.Text = $"Total: {completedFiles} / {_totalFilesToUpload} files ({FormatFileSize(_totalBytesUploaded)} / {FormatFileSize(_totalBytesToUpload)}) [{totalPercent:F1}%]";
|
||||
|
||||
var elapsed = DateTime.Now - _uploadStartTime;
|
||||
long currentBytes = Interlocked.Read(ref _totalBytesUploaded);
|
||||
var now = DateTime.Now;
|
||||
_speedWindowBytes[_speedWindowIndex] = currentBytes;
|
||||
_speedWindowTimes[_speedWindowIndex] = now;
|
||||
_speedWindowIndex = (_speedWindowIndex + 1) % SpeedWindowSize;
|
||||
if (_speedWindowCount < SpeedWindowSize) _speedWindowCount++;
|
||||
|
||||
double realtimeSpeed = 0;
|
||||
if (_speedWindowCount >= 2)
|
||||
{
|
||||
int oldestIndex = (_speedWindowIndex - _speedWindowCount + SpeedWindowSize) % SpeedWindowSize;
|
||||
long bytesDelta = currentBytes - _speedWindowBytes[oldestIndex];
|
||||
double timeDelta = (now - _speedWindowTimes[oldestIndex]).TotalSeconds;
|
||||
if (timeDelta > 0.1) realtimeSpeed = bytesDelta / timeDelta;
|
||||
}
|
||||
|
||||
if (_currentSpeed <= 0) _currentSpeed = realtimeSpeed;
|
||||
else if (realtimeSpeed > 0) _currentSpeed = (_currentSpeed * 0.7) + (realtimeSpeed * 0.3);
|
||||
|
||||
double avgSpeed = elapsed.TotalSeconds > 0 ? currentBytes / elapsed.TotalSeconds : 0;
|
||||
double etaSpeed = (_currentSpeed * 0.6) + (avgSpeed * 0.4);
|
||||
long remainingBytes = _totalBytesToUpload - currentBytes;
|
||||
TimeSpan rawETA = etaSpeed > 0 ? TimeSpan.FromSeconds(remainingBytes / etaSpeed) : TimeSpan.Zero;
|
||||
|
||||
if (_smoothedETA == TimeSpan.Zero) _smoothedETA = rawETA;
|
||||
else if (rawETA.TotalSeconds > 0)
|
||||
{
|
||||
double smoothedSeconds = (_smoothedETA.TotalSeconds * (1 - ETASmoothingFactor)) + (rawETA.TotalSeconds * ETASmoothingFactor);
|
||||
_smoothedETA = TimeSpan.FromSeconds(Math.Max(0, smoothedSeconds));
|
||||
}
|
||||
TimeSpan eta = _smoothedETA;
|
||||
|
||||
UploadSpeedText.Text = $"Speed: {FormatFileSize((long)_currentSpeed)}/s (avg {FormatFileSize((long)avgSpeed)}/s) | {activeTaskCount} active";
|
||||
string etaStr = eta.TotalHours >= 1 ? $"{(int)eta.TotalHours}h {eta.Minutes:D2}m {eta.Seconds:D2}s"
|
||||
: eta.TotalMinutes >= 1 ? $"{(int)eta.TotalMinutes}m {eta.Seconds:D2}s" : $"{eta.Seconds}s";
|
||||
UploadETAText.Text = $"ETA: {etaStr} | Elapsed: {elapsed:hh\\:mm\\:ss}";
|
||||
if (activeTaskCount > 0) UploadFileNameText.Text = $"Uploading {activeTaskCount} files in parallel...";
|
||||
|
||||
// Per-file bar — the progress callbacks only update these
|
||||
// fields now; the timer paints them (no per-report UI posts).
|
||||
long fb = Interlocked.Read(ref _currentFileBytes);
|
||||
long ft = Interlocked.Read(ref _currentFileTotalBytes);
|
||||
double fpct = ft > 0 ? (double)fb / ft * 100 : 0;
|
||||
UploadProgressBar.Value = fpct;
|
||||
UploadProgressText.Text = $"{FormatFileSize(fb)} / {FormatFileSize(ft)} ({fpct:F1}%)";
|
||||
});
|
||||
}
|
||||
|
||||
private async void UploadButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
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;
|
||||
_uploadCancellation = new CancellationTokenSource();
|
||||
|
||||
Log("========== UPLOAD STARTED ==========");
|
||||
var localFilesCopy = _localFiles.ToList();
|
||||
string currentPath = _currentPS5Path;
|
||||
UploadFileNameText.Text = "Preparing upload...";
|
||||
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, ct);
|
||||
else files.Add((item.FullPath, targetBasePath));
|
||||
}
|
||||
return files;
|
||||
}, ct);
|
||||
|
||||
Log("Checking for existing files...");
|
||||
var filesToUpload = await FilterDuplicateFilesAsync(allFiles);
|
||||
if (filesToUpload.Count == 0)
|
||||
{
|
||||
Log("⚠️ No files to upload (all skipped)");
|
||||
await ShowMessageAsync("No files to upload. All files were skipped.");
|
||||
UploadButton.IsEnabled = true; CancelButton.IsEnabled = false; ProgressPanel.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
allFiles = filesToUpload;
|
||||
|
||||
_totalFilesToUpload = allFiles.Count;
|
||||
_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;
|
||||
_smallFileBatchRemainder = 0; _smallFileCompletedTotal = 0; _smallFileBatchBytes = 0; _smallFileTotalBytes = 0;
|
||||
_activeLargeUploads = 0; _activeHugeUploads = 0;
|
||||
DrainConnectionPool();
|
||||
Log($"📊 Total: {_totalFilesToUpload} files, {FormatFileSize(_totalBytesToUpload)}");
|
||||
TotalProgressText.Text = $"Total: 0 / {_totalFilesToUpload} files ({FormatFileSize(0)} / {FormatFileSize(_totalBytesToUpload)})";
|
||||
TotalProgressBar.Value = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Create directories
|
||||
Log("Creating directories...");
|
||||
var directories = allFiles.Select(f => Path.GetDirectoryName(f.remotePath)?.Replace("\\", "/"))
|
||||
.Where(d => !string.IsNullOrEmpty(d) && d != _currentPS5Path).Distinct().OrderBy(d => d?.Length ?? 0).ToList();
|
||||
|
||||
if (!_protocol.IsConnected)
|
||||
{
|
||||
Log("⚠️ Connection lost before directory creation, reconnecting...");
|
||||
if (!await _protocol.ConnectAsync(_ps5IpAddress)) throw new Exception("Failed to reconnect to PS5 before directory creation");
|
||||
Log("✅ Reconnected successfully");
|
||||
}
|
||||
|
||||
int dirCreated = 0, dirTotal = directories.Count;
|
||||
int dirLogInterval = Math.Max(1, dirTotal / 10);
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) continue;
|
||||
if (dirCreated % dirLogInterval == 0 || dirCreated == 0)
|
||||
Dispatcher.UIThread.Post(() => UploadFileNameText.Text = $"Creating directories... ({dirCreated}/{dirTotal})");
|
||||
try
|
||||
{
|
||||
using var dirCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var dirTask = _protocol.CreateDirAsync(dir);
|
||||
var doneTask = await Task.WhenAny(dirTask, Task.Delay(-1, dirCts.Token));
|
||||
if (doneTask != dirTask) throw new TimeoutException($"Timeout creating directory: {dir}");
|
||||
await dirTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"⚠️ Failed to create dir {dir}: {ex.Message}, retrying...");
|
||||
try { _protocol.Disconnect(); await Task.Delay(500); if (await _protocol.ConnectAsync(_ps5IpAddress)) await _protocol.CreateDirAsync(dir); else throw new Exception("Reconnect failed"); }
|
||||
catch (Exception retryEx) { Log($"❌ Directory creation failed permanently: {dir} - {retryEx.Message}"); }
|
||||
}
|
||||
dirCreated++;
|
||||
}
|
||||
Log($"✅ Created {dirCreated}/{dirTotal} directories");
|
||||
|
||||
if (!_protocol.IsConnected)
|
||||
{
|
||||
Log("⚠️ Connection lost after directory creation, reconnecting...");
|
||||
if (!await _protocol.ConnectAsync(_ps5IpAddress)) throw new Exception("Failed to reconnect to PS5 after directory creation");
|
||||
Log("✅ Reconnected successfully");
|
||||
}
|
||||
|
||||
_uiUpdateTimer.Start();
|
||||
|
||||
// Pre-warm connection pool
|
||||
int preWarmCount = Math.Min(MaxParallelUploads - 1, allFiles.Count);
|
||||
Log($"🚀 Starting parallel upload with {MaxParallelUploads} connections (pre-warming {preWarmCount})...");
|
||||
var preWarmTasks = new List<Task<PS5Protocol?>>();
|
||||
for (int i = 0; i < preWarmCount; i++)
|
||||
preWarmTasks.Add(Task.Run(async () => { try { var c = new PS5Protocol(); if (await c.ConnectAsync(_ps5IpAddress)) { Interlocked.Increment(ref _currentPoolConnections); return c; } c.Dispose(); } catch { } return (PS5Protocol?)null; }));
|
||||
await Task.WhenAll(preWarmTasks);
|
||||
int pooled = 0;
|
||||
foreach (var t in preWarmTasks) { if (t.Result != null) { _connectionPool.Enqueue(t.Result); pooled++; } }
|
||||
Log($"✅ {pooled} connections ready");
|
||||
|
||||
// Parallel upload loop
|
||||
var fileQueue = new Queue<(string localPath, string remotePath)>(allFiles);
|
||||
var activeTasks = new List<Task>();
|
||||
var taskToConnection = new Dictionary<Task, PS5Protocol>();
|
||||
var taskToFilePath = new Dictionary<Task, string>();
|
||||
var taskToRemotePath = new Dictionary<Task, string>();
|
||||
var fileChunkCounts = new Dictionary<string, int>();
|
||||
var fileChunksCompleted = new Dictionary<string, int>();
|
||||
var completedFilesSet = new HashSet<string>();
|
||||
var failedFiles = new Dictionary<string, int>();
|
||||
var taskIsLargeFile = new Dictionary<Task, bool>();
|
||||
var taskIsHugeFile = new Dictionary<Task, bool>();
|
||||
const int MAX_RETRIES = 3;
|
||||
|
||||
UploadFileNameText.Text = $"Parallel upload: {MaxParallelUploads} connections";
|
||||
|
||||
while (fileQueue.Count > 0 || activeTasks.Count > 0)
|
||||
{
|
||||
if (_uploadCancellation.Token.IsCancellationRequested) { Log("⚠️ Upload cancelled by user"); break; }
|
||||
|
||||
while (fileQueue.Count > 0 && activeTasks.Count < MaxParallelUploads)
|
||||
{
|
||||
var (localPath, remotePath) = fileQueue.Dequeue();
|
||||
long fileSize = LocalIo.GetLength(localPath);
|
||||
bool isLargeFile = fileSize > LargeFileThresholdBytes;
|
||||
bool isHugeFile = fileSize >= HugeFileThresholdBytes;
|
||||
|
||||
if (isLargeFile && Volatile.Read(ref _activeLargeUploads) >= MaxParallelLargeFiles) { fileQueue.Enqueue((localPath, remotePath)); break; }
|
||||
if (isHugeFile && Volatile.Read(ref _activeHugeUploads) >= MaxParallelHugeFiles) { fileQueue.Enqueue((localPath, remotePath)); break; }
|
||||
|
||||
PS5Protocol connection;
|
||||
try { connection = await AcquireUploadConnectionAsync(); }
|
||||
catch (Exception ex) { Log($"❌ Unable to acquire connection: {ex.Message}. Requeueing {Path.GetFileName(localPath)}"); fileQueue.Enqueue((localPath, remotePath)); await Task.Delay(1000); break; }
|
||||
|
||||
if (isLargeFile) Interlocked.Increment(ref _activeLargeUploads);
|
||||
if (isHugeFile) Interlocked.Increment(ref _activeHugeUploads);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (activeTasks.Count > 0)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(activeTasks);
|
||||
bool taskSucceeded = false;
|
||||
try { await completedTask; taskSucceeded = true; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"❌ Task exception: {ex.Message}");
|
||||
if (taskToFilePath.TryGetValue(completedTask, out var fp) && taskToRemotePath.TryGetValue(completedTask, out var rp))
|
||||
{
|
||||
if (!failedFiles.ContainsKey(fp)) failedFiles[fp] = 0;
|
||||
failedFiles[fp]++;
|
||||
if (failedFiles[fp] <= MAX_RETRIES)
|
||||
{
|
||||
Log($"🔄 Requeueing ({failedFiles[fp]}/{MAX_RETRIES}): {Path.GetFileName(fp)}");
|
||||
try { await _protocol.DeleteFileAsync(rp); } catch { }
|
||||
fileQueue.Enqueue((fp, rp)); fileChunksCompleted[fp] = 0;
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
else Log($"❌ Max retries exceeded: {Path.GetFileName(fp)}");
|
||||
}
|
||||
Dispatcher.UIThread.Post(() => UploadFileNameText.Text = $"Upload error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (taskToFilePath.TryGetValue(completedTask, out var filePath))
|
||||
{
|
||||
if (taskSucceeded)
|
||||
{
|
||||
lock (_progressLock)
|
||||
{
|
||||
fileChunksCompleted[filePath]++;
|
||||
if (fileChunksCompleted[filePath] >= fileChunkCounts[filePath] && !completedFilesSet.Contains(filePath))
|
||||
{
|
||||
completedFilesSet.Add(filePath);
|
||||
int completed = Interlocked.Increment(ref _completedFiles);
|
||||
Log($"✅ File {completed}/{_totalFilesToUpload} completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
taskToFilePath.Remove(completedTask); taskToRemotePath.Remove(completedTask);
|
||||
}
|
||||
|
||||
activeTasks.Remove(completedTask);
|
||||
if (taskToConnection.TryGetValue(completedTask, out var completedConn))
|
||||
{
|
||||
taskToConnection.Remove(completedTask);
|
||||
if (taskIsLargeFile.TryGetValue(completedTask, out var wasLarge) && wasLarge) { Interlocked.Decrement(ref _activeLargeUploads); taskIsLargeFile.Remove(completedTask); }
|
||||
if (taskIsHugeFile.TryGetValue(completedTask, out var wasHuge) && wasHuge) { Interlocked.Decrement(ref _activeHugeUploads); taskIsHugeFile.Remove(completedTask); }
|
||||
if (taskSucceeded && completedConn.IsConnected) ReleaseUploadConnection(completedConn); else DestroyConnection(completedConn);
|
||||
}
|
||||
_activeTaskCount = activeTasks.Count;
|
||||
// No UpdateUploadStats here — the 500ms UI timer
|
||||
// refreshes stats; a UI post per completed file was
|
||||
// another flood source on 100k+ uploads.
|
||||
}
|
||||
else if (fileQueue.Count > 0) { Log($"⚠️ No active tasks but {fileQueue.Count} files remain - retrying..."); await Task.Delay(500); }
|
||||
}
|
||||
|
||||
Log("🔄 Upload loop finished"); FlushSmallFileBatch(); _uiUpdateTimer.Stop();
|
||||
foreach (var conn in taskToConnection.Values.ToList()) { try { conn.Disconnect(); conn.Dispose(); } catch { } }
|
||||
taskToConnection.Clear(); DrainConnectionPool(); Log("✅ Cleanup complete");
|
||||
|
||||
if (!_uploadCancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
Log($"🎉 Upload completed! {_totalFilesToUpload} files, {FormatFileSize(_totalBytesUploaded)}");
|
||||
await ShowMessageAsync($"Upload completed!\n\n{_totalFilesToUpload} files uploaded\nTotal: {FormatFileSize(_totalBytesUploaded)}", "Success");
|
||||
_localFiles.Clear();
|
||||
}
|
||||
else { Log("⚠️ Upload cancelled"); await ShowMessageAsync("Upload cancelled by user", "Cancelled"); }
|
||||
|
||||
// Reconnect if needed
|
||||
if (!_protocol.IsConnected) { try { if (await _protocol.ConnectAsync(_ps5IpAddress)) { Log("✅ Reconnected"); await LoadPS5DirectoryAsync(_currentPS5Path); } } catch { } }
|
||||
else { Log("✅ Main connection still active"); await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
}
|
||||
catch (OperationCanceledException) { Log("❌ Upload cancelled"); await ShowMessageAsync("Upload cancelled by user", "Cancelled"); if (!_protocol.IsConnected) { try { if (await _protocol.ConnectAsync(_ps5IpAddress)) await LoadPS5DirectoryAsync(_currentPS5Path); } catch { } } else await LoadPS5DirectoryAsync(_currentPS5Path); }
|
||||
catch (Exception ex) { Log($"❌ Upload failed: {ex.Message}\n{ex.StackTrace}"); await ShowMessageAsync($"Upload failed: {ex.Message}", "Error"); }
|
||||
finally
|
||||
{
|
||||
Log("========== UPLOAD FINISHED ==========");
|
||||
FlushLog();
|
||||
_uiUpdateTimer.Stop();
|
||||
FlushPendingHistory();
|
||||
ProgressPanel.IsVisible = false;
|
||||
UploadButton.IsEnabled = true; CancelButton.IsEnabled = false;
|
||||
_uploadCancellation?.Dispose(); _uploadCancellation = null;
|
||||
await LoadPS5DirectoryAsync(_currentPS5Path);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object? sender, RoutedEventArgs e) { _uploadCancellation?.Cancel(); CancelButton.IsEnabled = false; }
|
||||
|
||||
private async Task UploadFileParallelAsync(PS5Protocol connection, string localPath, string remotePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
string fileName = Path.GetFileName(localPath);
|
||||
long fileLen = LocalIo.GetLength(localPath);
|
||||
|
||||
if (fileLen > ChunkThresholdBytes)
|
||||
{
|
||||
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 = fileLen / maxParallelChunks;
|
||||
long minChunk = 64L * 1024 * 1024;
|
||||
if (scaledChunk < minChunk) scaledChunk = minChunk;
|
||||
if (scaledChunk < chunkSize) chunkSize = scaledChunk;
|
||||
|
||||
long totalChunks = (fileLen + chunkSize - 1) / chunkSize;
|
||||
int workerCount = (int)Math.Min(totalChunks, Math.Max(1, maxParallelChunks));
|
||||
Log($"⬆️ Uploading (chunked): {fileName} ({FormatFileSize(fileLen)}) {workerCount} lanes");
|
||||
_fileProgressBytes[localPath] = 0;
|
||||
_fileChunkProgressBytes[localPath] = new ConcurrentDictionary<long, long>();
|
||||
_chunkLogLastBytes[localPath] = 0;
|
||||
|
||||
int nextChunkIndex = -1;
|
||||
var chunk0Ready = new SemaphoreSlim(0, 1);
|
||||
bool chunk0Released = false;
|
||||
var chunk0ReleaseLock = new object();
|
||||
|
||||
// Release the chunk-0 gate exactly once, no matter whether
|
||||
// chunk 0 succeeded or failed. Without this, a failed first
|
||||
// chunk deadlocked every other worker forever.
|
||||
void ReleaseChunk0Once()
|
||||
{
|
||||
lock (chunk0ReleaseLock)
|
||||
{
|
||||
if (!chunk0Released)
|
||||
{
|
||||
chunk0Released = true;
|
||||
chunk0Ready.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var workerTasks = new List<Task>(workerCount);
|
||||
for (int wid = 0; wid < workerCount; wid++) workerTasks.Add(RunWorker(wid));
|
||||
await Task.WhenAll(workerTasks);
|
||||
|
||||
async Task RunWorker(int workerId)
|
||||
{
|
||||
PS5Protocol? wConn = null; bool owns = workerId != 0;
|
||||
try
|
||||
{
|
||||
wConn = owns ? await AcquireUploadConnectionAsync() : connection;
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
int ci = Interlocked.Increment(ref nextChunkIndex);
|
||||
if (ci >= totalChunks) break;
|
||||
if (ci > 0) { await chunk0Ready.WaitAsync(cancellationToken); chunk0Ready.Release(); }
|
||||
Action? cb = ci == 0 ? ReleaseChunk0Once : null;
|
||||
try
|
||||
{
|
||||
await DoChunk(wConn!, ci, cb);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Guarantee the gate opens even if chunk 0 throws
|
||||
if (ci == 0) ReleaseChunk0Once();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { if (owns && wConn != null) { if (wConn.IsConnected) ReleaseUploadConnection(wConn); else DestroyConnection(wConn); } }
|
||||
}
|
||||
|
||||
async Task DoChunk(PS5Protocol wConn, int chunkIndex, Action? readyCallback)
|
||||
{
|
||||
long offset = chunkIndex * chunkSize;
|
||||
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 = fileLen; }
|
||||
}
|
||||
|
||||
IProgress<UploadProgress> MakeChunkProgress(long chunkOffset, long chunkLength, long chunkNumber, long totalChunkCount)
|
||||
{
|
||||
int callCount = 0;
|
||||
// Runs on the worker thread — counters + display fields
|
||||
// only, no UI posts (the 500ms timer paints the bar).
|
||||
return new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
callCount++;
|
||||
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 % 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, fileLen);
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int callCount = 0;
|
||||
var progress = new InlineProgress<UploadProgress>(p =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount % 2 != 0 && p.BytesSent != p.TotalBytes) return;
|
||||
_currentFileName = fileName;
|
||||
Interlocked.Exchange(ref _currentFileBytes, p.BytesSent);
|
||||
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; }
|
||||
});
|
||||
bool success = await connection.UploadFileAsync(localPath, remotePath, progress, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
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 = 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 (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(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(failSize), Timestamp = DateTime.Now, LocalPath = localPath, RemotePath = remotePath });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PS5Protocol> AcquireUploadConnectionAsync()
|
||||
{
|
||||
while (_connectionPool.TryDequeue(out var p)) { if (p.IsConnected) return p; DestroyConnection(p); }
|
||||
var c = new PS5Protocol();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (await c.ConnectAsync(_ps5IpAddress))
|
||||
{
|
||||
// Verify the session was actually accepted. When the server
|
||||
// is over MAX_CLIENT_SESSIONS it replies RESP_ERROR and
|
||||
// closes — a bare TCP connect still "succeeds" and poisons
|
||||
// the first command with the stale error frame.
|
||||
try { if (await c.PingAsync()) { Interlocked.Increment(ref _currentPoolConnections); return c; } }
|
||||
catch { }
|
||||
Log($"⚠️ Connection rejected by PS5 (session cap?) - retrying ({i + 1}/3)...");
|
||||
c.Disconnect();
|
||||
}
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
c.Dispose(); throw new InvalidOperationException("Unable to acquire upload connection");
|
||||
}
|
||||
|
||||
private void ReleaseUploadConnection(PS5Protocol c) { if (!c.IsConnected) { DestroyConnection(c); return; } _connectionPool.Enqueue(c); }
|
||||
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, CancellationToken ct = default)
|
||||
{
|
||||
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)
|
||||
{
|
||||
lock (_smallFileLogLock) { _smallFileCompletedTotal++; _smallFileTotalBytes += fileSize; _smallFileBatchRemainder++; _smallFileBatchBytes += fileSize; if (_smallFileBatchRemainder >= SmallFileLogBatchSize) { Log($"✅ {SmallFileLogBatchSize} small files completed (batch {FormatFileSize(_smallFileBatchBytes)}, total {FormatFileSize(_smallFileTotalBytes)})"); _smallFileBatchRemainder = 0; _smallFileBatchBytes = 0; } }
|
||||
}
|
||||
|
||||
private void FlushSmallFileBatch()
|
||||
{
|
||||
lock (_smallFileLogLock) { if (_smallFileBatchRemainder > 0) { Log($"✅ {_smallFileBatchRemainder} small files completed (batch {FormatFileSize(_smallFileBatchBytes)}, total {FormatFileSize(_smallFileTotalBytes)})"); _smallFileBatchRemainder = 0; _smallFileBatchBytes = 0; } }
|
||||
}
|
||||
|
||||
private async Task<List<(string localPath, string remotePath)>> FilterDuplicateFilesAsync(List<(string localPath, string remotePath)> allFiles)
|
||||
{
|
||||
var filesToUpload = new List<(string localPath, string remotePath)>();
|
||||
_duplicateAction = DuplicateAction.Ask;
|
||||
var filesByDir = allFiles.GroupBy(f => Path.GetDirectoryName(f.remotePath)?.Replace("\\", "/") ?? "").ToList();
|
||||
Log($"🔍 Checking {filesByDir.Count} directories for duplicates...");
|
||||
|
||||
bool uploadsIdle = (_uploadCancellation == null || _uploadCancellation.IsCancellationRequested) && _activeTaskCount == 0;
|
||||
PS5Protocol? dupConn = null; bool disposeDup = false;
|
||||
if (!uploadsIdle) { dupConn = new PS5Protocol(); if (await dupConn.ConnectAsync(_ps5IpAddress)) disposeDup = true; else dupConn = null; }
|
||||
|
||||
int dirIndex = 0; const int LogInterval = 50;
|
||||
foreach (var dirGroup in filesByDir)
|
||||
{
|
||||
dirIndex++; string remoteDir = dirGroup.Key;
|
||||
Dictionary<string, long> existingFiles;
|
||||
try
|
||||
{
|
||||
if (dirIndex == 1 || dirIndex % LogInterval == 0 || dirIndex == filesByDir.Count) Log($"📂 Checking dir ({dirIndex}/{filesByDir.Count}): {remoteDir}");
|
||||
var proto = dupConn ?? _protocol;
|
||||
var entries = await proto.ListDirAsync(remoteDir);
|
||||
existingFiles = entries.Where(en => !en.IsDirectory).ToDictionary(en => en.Name, en => en.Size);
|
||||
}
|
||||
catch { filesToUpload.AddRange(dirGroup); continue; }
|
||||
|
||||
foreach (var file in dirGroup)
|
||||
{
|
||||
string? fn = Path.GetFileName(file.remotePath);
|
||||
if (existingFiles.ContainsKey(fn!))
|
||||
{
|
||||
if (_duplicateAction == DuplicateAction.ReplaceAll) { try { await _protocol.DeleteFileAsync(file.remotePath); } catch { } filesToUpload.Add(file); }
|
||||
else if (_duplicateAction == DuplicateAction.SkipAll) { /* skip */ }
|
||||
else
|
||||
{
|
||||
long localSize = LocalIo.GetLength(file.localPath);
|
||||
long remoteSize = existingFiles[fn!];
|
||||
var dlg = new DuplicateFileDialog(fn!, localSize, remoteSize);
|
||||
await dlg.ShowDialog(this);
|
||||
switch (dlg.UserAction)
|
||||
{
|
||||
case DuplicateFileDialog.FileAction.Replace: try { await _protocol.DeleteFileAsync(file.remotePath); } catch { } filesToUpload.Add(file); break;
|
||||
case DuplicateFileDialog.FileAction.Skip: break;
|
||||
case DuplicateFileDialog.FileAction.ReplaceAll: _duplicateAction = DuplicateAction.ReplaceAll; try { await _protocol.DeleteFileAsync(file.remotePath); } catch { } filesToUpload.Add(file); break;
|
||||
case DuplicateFileDialog.FileAction.SkipAll: _duplicateAction = DuplicateAction.SkipAll; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else filesToUpload.Add(file);
|
||||
}
|
||||
}
|
||||
if (disposeDup) dupConn?.Dispose();
|
||||
return filesToUpload;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,591 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using System.Text;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private PS5Protocol _protocol = new PS5Protocol();
|
||||
private ObservableCollection<LocalFileItem> _localFiles = new();
|
||||
private ObservableCollection<PS5FileItem> _ps5Files = new();
|
||||
private ObservableCollection<PS5FileItem> _ps5FilesFiltered = new();
|
||||
private string _currentPS5Path = "/data";
|
||||
private CancellationTokenSource? _uploadCancellation;
|
||||
private string _ps5IpAddress = "";
|
||||
private string _searchQuery = "";
|
||||
|
||||
// Multi-PS5 Support
|
||||
private Dictionary<string, string> _ps5Profiles = new();
|
||||
private const string ProfilesFileName = "ps5_profiles.json";
|
||||
|
||||
// Favorites/Bookmarks
|
||||
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();
|
||||
|
||||
// Parallel upload settings - optimized for maximum throughput
|
||||
private const int MaxParallelUploads = 28; // Increased from 24 for better saturation
|
||||
private const long SmallFileThresholdBytes = 5L * 1024 * 1024;
|
||||
private const long LargeFileThresholdBytes = 100L * 1024 * 1024;
|
||||
private const int MaxParallelLargeFiles = 8; // Increased from 6
|
||||
private const long HugeFileThresholdBytes = 20L * 1024 * 1024 * 1024;
|
||||
private const int MaxParallelHugeFiles = 4; // Increased from 3
|
||||
// Per-connection throughput is the bottleneck (each lane runs its own TCP
|
||||
// stream), so big files get MANY lanes instead of 3-4. The server accepts
|
||||
// 32 sessions and the pool pre-warms 27, so 16 lanes are always available.
|
||||
private const int MaxParallelChunksForLargeFile = 10; // Increased from 8
|
||||
private const int MaxParallelChunksForHugeFile = 16; // Increased from 12
|
||||
private const long ChunkLogIntervalBytes = 512L * 1024 * 1024;
|
||||
private const long SmallFileLogIntervalBytes = 100L * 1024 * 1024;
|
||||
private const long ChunkThresholdBytes = LargeFileThresholdBytes;
|
||||
private const long MinChunkSizeBytes = 128L * 1024 * 1024;
|
||||
private const long DefaultChunkSizeBytes = 512L * 1024 * 1024;
|
||||
private const long HugeFileChunkSizeBytes = 2048L * 1024 * 1024; // Increased from 1536MB to 2GB
|
||||
|
||||
// Total upload tracking
|
||||
private int _totalFilesToUpload = 0;
|
||||
private long _totalBytesToUpload = 0;
|
||||
private long _totalBytesUploaded = 0;
|
||||
private int _completedFiles = 0;
|
||||
private DateTime _uploadStartTime;
|
||||
private readonly object _progressLock = new();
|
||||
private ConcurrentDictionary<string, long> _fileProgressBytes = new();
|
||||
private ConcurrentDictionary<string, ConcurrentDictionary<long, long>> _fileChunkProgressBytes = new();
|
||||
private ConcurrentDictionary<string, long> _chunkLogLastBytes = new();
|
||||
private TimeSpan _smoothedETA = TimeSpan.Zero;
|
||||
private const double ETASmoothingFactor = 0.15;
|
||||
|
||||
// Sliding window for real-time speed
|
||||
private const int SpeedWindowSize = 10;
|
||||
private long[] _speedWindowBytes = new long[SpeedWindowSize];
|
||||
private DateTime[] _speedWindowTimes = new DateTime[SpeedWindowSize];
|
||||
private int _speedWindowIndex = 0;
|
||||
private int _speedWindowCount = 0;
|
||||
private double _currentSpeed = 0;
|
||||
|
||||
// Current file progress
|
||||
private string _currentFileName = "";
|
||||
private long _currentFileBytes = 0;
|
||||
private long _currentFileTotalBytes = 0;
|
||||
|
||||
// Real-time UI update timer
|
||||
private DispatcherTimer _uiUpdateTimer;
|
||||
private int _activeTaskCount = 0;
|
||||
|
||||
// Connection pooling
|
||||
private readonly ConcurrentQueue<PS5Protocol> _connectionPool = new();
|
||||
private int _currentPoolConnections = 0;
|
||||
private int _activeLargeUploads = 0;
|
||||
private int _activeHugeUploads = 0;
|
||||
|
||||
// Small file logging batches
|
||||
private readonly object _smallFileLogLock = new();
|
||||
private int _smallFileBatchRemainder = 0;
|
||||
private int _smallFileCompletedTotal = 0;
|
||||
private const int SmallFileLogBatchSize = 50;
|
||||
private long _smallFileBatchBytes = 0;
|
||||
private long _smallFileTotalBytes = 0;
|
||||
|
||||
// Log throttling
|
||||
private int _logCounter = 0;
|
||||
private const int MaxLogLines = 1000;
|
||||
private const int MaxLogChars = 256 * 1024;
|
||||
private readonly StringBuilder _logBuf = new();
|
||||
private readonly object _logBufLock = new();
|
||||
private long _lastLogFlushTicks = 0;
|
||||
|
||||
// Duplicate file handling
|
||||
private enum DuplicateAction { Ask, Replace, Skip, ReplaceAll, SkipAll }
|
||||
private DuplicateAction _duplicateAction = DuplicateAction.Ask;
|
||||
|
||||
// Shell Terminal
|
||||
private bool _shellActive = false;
|
||||
private ObservableCollection<string> _shellOutput = new();
|
||||
private string _shellCurrentDir = "/data";
|
||||
|
||||
// Auto-send payload
|
||||
private bool _autoSendPayload = false;
|
||||
private string _payloadPath = "";
|
||||
private int _payloadPort = 9021;
|
||||
|
||||
// Icon caches
|
||||
private readonly Dictionary<string, IImage> _iconCache = new();
|
||||
private readonly Dictionary<string, IImage> _screenshotThumbCache = new();
|
||||
|
||||
// Saves & Screenshots
|
||||
private List<PS5SaveFile> _currentSaves = new();
|
||||
private List<PS5Screenshot> _currentScreenshots = new();
|
||||
|
||||
// Hardware
|
||||
private DispatcherTimer? _hwAutoTimer;
|
||||
private int _hwBusyFlag;
|
||||
private bool _hwStaticLoaded;
|
||||
private bool _isRefreshingStorage = false;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
LocalFilesListBox.ItemsSource = _localFiles;
|
||||
PS5FilesListBox.ItemsSource = _ps5FilesFiltered;
|
||||
ShellOutputListBox.ItemsSource = _shellOutput;
|
||||
CompletedTransfersListBox.ItemsSource = _completedTransfers;
|
||||
FailedTransfersListBox.ItemsSource = _failedTransfers;
|
||||
|
||||
_uiUpdateTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||
_uiUpdateTimer.Tick += UiUpdateTimer_Tick;
|
||||
|
||||
_protocol.OnProgressMessage += (message) => Dispatcher.UIThread.Post(() => Log(message));
|
||||
|
||||
// Drag-drop for local files
|
||||
LocalFilesListBox.AddHandler(DragDrop.DropEvent, LocalFilesListBox_Drop);
|
||||
LocalFilesListBox.AddHandler(DragDrop.DragOverEvent, LocalFilesListBox_DragOver);
|
||||
|
||||
// Search text changed - use PropertyChanged event
|
||||
SearchTextBox.PropertyChanged += (s, args) =>
|
||||
{
|
||||
if (args.Property == TextBox.TextProperty)
|
||||
{
|
||||
_searchQuery = SearchTextBox.Text?.Trim() ?? "";
|
||||
ApplySearchFilter();
|
||||
}
|
||||
};
|
||||
|
||||
Log("Application started");
|
||||
LoadProfiles();
|
||||
LoadFavorites();
|
||||
LoadNasConnections();
|
||||
LoadSettings();
|
||||
|
||||
if (AutoSendPayloadCheckBox != null) AutoSendPayloadCheckBox.IsChecked = _autoSendPayload;
|
||||
if (PayloadPathTextBox != null) PayloadPathTextBox.Text = _payloadPath;
|
||||
if (PayloadPortTextBox != null) PayloadPortTextBox.Text = _payloadPort.ToString();
|
||||
|
||||
if (_autoSendPayload && !string.IsNullOrEmpty(_payloadPath) && !string.IsNullOrEmpty(_ps5IpAddress))
|
||||
_ = AutoSendPayloadOnStartup();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER DIALOGS
|
||||
// ============================================================
|
||||
private async Task ShowMessageAsync(string message, string title = "Info")
|
||||
{
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = title, Width = 450, Height = 200,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(20) };
|
||||
panel.Children.Add(new TextBlock { Text = message, Foreground = Brushes.White, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 20) });
|
||||
var btn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Width = 80 };
|
||||
btn.Click += (s, ev) => dlg.Close();
|
||||
panel.Children.Add(btn);
|
||||
dlg.Content = panel;
|
||||
await dlg.ShowDialog(this);
|
||||
}
|
||||
|
||||
private async Task<bool> ShowConfirmAsync(string message, string title = "Confirm")
|
||||
{
|
||||
bool result = false;
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = title, Width = 450, Height = 200,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(20) };
|
||||
panel.Children.Add(new TextBlock { Text = message, Foreground = Brushes.White, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 20) });
|
||||
var btnPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
|
||||
var yesBtn = new Button { Content = "Yes", Width = 80 };
|
||||
var noBtn = new Button { Content = "No", Width = 80 };
|
||||
yesBtn.Click += (s, ev) => { result = true; dlg.Close(); };
|
||||
noBtn.Click += (s, ev) => { result = false; dlg.Close(); };
|
||||
btnPanel.Children.Add(yesBtn);
|
||||
btnPanel.Children.Add(noBtn);
|
||||
panel.Children.Add(btnPanel);
|
||||
dlg.Content = panel;
|
||||
await dlg.ShowDialog(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<string?> ShowInputDialogAsync(string title, string label, string defaultValue = "")
|
||||
{
|
||||
string? result = null;
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = title, Width = 400, Height = 180,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
|
||||
};
|
||||
var grid = new Grid { Margin = new Thickness(20) };
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
var lbl = new TextBlock { Text = label, Foreground = Brushes.White, Margin = new Thickness(0, 0, 0, 10) };
|
||||
Grid.SetRow(lbl, 0);
|
||||
var textBox = new TextBox { Text = defaultValue, Margin = new Thickness(0, 0, 0, 15) };
|
||||
Grid.SetRow(textBox, 1);
|
||||
var buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Spacing = 10 };
|
||||
Grid.SetRow(buttonPanel, 2);
|
||||
var okButton = new Button { Content = "OK", Width = 80 };
|
||||
okButton.Click += (s, args) => { result = textBox.Text; dlg.Close(); };
|
||||
var cancelButton = new Button { Content = "Cancel", Width = 80 };
|
||||
cancelButton.Click += (s, args) => { dlg.Close(); };
|
||||
buttonPanel.Children.Add(okButton);
|
||||
buttonPanel.Children.Add(cancelButton);
|
||||
grid.Children.Add(lbl);
|
||||
grid.Children.Add(textBox);
|
||||
grid.Children.Add(buttonPanel);
|
||||
dlg.Content = grid;
|
||||
await dlg.ShowDialog(this);
|
||||
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
|
||||
// ============================================================
|
||||
private void NavButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// Hide all pages
|
||||
PageFiles.IsVisible = false;
|
||||
PageGames.IsVisible = false;
|
||||
PageMedia.IsVisible = false;
|
||||
PageSystem.IsVisible = false;
|
||||
PageTools.IsVisible = false;
|
||||
PageDebug.IsVisible = false;
|
||||
|
||||
// Show selected page
|
||||
if (sender == NavFiles) PageFiles.IsVisible = true;
|
||||
else if (sender == NavGames) PageGames.IsVisible = true;
|
||||
else if (sender == NavMedia) PageMedia.IsVisible = true;
|
||||
else if (sender == NavSystem)
|
||||
{
|
||||
PageSystem.IsVisible = true;
|
||||
StartHwAutoRefresh();
|
||||
_ = RefreshHardwareAsync();
|
||||
}
|
||||
else if (sender == NavTools) PageTools.IsVisible = true;
|
||||
else if (sender == NavDebug) PageDebug.IsVisible = true;
|
||||
|
||||
if (sender != NavSystem) StopHwAutoRefresh();
|
||||
}
|
||||
|
||||
private void StartHwAutoRefresh()
|
||||
{
|
||||
_hwAutoTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
_hwAutoTimer.Tick -= HwAutoTimer_Tick;
|
||||
_hwAutoTimer.Tick += HwAutoTimer_Tick;
|
||||
if (!_hwAutoTimer.IsEnabled) _hwAutoTimer.Start();
|
||||
}
|
||||
|
||||
private void StopHwAutoRefresh() { _hwAutoTimer?.Stop(); _hwStaticLoaded = false; }
|
||||
|
||||
// ============================================================
|
||||
// LOGGING
|
||||
// ============================================================
|
||||
private void Log(string message)
|
||||
{
|
||||
if (_uploadCancellation != null && _uploadCancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
if (!message.Contains("Upload cancelled") && !message.Contains("Cleanup complete") &&
|
||||
!message.Contains("UPLOAD FINISHED") && !message.Contains("Checking main connection"))
|
||||
return;
|
||||
}
|
||||
|
||||
bool isImportant = message.Contains("❌") || message.Contains("⚠️") ||
|
||||
message.Contains("Exception") || message.Contains("Error") ||
|
||||
(message.Contains("File") && message.Contains("completed")) ||
|
||||
message.Contains("Starting parallel") || message.Contains("🚀") ||
|
||||
message.Contains("Upload complete") || message.Contains("finished");
|
||||
|
||||
if (message.Contains("File") && message.Contains("/"))
|
||||
{
|
||||
_logCounter++;
|
||||
if (_logCounter % 100 == 0) isImportant = true;
|
||||
}
|
||||
|
||||
if (isImportant) App.LogToFile(message);
|
||||
|
||||
if (message.Contains("📊") || message.Contains("⬆️ Uploading:") ||
|
||||
message.Contains("⏳ Waiting") || message.Contains("✅ Task completed") ||
|
||||
message.Contains("✅ Task awaited") || message.Contains("🔍 Task index") ||
|
||||
message.Contains("🧹 Cleaning up") || message.Contains("📤 Starting upload") ||
|
||||
(message.Contains("✅ Connection") && message.Contains("established")))
|
||||
{
|
||||
_logCounter++;
|
||||
if (_logCounter % 50 != 0) return;
|
||||
}
|
||||
|
||||
// Buffer and flush to the TextBox at most ~4x/sec — during a
|
||||
// 100k-file upload, one Dispatcher.Post + full-text Split('\n')
|
||||
// per log line saturated the UI thread (frozen tabs, wait cursor).
|
||||
string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
|
||||
lock (_logBufLock)
|
||||
{
|
||||
_logBuf.Append('[').Append(timestamp).Append("] ").Append(message).Append('\n');
|
||||
}
|
||||
long nowTicks = DateTime.Now.Ticks;
|
||||
if (nowTicks - Interlocked.Read(ref _lastLogFlushTicks) >= TimeSpan.TicksPerSecond / 4)
|
||||
FlushLog();
|
||||
}
|
||||
|
||||
// Drain the buffered log lines into the TextBox (single UI post per
|
||||
// flush, single text rewrite). Safe to call from any thread.
|
||||
private void FlushLog()
|
||||
{
|
||||
string chunk;
|
||||
lock (_logBufLock)
|
||||
{
|
||||
if (_logBuf.Length == 0) return;
|
||||
chunk = _logBuf.ToString();
|
||||
_logBuf.Clear();
|
||||
}
|
||||
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)
|
||||
{
|
||||
trimmed = t.Length - MaxLogChars;
|
||||
t = t.Substring(trimmed);
|
||||
}
|
||||
LogTextBox.Text = t;
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1) { order++; len /= 1024; }
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DATA CLASSES
|
||||
// ============================================================
|
||||
public class LocalFileItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullPath { get; set; } = "";
|
||||
public string Icon { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string? RemotePathOverride { get; set; }
|
||||
public string SizeFormatted => IsDirectory ? "" : FileUtils.FormatFileSize(Size);
|
||||
}
|
||||
|
||||
public class PS5FileItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullPath { get; set; } = "";
|
||||
public string Icon { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string SizeFormatted => IsDirectory ? "" : FileUtils.FormatFileSize(Size);
|
||||
}
|
||||
|
||||
public static class FileUtils
|
||||
{
|
||||
public static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1) { order++; len /= 1024; }
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
|
||||
public class TransferHistoryItem
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string Size { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string FormattedTimestamp => Timestamp.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
public string LocalPath { get; set; } = "";
|
||||
public string RemotePath { get; set; } = "";
|
||||
public override string ToString() => $"{FileName} - {Status} ({Size}) [{FormattedTimestamp}]";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>14</LangVersion>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<AssemblyName>PS5Suite</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<ApplicationIcon>ps5_logo.ico</ApplicationIcon>
|
||||
<Version>7.2.3</Version>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
|
||||
<Copyright>Copyright © 2026 Manos</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="ps5_logo.ico" />
|
||||
<AvaloniaResource Include="ps5_logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" 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" />
|
||||
<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>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal HTTP/1.1 file server for on-the-fly PKG installs. The PS5 payload's
|
||||
/// localhost responder (:13801/remote) proxies ranged GETs to this server, so the
|
||||
/// console installer streams the PKG straight from the PC — nothing is staged on
|
||||
/// the console's disk. Implemented with raw TcpListener (no HTTP.sys URL ACL needed).
|
||||
/// Supports HEAD and GET with Range (what the install daemon uses).
|
||||
/// </summary>
|
||||
public sealed class PkgHttpServer : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly ConcurrentDictionary<string, string> _files = new(); // URL path -> local file
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public PkgHttpServer()
|
||||
{
|
||||
TcpListener? l = null;
|
||||
int port = 18990;
|
||||
for (; port < 19010; port++)
|
||||
{
|
||||
try { l = new TcpListener(IPAddress.Any, port); l.Start(); break; }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
if (l == null) throw new IOException("No free port for PKG HTTP server");
|
||||
_listener = l;
|
||||
Port = port;
|
||||
_ = AcceptLoopAsync();
|
||||
}
|
||||
|
||||
/// <summary>Register a local file; returns the URL path (escaped) to request it by.
|
||||
/// Keys are stored UNescaped because requests are unescaped before lookup.</summary>
|
||||
public string AddFile(string localPath)
|
||||
{
|
||||
var name = LocalIo.GetName(localPath);
|
||||
_files["/" + name] = localPath;
|
||||
return "/" + Uri.EscapeDataString(name);
|
||||
}
|
||||
|
||||
/// <summary>Optional per-request logger (hits, misses, ranges) wired to the app log.</summary>
|
||||
public Action<string>? OnRequest;
|
||||
|
||||
public void RemoveFile(string urlPath) => _files.TryRemove(urlPath, out _);
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
TcpClient c;
|
||||
try { c = await _listener.AcceptTcpClientAsync(); }
|
||||
catch { break; }
|
||||
_ = Task.Run(() => HandleAsync(c));
|
||||
}
|
||||
}
|
||||
|
||||
private int _reqCount;
|
||||
|
||||
private async Task HandleAsync(TcpClient c)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (c)
|
||||
{
|
||||
c.ReceiveTimeout = 15000;
|
||||
c.SendTimeout = 60000;
|
||||
c.NoDelay = true;
|
||||
c.SendBufferSize = 4 * 1024 * 1024; // bigger socket buffer → fewer context switches
|
||||
var ns = c.GetStream();
|
||||
var hdr = new byte[8192];
|
||||
int filled = 0; // bytes currently in hdr (pipelined leftovers carry over)
|
||||
|
||||
// Keep-alive loop: the payload proxy reuses this connection for
|
||||
// sequential range requests — one TCP setup serves the whole PKG.
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
// Read request headers (cap 8KB), with a 15s idle timeout.
|
||||
int hend = -1;
|
||||
using (var idle = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token))
|
||||
{
|
||||
idle.CancelAfter(15000);
|
||||
try
|
||||
{
|
||||
while (hend < 0 && filled < hdr.Length)
|
||||
{
|
||||
hend = IndexOf(hdr, filled, "\r\n\r\n");
|
||||
if (hend >= 0) break;
|
||||
int r = await ns.ReadAsync(hdr, filled, hdr.Length - filled, idle.Token);
|
||||
if (r <= 0) return;
|
||||
filled += r;
|
||||
}
|
||||
hend = IndexOf(hdr, filled, "\r\n\r\n");
|
||||
}
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (IOException) { return; }
|
||||
}
|
||||
if (hend < 0) return; // oversized/garbage request
|
||||
var req = Encoding.ASCII.GetString(hdr, 0, hend);
|
||||
// Shift leftover bytes (next request) to buffer start.
|
||||
int consumed = hend + 4;
|
||||
filled -= consumed;
|
||||
if (filled > 0) Buffer.BlockCopy(hdr, consumed, hdr, 0, filled);
|
||||
|
||||
var parts = req.Split("\r\n")[0].Split(' ');
|
||||
if (parts.Length < 2) return;
|
||||
var method = parts[0].ToUpperInvariant();
|
||||
var urlPath = Uri.UnescapeDataString(parts[1].Split('?')[0]);
|
||||
bool clientClose = req.IndexOf("Connection: close", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
if (method != "GET" && method != "HEAD" || !_files.TryGetValue(urlPath, out var localPath))
|
||||
{
|
||||
OnRequest?.Invoke($"404 {method} {urlPath}");
|
||||
await WriteAsciiAsync(ns, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
long total = LocalIo.GetLength(localPath), start = 0, end = total - 1;
|
||||
bool partial = false;
|
||||
|
||||
// Parse "Range: bytes=a-b" / "bytes=a-" / "bytes=-n".
|
||||
var rangeIdx = req.IndexOf("\r\nRange:", StringComparison.OrdinalIgnoreCase);
|
||||
if (rangeIdx < 0) rangeIdx = req.StartsWith("Range:", StringComparison.OrdinalIgnoreCase) ? 0 : -1;
|
||||
if (rangeIdx >= 0)
|
||||
{
|
||||
var lineEnd = req.IndexOf("\r\n", rangeIdx + 1);
|
||||
var spec = req.Substring(rangeIdx + 2 + 6, (lineEnd < 0 ? req.Length : lineEnd) - rangeIdx - 2 - 6).Trim();
|
||||
if (spec.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var se = spec.Substring(6).Split('-');
|
||||
if (se.Length == 2)
|
||||
{
|
||||
if (se[0].Length == 0 && long.TryParse(se[1], out var suffix))
|
||||
{
|
||||
start = Math.Max(0, total - suffix); // suffix range: last N bytes
|
||||
end = total - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
long.TryParse(se[0], out start);
|
||||
if (se[1].Length > 0 && long.TryParse(se[1], out var e2))
|
||||
end = Math.Min(e2, total - 1);
|
||||
}
|
||||
if (start < 0) start = 0;
|
||||
if (start > end || start >= total) { start = 0; end = total - 1; }
|
||||
partial = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long len = Math.Max(0, end - start + 1);
|
||||
// Log the first few requests + every 64th — the daemon fires
|
||||
// hundreds of range GETs, don't spam the UI with each one.
|
||||
int rn = Interlocked.Increment(ref _reqCount);
|
||||
if (rn <= 8 || (rn & 63) == 0)
|
||||
OnRequest?.Invoke($"{method} {urlPath} bytes={start}-{end}/{total} [#{rn}]");
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(partial ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n");
|
||||
sb.Append("Content-Type: application/octet-stream\r\nAccept-Ranges: bytes\r\n");
|
||||
sb.Append($"Content-Length: {len}\r\n");
|
||||
if (partial) sb.Append($"Content-Range: bytes {start}-{end}/{total}\r\n");
|
||||
sb.Append(clientClose ? "Connection: close\r\n\r\n" : "Connection: keep-alive\r\n\r\n");
|
||||
await WriteAsciiAsync(ns, sb.ToString());
|
||||
|
||||
if (method == "GET")
|
||||
{
|
||||
using var fs = LocalIo.OpenRead(localPath, randomAccess: true);
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
var buf = new byte[1024 * 1024];
|
||||
long remain = len;
|
||||
while (remain > 0)
|
||||
{
|
||||
int rd = await fs.ReadAsync(buf, 0, (int)Math.Min(buf.Length, remain), _cts.Token);
|
||||
if (rd <= 0) break;
|
||||
await ns.WriteAsync(buf, 0, rd, _cts.Token);
|
||||
remain -= rd;
|
||||
}
|
||||
}
|
||||
if (clientClose) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* connection aborted by installer — fine */ }
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] buf, int len, string needle)
|
||||
{
|
||||
var pat = Encoding.ASCII.GetBytes(needle);
|
||||
for (int i = 0; i + pat.Length <= len; i++)
|
||||
{
|
||||
bool m = true;
|
||||
for (int j = 0; j < pat.Length; j++) if (buf[i + j] != pat[j]) { m = false; break; }
|
||||
if (m) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static async Task WriteAsciiAsync(NetworkStream ns, string s)
|
||||
{
|
||||
var b = Encoding.ASCII.GetBytes(s);
|
||||
await ns.WriteAsync(b, 0, b.Length);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Stop(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.PkgPickerWindow"
|
||||
Title="Select PKG on PS5"
|
||||
Width="640" Height="480"
|
||||
Icon="/ps5_logo.ico"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="#1E1E1E">
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Path bar -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button Grid.Column="0" Content="⬆ Up" Click="Up_Click" Padding="10,6"
|
||||
Background="#3E3E42" Margin="0,0,8,0"/>
|
||||
<TextBox Grid.Column="1" x:Name="PathTextBox" Background="#2D2D30"
|
||||
BorderBrush="#3E3E42" Padding="8" Foreground="White"
|
||||
KeyDown="PathTextBox_KeyDown"/>
|
||||
<Button Grid.Column="2" Content="🔄" Click="Refresh_Click" Padding="10,6"
|
||||
Background="#3E3E42" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<!-- File list -->
|
||||
<Border Grid.Row="1" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="4">
|
||||
<ListBox x:Name="FilesListBox" Background="#252526" Foreground="White"
|
||||
SelectionMode="Multiple" DoubleTapped="FilesListBox_DoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Icon}" Margin="0,0,8,0" Width="22"/>
|
||||
<TextBlock Text="{Binding Name}" Foreground="{Binding NameColor}"/>
|
||||
<TextBlock Text="{Binding SizeText}" Foreground="#808080" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Filter + status -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,8">
|
||||
<CheckBox x:Name="OnlyPkgCheckBox" Content="Show only .pkg files"
|
||||
Foreground="#AAAAAA" IsChecked="True" IsCheckedChanged="OnlyPkg_Changed"/>
|
||||
<TextBlock x:Name="StatusText" Foreground="#808080" Margin="20,3,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
||||
<Button Content="Select" Click="Select_Click" Padding="18,8"
|
||||
Background="#007ACC" FontWeight="SemiBold"/>
|
||||
<Button Content="Cancel" Click="Cancel_Click" Padding="18,8"
|
||||
Background="#3E3E42"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,136 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
/// <summary>
|
||||
/// Modal PS5 file browser used by Tools → PKG → "Browse PS5".
|
||||
/// Lets the user navigate the console filesystem and pick one or more
|
||||
/// .pkg files without leaving the Tools tab.
|
||||
/// </summary>
|
||||
public partial class PkgPickerWindow : Window
|
||||
{
|
||||
private readonly PS5Protocol? _protocol;
|
||||
private string _currentPath = "/user/data";
|
||||
private List<FileEntry> _entries = new();
|
||||
|
||||
/// <summary>Full PS5 paths of the selected files (null when cancelled).</summary>
|
||||
public List<string>? SelectedPaths { get; private set; }
|
||||
|
||||
private class Row
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullPath { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public bool IsPkg { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string Icon => IsDirectory ? "📁" : (IsPkg ? "📦" : "📄");
|
||||
public string SizeText => IsDirectory ? "" : FileUtils.FormatFileSize(Size);
|
||||
public string NameColor => IsDirectory ? "#8FD4FF" : (IsPkg ? "#FFFFFF" : "#808080");
|
||||
}
|
||||
|
||||
public PkgPickerWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PkgPickerWindow(PS5Protocol protocol, string startPath) : this()
|
||||
{
|
||||
_protocol = protocol;
|
||||
if (!string.IsNullOrWhiteSpace(startPath))
|
||||
_currentPath = startPath.TrimEnd('/');
|
||||
Opened += async (_, _) => await LoadDir(_currentPath);
|
||||
}
|
||||
|
||||
private async Task LoadDir(string path)
|
||||
{
|
||||
StatusText.Text = "Loading…";
|
||||
PathTextBox.Text = path;
|
||||
try
|
||||
{
|
||||
if (_protocol == null) { StatusText.Text = "Not connected"; return; }
|
||||
_entries = (await _protocol.ListDirAsync(path)).ToList();
|
||||
_currentPath = path;
|
||||
RebuildList();
|
||||
StatusText.Text = $"{_entries.Count(e => e.IsDirectory)} folders, {_entries.Count(e => !e.IsDirectory)} files";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText.Text = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildList()
|
||||
{
|
||||
bool onlyPkg = OnlyPkgCheckBox.IsChecked == true;
|
||||
var rows = _entries
|
||||
.Where(e => e.IsDirectory || !onlyPkg ||
|
||||
e.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(e => e.IsDirectory)
|
||||
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(e => new Row
|
||||
{
|
||||
Name = e.Name,
|
||||
FullPath = (_currentPath == "/" ? "" : _currentPath) + "/" + e.Name,
|
||||
IsDirectory = e.IsDirectory,
|
||||
IsPkg = e.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase),
|
||||
Size = e.Size,
|
||||
})
|
||||
.ToList();
|
||||
FilesListBox.ItemsSource = rows;
|
||||
}
|
||||
|
||||
private async void FilesListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (FilesListBox.SelectedItem is Row row && row.IsDirectory)
|
||||
await LoadDir(row.FullPath);
|
||||
}
|
||||
|
||||
private async void Up_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
string parent = _currentPath.TrimEnd('/');
|
||||
int idx = parent.LastIndexOf('/');
|
||||
parent = idx <= 0 ? "/" : parent[..idx];
|
||||
await LoadDir(parent);
|
||||
}
|
||||
|
||||
private async void Refresh_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await LoadDir(_currentPath);
|
||||
}
|
||||
|
||||
private async void PathTextBox_KeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && !string.IsNullOrWhiteSpace(PathTextBox.Text))
|
||||
await LoadDir(PathTextBox.Text.Trim());
|
||||
}
|
||||
|
||||
private void OnlyPkg_Changed(object? sender, RoutedEventArgs e) => RebuildList();
|
||||
|
||||
private void Select_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var sel = FilesListBox.SelectedItems?.Cast<Row>()
|
||||
.Where(r => !r.IsDirectory)
|
||||
.Select(r => r.FullPath)
|
||||
.ToList();
|
||||
if (sel == null || sel.Count == 0)
|
||||
{
|
||||
StatusText.Text = "Select at least one file";
|
||||
return;
|
||||
}
|
||||
SelectedPaths = sel;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Cancel_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SelectedPaths = null;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace PS5Upload;
|
||||
|
||||
class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,64 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PS5Upload.SaveBrowserWindow"
|
||||
Title="Mounted Save — Decrypted Contents"
|
||||
Width="780" Height="560"
|
||||
Background="#1E1E1E"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="🔓 Mounted Save (decrypted)" FontSize="15" FontWeight="Bold" Foreground="#4EC9B0"/>
|
||||
<TextBlock x:Name="PathText" Text="/data/save_mnt" FontSize="11" Foreground="#9CDCFE" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<WrapPanel Grid.Row="1" Margin="0,0,0,8">
|
||||
<Button x:Name="UpButton" Content="⬅ Up" Padding="10,5" Margin="0,0,6,0" Click="UpButton_Click"/>
|
||||
<Button x:Name="RefreshButton" Content="🔄 Refresh" Padding="10,5" Margin="0,0,6,0" Click="RefreshButton_Click"/>
|
||||
<Button x:Name="DownloadFileButton" Content="⬇ Download" Padding="10,5" Margin="0,0,6,0" Background="#28A745" Click="DownloadFileButton_Click"/>
|
||||
<Button x:Name="UploadFileButton" Content="⬆ Upload" Padding="10,5" Margin="0,0,6,0" Background="#0E639C" Click="UploadFileButton_Click"/>
|
||||
<Button x:Name="NewFolderButton" Content="📁 New Folder" Padding="10,5" Margin="0,0,6,0" Click="NewFolderButton_Click"/>
|
||||
<Button x:Name="DeleteEntryButton" Content="🗑️ Delete" Padding="10,5" Background="#DC3545" Click="DeleteEntryButton_Click"/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- File list -->
|
||||
<ListBox Grid.Row="2" x:Name="FileListBox" Background="#252526" BorderThickness="0" DoubleTapped="FileListBox_DoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="{Binding IconText}" FontSize="15" Margin="4,4,8,4" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="13" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding SizeDisplay}" FontSize="11" Foreground="#9CDCFE" VerticalAlignment="Center" Margin="10,0,4,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Status + close -->
|
||||
<Grid Grid.Row="3" Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" x:Name="StatusText" Text="" FontSize="11" Foreground="#888888" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button x:Name="CloseStayMountedButton" Content="Close (stay mounted)" Padding="12,5" Margin="0,0,6,0" Click="CloseStayMountedButton_Click"/>
|
||||
<Button x:Name="UnmountCloseButton" Content="🔒 Unmount & Close" Padding="12,5" Background="#007ACC" Click="UnmountCloseButton_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Modal browser for the decrypted save mounted at /data/save_mnt.
|
||||
// Reuses the regular remote-file commands (LIST_DIR / DOWNLOAD / UPLOAD /
|
||||
// DELETE / MKDIR) — the mount point behaves like any other directory.
|
||||
public partial class SaveBrowserWindow : Window
|
||||
{
|
||||
private readonly PS5Protocol _protocol;
|
||||
private readonly string _rootPath;
|
||||
private readonly string _ps5Ip;
|
||||
private string _currentPath;
|
||||
|
||||
public class SaveEntryItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string IconText => IsDirectory ? "📁" : "📄";
|
||||
public string SizeDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsDirectory) return "";
|
||||
double mb = Size / (1024.0 * 1024.0);
|
||||
if (mb < 1) return $"{Size / 1024.0:F1} KB";
|
||||
if (mb < 1024) return $"{mb:F1} MB";
|
||||
return $"{mb / 1024.0:F2} GB";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SaveBrowserWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_protocol = null!;
|
||||
_ps5Ip = string.Empty;
|
||||
_rootPath = string.Empty;
|
||||
_currentPath = string.Empty;
|
||||
}
|
||||
|
||||
public SaveBrowserWindow(PS5Protocol protocol, string rootPath, string ps5Ip)
|
||||
{
|
||||
InitializeComponent();
|
||||
_protocol = protocol;
|
||||
_ps5Ip = ps5Ip;
|
||||
_rootPath = rootPath.TrimEnd('/');
|
||||
_currentPath = _rootPath;
|
||||
Opened += async (_, __) => await RefreshAsync();
|
||||
}
|
||||
|
||||
private void SetStatus(string s) => StatusText.Text = s;
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
SetStatus("Loading…");
|
||||
try
|
||||
{
|
||||
var entries = await _protocol.ListDirAsync(_currentPath);
|
||||
var items = entries
|
||||
.OrderByDescending(e => e.IsDirectory)
|
||||
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(e => new SaveEntryItem { Name = e.Name, IsDirectory = e.IsDirectory, Size = e.Size })
|
||||
.ToList();
|
||||
FileListBox.ItemsSource = items;
|
||||
PathText.Text = _currentPath;
|
||||
UpButton.IsEnabled = _currentPath.Length > _rootPath.Length;
|
||||
SetStatus($"{items.Count} item(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"List failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void RefreshButton_Click(object? sender, RoutedEventArgs e) => await RefreshAsync();
|
||||
|
||||
private async void UpButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_currentPath.Length <= _rootPath.Length) return;
|
||||
int idx = _currentPath.LastIndexOf('/');
|
||||
_currentPath = idx <= 0 ? _rootPath : _currentPath.Substring(0, idx);
|
||||
if (_currentPath.Length < _rootPath.Length) _currentPath = _rootPath;
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async void FileListBox_DoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (FileListBox.SelectedItem is SaveEntryItem item && item.IsDirectory)
|
||||
{
|
||||
_currentPath = _currentPath.TrimEnd('/') + "/" + item.Name;
|
||||
await RefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void DownloadFileButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (FileListBox.SelectedItem is not SaveEntryItem item) { SetStatus("Select a file first."); return; }
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
string remote = _currentPath.TrimEnd('/') + "/" + item.Name;
|
||||
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Choose destination folder" });
|
||||
if (folders.Count == 0) return;
|
||||
var dest = folders[0].TryGetLocalPath();
|
||||
if (dest == null) return;
|
||||
SetStatus($"Downloading folder {item.Name}…");
|
||||
try
|
||||
{
|
||||
var r = await _protocol.DownloadFolderAsync(remote, Path.Combine(dest, item.Name), _ps5Ip, null, System.Threading.CancellationToken.None);
|
||||
SetStatus($"Downloaded {r.filesDownloaded} files ({r.totalBytes / 1024} KB), {r.filesFailed} failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Download failed: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save file as",
|
||||
SuggestedFileName = item.Name
|
||||
});
|
||||
if (file == null) return;
|
||||
var dest = file.TryGetLocalPath();
|
||||
if (dest == null) return;
|
||||
SetStatus($"Downloading {item.Name}…");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DownloadFileAsync(remote, dest);
|
||||
SetStatus(ok ? $"Saved to {dest}" : "Download failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Download failed: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private async void UploadFileButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top == null) return;
|
||||
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select file(s) to upload into the save",
|
||||
AllowMultiple = true
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
|
||||
int done = 0, failed = 0;
|
||||
foreach (var f in files)
|
||||
{
|
||||
var local = f.TryGetLocalPath();
|
||||
if (local == null) { failed++; continue; }
|
||||
string remote = _currentPath.TrimEnd('/') + "/" + Path.GetFileName(local);
|
||||
SetStatus($"Uploading {Path.GetFileName(local)}…");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.UploadFileAsync(local, remote);
|
||||
if (ok) done++; else failed++;
|
||||
}
|
||||
catch { failed++; }
|
||||
}
|
||||
SetStatus($"Uploaded {done}, failed {failed}");
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async void NewFolderButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = "New Folder",
|
||||
Width = 360, Height = 140,
|
||||
Background = Avalonia.Media.Brush.Parse("#1E1E1E"),
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
var nameBox = new TextBox { PlaceholderText = "Folder name", Margin = new Avalonia.Thickness(10) };
|
||||
var okBtn = new Button { Content = "Create", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Padding = new Avalonia.Thickness(20, 6) };
|
||||
var panel = new StackPanel { Spacing = 8 };
|
||||
panel.Children.Add(nameBox);
|
||||
panel.Children.Add(okBtn);
|
||||
dlg.Content = panel;
|
||||
okBtn.Click += (_, __) => dlg.Close(nameBox.Text);
|
||||
var name = await dlg.ShowDialog<string?>(this);
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.CreateDirAsync(_currentPath.TrimEnd('/') + "/" + name.Trim());
|
||||
SetStatus(ok ? $"Created {name}" : "Create failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Create failed: {ex.Message}"); }
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async void DeleteEntryButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (FileListBox.SelectedItem is not SaveEntryItem item) { SetStatus("Select an entry first."); return; }
|
||||
string remote = _currentPath.TrimEnd('/') + "/" + item.Name;
|
||||
SetStatus($"Deleting {item.Name}…");
|
||||
try
|
||||
{
|
||||
bool ok = await _protocol.DeleteFileAsync(remote);
|
||||
SetStatus(ok ? $"Deleted {item.Name}" : "Delete failed");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Delete failed: {ex.Message}"); }
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private void CloseStayMountedButton_Click(object? sender, RoutedEventArgs e) => Close(false);
|
||||
|
||||
private void UnmountCloseButton_Click(object? sender, RoutedEventArgs e) => Close(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="PS5Suite.Desktop"/>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.401"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
@@ -0,0 +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."
|
||||
@@ -1,8 +0,0 @@
|
||||
<Application x:Class="PS5Upload.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
private static readonly string LogFilePath = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
$"ps5upload_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
|
||||
|
||||
private static readonly object _logLock = new object();
|
||||
|
||||
public App()
|
||||
{
|
||||
// Global exception handlers
|
||||
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
|
||||
LogToFile("=== Application Started ===");
|
||||
}
|
||||
|
||||
private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
LogToFile($"DISPATCHER EXCEPTION: {e.Exception}");
|
||||
e.Handled = true; // Prevent crash, but log it
|
||||
|
||||
MessageBox.Show($"Error: {e.Exception.Message}\n\nCheck log file: {LogFilePath}",
|
||||
"PS5 Upload Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
|
||||
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
LogToFile($"UNHANDLED EXCEPTION: {ex}");
|
||||
}
|
||||
|
||||
private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
LogToFile($"TASK EXCEPTION: {e.Exception}");
|
||||
e.SetObserved(); // Prevent crash
|
||||
}
|
||||
|
||||
public static void LogToFile(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
File.AppendAllText(LogFilePath, $"[{timestamp}] {message}\n");
|
||||
}
|
||||
}
|
||||
catch { /* Ignore logging errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<Window x:Class="PS5Upload.DuplicateFileDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="File Already Exists"
|
||||
Height="320"
|
||||
Width="550"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Background="#1E1E1E">
|
||||
|
||||
<Window.Resources>
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="#2D2D30"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderBrush" Value="#3F3F46"/>
|
||||
<Setter Property="Padding" Value="15,8"/>
|
||||
<Setter Property="Margin" Value="5"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#3E3E42"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Icon and Message -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,15">
|
||||
<TextBlock Text="⚠️" FontSize="32" Margin="0,0,15,0"/>
|
||||
<TextBlock x:Name="MessageTextBlock"
|
||||
Text="This file already exists on PS5:"
|
||||
Foreground="White"
|
||||
FontSize="14"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- File Name -->
|
||||
<TextBlock Grid.Row="1"
|
||||
x:Name="FileNameTextBlock"
|
||||
Foreground="#4EC9B0"
|
||||
FontSize="13"
|
||||
FontWeight="Bold"
|
||||
Margin="0,0,0,10"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- File Info -->
|
||||
<StackPanel Grid.Row="2" Margin="0,0,0,20">
|
||||
<TextBlock x:Name="LocalSizeTextBlock"
|
||||
Foreground="#9CDCFE"
|
||||
FontSize="12"
|
||||
Margin="0,0,0,5"/>
|
||||
<TextBlock x:Name="RemoteSizeTextBlock"
|
||||
Foreground="#9CDCFE"
|
||||
FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel Grid.Row="4" Orientation="Vertical">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,10">
|
||||
<Button Content="Replace" Width="120" Click="Replace_Click"/>
|
||||
<Button Content="Skip" Width="120" Click="Skip_Click"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="Replace All" Width="120" Click="ReplaceAll_Click"/>
|
||||
<Button Content="Skip All" Width="120" Click="SkipAll_Click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
public partial class DuplicateFileDialog : Window
|
||||
{
|
||||
public enum FileAction
|
||||
{
|
||||
Replace,
|
||||
Skip,
|
||||
ReplaceAll,
|
||||
SkipAll
|
||||
}
|
||||
|
||||
public FileAction UserAction { get; private set; }
|
||||
|
||||
public DuplicateFileDialog(string fileName, long localSize, long remoteSize)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
FileNameTextBlock.Text = fileName;
|
||||
LocalSizeTextBlock.Text = $"Local file: {FormatFileSize(localSize)}";
|
||||
RemoteSizeTextBlock.Text = $"Remote file: {FormatFileSize(remoteSize)}";
|
||||
}
|
||||
|
||||
private void Replace_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Replace;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Skip_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.Skip;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ReplaceAll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.ReplaceAll;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SkipAll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UserAction = FileAction.SkipAll;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,753 +0,0 @@
|
||||
<Window x:Class="PS5Upload.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="PS5 Upload - High-Speed File Transfer" Height="600" Width="1200"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="#1E1E1E"
|
||||
Icon="ps5_logo.png">
|
||||
|
||||
<Window.Resources>
|
||||
<!-- Override system colors for ComboBox dropdown -->
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.WindowBrushKey}" Color="#2D2D30"/>
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.WindowColorKey}" Color="#2D2D30"/>
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="#2D2D30"/>
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.ControlColorKey}" Color="#2D2D30"/>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="#007ACC"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="15,8"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#1C97EA"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Background" Value="#3E3E42"/>
|
||||
<Setter Property="Foreground" Value="#666666"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Background" Value="#2D2D30"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderBrush" Value="#3E3E42"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ListBox">
|
||||
<Setter Property="Background" Value="#2D2D30"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderBrush" Value="#3E3E42"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,20" Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Image Source="ps5_logo.png"
|
||||
Width="80"
|
||||
Height="80"
|
||||
Margin="0,0,15,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="PS5 Upload - High-Speed File Transfer"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
Foreground="#007ACC"/>
|
||||
<TextBlock Text="By Manos"
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Margin="0,5,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Connection Panel -->
|
||||
<Border Grid.Row="1"
|
||||
Background="#252526"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="15"
|
||||
Margin="0,0,0,15">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- PS5 Profile Selection -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="PS5 Profile:"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
<ComboBox x:Name="PS5ProfileComboBox"
|
||||
Width="200"
|
||||
VerticalAlignment="Center"
|
||||
SelectionChanged="PS5ProfileComboBox_SelectionChanged"
|
||||
Background="#2D2D30"
|
||||
Foreground="White"
|
||||
BorderBrush="#3E3E42">
|
||||
<ComboBox.Template>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton Name="ToggleButton"
|
||||
Grid.Column="2"
|
||||
Focusable="false"
|
||||
IsChecked="{Binding Path=IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
ClickMode="Press"
|
||||
Background="#2D2D30"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"/>
|
||||
<ContentPresenter Name="ContentSite"
|
||||
IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
Margin="5,3,23,3"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Popup Name="Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True"
|
||||
Focusable="False"
|
||||
PopupAnimation="Slide">
|
||||
<Grid Name="DropDown"
|
||||
SnapsToDevicePixels="True"
|
||||
MinWidth="{TemplateBinding ActualWidth}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<Border Name="DropDownBorder"
|
||||
Background="#2D2D30"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#3E3E42"/>
|
||||
<ScrollViewer Margin="4,6,4,6" SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</ComboBox.Template>
|
||||
<ComboBox.ItemContainerStyle>
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Background" Value="#2D2D30"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="Padding" Value="5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border Name="Border"
|
||||
Background="#2D2D30"
|
||||
Padding="5">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="#007ACC"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="#007ACC"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ComboBox.ItemContainerStyle>
|
||||
</ComboBox>
|
||||
<Button Content="💾 Save Profile"
|
||||
Click="SaveProfileButton_Click"
|
||||
Width="140"
|
||||
Margin="10,0,0,0"
|
||||
Background="#28A745"/>
|
||||
<Button Content="🗑️ Delete Profile"
|
||||
Click="DeleteProfileButton_Click"
|
||||
Width="150"
|
||||
Margin="5,0,0,0"
|
||||
Background="#DC3545"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- IP Address and Connect -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="PS5 IP Address:"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
|
||||
<TextBox Grid.Column="1"
|
||||
x:Name="IpAddressTextBox"
|
||||
Text="192.168.0.160"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
x:Name="ConnectButton"
|
||||
Content="Connect"
|
||||
Click="ConnectButton_Click"
|
||||
Width="100"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Main Content -->
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="15"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="15"/>
|
||||
<ColumnDefinition Width="300"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Local Files -->
|
||||
<Border Grid.Column="0"
|
||||
Background="#252526"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0"
|
||||
Background="#2D2D30"
|
||||
Padding="10"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="📁 Local Files"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1"
|
||||
x:Name="LocalFilesListBox"
|
||||
BorderThickness="0"
|
||||
AllowDrop="True"
|
||||
Drop="LocalFilesListBox_Drop"
|
||||
DragEnter="LocalFilesListBox_DragEnter"
|
||||
MouseRightButtonUp="LocalFilesListBox_RightClick">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="{Binding Icon}"
|
||||
FontSize="16"
|
||||
Margin="0,0,10,0"/>
|
||||
<TextBlock Text="{Binding Name}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="🗑️ Remove" Click="RemoveLocalFile_Click"/>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Margin="10"
|
||||
HorizontalAlignment="Center">
|
||||
<Button Content="Browse Files"
|
||||
Click="BrowseFilesButton_Click"
|
||||
Margin="0,0,5,0"/>
|
||||
<Button Content="Browse Folder"
|
||||
Click="BrowseFolderButton_Click"
|
||||
Margin="0,0,5,0"/>
|
||||
<Button Content="Clear All"
|
||||
Click="ClearLocalFiles_Click"
|
||||
Background="#DC3545"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- PS5 Files -->
|
||||
<Border Grid.Column="2"
|
||||
Background="#252526"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0"
|
||||
Background="#2D2D30"
|
||||
Padding="10"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="🎮 PS5 Files"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1"
|
||||
Margin="10"
|
||||
Background="#2D2D30">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="Current Path:"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
<TextBox x:Name="CurrentPathTextBox"
|
||||
Text="/data"
|
||||
Width="200"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
<Button Content="Go"
|
||||
Click="GoButton_Click"
|
||||
Width="60"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="⭐ Favorites:"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
<ComboBox x:Name="FavoritesComboBox"
|
||||
Width="200"
|
||||
VerticalAlignment="Center"
|
||||
SelectionChanged="FavoritesComboBox_SelectionChanged"
|
||||
Background="#2D2D30"
|
||||
Foreground="White"
|
||||
BorderBrush="#3E3E42">
|
||||
<ComboBox.Template>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton Name="ToggleButton"
|
||||
Grid.Column="2"
|
||||
Focusable="false"
|
||||
IsChecked="{Binding Path=IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
ClickMode="Press"
|
||||
Background="#2D2D30"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"/>
|
||||
<ContentPresenter Name="ContentSite"
|
||||
IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
Margin="5,3,23,3"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Popup Name="Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True"
|
||||
Focusable="False"
|
||||
PopupAnimation="Slide">
|
||||
<Grid Name="DropDown"
|
||||
SnapsToDevicePixels="True"
|
||||
MinWidth="{TemplateBinding ActualWidth}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<Border Name="DropDownBorder"
|
||||
Background="#2D2D30"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#3E3E42"/>
|
||||
<ScrollViewer Margin="4,6,4,6" SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</ComboBox.Template>
|
||||
<ComboBox.ItemContainerStyle>
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Background" Value="#2D2D30"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="Padding" Value="5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border Name="Border"
|
||||
Background="#2D2D30"
|
||||
Padding="5">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="#007ACC"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="#007ACC"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ComboBox.ItemContainerStyle>
|
||||
</ComboBox>
|
||||
<Button Content="⭐ Add"
|
||||
Click="AddFavoriteButton_Click"
|
||||
Width="90"
|
||||
Margin="10,0,0,0"
|
||||
Background="#28A745"/>
|
||||
<Button Content="🗑️ Remove"
|
||||
Click="RemoveFavoriteButton_Click"
|
||||
Width="110"
|
||||
Margin="5,0,0,0"
|
||||
Background="#DC3545"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="🔍 Search:"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"/>
|
||||
<TextBox x:Name="SearchTextBox"
|
||||
Width="250"
|
||||
VerticalAlignment="Center"
|
||||
TextChanged="SearchTextBox_TextChanged"
|
||||
Background="#1E1E1E"
|
||||
Foreground="White"
|
||||
BorderBrush="#3E3E42"/>
|
||||
<Button Content="✖ Clear"
|
||||
Click="ClearSearchButton_Click"
|
||||
Width="100"
|
||||
Margin="5,0,0,0"
|
||||
Background="#3E3E42"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox Grid.Row="2"
|
||||
x:Name="PS5FilesListBox"
|
||||
BorderThickness="0"
|
||||
SelectionMode="Extended"
|
||||
MouseDoubleClick="PS5FilesListBox_MouseDoubleClick">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="⬇️ Download to PC" Click="DownloadMenuItem_Click"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="📝 Rename" Click="RenameMenuItem_Click"/>
|
||||
<MenuItem Header="📋 Copy" Click="CopyMenuItem_Click"/>
|
||||
<MenuItem Header="✂️ Move" Click="MoveMenuItem_Click"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="🗑️ Delete" Click="DeleteMenuItem_Click"/>
|
||||
<MenuItem Header="🗑️ Delete Selected" Click="DeleteSelectedMenuItem_Click"/>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="{Binding Icon}"
|
||||
FontSize="16"
|
||||
Margin="0,0,10,0"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SizeText}"
|
||||
FontSize="11"
|
||||
Foreground="#999999"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Border Grid.Row="3"
|
||||
Background="#2D2D30"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Margin="10"
|
||||
Padding="10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="💾 PS5 Storage"
|
||||
FontSize="12"
|
||||
FontWeight="Bold"
|
||||
Foreground="#007ACC"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,8"/>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="Free:"
|
||||
FontSize="11"
|
||||
Foreground="#999999"
|
||||
Margin="0,0,8,4"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1"
|
||||
x:Name="StorageFreeText"
|
||||
Text="--"
|
||||
FontSize="11"
|
||||
Foreground="#4EC9B0"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,0,0,4"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||
Text="Used:"
|
||||
FontSize="11"
|
||||
Foreground="#999999"
|
||||
Margin="0,0,8,4"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1"
|
||||
x:Name="StorageUsedText"
|
||||
Text="--"
|
||||
FontSize="11"
|
||||
Foreground="#CE9178"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,0,0,4"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||
Text="Total:"
|
||||
FontSize="11"
|
||||
Foreground="#999999"
|
||||
Margin="0,0,8,4"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||
x:Name="StorageTotalText"
|
||||
Text="--"
|
||||
FontSize="11"
|
||||
Foreground="White"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,0,0,4"/>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<ProgressBar Grid.Row="3" Grid.ColumnSpan="2"
|
||||
x:Name="StorageProgressBar"
|
||||
Height="6"
|
||||
Margin="0,6,0,0"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Foreground="#CE9178"
|
||||
Background="#3E3E42"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Log Panel -->
|
||||
<Border Grid.Column="4"
|
||||
Background="#252526"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0"
|
||||
Background="#2D2D30"
|
||||
Padding="10"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="📋 Debug Log"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
x:Name="LogTextBox"
|
||||
Text=""
|
||||
FontFamily="Consolas"
|
||||
FontSize="11"
|
||||
Foreground="#CCCCCC"
|
||||
Background="#1E1E1E"
|
||||
BorderThickness="0"
|
||||
Padding="10"
|
||||
TextWrapping="Wrap"
|
||||
IsReadOnly="True"
|
||||
IsReadOnlyCaretVisible="False"
|
||||
Focusable="False"
|
||||
Cursor="Arrow"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto"/>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
Margin="10"
|
||||
HorizontalAlignment="Center">
|
||||
<Button Content="📋 Copy Log"
|
||||
Click="CopyLogButton_Click"
|
||||
Margin="0,0,10,0"
|
||||
Background="#28A745"/>
|
||||
<Button Content="Clear Log"
|
||||
Click="ClearLogButton_Click"
|
||||
Background="#3E3E42"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<Border Grid.Row="3"
|
||||
Background="#252526"
|
||||
BorderBrush="#3E3E42"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="15"
|
||||
Margin="0,15,0,0"
|
||||
x:Name="ProgressPanel"
|
||||
Visibility="Collapsed">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Files Remaining Counter -->
|
||||
<TextBlock Grid.Row="0"
|
||||
x:Name="FilesRemainingText"
|
||||
Text="Files: 0 / 0 (0 remaining)"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#FFC107"
|
||||
Margin="0,0,0,8"/>
|
||||
|
||||
<!-- Total Folder Progress -->
|
||||
<TextBlock Grid.Row="1"
|
||||
x:Name="TotalProgressText"
|
||||
Text="Total: 0 / 0 files"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#28A745"
|
||||
Margin="0,0,0,5"/>
|
||||
|
||||
<ProgressBar Grid.Row="2"
|
||||
x:Name="TotalProgressBar"
|
||||
Height="20"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Foreground="#17A2B8"
|
||||
Background="#2D2D30"
|
||||
BorderBrush="#3E3E42"
|
||||
Margin="0,0,0,10"/>
|
||||
|
||||
<!-- Current File -->
|
||||
<TextBlock Grid.Row="3"
|
||||
x:Name="UploadFileNameText"
|
||||
Text="Uploading: filename.bin"
|
||||
FontSize="13"
|
||||
Margin="0,0,0,5"/>
|
||||
|
||||
<ProgressBar Grid.Row="4"
|
||||
x:Name="UploadProgressBar"
|
||||
Height="20"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Foreground="#28A745"
|
||||
Background="#2D2D30"
|
||||
BorderBrush="#3E3E42"/>
|
||||
|
||||
<!-- Speed and Progress -->
|
||||
<Grid Grid.Row="5" Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0"
|
||||
x:Name="UploadSpeedText"
|
||||
Text="Speed: 0 MB/s | Avg: 0 MB/s"
|
||||
FontSize="12"
|
||||
Foreground="#999999"/>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
x:Name="UploadProgressText"
|
||||
Text="0 / 0 MB (0%)"
|
||||
FontSize="12"
|
||||
Foreground="#999999"/>
|
||||
</Grid>
|
||||
|
||||
<!-- ETA -->
|
||||
<TextBlock Grid.Row="6"
|
||||
x:Name="UploadETAText"
|
||||
Text="ETA: Calculating..."
|
||||
FontSize="12"
|
||||
Foreground="#999999"
|
||||
Margin="0,5,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Upload Buttons -->
|
||||
<Grid Grid.Row="4" Margin="0,15,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="10"/>
|
||||
<ColumnDefinition Width="150"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Button Grid.Column="0"
|
||||
x:Name="UploadButton"
|
||||
Content="⬆️ Upload to PS5"
|
||||
Click="UploadButton_Click"
|
||||
Height="45"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
IsEnabled="False"/>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
x:Name="CancelButton"
|
||||
Content="❌ Cancel"
|
||||
Click="CancelButton_Click"
|
||||
Height="45"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Background="#DC3545"
|
||||
IsEnabled="False"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>PS5Upload</AssemblyName>
|
||||
<RootNamespace>PS5Upload</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>Manos</Authors>
|
||||
<Description>High-Speed File Upload Tool for PS5</Description>
|
||||
<Copyright>Copyright © 2026 Manos</Copyright>
|
||||
<Nullable>enable</Nullable>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<ApplicationIcon>ps5_logo_fixed.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="ps5_logo.png" />
|
||||
<None Include="ps5_logo.ico">
|
||||
<Link>ps5_logo.ico</Link>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,633 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// Protocol commands
|
||||
public enum Command : byte
|
||||
{
|
||||
Ping = 0x01,
|
||||
ListStorage = 0x02,
|
||||
ListDir = 0x03,
|
||||
CreateDir = 0x04,
|
||||
DeleteFile = 0x05,
|
||||
DeleteDir = 0x06,
|
||||
Rename = 0x07,
|
||||
CopyFile = 0x08,
|
||||
MoveFile = 0x09,
|
||||
StartUpload = 0x10,
|
||||
UploadChunk = 0x11,
|
||||
EndUpload = 0x12,
|
||||
DownloadFile = 0x13,
|
||||
Shutdown = 0xFF
|
||||
}
|
||||
|
||||
// Protocol responses
|
||||
public enum Response : byte
|
||||
{
|
||||
Ok = 0x01,
|
||||
Error = 0x02,
|
||||
Data = 0x03,
|
||||
Ready = 0x04,
|
||||
Progress = 0x05
|
||||
}
|
||||
|
||||
public class PS5Protocol : IDisposable
|
||||
{
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private const int BufferSize = 4 * 1024 * 1024; // 4MB chunks - MUST match payload BUFFER_SIZE
|
||||
|
||||
public bool IsConnected => _client?.Connected ?? false;
|
||||
|
||||
public async Task<bool> ConnectAsync(string ipAddress, int port = 9113)
|
||||
{
|
||||
try
|
||||
{
|
||||
_client = new TcpClient();
|
||||
_client.ReceiveBufferSize = 64 * 1024 * 1024; // 64MB for maximum TCP throughput
|
||||
_client.SendBufferSize = 64 * 1024 * 1024; // 64MB for maximum TCP throughput
|
||||
_client.NoDelay = true;
|
||||
_client.LingerState = new System.Net.Sockets.LingerOption(false, 0);
|
||||
|
||||
// BUG FIX #1: Add 5 second timeout for connection to fail fast on wrong IP
|
||||
var connectTask = _client.ConnectAsync(ipAddress, port);
|
||||
var timeoutTask = Task.Delay(5000); // 5 second timeout
|
||||
|
||||
var completedTask = await Task.WhenAny(connectTask, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
// Timeout occurred
|
||||
_client?.Close();
|
||||
_client?.Dispose();
|
||||
_client = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if connection actually succeeded
|
||||
if (!_client.Connected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_stream = _client.GetStream();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_client?.Close();
|
||||
_client?.Dispose();
|
||||
_client = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
_stream?.Dispose();
|
||||
_client?.Dispose();
|
||||
_stream = null;
|
||||
_client = null;
|
||||
}
|
||||
|
||||
private async Task SendCommandAsync(Command cmd, byte[]? data = null)
|
||||
{
|
||||
if (_stream == null) throw new InvalidOperationException("Not connected");
|
||||
|
||||
byte[] header = new byte[5];
|
||||
header[0] = (byte)cmd;
|
||||
|
||||
uint dataLen = data != null ? (uint)data.Length : 0;
|
||||
BitConverter.GetBytes(dataLen).CopyTo(header, 1);
|
||||
|
||||
await _stream.WriteAsync(header, 0, 5);
|
||||
if (data != null && data.Length > 0)
|
||||
{
|
||||
await _stream.WriteAsync(data, 0, data.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(Response response, byte[] data)> ReceiveResponseAsync()
|
||||
{
|
||||
if (_stream == null) throw new InvalidOperationException("Not connected");
|
||||
|
||||
byte[] header = new byte[5];
|
||||
await ReadExactAsync(header, 5);
|
||||
|
||||
Response response = (Response)header[0];
|
||||
uint dataLen = BitConverter.ToUInt32(header, 1);
|
||||
|
||||
byte[] data = new byte[dataLen];
|
||||
if (dataLen > 0)
|
||||
{
|
||||
await ReadExactAsync(data, (int)dataLen);
|
||||
}
|
||||
|
||||
return (response, data);
|
||||
}
|
||||
|
||||
private async Task ReadExactAsync(byte[] buffer, int count, int timeoutMs = 120000)
|
||||
{
|
||||
if (_stream == null) throw new InvalidOperationException("Not connected");
|
||||
|
||||
int offset = 0;
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
while (offset < count)
|
||||
{
|
||||
int read = await _stream.ReadAsync(buffer, offset, count - offset, cts.Token);
|
||||
if (read == 0)
|
||||
{
|
||||
// Give PS5 a moment to recover before declaring connection dead
|
||||
await Task.Delay(100);
|
||||
read = await _stream.ReadAsync(buffer, offset, count - offset, cts.Token);
|
||||
if (read == 0) throw new IOException("Connection closed");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("Read timeout");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PingAsync()
|
||||
{
|
||||
await SendCommandAsync(Command.Ping);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
return response == Response.Ok;
|
||||
}
|
||||
|
||||
public async Task<StorageInfo[]> ListStorageAsync()
|
||||
{
|
||||
await SendCommandAsync(Command.ListStorage);
|
||||
var (response, data) = await ReceiveResponseAsync();
|
||||
|
||||
if (response != Response.Data) return Array.Empty<StorageInfo>();
|
||||
|
||||
using var ms = new MemoryStream(data);
|
||||
using var br = new BinaryReader(ms);
|
||||
|
||||
int count = br.ReadInt32();
|
||||
var result = new StorageInfo[count];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
ushort pathLen = br.ReadUInt16();
|
||||
string path = Encoding.UTF8.GetString(br.ReadBytes(pathLen));
|
||||
long total = br.ReadInt64();
|
||||
long free = br.ReadInt64();
|
||||
|
||||
result[i] = new StorageInfo { Path = path, TotalBytes = total, FreeBytes = free };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<FileEntry[]> ListDirAsync(string path)
|
||||
{
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
|
||||
await SendCommandAsync(Command.ListDir, pathBytes);
|
||||
var (response, data) = await ReceiveResponseAsync();
|
||||
|
||||
if (response != Response.Data) return Array.Empty<FileEntry>();
|
||||
|
||||
using var ms = new MemoryStream(data);
|
||||
using var br = new BinaryReader(ms);
|
||||
|
||||
int count = br.ReadInt32();
|
||||
|
||||
// Handle empty directory
|
||||
if (count <= 0) return Array.Empty<FileEntry>();
|
||||
|
||||
var result = new FileEntry[count];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
byte type = br.ReadByte();
|
||||
ushort nameLen = br.ReadUInt16();
|
||||
string name = Encoding.UTF8.GetString(br.ReadBytes(nameLen));
|
||||
long size = br.ReadInt64();
|
||||
long timestamp = br.ReadInt64();
|
||||
|
||||
// Clamp timestamp to valid DateTimeOffset range
|
||||
DateTime dt;
|
||||
try
|
||||
{
|
||||
if (timestamp < -62135596800 || timestamp > 253402300799)
|
||||
{
|
||||
dt = DateTime.Now; // Use current time for invalid timestamps
|
||||
}
|
||||
else
|
||||
{
|
||||
dt = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
dt = DateTime.Now;
|
||||
}
|
||||
|
||||
result[i] = new FileEntry
|
||||
{
|
||||
Name = name,
|
||||
IsDirectory = type == 1,
|
||||
Size = size,
|
||||
Timestamp = dt
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> CreateDirAsync(string path)
|
||||
{
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
|
||||
await SendCommandAsync(Command.CreateDir, pathBytes);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
return response == Response.Ok;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFileAsync(string path)
|
||||
{
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
|
||||
await SendCommandAsync(Command.DeleteFile, pathBytes);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
return response == Response.Ok;
|
||||
}
|
||||
|
||||
public event Action<string>? OnProgressMessage;
|
||||
|
||||
public async Task<bool> DeleteDirAsync(string path)
|
||||
{
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
|
||||
await SendCommandAsync(Command.DeleteDir, pathBytes);
|
||||
|
||||
// Receive initial OK response
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
if (response != Response.Ok)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Now listen for progress messages synchronously
|
||||
// Keep reading until we get the final OK/ERROR response
|
||||
bool deletionComplete = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (!deletionComplete)
|
||||
{
|
||||
var (progressResponse, progressData) = await ReceiveResponseAsync();
|
||||
|
||||
if (progressResponse == Response.Progress)
|
||||
{
|
||||
string message = Encoding.UTF8.GetString(progressData).TrimEnd('\0');
|
||||
OnProgressMessage?.Invoke(message);
|
||||
}
|
||||
else if (progressResponse == Response.Ok)
|
||||
{
|
||||
// Final OK response received - deletion complete
|
||||
OnProgressMessage?.Invoke("🔚 Received final OK - deletion complete");
|
||||
deletionComplete = true;
|
||||
}
|
||||
else if (progressResponse == Response.Error)
|
||||
{
|
||||
// Error response received
|
||||
OnProgressMessage?.Invoke("❌ Received error response");
|
||||
deletionComplete = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unexpected response - log it
|
||||
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {progressResponse}");
|
||||
deletionComplete = 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;
|
||||
}
|
||||
|
||||
public async Task<bool> RenameAsync(string oldPath, string newPath)
|
||||
{
|
||||
byte[] oldBytes = Encoding.UTF8.GetBytes(oldPath + "\0");
|
||||
byte[] newBytes = Encoding.UTF8.GetBytes(newPath + "\0");
|
||||
byte[] data = new byte[oldBytes.Length + newBytes.Length];
|
||||
Array.Copy(oldBytes, 0, data, 0, oldBytes.Length);
|
||||
Array.Copy(newBytes, 0, data, oldBytes.Length, newBytes.Length);
|
||||
|
||||
await SendCommandAsync(Command.Rename, data);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
return response == Response.Ok;
|
||||
}
|
||||
|
||||
public async Task<bool> CopyFileAsync(string srcPath, string dstPath)
|
||||
{
|
||||
byte[] srcBytes = Encoding.UTF8.GetBytes(srcPath + "\0");
|
||||
byte[] dstBytes = Encoding.UTF8.GetBytes(dstPath + "\0");
|
||||
byte[] data = new byte[srcBytes.Length + dstBytes.Length];
|
||||
Array.Copy(srcBytes, 0, data, 0, srcBytes.Length);
|
||||
Array.Copy(dstBytes, 0, data, srcBytes.Length, dstBytes.Length);
|
||||
|
||||
await SendCommandAsync(Command.CopyFile, data);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
return response == Response.Ok;
|
||||
}
|
||||
|
||||
public async Task<bool> MoveFileAsync(string srcPath, string dstPath)
|
||||
{
|
||||
byte[] srcBytes = Encoding.UTF8.GetBytes(srcPath + "\0");
|
||||
byte[] dstBytes = Encoding.UTF8.GetBytes(dstPath + "\0");
|
||||
byte[] data = new byte[srcBytes.Length + dstBytes.Length];
|
||||
Array.Copy(srcBytes, 0, data, 0, srcBytes.Length);
|
||||
Array.Copy(dstBytes, 0, data, srcBytes.Length, dstBytes.Length);
|
||||
|
||||
await SendCommandAsync(Command.MoveFile, data);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
return response == Response.Ok;
|
||||
}
|
||||
|
||||
public async Task<bool> UploadFileAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress = null, CancellationToken cancellationToken = default, long chunkOffset = 0, long chunkSize = 0)
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(localPath);
|
||||
if (!fileInfo.Exists) return false;
|
||||
|
||||
// Determine actual upload size
|
||||
long uploadSize = chunkSize > 0 ? chunkSize : fileInfo.Length;
|
||||
|
||||
// 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(chunkOffset).CopyTo(startData, pathBytes.Length + 9);
|
||||
|
||||
await SendCommandAsync(Command.StartUpload, startData);
|
||||
var (response, _) = await ReceiveResponseAsync();
|
||||
|
||||
if (response != Response.Ready) return false;
|
||||
|
||||
// Upload chunks - simple async approach
|
||||
long totalSent = 0;
|
||||
var startTime = DateTime.Now;
|
||||
double avgSpeed = 0;
|
||||
|
||||
byte[] sendBuffer = new byte[5 + BufferSize];
|
||||
sendBuffer[0] = (byte)Command.UploadChunk;
|
||||
|
||||
using (FileStream fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, FileOptions.SequentialScan))
|
||||
{
|
||||
// Seek to chunk offset if specified
|
||||
if (chunkOffset > 0)
|
||||
{
|
||||
fs.Seek(chunkOffset, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
int chunksSent = 0;
|
||||
int bytesRead;
|
||||
long bytesRemaining = uploadSize;
|
||||
|
||||
while (bytesRemaining > 0 && (bytesRead = await fs.ReadAsync(sendBuffer, 5, (int)Math.Min(BufferSize, bytesRemaining), cancellationToken)) > 0)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
|
||||
BitConverter.GetBytes((uint)bytesRead).CopyTo(sendBuffer, 1);
|
||||
|
||||
if (_stream == null) return false;
|
||||
|
||||
// FIX: Add timeout to WriteAsync to prevent hanging if PS5 becomes unresponsive
|
||||
// 30 second timeout per chunk write - should be more than enough for 4MB
|
||||
using var writeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, writeTimeout.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(sendBuffer, 0, 5 + bytesRead, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (writeTimeout.IsCancellationRequested)
|
||||
{
|
||||
// Write timeout - PS5 is unresponsive
|
||||
return false;
|
||||
}
|
||||
|
||||
totalSent += bytesRead;
|
||||
bytesRemaining -= bytesRead;
|
||||
chunksSent++;
|
||||
|
||||
if (chunksSent % 5 == 0 || bytesRemaining == 0)
|
||||
{
|
||||
var elapsed = DateTime.Now - startTime;
|
||||
double currentSpeed = elapsed.TotalSeconds > 0 ? totalSent / elapsed.TotalSeconds : 0;
|
||||
avgSpeed = currentSpeed;
|
||||
|
||||
TimeSpan eta = currentSpeed > 0
|
||||
? TimeSpan.FromSeconds(bytesRemaining / currentSpeed)
|
||||
: TimeSpan.Zero;
|
||||
|
||||
progress?.Report(new UploadProgress
|
||||
{
|
||||
BytesSent = chunkOffset + totalSent,
|
||||
TotalBytes = fileInfo.Length,
|
||||
SpeedBytesPerSecond = currentSpeed,
|
||||
AverageSpeedBytesPerSecond = avgSpeed,
|
||||
ElapsedTime = elapsed,
|
||||
EstimatedTimeRemaining = eta,
|
||||
CurrentFileName = fileInfo.Name
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send END_UPLOAD and wait for response
|
||||
// CRITICAL: Must wait for response to avoid protocol desync on chunked uploads
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(Command.EndUpload);
|
||||
var (endResponse, _) = await ReceiveResponseAsync();
|
||||
return endResponse == Response.Ok;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // Connection issue means upload failed
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DownloadFileAsync(string remotePath, string localPath, IProgress<UploadProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Send download command
|
||||
byte[] pathBytes = Encoding.UTF8.GetBytes(remotePath);
|
||||
await SendCommandAsync(Command.DownloadFile, pathBytes);
|
||||
|
||||
// Receive response header (5 bytes: 1 response + 4 data_len)
|
||||
byte[] header = new byte[5];
|
||||
await ReadExactAsync(header, 5);
|
||||
|
||||
Response response = (Response)header[0];
|
||||
uint dataLen = BitConverter.ToUInt32(header, 1);
|
||||
|
||||
// Check if error response
|
||||
if (response == Response.Error)
|
||||
{
|
||||
if (dataLen > 0)
|
||||
{
|
||||
byte[] errorMsg = new byte[dataLen];
|
||||
await ReadExactAsync(errorMsg, (int)dataLen);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Expecting RESP_DATA with 8-byte file size
|
||||
if (response != Response.Data || dataLen != 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read file size (8 bytes)
|
||||
byte[] sizeBytes = new byte[8];
|
||||
await ReadExactAsync(sizeBytes, 8);
|
||||
long fileSize = BitConverter.ToInt64(sizeBytes, 0);
|
||||
|
||||
// Now read raw file data directly from socket
|
||||
using var fs = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize);
|
||||
|
||||
byte[] buffer = new byte[BufferSize];
|
||||
long totalReceived = 0;
|
||||
var startTime = DateTime.Now;
|
||||
|
||||
while (totalReceived < fileSize)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
|
||||
int toRead = (int)Math.Min(BufferSize, fileSize - totalReceived);
|
||||
int received = await _stream!.ReadAsync(buffer, 0, toRead, cancellationToken);
|
||||
|
||||
if (received == 0) break;
|
||||
|
||||
await fs.WriteAsync(buffer, 0, received, cancellationToken);
|
||||
totalReceived += received;
|
||||
|
||||
// Report progress every 5MB or at completion
|
||||
if (totalReceived % (5 * 1024 * 1024) < BufferSize || totalReceived == fileSize)
|
||||
{
|
||||
var elapsed = DateTime.Now - startTime;
|
||||
double speed = elapsed.TotalSeconds > 0 ? totalReceived / elapsed.TotalSeconds : 0;
|
||||
|
||||
progress?.Report(new UploadProgress
|
||||
{
|
||||
BytesSent = totalReceived,
|
||||
TotalBytes = fileSize,
|
||||
SpeedBytesPerSecond = speed,
|
||||
AverageSpeedBytesPerSecond = speed,
|
||||
ElapsedTime = elapsed,
|
||||
EstimatedTimeRemaining = speed > 0 ? TimeSpan.FromSeconds((fileSize - totalReceived) / speed) : TimeSpan.Zero,
|
||||
CurrentFileName = Path.GetFileName(localPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return totalReceived == fileSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Download error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public class StorageInfo
|
||||
{
|
||||
public string Path { get; set; } = "";
|
||||
public long TotalBytes { get; set; }
|
||||
public long FreeBytes { get; set; }
|
||||
}
|
||||
|
||||
public class FileEntry
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsDirectory { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public class UploadProgress
|
||||
{
|
||||
public long BytesSent { get; set; }
|
||||
public long TotalBytes { get; set; }
|
||||
public double SpeedBytesPerSecond { get; set; }
|
||||
public double AverageSpeedBytesPerSecond { get; set; }
|
||||
public TimeSpan ElapsedTime { get; set; }
|
||||
public TimeSpan EstimatedTimeRemaining { get; set; }
|
||||
public string CurrentFileName { get; set; } = "";
|
||||
|
||||
// For folder progress
|
||||
public int CurrentFileIndex { get; set; }
|
||||
public int TotalFiles { get; set; }
|
||||
public long TotalFolderBytes { get; set; }
|
||||
public long TotalFolderBytesSent { get; set; }
|
||||
}
|
||||
|
||||
// High-speed parallel uploader using multiple connections
|
||||
public class ParallelUploader : IDisposable
|
||||
{
|
||||
private readonly string _ipAddress;
|
||||
private readonly int _port;
|
||||
private readonly int _connectionCount;
|
||||
private const int ChunkSize = 4 * 1024 * 1024; // 4MB chunks per connection
|
||||
|
||||
public ParallelUploader(string ipAddress, int port = 9113, int connectionCount = 4)
|
||||
{
|
||||
_ipAddress = ipAddress;
|
||||
_port = port;
|
||||
_connectionCount = connectionCount;
|
||||
}
|
||||
|
||||
public async Task<bool> UploadFileAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(localPath);
|
||||
if (!fileInfo.Exists) return false;
|
||||
|
||||
// For small files, use single connection
|
||||
if (fileInfo.Length < ChunkSize * 2)
|
||||
{
|
||||
using var protocol = new PS5Protocol();
|
||||
if (!await protocol.ConnectAsync(_ipAddress, _port)) return false;
|
||||
return await protocol.UploadFileAsync(localPath, remotePath, progress, cancellationToken);
|
||||
}
|
||||
|
||||
// For large files, use parallel connections
|
||||
// This requires server support for chunked/parallel uploads
|
||||
// For now, fall back to single connection
|
||||
using var proto = new PS5Protocol();
|
||||
if (!await proto.ConnectAsync(_ipAddress, _port)) return false;
|
||||
return await proto.UploadFileAsync(localPath, remotePath, progress, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
@echo off
|
||||
echo ================================================
|
||||
echo PS5 Upload Client - Building
|
||||
echo By Manos
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
echo [+] Building Windows GUI Client...
|
||||
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true
|
||||
|
||||
if exist "bin\Release\net6.0-windows\win-x64\publish\PS5Upload.exe" (
|
||||
echo.
|
||||
echo ================================================
|
||||
echo [+] SUCCESS! Built PS5Upload.exe
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Location: bin\Release\net6.0-windows\win-x64\publish\PS5Upload.exe
|
||||
echo.
|
||||
echo Copy PS5Upload.exe to any location and run it!
|
||||
) else (
|
||||
echo.
|
||||
echo ================================================
|
||||
echo [-] BUILD FAILED
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Make sure .NET 6.0 SDK is installed:
|
||||
echo https://dotnet.microsoft.com/download/dotnet/6.0
|
||||
exit /b 1
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB |
+2011
File diff suppressed because it is too large.
Load diff
+68
@@ -0,0 +1,68 @@
|
||||
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
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
/*
|
||||
* 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 */
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
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
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user