Compare commits

...

11 Commits

36 changed files with 2494 additions and 32 deletions

254
.github/workflows/canary.yml vendored Normal file
View File

@ -0,0 +1,254 @@
name: Canary release job
on:
workflow_dispatch:
inputs: {}
push:
branches: [ master ]
paths-ignore:
- '.github/**'
- 'docs/**'
- 'assets/**'
- '*.yml'
- '*.json'
- '*.config'
- '*.md'
concurrency: release
env:
POWERSHELL_TELEMETRY_OPTOUT: 1
DOTNET_CLI_TELEMETRY_OPTOUT: 1
RYUJINX_BASE_VERSION: "1.2"
RYUJINX_TARGET_RELEASE_CHANNEL_NAME: "canary"
RYUJINX_TARGET_RELEASE_CHANNEL_OWNER: "GreemDev"
RYUJINX_TARGET_RELEASE_CHANNEL_REPO: "Ryujinx-Canary"
RELEASE: 1
jobs:
tag:
name: Create tag
runs-on: ubuntu-20.04
steps:
- name: Get version info
id: version_info
run: |
echo "build_version=${{ env.RYUJINX_BASE_VERSION }}.${{ github.run_number }}" >> $GITHUB_OUTPUT
echo "prev_build_version=${{ env.RYUJINX_BASE_VERSION }}.$((${{ github.run_number }} - 1))" >> $GITHUB_OUTPUT
shell: bash
- name: Create tag
uses: actions/github-script@v7
with:
script: |
github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'refs/tags/Canary-${{ steps.version_info.outputs.build_version }}',
sha: context.sha
})
- name: Create release
uses: ncipollo/release-action@v1
with:
name: "Canary ${{ steps.version_info.outputs.build_version }}"
tag: ${{ steps.version_info.outputs.build_version }}
body: "**Full Changelog**: https://github.com/${{ github.repository }}/compare/Canary-${{ steps.version_info.outputs.prev_build_version }}...Canary-${{ steps.version_info.outputs.build_version }}"
omitBodyDuringUpdate: true
owner: ${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}
repo: ${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}
token: ${{ secrets.RELEASE_TOKEN }}
release:
name: Release for ${{ matrix.platform.name }}
runs-on: ${{ matrix.platform.os }}
strategy:
matrix:
platform:
- { name: win-x64, os: windows-latest, zip_os_name: win_x64 }
- { name: linux-x64, os: ubuntu-latest, zip_os_name: linux_x64 }
- { name: linux-arm64, os: ubuntu-latest, zip_os_name: linux_arm64 }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Overwrite csc problem matcher
run: echo "::add-matcher::.github/csc.json"
- name: Get version info
id: version_info
run: |
echo "build_version=${{ env.RYUJINX_BASE_VERSION }}.${{ github.run_number }}" >> $GITHUB_OUTPUT
echo "prev_build_version=${{ env.RYUJINX_BASE_VERSION }}.$((${{ github.run_number }} - 1))" >> $GITHUB_OUTPUT
echo "git_short_hash=$(git rev-parse --short "${{ github.sha }}")" >> $GITHUB_OUTPUT
shell: bash
- name: Configure for release
run: |
sed -r --in-place 's/\%\%RYUJINX_BUILD_VERSION\%\%/${{ steps.version_info.outputs.build_version }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_BUILD_GIT_HASH\%\%/${{ steps.version_info.outputs.git_short_hash }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_NAME\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_NAME }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_CONFIG_FILE_NAME\%\%/Config\.json/g;' src/Ryujinx.Common/ReleaseInformation.cs
shell: bash
- name: Create output dir
run: "mkdir release_output"
- name: Publish
run: |
dotnet publish -c Release -r "${{ matrix.platform.name }}" -o ./publish_ava/publish -p:Version="${{ steps.version_info.outputs.build_version }}" -p:SourceRevisionId="${{ steps.version_info.outputs.git_short_hash }}" -p:DebugType=embedded src/Ryujinx --self-contained -p:IncludeNativeLibrariesForSelfExtract=true
dotnet publish -c Release -r "${{ matrix.platform.name }}" -o ./publish_sdl2_headless/publish -p:Version="${{ steps.version_info.outputs.build_version }}" -p:SourceRevisionId="${{ steps.version_info.outputs.git_short_hash }}" -p:DebugType=embedded src/Ryujinx.Headless.SDL2 --self-contained -p:IncludeNativeLibrariesForSelfExtract=true
- name: Packing Windows builds
if: matrix.platform.os == 'windows-latest'
run: |
pushd publish_ava
rm publish/libarmeilleure-jitsupport.dylib
7z a ../release_output/ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.zip publish
popd
pushd publish_sdl2_headless
rm publish/libarmeilleure-jitsupport.dylib
7z a ../release_output/sdl2-ryujinx-headless-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.zip publish
popd
shell: bash
- name: Packing Linux builds
if: matrix.platform.os == 'ubuntu-latest'
run: |
pushd publish_ava
rm publish/libarmeilleure-jitsupport.dylib
chmod +x publish/Ryujinx.sh publish/Ryujinx
tar -czvf ../release_output/ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.tar.gz publish
popd
pushd publish_sdl2_headless
rm publish/libarmeilleure-jitsupport.dylib
chmod +x publish/Ryujinx.sh publish/Ryujinx.Headless.SDL2
tar -czvf ../release_output/sdl2-ryujinx-headless-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.tar.gz publish
popd
shell: bash
#- name: Build AppImage (Linux)
# if: matrix.platform.os == 'ubuntu-latest'
# run: |
# BUILD_VERSION="${{ steps.version_info.outputs.build_version }}"
# PLATFORM_NAME="${{ matrix.platform.name }}"
# sudo apt install -y zsync desktop-file-utils appstream
# mkdir -p tools
# export PATH="$PATH:$(readlink -f tools)"
# Setup appimagetool
# wget -q -O tools/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
# chmod +x tools/appimagetool
# chmod +x distribution/linux/appimage/build-appimage.sh
# Explicitly set $ARCH for appimagetool ($ARCH_NAME is for the file name)
# if [ "$PLATFORM_NAME" = "linux-x64" ]; then
# ARCH_NAME=x64
# export ARCH=x86_64
# elif [ "$PLATFORM_NAME" = "linux-arm64" ]; then
# ARCH_NAME=arm64
# export ARCH=aarch64
# else
# echo "Unexpected PLATFORM_NAME "$PLATFORM_NAME""
# exit 1
# fi
# export UFLAG="gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|latest|*-$ARCH_NAME.AppImage.zsync"
# BUILDDIR=publish_ava OUTDIR=publish_ava_appimage distribution/linux/appimage/build-appimage.sh
# Add to release output
# pushd publish_ava_appimage
# mv Ryujinx.AppImage ../release_output/ryujinx-$BUILD_VERSION-$ARCH_NAME.AppImage
# mv Ryujinx.AppImage.zsync ../release_output/ryujinx-$BUILD_VERSION-$ARCH_NAME.AppImage.zsync
# popd
# shell: bash
- name: Pushing new release
uses: ncipollo/release-action@v1
with:
name: ${{ steps.version_info.outputs.build_version }}
artifacts: "release_output/*.tar.gz,release_output/*.zip"
#artifacts: "release_output/*.tar.gz,release_output/*.zip/*AppImage*"
tag: ${{ steps.version_info.outputs.build_version }}
body: "**Full Changelog**: https://github.com/${{ github.repository }}/compare/Canary-${{ steps.version_info.outputs.prev_build_version }}...Canary-${{ steps.version_info.outputs.build_version }}"
omitBodyDuringUpdate: true
allowUpdates: true
replacesArtifacts: true
owner: ${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}
repo: ${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}
token: ${{ secrets.RELEASE_TOKEN }}
macos_release:
name: Release MacOS universal
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Setup LLVM 15
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 15
- name: Install rcodesign
run: |
mkdir -p $HOME/.bin
gh release download -R indygreg/apple-platform-rs -O apple-codesign.tar.gz -p 'apple-codesign-*-x86_64-unknown-linux-musl.tar.gz'
tar -xzvf apple-codesign.tar.gz --wildcards '*/rcodesign' --strip-components=1
rm apple-codesign.tar.gz
mv rcodesign $HOME/.bin/
echo "$HOME/.bin" >> $GITHUB_PATH
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version info
id: version_info
run: |
echo "build_version=${{ env.RYUJINX_BASE_VERSION }}.${{ github.run_number }}" >> $GITHUB_OUTPUT
echo "prev_build_version=${{ env.RYUJINX_BASE_VERSION }}.$((${{ github.run_number }} - 1))" >> $GITHUB_OUTPUT
echo "git_short_hash=$(git rev-parse --short "${{ github.sha }}")" >> $GITHUB_OUTPUT
- name: Configure for release
run: |
sed -r --in-place 's/\%\%RYUJINX_BUILD_VERSION\%\%/${{ steps.version_info.outputs.build_version }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_BUILD_GIT_HASH\%\%/${{ steps.version_info.outputs.git_short_hash }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_NAME\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_NAME }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs
sed -r --in-place 's/\%\%RYUJINX_CONFIG_FILE_NAME\%\%/Config\.json/g;' src/Ryujinx.Common/ReleaseInformation.cs
shell: bash
- name: Publish macOS Ryujinx
run: |
./distribution/macos/create_macos_build_ava.sh . publish_tmp_ava publish_ava ./distribution/macos/entitlements.xml "${{ steps.version_info.outputs.build_version }}" "${{ steps.version_info.outputs.git_short_hash }}" Release
- name: Publish macOS Ryujinx.Headless.SDL2
run: |
./distribution/macos/create_macos_build_headless.sh . publish_tmp_headless publish_headless ./distribution/macos/entitlements.xml "${{ steps.version_info.outputs.build_version }}" "${{ steps.version_info.outputs.git_short_hash }}" Release
- name: Pushing new release
uses: ncipollo/release-action@v1
with:
name: "Canary ${{ steps.version_info.outputs.build_version }}"
artifacts: "publish_ava/*.tar.gz, publish_headless/*.tar.gz"
tag: ${{ steps.version_info.outputs.build_version }}
body: "**Full Changelog**: https://github.com/${{ github.repository }}/compare/Canary-${{ steps.version_info.outputs.prev_build_version }}...Canary-${{ steps.version_info.outputs.build_version }}"
omitBodyDuringUpdate: true
allowUpdates: true
replacesArtifacts: true
owner: ${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}
repo: ${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}
token: ${{ secrets.RELEASE_TOKEN }}

