Bladeren bron

Add ability to trim and untrim XCI files from the application context menu AND in Bulk (#105)

TheToid 1 jaar geleden
bovenliggende
commit
4831965404

+ 1 - 0
src/Ryujinx.Common/Logging/LogClass.cs

@@ -72,5 +72,6 @@ namespace Ryujinx.Common.Logging
         TamperMachine,
         UI,
         Vic,
+        XCIFileTrimmer
     }
 }

+ 30 - 0
src/Ryujinx.Common/Logging/XCIFileTrimmerLog.cs

@@ -0,0 +1,30 @@
+using Ryujinx.Common.Utilities;
+
+namespace Ryujinx.Common.Logging
+{
+    public class XCIFileTrimmerLog : XCIFileTrimmer.ILog
+    {
+        public virtual void Progress(long current, long total, string text, bool complete)
+        {
+        }
+
+        public void Write(XCIFileTrimmer.LogType logType, string text)
+        {
+            switch (logType)
+            {
+                case XCIFileTrimmer.LogType.Info:
+                    Logger.Notice.Print(LogClass.XCIFileTrimmer, text);
+                    break;
+                case XCIFileTrimmer.LogType.Warn:
+                    Logger.Warning?.Print(LogClass.XCIFileTrimmer, text);
+                    break;
+                case XCIFileTrimmer.LogType.Error:
+                    Logger.Error?.Print(LogClass.XCIFileTrimmer, text);
+                    break;
+                case XCIFileTrimmer.LogType.Progress:
+                    Logger.Info?.Print(LogClass.XCIFileTrimmer, text);
+                    break;
+            }
+        }
+    }
+}

+ 524 - 0
src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs

