mirror of
https://git.naxdy.org/Mirror/Ryujinx.git
synced 2025-02-16 06:13:35 +00:00
removed old stuff
saveDataImporter working saveDataExporter fixes added import to saveDataManager
This commit is contained in:
parent
f47ee05ced
commit
4602519695
6 changed files with 386 additions and 364 deletions
357
Ryujinx.Ava/Common/SaveDataFileManager.cs
Normal file
357
Ryujinx.Ava/Common/SaveDataFileManager.cs
Normal file
|
@ -0,0 +1,357 @@
|
||||||
|
using LibHac;
|
||||||
|
using LibHac.Account;
|
||||||
|
using LibHac.Common;
|
||||||
|
using LibHac.Fs;
|
||||||
|
using LibHac.Fs.Fsa;
|
||||||
|
using LibHac.Fs.Shim;
|
||||||
|
using LibHac.Ns;
|
||||||
|
using LibHac.Tools.Fs;
|
||||||
|
using LibHac.Tools.FsSystem;
|
||||||
|
using Ryujinx.Ava.Ui.Controls;
|
||||||
|
using Ryujinx.Ava.Ui.Models;
|
||||||
|
using Ryujinx.Common.Logging;
|
||||||
|
using Ryujinx.HLE.FileSystem;
|
||||||
|
using Ryujinx.Ui.App.Common;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using Logger = Ryujinx.Common.Logging.Logger;
|
||||||
|
using Path = System.IO.Path;
|
||||||
|
using Result = LibHac.Result;
|
||||||
|
using UserId = LibHac.Fs.UserId;
|
||||||
|
using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile;
|
||||||
|
|
||||||
|
namespace Ryujinx.Ava.Common
|
||||||
|
{
|
||||||
|
internal class SaveDataFileManager
|
||||||
|
{
|
||||||
|
private readonly UserProfile _userProfile;
|
||||||
|
private readonly HorizonClient _horizonClient;
|
||||||
|
private readonly VirtualFileSystem _virtualFileSystem;
|
||||||
|
private readonly UserId _userId;
|
||||||
|
|
||||||
|
private readonly List<ApplicationData> _applications;
|
||||||
|
|
||||||
|
public readonly string mountName = "save";
|
||||||
|
public readonly string outputMountName = "output";
|
||||||
|
|
||||||
|
public SaveDataFileManager(List<ApplicationData> applications, UserProfile userProfile, HorizonClient horizonClient, VirtualFileSystem virtualFileSystem, UserId userId)
|
||||||
|
{
|
||||||
|
_userProfile = userProfile;
|
||||||
|
_horizonClient = horizonClient;
|
||||||
|
_virtualFileSystem = virtualFileSystem;
|
||||||
|
_applications = applications;
|
||||||
|
_userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#region Export
|
||||||
|
|
||||||
|
public void SaveUserSaveDirectoryAsZip(string backupFolder, List<SaveModel> saves)
|
||||||
|
{
|
||||||
|
CreateSaveDataBackup(backupFolder, saves);
|
||||||
|
|
||||||
|
ZipFile.CreateFromDirectory(backupFolder, backupFolder + ".zip");
|
||||||
|
Directory.Delete(backupFolder, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateSaveDataBackup(string backupPath, List<SaveModel> saves)
|
||||||
|
{
|
||||||
|
U8Span mountNameU8 = mountName.ToU8Span();
|
||||||
|
U8Span outputMountNameU8 = outputMountName.ToU8Span();
|
||||||
|
|
||||||
|
foreach (ApplicationData application in _applications)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//Register destination folder as output and mount output
|
||||||
|
Result registerOutpDirResult = RegisterOutputDirectory(Path.Combine(backupPath, application.TitleId.ToUpper()), outputMountNameU8);
|
||||||
|
if (registerOutpDirResult.IsFailure())
|
||||||
|
{
|
||||||
|
Logger.Error.Value.Print(LogClass.Application, $"Could not register and mount output directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
//Mount SaveData as save, opens the saveDataIterators and starts reading saveDataInfo
|
||||||
|
Result openAndReadSaveDataResult = OpenSaveDataIteratorAndReadSaveData(mountNameU8, application, out SaveDataInfo saveDataInfo);
|
||||||
|
|
||||||
|
if (openAndReadSaveDataResult.IsFailure())
|
||||||
|
{
|
||||||
|
Logger.Error.Value.Print(LogClass.Application, $"Could not open save Iterator and start reading for application: {application.TitleName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//Copies the whole directory from save mount to output mount
|
||||||
|
Result copyDirResult = CopySaveDataDirectory(mountName, outputMountName);
|
||||||
|
Logger.Info.Value.Print(LogClass.Application, $"Successfuly created backup for {application.TitleName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
//Unmount save and output
|
||||||
|
UnmountDirectory(mountNameU8);
|
||||||
|
UnmountDirectory(outputMountNameU8);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#region Import
|
||||||
|
|
||||||
|
public void RestoreSavedataBackup(string backupZipFile)
|
||||||
|
{
|
||||||
|
ExtractBackupToSaveDirectory(backupZipFile);
|
||||||
|
|
||||||
|
Logger.Info.Value.Print(LogClass.Application, $"Done extracting savedata backup!", nameof(SaveDataImporter));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExtractBackupToSaveDirectory(string backupZipFile)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(backupZipFile) && File.Exists(backupZipFile))
|
||||||
|
{
|
||||||
|
string extractedZipFolders = ExtractZip(backupZipFile);
|
||||||
|
|
||||||
|
Logger.Info.Value.Print(LogClass.Application, $"Extracted Backup zip to temp path: {extractedZipFolders}", nameof(SaveDataImporter));
|
||||||
|
|
||||||
|
U8Span mountNameU8 = mountName.ToU8Span();
|
||||||
|
U8Span outputMountNameU8 = outputMountName.ToU8Span();
|
||||||
|
|
||||||
|
|
||||||
|
foreach (ApplicationData application in _applications)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string backupTitleSaveDataFolder = Path.Combine(Directory.GetParent(extractedZipFolders).FullName, application.TitleId.ToUpper());
|
||||||
|
|
||||||
|
//Register destination folder as output and mount output
|
||||||
|
Result registerSaveDataBackupFolderResult = RegisterOutputDirectory(backupTitleSaveDataFolder, outputMountNameU8);
|
||||||
|
if (registerSaveDataBackupFolderResult.IsFailure())
|
||||||
|
{
|
||||||
|
Logger.Error.Value.Print(LogClass.Application, $"Could not register and mount output directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
//Mount SaveData as save, opens the saveDataIterators and starts reading saveDataInfo
|
||||||
|
Result openAndReadSaveDataResult = OpenSaveDataIteratorAndReadSaveData(mountNameU8, application, out SaveDataInfo saveDataInfo);
|
||||||
|
|
||||||
|
if (openAndReadSaveDataResult.IsFailure())
|
||||||
|
{
|
||||||
|
Logger.Error.Value.Print(LogClass.Application, $"Could not open save Iterator and start reading for application: {application.TitleName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//Copies the whole directory from backup mount to saveData mount
|
||||||
|
Result copyDirResult = CopySaveDataDirectory(outputMountName, mountName);
|
||||||
|
Logger.Info.Value.Print(LogClass.Application, $"Successfuly restored backup for: {application.TitleName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Result commitOuptut = _horizonClient.Fs.Commit(outputMountNameU8);
|
||||||
|
|
||||||
|
//Unmount save and output
|
||||||
|
UnmountDirectory(mountNameU8);
|
||||||
|
UnmountDirectory(outputMountNameU8);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ExtractZip(string backupZipFile)
|
||||||
|
{
|
||||||
|
string tempZipExtractionPath = Path.GetTempPath();
|
||||||
|
ZipFile.ExtractToDirectory(backupZipFile, tempZipExtractionPath, true);
|
||||||
|
|
||||||
|
return tempZipExtractionPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
#region Horizon OS Open/Register/Read/Mount Stuff
|
||||||
|
|
||||||
|
private Result RegisterOutputDirectory(string backupPath, U8Span mountName)
|
||||||
|
{
|
||||||
|
using UniqueRef<IFileSystem> outputFileSystem = new UniqueRef<IFileSystem>(new LibHac.FsSystem.LocalFileSystem(backupPath));
|
||||||
|
|
||||||
|
Result registerResult = _horizonClient.Fs.Register(mountName, ref outputFileSystem.Ref());
|
||||||
|
if (registerResult.IsFailure()) return registerResult.Miss();
|
||||||
|
|
||||||
|
return registerResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result GetSaveDataIterator(out SaveDataInfo saveDataInfo, ApplicationData application)
|
||||||
|
{
|
||||||
|
return _horizonClient.Fs.FindSaveDataWithFilter(out saveDataInfo,
|
||||||
|
SaveDataSpaceId.User,
|
||||||
|
SaveDataFilter.Make(ulong.Parse(application.TitleId, NumberStyles.HexNumber),
|
||||||
|
saveType: default,
|
||||||
|
_userId,
|
||||||
|
saveDataId: default,
|
||||||
|
index: default));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result MountSaveDataDirectory(ulong programId, U8Span mountName)
|
||||||
|
{
|
||||||
|
if (!_horizonClient.Fs.IsMounted(mountName))
|
||||||
|
{
|
||||||
|
return _horizonClient.Fs.MountSaveData(mountName, ConvertProgramIdToApplicationId(programId), _userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result UnmountDirectory(U8Span mountName)
|
||||||
|
{
|
||||||
|
if (_horizonClient.Fs.IsMounted(mountName))
|
||||||
|
{
|
||||||
|
Result commitFilesResult = _horizonClient.Fs.CommitSaveData(mountName);
|
||||||
|
if (commitFilesResult.IsFailure()) return commitFilesResult;
|
||||||
|
|
||||||
|
_horizonClient.Fs.Unmount(mountName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CreateSaveData(ApplicationData app)
|
||||||
|
{
|
||||||
|
ref ApplicationControlProperty control = ref app.ControlHolder.Value;
|
||||||
|
|
||||||
|
Logger.Info?.Print(LogClass.Application, $"Creating save directory for Title: {app.TitleName} [{app.TitleId:x16}]");
|
||||||
|
|
||||||
|
if (Utilities.IsZeros(app.ControlHolder.ByteSpan))
|
||||||
|
{
|
||||||
|
// 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 Uid(ulong.Parse(_userProfile.UserId.High.ToString(), NumberStyles.HexNumber),
|
||||||
|
ulong.Parse(_userProfile.UserId.Low.ToString(), NumberStyles.HexNumber));
|
||||||
|
|
||||||
|
Result findSaveDataResult = _horizonClient.Fs.EnsureApplicationSaveData(out _, new LibHac.Ncm.ApplicationId(ulong.Parse(app.TitleId, NumberStyles.HexNumber)), in control, in user);
|
||||||
|
|
||||||
|
return findSaveDataResult.IsSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result OpenSaveDataIteratorAndReadSaveData(U8Span mountName, ApplicationData application, out SaveDataInfo saveDataInfo)
|
||||||
|
{
|
||||||
|
Result getSvDataIteratorResult = GetSaveDataIterator(out saveDataInfo, application);
|
||||||
|
if (getSvDataIteratorResult.IsFailure())
|
||||||
|
{
|
||||||
|
bool createdSaveData = CreateSaveData(application);
|
||||||
|
|
||||||
|
if (!createdSaveData)
|
||||||
|
{
|
||||||
|
Logger.Warning?.Print(LogClass.Application, "Could not create saveData for " + application.TitleName);
|
||||||
|
return getSvDataIteratorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSvDataIteratorResult = GetSaveDataIterator(out saveDataInfo, application);
|
||||||
|
if (getSvDataIteratorResult.IsFailure()) return getSvDataIteratorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result mountSvDataResult = MountSaveDataDirectory(saveDataInfo.ProgramId.Value, mountName);
|
||||||
|
if (mountSvDataResult.IsFailure()) return mountSvDataResult;
|
||||||
|
|
||||||
|
UniqueRef<SaveDataIterator> saveDataIterator = new UniqueRef<SaveDataIterator>();
|
||||||
|
Result openSvDataIteratorResult = _horizonClient.Fs.OpenSaveDataIterator(ref saveDataIterator, SaveDataSpaceId.User);
|
||||||
|
if (openSvDataIteratorResult.IsFailure()) return openSvDataIteratorResult;
|
||||||
|
|
||||||
|
Result readSvDataInfoResult = saveDataIterator.Get.ReadSaveDataInfo(out _, new Span<SaveDataInfo>(ref saveDataInfo));
|
||||||
|
if (openSvDataIteratorResult.IsFailure()) return readSvDataInfoResult;
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
#region Copy Directory/File
|
||||||
|
private Result CopySaveDataDirectory(string sourcePath, string destPath)
|
||||||
|
{
|
||||||
|
Result openDir = _horizonClient.Fs.OpenDirectory(out DirectoryHandle dirHandle, $"{sourcePath}:/".ToU8Span(), OpenDirectoryMode.All);
|
||||||
|
if (openDir.IsFailure()) return openDir;
|
||||||
|
|
||||||
|
using (dirHandle)
|
||||||
|
{
|
||||||
|
sourcePath = $"{sourcePath}:/";
|
||||||
|
destPath = $"{destPath}:/";
|
||||||
|
foreach (DirectoryEntryEx entry in _horizonClient.Fs.EnumerateEntries(sourcePath, "*", SearchOptions.Default))
|
||||||
|
{
|
||||||
|
string subSrcPath = PathTools.Normalize(PathTools.Combine(sourcePath, entry.Name));
|
||||||
|
string subDstPath = PathTools.Normalize(PathTools.Combine(destPath, entry.Name));
|
||||||
|
|
||||||
|
if (entry.Type == DirectoryEntryType.Directory)
|
||||||
|
{
|
||||||
|
Result copyDirResult = CopyDirectory(subSrcPath, subDstPath);
|
||||||
|
}
|
||||||
|
if (entry.Type == DirectoryEntryType.File)
|
||||||
|
{
|
||||||
|
Result copyFinalDir = CopyFile(subSrcPath, subDstPath, entry.Size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_horizonClient.Fs.CloseDirectory(dirHandle);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result CopyDirectory(string subSrcPath, string subDstPath)
|
||||||
|
{
|
||||||
|
_horizonClient.Fs.EnsureDirectoryExists(subDstPath);
|
||||||
|
|
||||||
|
Result copyDirResult = _horizonClient.Fs.CopyDirectory(subSrcPath, subDstPath);
|
||||||
|
|
||||||
|
if (copyDirResult.IsFailure())
|
||||||
|
{
|
||||||
|
Logger.Error.Value.Print(LogClass.Application, $"Could not copy directory: \n{subSrcPath} to destination {subDstPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info.Value.Print(LogClass.Application, $"Successfully copied directory: \n{subSrcPath} to destination {subDstPath}");
|
||||||
|
|
||||||
|
return copyDirResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result CopyFile(string subSrcPath, string subDstPath, long entrySize)
|
||||||
|
{
|
||||||
|
_horizonClient.Fs.CreateOrOverwriteFile(subDstPath, entrySize);
|
||||||
|
|
||||||
|
Result copyFileResult = _horizonClient.Fs.CopyFile(subSrcPath, subDstPath);
|
||||||
|
if (copyFileResult.IsFailure())
|
||||||
|
{
|
||||||
|
Logger.Error.Value.Print(LogClass.Application, $"Could not copy file: \n{subSrcPath} to destination '{subDstPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info.Value.Print(LogClass.Application, $"Successfully copied file: \n{subSrcPath} to destination {subDstPath}");
|
||||||
|
|
||||||
|
return copyFileResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private LibHac.Ncm.ApplicationId ConvertProgramIdToApplicationId(ulong programId)
|
||||||
|
{
|
||||||
|
return new LibHac.Ncm.ApplicationId(programId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,224 +1,36 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LibHac;
|
using LibHac;
|
||||||
using LibHac.Common;
|
|
||||||
using LibHac.Fs;
|
using LibHac.Fs;
|
||||||
using LibHac.Fs.Fsa;
|
using Ryujinx.Ava.Common;
|
||||||
using LibHac.Fs.Shim;
|
|
||||||
using LibHac.Tools.Fs;
|
|
||||||
using LibHac.Tools.FsSystem;
|
|
||||||
using Ryujinx.Ava.Common.Locale;
|
using Ryujinx.Ava.Common.Locale;
|
||||||
using Ryujinx.Ava.Ui.Models;
|
using Ryujinx.Ava.Ui.Models;
|
||||||
using Ryujinx.Ava.Ui.Windows;
|
using Ryujinx.Ava.Ui.Windows;
|
||||||
using Ryujinx.Common.Logging;
|
|
||||||
using Ryujinx.HLE.FileSystem;
|
using Ryujinx.HLE.FileSystem;
|
||||||
using Ryujinx.Ui.App.Common;
|
using Ryujinx.Ui.App.Common;
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Logger = Ryujinx.Common.Logging.Logger;
|
using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile;
|
||||||
using Path = System.IO.Path;
|
|
||||||
|
|
||||||
namespace Ryujinx.Ava.Ui.Controls
|
namespace Ryujinx.Ava.Ui.Controls
|
||||||
{
|
{
|
||||||
internal class SaveDataExporter
|
internal class SaveDataExporter
|
||||||
{
|
{
|
||||||
private readonly UserProfile _userProfile;
|
private readonly SaveDataFileManager saveDataFileManager;
|
||||||
private readonly HorizonClient _horizonClient;
|
|
||||||
private readonly VirtualFileSystem _virtualFileSystem;
|
|
||||||
private readonly UserId _userId;
|
|
||||||
|
|
||||||
public SaveDataExporter(UserProfile userProfile, HorizonClient horizonClient, VirtualFileSystem virtualFileSystem)
|
public SaveDataExporter(List<ApplicationData> applications, UserProfile userProfile, HorizonClient horizonClient, VirtualFileSystem virtualFileSystem)
|
||||||
{
|
{
|
||||||
_userProfile = userProfile;
|
UserId userId = new UserId(ulong.Parse(userProfile.UserId.High.ToString(), NumberStyles.HexNumber),
|
||||||
_horizonClient = horizonClient;
|
ulong.Parse(userProfile.UserId.Low.ToString(), NumberStyles.HexNumber));
|
||||||
_virtualFileSystem = virtualFileSystem;
|
|
||||||
_userId = new UserId(
|
saveDataFileManager = new SaveDataFileManager(applications, userProfile, horizonClient, virtualFileSystem, userId);
|
||||||
ulong.Parse(_userProfile.UserId.High.ToString(), System.Globalization.NumberStyles.HexNumber),
|
|
||||||
ulong.Parse(_userProfile.UserId.Low.ToString(), System.Globalization.NumberStyles.HexNumber)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void SaveUserSaveDirectoryAsZip(MainWindow mainWindow, List<SaveModel> saves, List<ApplicationData> applications)
|
public async void SaveUserSaveDirectoryAsZip(MainWindow mainWindow, List<SaveModel> saves)
|
||||||
{
|
{
|
||||||
string backupFolder = await GetAndPrepareBackupPath(mainWindow);
|
string backupFolder = await GetAndPrepareBackupPath(mainWindow);
|
||||||
CreateBackup(backupFolder, saves, applications);
|
saveDataFileManager.SaveUserSaveDirectoryAsZip(backupFolder, saves);
|
||||||
|
|
||||||
ZipFile.CreateFromDirectory(backupFolder, backupFolder + ".zip");
|
|
||||||
Directory.Delete(backupFolder, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CreateBackup(string backupPath, List<SaveModel> saves, List<ApplicationData> applications)
|
|
||||||
{
|
|
||||||
string mountName = "save";
|
|
||||||
string outputMountName = "output";
|
|
||||||
|
|
||||||
foreach (ApplicationData application in applications)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
//Register destination folder as output and mount output
|
|
||||||
Result registerOutpDirResult = RegisterOutputDirectory(Path.Combine(backupPath, application.TitleId), outputMountName);
|
|
||||||
if (registerOutpDirResult.IsFailure())
|
|
||||||
{
|
|
||||||
Logger.Error.Value.Print(LogClass.Application, $"Could not register and mount output directory.");
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
//Mount SaveData as save, opens the saveDataIterators and starts reading saveDataInfo
|
|
||||||
Result openAndReadSaveDataResult = OpenSaveDataIteratorAndReadSaveData(mountName, application, out SaveDataInfo saveDataInfo);
|
|
||||||
|
|
||||||
if(openAndReadSaveDataResult.IsFailure())
|
|
||||||
{
|
|
||||||
Logger.Error.Value.Print(LogClass.Application, $"Could not open save Iterator and start reading for application: {application.TitleName}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
//Copies the whole directory from save mount to output mount
|
|
||||||
Result copyDirResult = CopySaveDataDirectory(mountName, outputMountName);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//Unmount save and output
|
|
||||||
UnmountDirectory(saveDataInfo.ProgramId.Value, mountName);
|
|
||||||
UnmountDirectory(saveDataInfo.ProgramId.Value, outputMountName);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result RegisterOutputDirectory(string backupPath, string mountName)
|
|
||||||
{
|
|
||||||
using UniqueRef<IFileSystem> outputFileSystem = new UniqueRef<IFileSystem>(new LibHac.FsSystem.LocalFileSystem(backupPath));
|
|
||||||
|
|
||||||
Result registerResult = _horizonClient.Fs.Register(mountName.ToU8Span(), ref outputFileSystem.Ref());
|
|
||||||
if (registerResult.IsFailure()) return registerResult.Miss();
|
|
||||||
|
|
||||||
return registerResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result GetSaveDataIterator(out SaveDataInfo saveDataInfo, ApplicationData application)
|
|
||||||
{
|
|
||||||
return _horizonClient.Fs.FindSaveDataWithFilter(out saveDataInfo,
|
|
||||||
SaveDataSpaceId.User,
|
|
||||||
SaveDataFilter.Make(ulong.Parse(application.TitleId, NumberStyles.HexNumber),
|
|
||||||
saveType: default,
|
|
||||||
_userId,
|
|
||||||
saveDataId: default,
|
|
||||||
index: default));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result MountSaveDataDirectory(ulong programId, string mountName)
|
|
||||||
{
|
|
||||||
U8Span mountNameu8 = mountName.ToU8Span();
|
|
||||||
|
|
||||||
if (!_horizonClient.Fs.IsMounted(mountNameu8))
|
|
||||||
{
|
|
||||||
return _horizonClient.Fs.MountSaveData(mountNameu8, ConvertProgramIdToApplicationId(programId), _userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result UnmountDirectory(ulong programId, string mountName)
|
|
||||||
{
|
|
||||||
U8Span mountNameu8 = mountName.ToU8Span();
|
|
||||||
|
|
||||||
if (_horizonClient.Fs.IsMounted(mountNameu8))
|
|
||||||
{
|
|
||||||
_horizonClient.Fs.Unmount(mountNameu8);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result OpenSaveDataIteratorAndReadSaveData(string mountName, ApplicationData application, out SaveDataInfo saveDataInfo)
|
|
||||||
{
|
|
||||||
Result getSvDataIteratorResult = GetSaveDataIterator(out saveDataInfo, application);
|
|
||||||
if (getSvDataIteratorResult.IsFailure()) return getSvDataIteratorResult;
|
|
||||||
|
|
||||||
Result mountSvDataResult = MountSaveDataDirectory(saveDataInfo.ProgramId.Value, mountName);
|
|
||||||
if (mountSvDataResult.IsFailure()) return mountSvDataResult;
|
|
||||||
|
|
||||||
UniqueRef<SaveDataIterator> saveDataIterator = new UniqueRef<SaveDataIterator>();
|
|
||||||
Result openSvDataIteratorResult = _horizonClient.Fs.OpenSaveDataIterator(ref saveDataIterator, SaveDataSpaceId.User);
|
|
||||||
if (openSvDataIteratorResult.IsFailure()) return openSvDataIteratorResult;
|
|
||||||
|
|
||||||
Result readSvDataInfoResult = saveDataIterator.Get.ReadSaveDataInfo(out long readCount, new Span<SaveDataInfo>(ref saveDataInfo));
|
|
||||||
if (readSvDataInfoResult.IsFailure()) return readSvDataInfoResult;
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result CopySaveDataDirectory(string sourcePath, string destPath)
|
|
||||||
{
|
|
||||||
Result openDir = _horizonClient.Fs.OpenDirectory(out DirectoryHandle dirHandle, $"{sourcePath}:/".ToU8Span(), OpenDirectoryMode.All);
|
|
||||||
if (openDir.IsFailure()) return openDir;
|
|
||||||
|
|
||||||
using (dirHandle)
|
|
||||||
{
|
|
||||||
sourcePath = $"{sourcePath}:/";
|
|
||||||
destPath = $"{destPath}:/";
|
|
||||||
foreach (DirectoryEntryEx entry in _horizonClient.Fs.EnumerateEntries(sourcePath, "*", SearchOptions.Default))
|
|
||||||
{
|
|
||||||
string subSrcPath = PathTools.Normalize(PathTools.Combine(sourcePath, entry.Name));
|
|
||||||
string subDstPath = PathTools.Normalize(PathTools.Combine(destPath, entry.Name));
|
|
||||||
|
|
||||||
if (entry.Type == DirectoryEntryType.Directory)
|
|
||||||
{
|
|
||||||
Result copyDirResult = CopyDirectory(subSrcPath, subDstPath);
|
|
||||||
}
|
|
||||||
if (entry.Type == DirectoryEntryType.File)
|
|
||||||
{
|
|
||||||
Result copyFinalDir = CopyFile(subSrcPath, subDstPath, entry.Size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_horizonClient.Fs.CloseDirectory(dirHandle);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result CopyDirectory(string subSrcPath, string subDstPath)
|
|
||||||
{
|
|
||||||
_horizonClient.Fs.EnsureDirectoryExists(subDstPath);
|
|
||||||
|
|
||||||
Result copyDirResult = _horizonClient.Fs.CopyDirectory(subSrcPath, subDstPath);
|
|
||||||
|
|
||||||
if (copyDirResult.IsFailure())
|
|
||||||
{
|
|
||||||
Logger.Error.Value.Print(LogClass.Application, $"Could not copy directory: \n{subSrcPath} to destination {subDstPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Info.Value.Print(LogClass.Application, $"Successfully copied directory: \n{subSrcPath} to destination {subDstPath}");
|
|
||||||
|
|
||||||
return copyDirResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result CopyFile(string subSrcPath, string subDstPath, long entrySize)
|
|
||||||
{
|
|
||||||
_horizonClient.Fs.CreateOrOverwriteFile(subDstPath, entrySize);
|
|
||||||
|
|
||||||
Result copyFileResult = _horizonClient.Fs.CopyFile(subSrcPath, subDstPath);
|
|
||||||
if (copyFileResult.IsFailure())
|
|
||||||
{
|
|
||||||
Logger.Error.Value.Print(LogClass.Application, $"Could not copy file: \n{subSrcPath} to destination '{subDstPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Info.Value.Print(LogClass.Application, $"Successfully copied file: \n{subSrcPath} to destination {subDstPath}");
|
|
||||||
|
|
||||||
return copyFileResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LibHac.Ncm.ApplicationId ConvertProgramIdToApplicationId(ulong programId)
|
|
||||||
{
|
|
||||||
return new LibHac.Ncm.ApplicationId(programId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetAndPrepareBackupPath(MainWindow mainWindow)
|
private async Task<string> GetAndPrepareBackupPath(MainWindow mainWindow)
|
||||||
|
|
|
@ -1,43 +1,39 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LibHac;
|
using LibHac;
|
||||||
|
using Ryujinx.Ava.Common;
|
||||||
using Ryujinx.Ava.Common.Locale;
|
using Ryujinx.Ava.Common.Locale;
|
||||||
using Ryujinx.Ava.Ui.Models;
|
|
||||||
using Ryujinx.Ava.Ui.Windows;
|
using Ryujinx.Ava.Ui.Windows;
|
||||||
using Ryujinx.Common.Configuration;
|
|
||||||
using Ryujinx.Common.Logging;
|
using Ryujinx.Common.Logging;
|
||||||
using Ryujinx.HLE.FileSystem;
|
using Ryujinx.HLE.FileSystem;
|
||||||
using System;
|
using Ryujinx.Ui.App.Common;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.Globalization;
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Path = System.IO.Path;
|
using UserId = LibHac.Fs.UserId;
|
||||||
|
using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile;
|
||||||
|
|
||||||
namespace Ryujinx.Ava.Ui.Controls
|
namespace Ryujinx.Ava.Ui.Controls
|
||||||
{
|
{
|
||||||
internal class SaveDataImporter
|
internal class SaveDataImporter
|
||||||
{
|
{
|
||||||
|
private readonly SaveDataFileManager saveDataFileManager;
|
||||||
|
|
||||||
private readonly UserProfile _userProfile;
|
public SaveDataImporter(List<ApplicationData> applications, UserProfile userProfile, HorizonClient horizonClient, VirtualFileSystem virtualFileSystem)
|
||||||
private readonly HorizonClient _horizonClient;
|
|
||||||
private readonly VirtualFileSystem _virtualFileSystem;
|
|
||||||
|
|
||||||
private async Task<bool> ShowConditionMessage()
|
|
||||||
{
|
{
|
||||||
return await ContentDialogHelper.CreateChoiceDialog("Restore Backup",
|
UserId userId = new UserId(
|
||||||
"You have to start every game at least once to create a save directory for the game before you can Restore the backup save data!",
|
ulong.Parse(userProfile.UserId.High.ToString(), NumberStyles.HexNumber),
|
||||||
"Do you want to continue?");
|
ulong.Parse(userProfile.UserId.Low.ToString(), NumberStyles.HexNumber));
|
||||||
|
|
||||||
|
saveDataFileManager = new SaveDataFileManager(applications, userProfile, horizonClient, virtualFileSystem, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void RestoreSavedataBackup(MainWindow mainWindow)
|
public async void RestoreSavedataBackup(MainWindow mainWindow)
|
||||||
{
|
{
|
||||||
if (!(await ShowConditionMessage())) return;
|
|
||||||
|
|
||||||
string[] backupZipFiles = await ShowFolderDialog(mainWindow);
|
string[] backupZipFiles = await ShowFolderDialog(mainWindow);
|
||||||
|
|
||||||
ExtractBackupToSaveDirectory(backupZipFiles);
|
//Single because we set AllowMultiple = False in folder dialog options and want only one backup file. => Our export
|
||||||
|
saveDataFileManager.RestoreSavedataBackup(backupZipFiles.Single());
|
||||||
|
|
||||||
Logger.Info.Value.Print(LogClass.Application, $"Done extracting savedata backup!", nameof(SaveDataImporter));
|
Logger.Info.Value.Print(LogClass.Application, $"Done extracting savedata backup!", nameof(SaveDataImporter));
|
||||||
}
|
}
|
||||||
|
@ -53,79 +49,5 @@ namespace Ryujinx.Ava.Ui.Controls
|
||||||
|
|
||||||
return await dialog.ShowAsync(mainWindow);
|
return await dialog.ShowAsync(mainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Dictionary<string, string> GetTitleIdWithSavedataPath(string saveDirectoryPath)
|
|
||||||
{
|
|
||||||
Dictionary<string, string> titleIdWithSavePath = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
//Loop through all ExtraData0 files in the savedata directory and read the first 8 bytes to determine which game this belongs to
|
|
||||||
foreach (var saveDataExtra0file in Directory.GetFiles(saveDirectoryPath, "ExtraData0*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string hexValues = FlipHexBytes(new string(Convert.ToHexString(File.ReadAllBytes(saveDataExtra0file)).Substring(0, 16).Reverse().ToArray()));
|
|
||||||
|
|
||||||
if (!titleIdWithSavePath.ContainsKey(hexValues))
|
|
||||||
{
|
|
||||||
titleIdWithSavePath.Add(hexValues, saveDataExtra0file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
Logger.Error.Value.Print(LogClass.Application, $"Could not extract hex from savedata file: {saveDataExtra0file}", nameof(SaveDataImporter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return titleIdWithSavePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FlipHexBytes(string hexString)
|
|
||||||
{
|
|
||||||
StringBuilder result = new StringBuilder();
|
|
||||||
|
|
||||||
for (int i = 0; i <= hexString.Length - 2; i = i + 2)
|
|
||||||
{
|
|
||||||
result.Append(new StringBuilder(new string(hexString.Substring(i, 2).Reverse().ToArray())));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExtractBackupToSaveDirectory(string[] backupZipFiles)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(backupZipFiles.First()) && File.Exists(backupZipFiles.First()))
|
|
||||||
{
|
|
||||||
string tempZipExtractionPath = Path.GetTempPath();
|
|
||||||
ZipFile.ExtractToDirectory(backupZipFiles.First(), tempZipExtractionPath, true);
|
|
||||||
|
|
||||||
Logger.Info.Value.Print(LogClass.Application, $"Extracted Backup zip to temp path: {tempZipExtractionPath}", nameof(SaveDataImporter));
|
|
||||||
|
|
||||||
string saveDir = Path.Combine(AppDataManager.BaseDirPath, AppDataManager.DefaultNandDir, "user", "save");
|
|
||||||
|
|
||||||
Dictionary<string, string> titleIdsAndSavePaths = GetTitleIdWithSavedataPath(saveDir);
|
|
||||||
Dictionary<string, string> titleIdsAndBackupPaths = GetTitleIdWithSavedataPath(tempZipExtractionPath);
|
|
||||||
|
|
||||||
ReplaceSavedataFiles(titleIdsAndSavePaths, titleIdsAndBackupPaths);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReplaceSavedataFiles(Dictionary<string, string> titleIdsWithSavePaths, Dictionary<string, string> titleIdsAndBackupPaths)
|
|
||||||
{
|
|
||||||
foreach (var titleIdAndBackupPath in titleIdsAndBackupPaths)
|
|
||||||
{
|
|
||||||
if (titleIdsWithSavePaths.ContainsKey(titleIdAndBackupPath.Key))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.Move(Directory.GetParent(titleIdAndBackupPath.Value).FullName, Directory.GetParent(titleIdsWithSavePaths[titleIdAndBackupPath.Key]).FullName);
|
|
||||||
Logger.Info.Value.Print(LogClass.Application, $"Copied Savedata {titleIdAndBackupPath.Value} to {titleIdsWithSavePaths[titleIdAndBackupPath.Key]}", nameof(SaveDataImporter));
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
Logger.Error.Value.Print(LogClass.Application, $"Could not copy Savedata {titleIdAndBackupPath.Value} to {titleIdsWithSavePaths[titleIdAndBackupPath.Key]}", nameof(SaveDataImporter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -91,15 +91,16 @@ namespace Ryujinx.Ava.Ui.Controls
|
||||||
|
|
||||||
public void ImportBackup()
|
public void ImportBackup()
|
||||||
{
|
{
|
||||||
SaveDataImporter restoreSavedataCommand = new SaveDataImporter();
|
SaveDataImporter restoreSavedataCommand = new SaveDataImporter(_applications, _userProfile, _horizonClient, _virtualFileSystem);
|
||||||
restoreSavedataCommand.RestoreSavedataBackup(Parent.VisualRoot as MainWindow);
|
restoreSavedataCommand.RestoreSavedataBackup(Parent.VisualRoot as MainWindow);
|
||||||
|
|
||||||
|
LoadSaves();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ExportBackup()
|
public void ExportBackup()
|
||||||
{
|
{
|
||||||
SaveDataExporter backupSavedataCommand = new SaveDataExporter(_userProfile, _horizonClient, _virtualFileSystem);
|
SaveDataExporter backupSavedataCommand = new SaveDataExporter(_applications, _userProfile, _horizonClient, _virtualFileSystem);
|
||||||
backupSavedataCommand.SaveUserSaveDirectoryAsZip(Parent.VisualRoot as MainWindow, Saves.ToList(), _applications);
|
backupSavedataCommand.SaveUserSaveDirectoryAsZip(Parent.VisualRoot as MainWindow, Saves.ToList());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadSaves()
|
public void LoadSaves()
|
||||||
|
|
|
@ -944,58 +944,6 @@ namespace Ryujinx.Ava.Ui.ViewModels
|
||||||
OpenHelper.OpenFolder(logPath);
|
OpenHelper.OpenFolder(logPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void OpenGlobalDlcsFolder()
|
|
||||||
{
|
|
||||||
string globalDlcsPath = Path.Combine(ReleaseInformations.GetBaseApplicationDirectory(), "GlobalDlcs");
|
|
||||||
|
|
||||||
new DirectoryInfo(globalDlcsPath).Create();
|
|
||||||
|
|
||||||
OpenHelper.OpenFolder(globalDlcsPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void OpenGlobalUpdatesFolder()
|
|
||||||
{
|
|
||||||
string golbalUpdatesPath = Path.Combine(ReleaseInformations.GetBaseApplicationDirectory(), "GlobalUpdates");
|
|
||||||
|
|
||||||
new DirectoryInfo(golbalUpdatesPath).Create();
|
|
||||||
|
|
||||||
OpenHelper.OpenFolder(golbalUpdatesPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void LoadGlobalDlcs()
|
|
||||||
{
|
|
||||||
AutoDownloadableContentLoader downloadableContentManager = new AutoDownloadableContentLoader(Applications, _owner.VirtualFileSystem);
|
|
||||||
|
|
||||||
//Searches for the files in the Global Dlcs folder and puts their path and titlename (from folder) in a dictionary.
|
|
||||||
Dictionary<string, string> dlcsAndUpdatesPathWithGameName = Directory.GetFiles(Path.Combine(ReleaseInformations.GetBaseApplicationDirectory(), "GlobalDlcs"))
|
|
||||||
.ToDictionary(x => x, y => Path.GetFileName(y).Split(new[] { "[" }, StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.First()
|
|
||||||
.Trim());
|
|
||||||
|
|
||||||
|
|
||||||
foreach (ApplicationData application in Applications)
|
|
||||||
{
|
|
||||||
await downloadableContentManager.AutoLoadDlcsAsync(application, dlcsAndUpdatesPathWithGameName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void LoadGlobalUpdates()
|
|
||||||
{
|
|
||||||
AutoTitleUpdateLoader titleUpdateManager = new AutoTitleUpdateLoader(Applications, _owner.VirtualFileSystem);
|
|
||||||
|
|
||||||
//Searches for the files in the Global Updates folder and puts their path and titlename (from folder) in a dictionary.
|
|
||||||
Dictionary<string, string> dlcsAndUpdatesPathWithGameName = Directory.GetFiles(Path.Combine(ReleaseInformations.GetBaseApplicationDirectory(), "GlobalUpdates"))
|
|
||||||
.ToDictionary(x => x, y => Path.GetFileName(y).Split(new[] { "[" }, StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.First()
|
|
||||||
.Trim());
|
|
||||||
|
|
||||||
|
|
||||||
foreach (ApplicationData application in Applications)
|
|
||||||
{
|
|
||||||
await titleUpdateManager.AutoLoadUpdatesAsync(application, dlcsAndUpdatesPathWithGameName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ToggleFullscreen()
|
public void ToggleFullscreen()
|
||||||
{
|
{
|
||||||
if (Environment.TickCount64 - _lastFullscreenToggle < HotKeyPressDelayMs)
|
if (Environment.TickCount64 - _lastFullscreenToggle < HotKeyPressDelayMs)
|
||||||
|
|
|
@ -104,24 +104,6 @@
|
||||||
Header="{locale:Locale MenuBarFileOpenLogsFolder}"
|
Header="{locale:Locale MenuBarFileOpenLogsFolder}"
|
||||||
ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
|
ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem
|
|
||||||
Command="{ReflectionBinding OpenGlobalUpdatesFolder}"
|
|
||||||
Header="Open Global Updates Folder"
|
|
||||||
ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
|
|
||||||
<MenuItem
|
|
||||||
Command="{ReflectionBinding LoadGlobalUpdates}"
|
|
||||||
Header="Load Gobal Updates Folder"
|
|
||||||
ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem
|
|
||||||
Command="{ReflectionBinding OpenGlobalDlcsFolder}"
|
|
||||||
Header="Open Global Dlc Folder"
|
|
||||||
ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
|
|
||||||
<MenuItem
|
|
||||||
Command="{ReflectionBinding LoadGlobalDlcs}"
|
|
||||||
Header="Load Gobal Dlcs"
|
|
||||||
ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
Command="{ReflectionBinding CloseWindow}"
|
Command="{ReflectionBinding CloseWindow}"
|
||||||
Header="{locale:Locale MenuBarFileExit}"
|
Header="{locale:Locale MenuBarFileExit}"
|
||||||
|
|
Loading…
Reference in a new issue