aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEmmanuel Hansen <emmausssss@gmail.com>2022-12-02 13:16:43 +0000
committerGitHub <noreply@github.com>2022-12-02 13:16:43 +0000
commitd9053bbe3745846dd758561e24dd060d76b3ad9d (patch)
tree3f37cfc7b2b074205da6f2ec89b52e3508c34c11
parentc25e8427aa40a4ae920496220fd1e1621eff178b (diff)
Avalonia - Save Manager (#3476)1.1.413
* Add save manager to account selector * add fallback to app metadata for titlename if app is not in gamelist * Allow recovering lost accounts
-rw-r--r--Ryujinx.Ava/Assets/Locales/en_US.json15
-rw-r--r--Ryujinx.Ava/Assets/Styles/BaseDark.xaml3
-rw-r--r--Ryujinx.Ava/Assets/Styles/Styles.xaml15
-rw-r--r--Ryujinx.Ava/Common/ApplicationHelper.cs5
-rw-r--r--Ryujinx.Ava/Ui/Controls/NavigationDialogHost.axaml.cs12
-rw-r--r--Ryujinx.Ava/Ui/Controls/SaveManager.axaml102
-rw-r--r--Ryujinx.Ava/Ui/Controls/SaveManager.axaml.cs160
-rw-r--r--Ryujinx.Ava/Ui/Controls/UserEditor.axaml3
-rw-r--r--Ryujinx.Ava/Ui/Controls/UserEditor.axaml.cs18
-rw-r--r--Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml70
-rw-r--r--Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml.cs44
-rw-r--r--Ryujinx.Ava/Ui/Controls/UserSelector.axaml51
-rw-r--r--Ryujinx.Ava/Ui/Models/SaveModel.cs122
-rw-r--r--Ryujinx.Ava/Ui/Models/TempProfile.cs9
-rw-r--r--Ryujinx.Ava/Ui/Models/UserProfile.cs10
-rw-r--r--Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs11
-rw-r--r--Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs77
-rw-r--r--Ryujinx.Ava/Ui/Windows/MainWindow.axaml.cs3
-rw-r--r--Ryujinx.Ui.Common/App/ApplicationLibrary.cs5
-rw-r--r--Ryujinx.Ui.Common/App/ApplicationMetadata.cs1
20 files changed, 693 insertions, 43 deletions
diff --git a/Ryujinx.Ava/Assets/Locales/en_US.json b/Ryujinx.Ava/Assets/Locales/en_US.json
index 4561ad8b..be3071cc 100644
--- a/Ryujinx.Ava/Assets/Locales/en_US.json
+++ b/Ryujinx.Ava/Assets/Locales/en_US.json
@@ -596,7 +596,18 @@
"RyujinxUpdaterMessage": "Do you want to update Ryujinx to the latest version?",
"SettingsTabHotkeysVolumeUpHotkey": "Increase Volume:",
"SettingsTabHotkeysVolumeDownHotkey": "Decrease Volume:",
- "VolumeShort": "Vol",
"SettingsEnableMacroHLE": "Enable Macro HLE",
- "SettingsEnableMacroHLETooltip": "High-level emulation of GPU Macro code.\n\nImproves performance, but may cause graphical glitches in some games.\n\nLeave ON if unsure."
+ "SettingsEnableMacroHLETooltip": "High-level emulation of GPU Macro code.\n\nImproves performance, but may cause graphical glitches in some games.\n\nLeave ON if unsure.",
+ "VolumeShort": "Vol",
+ "UserProfilesManageSaves": "Manage Saves",
+ "DeleteUserSave": "Do you want to delete user save for this game?",
+ "IrreversibleActionNote": "This action is not reversible.",
+ "SaveManagerHeading": "Manage Saves for {0}",
+ "SaveManagerTitle": "Save Manager",
+ "Name": "Name",
+ "Size": "Size",
+ "Search": "Search",
+ "UserProfilesRecoverLostAccounts": "Recover Lost Accounts",
+ "Recover": "Recover",
+ "UserProfilesRecoverHeading" : "Saves were found for the following accounts"
}
diff --git a/Ryujinx.Ava/Assets/Styles/BaseDark.xaml b/Ryujinx.Ava/Assets/Styles/BaseDark.xaml
index 074b1661..fbd4d4b3 100644
--- a/Ryujinx.Ava/Assets/Styles/BaseDark.xaml
+++ b/Ryujinx.Ava/Assets/Styles/BaseDark.xaml
@@ -41,6 +41,9 @@
<SolidColorBrush x:Key="DataGridSelectionBackgroundBrush" Color="{DynamicResource DataGridSelectionColor}" />
<SolidColorBrush x:Key="ThemeAccentColorBrush" Color="{DynamicResource SystemAccentColor}" />
<SolidColorBrush x:Key="ThemeAccentBrush4" Color="{DynamicResource ThemeAccentColor4}" />
+ <Color x:Key="ControlFillColorSecondary">#008AA8</Color>
+ <SolidColorBrush x:Key="ControlFillColorSecondaryBrush" Color="{StaticResource ControlFillColorSecondary}" />
+ <StaticResource x:Key="ButtonBackgroundPointerOver" ResourceKey="ControlFillColorSecondaryBrush" />
<Color x:Key="SystemAccentColor">#FF00C3E3</Color>
<Color x:Key="SystemAccentColorDark1">#FF99b000</Color>
<Color x:Key="SystemAccentColorDark2">#FF006d7d</Color>
diff --git a/Ryujinx.Ava/Assets/Styles/Styles.xaml b/Ryujinx.Ava/Assets/Styles/Styles.xaml
index 8b09bafd..8f7c2e73 100644
--- a/Ryujinx.Ava/Assets/Styles/Styles.xaml
+++ b/Ryujinx.Ava/Assets/Styles/Styles.xaml
@@ -1,7 +1,6 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- xmlns:sys="clr-namespace:System;assembly=netstandard"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<Border Height="2000" Padding="20">
@@ -269,13 +268,15 @@
<Color x:Key="DataGridSelectionColor">#FF00FABB</Color>
<Color x:Key="ThemeContentBackgroundColor">#FF2D2D2D</Color>
<Color x:Key="ThemeControlBorderColor">#FF505050</Color>
- <sys:Double x:Key="ScrollBarThickness">15</sys:Double>
- <sys:Double x:Key="FontSizeSmall">8</sys:Double>
- <sys:Double x:Key="FontSizeNormal">10</sys:Double>
- <sys:Double x:Key="FontSize">12</sys:Double>
- <sys:Double x:Key="FontSizeLarge">15</sys:Double>
- <sys:Double x:Key="ControlContentThemeFontSize">13</sys:Double>
+ <x:Double x:Key="ScrollBarThickness">15</x:Double>
+ <x:Double x:Key="FontSizeSmall">8</x:Double>
+ <x:Double x:Key="FontSizeNormal">10</x:Double>
+ <x:Double x:Key="FontSize">12</x:Double>
+ <x:Double x:Key="FontSizeLarge">15</x:Double>
+ <x:Double x:Key="ControlContentThemeFontSize">13</x:Double>
<x:Double x:Key="MenuItemHeight">26</x:Double>
<x:Double x:Key="TabItemMinHeight">28</x:Double>
+ <x:Double x:Key="ContentDialogMaxWidth">600</x:Double>
+ <x:Double x:Key="ContentDialogMaxHeight">756</x:Double>
</Styles.Resources>
</Styles> \ No newline at end of file
diff --git a/Ryujinx.Ava/Common/ApplicationHelper.cs b/Ryujinx.Ava/Common/ApplicationHelper.cs
index 47f4392e..7f766614 100644
--- a/Ryujinx.Ava/Common/ApplicationHelper.cs
+++ b/Ryujinx.Ava/Common/ApplicationHelper.cs
@@ -113,6 +113,11 @@ namespace Ryujinx.Ava.Common
return;
}
+ OpenSaveDir(saveDataId);
+ }
+
+ public static void OpenSaveDir(ulong saveDataId)
+ {
string saveRootPath = Path.Combine(_virtualFileSystem.GetNandPath(), $"user/save/{saveDataId:x16}");
if (!Directory.Exists(saveRootPath))
diff --git a/Ryujinx.Ava/Ui/Controls/NavigationDialogHost.axaml.cs b/Ryujinx.Ava/Ui/Controls/NavigationDialogHost.axaml.cs
index 9ba631ad..ced88328 100644
--- a/Ryujinx.Ava/Ui/Controls/NavigationDialogHost.axaml.cs
+++ b/Ryujinx.Ava/Ui/Controls/NavigationDialogHost.axaml.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls;
using FluentAvalonia.UI.Controls;
+using LibHac;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Ui.ViewModels;
using Ryujinx.HLE.FileSystem;
@@ -14,6 +15,8 @@ namespace Ryujinx.Ava.Ui.Controls
{
public AccountManager AccountManager { get; }
public ContentManager ContentManager { get; }
+ public VirtualFileSystem VirtualFileSystem { get; }
+ public HorizonClient HorizonClient { get; }
public UserProfileViewModel ViewModel { get; set; }
public NavigationDialogHost()
@@ -22,10 +25,12 @@ namespace Ryujinx.Ava.Ui.Controls
}
public NavigationDialogHost(AccountManager accountManager, ContentManager contentManager,
- VirtualFileSystem virtualFileSystem)
+ VirtualFileSystem virtualFileSystem, HorizonClient horizonClient)
{
AccountManager = accountManager;
ContentManager = contentManager;
+ VirtualFileSystem = virtualFileSystem;
+ HorizonClient = horizonClient;
ViewModel = new UserProfileViewModel(this);
@@ -54,9 +59,10 @@ namespace Ryujinx.Ava.Ui.Controls
ContentFrame.Navigate(sourcePageType, parameter);
}
- public static async Task Show(AccountManager ownerAccountManager, ContentManager ownerContentManager, VirtualFileSystem ownerVirtualFileSystem)
+ public static async Task Show(AccountManager ownerAccountManager, ContentManager ownerContentManager,
+ VirtualFileSystem ownerVirtualFileSystem, HorizonClient ownerHorizonClient)
{
- var content = new NavigationDialogHost(ownerAccountManager, ownerContentManager, ownerVirtualFileSystem);
+ var content = new NavigationDialogHost(ownerAccountManager, ownerContentManager, ownerVirtualFileSystem, ownerHorizonClient);
ContentDialog contentDialog = new ContentDialog
{
Title = LocaleManager.Instance["UserProfileWindowTitle"],
diff --git a/Ryujinx.Ava/Ui/Controls/SaveManager.axaml b/Ryujinx.Ava/Ui/Controls/SaveManager.axaml
new file mode 100644
index 00000000..ce337c7b
--- /dev/null
+++ b/Ryujinx.Ava/Ui/Controls/SaveManager.axaml
@@ -0,0 +1,102 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:controls="clr-namespace:Ryujinx.Ava.Ui.Controls"
+ xmlns:models="clr-namespace:Ryujinx.Ava.Ui.Models"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+ Height="400"
+ Width="550"
+ x:Class="Ryujinx.Ava.Ui.Controls.SaveManager">
+ <UserControl.Resources>
+ <controls:BitmapArrayValueConverter x:Key="ByteImage" />
+ </UserControl.Resources>
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <Grid Grid.Row="0" HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <StackPanel Spacing="10" Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
+ <Label Content="{locale:Locale CommonSort}" VerticalAlignment="Center" />
+ <ComboBox SelectedIndex="{Binding SortIndex}" Width="100">
+ <ComboBoxItem>
+ <Label VerticalAlignment="Center" HorizontalContentAlignment="Left"
+ Content="{locale:Locale Name}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <Label VerticalAlignment="Center" HorizontalContentAlignment="Left"
+ Content="{locale:Locale Size}" />
+ </ComboBoxItem>
+ </ComboBox>
+ <ComboBox SelectedIndex="{Binding OrderIndex}" Width="150">
+ <ComboBoxItem>
+ <Label VerticalAlignment="Center" HorizontalContentAlignment="Left"
+ Content="{locale:Locale OrderAscending}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <Label VerticalAlignment="Center" HorizontalContentAlignment="Left"
+ Content="{locale:Locale Descending}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <Grid Grid.Column="1" HorizontalAlignment="Stretch" Margin="10,0, 0, 0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto"/>
+ <ColumnDefinition/>
+ </Grid.ColumnDefinitions>
+ <Label Content="{locale:Locale Search}" VerticalAlignment="Center"/>
+ <TextBox Margin="5,0,0,0" Grid.Column="1" HorizontalAlignment="Stretch" Text="{Binding Search}"/>
+ </Grid>
+ </Grid>
+ <Border Grid.Row="1" Margin="0,5" BorderThickness="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+ <ListBox Name="SaveList" Items="{Binding View}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+ <ListBox.ItemTemplate>
+ <DataTemplate x:DataType="models:SaveModel">
+ <Grid HorizontalAlignment="Stretch" Margin="0,5">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <StackPanel Grid.Column="0" Orientation="Horizontal">
+ <Border Height="42" Margin="2" Width="42" Padding="10"
+ IsVisible="{Binding !InGameList}">
+ <ui:SymbolIcon Symbol="Help" FontSize="30" HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Border>
+ <Image IsVisible="{Binding InGameList}"
+ Margin="2"
+ Width="42"
+ Height="42"
+ Source="{Binding Icon, Converter={StaticResource ByteImage}}" />
+ <TextBlock MaxLines="3" Width="320" Margin="5" TextWrapping="Wrap"
+ Text="{Binding Title}" VerticalAlignment="Center" />
+ </StackPanel>
+ <StackPanel Grid.Column="1" Spacing="10" HorizontalAlignment="Right"
+ Orientation="Horizontal">
+ <Label Content="{Binding SizeString}" IsVisible="{Binding SizeAvailable}"
+ VerticalAlignment="Center" HorizontalAlignment="Right" />
+ <Button VerticalAlignment="Center" HorizontalAlignment="Right" Padding="10"
+ MinWidth="0" MinHeight="0" Name="OpenLocation" Command="{Binding OpenLocation}">
+ <ui:SymbolIcon Symbol="OpenFolder" HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ <Button VerticalAlignment="Center" HorizontalAlignment="Right" Padding="10"
+ MinWidth="0" MinHeight="0" Name="Delete" Command="{Binding Delete}">
+ <ui:SymbolIcon Symbol="Delete" HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ </StackPanel>
+ </Grid>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </Border>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/Ryujinx.Ava/Ui/Controls/SaveManager.axaml.cs b/Ryujinx.Ava/Ui/Controls/SaveManager.axaml.cs
new file mode 100644
index 00000000..499cd918
--- /dev/null
+++ b/Ryujinx.Ava/Ui/Controls/SaveManager.axaml.cs
@@ -0,0 +1,160 @@
+using Avalonia.Controls;
+using DynamicData;
+using DynamicData.Binding;
+using LibHac;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Shim;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Ui.Models;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.Ui.App.Common;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile;
+
+namespace Ryujinx.Ava.Ui.Controls
+{
+ public partial class SaveManager : UserControl
+ {
+ private readonly UserProfile _userProfile;
+ private readonly HorizonClient _horizonClient;
+ private readonly VirtualFileSystem _virtualFileSystem;
+ private int _sortIndex;
+ private int _orderIndex;
+ private ObservableCollection<SaveModel> _view = new ObservableCollection<SaveModel>();
+ private string _search;
+
+ public ObservableCollection<SaveModel> Saves { get; set; } = new ObservableCollection<SaveModel>();
+
+ public ObservableCollection<SaveModel> View
+ {
+ get => _view;
+ set => _view = value;
+ }
+
+ public int SortIndex
+ {
+ get => _sortIndex;
+ set
+ {
+ _sortIndex = value;
+ Sort();
+ }
+ }
+
+ public int OrderIndex
+ {
+ get => _orderIndex;
+ set
+ {
+ _orderIndex = value;
+ Sort();
+ }
+ }
+
+ public string Search
+ {
+ get => _search;
+ set
+ {
+ _search = value;
+ Sort();
+ }
+ }
+
+ public SaveManager()
+ {
+ InitializeComponent();
+ }
+
+ public SaveManager(UserProfile userProfile, HorizonClient horizonClient, VirtualFileSystem virtualFileSystem)
+ {
+ _userProfile = userProfile;
+ _horizonClient = horizonClient;
+ _virtualFileSystem = virtualFileSystem;
+ InitializeComponent();
+
+ DataContext = this;
+
+ Task.Run(LoadSaves);
+ }
+
+ public void LoadSaves()
+ {
+ Saves.Clear();
+ var saveDataFilter = SaveDataFilter.Make(programId: default, saveType: SaveDataType.Account,
+ new UserId((ulong)_userProfile.UserId.High, (ulong)_userProfile.UserId.Low), 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];
+
+ while (true)
+ {
+ saveDataIterator.Get.ReadSaveDataInfo(out long readCount, saveDataInfo).ThrowIfFailure();
+
+ if (readCount == 0)
+ {
+ break;
+ }
+
+ for (int i = 0; i < readCount; i++)
+ {
+ var save = saveDataInfo[i];
+ if (save.ProgramId.Value != 0)
+ {
+ var saveModel = new SaveModel(save, _horizonClient, _virtualFileSystem);
+ Saves.Add(saveModel);
+ saveModel.DeleteAction = () => { Saves.Remove(saveModel); };
+ }
+
+ Sort();
+ }
+ }
+ }
+
+ private void Sort()
+ {
+ Saves.AsObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out var view).AsObservableList();
+
+ _view.Clear();
+ _view.AddRange(view);
+ }
+
+ private IComparer<SaveModel> GetComparer()
+ {
+ switch (SortIndex)
+ {
+ case 0:
+ return OrderIndex == 0
+ ? SortExpressionComparer<SaveModel>.Ascending(save => save.Title)
+ : SortExpressionComparer<SaveModel>.Descending(save => save.Title);
+ case 1:
+ return OrderIndex == 0
+ ? SortExpressionComparer<SaveModel>.Ascending(save => save.Size)
+ : SortExpressionComparer<SaveModel>.Descending(save => save.Size);
+ default:
+ return null;
+ }
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is SaveModel save)
+ {
+ return string.IsNullOrWhiteSpace(_search) || save.Title.ToLower().Contains(_search.ToLower());
+ }
+
+ return false;
+ }
+ }
+} \ No newline at end of file
diff --git a/Ryujinx.Ava/Ui/Controls/UserEditor.axaml b/Ryujinx.Ava/Ui/Controls/UserEditor.axaml
index 90c5c1fe..898527e6 100644
--- a/Ryujinx.Ava/Ui/Controls/UserEditor.axaml
+++ b/Ryujinx.Ava/Ui/Controls/UserEditor.axaml
@@ -10,6 +10,7 @@
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels"
Margin="0"
+ MinWidth="500"
Padding="0"
mc:Ignorable="d">
<UserControl.Resources>
@@ -63,7 +64,7 @@
HorizontalAlignment="Stretch"
MaxLength="{Binding MaxProfileNameLength}"
Text="{Binding Name}" />
- <TextBlock Text="{Locale:Locale UserProfilesUserId}" />
+ <TextBlock Name="IdText" Text="{Locale:Locale UserProfilesUserId}" />
<TextBlock Name="IdLabel" Text="{Binding UserId}" />
</StackPanel>
<StackPanel
diff --git a/Ryujinx.Ava/Ui/Controls/UserEditor.axaml.cs b/Ryujinx.Ava/Ui/Controls/UserEditor.axaml.cs
index ea996da8..f5b51e4e 100644
--- a/Ryujinx.Ava/Ui/Controls/UserEditor.axaml.cs
+++ b/Ryujinx.Ava/Ui/Controls/UserEditor.axaml.cs
@@ -36,15 +36,8 @@ namespace Ryujinx.Ava.Ui.Controls
case NavigationMode.New:
var args = ((NavigationDialogHost parent, UserProfile profile, bool isNewUser))arg.Parameter;
_isNewUser = args.isNewUser;
- if (!_isNewUser)
- {
- _profile = args.profile;
- TempProfile = new TempProfile(_profile);
- }
- else
- {
- TempProfile = new TempProfile();
- }
+ _profile = args.profile;
+ TempProfile = new TempProfile(_profile);
_parent = args.parent;
break;
@@ -53,7 +46,8 @@ namespace Ryujinx.Ava.Ui.Controls
DataContext = TempProfile;
AddPictureButton.IsVisible = _isNewUser;
- IdLabel.IsVisible = !_isNewUser;
+ IdLabel.IsVisible = _profile != null;
+ IdText.IsVisible = _profile != null;
ChangePictureButton.IsVisible = !_isNewUser;
}
}
@@ -87,7 +81,7 @@ namespace Ryujinx.Ava.Ui.Controls
return;
}
- if (_profile != null)
+ if (_profile != null && !_isNewUser)
{
_profile.Name = TempProfile.Name;
_profile.Image = TempProfile.Image;
@@ -97,7 +91,7 @@ namespace Ryujinx.Ava.Ui.Controls
}
else if (_isNewUser)
{
- _parent.AccountManager.AddUser(TempProfile.Name, TempProfile.Image);
+ _parent.AccountManager.AddUser(TempProfile.Name, TempProfile.Image, TempProfile.UserId);
}
else
{
diff --git a/Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml b/Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml
new file mode 100644
index 00000000..6345ec67
--- /dev/null
+++ b/Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml
@@ -0,0 +1,70 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ mc:Ignorable="d"
+ d:DesignWidth="800"
+ d:DesignHeight="450"
+ MinWidth="500"
+ MinHeight="400"
+ xmlns:Locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels"
+ x:Class="Ryujinx.Ava.Ui.Controls.UserRecoverer">
+ <Design.DataContext>
+ <viewModels:UserProfileViewModel />
+ </Design.DataContext>
+ <Grid HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto"/>
+ <RowDefinition Height="Auto"/>
+ <RowDefinition/>
+ </Grid.RowDefinitions>
+ <Button Grid.Row="0"
+ Margin="5"
+ Height="30"
+ Width="50"
+ MinWidth="50"
+ HorizontalAlignment="Left"
+ Command="{Binding GoBack}">
+ <ui:SymbolIcon Symbol="Back"/>
+ </Button>
+ <TextBlock Grid.Row="1"
+ Text="{Locale:Locale UserProfilesRecoverHeading}"/>
+ <ListBox
+ Margin="5"
+ Grid.Row="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Items="{Binding LostProfiles}">
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <Border
+ Margin="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ ClipToBounds="True"
+ CornerRadius="5">
+ <Grid Margin="0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition/>
+ <ColumnDefinition Width="Auto"/>
+ </Grid.ColumnDefinitions>
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding UserId}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ <Button Grid.Column="1"
+ HorizontalAlignment="Right"
+ Command="{Binding Recover}"
+ CommandParameter="{Binding}"
+ Content="{Locale:Locale Recover}"/>
+ </Grid>
+ </Border>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </Grid>
+</UserControl>
diff --git a/Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml.cs b/Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml.cs
new file mode 100644
index 00000000..f093686d
--- /dev/null
+++ b/Ryujinx.Ava/Ui/Controls/UserRecoverer.axaml.cs
@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using Ryujinx.Ava.Ui.Models;
+using Ryujinx.Ava.Ui.ViewModels;
+
+namespace Ryujinx.Ava.Ui.Controls
+{
+ public partial class UserRecoverer : UserControl
+ {
+ private UserProfileViewModel _viewModel;
+ private NavigationDialogHost _parent;
+
+ public UserRecoverer()
+ {
+ InitializeComponent();
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ switch (arg.NavigationMode)
+ {
+ case NavigationMode.New:
+ var args = ((NavigationDialogHost parent, UserProfileViewModel viewModel))arg.Parameter;
+
+ _viewModel = args.viewModel;
+ _parent = args.parent;
+ break;
+ }
+
+ DataContext = _viewModel;
+ }
+ }
+ }
+}
diff --git a/Ryujinx.Ava/Ui/Controls/UserSelector.axaml b/Ryujinx.Ava/Ui/Controls/UserSelector.axaml
index c06bce23..bc6d8c09 100644
--- a/Ryujinx.Ava/Ui/Controls/UserSelector.axaml
+++ b/Ryujinx.Ava/Ui/Controls/UserSelector.axaml
@@ -10,6 +10,7 @@
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels"
d:DesignHeight="450"
+ MinWidth="500"
d:DesignWidth="800"
mc:Ignorable="d">
<UserControl.Resources>
@@ -25,6 +26,7 @@
</Grid.RowDefinitions>
<ListBox
Margin="5"
+ MaxHeight="300"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
DoubleTapped="ProfilesList_DoubleTapped"
@@ -88,21 +90,56 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
- <StackPanel
+ <Grid
Grid.Row="1"
- Margin="10,0"
- HorizontalAlignment="Center"
- Orientation="Horizontal"
- Spacing="10">
- <Button Command="{Binding AddUser}" Content="{Locale:Locale UserProfilesAddNewProfile}" />
+ HorizontalAlignment="Center">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto"/>
+ <RowDefinition Height="Auto"/>
+ <RowDefinition Height="Auto"/>
+ </Grid.RowDefinitions>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto"/>
+ <ColumnDefinition Width="Auto"/>
+ </Grid.ColumnDefinitions>
+ <Button
+ HorizontalAlignment="Stretch"
+ Grid.Row="0"
+ Grid.Column="0"
+ Margin="2"
+ Command="{Binding AddUser}"
+ Content="{Locale:Locale UserProfilesAddNewProfile}" />
<Button
+ HorizontalAlignment="Stretch"
+ Grid.Row="0"
+ Margin="2"
+ Grid.Column="1"
Command="{Binding EditUser}"
Content="{Locale:Locale UserProfilesEditProfile}"
IsEnabled="{Binding IsSelectedProfiledEditable}" />
+ <Button
+ HorizontalAlignment="Stretch"
+ Grid.Row="1"
+ Grid.Column="0"
+ Margin="2"
+ Content="{Locale:Locale UserProfilesManageSaves}"
+ Command="{Binding ManageSaves}" />
<Button
+ HorizontalAlignment="Stretch"
+ Grid.Row="1"
+ Grid.Column="1"
+ Margin="2"
Command="{Binding DeleteUser}"
Content="{Locale:Locale UserProfilesDeleteSelectedProfile}"
IsEnabled="{Binding IsSelectedProfileDeletable}" />
- </StackPanel>
+ <Button
+ HorizontalAlignment="Stretch"
+ Grid.Row="2"
+ Grid.ColumnSpan="2"
+ Grid.Column="0"
+ Margin="2"
+ Command="{Binding RecoverLostAccounts}"
+ Content="{Locale:Locale UserProfilesRecoverLostAccounts}" />
+ </Grid>
</Grid>
</UserControl> \ No newline at end of file
diff --git a/Ryujinx.Ava/Ui/Models/SaveModel.cs b/Ryujinx.Ava/Ui/Models/SaveModel.cs
new file mode 100644
index 00000000..70478cea
--- /dev/null
+++ b/Ryujinx.Ava/Ui/Models/SaveModel.cs
@@ -0,0 +1,122 @@
+using LibHac;
+using LibHac.Fs;
+using LibHac.Fs.Shim;
+using LibHac.Ncm;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Ui.Controls;
+using Ryujinx.Ava.Ui.ViewModels;
+using Ryujinx.Ava.Ui.Windows;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.Ui.App.Common;
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.Ui.Models
+{
+ public class SaveModel : BaseModel
+ {
+ private readonly HorizonClient _horizonClient;
+ private long _size;
+
+ public Action DeleteAction { get; set; }
+ public ulong SaveId { get; }
+ public ProgramId TitleId { get; }
+ public string TitleIdString => $"{TitleId.Value:X16}";
+ public UserId UserId { get; }
+ public bool InGameList { get; }
+ public string Title { get; }
+ public byte[] Icon { get; }
+
+ public long Size
+ {
+ get => _size; set
+ {
+ _size = value;
+ SizeAvailable = true;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SizeString));
+ OnPropertyChanged(nameof(SizeAvailable));
+ }
+ }
+
+ public bool SizeAvailable { get; set; }
+
+ public string SizeString => $"{((float)_size * 0.000000954):0.###}MB";
+
+ public SaveModel(SaveDataInfo info, HorizonClient horizonClient, VirtualFileSystem virtualFileSystem)
+ {
+ _horizonClient = horizonClient;
+ SaveId = info.SaveDataId;
+ TitleId = info.ProgramId;
+ UserId = info.UserId;
+
+ var appData = MainWindow.MainWindowViewModel.Applications.FirstOrDefault(x => x.TitleId.ToUpper() == TitleIdString);
+
+ InGameList = appData != null;
+
+ if (InGameList)
+ {
+ Icon = appData.Icon;
+ Title = appData.TitleName;
+ }
+ else
+ {
+ var appMetadata = MainWindow.MainWindowViewModel.ApplicationLibrary.LoadAndSaveMetaData(TitleIdString);
+ Title = appMetadata.Title ?? TitleIdString;
+ }
+
+ Task.Run(() =>
+ {
+ var saveRoot = System.IO.Path.Combine(virtualFileSystem.GetNandPath(), $"user/save/{info.SaveDataId:x16}");
+
+ long total_size = GetDirectorySize(saveRoot);
+ long GetDirectorySize(string path)
+ {
+ long size = 0;
+ if (Directory.Exists(path))
+ {
+ var directories = Directory.GetDirectories(path);
+ foreach (var directory in directories)
+ {
+ size += GetDirectorySize(directory);
+ }
+
+ var files = Directory.GetFiles(path);
+ foreach (var file in files)
+ {
+ size += new FileInfo(file).Length;
+ }
+ }
+
+ return size;
+ }
+
+ Size = total_size;
+ });
+
+ }
+
+ public void OpenLocation()
+ {
+ ApplicationHelper.OpenSaveDir(SaveId);
+ }
+
+ public async void Delete()
+ {
+ var result = await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance["DeleteUserSave"],
+ LocaleManager.Instance["IrreversibleActionNote"],
+ LocaleManager.Instance["InputDialogYes"],
+ LocaleManager.Instance["InputDialogNo"], "");
+
+ if (result == UserResult.Yes)
+ {
+ _horizonClient.Fs.DeleteSaveData(SaveDataSpaceId.User, SaveId);
+
+ DeleteAction?.Invoke();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Ryujinx.Ava/Ui/Models/TempProfile.cs b/Ryujinx.Ava/Ui/Models/TempProfile.cs
index e943687a..4e6d3446 100644
--- a/Ryujinx.Ava/Ui/Models/TempProfile.cs
+++ b/Ryujinx.Ava/Ui/Models/TempProfile.cs
@@ -45,9 +45,12 @@ namespace Ryujinx.Ava.Ui.Models
{
_profile = profile;
- Image = profile.Image;
- Name = profile.Name;
- UserId = profile.UserId;
+ if (_profile != null)
+ {
+ Image = profile.Image;
+ Name = profile.Name;
+ UserId = profile.UserId;
+ }
}
public TempProfile(){}
diff --git a/Ryujinx.Ava/Ui/Models/UserProfile.cs b/Ryujinx.Ava/Ui/Models/UserProfile.cs
index 351ada76..c0ea9451 100644
--- a/Ryujinx.Ava/Ui/Models/UserProfile.cs
+++ b/Ryujinx.Ava/Ui/Models/UserProfile.cs
@@ -1,3 +1,4 @@
+using Ryujinx.Ava.Ui.Controls;
using Ryujinx.Ava.Ui.ViewModels;
using Ryujinx.HLE.HOS.Services.Account.Acc;
using Profile = Ryujinx.HLE.HOS.Services.Account.Acc.UserProfile;
@@ -7,6 +8,7 @@ namespace Ryujinx.Ava.Ui.Models
public class UserProfile : BaseModel
{
private readonly Profile _profile;
+ private readonly NavigationDialogHost _owner;
private byte[] _image;
private string _name;
private UserId _userId;
@@ -41,9 +43,10 @@ namespace Ryujinx.Ava.Ui.Models
}
}
- public UserProfile(Profile profile)
+ public UserProfile(Profile profile, NavigationDialogHost owner)
{
_profile = profile;
+ _owner = owner;
Image = profile.Image;
Name = profile.Name;
@@ -57,5 +60,10 @@ namespace Ryujinx.Ava.Ui.Models
OnPropertyChanged(nameof(IsOpened));
OnPropertyChanged(nameof(Name));
}
+
+ public void Recover(UserProfile userProfile)
+ {
+ _owner.Navigate(typeof(UserEditor), (_owner, userProfile, true));
+ }
}
} \ No newline at end of file
diff --git a/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs
index cd437017..c7053eb1 100644
--- a/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs
+++ b/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs
@@ -76,6 +76,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
private bool _showAll;
private string _lastScannedAmiiboId;
private ReadOnlyObservableCollection<ApplicationData> _appsObservableList;
+ public ApplicationLibrary ApplicationLibrary => _owner.ApplicationLibrary;
public string TitleName { get; internal set; }
@@ -103,8 +104,8 @@ namespace Ryujinx.Ava.Ui.ViewModels
public void Initialize()
{
- _owner.ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated;
- _owner.ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded;
+ ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated;
+ ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded;
Ptc.PtcStateChanged -= ProgressHandler;
Ptc.PtcStateChanged += ProgressHandler;
@@ -817,7 +818,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
Thread thread = new(() =>
{
- _owner.ApplicationLibrary.LoadApplications(ConfigurationState.Instance.Ui.GameDirs.Value, ConfigurationState.Instance.System.Language);
+ ApplicationLibrary.LoadApplications(ConfigurationState.Instance.Ui.GameDirs.Value, ConfigurationState.Instance.System.Language);
_isLoading = false;
})
@@ -1005,7 +1006,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
public async void ManageProfiles()
{
- await NavigationDialogHost.Show(_owner.AccountManager, _owner.ContentManager, _owner.VirtualFileSystem);
+ await NavigationDialogHost.Show(_owner.AccountManager, _owner.ContentManager, _owner.VirtualFileSystem, _owner.LibHacHorizonManager.RyujinxClient);
}
public async void OpenAboutWindow()
@@ -1098,7 +1099,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
{
selection.Favorite = !selection.Favorite;
- _owner.ApplicationLibrary.LoadAndSaveMetaData(selection.TitleId, appMetadata =>
+ ApplicationLibrary.LoadAndSaveMetaData(selection.TitleId, appMetadata =>
{
appMetadata.Favorite = selection.Favorite;
});
diff --git a/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs
index a48b06e6..eb9f69d6 100644
--- a/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs
+++ b/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs
@@ -1,8 +1,14 @@
+using Avalonia;
using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Shim;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Ui.Controls;
using Ryujinx.HLE.HOS.Services.Account.Acc;
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile;
@@ -19,6 +25,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
public UserProfileViewModel()
{
Profiles = new ObservableCollection<UserProfile>();
+ LostProfiles = new ObservableCollection<UserProfile>();
}
public UserProfileViewModel(NavigationDialogHost owner) : this()
@@ -30,6 +37,8 @@ namespace Ryujinx.Ava.Ui.ViewModels
public ObservableCollection<UserProfile> Profiles { get; set; }
+ public ObservableCollection<UserProfile> LostProfiles { get; set; }
+
public UserProfile SelectedProfile
{
get => _selectedProfile;
@@ -65,12 +74,13 @@ namespace Ryujinx.Ava.Ui.ViewModels
public void LoadProfiles()
{
Profiles.Clear();
+ LostProfiles.Clear();
var profiles = _owner.AccountManager.GetAllUsers().OrderByDescending(x => x.AccountState == AccountState.Open);
foreach (var profile in profiles)
{
- Profiles.Add(new UserProfile(profile));
+ Profiles.Add(new UserProfile(profile, _owner));
}
SelectedProfile = Profiles.FirstOrDefault(x => x.UserId == _owner.AccountManager.LastOpenedUser.UserId);
@@ -84,6 +94,42 @@ namespace Ryujinx.Ava.Ui.ViewModels
_owner.AccountManager.OpenUser(_selectedProfile.UserId);
}
}
+
+ var saveDataFilter = SaveDataFilter.Make(programId: default, saveType: SaveDataType.Account,
+ default, saveDataId: default, index: default);
+
+ using var saveDataIterator = new UniqueRef<SaveDataIterator>();
+
+ _owner.HorizonClient.Fs.OpenSaveDataIterator(ref saveDataIterator.Ref(), SaveDataSpaceId.User, in saveDataFilter).ThrowIfFailure();
+
+ Span<SaveDataInfo> saveDataInfo = stackalloc SaveDataInfo[10];
+
+ HashSet<HLE.HOS.Services.Account.Acc.UserId> lostAccounts = new HashSet<HLE.HOS.Services.Account.Acc.UserId>();
+
+ while (true)
+ {
+ saveDataIterator.Get.ReadSaveDataInfo(out long readCount, saveDataInfo).ThrowIfFailure();
+
+ if (readCount == 0)
+ {
+ break;
+ }
+
+ for (int i = 0; i < readCount; i++)
+ {
+ var save = saveDataInfo[i];
+ var id = new HLE.HOS.Services.Account.Acc.UserId((long)save.UserId.Id.Low, (long)save.UserId.Id.High);
+ if (Profiles.FirstOrDefault( x=> x.UserId == id) == null)
+ {
+ lostAccounts.Add(id);
+ }
+ }
+ }
+
+ foreach(var account in lostAccounts)
+ {
+ LostProfiles.Add(new UserProfile(new HLE.HOS.Services.Account.Acc.UserProfile(account, "", null), _owner));
+ }
}
public void AddUser()
@@ -93,6 +139,25 @@ namespace Ryujinx.Ava.Ui.ViewModels
_owner.Navigate(typeof(UserEditor), (this._owner, userProfile, true));
}
+ public async void ManageSaves()
+ {
+ UserProfile userProfile = _highlightedProfile ?? SelectedProfile;
+
+ SaveManager manager = new SaveManager(userProfile, _owner.HorizonClient, _owner.VirtualFileSystem);
+
+ ContentDialog contentDialog = new ContentDialog
+ {
+ Title = string.Format(LocaleManager.Instance["SaveManagerHeading"], userProfile.Name),
+ PrimaryButtonText = "",
+ SecondaryButtonText = "",
+ CloseButtonText = LocaleManager.Instance["UserProfilesClose"],
+ Content = manager,
+ Padding = new Thickness(0)
+ };
+
+ await contentDialog.ShowAsync();
+ }
+
public void EditUser()
{
_owner.Navigate(typeof(UserEditor), (this._owner, _highlightedProfile ?? SelectedProfile, false));
@@ -134,5 +199,15 @@ namespace Ryujinx.Ava.Ui.ViewModels
LoadProfiles();
}
+
+ public void GoBack()
+ {
+ _owner.GoBack();
+ }
+
+ public void RecoverLostAccounts()
+ {
+ _owner.Navigate(typeof(UserRecoverer), (this._owner, this));
+ }
}
} \ No newline at end of file
diff --git a/Ryujinx.Ava/Ui/Windows/MainWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/MainWindow.axaml.cs
index 8b5a39a7..c0a94154 100644
--- a/Ryujinx.Ava/Ui/Windows/MainWindow.axaml.cs
+++ b/Ryujinx.Ava/Ui/Windows/MainWindow.axaml.cs
@@ -36,6 +36,7 @@ namespace Ryujinx.Ava.Ui.Windows
{
public partial class MainWindow : StyleableWindow
{
+ internal static MainWindowViewModel MainWindowViewModel { get; private set; }
private bool _canUpdate;
private bool _isClosing;
private bool _isLoading;
@@ -81,6 +82,8 @@ namespace Ryujinx.Ava.Ui.Windows
{
ViewModel = new MainWindowViewModel(this);
+ MainWindowViewModel = ViewModel;
+
DataContext = ViewModel;
InitializeComponent();
diff --git a/Ryujinx.Ui.Common/App/ApplicationLibrary.cs b/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
index 9a6c1997..6f254785 100644
--- a/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
+++ b/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
@@ -444,7 +444,10 @@ namespace Ryujinx.Ui.App.Common
continue;
}
- ApplicationMetadata appMetadata = LoadAndSaveMetaData(titleId);
+ ApplicationMetadata appMetadata = LoadAndSaveMetaData(titleId, appMetadata =>
+ {
+ appMetadata.Title = titleName;
+ });
if (appMetadata.LastPlayed != "Never" && !DateTime.TryParse(appMetadata.LastPlayed, out _))
{
diff --git a/Ryujinx.Ui.Common/App/ApplicationMetadata.cs b/Ryujinx.Ui.Common/App/ApplicationMetadata.cs
index 2631a8cf..e19f7483 100644
--- a/Ryujinx.Ui.Common/App/ApplicationMetadata.cs
+++ b/Ryujinx.Ui.Common/App/ApplicationMetadata.cs
@@ -2,6 +2,7 @@
{
public class ApplicationMetadata
{
+ public string Title { get; set; }
public bool Favorite { get; set; }
public double TimePlayed { get; set; }
public string LastPlayed { get; set; } = "Never";