@@ -0,0 +1,524 @@
+// 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
+{
+    public sealed class XCIFileTrimmer
+    {
+        private const long BytesInAMegabyte = 1024 * 1024;
+        private const int BufferSize = 8 * (int)BytesInAMegabyte;
+
+        private const long CartSizeMBinFormattedGB = 952;
+        private const int CartKeyAreaSize = 0x1000;
+        private const byte PaddingByte = 0xFF;
+        private const int HeaderFilePos = 0x100;
+        private const int CartSizeFilePos = 0x10D;
+        private const int DataSizeFilePos = 0x118;
+        private const string HeaderMagicValue = "HEAD";
+
+        /// <summary>
+        /// Cartridge Sizes (ByteIdentifier, SizeInGB)
+        /// </summary>
+        private static readonly Dictionary<byte, long> _cartSizesGB = new()
+        {
+            { 0xFA, 1 },
+            { 0xF8, 2 },
+            { 0xF0, 4 },
+            { 0xE0, 8 },
+            { 0xE1, 16 },
+            { 0xE2, 32 }
+        };
+
+        private static long RecordsToByte(long records)
+        {
+            return 512 + (records * 512);
+        }
+
+        public static bool CanTrim(string filename, ILog log = null)
+        {
+            if (Path.GetExtension(filename).Equals(".XCI", StringComparison.InvariantCultureIgnoreCase))
+            {
+                var trimmer = new XCIFileTrimmer(filename, log);
+                return trimmer.CanBeTrimmed;
+            }
+
+            return false;
+        }
+
+        public static bool CanUntrim(string filename, ILog log = null)
+        {
+            if (Path.GetExtension(filename).Equals(".XCI", StringComparison.InvariantCultureIgnoreCase))
+            {
+                var trimmer = new XCIFileTrimmer(filename, log);
+                return trimmer.CanBeUntrimmed;
+            }
+
+            return false;
+        }
+
+        private ILog _log;
+        private string _filename;
+        private FileStream _fileStream;
+        private BinaryReader _binaryReader;
+        private long _offsetB, _dataSizeB, _cartSizeB, _fileSizeB;
+        private bool _fileOK = true;
+        private bool _freeSpaceChecked = false;
+        private bool _freeSpaceValid = false;
+
+        public enum OperationOutcome
+        {
+            Undetermined,
+            InvalidXCIFile,
+            NoTrimNecessary,
+            NoUntrimPossible,
+            FreeSpaceCheckFailed,
+            FileIOWriteError,
+            ReadOnlyFileCannotFix,
+            FileSizeChanged,
+            Successful,
+            Cancelled
+        }
+
+        public enum LogType
+        {
+            Info,
+            Warn,
+            Error,
+            Progress
+        }
+
+        public interface ILog
+        {
+            public void Write(LogType logType, string text);
+            public void Progress(long current, long total, string text, bool complete);
+        }
+
+        public bool FileOK => _fileOK;
+        public bool Trimmed => _fileOK && FileSizeB < UntrimmedFileSizeB;
+        public bool ContainsKeyArea => _offsetB != 0;
+        public bool CanBeTrimmed => _fileOK && FileSizeB > TrimmedFileSizeB;
+        public bool CanBeUntrimmed => _fileOK && FileSizeB < UntrimmedFileSizeB;
+        public bool FreeSpaceChecked => _fileOK && _freeSpaceChecked;
+        public bool FreeSpaceValid => _fileOK && _freeSpaceValid;
+        public long DataSizeB => _dataSizeB;
+        public long CartSizeB => _cartSizeB;
+        public long FileSizeB => _fileSizeB;
+        public long DiskSpaceSavedB => CartSizeB - FileSizeB;
+        public long DiskSpaceSavingsB => CartSizeB - DataSizeB;
+        public long TrimmedFileSizeB => _offsetB + _dataSizeB;
+        public long UntrimmedFileSizeB => _offsetB + _cartSizeB;
+
+        public ILog Log
+        {
+            get => _log;
+            set => _log = value;
+        }
+
+        public String Filename
+        {
+            get => _filename;
+            set
+            {
+                _filename = value;
+                Reset();
+            }
+        }
+
+        public long Pos
+        {
+            get => _fileStream.Position;
+            set => _fileStream.Position = value;
+        }
+
+        public XCIFileTrimmer(string path, ILog log = null)
+        {
+            Log = log;
+            Filename = path;
+            ReadHeader();
+        }
+
+        public void CheckFreeSpace(CancellationToken? cancelToken = null)
+        {
+            if (FreeSpaceChecked)
+                return;
+
+            try
+            {
+                if (CanBeTrimmed)
+                {
+                    _freeSpaceValid = false;
+
+                    OpenReaders();
+
+                    try
+                    {
+                        Pos = TrimmedFileSizeB;
+                        bool freeSpaceValid = true;
+                        long readSizeB = FileSizeB - TrimmedFileSizeB;
+
+                        Stopwatch timedSw = Lambda.Timed(() =>
+                        {
+                            freeSpaceValid = CheckPadding(readSizeB, cancelToken);
+                        });
+
+                        if (timedSw.Elapsed.TotalSeconds > 0)
+                        {
+                            Log?.Write(LogType.Info, $"Checked at {readSizeB / (double)XCIFileTrimmer.BytesInAMegabyte / timedSw.Elapsed.TotalSeconds:N} Mb/sec");
+                        }
+
+                        if (freeSpaceValid)
+                            Log?.Write(LogType.Info, "Free space is valid");
+
+                        _freeSpaceValid = freeSpaceValid;
+                    }
+                    finally
+                    {
+                        CloseReaders();
+                    }
+
+                }
+                else
+                {
+                    Log?.Write(LogType.Warn, "There is no free space to check.");
+                    _freeSpaceValid = false;
+                }
+            }
+            finally
+            {
+                _freeSpaceChecked = true;
+            }
+        }
+
+        private bool CheckPadding(long readSizeB, CancellationToken? cancelToken = null)
+        {
+            long maxReads = readSizeB / XCIFileTrimmer.BufferSize;
+            long read = 0;
+            var buffer = new byte[BufferSize];
+
+            while (true)
+            {
+                if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested) 
+                {
+                    return false;
+                }
+
+                int bytes = _fileStream.Read(buffer, 0, XCIFileTrimmer.BufferSize);
+                if (bytes == 0)
+                    break;
+
+                Log?.Progress(read, maxReads, "Verifying file can be trimmed", false);
+                if (buffer.Take(bytes).AsParallel().Any(b => b != XCIFileTrimmer.PaddingByte))
+                {
+                    Log?.Write(LogType.Warn, "Free space is NOT valid");
+                    return false;
+                }
+
+                read++;
+            }
+
+            return true;
+        }
+
+        private void Reset()
+        {
+            _freeSpaceChecked = false;
+            _freeSpaceValid = false;
+            ReadHeader();
+        }
+
+        public OperationOutcome Trim(CancellationToken? cancelToken = null)
+        {
+            if (!FileOK)
+            {
+                return OperationOutcome.InvalidXCIFile;
+            }
+
+            if (!CanBeTrimmed)
+            {
+                return OperationOutcome.NoTrimNecessary;
+            }
+
+            if (!FreeSpaceChecked)
+            {
+                CheckFreeSpace(cancelToken);
+            }
+
+            if (!FreeSpaceValid)
+            {
+                if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+                {
+                    return OperationOutcome.Cancelled;
+                }
+                else 
+                {
+                    return OperationOutcome.FreeSpaceCheckFailed;
+                }
+            }
+
+            Log?.Write(LogType.Info, "Trimming...");
+
+            try
+            {
+                var info = new FileInfo(Filename);
+                if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
+                {
+                    try
+                    {
+                        Log?.Write(LogType.Info, "Attempting to remove ReadOnly attribute");
+                        File.SetAttributes(Filename, info.Attributes & ~FileAttributes.ReadOnly);
+                    }
+                    catch (Exception e)
+                    {
+                        Log?.Write(LogType.Error, e.ToString());
+                        return OperationOutcome.ReadOnlyFileCannotFix;
+                    }
+                }
+
+                if (info.Length != FileSizeB)
+                {
+                    Log?.Write(LogType.Error, "File size has changed, cannot safely trim.");
+                    return OperationOutcome.FileSizeChanged;
+                }
+
+                var outfileStream = new FileStream(_filename, FileMode.Open, FileAccess.Write, FileShare.Write);
+
+                try
+                {
+
+#if !XCI_TRIMMER_READ_ONLY_MODE
+                        outfileStream.SetLength(TrimmedFileSizeB);
+#endif
+                    return OperationOutcome.Successful;
+                }
+                finally
+                {
+                    outfileStream.Close();
+                    Reset();
+                }
+            }
+            catch (Exception e)
+            {
+                Log?.Write(LogType.Error, e.ToString());
+                return OperationOutcome.FileIOWriteError;
+            }
+        }
+
+        public OperationOutcome Untrim(CancellationToken? cancelToken = null)
+        {
+            if (!FileOK)
+            {
+                return OperationOutcome.InvalidXCIFile;
+            }
+
+            if (!CanBeUntrimmed)
+            {
+                return OperationOutcome.NoUntrimPossible;
+            }
+
+            try
+            {
+                Log?.Write(LogType.Info, "Untrimming...");
+
+                var info = new FileInfo(Filename);
+                if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
+                {
+                    try
+                    {
+                        Log?.Write(LogType.Info, "Attempting to remove ReadOnly attribute");
+                        File.SetAttributes(Filename, info.Attributes & ~FileAttributes.ReadOnly);
+                    }
+                    catch (Exception e)
+                    {
+                        Log?.Write(LogType.Error, e.ToString());
+                        return OperationOutcome.ReadOnlyFileCannotFix;
+                    }
+                }
+
+                if (info.Length != FileSizeB)
+                {
+                    Log?.Write(LogType.Error, "File size has changed, cannot safely untrim.");
+                    return OperationOutcome.FileSizeChanged;
+                }
+
+                var outfileStream = new FileStream(_filename, FileMode.Append, FileAccess.Write, FileShare.Write);
+                long bytesToWriteB = UntrimmedFileSizeB - FileSizeB;
+
+                try
+                {
+                    Stopwatch timedSw = Lambda.Timed(() =>
+                    {
+                        WritePadding(outfileStream, bytesToWriteB, cancelToken);
+                    });
+
+                    if (timedSw.Elapsed.TotalSeconds > 0)
+                    {
+                        Log?.Write(LogType.Info, $"Wrote at {bytesToWriteB / (double)XCIFileTrimmer.BytesInAMegabyte / timedSw.Elapsed.TotalSeconds:N} Mb/sec");
+                    }
+
+                    if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+                    {
+                        return OperationOutcome.Cancelled;
+                    }
+                    else 
+                    {
+                        return OperationOutcome.Successful;
+                    }
+                }
+                finally
+                {
+                    outfileStream.Close();
+                    Reset();
+                }
+            }
+            catch (Exception e)
+            {
+                Log?.Write(LogType.Error, e.ToString());
+                return OperationOutcome.FileIOWriteError;
+            }
+        }
+
+        private void WritePadding(FileStream outfileStream, long bytesToWriteB, CancellationToken? cancelToken = null)
+        {
+            long bytesLeftToWriteB = bytesToWriteB;
+            long writes = bytesLeftToWriteB / XCIFileTrimmer.BufferSize;
+            int write = 0;
+
+            try
+            {
+                var buffer = new byte[BufferSize];
+                Array.Fill<byte>(buffer, XCIFileTrimmer.PaddingByte);
+
+                while (bytesLeftToWriteB > 0)
+                {
+                    if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+                    {
+                        return;
+                    }
+
+                    long bytesToWrite = Math.Min(XCIFileTrimmer.BufferSize, bytesLeftToWriteB);
+                    
+#if !XCI_TRIMMER_READ_ONLY_MODE
+                        outfileStream.Write(buffer, 0, (int)bytesToWrite);
+#endif
+
+                    bytesLeftToWriteB -= bytesToWrite;
+                    Log?.Progress(write, writes, "Writing padding data...", false);
+                    write++;
+                }
+            }
+            finally
+            {
+                Log?.Progress(write, writes, "Writing padding data...", true);
+            }
+        }
+
+        private void OpenReaders()
+        {
+            if (_binaryReader == null)
+            {
+                _fileStream = new FileStream(_filename, FileMode.Open, FileAccess.Read, FileShare.Read);
+                _binaryReader = new BinaryReader(_fileStream);
+            }
+        }
+
+        private void CloseReaders()
+        {
+            if (_binaryReader != null && _binaryReader.BaseStream != null)
+                _binaryReader.Close();
+            _binaryReader = null;
+            _fileStream = null;
+            GC.Collect();
+        }
+
+        private void ReadHeader()
+        {
+            try
+            {
+                OpenReaders();
+
+                try
+                {
+                    // Attempt without key area
+                    bool success = CheckAndReadHeader(false);
+
+                    if (!success)
+                    {
+                        // Attempt with key area
+                        success = CheckAndReadHeader(true);
+                    }
+
+                    _fileOK = success;
+                }
+                finally
+                {
+                    CloseReaders();
+                }
+            }
+            catch (Exception ex)
+            {
+                Log?.Write(LogType.Error, ex.Message);
+                _fileOK = false;
+                _dataSizeB = 0;
+                _cartSizeB = 0;
+                _fileSizeB = 0;
+                _offsetB = 0;
+            }
+        }
+
+        private bool CheckAndReadHeader(bool assumeKeyArea)
+        {
+            // Read file size
+            _fileSizeB = _fileStream.Length;
+            if (_fileSizeB < 32 * 1024)
+            {
+                Log?.Write(LogType.Error, "The source file doesn't look like an XCI file as the data size is too small");
+                return false;
+            }
+
+            // Setup offset
+            _offsetB = (long)(assumeKeyArea ? XCIFileTrimmer.CartKeyAreaSize : 0);
+
+            // Check header
+            Pos = _offsetB + XCIFileTrimmer.HeaderFilePos;
+            string head = System.Text.Encoding.ASCII.GetString(_binaryReader.ReadBytes(4));
+            if (head != XCIFileTrimmer.HeaderMagicValue)
+            {
+                if (!assumeKeyArea)
+                {
+                    Log?.Write(LogType.Warn, $"Incorrect header found, file mat contain a key area...");
+                }
+                else
+                {
+                    Log?.Write(LogType.Error, "The source file doesn't look like an XCI file as the header is corrupted");
+                }
+
+                return false;
+            }
+
+            // Read Cart Size
+            Pos = _offsetB + XCIFileTrimmer.CartSizeFilePos;
+            byte cartSizeId = _binaryReader.ReadByte();
+            if (!_cartSizesGB.TryGetValue(cartSizeId, out long cartSizeNGB))
+            {
+                Log?.Write(LogType.Error, $"The source file doesn't look like an XCI file as the Cartridge Size is incorrect (0x{cartSizeId:X2})");
+                return false;
+            }
+            _cartSizeB = cartSizeNGB * XCIFileTrimmer.CartSizeMBinFormattedGB * XCIFileTrimmer.BytesInAMegabyte;
+
+            // Read data size
+            Pos = _offsetB + XCIFileTrimmer.DataSizeFilePos;
+            long records = (long)BitConverter.ToUInt32(_binaryReader.ReadBytes(4), 0);
+            _dataSizeB = RecordsToByte(records);
+
+            return true;
+        }
+    }
+}

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

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

