Compare commits

..
1 Commits
Author SHA1 Message Date
Manos a4005c1c00 v7.4.0: remove PKG-Zone + fix 5 deletion bugs
- Remove Homebrew Store (PKG-Zone) from desktop and Android clients
- Preserve Prospero Store Site untouched
- Fix race condition on shared globals in payload delete threads (per-thread delete_ctx_t)
- Fix DeleteDirAsync always returning true on failure (desktop + Android)
- Fix missing concurrent-delete guard in bulk delete operations
- Fix duplicate disconnect log message (copy-paste)
- Fix file count estimation including directories (only count files now)
2026-10-13 23:16:56 +03:00
14 changed files with 505 additions and 773 deletions

No files matched your search

@@ -5,8 +5,8 @@
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>com.manos.ps5suite</ApplicationId>
<ApplicationVersion>9</ApplicationVersion>
<ApplicationDisplayVersion>7.3.0</ApplicationDisplayVersion>
<ApplicationVersion>10</ApplicationVersion>
<ApplicationDisplayVersion>7.4.0</ApplicationDisplayVersion>
<ApplicationTitle>PS5 Suite</ApplicationTitle>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
@@ -3,7 +3,7 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<Version>7.3.0</Version>
<Version>7.4.0</Version>
</PropertyGroup>
<ItemGroup>
+19 -26
View File
@@ -1483,52 +1483,45 @@ namespace PS5Upload
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.DeleteDir, pathBytes);
// Payload does NOT send initial OK - goes straight to progress messages
// Keep reading until we get the final OK/ERROR response
bool deletionComplete = false;
bool success = false;
bool done = false;
try
{
while (!deletionComplete)
while (!done)
{
var (progressResponse, progressData) = await ReceiveResponseAsync();
if (progressResponse == Response.Progress)
var (resp, respData) = await ReceiveResponseAsync();
if (resp == Response.Progress)
{
string message = Encoding.UTF8.GetString(progressData).TrimEnd('\0');
string message = Encoding.UTF8.GetString(respData).TrimEnd('\0');
OnProgressMessage?.Invoke(message);
}
else if (progressResponse == Response.Ok)
else if (resp == Response.Ok)
{
// Final OK response received - deletion complete
OnProgressMessage?.Invoke("🔚 Received final OK - deletion complete");
deletionComplete = true;
success = true;
done = true;
}
else if (progressResponse == Response.Error)
else if (resp == Response.Error)
{
// Error response received
OnProgressMessage?.Invoke("❌ Received error response");
deletionComplete = true;
done = true;
}
else
{
// Unexpected response - log it
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {progressResponse}");
deletionComplete = true;
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {resp}");
done = true;
}
}
}
catch (Exception ex)
{
// Connection closed - log it
OnProgressMessage?.Invoke($"⚠️ Connection closed: {ex.Message}");
}
// Give server a moment to fully close the deletion thread
// CRITICAL: Increased delay to prevent race condition with next delete
await Task.Delay(500);
return true;
return success;
}
finally
{
@@ -3451,8 +3444,8 @@ namespace PS5Upload
: "Locked";
public string HiddenDisplay => Hidden ? "🙈" : "";
public string GroupDisplay => string.IsNullOrEmpty(GroupId) || GroupId == "default" ? "Base" : GroupId;
public bool CanUnlock => StateKnown && !IsUnlocked;
public bool CanLock => StateKnown && IsUnlocked;
public bool CanUnlock => StateKnown && !IsUnlocked && Grade != "P";
public bool CanLock => StateKnown && IsUnlocked && Grade != "P";
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
@@ -50,7 +50,6 @@
<ListBoxItem>🔌 Connect</ListBoxItem>
<ListBoxItem>📁 File Transfer</ListBoxItem>
<ListBoxItem>🎮 Games</ListBoxItem>
<ListBoxItem>🛒 Homebrew Store</ListBoxItem>
<ListBoxItem>🌐 Prospero Store</ListBoxItem>
<ListBoxItem>🏆 Trophies</ListBoxItem>
<ListBoxItem>💾 Saves &amp; Media</ListBoxItem>
@@ -190,49 +189,6 @@
</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>
<!-- Prospero Store Tab (homebrew.page native PS5 apps) -->
<Panel x:Name="PageProspero" IsVisible="False">
<Grid RowDefinitions="Auto,Auto,*">
@@ -25,9 +25,6 @@ public partial class MainView : UserControl
private ObservableCollection<PS5SaveFile> _saves = new();
private ObservableCollection<PS5Screenshot> _screenshots = new();
private ObservableCollection<string> _logLines = new();
private readonly List<StoreItem> _storeItems = new();
private static readonly HttpClient _storeHttp = new() { Timeout = TimeSpan.FromSeconds(20) };
public MainView()
{
InitializeComponent();
@@ -88,7 +85,7 @@ public partial class MainView : UserControl
{
if (NavList.SelectedIndex < 0 || PageConnect == null) return;
var pages = new Control[] {
PageConnect, PageFiles, PageGames, PageStore, PageProspero,
PageConnect, PageFiles, PageGames, PageProspero,
PageTrophies, PageSaves, PageSystem, PageTools, PageDevices, PageLog
};
for (int i = 0; i < pages.Length; i++)
@@ -101,8 +98,8 @@ public partial class MainView : UserControl
switch (NavList.SelectedIndex)
{
case 2: if (!_gamesLoadedOnce) { _gamesLoadedOnce = true; _ = RefreshGamesAsync(); } break;
case 4: if (!_prosperoLoadedOnce) { _prosperoLoadedOnce = true; _ = RefreshProsperoAsync(); } break;
case 5: if (!_trophyLoadedOnce) { _trophyLoadedOnce = true; _ = RefreshTrophiesAsync(); } break;
case 3: if (!_prosperoLoadedOnce) { _prosperoLoadedOnce = true; _ = RefreshProsperoAsync(); } break;
case 4: if (!_trophyLoadedOnce) { _trophyLoadedOnce = true; _ = RefreshTrophiesAsync(); } break;
}
}
}
@@ -793,144 +790,6 @@ public partial class MainView : UserControl
catch (Exception ex) { UpdateStatus($"Notify error: {ex.Message}"); }
}
// ============================================================
// HOMEBREW STORE (pkg-zone.com catalog)
// ============================================================
private sealed class StoreItem : System.ComponentModel.INotifyPropertyChanged
{
public string Name { get; set; } = "";
public string Id { get; set; } = "";
public string Version { get; set; } = "";
public string Author { get; set; } = "";
private Bitmap? _cover;
public Bitmap? Cover
{
get => _cover;
set { _cover = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Cover))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
private static string StoreClean(string s)
=> System.Net.WebUtility.HtmlDecode(s).Trim();
private async void StoreRefresh_Click(object? sender, RoutedEventArgs e)
{
try
{
StoreStatusText.Text = "Fetching pkg-zone.com catalog...";
UpdateStatus("Fetching homebrew catalog...");
var items = new List<StoreItem>();
var articleRx = new System.Text.RegularExpressions.Regex(
@"<article class=""pkg[\s\S]*?</article>",
System.Text.RegularExpressions.RegexOptions.Compiled);
var idRx = new System.Text.RegularExpressions.Regex(
@"/details/([A-Z0-9]+)", System.Text.RegularExpressions.RegexOptions.Compiled);
var verRx = new System.Text.RegularExpressions.Regex(
@"<div class=""number text-white text-sm"">\s*([^<]+)", System.Text.RegularExpressions.RegexOptions.Compiled);
var titleRx = new System.Text.RegularExpressions.Regex(
@"<div class=""title font-bold"">([^<]+)</div>", System.Text.RegularExpressions.RegexOptions.Compiled);
var authorRx = new System.Text.RegularExpressions.Regex(
@"<div class=""dark:text-gray-300"">([^<]*)</div>", System.Text.RegularExpressions.RegexOptions.Compiled);
var seen = new HashSet<string>();
for (int page = 1; page <= 15; page++)
{
string url = $"https://pkg-zone.com/?console=ps5&page={page}";
string html;
try { html = await _storeHttp.GetStringAsync(url); }
catch (Exception ex)
{
if (page == 1) throw;
UpdateStatus($"Store page {page} unreachable ({ex.Message})");
break;
}
var arts = articleRx.Matches(html);
if (arts.Count == 0) break;
int newOnes = 0;
foreach (System.Text.RegularExpressions.Match m in arts)
{
string block = m.Value;
if (!block.Contains("Supports PS5")) continue;
var idM = idRx.Match(block);
if (!idM.Success || !seen.Add(idM.Groups[1].Value)) continue;
items.Add(new StoreItem
{
Id = idM.Groups[1].Value,
Name = StoreClean(titleRx.Match(block).Groups[1].Value is var tv && tv.Length > 0 ? tv : idM.Groups[1].Value),
Version = StoreClean(verRx.Match(block).Groups[1].Value ?? ""),
Author = StoreClean(authorRx.Match(block).Groups[1].Value ?? ""),
});
newOnes++;
}
if (newOnes == 0) break;
StoreStatusText.Text = $"Fetched {items.Count} packages...";
}
_storeItems.Clear();
_storeItems.AddRange(items);
ApplyStoreFilter();
StoreStatusText.Text = $"{items.Count} PS5 packages";
UpdateStatus($"Store catalog: {items.Count} packages");
_ = Task.Run(async () =>
{
foreach (var it in _storeItems)
{
try
{
var bytes = await _storeHttp.GetByteArrayAsync($"https://pkg-zone.com/images/{it.Id}/cover.png");
using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
await Dispatcher.UIThread.InvokeAsync(() => { it.Cover = bmp; });
}
catch { }
}
});
}
catch (Exception ex)
{
StoreStatusText.Text = "Fetch failed";
UpdateStatus($"Store error: {ex.Message}");
}
}
private void ApplyStoreFilter()
{
string q = StoreSearchBox.Text?.Trim() ?? "";
var view = string.IsNullOrEmpty(q)
? _storeItems
: _storeItems.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.Id.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.Author.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
StoreListBox.ItemsSource = view;
}
private void StoreSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyStoreFilter();
private async void StoreInstall_Click(object? sender, RoutedEventArgs e)
{
if (!RequireConnection()) return;
string? id = (sender as Control)?.Tag as string;
if (string.IsNullOrEmpty(id)) return;
var item = _storeItems.FirstOrDefault(i => i.Id == id);
string name = item?.Name ?? id;
try
{
string url = $"https://pkg-zone.com/download/ps5/{id}/latest";
UpdateStatus($"Installing {name}...");
var (success, result) = await _protocol!.InstallPkgAsync(url);
UpdateStatus(success
? $"Install accepted: {name} — check the PS5 home screen"
: $"Install failed: {result}");
}
catch (Exception ex) { UpdateStatus($"Install error: {ex.Message}"); }
}
// ============================================================
// SYSTEM INFO — Extended / Network / Modules
// ============================================================
+25 -16
View File
@@ -170,7 +170,6 @@ namespace PS5Upload
{
ConnectButton.IsEnabled = false;
Log("🔌 Disconnecting from PS5...");
Log("🔌 Disconnecting from PS5...");
StopHwAutoRefresh();
_protocol.Disconnect();
_shellActive = false;
@@ -822,24 +821,28 @@ namespace PS5Upload
private async void DeleteMenuItem_Click(object? sender, RoutedEventArgs e)
{
if (_isDeleting) return;
if (PS5FilesListBox.SelectedItem is PS5FileItem item)
{
if (await ShowConfirmAsync($"Delete {item.Name}?"))
{
_isDeleting = true;
try
{
if (item.IsDirectory) { Log($"🗑️ Deleting folder: {item.Name}"); await _protocol.DeleteDirAsync(item.FullPath); Log($"✅ Folder deletion complete: {item.Name}"); }
if (item.IsDirectory) { Log($"🗑️ Deleting folder: {item.Name}"); bool ok = await _protocol.DeleteDirAsync(item.FullPath); Log(ok ? $"✅ Folder deletion complete: {item.Name}" : $"❌ Folder deletion failed: {item.Name}"); }
else { Log($"🗑️ Deleting file: {item.Name}"); await _protocol.DeleteFileAsync(item.FullPath); Log($"✅ File deletion complete: {item.Name}"); }
await Task.Delay(1500);
await LoadPS5DirectoryAsync(_currentPS5Path);
}
catch (Exception ex) { await ShowMessageAsync($"Delete failed: {ex.Message}", "Error"); Log($"❌ Delete failed: {ex.Message}"); }
finally { _isDeleting = false; }
}
}
}
private async void DeleteSelectedMenuItem_Click(object? sender, RoutedEventArgs e)
{
if (_isDeleting) return;
var selectedItems = (PS5FilesListBox.SelectedItems ?? Array.Empty<object>()).Cast<PS5FileItem>().ToList();
if (selectedItems.Count == 0) { await ShowMessageAsync("No items selected."); return; }
@@ -851,24 +854,30 @@ namespace PS5Upload
if (!await ShowConfirmAsync(message, "Confirm Bulk Delete")) return;
Log($"🗑️ Starting bulk delete of {selectedItems.Count} items...");
int successCount = 0, failCount = 0;
foreach (var item in selectedItems)
_isDeleting = true;
try
{
try
Log($"🗑️ Starting bulk delete of {selectedItems.Count} items...");
int successCount = 0, failCount = 0;
foreach (var item in selectedItems)
{
if (item.IsDirectory) await _protocol.DeleteDirAsync(item.FullPath);
else await _protocol.DeleteFileAsync(item.FullPath);
successCount++;
Log($"✅ Deleted: {item.Name}");
await Task.Delay(300);
try
{
bool ok;
if (item.IsDirectory) ok = await _protocol.DeleteDirAsync(item.FullPath);
else ok = await _protocol.DeleteFileAsync(item.FullPath);
if (ok) { successCount++; Log($"✅ Deleted: {item.Name}"); }
else { failCount++; Log($"❌ Failed to delete {item.Name}: server error"); }
await Task.Delay(300);
}
catch (Exception ex) { Log($"❌ Failed to delete {item.Name}: {ex.Message}"); failCount++; }
}
catch (Exception ex) { Log($"❌ Failed to delete {item.Name}: {ex.Message}"); failCount++; }
Log($"🗑️ Bulk delete complete: {successCount} succeeded, {failCount} failed");
await Task.Delay(500);
await LoadPS5DirectoryAsync(_currentPS5Path);
await ShowMessageAsync($"Bulk delete complete!\n\n✅ {successCount} deleted\n❌ {failCount} failed");
}
Log($"🗑️ Bulk delete complete: {successCount} succeeded, {failCount} failed");
await Task.Delay(500);
await LoadPS5DirectoryAsync(_currentPS5Path);
await ShowMessageAsync($"Bulk delete complete!\n\n✅ {successCount} deleted\n❌ {failCount} failed");
finally { _isDeleting = false; }
}
private async void DownloadMenuItem_Click(object? sender, RoutedEventArgs e)
+29 -330
View File
@@ -954,8 +954,12 @@ namespace PS5Upload
// file on a small HTTP server and the payload's localhost
// responder proxies ranged GETs to it, so the console
// installs on the fly without staging anything to disk.
// Falls back to staged upload if streaming can't start.
string? url = await TryServePkgAsync(p);
// Retail pkgs (FIH magic) skip streaming entirely: the
// URL-based daemon call refuses their header before
// fetching a byte, and the local-path fallbacks need a
// real console path anyway.
bool isRetail = IsRetailPkg(p);
string? url = isRetail ? null : await TryServePkgAsync(p);
if (url != null)
{
Log($"📦 Streaming install: {Path.GetFileName(p)} (no staging)");
@@ -971,6 +975,11 @@ namespace PS5Upload
}
Log($"⚠️ Stream install failed ({streamRes}) — staging on PS5 instead");
}
else if (isRetail)
{
Log($"📦 Retail-signed PKG detected — staging on PS5 for path install");
Log($"⚠️ Retail pkgs need etaHEN-side patches; the install will likely be refused under k-stuff");
}
string remotePath = "/user/data/pkg/" + Path.GetFileName(p);
if (!await UploadPkgToPs5Async(p, remotePath))
continue; // error already reported
@@ -1025,6 +1034,24 @@ namespace PS5Upload
private readonly List<string> _pkgStagedPaths = new();
private PkgHttpServer? _pkgServer;
/// <summary>PS5 pkgs start with \x7fFIH; the signed byte at offset 5 is
/// 0x80 for retail/submitted images and 0x00 for debug-signed (fPKG).
/// Retail images are rejected by the stock installer daemon on k-stuff
/// (no HEN-side patches) — fPKGs install fine. Used to warn early and
/// to skip the streaming path the daemon would refuse anyway.</summary>
private static bool IsRetailPkg(string path)
{
try
{
var buf = new byte[8];
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
if (fs.Read(buf, 0, 8) < 8) return false;
return buf[0] == 0x7F && buf[1] == (byte)'F' && buf[2] == (byte)'I' &&
buf[3] == (byte)'H' && (buf[5] & 0x80) != 0;
}
catch { return false; }
}
/// <summary>Host a local PKG on the embedded HTTP server; returns the URL the
/// payload can proxy (http://&lt;pc-ip&gt;:&lt;port&gt;/name.pkg), or null if the
/// PC address can't be determined or the server fails its own self-test.</summary>
@@ -1661,32 +1688,6 @@ namespace PS5Upload
catch (Exception ex) { Log($"❌ Beeper mute error: {ex.Message}"); }
}
// ============================================================
// HOMEBREW STORE (pkg-zone.com catalog)
// ============================================================
private sealed class StoreItem : System.ComponentModel.INotifyPropertyChanged
{
public string Name { get; set; } = "";
public string Id { get; set; } = "";
public string Version { get; set; } = "";
public string Author { get; set; } = "";
private Bitmap? _cover;
[System.Text.Json.Serialization.JsonIgnore]
public Bitmap? Cover
{
get => _cover;
set { _cover = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Cover))); }
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
}
private readonly List<StoreItem> _storeItems = new();
// pkg-zone.com is extremely flaky — roughly half of all requests either
// time out at the edge or hit a Laravel 500. Short timeout + per-request
// retry is the only way a fetch reliably lands; a single long timeout
// just makes the app look frozen.
private static readonly HttpClient _storeHttp = CreateStoreHttp();
private static HttpClient CreateStoreHttp()
{
@@ -1699,213 +1700,8 @@ namespace PS5Upload
return h;
}
// Retry wrapper for the flaky backend. Returns null after `attempts`
// failures so the caller can decide whether to abort or degrade.
private static async Task<string?> FetchStorePageAsync(string url, int attempts)
{
Exception? lastEx = null;
for (int a = 0; a < attempts; a++)
{
try { return await _storeHttp.GetStringAsync(url); }
catch (Exception ex)
{
lastEx = ex;
if (a + 1 < attempts) await Task.Delay(800 * (a + 1));
}
}
_ = lastEx;
return null;
}
private static string StoreClean(string s)
=> System.Net.WebUtility.HtmlDecode(s).Trim();
// The catalog is tiny and rarely changes — cache it next to the exe so
// the store paints instantly even when pkg-zone.com is having a bad day
// (it 500s roughly half of all requests).
private const string StoreCacheFile = "pkgzone_cache.json";
private const string StoreCoverDir = "pkgzone_covers";
private int LoadStoreCache()
{
try
{
if (!File.Exists(StoreCacheFile)) return 0;
var items = JsonSerializer.Deserialize<List<StoreItem>>(File.ReadAllText(StoreCacheFile));
if (items == null || items.Count == 0) return 0;
_storeItems.Clear();
_storeItems.AddRange(items);
ApplyStoreFilter();
StoreStatusText.Text = $"{items.Count} PS5 packages (cached)";
foreach (var it in items) TryLoadCachedCover(it);
return items.Count;
}
catch { return 0; }
}
private static void TryLoadCachedCover(StoreItem it)
{
try
{
string p = Path.Combine(StoreCoverDir, it.Id + ".png");
if (File.Exists(p))
it.Cover = new Bitmap(p);
}
catch { }
}
private static void SaveCoverToCache(StoreItem it, byte[] png)
{
try
{
Directory.CreateDirectory(StoreCoverDir);
File.WriteAllBytes(Path.Combine(StoreCoverDir, it.Id + ".png"), png);
}
catch { }
}
// Fetches every missing cover in parallel, writes successful ones to the
// disk cache. Runs after every refresh attempt — success OR failure —
// so cached catalogs still get their icons whenever the site responds.
private void FetchStoreCoversAsync()
{
var items = _storeItems.ToList();
_ = Task.Run(async () =>
{
await Task.WhenAll(items.Select(async it =>
{
if (it.Cover != null) return;
TryLoadCachedCover(it);
if (it.Cover != null) return;
for (int a = 0; a < 5 && it.Cover == null; a++)
{
try
{
var bytes = await _storeHttp.GetByteArrayAsync($"https://pkg-zone.com/images/{it.Id}/cover.png");
// Site outages return HTML error pages — reject
// anything that isn't actually a PNG.
if (bytes.Length < 100 || bytes[0] != 0x89 || bytes[1] != 0x50)
throw new Exception("not a PNG");
using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
await Dispatcher.UIThread.InvokeAsync(() => { it.Cover = bmp; });
SaveCoverToCache(it, bytes);
}
catch { await Task.Delay(800 * (a + 1)); }
}
}));
});
}
private bool _storeFetching;
private async void StoreRefresh_Click(object? sender, RoutedEventArgs e)
{
if (_storeFetching) return; // rapid clicks pile up overlapping fetches
_storeFetching = true;
try
{
int cached = LoadStoreCache();
StoreStatusText.Text = cached > 0
? $"{cached} PS5 packages (cached) — refreshing…"
: "Fetching pkg-zone.com catalog...";
Log("🛒 Fetching homebrew catalog...");
var items = new List<StoreItem>();
var articleRx = new System.Text.RegularExpressions.Regex(
@"<article class=""pkg[\s\S]*?</article>",
System.Text.RegularExpressions.RegexOptions.Compiled);
var idRx = new System.Text.RegularExpressions.Regex(
@"/details/([A-Z0-9]+)", System.Text.RegularExpressions.RegexOptions.Compiled);
var verRx = new System.Text.RegularExpressions.Regex(
@"<div class=""number text-white text-sm"">\s*([^<]+)", System.Text.RegularExpressions.RegexOptions.Compiled);
var titleRx = new System.Text.RegularExpressions.Regex(
@"<div class=""title font-bold"">([^<]+)</div>", System.Text.RegularExpressions.RegexOptions.Compiled);
var authorRx = new System.Text.RegularExpressions.Regex(
@"<div class=""dark:text-gray-300"">([^<]*)</div>", System.Text.RegularExpressions.RegexOptions.Compiled);
var seen = new HashSet<string>();
int consecutiveFailures = 0;
for (int page = 1; page <= 15; page++)
{
string url = $"https://pkg-zone.com/?console=ps5&page={page}";
// Retry every page — the backend 500s or edge-times-out on
// roughly half of all requests, so a single-attempt walk
// dies halfway through the catalog almost every time.
string? html = await FetchStorePageAsync(url, 4);
if (html == null)
{
consecutiveFailures++;
if (page == 1) throw new Exception("catalog unreachable after 4 attempts");
if (consecutiveFailures >= 3) { Log($"🛒 Page {page} unreachable — stopping"); break; }
continue;
}
consecutiveFailures = 0;
var arts = articleRx.Matches(html);
if (arts.Count == 0) break;
int newOnes = 0;
foreach (System.Text.RegularExpressions.Match m in arts)
{
string block = m.Value;
if (!block.Contains("Supports PS5")) continue;
var idM = idRx.Match(block);
if (!idM.Success || !seen.Add(idM.Groups[1].Value)) continue;
items.Add(new StoreItem
{
Id = idM.Groups[1].Value,
Name = StoreClean(titleRx.Match(block).Groups[1].Value is var tv && tv.Length > 0 ? tv : idM.Groups[1].Value),
Version = StoreClean(verRx.Match(block).Groups[1].Value ?? ""),
Author = StoreClean(authorRx.Match(block).Groups[1].Value ?? ""),
});
newOnes++;
}
if (newOnes == 0) break;
StoreStatusText.Text = $"Fetched {items.Count} packages...";
}
if (items.Count == 0) throw new Exception("catalog returned no PS5 packages");
_storeItems.Clear();
_storeItems.AddRange(items);
ApplyStoreFilter();
foreach (var it in items) TryLoadCachedCover(it);
StoreStatusText.Text = $"{items.Count} PS5 packages";
Log($"🛒 Catalog: {items.Count} PS5 packages");
try { File.WriteAllText(StoreCacheFile, JsonSerializer.Serialize(items)); } catch { }
}
catch (Exception ex)
{
if (_storeItems.Count > 0)
{
StoreStatusText.Text = $"{_storeItems.Count} PS5 packages (cached — pkg-zone.com down)";
Log($"⚠️ pkg-zone.com unreachable, showing cached catalog ({ex.Message})");
}
else
{
StoreStatusText.Text = "Fetch failed";
Log($"❌ Store error: {ex.Message}");
}
}
finally
{
_storeFetching = false;
// Covers are fetched regardless of whether the catalog refresh
// succeeded — cached items deserve icons too, and the image
// endpoint often stays up while the listing 500s.
if (_storeItems.Count > 0) FetchStoreCoversAsync();
}
}
private void ApplyStoreFilter()
{
string q = StoreSearchBox.Text?.Trim() ?? "";
var view = string.IsNullOrEmpty(q)
? _storeItems
: _storeItems.Where(i => i.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.Id.Contains(q, StringComparison.OrdinalIgnoreCase)
|| i.Author.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
StoreListBox.ItemsSource = view;
}
private void StoreSearch_TextChanged(object? sender, TextChangedEventArgs e) => ApplyStoreFilter();
// Downloads carry no client timeout — 60MB+ streams would trip the
// 20s catalog limit; we bound them with our own cancellation instead.
@@ -1917,102 +1713,5 @@ namespace PS5Upload
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
return h;
}
private bool _storeInstalling;
// pkg-zone only speaks HTTPS and the console can't fetch that itself —
// the PC downloads the PKG once (disk cache for re-installs), then
// serves it over plain LAN HTTP through TryServePkgAsync.
private async Task<bool> DownloadStorePkgAsync(string id, string name, string destPath)
{
for (int attempt = 0; attempt < 4; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
using var resp = await _storeDl.GetAsync(
$"https://pkg-zone.com/download/ps5/{id}/latest",
HttpCompletionOption.ResponseHeadersRead, cts.Token);
resp.EnsureSuccessStatusCode();
long total = resp.Content.Headers.ContentLength ?? 0;
await using var src = await resp.Content.ReadAsStreamAsync(cts.Token);
await using var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20);
var buf = new byte[512 * 1024];
long done = 0; int rd; int lastBucket = -1;
while ((rd = await src.ReadAsync(buf, cts.Token)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, rd), cts.Token);
done += rd;
if (total > 0)
{
int bucket = (int)(done * 10 / total); // log every ~10%
if (bucket != lastBucket)
{
lastBucket = bucket;
Log($"📥 {name}: {done / 1048576}/{total / 1048576} MB");
StoreStatusText.Text = $"Downloading {name} — {done / 1048576}/{total / 1048576} MB";
}
}
}
if (total > 0 && done < total) throw new IOException($"short read {done}/{total}");
// PKG sanity: Sony packages start with 0x7F 'CNT'.
await using (var chk = new FileStream(destPath, FileMode.Open, FileAccess.Read))
{
var magic = new byte[4];
if (await chk.ReadAsync(magic) < 4 || magic[0] != 0x7F || magic[1] != 'C')
throw new IOException("downloaded file is not a PKG");
}
return true;
}
catch (Exception ex)
{
try { File.Delete(destPath); } catch { }
Log($"⚠️ Download failed ({ex.Message}){(attempt < 3 ? $" — retry {attempt + 2}/4" : "")}");
if (attempt < 3) await Task.Delay(1500);
}
}
return false;
}
private async void StoreInstall_Click(object? sender, RoutedEventArgs e)
{
if (_storeInstalling) return; // one install at a time
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string? id = (sender as Control)?.Tag as string;
if (string.IsNullOrEmpty(id)) return;
var item = _storeItems.FirstOrDefault(i => i.Id == id);
string name = item?.Name ?? id;
if (!await ShowConfirmAsync($"Install \"{name}\" ({id}) on the PS5?")) return;
_storeInstalling = true;
try
{
string dir = Path.Combine(AppContext.BaseDirectory, "pkgzone_pkgs");
Directory.CreateDirectory(dir);
string local = Path.Combine(dir, id + ".pkg");
if (!File.Exists(local) || new FileInfo(local).Length < 1024)
{
if (!await DownloadStorePkgAsync(id, name, local))
{
StoreStatusText.Text = "download failed";
return;
}
Log($"✅ Downloaded {name} ({new FileInfo(local).Length / 1048576} MB)");
}
else Log($"� Using cached {name} ({new FileInfo(local).Length / 1048576} MB)");
StoreStatusText.Text = $"Installing {name}...";
string? url = await TryServePkgAsync(local);
if (url == null) { Log("❌ Could not start the PKG stream server"); return; }
var (success, result) = await _protocol.InstallPkgAsync(url);
Log(success
? $"✅ Install accepted: {name} — check the PS5 home screen"
: $"❌ Install failed: {result}");
if (success) _ = PollPkgInstallStatus();
StoreStatusText.Text = "";
}
catch (Exception ex) { Log($"❌ Install error: {ex.Message}"); }
finally { _storeInstalling = false; }
}
}
}
+37 -7
View File
@@ -24,15 +24,26 @@ namespace PS5Upload
private void TrophySetSortComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyTrophySetFilter();
private void TrophyUserComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) => ApplyTrophySetFilter();
private void ApplyTrophySetFilter()
{
if (TrophySetsListBox == null || TrophySetSearchBox == null || TrophySetSortComboBox == null) return;
var prevNpwr = (TrophySetsListBox.SelectedItem as PS5TrophySet)?.NpCommunicationId;
var prevUid = (TrophySetsListBox.SelectedItem as PS5TrophySet)?.UserId;
string? filterUser = null;
if (TrophyUserComboBox?.SelectedItem is string sel && sel.StartsWith("Profile "))
filterUser = sel.Substring(8);
var q = TrophySetSearchBox.Text?.Trim() ?? "";
IEnumerable<PS5TrophySet> view = string.IsNullOrEmpty(q)
? _trophySets
: _trophySets.Where(s =>
IEnumerable<PS5TrophySet> view = _trophySets;
if (filterUser != null)
view = view.Where(s => s.UserId == filterUser);
if (!string.IsNullOrEmpty(q))
view = view.Where(s =>
s.GameName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.NpCommunicationId.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.TitleId.Contains(q, StringComparison.OrdinalIgnoreCase));
@@ -54,7 +65,8 @@ namespace PS5Upload
: $"({list.Count}/{_trophySets.Count} sets)";
if (prevNpwr != null)
TrophySetsListBox.SelectedItem =
list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr);
list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr && s.UserId == prevUid)
?? list.FirstOrDefault(s => s.NpCommunicationId == prevNpwr);
}
private bool _trophyLoading;
@@ -100,9 +112,27 @@ namespace PS5Upload
}
_trophySets = sets;
// ApplyTrophySetFilter rebuilds the view and restores the set the
// user was working on — without this the next Unlock click hits
// a null selection and silently no-ops.
var users = sets.Select(s => s.UserId)
.Where(u => !string.IsNullOrEmpty(u))
.Distinct()
.OrderBy(u => u)
.ToList();
await Dispatcher.UIThread.InvokeAsync(() =>
{
var prevSel = TrophyUserComboBox.SelectedItem as string;
var items = new List<string>();
if (users.Count > 1)
items.Add("All Profiles");
foreach (var u in users)
items.Add($"Profile {u}");
if (items.Count == 0)
items.Add("No profiles");
TrophyUserComboBox.ItemsSource = items;
int idx = prevSel != null ? items.IndexOf(prevSel) : -1;
TrophyUserComboBox.SelectedIndex = idx >= 0 ? idx : 0;
});
await Dispatcher.UIThread.InvokeAsync(() => ApplyTrophySetFilter());
Log($"🏆 {sets.Count} trophy sets, {sets.Sum(s => s.TotalCount)} trophies total");
+4 -53
View File
@@ -461,58 +461,6 @@
</Grid>
</TabItem>
<!-- Homebrew Store (PKG-Zone catalog) -->
<TabItem Header="🛒 Homebrew Store">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#252526" CornerRadius="4" Padding="12" Margin="0,0,0,15">
<WrapPanel>
<TextBlock Text="🛒 PKG-Zone" FontSize="16" FontWeight="Bold" Foreground="#007ACC" VerticalAlignment="Center" Margin="0,0,15,0"/>
<Button Content="🔄 Refresh Catalog" Click="StoreRefresh_Click" Padding="10,5" Margin="0,0,8,0"/>
<TextBox x:Name="StoreSearchBox" PlaceholderText="Filter..." Width="200" Margin="0,0,8,0" TextChanged="StoreSearch_TextChanged"/>
<TextBlock x:Name="StoreStatusText" Text="" Foreground="#999999" VerticalAlignment="Center"/>
</WrapPanel>
</Border>
<ListBox Grid.Row="1" x:Name="StoreListBox" Background="#1E1E1E" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="6" Padding="12" Margin="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0">
<Border Background="#3E3E42" CornerRadius="8">
<TextBlock Text="📦" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="8" ClipToBounds="True">
<Image Source="{Binding Cover}" Stretch="UniformToFill"/>
</Border>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="15" FontWeight="Bold"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="{Binding Id}" FontSize="12" Foreground="#007ACC" FontWeight="SemiBold"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding Version}" FontSize="12" Foreground="#FFC107"/>
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding Author}" FontSize="12" Foreground="#9CDCFE"/>
</StackPanel>
</StackPanel>
<Button Grid.Column="2" Content="📥 Install" Click="StoreInstall_Click" Tag="{Binding Id}" Padding="12,6" Background="#28A745" VerticalAlignment="Center"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- Prospero Store Site (homebrew.page native PS5 catalog) -->
<TabItem Header="🌐 Prospero Store Site">
<Grid>
@@ -614,9 +562,11 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TrophySetSearchBox" PlaceholderText="🔍 Search games..." TextChanged="TrophySetSearchBox_TextChanged" Background="#1E1E1E" BorderBrush="#3E3E42" VerticalContentAlignment="Center"/>
<ComboBox Grid.Column="1" x:Name="TrophySetSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="TrophySetSortComboBox_SelectionChanged" VerticalContentAlignment="Center">
<ComboBox Grid.Column="1" x:Name="TrophyUserComboBox" MinWidth="160" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="TrophyUserComboBox_SelectionChanged" VerticalContentAlignment="Center"/>
<ComboBox Grid.Column="2" x:Name="TrophySetSortComboBox" Width="150" Margin="8,0,0,0" SelectedIndex="0" SelectionChanged="TrophySetSortComboBox_SelectionChanged" VerticalContentAlignment="Center">
<ComboBoxItem>Name A→Z</ComboBoxItem>
<ComboBoxItem>Name Z→A</ComboBoxItem>
<ComboBoxItem>Progress ↑</ComboBoxItem>
@@ -649,6 +599,7 @@
<TextBlock Text=" • " Foreground="#666666"/>
<TextBlock Text="{Binding ProgressDisplay}" FontSize="11" Foreground="#9CDCFE"/>
</StackPanel>
<TextBlock Text="{Binding UserIdDisplay}" FontSize="10" Foreground="#888888" Margin="0,2,0,0"/>
</StackPanel>
</Grid>
</Border>
+1
View File
@@ -25,6 +25,7 @@ namespace PS5Upload
private ObservableCollection<PS5FileItem> _ps5Files = new();
private ObservableCollection<PS5FileItem> _ps5FilesFiltered = new();
private string _currentPS5Path = "/data";
private bool _isDeleting;
private CancellationTokenSource? _uploadCancellation;
private string _ps5IpAddress = "";
private string _searchQuery = "";
+1 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>PS5Suite</AssemblyName>
<RootNamespace>PS5Upload</RootNamespace>
<ApplicationIcon>ps5_logo.ico</ApplicationIcon>
<Version>7.3.0</Version>
<Version>7.4.0</Version>
<Authors>Manos</Authors>
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
<Copyright>Copyright © 2026 Manos</Copyright>
+20 -26
View File
@@ -1952,52 +1952,45 @@ namespace PS5Upload
byte[] pathBytes = Encoding.UTF8.GetBytes(path + "\0");
await SendCommandAsync(Command.DeleteDir, pathBytes);
// Payload does NOT send initial OK - goes straight to progress messages
// Keep reading until we get the final OK/ERROR response
bool deletionComplete = false;
bool success = false;
bool done = false;
try
{
while (!deletionComplete)
while (!done)
{
var (progressResponse, progressData) = await ReceiveResponseAsync();
if (progressResponse == Response.Progress)
var (resp, respData) = await ReceiveResponseAsync();
if (resp == Response.Progress)
{
string message = Encoding.UTF8.GetString(progressData).TrimEnd('\0');
string message = Encoding.UTF8.GetString(respData).TrimEnd('\0');
OnProgressMessage?.Invoke(message);
}
else if (progressResponse == Response.Ok)
else if (resp == Response.Ok)
{
// Final OK response received - deletion complete
OnProgressMessage?.Invoke("🔚 Received final OK - deletion complete");
deletionComplete = true;
success = true;
done = true;
}
else if (progressResponse == Response.Error)
else if (resp == Response.Error)
{
// Error response received
OnProgressMessage?.Invoke("❌ Received error response");
deletionComplete = true;
done = true;
}
else
{
// Unexpected response - log it
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {progressResponse}");
deletionComplete = true;
OnProgressMessage?.Invoke($"⚠️ Unexpected response: {resp}");
done = true;
}
}
}
catch (Exception ex)
{
// Connection closed - log it
OnProgressMessage?.Invoke($"⚠️ Connection closed: {ex.Message}");
}
// Give server a moment to fully close the deletion thread
// CRITICAL: Increased delay to prevent race condition with next delete
await Task.Delay(500);
return true;
return success;
}
finally
{
@@ -3789,6 +3782,7 @@ namespace PS5Upload
? Trophies.Count(t => t.IsUnlocked)
: EarnedFallback >= 0 ? EarnedFallback : Trophies.Count(t => t.IsUnlocked);
public int TotalCount => Trophies.Count;
public string UserIdDisplay => string.IsNullOrEmpty(UserId) ? "" : $"👤 {UserId}";
public string ProgressDisplay => TotalCount == 0
? "—"
: (StateKnown || EarnedFallback >= 0)
@@ -3834,8 +3828,8 @@ namespace PS5Upload
: "Locked";
public string HiddenDisplay => Hidden ? "🙈" : "";
public string GroupDisplay => string.IsNullOrEmpty(GroupId) || GroupId == "default" ? "Base" : GroupId;
public bool CanUnlock => StateKnown && !IsUnlocked;
public bool CanLock => StateKnown && IsUnlocked;
public bool CanUnlock => StateKnown && !IsUnlocked && Grade != "P";
public bool CanLock => StateKnown && IsUnlocked && Grade != "P";
private Avalonia.Media.IImage? _icon;
public Avalonia.Media.IImage? Icon
+363 -123
View File
@@ -153,6 +153,9 @@ typedef struct {
} pkg_install_info_t;
// Meta info input structure
// NOTE: the daemon reads 8 bytes PAST icon_url (offset 0x30). If nonzero it
// takes the patch path and refuses plain installs (measured on 13.60).
// Keep the tail in the struct and zero it — relying on adjacent .bss is luck.
typedef struct {
const char *uri; // Package path or URL
const char *ex_uri; // Extra URI (usually empty)
@@ -160,6 +163,8 @@ typedef struct {
const char *content_id;
const char *content_name; // Display name during install
const char *icon_url;
uint64_t plain_install; // must be 0 for a plain install
uint64_t reserved[7]; // keep zero — future fw reads further
} pkg_meta_info_t;
// PlayGo info structure
@@ -1106,6 +1111,22 @@ static void *pkg_install_worker(void *arg) {
probe_cid, (int)probe_is_app, (unsigned)prc);
}
// Signature sniff: \x7fFIH at 0 with byte 0x05 = 0x80 means a
// retail/submitted image; 0x00 is a debug (fPKG) image. Only used to
// explain a refusal — never gates the install attempt itself.
int pkg_signed_retail = 0;
if (pkg_path[0] == '/') {
int hfd = open(pkg_path, O_RDONLY);
if (hfd >= 0) {
unsigned char hdr[8] = {0};
if (read(hfd, hdr, sizeof(hdr)) == sizeof(hdr) &&
hdr[0] == 0x7F && hdr[1] == 'F' && hdr[2] == 'I' && hdr[3] == 'H' &&
(hdr[5] & 0x80))
pkg_signed_retail = 1;
close(hfd);
}
}
// Display name in the PS5 Downloads notification = the package's own
// filename (basename, .pkg stripped) instead of a generic suite label.
// URL sources carry %XX escapes and a query string — decode and trim.
@@ -1175,12 +1196,26 @@ static void *pkg_install_worker(void *arg) {
}
if (rc != 0) {
pkg_set_text("install refused (first=0x%08X last=0x%08X)", first_rc, (unsigned)rc);
// A retail-signed image (FIH + signed byte 0x80) is rejected by the
// stock installer daemon - installing one needs the HEN-side patches
// etaHEN carries; bare k-stuff has none. AppInstallPkg is NOT a
// viable fallback: on 13.60 it returns APP_BROKEN, and even when it
// returns 0 it leaves a dead tile (appmeta written, /user/app never
// populated). Report honestly instead of trying it.
if (pkg_signed_retail)
pkg_set_text("retail-signed pkg - console installer accepts "
"debug-signed (fPKG) images only under k-stuff");
else
pkg_set_text("install refused (first=0x%08X last=0x%08X)", first_rc, (unsigned)rc);
pkg_finish(0, (unsigned)rc, NULL);
free(pkg_path);
return NULL;
}
if (!pkg_info.content_id[0] && probe_cid[0]) {
memcpy(pkg_info.content_id, probe_cid, PKG_CONTENTID_SIZE);
}
pthread_mutex_lock(&g_pkg_state_lock);
memcpy(g_pkg_state.content_id, pkg_info.content_id, PKG_CONTENTID_SIZE);
memcpy(g_active_pkg_content_id, pkg_info.content_id, PKG_CONTENTID_SIZE);
@@ -2140,7 +2175,7 @@ void release_file_mutex(const char *path) {
// payload loader (elfldr raw-socket protocol — identical to what the PC
// client does during deploy). The spawned instance sweeps every stale
// "payload*" process and binds the port — no explicit handoff needed.
#define SUITE_VERSION "7.3.0"
#define SUITE_VERSION "7.4.0"
#define SUITE_DIR "/data/ps5suite"
#define UPDATE_STAGE_PATH "/data/ps5suite/update.elf"
#define UPDATE_MARKER "/data/ps5suite/update.marker"
@@ -2162,6 +2197,7 @@ void release_file_mutex(const char *path) {
#define CMD_NOTIFY 0x7C // "text" → PS5 UI notification
#define CMD_PAD_ACTION 0x7D // "lightbar|r,g,b" | "vibrate|l,s"
#define CMD_ICC_CONTROL 0x7E // "led|n" "buzzer|n" "buzzervol|n" "buzzermute|n" "ledcolor|b,w,o"
#define CMD_PFS_TEST 0x7F // "pkgpath" → diagnostic pfs mount probe
// Trophy viewer (NpTrophy V2) — read-only for now: lists every registered
// trophy set and ships the raw tropconf/tropmeta JSON + TRPTITLE.DAT so the
@@ -4610,50 +4646,43 @@ int mkdir_recursive(const char *path) {
return 0;
}
// Global deletion progress counter (protected by g_progress_mutex)
static int g_delete_count = 0;
static int g_total_files = 0;
static time_t g_last_notify = 0;
// Per-thread deletion context — avoids global counter races when a
// previous detached delete thread is still running after reconnect.
typedef struct {
int delete_count;
int total_files;
int scan_count;
time_t last_notify;
time_t last_scan_notify;
int client_sock;
} delete_ctx_t;
// Global scan counter for progress updates (reset before each scan)
static int g_scan_count = 0;
static time_t g_last_scan_notify = 0;
// Count files in directory recursively with progress updates
int count_files_recursive(const char *path) {
static int count_files_ctx(const char *path, delete_ctx_t *ctx) {
DIR *dir = opendir(path);
if (!dir) {
return 0;
}
if (!dir) return 0;
int count = 0;
struct dirent *entry;
char child[MAX_PATH];
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
}
snprintf(child, sizeof(child), "%s/%s", path, entry->d_name);
struct stat st;
if (stat(child, &st) != 0) {
continue;
}
if (stat(child, &st) != 0) continue;
if (S_ISDIR(st.st_mode)) {
count++; // Count the directory itself
count += count_files_recursive(child); // Count its contents
count += count_files_ctx(child, ctx);
} else {
count++;
g_scan_count++;
// Send progress every 500 files or every 3 seconds during counting
ctx->scan_count++;
time_t now = time(NULL);
if (g_scan_count % 500 == 0 || (now - g_last_scan_notify) >= 3) {
if (ctx->scan_count % 500 == 0 || (now - ctx->last_scan_notify) >= 3) {
char msg[256];
snprintf(msg, sizeof(msg), "📊 Scanning... found %d files so far", g_scan_count);
snprintf(msg, sizeof(msg), "📊 Scanning... found %d files so far", ctx->scan_count);
send_progress_message(msg);
g_last_scan_notify = now;
ctx->last_scan_notify = now;
}
}
}
@@ -4714,40 +4743,33 @@ static void send_resp_locked(int sock, uint8_t resp, const void *data, uint32_t
pthread_mutex_unlock(&g_progress_mutex);
}
// Recursive directory deletion with progress reporting
int rmdir_recursive(const char *path) {
// Recursive directory deletion with per-thread progress reporting
static int rmdir_recursive_ctx(const char *path, delete_ctx_t *ctx) {
DIR *dir = opendir(path);
if (!dir) {
return -1;
}
if (!dir) return -1;
struct dirent *entry;
char child[MAX_PATH];
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
}
snprintf(child, sizeof(child), "%s/%s", path, entry->d_name);
struct stat st;
if (stat(child, &st) != 0) {
continue;
}
if (stat(child, &st) != 0) continue;
if (S_ISDIR(st.st_mode)) {
rmdir_recursive(child);
rmdir_recursive_ctx(child, ctx);
} else {
unlink_guarded(child); // never let a busy vnode hang the delete
g_delete_count++;
// Send progress every 50 files or every 2 seconds
unlink_guarded(child);
ctx->delete_count++;
time_t now = time(NULL);
if (g_delete_count % 50 == 0 || (now - g_last_notify) >= 2) {
int percentage = (g_total_files > 0) ? (g_delete_count * 100 / g_total_files) : 0;
if (ctx->delete_count % 50 == 0 || (now - ctx->last_notify) >= 2) {
int pct = (ctx->total_files > 0) ? (ctx->delete_count * 100 / ctx->total_files) : 0;
char msg[256];
snprintf(msg, sizeof(msg), "🗑️ Deleting... %d/%d files (%d%%)",
g_delete_count, g_total_files, percentage);
snprintf(msg, sizeof(msg), "🗑️ Deleting... %d/%d files (%d%%)",
ctx->delete_count, ctx->total_files, pct);
send_progress_message(msg);
g_last_notify = now;
ctx->last_notify = now;
}
}
}
@@ -4755,6 +4777,27 @@ int rmdir_recursive(const char *path) {
return rmdir_guarded(path);
}
// Plain recursive delete (no progress) — used by unmount, cleanup, etc.
int rmdir_recursive(const char *path) {
DIR *dir = opendir(path);
if (!dir) return -1;
struct dirent *entry;
char child[MAX_PATH];
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
snprintf(child, sizeof(child), "%s/%s", path, entry->d_name);
struct stat st;
if (stat(child, &st) != 0) continue;
if (S_ISDIR(st.st_mode))
rmdir_recursive(child);
else
unlink_guarded(child);
}
closedir(dir);
return rmdir_guarded(path);
}
// Handle PING
void handle_ping(client_session_t *session) {
send_ok(session->sock, "PONG");
@@ -5222,83 +5265,57 @@ void handle_delete_file(client_session_t *session, const char *path) {
typedef struct {
char path[MAX_PATH];
int client_sock;
delete_ctx_t ctx;
} delete_thread_data_t;
// Background deletion thread
// Background deletion thread — all counters are per-thread via ctx
void* delete_thread_func(void* arg) {
delete_thread_data_t* data = (delete_thread_data_t*)arg;
// Reset ALL progress counters
g_delete_count = 0;
g_scan_count = 0;
g_last_notify = time(NULL);
g_last_scan_notify = time(NULL);
memset(&data->ctx, 0, sizeof(data->ctx));
data->ctx.client_sock = data->client_sock;
data->ctx.last_notify = time(NULL);
data->ctx.last_scan_notify = time(NULL);
progress_socket_set(data->client_sock);
// Count total files first
char start_msg[256];
snprintf(start_msg, sizeof(start_msg), "📊 Scanning folder: %s", data->path);
send_progress_message(start_msg);
g_total_files = count_files_recursive(data->path);
if (g_total_files == 0) {
char empty_msg[256];
snprintf(empty_msg, sizeof(empty_msg), "⚠️ Folder is empty or already deleted");
send_progress_message(empty_msg);
// Still try to delete the empty folder itself
data->ctx.total_files = count_files_ctx(data->path, &data->ctx);
if (data->ctx.total_files == 0) {
send_progress_message("⚠️ Folder is empty or already deleted");
rmdir(data->path);
// Send final OK response even for empty folders
send_resp_locked(g_client_sock, RESP_OK, NULL, 0);
send_resp_locked(data->client_sock, RESP_OK, NULL, 0);
progress_socket_clear(data->client_sock);
free(data);
return NULL;
}
char count_msg[256];
snprintf(count_msg, sizeof(count_msg), "📊 Total: %d files to delete", g_total_files);
snprintf(count_msg, sizeof(count_msg), "📊 Total: %d files to delete", data->ctx.total_files);
send_progress_message(count_msg);
// Start deletion
char del_msg[256];
snprintf(del_msg, sizeof(del_msg), "🗑️ Starting deletion...");
send_progress_message(del_msg);
// Perform deletion in background
int result = rmdir_recursive(data->path);
// Send completion message
send_progress_message("🗑️ Starting deletion...");
int result = rmdir_recursive_ctx(data->path, &data->ctx);
struct timespec ts = { 0, 200000000 };
if (result == 0) {
char msg[256];
snprintf(msg, sizeof(msg), "✅ Deleted %d files (100%%)", g_delete_count);
snprintf(msg, sizeof(msg), "✅ Deleted %d files (100%%)", data->ctx.delete_count);
send_progress_message(msg);
send_notification(msg);
// Send final OK response to signal completion
send_resp_locked(g_client_sock, RESP_OK, NULL, 0);
// Wait for data to be sent before cleanup
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = 200000000; // 200ms
send_resp_locked(data->client_sock, RESP_OK, NULL, 0);
nanosleep(&ts, NULL);
} else {
char msg[256];
snprintf(msg, sizeof(msg), "❌ Failed to delete folder (%d files removed)", g_delete_count);
snprintf(msg, sizeof(msg), "❌ Failed to delete folder (%d files removed)", data->ctx.delete_count);
send_progress_message(msg);
// Send error response
send_resp_locked(g_client_sock, RESP_ERROR, NULL, 0);
// Wait for data to be sent before cleanup
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = 200000000; // 200ms
send_resp_locked(data->client_sock, RESP_ERROR, NULL, 0);
nanosleep(&ts, NULL);
}
progress_socket_clear(data->client_sock);
free(data);
return NULL;
@@ -7195,6 +7212,10 @@ static int (*g_sceFsInitMountSaveDataOpt)(save_mount_opt_t*) = NULL;
static int (*g_sceFsInitUmountSaveDataOpt)(save_mount_opt_t*) = NULL;
static int (*g_sceFsMountSaveData)(save_mount_opt_t*, const char*, const char*, void*) = NULL;
static int (*g_sceFsUmountSaveData)(save_mount_opt_t*, const char*, void*, int) = NULL;
static int (*g_sceFsInitMountGamePkgOpt)(save_mount_opt_t*) = NULL;
static int (*g_sceFsMountGamePkg)(save_mount_opt_t*, const char*, const char*, void*) = NULL;
static int (*g_sceFsInitMountDownloadDataOpt)(save_mount_opt_t*) = NULL;
static int (*g_sceFsMountDownloadData)(save_mount_opt_t*, const char*, const char*, void*) = NULL;
static int g_savefs_resolved = 0;
static pthread_mutex_t g_save_mtx = PTHREAD_MUTEX_INITIALIZER;
@@ -7229,6 +7250,10 @@ static int resolve_savefs(void) {
g_sceFsInitUmountSaveDataOpt = (int (*)(save_mount_opt_t*))dlsym(h, "sceFsInitUmountSaveDataOpt");
g_sceFsMountSaveData = (int (*)(save_mount_opt_t*, const char*, const char*, void*))dlsym(h, "sceFsMountSaveData");
g_sceFsUmountSaveData = (int (*)(save_mount_opt_t*, const char*, void*, int))dlsym(h, "sceFsUmountSaveData");
g_sceFsInitMountGamePkgOpt = (int (*)(save_mount_opt_t*))dlsym(h, "sceFsInitMountGamePkgOpt");
g_sceFsMountGamePkg = (int (*)(save_mount_opt_t*, const char*, const char*, void*))dlsym(h, "sceFsMountGamePkg");
g_sceFsMountDownloadData = (int (*)(save_mount_opt_t*, const char*, const char*, void*))dlsym(h, "sceFsMountDownloadData");
g_sceFsInitMountDownloadDataOpt = (int (*)(save_mount_opt_t*))dlsym(h, "sceFsInitMountDownloadDataOpt");
}
// Fallback: module may already be loaded in our process under a different
@@ -8766,6 +8791,203 @@ void handle_usb_list(client_session_t *session) {
free(out);
}
// ============================================================================
// PFS TEST — diagnostics: try to kernel-mount a .pkg (FIH) as a "pfs" image.
// The kernel's pfs driver unwraps the embedded EKC; on a retail console a
// retail-signed image should decrypt. If this mounts, a retail pkg can be
// extracted read-only and repacked — no installer changes needed.
// ============================================================================
#define PFS_TEST_MNT "/data/pkgmnt0"
// FreeBSD md(4): attach a file as vnode-backed block device via /dev/mdctl,
// then the pfs driver can consume it (it demands a block dev — ENOTBLK on files).
// PS5 ABI (from SceShellCore disasm): ioctl 0xC1C06D00, 0x1c0-byte struct,
// version=0, type=2 (MD_VNODE), sectorsize=0x200, options=0x04 (AUTOUNIT).
struct ps5_md_ioctl {
unsigned md_version; // 0x00
unsigned md_unit; // 0x04
int md_type; // 0x08 (2 = vnode)
char *md_file; // 0x10
long long md_mediasize; // 0x18
unsigned md_sectorsize; // 0x20
unsigned md_options; // 0x24
uint64_t md_base; // 0x28
int md_fwheads; // 0x30
int md_fwsectors; // 0x34
int md_pad[97]; // 0x38..0x1bc
};
#define PS5_MDIOCATTACH 0xC1C06D00
void handle_pfs_test(client_session_t *session, const char *pkgpath) {
char out[4096]; int off = 0;
struct stat st;
if (stat(pkgpath, &st) != 0) {
snprintf(out, sizeof(out), "no such pkg: %s (errno=%d)", pkgpath, errno);
send_error(session->sock, out); return;
}
rmdir(PFS_TEST_MNT);
if (mkdir(PFS_TEST_MNT, 0777) != 0 && errno != EEXIST) {
snprintf(out, sizeof(out), "mkdir %s failed errno=%d", PFS_TEST_MNT, errno);
send_error(session->sock, out); return;
}
// --- FIH header: image region = [q@0x10, q@0x20) ---
uint64_t fih[0x10] = {0};
{
int fd = open(pkgpath, O_RDONLY);
if (fd >= 0) { pread(fd, fih, sizeof(fih), 0); close(fd); }
}
uint64_t img_off = fih[2]; // 0x10 → image start
uint64_t img_end = fih[3]; // 0x18 → image end (includes plaintext superblock)
off += snprintf(out + off, sizeof(out)-off,
"FIH: img_off=%#llx img_end=%#llx signed=%d\n",
(unsigned long long)img_off, (unsigned long long)img_end,
fih[0] >> 40 == 0 ? 0 : (int)((fih[0] >> 40) & 0xff));
if (img_off == 0 || img_end <= img_off || img_end > (uint64_t)st.st_size) {
off += snprintf(out + off, sizeof(out)-off, "bad FIH fields\n");
rmdir(PFS_TEST_MNT); send_response(session->sock, RESP_DATA, out, off); return;
}
// --- extract image region to a standalone file ---
const char *img = "/data/pkg_img.pfs";
{
unlink(img);
int in = open(pkgpath, O_RDONLY), o = open(img, O_WRONLY|O_CREAT|O_TRUNC, 0644);
char *buf = malloc(1 << 20);
uint64_t left = img_end - img_off, pos = img_off;
int ok = (in >= 0 && o >= 0 && buf);
while (ok && left) {
size_t want = left < (1 << 20) ? (size_t)left : (1 << 20);
ssize_t n = pread(in, buf, want, (off_t)pos);
if (n <= 0) { ok = 0; break; }
if (write(o, buf, (size_t)n) != n) { ok = 0; break; }
pos += (uint64_t)n; left -= (uint64_t)n;
}
if (in >= 0) close(in); if (o >= 0) close(o); free(buf);
off += snprintf(out + off, sizeof(out)-off,
"extract image -> %s (%s)\n", img, ok ? "ok" : "FAILED");
if (!ok) { rmdir(PFS_TEST_MNT); send_response(session->sock, RESP_DATA, out, off); return; }
}
// --- key blob: the pkg's plaintext PFS superblock sits at FIH q@0x20,
// EKC at superblock+0x800 (same as save images) ---
uint64_t sb_off = fih[4]; // 0x20 → superblock offset
static uint8_t keybuf[SAVE_KEYBUF_LEN];
memset(keybuf, 0, sizeof(keybuf));
if (save_pread60(pkgpath, (off_t)sb_off + 0x800, keybuf) != 0) {
off += snprintf(out + off, sizeof(out)-off, "no key blob at sb+0x800\n");
unlink(img); rmdir(PFS_TEST_MNT); send_response(session->sock, RESP_DATA, out, off); return;
}
off += snprintf(out + off, sizeof(out)-off, "ekc magic: %.8s\n", keybuf);
errno = 0;
int kr = save_decrypt_key(keybuf);
off += snprintf(out + off, sizeof(out)-off,
"pfsmgr key decrypt -> rc=%d errno=%d\n", kr, errno);
if (kr != 0) {
unlink(img); rmdir(PFS_TEST_MNT); send_response(session->sock, RESP_DATA, out, off); return;
}
// --- superblock diff vs a real save image ---
{
const char *saveimg = "/user/home/1b68ce95/savedata_prospero/PPSA10737/sdimg_LNEESaveSlot1";
uint8_t sblk[0x100], pblk[0x100];
int sfd = open(saveimg, O_RDONLY);
int pfd = open(pkgpath, O_RDONLY);
memset(sblk, 0, sizeof(sblk)); memset(pblk, 0, sizeof(pblk));
if (sfd >= 0) { pread(sfd, sblk, sizeof(sblk), 0); close(sfd); }
if (pfd >= 0) { pread(pfd, pblk, sizeof(pblk), (off_t)sb_off); close(pfd); }
off += snprintf(out + off, sizeof(out)-off, "save_sblk: ");
for (int i = 0; i < 64; i++) off += snprintf(out + off, sizeof(out)-off, "%02x", sblk[i]);
off += snprintf(out + off, sizeof(out)-off, "\npkg_sblk: ");
for (int i = 0; i < 64; i++) off += snprintf(out + off, sizeof(out)-off, "%02x", pblk[i]);
off += snprintf(out + off, sizeof(out)-off, "\ndiff<0x100:");
for (int i = 0; i < 0x100 && off < 3800; i++)
if (sblk[i] != pblk[i]) off += snprintf(out + off, sizeof(out)-off, " %02x", i);
off += snprintf(out + off, sizeof(out)-off, "\nkey: ");
for (int i = 0; i < 32; i++) off += snprintf(out + off, sizeof(out)-off, "%02x", keybuf[0x60 + i]);
off += snprintf(out + off, sizeof(out)-off, "\n");
}
// --- mount the extracted pfs image with the decrypted key ---
if (!resolve_savefs()) {
off += snprintf(out + off, sizeof(out)-off, "savefs syms unresolved\n");
unlink(img); rmdir(PFS_TEST_MNT); send_response(session->sock, RESP_DATA, out, off); return;
}
memset(&g_save_mopt, 0, sizeof(g_save_mopt));
g_sceFsInitMountSaveDataOpt(&g_save_mopt);
g_save_mopt.budgetid = "system";
signal(SIGPIPE, SIG_DFL);
int rc = wdg_fn_call((void*)g_sceFsMountSaveData, &g_save_mopt,
(void*)img, (void*)PFS_TEST_MNT, keybuf + 0x60, 30000);
off += snprintf(out + off, sizeof(out)-off, "sceFsMountSaveData -> rc=%d\n", rc);
// Try the game-pkg mount API — that's the retail path. The opt struct is
// bigger than the save one — use a fat zeroed buffer and let Init fill it.
if (rc < 0) {
static uint8_t gopt[0x800];
off += snprintf(out + off, sizeof(out)-off, "GamePkg syms: init=%p mnt=%p\n",
g_sceFsInitMountGamePkgOpt, g_sceFsMountGamePkg);
if (g_sceFsInitMountGamePkgOpt && g_sceFsMountGamePkg) {
memset(gopt, 0, sizeof(gopt));
g_sceFsInitMountGamePkgOpt((save_mount_opt_t*)gopt);
int r2 = wdg_fn_call((void*)g_sceFsMountGamePkg, gopt,
(void*)img, (void*)PFS_TEST_MNT, keybuf + 0x60, 30000);
off += snprintf(out + off, sizeof(out)-off, "sceFsMountGamePkg(img,key) -> rc=%d\n", r2);
if (r2 >= 0) rc = r2;
else {
memset(gopt, 0, sizeof(gopt));
g_sceFsInitMountGamePkgOpt((save_mount_opt_t*)gopt);
r2 = wdg_fn_call((void*)g_sceFsMountGamePkg, gopt,
(void*)pkgpath, (void*)PFS_TEST_MNT, keybuf + 0x60, 30000);
off += snprintf(out + off, sizeof(out)-off, "sceFsMountGamePkg(pkg,key) -> rc=%d\n", r2);
if (r2 >= 0) rc = r2;
}
}
// DownloadData mount — the download0.dat volume path
if (rc < 0 && g_sceFsInitMountDownloadDataOpt && g_sceFsMountDownloadData) {
memset(gopt, 0, sizeof(gopt));
g_sceFsInitMountDownloadDataOpt((save_mount_opt_t*)gopt);
int r2 = wdg_fn_call((void*)g_sceFsMountDownloadData, gopt,
(void*)pkgpath, (void*)PFS_TEST_MNT, keybuf + 0x60, 30000);
off += snprintf(out + off, sizeof(out)-off, "sceFsMountDownloadData(pkg,key) -> rc=%d\n", r2);
if (r2 >= 0) rc = r2;
}
}
const char *used = NULL;
if (rc >= 0) used = "pfs(key)";
else {
// fallback: md-attach the image + raw pfs nmount (no key support but cheap)
int mdfd = open("/dev/mdctl", O_RDWR);
if (mdfd >= 0) {
static struct ps5_md_ioctl md;
memset(&md, 0, sizeof(md));
md.md_type = 2; md.md_file = (char *)img;
md.md_mediasize = (long long)(img_end - img_off);
md.md_sectorsize = 0x200; md.md_options = 0x04;
errno = 0;
int arc = ioctl(mdfd, PS5_MDIOCATTACH, &md);
off += snprintf(out + off, sizeof(out)-off,
"mdctl attach img -> rc=%d errno=%d unit=%u\n", arc, errno, md.md_unit);
close(mdfd);
}
}
if (used) {
DIR *d = opendir(PFS_TEST_MNT);
if (d) {
struct dirent *e; int cnt = 0;
while ((e = readdir(d)) && cnt++ < 32 && off < 3800)
off += snprintf(out + off, sizeof(out) - off, " %s%s\n",
e->d_name, e->d_type == 4 ? "/" : "");
closedir(d);
} else off += snprintf(out + off, sizeof(out) - off, "opendir errno=%d\n", errno);
unmount_guarded(PFS_TEST_MNT, 0);
}
rmdir(PFS_TEST_MNT);
send_response(session->sock, RESP_DATA, out, off);
}
// ============================================================================
// PAD INFO — DualSense status through libScePad. Resolved at runtime like the
// save-fs functions (the lib may not be mapped in a bare payload); the whole
@@ -12064,39 +12286,53 @@ void handle_trophy_list(client_session_t *session) {
ucp_extract_prefix(ucp_path, "tropmeta_", &mt, &mts, NULL, 0);
}
// Per-user unlock state (T2PD/TRPTITLE.DAT) — first uid that has it.
uint8_t *trp = NULL; size_t trp_sz = 0;
char uid[32] = "";
// Per-user unlock state — emit one set entry per uid that has
// TRPTITLE.DAT, so the client can show/filter by profile.
int emitted = 0;
DIR *home = opendir("/user/home");
if (home) {
struct dirent *he;
while ((he = readdir(home))) {
while ((he = readdir(home)) && count < 64) {
if (he->d_name[0] == '.') continue;
char tp[PATH_MAX];
snprintf(tp, sizeof(tp),
"/user/home/%s/trophy2/nobackup/data/%s/TRPTITLE.DAT",
he->d_name, npwr);
if (read_whole_file(tp, &trp, &trp_sz) == 0) {
snprintf(uid, sizeof(uid), "%s", he->d_name);
break;
}
uint8_t *trp = NULL; size_t trp_sz = 0;
if (read_whole_file(tp, &trp, &trp_sz) != 0) continue;
size_t need = 1 + strlen(npwr) + 1 + strlen(tid) + 1 + strlen(he->d_name)
+ 4 + cfs + 4 + mts + 4 + trp_sz;
if (off + need >= cap) { free(trp); break; }
buf[off++] = (uint8_t)strlen(npwr); memcpy(buf + off, npwr, strlen(npwr)); off += strlen(npwr);
buf[off++] = (uint8_t)strlen(tid); memcpy(buf + off, tid, strlen(tid)); off += strlen(tid);
buf[off++] = (uint8_t)strlen(he->d_name); memcpy(buf + off, he->d_name, strlen(he->d_name)); off += strlen(he->d_name);
uint32_t l;
l = (uint32_t)cfs; memcpy(buf + off, &l, 4); off += 4; memcpy(buf + off, cf, cfs); off += cfs;
l = (uint32_t)mts; memcpy(buf + off, &l, 4); off += 4; if (mt) { memcpy(buf + off, mt, mts); off += mts; }
l = (uint32_t)trp_sz; memcpy(buf + off, &l, 4); off += 4; memcpy(buf + off, trp, trp_sz); off += trp_sz;
free(trp);
count++;
emitted++;
}
closedir(home);
}
size_t need = 1 + strlen(npwr) + 1 + strlen(tid) + 1 + strlen(uid)
+ 4 + cfs + 4 + mts + 4 + trp_sz;
if (off + need >= cap) { free(cf); free(mt); free(trp); break; }
buf[off++] = (uint8_t)strlen(npwr); memcpy(buf + off, npwr, strlen(npwr)); off += strlen(npwr);
buf[off++] = (uint8_t)strlen(tid); memcpy(buf + off, tid, strlen(tid)); off += strlen(tid);
buf[off++] = (uint8_t)strlen(uid); memcpy(buf + off, uid, strlen(uid)); off += strlen(uid);
uint32_t l;
l = (uint32_t)cfs; memcpy(buf + off, &l, 4); off += 4; memcpy(buf + off, cf, cfs); off += cfs;
l = (uint32_t)mts; memcpy(buf + off, &l, 4); off += 4; if (mt) { memcpy(buf + off, mt, mts); off += mts; }
l = (uint32_t)trp_sz;memcpy(buf + off, &l, 4); off += 4; if (trp) { memcpy(buf + off, trp, trp_sz); off += trp_sz; }
free(cf); free(mt); free(trp);
count++;
if (emitted == 0 && count < 64) {
size_t need = 1 + strlen(npwr) + 1 + strlen(tid) + 1
+ 4 + cfs + 4 + mts + 4;
if (off + need < cap) {
buf[off++] = (uint8_t)strlen(npwr); memcpy(buf + off, npwr, strlen(npwr)); off += strlen(npwr);
buf[off++] = (uint8_t)strlen(tid); memcpy(buf + off, tid, strlen(tid)); off += strlen(tid);
buf[off++] = 0;
uint32_t l;
l = (uint32_t)cfs; memcpy(buf + off, &l, 4); off += 4; memcpy(buf + off, cf, cfs); off += cfs;
l = (uint32_t)mts; memcpy(buf + off, &l, 4); off += 4; if (mt) { memcpy(buf + off, mt, mts); off += mts; }
l = 0; memcpy(buf + off, &l, 4); off += 4;
count++;
}
}
free(cf); free(mt);
}
closedir(conf);
@@ -14979,6 +15215,10 @@ void *client_thread(void *arg) {
if (data) handle_icc_control(session, (const char *)data);
else send_error(session->sock, "Usage: led|n buzzer|n buzzervol|n buzzermute|n ledcolor|b,w,o");
break;
case CMD_PFS_TEST:
if (data) handle_pfs_test(session, (const char *)data);
else send_error(session->sock, "Usage: pkgpath");
break;
case CMD_LAUNCH_GAME:
if (data) {
handle_launch_game(session, (const char *)data);
Binary file not shown.