View File

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs: {}
push:
branches: [ master ]
branches: [ release ]
paths-ignore:
- '.github/**'
- 'docs/**'
@ -20,7 +20,7 @@ env:
POWERSHELL_TELEMETRY_OPTOUT: 1
DOTNET_CLI_TELEMETRY_OPTOUT: 1
RYUJINX_BASE_VERSION: "1.2"
RYUJINX_TARGET_RELEASE_CHANNEL_NAME: "master"
RYUJINX_TARGET_RELEASE_CHANNEL_NAME: "release"
RYUJINX_TARGET_RELEASE_CHANNEL_OWNER: "GreemDev"
RYUJINX_TARGET_RELEASE_CHANNEL_REPO: "Ryujinx"
RELEASE: 1

View File

@ -61,9 +61,9 @@ Use the search function to see if a game has been tested already!
To run this emulator, your PC must be equipped with at least 8GiB of RAM;
failing to meet this requirement may result in a poor gameplay experience or unexpected crashes.
## Latest build
## Latest release
These builds are compiled automatically for each commit on the master branch.
Releases are compiled automatically for each commit on the master branch.
While we strive to ensure optimal stability and performance prior to pushing an update, our automated builds **may be unstable or completely broken**.
You can find the latest release [here](https://github.com/GreemDev/Ryujinx/releases/latest).
@ -74,6 +74,7 @@ If you are planning to contribute or just want to learn more about this project
## Building
Building the project is for advanced users.
If you wish to build the emulator yourself, follow these steps:
### Step 1

View File

@ -29,14 +29,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Nvdec", "s
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Audio", "src\Ryujinx.Audio\Ryujinx.Audio.csproj", "{806ACF6D-90B0-45D0-A1AC-5F220F3B3985}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{36F870C1-3E5F-485F-B426-F0645AF78751}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
Directory.Packages.props = Directory.Packages.props
Release Script = .github/workflows/release.yml
Build Script = .github/workflows/build.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Memory", "src\Ryujinx.Memory\Ryujinx.Memory.csproj", "{A5E6C691-9E22-4263-8F40-42F002CE66BE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Tests.Memory", "src\Ryujinx.Tests.Memory\Ryujinx.Tests.Memory.csproj", "{D1CC5322-7325-4F6B-9625-194B30BE1296}"
@ -89,6 +81,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Horizon.Kernel.Gene
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.HLE.Generators", "src\Ryujinx.HLE.Generators\Ryujinx.HLE.Generators.csproj", "{B575BCDE-2FD8-4A5D-8756-31CDD7FE81F0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{36F870C1-3E5F-485F-B426-F0645AF78751}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
Directory.Packages.props = Directory.Packages.props
.github/workflows/release.yml = .github/workflows/release.yml
.github/workflows/canary.yml = .github/workflows/canary.yml
.github/workflows/build.yml = .github/workflows/build.yml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU

View File

@ -81,7 +81,7 @@ namespace Ryujinx.Audio.Renderer.Dsp
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static short GetCoefficientAtIndex(ReadOnlySpan<short> coefficients, int index)
{
if ((uint)index > (uint)coefficients.Length)
if ((uint)index >= (uint)coefficients.Length)
{
Logger.Error?.Print(LogClass.AudioRenderer, $"Out of bound read for coefficient at index {index}");

View File

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

View File

@ -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;
}
}
}
}

View File

@ -5,7 +5,9 @@ namespace Ryujinx.Common
// DO NOT EDIT, filled by CI
public static class ReleaseInformation
{
private const string FlatHubChannelOwner = "flathub";
private const string FlatHubChannel = "flathub";
private const string CanaryChannel = "canary";
private const string ReleaseChannel = "release";
private const string BuildVersion = "%%RYUJINX_BUILD_VERSION%%";
public const string BuildGitHash = "%%RYUJINX_BUILD_GIT_HASH%%";
@ -24,7 +26,11 @@ namespace Ryujinx.Common
!ReleaseChannelRepo.StartsWith("%%") &&
!ConfigFileName.StartsWith("%%");
public static bool IsFlatHubBuild => IsValid && ReleaseChannelOwner.Equals(FlatHubChannelOwner);
public static bool IsFlatHubBuild => IsValid && ReleaseChannelOwner.Equals(FlatHubChannel);
public static bool IsCanaryBuild => IsValid && ReleaseChannelOwner.Equals(CanaryChannel);
public static bool IsReleaseBuild => IsValid && ReleaseChannelOwner.Equals(ReleaseChannel);
public static string Version => IsValid ? BuildVersion : Assembly.GetEntryAssembly()!.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
}

View File

@ -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;
}
}
}