+ 40 - 0
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",
@@ -84,8 +85,11 @@
   "GameListContextMenuOpenModsDirectoryToolTip": "Opens the directory which contains Application's Mods",
   "GameListContextMenuOpenSdModsDirectory": "Open Atmosphere Mods Directory",
   "GameListContextMenuOpenSdModsDirectoryToolTip": "Opens the alternative SD card Atmosphere directory which contains Application's Mods. Useful for mods that are packaged for real hardware.",
+  "GameListContextMenuTrimXCI": "Check and Trim XCI File",
+  "GameListContextMenuTrimXCIToolTip": "Check and Trim XCI File to Save Disk Space",
   "StatusBarGamesLoaded": "{0}/{1} Games Loaded",
   "StatusBarSystemVersion": "System Version: {0}",
+  "StatusBarXCIFileTrimming": "Trimming XCI File '{0}'",
   "LinuxVmMaxMapCountDialogTitle": "Low limit for memory mappings detected",
   "LinuxVmMaxMapCountDialogTextPrimary": "Would you like to increase the value of vm.max_map_count to {0}",
   "LinuxVmMaxMapCountDialogTextSecondary": "Some games might try to create more memory mappings than currently allowed. Ryujinx will crash as soon as this limit gets exceeded.",
@@ -400,6 +404,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})",
@@ -468,6 +474,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}",
@@ -670,6 +677,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",
@@ -722,11 +735,37 @@
   "SelectDlcDialogTitle": "Select DLC files",
   "SelectUpdateDialogTitle": "Select update files",
   "SelectModDialogTitle": "Select mod directory",
+  "TrimXCIFileDialogTitle": "Check and Trim XCI File",
+  "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}]",
@@ -740,6 +779,7 @@
   "AutoloadUpdateRemovedMessage": "{0} missing update(s) removed",
   "ModWindowHeading": "{0} Mod(s)",
   "UserProfilesEditProfile": "Edit Selected",
+  "Continue": "Continue",
   "Cancel": "Cancel",
   "Save": "Save",
   "Discard": "Discard",

+ 13 - 1
src/Ryujinx/Assets/Styles/Styles.xaml

@@ -43,6 +43,10 @@
             </StackPanel>
         </Border>
     </Design.PreviewWith>
+    <Style Selector="DropDownButton">
+        <Setter Property="FontSize"
+                Value="12" />
+    </Style>
     <Style Selector="Border.small">
         <Setter Property="Width"
                 Value="100" />
@@ -231,6 +235,14 @@
         <Setter Property="MinWidth"
                 Value="80" />
     </Style>
+    <Style Selector="ProgressBar:horizontal">
+        <Setter Property="MinWidth" Value="0"/>
+        <Setter Property="MinHeight" Value="0"/>
+    </Style>
+    <Style Selector="ProgressBar:vertical">
+        <Setter Property="MinWidth" Value="0"/>
+        <Setter Property="MinHeight" Value="0"/>
+    </Style>
     <Style Selector="ProgressBar /template/ Border#ProgressBarTrack">
         <Setter Property="IsVisible"
                 Value="False" />
@@ -389,7 +401,7 @@
         <x:Double x:Key="ControlContentThemeFontSize">13</x:Double>
         <x:Double x:Key="MenuItemHeight">26</x:Double>
         <x:Double x:Key="TabItemMinHeight">28</x:Double>
-        <x:Double x:Key="ContentDialogMaxWidth">600</x:Double>
+        <x:Double x:Key="ContentDialogMaxWidth">700</x:Double>
         <x:Double x:Key="ContentDialogMaxHeight">756</x:Double>
     </Styles.Resources>
 </Styles>

+ 24 - 0
src/Ryujinx/Common/XCIFileTrimmerMainWindowLog.cs

@@ -0,0 +1,24 @@
+using Avalonia.Threading;
+using Ryujinx.Ava.UI.ViewModels;
+
+namespace Ryujinx.Ava.Common
+{
+    internal class XCIFileTrimmerMainWindowLog : Ryujinx.Common.Logging.XCIFileTrimmerLog
+    {
+        private readonly MainWindowViewModel _viewModel;
+
+        public XCIFileTrimmerMainWindowLog(MainWindowViewModel viewModel)
+        {
+            _viewModel = viewModel;
+        }
+
+        public override void Progress(long current, long total, string text, bool complete)
+        {
+            Dispatcher.UIThread.Post(() =>
+            {
+                _viewModel.StatusBarProgressMaximum = (int)(total);
+                _viewModel.StatusBarProgressValue = (int)(current);
+            });
+        }
+    }
+}

+ 23 - 0
src/Ryujinx/Common/XCIFileTrimmerWindowLog.cs

@@ -0,0 +1,23 @@
+using Avalonia.Threading;
+using Ryujinx.Ava.UI.ViewModels;
+
+namespace Ryujinx.Ava.Common
+{
+    internal class XCIFileTrimmerWindowLog : Ryujinx.Common.Logging.XCIFileTrimmerLog
+    {
+        private readonly XCITrimmerViewModel _viewModel;
+
+        public XCIFileTrimmerWindowLog(XCITrimmerViewModel viewModel)
+        {
+            _viewModel = viewModel;
+        }
+
+        public override void Progress(long current, long total, string text, bool complete)
+        {
+            Dispatcher.UIThread.Post(() =>
+            {
+                _viewModel.SetProgress((int)(current), (int)(total));
+            });
+        }
+    }
+}

+ 6 - 0
src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml

@@ -69,6 +69,12 @@
         Header="{ext:Locale GameListContextMenuOpenSdModsDirectory}"
         Icon="{ext:Icon mdi-folder-file}"
         ToolTip.Tip="{ext:Locale GameListContextMenuOpenSdModsDirectoryToolTip}" />
+    <Separator />
+	<MenuItem
+		Click="TrimXCI_Click"
+		Header="{ext:Locale GameListContextMenuTrimXCI}"
+        IsEnabled="{Binding TrimXCIEnabled}"
+		ToolTip.Tip="{ext:Locale GameListContextMenuTrimXCIToolTip}" />
     <Separator />
     <MenuItem Header="{ext:Locale GameListContextMenuCacheManagement}" Icon="{ext:Icon mdi-cached}">
         <MenuItem

+ 13 - 0
src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs

@@ -2,6 +2,7 @@ using Avalonia.Controls;
 using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
 using Avalonia.Platform.Storage;
+using Avalonia.Threading;
 using LibHac.Fs;
 using LibHac.Tools.FsSystem.NcaUtils;
 using Ryujinx.Ava.Common;
@@ -17,6 +18,8 @@ using SkiaSharp;
 using System;
 using System.Collections.Generic;
 using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
 using Path = System.IO.Path;
 
 namespace Ryujinx.Ava.UI.Controls
@@ -323,5 +326,15 @@ namespace Ryujinx.Ava.UI.Controls
             if (sender is MenuItem { DataContext: MainWindowViewModel { SelectedApplication: not null } viewModel })
                 await viewModel.LoadApplication(viewModel.SelectedApplication);
         }
+
+        public async void TrimXCI_Click(object sender, RoutedEventArgs args)
+        {
+            var viewModel = (sender as MenuItem)?.DataContext as MainWindowViewModel;
+
+            if (viewModel?.SelectedApplication != null)
+            {
+                await viewModel.TrimXCIFile(viewModel.SelectedApplication.Path);
+            }
+        }
     }
 }

+ 62 - 0
src/Ryujinx/UI/Helpers/AvaloniaListExtensions.cs

