Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Example: `id:PPSA01 size:>20GB -dlc | category:patch`.
| Settings (including consoles and presets), library cache, task history | Windows `%LOCALAPPDATA%\Packwright`, Linux and macOS `~/.local/share/Packwright` |
| Library-only edits (`metadata-overrides.json`, `overrides/` for custom icons) | the same folder |
| Logs | `logs/` inside that folder (also shown on the **Log** page) |
| Temporary work files | your system temporary folder (only used when an image has to be unpacked first) |
| Temporary work files | your system temporary folder, or the one chosen under **Settings > Temporary folder** (or next to the output); only used when an image has to be unpacked first. The command line takes `--temp <folder>` too |

Delete the folder to reset the app. Setting the `PACKWRIGHT_DATA` environment variable to a folder makes a separate copy of the app use that folder instead (handy for testing).

Expand Down
11 changes: 10 additions & 1 deletion src/Packwright.App/Cli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ internal static class Cli
private static readonly HashSet<string> ValueOptions = new(StringComparer.OrdinalIgnoreCase)
{
"to", "o", "output", "preset", "passcode", "level", "gain", "cluster", "block", "fragment", "density", "min-free",
"compression", "kraken-level", "threads", "playgo", "drm", "format", "folder", "host", "port", "user", "password"
"compression", "kraken-level", "threads", "playgo", "drm", "format", "folder", "host", "port", "user", "password",
"temp"
};

public static bool IsCommand(string[] args) =>
Expand Down Expand Up @@ -165,6 +166,7 @@ version Print the version.