View File

@ -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());
}

View File

@ -5,6 +5,7 @@ using Ryujinx.HLE.Exceptions;
using Ryujinx.HLE.HOS.Kernel.Memory;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Common;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugMouse;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugPad;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Keyboard;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Mouse;
@ -28,6 +29,7 @@ namespace Ryujinx.HLE.HOS.Services.Hid
public DebugPadDevice DebugPad;
public TouchDevice Touchscreen;
public MouseDevice Mouse;
public DebugMouseDevice DebugMouse;
public KeyboardDevice Keyboard;
public NpadDevices Npads;
@ -44,6 +46,7 @@ namespace Ryujinx.HLE.HOS.Services.Hid
CheckTypeSizeOrThrow<RingLifo<DebugPadState>>(0x2c8);
CheckTypeSizeOrThrow<RingLifo<TouchScreenState>>(0x2C38);
CheckTypeSizeOrThrow<RingLifo<MouseState>>(0x350);
CheckTypeSizeOrThrow<RingLifo<DebugMouseState>>(0x350);
CheckTypeSizeOrThrow<RingLifo<KeyboardState>>(0x3D8);
CheckTypeSizeOrThrow<Array10<NpadState>>(0x32000);
CheckTypeSizeOrThrow<SharedMemory>(Horizon.HidSize);
@ -64,6 +67,7 @@ namespace Ryujinx.HLE.HOS.Services.Hid
DebugPad = new DebugPadDevice(_device, true);
Touchscreen = new TouchDevice(_device, true);
Mouse = new MouseDevice(_device, false);
DebugMouse = new DebugMouseDevice(_device, false);
Keyboard = new KeyboardDevice(_device, false);
Npads = new NpadDevices(_device, true);
}

