mirror of
https://github.com/manos555555/PS5-Suite.git
synced 2026-10-06 10:00:26 +02:00
802 lines
39 KiB
C#
802 lines
39 KiB
C#
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"));
|
||
}
|
||
|
||
_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}%";
|
||
}
|
||
|
||
HwStatusText.Text = $"Last updated: {DateTime.Now:HH:mm:ss}";
|
||
}
|
||
catch (Exception ex) { HwStatusText.Text = $"Error: {ex.Message}"; }
|
||
}
|
||
|
||
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 { }
|
||
}
|
||
}
|
||
}
|
||
}
|