From f33540670fe0292b37dd030a13f0b77a2ad835ad Mon Sep 17 00:00:00 2001 From: Aaron Murgatroyd Date: Tue, 29 Oct 2024 22:02:01 +1000 Subject: [PATCH] Add ability to Trim XCI Files in Bulk Stop warning in IpcServiceGenerator regarding nullable context --- .../Utilities/XCIFileTrimmer.cs | 63 +- .../IpcServiceGenerator.cs | 2 + .../Models/XCITrimmerFileModel.cs | 55 ++ src/Ryujinx/Assets/Locales/en_US.json | 26 + src/Ryujinx/Assets/Styles/Styles.xaml | 14 +- ...rLog.cs => XCIFileTrimmerMainWindowLog.cs} | 8 +- src/Ryujinx/Common/XCIFileTrimmerWindowLog.cs | 23 + .../UI/Helpers/AvaloniaListExtensions.cs | 62 ++ .../XCITrimmerFileSpaceSavingsConverter.cs | 48 ++ .../Helpers/XCITrimmerFileStatusConverter.cs | 46 ++ .../XCITrimmerFileStatusDetailConverter.cs | 42 ++ .../XCITrimmerOperationOutcomeHelper.cs | 36 ++ .../UI/ViewModels/MainWindowViewModel.cs | 51 +- .../UI/ViewModels/XCITrimmerViewModel.cs | 541 ++++++++++++++++++ .../UI/Views/Main/MainMenuBarView.axaml | 2 + .../UI/Views/Main/MainMenuBarView.axaml.cs | 2 + .../UI/Views/Main/MainStatusBarView.axaml | 2 + src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml | 354 ++++++++++++ .../UI/Windows/XCITrimmerWindow.axaml.cs | 101 ++++ 19 files changed, 1426 insertions(+), 52 deletions(-) create mode 100644 src/Ryujinx.UI.Common/Models/XCITrimmerFileModel.cs rename src/Ryujinx/Common/{XCIFileTrimmerLog.cs => XCIFileTrimmerMainWindowLog.cs} (66%) create mode 100644 src/Ryujinx/Common/XCIFileTrimmerWindowLog.cs create mode 100644 src/Ryujinx/UI/Helpers/AvaloniaListExtensions.cs create mode 100644 src/Ryujinx/UI/Helpers/XCITrimmerFileSpaceSavingsConverter.cs create mode 100644 src/Ryujinx/UI/Helpers/XCITrimmerFileStatusConverter.cs create mode 100644 src/Ryujinx/UI/Helpers/XCITrimmerFileStatusDetailConverter.cs create mode 100644 src/Ryujinx/UI/Helpers/XCITrimmerOperationOutcomeHelper.cs create mode 100644 src/Ryujinx/UI/ViewModels/XCITrimmerViewModel.cs create mode 100644 src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml create mode 100644 src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs diff --git a/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs b/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs index 957eaf5e9..050e78d1e 100644 --- a/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs +++ b/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs @@ -1,9 +1,13 @@ +// Uncomment the line below to ensure XCIFileTrimmer does not modify files +//#define XCI_TRIMMER_READ_ONLY_MODE + using Gommon; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; namespace Ryujinx.Common.Utilities { @@ -71,6 +75,7 @@ namespace Ryujinx.Common.Utilities public enum OperationOutcome { + Undetermined, InvalidXCIFile, NoTrimNecessary, NoUntrimPossible, @@ -78,7 +83,8 @@ namespace Ryujinx.Common.Utilities FileIOWriteError, ReadOnlyFileCannotFix, FileSizeChanged, - Successful + Successful, + Cancelled } public enum LogType @@ -139,7 +145,7 @@ namespace Ryujinx.Common.Utilities ReadHeader(); } - public void CheckFreeSpace() + public void CheckFreeSpace(CancellationToken? cancelToken = null) { if (FreeSpaceChecked) return; @@ -160,7 +166,7 @@ namespace Ryujinx.Common.Utilities Stopwatch timedSw = Lambda.Timed(() => { - freeSpaceValid = CheckPadding(readSizeB); + freeSpaceValid = CheckPadding(readSizeB, cancelToken); }); if (timedSw.Elapsed.TotalSeconds > 0) @@ -191,7 +197,7 @@ namespace Ryujinx.Common.Utilities } } - private bool CheckPadding(long readSizeB) + private bool CheckPadding(long readSizeB, CancellationToken? cancelToken = null) { long maxReads = readSizeB / XCIFileTrimmer.BufferSize; long read = 0; @@ -199,6 +205,11 @@ namespace Ryujinx.Common.Utilities while (true) { + if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested) + { + return false; + } + int bytes = _fileStream.Read(buffer, 0, XCIFileTrimmer.BufferSize); if (bytes == 0) break; @@ -223,7 +234,7 @@ namespace Ryujinx.Common.Utilities ReadHeader(); } - public OperationOutcome Trim() + public OperationOutcome Trim(CancellationToken? cancelToken = null) { if (!FileOK) { @@ -237,12 +248,19 @@ namespace Ryujinx.Common.Utilities if (!FreeSpaceChecked) { - CheckFreeSpace(); + CheckFreeSpace(cancelToken); } if (!FreeSpaceValid) { - return OperationOutcome.FreeSpaceCheckFailed; + if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested) + { + return OperationOutcome.Cancelled; + } + else + { + return OperationOutcome.FreeSpaceCheckFailed; + } } Log?.Write(LogType.Info, "Trimming..."); @@ -274,7 +292,10 @@ namespace Ryujinx.Common.Utilities try { - outfileStream.SetLength(TrimmedFileSizeB); + +#if !XCI_TRIMMER_READ_ONLY_MODE + outfileStream.SetLength(TrimmedFileSizeB); +#endif return OperationOutcome.Successful; } finally @@ -290,7 +311,7 @@ namespace Ryujinx.Common.Utilities } } - public OperationOutcome Untrim() + public OperationOutcome Untrim(CancellationToken? cancelToken = null) { if (!FileOK) { @@ -334,7 +355,7 @@ namespace Ryujinx.Common.Utilities { Stopwatch timedSw = Lambda.Timed(() => { - WritePadding(outfileStream, bytesToWriteB); + WritePadding(outfileStream, bytesToWriteB, cancelToken); }); if (timedSw.Elapsed.TotalSeconds > 0) @@ -342,7 +363,14 @@ namespace Ryujinx.Common.Utilities Log?.Write(LogType.Info, $"Wrote at {bytesToWriteB / (double)XCIFileTrimmer.BytesInAMegabyte / timedSw.Elapsed.TotalSeconds:N} Mb/sec"); } - return OperationOutcome.Successful; + if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested) + { + return OperationOutcome.Cancelled; + } + else + { + return OperationOutcome.Successful; + } } finally { @@ -357,7 +385,7 @@ namespace Ryujinx.Common.Utilities } } - private void WritePadding(FileStream outfileStream, long bytesToWriteB) + private void WritePadding(FileStream outfileStream, long bytesToWriteB, CancellationToken? cancelToken = null) { long bytesLeftToWriteB = bytesToWriteB; long writes = bytesLeftToWriteB / XCIFileTrimmer.BufferSize; @@ -370,8 +398,17 @@ namespace Ryujinx.Common.Utilities while (bytesLeftToWriteB > 0) { + if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested) + { + return; + } + long bytesToWrite = Math.Min(XCIFileTrimmer.BufferSize, bytesLeftToWriteB); - outfileStream.Write(buffer, 0, (int)bytesToWrite); + +#if !XCI_TRIMMER_READ_ONLY_MODE + outfileStream.Write(buffer, 0, (int)bytesToWrite); +#endif + bytesLeftToWriteB -= bytesToWrite; Log?.Progress(write, writes, "Writing padding data...", false); write++; diff --git a/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs b/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs index d6b6f23ef..8e40b7507 100644 --- a/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs +++ b/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs @@ -13,6 +13,7 @@ namespace Ryujinx.HLE.Generators var syntaxReceiver = (ServiceSyntaxReceiver)context.SyntaxReceiver; CodeGenerator generator = new CodeGenerator(); + generator.AppendLine("#nullable enable"); generator.AppendLine("using System;"); generator.EnterScope($"namespace Ryujinx.HLE.HOS.Services.Sm"); generator.EnterScope($"partial class IUserInterface"); @@ -58,6 +59,7 @@ namespace Ryujinx.HLE.Generators generator.LeaveScope(); generator.LeaveScope(); + generator.AppendLine("#nullable disable"); context.AddSource($"IUserInterface.g.cs", generator.ToString()); } diff --git a/src/Ryujinx.UI.Common/Models/XCITrimmerFileModel.cs b/src/Ryujinx.UI.Common/Models/XCITrimmerFileModel.cs new file mode 100644 index 000000000..05fa82920 --- /dev/null +++ b/src/Ryujinx.UI.Common/Models/XCITrimmerFileModel.cs @@ -0,0 +1,55 @@ +using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.UI.App.Common; + +namespace Ryujinx.UI.Common.Models +{ + public record XCITrimmerFileModel( + string Name, + string Path, + bool Trimmable, + bool Untrimmable, + long PotentialSavingsB, + long CurrentSavingsB, + int? PercentageProgress, + XCIFileTrimmer.OperationOutcome ProcessingOutcome) + { + public static XCITrimmerFileModel FromApplicationData(ApplicationData applicationData, XCIFileTrimmerLog logger) + { + var trimmer = new XCIFileTrimmer(applicationData.Path, logger); + + return new XCITrimmerFileModel( + applicationData.Name, + applicationData.Path, + trimmer.CanBeTrimmed, + trimmer.CanBeUntrimmed, + trimmer.DiskSpaceSavingsB, + trimmer.DiskSpaceSavedB, + null, + XCIFileTrimmer.OperationOutcome.Undetermined + ); + } + + public bool IsFailed + { + get + { + return ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Undetermined && + ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Successful; + } + } + + public virtual bool Equals(XCITrimmerFileModel obj) + { + if (obj == null) + return false; + else + return this.Path == obj.Path; + } + + public override int GetHashCode() + { + return this.Path.GetHashCode(); + } + } +} diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index ad316497b..cc02c3b1b 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -33,6 +33,7 @@ "MenuBarToolsManageFileTypes": "Manage file types", "MenuBarToolsInstallFileTypes": "Install file types", "MenuBarToolsUninstallFileTypes": "Uninstall file types", + "MenuBarToolsXCITrimmer": "Trim XCI Files", "MenuBarView": "_View", "MenuBarViewWindow": "Window Size", "MenuBarViewWindow720": "720p", @@ -402,6 +403,8 @@ "InputDialogTitle": "Input Dialog", "InputDialogOk": "OK", "InputDialogCancel": "Cancel", + "InputDialogCancelling": "Cancelling", + "InputDialogClose": "Close", "InputDialogAddNewProfileTitle": "Choose the Profile Name", "InputDialogAddNewProfileHeader": "Please Enter a Profile Name", "InputDialogAddNewProfileSubtext": "(Max Length: {0})", @@ -470,6 +473,7 @@ "DialogUninstallFileTypesSuccessMessage": "Successfully uninstalled file types!", "DialogUninstallFileTypesErrorMessage": "Failed to uninstall file types.", "DialogOpenSettingsWindowLabel": "Open Settings Window", + "DialogOpenXCITrimmerWindowLabel": "XCI Trimmer Window", "DialogControllerAppletTitle": "Controller Applet", "DialogMessageDialogErrorExceptionMessage": "Error displaying Message Dialog: {0}", "DialogSoftwareKeyboardErrorExceptionMessage": "Error displaying Software Keyboard: {0}", @@ -672,6 +676,12 @@ "TitleUpdateVersionLabel": "Version {0}", "TitleBundledUpdateVersionLabel": "Bundled: Version {0}", "TitleBundledDlcLabel": "Bundled:", + "TitleXCIStatusPartialLabel": "Partial", + "TitleXCIStatusTrimmableLabel": "Untrimmed", + "TitleXCIStatusUntrimmableLabel": "Trimmed", + "TitleXCIStatusFailedLabel": "(Failed)", + "TitleXCICanSaveLabel": "Save {0:n0} Mb", + "TitleXCISavingLabel": "Saved {0:n0} Mb", "RyujinxInfo": "Ryujinx - Info", "RyujinxConfirm": "Ryujinx - Confirmation", "FileDialogAllTypes": "All types", @@ -728,17 +738,33 @@ "TrimXCIFileDialogPrimaryText": "This function will first check the empty space and then trim the XCI File to save disk space.", "TrimXCIFileDialogSecondaryText": "Current File Size: {0:n} MB\nGame Data Size: {1:n} MB\nDisk Space Savings: {2:n} MB", "TrimXCIFileNoTrimNecessary": "XCI File does not need to be trimmed. Check logs for further details", + "TrimXCIFileNoUntrimPossible": "XCI File cannot be untrimmed. Check logs for further details", "TrimXCIFileReadOnlyFileCannotFix": "XCI File is Read Only and could not be made writable. Check logs for further details", "TrimXCIFileFileSizeChanged": "XCI File has changed in size since it was scanned. Please check the file is not being written to and try again.", "TrimXCIFileFreeSpaceCheckFailed": "XCI File has data in the free space area, it is not safe to trim", "TrimXCIFileInvalidXCIFile": "XCI File contains invalid data. Check logs for further details", "TrimXCIFileFileIOWriteError": "XCI File could not be opened for writing. Check logs for further details", "TrimXCIFileFailedPrimaryText": "Trimming of the XCI file failed", + "TrimXCIFileCancelled": "The operation was cancelled", + "TrimXCIFileFileUndertermined": "No operation was performed", "UserProfileWindowTitle": "User Profiles Manager", "CheatWindowTitle": "Cheats Manager", "DlcWindowTitle": "Manage Downloadable Content for {0} ({1})", "ModWindowTitle": "Manage Mods for {0} ({1})", "UpdateWindowTitle": "Title Update Manager", + "XCITrimmerWindowTitle": "XCI File Trimmer", + "XCITrimmerTitleStatusCount": "{0} of {1} Title(s) Selected", + "XCITrimmerTitleStatusCountWithFilter": "{0} of {1} Title(s) Selected ({2} displayed)", + "XCITrimmerTitleStatusTrimming": "Trimming {0} Title(s)...", + "XCITrimmerTitleStatusUntrimming": "Untrimming {0} Title(s)...", + "XCITrimmerTitleStatusFailed": "Failed", + "XCITrimmerPotentialSavings": "Potential Savings", + "XCITrimmerActualSavings": "Actual Savings", + "XCITrimmerSavingsMb": "{0:n0} Mb", + "XCITrimmerSelectDisplayed": "Select Shown", + "XCITrimmerDeselectDisplayed": "Deselect Shown", + "XCITrimmerSortName": "Title", + "XCITrimmerSortSaved": "Space Savings", "UpdateWindowUpdateAddedMessage": "{0} new update(s) added", "UpdateWindowBundledContentNotice": "Bundled updates cannot be removed, only disabled.", "CheatWindowHeading": "Cheats Available for {0} [{1}]", diff --git a/src/Ryujinx/Assets/Styles/Styles.xaml b/src/Ryujinx/Assets/Styles/Styles.xaml index b3a6f59c8..05212a7dd 100644 --- a/src/Ryujinx/Assets/Styles/Styles.xaml +++ b/src/Ryujinx/Assets/Styles/Styles.xaml @@ -43,6 +43,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs b/src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs new file mode 100644 index 000000000..580ebc9da --- /dev/null +++ b/src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs @@ -0,0 +1,101 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.UI.Common.Models; +using System; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.UI.Windows +{ + public partial class XCITrimmerWindow : UserControl + { + public XCITrimmerViewModel ViewModel; + + public XCITrimmerWindow() + { + DataContext = this; + + InitializeComponent(); + } + + public XCITrimmerWindow(MainWindowViewModel mainWindowViewModel) + { + DataContext = ViewModel = new XCITrimmerViewModel(mainWindowViewModel); + + InitializeComponent(); + } + + public static async Task Show(MainWindowViewModel mainWindowViewModel) + { + ContentDialog contentDialog = new() + { + PrimaryButtonText = "", + SecondaryButtonText = "", + CloseButtonText = "", + Content = new XCITrimmerWindow(mainWindowViewModel), + Title = string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerWindowTitle]), + }; + + Style bottomBorder = new(x => x.OfType().Name("DialogSpace").Child().OfType()); + bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false)); + + contentDialog.Styles.Add(bottomBorder); + + await contentDialog.ShowAsync(); + } + + private void Trim(object sender, RoutedEventArgs e) + { + ViewModel.TrimSelected(); + } + + private void Untrim(object sender, RoutedEventArgs e) + { + ViewModel.UntrimSelected(); + } + + private void Close(object sender, RoutedEventArgs e) + { + ((ContentDialog)Parent).Hide(); + } + + private void Cancel(Object sender, RoutedEventArgs e) + { + ViewModel.Cancel = true; + } + + public void Sort_Checked(object sender, RoutedEventArgs args) + { + if (sender is RadioButton { Tag: string sortField }) + ViewModel.SortingField = Enum.Parse(sortField); + } + + public void Order_Checked(object sender, RoutedEventArgs args) + { + if (sender is RadioButton { Tag: string sortOrder }) + ViewModel.SortingAscending = sortOrder is "Ascending"; + } + + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + foreach (var content in e.AddedItems) + { + if (content is XCITrimmerFileModel applicationData) + { + ViewModel.Select(applicationData); + } + } + + foreach (var content in e.RemovedItems) + { + if (content is XCITrimmerFileModel applicationData) + { + ViewModel.Deselect(applicationData); + } + } + } + } +}