View File

@ -0,0 +1,29 @@
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Common;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugMouse;
namespace Ryujinx.HLE.HOS.Services.Hid
{
public class DebugMouseDevice : BaseDevice
{
public DebugMouseDevice(Switch device, bool active) : base(device, active) { }
public void Update()
{
ref RingLifo<DebugMouseState> lifo = ref _device.Hid.SharedMemory.DebugMouse;
ref DebugMouseState previousEntry = ref lifo.GetCurrentEntryRef();
DebugMouseState newState = new()
{
SamplingNumber = previousEntry.SamplingNumber + 1,
};
if (Active)
{
// TODO: This is a debug device only present in dev environment, do we want to support it?
}
lifo.Write(ref newState);
}
}
}

View File

@ -130,6 +130,26 @@ namespace Ryujinx.HLE.HOS.Services.Hid
return ResultCode.Success;
}
[CommandCmif(26)]
// ActivateDebugMouse(nn::applet::AppletResourceUserId)
public ResultCode ActivateDebugMouse(ServiceCtx context)
{
long appletResourceUserId = context.RequestData.ReadInt64();
context.Device.Hid.DebugMouse.Active = true;
// Initialize entries to avoid issues with some games.
for (int entry = 0; entry < Hid.SharedMemEntryCount; entry++)
{
context.Device.Hid.DebugMouse.Update();
}
Logger.Stub?.PrintStub(LogClass.ServiceHid, new { appletResourceUserId });
return ResultCode.Success;
}
[CommandCmif(31)]
// ActivateKeyboard(nn::applet::AppletResourceUserId)