@@ -0,0 +1,62 @@
+using Avalonia.Collections;
+using System.Collections.Generic;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+    public static class AvaloniaListExtensions
+    {
+        /// <summary>
+        /// Adds or Replaces an item in an AvaloniaList irrespective of whether the item already exists
+        /// </summary>
+        /// <typeparam name="T">The type of the element in the AvaoloniaList</typeparam>
+        /// <param name="list">The list containing the item to replace</param>
+        /// <param name="item">The item to replace</param>
+        /// <param name="addIfNotFound">True to add the item if its not found</param>
+        /// <returns>True if the item was found and replaced, false if it was addded</returns>
+        /// <remarks>
+        /// The indexes on the AvaloniaList will only replace if the item does not match, 
+        /// this causes the items to not be replaced if the Equality is customised on the 
+        /// items. This method will instead find, remove and add the item to ensure it is
+        /// replaced correctly.
+        /// </remarks>
+        public static bool ReplaceWith<T>(this AvaloniaList<T> list, T item, bool addIfNotFound = true)
+        {
+            var index = list.IndexOf(item);
+
+            if (index != -1)
+            {
+                list.RemoveAt(index);
+                list.Insert(index, item);
+                return true;
+            }
+            else
+            {
+                list.Add(item);
+                return false;
+            }
+        }
+
+        /// <summary>
+        /// Adds or Replaces items in an AvaloniaList from another list irrespective of whether the item already exists
+        /// </summary>
+        /// <typeparam name="T">The type of the element in the AvaoloniaList</typeparam>
+        /// <param name="list">The list containing the item to replace</param>
+        /// <param name="sourceList">The list of items to be actually added to `list`</param>
+        /// <param name="matchingList">The items to use as matching records to search for in the `sourceList', if not found this item will be added instead</params>
+        public static void AddOrReplaceMatching<T>(this AvaloniaList<T> list, IList<T> sourceList, IList<T> matchingList)
+        {
+            foreach (var match in matchingList)
+            {
+                var index = sourceList.IndexOf(match);
+                if (index != -1)
+                {
+                    list.ReplaceWith(sourceList[index]);
+                }
+                else
+                {
+                    list.ReplaceWith(match);
+                }
+            }
+        }
+    }
+}

+ 48 - 0
src/Ryujinx/UI/Helpers/XCITrimmerFileSpaceSavingsConverter.cs

@@ -0,0 +1,48 @@
+using Avalonia;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.UI.Common.Models;
+using System;
+using System.Globalization;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+    internal class XCITrimmerFileSpaceSavingsConverter : IValueConverter
+    {
+        private const long _bytesPerMB = 1024 * 1024;
+        public static XCITrimmerFileSpaceSavingsConverter Instance = new();
+
+        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            if (value is UnsetValueType)
+            {
+                return BindingOperations.DoNothing;
+            }
+
+            if (!targetType.IsAssignableFrom(typeof(string)))
+            {
+                return null;
+            }
+
+            if (value is not XCITrimmerFileModel app)
+            {
+                return null;
+            }
+
+            if (app.CurrentSavingsB < app.PotentialSavingsB)
+            {
+                return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCICanSaveLabel, (app.PotentialSavingsB - app.CurrentSavingsB) / _bytesPerMB);
+            }
+            else
+            {
+                return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCISavingLabel, app.CurrentSavingsB / _bytesPerMB);
+            }
+        }
+
+        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            throw new NotSupportedException();
+        }
+    }
+}

+ 46 - 0
src/Ryujinx/UI/Helpers/XCITrimmerFileStatusConverter.cs

@@ -0,0 +1,46 @@
+using Avalonia;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.UI.Common.Models;
+using System;
+using System.Globalization;
+using static Ryujinx.Common.Utilities.XCIFileTrimmer;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+    internal class XCITrimmerFileStatusConverter : IValueConverter
+    {
+        public static XCITrimmerFileStatusConverter Instance = new();
+
+        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            if (value is UnsetValueType)
+            {
+                return BindingOperations.DoNothing;
+            }
+
+            if (!targetType.IsAssignableFrom(typeof(string)))
+            {
+                return null;
+            }
+
+            if (value is not XCITrimmerFileModel app)
+            {
+                return null;
+            }
+
+            return app.PercentageProgress != null ? String.Empty :
+                app.ProcessingOutcome != OperationOutcome.Successful && app.ProcessingOutcome != OperationOutcome.Undetermined ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusFailedLabel] :
+                app.Trimmable & app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusPartialLabel] :
+                app.Trimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusTrimmableLabel] :
+                app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusUntrimmableLabel] :
+                String.Empty;
+        }
+
+        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            throw new NotSupportedException();
+        }
+    }
+}

+ 42 - 0
src/Ryujinx/UI/Helpers/XCITrimmerFileStatusDetailConverter.cs

@@ -0,0 +1,42 @@
+using Avalonia;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Ryujinx.UI.Common.Models;
+using System;
+using System.Globalization;
+using static Ryujinx.Common.Utilities.XCIFileTrimmer;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+    internal class XCITrimmerFileStatusDetailConverter : IValueConverter
+    {
+        public static XCITrimmerFileStatusDetailConverter Instance = new();
+
+        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            if (value is UnsetValueType)
+            {
+                return BindingOperations.DoNothing;
+            }
+
+            if (!targetType.IsAssignableFrom(typeof(string)))
+            {
+                return null;
+            }
+
+            if (value is not XCITrimmerFileModel app)
+            {
+                return null;
+            }
+
+            return app.PercentageProgress != null ? null :
+                app.ProcessingOutcome != OperationOutcome.Successful && app.ProcessingOutcome != OperationOutcome.Undetermined ? app.ProcessingOutcome.ToLocalisedText() :
+                null;
+        }
+
+        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            throw new NotSupportedException();
+        }
+    }
+}

+ 36 - 0
src/Ryujinx/UI/Helpers/XCITrimmerOperationOutcomeHelper.cs

@@ -0,0 +1,36 @@
+using Ryujinx.Ava.Common.Locale;
+using static Ryujinx.Common.Utilities.XCIFileTrimmer;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+    public static class XCIFileTrimmerOperationOutcomeExtensions
+    {
+        public static string ToLocalisedText(this OperationOutcome operationOutcome)
+        {
+            switch (operationOutcome)
+            {
+                case OperationOutcome.NoTrimNecessary:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileNoTrimNecessary];
+                case OperationOutcome.NoUntrimPossible:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileNoUntrimPossible];
+                case OperationOutcome.ReadOnlyFileCannotFix:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileReadOnlyFileCannotFix];
+                case OperationOutcome.FreeSpaceCheckFailed:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileFreeSpaceCheckFailed];
+                case OperationOutcome.InvalidXCIFile:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileInvalidXCIFile];
+                case OperationOutcome.FileIOWriteError:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileFileIOWriteError];
+                case OperationOutcome.FileSizeChanged:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileFileSizeChanged];
+                case OperationOutcome.Cancelled:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileCancelled];
+                case OperationOutcome.Undetermined:
+                    return LocaleManager.Instance[LocaleKeys.TrimXCIFileFileUndertermined];
+                case OperationOutcome.Successful:
+                default:
+                    return null;
+            }
+        }
+    }
+}

+ 119 - 0
src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs

@@ -22,6 +22,7 @@ using Ryujinx.Ava.UI.Windows;
 using Ryujinx.Common;
 using Ryujinx.Common.Configuration;
 using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
 using Ryujinx.Cpu;
 using Ryujinx.HLE;
 using Ryujinx.HLE.FileSystem;
@@ -84,6 +85,8 @@ namespace Ryujinx.Ava.UI.ViewModels
         private bool _isAppletMenuActive;
         private int _statusBarProgressMaximum;
         private int _statusBarProgressValue;
+        private string _statusBarProgressStatusText;
+        private bool _statusBarProgressStatusVisible;
         private bool _isPaused;
         private bool _showContent = true;
         private bool _isLoadingIndeterminate = true;
@@ -391,6 +394,8 @@ namespace Ryujinx.Ava.UI.ViewModels
 
         public bool OpenDeviceSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0;
 
+        public bool TrimXCIEnabled => Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(SelectedApplication.Path, new Common.XCIFileTrimmerMainWindowLog(this));
+
         public bool OpenBcatSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;
 
         public bool CreateShortcutEnabled => !ReleaseInformation.IsFlatHubBuild;
@@ -505,6 +510,28 @@ namespace Ryujinx.Ava.UI.ViewModels
             }
         }
 
+        public bool StatusBarProgressStatusVisible
+        {
+            get => _statusBarProgressStatusVisible;
+            set
+            {
+                _statusBarProgressStatusVisible = value;
+
+                OnPropertyChanged();
+            }
+        }
+
+        public string StatusBarProgressStatusText
+        {
+            get => _statusBarProgressStatusText;
+            set
+            {
+                _statusBarProgressStatusText = value;
+
+                OnPropertyChanged();
+            }
+        }
+
         public string FifoStatusText
         {
             get => _fifoStatusText;
@@ -1834,6 +1861,98 @@ namespace Ryujinx.Ava.UI.ViewModels
                 }
             }
         }