convert options:
-o, --output <file> Where to write it (default: next to the source). --overwrite replaces an existing file.
--temp <folder> Where to unpack work files (default: the folder chosen in Settings, or the system temp).
--preset <name> Use a saved or built-in preset from the Tools page (its target and options).
--level 1-9 --gain 0-100 FFPFSC compression
--cluster 32|64 --no-ampr exFAT
Expand Down Expand Up @@ -357,6 +359,13 @@ private static async Task<int> JobAsync(string command, Options options, Cancell

Func<IProgress<PackageTaskProgress>, CancellationToken, Task>? run = spec.Executor();
if (run is null) return Fail("That job cannot run with these arguments.", Usage);
// Work files go where Settings says, unless --temp names a folder for this run.
if (options.Get("temp") is { } temp) TemporaryFolders.Configure(temp, nextToOutput: false);
else
{
AppSettings saved = new AppStateStore().LoadSettings();
TemporaryFolders.Configure(saved.TempDirectory, saved.TempNextToOutput);
}
var printer = new ConsoleProgress();
Console.Error.WriteLine($"{command}: {source}");
if (spec.Output.Length > 0) Console.Error.WriteLine(" -> " + spec.Output);
Expand Down
1 change: 1 addition & 0 deletions src/Packwright.App/Infrastructure/AppSettingsNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public static AppSettings Normalize(AppSettings? settings)
settings.ThumbnailCacheCount = Math.Clamp(settings.ThumbnailCacheCount, MinThumbnailCache, MaxThumbnailCache);

settings.OutputDirectory = (settings.OutputDirectory ?? string.Empty).Trim();
settings.TempDirectory = (settings.TempDirectory ?? string.Empty).Trim();

// A passcode is either blank (meaning the built-in all-zero default) or exactly 32 printable
// ASCII characters. Anything else is discarded rather than carried into a build.
Expand Down
4 changes: 4 additions & 0 deletions src/Packwright.App/Infrastructure/AppStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ public sealed class AppSettings
// Paths & outputs
public string OutputDirectory { get; set; } = string.Empty;
public bool OpenOutputAfterTask { get; set; }
/// <summary>Where conversions and edits unpack their work files; empty is the system temporary folder.</summary>
public string TempDirectory { get; set; } = string.Empty;
/// <summary>Keep work files next to the output instead, on the same drive.</summary>
public bool TempNextToOutput { get; set; }

// Tasks workspace
/// <summary>Remembered vertical split between the task list and the details panel.</summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Packwright.App/Services/AppServices.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Packwright.Containers;
using Packwright.Core.Models;
using Packwright.Core.Services;
using Packwright.Core.Tasks;
Expand All @@ -19,6 +20,7 @@ private AppServices()
SettingsLoadResult loaded = Store.LoadSettingsWithDiagnostics();
Settings = loaded.Settings;
SettingsWarning = loaded.Warning;
TemporaryFolders.Configure(Settings.TempDirectory, Settings.TempNextToOutput);
Queue = new PackageTaskQueue();
Queue.TasksChanged += (_, _) => LogTaskChanges();
string taskFile = Path.Combine(Store.AppDataDirectory, "studio-tasks.json");
Expand Down Expand Up @@ -160,6 +162,7 @@ public void SaveSettings()
public void ReplaceSettings(AppSettings settings)
{
Settings = AppSettingsNormalizer.Normalize(settings);
TemporaryFolders.Configure(Settings.TempDirectory, Settings.TempNextToOutput);
SaveSettings();
}

Expand Down
3 changes: 1 addition & 2 deletions src/Packwright.App/Services/MetadataEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,7 @@ public static async Task ApplyAsync(string source, string output, bool fromPacka
if (!inPlace && (File.Exists(target) || Directory.Exists(target)))
throw new IOException("The output already exists: " + target);

string work = Path.Combine(Path.GetTempPath(), "Packwright-edit-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(work);
string work = TemporaryFolders.Create("edit", target);
try
{
byte[]? icon = edit.IconPath.Length > 0 ? await File.ReadAllBytesAsync(edit.IconPath, token).ConfigureAwait(false) : null;
Expand Down
8 changes: 8 additions & 0 deletions src/Packwright.App/Views/SettingsView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@
<Button x:Name="BrowseOutputButton" DockPanel.Dock="Right" Content="Browse..." Margin="8,0,0,0" />
<TextBox x:Name="OutputBox" Watermark="Next to the source when empty" />
</DockPanel>
<TextBlock Text="Temporary folder" Classes="h" Margin="0,6,0,0" />
<DockPanel>
<Button x:Name="BrowseTempButton" DockPanel.Dock="Right" Content="Browse..." Margin="8,0,0,0" />
<TextBox x:Name="TempBox" Watermark="System temporary folder when empty" />
</DockPanel>
<CheckBox x:Name="TempNextToOutputBox" Content="Keep temporary files next to the output instead" />
<TextBlock Classes="muted" FontSize="11" TextWrapping="Wrap"
Text="Converting an image, or editing the details of an FFPFSC image or a package, unpacks the whole game here first, so it needs about as much free space as the game." />
<TextBlock Text="Custom rename format" Classes="h" Margin="0,6,0,6" />
<TextBox x:Name="RenameFormatBox" Watermark="{}{TITLE} [{TITLE_ID}]" />
<TextBlock x:Name="RenameTokens" Classes="muted" FontSize="11" TextWrapping="Wrap" />
Expand Down
25 changes: 25 additions & 0 deletions src/Packwright.App/Views/SettingsView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public SettingsView()
{
if (await Dialogs.PickFolderAsync(this, "Default output folder") is { } folder) OutputBox.Text = folder;
};
BrowseTempButton.Click += async (_, _) =>
{
if (await Dialogs.PickFolderAsync(this, "Temporary folder") is { } folder) TempBox.Text = folder;
};
TempNextToOutputBox.IsCheckedChanged += (_, _) => UpdateTempControls();
SaveButton.Click += async (_, _) => await SaveAsync();
ConsolesButton.Click += async (_, _) => await ConsolesWindow.ShowAsync(Dialogs.OwnerOf(this));
WindowsCard.IsVisible = OperatingSystem.IsWindows();
Expand Down Expand Up @@ -105,6 +110,9 @@ private void Load()
ViewBox.SelectedItem = Layouts.Contains(settings.LibraryLayout) ? settings.LibraryLayout : "Cards";
ThumbnailsBox.IsChecked = settings.ShowThumbnails;
OutputBox.Text = settings.OutputDirectory;
TempBox.Text = settings.TempDirectory;
TempNextToOutputBox.IsChecked = settings.TempNextToOutput;
UpdateTempControls();
RenameFormatBox.Text = settings.RenameFormat;
OpenOutputBox.IsChecked = settings.OpenOutputAfterTask;
PreviewMbBox.Value = settings.MaxPreviewMb;
Expand All @@ -116,6 +124,14 @@ private void Load()
ShowKeysStatus();
}

/// <summary>The folder box only matters when temporary files are not kept next to the output.</summary>
private void UpdateTempControls()
{
bool beside = TempNextToOutputBox.IsChecked == true;
TempBox.IsEnabled = !beside;
BrowseTempButton.IsEnabled = !beside;
}

private void ShowKeysStatus()
{
Jobs.EnsureKeys();
Expand Down Expand Up @@ -161,6 +177,13 @@ await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Settings",
"The passcode must be exactly 32 characters, or blank for the default.");
return;
}
string tempFolder = TempBox.Text?.Trim() ?? string.Empty;
if (tempFolder.Length > 0 && !System.IO.Path.IsPathFullyQualified(tempFolder))
{
await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Settings",
"The temporary folder must be a full path, such as D:\\Temp, or blank for the system temporary folder.");
return;
}
AppSettings settings = _services.Settings;
// Files and items that were added individually stay manual sources; the rest are library folders.
settings.ManualSources = _folders.Where(path => File.Exists(path) || _manual.Contains(path)).ToList();
Expand All @@ -172,6 +195,8 @@ await Dialogs.ShowMessageAsync(Dialogs.OwnerOf(this), "Settings",
settings.LibraryLayout = ViewBox.SelectedItem as string ?? "Cards";
settings.ShowThumbnails = ThumbnailsBox.IsChecked == true;
settings.OutputDirectory = OutputBox.Text?.Trim() ?? string.Empty;
settings.TempDirectory = tempFolder;
settings.TempNextToOutput = TempNextToOutputBox.IsChecked == true;
settings.RenameFormat = RenameFormatBox.Text?.Trim() ?? string.Empty;
settings.OpenOutputAfterTask = OpenOutputBox.IsChecked == true;
settings.MaxPreviewMb = (int)(PreviewMbBox.Value ?? 16);
Expand Down
9 changes: 5 additions & 4 deletions src/Packwright.Containers/ImageConversionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public static async Task<Ps5ImageConversionResult> ConvertAsync(string sourcePat
throw new InvalidDataException(
$"Converting {sourceFormat} to {target} is not supported.");

string tempRoot = Path.Combine(Path.GetTempPath(), "Packwright", "convert", Guid.NewGuid().ToString("N"));
string? tempRoot = null;
string? tempOutput = null;
try
{
Expand All @@ -135,13 +135,14 @@ public static async Task<Ps5ImageConversionResult> ConvertAsync(string sourcePat
}
else
{
Directory.CreateDirectory(tempRoot);
tempRoot = TemporaryFolders.Create("convert", outputFull);
tree = Path.Combine(tempRoot, "tree");
await ExtractAsync(sourceFull, tree, sourceFormat, progress, cancellationToken).ConfigureAwait(false);
fileCount = Directory.EnumerateFiles(tree, "*", SearchOption.AllDirectories).Count();
}

string outputDirectory = Path.GetDirectoryName(outputFull) ?? tempRoot;
string outputDirectory = Path.GetDirectoryName(outputFull)
?? throw new IOException("The output path has no parent folder: " + outputFull);
Directory.CreateDirectory(outputDirectory);
tempOutput = outputFull + "." + Guid.NewGuid().ToString("N") + ".tmp";
await BuildAsync(tree, tempOutput, target, BuildVolumeName(sourceFull), progress, cancellationToken, exfatOptions, ffpfscOptions, ffpkgOptions)
Expand All @@ -164,7 +165,7 @@ await BuildAsync(tree, tempOutput, target, BuildVolumeName(sourceFull), progress
finally
{
if (tempOutput is not null) TryDelete(tempOutput);
if (Directory.Exists(tempRoot)) TryDeleteDirectory(tempRoot);
if (tempRoot is not null && Directory.Exists(tempRoot)) TryDeleteDirectory(tempRoot);
}
}

Expand Down
57 changes: 57 additions & 0 deletions src/Packwright.Containers/TemporaryFolders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace Packwright.Containers;

/// <summary>
/// Where large temporary work files go (an image unpacked to a folder, a copy of an image being edited). By default
/// that is the system temporary folder; another folder can be chosen, or the files can be kept next to the output, so
/// a big conversion does not fill the system drive.
/// </summary>
public static class TemporaryFolders
{
private sealed record Choice(string Root, bool NextToOutput);

private static volatile Choice _choice = new(string.Empty, false);

/// <summary>The chosen folder, or empty for the system temporary folder.</summary>
public static string Root => _choice.Root;

/// <summary>True when work files are kept next to the output instead.</summary>
public static bool NextToOutput => _choice.NextToOutput;

/// <summary>Sets where later jobs write their work files. A job that is already running keeps its folder.</summary>
public static void Configure(string? root, bool nextToOutput)
{
string folder = string.IsNullOrWhiteSpace(root) ? string.Empty : root.Trim();
if (folder.Length > 0)
{
try { folder = Path.GetFullPath(folder); }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { }
}
_choice = new Choice(folder, nextToOutput);
}

/// <summary>
/// Creates a new, empty folder for one job and returns it. The caller deletes that folder when done; the chosen
/// folder itself is never removed. <paramref name="outputPath"/> is the file or folder the job writes, used when
/// work files are kept next to the output.
/// </summary>
public static string Create(string purpose, string outputPath)
{
Choice choice = _choice;
string id = Guid.NewGuid().ToString("N");
string? beside = choice.NextToOutput ? Path.GetDirectoryName(Path.GetFullPath(outputPath)) : null;
string folder = !string.IsNullOrEmpty(beside)
? Path.Combine(beside, $".Packwright-{purpose}-{id}")
: Path.Combine(choice.Root.Length > 0 ? choice.Root : Path.GetTempPath(), "Packwright", purpose, id);
try
{
Directory.CreateDirectory(folder);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
// Never fall back to the system drive on its own: running out of room there is what the setting avoids.
throw new IOException($"The temporary folder could not be created: {folder}. {ex.Message.Trim()} " +
"Choose another temporary folder.", ex);
}
return folder;
}
}
11 changes: 11 additions & 0 deletions src/Packwright.Core/Services/Ps5DumpLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,19 @@ public LocatedSources FindAll(string selectedPath, bool recursive, CancellationT
return found;
}

/// <summary>
/// True for a file inside a job's work folder (<c>.Packwright-convert-…</c>, <c>.Packwright-exfat-edit-…</c> and
/// so on), which can sit next to an output in a library folder while a job runs. Its half-built dump or image copy
/// is not a title.
/// </summary>
public static bool IsInWorkFolder(string fullPath) =>
fullPath.Contains(Path.DirectorySeparatorChar + ".Packwright-", StringComparison.OrdinalIgnoreCase) ||
(Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar &&
fullPath.Contains(Path.AltDirectorySeparatorChar + ".Packwright-", StringComparison.OrdinalIgnoreCase));

private static void Classify(string fullPath, LocatedSources found)
{
if (IsInWorkFolder(fullPath)) return;
string name = Path.GetFileName(fullPath);
if (name.Equals("param.json", StringComparison.OrdinalIgnoreCase))
{
Expand Down
3 changes: 1 addition & 2 deletions src/Packwright.Core/Services/Ps5PackageImageConversion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ public static async Task<Ps5ImageConversionResult> ConvertAsync(string packagePa
if (string.Equals(packageFull, outputFull, StringComparison.OrdinalIgnoreCase))
throw new IOException("The output path must be different from the source package.");

string tempRoot = Path.Combine(Path.GetTempPath(), "Packwright", "pkg-image",
Guid.NewGuid().ToString("N"));
string tempRoot = TemporaryFolders.Create("pkg-image", outputFull);
string tree = Path.Combine(tempRoot, "tree");
try
{
Expand Down
1 change: 1 addition & 0 deletions tests/Packwright.SmokeTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
return 0;
}
if (args.Length == 5 && args[0] == "edit-tests") return await Packwright.SmokeTests.EditTests.Run(args);
if (args.Length == 2 && args[0] == "temp-folders") return await Packwright.SmokeTests.TempFolderTests.Run(args);
if (args.Length == 2 && args[0] == "ampr-refresh")
{
var result = await Packwright.Containers.ExfatAmprPatcher.RefreshAsync(args[1]);
Expand Down
Loading