diff --git a/src/Ryujinx.Ava/Common/ApplicationHelper.cs b/src/Ryujinx.Ava/Common/ApplicationHelper.cs index 91ca8f4d5..a743de233 100644 --- a/src/Ryujinx.Ava/Common/ApplicationHelper.cs +++ b/src/Ryujinx.Ava/Common/ApplicationHelper.cs @@ -16,6 +16,7 @@ using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Controls; using Ryujinx.Ava.UI.Helpers; using Ryujinx.Common.Logging; +using Ryujinx.Common.Configuration; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.Ui.App.Common; @@ -24,6 +25,8 @@ using System; using System.Buffers; using System.IO; using System.Threading; +using System.IO.Compression; +using System.Linq; using System.Threading.Tasks; using ApplicationId = LibHac.Ncm.ApplicationId; using Path = System.IO.Path; @@ -110,6 +113,11 @@ namespace Ryujinx.Ava.Common } public static void OpenSaveDir(ulong saveDataId) + { + OpenHelper.OpenFolder(FindValidSaveDir(saveDataId)); + } + + public static string FindValidSaveDir(ulong saveDataId) { string saveRootPath = Path.Combine(VirtualFileSystem.GetNandPath(), $"user/save/{saveDataId:x16}"); @@ -119,24 +127,26 @@ namespace Ryujinx.Ava.Common Directory.CreateDirectory(saveRootPath); } - string committedPath = Path.Combine(saveRootPath, "0"); + // commited expected to be at /0, otherwise working is /1 + string attemptPath = Path.Combine(saveRootPath, "0"); string workingPath = Path.Combine(saveRootPath, "1"); // If the committed directory exists, that path will be loaded the next time the savedata is mounted - if (Directory.Exists(committedPath)) + if (Directory.Exists(attemptPath)) { - OpenHelper.OpenFolder(committedPath); + return attemptPath; } else { // If the working directory exists and the committed directory doesn't, // the working directory will be loaded the next time the savedata is mounted - if (!Directory.Exists(workingPath)) + attemptPath = Path.Combine(saveRootPath, "1"); + if (!Directory.Exists(attemptPath)) { - Directory.CreateDirectory(workingPath); + Directory.CreateDirectory(attemptPath); } - OpenHelper.OpenFolder(workingPath); + return attemptPath; } } diff --git a/src/Ryujinx.Ava/Common/SaveManager/BackupRequestOutcome.cs b/src/Ryujinx.Ava/Common/SaveManager/BackupRequestOutcome.cs new file mode 100644 index 000000000..bbf61b852 --- /dev/null +++ b/src/Ryujinx.Ava/Common/SaveManager/BackupRequestOutcome.cs @@ -0,0 +1,10 @@ +using System; + +namespace Ryujinx.Ava.Common.SaveManager +{ + public readonly record struct BackupRequestOutcome + { + public bool DidFail { get; init; } + public string Message { get; init; } + } +} diff --git a/src/Ryujinx.Ava/Common/SaveManager/ISaveManager.cs b/src/Ryujinx.Ava/Common/SaveManager/ISaveManager.cs new file mode 100644 index 000000000..d9e9e1214 --- /dev/null +++ b/src/Ryujinx.Ava/Common/SaveManager/ISaveManager.cs @@ -0,0 +1,36 @@ + +using LibHac.Fs; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Ui.Common.Helper; +using Ryujinx.Ui.Common.SaveManager; +using System; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.Common.SaveManager +{ + public interface ISaveManager + { + public event EventHandler BackupProgressUpdated; + public event EventHandler BackupImportSave; + + #region Backup + public Task BackupUserSaveDataToZip(UserId userId, + string location, + SaveOptions saveOptions = SaveOptions.Default); + + public Task BackupUserTitleSaveDataToZip(UserId userId, + ulong titleId, + string location, + SaveOptions saveOptions = SaveOptions.Default); + #endregion + + #region Restore + public Task RestoreUserSaveDataFromZip(UserId userId, + string sourceDataPath); + + public Task RestoreUserTitleSaveFromZip(UserId userId, + ulong titleId, + string sourceDataPath); + #endregion + } +} diff --git a/src/Ryujinx.Ava/Common/SaveManager/SaveManager.cs b/src/Ryujinx.Ava/Common/SaveManager/SaveManager.cs new file mode 100644 index 000000000..ab63332ef --- /dev/null +++ b/src/Ryujinx.Ava/Common/SaveManager/SaveManager.cs @@ -0,0 +1,621 @@ +using LibHac; +using LibHac.Account; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Shim; +using LibHac.Ns; +using Microsoft.IdentityModel.Tokens; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Ava.UI.Windows; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using Ryujinx.Ui.Common.Helper; +using Ryujinx.Ui.Common.SaveManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using LibHacUserId = LibHac.Fs.UserId; +using Path = System.IO.Path; + +namespace Ryujinx.Ava.Common.SaveManager +{ + public class SaveManager : ISaveManager + { + // UI Metadata + public event EventHandler BackupProgressUpdated; + public event EventHandler BackupImportSave; + private readonly LoadingBarEventArgs _loadingEventArgs; + + private readonly HorizonClient _horizonClient; + private readonly AccountManager _accountManager; + + public SaveManager(HorizonClient hzClient, AccountManager acctManager) + { + _loadingEventArgs = new(); + + _horizonClient = hzClient; + _accountManager = acctManager; + } + + #region Backup + public Task BackupUserTitleSaveDataToZip(LibHacUserId userId, + ulong titleId, + string location, + SaveOptions saveOptions = SaveOptions.Default) + { + throw new NotImplementedException(); + } + + public async Task BackupUserSaveDataToZip(LibHacUserId userId, + string location, + SaveOptions saveOptions = SaveOptions.Default) + { + // TODO: Eventually add cancellation source + + var userSaves = GetUserSaveData(userId, saveOptions); + if (userSaves.IsNullOrEmpty()) + { + Logger.Warning?.Print(LogClass.Application, "No save data found"); + return new BackupRequestOutcome + { + DidFail = false, + Message = "No save data found" + }; + } + + _loadingEventArgs.Curr = 0; + _loadingEventArgs.Max = userSaves.Count() + 1; // add one for metadata file + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + + // Create the top level temp dir for the intermediate copies - ensure it's empty + // TODO: should this go in the location since data has to go there anyway? might make the ultimate zip faster since IO is local? + var backupTempDir = Path.Combine(AppDataManager.BackupDirPath, $"{userId}_library_saveTemp"); + + try + { + // Delete temp for good measure? + _ = Directory.CreateDirectory(backupTempDir); + + var outcome = await BatchCopySavesToTempDir(userId, userSaves, backupTempDir) + && CompleteBackup(location, userId, backupTempDir); + + return new BackupRequestOutcome + { + DidFail = !outcome, + Message = outcome + ? string.Empty + : "Failed to backup user saves" + }; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to backup user data - {ex.Message}"); + return new BackupRequestOutcome + { + DidFail = true, + Message = $"Failed to backup user data - {ex.Message}" + }; + } + finally + { + if (Directory.Exists(backupTempDir)) + { + Directory.Delete(backupTempDir, true); + } + } + + // Produce the actual zip + bool CompleteBackup(string location, LibHacUserId userId, string backupTempDir) + { + var currDate = DateTime.UtcNow.ToString("yyyy-MM-dd"); + var profileName = _accountManager + .GetAllUsers() + .FirstOrDefault(u => u.UserId.ToLibHacUserId() == userId)?.Name; + + var backupFile = Path.Combine(location, $"{profileName}_{currDate}_saves.zip"); + return CreateOrReplaceZipFile(backupTempDir, backupFile); + } + } + + private IEnumerable GetUserSaveData(LibHacUserId userId, SaveOptions saveOptions) + { + try + { + // Almost all games have user savess + var userSaves = GetSaveData(userId, SaveDataType.Account) + .ToList(); + + var deviceSaves = saveOptions.HasFlag(SaveOptions.SaveTypeDevice) + ? GetSaveData(default, SaveDataType.Device) + : Enumerable.Empty(); + userSaves.AddRange(deviceSaves); + + var bcatSaves = saveOptions.HasFlag(SaveOptions.SaveTypeDevice) + ? GetSaveData(default, SaveDataType.Bcat) + : Enumerable.Empty(); + userSaves.AddRange(bcatSaves); + + return userSaves; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to enumerate user save data - {ex.Message}"); + } + + return Enumerable.Empty(); + } + + private IEnumerable GetSaveData(LibHacUserId userId, SaveDataType saveType) + { + var saveDataFilter = SaveDataFilter.Make( + programId: default, + saveType: saveType, + userId: userId, + saveDataId: default, + index: default); + + using var saveDataIterator = new UniqueRef(); + + _horizonClient.Fs + .OpenSaveDataIterator(ref saveDataIterator.Ref, SaveDataSpaceId.User, in saveDataFilter) + .ThrowIfFailure(); + + Span saveDataInfo = stackalloc SaveDataInfo[10]; + List saves = new(); + + do + { + saveDataIterator.Get + .ReadSaveDataInfo(out long readCount, saveDataInfo) + .ThrowIfFailure(); + + if (readCount == 0) + { + break; + } + + for (int i = 0; i < readCount; i++) + { + if (saveDataInfo[i].ProgramId.Value != 0) + { + saves.Add(new BackupSaveMeta + { + SaveDataId = saveDataInfo[i].SaveDataId, + Type = saveDataInfo[i].Type, + TitleId = saveDataInfo[i].ProgramId, + }); + } + } + } while (true); + + return saves; + } + + private async Task BatchCopySavesToTempDir(LibHacUserId userId, IEnumerable userSaves, string backupTempDir) + { + // Generate a metadata item so users know what titleIds ares in case they're moving around, jksv, sanity, etc + Dictionary userFriendlyMetadataMap = new(); + + try + { + // batch intermediate copies so we don't overwhelm systems + const int BATCH_SIZE = 5; + List> tempCopyTasks = new(BATCH_SIZE); + + // Copy each applications save data to it's own folder in the temp dir + foreach (var meta in userSaves) + { + // if the buffer is full, wait for it to drain + if (tempCopyTasks.Count >= BATCH_SIZE) + { + // TODO: error handling with options + _ = await Task.WhenAll(tempCopyTasks); + tempCopyTasks.Clear(); + } + + // Add backup task + tempCopyTasks.Add(CopySaveDataToIntermediateDirectory(meta, backupTempDir)); + + // Add title metadata entry - might be dupe from bcat/device + if (!userFriendlyMetadataMap.ContainsKey(meta.TitleId.Value)) + { + var titleIdHex = meta.TitleId.Value.ToString("x16"); + + var appData = MainWindow.MainWindowViewModel.Applications + .FirstOrDefault(x => x.TitleId.Equals(titleIdHex, StringComparison.OrdinalIgnoreCase)); + + userFriendlyMetadataMap.Add(meta.TitleId.Value, new UserFriendlyAppData + { + Title = appData?.TitleName, + TitleId = meta.TitleId.Value, + TitleIdHex = titleIdHex + }); + } + } + + // wait for any outstanding temp copies to complete + _ = await Task.WhenAll(tempCopyTasks); + + // finally, move the metadata tag file into the backup dir and track progress + await WriteMetadataFile(backupTempDir, userId, userFriendlyMetadataMap); + _loadingEventArgs.Curr++; + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + + return true; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to copy save data to intermediate directory - {ex.Message}"); + } + + return false; + + #region LocalMethods + async Task WriteMetadataFile(string backupTempDir, + LibHacUserId userId, + Dictionary userFriendlyMetadataMap) + { + try + { + var userProfile = _accountManager.GetAllUsers() + .FirstOrDefault(u => u.UserId.ToLibHacUserId() == userId); + + var tagFile = Path.Combine(backupTempDir, "tag.json"); + + var completeMeta = System.Text.Json.JsonSerializer.Serialize(new UserFriendlySaveMetadata + { + UserId = userId.ToString(), + ProfileName = userProfile.Name, + CreationTimeUtc = DateTime.UtcNow, + ApplicationMap = userFriendlyMetadataMap.Values + }); + await File.WriteAllTextAsync(tagFile, completeMeta); + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to write user friendly save metadata file - {ex.Message}"); + } + } + #endregion + } + + private async Task CopySaveDataToIntermediateDirectory(BackupSaveMeta saveMeta, string destinationDir) + { + // Find the most recent version of the data, there is a commited (0) and working (1) paths directory + var saveRootPath = ApplicationHelper.FindValidSaveDir(saveMeta.SaveDataId); + + // the actual title in the name would be nice but titleId is more reliable + // [backupLocation]/[titleId]/[saveType] + var copyDestPath = Path.Combine(destinationDir, saveMeta.TitleId.Value.ToString(), saveMeta.Type.ToString()); + + var result = await CopyDirectoryAsync(saveRootPath, copyDestPath); + + // Update progress for each dir we copy save data for + _loadingEventArgs.Curr++; + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + + return result; + } + + public static bool CreateOrReplaceZipFile(string sourceDataDirectory, string backupDestinationFullPath) + { + try + { + if (File.Exists(backupDestinationFullPath)) + { + File.Delete(backupDestinationFullPath); + } + + ZipFile.CreateFromDirectory(sourceDataDirectory, backupDestinationFullPath, CompressionLevel.SmallestSize, false); + return true; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to zip data.\n{ex.Message}"); + return false; + } + } + #endregion + + #region Restore + public Task RestoreUserTitleSaveFromZip(LibHacUserId userId, + ulong titleId, + string sourceDataPath) + { + throw new NotImplementedException(); + } + + public async Task RestoreUserSaveDataFromZip(LibHacUserId userId, + string sourceDataPath) + { + var sourceInfo = new FileInfo(sourceDataPath); + var determinedSourcePath = sourceInfo.FullName; + bool requireSourceCleanup = false; + + if (sourceInfo.Extension.Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + determinedSourcePath = Path.Combine(AppDataManager.BackupDirPath, userId.ToString(), "import", "temp"); + Directory.CreateDirectory(determinedSourcePath); + requireSourceCleanup = true; + + try + { + ZipFile.ExtractToDirectory(sourceInfo.FullName, determinedSourcePath, true); + sourceInfo = new FileInfo(determinedSourcePath); + } + catch (Exception ex) + { + var error = $"Failed to extract save backup zip {ex.Message}"; + Logger.Error?.Print(LogClass.Application, error); + + return new() + { + DidFail = true, + Message = error + }; + } + } + + try + { + // Reset progress bar + _loadingEventArgs.Curr = 0; + _loadingEventArgs.Max = 0; + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + + var identifiedTitleDirectories = GetTitleDirectories(sourceInfo); + if (identifiedTitleDirectories.IsNullOrEmpty()) + { + return new() + { + DidFail = true, + }; + } + + // Start import + _loadingEventArgs.Max = identifiedTitleDirectories.Count(); + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + + // buffer of concurrent saves to import -- limited so we don't overwhelm systems + const int BUFFER_SIZE = 4; // multiple of cores is ideal + List> importBuffer = new(BUFFER_SIZE); + + // find the saveId for each titleId and migrate it. Use cache to avoid duplicate lookups of known titleId + foreach (var importMeta in identifiedTitleDirectories) + { + if (importBuffer.Count >= BUFFER_SIZE) + { + _ = await Task.WhenAll(importBuffer); + importBuffer.Clear(); + } + + importBuffer.Add(ImportSaveData(importMeta, userId)); + + // Mark complete + _loadingEventArgs.Curr++; + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + } + + // let the import complete + _ = await Task.WhenAll(importBuffer); + + return new BackupRequestOutcome + { + DidFail = false + }; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to import save data - {ex.Message}"); + return new BackupRequestOutcome + { + DidFail = false + }; + } + finally + { + if (requireSourceCleanup) + { + Directory.Delete(determinedSourcePath, true); + } + } + } + + private async Task ImportSaveData(RestoreSaveMeta meta, LibHacUserId userId) + { + // Lookup the saveId based on title for the user we're importing too + var saveDataFilter = SaveDataFilter.Make(meta.TitleId, + meta.SaveType, + meta.SaveType == SaveDataType.Account + ? userId + : default, + saveDataId: default, + index: default); + + var result = _horizonClient.Fs.FindSaveDataWithFilter(out var saveDataInfo, SaveDataSpaceId.User, in saveDataFilter); + if (result.IsFailure()) + { + if (ResultFs.TargetNotFound.Includes(result)) + { + Logger.Debug?.Print(LogClass.Application, $"Title {meta.TitleId} does not have existing {meta.SaveType} data"); + + // Try to create it and re-fetch it + TryGenerateSaveEntry(meta.TitleId, userId); + result = _horizonClient.Fs.FindSaveDataWithFilter(out saveDataInfo, SaveDataSpaceId.User, in saveDataFilter); + + if (result.IsFailure()) + { + return false; + } + } + else + { + Logger.Warning?.Print(LogClass.Application, $"Title {meta.TitleId} does not have {meta.SaveType} data - ErrorCode: {result.ErrorCode}"); + return false; + } + } + + // Find the most recent version of the data, there is a commited (0) and working (1) directory + var userHostSavePath = ApplicationHelper.FindValidSaveDir(saveDataInfo.SaveDataId); + if (string.IsNullOrWhiteSpace(userHostSavePath)) + { + Logger.Warning?.Print(LogClass.Application, $"Unable to locate host save directory for {meta.TitleId}"); + return false; + } + + // copy from backup path to host save path + var copyResult = await CopyDirectoryAsync(meta.ImportPath, userHostSavePath); + + _loadingEventArgs.Curr++; + BackupProgressUpdated?.Invoke(this, _loadingEventArgs); + + BackupImportSave?.Invoke(this, new ImportSaveEventArgs + { + SaveInfo = saveDataInfo + }); + + return copyResult; + + #region LocalFunction + bool TryGenerateSaveEntry(ulong titleId, LibHacUserId userId) + { + // resolve from app data + var titleIdHex = titleId.ToString("x16"); + var appData = MainWindow.MainWindowViewModel.Applications + .FirstOrDefault(x => x.TitleId.Equals(titleIdHex, StringComparison.OrdinalIgnoreCase)); + if (appData is null) + { + Logger.Error?.Print(LogClass.Application, $"No application loaded with titleId {titleIdHex}"); + return false; + } + + ref ApplicationControlProperty control = ref appData.ControlHolder.Value; + + Logger.Info?.Print(LogClass.Application, $"Creating save directory for Title: [{titleId:x16}]"); + + if (appData.ControlHolder.ByteSpan.IsZeros()) + { + // If the current application doesn't have a loaded control property, create a dummy one + // and set the savedata sizes so a user savedata will be created. + control = ref new BlitStruct(1).Value; + + // The set sizes don't actually matter as long as they're non-zero because we use directory savedata. + control.UserAccountSaveDataSize = 0x4000; + control.UserAccountSaveDataJournalSize = 0x4000; + + Logger.Warning?.Print(LogClass.Application, "No control file was found for this game. Using a dummy one instead. This may cause inaccuracies in some games."); + } + + Uid user = new(userId.Id.High, userId.Id.Low); + return _horizonClient.Fs.EnsureApplicationSaveData(out _, new LibHac.Ncm.ApplicationId(titleId), in control, in user) + .IsSuccess(); + } + #endregion + } + + private static IEnumerable GetTitleDirectories(FileInfo sourceInfo) + { + if ((sourceInfo.Attributes & FileAttributes.Directory) != FileAttributes.Directory) + { + Logger.Error?.Print(LogClass.Application, $"Unsupported entry specified to extract save data from {sourceInfo.FullName}"); + return Enumerable.Empty(); + } + + // Find the "root" save directories + var outcome = new List(); + foreach (var entry in Directory.EnumerateDirectories(sourceInfo.FullName)) + { + // check if the leaf directory is a titleId + if (!ulong.TryParse(entry[(entry.LastIndexOf(Path.DirectorySeparatorChar) + 1)..], out var titleId)) + { + Logger.Warning?.Print(LogClass.Application, $"Skipping import of unknown directory {entry}"); + continue; + } + + // it looks like a titleId, see if we can find the save type directories we expect + foreach (var saveTypeDir in Directory.EnumerateDirectories(entry)) + { + var saveTypeEntryInfo = new FileInfo(saveTypeDir); + + // Check empty dirs? + if (Enum.TryParse(saveTypeEntryInfo.Name, out var saveType)) + { + outcome.Add(new RestoreSaveMeta + { + TitleId = titleId, + SaveType = saveType, + ImportPath = saveTypeEntryInfo.FullName + }); + } + } + } + + return outcome; + } + #endregion + + #region Utilities + private static async Task CopyDirectoryAsync(string sourceDirectory, string destDirectory) + { + bool result = true; + Directory.CreateDirectory(destDirectory); + + foreach (string filename in Directory.EnumerateFileSystemEntries(sourceDirectory)) + { + var itemDest = Path.Combine(destDirectory, Path.GetFileName(filename)); + var attrs = File.GetAttributes(filename); + + result &= attrs switch + { + _ when (attrs & FileAttributes.Directory) == FileAttributes.Directory => await CopyDirectoryAsync(filename, itemDest), + _ => await CopyFileAsync(filename, itemDest) + }; + + if (!result) + { + // TODO: use options to decide hard fail + continue; + } + } + + return result; + + #region LocalMethod + static async Task CopyFileAsync(string source, string destination, int retryCount = 0) + { + try + { + if (retryCount > 0) + { + Logger.Debug?.Print(LogClass.Application, $"Backing off retrying copy of {source}"); + await Task.Delay((int)(Math.Pow(2, retryCount) * 200)); + } + + using FileStream sourceStream = File.Open(source, FileMode.Open, FileAccess.Read); + using FileStream destinationStream = File.Create(destination); + + await sourceStream.CopyToAsync(destinationStream); + return true; + } + catch (Exception ex) + { + if (ex.Message.Contains("is being used by another process", StringComparison.OrdinalIgnoreCase)) + { + const int RetryThreshold = 3; + return ++retryCount < RetryThreshold + && await CopyFileAsync(source, destination, retryCount); + } + + Logger.Error?.Print(LogClass.Application, $"Failed to copy file {source} - {ex.Message}"); + return false; + } + } + #endregion + } + #endregion + } +} diff --git a/src/Ryujinx.Ava/Common/SaveManager/SaveManagerMetadata.cs b/src/Ryujinx.Ava/Common/SaveManager/SaveManagerMetadata.cs new file mode 100644 index 000000000..f07ff1033 --- /dev/null +++ b/src/Ryujinx.Ava/Common/SaveManager/SaveManagerMetadata.cs @@ -0,0 +1,19 @@ +using LibHac.Fs; +using LibHac.Ncm; + +namespace Ryujinx.Ava.Common.SaveManager +{ + internal readonly record struct BackupSaveMeta + { + public ulong SaveDataId { get; init; } + public SaveDataType Type { get; init; } + public ProgramId TitleId { get; init; } + } + + internal readonly record struct RestoreSaveMeta + { + public ulong TitleId { get; init; } + public SaveDataType SaveType { get; init; } + public string ImportPath { get; init; } + } +} diff --git a/src/Ryujinx.Ava/Common/SaveManager/SaveOptions.cs b/src/Ryujinx.Ava/Common/SaveManager/SaveOptions.cs new file mode 100644 index 000000000..0d1fe4243 --- /dev/null +++ b/src/Ryujinx.Ava/Common/SaveManager/SaveOptions.cs @@ -0,0 +1,23 @@ +using System; + +namespace Ryujinx.Ava.Common.SaveManager +{ + [Flags] + public enum SaveOptions + { + // Save Data Types + SaveTypeAccount, + SaveTypeBcat, + SaveTypeDevice, + SaveTypeAll = SaveTypeAccount | SaveTypeBcat | SaveTypeDevice, + + // Request Semantics -- Not Implemented + SkipEmptyDirectories, + FlattenSaveStructure, + StopOnFirstFailure, + UseDateInName, + ObfuscateZipExtension, + + Default = SaveTypeAll + } +} diff --git a/src/Ryujinx.Ava/Common/SaveManager/UserFriendlySaveMetadata.cs b/src/Ryujinx.Ava/Common/SaveManager/UserFriendlySaveMetadata.cs new file mode 100644 index 000000000..514803373 --- /dev/null +++ b/src/Ryujinx.Ava/Common/SaveManager/UserFriendlySaveMetadata.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Ryujinx.Ava.Common.SaveManager +{ + internal readonly record struct UserFriendlyAppData + { + public ulong TitleId { get; init; } + public string Title { get; init; } + public string TitleIdHex { get; init; } + } + + internal readonly record struct UserFriendlySaveMetadata + { + public string UserId { get; init; } + public string ProfileName { get; init; } + public DateTime CreationTimeUtc { get; init; } + public IEnumerable ApplicationMap { get; init; } + } +} diff --git a/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs b/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs index a57deb1a0..efc8eae6b 100644 --- a/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs +++ b/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs @@ -268,7 +268,11 @@ namespace Ryujinx.Ava.UI.Helpers (int)Symbol.Dismiss); } - internal static async Task CreateChoiceDialog(string title, string primary, string secondaryText) + internal static async Task CreateChoiceDialog(string title, + string primary, + string secondaryText, + LocaleKeys primaryButtonKey = LocaleKeys.InputDialogYes, + LocaleKeys closeButtonKey = LocaleKeys.InputDialogNo) { if (_isChoiceDialogOpen) { @@ -281,9 +285,9 @@ namespace Ryujinx.Ava.UI.Helpers title, primary, secondaryText, - LocaleManager.Instance[LocaleKeys.InputDialogYes], - "", - LocaleManager.Instance[LocaleKeys.InputDialogNo], + primaryButton: LocaleManager.Instance[primaryButtonKey], + secondaryButton: "", + closeButton: LocaleManager.Instance[closeButtonKey], (int)Symbol.Help, UserResult.Yes); diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs index 85adef005..cdc34ae23 100644 --- a/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs +++ b/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs @@ -1,8 +1,11 @@ using DynamicData; using DynamicData.Binding; +using LibHac.Fs; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Models; using Ryujinx.HLE.HOS.Services.Account.Acc; +using Ryujinx.Ui.Common.Helper; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -13,6 +16,8 @@ namespace Ryujinx.Ava.UI.ViewModels private int _sortIndex; private int _orderIndex; private string _search; + private bool _isGoBackEnabled = true; + private LoadingBarData _loadingBarData = new(); private ObservableCollection _saves = new(); private ObservableCollection _views = new(); private readonly AccountManager _accountManager; @@ -52,6 +57,30 @@ namespace Ryujinx.Ava.UI.ViewModels } } + public bool IsGoBackEnabled + { + get => _isGoBackEnabled; + set + { + _isGoBackEnabled = value; + + OnPropertyChanged(); + } + } + + public LoadingBarData LoadingBarData + { + get => _loadingBarData; + set + { + _loadingBarData = value; + + OnPropertyChanged(); + } + } + + + public ObservableCollection Saves { get => _saves; @@ -78,6 +107,18 @@ namespace Ryujinx.Ava.UI.ViewModels _accountManager = accountManager; } + public void AddNewSaveEntry(SaveModel model) + { + _saves.Add(model); + + if (Filter(model)) + { + _views.Add(model); + } + + OnPropertyChanged(nameof(Views)); + } + public void Sort() { Saves.AsObservableChangeSet() @@ -92,12 +133,8 @@ namespace Ryujinx.Ava.UI.ViewModels private bool Filter(object arg) { - if (arg is SaveModel save) - { - return string.IsNullOrWhiteSpace(_search) || save.Title.ToLower().Contains(_search.ToLower()); - } - - return false; + return arg is SaveModel save + && (string.IsNullOrWhiteSpace(_search) || save.Title.Contains(_search, StringComparison.OrdinalIgnoreCase)); } private IComparer GetComparer() diff --git a/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml index 8bc5125a7..69046299b 100644 --- a/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml +++ b/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml @@ -201,13 +201,36 @@ + Orientation="Horizontal" + Spacing="10"> + + +