View File

@ -0,0 +1,12 @@
using System;
namespace Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugMouse
{
[Flags]
enum DebugMouseAttribute : uint
{
None = 0,
Transferable = 1 << 0,
IsConnected = 1 << 1,
}
}

View File

@ -0,0 +1,15 @@
using System;
namespace Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugMouse
{
[Flags]
enum DebugMouseButton : uint
{
None = 0,
Left = 1 << 0,
Right = 1 << 1,
Middle = 1 << 2,
Forward = 1 << 3,
Back = 1 << 4,
}
}

View File

@ -0,0 +1,19 @@
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Common;
using System.Runtime.InteropServices;
namespace Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugMouse
{
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct DebugMouseState : ISampledDataStruct
{
public ulong SamplingNumber;
public int X;
public int Y;
public int DeltaX;
public int DeltaY;
public int WheelDeltaX;
public int WheelDeltaY;
public DebugMouseButton Buttons;
public DebugMouseAttribute Attributes;
}
}

View File

@ -1,5 +1,6 @@
using Ryujinx.Common.Memory;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Common;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugMouse;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.DebugPad;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Keyboard;
using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Mouse;
@ -44,6 +45,12 @@ namespace Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory
/// </summary>
[FieldOffset(0x9A00)]
public Array10<NpadState> Npads;
/// <summary>
/// Debug mouse.
/// </summary>
[FieldOffset(0x3DC00)]
public RingLifo<DebugMouseState> DebugMouse;
public static SharedMemory Create()
{

View File

@ -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();
}
}
}

View File

@ -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})",
@ -439,13 +445,13 @@
"DialogNcaExtractionMessage": "Extracting {0} section from {1}...",
"DialogNcaExtractionTitle": "Ryujinx - NCA Section Extractor",
"DialogNcaExtractionMainNcaNotFoundErrorMessage": "Extraction failure. The main NCA was not present in the selected file.",
"DialogNcaExtractionCheckLogErrorMessage": "Extraction failure. Read the log file for further information.",
"DialogNcaExtractionCheckLogErrorMessage": "Extraction failed. Please check the log file for more details.",
"DialogNcaExtractionSuccessMessage": "Extraction completed successfully.",
"DialogUpdaterConvertFailedMessage": "Failed to convert the current Ryujinx version.",
"DialogUpdaterCancelUpdateMessage": "Cancelling Update!",
"DialogUpdaterAlreadyOnLatestVersionMessage": "You are already using the most updated version of Ryujinx!",
"DialogUpdaterFailedToGetVersionMessage": "An error has occurred when trying to get release information from GitHub Release. This can be caused if a new release is being compiled by GitHub Actions. Try again in a few minutes.",
"DialogUpdaterConvertFailedGithubMessage": "Failed to convert the received Ryujinx version from Github Release.",
"DialogUpdaterConvertFailedMessage": "Unable to convert the current Ryujinx version.",
"DialogUpdaterCancelUpdateMessage": "Update canceled!",
"DialogUpdaterAlreadyOnLatestVersionMessage": "You are already using the latest version of Ryujinx!",
"DialogUpdaterFailedToGetVersionMessage": "An error occurred while trying to retrieve release information from GitHub. This may happen if a new release is currently being compiled by GitHub Actions. Please try again in a few minutes.",
"DialogUpdaterConvertFailedGithubMessage": "Failed to convert the Ryujinx version received from GitHub."
"DialogUpdaterDownloadingMessage": "Downloading Update...",
"DialogUpdaterExtractionMessage": "Extracting Update...",
"DialogUpdaterRenamingMessage": "Renaming Update...",
@ -455,7 +461,7 @@
"DialogUpdaterNoInternetMessage": "You are not connected to the Internet!",
"DialogUpdaterNoInternetSubMessage": "Please verify that you have a working Internet connection!",
"DialogUpdaterDirtyBuildMessage": "You Cannot update a Dirty build of Ryujinx!",
"DialogUpdaterDirtyBuildSubMessage": "Please download Ryujinx at https://https://github.com/GreemDev/Ryujinx/releases/ if you are looking for a supported version.",
"DialogUpdaterDirtyBuildSubMessage": "Please download Ryujinx at https://github.com/GreemDev/Ryujinx/releases/ if you are looking for a supported version.",
"DialogRestartRequiredMessage": "Restart Required",
"DialogThemeRestartMessage": "Theme has been saved. A restart is needed to apply the theme.",
"DialogThemeRestartSubMessage": "Do you want to restart",
@ -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",

View File

@ -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>

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
@ -1776,6 +1803,7 @@ namespace Ryujinx.Ava.UI.ViewModels
if (WindowState is not WindowState.Normal)
{
WindowState = WindowState.Normal;
Window.TitleBar.ExtendsContentIntoTitleBar = !ConfigurationState.Instance.ShowTitleBar;
if (IsGameRunning)
{
@ -1785,6 +1813,7 @@ namespace Ryujinx.Ava.UI.ViewModels
else
{
WindowState = WindowState.FullScreen;
Window.TitleBar.ExtendsContentIntoTitleBar = true;
if (IsGameRunning)
{
@ -1832,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
}
}

View File

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

View File

@ -12,13 +12,13 @@
<viewModels:MainWindowViewModel />
</Design.DataContext>
<DockPanel HorizontalAlignment="Stretch">
<Border Name="RyuLogo" Padding="7, 0, 0, 0" VerticalAlignment="Center" HorizontalAlignment="Center">
<Image
ToolTip.Tip="{Binding Title}"
Height="25"
Width="25"
Source="resm:Ryujinx.UI.Common.Resources.Logo_Ryujinx.png?assembly=Ryujinx.UI.Common" />
</Border>
<Image
Name="RyuLogo"
Margin="7,0"
Height="25"
Width="25"
ToolTip.Tip="{Binding Title}"
Source="resm:Ryujinx.UI.Common.Resources.Logo_Ryujinx.png?assembly=Ryujinx.UI.Common" />
<Menu
Name="Menu"
Height="35"
@ -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}">

View File

@ -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();

View File

@ -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}"

View File

@ -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>

View File

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