mirror of
https://github.com/itsmemac/Packwright.git
synced 2026-10-06 13:00:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5395322138 |
No files matched your search
@@ -187,9 +187,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
version="${{ needs.version.outputs.version }}"
|
version="${{ needs.version.outputs.version }}"
|
||||||
(cd dist && sha256sum * > SHA256SUMS.txt)
|
(cd dist && sha256sum * > SHA256SUMS.txt)
|
||||||
cat > notes.md <<NOTES
|
# The notes file for this version (release-notes/<version>.md), when there is one, goes first: it is what
|
||||||
## Packwright v${version}
|
# the app's update window shows under "what is new".
|
||||||
|
{
|
||||||
|
echo "## Packwright v${version}"
|
||||||
|
echo
|
||||||
|
if [ -f "release-notes/${version}.md" ]; then cat "release-notes/${version}.md"; echo; fi
|
||||||
|
} > notes.md
|
||||||
|
cat >> notes.md <<NOTES
|
||||||
Pick the installer for your system. Everything is self-contained, nothing else needs to be installed.
|
Pick the installer for your system. Everything is self-contained, nothing else needs to be installed.
|
||||||
|
|
||||||
| System | Download |
|
| System | Download |
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ Only do this for downloads you got from this project's [releases page](https://g
|
|||||||
|
|
||||||
1. Open **Library** and choose **Add folder**. Point it at a folder with PS5 dumps, `.pkg` files or images. Use **More > Add a package or image file** for a single item.
|
1. Open **Library** and choose **Add folder**. Point it at a folder with PS5 dumps, `.pkg` files or images. Use **More > Add a package or image file** for a single item.
|
||||||
2. Browse your collection as artwork cards or as a list. Select a game to see its details on the right.
|
2. Browse your collection as artwork cards or as a list. Select a game to see its details on the right.
|
||||||
3. Right-click a game for the things you can do with it: convert or build it, edit its details, send it to a console, rename, move or delete it.
|
3. Right-click a game for the things you can do with it: convert or build, extract or verify it, edit its details, send it to a console, rename, move or delete it.
|
||||||
4. Open **Tools** to convert, extract, verify or repair something. The source can come from the library, the file pickers, drag and drop onto the window, or the command line:
|
4. Open **Tools** to convert, extract, verify or repair something. The source can come from the library, the file pickers, drag and drop onto the window, or the command line:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -157,7 +157,7 @@ The **Health** page checks the whole library at a glance: files that were moved
|
|||||||
|
|
||||||
- **Convert** between dump folders and **exFAT**, **FFPKG**, **FFPFSC** and **ZArchive (`.zar`)** images, or build a debug package (**FPKG**) from a dump or an image. A `.zar` can be used as a source too: extract it, verify it, convert it to another image, or build a package from it.
|
- **Convert** between dump folders and **exFAT**, **FFPKG**, **FFPFSC** and **ZArchive (`.zar`)** images, or build a debug package (**FPKG**) from a dump or an image. A `.zar` can be used as a source too: extract it, verify it, convert it to another image, or build a package from it.
|
||||||
- **Presets:** save the target and all its options under a name and apply them again in one click. A few presets are built in (for example the smallest FFPFSC, or a reproducible package). A preset never contains the source or output path.
|
- **Presets:** save the target and all its options under a name and apply them again in one click. A few presets are built in (for example the smallest FFPFSC, or a reproducible package). A preset never contains the source or output path.
|
||||||
- **Extract** a dump tree out of an image or package, and **verify** images and packages.
|
- **Extract** a dump tree out of an image or package, and **verify** images and packages. A finished check says "Verified: no problems found" with what was checked and how long it took; a failed one says in plain words what is wrong.
|
||||||
- **Edit files** inside exFAT and FFPKG images (replace, add, import a folder, create folders, delete). Changes are rebuilt beside the original and swapped in only after verification.
|
- **Edit files** inside exFAT and FFPKG images (replace, add, import a folder, create folders, delete). Changes are rebuilt beside the original and swapped in only after verification.
|
||||||
- **Repair** a damaged exFAT image, **refresh the AMPR index**, and **rebuild** FFPKG metadata.
|
- **Repair** a damaged exFAT image, **refresh the AMPR index**, and **rebuild** FFPKG metadata.
|
||||||
- ZArchive files are written and read by a managed implementation of the [ZArchive](https://github.com/Exzap/ZArchive) format (zstd blocks, SHA-256 integrity). A new archive is verified after the build, and a `.zar` in your library is browsed in place, without unpacking it.
|
- ZArchive files are written and read by a managed implementation of the [ZArchive](https://github.com/Exzap/ZArchive) format (zstd blocks, SHA-256 integrity). A new archive is verified after the build, and a `.zar` in your library is browsed in place, without unpacking it.
|
||||||
@@ -173,10 +173,10 @@ The **Health** page checks the whole library at a glance: files that were moved
|
|||||||
|
|
||||||
Right-click a game, image or package and choose **Send to console...** to put it on a console on your own network.
|
Right-click a game, image or package and choose **Send to console...** to put it on a console on your own network.
|
||||||
|
|
||||||
- **FTP upload:** large files are streamed, a partly sent file is continued, a copy that is already there is skipped, the size is checked afterwards, and dropped connections are retried. Dump folders upload file by file. It runs as a background task with progress on the Tasks page.
|
- **FTP upload:** large files are streamed, a partly sent file is continued (when the console supports it), a copy that is already there is skipped, and the size is checked afterwards. If the connection drops (a sleeping phone, a Wi-Fi hiccup) Packwright waits and reconnects by itself, up to six times in a row without progress, and shows a countdown. If it finally gives up, the task says why in plain words and **Retry** on the Tasks page picks up where it stopped. Dump folders upload file by file. It runs as a background task with progress on the Tasks page.
|
||||||
- **Install by URL** (for a `.pkg`): Packwright shares the file from your PC for the length of the download and asks the console's package installer to fetch it. Installer payloads differ in the request they expect, so the request is a template you can adjust; its defaults follow the common "remote package installer" style and are **untested** on a real console.
|
- **Install by URL** (for a `.pkg`): Packwright shares the file from your PC for the length of the download and asks the console's package installer to fetch it. Installer payloads differ in the request they expect, so the request is a template you can adjust; its defaults follow the common "remote package installer" style and are **untested** on a real console.
|
||||||
- **Consoles** are set up in **Settings > Manage consoles** (address, FTP port, default folder, **Test connection**).
|
- **Consoles** are set up in **Settings > Manage consoles** (address, FTP port, default folder, **Test connection**). Console FTP payloads usually let anyone in, so the user name and password can stay empty. A phone or PC running an FTP app normally shows a user name and password to enter there; the password is saved in your settings file, not encrypted, and is never exported.
|
||||||
- **Privacy and safety:** only addresses on your local network are accepted, nothing is contacted until you press Send or Test, and no account or password is stored. Your firewall may ask you to allow Packwright while it shares a file.
|
- **Privacy and safety:** only addresses on your local network are accepted, and nothing is contacted until you press Send or Test. Your firewall may ask you to allow Packwright while it shares a file.
|
||||||
- Also available as `packwright send` (see [Command line](#command-line)).
|
- Also available as `packwright send` (see [Command line](#command-line)).
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
; Packwright installer (Inno Setup 6). Built by the release workflow:
|
; Packwright installer (Inno Setup 6). Built by the release workflow:
|
||||||
; iscc /DAppVersion=1.0.0 /DSourceDir=<publish folder> /O<output folder> installer\Packwright.iss
|
; iscc /DAppVersion=1.0.1 /DSourceDir=<publish folder> /O<output folder> installer\Packwright.iss
|
||||||
; Installs for the current user by default (no administrator prompt), so a silent update is smooth. The "for all
|
; Installs for the current user by default (no administrator prompt), so a silent update is smooth. The "for all
|
||||||
; users" choice is offered when the installer is started as administrator.
|
; users" choice is offered when the installer is started as administrator.
|
||||||
|
|
||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "1.0.0"
|
#define AppVersion "1.0.1"
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceDir
|
#ifndef SourceDir
|
||||||
#define SourceDir "..\publish"
|
#define SourceDir "..\publish"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
### What is new
|
||||||
|
|
||||||
|
- **Send to console is much more reliable.** The progress bar and the sizes (done, left, total) now work. If the connection drops, for example when a phone goes to sleep, Packwright waits, reconnects and carries on by itself. FTP servers that need a user name and password (a phone or PC running an FTP app) are supported in **Settings, Manage consoles**.
|
||||||
|
- **Clear error messages.** Problems on the Tasks page are now written in plain words and say what to check. The technical details are saved in the log.
|
||||||
|
- **Verify tells you the result.** A finished check says "Verified: no problems found" with what was checked and how long it took. A failed check says in plain words what is wrong and what to do. Verifying an image now also shows real progress.
|
||||||
|
- **Right-click a game for Convert / Build, Extract or Verify.** Each one opens Tools with the game and the action already chosen.
|
||||||
|
- **Tasks show how much is done.** Every task that knows its size shows the amount done, left and total.
|
||||||
|
- **Tools:** a built-in ZArchive preset. Presets now only appear for Convert / Build, so they no longer switch you away from Verify or Extract.
|
||||||
|
- **A clearer task counter** in the sidebar, including the collapsed sidebar.
|
||||||
|
- A package that is incomplete or damaged now says so, instead of suggesting a passcode problem.
|
||||||
|
|
||||||
|
If you already have Packwright 1.0.0, open **About** and choose **Check for updates**.
|
||||||
@@ -29,7 +29,7 @@ internal static class Cli
|
|||||||
private static readonly HashSet<string> ValueOptions = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> ValueOptions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"to", "o", "output", "preset", "passcode", "level", "gain", "cluster", "block", "fragment", "density", "min-free",
|
"to", "o", "output", "preset", "passcode", "level", "gain", "cluster", "block", "fragment", "density", "min-free",
|
||||||
"compression", "kraken-level", "threads", "playgo", "drm", "builder", "workspace", "format", "folder", "host", "port"
|
"compression", "kraken-level", "threads", "playgo", "drm", "builder", "workspace", "format", "folder", "host", "port", "user", "password"
|
||||||
};
|
};
|
||||||
|
|
||||||
public static bool IsCommand(string[] args) =>
|
public static bool IsCommand(string[] args) =>
|
||||||
@@ -159,6 +159,7 @@ internal static class Cli
|
|||||||
verify <source> Check an image or package for damage.
|
verify <source> Check an image or package for damage.
|
||||||
send <source> --to <name> Send to a console on your network (FTP). --folder <path> | --install (a .pkg: the
|
send <source> --to <name> Send to a console on your network (FTP). --folder <path> | --install (a .pkg: the
|
||||||
console downloads it from this PC) | --host <ip> --port <n> instead of --to
|
console downloads it from this PC) | --host <ip> --port <n> instead of --to
|
||||||
|
(a console that needs a login: --user <name> --password <text>)
|
||||||
consoles List the consoles saved in Settings.
|
consoles List the consoles saved in Settings.
|
||||||
version Print the version.
|
version Print the version.
|
||||||
|
|
||||||
@@ -296,6 +297,8 @@ internal static class Cli
|
|||||||
profile = new ConsoleProfile { Name = host, Host = host, FtpPort = options.Int("port") ?? 1337 };
|
profile = new ConsoleProfile { Name = host, Host = host, FtpPort = options.Int("port") ?? 1337 };
|
||||||
else
|
else
|
||||||
return Fail("send needs --to <console name> or --host <address>.", Usage);
|
return Fail("send needs --to <console name> or --host <address>.", Usage);
|
||||||
|
if (options.Get("user") is { } user) profile.FtpUser = user;
|
||||||
|
if (options.Get("password") is { } password) profile.FtpPassword = password;
|
||||||
var progress = new ConsoleProgress();
|
var progress = new ConsoleProgress();
|
||||||
Console.Error.WriteLine($"send: {source}");
|
Console.Error.WriteLine($"send: {source}");
|
||||||
if (options.Has("install"))
|
if (options.Has("install"))
|
||||||
@@ -359,7 +362,7 @@ internal static class Cli
|
|||||||
if (spec.Output.Length > 0) Console.Error.WriteLine(" -> " + spec.Output);
|
if (spec.Output.Length > 0) Console.Error.WriteLine(" -> " + spec.Output);
|
||||||
await run(printer, token);
|
await run(printer, token);
|
||||||
printer.Finish();
|
printer.Finish();
|
||||||
Console.Error.WriteLine(command == "verify" ? "Verified: no problems found." : "Done.");
|
Console.Error.WriteLine(printer.Result.Length > 0 ? printer.Result : command == "verify" ? "Verified: no problems found." : "Done.");
|
||||||
return Ok;
|
return Ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,11 +472,13 @@ internal static class Cli
|
|||||||
private sealed class ConsoleProgress : IProgress<PackageTaskProgress>
|
private sealed class ConsoleProgress : IProgress<PackageTaskProgress>
|
||||||
{
|
{
|
||||||
private string _stage = string.Empty;
|
private string _stage = string.Empty;
|
||||||
|
public string Result { get; private set; } = string.Empty;
|
||||||
private int _lastPercent = -1;
|
private int _lastPercent = -1;
|
||||||
private bool _lineOpen;
|
private bool _lineOpen;
|
||||||
|
|
||||||
public void Report(PackageTaskProgress value)
|
public void Report(PackageTaskProgress value)
|
||||||
{
|
{
|
||||||
|
if (value.Result.Length > 0) Result = value.Result;
|
||||||
int percent = value.StagePercent >= 0 ? (int)value.StagePercent
|
int percent = value.StagePercent >= 0 ? (int)value.StagePercent
|
||||||
: value.TotalBytes > 0 ? (int)(value.CurrentBytes * 100 / value.TotalBytes) : -1;
|
: value.TotalBytes > 0 ? (int)(value.CurrentBytes * 100 / value.TotalBytes) : -1;
|
||||||
bool newStage = value.Stage.Length > 0 && value.Stage != _stage;
|
bool newStage = value.Stage.Length > 0 && value.Stage != _stage;
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ public static class AppSettingsNormalizer
|
|||||||
console.Host = (console.Host ?? string.Empty).Trim();
|
console.Host = (console.Host ?? string.Empty).Trim();
|
||||||
console.FtpPort = console.FtpPort is > 0 and <= 65535 ? console.FtpPort : 1337;
|
console.FtpPort = console.FtpPort is > 0 and <= 65535 ? console.FtpPort : 1337;
|
||||||
console.DefaultFolder = string.IsNullOrWhiteSpace(console.DefaultFolder) ? "/data" : console.DefaultFolder.Trim();
|
console.DefaultFolder = string.IsNullOrWhiteSpace(console.DefaultFolder) ? "/data" : console.DefaultFolder.Trim();
|
||||||
|
console.FtpUser = (console.FtpUser ?? string.Empty).Trim();
|
||||||
|
console.FtpPassword ??= string.Empty;
|
||||||
console.InstallUrl ??= string.Empty;
|
console.InstallUrl ??= string.Empty;
|
||||||
console.InstallBody ??= string.Empty;
|
console.InstallBody ??= string.Empty;
|
||||||
console.InstallContentType = string.IsNullOrWhiteSpace(console.InstallContentType) ? "application/json" : console.InstallContentType.Trim();
|
console.InstallContentType = string.IsNullOrWhiteSpace(console.InstallContentType) ? "application/json" : console.InstallContentType.Trim();
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ public sealed class ConsoleProfile
|
|||||||
public int FtpPort { get; set; } = 1337;
|
public int FtpPort { get; set; } = 1337;
|
||||||
/// <summary>Where files go on the console unless another folder is chosen when sending.</summary>
|
/// <summary>Where files go on the console unless another folder is chosen when sending.</summary>
|
||||||
public string DefaultFolder { get; set; } = "/data";
|
public string DefaultFolder { get; set; } = "/data";
|
||||||
|
/// <summary>The FTP login. Empty means anyone (anonymous), which console FTP payloads usually allow.</summary>
|
||||||
|
public string FtpUser { get; set; } = string.Empty;
|
||||||
|
/// <summary>Saved on this PC in the settings file (not encrypted). Queued tasks do not keep it.</summary>
|
||||||
|
public string FtpPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
// "Install by URL": the console's installer is asked to download a package from this PC. Different installers
|
// "Install by URL": the console's installer is asked to download a package from this PC. Different installers
|
||||||
// use different requests, so the request is a template. {host} is the console, {url} the address of the file.
|
// use different requests, so the request is a template. {host} is the console, {url} the address of the file.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<RootNamespace>Packwright.App</RootNamespace>
|
<RootNamespace>Packwright.App</RootNamespace>
|
||||||
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.1</Version>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.0.1.0</FileVersion>
|
||||||
<Product>Packwright</Product>
|
<Product>Packwright</Product>
|
||||||
<Authors>itsmemac</Authors>
|
<Authors>itsmemac</Authors>
|
||||||
<Company>itsmemac</Company>
|
<Company>itsmemac</Company>
|
||||||
|
|||||||
@@ -95,9 +95,10 @@ public sealed class AppServices
|
|||||||
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
|
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
|
||||||
|
|
||||||
/// <summary>Raised when another view wants the Tools tab to work on a path.</summary>
|
/// <summary>Raised when another view wants the Tools tab to work on a path.</summary>
|
||||||
public event Action<string>? ToolsSourceRequested;
|
/// <summary>Open the Tools page with this source; the optional action ("convert", "extract" or "verify") is chosen for the user.</summary>
|
||||||
|
public event Action<string, string?>? ToolsSourceRequested;
|
||||||
|
|
||||||
public void RequestToolsSource(string path) => ToolsSourceRequested?.Invoke(path);
|
public void RequestToolsSource(string path, string? action = null) => ToolsSourceRequested?.Invoke(path, action);
|
||||||
|
|
||||||
/// <summary>Queues a job described by <paramref name="spec"/>; it is saved and restored across restarts.</summary>
|
/// <summary>Queues a job described by <paramref name="spec"/>; it is saved and restored across restarts.</summary>
|
||||||
public QueuedPackageTask EnqueueJob(JobSpec spec, string displayName, string operation, string sourceFormat,
|
public QueuedPackageTask EnqueueJob(JobSpec spec, string displayName, string operation, string sourceFormat,
|
||||||
|
|||||||
@@ -12,36 +12,86 @@ namespace Packwright.App.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends files to a console on the local network: FTP upload, or "install by URL" where the console's package installer
|
/// Sends files to a console on the local network: FTP upload, or "install by URL" where the console's package installer
|
||||||
/// downloads a .pkg from this PC. Only addresses on the local network are used, and only when the user sends something.
|
/// downloads a .pkg from this PC. Only addresses on the local network are used, and only when the user sends something.
|
||||||
|
/// Errors are turned into plain-language messages; the technical details go to the log.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ConsoleSender
|
public static class ConsoleSender
|
||||||
{
|
{
|
||||||
private const int Attempts = 3;
|
/// <summary>How many times in a row a connection may drop without any new data getting through before giving up.</summary>
|
||||||
|
private const int MaximumFailuresWithoutProgress = 6;
|
||||||
|
private static readonly int[] BackoffSeconds = [2, 4, 8, 15, 30, 30];
|
||||||
|
|
||||||
/// <summary>The remote folder a source will end up in: the chosen folder plus the file or dump folder name.</summary>
|
/// <summary>The remote folder a source will end up in: the chosen folder plus the file or dump folder name.</summary>
|
||||||
public static string RemotePathFor(string remoteFolder, string source) =>
|
public static string RemotePathFor(string remoteFolder, string source) =>
|
||||||
FtpClient.Normalize(FtpClient.Normalize(remoteFolder) + "/" +
|
FtpClient.Normalize(FtpClient.Normalize(remoteFolder) + "/" +
|
||||||
Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)));
|
Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)));
|
||||||
|
|
||||||
|
/// <summary>A queued task does not keep the password; it is looked up from the saved console when the task runs.</summary>
|
||||||
|
public static ConsoleProfile WithSavedLogin(ConsoleProfile profile)
|
||||||
|
{
|
||||||
|
if (profile.FtpPassword.Length > 0 || profile.FtpUser.Length == 0) return profile;
|
||||||
|
ConsoleProfile? saved = AppServices.Instance.Settings.Consoles
|
||||||
|
.FirstOrDefault(console => console.Name.Equals(profile.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return saved is null || !saved.FtpUser.Equals(profile.FtpUser, StringComparison.Ordinal)
|
||||||
|
? profile
|
||||||
|
: new ConsoleProfile
|
||||||
|
{
|
||||||
|
Name = profile.Name, Host = profile.Host, FtpPort = profile.FtpPort, DefaultFolder = profile.DefaultFolder,
|
||||||
|
FtpUser = profile.FtpUser, FtpPassword = saved.FtpPassword, InstallUrl = profile.InstallUrl,
|
||||||
|
InstallBody = profile.InstallBody, InstallContentType = profile.InstallContentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ test
|
||||||
|
|
||||||
/// <summary>Connects and reports what the console's FTP server says, so a profile can be checked before sending.</summary>
|
/// <summary>Connects and reports what the console's FTP server says, so a profile can be checked before sending.</summary>
|
||||||
public static async Task<string> TestFtpAsync(ConsoleProfile profile, CancellationToken token)
|
public static async Task<string> TestFtpAsync(ConsoleProfile profile, CancellationToken token)
|
||||||
{
|
{
|
||||||
IPAddress address = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false);
|
try
|
||||||
await using FtpClient client = await ConnectAsync(address, profile, token).ConfigureAwait(false);
|
{
|
||||||
string system = await client.SystemAsync(token).ConfigureAwait(false);
|
IPAddress address = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false);
|
||||||
(int code, string text) = await client.CommandAsync("PWD", token).ConfigureAwait(false);
|
await using FtpClient client = await ConnectAsync(address, profile, token).ConfigureAwait(false);
|
||||||
int open = text.IndexOf('"'), close = text.IndexOf('"', open + 1);
|
string system = await client.SystemAsync(token).ConfigureAwait(false);
|
||||||
string folder = code == 257 && open >= 0 && close > open ? text[(open + 1)..close] : "unknown";
|
(int code, string text) = await client.CommandAsync("PWD", token).ConfigureAwait(false);
|
||||||
return $"Connected to {address}:{profile.FtpPort}. {(system.Length > 0 ? system + ". " : string.Empty)}Start folder: {folder}.";
|
int open = text.IndexOf('"'), close = text.IndexOf('"', open + 1);
|
||||||
|
string folder = code == 257 && open >= 0 && close > open ? text[(open + 1)..close] : "unknown";
|
||||||
|
return $"Connected to {address}:{profile.FtpPort}" +
|
||||||
|
(profile.FtpUser.Length > 0 ? $" as {profile.FtpUser}" : " (no login needed)") +
|
||||||
|
$". {(system.Length > 0 ? system + ". " : string.Empty)}Start folder: {folder}.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.Exception($"Test connection to {profile.Name} failed", ex);
|
||||||
|
throw Friendly(profile, ex, string.Empty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<FtpClient> ConnectAsync(IPAddress address, ConsoleProfile profile, CancellationToken token) =>
|
private static Task<FtpClient> ConnectAsync(IPAddress address, ConsoleProfile profile, CancellationToken token)
|
||||||
// Console FTP payloads accept anyone, so no account is used (and no password is stored).
|
{
|
||||||
FtpClient.ConnectAsync(address.ToString(), profile.FtpPort, "anonymous", "packwright@localhost", token);
|
// Many console FTP payloads let anyone in; phones and PCs running an FTP app usually want the login shown in that app.
|
||||||
|
bool login = profile.FtpUser.Length > 0;
|
||||||
|
return FtpClient.ConnectAsync(address.ToString(), profile.FtpPort, login ? profile.FtpUser : "anonymous",
|
||||||
|
login ? profile.FtpPassword : "packwright@localhost", token);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ FTP upload
|
// ------------------------------------------------------------------ FTP upload
|
||||||
|
|
||||||
public static async Task UploadAsync(ConsoleProfile profile, string source, string remoteFolder,
|
public static async Task UploadAsync(ConsoleProfile profile, string source, string remoteFolder,
|
||||||
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
profile = WithSavedLogin(profile);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UploadCoreAsync(profile, source, remoteFolder, progress, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.Exception($"Send to {profile.Name} ({profile.Host}:{profile.FtpPort}) failed", ex);
|
||||||
|
throw Friendly(profile, ex, remoteFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UploadCoreAsync(ConsoleProfile profile, string source, string remoteFolder,
|
||||||
|
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||||
{
|
{
|
||||||
IPAddress address = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false);
|
IPAddress address = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false);
|
||||||
string remoteRoot = RemotePathFor(remoteFolder, source);
|
string remoteRoot = RemotePathFor(remoteFolder, source);
|
||||||
@@ -59,44 +109,64 @@ public static class ConsoleSender
|
|||||||
if (files.Count == 0) throw new InvalidDataException("There is nothing in that folder to send.");
|
if (files.Count == 0) throw new InvalidDataException("There is nothing in that folder to send.");
|
||||||
|
|
||||||
long total = files.Sum(file => file.Size), done = 0;
|
long total = files.Sum(file => file.Size), done = 0;
|
||||||
|
string stage = "Sending to " + profile.Name;
|
||||||
var watch = Stopwatch.StartNew();
|
var watch = Stopwatch.StartNew();
|
||||||
long lastReport = 0;
|
long lastReport = 0;
|
||||||
|
void Report(string text, long bytes, int item, string file) =>
|
||||||
|
progress.Report(new PackageTaskProgress(text, 0, 0, bytes, total, item, files.Count, file));
|
||||||
|
|
||||||
FtpClient? client = null;
|
FtpClient? client = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Report(stage, 0, 0, string.Empty);
|
||||||
for (int index = 0; index < files.Count; index++)
|
for (int index = 0; index < files.Count; index++)
|
||||||
{
|
{
|
||||||
(string local, string remote, long size) = files[index];
|
(string local, string remote, long size) = files[index];
|
||||||
|
int failures = 0; // drops in a row without any new data getting through
|
||||||
|
long furthest = 0; // the most bytes of this file the console has had so far
|
||||||
|
long furthestAtLastDrop = -1;
|
||||||
for (int attempt = 1; ; attempt++)
|
for (int attempt = 1; ; attempt++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
long before = done;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
client ??= await ConnectAsync(address, profile, token).ConfigureAwait(false);
|
client ??= await ConnectAsync(address, profile, token).ConfigureAwait(false);
|
||||||
await client.EnsureDirectoryAsync(remote[..remote.LastIndexOf('/')], token).ConfigureAwait(false);
|
await client.EnsureDirectoryAsync(remote[..remote.LastIndexOf('/')], token).ConfigureAwait(false);
|
||||||
long before = done;
|
|
||||||
await client.UploadAsync(local, remote, sent =>
|
await client.UploadAsync(local, remote, sent =>
|
||||||
{
|
{
|
||||||
|
if (sent > furthest) furthest = sent;
|
||||||
long now = watch.ElapsedMilliseconds;
|
long now = watch.ElapsedMilliseconds;
|
||||||
if (now - lastReport < 250 && sent < size) return;
|
if (now - lastReport < 250 && sent < size) return;
|
||||||
lastReport = now;
|
lastReport = now;
|
||||||
progress.Report(new PackageTaskProgress("Sending to " + profile.Name, 1, 1, before + sent, total,
|
Report(stage, before + sent, index + 1, remote);
|
||||||
index + 1, files.Count, remote, StagePercent: total > 0 ? (before + sent) * 100.0 / total : -1));
|
|
||||||
}, token).ConfigureAwait(false);
|
}, token).ConfigureAwait(false);
|
||||||
done += size;
|
done += size;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (attempt < Attempts && IsRetryable(ex))
|
catch (Exception ex) when (IsRetryable(ex))
|
||||||
{
|
{
|
||||||
// The link dropped or the console was busy: reconnect and carry on (a partly sent file is continued).
|
// The link dropped or the console was busy: reconnect and carry on (a partly sent file is continued
|
||||||
Logger.Warn($"Send to {profile.Name}: attempt {attempt} failed ({ex.Message}); retrying.");
|
// when the console supports it). Data getting through again resets the count, so a long transfer
|
||||||
|
// over a flaky link can survive many drops, while a console that is simply gone gives up soon.
|
||||||
if (client is not null) await client.DisposeAsync().ConfigureAwait(false);
|
if (client is not null) await client.DisposeAsync().ConfigureAwait(false);
|
||||||
client = null;
|
client = null;
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2), token).ConfigureAwait(false);
|
if (furthest > furthestAtLastDrop && furthestAtLastDrop >= 0) failures = 0; // data got through since the last drop
|
||||||
|
furthestAtLastDrop = furthest;
|
||||||
|
failures++;
|
||||||
|
Logger.Warn($"Send to {profile.Name}: the connection dropped ({ex.Message}); try {failures} of {MaximumFailuresWithoutProgress}.");
|
||||||
|
if (failures >= MaximumFailuresWithoutProgress) throw;
|
||||||
|
int wait = BackoffSeconds[Math.Min(failures - 1, BackoffSeconds.Length - 1)];
|
||||||
|
for (int left = wait; left > 0; left--)
|
||||||
|
{
|
||||||
|
Report($"Connection lost. Trying again in {left} s ({failures} of {MaximumFailuresWithoutProgress})", before + Math.Min(furthest, size), index + 1, remote);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
Report($"Reconnecting to {profile.Name}...", before + Math.Min(furthest, size), index + 1, remote);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progress.Report(new PackageTaskProgress("Sent to " + profile.Name, 1, 1, total, total, files.Count, files.Count, string.Empty, 100));
|
Report("Sent to " + profile.Name, total, files.Count, string.Empty);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -109,10 +179,66 @@ public static class ConsoleSender
|
|||||||
private static bool IsRetryable(Exception ex) => ex switch
|
private static bool IsRetryable(Exception ex) => ex switch
|
||||||
{
|
{
|
||||||
FtpException ftp => ftp.IsTemporary,
|
FtpException ftp => ftp.IsTemporary,
|
||||||
|
UserMessageException => false,
|
||||||
IOException or SocketException => true,
|
IOException or SocketException => true,
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ plain-language errors
|
||||||
|
|
||||||
|
/// <summary>Turns what the system reported into words for the person using the app. The details stay in the log.</summary>
|
||||||
|
internal static UserMessageException Friendly(ConsoleProfile profile, Exception ex, string remoteFolder)
|
||||||
|
{
|
||||||
|
if (ex is UserMessageException already) return already;
|
||||||
|
string who = profile.Name.Length > 0 ? profile.Name : profile.Host;
|
||||||
|
string folder = remoteFolder.Length > 0 ? remoteFolder : profile.DefaultFolder;
|
||||||
|
Exception root = ex;
|
||||||
|
while (root.InnerException is not null) root = root.InnerException;
|
||||||
|
SocketError? socket = (root as SocketException)?.SocketErrorCode ?? (ex as SocketException)?.SocketErrorCode;
|
||||||
|
string text = ex.Message;
|
||||||
|
string message;
|
||||||
|
|
||||||
|
if (ex is FtpException ftp)
|
||||||
|
message = ftp.Code switch
|
||||||
|
{
|
||||||
|
530 or 331 or 332 or 532 => $"{who} did not accept the login. Open Settings, Manage consoles and enter the user name and password " +
|
||||||
|
"shown in its FTP app (leave them empty only if it lets anyone in).",
|
||||||
|
452 or 552 => $"{who} is out of space. Free some space on it and try again.",
|
||||||
|
421 => $"{who} ended the session (it may be busy or shutting down). Wait a moment and try again.",
|
||||||
|
550 or 553 or 450 or 451 => $"{who} would not let Packwright save to {folder}. Check that the folder exists and that its FTP app " +
|
||||||
|
"is allowed to write there (on a phone, give the app storage access).",
|
||||||
|
_ => $"{who} did not accept the transfer."
|
||||||
|
};
|
||||||
|
else if (text.Contains("not on your local network", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("could not be found", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("Enter the console", StringComparison.OrdinalIgnoreCase))
|
||||||
|
message = text; // already written for people
|
||||||
|
else if (socket is SocketError.ConnectionRefused || text.StartsWith("Could not connect", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.StartsWith("Could not reach", StringComparison.OrdinalIgnoreCase))
|
||||||
|
message = $"Could not connect to {who} ({profile.Host}, port {profile.FtpPort}). Check that it is switched on and on the same " +
|
||||||
|
"Wi-Fi or network as this PC, and that its FTP server is running with that port number.";
|
||||||
|
else if (socket is SocketError.ConnectionReset or SocketError.ConnectionAborted or SocketError.Shutdown ||
|
||||||
|
text.Contains("forcibly closed", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("closed the connection", StringComparison.OrdinalIgnoreCase))
|
||||||
|
message = $"{who} closed the connection while the file was being sent, even after several tries. This usually means the Wi-Fi " +
|
||||||
|
"dropped, it went to sleep, its FTP app stopped or was moved to the background, or it ran out of space or " +
|
||||||
|
"cannot store a file this large. Keep it awake with the FTP app open, make sure it has free space, then press Retry.";
|
||||||
|
else if (text.Contains("did not answer in time", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("stalled", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("data connection in time", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
socket is SocketError.TimedOut)
|
||||||
|
message = $"{who} stopped answering. Check that it is still on, awake and on the same network, then press Retry.";
|
||||||
|
else if (text.Contains("bytes but the file is", StringComparison.OrdinalIgnoreCase))
|
||||||
|
message = $"{who} did not receive the whole file (the size on it does not match). Press Retry; if it happens again, check its free space.";
|
||||||
|
else if (ex is FileNotFoundException or DirectoryNotFoundException)
|
||||||
|
message = "Packwright could not find the file to send. It may have been moved or deleted.";
|
||||||
|
else if (ex is UnauthorizedAccessException)
|
||||||
|
message = "Packwright is not allowed to read the file to send.";
|
||||||
|
else
|
||||||
|
message = $"Sending to {who} did not work. The technical details are in the log (open the Log page).";
|
||||||
|
return new UserMessageException(message, ex);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ install by URL
|
// ------------------------------------------------------------------ install by URL
|
||||||
|
|
||||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||||
@@ -129,11 +255,31 @@ public static class ConsoleSender
|
|||||||
|
|
||||||
public static async Task InstallByUrlAsync(ConsoleProfile profile, string package,
|
public static async Task InstallByUrlAsync(ConsoleProfile profile, string package,
|
||||||
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InstallByUrlCoreAsync(profile, package, progress, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException and not UserMessageException)
|
||||||
|
{
|
||||||
|
Logger.Exception($"Install on {profile.Name} ({profile.Host}) failed", ex);
|
||||||
|
string who = profile.Name.Length > 0 ? profile.Name : profile.Host;
|
||||||
|
if (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
throw new UserMessageException($"Packwright could not reach the installer on {who}. Check that the installer app is running on it " +
|
||||||
|
"and that the request address in Settings, Manage consoles is right.", ex);
|
||||||
|
if (ex is InvalidDataException or InvalidOperationException or ArgumentException)
|
||||||
|
throw new UserMessageException(ex.Message, ex);
|
||||||
|
throw Friendly(profile, ex, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InstallByUrlCoreAsync(ConsoleProfile profile, string package,
|
||||||
|
IProgress<PackageTaskProgress> progress, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (!File.Exists(package) || !package.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
if (!File.Exists(package) || !package.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||||
throw new InvalidDataException("Install by URL needs a .pkg file.");
|
throw new InvalidDataException("Install by URL only works with a .pkg file.");
|
||||||
if (string.IsNullOrWhiteSpace(profile.InstallUrl))
|
if (string.IsNullOrWhiteSpace(profile.InstallUrl))
|
||||||
throw new InvalidOperationException("This console has no install address. Set it in the console's settings.");
|
throw new InvalidOperationException("This console has no install address yet. Add one in Settings, Manage consoles.");
|
||||||
IPAddress console = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false);
|
IPAddress console = await ConsoleNetwork.ResolveLocalAsync(profile.Host, token).ConfigureAwait(false);
|
||||||
IPAddress local = ConsoleNetwork.LocalAddressFor(console);
|
IPAddress local = ConsoleNetwork.LocalAddressFor(console);
|
||||||
await using SingleFileServer server = SingleFileServer.Start(package, local);
|
await using SingleFileServer server = SingleFileServer.Start(package, local);
|
||||||
@@ -141,17 +287,19 @@ public static class ConsoleSender
|
|||||||
|
|
||||||
(string requestUrl, string body) = BuildInstallRequest(profile, server.Url);
|
(string requestUrl, string body) = BuildInstallRequest(profile, server.Url);
|
||||||
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out Uri? target) || target.Scheme != Uri.UriSchemeHttp)
|
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out Uri? target) || target.Scheme != Uri.UriSchemeHttp)
|
||||||
throw new InvalidOperationException("The install address is not a valid http:// address: " + requestUrl);
|
throw new InvalidOperationException("The install address in Settings, Manage consoles is not a valid http:// address.");
|
||||||
await ConsoleNetwork.ResolveLocalAsync(target.Host, token).ConfigureAwait(false);
|
await ConsoleNetwork.ResolveLocalAsync(target.Host, token).ConfigureAwait(false);
|
||||||
|
|
||||||
progress.Report(new PackageTaskProgress("Asking " + profile.Name + " to install", 1, 3, 0, server.Length, 0, 0, string.Empty, 0));
|
progress.Report(new PackageTaskProgress("Asking " + profile.Name + " to install", 0, 0, 0, server.Length));
|
||||||
using var content = new StringContent(body, Encoding.UTF8);
|
using var content = new StringContent(body, Encoding.UTF8);
|
||||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(profile.InstallContentType);
|
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(profile.InstallContentType);
|
||||||
using HttpResponseMessage response = await Http.PostAsync(target, content, token).ConfigureAwait(false);
|
using HttpResponseMessage response = await Http.PostAsync(target, content, token).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
string text = (await response.Content.ReadAsStringAsync(token).ConfigureAwait(false)).Trim();
|
string text = (await response.Content.ReadAsStringAsync(token).ConfigureAwait(false)).Trim();
|
||||||
throw new IOException($"The console's installer answered {(int)response.StatusCode} {response.ReasonPhrase}. {(text.Length > 200 ? text[..200] : text)}");
|
Logger.Warn($"The installer on {profile.Name} answered {(int)response.StatusCode} {response.ReasonPhrase}: {(text.Length > 300 ? text[..300] : text)}");
|
||||||
|
throw new UserMessageException($"The installer on {profile.Name} did not accept the request. Check the request address and body in " +
|
||||||
|
"Settings, Manage consoles against the installer's instructions. Details are in the log.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the file available while the console downloads it: wait until it has taken the whole file and has been quiet.
|
// Keep the file available while the console downloads it: wait until it has taken the whole file and has been quiet.
|
||||||
@@ -160,17 +308,17 @@ public static class ConsoleSender
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
await Task.Delay(500, token).ConfigureAwait(false);
|
await Task.Delay(500, token).ConfigureAwait(false);
|
||||||
double percent = server.Length > 0 ? Math.Min(100.0, server.BytesServed * 100.0 / server.Length) : 0;
|
|
||||||
bool started = server.Requests > 0;
|
bool started = server.Requests > 0;
|
||||||
progress.Report(new PackageTaskProgress(started ? profile.Name + " is downloading the package" : "Waiting for " + profile.Name + " to start the download",
|
progress.Report(new PackageTaskProgress(started ? profile.Name + " is downloading the package" : "Waiting for " + profile.Name + " to start the download",
|
||||||
2, 3, Math.Min(server.BytesServed, server.Length), server.Length, 0, 0, string.Empty, percent));
|
0, 0, Math.Min(server.BytesServed, server.Length), server.Length));
|
||||||
if (!started && DateTime.UtcNow - asked > TimeSpan.FromMinutes(5))
|
if (!started && DateTime.UtcNow - asked > TimeSpan.FromMinutes(5))
|
||||||
throw new IOException("The console never asked for the file. Check the install address in the console's settings, and that the firewall allows Packwright on your network.");
|
throw new UserMessageException($"{profile.Name} never asked for the file. Check the request address in Settings, Manage consoles, " +
|
||||||
|
"and that your firewall lets Packwright share files on your network.");
|
||||||
if (server.FullyServed && DateTime.UtcNow - server.LastRequestUtc > TimeSpan.FromSeconds(20)) break;
|
if (server.FullyServed && DateTime.UtcNow - server.LastRequestUtc > TimeSpan.FromSeconds(20)) break;
|
||||||
if (started && DateTime.UtcNow - server.LastRequestUtc > TimeSpan.FromMinutes(5))
|
if (started && DateTime.UtcNow - server.LastRequestUtc > TimeSpan.FromMinutes(5))
|
||||||
throw new IOException("The console stopped downloading before it had the whole file.");
|
throw new UserMessageException($"{profile.Name} stopped downloading before it had the whole file. Try again.");
|
||||||
}
|
}
|
||||||
progress.Report(new PackageTaskProgress("The console has the package", 3, 3, server.Length, server.Length, 0, 0, string.Empty, 100));
|
progress.Report(new PackageTaskProgress("The console has the package", 0, 0, server.Length, server.Length));
|
||||||
Logger.Info($"{profile.Name} downloaded {Path.GetFileName(package)}.");
|
Logger.Info($"{profile.Name} downloaded {Path.GetFileName(package)}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,14 @@ public sealed class JobSpec
|
|||||||
catch (JsonException) { return null; }
|
catch (JsonException) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string Ps5LibraryHealthSize(long bytes) => Packwright.Core.Services.Ps5LibraryHealth.FormatBytes(bytes);
|
||||||
|
|
||||||
|
private static string Elapsed(System.Diagnostics.Stopwatch watch)
|
||||||
|
{
|
||||||
|
TimeSpan time = watch.Elapsed;
|
||||||
|
return time.TotalMinutes >= 1 ? $"{(int)time.TotalMinutes} min {time.Seconds} s" : $"{Math.Max(1, (int)Math.Round(time.TotalSeconds))} s";
|
||||||
|
}
|
||||||
|
|
||||||
public PackageTaskStage[] Plan() => Kind switch
|
public PackageTaskStage[] Plan() => Kind switch
|
||||||
{
|
{
|
||||||
Convert => FromPackage ? PackageTaskPlans.ConvertPackage : PackageTaskPlans.ConvertImage,
|
Convert => FromPackage ? PackageTaskPlans.ConvertPackage : PackageTaskPlans.ConvertImage,
|
||||||
@@ -155,14 +163,42 @@ public sealed class JobSpec
|
|||||||
string code = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode;
|
string code = Passcode.Length > 0 ? Passcode : SonyDebugPackageCredentials.DefaultPasscode;
|
||||||
return (progress, token) =>
|
return (progress, token) =>
|
||||||
{
|
{
|
||||||
|
var watch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
SonyDebugPackageValidationResult result = Jobs.VerifyPackage(Source, code);
|
SonyDebugPackageValidationResult result = Jobs.VerifyPackage(Source, code);
|
||||||
if (!result.IsValid) throw new InvalidDataException(result.Message);
|
if (!result.IsValid)
|
||||||
|
throw new InvalidDataException("This package did not pass verification: " + result.Message.Trim().TrimEnd('.') +
|
||||||
|
". The file may be damaged or incomplete (compare its size and checksum with where you got it).");
|
||||||
Logger.Info($"Package verified: {result.IndexedFiles:N0} indexed file(s).");
|
Logger.Info($"Package verified: {result.IndexedFiles:N0} indexed file(s).");
|
||||||
|
progress.Report(new PackageTaskProgress("Verified",
|
||||||
|
Result: $"Verified: no problems found. {result.IndexedFiles:N0} file(s) checked in {Elapsed(watch)}."));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Ps5ImageFormat verifyFormat = ImageFormat;
|
Ps5ImageFormat verifyFormat = ImageFormat;
|
||||||
return (progress, token) => Jobs.VerifyImageAsync(Source, verifyFormat, token);
|
return async (progress, token) =>
|
||||||
|
{
|
||||||
|
var watch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
string label = verifyFormat switch
|
||||||
|
{
|
||||||
|
Ps5ImageFormat.Exfat => "exFAT image",
|
||||||
|
Ps5ImageFormat.Ufs2 => "FFPKG image",
|
||||||
|
Ps5ImageFormat.Pfs => "FFPFSC image",
|
||||||
|
Ps5ImageFormat.ZArchive => "ZArchive",
|
||||||
|
_ => "image"
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Jobs.VerifyImageAsync(Source, verifyFormat, token, progress).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"This {label} did not pass verification: {ex.Message.Trim().TrimEnd('.')}. " +
|
||||||
|
"The file may be damaged or incomplete (compare its size and checksum with where you got it).", ex);
|
||||||
|
}
|
||||||
|
long length = File.Exists(Source) ? new FileInfo(Source).Length : 0;
|
||||||
|
progress.Report(new PackageTaskProgress("Verified", Result: $"Verified: no problems found. The {label}" +
|
||||||
|
(length > 0 ? $" ({Ps5LibraryHealthSize(length)})" : string.Empty) + $" was checked in {Elapsed(watch)}."));
|
||||||
|
};
|
||||||
case Repair:
|
case Repair:
|
||||||
return async (progress, token) =>
|
return async (progress, token) =>
|
||||||
await ExfatImageMaintenance.RepairAsync(Source, Jobs.Adapt(progress), token).ConfigureAwait(false);
|
await ExfatImageMaintenance.RepairAsync(Source, Jobs.Adapt(progress), token).ConfigureAwait(false);
|
||||||
@@ -182,7 +218,7 @@ public sealed class JobSpec
|
|||||||
if (Console is null) return null;
|
if (Console is null) return null;
|
||||||
ConsoleProfile ftpTarget = Console;
|
ConsoleProfile ftpTarget = Console;
|
||||||
string folder = RemoteFolder.Length > 0 ? RemoteFolder : ftpTarget.DefaultFolder;
|
string folder = RemoteFolder.Length > 0 ? RemoteFolder : ftpTarget.DefaultFolder;
|
||||||
return (progress, token) => ConsoleSender.UploadAsync(ftpTarget, Source, folder, progress, token);
|
return (progress, token) => ConsoleSender.UploadAsync(ConsoleSender.WithSavedLogin(ftpTarget), Source, folder, progress, token);
|
||||||
case SendInstall:
|
case SendInstall:
|
||||||
if (Console is null) return null;
|
if (Console is null) return null;
|
||||||
ConsoleProfile installTarget = Console;
|
ConsoleProfile installTarget = Console;
|
||||||
|
|||||||
@@ -96,18 +96,21 @@ public static class Jobs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task VerifyImageAsync(string source, Ps5ImageFormat format, CancellationToken token)
|
public static async Task VerifyImageAsync(string source, Ps5ImageFormat format, CancellationToken token,
|
||||||
|
IProgress<PackageTaskProgress>? progress = null)
|
||||||
{
|
{
|
||||||
|
// Report how much has been checked, so the task shows a bar and the size done, left and total.
|
||||||
|
IProgress<FfpfscProgress>? bytes = progress is null ? null : Adapt(progress);
|
||||||
switch (format)
|
switch (format)
|
||||||
{
|
{
|
||||||
case Ps5ImageFormat.Exfat:
|
case Ps5ImageFormat.Exfat:
|
||||||
await ExfatImage.VerifyAsync(source, null, token).ConfigureAwait(false);
|
await ExfatImage.VerifyAsync(source, bytes, token).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
case Ps5ImageFormat.Ufs2:
|
case Ps5ImageFormat.Ufs2:
|
||||||
await Ufs2Operations.VerifyAsync(source, null, token).ConfigureAwait(false);
|
await Ufs2Operations.VerifyAsync(source, progress is null ? null : AdaptUfs2(progress), token).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
case Ps5ImageFormat.Pfs:
|
case Ps5ImageFormat.Pfs:
|
||||||
FfpfscVerificationResult result = await FfpfscImage.TryVerifyAsync(source, null, null, token)
|
FfpfscVerificationResult result = await FfpfscImage.TryVerifyAsync(source, null, bytes, token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (!result.StructureValid)
|
if (!result.StructureValid)
|
||||||
throw new InvalidDataException("The FFPFSC structure is invalid. " + (result.Error ?? string.Empty));
|
throw new InvalidDataException("The FFPFSC structure is invalid. " + (result.Error ?? string.Empty));
|
||||||
@@ -115,7 +118,7 @@ public static class Jobs
|
|||||||
throw new InvalidDataException("The FFPFSC block decode failed. " + (result.Error ?? string.Empty));
|
throw new InvalidDataException("The FFPFSC block decode failed. " + (result.Error ?? string.Empty));
|
||||||
break;
|
break;
|
||||||
case Ps5ImageFormat.ZArchive:
|
case Ps5ImageFormat.ZArchive:
|
||||||
await ZArchiveImage.VerifyAsync(source, null, token).ConfigureAwait(false);
|
await ZArchiveImage.VerifyAsync(source, bytes, token).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidDataException("Unsupported image format.");
|
throw new InvalidDataException("Unsupported image format.");
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Packwright.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the small part of Markdown that release notes use: headings, bullet lists, **bold**, `code`, [links](url) and simple
|
||||||
|
/// tables. It is a display aid, not a full Markdown engine; anything else is shown as plain text.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class MarkdownLite
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"(\*\*.+?\*\*|`.+?`|\[[^\]]+\]\([^)]+\))")]
|
||||||
|
private static partial Regex InlinePattern();
|
||||||
|
|
||||||
|
public static Control Render(string markdown)
|
||||||
|
{
|
||||||
|
var panel = new StackPanel { Spacing = 7 };
|
||||||
|
foreach (string raw in markdown.Replace("\r\n", "\n").Split('\n'))
|
||||||
|
{
|
||||||
|
string line = raw.TrimEnd();
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
if (line.StartsWith("### ", StringComparison.Ordinal)) panel.Children.Add(Heading(line[4..], 14));
|
||||||
|
else if (line.StartsWith("## ", StringComparison.Ordinal)) panel.Children.Add(Heading(line[3..], 17));
|
||||||
|
else if (line.StartsWith("# ", StringComparison.Ordinal)) panel.Children.Add(Heading(line[2..], 19));
|
||||||
|
else if (line.StartsWith("- ", StringComparison.Ordinal) || line.StartsWith("* ", StringComparison.Ordinal))
|
||||||
|
panel.Children.Add(Bullet(line[2..]));
|
||||||
|
else if (line.StartsWith('|'))
|
||||||
|
{
|
||||||
|
string[] cells = line.Trim('|').Split('|').Select(cell => cell.Trim()).ToArray();
|
||||||
|
if (cells.All(cell => cell.Length == 0 || cell.All(ch => ch is '-' or ':' or ' '))) continue; // the ---|--- row
|
||||||
|
panel.Children.Add(Bullet(string.Join(" · ", cells.Where(cell => cell.Length > 0)), "▸"));
|
||||||
|
}
|
||||||
|
else panel.Children.Add(Paragraph(line));
|
||||||
|
}
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextBlock Heading(string text, double size)
|
||||||
|
{
|
||||||
|
var block = new TextBlock { FontSize = size, FontWeight = FontWeight.Bold, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) };
|
||||||
|
AddInlines(block, text);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextBlock Paragraph(string text)
|
||||||
|
{
|
||||||
|
var block = new TextBlock { TextWrapping = TextWrapping.Wrap };
|
||||||
|
AddInlines(block, text);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control Bullet(string text, string marker = "•")
|
||||||
|
{
|
||||||
|
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("18,*") };
|
||||||
|
grid.Children.Add(new TextBlock { Text = marker, Opacity = 0.7 });
|
||||||
|
TextBlock body = Paragraph(text);
|
||||||
|
Grid.SetColumn(body, 1);
|
||||||
|
grid.Children.Add(body);
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddInlines(TextBlock block, string text)
|
||||||
|
{
|
||||||
|
int position = 0;
|
||||||
|
foreach (Match match in InlinePattern().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > position) block.Inlines!.Add(new Run(text[position..match.Index]));
|
||||||
|
string token = match.Value;
|
||||||
|
if (token.StartsWith("**", StringComparison.Ordinal))
|
||||||
|
block.Inlines!.Add(new Run(token[2..^2]) { FontWeight = FontWeight.Bold });
|
||||||
|
else if (token.StartsWith('`'))
|
||||||
|
block.Inlines!.Add(new Run(token[1..^1]) { FontFamily = new FontFamily("Cascadia Mono, Consolas, Menlo, monospace"), FontSize = Math.Max(11, block.FontSize - 1) });
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int close = token.IndexOf("](", StringComparison.Ordinal);
|
||||||
|
block.Inlines!.Add(new Run(token[1..close]) { TextDecorations = TextDecorations.Underline });
|
||||||
|
}
|
||||||
|
position = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
if (position < text.Length) block.Inlines!.Add(new Run(text[position..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ public sealed class ConsolesWindow : Window
|
|||||||
private readonly TextBox _host = new() { Watermark = "For example 192.168.1.50" };
|
private readonly TextBox _host = new() { Watermark = "For example 192.168.1.50" };
|
||||||
private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, FormatString = "0", Value = 1337 };
|
private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, FormatString = "0", Value = 1337 };
|
||||||
private readonly TextBox _folder = new() { Watermark = "/data" };
|
private readonly TextBox _folder = new() { Watermark = "/data" };
|
||||||
|
private readonly TextBox _user = new() { Watermark = "Leave empty if it lets anyone in" };
|
||||||
|
private readonly TextBox _password = new() { PasswordChar = '•' };
|
||||||
private readonly TextBox _installUrl = new();
|
private readonly TextBox _installUrl = new();
|
||||||
private readonly TextBox _installBody = new() { AcceptsReturn = true, TextWrapping = Avalonia.Media.TextWrapping.Wrap, MinHeight = 64 };
|
private readonly TextBox _installBody = new() { AcceptsReturn = true, TextWrapping = Avalonia.Media.TextWrapping.Wrap, MinHeight = 64 };
|
||||||
private readonly TextBox _contentType = new();
|
private readonly TextBox _contentType = new();
|
||||||
@@ -66,6 +68,14 @@ public sealed class ConsolesWindow : Window
|
|||||||
_form.Children.Add(Field("Name", _name));
|
_form.Children.Add(Field("Name", _name));
|
||||||
_form.Children.Add(Field("Address on your network", _host));
|
_form.Children.Add(Field("Address on your network", _host));
|
||||||
_form.Children.Add(Field("FTP port", _port));
|
_form.Children.Add(Field("FTP port", _port));
|
||||||
|
_form.Children.Add(Field("User name", _user));
|
||||||
|
_form.Children.Add(Field("Password", _password));
|
||||||
|
_form.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Classes = { "muted" }, FontSize = 12, TextWrapping = Avalonia.Media.TextWrapping.Wrap,
|
||||||
|
Text = "Consoles with an FTP payload usually let anyone in, so leave these empty. A phone or PC running an FTP app " +
|
||||||
|
"shows the user name and password to use. The password is saved on this PC in Packwright's settings file, not encrypted."
|
||||||
|
});
|
||||||
_form.Children.Add(Field("Default folder on the console", _folder));
|
_form.Children.Add(Field("Default folder on the console", _folder));
|
||||||
_form.Children.Add(new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Children = { _test } });
|
_form.Children.Add(new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Children = { _test } });
|
||||||
_form.Children.Add(_result);
|
_form.Children.Add(_result);
|
||||||
@@ -151,6 +161,7 @@ public sealed class ConsolesWindow : Window
|
|||||||
private static ConsoleProfile Clone(ConsoleProfile source) => new()
|
private static ConsoleProfile Clone(ConsoleProfile source) => new()
|
||||||
{
|
{
|
||||||
Name = source.Name, Host = source.Host, FtpPort = source.FtpPort, DefaultFolder = source.DefaultFolder,
|
Name = source.Name, Host = source.Host, FtpPort = source.FtpPort, DefaultFolder = source.DefaultFolder,
|
||||||
|
FtpUser = source.FtpUser, FtpPassword = source.FtpPassword,
|
||||||
InstallUrl = source.InstallUrl, InstallBody = source.InstallBody, InstallContentType = source.InstallContentType
|
InstallUrl = source.InstallUrl, InstallBody = source.InstallBody, InstallContentType = source.InstallContentType
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,7 +191,7 @@ public sealed class ConsolesWindow : Window
|
|||||||
_form.IsEnabled = index >= 0 && index < _profiles.Count;
|
_form.IsEnabled = index >= 0 && index < _profiles.Count;
|
||||||
if (!_form.IsEnabled)
|
if (!_form.IsEnabled)
|
||||||
{
|
{
|
||||||
foreach (TextBox box in new[] { _name, _host, _folder, _installUrl, _installBody, _contentType }) box.Text = string.Empty;
|
foreach (TextBox box in new[] { _name, _host, _folder, _user, _password, _installUrl, _installBody, _contentType }) box.Text = string.Empty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ConsoleProfile profile = _profiles[index];
|
ConsoleProfile profile = _profiles[index];
|
||||||
@@ -188,6 +199,8 @@ public sealed class ConsolesWindow : Window
|
|||||||
_host.Text = profile.Host;
|
_host.Text = profile.Host;
|
||||||
_port.Value = profile.FtpPort;
|
_port.Value = profile.FtpPort;
|
||||||
_folder.Text = profile.DefaultFolder;
|
_folder.Text = profile.DefaultFolder;
|
||||||
|
_user.Text = profile.FtpUser;
|
||||||
|
_password.Text = profile.FtpPassword;
|
||||||
_installUrl.Text = profile.InstallUrl;
|
_installUrl.Text = profile.InstallUrl;
|
||||||
_installBody.Text = profile.InstallBody;
|
_installBody.Text = profile.InstallBody;
|
||||||
_contentType.Text = profile.InstallContentType;
|
_contentType.Text = profile.InstallContentType;
|
||||||
@@ -204,6 +217,8 @@ public sealed class ConsolesWindow : Window
|
|||||||
profile.Host = (_host.Text ?? string.Empty).Trim();
|
profile.Host = (_host.Text ?? string.Empty).Trim();
|
||||||
profile.FtpPort = (int)(_port.Value ?? 1337);
|
profile.FtpPort = (int)(_port.Value ?? 1337);
|
||||||
profile.DefaultFolder = string.IsNullOrWhiteSpace(_folder.Text) ? "/data" : _folder.Text.Trim();
|
profile.DefaultFolder = string.IsNullOrWhiteSpace(_folder.Text) ? "/data" : _folder.Text.Trim();
|
||||||
|
profile.FtpUser = (_user.Text ?? string.Empty).Trim();
|
||||||
|
profile.FtpPassword = _password.Text ?? string.Empty;
|
||||||
profile.InstallUrl = (_installUrl.Text ?? string.Empty).Trim();
|
profile.InstallUrl = (_installUrl.Text ?? string.Empty).Trim();
|
||||||
profile.InstallBody = _installBody.Text ?? string.Empty;
|
profile.InstallBody = _installBody.Text ?? string.Empty;
|
||||||
profile.InstallContentType = string.IsNullOrWhiteSpace(_contentType.Text) ? "application/json" : _contentType.Text.Trim();
|
profile.InstallContentType = string.IsNullOrWhiteSpace(_contentType.Text) ? "application/json" : _contentType.Text.Trim();
|
||||||
|
|||||||
@@ -439,7 +439,17 @@ public partial class LibraryView
|
|||||||
|
|
||||||
private void OnMenuConvert(object? sender, RoutedEventArgs e)
|
private void OnMenuConvert(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_selection.FirstOrDefault() is { } item) _services.RequestToolsSource(item.Path);
|
if (_selection.FirstOrDefault() is { } item) _services.RequestToolsSource(item.Path, "convert");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMenuExtract(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_selection.FirstOrDefault() is { } item) _services.RequestToolsSource(item.Path, "extract");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMenuVerify(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_selection.FirstOrDefault() is { } item) _services.RequestToolsSource(item.Path, "verify");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnMenuEdit(object? sender, RoutedEventArgs e) => await EditSelectedAsync();
|
private async void OnMenuEdit(object? sender, RoutedEventArgs e) => await EditSelectedAsync();
|
||||||
|
|||||||
@@ -115,7 +115,9 @@
|
|||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
<ListBox.ContextMenu>
|
<ListBox.ContextMenu>
|
||||||
<ContextMenu Opening="OnContextMenuOpening">
|
<ContextMenu Opening="OnContextMenuOpening">
|
||||||
<MenuItem Header="Convert / Build in Tools..." Click="OnMenuConvert" />
|
<MenuItem Header="Convert / Build..." Click="OnMenuConvert" />
|
||||||
|
<MenuItem Header="Extract..." Click="OnMenuExtract" />
|
||||||
|
<MenuItem Header="Verify..." Click="OnMenuVerify" />
|
||||||
<MenuItem Header="Edit details and icon..." Click="OnMenuEdit" />
|
<MenuItem Header="Edit details and icon..." Click="OnMenuEdit" />
|
||||||
<MenuItem Header="Send to console..." Click="OnMenuSend" />
|
<MenuItem Header="Send to console..." Click="OnMenuSend" />
|
||||||
<MenuItem Header="Show in file manager" Click="OnMenuReveal" />
|
<MenuItem Header="Show in file manager" Click="OnMenuReveal" />
|
||||||
@@ -170,7 +172,9 @@
|
|||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
<DataGrid.ContextMenu>
|
<DataGrid.ContextMenu>
|
||||||
<ContextMenu Opening="OnContextMenuOpening">
|
<ContextMenu Opening="OnContextMenuOpening">
|
||||||
<MenuItem Header="Convert / Build in Tools..." Click="OnMenuConvert" />
|
<MenuItem Header="Convert / Build..." Click="OnMenuConvert" />
|
||||||
|
<MenuItem Header="Extract..." Click="OnMenuExtract" />
|
||||||
|
<MenuItem Header="Verify..." Click="OnMenuVerify" />
|
||||||
<MenuItem Header="Edit details and icon..." Click="OnMenuEdit" />
|
<MenuItem Header="Edit details and icon..." Click="OnMenuEdit" />
|
||||||
<MenuItem Header="Send to console..." Click="OnMenuSend" />
|
<MenuItem Header="Send to console..." Click="OnMenuSend" />
|
||||||
<MenuItem Header="Show in file manager" Click="OnMenuReveal" />
|
<MenuItem Header="Show in file manager" Click="OnMenuReveal" />
|
||||||
|
|||||||
@@ -37,8 +37,10 @@
|
|||||||
<Grid x:Name="TasksRow" ColumnDefinitions="Auto,*,Auto">
|
<Grid x:Name="TasksRow" ColumnDefinitions="Auto,*,Auto">
|
||||||
<Path Classes="icon" Data="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
|
<Path Classes="icon" Data="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
|
||||||
<TextBlock x:Name="NavTasksText" Grid.Column="1" Text="Tasks" Margin="12,0,0,0" VerticalAlignment="Center" />
|
<TextBlock x:Name="NavTasksText" Grid.Column="1" Text="Tasks" Margin="12,0,0,0" VerticalAlignment="Center" />
|
||||||
<Border x:Name="TaskBadge" Grid.Column="2" Classes="pill" IsVisible="False" Padding="8,1">
|
<Border x:Name="TaskBadge" Grid.Column="2" IsVisible="False" MinWidth="20" Height="20" CornerRadius="10" Padding="6,0"
|
||||||
<TextBlock x:Name="TaskBadgeText" />
|
Background="{DynamicResource Accent}" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="TaskBadgeText" FontSize="11" FontWeight="Bold" Foreground="White"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</RadioButton>
|
</RadioButton>
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ public partial class MainWindow : Window
|
|||||||
};
|
};
|
||||||
DragDrop.SetAllowDrop(this, true);
|
DragDrop.SetAllowDrop(this, true);
|
||||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||||
services.ToolsSourceRequested += path =>
|
services.ToolsSourceRequested += (path, action) =>
|
||||||
{
|
{
|
||||||
NavTools.IsChecked = true;
|
NavTools.IsChecked = true;
|
||||||
ToolsPane.SetSource(path);
|
ToolsPane.SetSource(path);
|
||||||
|
if (action is not null) ToolsPane.ChooseAction(action);
|
||||||
};
|
};
|
||||||
LibraryPane.StatusChanged += text => StatusText.Text = text;
|
LibraryPane.StatusChanged += text => StatusText.Text = text;
|
||||||
HealthPane.StatusChanged += text => StatusText.Text = text;
|
HealthPane.StatusChanged += text => StatusText.Text = text;
|
||||||
@@ -155,10 +156,18 @@ public partial class MainWindow : Window
|
|||||||
if (compact) button.Padding = new Thickness(0, 10);
|
if (compact) button.Padding = new Thickness(0, 10);
|
||||||
else button.ClearValue(Avalonia.Controls.Primitives.TemplatedControl.PaddingProperty);
|
else button.ClearValue(Avalonia.Controls.Primitives.TemplatedControl.PaddingProperty);
|
||||||
}
|
}
|
||||||
// The task count badge sits right next to the icon when the label is hidden.
|
// With the label hidden, the task count becomes a small badge on the corner of the icon; otherwise it sits at the right edge.
|
||||||
TasksRow.ColumnDefinitions = compact ? new ColumnDefinitions("Auto,Auto") : new ColumnDefinitions("Auto,*,Auto");
|
TasksRow.ColumnDefinitions = compact ? new ColumnDefinitions("Auto") : new ColumnDefinitions("Auto,*,Auto");
|
||||||
Grid.SetColumn(TaskBadge, compact ? 1 : 2);
|
Grid.SetColumn(TaskBadge, compact ? 0 : 2);
|
||||||
TaskBadge.Margin = compact ? new Thickness(6, 0, 0, 0) : default;
|
TaskBadge.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
|
||||||
|
TaskBadge.VerticalAlignment = compact ? Avalonia.Layout.VerticalAlignment.Top : Avalonia.Layout.VerticalAlignment.Center;
|
||||||
|
TaskBadge.Margin = compact ? new Thickness(0, -7, -9, 0) : default;
|
||||||
|
TaskBadge.MinWidth = compact ? 16 : 20;
|
||||||
|
TaskBadge.Height = compact ? 16 : 20;
|
||||||
|
TaskBadge.CornerRadius = new CornerRadius(compact ? 8 : 10);
|
||||||
|
TaskBadge.Padding = new Thickness(compact ? 4 : 6, 0);
|
||||||
|
TaskBadge.ZIndex = 5;
|
||||||
|
TaskBadgeText.FontSize = compact ? 10 : 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Puts the window back where, and as big as, the user left it.</summary>
|
/// <summary>Puts the window back where, and as big as, the user left it.</summary>
|
||||||
@@ -286,7 +295,7 @@ public partial class MainWindow : Window
|
|||||||
int queued = tasks.Count(task => task.Status == PackageTaskStatus.Queued);
|
int queued = tasks.Count(task => task.Status == PackageTaskStatus.Queued);
|
||||||
TaskSummary.Text = running + queued == 0 ? string.Empty : $"{running} running, {queued} queued";
|
TaskSummary.Text = running + queued == 0 ? string.Empty : $"{running} running, {queued} queued";
|
||||||
TaskBadge.IsVisible = running + queued > 0;
|
TaskBadge.IsVisible = running + queued > 0;
|
||||||
TaskBadgeText.Text = (running + queued).ToString();
|
TaskBadgeText.Text = running + queued > 99 ? "99+" : (running + queued).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDrop(object? sender, DragEventArgs e)
|
private void OnDrop(object? sender, DragEventArgs e)
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ public sealed class SendToConsoleWindow : Window
|
|||||||
Console = new ConsoleProfile
|
Console = new ConsoleProfile
|
||||||
{
|
{
|
||||||
Name = profile.Name, Host = profile.Host, FtpPort = profile.FtpPort, DefaultFolder = profile.DefaultFolder,
|
Name = profile.Name, Host = profile.Host, FtpPort = profile.FtpPort, DefaultFolder = profile.DefaultFolder,
|
||||||
|
FtpUser = profile.FtpUser, // the password is looked up from the saved console when the task runs
|
||||||
InstallUrl = profile.InstallUrl, InstallBody = profile.InstallBody, InstallContentType = profile.InstallContentType
|
InstallUrl = profile.InstallUrl, InstallBody = profile.InstallBody, InstallContentType = profile.InstallContentType
|
||||||
},
|
},
|
||||||
RemoteFolder = UseInstall ? string.Empty : (_folder.Text ?? string.Empty).Trim()
|
RemoteFolder = UseInstall ? string.Empty : (_folder.Text ?? string.Empty).Trim()
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ public partial class SettingsView : UserControl
|
|||||||
"Include the saved package passcode in the exported file?", "Include", "Leave out");
|
"Include the saved package passcode in the exported file?", "Include", "Leave out");
|
||||||
AppSettings copy = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(_services.Settings)) ?? new();
|
AppSettings copy = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(_services.Settings)) ?? new();
|
||||||
if (!includePasscode) copy.DebugPasscode = string.Empty;
|
if (!includePasscode) copy.DebugPasscode = string.Empty;
|
||||||
|
foreach (ConsoleProfile console in copy.Consoles) console.FtpPassword = string.Empty; // passwords are never exported
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true }));
|
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ public partial class TasksView : UserControl
|
|||||||
string message = string.IsNullOrWhiteSpace(task.Message)
|
string message = string.IsNullOrWhiteSpace(task.Message)
|
||||||
? stage
|
? stage
|
||||||
: stage.Length > 0 ? stage + ": " + task.Message : task.Message;
|
: stage.Length > 0 ? stage + ": " + task.Message : task.Message;
|
||||||
|
// "Starting..." is only a placeholder; once there is a real step to show, show that (and how much is done).
|
||||||
|
if (!task.IsTerminal && stage.Length > 0 && task.Message == "Starting...") message = stage;
|
||||||
|
if (!task.IsTerminal && task.Progress.TotalBytes > 0 && task.Status is PackageTaskStatus.Running or PackageTaskStatus.Cancelling)
|
||||||
|
{
|
||||||
|
// Processed, remaining and total size, for every task that knows how big its work is.
|
||||||
|
long total = task.Progress.TotalBytes, done = Math.Clamp(task.Progress.CurrentBytes, 0, total);
|
||||||
|
string Size(long bytes) => Packwright.Core.Services.Ps5LibraryHealth.FormatBytes(bytes);
|
||||||
|
message += $" · {Size(done)} done · {Size(total - done)} left · {Size(total)} total";
|
||||||
|
}
|
||||||
if (current.Length > 0) message = (message + " " + current).Trim();
|
if (current.Length > 0) message = (message + " " + current).Trim();
|
||||||
string details = task.Failure is { } failure
|
string details = task.Failure is { } failure
|
||||||
? failure.Message
|
? failure.Message
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public partial class ToolsView
|
|||||||
new() { Name = "Smallest FFPFSC (level 9)", Target = nameof(Target.Ffpfsc), Level = 9, Gain = 1 },
|
new() { Name = "Smallest FFPFSC (level 9)", Target = nameof(Target.Ffpfsc), Level = 9, Gain = 1 },
|
||||||
new() { Name = "Fast FFPFSC (level 3)", Target = nameof(Target.Ffpfsc), Level = 3, Gain = 5 },
|
new() { Name = "Fast FFPFSC (level 3)", Target = nameof(Target.Ffpfsc), Level = 3, Gain = 5 },
|
||||||
new() { Name = "exFAT image with AMPR index", Target = nameof(Target.Exfat), GenerateAmpr = true },
|
new() { Name = "exFAT image with AMPR index", Target = nameof(Target.Exfat), GenerateAmpr = true },
|
||||||
|
new() { Name = "ZArchive (.zar)", Target = nameof(Target.ZArchive) },
|
||||||
new() { Name = "Debug package, reproducible", Target = nameof(Target.Fpkg), Deterministic = true }
|
new() { Name = "Debug package, reproducible", Target = nameof(Target.Fpkg), Deterministic = true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ public partial class ToolsView : UserControl
|
|||||||
|
|
||||||
public event Action<string>? StatusChanged;
|
public event Action<string>? StatusChanged;
|
||||||
|
|
||||||
|
/// <summary>Picks "convert", "extract" or "verify" in the Action list, when the current source allows it.</summary>
|
||||||
|
public void ChooseAction(string name)
|
||||||
|
{
|
||||||
|
string wanted = name switch { "extract" => ActionExtract, "verify" => ActionVerify, _ => ActionConvert };
|
||||||
|
if (ActionBox.ItemsSource is IEnumerable<string> actions && actions.Contains(wanted)) ActionBox.SelectedItem = wanted;
|
||||||
|
}
|
||||||
|
|
||||||
private SourceSummary _summary = SourceSummary.Empty;
|
private SourceSummary _summary = SourceSummary.Empty;
|
||||||
|
|
||||||
private async Task LoadSummaryAsync(string source)
|
private async Task LoadSummaryAsync(string source)
|
||||||
@@ -272,6 +279,8 @@ public partial class ToolsView : UserControl
|
|||||||
bool convert = action == ActionConvert;
|
bool convert = action == ActionConvert;
|
||||||
bool extract = action == ActionExtract;
|
bool extract = action == ActionExtract;
|
||||||
TargetPanel.IsVisible = convert;
|
TargetPanel.IsVisible = convert;
|
||||||
|
// Presets hold conversion settings, so they only make sense for Convert / Build (or before a source is chosen).
|
||||||
|
PresetPanel.IsVisible = convert || action is null;
|
||||||
OutputPanel.IsVisible = convert || extract;
|
OutputPanel.IsVisible = convert || extract;
|
||||||
OutputLabel.Text = extract ? "Output folder" : "Output file";
|
OutputLabel.Text = extract ? "Output folder" : "Output file";
|
||||||
PasscodePanel.IsVisible = _isPackage && (extract || action == ActionVerify);
|
PasscodePanel.IsVisible = _isPackage && (extract || action == ActionVerify);
|
||||||
|
|||||||
@@ -33,10 +33,18 @@ public sealed class UpdateWindow : Window
|
|||||||
ShowInTaskbar = false;
|
ShowInTaskbar = false;
|
||||||
|
|
||||||
string when = release.PublishedUtc is { } published ? $" · released {published.ToLocalTime():d MMMM yyyy}" : string.Empty;
|
string when = release.PublishedUtc is { } published ? $" · released {published.ToLocalTime():d MMMM yyyy}" : string.Empty;
|
||||||
var notes = new TextBox
|
// The notes are Markdown (headings, bold, bullets): show them formatted, not as raw text.
|
||||||
|
var notes = new Border
|
||||||
{
|
{
|
||||||
Text = release.Notes.Length > 0 ? release.Notes.Replace("\r\n", "\n").Trim() : "No release notes were published.",
|
Classes = { "card" }, Padding = new Thickness(14, 10), MaxHeight = 300,
|
||||||
IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MaxHeight = 240, MinHeight = 110
|
Child = new ScrollViewer
|
||||||
|
{
|
||||||
|
VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
|
||||||
|
HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
|
||||||
|
Content = release.Notes.Trim().Length > 0
|
||||||
|
? new Border { Padding = new Thickness(0, 0, 18, 0), Child = MarkdownLite.Render(release.Notes) } // room for the scrollbar
|
||||||
|
: new TextBlock { Text = "No release notes were published.", Classes = { "muted" } }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bool installer = _asset is not null && UpdateChecker.IsInstaller(_asset);
|
bool installer = _asset is not null && UpdateChecker.IsInstaller(_asset);
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ public static class SonyPackageImageConversion
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Explains why a package gave no files, using what the package check itself reports.</summary>
|
||||||
|
private static string NoReadableFilesMessage(Ps5GameInfo game)
|
||||||
|
{
|
||||||
|
string reason = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Packwright.Core.Builders.SonyDebugPackageBuilder.Validate(game.RootPath) is { IsValid: false } result)
|
||||||
|
reason = result.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
|
||||||
|
NotSupportedException or ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
reason = ex.Message;
|
||||||
|
}
|
||||||
|
reason = reason.Trim().TrimEnd('.');
|
||||||
|
return "The package does not expose any readable files." +
|
||||||
|
(reason.Length > 0 ? $" The package check says: {reason}." : string.Empty) +
|
||||||
|
" The file may be incomplete or damaged (compare its size and checksum with the source), or it may be a " +
|
||||||
|
"retail package or need a different passcode.";
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task ExtractCleanTreeAsync(Ps5GameInfo game, string tree,
|
private static async Task ExtractCleanTreeAsync(Ps5GameInfo game, string tree,
|
||||||
IProgress<Ps5ImageConversionProgress>? progress, CancellationToken cancellationToken)
|
IProgress<Ps5ImageConversionProgress>? progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -66,8 +87,7 @@ public static class SonyPackageImageConversion
|
|||||||
.Where(file => file.RelativePath.Length > 0 && !ExcludedPaths.Contains(file.RelativePath))
|
.Where(file => file.RelativePath.Length > 0 && !ExcludedPaths.Contains(file.RelativePath))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (wanted.Length == 0)
|
if (wanted.Length == 0)
|
||||||
throw new InvalidDataException(
|
throw new InvalidDataException(NoReadableFilesMessage(game));
|
||||||
"The package does not expose any readable files (it may be retail or require a passcode).");
|
|
||||||
|
|
||||||
long total = wanted.Sum(file => file.Size);
|
long total = wanted.Sum(file => file.Size);
|
||||||
long completed = 0;
|
long completed = 0;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Packwright.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An error whose <see cref="Exception.Message"/> is written for the person using the app: plain words, no technical terms.
|
||||||
|
/// The original exception (what the system really reported) is kept as <see cref="Exception.InnerException"/> and goes to the log.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserMessageException(string message, Exception? inner = null) : IOException(message, inner);
|
||||||
@@ -352,7 +352,8 @@ public sealed class PackageTaskQueue : IAsyncDisposable
|
|||||||
StagePercent = 1d,
|
StagePercent = 1d,
|
||||||
Step = task.Progress.TotalSteps > 0 ? task.Progress.TotalSteps : task.Progress.Step
|
Step = task.Progress.TotalSteps > 0 ? task.Progress.TotalSteps : task.Progress.Step
|
||||||
});
|
});
|
||||||
task.Apply(PackageTaskStatus.Completed, "Completed");
|
// A task may report how it ended (for example "Verified: no problems found"); otherwise it is just "Completed".
|
||||||
|
task.Apply(PackageTaskStatus.Completed, string.IsNullOrWhiteSpace(task.Progress.Result) ? "Completed" : task.Progress.Result);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -361,7 +362,7 @@ public sealed class PackageTaskQueue : IAsyncDisposable
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
task.Failure = ex;
|
task.Failure = ex;
|
||||||
task.Apply(PackageTaskStatus.Failed, $"{ex.GetType().Name}: {ex.Message}");
|
task.Apply(PackageTaskStatus.Failed, DescribeFailure(ex));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -371,6 +372,16 @@ public sealed class PackageTaskQueue : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The text shown for a failed task: the message alone for the problems people can act on, never a class name.</summary>
|
||||||
|
private static string DescribeFailure(Exception ex)
|
||||||
|
{
|
||||||
|
string message = ex.Message.TrimEnd('.', ' ');
|
||||||
|
return ex is IOException or InvalidDataException or UnauthorizedAccessException or NotSupportedException or ArgumentException
|
||||||
|
or InvalidOperationException
|
||||||
|
? message + "."
|
||||||
|
: "Something unexpected went wrong. The details are saved in the log (open the Log page).";
|
||||||
|
}
|
||||||
|
|
||||||
private void Notify() => TasksChanged?.Invoke(this, EventArgs.Empty);
|
private void Notify() => TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
private sealed class RelayProgress<T>(Action<T> report) : IProgress<T>
|
private sealed class RelayProgress<T>(Action<T> report) : IProgress<T>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ public readonly record struct PackageTaskProgress(
|
|||||||
int CurrentItem = 0,
|
int CurrentItem = 0,
|
||||||
int TotalItems = 0,
|
int TotalItems = 0,
|
||||||
string CurrentFile = "",
|
string CurrentFile = "",
|
||||||
double StagePercent = -1)
|
double StagePercent = -1,
|
||||||
|
string Result = "")
|
||||||
{
|
{
|
||||||
public static readonly PackageTaskProgress Empty = new(string.Empty);
|
public static readonly PackageTaskProgress Empty = new(string.Empty);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user