+
+        public async void ProcessTrimResult(String filename, Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome operationOutcome)
+        {
+            string notifyUser = operationOutcome.ToLocalisedText();
+
+            if (notifyUser != null)
+            {
+                await ContentDialogHelper.CreateWarningDialog(
+                    LocaleManager.Instance[LocaleKeys.TrimXCIFileFailedPrimaryText],
+                    notifyUser
+                );
+            }
+            else
+            {
+                switch (operationOutcome)
+                {
+                    case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.Successful:
+                        if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+                        {
+                            if (desktop.MainWindow is MainWindow mainWindow)
+                                mainWindow.LoadApplications();
+                        }
+                        break;
+                }
+            }
+        }
+
+        public async Task TrimXCIFile(string filename)
+        {
+            if (filename == null)
+            {
+                return;
+            }
+
+            var trimmer = new XCIFileTrimmer(filename, new Common.XCIFileTrimmerMainWindowLog(this));
+
+            if (trimmer.CanBeTrimmed)
+            {
+                var savings = (double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0;
+                var currentFileSize = (double)trimmer.FileSizeB / 1024.0 / 1024.0;
+                var cartDataSize = (double)trimmer.DataSizeB / 1024.0 / 1024.0;
+                string secondaryText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TrimXCIFileDialogSecondaryText, currentFileSize, cartDataSize, savings);
+
+                var result = await ContentDialogHelper.CreateConfirmationDialog(
+                    LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogPrimaryText],
+                    secondaryText,
+                    LocaleManager.Instance[LocaleKeys.Continue],
+                    LocaleManager.Instance[LocaleKeys.Cancel],
+                    LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogTitle]
+                );
+
+                if (result == UserResult.Yes)
+                {
+                    Thread XCIFileTrimThread = new(() =>
+                    {
+                        Dispatcher.UIThread.Post(() =>
+                        {
+                            StatusBarProgressStatusText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarXCIFileTrimming, Path.GetFileName(filename));
+                            StatusBarProgressStatusVisible = true;
+                            StatusBarProgressMaximum = 1;
+                            StatusBarProgressValue = 0;
+                            StatusBarVisible = true;
+                        });
+
+                        try
+                        {
+                            XCIFileTrimmer.OperationOutcome operationOutcome = trimmer.Trim();
+
+                            Dispatcher.UIThread.Post(() =>
+                            {
+                                ProcessTrimResult(filename, operationOutcome);
+                            });
+                        }
+                        finally
+                        {
+                            Dispatcher.UIThread.Post(() =>
+                            {
+                                StatusBarProgressStatusVisible = false;
+                                StatusBarProgressStatusText = string.Empty;
+                                StatusBarVisible = false;
+                            });
+                        }
+                    })
+                    {
+                        Name = "GUI.XCIFileTrimmerThread",
+                        IsBackground = true,
+                    };
+                    XCIFileTrimThread.Start();
+                }
+            }
+        }
+
         #endregion
     }
 }

+ 541 - 0
src/Ryujinx/UI/ViewModels/XCITrimmerViewModel.cs

