mirror of
https://git.naxdy.org/Mirror/Ryujinx.git
synced 2025-01-12 13:49:12 +00:00
Quack
This commit is contained in:
parent
f6475cca17
commit
79f94020df
17 changed files with 1050 additions and 35 deletions
|
@ -16,6 +16,7 @@ using Ryujinx.Ava.Common.Locale;
|
||||||
using Ryujinx.Ava.UI.Controls;
|
using Ryujinx.Ava.UI.Controls;
|
||||||
using Ryujinx.Ava.UI.Helpers;
|
using Ryujinx.Ava.UI.Helpers;
|
||||||
using Ryujinx.Common.Logging;
|
using Ryujinx.Common.Logging;
|
||||||
|
using Ryujinx.Common.Configuration;
|
||||||
using Ryujinx.HLE.FileSystem;
|
using Ryujinx.HLE.FileSystem;
|
||||||
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
||||||
using Ryujinx.Ui.App.Common;
|
using Ryujinx.Ui.App.Common;
|
||||||
|
@ -24,6 +25,8 @@ using System;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ApplicationId = LibHac.Ncm.ApplicationId;
|
using ApplicationId = LibHac.Ncm.ApplicationId;
|
||||||
using Path = System.IO.Path;
|
using Path = System.IO.Path;
|
||||||
|
@ -110,6 +113,11 @@ namespace Ryujinx.Ava.Common
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void OpenSaveDir(ulong saveDataId)
|
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}");
|
string saveRootPath = Path.Combine(VirtualFileSystem.GetNandPath(), $"user/save/{saveDataId:x16}");
|
||||||
|
|
||||||
|
@ -119,24 +127,26 @@ namespace Ryujinx.Ava.Common
|
||||||
Directory.CreateDirectory(saveRootPath);
|
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");
|
string workingPath = Path.Combine(saveRootPath, "1");
|
||||||
|
|
||||||
// If the committed directory exists, that path will be loaded the next time the savedata is mounted
|
// 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
|
else
|
||||||
{
|
{
|
||||||
// If the working directory exists and the committed directory doesn't,
|
// If the working directory exists and the committed directory doesn't,
|
||||||
// the working directory will be loaded the next time the savedata is mounted
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
10
src/Ryujinx.Ava/Common/SaveManager/BackupRequestOutcome.cs
Normal file
10
src/Ryujinx.Ava/Common/SaveManager/BackupRequestOutcome.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
36
src/Ryujinx.Ava/Common/SaveManager/ISaveManager.cs
Normal file
36
src/Ryujinx.Ava/Common/SaveManager/ISaveManager.cs
Normal file
|
@ -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<LoadingBarEventArgs> BackupProgressUpdated;
|
||||||
|
public event EventHandler<ImportSaveEventArgs> BackupImportSave;
|
||||||
|
|
||||||
|
#region Backup
|
||||||
|
public Task<BackupRequestOutcome> BackupUserSaveDataToZip(UserId userId,
|
||||||
|
string location,
|
||||||
|
SaveOptions saveOptions = SaveOptions.Default);
|
||||||
|
|
||||||
|
public Task<BackupRequestOutcome> BackupUserTitleSaveDataToZip(UserId userId,
|
||||||
|
ulong titleId,
|
||||||
|
string location,
|
||||||
|
SaveOptions saveOptions = SaveOptions.Default);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Restore
|
||||||
|
public Task<BackupRequestOutcome> RestoreUserSaveDataFromZip(UserId userId,
|
||||||
|
string sourceDataPath);
|
||||||
|
|
||||||
|
public Task<BackupRequestOutcome> RestoreUserTitleSaveFromZip(UserId userId,
|
||||||
|
ulong titleId,
|
||||||
|
string sourceDataPath);
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
621
src/Ryujinx.Ava/Common/SaveManager/SaveManager.cs
Normal file
621
src/Ryujinx.Ava/Common/SaveManager/SaveManager.cs
Normal file
|
@ -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<LoadingBarEventArgs> BackupProgressUpdated;
|
||||||
|
public event EventHandler<ImportSaveEventArgs> 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<BackupRequestOutcome> BackupUserTitleSaveDataToZip(LibHacUserId userId,
|
||||||
|
ulong titleId,
|
||||||
|
string location,
|
||||||
|
SaveOptions saveOptions = SaveOptions.Default)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BackupRequestOutcome> 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<BackupSaveMeta> 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<BackupSaveMeta>();
|
||||||
|
userSaves.AddRange(deviceSaves);
|
||||||
|
|
||||||
|
var bcatSaves = saveOptions.HasFlag(SaveOptions.SaveTypeDevice)
|
||||||
|
? GetSaveData(default, SaveDataType.Bcat)
|
||||||
|
: Enumerable.Empty<BackupSaveMeta>();
|
||||||
|
userSaves.AddRange(bcatSaves);
|
||||||
|
|
||||||
|
return userSaves;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error?.Print(LogClass.Application, $"Failed to enumerate user save data - {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Enumerable.Empty<BackupSaveMeta>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<BackupSaveMeta> GetSaveData(LibHacUserId userId, SaveDataType saveType)
|
||||||
|
{
|
||||||
|
var saveDataFilter = SaveDataFilter.Make(
|
||||||
|
programId: default,
|
||||||
|
saveType: saveType,
|
||||||
|
userId: userId,
|
||||||
|
saveDataId: default,
|
||||||
|
index: default);
|
||||||
|
|
||||||
|
using var saveDataIterator = new UniqueRef<SaveDataIterator>();
|
||||||
|
|
||||||
|
_horizonClient.Fs
|
||||||
|
.OpenSaveDataIterator(ref saveDataIterator.Ref, SaveDataSpaceId.User, in saveDataFilter)
|
||||||
|
.ThrowIfFailure();
|
||||||
|
|
||||||
|
Span<SaveDataInfo> saveDataInfo = stackalloc SaveDataInfo[10];
|
||||||
|
List<BackupSaveMeta> 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<bool> BatchCopySavesToTempDir(LibHacUserId userId, IEnumerable<BackupSaveMeta> userSaves, string backupTempDir)
|
||||||
|
{
|
||||||
|
// Generate a metadata item so users know what titleIds ares in case they're moving around, jksv, sanity, etc
|
||||||
|
Dictionary<ulong, UserFriendlyAppData> userFriendlyMetadataMap = new();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// batch intermediate copies so we don't overwhelm systems
|
||||||
|
const int BATCH_SIZE = 5;
|
||||||
|
List<Task<bool>> 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<ulong, UserFriendlyAppData> 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<bool> 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<BackupRequestOutcome> RestoreUserTitleSaveFromZip(LibHacUserId userId,
|
||||||
|
ulong titleId,
|
||||||
|
string sourceDataPath)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BackupRequestOutcome> 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<Task<bool>> 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<bool> 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<ApplicationControlProperty>(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<RestoreSaveMeta> 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<RestoreSaveMeta>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the "root" save directories
|
||||||
|
var outcome = new List<RestoreSaveMeta>();
|
||||||
|
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<SaveDataType>(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<bool> 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<bool> 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
|
||||||
|
}
|
||||||
|
}
|
19
src/Ryujinx.Ava/Common/SaveManager/SaveManagerMetadata.cs
Normal file
19
src/Ryujinx.Ava/Common/SaveManager/SaveManagerMetadata.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
23
src/Ryujinx.Ava/Common/SaveManager/SaveOptions.cs
Normal file
23
src/Ryujinx.Ava/Common/SaveManager/SaveOptions.cs
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<UserFriendlyAppData> ApplicationMap { get; init; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -268,7 +268,11 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||||
(int)Symbol.Dismiss);
|
(int)Symbol.Dismiss);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static async Task<bool> CreateChoiceDialog(string title, string primary, string secondaryText)
|
internal static async Task<bool> CreateChoiceDialog(string title,
|
||||||
|
string primary,
|
||||||
|
string secondaryText,
|
||||||
|
LocaleKeys primaryButtonKey = LocaleKeys.InputDialogYes,
|
||||||
|
LocaleKeys closeButtonKey = LocaleKeys.InputDialogNo)
|
||||||
{
|
{
|
||||||
if (_isChoiceDialogOpen)
|
if (_isChoiceDialogOpen)
|
||||||
{
|
{
|
||||||
|
@ -281,9 +285,9 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||||
title,
|
title,
|
||||||
primary,
|
primary,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
LocaleManager.Instance[LocaleKeys.InputDialogYes],
|
primaryButton: LocaleManager.Instance[primaryButtonKey],
|
||||||
"",
|
secondaryButton: "",
|
||||||
LocaleManager.Instance[LocaleKeys.InputDialogNo],
|
closeButton: LocaleManager.Instance[closeButtonKey],
|
||||||
(int)Symbol.Help,
|
(int)Symbol.Help,
|
||||||
UserResult.Yes);
|
UserResult.Yes);
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
using DynamicData;
|
using DynamicData;
|
||||||
using DynamicData.Binding;
|
using DynamicData.Binding;
|
||||||
|
using LibHac.Fs;
|
||||||
using Ryujinx.Ava.Common.Locale;
|
using Ryujinx.Ava.Common.Locale;
|
||||||
using Ryujinx.Ava.UI.Models;
|
using Ryujinx.Ava.UI.Models;
|
||||||
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
||||||
|
using Ryujinx.Ui.Common.Helper;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
@ -13,6 +16,8 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||||
private int _sortIndex;
|
private int _sortIndex;
|
||||||
private int _orderIndex;
|
private int _orderIndex;
|
||||||
private string _search;
|
private string _search;
|
||||||
|
private bool _isGoBackEnabled = true;
|
||||||
|
private LoadingBarData _loadingBarData = new();
|
||||||
private ObservableCollection<SaveModel> _saves = new();
|
private ObservableCollection<SaveModel> _saves = new();
|
||||||
private ObservableCollection<SaveModel> _views = new();
|
private ObservableCollection<SaveModel> _views = new();
|
||||||
private readonly AccountManager _accountManager;
|
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<SaveModel> Saves
|
public ObservableCollection<SaveModel> Saves
|
||||||
{
|
{
|
||||||
get => _saves;
|
get => _saves;
|
||||||
|
@ -78,6 +107,18 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||||
_accountManager = accountManager;
|
_accountManager = accountManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AddNewSaveEntry(SaveModel model)
|
||||||
|
{
|
||||||
|
_saves.Add(model);
|
||||||
|
|
||||||
|
if (Filter(model))
|
||||||
|
{
|
||||||
|
_views.Add(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(Views));
|
||||||
|
}
|
||||||
|
|
||||||
public void Sort()
|
public void Sort()
|
||||||
{
|
{
|
||||||
Saves.AsObservableChangeSet()
|
Saves.AsObservableChangeSet()
|
||||||
|
@ -92,12 +133,8 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||||
|
|
||||||
private bool Filter(object arg)
|
private bool Filter(object arg)
|
||||||
{
|
{
|
||||||
if (arg is SaveModel save)
|
return arg is SaveModel save
|
||||||
{
|
&& (string.IsNullOrWhiteSpace(_search) || save.Title.Contains(_search, StringComparison.OrdinalIgnoreCase));
|
||||||
return string.IsNullOrWhiteSpace(_search) || save.Title.ToLower().Contains(_search.ToLower());
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private IComparer<SaveModel> GetComparer()
|
private IComparer<SaveModel> GetComparer()
|
||||||
|
|
|
@ -201,13 +201,36 @@
|
||||||
<StackPanel
|
<StackPanel
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Margin="0 24 0 0"
|
Margin="0 24 0 0"
|
||||||
Orientation="Horizontal">
|
Orientation="Horizontal"
|
||||||
|
Spacing="10">
|
||||||
<Button
|
<Button
|
||||||
Width="50"
|
Width="50"
|
||||||
MinWidth="50"
|
MinWidth="50"
|
||||||
|
IsEnabled="{Binding IsGoBackEnabled}"
|
||||||
Click="GoBack">
|
Click="GoBack">
|
||||||
<ui:SymbolIcon Symbol="Back" />
|
<ui:SymbolIcon Symbol="Back" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel
|
||||||
|
Grid.Row="2"
|
||||||
|
Margin="0 24 0 0"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="10">
|
||||||
|
<ProgressBar
|
||||||
|
Name="LoadProgressBar"
|
||||||
|
Height="6"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource HighlightColor}"
|
||||||
|
IsVisible="{Binding LoadingBarData.IsVisible}"
|
||||||
|
Maximum="{Binding LoadingBarData.Max}"
|
||||||
|
Value="{Binding LoadingBarData.Curr}" />
|
||||||
|
<Button
|
||||||
|
Click="GenerateProfileSaveBackup"
|
||||||
|
Content="{locale:Locale ExportUserSaveBackup}" />
|
||||||
|
<Button
|
||||||
|
Click="ImportSaveBackup"
|
||||||
|
Content="{locale:Locale ImportUserSaveBackup}" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
|
@ -1,6 +1,9 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Notifications;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using DynamicData;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
using FluentAvalonia.UI.Navigation;
|
using FluentAvalonia.UI.Navigation;
|
||||||
using LibHac;
|
using LibHac;
|
||||||
|
@ -9,14 +12,18 @@ using LibHac.Fs;
|
||||||
using LibHac.Fs.Shim;
|
using LibHac.Fs.Shim;
|
||||||
using Ryujinx.Ava.Common;
|
using Ryujinx.Ava.Common;
|
||||||
using Ryujinx.Ava.Common.Locale;
|
using Ryujinx.Ava.Common.Locale;
|
||||||
|
using Ryujinx.Ava.Common.SaveManager;
|
||||||
using Ryujinx.Ava.UI.Controls;
|
using Ryujinx.Ava.UI.Controls;
|
||||||
using Ryujinx.Ava.UI.Helpers;
|
using Ryujinx.Ava.UI.Helpers;
|
||||||
using Ryujinx.Ava.UI.Models;
|
using Ryujinx.Ava.UI.Models;
|
||||||
using Ryujinx.Ava.UI.ViewModels;
|
using Ryujinx.Ava.UI.ViewModels;
|
||||||
using Ryujinx.HLE.FileSystem;
|
using Ryujinx.HLE.FileSystem;
|
||||||
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
using Ryujinx.HLE.HOS.Services.Account.Acc;
|
||||||
|
using Ryujinx.Ui.Common.Helper;
|
||||||
|
using Ryujinx.Ui.Common.SaveManager;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Button = Avalonia.Controls.Button;
|
using Button = Avalonia.Controls.Button;
|
||||||
using UserId = LibHac.Fs.UserId;
|
using UserId = LibHac.Fs.UserId;
|
||||||
|
@ -31,6 +38,7 @@ namespace Ryujinx.Ava.UI.Views.User
|
||||||
private HorizonClient _horizonClient;
|
private HorizonClient _horizonClient;
|
||||||
private VirtualFileSystem _virtualFileSystem;
|
private VirtualFileSystem _virtualFileSystem;
|
||||||
private NavigationDialogHost _parent;
|
private NavigationDialogHost _parent;
|
||||||
|
private ISaveManager _saveManager;
|
||||||
|
|
||||||
public UserSaveManagerView()
|
public UserSaveManagerView()
|
||||||
{
|
{
|
||||||
|
@ -43,25 +51,27 @@ namespace Ryujinx.Ava.UI.Views.User
|
||||||
|
|
||||||
private void NavigatedTo(NavigationEventArgs arg)
|
private void NavigatedTo(NavigationEventArgs arg)
|
||||||
{
|
{
|
||||||
if (Program.PreviewerDetached)
|
if (!Program.PreviewerDetached)
|
||||||
{
|
{
|
||||||
switch (arg.NavigationMode)
|
return;
|
||||||
{
|
|
||||||
case NavigationMode.New:
|
|
||||||
var (parent, accountManager, client, virtualFileSystem) = ((NavigationDialogHost parent, AccountManager accountManager, HorizonClient client, VirtualFileSystem virtualFileSystem))arg.Parameter;
|
|
||||||
_accountManager = accountManager;
|
|
||||||
_horizonClient = client;
|
|
||||||
_virtualFileSystem = virtualFileSystem;
|
|
||||||
|
|
||||||
_parent = parent;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataContext = ViewModel = new UserSaveManagerViewModel(_accountManager);
|
|
||||||
((ContentDialog)_parent.Parent).Title = $"{LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle]} - {ViewModel.SaveManagerHeading}";
|
|
||||||
|
|
||||||
Task.Run(LoadSaves);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (arg.NavigationMode)
|
||||||
|
{
|
||||||
|
case NavigationMode.New:
|
||||||
|
(_parent, _accountManager, _horizonClient, _virtualFileSystem) =
|
||||||
|
((NavigationDialogHost parent, AccountManager accountManager, HorizonClient client, VirtualFileSystem virtualFileSystem))arg.Parameter;
|
||||||
|
|
||||||
|
_saveManager = new SaveManager(_horizonClient, _accountManager);
|
||||||
|
_saveManager.BackupProgressUpdated += BackupManager_ProgressUpdate;
|
||||||
|
_saveManager.BackupImportSave += BackupManager_ImportSave;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
DataContext = ViewModel = new UserSaveManagerViewModel(_accountManager);
|
||||||
|
((ContentDialog)_parent.Parent).Title = $"{LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle]} - {ViewModel.SaveManagerHeading}";
|
||||||
|
|
||||||
|
_ = Task.Run(LoadSaves);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadSaves()
|
public void LoadSaves()
|
||||||
|
@ -110,7 +120,10 @@ namespace Ryujinx.Ava.UI.Views.User
|
||||||
|
|
||||||
private void GoBack(object sender, RoutedEventArgs e)
|
private void GoBack(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_parent?.GoBack();
|
if (ViewModel.IsGoBackEnabled)
|
||||||
|
{
|
||||||
|
_parent?.GoBack();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenLocation(object sender, RoutedEventArgs e)
|
private void OpenLocation(object sender, RoutedEventArgs e)
|
||||||
|
@ -144,5 +157,154 @@ namespace Ryujinx.Ava.UI.Views.User
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void GenerateProfileSaveBackup(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OpenFolderDialog dialog = new()
|
||||||
|
{
|
||||||
|
Title = LocaleManager.Instance[LocaleKeys.SaveManagerChooseBackupFolderTitle]
|
||||||
|
};
|
||||||
|
|
||||||
|
var backupDir = await dialog.ShowAsync(((TopLevel)_parent.GetVisualRoot()) as Window);
|
||||||
|
if (string.IsNullOrWhiteSpace(backupDir))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable the user from doing anything until we complete
|
||||||
|
ViewModel.IsGoBackEnabled = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Could potentially seed with existing saves already enumerated but we still need bcat and device data
|
||||||
|
var result = await _saveManager.BackupUserSaveDataToZip(
|
||||||
|
userId: _accountManager.LastOpenedUser.UserId.ToLibHacUserId(),
|
||||||
|
location: backupDir,
|
||||||
|
saveOptions: SaveOptions.Default);
|
||||||
|
|
||||||
|
var notificationType = result.DidFail
|
||||||
|
? NotificationType.Error
|
||||||
|
: NotificationType.Success;
|
||||||
|
|
||||||
|
var message = result.DidFail
|
||||||
|
? LocaleManager.Instance[LocaleKeys.SaveManagerBackupFailed]
|
||||||
|
: LocaleManager.Instance[LocaleKeys.SaveManagerBackupComplete];
|
||||||
|
|
||||||
|
NotificationHelper.Show(LocaleManager.Instance[LocaleKeys.NotificationBackupTitle],
|
||||||
|
message,
|
||||||
|
notificationType);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ContentDialogHelper.CreateErrorDialog($"Failed to generate backup - {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ViewModel.LoadingBarData = new();
|
||||||
|
ViewModel.IsGoBackEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ImportSaveBackup(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
bool userConfirmation = await ContentDialogHelper.CreateChoiceDialog(LocaleManager.Instance[LocaleKeys.SaveManagerConfirmRestoreTitle],
|
||||||
|
LocaleManager.Instance[LocaleKeys.SaveManagerChooseRestoreZipPrimaryMessage],
|
||||||
|
LocaleManager.Instance[LocaleKeys.SaveManagerChooseRestoreZipSecondaryMessage],
|
||||||
|
primaryButtonKey: LocaleKeys.SaveMangerRestoreUserConfirm,
|
||||||
|
closeButtonKey: LocaleKeys.SaveMangerRestoreUserCancel);
|
||||||
|
|
||||||
|
if (!userConfirmation)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenFileDialog dialog = new()
|
||||||
|
{
|
||||||
|
Title = LocaleManager.Instance[LocaleKeys.SaveManagerChooseRestoreZipTitle],
|
||||||
|
AllowMultiple = false,
|
||||||
|
Filters = {
|
||||||
|
new FileDialogFilter() {
|
||||||
|
Name = "Zip files",
|
||||||
|
Extensions = { "zip" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var saveBackupZip = await dialog.ShowAsync(((TopLevel)_parent.GetVisualRoot()) as Window);
|
||||||
|
if (saveBackupZip is null
|
||||||
|
|| saveBackupZip.Length == 0
|
||||||
|
|| string.IsNullOrWhiteSpace(saveBackupZip[0]))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable the user from doing anything until we complete
|
||||||
|
ViewModel.IsGoBackEnabled = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Could potentially seed with existing saves already enumerated but we still need bcat and device data
|
||||||
|
var result = await _saveManager.RestoreUserSaveDataFromZip(
|
||||||
|
userId: _accountManager.LastOpenedUser.UserId.ToLibHacUserId(),
|
||||||
|
sourceDataPath: saveBackupZip[0]);
|
||||||
|
|
||||||
|
var notificationType = result.DidFail
|
||||||
|
? NotificationType.Error
|
||||||
|
: NotificationType.Success;
|
||||||
|
|
||||||
|
var message = result.DidFail
|
||||||
|
? LocaleManager.Instance[LocaleKeys.SaveManagerRestoreFailed]
|
||||||
|
: LocaleManager.Instance[LocaleKeys.SaveManagerRestoreComplete];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ViewModel.Search))
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
ViewModel.Sort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationHelper.Show(LocaleManager.Instance[LocaleKeys.NotificationBackupTitle],
|
||||||
|
message,
|
||||||
|
notificationType);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ContentDialogHelper.CreateErrorDialog($"Failed to import backup saves - {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ViewModel.LoadingBarData = new();
|
||||||
|
ViewModel.IsGoBackEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackupManager_ProgressUpdate(object sender, LoadingBarEventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
ViewModel.LoadingBarData = new()
|
||||||
|
{
|
||||||
|
Curr = e.Curr,
|
||||||
|
Max = e.Max
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackupManager_ImportSave(object sender, ImportSaveEventArgs e)
|
||||||
|
{
|
||||||
|
var existingSave = ViewModel.Saves.FirstOrDefault(s => s.TitleId == e.SaveInfo.ProgramId);
|
||||||
|
|
||||||
|
if (existingSave == default)
|
||||||
|
{
|
||||||
|
ViewModel.AddNewSaveEntry(new SaveModel(e.SaveInfo));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewModel.Saves.Replace(existingSave, new SaveModel(e.SaveInfo));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ namespace Ryujinx.Common.Configuration
|
||||||
private const string GamesDir = "games";
|
private const string GamesDir = "games";
|
||||||
private const string ProfilesDir = "profiles";
|
private const string ProfilesDir = "profiles";
|
||||||
private const string KeysDir = "system";
|
private const string KeysDir = "system";
|
||||||
|
private const string BackupDir = "backup";
|
||||||
|
|
||||||
public enum LaunchMode
|
public enum LaunchMode
|
||||||
{
|
{
|
||||||
|
@ -28,6 +29,7 @@ namespace Ryujinx.Common.Configuration
|
||||||
public static string ProfilesDirPath { get; private set; }
|
public static string ProfilesDirPath { get; private set; }
|
||||||
public static string KeysDirPath { get; private set; }
|
public static string KeysDirPath { get; private set; }
|
||||||
public static string KeysDirPathUser { get; }
|
public static string KeysDirPathUser { get; }
|
||||||
|
public static string BackupDirPath { get; private set; }
|
||||||
|
|
||||||
public const string DefaultNandDir = "bis";
|
public const string DefaultNandDir = "bis";
|
||||||
public const string DefaultSdcardDir = "sdcard";
|
public const string DefaultSdcardDir = "sdcard";
|
||||||
|
@ -124,6 +126,7 @@ namespace Ryujinx.Common.Configuration
|
||||||
Directory.CreateDirectory(GamesDirPath = Path.Combine(BaseDirPath, GamesDir));
|
Directory.CreateDirectory(GamesDirPath = Path.Combine(BaseDirPath, GamesDir));
|
||||||
Directory.CreateDirectory(ProfilesDirPath = Path.Combine(BaseDirPath, ProfilesDir));
|
Directory.CreateDirectory(ProfilesDirPath = Path.Combine(BaseDirPath, ProfilesDir));
|
||||||
Directory.CreateDirectory(KeysDirPath = Path.Combine(BaseDirPath, KeysDir));
|
Directory.CreateDirectory(KeysDirPath = Path.Combine(BaseDirPath, KeysDir));
|
||||||
|
Directory.CreateDirectory(BackupDirPath = Path.Combine(BaseDirPath, BackupDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if existing old baseDirPath is a symlink, to prevent possible errors.
|
// Check if existing old baseDirPath is a symlink, to prevent possible errors.
|
||||||
|
|
|
@ -56,6 +56,11 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc
|
||||||
return new Uid((ulong)High, (ulong)Low);
|
return new Uid((ulong)High, (ulong)Low);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LibHac.Fs.UserId ToLibHacUserId()
|
||||||
|
{
|
||||||
|
return new LibHac.Fs.UserId((ulong)High, (ulong)Low);
|
||||||
|
}
|
||||||
|
|
||||||
public UInt128 ToUInt128()
|
public UInt128 ToUInt128()
|
||||||
{
|
{
|
||||||
return new UInt128((ulong)High, (ulong)Low);
|
return new UInt128((ulong)High, (ulong)Low);
|
||||||
|
|
17
src/Ryujinx.Ui.Common/Helper/LoadingBarData.cs
Normal file
17
src/Ryujinx.Ui.Common/Helper/LoadingBarData.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Ryujinx.Ui.Common.Helper
|
||||||
|
{
|
||||||
|
public record class LoadingBarData
|
||||||
|
{
|
||||||
|
public int Max { get; set; } = 0;
|
||||||
|
public int Curr { get; set; } = 0;
|
||||||
|
public bool IsVisible => Max > 0 && Curr < Max;
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
Max = 0;
|
||||||
|
Curr = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
src/Ryujinx.Ui.Common/Helper/LoadingBarEventArgs.cs
Normal file
10
src/Ryujinx.Ui.Common/Helper/LoadingBarEventArgs.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Ryujinx.Ui.Common.Helper
|
||||||
|
{
|
||||||
|
public class LoadingBarEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public int Curr { get; set; }
|
||||||
|
public int Max { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@
|
||||||
<None Remove="Resources\Logo_Patreon.png" />
|
<None Remove="Resources\Logo_Patreon.png" />
|
||||||
<None Remove="Resources\Logo_Ryujinx.png" />
|
<None Remove="Resources\Logo_Ryujinx.png" />
|
||||||
<None Remove="Resources\Logo_Twitter.png" />
|
<None Remove="Resources\Logo_Twitter.png" />
|
||||||
|
<None Remove="SaveManager\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -66,4 +67,8 @@
|
||||||
<ProjectReference Include="..\Ryujinx.Graphics.Vulkan\Ryujinx.Graphics.Vulkan.csproj" />
|
<ProjectReference Include="..\Ryujinx.Graphics.Vulkan\Ryujinx.Graphics.Vulkan.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="SaveManager\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
10
src/Ryujinx.Ui.Common/SaveManager/ImportSaveEventArgs.cs
Normal file
10
src/Ryujinx.Ui.Common/SaveManager/ImportSaveEventArgs.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using LibHac.Fs;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Ryujinx.Ui.Common.SaveManager
|
||||||
|
{
|
||||||
|
public class ImportSaveEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public SaveDataInfo SaveInfo { get; init; }
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue