From 2880892c2c363f1a9cc717430b4e458017313d21 Mon Sep 17 00:00:00 2001 From: reggie Date: Sun, 6 Oct 2024 14:09:51 -0500 Subject: [PATCH 01/10] meta: Update issue template's Discord link --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3ba4ac93b..2dd72d293 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - - name: Latte Softworks Discord - url: https://latte.to/discord + - name: ryujinx-mirror (Discord) + url: https://discord.gg/xmHPGDfVCa about: This is the home of development for the ryujinx-mirror fork, feel free to make a post in `#ryujinx-help` for general support & technical issues From 51b956ac7fc835a97a783b93e5be749de74f6a87 Mon Sep 17 00:00:00 2001 From: Samuel <36420837+Samueru-sama@users.noreply.github.com> Date: Sun, 6 Oct 2024 15:14:04 -0400 Subject: [PATCH 02/10] infra: don't repackage appimages and fix zsync files (#34) Co-authored-by: reggie --- .github/workflows/build.yml | 10 +++-- .github/workflows/release.yml | 42 ++++++++++++------- distribution/linux/appimage/build-appimage.sh | 9 ++-- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d5c37652..b4bfc6474 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,7 +82,7 @@ jobs: - name: Build AppImage if: github.event_name == 'pull_request' && matrix.platform.os == 'ubuntu-latest' run: | - PLATFORM_NAME="${{ matrix.platform.name }}" + ARCH_NAME="${RUNNER_ARCH,,}" sudo apt install -y zsync desktop-file-utils appstream @@ -93,15 +93,17 @@ jobs: wget -q -O tools/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" chmod +x tools/appimagetool - if [ "$PLATFORM_NAME" = "linux-x64" ]; then + # Explicitly set $ARCH for appimagetool + if [ "$ARCH_NAME" = "x64" ]; then export ARCH=x86_64 - elif [ "$PLATFORM_NAME" = "linux-arm64" ]; then + elif [ "$ARCH_NAME" = "arm64" ]; then export ARCH=aarch64 else - echo "Unexpected PLATFORM_NAME "$PLATFORM_NAME"" + echo "Unexpected ARCH_NAME "$ARCH_NAME"" exit 1 fi + export UFLAG="gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|latest|*-$ARCH_NAME.AppImage.zsync" BUILDDIR=publish OUTDIR=publish_appimage distribution/linux/appimage/build-appimage.sh shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69a8c9278..5acc28de9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,21 +106,25 @@ jobs: - name: Packing Windows builds if: matrix.platform.os == 'windows-latest' run: | + BUILD_VERSION="${{ steps.version_info.outputs.build_version }}" + ZIP_OS_NAME="${{ matrix.platform.zip_os_name }}" + pushd publish_ava cp Ryujinx.exe Ryujinx.Ava.exe - 7z a ../release_output/ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.zip * - 7z a ../release_output/test-ava-ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.zip * + 7z a ../release_output/ryujinx-$BUILD_VERSION-$ZIP_OS_NAME.zip * + 7z a ../release_output/test-ava-ryujinx-$BUILD_VERSION-$ZIP_OS_NAME.zip * popd pushd publish_sdl2_headless - 7z a ../release_output/sdl2-ryujinx-headless-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.zip * + 7z a ../release_output/sdl2-ryujinx-headless-$BUILD_VERSION-$ZIP_OS_NAME.zip * popd shell: bash - name: Build AppImage (Linux) if: matrix.platform.os == 'ubuntu-latest' run: | - PLATFORM_NAME="${{ matrix.platform.name }}" + BUILD_VERSION="${{ steps.version_info.outputs.build_version }}" + ARCH_NAME="${RUNNER_ARCH,,}" sudo apt install -y zsync desktop-file-utils appstream @@ -131,36 +135,42 @@ jobs: wget -q -O tools/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" chmod +x tools/appimagetool - if [ "$PLATFORM_NAME" = "linux-x64" ]; then + # Explicitly set $ARCH for appimagetool + if [ "$ARCH_NAME" = "x64" ]; then export ARCH=x86_64 - elif [ "$PLATFORM_NAME" = "linux-arm64" ]; then + elif [ "$ARCH_NAME" = "arm64" ]; then export ARCH=aarch64 else - echo "Unexpected PLATFORM_NAME "$PLATFORM_NAME"" + echo "Unexpected ARCH_NAME "$ARCH_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: Packing Linux builds if: matrix.platform.os == 'ubuntu-latest' run: | + BUILD_VERSION="${{ steps.version_info.outputs.build_version }}" + ZIP_OS_NAME="${{ matrix.platform.zip_os_name }}" + pushd publish_ava cp Ryujinx Ryujinx.Ava chmod +x Ryujinx.sh Ryujinx Ryujinx.Ava - tar -czvf ../release_output/ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.tar.gz * - tar -czvf ../release_output/test-ava-ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.tar.gz * + tar -czvf ../release_output/ryujinx-$BUILD_VERSION-$ZIP_OS_NAME.tar.gz * + tar -czvf ../release_output/test-ava-ryujinx-$BUILD_VERSION-$ZIP_OS_NAME.tar.gz * popd pushd publish_sdl2_headless chmod +x Ryujinx.sh Ryujinx.Headless.SDL2 - tar -czvf ../release_output/sdl2-ryujinx-headless-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}.tar.gz * - popd - - # AppImage archive - pushd publish_ava_appimage - tar -czvf ../release_output/ryujinx-${{ steps.version_info.outputs.build_version }}-${{ matrix.platform.zip_os_name }}-AppImage.tar.gz * + tar -czvf ../release_output/sdl2-ryujinx-headless-$BUILD_VERSION-$ZIP_OS_NAME.tar.gz * popd shell: bash @@ -169,7 +179,7 @@ jobs: with: name: ${{ steps.version_info.outputs.build_version }} tag: ${{ steps.version_info.outputs.build_version }} - artifacts: "release_output/*.tar.gz,release_output/*.zip" + artifacts: "release_output/*.tar.gz,release_output/*.zip,release_output/*AppImage*" draft: "true" omitBody: true #omitBodyDuringUpdate: true diff --git a/distribution/linux/appimage/build-appimage.sh b/distribution/linux/appimage/build-appimage.sh index 5cb4db652..2f34f8ad3 100755 --- a/distribution/linux/appimage/build-appimage.sh +++ b/distribution/linux/appimage/build-appimage.sh @@ -1,14 +1,15 @@ #!/bin/sh -set -e +set -eu ROOTDIR="$(readlink -f "$(dirname "$0")")"/../../../ cd "$ROOTDIR" BUILDDIR=${BUILDDIR:-publish} OUTDIR=${OUTDIR:-publish_appimage} +UFLAG=${UFLAG:-"gh-releases-zsync|ryujinx-mirror|ryujinx|latest|*-x64.AppImage.zsync"} rm -rf AppDir -mkdir -p AppDir/usr/bin/bin +mkdir -p AppDir/usr/bin cp distribution/linux/Ryujinx.desktop AppDir/Ryujinx.desktop cp distribution/linux/appimage/AppRun AppDir/AppRun @@ -22,8 +23,8 @@ chmod +x AppDir/AppRun AppDir/usr/bin/Ryujinx* mkdir -p "$OUTDIR" appimagetool --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 21 \ - -u "gh-releases-zsync|$GITHUB_REPOSITORY_OWNER|Ryujinx|latest|*.AppImage.zsync" \ + -u "$UFLAG" \ AppDir "$OUTDIR"/Ryujinx.AppImage -# ?? +# Move zsync file needed for delta updates mv ./*.AppImage.zsync "$OUTDIR" From dc545c33e41c35623e6eb6294b0a96aa7881dcad Mon Sep 17 00:00:00 2001 From: reggie Date: Sun, 6 Oct 2024 14:42:31 -0500 Subject: [PATCH 03/10] infra: Fix AppImage build for arm64 --- .github/workflows/build.yml | 12 +++++++----- .github/workflows/release.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4bfc6474..d4aff70fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,7 +82,7 @@ jobs: - name: Build AppImage if: github.event_name == 'pull_request' && matrix.platform.os == 'ubuntu-latest' run: | - ARCH_NAME="${RUNNER_ARCH,,}" + PLATFORM_NAME="${{ matrix.platform.name }}" sudo apt install -y zsync desktop-file-utils appstream @@ -93,13 +93,15 @@ jobs: wget -q -O tools/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" chmod +x tools/appimagetool - # Explicitly set $ARCH for appimagetool - if [ "$ARCH_NAME" = "x64" ]; then + # 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 [ "$ARCH_NAME" = "arm64" ]; then + elif [ "$PLATFORM_NAME" = "linux-arm64" ]; then + ARCH_NAME=arm64 export ARCH=aarch64 else - echo "Unexpected ARCH_NAME "$ARCH_NAME"" + echo "Unexpected PLATFORM_NAME "$PLATFORM_NAME"" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5acc28de9..bba8309db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,7 +124,7 @@ jobs: if: matrix.platform.os == 'ubuntu-latest' run: | BUILD_VERSION="${{ steps.version_info.outputs.build_version }}" - ARCH_NAME="${RUNNER_ARCH,,}" + PLATFORM_NAME="${{ matrix.platform.name }}" sudo apt install -y zsync desktop-file-utils appstream @@ -135,13 +135,15 @@ jobs: wget -q -O tools/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" chmod +x tools/appimagetool - # Explicitly set $ARCH for appimagetool - if [ "$ARCH_NAME" = "x64" ]; then + # 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 [ "$ARCH_NAME" = "arm64" ]; then + elif [ "$PLATFORM_NAME" = "linux-arm64" ]; then + ARCH_NAME=arm64 export ARCH=aarch64 else - echo "Unexpected ARCH_NAME "$ARCH_NAME"" + echo "Unexpected PLATFORM_NAME "$PLATFORM_NAME"" exit 1 fi From 0e5ce0bd209cf1c73bbefc6cab244f1c3fcb49e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 09:03:28 -0500 Subject: [PATCH 04/10] nuget: bump Microsoft.IdentityModel.JsonWebTokens from 8.0.1 to 8.1.1 (#37) Bumps [Microsoft.IdentityModel.JsonWebTokens](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet) from 8.0.1 to 8.1.1. - [Release notes](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/releases) - [Changelog](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/dev/CHANGELOG.md) - [Commits](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/compare/8.0.1...8.1.1) --- updated-dependencies: - dependency-name: Microsoft.IdentityModel.JsonWebTokens dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7bee6250b..510d363cb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,7 @@ - + From b4cac89c1fa93c55a5a14a6e9bd3a5a65ea9c6f7 Mon Sep 17 00:00:00 2001 From: TheToid Date: Wed, 9 Oct 2024 12:41:31 +1000 Subject: [PATCH 05/10] Fix some input controller issues (mapping sticks and duplicate controller names) (#31) Co-authored-by: reggie --- src/Ryujinx.Input.SDL2/SDL2Gamepad.cs | 58 ++++++++++++++----- .../UI/ViewModels/Input/InputViewModel.cs | 32 ++++++---- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs b/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs index 187ca48dd..ef27b7ca3 100644 --- a/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs +++ b/src/Ryujinx.Input.SDL2/SDL2Gamepad.cs @@ -313,6 +313,32 @@ private static float ConvertRawStickValue(short value) return value * ConvertRate; } + private JoyconConfigControllerStick GetLogicalJoyStickConfig(StickInputId inputId) + { + switch (inputId) + { + case StickInputId.Left: + if (_configuration.RightJoyconStick.Joystick == Common.Configuration.Hid.Controller.StickInputId.Left) + { + return _configuration.RightJoyconStick; + } + else + { + return _configuration.LeftJoyconStick; + } + case StickInputId.Right: + if (_configuration.LeftJoyconStick.Joystick == Common.Configuration.Hid.Controller.StickInputId.Right) + { + return _configuration.LeftJoyconStick; + } + else + { + return _configuration.RightJoyconStick; + } + } + + return null; + } public (float, float) GetStick(StickInputId inputId) { if (inputId == StickInputId.Unbound) @@ -343,24 +369,26 @@ private static float ConvertRawStickValue(short value) if (HasConfiguration) { - if ((inputId == StickInputId.Left && _configuration.LeftJoyconStick.InvertStickX) || - (inputId == StickInputId.Right && _configuration.RightJoyconStick.InvertStickX)) - { - resultX = -resultX; - } + var joyconStickConfig = GetLogicalJoyStickConfig(inputId); - if ((inputId == StickInputId.Left && _configuration.LeftJoyconStick.InvertStickY) || - (inputId == StickInputId.Right && _configuration.RightJoyconStick.InvertStickY)) + if (joyconStickConfig != null) { - resultY = -resultY; - } + if (joyconStickConfig.InvertStickX) + { + resultX = -resultX; + } - if ((inputId == StickInputId.Left && _configuration.LeftJoyconStick.Rotate90CW) || - (inputId == StickInputId.Right && _configuration.RightJoyconStick.Rotate90CW)) - { - float temp = resultX; - resultX = resultY; - resultY = -temp; + if (joyconStickConfig.InvertStickY) + { + resultY = -resultY; + } + + if (joyconStickConfig.Rotate90CW) + { + float temp = resultX; + resultX = resultY; + resultY = -temp; + } } } diff --git a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs index 89cc6496d..b5252c5b0 100644 --- a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs @@ -45,7 +45,6 @@ public class InputViewModel : BaseModel, IDisposable private PlayerIndex _playerId; private int _controller; - private int _controllerNumber; private string _controllerImage; private int _device; private object _configViewModel; @@ -439,6 +438,24 @@ private static string GetShortGamepadId(string str) public void LoadDevices() { + string GetGamepadName(IGamepad gamepad, int controllerNumber) + { + return $"{GetShortGamepadName(gamepad.Name)} ({controllerNumber})"; + } + + string GetUniqueGamepadName(IGamepad gamepad, ref int controllerNumber) + { + string name = GetGamepadName(gamepad, controllerNumber); + + if (Devices.Any(controller => controller.Name == name)) + { + controllerNumber++; + name = GetGamepadName(gamepad, controllerNumber); + } + + return name; + } + lock (Devices) { Devices.Clear(); @@ -455,23 +472,18 @@ public void LoadDevices() } } + int controllerNumber = 0; foreach (string id in _mainWindow.InputManager.GamepadDriver.GamepadsIds) { using IGamepad gamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id); if (gamepad != null) { - if (Devices.Any(controller => GetShortGamepadId(controller.Id) == GetShortGamepadId(gamepad.Id))) - { - _controllerNumber++; - } - - Devices.Add((DeviceType.Controller, id, $"{GetShortGamepadName(gamepad.Name)} ({_controllerNumber})")); + string name = GetUniqueGamepadName(gamepad, ref controllerNumber); + Devices.Add((DeviceType.Controller, id, name)); } } - _controllerNumber = 0; - DeviceList.AddRange(Devices.Select(x => x.Name)); Device = Math.Min(Device, DeviceList.Count); } @@ -685,7 +697,7 @@ public async void LoadProfile() if (!File.Exists(path)) { - var index = ProfilesList.IndexOf(ProfileName); + int index = ProfilesList.IndexOf(ProfileName); if (index != -1) { ProfilesList.RemoveAt(index); From 80fa93faefa6184d3a15bd36407c600a145919a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 12:14:31 -0500 Subject: [PATCH 06/10] nuget: bump Microsoft.IdentityModel.JsonWebTokens from 8.1.1 to 8.1.2 (#40) Bumps [Microsoft.IdentityModel.JsonWebTokens](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet) from 8.1.1 to 8.1.2. - [Release notes](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/releases) - [Changelog](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/dev/CHANGELOG.md) - [Commits](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/compare/8.1.1...8.1.2) --- updated-dependencies: - dependency-name: Microsoft.IdentityModel.JsonWebTokens dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 510d363cb..99694b535 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,7 @@ - + From c4ee9c7555a7b89665a4eb5b938359bd73de5794 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Oct 2024 19:29:54 -0500 Subject: [PATCH 07/10] nuget: bump the avalonia group with 6 updates (#41) Bumps the avalonia group with 6 updates: | Package | From | To | | --- | --- | --- | | [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.1.3` | `11.1.4` | | [Avalonia.Controls.DataGrid](https://github.com/AvaloniaUI/Avalonia) | `11.1.3` | `11.1.4` | | [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) | `11.1.3` | `11.1.4` | | [SkiaSharp.NativeAssets.Linux](https://github.com/mono/SkiaSharp) | `2.88.7` | `2.88.8` | | [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) | `11.1.3` | `11.1.4` | | [Avalonia.Markup.Xaml.Loader](https://github.com/AvaloniaUI/Avalonia) | `11.1.3` | `11.1.4` | Updates `Avalonia` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia.Controls.DataGrid` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia.Desktop` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `SkiaSharp.NativeAssets.Linux` from 2.88.7 to 2.88.8 - [Release notes](https://github.com/mono/SkiaSharp/releases) - [Commits](https://github.com/mono/SkiaSharp/compare/v2.88.7...v2.88.8) Updates `Avalonia.Diagnostics` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia.Controls.DataGrid` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia.Markup.Xaml.Loader` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) Updates `Avalonia` from 11.1.3 to 11.1.4 - [Release notes](https://github.com/AvaloniaUI/Avalonia/releases) - [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.1.3...11.1.4) --- updated-dependencies: - dependency-name: Avalonia dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia.Controls.DataGrid dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia.Desktop dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: SkiaSharp.NativeAssets.Linux dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia.Diagnostics dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia.Controls.DataGrid dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia.Markup.Xaml.Loader dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia - dependency-name: Avalonia dependency-type: direct:production update-type: version-update:semver-patch dependency-group: avalonia ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 99694b535..57db53944 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,11 @@ true - - - - - + + + + + From 06f9adfa900fc5b2c2a9c75e496ac5db8e6ffccc Mon Sep 17 00:00:00 2001 From: Jimmy Reichley Date: Mon, 7 Oct 2024 21:08:41 -0400 Subject: [PATCH 08/10] AutoLoad DLC/updates (#12) * Add hooks to ApplicationLibrary for loading DLC/updates * Trigger DLC/update load on games refresh * Initial moving of DLC/updates to UI.Common * Use new models in ApplicationLibrary * Make dlc/updates records; use ApplicationLibrary for loading logic * Fix a bug with DLC window; rework some logic * Auto-load bundled DLC on startup * Autoload DLC * Add setting for autoloading dlc/updates * Remove dead code; bind to AppLibrary apps directly in mainwindow * Stub out bulk dlc menu item * Add localization; stub out bulk load updates * Set autoload dirs explicitly * Begin extracting updates to match DLC refactors * Add title update autoloading * Reduce size of settings sections * Better cache lookup for apps * Dont reload entire library on game version change * Remove ApplicationAdded event; always enumerate nsp when autoloading --- .../App/ApplicationAddedEventArgs.cs | 9 - .../App/ApplicationLibrary.cs | 549 +++++++++++++++++- .../Configuration/ConfigurationFileFormat.cs | 7 +- .../Configuration/ConfigurationState.cs | 18 + .../Helper/DownloadableContentsHelper.cs | 135 +++++ .../Helper/TitleUpdatesHelper.cs | 162 ++++++ .../Models/DownloadableContentModel.cs | 12 + .../Models/TitleUpdateModel.cs | 11 + .../Ryujinx.UI.Common.csproj | 1 + src/Ryujinx/Assets/Locales/en_US.json | 15 + .../Controls/ApplicationContextMenu.axaml.cs | 4 +- .../DownloadableContentLabelConverter.cs | 42 ++ src/Ryujinx/UI/Helpers/Glyph.cs | 1 + src/Ryujinx/UI/Helpers/GlyphValueConverter.cs | 1 + .../UI/Helpers/TitleUpdateLabelConverter.cs | 42 ++ .../UI/Models/DownloadableContentModel.cs | 39 -- src/Ryujinx/UI/Models/TitleUpdateModel.cs | 21 - .../DownloadableContentManagerViewModel.cs | 266 ++++----- .../UI/ViewModels/MainWindowViewModel.cs | 45 +- .../UI/ViewModels/SettingsViewModel.cs | 36 +- .../UI/ViewModels/TitleUpdateViewModel.cs | 217 +++---- .../UI/Views/Main/MainMenuBarView.axaml | 10 + .../UI/Views/Settings/SettingsUIView.axaml | 65 ++- .../UI/Views/Settings/SettingsUIView.axaml.cs | 63 +- .../DownloadableContentManagerWindow.axaml | 42 +- .../DownloadableContentManagerWindow.axaml.cs | 25 +- src/Ryujinx/UI/Windows/MainWindow.axaml.cs | 58 +- .../UI/Windows/SettingsWindow.axaml.cs | 2 +- .../UI/Windows/TitleUpdateWindow.axaml | 40 +- .../UI/Windows/TitleUpdateWindow.axaml.cs | 22 +- 30 files changed, 1505 insertions(+), 455 deletions(-) delete mode 100644 src/Ryujinx.UI.Common/App/ApplicationAddedEventArgs.cs create mode 100644 src/Ryujinx.UI.Common/Helper/DownloadableContentsHelper.cs create mode 100644 src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs create mode 100644 src/Ryujinx.UI.Common/Models/DownloadableContentModel.cs create mode 100644 src/Ryujinx.UI.Common/Models/TitleUpdateModel.cs create mode 100644 src/Ryujinx/UI/Helpers/DownloadableContentLabelConverter.cs create mode 100644 src/Ryujinx/UI/Helpers/TitleUpdateLabelConverter.cs delete mode 100644 src/Ryujinx/UI/Models/DownloadableContentModel.cs delete mode 100644 src/Ryujinx/UI/Models/TitleUpdateModel.cs diff --git a/src/Ryujinx.UI.Common/App/ApplicationAddedEventArgs.cs b/src/Ryujinx.UI.Common/App/ApplicationAddedEventArgs.cs deleted file mode 100644 index 58e066b9d..000000000 --- a/src/Ryujinx.UI.Common/App/ApplicationAddedEventArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace Ryujinx.UI.App.Common -{ - public class ApplicationAddedEventArgs : EventArgs - { - public ApplicationData AppData { get; set; } - } -} diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs index 2defc1f6c..c464cb0a5 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs @@ -1,6 +1,7 @@ +using DynamicData; +using DynamicData.Kernel; using LibHac; using LibHac.Common; -using LibHac.Common.Keys; using LibHac.Fs; using LibHac.Fs.Fsa; using LibHac.FsSystem; @@ -16,8 +17,11 @@ using Ryujinx.HLE.HOS.SystemState; using Ryujinx.HLE.Loaders.Npdm; using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Configuration.System; +using Ryujinx.UI.Common.Helper; +using Ryujinx.UI.Common.Models; using System; using System.Collections.Generic; using System.IO; @@ -27,7 +31,9 @@ using System.Text.Json; using System.Threading; using ContentType = LibHac.Ncm.ContentType; +using MissingKeyException = LibHac.Common.Keys.MissingKeyException; using Path = System.IO.Path; +using SpanHelpers = LibHac.Common.SpanHelpers; using TimeSpan = System.TimeSpan; namespace Ryujinx.UI.App.Common @@ -35,9 +41,12 @@ namespace Ryujinx.UI.App.Common public class ApplicationLibrary { public Language DesiredLanguage { get; set; } - public event EventHandler ApplicationAdded; public event EventHandler ApplicationCountUpdated; + public readonly IObservableCache Applications; + public readonly IObservableCache<(TitleUpdateModel TitleUpdate, bool IsSelected), TitleUpdateModel> TitleUpdates; + public readonly IObservableCache<(DownloadableContentModel Dlc, bool IsEnabled), DownloadableContentModel> DownloadableContents; + private readonly byte[] _nspIcon; private readonly byte[] _xciIcon; private readonly byte[] _ncaIcon; @@ -47,6 +56,9 @@ public class ApplicationLibrary private readonly VirtualFileSystem _virtualFileSystem; private readonly IntegrityCheckLevel _checkLevel; private CancellationTokenSource _cancellationToken; + private readonly SourceCache _applications = new(it => it.Id); + private readonly SourceCache<(TitleUpdateModel TitleUpdate, bool IsSelected), TitleUpdateModel> _titleUpdates = new(it => it.TitleUpdate); + private readonly SourceCache<(DownloadableContentModel Dlc, bool IsEnabled), DownloadableContentModel> _downloadableContents = new(it => it.Dlc); private static readonly ApplicationJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); @@ -55,6 +67,10 @@ public ApplicationLibrary(VirtualFileSystem virtualFileSystem, IntegrityCheckLev _virtualFileSystem = virtualFileSystem; _checkLevel = checkLevel; + Applications = _applications.AsObservableCache(); + TitleUpdates = _titleUpdates.AsObservableCache(); + DownloadableContents = _downloadableContents.AsObservableCache(); + _nspIcon = GetResourceBytes("Ryujinx.UI.Common.Resources.Icon_NSP.png"); _xciIcon = GetResourceBytes("Ryujinx.UI.Common.Resources.Icon_XCI.png"); _ncaIcon = GetResourceBytes("Ryujinx.UI.Common.Resources.Icon_NCA.png"); @@ -100,7 +116,7 @@ private ApplicationData GetApplicationFromExeFs(PartitionFileSystem pfs, string return data; } - /// The configured key set is missing a key. + /// The configured key set is missing a key. /// The NCA header could not be decrypted. /// The NCA version is not supported. /// An error occured while reading PFS data. @@ -176,7 +192,7 @@ private ApplicationData GetApplicationFromNsp(PartitionFileSystem pfs, string fi return null; } - /// The configured key set is missing a key. + /// The configured key set is missing a key. /// The NCA header could not be decrypted. /// The NCA version is not supported. /// An error occured while reading PFS data. @@ -474,6 +490,148 @@ byte[] Read(long position, int size) return true; } + public bool TryGetDownloadableContentFromFile(string filePath, out List titleUpdates) + { + titleUpdates = []; + + try + { + string extension = Path.GetExtension(filePath).ToLower(); + + using FileStream file = new(filePath, FileMode.Open, FileAccess.Read); + + switch (extension) + { + case ".xci": + case ".nsp": + { + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + using IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(filePath, _virtualFileSystem); + + foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) + { + using var ncaFile = new UniqueRef(); + + pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryOpenNca(ncaFile.Get.AsStorage()); + if (nca == null) + { + continue; + } + + if (nca.Header.ContentType == NcaContentType.PublicData) + { + titleUpdates.Add(new DownloadableContentModel(nca.Header.TitleId, filePath, fileEntry.FullPath)); + } + } + + return titleUpdates.Count != 0; + } + } + } + catch (MissingKeyException exception) + { + Logger.Warning?.Print(LogClass.Application, $"Your key set is missing a key with the name: {exception.Name}"); + } + catch (InvalidDataException) + { + Logger.Warning?.Print(LogClass.Application, $"The header key is incorrect or missing and therefore the NCA header content type check has failed. Errored File: {filePath}"); + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, exception.Message); + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. File: '{filePath}' Error: {exception}"); + } + + return false; + } + + public bool TryGetTitleUpdatesFromFile(string filePath, out List titleUpdates) + { + titleUpdates = []; + + try + { + string extension = Path.GetExtension(filePath).ToLower(); + + using FileStream file = new(filePath, FileMode.Open, FileAccess.Read); + + switch (extension) + { + case ".xci": + case ".nsp": + { + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + using IFileSystem pfs = + PartitionFileSystemUtils.OpenApplicationFileSystem(filePath, _virtualFileSystem); + + Dictionary updates = + pfs.GetContentData(ContentMetaType.Patch, _virtualFileSystem, checkLevel); + + if (updates.Count == 0) + { + return false; + } + + foreach ((_, ContentMetaData content) in updates) + { + Nca patchNca = content.GetNcaByType(_virtualFileSystem.KeySet, ContentType.Program); + Nca controlNca = content.GetNcaByType(_virtualFileSystem.KeySet, ContentType.Control); + + if (controlNca != null && patchNca != null) + { + ApplicationControlProperty controlData = new(); + + using UniqueRef nacpFile = new(); + + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None) + .OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read) + .ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), + ReadOption.None).ThrowIfFailure(); + + var displayVersion = controlData.DisplayVersionString.ToString(); + var update = new TitleUpdateModel(content.ApplicationId, content.Version.Version, + displayVersion, filePath); + + titleUpdates.Add(update); + } + } + + return true; + } + } + } + catch (MissingKeyException exception) + { + Logger.Warning?.Print(LogClass.Application, $"Your key set is missing a key with the name: {exception.Name}"); + } + catch (InvalidDataException) + { + Logger.Warning?.Print(LogClass.Application, $"The header key is incorrect or missing and therefore the NCA header content type check has failed. Errored File: {filePath}"); + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, exception.Message); + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. File: '{filePath}' Error: {exception}"); + } + + return false; + } + public void CancelLoading() { _cancellationToken?.Cancel(); @@ -493,6 +651,7 @@ public void LoadApplications(List appDirs) int numApplicationsLoaded = 0; _cancellationToken = new CancellationTokenSource(); + _applications.Clear(); // Builds the applications list with paths to found applications List applicationPaths = new(); @@ -524,12 +683,12 @@ public void LoadApplications(List appDirs) IEnumerable files = Directory.EnumerateFiles(appDir, "*", options).Where(file => { return - (Path.GetExtension(file).ToLower() is ".nsp" && ConfigurationState.Instance.UI.ShownFileTypes.NSP.Value) || - (Path.GetExtension(file).ToLower() is ".pfs0" && ConfigurationState.Instance.UI.ShownFileTypes.PFS0.Value) || - (Path.GetExtension(file).ToLower() is ".xci" && ConfigurationState.Instance.UI.ShownFileTypes.XCI.Value) || - (Path.GetExtension(file).ToLower() is ".nca" && ConfigurationState.Instance.UI.ShownFileTypes.NCA.Value) || - (Path.GetExtension(file).ToLower() is ".nro" && ConfigurationState.Instance.UI.ShownFileTypes.NRO.Value) || - (Path.GetExtension(file).ToLower() is ".nso" && ConfigurationState.Instance.UI.ShownFileTypes.NSO.Value); + (Path.GetExtension(file).ToLower() is ".nsp" && ConfigurationState.Instance.UI.ShownFileTypes.NSP.Value) || + (Path.GetExtension(file).ToLower() is ".pfs0" && ConfigurationState.Instance.UI.ShownFileTypes.PFS0.Value) || + (Path.GetExtension(file).ToLower() is ".xci" && ConfigurationState.Instance.UI.ShownFileTypes.XCI.Value) || + (Path.GetExtension(file).ToLower() is ".nca" && ConfigurationState.Instance.UI.ShownFileTypes.NCA.Value) || + (Path.GetExtension(file).ToLower() is ".nro" && ConfigurationState.Instance.UI.ShownFileTypes.NRO.Value) || + (Path.GetExtension(file).ToLower() is ".nso" && ConfigurationState.Instance.UI.ShownFileTypes.NSO.Value); }); foreach (string app in files) @@ -570,13 +729,19 @@ public void LoadApplications(List appDirs) if (TryGetApplicationsFromFile(applicationPath, out List applications)) { - foreach (var application in applications) + _applications.Edit(it => { - OnApplicationAdded(new ApplicationAddedEventArgs + foreach (var application in applications) { - AppData = application, - }); - } + it.AddOrUpdate(application); + LoadDlcForApplication(application); + if (LoadTitleUpdatesForApplication(application)) + { + // Trigger a reload of the version data + RefreshApplicationInfo(application.IdBase); + } + } + }); if (applications.Count > 1) { @@ -610,9 +775,236 @@ public void LoadApplications(List appDirs) } } - protected void OnApplicationAdded(ApplicationAddedEventArgs e) + // Replace the currently stored DLC state for the game with the provided DLC state. + public void SaveDownloadableContentsForGame(ApplicationData application, List<(DownloadableContentModel, bool IsEnabled)> dlcs) + { + _downloadableContents.Edit(it => + { + DownloadableContentsHelper.SaveDownloadableContentsJson(_virtualFileSystem, application.IdBase, dlcs); + + it.Remove(it.Items.Where(item => item.Dlc.TitleIdBase == application.IdBase)); + it.AddOrUpdate(dlcs); + }); + } + + // Replace the currently stored update state for the game with the provided update state. + public void SaveTitleUpdatesForGame(ApplicationData application, List<(TitleUpdateModel, bool IsSelected)> updates) + { + _titleUpdates.Edit(it => + { + TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, application.IdBase, updates); + + it.Remove(it.Items.Where(item => item.TitleUpdate.TitleIdBase == application.IdBase)); + it.AddOrUpdate(updates); + RefreshApplicationInfo(application.IdBase); + }); + } + + // Searches the provided directories for DLC NSP files that are _valid for the currently detected games in the + // library_, and then enables those DLC. + public int AutoLoadDownloadableContents(List appDirs) + { + _cancellationToken = new CancellationTokenSource(); + + List dlcPaths = new(); + int newDlcLoaded = 0; + + try + { + foreach (string appDir in appDirs) + { + if (_cancellationToken.Token.IsCancellationRequested) + { + return newDlcLoaded; + } + + if (!Directory.Exists(appDir)) + { + Logger.Warning?.Print(LogClass.Application, + $"The specified autoload directory \"{appDir}\" does not exist."); + + continue; + } + + try + { + EnumerationOptions options = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + }; + + IEnumerable files = Directory.EnumerateFiles(appDir, "*", options).Where( + file => Path.GetExtension(file).ToLower() is ".nsp"); + + foreach (string app in files) + { + if (_cancellationToken.Token.IsCancellationRequested) + { + return newDlcLoaded; + } + + var fileInfo = new FileInfo(app); + + try + { + var fullPath = fileInfo.ResolveLinkTarget(true)?.FullName ?? fileInfo.FullName; + + dlcPaths.Add(fullPath); + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, + $"Failed to resolve the full path to file: \"{app}\" Error: {exception}"); + } + } + } + catch (UnauthorizedAccessException) + { + Logger.Warning?.Print(LogClass.Application, + $"Failed to get access to directory: \"{appDir}\""); + } + } + + var appIdLookup = Applications.Items.Select(it => it.IdBase).ToHashSet(); + + foreach (string dlcPath in dlcPaths) + { + if (_cancellationToken.Token.IsCancellationRequested) + { + return newDlcLoaded; + } + + if (TryGetDownloadableContentFromFile(dlcPath, out var foundDlcs)) + { + foreach (var dlc in foundDlcs.Where(it => appIdLookup.Contains(it.TitleIdBase))) + { + if (!_downloadableContents.Lookup(dlc).HasValue) + { + _downloadableContents.AddOrUpdate((dlc, true)); + SaveDownloadableContentsForGame(dlc.TitleIdBase); + newDlcLoaded++; + } + } + } + } + } + finally + { + _cancellationToken.Dispose(); + _cancellationToken = null; + } + + return newDlcLoaded; + } + + // Searches the provided directories for update NSP files that are _valid for the currently detected games in the + // library_, and then applies those updates. If a newly-detected update is a newer version than the currently + // selected update (or if no update is currently selected), then that update will be selected. + public int AutoLoadTitleUpdates(List appDirs) { - ApplicationAdded?.Invoke(null, e); + _cancellationToken = new CancellationTokenSource(); + + List updatePaths = new(); + int numUpdatesLoaded = 0; + + try + { + foreach (string appDir in appDirs) + { + if (_cancellationToken.Token.IsCancellationRequested) + { + return numUpdatesLoaded; + } + + if (!Directory.Exists(appDir)) + { + Logger.Warning?.Print(LogClass.Application, + $"The specified autoload directory \"{appDir}\" does not exist."); + + continue; + } + + try + { + EnumerationOptions options = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + }; + + IEnumerable files = Directory.EnumerateFiles(appDir, "*", options).Where( + file => Path.GetExtension(file).ToLower() is ".nsp"); + + foreach (string app in files) + { + if (_cancellationToken.Token.IsCancellationRequested) + { + return numUpdatesLoaded; + } + + var fileInfo = new FileInfo(app); + + try + { + var fullPath = fileInfo.ResolveLinkTarget(true)?.FullName ?? fileInfo.FullName; + + updatePaths.Add(fullPath); + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, + $"Failed to resolve the full path to file: \"{app}\" Error: {exception}"); + } + } + } + catch (UnauthorizedAccessException) + { + Logger.Warning?.Print(LogClass.Application, + $"Failed to get access to directory: \"{appDir}\""); + } + } + + var appIdLookup = Applications.Items.Select(it => it.IdBase).ToHashSet(); + + foreach (string updatePath in updatePaths) + { + if (_cancellationToken.Token.IsCancellationRequested) + { + return numUpdatesLoaded; + } + + if (TryGetTitleUpdatesFromFile(updatePath, out var foundUpdates)) + { + foreach (var update in foundUpdates.Where(it => appIdLookup.Contains(it.TitleIdBase))) + { + if (!_titleUpdates.Lookup(update).HasValue) + { + var currentlySelected = TitleUpdates.Items.FirstOrOptional(it => + it.TitleUpdate.TitleIdBase == update.TitleIdBase && it.IsSelected); + + var shouldSelect = !currentlySelected.HasValue || + currentlySelected.Value.TitleUpdate.Version < update.Version; + _titleUpdates.AddOrUpdate((update, shouldSelect)); + SaveTitleUpdatesForGame(update.TitleIdBase); + numUpdatesLoaded++; + + if (shouldSelect) + { + RefreshApplicationInfo(update.TitleIdBase); + } + } + } + } + } + } + finally + { + _cancellationToken.Dispose(); + _cancellationToken = null; + } + + return numUpdatesLoaded; } protected void OnApplicationCountUpdated(ApplicationCountUpdatedEventArgs e) @@ -936,5 +1328,128 @@ private bool IsUpdateApplied(Nca mainNca, out IFileSystem updatedControlFs) return false; } + + private Nca TryOpenNca(IStorage ncaStorage) + { + try + { + return new Nca(_virtualFileSystem.KeySet, ncaStorage); + } + catch (Exception) { } + + return null; + } + + // Does a two-phase load of DLC. First reading the metadata on disk, then loading anything bundled in the game + // file itself + private void LoadDlcForApplication(ApplicationData application) + { + _downloadableContents.Edit(it => + { + var savedDlc = + DownloadableContentsHelper.LoadDownloadableContentsJson(_virtualFileSystem, application.IdBase); + it.AddOrUpdate(savedDlc); + + if (TryGetDownloadableContentFromFile(application.Path, out var bundledDlc)) + { + var savedDlcLookup = savedDlc.Select(dlc => dlc.Item1).ToHashSet(); + + bool addedNewDlc = false; + foreach (var dlc in bundledDlc) + { + if (!savedDlcLookup.Contains(dlc)) + { + addedNewDlc = true; + it.AddOrUpdate((dlc, true)); + } + } + + if (addedNewDlc) + { + var gameDlcs = it.Items.Where(dlc => dlc.Dlc.TitleIdBase == application.IdBase).ToList(); + DownloadableContentsHelper.SaveDownloadableContentsJson(_virtualFileSystem, application.IdBase, + gameDlcs); + } + } + }); + } + + // Does a two-phase load of updates. First reading the metadata on disk, then loading anything bundled in the game + // file itself + private bool LoadTitleUpdatesForApplication(ApplicationData application) + { + var modifiedVersion = false; + + _titleUpdates.Edit(it => + { + var savedUpdates = + TitleUpdatesHelper.LoadTitleUpdatesJson(_virtualFileSystem, application.IdBase); + it.AddOrUpdate(savedUpdates); + + var selectedUpdate = savedUpdates.FirstOrOptional(update => update.IsSelected); + + if (TryGetTitleUpdatesFromFile(application.Path, out var bundledUpdates)) + { + var savedUpdateLookup = savedUpdates.Select(update => update.Item1).ToHashSet(); + + bool addedNewUpdate = false; + foreach (var update in bundledUpdates.OrderByDescending(bundled => bundled.Version)) + { + if (!savedUpdateLookup.Contains(update)) + { + bool shouldSelect = false; + if (!selectedUpdate.HasValue || selectedUpdate.Value.Item1.Version < update.Version) + { + shouldSelect = true; + selectedUpdate = Optional<(TitleUpdateModel, bool IsSelected)>.Create((update, true)); + } + + modifiedVersion = modifiedVersion || shouldSelect; + it.AddOrUpdate((update, shouldSelect)); + + addedNewUpdate = true; + } + } + + if (addedNewUpdate) + { + var gameUpdates = it.Items.Where(update => update.TitleUpdate.TitleIdBase == application.IdBase).ToList(); + TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, application.IdBase, gameUpdates); + } + } + }); + + return modifiedVersion; + } + + // Save the _currently tracked_ DLC state for the game + private void SaveDownloadableContentsForGame(ulong titleIdBase) + { + var dlcs = DownloadableContents.Items.Where(dlc => dlc.Dlc.TitleIdBase == titleIdBase).ToList(); + DownloadableContentsHelper.SaveDownloadableContentsJson(_virtualFileSystem, titleIdBase, dlcs); + } + + // Save the _currently tracked_ update state for the game + private void SaveTitleUpdatesForGame(ulong titleIdBase) + { + var updates = TitleUpdates.Items.Where(update => update.TitleUpdate.TitleIdBase == titleIdBase).ToList(); + TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, titleIdBase, updates); + } + + // ApplicationData isnt live-updating (e.g. when an update gets applied) and so this is meant to trigger a refresh + // of its state + private void RefreshApplicationInfo(ulong appIdBase) + { + var application = _applications.Lookup(appIdBase); + + if (!application.HasValue) + return; + + if (!TryGetApplicationsFromFile(application.Value.Path, out List newApplications)) + return; + + var newApplication = newApplications.First(it => it.IdBase == appIdBase); + _applications.AddOrUpdate(newApplication); + } } } diff --git a/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs index 8a0be4028..77c4c4260 100644 --- a/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs @@ -15,7 +15,7 @@ public class ConfigurationFileFormat /// /// The current version of the file format /// - public const int CurrentVersion = 51; + public const int CurrentVersion = 52; /// /// Version of the configuration file format @@ -262,6 +262,11 @@ public class ConfigurationFileFormat /// public List GameDirs { get; set; } + /// + /// A list of directories containing DLC/updates the user wants to autoload during library refreshes + /// + public List AutoloadDirs { get; set; } + /// /// A list of file types to be hidden in the games List /// diff --git a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs index 8420dc5d9..2cb814afa 100644 --- a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs +++ b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs @@ -122,6 +122,11 @@ public WindowStartupSettings() /// public ReactiveObject> GameDirs { get; private set; } + /// + /// A list of directories containing DLC/updates the user wants to autoload during library refreshes + /// + public ReactiveObject> AutoloadDirs { get; private set; } + /// /// A list of file types to be hidden in the games List /// @@ -192,6 +197,7 @@ public UISection() GuiColumns = new Columns(); ColumnSort = new ColumnSortSettings(); GameDirs = new ReactiveObject>(); + AutoloadDirs = new ReactiveObject>(); ShownFileTypes = new ShownFileTypeSettings(); WindowStartup = new WindowStartupSettings(); EnableCustomTheme = new ReactiveObject(); @@ -728,6 +734,7 @@ public ConfigurationFileFormat ToFileFormat() SortAscending = UI.ColumnSort.SortAscending, }, GameDirs = UI.GameDirs, + AutoloadDirs = UI.AutoloadDirs, ShownFileTypes = new ShownFileTypes { NSP = UI.ShownFileTypes.NSP, @@ -836,6 +843,7 @@ public void LoadDefault() UI.ColumnSort.SortColumnId.Value = 0; UI.ColumnSort.SortAscending.Value = false; UI.GameDirs.Value = new List(); + UI.AutoloadDirs.Value = new List(); UI.ShownFileTypes.NSP.Value = true; UI.ShownFileTypes.PFS0.Value = true; UI.ShownFileTypes.XCI.Value = true; @@ -1477,6 +1485,15 @@ public void Load(ConfigurationFileFormat configurationFileFormat, string configu configurationFileUpdated = true; } + if (configurationFileFormat.Version < 52) + { + Ryujinx.Common.Logging.Logger.Warning?.Print(LogClass.Application, $"Outdated configuration version {configurationFileFormat.Version}, migrating to version 52."); + + configurationFileFormat.AutoloadDirs = new(); + + configurationFileUpdated = true; + } + Logger.EnableFileLog.Value = configurationFileFormat.EnableFileLog; Graphics.ResScale.Value = configurationFileFormat.ResScale; Graphics.ResScaleCustom.Value = configurationFileFormat.ResScaleCustom; @@ -1538,6 +1555,7 @@ public void Load(ConfigurationFileFormat configurationFileFormat, string configu UI.ColumnSort.SortColumnId.Value = configurationFileFormat.ColumnSort.SortColumnId; UI.ColumnSort.SortAscending.Value = configurationFileFormat.ColumnSort.SortAscending; UI.GameDirs.Value = configurationFileFormat.GameDirs; + UI.AutoloadDirs.Value = configurationFileFormat.AutoloadDirs; UI.ShownFileTypes.NSP.Value = configurationFileFormat.ShownFileTypes.NSP; UI.ShownFileTypes.PFS0.Value = configurationFileFormat.ShownFileTypes.PFS0; UI.ShownFileTypes.XCI.Value = configurationFileFormat.ShownFileTypes.XCI; diff --git a/src/Ryujinx.UI.Common/Helper/DownloadableContentsHelper.cs b/src/Ryujinx.UI.Common/Helper/DownloadableContentsHelper.cs new file mode 100644 index 000000000..3695c5c5c --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/DownloadableContentsHelper.cs @@ -0,0 +1,135 @@ +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Utilities; +using Ryujinx.UI.Common.Models; +using System; +using System.Collections.Generic; +using System.IO; +using Path = System.IO.Path; + +namespace Ryujinx.UI.Common.Helper +{ + public static class DownloadableContentsHelper + { + private static readonly DownloadableContentJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + public static List<(DownloadableContentModel, bool IsEnabled)> LoadDownloadableContentsJson(VirtualFileSystem vfs, ulong applicationIdBase) + { + var downloadableContentJsonPath = PathToGameDLCJson(applicationIdBase); + + if (!File.Exists(downloadableContentJsonPath)) + { + return []; + } + + try + { + var downloadableContentContainerList = JsonHelper.DeserializeFromFile(downloadableContentJsonPath, + _serializerContext.ListDownloadableContentContainer); + return LoadDownloadableContents(vfs, downloadableContentContainerList); + } + catch + { + Logger.Error?.Print(LogClass.Configuration, "Downloadable Content JSON failed to deserialize."); + return []; + } + } + + public static void SaveDownloadableContentsJson(VirtualFileSystem vfs, ulong applicationIdBase, List<(DownloadableContentModel, bool IsEnabled)> dlcs) + { + DownloadableContentContainer container = default; + List downloadableContentContainerList = new(); + + foreach ((DownloadableContentModel dlc, bool isEnabled) in dlcs) + { + if (container.ContainerPath != dlc.ContainerPath) + { + if (!string.IsNullOrWhiteSpace(container.ContainerPath)) + { + downloadableContentContainerList.Add(container); + } + + container = new DownloadableContentContainer + { + ContainerPath = dlc.ContainerPath, + DownloadableContentNcaList = [], + }; + } + + container.DownloadableContentNcaList.Add(new DownloadableContentNca + { + Enabled = isEnabled, + TitleId = dlc.TitleId, + FullPath = dlc.FullPath, + }); + } + + if (!string.IsNullOrWhiteSpace(container.ContainerPath)) + { + downloadableContentContainerList.Add(container); + } + + var downloadableContentJsonPath = PathToGameDLCJson(applicationIdBase); + JsonHelper.SerializeToFile(downloadableContentJsonPath, downloadableContentContainerList, _serializerContext.ListDownloadableContentContainer); + } + + private static List<(DownloadableContentModel, bool IsEnabled)> LoadDownloadableContents(VirtualFileSystem vfs, List downloadableContentContainers) + { + var result = new List<(DownloadableContentModel, bool IsEnabled)>(); + + foreach (DownloadableContentContainer downloadableContentContainer in downloadableContentContainers) + { + if (!File.Exists(downloadableContentContainer.ContainerPath)) + { + continue; + } + + using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(downloadableContentContainer.ContainerPath, vfs); + + foreach (DownloadableContentNca downloadableContentNca in downloadableContentContainer.DownloadableContentNcaList) + { + using UniqueRef ncaFile = new(); + + partitionFileSystem.OpenFile(ref ncaFile.Ref, downloadableContentNca.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryOpenNca(vfs, ncaFile.Get.AsStorage()); + if (nca == null) + { + continue; + } + + var content = new DownloadableContentModel(nca.Header.TitleId, + downloadableContentContainer.ContainerPath, + downloadableContentNca.FullPath); + + result.Add((content, downloadableContentNca.Enabled)); + } + } + + return result; + } + + private static Nca TryOpenNca(VirtualFileSystem vfs, IStorage ncaStorage) + { + try + { + return new Nca(vfs.KeySet, ncaStorage); + } + catch (Exception) { } + + return null; + } + + private static string PathToGameDLCJson(ulong applicationIdBase) + { + return Path.Combine(AppDataManager.GamesDirPath, applicationIdBase.ToString("x16"), "dlc.json"); + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs b/src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs new file mode 100644 index 000000000..9dc3d4f73 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/TitleUpdatesHelper.cs @@ -0,0 +1,162 @@ +using LibHac.Common; +using LibHac.Common.Keys; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.Ncm; +using LibHac.Ns; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; +using Ryujinx.UI.Common.Configuration; +using Ryujinx.UI.Common.Models; +using System; +using System.Collections.Generic; +using System.IO; +using ContentType = LibHac.Ncm.ContentType; +using Path = System.IO.Path; +using SpanHelpers = LibHac.Common.SpanHelpers; +using TitleUpdateMetadata = Ryujinx.Common.Configuration.TitleUpdateMetadata; + +namespace Ryujinx.UI.Common.Helper +{ + public static class TitleUpdatesHelper + { + private static readonly TitleUpdateMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + public static List<(TitleUpdateModel, bool IsSelected)> LoadTitleUpdatesJson(VirtualFileSystem vfs, ulong applicationIdBase) + { + var titleUpdatesJsonPath = PathToGameUpdatesJson(applicationIdBase); + + if (!File.Exists(titleUpdatesJsonPath)) + { + return []; + } + + try + { + var titleUpdateWindowData = JsonHelper.DeserializeFromFile(titleUpdatesJsonPath, _serializerContext.TitleUpdateMetadata); + return LoadTitleUpdates(vfs, titleUpdateWindowData, applicationIdBase); + } + catch + { + Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {applicationIdBase:x16} at {titleUpdatesJsonPath}"); + return []; + } + } + + public static void SaveTitleUpdatesJson(VirtualFileSystem vfs, ulong applicationIdBase, List<(TitleUpdateModel, bool IsSelected)> updates) + { + var titleUpdateWindowData = new TitleUpdateMetadata + { + Selected = "", + Paths = [], + }; + + foreach ((TitleUpdateModel update, bool isSelected) in updates) + { + titleUpdateWindowData.Paths.Add(update.Path); + if (isSelected) + { + if (!string.IsNullOrEmpty(titleUpdateWindowData.Selected)) + { + Logger.Error?.Print(LogClass.Application, + $"Tried to save two updates as 'IsSelected' for {applicationIdBase:x16}"); + return; + } + + titleUpdateWindowData.Selected = update.Path; + } + } + + var titleUpdatesJsonPath = PathToGameUpdatesJson(applicationIdBase); + JsonHelper.SerializeToFile(titleUpdatesJsonPath, titleUpdateWindowData, _serializerContext.TitleUpdateMetadata); + } + + private static List<(TitleUpdateModel, bool IsSelected)> LoadTitleUpdates(VirtualFileSystem vfs, TitleUpdateMetadata titleUpdateMetadata, ulong applicationIdBase) + { + var result = new List<(TitleUpdateModel, bool IsSelected)>(); + + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + foreach (string path in titleUpdateMetadata.Paths) + { + if (!File.Exists(path)) + { + continue; + } + + try + { + using IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(path, vfs); + + Dictionary updates = + pfs.GetContentData(ContentMetaType.Patch, vfs, checkLevel); + + Nca patchNca = null; + Nca controlNca = null; + + if (!updates.TryGetValue(applicationIdBase, out ContentMetaData content)) + { + continue; + } + + patchNca = content.GetNcaByType(vfs.KeySet, ContentType.Program); + controlNca = content.GetNcaByType(vfs.KeySet, ContentType.Control); + + if (controlNca == null || patchNca == null) + { + continue; + } + + ApplicationControlProperty controlData = new(); + + using UniqueRef nacpFile = new(); + + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None) + .OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None) + .ThrowIfFailure(); + + var displayVersion = controlData.DisplayVersionString.ToString(); + var update = new TitleUpdateModel(content.ApplicationId, content.Version.Version, + displayVersion, path); + + result.Add((update, path == titleUpdateMetadata.Selected)); + } + catch (MissingKeyException exception) + { + Logger.Warning?.Print(LogClass.Application, + $"Your key set is missing a key with the name: {exception.Name}"); + } + catch (InvalidDataException) + { + Logger.Warning?.Print(LogClass.Application, + $"The header key is incorrect or missing and therefore the NCA header content type check has failed. Errored File: {path}"); + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, exception.Message); + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, + $"The file encountered was not of a valid type. File: '{path}' Error: {exception}"); + } + } + + return result; + } + + private static string PathToGameUpdatesJson(ulong applicationIdBase) + { + return Path.Combine(AppDataManager.GamesDirPath, applicationIdBase.ToString("x16"), "updates.json"); + } + } +} diff --git a/src/Ryujinx.UI.Common/Models/DownloadableContentModel.cs b/src/Ryujinx.UI.Common/Models/DownloadableContentModel.cs new file mode 100644 index 000000000..95c64f078 --- /dev/null +++ b/src/Ryujinx.UI.Common/Models/DownloadableContentModel.cs @@ -0,0 +1,12 @@ +namespace Ryujinx.UI.Common.Models +{ + // NOTE: most consuming code relies on this model being value-comparable + public record DownloadableContentModel(ulong TitleId, string ContainerPath, string FullPath) + { + public bool IsBundled { get; } = System.IO.Path.GetExtension(ContainerPath)?.ToLower() == ".xci"; + + public string FileName => System.IO.Path.GetFileName(ContainerPath); + public string TitleIdStr => TitleId.ToString("x16"); + public ulong TitleIdBase => TitleId & ~0x1FFFUL; + } +} diff --git a/src/Ryujinx.UI.Common/Models/TitleUpdateModel.cs b/src/Ryujinx.UI.Common/Models/TitleUpdateModel.cs new file mode 100644 index 000000000..5422e1303 --- /dev/null +++ b/src/Ryujinx.UI.Common/Models/TitleUpdateModel.cs @@ -0,0 +1,11 @@ +namespace Ryujinx.UI.Common.Models +{ + // NOTE: most consuming code relies on this model being value-comparable + public record TitleUpdateModel(ulong TitleId, ulong Version, string DisplayVersion, string Path) + { + public bool IsBundled { get; } = System.IO.Path.GetExtension(Path)?.ToLower() == ".xci"; + + public string TitleIdStr => TitleId.ToString("x16"); + public ulong TitleIdBase => TitleId & ~0x1FFFUL; + } +} diff --git a/src/Ryujinx.UI.Common/Ryujinx.UI.Common.csproj b/src/Ryujinx.UI.Common/Ryujinx.UI.Common.csproj index 387e998b0..fcbbaba30 100644 --- a/src/Ryujinx.UI.Common/Ryujinx.UI.Common.csproj +++ b/src/Ryujinx.UI.Common/Ryujinx.UI.Common.csproj @@ -56,6 +56,7 @@ + diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index b3cab7f5f..45befacb3 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -12,6 +12,8 @@ "MenuBarFileOpenFromFile": "_Load Application From File", "MenuBarFileOpenFromFileError": "No applications found in selected file.", "MenuBarFileOpenUnpacked": "Load _Unpacked Game", + "MenuBarFileLoadDlcFromFolder": "Load DLC From Folder", + "MenuBarFileLoadTitleUpdatesFromFolder": "Load Title Updates From Folder", "MenuBarFileOpenEmuFolder": "Open Ryujinx Folder", "MenuBarFileOpenLogsFolder": "Open Logs Folder", "MenuBarFileExit": "_Exit", @@ -103,6 +105,7 @@ "SettingsTabGeneralHideCursorOnIdle": "On Idle", "SettingsTabGeneralHideCursorAlways": "Always", "SettingsTabGeneralGameDirectories": "Game Directories", + "SettingsTabGeneralAutoloadDirectories": "Autoload DLC/Updates Directories", "SettingsTabGeneralAdd": "Add", "SettingsTabGeneralRemove": "Remove", "SettingsTabSystem": "System", @@ -556,6 +559,9 @@ "AddGameDirBoxTooltip": "Enter a game directory to add to the list", "AddGameDirTooltip": "Add a game directory to the list", "RemoveGameDirTooltip": "Remove selected game directory", + "AddAutoloadDirBoxTooltip": "Enter an autoload directory to add to the list", + "AddAutoloadDirTooltip": "Add an autoload directory to the list", + "RemoveAutoloadDirTooltip": "Remove selected autoload directory", "CustomThemeCheckTooltip": "Use a custom Avalonia theme for the GUI to change the appearance of the emulator menus", "CustomThemePathTooltip": "Path to custom GUI theme", "CustomThemeBrowseTooltip": "Browse for a custom GUI theme", @@ -599,6 +605,8 @@ "DebugLogTooltip": "Prints debug log messages in the console.\n\nOnly use this if specifically instructed by a staff member, as it will make logs difficult to read and worsen emulator performance.", "LoadApplicationFileTooltip": "Open a file explorer to choose a Switch compatible file to load", "LoadApplicationFolderTooltip": "Open a file explorer to choose a Switch compatible, unpacked application to load", + "LoadDlcFromFolderTooltip": "Open a file explorer to choose one or more folders to bulk load DLC from", + "LoadTitleUpdatesFromFolderTooltip": "Open a file explorer to choose one or more folders to bulk load title updates from", "OpenRyujinxFolderTooltip": "Open Ryujinx filesystem folder", "OpenRyujinxLogsTooltip": "Opens the folder where logs are written to", "ExitTooltip": "Exit Ryujinx", @@ -709,9 +717,16 @@ "DlcWindowTitle": "Manage Downloadable Content for {0} ({1})", "ModWindowTitle": "Manage Mods for {0} ({1})", "UpdateWindowTitle": "Title Update Manager", + "UpdateWindowUpdateAddedMessage": "{0} new update(s) added", + "UpdateWindowBundledContentNotice": "Bundled updates cannot be removed, only disabled.", "CheatWindowHeading": "Cheats Available for {0} [{1}]", "BuildId": "BuildId:", + "DlcWindowBundledContentNotice": "Bundled DLC cannot be removed, only disabled.", "DlcWindowHeading": "{0} Downloadable Content(s)", + "DlcWindowDlcAddedMessage": "{0} new downloadable content(s) added", + "AutoloadDlcAddedMessage": "{0} new downloadable content(s) added", + "AutoloadUpdateAddedMessage": "{0} new update(s) added", + "AutoloadDlcAndUpdateAddedMessage": "{0} new downloadable content(s) and {1} new update(s) added", "ModWindowHeading": "{0} Mod(s)", "UserProfilesEditProfile": "Edit Selected", "Cancel": "Cancel", diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs index 5edd02308..068968650 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs @@ -86,7 +86,7 @@ public async void OpenTitleUpdateManager_Click(object sender, RoutedEventArgs ar if (viewModel?.SelectedApplication != null) { - await TitleUpdateWindow.Show(viewModel.VirtualFileSystem, viewModel.SelectedApplication); + await TitleUpdateWindow.Show(viewModel.ApplicationLibrary, viewModel.SelectedApplication); } } @@ -96,7 +96,7 @@ public async void OpenDownloadableContentManager_Click(object sender, RoutedEven if (viewModel?.SelectedApplication != null) { - await DownloadableContentManagerWindow.Show(viewModel.VirtualFileSystem, viewModel.SelectedApplication); + await DownloadableContentManagerWindow.Show(viewModel.ApplicationLibrary, viewModel.SelectedApplication); } } diff --git a/src/Ryujinx/UI/Helpers/DownloadableContentLabelConverter.cs b/src/Ryujinx/UI/Helpers/DownloadableContentLabelConverter.cs new file mode 100644 index 000000000..22193b97e --- /dev/null +++ b/src/Ryujinx/UI/Helpers/DownloadableContentLabelConverter.cs @@ -0,0 +1,42 @@ +using Avalonia; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Ryujinx.Ava.Common.Locale; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Ryujinx.Ava.UI.Helpers +{ + internal class DownloadableContentLabelConverter : IMultiValueConverter + { + public static DownloadableContentLabelConverter Instance = new(); + + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Any(it => it is UnsetValueType)) + { + return BindingOperations.DoNothing; + } + + if (values.Count != 2 || !targetType.IsAssignableFrom(typeof(string))) + { + return null; + } + + if (values is not [string label, bool isBundled]) + { + return null; + } + + return isBundled ? $"{LocaleManager.Instance[LocaleKeys.TitleBundledDlcLabel]} {label}" : label; + } + + public object[] ConvertBack(object[] values, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + } +} diff --git a/src/Ryujinx/UI/Helpers/Glyph.cs b/src/Ryujinx/UI/Helpers/Glyph.cs index f257dc02c..a6888a67b 100644 --- a/src/Ryujinx/UI/Helpers/Glyph.cs +++ b/src/Ryujinx/UI/Helpers/Glyph.cs @@ -5,5 +5,6 @@ public enum Glyph List, Grid, Chip, + Important, } } diff --git a/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs b/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs index 7da23648e..1544d33ae 100644 --- a/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs +++ b/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs @@ -14,6 +14,7 @@ public class GlyphValueConverter : MarkupExtension { Glyph.List, char.ConvertFromUtf32((int)Symbol.List) }, { Glyph.Grid, char.ConvertFromUtf32((int)Symbol.ViewAll) }, { Glyph.Chip, char.ConvertFromUtf32(59748) }, + { Glyph.Important, char.ConvertFromUtf32((int)Symbol.Important) }, }; public GlyphValueConverter(string key) diff --git a/src/Ryujinx/UI/Helpers/TitleUpdateLabelConverter.cs b/src/Ryujinx/UI/Helpers/TitleUpdateLabelConverter.cs new file mode 100644 index 000000000..cbb6edff1 --- /dev/null +++ b/src/Ryujinx/UI/Helpers/TitleUpdateLabelConverter.cs @@ -0,0 +1,42 @@ +using Avalonia; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Ryujinx.Ava.Common.Locale; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Ryujinx.Ava.UI.Helpers +{ + internal class TitleUpdateLabelConverter : IMultiValueConverter + { + public static TitleUpdateLabelConverter Instance = new(); + + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Any(it => it is UnsetValueType)) + { + return BindingOperations.DoNothing; + } + + if (values.Count != 2 || !targetType.IsAssignableFrom(typeof(string))) + { + return null; + } + + if (values is not [string label, bool isBundled]) + { + return null; + } + + var key = isBundled ? LocaleKeys.TitleBundledUpdateVersionLabel : LocaleKeys.TitleUpdateVersionLabel; + return LocaleManager.Instance.UpdateAndGetDynamicValue(key, label); + } + + public object[] ConvertBack(object[] values, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Ryujinx/UI/Models/DownloadableContentModel.cs b/src/Ryujinx/UI/Models/DownloadableContentModel.cs deleted file mode 100644 index 1409d9713..000000000 --- a/src/Ryujinx/UI/Models/DownloadableContentModel.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Ryujinx.Ava.Common.Locale; -using Ryujinx.Ava.UI.ViewModels; -using System.IO; - -namespace Ryujinx.Ava.UI.Models -{ - public class DownloadableContentModel : BaseModel - { - private bool _enabled; - - public bool Enabled - { - get => _enabled; - set - { - _enabled = value; - - OnPropertyChanged(); - } - } - - public string TitleId { get; } - public string ContainerPath { get; } - public string FullPath { get; } - - public string FileName => Path.GetFileName(ContainerPath); - - public string Label => - Path.GetExtension(FileName)?.ToLower() == ".xci" ? $"{LocaleManager.Instance[LocaleKeys.TitleBundledDlcLabel]} {FileName}" : FileName; - - public DownloadableContentModel(string titleId, string containerPath, string fullPath, bool enabled) - { - TitleId = titleId; - ContainerPath = containerPath; - FullPath = fullPath; - Enabled = enabled; - } - } -} diff --git a/src/Ryujinx/UI/Models/TitleUpdateModel.cs b/src/Ryujinx/UI/Models/TitleUpdateModel.cs deleted file mode 100644 index 46f6f46d8..000000000 --- a/src/Ryujinx/UI/Models/TitleUpdateModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Ryujinx.Ava.Common.Locale; - -namespace Ryujinx.Ava.UI.Models -{ - public class TitleUpdateModel - { - public uint Version { get; } - public string Path { get; } - public string Label { get; } - - public TitleUpdateModel(uint version, string displayVersion, string path) - { - Version = version; - Label = LocaleManager.Instance.UpdateAndGetDynamicValue( - System.IO.Path.GetExtension(path)?.ToLower() == ".xci" ? LocaleKeys.TitleBundledUpdateVersionLabel : LocaleKeys.TitleUpdateVersionLabel, - displayVersion - ); - Path = path; - } - } -} diff --git a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs index c919a7ad1..8206d863b 100644 --- a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs @@ -3,47 +3,32 @@ using Avalonia.Platform.Storage; using Avalonia.Threading; using DynamicData; -using LibHac.Common; -using LibHac.Fs; -using LibHac.Fs.Fsa; -using LibHac.Tools.Fs; -using LibHac.Tools.FsSystem; -using LibHac.Tools.FsSystem.NcaUtils; +using FluentAvalonia.UI.Controls; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Helpers; -using Ryujinx.Ava.UI.Models; -using Ryujinx.Common.Configuration; -using Ryujinx.Common.Logging; -using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; -using Ryujinx.HLE.Loaders.Processes.Extensions; -using Ryujinx.HLE.Utilities; using Ryujinx.UI.App.Common; -using System; +using Ryujinx.UI.Common.Models; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using Application = Avalonia.Application; -using Path = System.IO.Path; namespace Ryujinx.Ava.UI.ViewModels { public class DownloadableContentManagerViewModel : BaseModel { - private readonly List _downloadableContentContainerList; - private readonly string _downloadableContentJsonPath; - - private readonly VirtualFileSystem _virtualFileSystem; + private readonly ApplicationLibrary _applicationLibrary; private AvaloniaList _downloadableContents = new(); - private AvaloniaList _views = new(); private AvaloniaList _selectedDownloadableContents = new(); + private AvaloniaList _views = new(); + private bool _showBundledContentNotice = false; private string _search; private readonly ApplicationData _applicationData; private readonly IStorageProvider _storageProvider; - private static readonly DownloadableContentJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); - public AvaloniaList DownloadableContents { get => _downloadableContents; @@ -92,34 +77,25 @@ public string UpdateCount get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count); } - public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) + public bool ShowBundledContentNotice { - _virtualFileSystem = virtualFileSystem; - - _applicationData = applicationData; - - if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + get => _showBundledContentNotice; + set { - _storageProvider = desktop.MainWindow.StorageProvider; + _showBundledContentNotice = value; + OnPropertyChanged(); } + } - _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, applicationData.IdBaseString, "dlc.json"); - - if (!File.Exists(_downloadableContentJsonPath)) - { - _downloadableContentContainerList = new List(); + public DownloadableContentManagerViewModel(ApplicationLibrary applicationLibrary, ApplicationData applicationData) + { + _applicationLibrary = applicationLibrary; - Save(); - } + _applicationData = applicationData; - try - { - _downloadableContentContainerList = JsonHelper.DeserializeFromFile(_downloadableContentJsonPath, _serializerContext.ListDownloadableContentContainer); - } - catch + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - Logger.Error?.Print(LogClass.Configuration, "Downloadable Content JSON failed to deserialize."); - _downloadableContentContainerList = new List(); + _storageProvider = desktop.MainWindow.StorageProvider; } LoadDownloadableContents(); @@ -127,83 +103,61 @@ public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, private void LoadDownloadableContents() { - foreach (DownloadableContentContainer downloadableContentContainer in _downloadableContentContainerList) - { - if (File.Exists(downloadableContentContainer.ContainerPath)) - { - using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(downloadableContentContainer.ContainerPath, _virtualFileSystem); + var dlcs = _applicationLibrary.DownloadableContents.Items + .Where(it => it.Dlc.TitleIdBase == _applicationData.IdBase); - foreach (DownloadableContentNca downloadableContentNca in downloadableContentContainer.DownloadableContentNcaList) - { - using UniqueRef ncaFile = new(); - - partitionFileSystem.OpenFile(ref ncaFile.Ref, downloadableContentNca.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - Nca nca = TryOpenNca(ncaFile.Get.AsStorage(), downloadableContentContainer.ContainerPath); - if (nca != null) - { - var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"), - downloadableContentContainer.ContainerPath, - downloadableContentNca.FullPath, - downloadableContentNca.Enabled); - - DownloadableContents.Add(content); - - if (content.Enabled) - { - SelectedDownloadableContents.Add(content); - } + bool hasBundledContent = false; + foreach ((DownloadableContentModel dlc, bool isEnabled) in dlcs) + { + DownloadableContents.Add(dlc); + hasBundledContent = hasBundledContent || dlc.IsBundled; - OnPropertyChanged(nameof(UpdateCount)); - } - } + if (isEnabled) + { + SelectedDownloadableContents.Add(dlc); } + + OnPropertyChanged(nameof(UpdateCount)); } - // NOTE: Try to load downloadable contents from PFS last to preserve enabled state. - AddDownloadableContent(_applicationData.Path); + ShowBundledContentNotice = hasBundledContent; - // NOTE: Save the list again to remove leftovers. - Save(); Sort(); } public void Sort() { - DownloadableContents.AsObservableChangeSet() + DownloadableContents + // Sort bundled last + .OrderBy(it => it.IsBundled ? 0 : 1) + .ThenBy(it => it.TitleId) + .AsObservableChangeSet() .Filter(Filter) .Bind(out var view).AsObservableList(); + // NOTE(jpr): this works around a bug where calling _views.Clear also clears SelectedDownloadableContents for + // some reason. so we save the items here and add them back after + var items = SelectedDownloadableContents.ToArray(); + _views.Clear(); _views.AddRange(view); - OnPropertyChanged(nameof(Views)); - } - private bool Filter(object arg) - { - if (arg is DownloadableContentModel content) + foreach (DownloadableContentModel item in items) { - return string.IsNullOrWhiteSpace(_search) || content.FileName.ToLower().Contains(_search.ToLower()) || content.TitleId.ToLower().Contains(_search.ToLower()); + SelectedDownloadableContents.ReplaceOrAdd(item, item); } - return false; + OnPropertyChanged(nameof(Views)); } - private Nca TryOpenNca(IStorage ncaStorage, string containerPath) + private bool Filter(T arg) { - try - { - return new Nca(_virtualFileSystem.KeySet, ncaStorage); - } - catch (Exception ex) + if (arg is DownloadableContentModel content) { - Dispatcher.UIThread.InvokeAsync(async () => - { - await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogLoadFileErrorMessage], ex.Message, containerPath)); - }); + return string.IsNullOrWhiteSpace(_search) || content.FileName.ToLower().Contains(_search.ToLower()) || content.TitleIdStr.ToLower().Contains(_search.ToLower()); } - return null; + return false; } public async void Add() @@ -223,78 +177,88 @@ public async void Add() }, }); + var totalDlcAdded = 0; foreach (var file in result) { - if (!AddDownloadableContent(file.Path.LocalPath)) + if (!AddDownloadableContent(file.Path.LocalPath, out var newDlcAdded)) { await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogDlcNoDlcErrorMessage]); } + + totalDlcAdded += newDlcAdded; + } + + if (totalDlcAdded > 0) + { + await ShowNewDlcAddedDialog(totalDlcAdded); } } - private bool AddDownloadableContent(string path) + private bool AddDownloadableContent(string path, out int numDlcAdded) { - if (!File.Exists(path) || _downloadableContentContainerList.Any(x => x.ContainerPath == path)) + numDlcAdded = 0; + + if (!File.Exists(path)) { - return true; + return false; } - using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(path, _virtualFileSystem); - - bool success = false; - foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca")) + if (!_applicationLibrary.TryGetDownloadableContentFromFile(path, out var dlcs) || dlcs.Count == 0) { - using var ncaFile = new UniqueRef(); + return false; + } - partitionFileSystem.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + var dlcsForThisGame = dlcs.Where(it => it.TitleIdBase == _applicationData.IdBase).ToList(); + if (dlcsForThisGame.Count == 0) + { + return false; + } - Nca nca = TryOpenNca(ncaFile.Get.AsStorage(), path); - if (nca == null) + foreach (var dlc in dlcsForThisGame) + { + if (!DownloadableContents.Contains(dlc)) { - continue; - } + DownloadableContents.Add(dlc); + SelectedDownloadableContents.ReplaceOrAdd(dlc, dlc); - if (nca.Header.ContentType == NcaContentType.PublicData) - { - if (nca.GetProgramIdBase() != _applicationData.IdBase) - { - continue; - } - - var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"), path, fileEntry.FullPath, true); - DownloadableContents.Add(content); - Dispatcher.UIThread.InvokeAsync(() => SelectedDownloadableContents.Add(content)); - - success = true; + numDlcAdded++; } } - if (success) + if (numDlcAdded > 0) { OnPropertyChanged(nameof(UpdateCount)); Sort(); } - return success; + return true; } public void Remove(DownloadableContentModel model) { - DownloadableContents.Remove(model); - OnPropertyChanged(nameof(UpdateCount)); - Sort(); + SelectedDownloadableContents.Remove(model); + + if (!model.IsBundled) + { + DownloadableContents.Remove(model); + OnPropertyChanged(nameof(UpdateCount)); + Sort(); + } } public void RemoveAll() { - DownloadableContents.Clear(); + SelectedDownloadableContents.Clear(); + DownloadableContents.RemoveMany(DownloadableContents.Where(it => !it.IsBundled)); + OnPropertyChanged(nameof(UpdateCount)); Sort(); } public void EnableAll() { - SelectedDownloadableContents = new(DownloadableContents); + SelectedDownloadableContents.Clear(); + SelectedDownloadableContents.AddRange(DownloadableContents); } public void DisableAll() @@ -302,43 +266,29 @@ public void DisableAll() SelectedDownloadableContents.Clear(); } - public void Save() + public void Enable(DownloadableContentModel model) { - _downloadableContentContainerList.Clear(); - - DownloadableContentContainer container = default; - - foreach (DownloadableContentModel downloadableContent in DownloadableContents) - { - if (container.ContainerPath != downloadableContent.ContainerPath) - { - if (!string.IsNullOrWhiteSpace(container.ContainerPath)) - { - _downloadableContentContainerList.Add(container); - } + SelectedDownloadableContents.ReplaceOrAdd(model, model); + } - container = new DownloadableContentContainer - { - ContainerPath = downloadableContent.ContainerPath, - DownloadableContentNcaList = new List(), - }; - } + public void Disable(DownloadableContentModel model) + { + SelectedDownloadableContents.Remove(model); + } - container.DownloadableContentNcaList.Add(new DownloadableContentNca - { - Enabled = downloadableContent.Enabled, - TitleId = Convert.ToUInt64(downloadableContent.TitleId, 16), - FullPath = downloadableContent.FullPath, - }); - } + public void Save() + { + var dlcs = DownloadableContents.Select(it => (it, SelectedDownloadableContents.Contains(it))).ToList(); + _applicationLibrary.SaveDownloadableContentsForGame(_applicationData, dlcs); + } - if (!string.IsNullOrWhiteSpace(container.ContainerPath)) + private Task ShowNewDlcAddedDialog(int numAdded) + { + var msg = string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowDlcAddedMessage], numAdded); + return Dispatcher.UIThread.InvokeAsync(async () => { - _downloadableContentContainerList.Add(container); - } - - JsonHelper.SerializeToFile(_downloadableContentJsonPath, _downloadableContentContainerList, _serializerContext.ListDownloadableContentContainer); + await ContentDialogHelper.ShowTextDialog(LocaleManager.Instance[LocaleKeys.DialogConfirmationTitle], msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark); + }); } - } } diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index bd9f165b9..c9b645a5c 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -6,7 +6,9 @@ using Avalonia.Platform.Storage; using Avalonia.Threading; using DynamicData; +using DynamicData.Alias; using DynamicData.Binding; +using FluentAvalonia.UI.Controls; using LibHac.Common; using Ryujinx.Ava.Common; using Ryujinx.Ava.Common.Locale; @@ -38,6 +40,7 @@ using System.Collections.ObjectModel; using System.Globalization; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Key = Ryujinx.Input.Key; @@ -50,7 +53,7 @@ public class MainWindowViewModel : BaseModel { private const int HotKeyPressDelayMs = 500; - private ObservableCollection _applications; + private ObservableCollectionExtended _applications; private string _aspectStatusText; private string _loadHeading; @@ -112,7 +115,7 @@ public class MainWindowViewModel : BaseModel public MainWindowViewModel() { - Applications = new ObservableCollection(); + Applications = new ObservableCollectionExtended(); Applications.ToObservableChangeSet() .Filter(Filter) @@ -741,7 +744,7 @@ public bool ManageFileTypesVisible get => FileAssociationHelper.IsTypeAssociationSupported; } - public ObservableCollection Applications + public ObservableCollectionExtended Applications { get => _applications; set @@ -1256,6 +1259,30 @@ private void RendererHost_Created(object sender, EventArgs e) _rendererWaitEvent.Set(); } + private async Task LoadContentFromFolder(LocaleKeys localeMessageKey, Func, int> onDirsSelected) + { + var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = LocaleManager.Instance[LocaleKeys.OpenFolderDialogTitle], + AllowMultiple = true, + }); + + if (result.Count > 0) + { + var dirs = result.Select(it => it.Path.LocalPath).ToList(); + var numAdded = onDirsSelected(dirs); + + var msg = string.Format(LocaleManager.Instance[localeMessageKey], numAdded); + + await Dispatcher.UIThread.InvokeAsync(async () => + { + await ContentDialogHelper.ShowTextDialog( + LocaleManager.Instance[numAdded > 0 ? LocaleKeys.RyujinxConfirm : LocaleKeys.RyujinxInfo], + msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark); + }); + } + } + #endregion #region PublicMethods @@ -1504,6 +1531,18 @@ public async Task OpenFile() } } + public async Task LoadDlcFromFolder() + { + await LoadContentFromFolder(LocaleKeys.AutoloadDlcAddedMessage, + dirs => ApplicationLibrary.AutoLoadDownloadableContents(dirs)); + } + + public async Task LoadTitleUpdatesFromFolder() + { + await LoadContentFromFolder(LocaleKeys.AutoloadUpdateAddedMessage, + dirs => ApplicationLibrary.AutoLoadTitleUpdates(dirs)); + } + public async Task OpenFolder() { var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 70e5fa5c7..717d3b0ac 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -44,7 +44,8 @@ public class SettingsViewModel : BaseModel private int _graphicsBackendMultithreadingIndex; private float _volume; private bool _isVulkanAvailable = true; - private bool _directoryChanged; + private bool _gameDirectoryChanged; + private bool _autoloadDirectoryChanged; private readonly List _gpuIds = new(); private int _graphicsBackendIndex; private int _scalingFilter; @@ -115,12 +116,23 @@ public bool IsVulkanAvailable public bool IsHypervisorAvailable => OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64; - public bool DirectoryChanged + public bool GameDirectoryChanged { - get => _directoryChanged; + get => _gameDirectoryChanged; set { - _directoryChanged = value; + _gameDirectoryChanged = value; + + OnPropertyChanged(); + } + } + + public bool AutoloadDirectoryChanged + { + get => _autoloadDirectoryChanged; + set + { + _autoloadDirectoryChanged = value; OnPropertyChanged(); } @@ -230,6 +242,7 @@ public float Volume internal AvaloniaList TimeZones { get; set; } public AvaloniaList GameDirectories { get; set; } + public AvaloniaList AutoloadDirectories { get; set; } public ObservableCollection AvailableGpus { get; set; } public AvaloniaList NetworkInterfaceList @@ -272,6 +285,7 @@ public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager con public SettingsViewModel() { GameDirectories = new AvaloniaList(); + AutoloadDirectories = new AvaloniaList(); TimeZones = new AvaloniaList(); AvailableGpus = new ObservableCollection(); _validTzRegions = new List(); @@ -397,6 +411,9 @@ public void LoadCurrentConfiguration() GameDirectories.Clear(); GameDirectories.AddRange(config.UI.GameDirs.Value); + AutoloadDirectories.Clear(); + AutoloadDirectories.AddRange(config.UI.AutoloadDirs.Value); + BaseStyleIndex = config.UI.BaseStyle.Value switch { "Auto" => 0, @@ -486,12 +503,18 @@ public void SaveSettings() config.RememberWindowState.Value = RememberWindowState; config.HideCursor.Value = (HideCursorMode)HideCursor; - if (_directoryChanged) + if (_gameDirectoryChanged) { List gameDirs = new(GameDirectories); config.UI.GameDirs.Value = gameDirs; } + if (_autoloadDirectoryChanged) + { + List autoloadDirs = new(AutoloadDirectories); + config.UI.AutoloadDirs.Value = autoloadDirs; + } + config.UI.BaseStyle.Value = BaseStyleIndex switch { 0 => "Auto", @@ -587,7 +610,8 @@ public void SaveSettings() SaveSettingsEvent?.Invoke(); - _directoryChanged = false; + _gameDirectoryChanged = false; + _autoloadDirectoryChanged = false; } private static void RevertIfNotSaved() diff --git a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs index e9b39dfe1..108bbbc61 100644 --- a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs @@ -2,48 +2,31 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform.Storage; using Avalonia.Threading; -using LibHac.Common; -using LibHac.Fs; -using LibHac.Fs.Fsa; -using LibHac.Ncm; -using LibHac.Ns; -using LibHac.Tools.FsSystem; -using LibHac.Tools.FsSystem.NcaUtils; +using FluentAvalonia.UI.Controls; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Helpers; -using Ryujinx.Ava.UI.Models; -using Ryujinx.Common.Configuration; -using Ryujinx.Common.Logging; -using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; -using Ryujinx.HLE.Loaders.Processes.Extensions; -using Ryujinx.HLE.Utilities; using Ryujinx.UI.App.Common; -using Ryujinx.UI.Common.Configuration; -using System; +using Ryujinx.UI.Common.Models; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Application = Avalonia.Application; -using ContentType = LibHac.Ncm.ContentType; -using Path = System.IO.Path; -using SpanHelpers = LibHac.Common.SpanHelpers; namespace Ryujinx.Ava.UI.ViewModels { + public record TitleUpdateViewNoUpdateSentinal(); + public class TitleUpdateViewModel : BaseModel { - public TitleUpdateMetadata TitleUpdateWindowData; - public readonly string TitleUpdateJsonPath; - private VirtualFileSystem VirtualFileSystem { get; } + private ApplicationLibrary ApplicationLibrary { get; } private ApplicationData ApplicationData { get; } private AvaloniaList _titleUpdates = new(); private AvaloniaList _views = new(); - private object _selectedUpdate; - - private static readonly TitleUpdateMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + private object _selectedUpdate = new TitleUpdateViewNoUpdateSentinal(); + private bool _showBundledContentNotice = false; public AvaloniaList TitleUpdates { @@ -75,11 +58,21 @@ public object SelectedUpdate } } + public bool ShowBundledContentNotice + { + get => _showBundledContentNotice; + set + { + _showBundledContentNotice = value; + OnPropertyChanged(); + } + } + public IStorageProvider StorageProvider; - public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) + public TitleUpdateViewModel(ApplicationLibrary applicationLibrary, ApplicationData applicationData) { - VirtualFileSystem = virtualFileSystem; + ApplicationLibrary = applicationLibrary; ApplicationData = applicationData; @@ -88,44 +81,29 @@ public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ApplicationData StorageProvider = desktop.MainWindow.StorageProvider; } - TitleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, ApplicationData.IdBaseString, "updates.json"); - - try - { - TitleUpdateWindowData = JsonHelper.DeserializeFromFile(TitleUpdateJsonPath, _serializerContext.TitleUpdateMetadata); - } - catch - { - Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {ApplicationData.IdBaseString} at {TitleUpdateJsonPath}"); - - TitleUpdateWindowData = new TitleUpdateMetadata - { - Selected = "", - Paths = new List(), - }; - - Save(); - } - LoadUpdates(); } private void LoadUpdates() { - // Try to load updates from PFS first - AddUpdate(ApplicationData.Path, true); + var updates = ApplicationLibrary.TitleUpdates.Items + .Where(it => it.TitleUpdate.TitleIdBase == ApplicationData.IdBase); - foreach (string path in TitleUpdateWindowData.Paths) + bool hasBundledContent = false; + SelectedUpdate = new TitleUpdateViewNoUpdateSentinal(); + foreach ((TitleUpdateModel update, bool isSelected) in updates) { - AddUpdate(path); + TitleUpdates.Add(update); + hasBundledContent = hasBundledContent || update.IsBundled; + + if (isSelected) + { + SelectedUpdate = update; + } } - TitleUpdateModel selected = TitleUpdates.FirstOrDefault(x => x.Path == TitleUpdateWindowData.Selected, null); + ShowBundledContentNotice = hasBundledContent; - SelectedUpdate = selected; - - // NOTE: Save the list again to remove leftovers. - Save(); SortUpdates(); } @@ -133,89 +111,76 @@ public void SortUpdates() { var sortedUpdates = TitleUpdates.OrderByDescending(update => update.Version); + // NOTE(jpr): this works around a bug where calling Views.Clear also clears SelectedUpdate for + // some reason. so we save the item here and restore it after + var selected = SelectedUpdate; + Views.Clear(); - Views.Add(new BaseModel()); + Views.Add(new TitleUpdateViewNoUpdateSentinal()); Views.AddRange(sortedUpdates); - if (SelectedUpdate == null) + SelectedUpdate = selected; + + if (SelectedUpdate is TitleUpdateViewNoUpdateSentinal) { SelectedUpdate = Views[0]; } - else if (!TitleUpdates.Contains(SelectedUpdate)) + // this is mainly to handle a scenario where the user removes the selected update + else if (!TitleUpdates.Contains((TitleUpdateModel)SelectedUpdate)) { - if (Views.Count > 1) - { - SelectedUpdate = Views[1]; - } - else - { - SelectedUpdate = Views[0]; - } + SelectedUpdate = Views.Count > 1 ? Views[1] : Views[0]; } } - private void AddUpdate(string path, bool ignoreNotFound = false, bool selected = false) + private bool AddUpdate(string path, out int numUpdatesAdded) { - if (!File.Exists(path) || TitleUpdates.Any(x => x.Path == path)) + numUpdatesAdded = 0; + + if (!File.Exists(path)) { - return; + return false; } - IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks - ? IntegrityCheckLevel.ErrorOnInvalid - : IntegrityCheckLevel.None; - - try + if (!ApplicationLibrary.TryGetTitleUpdatesFromFile(path, out var updates)) { - using IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(path, VirtualFileSystem); - - Dictionary updates = pfs.GetContentData(ContentMetaType.Patch, VirtualFileSystem, checkLevel); - - Nca patchNca = null; - Nca controlNca = null; + return false; + } - if (updates.TryGetValue(ApplicationData.Id, out ContentMetaData content)) - { - patchNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Program); - controlNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Control); - } + var updatesForThisGame = updates.Where(it => it.TitleIdBase == ApplicationData.Id).ToList(); + if (updatesForThisGame.Count == 0) + { + return false; + } - if (controlNca != null && patchNca != null) + foreach (var update in updatesForThisGame) + { + if (!TitleUpdates.Contains(update)) { - ApplicationControlProperty controlData = new(); - - using UniqueRef nacpFile = new(); - - controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); - nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); - - var displayVersion = controlData.DisplayVersionString.ToString(); - var update = new TitleUpdateModel(content.Version.Version, displayVersion, path); - TitleUpdates.Add(update); + SelectedUpdate = update; - if (selected) - { - Dispatcher.UIThread.InvokeAsync(() => SelectedUpdate = update); - } - } - else - { - if (!ignoreNotFound) - { - Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage])); - } + numUpdatesAdded++; } } - catch (Exception ex) + + if (numUpdatesAdded > 0) { - Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadFileErrorMessage, ex.Message, path))); + SortUpdates(); } + + return true; } public void RemoveUpdate(TitleUpdateModel update) { - TitleUpdates.Remove(update); + if (!update.IsBundled) + { + TitleUpdates.Remove(update); + } + else if (update == SelectedUpdate as TitleUpdateModel) + { + SelectedUpdate = new TitleUpdateViewNoUpdateSentinal(); + } SortUpdates(); } @@ -236,30 +201,36 @@ public async Task Add() }, }); + var totalUpdatesAdded = 0; foreach (var file in result) { - AddUpdate(file.Path.LocalPath, selected: true); + if (!AddUpdate(file.Path.LocalPath, out var newUpdatesAdded)) + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]); + } + + totalUpdatesAdded += newUpdatesAdded; } - SortUpdates(); + if (totalUpdatesAdded > 0) + { + await ShowNewUpdatesAddedDialog(totalUpdatesAdded); + } } public void Save() { - TitleUpdateWindowData.Paths.Clear(); - TitleUpdateWindowData.Selected = ""; + var updates = TitleUpdates.Select(it => (it, it == SelectedUpdate as TitleUpdateModel)).ToList(); + ApplicationLibrary.SaveTitleUpdatesForGame(ApplicationData, updates); + } - foreach (TitleUpdateModel update in TitleUpdates) + private Task ShowNewUpdatesAddedDialog(int numAdded) + { + var msg = string.Format(LocaleManager.Instance[LocaleKeys.UpdateWindowUpdateAddedMessage], numAdded); + return Dispatcher.UIThread.InvokeAsync(async () => { - TitleUpdateWindowData.Paths.Add(update.Path); - - if (update == SelectedUpdate) - { - TitleUpdateWindowData.Selected = update.Path; - } - } - - JsonHelper.SerializeToFile(TitleUpdateJsonPath, TitleUpdateWindowData, _serializerContext.TitleUpdateMetadata); + await ContentDialogHelper.ShowTextDialog(LocaleManager.Instance[LocaleKeys.DialogConfirmationTitle], msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark); + }); } } } diff --git a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml index 79f9dfefc..0e7e457c4 100644 --- a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml +++ b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml @@ -34,6 +34,16 @@ Header="{locale:Locale MenuBarFileOpenUnpacked}" IsEnabled="{Binding EnableNonGameRunningControls}" ToolTip.Tip="{locale:Locale LoadApplicationFolderTooltip}" /> + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml.cs b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml.cs index 996d15cdb..34ddce071 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml.cs +++ b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml.cs @@ -19,14 +19,14 @@ public SettingsUiView() InitializeComponent(); } - private async void AddButton_OnClick(object sender, RoutedEventArgs e) + private async void AddGameDirButton_OnClick(object sender, RoutedEventArgs e) { - string path = PathBox.Text; + string path = GameDirPathBox.Text; if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path) && !ViewModel.GameDirectories.Contains(path)) { ViewModel.GameDirectories.Add(path); - ViewModel.DirectoryChanged = true; + ViewModel.GameDirectoryChanged = true; } else { @@ -40,25 +40,68 @@ private async void AddButton_OnClick(object sender, RoutedEventArgs e) if (result.Count > 0) { ViewModel.GameDirectories.Add(result[0].Path.LocalPath); - ViewModel.DirectoryChanged = true; + ViewModel.GameDirectoryChanged = true; } } } } - private void RemoveButton_OnClick(object sender, RoutedEventArgs e) + private void RemoveGameDirButton_OnClick(object sender, RoutedEventArgs e) { - int oldIndex = GameList.SelectedIndex; + int oldIndex = GameDirsList.SelectedIndex; - foreach (string path in new List(GameList.SelectedItems.Cast())) + foreach (string path in new List(GameDirsList.SelectedItems.Cast())) { ViewModel.GameDirectories.Remove(path); - ViewModel.DirectoryChanged = true; + ViewModel.GameDirectoryChanged = true; } - if (GameList.ItemCount > 0) + if (GameDirsList.ItemCount > 0) { - GameList.SelectedIndex = oldIndex < GameList.ItemCount ? oldIndex : 0; + GameDirsList.SelectedIndex = oldIndex < GameDirsList.ItemCount ? oldIndex : 0; + } + } + + private async void AddAutoloadDirButton_OnClick(object sender, RoutedEventArgs e) + { + string path = AutoloadDirPathBox.Text; + + if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path) && !ViewModel.AutoloadDirectories.Contains(path)) + { + ViewModel.AutoloadDirectories.Add(path); + ViewModel.AutoloadDirectoryChanged = true; + } + else + { + if (this.GetVisualRoot() is Window window) + { + var result = await window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + AllowMultiple = false, + }); + + if (result.Count > 0) + { + ViewModel.AutoloadDirectories.Add(result[0].Path.LocalPath); + ViewModel.AutoloadDirectoryChanged = true; + } + } + } + } + + private void RemoveAutoloadDirButton_OnClick(object sender, RoutedEventArgs e) + { + int oldIndex = AutoloadDirsList.SelectedIndex; + + foreach (string path in new List(AutoloadDirsList.SelectedItems.Cast())) + { + ViewModel.AutoloadDirectories.Remove(path); + ViewModel.AutoloadDirectoryChanged = true; + } + + if (AutoloadDirsList.ItemCount > 0) + { + AutoloadDirsList.SelectedIndex = oldIndex < AutoloadDirsList.ItemCount ? oldIndex : 0; } } } diff --git a/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml b/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml index 98aac09ce..d53074499 100644 --- a/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml +++ b/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml @@ -6,22 +6,44 @@ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels" - xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models" + 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="500" Height="380" mc:Ignorable="d" x:DataType="viewModels:DownloadableContentManagerViewModel" Focusable="True"> + + + + + + + + + + Grid.Row="1"> @@ -60,7 +82,7 @@ + TextTrimming="CharacterEllipsis"> + + + + + + + - { - ViewModel.Applications.Add(e.AppData); - }); - } - private void ApplicationLibrary_ApplicationCountUpdated(object sender, ApplicationCountUpdatedEventArgs e) { LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarGamesLoaded, e.NumAppsLoaded, e.NumAppsFound); @@ -475,7 +470,12 @@ protected override void OnOpened(EventArgs e) this); ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated; - ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded; + _appLibraryAppsSubscription?.Dispose(); + _appLibraryAppsSubscription = ApplicationLibrary.Applications + .Connect() + .ObserveOn(SynchronizationContext.Current) + .Bind(ViewModel.Applications) + .Subscribe(); ViewModel.RefreshFirmwareStatus(); @@ -578,6 +578,7 @@ protected override void OnClosing(WindowClosingEventArgs e) ApplicationLibrary.CancelLoading(); InputManager.Dispose(); + _appLibraryAppsSubscription?.Dispose(); Program.Exit(); base.OnClosing(e); @@ -599,7 +600,6 @@ private void ConfirmExit() public void LoadApplications() { _applicationsLoadedOnce = true; - ViewModel.Applications.Clear(); StatusBarView.LoadProgressBar.IsVisible = true; ViewModel.StatusBarProgressMaximum = 0; @@ -641,8 +641,18 @@ private void ReloadGameList() Thread applicationLibraryThread = new(() => { ApplicationLibrary.DesiredLanguage = ConfigurationState.Instance.System.Language; + ApplicationLibrary.LoadApplications(ConfigurationState.Instance.UI.GameDirs); + var autoloadDirs = ConfigurationState.Instance.UI.AutoloadDirs.Value; + if (autoloadDirs.Count > 0) + { + var updatesLoaded = ApplicationLibrary.AutoLoadTitleUpdates(autoloadDirs); + var dlcLoaded = ApplicationLibrary.AutoLoadDownloadableContents(autoloadDirs); + + ShowNewContentAddedDialog(dlcLoaded, updatesLoaded); + } + _isLoading = false; }) { @@ -651,5 +661,33 @@ private void ReloadGameList() }; applicationLibraryThread.Start(); } + + private Task ShowNewContentAddedDialog(int numDlcAdded, int numUpdatesAdded) + { + var msg = ""; + + if (numDlcAdded > 0 && numUpdatesAdded > 0) + { + msg = string.Format(LocaleManager.Instance[LocaleKeys.AutoloadDlcAndUpdateAddedMessage], numDlcAdded, numUpdatesAdded); + } + else if (numDlcAdded > 0) + { + msg = string.Format(LocaleManager.Instance[LocaleKeys.AutoloadDlcAddedMessage], numDlcAdded); + } + else if (numUpdatesAdded > 0) + { + msg = string.Format(LocaleManager.Instance[LocaleKeys.AutoloadUpdateAddedMessage], numUpdatesAdded); + } + else + { + return Task.CompletedTask; + } + + return Dispatcher.UIThread.InvokeAsync(async () => + { + await ContentDialogHelper.ShowTextDialog(LocaleManager.Instance[LocaleKeys.DialogConfirmationTitle], + msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark); + }); + } } } diff --git a/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs b/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs index 314501c52..4d7871886 100644 --- a/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs @@ -39,7 +39,7 @@ public void SaveSettings() { InputPage.InputView?.SaveCurrentProfile(); - if (Owner is MainWindow window && ViewModel.DirectoryChanged) + if (Owner is MainWindow window && (ViewModel.GameDirectoryChanged || ViewModel.AutoloadDirectoryChanged)) { window.LoadApplications(); } diff --git a/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml b/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml index 3eff389f0..aea46df36 100644 --- a/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml +++ b/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml @@ -6,20 +6,42 @@ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels" - xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models" + 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="500" Height="300" mc:Ignorable="d" x:DataType="viewModels:TitleUpdateViewModel" Focusable="True"> + + + + - + + + + + + TextWrapping="Wrap"> + + + + + + + + DataType="viewModels:TitleUpdateViewNoUpdateSentinal"> @@ -92,7 +120,7 @@ Date: Tue, 22 Oct 2024 02:54:48 +1000 Subject: [PATCH 09/10] Fix repeated dialog popup notifying of new updates when there aren't any if you have bundled updates with external updates --- src/Ryujinx.UI.Common/App/ApplicationLibrary.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs index c464cb0a5..fbe05ed18 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs @@ -986,6 +986,10 @@ public int AutoLoadTitleUpdates(List appDirs) var shouldSelect = !currentlySelected.HasValue || currentlySelected.Value.TitleUpdate.Version < update.Version; _titleUpdates.AddOrUpdate((update, shouldSelect)); + + if (currentlySelected.HasValue && shouldSelect) + _titleUpdates.AddOrUpdate((currentlySelected.Value.TitleUpdate, false)); + SaveTitleUpdatesForGame(update.TitleIdBase); numUpdatesLoaded++; From ce24341d1d0ebee030c8ee579793b40c6bb193fb Mon Sep 17 00:00:00 2001 From: Aaron Murgatroyd Date: Thu, 24 Oct 2024 00:35:51 +1000 Subject: [PATCH 10/10] Automatically remove invalid dlc and updates as part of auto load Fixed some minor label spacing issues in options dialog Removal of unused variable in input view model --- .../App/ApplicationLibrary.cs | 87 +++++++++++++++---- src/Ryujinx/Assets/Locales/en_US.json | 4 +- src/Ryujinx/Assets/Locales/fr_FR.json | 7 ++ .../UI/ViewModels/MainWindowViewModel.cs | 24 +++-- .../Views/Settings/SettingsLoggingView.axaml | 2 +- .../Views/Settings/SettingsSystemView.axaml | 2 +- .../UI/Views/Settings/SettingsUIView.axaml | 7 +- src/Ryujinx/UI/Windows/MainWindow.axaml.cs | 37 ++++---- 8 files changed, 117 insertions(+), 53 deletions(-) diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs index fbe05ed18..8a0bad181 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs @@ -802,17 +802,31 @@ public void SaveTitleUpdatesForGame(ApplicationData application, List<(TitleUpda // Searches the provided directories for DLC NSP files that are _valid for the currently detected games in the // library_, and then enables those DLC. - public int AutoLoadDownloadableContents(List appDirs) + public int AutoLoadDownloadableContents(List appDirs, out int numDlcRemoved) { _cancellationToken = new CancellationTokenSource(); List dlcPaths = new(); int newDlcLoaded = 0; + numDlcRemoved = 0; try { + // Remove any downloadable content which can no longer be located on disk + Logger.Notice.Print(LogClass.Application, $"Removing non-existing Title DLCs"); + var dlcToRemove = _downloadableContents.Items + .Where(dlc => !File.Exists(dlc.Dlc.ContainerPath)) + .ToList(); + dlcToRemove.ForEach(dlc => + Logger.Warning?.Print(LogClass.Application, $"Title DLC removed: {dlc.Dlc.ContainerPath}") + ); + numDlcRemoved += dlcToRemove.Distinct().Count(); + _downloadableContents.RemoveKeys(dlcToRemove.Select(dlc => dlc.Dlc)); + foreach (string appDir in appDirs) { + Logger.Notice.Print(LogClass.Application, $"Auto loading DLC from: {appDir}"); + if (_cancellationToken.Token.IsCancellationRequested) { return newDlcLoaded; @@ -901,17 +915,37 @@ public int AutoLoadDownloadableContents(List appDirs) // Searches the provided directories for update NSP files that are _valid for the currently detected games in the // library_, and then applies those updates. If a newly-detected update is a newer version than the currently // selected update (or if no update is currently selected), then that update will be selected. - public int AutoLoadTitleUpdates(List appDirs) + public int AutoLoadTitleUpdates(List appDirs, out int numUpdatesRemoved) { _cancellationToken = new CancellationTokenSource(); List updatePaths = new(); int numUpdatesLoaded = 0; + numUpdatesRemoved = 0; try { + var titleIdsToSave = new HashSet(); + var titleIdsToRefresh = new HashSet(); + + // Remove any updates which can no longer be located on disk + Logger.Notice.Print(LogClass.Application, $"Removing non-existing Title Updates"); + var updatesToRemove = _titleUpdates.Items + .Where(it => !File.Exists(it.TitleUpdate.Path)) + .ToList(); + + numUpdatesRemoved += updatesToRemove.Select(it => it.TitleUpdate).Distinct().Count(); + updatesToRemove.ForEach(ti => + Logger.Warning?.Print(LogClass.Application, $"Title update removed: {ti.TitleUpdate.Path}") + ); + _titleUpdates.RemoveKeys(updatesToRemove.Select(it => it.TitleUpdate)); + titleIdsToSave.UnionWith(updatesToRemove.Select(it => it.TitleUpdate.TitleIdBase)); + titleIdsToRefresh.UnionWith(updatesToRemove.Where(it => it.IsSelected).Select(update => update.TitleUpdate.TitleIdBase)); + foreach (string appDir in appDirs) { + Logger.Notice.Print(LogClass.Application, $"Auto loading updates from: {appDir}"); + if (_cancellationToken.Token.IsCancellationRequested) { return numUpdatesLoaded; @@ -980,27 +1014,24 @@ public int AutoLoadTitleUpdates(List appDirs) { if (!_titleUpdates.Lookup(update).HasValue) { - var currentlySelected = TitleUpdates.Items.FirstOrOptional(it => - it.TitleUpdate.TitleIdBase == update.TitleIdBase && it.IsSelected); - - var shouldSelect = !currentlySelected.HasValue || - currentlySelected.Value.TitleUpdate.Version < update.Version; - _titleUpdates.AddOrUpdate((update, shouldSelect)); - - if (currentlySelected.HasValue && shouldSelect) - _titleUpdates.AddOrUpdate((currentlySelected.Value.TitleUpdate, false)); - - SaveTitleUpdatesForGame(update.TitleIdBase); + bool shouldSelect = AddAndAutoSelectUpdate(update); + titleIdsToSave.Add(update.TitleIdBase); numUpdatesLoaded++; if (shouldSelect) { - RefreshApplicationInfo(update.TitleIdBase); + titleIdsToRefresh.Add(update.TitleIdBase); } } } } } + + foreach (var titleId in titleIdsToSave) + SaveTitleUpdatesForGame(titleId); + + foreach (var titleId in titleIdsToRefresh) + RefreshApplicationInfo(titleId); } finally { @@ -1011,6 +1042,24 @@ public int AutoLoadTitleUpdates(List appDirs) return numUpdatesLoaded; } + private bool AddAndAutoSelectUpdate(TitleUpdateModel update) + { + var currentlySelected = TitleUpdates.Items.FirstOrOptional(it => + it.TitleUpdate.TitleIdBase == update.TitleIdBase && it.IsSelected); + + var shouldSelect = !currentlySelected.HasValue || + currentlySelected.Value.TitleUpdate.Version < update.Version; + + _titleUpdates.AddOrUpdate((update, shouldSelect)); + + if (currentlySelected.HasValue && shouldSelect) + { + _titleUpdates.AddOrUpdate((currentlySelected.Value.TitleUpdate, false)); + } + + return shouldSelect; + } + protected void OnApplicationCountUpdated(ApplicationCountUpdatedEventArgs e) { ApplicationCountUpdated?.Invoke(null, e); @@ -1395,8 +1444,8 @@ private bool LoadTitleUpdatesForApplication(ApplicationData application) if (TryGetTitleUpdatesFromFile(application.Path, out var bundledUpdates)) { var savedUpdateLookup = savedUpdates.Select(update => update.Item1).ToHashSet(); + bool updatesChanged = false; - bool addedNewUpdate = false; foreach (var update in bundledUpdates.OrderByDescending(bundled => bundled.Version)) { if (!savedUpdateLookup.Contains(update)) @@ -1405,17 +1454,19 @@ private bool LoadTitleUpdatesForApplication(ApplicationData application) if (!selectedUpdate.HasValue || selectedUpdate.Value.Item1.Version < update.Version) { shouldSelect = true; - selectedUpdate = Optional<(TitleUpdateModel, bool IsSelected)>.Create((update, true)); + if (selectedUpdate.HasValue) + _titleUpdates.AddOrUpdate((selectedUpdate.Value.Item1, false)); + selectedUpdate = DynamicData.Kernel.Optional<(TitleUpdateModel, bool IsSelected)>.Create((update, true)); } modifiedVersion = modifiedVersion || shouldSelect; it.AddOrUpdate((update, shouldSelect)); - addedNewUpdate = true; + updatesChanged = true; } } - if (addedNewUpdate) + if (updatesChanged) { var gameUpdates = it.Items.Where(update => update.TitleUpdate.TitleIdBase == application.IdBase).ToList(); TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, application.IdBase, gameUpdates); diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index 45befacb3..e275d08ec 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -106,6 +106,7 @@ "SettingsTabGeneralHideCursorAlways": "Always", "SettingsTabGeneralGameDirectories": "Game Directories", "SettingsTabGeneralAutoloadDirectories": "Autoload DLC/Updates Directories", + "SettingsTabGeneralAutoloadNote": "DLC and Updates which refer to missing files will be unloaded automatically", "SettingsTabGeneralAdd": "Add", "SettingsTabGeneralRemove": "Remove", "SettingsTabSystem": "System", @@ -725,8 +726,9 @@ "DlcWindowHeading": "{0} Downloadable Content(s)", "DlcWindowDlcAddedMessage": "{0} new downloadable content(s) added", "AutoloadDlcAddedMessage": "{0} new downloadable content(s) added", + "AutoloadDlcRemovedMessage": "{0} missing downloadable content(s) removed", "AutoloadUpdateAddedMessage": "{0} new update(s) added", - "AutoloadDlcAndUpdateAddedMessage": "{0} new downloadable content(s) and {1} new update(s) added", + "AutoloadUpdateRemovedMessage": "{0} missing update(s) removed", "ModWindowHeading": "{0} Mod(s)", "UserProfilesEditProfile": "Edit Selected", "Cancel": "Cancel", diff --git a/src/Ryujinx/Assets/Locales/fr_FR.json b/src/Ryujinx/Assets/Locales/fr_FR.json index 99a060650..101c5b58f 100644 --- a/src/Ryujinx/Assets/Locales/fr_FR.json +++ b/src/Ryujinx/Assets/Locales/fr_FR.json @@ -102,6 +102,8 @@ "SettingsTabGeneralHideCursorOnIdle": "Masquer le curseur si inactif", "SettingsTabGeneralHideCursorAlways": "Toujours", "SettingsTabGeneralGameDirectories": "Dossiers des jeux", + "SettingsTabGeneralAutoloadDirectories": "Dossiers des mises à jour/DLC", + "SettingsTabGeneralAutoloadNote": "Les DLC et les mises à jour faisant référence aux fichiers manquants seront automatiquement déchargés.", "SettingsTabGeneralAdd": "Ajouter", "SettingsTabGeneralRemove": "Retirer", "SettingsTabSystem": "Système", @@ -708,6 +710,11 @@ "CheatWindowHeading": "Cheats disponibles pour {0} [{1}]", "BuildId": "BuildId:", "DlcWindowHeading": "{0} Contenu(s) téléchargeable(s)", + "DlcWindowDlcAddedMessage": "{0} nouveau(x) contenu(s) téléchargeable(s) ajouté(s)", + "AutoloadDlcAddedMessage": "{0} nouveau(x) contenu(s) téléchargeable(s) ajouté(s)", + "AutoloadDlcRemovedMessage": "{0} contenu(s) téléchargeable(s) manquant(s) supprimé(s)", + "AutoloadUpdateAddedMessage": "{0} nouvelle(s) mise(s) à jour ajoutée(s)", + "AutoloadUpdateRemovedMessage": "{0} mises à jour manquantes supprimées", "ModWindowHeading": "{0} Mod(s)", "UserProfilesEditProfile": "Éditer la sélection", "Cancel": "Annuler", diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index c9b645a5c..321f306c0 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -52,6 +52,7 @@ namespace Ryujinx.Ava.UI.ViewModels public class MainWindowViewModel : BaseModel { private const int HotKeyPressDelayMs = 500; + private delegate int LoadContentFromFolderDelegate(List dirs, out int numRemoved); private ObservableCollectionExtended _applications; private string _aspectStatusText; @@ -1259,7 +1260,7 @@ private void RendererHost_Created(object sender, EventArgs e) _rendererWaitEvent.Set(); } - private async Task LoadContentFromFolder(LocaleKeys localeMessageKey, Func, int> onDirsSelected) + private async Task LoadContentFromFolder(LocaleKeys localeMessageAddedKey, LocaleKeys localeMessageRemovedKey, LoadContentFromFolderDelegate onDirsSelected) { var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { @@ -1270,14 +1271,17 @@ private async Task LoadContentFromFolder(LocaleKeys localeMessageKey, Func 0) { var dirs = result.Select(it => it.Path.LocalPath).ToList(); - var numAdded = onDirsSelected(dirs); + var numAdded = onDirsSelected(dirs, out int numRemoved); - var msg = string.Format(LocaleManager.Instance[localeMessageKey], numAdded); + var msg = String.Join("\r\n", new string[] { + string.Format(LocaleManager.Instance[localeMessageRemovedKey], numRemoved), + string.Format(LocaleManager.Instance[localeMessageAddedKey], numAdded) + }); await Dispatcher.UIThread.InvokeAsync(async () => { await ContentDialogHelper.ShowTextDialog( - LocaleManager.Instance[numAdded > 0 ? LocaleKeys.RyujinxConfirm : LocaleKeys.RyujinxInfo], + LocaleManager.Instance[numAdded > 0 || numRemoved > 0 ? LocaleKeys.RyujinxConfirm : LocaleKeys.RyujinxInfo], msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark); }); } @@ -1533,14 +1537,18 @@ public async Task OpenFile() public async Task LoadDlcFromFolder() { - await LoadContentFromFolder(LocaleKeys.AutoloadDlcAddedMessage, - dirs => ApplicationLibrary.AutoLoadDownloadableContents(dirs)); + await LoadContentFromFolder( + LocaleKeys.AutoloadDlcAddedMessage, + LocaleKeys.AutoloadDlcRemovedMessage, + ApplicationLibrary.AutoLoadDownloadableContents); } public async Task LoadTitleUpdatesFromFolder() { - await LoadContentFromFolder(LocaleKeys.AutoloadUpdateAddedMessage, - dirs => ApplicationLibrary.AutoLoadTitleUpdates(dirs)); + await LoadContentFromFolder( + LocaleKeys.AutoloadUpdateAddedMessage, + LocaleKeys.AutoloadUpdateRemovedMessage, + ApplicationLibrary.AutoLoadTitleUpdates); } public async Task OpenFolder() diff --git a/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml index 0fc9ea1bb..5d22b891c 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml @@ -52,7 +52,7 @@ - + diff --git a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml index e6f7c6e46..0db0eae63 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml @@ -195,7 +195,7 @@ + Spacing="5"> diff --git a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml index ac5e8371f..d91d68a30 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml @@ -129,7 +129,10 @@ - + + + +