@@ -0,0 +1,541 @@
+using Avalonia.Collections;
+using DynamicData;
+using Gommon;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Common.Utilities;
+using Ryujinx.UI.App.Common;
+using Ryujinx.UI.Common.Models;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using static Ryujinx.Common.Utilities.XCIFileTrimmer;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+    public class XCITrimmerViewModel : BaseModel
+    {
+        private const long _bytesPerMB = 1024 * 1024;
+        private enum ProcessingMode
+        {
+            Trimming,
+            Untrimming
+        }
+
+        public enum SortField
+        {
+            Name,
+            Saved
+        }
+
+        private const string _FileExtXCI = "XCI";
+
+        private readonly Ryujinx.Common.Logging.XCIFileTrimmerLog _logger;
+        private readonly ApplicationLibrary _applicationLibrary;
+        private Optional<XCITrimmerFileModel> _processingApplication = null;
+        private AvaloniaList<XCITrimmerFileModel> _allXCIFiles = new();
+        private AvaloniaList<XCITrimmerFileModel> _selectedXCIFiles = new();
+        private AvaloniaList<XCITrimmerFileModel> _displayedXCIFiles = new();
+        private MainWindowViewModel _mainWindowViewModel;
+        private CancellationTokenSource _cancellationTokenSource;
+        private string _search;
+        private ProcessingMode _processingMode;
+        private SortField _sortField = SortField.Name;
+        private bool _sortAscending = true;
+
+        public XCITrimmerViewModel(MainWindowViewModel mainWindowViewModel)
+        {
+            _logger = new XCIFileTrimmerWindowLog(this);
+            _mainWindowViewModel = mainWindowViewModel;
+            _applicationLibrary = _mainWindowViewModel.ApplicationLibrary;
+            LoadXCIApplications();
+        }
+
+        private void LoadXCIApplications()
+        {
+            var apps = _applicationLibrary.Applications.Items
+                .Where(app => app.FileExtension == _FileExtXCI);
+
+            foreach (var xciApp in apps)
+                AddOrUpdateXCITrimmerFile(CreateXCITrimmerFile(xciApp.Path));
+
+            ApplicationsChanged();
+        }
+
+        private XCITrimmerFileModel CreateXCITrimmerFile(
+            string path,
+            OperationOutcome operationOutcome = OperationOutcome.Undetermined)
+        {
+            var xciApp = _applicationLibrary.Applications.Items.First(app => app.FileExtension == _FileExtXCI && app.Path == path);
+            return XCITrimmerFileModel.FromApplicationData(xciApp, _logger) with { ProcessingOutcome = operationOutcome };
+        }
+
+        private bool AddOrUpdateXCITrimmerFile(XCITrimmerFileModel xci, bool suppressChanged = true, bool autoSelect = true)
+        {
+            bool replaced = _allXCIFiles.ReplaceWith(xci);
+            _displayedXCIFiles.ReplaceWith(xci, Filter(xci));
+            _selectedXCIFiles.ReplaceWith(xci, xci.Trimmable && autoSelect);
+
+            if (!suppressChanged)
+                ApplicationsChanged();
+
+            return replaced;
+        }
+
+        private void FilteringChanged()
+        {
+            OnPropertyChanged(nameof(Search));
+            SortAndFilter();
+        }
+
+        private void SortingChanged()
+        {
+            OnPropertyChanged(nameof(IsSortedByName));
+            OnPropertyChanged(nameof(IsSortedBySaved));
+            OnPropertyChanged(nameof(SortingAscending));
+            OnPropertyChanged(nameof(SortingField));
+            OnPropertyChanged(nameof(SortingFieldName));
+            SortAndFilter();
+        }
+
+        private void DisplayedChanged()
+        {
+            OnPropertyChanged(nameof(Status));
+            OnPropertyChanged(nameof(DisplayedXCIFiles));
+            OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
+        }
+
+        private void ApplicationsChanged()
+        {
+            OnPropertyChanged(nameof(AllXCIFiles));
+            OnPropertyChanged(nameof(Status));
+            OnPropertyChanged(nameof(PotentialSavings));
+            OnPropertyChanged(nameof(ActualSavings));
+            OnPropertyChanged(nameof(CanTrim));
+            OnPropertyChanged(nameof(CanUntrim));
+            DisplayedChanged();
+            SortAndFilter();
+        }
+
+        private void SelectionChanged(bool displayedChanged = true)
+        {
+            OnPropertyChanged(nameof(Status));
+            OnPropertyChanged(nameof(CanTrim));
+            OnPropertyChanged(nameof(CanUntrim));
+            OnPropertyChanged(nameof(SelectedXCIFiles));
+
+            if (displayedChanged)
+                OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
+        }
+
+        private void ProcessingChanged()
+        {
+            OnPropertyChanged(nameof(Processing));
+            OnPropertyChanged(nameof(Cancel));
+            OnPropertyChanged(nameof(Status));
+            OnPropertyChanged(nameof(CanTrim));
+            OnPropertyChanged(nameof(CanUntrim));
+        }
+
+        private IEnumerable<XCITrimmerFileModel> GetSelectedDisplayedXCIFiles()
+        {
+            return _displayedXCIFiles.Where(xci => _selectedXCIFiles.Contains(xci));
+        }
+
+        private void PerformOperation(ProcessingMode processingMode)
+        {
+            if (Processing)
+            {
+                return;
+            }
+
+            _processingMode = processingMode;
+            Processing = true;
+            var cancellationToken = _cancellationTokenSource.Token;
+
+            Thread XCIFileTrimThread = new(() =>
+            {
+                var toProcess = Sort(SelectedXCIFiles
+                    .Where(xci =>
+                        (processingMode == ProcessingMode.Untrimming && xci.Untrimmable) ||
+                        (processingMode == ProcessingMode.Trimming && xci.Trimmable)
+                    )).ToList();
+
+                var viewsSaved = DisplayedXCIFiles.ToList();
+
+                Dispatcher.UIThread.Post(() =>
+                {
+                    _selectedXCIFiles.Clear();
+                    _displayedXCIFiles.Clear();
+                    _displayedXCIFiles.AddRange(toProcess);
+                });
+
+                try
+                {
+                    foreach (var xciApp in toProcess)
+                    {
+                        if (cancellationToken.IsCancellationRequested)
+                            break;
+
+                        var trimmer = new XCIFileTrimmer(xciApp.Path, _logger);
+
+                        Dispatcher.UIThread.Post(() =>
+                        {
+                            ProcessingApplication = xciApp;
+                        });
+
+                        var outcome = OperationOutcome.Undetermined;
+
+                        try
+                        {
+                            if (cancellationToken.IsCancellationRequested)
+                                break;
+
+                            switch (processingMode)
+                            {
+                                case ProcessingMode.Trimming:
+                                    outcome = trimmer.Trim(cancellationToken);
+                                    break;
+                                case ProcessingMode.Untrimming:
+                                    outcome = trimmer.Untrim(cancellationToken);
+                                    break;
+                            }
+
+                            if (outcome == OperationOutcome.Cancelled)
+                                outcome = OperationOutcome.Undetermined;
+                        }
+                        finally
+                        {
+                            Dispatcher.UIThread.Post(() =>
+                            {
+                                ProcessingApplication = CreateXCITrimmerFile(xciApp.Path);
+                                AddOrUpdateXCITrimmerFile(ProcessingApplication, false, false);
+                                ProcessingApplication = null;
+                            });
+                        }
+                    }
+                }
+                finally
+                {
+                    Dispatcher.UIThread.Post(() =>
+                    {
+                        _displayedXCIFiles.AddOrReplaceMatching(_allXCIFiles, viewsSaved);
+                        _selectedXCIFiles.AddOrReplaceMatching(_allXCIFiles, toProcess);
+                        Processing = false;
+                        ApplicationsChanged();
+                    });
+                }
+            })
+            {
+                Name = "GUI.XCIFilesTrimmerThread",
+                IsBackground = true,
+            };
+
+            XCIFileTrimThread.Start();
+        }
+
+        private bool Filter<T>(T arg)
+        {
+            if (arg is XCITrimmerFileModel content)
+            {
+                return string.IsNullOrWhiteSpace(_search)
+                    || content.Name.ToLower().Contains(_search.ToLower())
+                    || content.Path.ToLower().Contains(_search.ToLower());
+            }
+
+            return false;
+        }
+
+        private class CompareXCITrimmerFiles : IComparer<XCITrimmerFileModel>
+        {
+            private XCITrimmerViewModel _viewModel;
+
+            public CompareXCITrimmerFiles(XCITrimmerViewModel ViewModel)
+            {
+                _viewModel = ViewModel;
+            }
+
+            public int Compare(XCITrimmerFileModel x, XCITrimmerFileModel y)
+            {
+                int result = 0;
+
+                switch (_viewModel.SortingField)
+                {
+                    case SortField.Name:
+                        result = x.Name.CompareTo(y.Name);
+                        break;
+                    case SortField.Saved:
+                        result = x.PotentialSavingsB.CompareTo(y.PotentialSavingsB);
+                        break;
+                }
+
+                if (!_viewModel.SortingAscending)
+                    result = -result;
+
+                if (result == 0)
+                    result = x.Path.CompareTo(y.Path);
+
+                return result;
+            }
+        }
+
+        private IOrderedEnumerable<XCITrimmerFileModel> Sort(IEnumerable<XCITrimmerFileModel> list)
+        {
+            return list
+                .OrderBy(xci => xci, new CompareXCITrimmerFiles(this))
+                .ThenBy(it => it.Path);
+        }
+
+        public void TrimSelected()
+        {
+            PerformOperation(ProcessingMode.Trimming);
+        }
+
+        public void UntrimSelected()
+        {
+            PerformOperation(ProcessingMode.Untrimming);
+        }
+
+        public void SetProgress(int current, int maximum)
+        {
+            if (_processingApplication != null)
+            {
+                int percentageProgress = 100 * current / maximum;
+                if (!ProcessingApplication.HasValue || (ProcessingApplication.Value.PercentageProgress != percentageProgress))
+                    ProcessingApplication = ProcessingApplication.Value with { PercentageProgress = percentageProgress };
+            }
+        }
+
+        public void SelectDisplayed()
+        {
+            SelectedXCIFiles.AddRange(DisplayedXCIFiles);
+            SelectionChanged();
+        }
+
+        public void DeselectDisplayed()
+        {
+            SelectedXCIFiles.RemoveMany(DisplayedXCIFiles);
+            SelectionChanged();
+        }
+
+        public void Select(XCITrimmerFileModel model)
+        {
+            bool selectionChanged = !SelectedXCIFiles.Contains(model);
+            bool displayedSelectionChanged = !SelectedDisplayedXCIFiles.Contains(model);
+            SelectedXCIFiles.ReplaceOrAdd(model, model);
+            if (selectionChanged)
+                SelectionChanged(displayedSelectionChanged);
+        }
+
+        public void Deselect(XCITrimmerFileModel model)
+        {
+            bool displayedSelectionChanged = !SelectedDisplayedXCIFiles.Contains(model);
+            if (SelectedXCIFiles.Remove(model))
+                SelectionChanged(displayedSelectionChanged);
+        }
+
+        public void SortAndFilter()
+        {
+            if (Processing)
+                return;
+
+            Sort(AllXCIFiles)
+                .AsObservableChangeSet()
+                .Filter(Filter)
+                .Bind(out var view).AsObservableList();
+
+            _displayedXCIFiles.Clear();
+            _displayedXCIFiles.AddRange(view);
+
+            DisplayedChanged();
+        }
+
+        public Optional<XCITrimmerFileModel> ProcessingApplication
+        {
+            get => _processingApplication;
+            set
+            {
+                if (!value.HasValue && _processingApplication.HasValue)
+                    value = _processingApplication.Value with { PercentageProgress = null };
+
+                if (value.HasValue)
+                    _displayedXCIFiles.ReplaceWith(value.Value);
+
+                _processingApplication = value;
+                OnPropertyChanged();
+            }
+        }
+
+        public bool Processing
+        {
+            get => _cancellationTokenSource != null;
+            private set
+            {
+                if (value && !Processing)
+                {
+                    _cancellationTokenSource = new CancellationTokenSource();
+                }
+                else if (!value && Processing)
+                {
+                    _cancellationTokenSource.Dispose();
+                    _cancellationTokenSource = null;
+                }
+
+                ProcessingChanged();
+            }
+        }
+
+        public bool Cancel
+        {
+            get => _cancellationTokenSource != null && _cancellationTokenSource.IsCancellationRequested;
+            set
+            {
+                if (value)
+                {
+                    if (!Processing)
+                        return;
+
+                    _cancellationTokenSource.Cancel();
+                }
+
+                ProcessingChanged();
+            }
+        }
+
+        public string Status
+        {
+            get
+            {
+                if (Processing)
+                {
+                    return _processingMode switch
+                    {
+                        ProcessingMode.Trimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusTrimming], DisplayedXCIFiles.Count),
+                        ProcessingMode.Untrimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusUntrimming], DisplayedXCIFiles.Count),
+                        _ => string.Empty
+                    };
+                }
+                else
+                {
+                    return string.IsNullOrEmpty(Search) ?
+                        string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCount], SelectedXCIFiles.Count, AllXCIFiles.Count) :
+                        string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCountWithFilter], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
+                }
+            }
+        }
+
+        public string Search
+        {
+            get => _search;
+            set
+            {
+                _search = value;
+                FilteringChanged();
+            }
+        }
+
+        public SortField SortingField
+        {
+            get => _sortField;
+            set
+            {
+                _sortField = value;
+                SortingChanged();
+            }
+        }
+
+        public string SortingFieldName
+        {
+            get
+            {
+                return SortingField switch
+                {
+                    SortField.Name => LocaleManager.Instance[LocaleKeys.XCITrimmerSortName],
+                    SortField.Saved => LocaleManager.Instance[LocaleKeys.XCITrimmerSortSaved],
+                    _ => string.Empty,
+                };
+            }
+        }
+        public bool SortingAscending
+        {
+            get => _sortAscending;
+            set
+            {
+                _sortAscending = value;
+                SortingChanged();
+            }
+        }
+
+        public bool IsSortedByName
+        {
+            get => _sortField == SortField.Name;
+        }
+
+        public bool IsSortedBySaved
+        {
+            get => _sortField == SortField.Saved;
+        }
+
+        public AvaloniaList<XCITrimmerFileModel> SelectedXCIFiles
+        {
+            get => _selectedXCIFiles;
+            set
+            {
+                _selectedXCIFiles = value;
+                SelectionChanged();
+            }
+        }
+
+        public AvaloniaList<XCITrimmerFileModel> AllXCIFiles
+        {
+            get => _allXCIFiles;
+        }
+
+        public AvaloniaList<XCITrimmerFileModel> DisplayedXCIFiles
+        {
+            get => _displayedXCIFiles;
+        }
+
+        public string PotentialSavings
+        {
+            get
+            {
+                return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / _bytesPerMB));
+            }
+        }
+
+        public string ActualSavings
+        {
+            get
+            {
+                return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / _bytesPerMB));
+            }
+        }
+
+        public IEnumerable<XCITrimmerFileModel> SelectedDisplayedXCIFiles
+        {
+            get
+            {
+                return GetSelectedDisplayedXCIFiles().ToList();
+            }
+        }
+
+        public bool CanTrim
+        {
+            get
+            {
+                return !Processing && _selectedXCIFiles.Any(xci => xci.Trimmable);
+            }
+        }
+
+        public bool CanUntrim
+        {
+            get
+            {
+                return !Processing && _selectedXCIFiles.Any(xci => xci.Untrimmable);
+            }
+        }
+    }
+}

