From 565acec46850fc66def70adb0a9fae94f9ed8e59 Mon Sep 17 00:00:00 2001 From: Jimmy Reichley Date: Mon, 7 Oct 2024 21:08:41 -0400 Subject: [PATCH] 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 | 274 ++++----- .../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, 1509 insertions(+), 459 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.FileSystem; 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; 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 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.App.Common _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 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.App.Common int numApplicationsLoaded = 0; _cancellationToken = new CancellationTokenSource(); + _applications.Clear(); // Builds the applications list with paths to found applications List applicationPaths = new(); @@ -524,12 +683,12 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.App.Common } } - 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) { - ApplicationAdded?.Invoke(null, e); + _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) + { + _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 @@ namespace Ryujinx.UI.App.Common 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 @@ namespace Ryujinx.UI.Common.Configuration /// /// 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 @@ namespace Ryujinx.UI.Common.Configuration /// 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 @@ namespace Ryujinx.UI.Common.Configuration /// 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 @@ namespace Ryujinx.UI.Common.Configuration 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 @@ namespace Ryujinx.UI.Common.Configuration SortAscending = UI.ColumnSort.SortAscending, }, GameDirs = UI.GameDirs, + AutoloadDirs = UI.AutoloadDirs, ShownFileTypes = new ShownFileTypes { NSP = UI.ShownFileTypes.NSP, @@ -836,6 +843,7 @@ namespace Ryujinx.UI.Common.Configuration 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 @@ namespace Ryujinx.UI.Common.Configuration 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 @@ namespace Ryujinx.UI.Common.Configuration 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 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await TitleUpdateWindow.Show(viewModel.VirtualFileSystem, viewModel.SelectedApplication); + await TitleUpdateWindow.Show(viewModel.ApplicationLibrary, viewModel.SelectedApplication); } } @@ -96,7 +96,7 @@ namespace Ryujinx.Ava.UI.Controls 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 @@ namespace Ryujinx.Ava.UI.Helpers 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 @@ namespace Ryujinx.Ava.UI.Helpers { 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.Controls.ApplicationLifetimes; 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,9 +77,19 @@ namespace Ryujinx.Ava.UI.ViewModels get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count); } - public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) + public bool ShowBundledContentNotice { - _virtualFileSystem = virtualFileSystem; + get => _showBundledContentNotice; + set + { + _showBundledContentNotice = value; + OnPropertyChanged(); + } + } + + public DownloadableContentManagerViewModel(ApplicationLibrary applicationLibrary, ApplicationData applicationData) + { + _applicationLibrary = applicationLibrary; _applicationData = applicationData; @@ -103,109 +98,68 @@ namespace Ryujinx.Ava.UI.ViewModels _storageProvider = desktop.MainWindow.StorageProvider; } - _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, applicationData.IdBaseString, "dlc.json"); - - if (!File.Exists(_downloadableContentJsonPath)) - { - _downloadableContentContainerList = new List(); - - Save(); - } - - try - { - _downloadableContentContainerList = JsonHelper.DeserializeFromFile(_downloadableContentJsonPath, _serializerContext.ListDownloadableContentContainer); - } - catch - { - Logger.Error?.Print(LogClass.Configuration, "Downloadable Content JSON failed to deserialize."); - _downloadableContentContainerList = new List(); - } - LoadDownloadableContents(); } private void LoadDownloadableContents() { - foreach (DownloadableContentContainer downloadableContentContainer in _downloadableContentContainerList) + var dlcs = _applicationLibrary.DownloadableContents.Items + .Where(it => it.Dlc.TitleIdBase == _applicationData.IdBase); + + bool hasBundledContent = false; + foreach ((DownloadableContentModel dlc, bool isEnabled) in dlcs) { - if (File.Exists(downloadableContentContainer.ContainerPath)) + DownloadableContents.Add(dlc); + hasBundledContent = hasBundledContent || dlc.IsBundled; + + if (isEnabled) { - using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(downloadableContentContainer.ContainerPath, _virtualFileSystem); - - 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); - } - - OnPropertyChanged(nameof(UpdateCount)); - } - } + 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); + + foreach (DownloadableContentModel item in items) + { + SelectedDownloadableContents.ReplaceOrAdd(item, item); + } + OnPropertyChanged(nameof(Views)); } - private bool Filter(object arg) + private bool Filter(T arg) { if (arg is DownloadableContentModel content) { - return string.IsNullOrWhiteSpace(_search) || content.FileName.ToLower().Contains(_search.ToLower()) || content.TitleId.ToLower().Contains(_search.ToLower()); + return string.IsNullOrWhiteSpace(_search) || content.FileName.ToLower().Contains(_search.ToLower()) || content.TitleIdStr.ToLower().Contains(_search.ToLower()); } return false; } - private Nca TryOpenNca(IStorage ncaStorage, string containerPath) - { - try - { - return new Nca(_virtualFileSystem.KeySet, ncaStorage); - } - catch (Exception ex) - { - Dispatcher.UIThread.InvokeAsync(async () => - { - await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogLoadFileErrorMessage], ex.Message, containerPath)); - }); - } - - return null; - } - public async void Add() { var result = await _storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions @@ -223,78 +177,88 @@ namespace Ryujinx.Ava.UI.ViewModels }, }); + 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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); - } - - container = new DownloadableContentContainer - { - ContainerPath = downloadableContent.ContainerPath, - DownloadableContentNcaList = new List(), - }; - } - - container.DownloadableContentNcaList.Add(new DownloadableContentNca - { - Enabled = downloadableContent.Enabled, - TitleId = Convert.ToUInt64(downloadableContent.TitleId, 16), - FullPath = downloadableContent.FullPath, - }); - } - - if (!string.IsNullOrWhiteSpace(container.ContainerPath)) - { - _downloadableContentContainerList.Add(container); - } - - JsonHelper.SerializeToFile(_downloadableContentJsonPath, _downloadableContentContainerList, _serializerContext.ListDownloadableContentContainer); + SelectedDownloadableContents.ReplaceOrAdd(model, model); } + public void Disable(DownloadableContentModel model) + { + SelectedDownloadableContents.Remove(model); + } + + public void Save() + { + var dlcs = DownloadableContents.Select(it => (it, SelectedDownloadableContents.Contains(it))).ToList(); + _applicationLibrary.SaveDownloadableContentsForGame(_applicationData, dlcs); + } + + private Task ShowNewDlcAddedDialog(int numAdded) + { + var msg = string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowDlcAddedMessage], numAdded); + 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/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.Media; 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.Generic; 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 @@ namespace Ryujinx.Ava.UI.ViewModels { private const int HotKeyPressDelayMs = 500; - private ObservableCollection _applications; + private ObservableCollectionExtended _applications; private string _aspectStatusText; private string _loadHeading; @@ -112,7 +115,7 @@ namespace Ryujinx.Ava.UI.ViewModels public MainWindowViewModel() { - Applications = new ObservableCollection(); + Applications = new ObservableCollectionExtended(); Applications.ToObservableChangeSet() .Filter(Filter) @@ -741,7 +744,7 @@ namespace Ryujinx.Ava.UI.ViewModels get => FileAssociationHelper.IsTypeAssociationSupported; } - public ObservableCollection Applications + public ObservableCollectionExtended Applications { get => _applications; set @@ -1256,6 +1259,30 @@ namespace Ryujinx.Ava.UI.ViewModels _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 @@ namespace Ryujinx.Ava.UI.ViewModels } } + 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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 @@ namespace Ryujinx.Ava.UI.ViewModels public SettingsViewModel() { GameDirectories = new AvaloniaList(); + AutoloadDirectories = new AvaloniaList(); TimeZones = new AvaloniaList(); AvailableGpus = new ObservableCollection(); _validTzRegions = new List(); @@ -397,6 +411,9 @@ namespace Ryujinx.Ava.UI.ViewModels 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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.Collections; 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 @@ namespace Ryujinx.Ava.UI.ViewModels } } + 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 @@ namespace Ryujinx.Ava.UI.ViewModels 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 @@ namespace Ryujinx.Ava.UI.ViewModels { 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); + return false; + } - Dictionary updates = pfs.GetContentData(ContentMetaType.Patch, VirtualFileSystem, checkLevel); + var updatesForThisGame = updates.Where(it => it.TitleIdBase == ApplicationData.Id).ToList(); + if (updatesForThisGame.Count == 0) + { + return false; + } - Nca patchNca = null; - Nca controlNca = null; - - if (updates.TryGetValue(ApplicationData.Id, out ContentMetaData content)) + foreach (var update in updatesForThisGame) + { + if (!TitleUpdates.Contains(update)) { - patchNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Program); - 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.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 @@ namespace Ryujinx.Ava.UI.ViewModels }, }); + 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 ac3736110..e7815bba8 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 @@ namespace Ryujinx.Ava.UI.Views.Settings 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 @@ namespace Ryujinx.Ava.UI.Views.Settings 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); @@ -472,7 +467,12 @@ namespace Ryujinx.Ava.UI.Windows this); ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated; - ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded; + _appLibraryAppsSubscription?.Dispose(); + _appLibraryAppsSubscription = ApplicationLibrary.Applications + .Connect() + .ObserveOn(SynchronizationContext.Current) + .Bind(ViewModel.Applications) + .Subscribe(); ViewModel.RefreshFirmwareStatus(); @@ -575,6 +575,7 @@ namespace Ryujinx.Ava.UI.Windows ApplicationLibrary.CancelLoading(); InputManager.Dispose(); + _appLibraryAppsSubscription?.Dispose(); Program.Exit(); base.OnClosing(e); @@ -596,7 +597,6 @@ namespace Ryujinx.Ava.UI.Windows public void LoadApplications() { _applicationsLoadedOnce = true; - ViewModel.Applications.Clear(); StatusBarView.LoadProgressBar.IsVisible = true; ViewModel.StatusBarProgressMaximum = 0; @@ -638,8 +638,18 @@ namespace Ryujinx.Ava.UI.Windows 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; }) { @@ -648,5 +658,33 @@ namespace Ryujinx.Ava.UI.Windows }; 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 @@ namespace Ryujinx.Ava.UI.Windows { 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 @@