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);
+ }
+ }
+ }
+ }
+}