+ 2 - 0
src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml

@@ -268,6 +268,8 @@
                     <MenuItem Header="{ext:Locale MenuBarToolsInstallFileTypes}" Click="InstallFileTypes_Click"/>
                     <MenuItem Header="{ext:Locale MenuBarToolsUninstallFileTypes}" Click="UninstallFileTypes_Click"/>
                 </MenuItem>
+                <Separator />
+                <MenuItem Header="{ext:Locale MenuBarToolsXCITrimmer}" Click="OpenXCITrimmerWindow" Icon="{ext:Icon fa-solid fa-scissors}" />
             </MenuItem>
             <MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarView}">
                 <MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarViewWindow}">

+ 2 - 0
src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs

@@ -203,6 +203,8 @@ namespace Ryujinx.Ava.UI.Views.Main
                 await Updater.BeginParse(Window, true);
         }
 
+        public async void OpenXCITrimmerWindow(object sender, RoutedEventArgs e) => await XCITrimmerWindow.Show(ViewModel);
+
         public async void OpenAboutWindow(object sender, RoutedEventArgs e) => await AboutWindow.Show();
 
         public void CloseWindow(object sender, RoutedEventArgs e) => Window.Close();

+ 11 - 2
src/Ryujinx/UI/Views/Main/MainStatusBarView.axaml

@@ -29,7 +29,7 @@
             Margin="5"
             VerticalAlignment="Center"
             IsVisible="{Binding EnableNonGameRunningControls}">
-            <Grid Margin="0" ColumnDefinitions="Auto,Auto,*">
+            <Grid Margin="0" ColumnDefinitions="Auto,Auto,Auto,*">
                 <Button
                     Width="25"
                     Height="25"
@@ -50,9 +50,18 @@
                     VerticalAlignment="Center"
                     IsVisible="{Binding EnableNonGameRunningControls}"
                     Text="{ext:Locale StatusBarGamesLoaded}" />
+                <TextBlock
+                    Name="StatusBarProgressStatus"
+                    Grid.Column="2"
+                    MinWidth="200"
+                    Margin="10,0,5,0"
+                    VerticalAlignment="Center"
+                    IsVisible="{Binding StatusBarProgressStatusVisible}"
+                    Text="{Binding StatusBarProgressStatusText}" />
                 <ProgressBar
                     Name="LoadProgressBar"
-                    Grid.Column="2"
+                    Grid.Column="3"
+                    MinWidth="200"
                     Height="6"
                     VerticalAlignment="Center"
                     Foreground="{DynamicResource SystemAccentColorLight2}"

+ 354 - 0
src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml

