Compare commits

...
2 Commits
Author SHA1 Message Date
ManosandDevin 7d48ae610b v7.2.3: Android client, controller bridge, console LED, App Manager v2
- New Android app (Avalonia, shared protocol) with full desktop parity,
  hamburger navigation and signed APK — replaces the old android stub
- App Manager v2: real per-app info, per-process CPU%, suspend/resume/
  kill/coredump; serial-protocol lag fixed via ki_stat + appinfo cache
- DualSense state/info/lightbar via SceShellUI remote bridge
  (ptrace + kernel proc copyin/out); vibration removed (non-foreground)
- Console LED colors + effects via /dev/icc_indicator (3 channels,
  hardware-verified)
- Homebrew Store (pkg-zone catalog), saves/media, screenshots, shell,
  file search, memory tools, kernel log, PKG streaming installs
- Fix progress bar freeze + "0 active connections" counter (#7)
- Fix macOS ENOBUFS: chunked socket writes + socket buffer clamp (#6)
- Version unified to 7.2.3 across payload, desktop and Android

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-06 11:50:55 +03:00
Manos 5b65f0dd32 docs: etaHEN/Kstuff compatibility, clarify 280 MB/s is over Ethernet 2026-10-04 02:25:34 +03:00
83 changed files with 11222 additions and 301 deletions

No files matched your search

+5 -2
View File
@@ -40,7 +40,10 @@ payload/appdb_*.db
payload/port9113_killer.*
lib-prospero-pkg/test-stream/realbuild-out/
# legacy clients (WPF v5/v6, old MAUI app, empty stub template)
# legacy clients (WPF v5/v6, old MAUI app, empty stub template, old Android WIP)
client/
mobile/
client-android/
client-avalonia-android/
# dev scratch: homebrew pkg builds, extracted binaries — not for the repo
homebrew/
+53 -9
View File
@@ -1,8 +1,8 @@
# 🎮 PS5 Suite v7.1.5 — Complete PS5 Management Platform
# 🎮 PS5 Suite v7.2.3 — Complete PS5 Management Platform
**By Manos**
All-in-one management suite for jailbroken PS5 consoles. One app — on Windows, Linux or macOS (Android build in progress) — that does everything: high-speed file transfers, game mount/launch, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, memory tools, fan control, kernel log and a remote shell.
All-in-one management suite for jailbroken PS5 consoles. One app — on **Windows, Linux, macOS and Android** — that does everything: high-speed file transfers, game mount/launch, decrypted save backup/restore, screenshots, live hardware monitoring, PKG streaming installs, full FPKG building on the PC, an integrated Homebrew Store, memory tools, fan control, kernel log, a remote shell — plus console LED control and DualSense lightbar support.
![PS5 Suite — File Transfer](screenshots/file_transfer.png)
@@ -28,13 +28,14 @@ All-in-one management suite for jailbroken PS5 consoles. One app — on Windows,
| `PS5Suite-Linux-ARM64` | ARM64 Linux (Raspberry Pi, etc.) |
| `PS5Suite-macOS-x64` | Intel Macs — `chmod +x`, Gatekeeper: right-click → Open |
| `PS5Suite-macOS-ARM64` | Apple Silicon (M1/M2/M3/M4) |
| `PS5Suite.apk` | **Android** — signed APK, full feature parity with desktop |
| `ps5_suite_server.elf` | **Required** on-console server component |
---
## 🚀 Setup (2 minutes)
1. Load a jailbreak / payload loader on the PS5 (e.g. GoldHEN, or etaHEN itemzflow).
1. Load a jailbreak / payload loader on the PS5 — tested with **etaHEN** and **Kstuff** (any payload loader that accepts ELFs works).
2. Open the app → expand **⚙️ Payload Settings** → **📂 Browse** → pick `payload/ps5_suite_server.elf` → **📤 Send Payload** (default port 9020).
3. The payload installs itself and starts the suite server (ports 9113–9116).
4. Press **🔍** to auto-discover the console on your LAN, or type its IP → **🔌 Connect**.
@@ -63,8 +64,8 @@ Two side-by-side panes: your PC on the left, the PS5 on the right.
**Local PC pane**
- Browse all local drives/folders like a file manager.
- **📄 Files** — multi-select files to queue. **📁 Folder** — queue an entire folder (recursive).
- **🌐 NAS** — add a network/SMB path (`\\server\share\folder`) directly — uploads straight from your NAS to the PS5.
- Right-click a local `.pkg` → **📦 Install on PS5** — *streams the package from the PC to the console's installer over HTTP; no 20 GB copy to the PS5 drive needed.*
- **🌐 NAS** — add a network/SMB path (`\\server\share\folder`) with **username/password login** — managed SMB client (SMBLibrary) so it works on every OS, not just Windows; credentials are kept in memory only. Guest/anonymous shares work too. Uploads stream straight from your NAS to the PS5.
- Right-click a local or NAS `.pkg` → **📦 Install on PS5** — *streams the package to the console's installer over HTTP; no 20 GB copy to the PS5 drive needed.*
- **🗑️ Clear** empties the upload queue.
**PS5 remote pane**
@@ -73,7 +74,7 @@ Two side-by-side panes: your PC on the left, the PS5 on the right.
- Downloads ask where to save on the PC; folders download recursively.
**Transfer engine**
- **📤 Upload** — parallel chunked transfers; ~**280 MB/s** on fast LANs (2.5GbE), saturates gigabit easily.
- **📤 Upload** — parallel chunked transfers; ~**280 MB/s over Ethernet** on fast LANs (2.5GbE), saturates gigabit wired links easily. Wi-Fi will be significantly slower — use a wired connection on both PC and PS5 for full speed.
- Duplicate files on the PS5 trigger a dialog: skip / overwrite / rename.
- Live progress bar with per-file %, overall speed (MB/s) and ETA. **⏹ STOP** cancels cleanly.
- **🎮 Mount Games** — mounts uploaded game dumps so they appear in the PS5's game list.
@@ -92,7 +93,14 @@ Two side-by-side panes: your PC on the left, the PS5 on the right.
---
### 💾 SAVES & MEDIA → Saves
### � GAMES → Homebrew Store
- Built-in **pkg-zone.com** catalog: browse/search every public PS5 homebrew package.
- Covers, version + author info, one-tap **📥 Install** — the package streams straight to the console installer.
---
### �💾 SAVES & MEDIA → Saves
- Enumerates all save data on the console (per user, per title) with size/type badges.
- **🔓 Mount (decrypt)** — mounts the save decrypted on the PS5 so its files are readable.
@@ -122,11 +130,33 @@ Everything is read **live from the console** — nothing hardcoded; values the c
- **Live Sensors** (● LIVE, auto-refresh) — 🔥 CPU temp, 🌡️ SoC temp, ⚡ CPU frequency, 🔋 SoC power draw (watts).
- **CPU Usage** — per-core usage bars (all 8 cores) + total.
- **RAM** — used/free memory.
- **Network** — interface info + ⚡ **LAN speed test** (payload streams 16 MB, shows real Mbps).
- **Storage** — free/total per mount point.
- **Modules** — **🔄 Load** lists all loaded kernel/user modules.
---
### 🎯 APPS (App Manager v2)
- Lists every running app/process with real data from `sceKernelGetAppInfo`: pid, app_id, title_id, name, type, per-process **CPU%** (kernel `ki_runtime` deltas) and suspended state (`ki_stat`, no IPC stall).
- **⏸ Suspend / ▶ Resume** — SIGSTOP/SIGCONT freeze, works on any app.
- **🗑️ Kill** — ForceKillApp → KillApp → SIGKILL fallback chain.
- **🧠 Coredump** — triggers a process memory dump (needs etaHEN).
---
### ⚡ POWER & DEVICES
- **Power** — reboot / shutdown / rest mode from the app.
- **USB drives** — list mounted USB storage.
- **🎮 Controller (DualSense)** — live pad state + controller info read through a remote bridge into `SceShellUI` (the payload gets no pad session of its own, so it resolves the real logged-in user remotely — nothing hardcoded). **Lightbar**: set any RGB color.
- **💡 Console LED** — real `/dev/icc_indicator` interface with three hardware channels (blue `0x01` / white `0x11` / amber `0x21` — verified on hardware): pick a color, run effects (breathe, sunrise, blink, chase…), `auto` hands control back to the system.
- **🔔 Notify** — push a custom notification to the PS5 screen.
- **📀 Disc Dump** — dump the inserted BD disc to `/user/disc` with live progress + cancel.
- **🔊 Beeper** — console beep / mute.
---
### 🔧 TOOLS → Shell
- Run shell commands directly on the PS5 — type, Enter, output streams back.
@@ -206,8 +236,22 @@ Everything is read **live from the console** — nothing hardcoded; values the c
| Payload proxy port | 13801+ |
- All heavy work (transfers, FPKG builds) runs on background threads — the UI never freezes.
- The Android app shares **the same UI + protocol code as the desktop EXE** (source included in the repo — APK build in progress).
-
- **The Android app is the same Avalonia codebase** — shared protocol layer, same dark theme and the same feature set as the desktop build, wrapped in a hamburger-navigation layout for touch. Source is in `client-android/`; a signed `PS5Suite.apk` ships in every release.
---
## 📱 Android app
- Same Avalonia UI/protocol as desktop — every feature above works on Android: transfers, games, store, saves, system info, tools, devices, debug log.
- Side **☰ hamburger menu** navigation, signed APK, version synced with desktop releases.
- Install → enter PS5 IP → Connect. Same payload, same ports.
---
## 🐞 Found a bug?
Please report it in the **[Issues](https://github.com/manos555555/PS5-Suite/issues)** section — include your firmware, what you were doing, and any error message or log output (the app's Debug Log tab + the `ps5suite_*.log` file next to the executable help a lot). Every report gets looked at — the last two fixes came straight from user reports. 🙏
---
## ⚠️ Disclaimer
+454
View File
@@ -0,0 +1,454 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# JetBrains Rider
.idea/
*.sln.iml
##
## Visual Studio Code
##
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
+18
View File
@@ -0,0 +1,18 @@
<Project>
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
<PackageVersion Include="Avalonia" Version="11.0.6" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.0.6" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageVersion Include="Avalonia.Desktop" Version="11.0.6" />
<PackageVersion Include="Avalonia.iOS" Version="11.0.6" />
<PackageVersion Include="Avalonia.Browser" Version="11.0.6" />
<PackageVersion Include="Avalonia.Android" Version="11.0.6" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.2" />
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,21 @@
using Android.App;
using Android.Content.PM;
using Avalonia;
using Avalonia.Android;
namespace PS5SuiteAndroid.Android;
[Activity(
Label = "PS5 Suite",
Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon",
MainLauncher = true,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity<App>
{
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont();
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-android</TargetFramework>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>com.manos.ps5suite</ApplicationId>
<ApplicationVersion>7</ApplicationVersion>
<ApplicationDisplayVersion>7.2.3</ApplicationDisplayVersion>
<ApplicationTitle>PS5 Suite</ApplicationTitle>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Resources\drawable\icon.jpg" />
<AndroidResource Include="Resources\drawable\splash_screen.xml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application android:label="PS5 Suite" android:icon="@drawable/icon" />
</manifest>
@@ -0,0 +1,44 @@
Images, layout descriptions, binary blobs and string dictionaries can be included
in your application as resource files. Various Android APIs are designed to
operate on the resource IDs instead of dealing with images, strings or binary blobs
directly.
For example, a sample Android app that contains a user interface layout (main.axml),
an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png)
would keep its resources in the "Resources" directory of the application:
Resources/
drawable/
icon.png
layout/
main.axml
values/
strings.xml
In order to get the build system to recognize Android resources, set the build action to
"AndroidResource". The native Android APIs do not operate directly with filenames, but
instead operate on resource IDs. When you compile an Android application that uses resources,
the build system will package the resources for distribution and generate a class called "R"
(this is an Android convention) that contains the tokens for each one of the resources
included. For example, for the above Resources layout, this is what the R class would expose:
public class R {
public class drawable {
public const int icon = 0x123;
}
public class layout {
public const int main = 0x456;
}
public class strings {
public const int first_string = 0xabc;
public const int second_string = 0xbcd;
}
}
You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main
to reference the layout/main.axml file, or R.strings.first_string to reference the first
string in the dictionary file values/strings.xml.
@@ -0,0 +1,66 @@
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="vector"
android:width="128dp"
android:height="128dp"
android:viewportWidth="128"
android:viewportHeight="128">
<group
android:name="wrapper"
android:translateX="21"
android:translateY="21">
<group android:name="group">
<path
android:name="path"
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
android:strokeWidth="1"
android:fillType="evenOdd"/>
<path
android:name="path_2"
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
android:strokeWidth="1"/>
</group>
</group>
</vector>
</aapt:attr>
<target android:name="path">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="1000"
android:valueFrom="#00ffffff"
android:valueTo="#161c2d"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_1">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="1000"
android:valueFrom="#00ffffff"
android:valueTo="#f9f9fb"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_2">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="1000"
android:valueFrom="#00ffffff"
android:valueTo="#f9f9fb"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
</animated-vector>
@@ -0,0 +1,71 @@
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="vector"
android:width="128dp"
android:height="128dp"
android:viewportWidth="128"
android:viewportHeight="128">
<group
android:name="wrapper"
android:translateX="21"
android:translateY="21">
<group android:name="group">
<path
android:name="path"
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
android:fillColor="#00ffffff"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
android:fillColor="#00ffffff"
android:strokeWidth="1"
android:fillType="evenOdd"/>
<path
android:name="path_2"
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
android:fillColor="#00ffffff"
android:strokeWidth="1"/>
</group>
</group>
</vector>
</aapt:attr>
<target android:name="path_2">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:startOffset="100"
android:duration="900"
android:valueFrom="#00ffffff"
android:valueTo="#161c2d"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="500"
android:valueFrom="#00ffffff"
android:valueTo="#f9f9fb"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_1">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:startOffset="100"
android:duration="900"
android:valueFrom="#00ffffff"
android:valueTo="#161c2d"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
</animated-vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/splash_background"/>
</item>
<item android:drawable="@drawable/avalonia_anim"
android:width="120dp"
android:height="120dp"
android:gravity="center" />
</layer-list>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#212121</color>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<style name="MyTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
<style name="MyTheme.Main" parent="MyTheme">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowIsTranslucent">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<style name="MyTheme">
</style>
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
<PropertyGroup>
<TargetFramework>net10.0-browser</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Browser" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
</ItemGroup>
<ItemGroup>
<StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" Expression="#[.{fingerprint}]!" />
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Browser;
using PS5SuiteAndroid;
internal sealed partial class Program
{
private static Task Main(string[] args) => BuildAvaloniaApp()
.WithInterFont()
.StartBrowserAppAsync("out");
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>();
}
@@ -0,0 +1 @@
[assembly:System.Runtime.Versioning.SupportedOSPlatform("browser")]
@@ -0,0 +1,13 @@
{
"profiles": {
"PS5SuiteAndroid.Browser": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:7169;http://localhost:5235",
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}"
}
}
}
@@ -0,0 +1,58 @@
/* HTML styles for the splash screen */
.avalonia-splash {
position: absolute;
height: 100%;
width: 100%;
background: white;
font-family: 'Outfit', sans-serif;
justify-content: center;
align-items: center;
display: flex;
pointer-events: none;
}
/* Light theme styles */
@media (prefers-color-scheme: light) {
.avalonia-splash {
background: white;
}
.avalonia-splash h2 {
color: #1b2a4e;
}
.avalonia-splash a {
color: #0D6EFD;
}
}
@media (prefers-color-scheme: dark) {
.avalonia-splash {
background: #1b2a4e;
}
.avalonia-splash h2 {
color: white;
}
.avalonia-splash a {
color: white;
}
}
.avalonia-splash h2 {
font-weight: 400;
font-size: 1.5rem;
}
.avalonia-splash a {
text-decoration: none;
font-size: 2.5rem;
display: block;
}
.avalonia-splash.splash-close {
transition: opacity 200ms, display 200ms;
display: none;
opacity: 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<title>PS5SuiteAndroid.Browser</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preload" id="webassembly" />
<script type="importmap"></script>
<script type='module' src="main#[.{fingerprint}].js"></script>
<link rel="stylesheet" href="./app.css" />
</head>
<body style="margin: 0; overflow: hidden">
<div id="out">
<div class="avalonia-splash">
<h2>
Powered by
<a href="https://www.avaloniaui.net/" target="_blank">
<svg width="266" height="52" viewBox="0 0 266 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M55.8592 47.3941C54.9035 47.3941 54.1184 47.1723 53.504 46.7285C52.9237 46.2848 52.5483 45.6875 52.3776 44.9365C52.2411 44.1856 52.3947 43.3493 52.8384 42.4277L65.9456 13.7045C66.4917 12.544 67.1403 11.7077 67.8912 11.1957C68.6421 10.6496 69.5125 10.3765 70.5024 10.3765C71.4923 10.3765 72.3627 10.6496 73.1136 11.1957C73.8645 11.7077 74.496 12.544 75.008 13.7045L88.2176 42.4277C88.6613 43.3493 88.8149 44.2027 88.6784 44.9877C88.576 45.7387 88.2347 46.336 87.6544 46.7797C87.0741 47.1893 86.3232 47.3941 85.4016 47.3941C84.2411 47.3941 83.3365 47.1211 82.688 46.5749C82.0736 46.0288 81.5275 45.1755 81.0496 44.0149L78.9279 39.0997H62.0415L59.9552 44.0149C59.4432 45.2096 58.8971 46.08 58.3168 46.6261C57.7707 47.1381 56.9515 47.3941 55.8592 47.3941ZM70.4 19.2853L64.6844 32.9045H76.2627L70.5024 19.2853H70.4Z" fill="currentColor"/>
<path d="M101.869 47.3941C100.879 47.3941 100.009 47.1381 99.258 46.6261C98.5071 46.08 97.9096 45.2779 97.4659 44.2197L89.7348 26.4021C89.3593 25.5147 89.2228 24.6955 89.3252 23.9445C89.4276 23.1595 89.786 22.5451 90.4004 22.1013C91.0489 21.6235 91.9364 21.3845 93.0628 21.3845C93.9844 21.3845 94.7353 21.6064 95.3156 22.0501C95.8959 22.4597 96.4079 23.2619 96.8516 24.4565L102.018 37.95L107.552 24.4053C108.03 23.2448 108.559 22.4597 109.14 22.0501C109.72 21.6064 110.522 21.3845 111.546 21.3845C112.433 21.3845 113.133 21.6235 113.645 22.1013C114.191 22.5451 114.516 23.1424 114.618 23.8933C114.755 24.6443 114.618 25.4635 114.208 26.3509L106.324 44.2197C105.88 45.312 105.283 46.1141 104.532 46.6261C103.815 47.1381 102.927 47.3941 101.869 47.3941Z" fill="currentColor"/>
<path d="M126.569 47.4965C124.726 47.4965 123.07 47.1381 121.602 46.4213C120.135 45.7045 118.991 44.7317 118.172 43.5029C117.353 42.2741 116.943 40.8917 116.943 39.3557C116.943 37.5125 117.421 36.0619 118.377 35.0037C119.333 33.9115 120.886 33.1435 123.036 32.6997C125.186 32.2219 128.037 31.9829 131.586 31.9829H133.43V35.9765H131.638C129.897 35.9765 128.48 36.0789 127.388 36.2837C126.33 36.4544 125.562 36.7616 125.084 37.2053C124.64 37.6491 124.418 38.2635 124.418 39.0485C124.418 40.0043 124.743 40.7893 125.391 41.4037C126.074 42.0181 127.047 42.3253 128.31 42.3253C129.299 42.3253 130.17 42.1035 130.921 41.6597C131.706 41.1819 132.32 40.5504 132.764 39.7653C133.208 38.9461 133.43 38.0245 133.43 37.0005V31.1125C133.43 29.6107 133.088 28.5525 132.406 27.9381C131.723 27.2896 130.562 26.9653 128.924 26.9653C128.002 26.9653 126.995 27.0848 125.903 27.3237C124.845 27.5285 123.667 27.8869 122.37 28.3989C121.619 28.7403 120.954 28.8256 120.374 28.6549C119.793 28.4501 119.35 28.1088 119.042 27.6309C118.735 27.1189 118.582 26.5728 118.582 25.9925C118.582 25.3781 118.752 24.7979 119.094 24.2517C119.435 23.6715 119.998 23.2448 120.783 22.9717C122.387 22.3232 123.889 21.8795 125.289 21.6405C126.722 21.4016 128.037 21.2821 129.231 21.2821C131.859 21.2821 134.01 21.6747 135.682 22.4597C137.389 23.2107 138.669 24.3883 139.522 25.9925C140.376 27.5627 140.802 29.5936 140.802 32.0853V43.4517C140.802 44.7147 140.495 45.6875 139.881 46.3701C139.266 47.0528 138.379 47.3941 137.218 47.3941C136.058 47.3941 135.153 47.0528 134.505 46.3701C133.89 45.6875 133.583 44.7147 133.583 43.4517L133.594 43.15C133.594 43.15 133.293 44.032 132.61 44.8853C131.962 45.7045 131.126 46.3531 130.102 46.8309C129.078 47.2747 127.9 47.4965 126.569 47.4965Z" fill="currentColor"/>
<path d="M155.632 47.4965C152.594 47.4965 150.324 46.6603 148.822 44.9877C147.321 43.2811 146.57 40.7552 146.57 37.4101V14.3189C146.57 13.0219 146.894 12.0491 147.542 11.4005C148.225 10.7179 149.198 10.3765 150.461 10.3765C151.69 10.3765 152.628 10.7179 153.277 11.4005C153.959 12.0491 154.301 13.0219 154.301 14.3189V37.1029C154.301 38.5024 154.591 39.5435 155.171 40.2261C155.786 40.8747 156.588 41.1989 157.578 41.1989C157.851 41.1989 158.107 41.1819 158.346 41.1477C158.585 41.1136 158.841 41.0965 159.114 41.0965C159.66 41.0283 160.035 41.1989 160.24 41.6085C160.479 41.984 160.598 42.752 160.598 43.9125C160.598 44.9365 160.394 45.7216 159.984 46.2677C159.574 46.7797 158.943 47.1211 158.09 47.2917C157.748 47.3259 157.356 47.36 156.912 47.3941C156.468 47.4624 156.042 47.4965 155.632 47.4965Z" fill="currentColor"/>
<path d="M175.453 47.4965C172.756 47.4965 170.401 46.9675 168.387 45.9093C166.407 44.8512 164.871 43.3323 163.779 41.3525C162.687 39.3728 162.141 37.0347 162.141 34.3381C162.141 32.3243 162.448 30.5152 163.062 28.9109C163.677 27.3067 164.564 25.9413 165.725 24.8149C166.919 23.6544 168.336 22.784 169.974 22.2037C171.613 21.5893 173.439 21.2821 175.453 21.2821C178.149 21.2821 180.487 21.8112 182.467 22.8693C184.481 23.9275 186.034 25.4293 187.126 27.3749C188.253 29.3205 188.816 31.6416 188.816 34.3381C188.816 36.3861 188.492 38.2123 187.843 39.8165C187.229 41.4208 186.341 42.8032 185.181 43.9637C184.02 45.1243 182.604 46.0117 180.931 46.6261C179.293 47.2064 177.467 47.4965 175.453 47.4965ZM175.453 41.7109C176.579 41.7109 177.552 41.4379 178.371 40.8917C179.19 40.3456 179.839 39.5435 180.317 38.4853C180.795 37.3931 181.034 36.0107 181.034 34.3381C181.034 31.8464 180.522 30.0203 179.498 28.8597C178.474 27.6651 177.125 27.0677 175.453 27.0677C174.361 27.0677 173.388 27.3237 172.534 27.8357C171.715 28.3477 171.067 29.1499 170.589 30.2421C170.145 31.3003 169.923 32.6656 169.923 34.3381C169.923 36.8299 170.435 38.6901 171.459 39.9189C172.483 41.1136 173.814 41.7109 175.453 41.7109Z" fill="currentColor"/>
<path d="M197.411 47.3941C196.148 47.3941 195.175 47.0528 194.492 46.3701C193.844 45.6875 193.52 44.7147 193.52 43.4517V25.2757C193.52 24.0128 193.844 23.0571 194.492 22.4085C195.175 21.7259 196.114 21.3845 197.308 21.3845C198.537 21.3845 199.476 21.7259 200.124 22.4085C200.773 23.0571 201.112 24.1871 201.112 25.45C201.141 25.3955 202.48 23.552 204.016 22.6645C205.586 21.7429 207.361 21.2821 209.34 21.2821C211.354 21.2821 213.01 21.6747 214.307 22.4597C215.604 23.2107 216.577 24.3712 217.225 25.9413C217.874 27.4773 218.198 29.44 218.198 31.8293V43.4517C218.198 44.7147 217.857 45.6875 217.174 46.3701C216.525 47.0528 215.57 47.3941 214.307 47.3941C213.078 47.3941 212.122 47.0528 211.44 46.3701C210.791 45.6875 210.467 44.7147 210.467 43.4517V32.1877C210.467 30.4469 210.143 29.2011 209.494 28.4501C208.88 27.6651 207.924 27.2725 206.627 27.2725C204.988 27.2725 203.674 27.7845 202.684 28.8085C201.729 29.8325 201.251 31.1979 201.251 32.9045V43.4517C201.251 46.08 199.971 47.3941 197.411 47.3941Z" fill="currentColor"/>
<path d="M227.861 47.3429C226.598 47.3429 225.625 46.9845 224.942 46.2677C224.294 45.5168 223.97 44.4757 223.97 43.1445V25.6341C223.97 24.2688 224.294 23.2277 224.942 22.5109C225.625 21.76 226.598 21.3845 227.861 21.3845C229.09 21.3845 230.028 21.76 230.677 22.5109C231.359 23.2277 231.701 24.2688 231.701 25.6341V43.1445C231.701 44.4757 231.377 45.5168 230.728 46.2677C230.079 46.9845 229.124 47.3429 227.861 47.3429ZM227.861 17.1861C226.427 17.1861 225.318 16.8619 224.533 16.2133C223.782 15.5307 223.406 14.5749 223.406 13.3461C223.406 12.0832 223.782 11.1275 224.533 10.4789C225.318 9.79629 226.427 9.45496 227.861 9.45496C229.294 9.45496 230.387 9.79629 231.138 10.4789C231.889 11.1275 232.264 12.0832 232.264 13.3461C232.264 14.5749 231.889 15.5307 231.138 16.2133C230.387 16.8619 229.294 17.1861 227.861 17.1861Z" fill="currentColor"/>
<path d="M246.169 47.4965C244.326 47.4965 242.67 47.1381 241.202 46.4213C239.735 45.7045 238.591 44.7317 237.772 43.5029C236.953 42.2741 236.543 40.8917 236.543 39.3557C236.543 37.5125 237.021 36.0619 237.977 35.0037C238.933 33.9115 240.486 33.1435 242.636 32.6997C244.786 32.2219 247.637 31.9829 251.186 31.9829H253.03V35.9765H251.238C249.497 35.9765 248.08 36.0789 246.988 36.2837C245.93 36.4544 245.162 36.7616 244.684 37.2053C244.24 37.6491 244.018 38.2635 244.018 39.0485C244.018 40.0043 244.343 40.7893 244.991 41.4037C245.674 42.0181 246.647 42.3253 247.91 42.3253C248.899 42.3253 249.77 42.1035 250.521 41.6597C251.306 41.1819 251.92 40.5504 252.364 39.7653C252.808 38.9461 253.03 38.0245 253.03 37.0005V31.1125C253.03 29.6107 252.688 28.5525 252.006 27.9381C251.323 27.2896 250.162 26.9653 248.524 26.9653C247.602 26.9653 246.595 27.0848 245.503 27.3237C244.445 27.5285 243.267 27.8869 241.97 28.3989C241.219 28.7403 240.554 28.8256 239.974 28.6549C239.393 28.4501 238.95 28.1088 238.642 27.6309C238.335 27.1189 238.182 26.5728 238.182 25.9925C238.182 25.3781 238.352 24.7979 238.694 24.2517C239.035 23.6715 239.598 23.2448 240.383 22.9717C241.987 22.3232 243.489 21.8795 244.889 21.6405C246.322 21.4016 247.637 21.2821 248.831 21.2821C251.459 21.2821 253.61 21.6747 255.282 22.4597C256.989 23.2107 258.269 24.3883 259.122 25.9925C259.976 27.5627 260.402 29.5936 260.402 32.0853V43.4517C260.402 44.7147 260.095 45.6875 259.481 46.3701C258.866 47.0528 257.979 47.3941 256.818 47.3941C255.658 47.3941 254.753 47.0528 254.105 46.3701C253.49 45.6875 253.183 44.7147 253.183 43.4517V43.1789C253.183 43.3144 252.893 44.032 252.21 44.8853C251.562 45.7045 250.726 46.3531 249.702 46.8309C248.678 47.2747 247.5 47.4965 246.169 47.4965Z" fill="currentColor"/>
<path d="M22.3444 20.9916C18.7895 20.9916 15.9077 24.0073 15.9077 27.7274C15.9077 31.4475 18.7895 34.4632 22.3444 34.4632C25.8993 34.4632 28.7811 31.4475 28.7811 27.7274C28.7811 24.0073 25.8993 20.9916 22.3444 20.9916Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.6937 49.0667H21.9271C10.8346 48.8653 1.90173 39.3893 1.90173 27.7333C1.90173 15.9513 11.0289 6.40002 22.2878 6.40002C33.3638 6.40002 42.3768 15.6435 42.6667 27.161L42.6314 44.4824C42.338 47.0679 40.2434 49.0667 37.6937 49.0667ZM9.22617 24.667C10.5612 18.3725 15.9275 13.6656 22.3444 13.6656C29.7657 13.6656 35.7818 19.9613 35.7818 27.7274C35.7818 27.7857 35.7825 27.8488 35.7831 27.9136C35.7846 28.0483 35.7861 28.1901 35.7818 28.3103V41.6939H28.7907V40.0685C26.877 41.1655 24.6803 41.7892 22.3444 41.7892C15.9275 41.7892 10.5612 37.0823 9.22617 30.7878C10.5043 30.4129 11.4416 29.1847 11.4416 27.7274C11.4416 26.2701 10.5043 25.0419 9.22617 24.667ZM8.33937 29.9683C9.52696 29.9683 10.4897 28.9609 10.4897 27.7181C10.4897 26.4753 9.52696 25.4678 8.33937 25.4678C7.15178 25.4678 6.18904 26.4753 6.18904 27.7181C6.18904 28.9609 7.15178 29.9683 8.33937 29.9683Z" fill="currentColor"/>
</svg>
</a>
</h2>
</div>
</div>
</body>
</html>
@@ -0,0 +1,13 @@
import { dotnet } from './_framework/dotnet.js'
const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);
const dotnetRuntime = await dotnet
.withDiagnosticTracing(false)
.withApplicationArgumentsFromQuery()
.create();
const config = dotnetRuntime.getConfig();
await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]);
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!--If you are willing to use platform-specific APIs, use conditional compilation.
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
using System;
using Avalonia;
namespace PS5SuiteAndroid.Desktop;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="PS5SuiteAndroid.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
@@ -0,0 +1,23 @@
using Foundation;
using UIKit;
using Avalonia;
using Avalonia.Controls;
using Avalonia.iOS;
using Avalonia.Media;
namespace PS5SuiteAndroid.iOS;
// The UIApplicationDelegate for the application. This class is responsible for launching the
// User Interface of the application, as well as listening (and optionally responding) to
// application events from iOS.
[Register("AppDelegate")]
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
public partial class AppDelegate : AvaloniaAppDelegate<App>
#pragma warning restore CA1711 // Identifiers should not have incorrect suffix
{
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont();
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>PS5SuiteAndroid</string>
<key>CFBundleIdentifier</key>
<string>companyName.PS5SuiteAndroid</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>13.0</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,14 @@
using UIKit;
namespace PS5SuiteAndroid.iOS;
public class Application
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-ios</TargetFramework>
<SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.iOS" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PS5SuiteAndroid\PS5SuiteAndroid.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="6214" systemVersion="14A314h" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="6207" />
<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1" />
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" />
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder" />
<view contentMode="scaleToFill" id="iN0-l3-epB">
<rect key="frame" x="0.0" y="0.0" width="480" height="480" />
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES" />
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" Copyright (c) 2022 " textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines"
minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
<rect key="frame" x="20" y="439" width="441" height="21" />
<fontDescription key="fontDescription" type="system" pointSize="17" />
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
<nil key="highlightedColor" />
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="PS5SuiteAndroid" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines"
minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="kId-c2-rCX">
<rect key="frame" x="20" y="140" width="441" height="43" />
<fontDescription key="fontDescription" type="boldSystem" pointSize="36" />
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
<nil key="highlightedColor" />
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite" />
<constraints>
<constraint firstItem="kId-c2-rCX" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="bottom" multiplier="1/3" constant="1" id="5cJ-9S-tgC" />
<constraint firstAttribute="centerX" secondItem="kId-c2-rCX" secondAttribute="centerX" id="Koa-jz-hwk" />
<constraint firstAttribute="bottom" secondItem="8ie-xW-0ye" secondAttribute="bottom" constant="20" id="Kzo-t9-V3l" />
<constraint firstItem="8ie-xW-0ye" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="MfP-vx-nX0" />
<constraint firstAttribute="centerX" secondItem="8ie-xW-0ye" secondAttribute="centerX" id="ZEH-qu-HZ9" />
<constraint firstItem="kId-c2-rCX" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="fvb-Df-36g" />
</constraints>
<nil key="simulatedStatusBarMetrics" />
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics" />
<point key="canvasLocation" x="548" y="455" />
</view>
</objects>
</document>
+11
View File
@@ -0,0 +1,11 @@
<Solution>
<Project Path="PS5SuiteAndroid.Android/PS5SuiteAndroid.Android.csproj">
<Deploy />
</Project>
<Project Path="PS5SuiteAndroid.Browser/PS5SuiteAndroid.Browser.csproj" />
<Project Path="PS5SuiteAndroid.Desktop/PS5SuiteAndroid.Desktop.csproj" />
<Project Path="PS5SuiteAndroid.iOS/PS5SuiteAndroid.iOS.csproj">
<Deploy />
</Project>
<Project Path="PS5SuiteAndroid/PS5SuiteAndroid.csproj" />
</Solution>
+14
View File
@@ -0,0 +1,14 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PS5SuiteAndroid"
x:Class="PS5SuiteAndroid.App"
RequestedThemeVariant="Dark">
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
@@ -0,0 +1,35 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using PS5SuiteAndroid.ViewModels;
using PS5SuiteAndroid.Views;
namespace PS5SuiteAndroid;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainViewModel()
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = new MainView
{
DataContext = new MainViewModel()
};
}
base.OnFrameworkInitializationCompleted();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
</Project>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using PS5SuiteAndroid.ViewModels;
namespace PS5SuiteAndroid;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
@@ -0,0 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PS5SuiteAndroid.ViewModels;
public partial class MainViewModel : ViewModelBase
{
public string Greeting { get; set; } = "PS5 Suite";
}
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PS5SuiteAndroid.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
@@ -0,0 +1,754 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:Class="PS5SuiteAndroid.Views.MainView"
Background="#1E1E1E">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Header -->
<Border Grid.Row="0" Background="#252526" Padding="10,15,15,15">
<Grid ColumnDefinitions="Auto,*">
<Button Grid.Column="0" x:Name="MenuButton" Content="☰" FontSize="18"
Background="Transparent" Foreground="#0E9CFF" Padding="8,0"
VerticalAlignment="Center" Click="MenuButton_Click"/>
<StackPanel Grid.Column="1" Margin="8,0,0,0">
<StackPanel Orientation="Horizontal">
<Ellipse x:Name="StatusIndicator" Width="10" Height="10" Fill="#FF6B35" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="PS5 Suite" FontSize="20" FontWeight="Bold" Foreground="#0E9CFF"/>
</StackPanel>
<TextBlock x:Name="ConnectionStatusText" Text="Disconnected" FontSize="11" Foreground="#A0A0A0"/>
</StackPanel>
</Grid>
</Border>
<!-- Navigation drawer + content -->
<SplitView Grid.Row="1" x:Name="NavDrawer" DisplayMode="Overlay"
CompactPaneLength="0" OpenPaneLength="250"
PaneBackground="#252526" IsPaneOpen="False">
<SplitView.Pane>
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Background="#2D2D30" Padding="15,12">
<TextBlock Text="Menu" FontSize="14" FontWeight="Bold" Foreground="#0E9CFF"/>
</Border>
<ListBox Grid.Row="1" x:Name="NavList" Background="Transparent"
SelectionChanged="NavList_SelectionChanged">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="15,13"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="#E0E0E0"/>
</Style>
<Style Selector="ListBoxItem:selected">
<Setter Property="Background" Value="#0E639C"/>
</Style>
<Style Selector="ListBoxItem:pointerover">
<Setter Property="Background" Value="#3E3E42"/>
</Style>
</ListBox.Styles>
<ListBoxItem>🔌 Connect</ListBoxItem>
<ListBoxItem>📁 File Transfer</ListBoxItem>
<ListBoxItem>🎮 Games</ListBoxItem>
<ListBoxItem>🛒 Homebrew Store</ListBoxItem>
<ListBoxItem>💾 Saves &amp; Media</ListBoxItem>
<ListBoxItem>🖥️ System Info</ListBoxItem>
<ListBoxItem>🛠️ Tools</ListBoxItem>
<ListBoxItem>⚡ Devices</ListBoxItem>
<ListBoxItem>📋 Debug Log</ListBoxItem>
</ListBox>
</Grid>
</SplitView.Pane>
<SplitView.Content>
<Panel x:Name="ContentHost" Background="#1E1E1E">
<!-- Connect Tab -->
<Panel x:Name="PageConnect">
<ScrollViewer>
<StackPanel Margin="15" Spacing="15">
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="PS5 IP Address" Foreground="#A0A0A0" FontSize="14"/>
<TextBox x:Name="IpAddressInput" Text="192.168.1.100" Background="#2D2D30"
BorderBrush="#3E3E42" Padding="12" FontSize="16"/>
<Button x:Name="ConnectButton" Content="Connect" Background="#007ACC"
Foreground="White" Padding="15" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" FontSize="16" Click="ConnectButton_Click"/>
</StackPanel>
</Border>
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<TextBlock Text="Quick Actions" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Content="📁 Browse Files" Background="#3E3E42" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="BrowseFilesButton_Click"/>
<Button Content="🎮 View Games" Background="#3E3E42" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="ViewGamesButton_Click"/>
<Button Content="🖥️ System Info" Background="#3E3E42" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="SystemInfoButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
<!-- Files Tab -->
<Panel x:Name="PageFiles" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="CurrentPathText" Text="/data" Foreground="#A0A0A0"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="1" Content="⬆️" Background="#3E3E42" Padding="10" Click="GoUpButton_Click"/>
</Grid>
</Border>
<ListBox Grid.Row="1" x:Name="FilesListBox" Background="#1E1E1E"
SelectionMode="Single" DoubleTapped="FilesListBox_DoubleTapped">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="12" Margin="4">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Icon}" FontSize="24" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SizeText}" FontSize="11" Foreground="#808080"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="›" FontSize="20" Foreground="#808080"
VerticalAlignment="Center" IsVisible="{Binding IsDirectory}"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Panel>
<!-- Games Tab -->
<Panel x:Name="PageGames" IsVisible="False">
<Grid RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Mounted Games" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="RefreshGamesButton_Click"/>
</Grid>
</Border>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="10,6">
<Button Content="🎮 Mount All" Background="#007ACC" Foreground="White" Padding="12" Click="MountGamesButton_Click"/>
<Button Content="▶️ Launch" Background="#3E3E42" Foreground="White" Padding="12" Click="LaunchGameButton_Click"/>
<Button Content="🗑️ Unmount" Background="#3E3E42" Foreground="White" Padding="12" Click="UnmountGameButton_Click"/>
</StackPanel>
<ListBox Grid.Row="2" x:Name="GamesListBox" Background="#1E1E1E">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="60" Height="60" Background="#3E3E42"
CornerRadius="8" Margin="0,0,12,0">
<TextBlock Text="🎮" FontSize="28" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding TitleId}" FontSize="12" Foreground="#0E9CFF"/>
<TextBlock Text="{Binding SizeText}" FontSize="11" Foreground="#808080"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Panel>
<!-- Store Tab -->
<Panel x:Name="PageStore" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="StoreSearchBox" Watermark="Search homebrew..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10"
TextChanged="StoreSearch_TextChanged"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="10" Click="StoreRefresh_Click"/>
</Grid>
<TextBlock x:Name="StoreStatusText" Text="Tap 🔄 to fetch pkg-zone catalog" Foreground="#808080" FontSize="11"/>
</StackPanel>
</Border>
<ListBox Grid.Row="1" x:Name="StoreListBox" Background="#1E1E1E">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="8" Padding="12" Margin="4">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="52" Height="52" Background="#3E3E42"
CornerRadius="8" Margin="0,0,12,0" ClipToBounds="True">
<Panel>
<TextBlock Text="📦" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image Source="{Binding Cover}" Stretch="UniformToFill"/>
</Panel>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,8,0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Id}" FontSize="11" Foreground="#0E9CFF"/>
<TextBlock FontSize="10" Foreground="#808080">
<Run Text="{Binding Version}"/><Run Text=" · "/><Run Text="{Binding Author}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="2" Content="📥" Background="#007ACC" Foreground="White"
Padding="8" VerticalAlignment="Center" Tag="{Binding Id}" Click="StoreInstall_Click"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Panel>
<!-- Saves & Media Tab -->
<Panel x:Name="PageSaves" IsVisible="False">
<ScrollViewer>
<StackPanel Margin="15" Spacing="15">
<!-- Save Images Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="💾 Save Images" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔄 Scan" Background="#3E3E42" Padding="8" Click="SaveScanButton_Click"/>
</Grid>
<TextBlock x:Name="SaveMountStatusText" Text="No save mounted" Foreground="#808080" FontSize="11"/>
<ListBox x:Name="SavesListBox" Background="#1E1E1E" MaxHeight="300">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
<StackPanel>
<TextBlock Text="{Binding SaveName}" FontSize="13" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding TitleId}" FontSize="11" Foreground="#0E9CFF"/>
<TextBlock Text="{Binding TypeBadge}" FontSize="11" Foreground="#9C27B0"/>
<TextBlock Text="{Binding SizeDisplay}" FontSize="11" Foreground="#808080"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="🔓 Mount" Background="#007ACC" Foreground="White" Padding="10" Click="SaveMountButton_Click"/>
<Button Content="🔒 Unmount" Background="#3E3E42" Foreground="White" Padding="10" Click="SaveUnmountButton_Click"/>
<Button Content="🗑️ Delete" Background="#DC3545" Foreground="White" Padding="10" Click="SaveDeleteButton_Click"/>
</StackPanel>
<TextBlock Text="Mounted saves are browsable at /data/save_mnt" Foreground="#606060" FontSize="10"/>
</StackPanel>
</Border>
<!-- Screenshots Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="📸 Screenshots" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="ScreenshotsRefreshButton_Click"/>
</Grid>
<Button Content="📸 Capture Screenshot" Background="#007ACC" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="CaptureScreenshotButton_Click"/>
<ListBox x:Name="ScreenshotsListBox" Background="#1E1E1E" MaxHeight="260">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
<StackPanel>
<TextBlock Text="{Binding FileName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock FontSize="10" Foreground="#808080">
<Run Text="{Binding DateDisplay}"/><Run Text=" · "/><Run Text="{Binding SizeDisplay}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Content="🗑️ Delete Selected" Background="#DC3545" Foreground="White"
Padding="10" HorizontalAlignment="Stretch" Click="ScreenshotDeleteButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
<!-- System Tab -->
<Panel x:Name="PageSystem" IsVisible="False">
<ScrollViewer>
<StackPanel Margin="15" Spacing="15">
<!-- Sensors Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🌡️ Live Sensors" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="RefreshSensorsButton_Click"/>
</Grid>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0" Margin="0,0,8,8">
<TextBlock Text="CPU Temp" Foreground="#808080" FontSize="11"/>
<TextBlock x:Name="CpuTempText" Text="—°C" Foreground="#FFA500" FontSize="20" FontWeight="Bold"/>
<ProgressBar x:Name="CpuTempBar" Height="8" Minimum="0" Maximum="100" Value="0" Background="#1E1E1E" Foreground="#FF6B35"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Margin="8,0,0,8">
<TextBlock Text="SoC Temp" Foreground="#808080" FontSize="11"/>
<TextBlock x:Name="SocTempText" Text="—°C" Foreground="#FFA500" FontSize="20" FontWeight="Bold"/>
<ProgressBar x:Name="SocTempBar" Height="8" Minimum="0" Maximum="100" Value="0" Background="#1E1E1E" Foreground="#FF6B35"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Margin="0,8,8,0">
<TextBlock Text="CPU Freq" Foreground="#808080" FontSize="11"/>
<TextBlock x:Name="CpuFreqText" Text="— MHz" Foreground="#0E9CFF" FontSize="20" FontWeight="Bold"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="8,8,0,0">
<TextBlock Text="Power" Foreground="#808080" FontSize="11"/>
<TextBlock x:Name="PowerText" Text="— W" Foreground="#28A745" FontSize="20" FontWeight="Bold"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- CPU Usage Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<TextBlock Text="📊 CPU Usage" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 0:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu0Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu0Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 4:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu4Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu4Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 1:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu1Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu1Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 5:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu5Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu5Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 2:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu2Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu2Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 6:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu6Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu6Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 3:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu3Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu3Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBlock Text="Core 7:" Foreground="#808080" Width="50" FontSize="12"/>
<TextBlock x:Name="Cpu7Text" Text="—%" Foreground="#9CDCFE" Width="40" FontSize="12"/>
<ProgressBar x:Name="Cpu7Bar" Height="10" Width="60" Minimum="0" Maximum="100" Background="#1E1E1E" Foreground="#007ACC"/>
</StackPanel>
</Grid>
<Border Background="#1E1E1E" CornerRadius="4" Padding="8" Margin="0,8,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="Average:" Foreground="#808080" FontSize="12"/>
<TextBlock x:Name="CpuAvgText" Text="—%" Foreground="#28A745" FontWeight="Bold" FontSize="14" Margin="8,0,0,0"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Memory Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<TextBlock Text="💾 Memory" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Direct Total:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="MemTotalText" Text="—" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Available:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="MemAvailText" Text="—" Foreground="#28A745" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Used:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="MemUsedText" Text="—" Foreground="#FFC107" FontSize="12" Margin="10,2,0,2"/>
</Grid>
<Grid Margin="0,8,0,0">
<ProgressBar x:Name="MemUsageBar" Height="20" Minimum="0" Maximum="100" Value="0" Background="#1E1E1E" Foreground="#007ACC"/>
<TextBlock x:Name="MemUsageText" Text="—%" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="11" FontWeight="Bold"/>
</Grid>
</StackPanel>
</Border>
<!-- System Info Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<TextBlock Text="📋 System Info" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Model:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="ModelText" Text="—" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Firmware:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="FirmwareText" Text="—" Foreground="#28A745" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Total Uptime:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="UptimeText" Text="—" Foreground="#FFC107" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Boot Count:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="BootCountText" Text="—" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="BD Drive:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="BdDriveText" Text="—" FontSize="12" Margin="10,2,0,2"/>
</Grid>
</StackPanel>
</Border>
<!-- Extended Info Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🔧 Extended Info" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="ExtInfoRefresh_Click"/>
</Grid>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Prospero:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="ProsperoText" Text="—" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Product:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="ProductText" Text="—" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Shutdowns:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="ShutdownCountText" Text="—" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Thermal Alert:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="ThermalAlertText" Text="—" Foreground="#FFA500" FontSize="12" Margin="10,2,0,2"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="BD Power:" Foreground="#808080" FontSize="12" Margin="0,2"/>
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="BdPowerText" Text="—" FontSize="12" Margin="10,2,0,2"/>
</Grid>
</StackPanel>
</Border>
<!-- Network Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🌐 Network" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="NetRefresh_Click"/>
</Grid>
<TextBlock x:Name="NetInfoText" Text="Not loaded" Foreground="#A0A0A0" FontSize="12" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto">
<Button Content="⚡ Speed Test" Background="#007ACC" Foreground="White"
Padding="10" HorizontalAlignment="Stretch" Click="NetSpeed_Click"/>
<TextBlock Grid.Column="1" x:Name="NetSpeedText" Text="" Foreground="#28A745" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" Margin="10,0,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- Modules Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="📦 Loaded Modules" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="ModulesRefresh_Click"/>
</Grid>
<ListBox x:Name="ModulesListBox" Background="#1E1E1E" MaxHeight="260">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="8" Margin="3">
<StackPanel>
<TextBlock Text="{Binding Name}" Foreground="#9CDCFE" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Info}" Foreground="#808080" FontSize="10"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
<!-- Tools Tab -->
<Panel x:Name="PageTools" IsVisible="False">
<ScrollViewer>
<StackPanel Margin="15" Spacing="15">
<!-- PKG Install Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="📦 Install PKG" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<TextBlock Text="PKG path on PS5 or HTTP URL:" Foreground="#A0A0A0" FontSize="12"/>
<TextBox x:Name="PkgPathInput" Watermark="/user/data/pkg/game.pkg or http://..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="12" FontSize="13"/>
<Button Content="📦 Install PKG" Background="#007ACC" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="InstallPkgButton_Click"/>
<ProgressBar x:Name="PkgProgressBar" Height="8" Minimum="0" Maximum="100" Value="0"
Background="#1E1E1E" Foreground="#0E9CFF"/>
<TextBlock x:Name="PkgStatusText" Text="Idle" Foreground="#808080" FontSize="12"/>
</StackPanel>
</Border>
<!-- Fan Control Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="🌀 Fan Control" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Current threshold:" Foreground="#A0A0A0" FontSize="12" VerticalAlignment="Center"/>
<TextBlock x:Name="FanCurrentText" Text="—" Foreground="#FFA500" FontSize="14" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Content="🔄" Background="#3E3E42" Padding="8" Click="FanRefreshButton_Click"/>
</StackPanel>
<Slider x:Name="FanSlider" Minimum="30" Maximum="90" Value="60"
TickFrequency="1" IsSnapToTickEnabled="True"
ValueChanged="FanSlider_ValueChanged"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
<Button Content="Cool 50°" Background="#3E3E42" Padding="10" Click="FanPreset_Click"/>
<TextBlock x:Name="FanSliderText" Text="60°C" FontSize="18" FontWeight="Bold"
Foreground="#0E9CFF" VerticalAlignment="Center" MinWidth="60"/>
<Button Content="Quiet 70°" Background="#3E3E42" Padding="10" Click="FanPreset_Click"/>
</StackPanel>
<Button Content="🌀 Set Threshold" Background="#007ACC" Foreground="White"
Padding="12" HorizontalAlignment="Stretch" Click="FanSetButton_Click"/>
</StackPanel>
</Border>
<!-- Shell Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="💻 Shell" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<TextBox x:Name="ShellOutputBox" IsReadOnly="True" Height="180" FontFamily="Consolas" FontSize="11"
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="ShellInputBox" Watermark="command..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"
KeyDown="ShellInput_KeyDown"/>
<Button Grid.Column="1" Content="▶" Background="#007ACC" Foreground="White" Padding="12" Click="ShellExec_Click"/>
</Grid>
</StackPanel>
</Border>
<!-- Search Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="🔍 File Search" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="📇 Index" Background="#3E3E42" Padding="8" Click="IndexStart_Click"/>
<TextBlock x:Name="IndexStatusText" Text="index not built" Foreground="#808080" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="SearchBox" Watermark="search pattern..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<Button Grid.Column="1" Content="🔍" Background="#007ACC" Foreground="White" Padding="10" Click="SearchRun_Click"/>
</Grid>
<ListBox x:Name="SearchResultsBox" Background="#1E1E1E" MaxHeight="200">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="#9CDCFE" FontSize="11" Margin="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
<!-- Memory Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="🧠 Memory" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="MemPidBox" Watermark="pid" Width="100"
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<Button Content="📋 Regions" Background="#3E3E42" Padding="8" Click="MemRegions_Click"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="MemAddrBox" Watermark="0xaddr" Width="150"
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<TextBox x:Name="MemLenBox" Watermark="len" Width="80"
Background="#2D2D30" BorderBrush="#3E3E42" Padding="10" FontSize="12"/>
<Button Content="📖 Read" Background="#007ACC" Padding="8" Click="MemRead_Click"/>
</StackPanel>
<TextBox x:Name="MemOutputBox" IsReadOnly="True" Height="160" FontFamily="Consolas" FontSize="10"
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- Apps Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🎯 Running Apps" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="AppsRefresh_Click"/>
</Grid>
<ListBox x:Name="AppsListBox" Background="#1E1E1E" MaxHeight="260"
SelectionChanged="AppsListBox_SelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="#2D2D30" CornerRadius="4" Padding="10" Margin="3">
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding TitleId}" FontSize="10" Foreground="#0E9CFF"/>
<TextBlock Text="{Binding Cpu}" FontSize="10" Foreground="#9CDCFE"/>
<TextBlock Text="{Binding Status}" FontSize="10" Foreground="#FFA500"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
<Button Content="⏸ Suspend" Background="#3E3E42" Padding="8" Click="AppSuspend_Click"/>
<Button Content="▶ Resume" Background="#3E3E42" Padding="8" Click="AppResume_Click"/>
<Button Content="🗑 Kill" Background="#DC3545" Foreground="White" Padding="8" Click="AppKill_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Kernel Log Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🧾 Kernel Log" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="KlogRefresh_Click"/>
</Grid>
<TextBox x:Name="KlogBox" IsReadOnly="True" Height="220" FontFamily="Consolas" FontSize="10"
Background="#0D0D0D" BorderBrush="#3E3E42" Padding="8" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
<!-- Devices Tab -->
<Panel x:Name="PageDevices" IsVisible="False">
<ScrollViewer>
<StackPanel Margin="15" Spacing="15">
<!-- Power Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="⚡ Power" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="🔄 Reboot" Background="#3E3E42" Foreground="White" Padding="10" Click="PowerReboot_Click"/>
<Button Content="⏻ Shutdown" Background="#DC3545" Foreground="White" Padding="10" Click="PowerShutdown_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- USB Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🔌 USB Drives" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔄" Background="#3E3E42" Padding="8" Click="UsbRefresh_Click"/>
</Grid>
<TextBlock x:Name="UsbListText" Text="No USB drives scanned" Foreground="#A0A0A0" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- Controller Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="🎮 Controller" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔄 Info" Background="#3E3E42" Padding="8" Click="PadRefresh_Click"/>
</Grid>
<TextBlock x:Name="PadInfoText" Text="—" Foreground="#A0A0A0" FontSize="11" TextWrapping="Wrap"/>
<TextBlock Text="Light Bar" Foreground="#A0A0A0" FontSize="12"/>
<WrapPanel>
<Button Content="🔵" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="0,0,255"/>
<Button Content="🔴" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="255,0,0"/>
<Button Content="🟢" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="0,255,0"/>
<Button Content="🟣" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="128,0,255"/>
<Button Content="⚪" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="255,255,255"/>
<Button Content="⚫" Padding="8" Margin="2" Background="#3E3E42" Click="LightBar_Click" Tag="0,0,0"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- Console LED Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="💡 Console LED" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Bright" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="0"/>
<Button Content="Medium" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="1"/>
<Button Content="Dim" Background="#3E3E42" Padding="8" Click="LedDim_Click" Tag="2"/>
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<ComboBox x:Name="LedEffectCombo" Grid.Column="0" Background="#2D2D30" SelectedIndex="1">
<ComboBoxItem>off</ComboBoxItem>
<ComboBoxItem>white</ComboBoxItem>
<ComboBoxItem>white-dim</ComboBoxItem>
<ComboBoxItem>blue</ComboBoxItem>
<ComboBoxItem>orange</ComboBoxItem>
<ComboBoxItem>purple</ComboBoxItem>
<ComboBoxItem>pink</ComboBoxItem>
<ComboBoxItem>sunrise</ComboBoxItem>
<ComboBoxItem>blue-breathe</ComboBoxItem>
<ComboBoxItem>white-breathe</ComboBoxItem>
<ComboBoxItem>orange-breathe</ComboBoxItem>
<ComboBoxItem>pink-breathe</ComboBoxItem>
<ComboBoxItem>pink-breathe-fast</ComboBoxItem>
<ComboBoxItem>blue-to-richblue</ComboBoxItem>
<ComboBoxItem>blue-white-anim</ComboBoxItem>
</ComboBox>
<Button Grid.Column="1" Content="✨ Apply" Background="#007ACC" Foreground="White" Padding="10" Click="LedEffect_Click"/>
</Grid>
</StackPanel>
</Border>
<!-- Beeper Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="🔊 Beeper" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<WrapPanel>
<Button Content="1×" Padding="10" Margin="2" Background="#3E3E42" Click="Beep_Click" Tag="1"/>
<Button Content="2×" Padding="10" Margin="2" Background="#3E3E42" Click="Beep_Click" Tag="2"/>
<Button Content="3×" Padding="10" Margin="2" Background="#3E3E42" Click="Beep_Click" Tag="3"/>
<Button Content="Mute" Padding="10" Margin="2" Background="#DC3545" Click="BeepMute_Click" Tag="1"/>
<Button Content="Unmute" Padding="10" Margin="2" Background="#3E3E42" Click="BeepMute_Click" Tag="0"/>
</WrapPanel>
</StackPanel>
</Border>
<!-- Notification Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="🔔 Notification" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<TextBox x:Name="NotifyTextBox" Watermark="Message to show on PS5..."
Background="#2D2D30" BorderBrush="#3E3E42" Padding="12" FontSize="13"/>
<Button Content="🔔 Send" Background="#007ACC" Foreground="White"
Padding="10" HorizontalAlignment="Stretch" Click="NotifySend_Click"/>
</StackPanel>
</Border>
<!-- Disc Dump Card -->
<Border Background="#252526" CornerRadius="8" Padding="15">
<StackPanel Spacing="10">
<TextBlock Text="💿 Disc Dump" Foreground="#0E9CFF" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="▶ Start" Background="#007ACC" Foreground="White" Padding="10" Click="DiscDumpStart_Click"/>
<Button Content="⏹ Cancel" Background="#DC3545" Foreground="White" Padding="10" Click="DiscDumpCancel_Click"/>
</StackPanel>
<ProgressBar x:Name="DiscDumpBar" Height="8" Minimum="0" Maximum="100" Value="0"
Background="#1E1E1E" Foreground="#0E9CFF"/>
<TextBlock x:Name="DiscDumpStatus" Text="Idle — dumps /mnt/disc to /user/data/disc_dumps"
Foreground="#808080" FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
<!-- Debug Log Tab -->
<Panel x:Name="PageLog" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Background="#252526" Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<CheckBox x:Name="LogAutoScroll" Content="Auto-scroll" Foreground="#A0A0A0" IsChecked="True"/>
<Button Content="🗑️ Clear" Background="#3E3E42" Padding="8" Click="LogClear_Click"/>
</StackPanel>
</Border>
<ScrollViewer Grid.Row="1" x:Name="LogScroller" Background="#1E1E1E">
<ItemsControl x:Name="LogItems" Margin="8"/>
</ScrollViewer>
</Grid>
</Panel>
</Panel>
</SplitView.Content>
</SplitView>
<!-- Bottom Status Bar -->
<Border Grid.Row="2" Background="#252526" Padding="10">
<TextBlock x:Name="StatusText" Text="Ready" Foreground="#808080" FontSize="11" HorizontalAlignment="Center"/>
</Border>
</Grid>
</UserControl>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PS5SuiteAndroid.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PS5SuiteAndroid.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PS5SuiteAndroid.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico"
Title="PS5SuiteAndroid">
<views:MainView />
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PS5SuiteAndroid.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.425",
"rollForward": "latestFeature"
}
}
-33
View File
@@ -1,33 +0,0 @@
using Android.App;
using Android.Content.PM;
using Avalonia;
using Avalonia.Android;
namespace PS5Upload.Android;
[Application]
public class Application : AvaloniaAndroidApplication<App>
{
public Application(nint javaReference, global::Android.Runtime.JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont()
.LogToTrace();
}
}
[Activity(
Label = "PS5 Suite",
Theme = "@style/MyTheme.NoActionBar",
Icon = "@mipmap/ic_launcher",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize)]
public class MainActivity : AvaloniaMainActivity
{
}
@@ -1,51 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-android</TargetFramework>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<LangVersion>14</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
<ApplicationId>com.manos.ps5suite</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>7.1.5</ApplicationDisplayVersion>
<ApplicationTitle>PS5 Suite</ApplicationTitle>
<AssemblyName>PS5Suite</AssemblyName>
<RootNamespace>PS5Upload</RootNamespace>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<!-- The UI relies on runtime reflection bindings ({Binding ...} without x:DataType);
trimming would strip bound properties and blank out the lists. -->
<PublishTrimmed>false</PublishTrimmed>
<RunAOTCompilation>false</RunAOTCompilation>
<Authors>Manos</Authors>
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
</PropertyGroup>
<!-- Reuse the full desktop UI/codebase so the Android app has feature parity -->
<ItemGroup>
<Compile Include="..\client-avalonia\**\*.cs"
Exclude="..\client-avalonia\Program.cs;..\client-avalonia\bin\**;..\client-avalonia\obj\**" />
<AvaloniaXaml Include="..\client-avalonia\**\*.axaml"
Exclude="..\client-avalonia\bin\**;..\client-avalonia\obj\**" />
<AvaloniaResource Include="..\client-avalonia\ps5_logo.png" Link="ps5_logo.png" />
<AvaloniaResource Include="..\client-avalonia\ps5_logo.ico" Link="ps5_logo.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.3" />
<PackageReference Include="Avalonia.Android" Version="12.1.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<Reference Include="LibProsperoPkg">
<HintPath>..\client-avalonia\libs\LibProsperoPkg.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<AndroidResource Include="Resources\**\*.xml" />
<AndroidResource Include="Resources\**\*.png" />
</ItemGroup>
</Project>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application android:label="PS5 Suite" android:icon="@mipmap/ic_launcher" android:theme="@style/MyTheme.Main" android:usesCleartextTraffic="true" />
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
</manifest>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<color name="ic_launcher_background">#1E1E1E</color>
</resources>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<style name="MyTheme.Main" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">#1E1E1E</item>
<item name="android:navigationBarColor">#1E1E1E</item>
<item name="android:windowLightStatusBar">false</item>
</style>
<style name="MyTheme.NoActionBar" parent="MyTheme.Main" />
</resources>
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
namespace PS5Upload
{
// App Manager v2 — real per-app data from sceKernelGetAppInfo +
// suspend/resume/kill/coredump through LncUtil (daemon IPC, needs etaHEN).
public partial class MainWindow
{
private class AppEntry
{
public int Pid;
public uint AppId;
public string TitleId = "";
public string Name = "";
public uint AppType;
public double CpuPct;
public int Suspended; // -1 unknown, 0 running, 1 suspended
public string Display =>
$"pid={Pid,-6} app={AppId,-4} {TitleId,-10} {Name,-20} cpu={CpuPct,6:0.00}% " +
(Suspended < 0 ? "?" : Suspended == 1 ? "suspended" : "running");
}
private readonly List<AppEntry> _apps = new();
private AppEntry? _appSel;
private DispatcherTimer? _appTimer;
private async Task AppMgrLoadAsync()
{
var txt = await _protocol.AppListV2Async();
if (txt == null)
{
AppMgrStatus.Text = $"app list failed: {_protocol.LastError}";
return;
}
_apps.Clear();
foreach (var line in txt.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("appinfo="))
{
if (!line.EndsWith("ok")) AppMgrStatus.Text = "sceKernelGetAppInfo unavailable — app fields empty";
continue;
}
var f = line.Split('|');
if (f.Length < 7) continue;
_apps.Add(new AppEntry
{
Pid = int.Parse(f[0]),
AppId = uint.Parse(f[1]),
TitleId = f[2],
Name = f[3],
AppType = uint.Parse(f[4]),
CpuPct = uint.Parse(f[5]) / 100.0,
Suspended = int.Parse(f[6]),
});
}
AppMgrList.ItemsSource = _apps.Select(a => a.Display).ToList();
if (!(AppMgrStatus.Text ?? "").StartsWith("sceKernelGetAppInfo"))
AppMgrStatus.Text = $"{_apps.Count} app(s) — select one";
}
private async void AppMgrRefresh_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { AppMgrStatus.Text = "Not connected"; return; }
await AppMgrLoadAsync();
}
private void AppMgrAutoRefresh_Changed(object? sender, RoutedEventArgs e)
{
if (AppMgrAutoRefresh.IsChecked == true)
{
_appTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
_appTimer.Tick -= AppTimer_Tick;
_appTimer.Tick += AppTimer_Tick;
_appTimer.Start();
}
else _appTimer?.Stop();
}
private async void AppTimer_Tick(object? sender, EventArgs e)
{
if (_protocol.IsConnected) await AppMgrLoadAsync();
}
private void AppMgrList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
var s = AppMgrList.SelectedItem?.ToString();
_appSel = s == null ? null : _apps.FirstOrDefault(a => a.Display == s);
}
private async Task AppAction(Func<int, int, Task<(bool ok, string msg)>> fn)
{
if (_appSel == null) { AppMgrStatus.Text = "Select an app first"; return; }
var (ok, msg) = await fn((int)_appSel.AppId, _appSel.Pid);
AppMgrStatus.Text = ok ? $"✅ {msg}" : $"❌ {msg}";
await AppMgrLoadAsync();
}
private async void AppMgrSuspend_Click(object? sender, RoutedEventArgs e)
=> await AppAction(_protocol.AppSuspendAsync);
private async void AppMgrResume_Click(object? sender, RoutedEventArgs e)
=> await AppAction(_protocol.AppResumeAsync);
private async void AppMgrKill_Click(object? sender, RoutedEventArgs e)
=> await AppAction(_protocol.AppKillAsync);
private async void AppMgrCoredump_Click(object? sender, RoutedEventArgs e)
{
if (_appSel == null) { AppMgrStatus.Text = "Select an app first"; return; }
var (ok, msg) = await _protocol.AppCoredumpAsync((int)_appSel.AppId);
AppMgrStatus.Text = ok ? $"✅ {msg}" : $"❌ {msg}";
}
}
}
+261 -56
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.IO.Compression;
using System.Linq;
@@ -357,6 +358,8 @@ namespace PS5Upload
}
}
}
// Files were queued while browsing a folder — surface the queue.
if (_localBrowsePath != null) ExitLocalBrowse();
}
private async void BrowseFolderButton_Click(object? sender, RoutedEventArgs e)
@@ -373,9 +376,94 @@ namespace PS5Upload
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = path, Icon = "📁", IsDirectory = true, Size = 0 });
}
}
if (_localBrowsePath != null) ExitLocalBrowse();
}
private void ClearLocalFiles_Click(object? sender, RoutedEventArgs e) => _localFiles.Clear();
// ============================================================
// LOCAL PANE BROWSE MODE — double-click a folder in the queue
// to navigate into it (local + NAS); files double-clicked in
// browse mode are added to the upload queue.
// ============================================================
private string? _localBrowsePath; // null = queue view
private readonly ObservableCollection<LocalFileItem> _localBrowse = new();
private const string BrowseBackSentinel = "\0back";
private void LocalFilesListBox_DoubleTapped(object? sender, TappedEventArgs e)
{
if (LocalFilesListBox.SelectedItem is not LocalFileItem item) return;
if (_localBrowsePath == null)
{
if (item.IsDirectory) EnterLocalBrowse(item.FullPath);
}
else
{
if (item.FullPath == BrowseBackSentinel) { LocalBrowseUp(); return; }
if (item.IsDirectory) EnterLocalBrowse(item.FullPath);
else
{
// Queue the file — keep the browse view so more can be picked.
_localFiles.Add(new LocalFileItem { Name = item.Name, FullPath = item.FullPath, Icon = "📄", IsDirectory = false, Size = item.Size });
Log($"➕ Queued from browse: {item.Name} ({FormatFileSize(item.Size)})");
}
}
}
private void EnterLocalBrowse(string path)
{
try
{
var entries = LocalIo.Enumerate(path);
_localBrowse.Clear();
_localBrowse.Add(new LocalFileItem { Name = "⬆ .. (Back)", FullPath = BrowseBackSentinel, Icon = "⬆", IsDirectory = false });
foreach (var en in entries.OrderByDescending(x => x.IsDirectory).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
_localBrowse.Add(new LocalFileItem { Name = en.Name, FullPath = en.FullPath, IsDirectory = en.IsDirectory, Size = en.Size, Icon = en.IsDirectory ? "📁" : "📄" });
_localBrowsePath = path;
LocalFilesListBox.ItemsSource = _localBrowse;
LocalFilesHeader.Text = "📂 " + path;
LocalBrowseBackBtn.IsVisible = true;
}
catch (Exception ex)
{
Log($"❌ Cannot browse {path}: {ex.Message}");
}
}
private void LocalBrowseUp()
{
if (_localBrowsePath == null) { ExitLocalBrowse(); return; }
string? parent = GetLocalParentPath(_localBrowsePath);
if (parent == null) ExitLocalBrowse();
else EnterLocalBrowse(parent);
}
private void ExitLocalBrowse()
{
_localBrowsePath = null;
LocalFilesListBox.ItemsSource = _localFiles;
LocalFilesHeader.Text = "📁 Local Files";
LocalBrowseBackBtn.IsVisible = false;
}
private void LocalBrowseBack_Click(object? sender, RoutedEventArgs e) => LocalBrowseUp();
private static string? GetLocalParentPath(string path)
{
if (LocalIo.IsUnc(path))
{
string p = NasManager.Normalize(path).TrimEnd('\\');
string root = NasManager.ShareRoot(p);
if (p.Length <= root.Length) return null; // at share root → exit
return p.Substring(0, p.LastIndexOf('\\'));
}
string? parent = Path.GetDirectoryName(path.TrimEnd('\\', '/'));
return string.IsNullOrEmpty(parent) ? null : parent;
}
private void ClearLocalFiles_Click(object? sender, RoutedEventArgs e)
{
_localFiles.Clear();
if (_localBrowsePath != null) ExitLocalBrowse();
}
private void LocalFilesListBox_DragOver(object? sender, DragEventArgs e) => e.DragEffects = DragDropEffects.Copy;
@@ -410,6 +498,7 @@ namespace PS5Upload
}
}
}
if (_localBrowsePath != null) ExitLocalBrowse();
}
// ============================================================
@@ -426,8 +515,20 @@ namespace PS5Upload
string tempDir = Path.Combine(Path.GetTempPath(), "ps5suite_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
// Extract ZIP
await Task.Run(() => System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir));
// Extract ZIP — stream-based so NAS-sourced archives work too
await Task.Run(() =>
{
if (LocalIo.IsUnc(zipPath) && NasManager.HasSession(zipPath))
{
using var zs = LocalIo.OpenRead(zipPath);
using var za = new System.IO.Compression.ZipArchive(zs, System.IO.Compression.ZipArchiveMode.Read);
za.ExtractToDirectory(tempDir);
}
else
{
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir);
}
});
// Add all extracted files
var files = Directory.GetFiles(tempDir, "*", SearchOption.AllDirectories);
@@ -452,8 +553,8 @@ namespace PS5Upload
{
Log($"❌ Failed to extract ZIP: {ex.Message}");
// Fall back to adding the zip file itself
FileInfo info = new FileInfo(zipPath);
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = zipPath, Icon = "📄", IsDirectory = false, Size = info.Length });
long sz = LocalIo.GetLength(zipPath);
_localFiles.Add(new LocalFileItem { Name = LocalIo.GetName(zipPath), FullPath = zipPath, Icon = "📄", IsDirectory = false, Size = sz });
}
}
@@ -480,25 +581,74 @@ namespace PS5Upload
}
// ============================================================
// NAS/SMB SUPPORT (ps5upload-style)
// Adds network paths (\\server\share\folder) to upload queue
// NAS/SMB SUPPORT (managed SMB via SMBLibrary — works on
// Windows/Linux/macOS/Android with explicit credentials)
// ============================================================
private async void AddNasPath_Click(object? sender, RoutedEventArgs e)
{
try
{
string? nasPath = await ShowInputDialogAsync(
"Add NAS/Network Path",
"Enter UNC path (\\\\server\\share\\folder):",
"\\\\NAS\\games");
if (string.IsNullOrWhiteSpace(nasPath)) return;
nasPath = nasPath.Trim();
// Validate UNC path format
if (!nasPath.StartsWith("\\\\") && !nasPath.StartsWith("//"))
// Saved connections → quick picker (or "New" for the full dialog).
if (_nasSaved.Count > 0)
{
var pick = await ShowNasPickerAsync();
if (pick == null) return;
if (!ReferenceEquals(pick, _nasNewSentinel))
{
try
{
if (!NasManager.HasSession(pick.Path))
{
Log($"🔐 Reconnecting to \\\\{pick.Server}\\{pick.Share}...");
string pass = NasManager.DecodePass(pick.PassEnc);
await Task.Run(() => NasManager.Connect(pick.Server, pick.Share, pick.User, pass));
Log($"✅ Connected to \\\\{pick.Server}\\{pick.Share}");
}
AddNasItem(NasManager.Normalize(pick.Path));
}
catch (Exception ex)
{
Log($"❌ Saved NAS connection failed: {ex.Message}");
await ShowMessageAsync($"Saved connection failed:\n{ex.Message}\n\nUse ➕ New to enter different credentials.", "Connection Failed");
}
return;
}
}
var input = await ShowNasDialogAsync();
if (input == null || string.IsNullOrWhiteSpace(input.Value.path)) return;
string nasPath = NasManager.Normalize(input.Value.path);
if (!NasManager.TrySplit(nasPath, out string server, out string share, out _))
{
// "\\host" with no share — try to list the host's shares
// via OS tools so the user sees what's available.
string bare = nasPath.TrimEnd('\\');
if (NasManager.IsUncPath(bare) && bare.Count(c => c == '\\') == 2)
{
string host = bare.TrimStart('\\');
Log($"🌐 Querying shares on \\\\{host}...");
var shares = await Task.Run(() => NasManager.EnumerateSharesOs(host, input.Value.user, input.Value.pass));
if (shares != null)
{
await ShowMessageAsync(
$"\\\\{host} is reachable — pick a share and add it to the path.\n\n" +
"Shares found:\n " + string.Join("\n ", shares.Select(s => $"\\\\{host}\\{s}")) +
"\n\nExample: \\\\" + host + "\\" + shares[0],
"Select a Share");
}
else
{
await ShowMessageAsync(
$"The path \\\\{host} is missing the share name.\n\n" +
"SMB connects to a share, not a host:\n" +
" \\\\host\\share\\folder\n\n" +
"Couldn't enumerate shares automatically — check your NAS admin page for share names.",
"Share Name Required");
}
return;
}
await ShowMessageAsync(
"Invalid path format.\n\n" +
"Use UNC format: \\\\server\\share\\folder\n" +
@@ -506,49 +656,104 @@ namespace PS5Upload
"Invalid Path");
return;
}
// Normalize path (convert // to \\ for Windows)
if (nasPath.StartsWith("//"))
// Establish/reuse the authenticated session for this share.
if (!NasManager.HasSession(nasPath))
{
nasPath = "\\\\" + nasPath.Substring(2).Replace("/", "\\");
}
Log($"🌐 Checking NAS path: {nasPath}");
// Check if path exists
if (Directory.Exists(nasPath))
{
// It's a directory - add it
DirectoryInfo dirInfo = new DirectoryInfo(nasPath);
_localFiles.Add(new LocalFileItem {
Name = dirInfo.Name,
FullPath = nasPath,
Icon = "🌐",
IsDirectory = true,
Size = 0
});
Log($"✅ Added NAS folder: {dirInfo.Name} ({nasPath})");
}
else if (File.Exists(nasPath))
{
// It's a file - add it
FileInfo fileInfo = new FileInfo(nasPath);
// Check if it's a ZIP file
if (fileInfo.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase))
// No creds entered → a Windows-authenticated share may work
// via System.IO; otherwise try guest, then ask to retry.
if (string.IsNullOrEmpty(input.Value.user))
{
await ExtractAndAddZipContents(nasPath, fileInfo.Name);
if (Directory.Exists(nasPath) || File.Exists(nasPath))
{
Log($"🌐 NAS path accessible via OS session (no SMB login needed)");
AddNasItem(nasPath);
return;
}
}
Log($"🔐 Connecting to \\\\{server}\\{share}...");
try
{
await Task.Run(() => NasManager.Connect(server, share, input.Value.user, input.Value.pass));
Log(input.Value.user.Length > 0
? $"✅ Connected to \\\\{server}\\{share} as {input.Value.user}"
: $"✅ Connected to \\\\{server}\\{share} (guest)");
// Optional persistence — path + creds for next launch.
if (input.Value.remember)
{
var existing = _nasSaved.FindIndex(c => c.Path.Equals(nasPath, StringComparison.OrdinalIgnoreCase));
var saved = new NasManager.SavedConnection
{
Path = nasPath, Server = server, Share = share,
User = input.Value.user, PassEnc = NasManager.EncodePass(input.Value.pass)
};
if (existing >= 0) _nasSaved[existing] = saved; else _nasSaved.Add(saved);
SaveNasConnections();
Log($"💾 Saved NAS connection: {nasPath}");
}
}
catch (NasManager.NasAuthException ex)
{
Log($"🔒 NAS auth failed: {ex.Message}");
await ShowMessageAsync($"{ex.Message}\n\nRe-open the NAS dialog and enter the share's username/password.", "Authentication Failed");
return;
}
catch (Exception ex)
{
Log($"❌ NAS connect failed: {ex.Message}");
await ShowMessageAsync($"Cannot connect to \\\\{server}\\{share}:\n{ex.Message}", "Connection Failed");
return;
}
}
Log($"🌐 Checking NAS path: {nasPath}");
AddNasItem(nasPath);
}
catch (Exception ex)
{
Log($"❌ NAS error: {ex.Message}");
await ShowMessageAsync($"Error accessing NAS path: {ex.Message}", "Error");
}
}
/// Adds the NAS path (file or folder) to the upload queue — works via
/// the SMB session when present, or the OS's own share session.
private async void AddNasItem(string nasPath)
{
try
{
bool isDir, isFile;
long size = 0;
if (NasManager.HasSession(nasPath))
{
var kind = NasManager.ExistsAsDir(nasPath);
isDir = kind == true; isFile = kind == false;
if (isFile) size = NasManager.GetLength(nasPath);
}
else
{
isDir = Directory.Exists(nasPath); isFile = File.Exists(nasPath);
if (isFile) size = new FileInfo(nasPath).Length;
}
string name = LocalIo.GetName(nasPath);
if (isDir)
{
_localFiles.Add(new LocalFileItem { Name = name, FullPath = nasPath, Icon = "🌐", IsDirectory = true, Size = 0 });
Log($"✅ Added NAS folder: {name} ({nasPath})");
}
else if (isFile)
{
if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
await ExtractAndAddZipContents(nasPath, name);
}
else
{
_localFiles.Add(new LocalFileItem {
Name = fileInfo.Name,
FullPath = nasPath,
Icon = "🌐",
IsDirectory = false,
Size = fileInfo.Length
});
Log($"✅ Added NAS file: {fileInfo.Name} ({FormatFileSize(fileInfo.Length)})");
_localFiles.Add(new LocalFileItem { Name = name, FullPath = nasPath, Icon = "🌐", IsDirectory = false, Size = size });
Log($"✅ Added NAS file: {name} ({FormatFileSize(size)})");
}
}
else
+509 -8
View File
@@ -232,6 +232,32 @@ namespace PS5Upload
else await ShowMessageAsync("No favorite selected");
}
// ============================================================
// SAVED NAS CONNECTIONS
// ============================================================
private void LoadNasConnections()
{
try
{
if (File.Exists(NasFileName))
{
string json = File.ReadAllText(NasFileName);
_nasSaved = System.Text.Json.JsonSerializer.Deserialize<List<NasManager.SavedConnection>>(json) ?? new();
}
}
catch (Exception ex) { Log($"⚠️ Failed to load NAS connections: {ex.Message}"); }
}
private void SaveNasConnections()
{
try
{
string json = System.Text.Json.JsonSerializer.Serialize(_nasSaved, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(NasFileName, json);
}
catch (Exception ex) { Log($"❌ Failed to save NAS connections: {ex.Message}"); }
}
private async void FavoritesComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (FavoritesComboBox.SelectedItem is string favoritePath && !string.IsNullOrWhiteSpace(favoritePath))
@@ -489,23 +515,24 @@ namespace PS5Upload
{
if (FailedTransfersListBox.SelectedItem is TransferHistoryItem failedItem)
{
if (!File.Exists(failedItem.LocalPath) && !Directory.Exists(failedItem.LocalPath))
bool isUnc = LocalIo.IsUnc(failedItem.LocalPath) && NasManager.HasSession(failedItem.LocalPath);
bool isFile = isUnc ? NasManager.IsFile(failedItem.LocalPath) : File.Exists(failedItem.LocalPath);
bool isDir = !isFile && (isUnc ? NasManager.IsDirectory(failedItem.LocalPath) : Directory.Exists(failedItem.LocalPath));
if (!isFile && !isDir)
{
await ShowMessageAsync($"File or folder not found: {failedItem.LocalPath}", "Error");
await ShowMessageAsync($"File or folder not found: {failedItem.LocalPath}\n\n(NAS items need an active session — re-add via 🌐 NAS if it was a network path.)", "Error");
return;
}
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5. Please connect first.", "Error"); return; }
_failedTransfers.Remove(failedItem);
if (File.Exists(failedItem.LocalPath))
if (isFile)
{
FileInfo info = new(failedItem.LocalPath);
_localFiles.Add(new LocalFileItem { Name = info.Name, FullPath = failedItem.LocalPath, Size = info.Length, IsDirectory = false, Icon = "📄" });
_localFiles.Add(new LocalFileItem { Name = LocalIo.GetName(failedItem.LocalPath), FullPath = failedItem.LocalPath, Size = LocalIo.GetLength(failedItem.LocalPath), IsDirectory = false, Icon = "📄" });
}
else if (Directory.Exists(failedItem.LocalPath))
else
{
DirectoryInfo dirInfo = new(failedItem.LocalPath);
_localFiles.Add(new LocalFileItem { Name = dirInfo.Name, FullPath = failedItem.LocalPath, Size = 0, IsDirectory = true, Icon = "📁" });
_localFiles.Add(new LocalFileItem { Name = LocalIo.GetName(failedItem.LocalPath), FullPath = failedItem.LocalPath, Size = 0, IsDirectory = true, Icon = "📁" });
}
Log($"🔄 Retrying upload: {failedItem.FileName}");
await ShowMessageAsync($"Added {failedItem.FileName} back to upload queue. Click 'Upload to PS5' to retry.", "Retry Queued");
@@ -753,6 +780,112 @@ namespace PS5Upload
catch (Exception ex) { Log($"❌ Fan error: {ex.Message}"); }
}
// ============================================================
// POWER & DEVICES (reboot/shutdown, USB drives, controllers)
// ============================================================
private async void PowerReboot_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
if (!await ShowConfirmAsync("Reboot the PS5 now?\nThe connection will drop.")) return;
try
{
Log("⚡ Sending reboot...");
var (success, message) = await _protocol.PowerActionAsync("reboot");
Log(success ? "✅ Reboot command accepted — console is restarting" : $"❌ Reboot failed: {message}");
}
catch (Exception ex) { Log($"❌ Power error: {ex.Message}"); }
}
private async void PowerShutdown_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
if (!await ShowConfirmAsync("Shut down the PS5 now?\nThe connection will drop.")) return;
try
{
Log("⚡ Sending shutdown...");
var (success, message) = await _protocol.PowerActionAsync("shutdown");
Log(success ? "✅ Shutdown command accepted — console is powering off" : $"❌ Shutdown failed: {message}");
}
catch (Exception ex) { Log($"❌ Power error: {ex.Message}"); }
}
private readonly List<PS5UsbDrive> _usbDrives = new();
private async void RefreshUsbDrives_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
Log("🔌 Scanning USB drives...");
var drives = await _protocol.ListUsbDrivesAsync();
_usbDrives.Clear();
_usbDrives.AddRange(drives);
var items = drives.Select(d =>
$"🔌 {d.MountPath} ({d.FsType}, {d.Device}) {d.FreeGB} free / {d.TotalGB}").ToList();
UsbDrivesList.ItemsSource = items.Count > 0 ? items : new List<string> { "No USB drives mounted" };
Log(drives.Count > 0 ? $"✅ {drives.Count} USB drive(s)" : "ℹ️ No USB drives mounted");
}
catch (Exception ex) { Log($"❌ USB error: {ex.Message}"); }
}
private async void BrowseUsbDrive_Click(object? sender, RoutedEventArgs e)
{
int idx = UsbDrivesList.SelectedIndex;
if (idx < 0 || idx >= _usbDrives.Count) { await ShowMessageAsync("Select a USB drive first.", "USB"); return; }
string path = _usbDrives[idx].MountPath;
Log($"📂 Navigating to {path}");
_currentPS5Path = path;
NavFiles.IsChecked = true;
NavButton_Click(NavFiles, e);
await LoadPS5DirectoryAsync(path);
}
private async void RefreshPadInfo_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
Log("🎮 Reading controller info...");
string? info = await _protocol.GetPadInfoAsync();
if (info == null) { PadInfoText.Text = "—"; Log("❌ Pad info unavailable"); return; }
var kv = info.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (kv.TryGetValue("error", out var err))
{
PadInfoText.Text = $"⚠️ {err}";
Log($"🎮 Pad: {err}");
return;
}
int handle = int.TryParse(kv.GetValueOrDefault("handle", "-1"), out var h) ? h : -1;
if (handle < 0)
{
PadInfoText.Text = "No controller connected";
Log($"🎮 No controller connected (handle={handle} init={kv.GetValueOrDefault("init", "?")} user={kv.GetValueOrDefault("user", "?")})");
if (kv.TryGetValue("tries", out var tries))
Log($"🎮 open attempts: {tries}");
return;
}
// The info struct layout varies by SDK rev — show what we know
// for sure plus the raw dump so offsets can be mapped.
string hex = kv.GetValueOrDefault("infohex", "");
string summary = $"Handle: {handle} User: {kv.GetValueOrDefault("user", "?")}\n";
if (hex.Length >= 16)
summary += $"Connected flag: {hex.Substring(12, 2)} Type: {hex.Substring(10, 2)}\n";
summary += $"Info rc: {kv.GetValueOrDefault("info", "?")} State rc: {kv.GetValueOrDefault("state", "?")}";
PadInfoText.Text = summary;
Log($"🎮 Pad info: handle={handle} user={kv.GetValueOrDefault("user", "?")}");
if (!string.IsNullOrEmpty(hex))
Log($"🎮 infohex: {hex}");
}
catch (Exception ex) { Log($"❌ Pad error: {ex.Message}"); }
}
// ============================================================
// PKG INSTALL (ps5upload-style)
// ============================================================
@@ -1188,6 +1321,26 @@ namespace PS5Upload
foreach (var f in selected) _localFiles.Remove(f);
}
/// Right-click a NAS item → close its SMB session (logoff + disconnect).
/// Local paths just get a nudge — nothing to disconnect.
private void DisconnectNasMenuItem_Click(object? sender, RoutedEventArgs e)
{
var selected = LocalFilesListBox.SelectedItems?.Cast<LocalFileItem>().ToList();
string? target = selected?.Select(f => f.FullPath).FirstOrDefault(LocalIo.IsUnc)
?? (_localBrowsePath != null && LocalIo.IsUnc(_localBrowsePath) ? _localBrowsePath : null);
if (target == null)
{
Log("ℹ️ No NAS item selected — nothing to disconnect");
return;
}
if (NasManager.Disconnect(target))
{
Log($"🔌 NAS session closed: {NasManager.ShareRoot(target)}");
if (_localBrowsePath != null) ExitLocalBrowse();
}
else Log("ℹ️ No active SMB session for that share");
}
// ============================================================
// FPKG CONVERSION (LibProsperoPkg)
// ============================================================
@@ -1366,5 +1519,353 @@ namespace PS5Upload
FpkgProgressBar.IsVisible = false;
}
}
// ============================================================
// LIGHT BAR / VIBRATION / SCREENSHOT / NOTIFY / LED / BEEPER
// ============================================================
private async void LightBar_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string rgb = (sender as Control)?.Tag as string ?? "0,0,255";
try
{
var (success, message) = await _protocol.PadActionAsync($"lightbar|{rgb}");
Log(success ? $"🎨 Light bar set ({rgb})" : $"❌ Light bar: {message}");
}
catch (Exception ex) { Log($"❌ Light bar error: {ex.Message}"); }
}
private async void Screenshot_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
Log("📸 Requesting screenshot...");
var (success, message) = await _protocol.CaptureScreenshotAsync();
Log(success
? $"✅ Screenshot captured ({message}) — find it under Saves & Media → Screenshots"
: $"❌ Screenshot failed: {message}");
}
catch (Exception ex) { Log($"❌ Screenshot error: {ex.Message}"); }
}
private async void SendNotify_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string text = NotifyTextBox.Text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(text)) { await ShowMessageAsync("Type a notification message first.", "Notify"); return; }
try
{
var (success, message) = await _protocol.NotifyAsync(text);
Log(success ? $"🔔 Notification sent: \"{text}\"" : $"❌ Notify failed: {message}");
}
catch (Exception ex) { Log($"❌ Notify error: {ex.Message}"); }
}
private async void LedDim_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string level = (sender as Control)?.Tag as string ?? "0";
try
{
var (success, message) = await _protocol.IccControlAsync($"led|{level}");
Log(success ? $"💡 LED brightness set" : $"❌ LED: {message}");
}
catch (Exception ex) { Log($"❌ LED error: {ex.Message}"); }
}
private async void LedColor_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string bwo = (sender as Control)?.Tag as string ?? "0,255,0";
try
{
var (success, message) = await _protocol.IccControlAsync($"ledcolor|{bwo}");
Log(success ? $"💡 LED color set" : $"❌ LED color: {message}");
}
catch (Exception ex) { Log($"❌ LED color error: {ex.Message}"); }
}
private async void LedEffect_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string label = ((LedEffectCombo.SelectedItem as ComboBoxItem)?.Content as string) ?? "white";
// ComboBox display text may carry a description suffix — take the
// first token, which is always the preset name.
string name = label.Split(' ')[0];
try
{
var (success, message) = await _protocol.IccControlAsync($"ledeffect|{name}");
Log(success ? $"✨ LED effect: {name}" : $"❌ LED effect: {message}");
}
catch (Exception ex) { Log($"❌ LED effect error: {ex.Message}"); }
}
private async void Buzzer_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string type = (sender as Control)?.Tag as string ?? "1";
try
{
var (success, message) = await _protocol.IccControlAsync($"buzzer|{type}");
Log(success ? "🔊 Beep!" : $"❌ Beeper: {message}");
}
catch (Exception ex) { Log($"❌ Beeper error: {ex.Message}"); }
}
private async void BuzzerVol_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string level = (sender as Control)?.Tag as string ?? "0";
try
{
var (success, message) = await _protocol.IccControlAsync($"buzzervol|{level}");
Log(success ? "🔊 Beeper volume set" : $"❌ Beeper volume: {message}");
}
catch (Exception ex) { Log($"❌ Beeper volume error: {ex.Message}"); }
}
private async void BuzzerMute_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
string mute = (sender as Control)?.Tag as string ?? "1";
try
{
var (success, message) = await _protocol.IccControlAsync($"buzzermute|{mute}");
Log(success ? (mute == "1" ? "🔇 Beeper muted" : "🔊 Beeper unmuted") : $"❌ Beeper mute: {message}");
}
catch (Exception ex) { Log($"❌ Beeper mute error: {ex.Message}"); }
}
// ============================================================
// DISC DUMP
// ============================================================
private DispatcherTimer? _discDumpTimer;
private async void DiscDumpStart_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
var (success, message) = await _protocol.DiscDumpAsync("start");
if (success)
{
Log($"💿 Disc dump started → {message}");
DiscDumpStatusText.Text = $"Dumping to {message}...";
_discDumpTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_discDumpTimer.Tick -= DiscDumpTimer_Tick;
_discDumpTimer.Tick += DiscDumpTimer_Tick;
_discDumpTimer.Start();
}
else
{
DiscDumpStatusText.Text = $"❌ {message}";
Log($"❌ Disc dump: {message}");
}
}
catch (Exception ex) { Log($"❌ Disc dump error: {ex.Message}"); }
}
private async void DiscDumpTimer_Tick(object? sender, EventArgs e)
{
try
{
var (success, message) = await _protocol.DiscDumpAsync("status");
if (!success && string.IsNullOrEmpty(message)) return;
var kv = message.Split('|')
.Select(l => l.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
bool active = kv.GetValueOrDefault("active", "0") == "1";
ulong done = ulong.TryParse(kv.GetValueOrDefault("done", "0"), out var d) ? d : 0;
ulong total = ulong.TryParse(kv.GetValueOrDefault("total", "0"), out var t) ? t : 0;
string file = kv.GetValueOrDefault("file", "");
string err = kv.GetValueOrDefault("err", "");
if (total > 0)
{
DiscDumpProgress.Value = Math.Min(100, (double)done * 100.0 / total);
DiscDumpStatusText.Text = $"{done / 1048576:N0} / {total / 1048576:N0} MB — {file}";
}
else
{
DiscDumpStatusText.Text = $"{done / 1048576:N0} MB — {file}";
}
if (!active)
{
_discDumpTimer?.Stop();
DiscDumpStatusText.Text = string.IsNullOrEmpty(err)
? $"✅ Done — {done / 1048576:N0} MB copied to {kv.GetValueOrDefault("dest", "?")}"
: $"⚠️ Stopped: {err}";
Log($"💿 {DiscDumpStatusText.Text}");
}
}
catch { /* transient read errors — keep polling */ }
}
private async void DiscDumpStatus_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
var (success, message) = await _protocol.DiscDumpAsync("status");
DiscDumpStatusText.Text = message;
Log($"💿 Status: {message}");
}
catch (Exception ex) { Log($"❌ Disc dump error: {ex.Message}"); }
}
private async void DiscDumpCancel_Click(object? sender, RoutedEventArgs e)
{
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
try
{
var (success, message) = await _protocol.DiscDumpAsync("cancel");
Log(success ? "💿 Dump cancelled" : $"❌ Cancel: {message}");
}
catch (Exception ex) { Log($"❌ Disc dump 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 readonly List<StoreItem> _storeItems = new();
private static readonly HttpClient _storeHttp = new() { Timeout = TimeSpan.FromSeconds(20) };
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...";
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>();
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;
Log($"🛒 Page {page} unreachable ({ex.Message}) — stopping");
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";
Log($"🛒 Catalog: {items.Count} PS5 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";
Log($"❌ 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 (!_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;
try
{
string url = $"https://pkg-zone.com/download/ps5/{id}/latest";
Log($"📥 Installing {name} from pkg-zone...");
var (success, result) = await _protocol.InstallPkgAsync(url);
Log(success
? $"✅ Install accepted: {name} — check the PS5 home screen"
: $"❌ Install failed: {result}");
}
catch (Exception ex) { Log($"❌ Install error: {ex.Message}"); }
}
}
}
+4
View File
@@ -35,6 +35,8 @@ namespace PS5Upload
return;
}
KlogText.Text = txt;
if (KlogAutoScroll.IsChecked == true)
KlogText.CaretIndex = txt.Length;
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss}";
}
@@ -63,6 +65,8 @@ namespace PS5Upload
if (txt != null)
{
KlogText.Text = txt;
if (KlogAutoScroll.IsChecked == true)
KlogText.CaretIndex = txt.Length;
KlogStatus.Text = $"{txt.Length:N0} bytes · {DateTime.Now:HH:mm:ss} (auto)";
}
}
+97
View File
@@ -549,6 +549,9 @@ namespace PS5Upload
else HwThermalAlertText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#28A745"));
}
var net = await _protocol.GetNetInfoAsync();
if (net != null) ApplyNetInfo(net, fillStatic: true);
_hwStaticLoaded = true;
}
@@ -618,11 +621,105 @@ namespace PS5Upload
HwCpuAvgText.Text = $"{cpu.Average}%";
}
// Live traffic rates — refresh counters every tick
var net = await _protocol.GetNetInfoAsync();
if (net != null) ApplyNetInfo(net, fillStatic: false);
HwStatusText.Text = $"Last updated: {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex) { HwStatusText.Text = $"Error: {ex.Message}"; }
}
// ---------------- network info ----------------
private ulong _netPrevRx, _netPrevTx;
private DateTime _netPrevT = DateTime.MinValue;
private void ApplyNetInfo(string txt, bool fillStatic)
{
var kv = new Dictionary<string, string>(StringComparer.Ordinal);
ulong rx = 0, tx = 0;
string? activeIf = null; ulong activeBytes = 0;
foreach (var raw in txt.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.Trim();
if (line.StartsWith("if="))
{
var f = line.Split('|');
if (f.Length == 3 && ulong.TryParse(f[1].AsSpan(3), out ulong r) && ulong.TryParse(f[2].AsSpan(3), out ulong t))
{
rx += r; tx += t;
if (r + t > activeBytes) { activeBytes = r + t; activeIf = f[0].Substring(3); }
}
continue;
}
int eq = line.IndexOf('=');
if (eq > 0) kv[line[..eq]] = line[(eq + 1)..];
}
if (fillStatic)
{
if (kv.TryGetValue("state", out var st))
NetStateText.Text = st switch { "3" => "🟢 Online", "2" => "🟡 IP obtained", "1" => "🟡 Connecting…", "0" => "🔴 Offline", _ => st };
if (kv.TryGetValue("ssid", out var ssid)) NetSsidText.Text = ssid;
if (kv.TryGetValue("rssi", out var rssi)) NetRssiText.Text = $"{rssi}%";
if (kv.TryGetValue("ip", out var ip)) NetIpText.Text = ip;
// Classify the raw fN= IPv4 fields: netmask starts with 255,
// gateway is the first remaining non-IP address, DNS are rest.
var fvals = kv.Where(p => p.Key.StartsWith("f") && System.Net.IPAddress.TryParse(p.Value, out _))
.Select(p => p.Value).Where(v => v != "0.0.0.0").ToList();
var netmask = fvals.FirstOrDefault(v => v.StartsWith("255."));
var rest = fvals.Where(v => v != netmask && v != ip).ToList();
if (rest.Count > 0) NetGwText.Text = rest[0];
if (rest.Count > 1) NetDnsText.Text = string.Join(" ", rest.Skip(1));
if (kv.TryGetValue("mac", out var mac)) NetMacText.Text = mac;
var mtu = kv.TryGetValue("mtu", out var m) ? m : "—";
var link = kv.TryGetValue("link", out var l) ? (l == "0" ? "down" : "up") : "?";
var dev = kv.TryGetValue("device", out var d) ? (d == "1" ? "WiFi" : "Ethernet") : "";
NetMtuText.Text = $"{mtu} / {link}{(dev.Length > 0 ? $" / {dev}" : "")}";
if (kv.TryGetValue("nat_type", out var nt))
NetNatText.Text = nt switch { "1" => "Type 1 (Open)", "2" => "Type 2 (Moderate)", "3" => "Type 3 (Strict)", _ => $"Type {nt}" };
if (kv.TryGetValue("nat_ip", out var nip))
NetNatIpText.Text = kv.TryGetValue("nat_port", out var np) ? $"{nip}:{np}" : nip;
}
// rates from counter deltas
var now = DateTime.UtcNow;
if (_netPrevT != DateTime.MinValue)
{
double dt = (now - _netPrevT).TotalSeconds;
if (dt > 0.3)
{
NetRxRateText.Text = $"{FormatRate((ulong)Math.Max(0, (double)(rx - _netPrevRx)) / dt)}";
NetTxRateText.Text = $"{FormatRate((ulong)Math.Max(0, (double)(tx - _netPrevTx)) / dt)}";
if (activeIf != null) NetIfText.Text = activeIf;
NetTotalsText.Text = $"↓{FormatBytes(rx)} ↑{FormatBytes(tx)}";
}
}
_netPrevRx = rx; _netPrevTx = tx; _netPrevT = now;
}
private static string FormatRate(double bps)
{
if (bps >= 1024.0 * 1024 * 1024) return $"{bps / (1024.0 * 1024 * 1024):0.00} GB/s";
if (bps >= 1024.0 * 1024) return $"{bps / (1024.0 * 1024):0.0} MB/s";
if (bps >= 1024.0) return $"{bps / 1024.0:0.0} KB/s";
return $"{bps:0} B/s";
}
private async void NetSpeedTestButton_Click(object? sender, RoutedEventArgs e)
{
if (_protocol == null) return;
NetSpeedTestButton.IsEnabled = false;
NetSpeedText.Text = "Running…";
try
{
var mbps = await _protocol.NetSpeedTestAsync();
NetSpeedText.Text = mbps.HasValue ? $"Link: {mbps.Value:0} Mbps ↓" : $"Failed: {_protocol.LastError}";
}
catch (Exception ex) { NetSpeedText.Text = $"Error: {ex.Message}"; }
finally { NetSpeedTestButton.IsEnabled = true; }
}
private async void LoadModulesButton_Click(object? sender, RoutedEventArgs e)
{
if (_protocol == null || !_protocol.IsConnected) { Log("❌ Not connected to PS5"); return; }
+61 -26
View File
@@ -122,6 +122,19 @@ namespace PS5Upload
if (_localFiles.Count == 0) { await ShowMessageAsync("No files selected for upload"); return; }
if (!_protocol.IsConnected) { await ShowMessageAsync("Not connected to PS5", "Error"); return; }
// Queued a WHOLE NAS share? Enumerating it recursively can mean
// thousands of files over SMB — confirm before burning the time.
var shareRoots = _localFiles.Where(f => f.IsDirectory && LocalIo.IsUnc(f.FullPath)
&& NasManager.Normalize(f.FullPath).TrimEnd('\\').Equals(NasManager.ShareRoot(f.FullPath), StringComparison.OrdinalIgnoreCase)).ToList();
if (shareRoots.Count > 0)
{
if (!await ShowConfirmAsync(
"You're uploading ENTIRE share(s):\n " + string.Join("\n ", shareRoots.Select(f => f.FullPath)) +
"\n\nThis will enumerate every file on the share (can take a while).\nTip: double-click it and pick the files/folders you actually want.\n\nContinue anyway?",
"Upload Entire Share?"))
return;
}
UploadButton.IsEnabled = false;
CancelButton.IsEnabled = true;
ProgressPanel.IsVisible = true;
@@ -134,17 +147,18 @@ namespace PS5Upload
TotalProgressText.Text = "Collecting files...";
Log("Collecting files...");
var ct = _uploadCancellation.Token;
var allFiles = await Task.Run(() =>
{
var files = new List<(string localPath, string remotePath)>();
foreach (var item in localFilesCopy)
{
string targetBasePath = item.RemotePathOverride ?? (currentPath + "/" + item.Name);
if (item.IsDirectory) CollectFilesFromDirectory(item.FullPath, targetBasePath, files);
if (item.IsDirectory) CollectFilesFromDirectory(item.FullPath, targetBasePath, files, ct);
else files.Add((item.FullPath, targetBasePath));
}
return files;
});
}, ct);
Log("Checking for existing files...");
var filesToUpload = await FilterDuplicateFilesAsync(allFiles);
@@ -158,7 +172,7 @@ namespace PS5Upload
allFiles = filesToUpload;
_totalFilesToUpload = allFiles.Count;
_totalBytesToUpload = await Task.Run(() => { long total = 0; foreach (var f in allFiles) { try { total += new FileInfo(f.localPath).Length; } catch { } } return total; });
_totalBytesToUpload = await Task.Run(() => { long total = 0; foreach (var f in allFiles) { try { total += LocalIo.GetLength(f.localPath); } catch { } } return total; });
_totalBytesUploaded = 0; _completedFiles = 0; _uploadStartTime = DateTime.Now;
_fileProgressBytes.Clear(); _fileChunkProgressBytes.Clear(); _chunkLogLastBytes.Clear();
_smoothedETA = TimeSpan.Zero; _speedWindowIndex = 0; _speedWindowCount = 0; _currentSpeed = 0;
@@ -251,7 +265,7 @@ namespace PS5Upload
while (fileQueue.Count > 0 && activeTasks.Count < MaxParallelUploads)
{
var (localPath, remotePath) = fileQueue.Dequeue();
long fileSize = new FileInfo(localPath).Length;
long fileSize = LocalIo.GetLength(localPath);
bool isLargeFile = fileSize > LargeFileThresholdBytes;
bool isHugeFile = fileSize >= HugeFileThresholdBytes;
@@ -267,6 +281,7 @@ namespace PS5Upload
var task = UploadFileParallelAsync(connection, localPath, remotePath, _uploadCancellation.Token);
activeTasks.Add(task); taskToConnection[task] = connection; taskToFilePath[task] = localPath; taskToRemotePath[task] = remotePath;
_activeTaskCount = activeTasks.Count;
taskIsLargeFile[task] = isLargeFile; taskIsHugeFile[task] = isHugeFile;
fileChunkCounts[localPath] = 1; fileChunksCompleted[localPath] = 0;
}
@@ -367,24 +382,24 @@ namespace PS5Upload
try
{
string fileName = Path.GetFileName(localPath);
FileInfo fileInfo = new FileInfo(localPath);
long fileLen = LocalIo.GetLength(localPath);
if (fileInfo.Length > ChunkThresholdBytes)
if (fileLen > ChunkThresholdBytes)
{
int maxParallelChunks = fileInfo.Length >= HugeFileThresholdBytes ? MaxParallelChunksForHugeFile : MaxParallelChunksForLargeFile;
long chunkSize = fileInfo.Length >= HugeFileThresholdBytes ? HugeFileChunkSizeBytes : DefaultChunkSizeBytes;
int maxParallelChunks = fileLen >= HugeFileThresholdBytes ? MaxParallelChunksForHugeFile : MaxParallelChunksForLargeFile;
long chunkSize = fileLen >= HugeFileThresholdBytes ? HugeFileChunkSizeBytes : DefaultChunkSizeBytes;
// Scale chunk size so medium files still get parallel lanes:
// a 140MB file shouldn't fall back to 1 chunk / 1 lane while
// a 10GB file grabs 10 workers.
long scaledChunk = fileInfo.Length / maxParallelChunks;
long scaledChunk = fileLen / maxParallelChunks;
long minChunk = 64L * 1024 * 1024;
if (scaledChunk < minChunk) scaledChunk = minChunk;
if (scaledChunk < chunkSize) chunkSize = scaledChunk;
long totalChunks = (fileInfo.Length + chunkSize - 1) / chunkSize;
long totalChunks = (fileLen + chunkSize - 1) / chunkSize;
int workerCount = (int)Math.Min(totalChunks, Math.Max(1, maxParallelChunks));
Log($"⬆️ Uploading (chunked): {fileName} ({FormatFileSize(fileInfo.Length)}) {workerCount} lanes");
Log($"⬆️ Uploading (chunked): {fileName} ({FormatFileSize(fileLen)}) {workerCount} lanes");
_fileProgressBytes[localPath] = 0;
_fileChunkProgressBytes[localPath] = new ConcurrentDictionary<long, long>();
_chunkLogLastBytes[localPath] = 0;
@@ -443,12 +458,12 @@ namespace PS5Upload
async Task DoChunk(PS5Protocol wConn, int chunkIndex, Action? readyCallback)
{
long offset = chunkIndex * chunkSize;
long size = Math.Min(chunkSize, fileInfo.Length - offset);
long size = Math.Min(chunkSize, fileLen - offset);
long humanIdx = chunkIndex + 1;
var prog = MakeChunkProgress(offset, size, humanIdx, totalChunks);
bool ok = await wConn.UploadFileAsync(localPath, remotePath, prog, cancellationToken, offset, size, readyCallback);
if (!ok) throw new Exception($"Chunk {humanIdx}/{totalChunks} failed for {fileName}");
if (_fileChunkProgressBytes.TryGetValue(localPath, out var cm)) { cm[offset] = size; long agg = cm.Values.Sum(); long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long d = agg - prev; if (d > 0) { Interlocked.Add(ref _totalBytesUploaded, d); _fileProgressBytes[localPath] = agg; } _currentFileBytes = agg; _currentFileTotalBytes = fileInfo.Length; }
if (_fileChunkProgressBytes.TryGetValue(localPath, out var cm)) { cm[offset] = size; long agg = cm.Values.Sum(); long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long d = agg - prev; if (d > 0) { Interlocked.Add(ref _totalBytesUploaded, d); _fileProgressBytes[localPath] = agg; } _currentFileBytes = agg; _currentFileTotalBytes = fileLen; }
}
IProgress<UploadProgress> MakeChunkProgress(long chunkOffset, long chunkLength, long chunkNumber, long totalChunkCount)
@@ -462,13 +477,13 @@ namespace PS5Upload
long sent = p.BytesSent - chunkOffset; if (sent < 0) sent = 0; if (sent > chunkLength) sent = chunkLength;
var map = _fileChunkProgressBytes.GetOrAdd(localPath, _ => new ConcurrentDictionary<long, long>());
map[chunkOffset] = sent;
if (callCount % 10 != 0 && sent != chunkLength) return;
if (callCount % 2 != 0 && sent != chunkLength) return;
long agg = map.Values.Sum();
long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long delta = agg - prev;
if (delta != 0) { Interlocked.Add(ref _totalBytesUploaded, delta); _fileProgressBytes[localPath] = agg; }
_currentFileName = fileName;
Interlocked.Exchange(ref _currentFileBytes, agg);
Interlocked.Exchange(ref _currentFileTotalBytes, fileInfo.Length);
Interlocked.Exchange(ref _currentFileTotalBytes, fileLen);
});
}
}
@@ -478,10 +493,10 @@ namespace PS5Upload
var progress = new InlineProgress<UploadProgress>(p =>
{
callCount++;
if (callCount % 10 != 0 && p.BytesSent != p.TotalBytes) return;
if (callCount % 2 != 0 && p.BytesSent != p.TotalBytes) return;
_currentFileName = fileName;
Interlocked.Exchange(ref _currentFileBytes, p.BytesSent);
Interlocked.Exchange(ref _currentFileTotalBytes, fileInfo.Length);
Interlocked.Exchange(ref _currentFileTotalBytes, fileLen);
long prev = _fileProgressBytes.GetOrAdd(localPath, 0); long add = p.BytesSent - prev;
if (add > 0) { Interlocked.Add(ref _totalBytesUploaded, add); _fileProgressBytes[localPath] = p.BytesSent; }
});
@@ -491,25 +506,26 @@ namespace PS5Upload
string detail = connection.LastError;
throw new Exception(string.IsNullOrEmpty(detail) ? $"Upload failed for {fileName}" : $"Upload failed for {fileName}: {detail}");
}
long prevB = _fileProgressBytes.GetOrAdd(localPath, 0); long d2 = fileInfo.Length - prevB;
if (d2 > 0) { Interlocked.Add(ref _totalBytesUploaded, d2); _fileProgressBytes[localPath] = fileInfo.Length; }
long prevB = _fileProgressBytes.GetOrAdd(localPath, 0); long d2 = fileLen - prevB;
if (d2 > 0) { Interlocked.Add(ref _totalBytesUploaded, d2); _fileProgressBytes[localPath] = fileLen; }
}
_fileProgressBytes.TryRemove(localPath, out _); _fileChunkProgressBytes.TryRemove(localPath, out _); _chunkLogLastBytes.TryRemove(localPath, out _);
if (fileInfo.Length < 10 * 1024 * 1024) TrackSmallFileCompletion(fileName, fileInfo.Length); else Log($"✅ Upload complete: {fileName}");
if (fileLen < 10 * 1024 * 1024) TrackSmallFileCompletion(fileName, fileLen); else Log($"✅ Upload complete: {fileName}");
// Batch into pending list — the UI timer drains them. A post +
// collection-change + layout pass per file froze the window
// on 100k-file uploads.
lock (_pendingHistoryLock)
_pendingHistory.Add(new TransferHistoryItem { FileName = fileName, Status = "✅ Completed", Size = FormatFileSize(fileInfo.Length), Timestamp = DateTime.Now });
_pendingHistory.Add(new TransferHistoryItem { FileName = fileName, Status = "✅ Completed", Size = FormatFileSize(fileLen), Timestamp = DateTime.Now });
}
catch (Exception ex)
{
string fn = Path.GetFileName(localPath);
Log($"❌ Exception uploading {fn}: {ex.Message}");
_fileProgressBytes.TryRemove(localPath, out _); _fileChunkProgressBytes.TryRemove(localPath, out _); _chunkLogLastBytes.TryRemove(localPath, out _);
long failSize = 0; try { failSize = LocalIo.GetLength(localPath); } catch { }
lock (_pendingHistoryLock)
_pendingHistory.Add(new TransferHistoryItem { FileName = fn, Status = "❌ Failed", Size = FormatFileSize(new FileInfo(localPath).Length), Timestamp = DateTime.Now, LocalPath = localPath, RemotePath = remotePath });
_pendingHistory.Add(new TransferHistoryItem { FileName = fn, Status = "❌ Failed", Size = FormatFileSize(failSize), Timestamp = DateTime.Now, LocalPath = localPath, RemotePath = remotePath });
throw;
}
}
@@ -540,10 +556,29 @@ namespace PS5Upload
private void DestroyConnection(PS5Protocol c) { try { c.Disconnect(); c.Dispose(); } catch { } finally { Interlocked.Decrement(ref _currentPoolConnections); } }
private void DrainConnectionPool() { while (_connectionPool.TryDequeue(out var c)) DestroyConnection(c); }
private void CollectFilesFromDirectory(string localDir, string remoteDir, List<(string localPath, string remotePath)> files)
private void CollectFilesFromDirectory(string localDir, string remoteDir, List<(string localPath, string remotePath)> files, CancellationToken ct = default)
{
foreach (string file in Directory.GetFiles(localDir)) { FileInfo info = new(file); files.Add((file, remoteDir + "/" + info.Name)); }
foreach (string dir in Directory.GetDirectories(localDir)) { DirectoryInfo info = new(dir); CollectFilesFromDirectory(dir, remoteDir + "/" + info.Name, files); }
ct.ThrowIfCancellationRequested();
foreach (var e in LocalIo.Enumerate(localDir))
{
ct.ThrowIfCancellationRequested();
if (e.IsDirectory) CollectFilesFromDirectory(e.FullPath, remoteDir + "/" + e.Name, files, ct);
else
{
files.Add((e.FullPath, remoteDir + "/" + e.Name));
// Live collect counter — recursive NAS enumeration over
// thousands of files must not look like a freeze.
if (files.Count % 200 == 0)
{
int n = files.Count;
Dispatcher.UIThread.Post(() =>
{
TotalProgressText.Text = $"Collecting files... ({n} found)";
UploadFileNameText.Text = e.Name;
});
}
}
}
}
private void TrackSmallFileCompletion(string fileName, long fileSize)
@@ -590,7 +625,7 @@ namespace PS5Upload
else if (_duplicateAction == DuplicateAction.SkipAll) { /* skip */ }
else
{
long localSize = new FileInfo(file.localPath).Length;
long localSize = LocalIo.GetLength(file.localPath);
long remoteSize = existingFiles[fn!];
var dlg = new DuplicateFileDialog(fn!, localSize, remoteSize);
await dlg.ShowDialog(this);
+299 -7
View File
@@ -232,7 +232,7 @@
</StackPanel>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="Mounted Games:" Width="110" Foreground="#999999"/>
<TextBlock Text="Games &amp; Apps:" Width="110" Foreground="#999999"/>
<TextBlock x:Name="MountedGamesText" Text="---" FontWeight="Bold" Foreground="#007ACC"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
@@ -281,12 +281,13 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#2D2D30" Padding="10" CornerRadius="4,4,0,0">
<TextBlock Text="📁 Local Files" FontSize="14" FontWeight="SemiBold"/>
<TextBlock x:Name="LocalFilesHeader" Text="📁 Local Files" FontSize="14" FontWeight="SemiBold"/>
</Border>
<ListBox Grid.Row="1" x:Name="LocalFilesListBox" BorderThickness="0" DragDrop.AllowDrop="True">
<ListBox Grid.Row="1" x:Name="LocalFilesListBox" BorderThickness="0" DragDrop.AllowDrop="True" DoubleTapped="LocalFilesListBox_DoubleTapped">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="📦 Install on PS5 (.pkg)" Click="InstallLocalPkgMenuItem_Click"/>
<MenuItem Header="🔌 Disconnect NAS session" Click="DisconnectNasMenuItem_Click"/>
<Separator/>
<MenuItem Header="🗑️ Remove from list" Click="RemoveLocalFileMenuItem_Click"/>
</ContextMenu>
@@ -301,6 +302,7 @@
</ListBox.ItemTemplate>
</ListBox>
<WrapPanel Grid.Row="2" Margin="10" HorizontalAlignment="Center">
<Button x:Name="LocalBrowseBackBtn" Content="⬆ Back" Click="LocalBrowseBack_Click" Padding="10,5" Margin="0,0,5,0" Background="#6C757D" IsVisible="False" ToolTip.Tip="Leave browse mode / go up one level"/>
<Button Content="📄 Files" Click="BrowseFilesButton_Click" Padding="10,5" Margin="0,0,5,0"/>
<Button Content="📁 Folder" Click="BrowseFolderButton_Click" Padding="10,5" Margin="0,0,5,0"/>
<Button Content="🌐 NAS" Click="AddNasPath_Click" Padding="10,5" Background="#17A2B8" Margin="0,0,5,0" ToolTip.Tip="Add network/NAS path (\\server\share)"/>
@@ -373,10 +375,13 @@
<!-- PAGE 2: Games -->
<Grid x:Name="PageGames" IsVisible="False">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TabControl Background="#1E1E1E">
<TabItem Header="🎮 Games">
<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>
@@ -433,6 +438,56 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</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>
<Border Grid.Column="0" Width="64" Height="64" Margin="0,0,15,0" Background="#3E3E42" CornerRadius="8" ClipToBounds="True">
<Image Source="{Binding Cover}" Stretch="UniformToFill"/>
</Border>
<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>
</TabControl>
</Grid>
<!-- PAGE 3: Saves & Media -->
@@ -812,6 +867,78 @@
</Border>
</Grid>
<!-- Row 3b: Network -->
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="🌐 Network" FontWeight="Bold" FontSize="14" Foreground="#0E9CFF" Margin="0,0,0,12"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Status:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="NetStateText" Text="—" Margin="0,3" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="SSID:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="NetSsidText" Text="—" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Signal (RSSI):" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="NetRssiText" Text="—" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="IP Address:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="NetIpText" Text="—" Margin="0,3" FontFamily="Consolas"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Gateway:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="NetGwText" Text="—" Margin="0,3" FontFamily="Consolas"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="DNS:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="5" Grid.Column="1" x:Name="NetDnsText" Text="—" Margin="0,3" FontFamily="Consolas"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="MAC:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="6" Grid.Column="1" x:Name="NetMacText" Text="—" Margin="0,3" FontFamily="Consolas"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="MTU / Link:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="7" Grid.Column="1" x:Name="NetMtuText" Text="—" Margin="0,3"/>
</Grid>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="📊 Live Traffic" FontWeight="Bold" FontSize="14" Foreground="#0E9CFF" Margin="0,0,0,12"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="NAT Type:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="NetNatText" Text="—" Margin="0,3" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Public IP:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="NetNatIpText" Text="—" Margin="0,3" FontFamily="Consolas"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="⬇ Download:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="NetRxRateText" Text="—" Margin="0,3" Foreground="#28A745" FontWeight="Bold" FontFamily="Consolas"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="⬆ Upload:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="NetTxRateText" Text="—" Margin="0,3" Foreground="#0E9CFF" FontWeight="Bold" FontFamily="Consolas"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Interface:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="NetIfText" Text="—" Margin="0,3" FontFamily="Consolas" FontSize="11"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Totals:" Foreground="#808080" Margin="0,3"/>
<TextBlock Grid.Row="5" Grid.Column="1" x:Name="NetTotalsText" Text="—" Margin="0,3" FontFamily="Consolas" FontSize="11"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button x:Name="NetSpeedTestButton" Content="⚡ Speed Test" Background="#0E9CFF" Foreground="White" Padding="14,7" CornerRadius="4" Click="NetSpeedTestButton_Click"/>
<TextBlock x:Name="NetSpeedText" Text="" VerticalAlignment="Center" Margin="12,0,0,0" Foreground="#FFAA00" FontWeight="Bold" FontFamily="Consolas"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
<!-- Row 4: Loaded Modules -->
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
@@ -933,6 +1060,139 @@
</Grid>
</TabItem>
<!-- Power & Devices Tab -->
<TabItem Header="⚡ Power &amp; Devices">
<ScrollViewer Background="#1E1E1E">
<StackPanel Margin="15">
<TextBlock Text="⚡ Console Power" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel>
<Button Content="🔄 Reboot" Click="PowerReboot_Click" Padding="15,8" Background="#007ACC" FontWeight="SemiBold" Margin="0,0,10,0"/>
<Button Content="⏻ Shutdown" Click="PowerShutdown_Click" Padding="15,8" Background="#DC3545" FontWeight="SemiBold"/>
</WrapPanel>
<TextBlock Text="⚠️ The console acts immediately after confirmation — the connection drops." Foreground="#707070" FontSize="11" Margin="0,10,0,0"/>
</StackPanel>
</Border>
<TextBlock Text="🔌 USB Drives" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel Margin="0,0,0,10">
<Button Content="🔄 Refresh" Click="RefreshUsbDrives_Click" Padding="10,5" Background="#3E3E42" Margin="0,0,10,0"/>
<Button Content="📂 Browse Selected" Click="BrowseUsbDrive_Click" Padding="10,5" Background="#007ACC"/>
</WrapPanel>
<ListBox x:Name="UsbDrivesList" Height="110" FontFamily="Consolas" FontSize="12" Background="#1A1A1B"/>
</StackPanel>
</Border>
<TextBlock Text="🎮 Controllers" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel Margin="0,0,0,10">
<Button Content="🔄 Refresh" Click="RefreshPadInfo_Click" Padding="10,5" Background="#3E3E42"/>
</WrapPanel>
<TextBlock x:Name="PadInfoText" Text="—" FontFamily="Consolas" FontSize="12" Foreground="#CCCCCC" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<TextBlock Text="🎨 Light Bar" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel>
<Button Content="🔵 Blue" Click="LightBar_Click" Tag="0,0,255" Padding="10,5" Background="#1E3A5F" Margin="0,0,6,0"/>
<Button Content="🔴 Red" Click="LightBar_Click" Tag="255,0,0" Padding="10,5" Background="#5F1E1E" Margin="0,0,6,0"/>
<Button Content="🟢 Green" Click="LightBar_Click" Tag="0,255,0" Padding="10,5" Background="#1E5F1E" Margin="0,0,6,0"/>
<Button Content="🟣 Purple" Click="LightBar_Click" Tag="160,32,240" Padding="10,5" Background="#3E1E5F" Margin="0,0,6,0"/>
<Button Content="⚪ White" Click="LightBar_Click" Tag="255,255,255" Padding="10,5" Background="#4E4E4E" Margin="0,0,6,0"/>
<Button Content="⚫ Off" Click="LightBar_Click" Tag="0,0,0" Padding="10,5" Background="#3E3E42"/>
</WrapPanel>
</StackPanel>
</Border>
<TextBlock Text="📸 Screenshot &amp; 🔔 Notification" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel Margin="0,0,0,10">
<Button Content="📸 Capture Screen" Click="Screenshot_Click" Padding="12,6" Background="#007ACC" Margin="0,0,10,0"/>
<TextBlock Text="Saved to PS5 — grab it from Saves &amp; Media" Foreground="#707070" FontSize="11" VerticalAlignment="Center"/>
</WrapPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBox x:Name="NotifyTextBox" PlaceholderText="Notification text..." Padding="8,6" Margin="0,0,10,0"/>
<Button Grid.Column="1" Content="🔔 Send" Click="SendNotify_Click" Padding="12,6" Background="#28A745"/>
</Grid>
</StackPanel>
</Border>
<TextBlock Text="💡 Console LED &amp; 🔊 Beeper" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel Margin="0,0,0,8">
<TextBlock Text="LED:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button Content="Bright" Click="LedDim_Click" Tag="0" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Medium" Click="LedDim_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Dim" Click="LedDim_Click" Tag="2" Padding="10,5" Background="#3E3E42"/>
</WrapPanel>
<WrapPanel Margin="0,0,0,8">
<TextBlock Text="Color:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button Content="White" Click="LedColor_Click" Tag="0,255,0" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Blue" Click="LedColor_Click" Tag="255,0,0" Padding="10,5" Background="#1E3A5F" Margin="0,0,6,0"/>
<Button Content="Orange" Click="LedColor_Click" Tag="0,0,255" Padding="10,5" Background="#5F3A1E" Margin="0,0,6,0"/>
<Button Content="Purple" Click="LedColor_Click" Tag="64,0,255" Padding="10,5" Background="#3E1E5F" Margin="0,0,6,0"/>
<Button Content="Pink" Click="LedColor_Click" Tag="32,48,255" Padding="10,5" Background="#5F1E3E" Margin="0,0,6,0"/>
<Button Content="Off" Click="LedColor_Click" Tag="0,0,0" Padding="10,5" Background="#3E3E42"/>
</WrapPanel>
<WrapPanel Margin="0,0,0,8">
<TextBlock Text="Effect:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox x:Name="LedEffectCombo" Width="180" SelectedIndex="1" Margin="0,0,8,0">
<ComboBoxItem Content="off"/>
<ComboBoxItem Content="white"/>
<ComboBoxItem Content="white-dim"/>
<ComboBoxItem Content="blue"/>
<ComboBoxItem Content="orange"/>
<ComboBoxItem Content="purple"/>
<ComboBoxItem Content="pink"/>
<ComboBoxItem Content="sunrise (white breathe + orange)"/>
<ComboBoxItem Content="blue-breathe"/>
<ComboBoxItem Content="white-breathe"/>
<ComboBoxItem Content="orange-breathe"/>
<ComboBoxItem Content="pink-breathe"/>
<ComboBoxItem Content="pink-breathe-fast"/>
<ComboBoxItem Content="blue-to-richblue"/>
<ComboBoxItem Content="blue-white-anim"/>
</ComboBox>
<Button Content="✨ Apply" Click="LedEffect_Click" Padding="10,5" Background="#007ACC"/>
</WrapPanel>
<WrapPanel>
<TextBlock Text="Beeper:" Foreground="#CCCCCC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button Content="Beep" Click="Buzzer_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Error" Click="Buzzer_Click" Tag="2" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Long" Click="Buzzer_Click" Tag="3" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Vol High" Click="BuzzerVol_Click" Tag="0" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Vol Med" Click="BuzzerVol_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Vol Low" Click="BuzzerVol_Click" Tag="2" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Mute" Click="BuzzerMute_Click" Tag="1" Padding="10,5" Background="#3E3E42" Margin="0,0,6,0"/>
<Button Content="Unmute" Click="BuzzerMute_Click" Tag="0" Padding="10,5" Background="#3E3E42"/>
</WrapPanel>
</StackPanel>
</Border>
<TextBlock Text="💿 Disc Dump" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,15"/>
<Border Background="#252526" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="6" Padding="15" Margin="0,0,0,15">
<StackPanel>
<WrapPanel Margin="0,0,0,10">
<Button Content="▶ Start Dump" Click="DiscDumpStart_Click" Padding="12,6" Background="#28A745" Margin="0,0,8,0"/>
<Button Content="🔄 Status" Click="DiscDumpStatus_Click" Padding="12,6" Background="#3E3E42" Margin="0,0,8,0"/>
<Button Content="⏹ Cancel" Click="DiscDumpCancel_Click" Padding="12,6" Background="#DC3545"/>
</WrapPanel>
<ProgressBar x:Name="DiscDumpProgress" Height="8" Minimum="0" Maximum="100" Value="0" Margin="0,0,0,8"/>
<TextBlock x:Name="DiscDumpStatusText" Text="Insert a disc, then Start — copies /mnt/disc → /user/data/disc_dumps" Foreground="#999999" FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- Memory Editor Tab -->
<TabItem Header="🧠 Memory">
<Grid Background="#1E1E1E" Margin="15">
@@ -1014,6 +1274,36 @@
</Grid>
</TabItem>
<!-- App Manager v2 Tab -->
<TabItem Header="🎯 Apps">
<Grid Background="#1E1E1E" Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="🎯 App Manager" FontWeight="Bold" FontSize="16" Foreground="#0E9CFF" Margin="0,0,0,10"/>
<WrapPanel Grid.Row="1" Margin="0,0,0,8">
<Button Content="🔄 Refresh" Click="AppMgrRefresh_Click" Padding="10,5" Background="#3E3E42" Margin="0,0,8,0"/>
<CheckBox x:Name="AppMgrAutoRefresh" Content="Auto (3s)" Foreground="#CCCCCC" Margin="0,0,10,0" VerticalAlignment="Center" IsCheckedChanged="AppMgrAutoRefresh_Changed"/>
<Button x:Name="AppMgrSuspendBtn" Content="⏸ Freeze" Click="AppMgrSuspend_Click" Padding="10,5" Background="#8A6D00" Margin="0,0,8,0" ToolTip.Tip="Freeze all app threads (SIGSTOP) — game pauses completely"/>
<Button x:Name="AppMgrResumeBtn" Content="▶ Resume" Click="AppMgrResume_Click" Padding="10,5" Background="#28A745" Margin="0,0,8,0" ToolTip.Tip="Resume a frozen app (SIGCONT)"/>
<Button x:Name="AppMgrKillBtn" Content="❌ Kill" Click="AppMgrKill_Click" Padding="10,5" Background="#B03030" Margin="0,0,8,0" ToolTip.Tip="Force-kill (LncUtil, SIGKILL fallback)"/>
<Button x:Name="AppMgrCoredumpBtn" Content="💥 Coredump" Click="AppMgrCoredump_Click" Padding="10,5" Background="#6F42C1" Margin="0,0,8,0" ToolTip.Tip="Ask ShellCore to dump the app's memory (for debugging)"/>
<TextBlock x:Name="AppMgrStatus" Foreground="#808080" VerticalAlignment="Center" Margin="8,0,0,0"/>
</WrapPanel>
<ListBox Grid.Row="2" x:Name="AppMgrList" FontFamily="Consolas" FontSize="12" Background="#252526" SelectionChanged="AppMgrList_SelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="3" Text="Real app info via sceKernelGetAppInfo — suspend/resume/kill via LncUtil (needs etaHEN). CPU% from kernel scheduler stats." Foreground="#606060" FontSize="11" Margin="0,6,0,0"/>
</Grid>
</TabItem>
<!-- Kernel Log Tab -->
<TabItem Header="🧾 Kernel Log">
<Grid Background="#1E1E1E" Margin="15">
@@ -1027,6 +1317,7 @@
<WrapPanel Grid.Row="1" Margin="0,0,0,10">
<Button Content="🔄 Refresh" Click="KlogRefresh_Click" Padding="10,5" Background="#3E3E42" Margin="0,0,8,0"/>
<CheckBox x:Name="KlogAutoRefresh" Content="Auto (3s)" Foreground="#CCCCCC" Margin="0,0,10,0" VerticalAlignment="Center" IsCheckedChanged="KlogAutoRefresh_Changed"/>
<CheckBox x:Name="KlogAutoScroll" Content="Auto-scroll" Foreground="#CCCCCC" IsChecked="True" Margin="0,0,10,0" VerticalAlignment="Center"/>
<ComboBox x:Name="KlogTailSelect" Width="120" SelectedIndex="0" Margin="0,0,8,0">
<ComboBoxItem Content="Last 32 KB"/>
<ComboBoxItem Content="Last 128 KB"/>
@@ -1169,6 +1460,7 @@
<TextBox Grid.Row="1" x:Name="LogTextBox" Text="" FontFamily="Consolas" FontSize="11" Foreground="#CCCCCC" Background="#1E1E1E" BorderThickness="0" Padding="10" TextWrapping="Wrap" IsReadOnly="True" AcceptsReturn="True"/>
<WrapPanel Grid.Row="2" Margin="10" HorizontalAlignment="Center">
<CheckBox x:Name="LogAutoScroll" Content="Auto-scroll" Foreground="#CCCCCC" IsChecked="True" VerticalAlignment="Center" Margin="0,0,15,0"/>
<Button Content="📋 Copy Log" Click="CopyLogButton_Click" Padding="8,5" Margin="0,0,5,0" Background="#28A745"/>
<Button Content="🗑️ Clear Log" Click="ClearLogButton_Click" Padding="8,5" Background="#3E3E42"/>
</WrapPanel>
+165 -2
View File
@@ -37,6 +37,10 @@ namespace PS5Upload
private List<string> _favoritePaths = new();
private const string FavoritesFileName = "ps5_favorites.json";
// Saved NAS connections
private List<NasManager.SavedConnection> _nasSaved = new();
private const string NasFileName = "ps5_nas_connections.json";
// Transfer History
private ObservableCollection<TransferHistoryItem> _completedTransfers = new();
private ObservableCollection<TransferHistoryItem> _failedTransfers = new();
@@ -171,6 +175,7 @@ namespace PS5Upload
Log("Application started");
LoadProfiles();
LoadFavorites();
LoadNasConnections();
LoadSettings();
if (AutoSendPayloadCheckBox != null) AutoSendPayloadCheckBox.IsChecked = _autoSendPayload;
@@ -258,6 +263,155 @@ namespace PS5Upload
return result;
}
/// NAS connect dialog: UNC path + optional credentials + LAN discovery
/// + optional "remember this connection" save.
/// Returns null on cancel; empty user = guest/anonymous attempt.
private async Task<(string path, string user, string pass, bool remember)?> ShowNasDialogAsync()
{
(string, string, string, bool)? result = null;
var dlg = new Window
{
Title = "Add NAS / Network Share", Width = 440, Height = 510,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
};
var panel = new StackPanel { Margin = new Thickness(20), Spacing = 6 };
var pathBox = new TextBox { Text = "\\\\NAS\\share\\folder", Margin = new Thickness(0, 0, 0, 8) };
// --- Discovery row ---
var scanRow = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 8 };
var scanBtn = new Button { Content = "🔍 Find NAS on LAN", Padding = new Thickness(10, 4) };
var scanStatus = new TextBlock { Text = "", Foreground = new SolidColorBrush(Color.FromRgb(150, 150, 150)), FontSize = 11, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center };
scanRow.Children.Add(scanBtn);
scanRow.Children.Add(scanStatus);
panel.Children.Add(scanRow);
var hostList = new ListBox
{
Height = 80, IsVisible = false,
Background = new SolidColorBrush(Color.FromRgb(20, 20, 20)),
Margin = new Thickness(0, 0, 0, 6)
};
var foundHosts = new List<NasManager.NasHost>();
hostList.SelectionChanged += (s, ev) =>
{
if (hostList.SelectedIndex >= 0 && hostList.SelectedIndex < foundHosts.Count)
pathBox.Text = "\\\\" + foundHosts[hostList.SelectedIndex].Ip + "\\";
};
panel.Children.Add(hostList);
scanBtn.Click += async (s, ev) =>
{
scanBtn.IsEnabled = false;
scanStatus.Text = "Scanning LAN…";
hostList.IsVisible = false;
try
{
foundHosts.Clear();
foundHosts.AddRange(await NasManager.DiscoverAsync());
var items = new Avalonia.Controls.ItemsControl();
var strings = new System.Collections.ObjectModel.ObservableCollection<string>(foundHosts.Select(h => h.Display));
hostList.ItemsSource = strings;
hostList.IsVisible = strings.Count > 0;
scanStatus.Text = strings.Count == 0 ? "No SMB hosts found" : $"{strings.Count} host(s) — pick one:";
}
catch (Exception ex) { scanStatus.Text = "Scan failed: " + ex.Message; }
scanBtn.IsEnabled = true;
};
panel.Children.Add(new TextBlock { Text = "UNC path:", Foreground = Brushes.White });
panel.Children.Add(pathBox);
panel.Children.Add(new TextBlock { Text = "Username (leave empty for guest):", Foreground = Brushes.White });
var userBox = new TextBox { PlaceholderText = "e.g. admin or WORKGROUP\\user", Margin = new Thickness(0, 0, 0, 8) };
panel.Children.Add(userBox);
panel.Children.Add(new TextBlock { Text = "Password:", Foreground = Brushes.White });
var passBox = new TextBox { PasswordChar = '●', Margin = new Thickness(0, 0, 0, 8) };
panel.Children.Add(passBox);
var rememberBox = new CheckBox { Content = "💾 Remember this connection", Foreground = Brushes.White, Margin = new Thickness(0, 0, 0, 6) };
panel.Children.Add(rememberBox);
panel.Children.Add(new TextBlock
{
Text = "Saved connections reappear when you press NAS.\nPassword is stored locally on this PC only (obfuscated, not encrypted).",
Foreground = new SolidColorBrush(Color.FromRgb(150, 150, 150)),
FontSize = 11, TextWrapping = Avalonia.Media.TextWrapping.Wrap,
Margin = new Thickness(0, 0, 0, 10)
});
var btnPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Spacing = 10 };
var okBtn = new Button { Content = "Connect", Width = 90, IsDefault = true };
okBtn.Click += (s, ev) => { result = (pathBox.Text ?? "", userBox.Text ?? "", passBox.Text ?? "", rememberBox.IsChecked == true); dlg.Close(); };
var cancelBtn = new Button { Content = "Cancel", Width = 90, IsCancel = true };
cancelBtn.Click += (s, ev) => dlg.Close();
btnPanel.Children.Add(okBtn);
btnPanel.Children.Add(cancelBtn);
panel.Children.Add(btnPanel);
dlg.Content = panel;
await dlg.ShowDialog(this);
return result;
}
/// Picker shown when saved NAS connections exist.
/// Returns the chosen connection, the sentinel for "new connection", or null.
private static readonly NasManager.SavedConnection _nasNewSentinel = new() { Path = "__new__" };
private async Task<NasManager.SavedConnection?> ShowNasPickerAsync()
{
NasManager.SavedConnection? result = null;
var dlg = new Window
{
Title = "NAS Connections", Width = 460, Height = 330,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30))
};
var panel = new StackPanel { Margin = new Thickness(15), Spacing = 8 };
panel.Children.Add(new TextBlock { Text = "Saved connections:", Foreground = Brushes.White, Margin = new Thickness(0, 0, 0, 4) });
var list = new ListBox
{
Height = 170,
Background = new SolidColorBrush(Color.FromRgb(20, 20, 20)),
ItemsSource = new ObservableCollection<string>(_nasSaved.Select(c => c.Display))
};
list.SelectedIndex = 0;
panel.Children.Add(list);
var btnPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
var connBtn = new Button { Content = "🔌 Connect", Width = 100, IsDefault = true };
var newBtn = new Button { Content = "➕ New…", Width = 90 };
var delBtn = new Button { Content = "🗑️ Delete", Width = 90 };
var cancelBtn = new Button { Content = "Cancel", Width = 90, IsCancel = true };
connBtn.Click += (s, ev) => { if (list.SelectedIndex >= 0 && list.SelectedIndex < _nasSaved.Count) result = _nasSaved[list.SelectedIndex]; dlg.Close(); };
newBtn.Click += (s, ev) => { result = _nasNewSentinel; dlg.Close(); };
delBtn.Click += (s, ev) =>
{
if (list.SelectedIndex >= 0 && list.SelectedIndex < _nasSaved.Count)
{
_nasSaved.RemoveAt(list.SelectedIndex);
SaveNasConnections();
list.ItemsSource = new ObservableCollection<string>(_nasSaved.Select(c => c.Display));
if (_nasSaved.Count == 0) { result = _nasNewSentinel; dlg.Close(); }
}
};
cancelBtn.Click += (s, ev) => dlg.Close();
// Double-click a saved entry = Connect
list.DoubleTapped += (s, ev) => { if (list.SelectedIndex >= 0 && list.SelectedIndex < _nasSaved.Count) { result = _nasSaved[list.SelectedIndex]; dlg.Close(); } };
btnPanel.Children.Add(connBtn);
btnPanel.Children.Add(newBtn);
btnPanel.Children.Add(delBtn);
btnPanel.Children.Add(cancelBtn);
panel.Children.Add(btnPanel);
dlg.Content = panel;
await dlg.ShowDialog(this);
return result;
}
// ============================================================
// NAVIGATION
// ============================================================
@@ -360,11 +514,20 @@ namespace PS5Upload
Interlocked.Exchange(ref _lastLogFlushTicks, DateTime.Now.Ticks);
Dispatcher.UIThread.Post(() =>
{
int caret = LogTextBox.CaretIndex;
string t = (LogTextBox.Text ?? "") + chunk;
int trimmed = 0;
if (t.Length > MaxLogChars)
t = t.Substring(t.Length - MaxLogChars);
{
trimmed = t.Length - MaxLogChars;
t = t.Substring(trimmed);
}
LogTextBox.Text = t;
LogTextBox.CaretIndex = t.Length;
// Auto-scroll: jump to the end; otherwise keep the user's
// scroll position (caret tracks what they're reading).
LogTextBox.CaretIndex = (LogAutoScroll.IsChecked == true)
? t.Length
: Math.Clamp(caret - trimmed, 0, t.Length);
});
}
+630
View File
@@ -0,0 +1,630 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SMBLibrary;
using SMBLibrary.Client;
namespace PS5Upload
{
/// <summary>
/// Managed SMB (NAS) access via SMBLibrary. One authenticated session per
/// share root (\\server\share) is kept for the app lifetime; every file
/// under that share reuses it. All store ops run under a per-session lock —
/// SMBLibrary isn't thread-safe, so parallel upload lanes interleave their
/// reads on the single session.
/// </summary>
public static class NasManager
{
public sealed class NasAuthException : IOException { public NasAuthException(string m) : base(m) { } }
private sealed class Session
{
public string Server = "", Share = "";
public string Domain = "", User = "", Pass = "";
public SMB2Client? Client;
public ISMBFileStore? Store;
public readonly object Lock = new();
}
private static readonly ConcurrentDictionary<string, Session> _sessions = new(StringComparer.OrdinalIgnoreCase);
/// A NAS connection the user chose to keep between app launches.
/// Password is stored obfuscated (DPAPI on Windows, Base64 elsewhere)
/// — convenient, not secure; never treat as a real credential vault.
public sealed class SavedConnection
{
public string Path { get; set; } = ""; // e.g. \\192.168.0.5\G\games
public string Server { get; set; } = "";
public string Share { get; set; } = "";
public string User { get; set; } = "";
public string PassEnc { get; set; } = ""; // encoded, see Encode/DecodePass
public string Display => string.IsNullOrEmpty(User) ? Path : $"{Path} ({User})";
}
public static string EncodePass(string plain)
{
try
{
if (OperatingSystem.IsWindows())
{
var enc = System.Security.Cryptography.ProtectedData.Protect(
System.Text.Encoding.UTF8.GetBytes(plain), null,
System.Security.Cryptography.DataProtectionScope.CurrentUser);
return "dpapi:" + Convert.ToBase64String(enc);
}
}
catch { }
return "b64:" + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(plain));
}
public static string DecodePass(string enc)
{
try
{
if (enc.StartsWith("dpapi:") && OperatingSystem.IsWindows())
{
var raw = System.Security.Cryptography.ProtectedData.Unprotect(
Convert.FromBase64String(enc.Substring(6)), null,
System.Security.Cryptography.DataProtectionScope.CurrentUser);
return System.Text.Encoding.UTF8.GetString(raw);
}
if (enc.StartsWith("b64:"))
return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(enc.Substring(4)));
}
catch { }
return "";
}
// ============================================================
// LAN DISCOVERY — scan local subnets for hosts listening on
// TCP 445 (SMB). ~2s for a /24 with parallel probes.
// ============================================================
public sealed class NasHost
{
public string Ip = "";
public string Name = "";
public string Display => string.IsNullOrEmpty(Name) ? Ip : $"{Ip} ({Name})";
}
public static async Task<List<NasHost>> DiscoverAsync(CancellationToken ct = default)
{
var ranges = GetLocalScanRanges();
var found = new ConcurrentBag<string>();
var probes = new List<Task>();
using var sem = new SemaphoreSlim(220);
foreach (var (network, mask) in ranges)
{
uint count = ~mask + 1;
if (count > 2048) count = 2048; // sanity cap
for (uint i = 1; i < count - 1; i++)
{
uint ipN = network + i;
string ip = $"{(ipN >> 24) & 0xFF}.{(ipN >> 16) & 0xFF}.{(ipN >> 8) & 0xFF}.{ipN & 0xFF}";
probes.Add(Task.Run(async () =>
{
await sem.WaitAsync(ct).ConfigureAwait(false);
try
{
using var c = new System.Net.Sockets.TcpClient();
var t = c.ConnectAsync(ip, 445);
if (await Task.WhenAny(t, Task.Delay(350, ct)).ConfigureAwait(false) == t && c.Connected)
found.Add(ip);
}
catch { }
finally { sem.Release(); }
}, ct));
}
}
try { await Task.WhenAll(probes).ConfigureAwait(false); } catch { }
// Reverse-DNS the hits (parallel, short timeout, tolerate failures).
var results = new ConcurrentBag<NasHost>();
await Task.WhenAll(found.Select(async ip =>
{
string name = "";
try
{
var t = System.Net.Dns.GetHostEntryAsync(ip);
if (await Task.WhenAny(t, Task.Delay(1200, ct)).ConfigureAwait(false) == t)
{
var e = await t;
if (!string.IsNullOrEmpty(e.HostName) && e.HostName != ip) name = e.HostName;
}
}
catch { }
results.Add(new NasHost { Ip = ip, Name = name });
})).ConfigureAwait(false);
return results.OrderBy(h => h.Ip).ToList();
}
/// Try to list a host's shares via OS tools: `net view` on Windows,
/// `smbclient -L` elsewhere (if installed). Returns null when the
/// tool isn't available or the output can't be parsed.
public static List<string>? EnumerateSharesOs(string host, string user = "", string pass = "")
{
try
{
string exe, args;
if (OperatingSystem.IsWindows())
{
exe = "net"; args = $"view \\\\{host} /all";
}
else
{
exe = "smbclient";
string u = string.IsNullOrEmpty(user) ? "-N" : $"-U {user}%{pass}";
args = $"-L //{host} {u} -g"; // -g = parseable output
}
var psi = new System.Diagnostics.ProcessStartInfo(exe, args)
{
RedirectStandardOutput = true, RedirectStandardError = true,
UseShellExecute = false, CreateNoWindow = true
};
using var p = System.Diagnostics.Process.Start(psi);
if (p == null) return null;
string outp = p.StandardOutput.ReadToEnd();
if (!p.WaitForExit(8000)) { try { p.Kill(); } catch { } return null; }
var shares = new List<string>();
if (OperatingSystem.IsWindows())
{
// "Share name Type Used as Comment" — names in col 0, e.g. "games Disk"
foreach (var line in outp.Split('\n'))
{
var t = line.TrimEnd('\r');
if (t.Contains(" Disk") && t.TrimStart().Length > 0)
{
int i = t.IndexOf(" ");
string name = (i > 0 ? t.Substring(0, i) : t).Trim();
if (name.Length > 0 && !name.EndsWith("$") && !name.Equals("Share name", StringComparison.OrdinalIgnoreCase))
shares.Add(name);
}
}
}
else
{
// smbclient -g lines: "Disk|sharename|comment"
foreach (var line in outp.Split('\n'))
{
var parts = line.Trim().Split('|');
if (parts.Length >= 2 && parts[0] == "Disk" && !parts[1].EndsWith("$"))
shares.Add(parts[1]);
}
}
return shares.Count > 0 ? shares : null;
}
catch { return null; }
}
/// Local IPv4 subnets to scan — (network, mask) as uints, big-endian.
private static List<(uint network, uint mask)> GetLocalScanRanges()
{
var ranges = new List<(uint, uint)>();
try
{
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
foreach (var ua in nic.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
var ipB = ua.Address.GetAddressBytes();
var mB = ua.IPv4Mask?.GetAddressBytes();
if (mB == null) continue;
uint ip = ((uint)ipB[0] << 24) | ((uint)ipB[1] << 16) | ((uint)ipB[2] << 8) | ipB[3];
uint mask = ((uint)mB[0] << 24) | ((uint)mB[1] << 16) | ((uint)mB[2] << 8) | mB[3];
ranges.Add((ip & mask, mask));
}
}
}
catch { }
// Fallback: if NIC enumeration gave nothing (odd platforms), guess common /24s is pointless — return empty.
return ranges.Distinct().ToList();
}
public static bool IsUncPath(string? path) =>
!string.IsNullOrEmpty(path) && (path.StartsWith("\\\\") || path.StartsWith("//"));
public static string Normalize(string path)
{
path = path.Trim();
if (path.StartsWith("//")) path = "\\\\" + path.Substring(2).Replace('/', '\\');
return path;
}
/// "\\server\share" portion of a UNC path ("" if not UNC).
public static string ShareRoot(string uncPath)
{
string p = Normalize(uncPath).TrimEnd('\\');
int i = p.IndexOf('\\', 2); // end of server
if (i < 0) return p;
int j = p.IndexOf('\\', i + 1); // end of share
return j < 0 ? p : p.Substring(0, j);
}
/// Split \\server\share\a\b → (server, share, "a\b").
public static bool TrySplit(string uncPath, out string server, out string share, out string rel)
{
server = share = rel = "";
if (!IsUncPath(uncPath)) return false;
string p = Normalize(uncPath);
string rest = p.Substring(2);
int i = rest.IndexOf('\\');
if (i < 0) return false;
server = rest.Substring(0, i);
string rest2 = rest.Substring(i + 1);
int j = rest2.IndexOf('\\');
if (j < 0) { share = rest2; }
else { share = rest2.Substring(0, j); rel = rest2.Substring(j + 1); }
return server.Length > 0 && share.Length > 0;
}
public static bool HasSession(string uncPath) => _sessions.ContainsKey(ShareRoot(uncPath));
/// Connect + authenticate + tree-connect to \\server\share. Throws
/// NasAuthException on logon failure, IOException on other errors.
public static void Connect(string server, string share, string user, string password, string domain = "")
{
var s = new Session { Server = server, Share = share, User = user, Pass = password, Domain = domain };
Establish(s);
_sessions["\\\\" + server + "\\" + share] = s;
}
public static void DisconnectAll()
{
foreach (var s in _sessions.Values) { try { s.Client?.Logoff(); s.Client?.Disconnect(); } catch { } }
_sessions.Clear();
}
/// Drop the session for the share that contains uncPath (if any).
public static bool Disconnect(string uncPath)
{
string root = ShareRoot(uncPath);
if (_sessions.TryRemove(root, out var s))
{
try { s.Client?.Logoff(); s.Client?.Disconnect(); } catch { }
return true;
}
return false;
}
private static void Establish(Session s)
{
var client = new SMB2Client();
bool ok;
try { ok = client.Connect(s.Server, SMBTransportType.DirectTCPTransport); }
catch (Exception ex) { throw new IOException($"Cannot reach {s.Server}:445 — {ex.Message}"); }
if (!ok) throw new IOException($"Cannot reach {s.Server}:445 (connection timeout)");
NTStatus st = client.Login(s.Domain, s.User, s.Pass);
if (st != NTStatus.STATUS_SUCCESS)
{
try { client.Disconnect(); } catch { }
if (st == NTStatus.STATUS_LOGON_FAILURE || st == NTStatus.STATUS_ACCESS_DENIED)
throw new NasAuthException($"Login failed on {s.Server} — check username/password ({st})");
throw new IOException($"Login failed on {s.Server}: {st}");
}
ISMBFileStore? store = client.TreeConnect(s.Share, out st);
if (st != NTStatus.STATUS_SUCCESS || store == null)
{
try { client.Logoff(); client.Disconnect(); } catch { }
if (st == NTStatus.STATUS_ACCESS_DENIED) throw new NasAuthException($"Access denied to \\\\{s.Server}\\{s.Share} — insufficient permissions");
throw new IOException($"Cannot mount share \\\\{s.Server}\\{s.Share}: {st}");
}
s.Client = client; s.Store = store;
}
private static Session SessionFor(string uncPath)
{
if (_sessions.TryGetValue(ShareRoot(uncPath), out var s)) return s;
throw new IOException($"No NAS session for {ShareRoot(uncPath)} — add the path via the 🌐 NAS button first.");
}
private static string RelPath(Session s, string uncPath)
{
string root = "\\\\" + s.Server + "\\" + s.Share;
return Normalize(uncPath).Substring(root.Length).TrimStart('\\');
}
/// Run an op on the session's store; on stale connection reconnect once and retry.
private static T WithStore<T>(string uncPath, Func<ISMBFileStore, Session, T> op)
{
var s = SessionFor(uncPath);
lock (s.Lock)
{
try
{
return op(s.Store ?? throw new IOException("NAS session not connected"), s);
}
catch (NasAuthException) { throw; }
catch (IOException) { throw; }
catch (Exception ex)
{
// Dead socket / broken pipe → one reconnect attempt.
try { Establish(s); return op(s.Store!, s); }
catch (IOException) { throw; }
catch (Exception ex2) { throw new IOException($"NAS error on {s.Server}: {ex2.Message}", ex); }
}
}
}
private static NTStatus OpenFile(ISMBFileStore store, string rel, bool isDir, out object handle)
{
handle = null!;
return store.CreateFile(out handle, out _,
rel,
AccessMask.MAXIMUM_ALLOWED,
SMBLibrary.FileAttributes.Normal,
ShareAccess.Read | ShareAccess.Write,
CreateDisposition.FILE_OPEN,
isDir ? CreateOptions.FILE_DIRECTORY_FILE : CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_NONALERT,
null);
}
/// true=dir, false=file, null=doesn't exist / not accessible.
public static bool? ExistsAsDir(string uncPath)
{
try
{
return WithStore(uncPath, (store, s) =>
{
string rel = RelPath(s, uncPath);
if (rel.Length == 0) return (bool?)true; // share root = directory
NTStatus st = OpenFile(store, rel, true, out object h);
if (st == NTStatus.STATUS_SUCCESS) { store.CloseFile(h); return (bool?)true; }
st = OpenFile(store, rel, false, out h);
if (st == NTStatus.STATUS_SUCCESS) { store.CloseFile(h); return (bool?)false; }
if (st == NTStatus.STATUS_ACCESS_DENIED) throw new NasAuthException($"Access denied: {uncPath}");
return (bool?)null;
});
}
catch (IOException) { throw; }
catch { return null; }
}
public static bool IsFile(string uncPath) => ExistsAsDir(uncPath) == false;
public static bool IsDirectory(string uncPath) => ExistsAsDir(uncPath) == true;
public static long GetLength(string uncPath)
{
return WithStore(uncPath, (store, s) =>
{
string rel = RelPath(s, uncPath);
NTStatus st = OpenFile(store, rel, false, out object h);
if (st != NTStatus.STATUS_SUCCESS)
throw new IOException($"Cannot open {uncPath}: {st}");
try
{
st = store.GetFileInformation(out FileInformation? info, h, FileInformationClass.FileStandardInformation);
if (st != NTStatus.STATUS_SUCCESS || info is not FileStandardInformation fsi)
throw new IOException($"Cannot stat {uncPath}: {st}");
return fsi.EndOfFile;
}
finally { store.CloseFile(h); }
});
}
public sealed class NasEntry
{
public string FullPath = "";
public string Name = "";
public bool IsDirectory;
public long Size;
}
/// Direct children of a UNC directory (skips . and ..).
public static List<NasEntry> Enumerate(string uncDir)
{
return WithStore(uncDir, (store, s) =>
{
string rel = RelPath(s, uncDir);
NTStatus st = OpenFile(store, rel, true, out object h);
if (st != NTStatus.STATUS_SUCCESS && rel.Length == 0)
st = OpenFile(store, ".", true, out h); // some servers want "." for share root
if (st != NTStatus.STATUS_SUCCESS)
throw new IOException($"Cannot open directory {uncDir}: {st}");
try
{
st = store.QueryDirectory(out List<QueryDirectoryFileInformation> entries, h, "*", FileInformationClass.FileDirectoryInformation);
if (st != NTStatus.STATUS_SUCCESS && st != NTStatus.STATUS_NO_MORE_FILES)
throw new IOException($"Cannot list {uncDir}: {st}");
var result = new List<NasEntry>();
string basePath = Normalize(uncDir).TrimEnd('\\');
foreach (var e in entries)
{
if (e is not FileDirectoryInformation fdi) continue;
if (fdi.FileName is "." or "..") continue;
bool isDir = (fdi.FileAttributes & SMBLibrary.FileAttributes.Directory) != 0;
result.Add(new NasEntry
{
Name = fdi.FileName,
FullPath = basePath + "\\" + fdi.FileName,
IsDirectory = isDir,
Size = isDir ? 0 : fdi.EndOfFile
});
}
return result;
}
finally { store.CloseFile(h); }
});
}
/// Seekable read stream over an SMB file. Multiple streams can be open
/// concurrently on one session (each owns its file handle).
public static Stream OpenRead(string uncPath)
{
var s = SessionFor(uncPath);
lock (s.Lock)
{
if (s.Store == null) Establish(s);
string rel = RelPath(s, uncPath);
NTStatus st = OpenFile(s.Store!, rel, false, out object h);
if (st == NTStatus.STATUS_ACCESS_DENIED) throw new NasAuthException($"Access denied: {uncPath}");
if (st != NTStatus.STATUS_SUCCESS)
throw new IOException($"Cannot open {uncPath}: {st}");
return new SmbReadStream(s, h, uncPath);
}
}
private sealed class SmbReadStream : Stream
{
private readonly Session _s;
private object _handle;
private readonly string _path;
private long _pos = -1; // lazily resolved
private long _len = -1;
private bool _disposed;
public SmbReadStream(Session s, object handle, string path) { _s = s; _handle = handle; _path = path; }
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length
{
get
{
if (_len < 0)
{
lock (_s.Lock)
{
var st = _s.Store!.GetFileInformation(out FileInformation? info, _handle, FileInformationClass.FileStandardInformation);
_len = (st == NTStatus.STATUS_SUCCESS && info is FileStandardInformation fsi) ? fsi.EndOfFile : 0;
}
}
return _len;
}
}
public override long Position { get => _pos < 0 ? 0 : _pos; set => _pos = value; }
public override long Seek(long offset, SeekOrigin origin)
{
long basePos = origin == SeekOrigin.Begin ? 0 : origin == SeekOrigin.Current ? Position : Length;
_pos = basePos + offset;
if (_pos < 0) _pos = 0;
return _pos;
}
public override int Read(byte[] buffer, int offset, int count)
{
if (_disposed) throw new ObjectDisposedException(nameof(SmbReadStream));
lock (_s.Lock)
{
EnsureFresh();
int total = 0;
while (total < count)
{
int want = Math.Min(count - total, 1024 * 1024); // 1MB per SMB read
var st = _s.Store!.ReadFile(out byte[]? data, _handle, _pos, want);
if (st == NTStatus.STATUS_END_OF_FILE || data == null || data.Length == 0) break;
if (st != NTStatus.STATUS_SUCCESS) break;
Buffer.BlockCopy(data, 0, buffer, offset + total, data.Length);
_pos += data.Length;
total += data.Length;
}
return total;
}
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> Task.Run(() => Read(buffer, offset, count), cancellationToken);
private void EnsureFresh()
{
if (_pos < 0) _pos = 0;
if (_s.Store == null)
{
Establish(_s);
string rel = RelPath(_s, _path);
NTStatus st = OpenFile(_s.Store!, rel, false, out object h);
if (st != NTStatus.STATUS_SUCCESS) throw new IOException($"Cannot reopen {_path}: {st}");
_handle = h;
}
}
public override void Flush() { }
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
_disposed = true;
lock (_s.Lock) { try { _s.Store?.CloseFile(_handle); } catch { } }
}
base.Dispose(disposing);
}
}
}
/// <summary>
/// Uniform file access for the upload queue: UNC paths go through the
/// NAS session (SMBLibrary) when one exists — otherwise System.IO so
/// already-authenticated Windows shares keep working; local paths always
/// use System.IO.
/// </summary>
public static class LocalIo
{
public static bool IsUnc(string? path) => NasManager.IsUncPath(path);
/// File exists? (either kind)
public static bool FileExists(string path)
=> IsUnc(path) && NasManager.HasSession(path) ? NasManager.IsFile(path) : File.Exists(path);
public static bool DirExists(string path)
=> IsUnc(path) && NasManager.HasSession(path) ? NasManager.IsDirectory(path) : Directory.Exists(path);
public static long GetLength(string path)
{
if (IsUnc(path) && NasManager.HasSession(path)) return NasManager.GetLength(path);
return new FileInfo(path).Length;
}
public static string GetName(string path)
{
if (IsUnc(path)) return NasManager.Normalize(path).TrimEnd('\\').Split('\\')[^1];
return Path.GetFileName(path.TrimEnd('\\', '/'));
}
public static Stream OpenRead(string path, bool randomAccess = false)
{
if (IsUnc(path) && NasManager.HasSession(path)) return NasManager.OpenRead(path);
var opts = randomAccess ? FileOptions.RandomAccess : FileOptions.SequentialScan;
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, opts);
}
public sealed class Entry
{
public string FullPath = "";
public string Name = "";
public bool IsDirectory;
public long Size;
}
/// Direct children of a local or NAS directory.
public static List<Entry> Enumerate(string dir)
{
if (IsUnc(dir) && NasManager.HasSession(dir))
{
var l = new List<Entry>();
foreach (var e in NasManager.Enumerate(dir))
l.Add(new Entry { FullPath = e.FullPath, Name = e.Name, IsDirectory = e.IsDirectory, Size = e.Size });
return l;
}
var result = new List<Entry>();
var di = new DirectoryInfo(dir);
foreach (var f in di.GetFiles()) result.Add(new Entry { FullPath = f.FullName, Name = f.Name, IsDirectory = false, Size = f.Length });
foreach (var d in di.GetDirectories()) result.Add(new Entry { FullPath = d.FullName, Name = d.Name, IsDirectory = true, Size = 0 });
return result;
}
}
}
+3 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>PS5Suite</AssemblyName>
<RootNamespace>PS5Upload</RootNamespace>
<ApplicationIcon>ps5_logo.ico</ApplicationIcon>
<Version>7.0.0</Version>
<Version>7.2.3</Version>
<Authors>Manos</Authors>
<Description>PS5 Suite - Complete PS5 Management Platform</Description>
<Copyright>Copyright © 2026 Manos</Copyright>
@@ -28,6 +28,8 @@
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.22" />
<PackageReference Include="SMBLibrary" Version="1.5.8.1" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.12" />
<Reference Include="LibProsperoPkg">
<HintPath>libs\LibProsperoPkg.dll</HintPath>
</Reference>
+3 -4
View File
@@ -43,7 +43,7 @@ namespace PS5Upload
/// Keys are stored UNescaped because requests are unescaped before lookup.</summary>
public string AddFile(string localPath)
{
var name = Path.GetFileName(localPath);
var name = LocalIo.GetName(localPath);
_files["/" + name] = localPath;
return "/" + Uri.EscapeDataString(name);
}
@@ -124,8 +124,7 @@ namespace PS5Upload
return;
}
var fi = new FileInfo(localPath);
long total = fi.Length, start = 0, end = total - 1;
long total = LocalIo.GetLength(localPath), start = 0, end = total - 1;
bool partial = false;
// Parse "Range: bytes=a-b" / "bytes=a-" / "bytes=-n".
@@ -174,7 +173,7 @@ namespace PS5Upload
if (method == "GET")
{
using var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var fs = LocalIo.OpenRead(localPath, randomAccess: true);
fs.Seek(start, SeekOrigin.Begin);
var buf = new byte[1024 * 1024];
long remain = len;
+358 -23
View File
@@ -81,6 +81,22 @@ namespace PS5Upload
MemSearch = 0x6C,
KlogRead = 0x6D,
MountGame = 0x6F,
// App Manager v2
AppListV2 = 0x70,
AppSuspend = 0x71,
AppResume = 0x72,
AppKill = 0x73,
AppCoredump = 0x74,
NetInfo = 0x75,
NetSpeedTest = 0x76,
PowerAction = 0x77,
UsbList = 0x78,
PadInfo = 0x79,
DiscDump = 0x7A,
Screenshot = 0x7B,
Notify = 0x7C,
PadAction = 0x7D,
IccControl = 0x7E,
Shutdown = 0xFF
}
@@ -139,19 +155,82 @@ namespace PS5Upload
for (int p = 9113; p <= 9118; p++)
if (!ports.Contains(p)) ports.Add(p);
foreach (var p in ports)
// Probe ALL ports in parallel — a wedged payload accepts TCP but
// never answers PING, and probing serially wastes ~3s per dead port.
var probes = ports.Select(p => ProbePortAsync(ipAddress, p, ct)).ToList();
(int port, TcpClient? client)[] results;
try { results = await Task.WhenAll(probes); }
catch (OperationCanceledException)
{
ct.ThrowIfCancellationRequested();
var r = await ConnectSingleAsync(ipAddress, p, ct);
if (r == ConnectResult.Timeout) { Disconnect(); return false; }
if (r == ConnectResult.Ok && await VerifyAliveAsync())
foreach (var pr in probes)
try { var r = pr.Result; r.client?.Dispose(); } catch { }
throw;
}
TcpClient? winner = null; int winnerPort = 0;
foreach (var (p, client) in results)
{
if (client != null && winner == null) { winner = client; winnerPort = p; }
else if (client != null) { client.Close(); client.Dispose(); }
}
if (winner == null) { Disconnect(); return false; }
_client = winner;
TrySetSocketBuffers(_client, 16 * 1024 * 1024);
_stream = winner.GetStream();
Port = winnerPort;
return true;
}
/// <summary>TCP-connect + one bounded PING on a single port. Returns the
/// live socket on success so the caller can keep using it.</summary>
private static async Task<(int port, TcpClient? client)> ProbePortAsync(
string ipAddress, int port, CancellationToken ct)
{
TcpClient? c = null;
try
{
c = new TcpClient { NoDelay = true, LingerState = new System.Net.Sockets.LingerOption(false, 0) };
var connectTask = c.ConnectAsync(ipAddress, port);
if (await Task.WhenAny(connectTask, Task.Delay(2000, ct)) != connectTask)
{ c.Dispose(); return (port, null); }
await connectTask;
if (!c.Connected) { c.Dispose(); return (port, null); }
var s = c.GetStream();
byte[] ping = { (byte)Command.Ping, 0, 0, 0, 0 };
using var probe = new CancellationTokenSource(2000);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(probe.Token, ct);
await s.WriteAsync(ping, linked.Token);
// Read the FULL framed response (5-byte header + payload) —
// PING answers "PONG" (4 bytes); leaving them in the stream
// desyncs every subsequent command.
var hdr = new byte[5];
int got = 0;
while (got < 5)
{
Port = p;
return true;
int n = await s.ReadAsync(hdr, got, 5 - got, linked.Token);
if (n == 0) break;
got += n;
}
Disconnect();
uint payloadLen = got == 5 ? BitConverter.ToUInt32(hdr, 1) : 0;
var body = new byte[payloadLen];
got = 0;
while (got < payloadLen)
{
int n = await s.ReadAsync(body, got, (int)payloadLen - got, linked.Token);
if (n == 0) break;
got += n;
}
if (hdr[0] == (byte)Response.Ok && got == payloadLen) return (port, c);
c.Close(); c.Dispose();
return (port, null);
}
catch
{
c?.Dispose();
return (port, null);
}
return false;
}
/// <summary>
@@ -243,8 +322,7 @@ namespace PS5Upload
try
{
_client = new TcpClient();
_client.ReceiveBufferSize = 16 * 1024 * 1024; // 16MB - matches payload SO_RCVBUF setting
_client.SendBufferSize = 16 * 1024 * 1024; // 16MB - matches payload SO_RCVBUF setting
TrySetSocketBuffers(_client, 16 * 1024 * 1024); // 16MB - matches payload SO_RCVBUF setting
_client.NoDelay = true;
_client.LingerState = new System.Net.Sockets.LingerOption(false, 0);
@@ -318,7 +396,34 @@ namespace PS5Upload
await _stream.WriteAsync(header, 0, 5);
if (data != null && data.Length > 0)
{
await _stream.WriteAsync(data, 0, data.Length);
await WriteSocketChunkedAsync(_stream, data, 0, data.Length, CancellationToken.None);
}
}
// macOS caps SO_SNDBUF/SO_RCVBUF at kern.ipc.maxsockbuf (8MB default).
// setsockopt above the cap returns ENOBUFS — clamp down until it sticks.
private static void TrySetSocketBuffers(TcpClient client, int desired)
{
for (int sz = desired; sz >= 1 * 1024 * 1024; sz >>= 1)
{
try { client.ReceiveBufferSize = sz; client.SendBufferSize = sz; return; }
catch (SocketException) { }
}
}
// macOS kern.ipc.maxsockbuf defaults to 8MB — a single send() larger
// than that fails with ENOBUFS. Slice every socket write to 4MB;
// invisible overhead on Windows/Linux, fixes Darwin transfers.
private const int MaxSocketWriteBytes = 4 * 1024 * 1024;
private static async Task WriteSocketChunkedAsync(NetworkStream stream, byte[] buffer, int offset, int count, CancellationToken ct)
{
while (count > 0)
{
int n = Math.Min(count, MaxSocketWriteBytes);
await stream.WriteAsync(buffer, offset, n, ct);
offset += n;
count -= n;
}
}
@@ -420,7 +525,8 @@ namespace PS5Upload
return null;
}
// Get path if available (part 8)
// Get path if available (part 8); part 9 was a dev-only mount
// dump — intentionally ignored now.
string storagePath = (parts.Length >= 8) ? parts[7].Trim() : "unknown";
return new PS5StorageInfo
@@ -1047,6 +1153,138 @@ namespace PS5Upload
}
}
// Power action: "reboot" or "shutdown" — the console goes down right
// after the ACK, so callers should expect the connection to drop.
public async Task<(bool success, string message)> PowerActionAsync(string action)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.PowerAction, Encoding.UTF8.GetBytes(action));
var (response, data) = await ReceiveResponseAsync(10000);
string msg = data.Length > 0 ? Encoding.UTF8.GetString(data) : response.ToString();
return (response == Response.Ok, msg.TrimEnd('\0', '\n', '\r'));
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
public async Task<List<PS5UsbDrive>> ListUsbDrivesAsync()
{
var drives = new List<PS5UsbDrive>();
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.UsbList);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) return drives;
foreach (var line in Encoding.UTF8.GetString(data).Split('\n'))
{
if (string.IsNullOrWhiteSpace(line) || line == "NONE") continue;
var p = line.Trim().Split('|');
if (p.Length < 5) continue;
drives.Add(new PS5UsbDrive
{
MountPath = p[0],
FsType = p[1],
Device = p[2],
TotalBytes = ulong.TryParse(p[3], out var t) ? t : 0,
FreeBytes = ulong.TryParse(p[4], out var f) ? f : 0
});
}
return drives;
}
catch
{
return drives;
}
finally
{
_commandLock.Release();
}
}
public async Task<string?> GetPadInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.PadInfo);
var (response, data) = await ReceiveResponseAsync(15000);
if (response != Response.Data) return null;
return Encoding.UTF8.GetString(data);
}
catch
{
return null;
}
finally
{
_commandLock.Release();
}
}
// Generic simple command that takes a string arg and returns a short
// status string — used by the devices/fun features.
public async Task<(bool success, string message)> SendTextCommandAsync(Command cmd, string arg, int timeoutMs = 15000)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(cmd, Encoding.UTF8.GetBytes(arg));
var (response, data) = await ReceiveResponseAsync(timeoutMs);
string msg = data.Length > 0 ? Encoding.UTF8.GetString(data).TrimEnd('\0', '\n', '\r') : response.ToString();
return (response == Response.Ok, msg);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
public Task<(bool success, string message)> DiscDumpAsync(string action)
=> SendTextCommandAsync(Command.DiscDump, action);
public async Task<(bool success, string message)> CaptureScreenshotAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.Screenshot);
var (response, data) = await ReceiveResponseAsync(20000);
string msg = data.Length > 0 ? Encoding.UTF8.GetString(data).TrimEnd('\0', '\n', '\r') : response.ToString();
return (response == Response.Ok, msg);
}
catch (Exception ex)
{
return (false, ex.Message);
}
finally
{
_commandLock.Release();
}
}
public Task<(bool success, string message)> NotifyAsync(string text)
=> SendTextCommandAsync(Command.Notify, text);
public Task<(bool success, string message)> PadActionAsync(string cmd)
=> SendTextCommandAsync(Command.PadAction, cmd);
public Task<(bool success, string message)> IccControlAsync(string cmd)
=> SendTextCommandAsync(Command.IccControl, cmd);
// NEW: Get list of mounted games
public async Task<List<PS5MountedGame>> GetGameListAsync()
{
@@ -1729,17 +1967,17 @@ namespace PS5Upload
private async Task<bool> UploadFileCoreAsync(string localPath, string remotePath, IProgress<UploadProgress>? progress, CancellationToken cancellationToken, long chunkOffset, long chunkSize, Action? onReadyCallback)
{
FileInfo fileInfo = new FileInfo(localPath);
if (!fileInfo.Exists) return false;
if (!LocalIo.FileExists(localPath)) return false;
long fileLen = LocalIo.GetLength(localPath);
// Determine actual upload size
long uploadSize = chunkSize > 0 ? chunkSize : fileInfo.Length;
long uploadSize = chunkSize > 0 ? chunkSize : fileLen;
// Send START_UPLOAD with optional chunk offset
byte[] pathBytes = Encoding.UTF8.GetBytes(remotePath);
byte[] startData = new byte[pathBytes.Length + 1 + 8 + 8]; // path + null + size + offset
Array.Copy(pathBytes, 0, startData, 0, pathBytes.Length);
BitConverter.GetBytes(fileInfo.Length).CopyTo(startData, pathBytes.Length + 1);
BitConverter.GetBytes(fileLen).CopyTo(startData, pathBytes.Length + 1);
BitConverter.GetBytes(chunkOffset).CopyTo(startData, pathBytes.Length + 9);
await SendCommandAsync(Command.StartUpload, startData);
@@ -1771,9 +2009,10 @@ namespace PS5Upload
try
{
// Use RandomAccess for chunked uploads to avoid disk I/O contention when multiple workers read same file
FileOptions fileOptions = chunkOffset > 0 ? FileOptions.RandomAccess : FileOptions.SequentialScan;
using (FileStream fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, fileOptions))
// RandomAccess for chunked uploads to avoid disk I/O contention
// when multiple workers read the same file. UNC paths go
// through the NAS session via a seekable SMB stream.
using (Stream fs = LocalIo.OpenRead(localPath, randomAccess: chunkOffset > 0))
{
if (chunkOffset > 0)
{
@@ -1809,7 +2048,7 @@ namespace PS5Upload
try
{
await _stream.WriteAsync(writeBuffer, 0, 5 + bytesRead, linkedCts.Token);
await WriteSocketChunkedAsync(_stream, writeBuffer, 0, 5 + bytesRead, linkedCts.Token);
}
catch (OperationCanceledException) when (writeTimeout.IsCancellationRequested)
{
@@ -1837,12 +2076,12 @@ namespace PS5Upload
progress?.Report(new UploadProgress
{
BytesSent = chunkOffset + totalSent,
TotalBytes = fileInfo.Length,
TotalBytes = fileLen,
SpeedBytesPerSecond = currentSpeed,
AverageSpeedBytesPerSecond = avgSpeed,
ElapsedTime = elapsed,
EstimatedTimeRemaining = eta,
CurrentFileName = fileInfo.Name
CurrentFileName = LocalIo.GetName(localPath)
});
}
@@ -2436,6 +2675,83 @@ namespace PS5Upload
finally { _commandLock.Release(); }
}
// ================= App Manager v2 =================
/// <summary>pid|appid|title|comm|apptype|cpu_x100|suspended per line (+ appinfo=ok line)</summary>
public async Task<string?> AppListV2Async()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.AppListV2);
var (response, data) = await ReceiveResponseAsync(30000);
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
return Encoding.UTF8.GetString(data);
}
catch (Exception ex) { LastError = $"AppListV2Async: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
private async Task<(bool ok, string msg)> AppActionAsync(Command cmd, int appId, int pid = 0)
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(cmd, Encoding.UTF8.GetBytes($"{appId}|{pid}"));
var (response, data) = await ReceiveResponseAsync(30000);
return (response == Response.Ok, Encoding.UTF8.GetString(data));
}
catch (Exception ex) { LastError = $"AppAction: {ex.Message}"; return (false, LastError); }
finally { _commandLock.Release(); }
}
public Task<(bool ok, string msg)> AppSuspendAsync(int appId, int pid) => AppActionAsync(Command.AppSuspend, appId, pid);
public Task<(bool ok, string msg)> AppResumeAsync(int appId, int pid) => AppActionAsync(Command.AppResume, appId, pid);
public Task<(bool ok, string msg)> AppKillAsync(int appId, int pid) => AppActionAsync(Command.AppKill, appId, pid);
public Task<(bool ok, string msg)> AppCoredumpAsync(int appId) => AppActionAsync(Command.AppCoredump, appId);
/// <summary>
/// Network info: key=value lines plus per-interface byte counters
/// ("if=name|rx=N|tx=N"). Returns the raw text for the caller to parse.
/// </summary>
public async Task<string?> GetNetInfoAsync()
{
await _commandLock.WaitAsync();
try
{
await SendCommandAsync(Command.NetInfo);
var (response, data) = await ReceiveResponseAsync();
if (response != Response.Data) { LastError = Encoding.UTF8.GetString(data); return null; }
return Encoding.UTF8.GetString(data);
}
catch (Exception ex) { LastError = $"GetNetInfoAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
/// <summary>
/// LAN link speed test: payload streams 16MB; returns receive throughput in Mbps.
/// </summary>
public async Task<double?> NetSpeedTestAsync()
{
await _commandLock.WaitAsync();
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
await SendCommandAsync(Command.NetSpeedTest);
var (response, data) = await ReceiveResponseAsync();
sw.Stop();
if (response != Response.Data || data.Length == 0)
{
LastError = data.Length > 0 ? Encoding.UTF8.GetString(data) : "no data";
return null;
}
double mbps = data.Length * 8.0 / sw.Elapsed.TotalSeconds / 1_000_000.0;
return mbps;
}
catch (Exception ex) { LastError = $"NetSpeedTestAsync: {ex.Message}"; return null; }
finally { _commandLock.Release(); }
}
/// <summary>
/// Read the kernel message buffer (kern.msgbuf). tailBytes &gt; 0 limits to the tail.
/// </summary>
@@ -3159,6 +3475,25 @@ namespace PS5Upload
public string Path { get; set; } = "";
}
public class PS5UsbDrive
{
public string MountPath { get; set; } = "";
public string FsType { get; set; } = "";
public string Device { get; set; } = "";
public ulong TotalBytes { get; set; }
public ulong FreeBytes { get; set; }
public string TotalGB => FormatBytes(TotalBytes);
public string FreeGB => FormatBytes(FreeBytes);
private static string FormatBytes(ulong bytes)
{
double b = bytes;
if (b >= 1000.0 * 1000 * 1000) return $"{b / (1000.0 * 1000 * 1000):F1} GB";
if (b >= 1000.0 * 1000) return $"{b / (1000.0 * 1000):F1} MB";
return $"{b:F0} B";
}
}
// Save game info
public class PS5SaveGame : System.ComponentModel.INotifyPropertyChanged
{
+55 -2
View File
@@ -24,12 +24,19 @@ if (args.Length >= 1 && args[0] == "inodeoff")
return InodeOffsetCheck();
if (args.Length >= 3 && args[0] == "realbuild")
return RealBuild(args[1], args[2]);
if (args.Length >= 3 && args[0] == "homebrew")
return HomebrewBuild(args[1], args[2]);
if (args.Length >= 3 && args[0] == "fself")
return FselfWrap(args[1], args[2]);
if (args.Length >= 2 && args[0] == "cntdump")
return CntDump(args[1]);
Console.WriteLine("usage: test-stream verify <pkg> <srcFolder> <outDir> [passcode]\n" +
" test-stream diag <pkg> [passcode]\n" +
" test-stream napsrt (synthetic inner-image -> naps -> mount round-trip)\n" +
" test-stream pkgbuild [srcBytes] (full build -> extract -> diff round-trip)\n" +
" test-stream inodeoff (inode LogicalOffset >4GiB serialization check)");
" test-stream inodeoff (inode LogicalOffset >4GiB serialization check)\n" +
" test-stream homebrew <srcFolder> <outDir> (license-free debug fPKG via ProsperoHomebrewPackager)");
return 0;
// ---------- napsrt: inner-image -> naps -> mount reconstruction round-trip ----------
@@ -659,6 +666,52 @@ static int RealBuild(string srcFolder, string outDir)
return 0;
}
// ---------- homebrew: license-free debug fPKG via ProsperoHomebrewPackager ----------
static int HomebrewBuild(string homebrewFolder, string outDir)
{
var opts = new ProsperoHomebrewPackageOptions
{
HomebrewFolder = homebrewFolder,
OutputFolder = outDir,
KeepStaging = false,
};
var result = ProsperoHomebrewPackager.Package(opts, m => Console.WriteLine($" | {m}"));
if (result == null || string.IsNullOrEmpty(result.OutputPath)) { Console.WriteLine("BUILD FAILED"); return 1; }
Console.WriteLine($"built: {result.OutputPath} ({new FileInfo(result.OutputPath).Length:N0} bytes)");
Console.WriteLine($"launch ready: {(result.LaunchReadiness.IsLaunchReady ? "yes" : "NO")}");
foreach (var i in result.LaunchReadiness.Issues) Console.WriteLine($" issue: {i}");
foreach (var w in result.Warnings) Console.WriteLine($" warning: {w}");
return result.LaunchReadiness.IsLaunchReady ? 0 : 1;
}
// ---------- cntdump: list container header + entries ----------
static int CntDump(string pkgPath)
{
var pkg = ProsperoPkgReader.Read(pkgPath);
Console.WriteLine($"type={pkg.Type}");
if (pkg.Header is { } h)
Console.WriteLine($"flags=0x{h.Flags:X8} entries={h.EntryCount} scEntries={h.ScEntryCount} " +
$"contentId={h.ContentId} drm=0x{h.DrmType:X} ctype=0x{h.ContentType:X} cflags=0x{h.ContentFlags:X8}");
if (pkg.Fih is { } f)
Console.WriteLine($"FIH: signed=0x{f.SignedByte:X2} fmt={f.FormatVersion} pfs@0x{f.PfsImageOffset:X}+0x{f.PfsImageSize:X} cnt@0x{f.EmbeddedCntOffset:X}");
foreach (var e in pkg.Entries)
Console.WriteLine($" id=0x{e.RawId:X4} name={e.Name ?? "(unnamed)"} off=0x{e.DataOffset:X} size=0x{e.DataSize:X} " +
$"enc={(e.Encrypted ? 1 : 0)} keyIdx={e.KeyIndex} f1=0x{e.Flags1:X8} f2=0x{e.Flags2:X8}");
return 0;
}
// ---------- fself: wrap a raw ELF module in the PS5 SELF container ----------
static int FselfWrap(string inElf, string outFile)
{
var elf = File.ReadAllBytes(inElf);
var fself = LibProsperoPkg.Content.ProsperoFself.MakeFself(elf);
File.WriteAllBytes(outFile, fself);
Console.WriteLine($"fself: {inElf} ({elf.Length} B) -> {outFile} ({fself.Length} B), magic={BinaryPrimitives.ReadUInt32LittleEndian(fself):X8}");
return 0;
}
sealed class OuterDecReader : IMemoryReader
{
@@ -696,4 +749,4 @@ sealed class OuterDecReader : IMemoryReader
pos += n; offset += n;
}
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ all: $(ELF)
# (NOT kernel_web: externs like sceKernelGetCpuTemperature don't exist in the
# runtime web variant and would make the ELF fail to launch).
$(ELF): main.c
$(CC) $(CFLAGS) -o $@ $^ -lkernel_sys -lSceSystemService -lSceUserService -lSceAppInstUtil -lsqlite3
$(CC) $(CFLAGS) -o $@ $^ -lkernel_sys -lSceSystemService -lSceUserService -lSceAppInstUtil -lSceNetCtl -lScePad -lsqlite3
$(PS5_PAYLOAD_SDK)/bin/llvm-strip $@
# Push the freshly built payload straight to the console (elfldr must be
+2051 -42
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
"""Direct PS5 Suite payload probe — sends raw commands to the payload server
and prints the raw response, for debugging without the client.
Usage: python ps5_probe.py [ip] <cmd_hex> [arg-string]
python ps5_probe.py 0x79 (pad info)
python ps5_probe.py 0x7E ledcolor|255,0,0
"""
import socket
import struct
import sys
IP = "192.168.0.62"
PORT = 9113
def send_cmd(ip, cmd, data: bytes = b"", timeout=15.0):
s = socket.socket()
s.settimeout(timeout)
s.connect((ip, PORT))
s.sendall(bytes([cmd]) + struct.pack("<I", len(data)) + data)
hdr = b""
while len(hdr) < 5:
chunk = s.recv(5 - len(hdr))
if not chunk:
raise ConnectionError("closed while reading header")
hdr += chunk
resp, dlen = hdr[0], struct.unpack("<I", hdr[1:])[0]
body = b""
while len(body) < dlen:
chunk = s.recv(min(65536, dlen - len(body)))
if not chunk:
break
body += chunk
s.close()
return resp, body
def main():
args = sys.argv[1:]
ip = IP
if args and ":" in args[0] or (args and args[0].count(".") == 3):
ip = args.pop(0)
if not args:
print(__doc__)
return
cmd = int(args[0], 0)
data = args[1].encode() if len(args) > 1 else b""
resp, body = send_cmd(ip, cmd, data)
name = {1: "OK", 2: "ERROR", 3: "DATA", 0x10: "PROGRESS"}.get(resp, hex(resp))
print(f"[resp={name} len={len(body)}]")
try:
print(body.decode("utf-8", errors="replace"))
except Exception:
print(body)
if __name__ == "__main__":
main()