@@ -0,0 +1,354 @@
+<UserControl
+    x:Class="Ryujinx.Ava.UI.Windows.XCITrimmerWindow"
+    xmlns="https://github.com/avaloniaui"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+    xmlns:models="clr-namespace:Ryujinx.UI.Common.Models;assembly=Ryujinx.UI.Common"
+    xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+    xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+    Width="700"
+    Height="600"
+    x:DataType="viewModels:XCITrimmerViewModel"
+    Focusable="True"
+    mc:Ignorable="d">    
+    <UserControl.Resources>
+        <helpers:XCITrimmerFileStatusConverter x:Key="StatusLabel" />
+        <helpers:XCITrimmerFileStatusDetailConverter x:Key="StatusDetailLabel" />
+        <helpers:XCITrimmerFileSpaceSavingsConverter x:Key="SpaceSavingsLabel" />
+    </UserControl.Resources>
+    <Grid Margin="20 0 20 0">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="*" />
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="Auto" />
+        </Grid.RowDefinitions>
+        <Panel
+            Margin="10 10 10 10"
+            Grid.Row="0">
+            <TextBlock Text="{Binding Status}" />
+        </Panel>
+        <Panel
+            Margin="0 0 10 10"
+            IsVisible="{Binding !Processing}"
+            Grid.Row="1">
+            <Grid>
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="Auto" />
+                    <ColumnDefinition Width="*" />
+                    <ColumnDefinition Width="Auto" />
+                </Grid.ColumnDefinitions>
+                <StackPanel 
+                    Grid.Column="0"
+                    Orientation="Horizontal">
+                    <TextBlock
+                        Margin="10,0"
+                        HorizontalAlignment="Left"
+                        VerticalAlignment="Center"
+                        Text="{ext:Locale CommonSort}" />
+                    <DropDownButton
+                        Width="150"
+                        HorizontalAlignment="Left"
+                        VerticalAlignment="Center"
+                        Content="{Binding SortingFieldName}">
+                        <DropDownButton.Flyout>
+                            <Flyout Placement="Bottom">
+                                <StackPanel
+                                    Margin="0"
+                                    HorizontalAlignment="Stretch"
+                                    Orientation="Vertical">
+                                    <StackPanel>
+                                        <RadioButton
+                                            Checked="Sort_Checked"
+                                            Content="{ext:Locale XCITrimmerSortName}"
+                                            GroupName="Sort"
+                                            IsChecked="{Binding IsSortedByName, Mode=OneTime}"
+                                            Tag="Name" />
+                                        <RadioButton
+                                            Checked="Sort_Checked"
+                                            Content="{ext:Locale XCITrimmerSortSaved}"
+                                            GroupName="Sort"
+                                            IsChecked="{Binding IsSortedBySaved, Mode=OneTime}"
+                                            Tag="Saved" />
+                                    </StackPanel>
+                                    <Border
+                                        Width="60"
+                                        Height="2"
+                                        Margin="5"
+                                        HorizontalAlignment="Stretch"
+                                        BorderBrush="White"
+                                        BorderThickness="0,1,0,0">
+                                        <Separator Height="0" HorizontalAlignment="Stretch" />
+                                    </Border>
+                                    <RadioButton
+                                        Checked="Order_Checked"
+                                        Content="{ext:Locale OrderAscending}"
+                                        GroupName="Order"
+                                        IsChecked="{Binding SortingAscending, Mode=OneTime}"
+                                        Tag="Ascending" />
+                                    <RadioButton
+                                        Checked="Order_Checked"
+                                        Content="{ext:Locale OrderDescending}"
+                                        GroupName="Order"
+                                        IsChecked="{Binding !SortingAscending, Mode=OneTime}"
+                                        Tag="Descending" />
+                                </StackPanel>
+                            </Flyout>
+                        </DropDownButton.Flyout>
+                    </DropDownButton>
+                </StackPanel>
+                <TextBox
+                    Grid.Column="1"
+                    MinHeight="29"
+                    MaxHeight="29"
+                    Margin="5 0 5 0"
+                    HorizontalAlignment="Stretch"
+                    Watermark="{ext:Locale Search}"
+                    Text="{Binding Search}" />
+                <StackPanel
+                    Grid.Column="2"
+                    Orientation="Horizontal">
+                    <Button
+                        Name="SelectDisplayedButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Command="{Binding SelectDisplayed}">
+                        <TextBlock Text="{ext:Locale XCITrimmerSelectDisplayed}" />
+                    </Button>
+                    <Button
+                        Name="DeselectDisplayedButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Command="{Binding DeselectDisplayed}">
+                        <TextBlock Text="{ext:Locale XCITrimmerDeselectDisplayed}" />
+                    </Button>
+                </StackPanel>
+            </Grid>
+        </Panel>
+        <Border
+            Grid.Row="2"
+            Margin="0 0 0 10"
+            HorizontalAlignment="Stretch"
+            VerticalAlignment="Stretch"
+            BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+            BorderThickness="1"
+            CornerRadius="5"
+            Padding="2.5">
+            <ListBox
+                AutoScrollToSelectedItem="{Binding Processing}"
+                SelectedItem="{Binding ProcessingApplication.Value}"
+                SelectionMode="Multiple, Toggle"
+                Background="Transparent"
+                SelectionChanged="OnSelectionChanged"
+                SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
+                ItemsSource="{Binding DisplayedXCIFiles}"
+                IsEnabled="{Binding !Processing}">
+                <ListBox.DataTemplates>
+                    <DataTemplate
+                        DataType="models:XCITrimmerFileModel">
+                        <Panel Margin="10">
+                            <Grid>
+                                <Grid.ColumnDefinitions>
+                                    <ColumnDefinition Width="65*" />
+                                    <ColumnDefinition Width="35*" />
+                                </Grid.ColumnDefinitions>
+                                <TextBlock
+                                    Grid.Column="0"
+                                    Margin="10 0 10 0"
+                                    HorizontalAlignment="Left"
+                                    VerticalAlignment="Center"
+                                    MaxLines="2"
+                                    TextWrapping="Wrap"
+                                    TextTrimming="CharacterEllipsis"
+                                    Text="{Binding Name}">
+                                </TextBlock>
+                                <Grid Grid.Column="1">
+                                    <Grid.ColumnDefinitions>
+                                        <ColumnDefinition Width="45*" />
+                                        <ColumnDefinition Width="55*" />
+                                    </Grid.ColumnDefinitions>
+                                    <ProgressBar
+                                        Height="10"
+                                        Margin="10 0 10 0"
+                                        HorizontalAlignment="Stretch"
+                                        VerticalAlignment="Center"
+                                        CornerRadius="5"
+                                        IsVisible="{Binding $parent[UserControl].((viewModels:XCITrimmerViewModel)DataContext).Processing}"
+                                        Maximum="100"
+                                        Minimum="0"
+                                        Value="{Binding PercentageProgress}" />
+                                    <TextBlock
+                                        Grid.Column="0"
+                                        Margin="10 0 10 0"
+                                        HorizontalAlignment="Left"
+                                        VerticalAlignment="Center"
+                                        MaxLines="1"
+                                        Text="{Binding ., Converter={StaticResource StatusLabel}}">
+                                        <ToolTip.Tip>
+                                            <StackPanel
+                                                IsVisible="{Binding IsFailed}">
+                                                <TextBlock 
+                                                    Classes="h1" 
+                                                    Text="{ext:Locale XCITrimmerTitleStatusFailed}" />
+                                                <TextBlock 
+                                                    Text="{Binding ., Converter={StaticResource StatusDetailLabel}}" 
+                                                    MaxLines="5"
+                                                    MaxWidth="200"
+                                                    MaxHeight="100"
+                                                    TextTrimming="None"
+                                                    TextWrapping="Wrap"/>
+                                            </StackPanel>
+                                        </ToolTip.Tip>
+                                    </TextBlock>
+                                    <TextBlock
+                                        Grid.Column="1"
+                                        Margin="10 0 10 0"
+                                        HorizontalAlignment="Left"
+                                        VerticalAlignment="Center"
+                                        MaxLines="1"
+                                        Text="{Binding ., Converter={StaticResource SpaceSavingsLabel}}">>
+                                    </TextBlock>
+                                </Grid>
+                            </Grid>
+                        </Panel>
+                    </DataTemplate>
+                </ListBox.DataTemplates>
+                <ListBox.Styles>
+                    <Style Selector="ListBoxItem">
+                        <Setter Property="Background" Value="Transparent" />
+                    </Style>
+                </ListBox.Styles>
+            </ListBox>
+        </Border>
+        <Border
+            Grid.Row="3"
+            Margin="0 0 0 10"
+            HorizontalAlignment="Stretch"
+            BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+            BorderThickness="1"
+            CornerRadius="5"
+            Padding="2.5">
+            <Grid>
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="Auto" />
+                    <ColumnDefinition Width="*" />
+                </Grid.ColumnDefinitions>
+                <Grid.RowDefinitions>
+                    <RowDefinition Height="Auto" />
+                    <RowDefinition Height="Auto" />
+                </Grid.RowDefinitions>
+                <TextBlock
+                    Grid.Column="0"
+                    Grid.Row="0"
+                    Classes="h1"
+                    Margin="5"
+                    HorizontalAlignment="Right"
+                    VerticalAlignment="Center"
+                    MaxLines="1"
+                    Text="{ext:Locale XCITrimmerPotentialSavings}" />
+                <TextBlock
+                    Grid.Column="0"
+                    Grid.Row="1"
+                    Classes="h1"
+                    Margin="5"
+                    HorizontalAlignment="Right"
+                    VerticalAlignment="Center"
+                    MaxLines="1"
+                    Text="{ext:Locale XCITrimmerActualSavings}" />
+                <TextBlock
+                    Grid.Column="1"
+                    Grid.Row="0"
+                    Margin="5"
+                    HorizontalAlignment="Left"
+                    VerticalAlignment="Center"
+                    MaxLines="1"
+                    Text="{Binding PotentialSavings}" />
+                <TextBlock
+                    Grid.Column="1"
+                    Grid.Row="1"
+                    Margin="5"
+                    HorizontalAlignment="Left"
+                    VerticalAlignment="Center"
+                    MaxLines="1"
+                    Text="{Binding ActualSavings}" />
+            </Grid>
+        </Border>
+        <Panel
+            Grid.Row="4"
+            HorizontalAlignment="Stretch">
+            <Grid>
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="*" />
+                    <ColumnDefinition Width="Auto" />
+                </Grid.ColumnDefinitions>
+                <StackPanel
+                    Grid.Column="0"
+                    Orientation="Horizontal"
+                    Spacing="10"
+                    HorizontalAlignment="Left">
+                    <Button
+                        Name="TrimButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Click="Trim"
+                        IsEnabled="{Binding CanTrim}">
+                        <TextBlock Text="Trim" />
+                    </Button>
+                    <Button
+                        Name="UntrimButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Click="Untrim"
+                        IsEnabled="{Binding CanUntrim}">
+                        <TextBlock Text="Untrim" />
+                    </Button>
+                </StackPanel>
+                <StackPanel
+                    Grid.Column="1"
+                    Orientation="Horizontal"
+                    Spacing="10"
+                    HorizontalAlignment="Right">
+                    <Button
+                        Name="CancellingButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Click="Cancel"
+                        IsEnabled="False">
+                        <Button.IsVisible>
+                            <MultiBinding Converter="{x:Static BoolConverters.And}">
+                                <Binding Path="Processing" />
+                                <Binding Path="Cancel" />
+                            </MultiBinding>
+                        </Button.IsVisible>
+                        <TextBlock Text="{ext:Locale InputDialogCancelling}" />
+                    </Button>
+                    <Button
+                        Name="CancelButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Click="Cancel">
+                        <Button.IsVisible>
+                            <MultiBinding Converter="{x:Static BoolConverters.And}">
+                                <Binding Path="Processing" />
+                                <Binding Path="!Cancel" />
+                            </MultiBinding>
+                        </Button.IsVisible>
+                        <TextBlock Text="{ext:Locale InputDialogCancel}" />
+                    </Button>
+                    <Button
+                        Name="CloseButton"
+                        MinWidth="90"
+                        Margin="5"
+                        Click="Close"
+                        IsVisible="{Binding !Processing}">
+                        <TextBlock Text="{ext:Locale InputDialogClose}" />
+                    </Button>
+                </StackPanel>
+            </Grid>
+        </Panel>
+    </Grid>
+</UserControl>

+ 101 - 0
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<Grid>().Name("DialogSpace").Child().OfType<Border>());
+            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<XCITrimmerViewModel.SortField>(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);
+                }
+            }
+        }
+    }
+}