Avalonia - Save Manager (#3476)

* Add save manager to account selector

* add fallback to app metadata for titlename if app is not in gamelist

* Allow recovering lost accounts
This commit is contained in:
Emmanuel Hansen 2022-12-02 13:16:43 +00:00 committed by GitHub
parent c25e8427aa
commit d9053bbe37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 693 additions and 43 deletions

View file

@ -596,7 +596,18 @@
"RyujinxUpdaterMessage": "Do you want to update Ryujinx to the latest version?", "RyujinxUpdaterMessage": "Do you want to update Ryujinx to the latest version?",
"SettingsTabHotkeysVolumeUpHotkey": "Increase Volume:", "SettingsTabHotkeysVolumeUpHotkey": "Increase Volume:",
"SettingsTabHotkeysVolumeDownHotkey": "Decrease Volume:", "SettingsTabHotkeysVolumeDownHotkey": "Decrease Volume:",
"VolumeShort": "Vol",
"SettingsEnableMacroHLE": "Enable Macro HLE", "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"
} }

View file

@ -41,6 +41,9 @@
<SolidColorBrush x:Key="DataGridSelectionBackgroundBrush" Color="{DynamicResource DataGridSelectionColor}" /> <SolidColorBrush x:Key="DataGridSelectionBackgroundBrush" Color="{DynamicResource DataGridSelectionColor}" />
<SolidColorBrush x:Key="ThemeAccentColorBrush" Color="{DynamicResource SystemAccentColor}" /> <SolidColorBrush x:Key="ThemeAccentColorBrush" Color="{DynamicResource SystemAccentColor}" />
<SolidColorBrush x:Key="ThemeAccentBrush4" Color="{DynamicResource ThemeAccentColor4}" /> <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="SystemAccentColor">#FF00C3E3</Color>
<Color x:Key="SystemAccentColorDark1">#FF99b000</Color> <Color x:Key="SystemAccentColorDark1">#FF99b000</Color>
<Color x:Key="SystemAccentColorDark2">#FF006d7d</Color> <Color x:Key="SystemAccentColorDark2">#FF006d7d</Color>

View file

@ -1,7 +1,6 @@
<Styles <Styles
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 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"> xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith> <Design.PreviewWith>
<Border Height="2000" Padding="20"> <Border Height="2000" Padding="20">
@ -269,13 +268,15 @@
<Color x:Key="DataGridSelectionColor">#FF00FABB</Color> <Color x:Key="DataGridSelectionColor">#FF00FABB</Color>
<Color x:Key="ThemeContentBackgroundColor">#FF2D2D2D</Color> <Color x:Key="ThemeContentBackgroundColor">#FF2D2D2D</Color>
<Color x:Key="ThemeControlBorderColor">#FF505050</Color> <Color x:Key="ThemeControlBorderColor">#FF505050</Color>
<sys:Double x:Key="ScrollBarThickness">15</sys:Double> <x:Double x:Key="ScrollBarThickness">15</x:Double>
<sys:Double x:Key="FontSizeSmall">8</sys:Double> <x:Double x:Key="FontSizeSmall">8</x:Double>
<sys:Double x:Key="FontSizeNormal">10</sys:Double> <x:Double x:Key="FontSizeNormal">10</x:Double>
<sys:Double x:Key="FontSize">12</sys:Double> <x:Double x:Key="FontSize">12</x:Double>
<sys:Double x:Key="FontSizeLarge">15</sys:Double> <x:Double x:Key="FontSizeLarge">15</x:Double>
<sys:Double x:Key="ControlContentThemeFontSize">13</sys:Double> <x:Double x:Key="ControlContentThemeFontSize">13</x:Double>
<x:Double x:Key="MenuItemHeight">26</x:Double> <x:Double x:Key="MenuItemHeight">26</x:Double>
<x:Double x:Key="TabItemMinHeight">28</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.Resources>
</Styles> </Styles>

View file

@ -113,6 +113,11 @@ namespace Ryujinx.Ava.Common
return; return;
} }
OpenSaveDir(saveDataId);
}
public static void OpenSaveDir(ulong saveDataId)
{
string saveRootPath = Path.Combine(_virtualFileSystem.GetNandPath(), $"user/save/{saveDataId:x16}"); string saveRootPath = Path.Combine(_virtualFileSystem.GetNandPath(), $"user/save/{saveDataId:x16}");
if (!Directory.Exists(saveRootPath)) if (!Directory.Exists(saveRootPath))

View file

@ -1,6 +1,7 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LibHac;
using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Ui.ViewModels; using Ryujinx.Ava.Ui.ViewModels;
using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.FileSystem;
@ -14,6 +15,8 @@ namespace Ryujinx.Ava.Ui.Controls
{ {
public AccountManager AccountManager { get; } public AccountManager AccountManager { get; }
public ContentManager ContentManager { get; } public ContentManager ContentManager { get; }
public VirtualFileSystem VirtualFileSystem { get; }
public HorizonClient HorizonClient { get; }
public UserProfileViewModel ViewModel { get; set; } public UserProfileViewModel ViewModel { get; set; }
public NavigationDialogHost() public NavigationDialogHost()
@ -22,10 +25,12 @@ namespace Ryujinx.Ava.Ui.Controls
} }
public NavigationDialogHost(AccountManager accountManager, ContentManager contentManager, public NavigationDialogHost(AccountManager accountManager, ContentManager contentManager,
VirtualFileSystem virtualFileSystem) VirtualFileSystem virtualFileSystem, HorizonClient horizonClient)
{ {
AccountManager = accountManager; AccountManager = accountManager;
ContentManager = contentManager; ContentManager = contentManager;
VirtualFileSystem = virtualFileSystem;
HorizonClient = horizonClient;
ViewModel = new UserProfileViewModel(this); ViewModel = new UserProfileViewModel(this);
@ -54,9 +59,10 @@ namespace Ryujinx.Ava.Ui.Controls
ContentFrame.Navigate(sourcePageType, parameter); 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 ContentDialog contentDialog = new ContentDialog
{ {
Title = LocaleManager.Instance["UserProfileWindowTitle"], Title = LocaleManager.Instance["UserProfileWindowTitle"],

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -10,6 +10,7 @@
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels" xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels"
Margin="0" Margin="0"
MinWidth="500"
Padding="0" Padding="0"
mc:Ignorable="d"> mc:Ignorable="d">
<UserControl.Resources> <UserControl.Resources>
@ -63,7 +64,7 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
MaxLength="{Binding MaxProfileNameLength}" MaxLength="{Binding MaxProfileNameLength}"
Text="{Binding Name}" /> Text="{Binding Name}" />
<TextBlock Text="{Locale:Locale UserProfilesUserId}" /> <TextBlock Name="IdText" Text="{Locale:Locale UserProfilesUserId}" />
<TextBlock Name="IdLabel" Text="{Binding UserId}" /> <TextBlock Name="IdLabel" Text="{Binding UserId}" />
</StackPanel> </StackPanel>
<StackPanel <StackPanel

View file

@ -36,15 +36,8 @@ namespace Ryujinx.Ava.Ui.Controls
case NavigationMode.New: case NavigationMode.New:
var args = ((NavigationDialogHost parent, UserProfile profile, bool isNewUser))arg.Parameter; var args = ((NavigationDialogHost parent, UserProfile profile, bool isNewUser))arg.Parameter;
_isNewUser = args.isNewUser; _isNewUser = args.isNewUser;
if (!_isNewUser) _profile = args.profile;
{ TempProfile = new TempProfile(_profile);
_profile = args.profile;
TempProfile = new TempProfile(_profile);
}
else
{
TempProfile = new TempProfile();
}
_parent = args.parent; _parent = args.parent;
break; break;
@ -53,7 +46,8 @@ namespace Ryujinx.Ava.Ui.Controls
DataContext = TempProfile; DataContext = TempProfile;
AddPictureButton.IsVisible = _isNewUser; AddPictureButton.IsVisible = _isNewUser;
IdLabel.IsVisible = !_isNewUser; IdLabel.IsVisible = _profile != null;
IdText.IsVisible = _profile != null;
ChangePictureButton.IsVisible = !_isNewUser; ChangePictureButton.IsVisible = !_isNewUser;
} }
} }
@ -87,7 +81,7 @@ namespace Ryujinx.Ava.Ui.Controls
return; return;
} }
if (_profile != null) if (_profile != null && !_isNewUser)
{ {
_profile.Name = TempProfile.Name; _profile.Name = TempProfile.Name;
_profile.Image = TempProfile.Image; _profile.Image = TempProfile.Image;
@ -97,7 +91,7 @@ namespace Ryujinx.Ava.Ui.Controls
} }
else if (_isNewUser) else if (_isNewUser)
{ {
_parent.AccountManager.AddUser(TempProfile.Name, TempProfile.Image); _parent.AccountManager.AddUser(TempProfile.Name, TempProfile.Image, TempProfile.UserId);
} }
else else
{ {

View file

@ -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>

View file

@ -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;
}
}
}
}

View file

@ -10,6 +10,7 @@
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels" xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels"
d:DesignHeight="450" d:DesignHeight="450"
MinWidth="500"
d:DesignWidth="800" d:DesignWidth="800"
mc:Ignorable="d"> mc:Ignorable="d">
<UserControl.Resources> <UserControl.Resources>
@ -25,6 +26,7 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ListBox <ListBox
Margin="5" Margin="5"
MaxHeight="300"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Center" VerticalAlignment="Center"
DoubleTapped="ProfilesList_DoubleTapped" DoubleTapped="ProfilesList_DoubleTapped"
@ -88,21 +90,56 @@
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
<StackPanel <Grid
Grid.Row="1" Grid.Row="1"
Margin="10,0" HorizontalAlignment="Center">
HorizontalAlignment="Center" <Grid.RowDefinitions>
Orientation="Horizontal" <RowDefinition Height="Auto"/>
Spacing="10"> <RowDefinition Height="Auto"/>
<Button Command="{Binding AddUser}" Content="{Locale:Locale UserProfilesAddNewProfile}" /> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button <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}" Command="{Binding EditUser}"
Content="{Locale:Locale UserProfilesEditProfile}" Content="{Locale:Locale UserProfilesEditProfile}"
IsEnabled="{Binding IsSelectedProfiledEditable}" /> IsEnabled="{Binding IsSelectedProfiledEditable}" />
<Button <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}" Command="{Binding DeleteUser}"
Content="{Locale:Locale UserProfilesDeleteSelectedProfile}" Content="{Locale:Locale UserProfilesDeleteSelectedProfile}"
IsEnabled="{Binding IsSelectedProfileDeletable}" /> 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> </Grid>
</UserControl> </UserControl>

View file

@ -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();
}
}
}
}

View file

@ -45,9 +45,12 @@ namespace Ryujinx.Ava.Ui.Models
{ {
_profile = profile; _profile = profile;
Image = profile.Image; if (_profile != null)
Name = profile.Name; {
UserId = profile.UserId; Image = profile.Image;
Name = profile.Name;
UserId = profile.UserId;
}
} }
public TempProfile(){} public TempProfile(){}

View file

@ -1,3 +1,4 @@
using Ryujinx.Ava.Ui.Controls;
using Ryujinx.Ava.Ui.ViewModels; using Ryujinx.Ava.Ui.ViewModels;
using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.HLE.HOS.Services.Account.Acc;
using Profile = Ryujinx.HLE.HOS.Services.Account.Acc.UserProfile; using Profile = Ryujinx.HLE.HOS.Services.Account.Acc.UserProfile;
@ -7,6 +8,7 @@ namespace Ryujinx.Ava.Ui.Models
public class UserProfile : BaseModel public class UserProfile : BaseModel
{ {
private readonly Profile _profile; private readonly Profile _profile;
private readonly NavigationDialogHost _owner;
private byte[] _image; private byte[] _image;
private string _name; private string _name;
private UserId _userId; private UserId _userId;
@ -41,9 +43,10 @@ namespace Ryujinx.Ava.Ui.Models
} }
} }
public UserProfile(Profile profile) public UserProfile(Profile profile, NavigationDialogHost owner)
{ {
_profile = profile; _profile = profile;
_owner = owner;
Image = profile.Image; Image = profile.Image;
Name = profile.Name; Name = profile.Name;
@ -57,5 +60,10 @@ namespace Ryujinx.Ava.Ui.Models
OnPropertyChanged(nameof(IsOpened)); OnPropertyChanged(nameof(IsOpened));
OnPropertyChanged(nameof(Name)); OnPropertyChanged(nameof(Name));
} }
public void Recover(UserProfile userProfile)
{
_owner.Navigate(typeof(UserEditor), (_owner, userProfile, true));
}
} }
} }

View file

@ -76,6 +76,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
private bool _showAll; private bool _showAll;
private string _lastScannedAmiiboId; private string _lastScannedAmiiboId;
private ReadOnlyObservableCollection<ApplicationData> _appsObservableList; private ReadOnlyObservableCollection<ApplicationData> _appsObservableList;
public ApplicationLibrary ApplicationLibrary => _owner.ApplicationLibrary;
public string TitleName { get; internal set; } public string TitleName { get; internal set; }
@ -103,8 +104,8 @@ namespace Ryujinx.Ava.Ui.ViewModels
public void Initialize() public void Initialize()
{ {
_owner.ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated; ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated;
_owner.ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded; ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded;
Ptc.PtcStateChanged -= ProgressHandler; Ptc.PtcStateChanged -= ProgressHandler;
Ptc.PtcStateChanged += ProgressHandler; Ptc.PtcStateChanged += ProgressHandler;
@ -817,7 +818,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
Thread thread = new(() => 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; _isLoading = false;
}) })
@ -1005,7 +1006,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
public async void ManageProfiles() 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() public async void OpenAboutWindow()
@ -1098,7 +1099,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
{ {
selection.Favorite = !selection.Favorite; selection.Favorite = !selection.Favorite;
_owner.ApplicationLibrary.LoadAndSaveMetaData(selection.TitleId, appMetadata => ApplicationLibrary.LoadAndSaveMetaData(selection.TitleId, appMetadata =>
{ {
appMetadata.Favorite = selection.Favorite; appMetadata.Favorite = selection.Favorite;
}); });

View file

@ -1,8 +1,14 @@
using Avalonia;
using Avalonia.Threading; 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.Common.Locale;
using Ryujinx.Ava.Ui.Controls; using Ryujinx.Ava.Ui.Controls;
using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.HLE.HOS.Services.Account.Acc;
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile; using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile;
@ -19,6 +25,7 @@ namespace Ryujinx.Ava.Ui.ViewModels
public UserProfileViewModel() public UserProfileViewModel()
{ {
Profiles = new ObservableCollection<UserProfile>(); Profiles = new ObservableCollection<UserProfile>();
LostProfiles = new ObservableCollection<UserProfile>();
} }
public UserProfileViewModel(NavigationDialogHost owner) : this() public UserProfileViewModel(NavigationDialogHost owner) : this()
@ -30,6 +37,8 @@ namespace Ryujinx.Ava.Ui.ViewModels
public ObservableCollection<UserProfile> Profiles { get; set; } public ObservableCollection<UserProfile> Profiles { get; set; }
public ObservableCollection<UserProfile> LostProfiles { get; set; }
public UserProfile SelectedProfile public UserProfile SelectedProfile
{ {
get => _selectedProfile; get => _selectedProfile;
@ -65,12 +74,13 @@ namespace Ryujinx.Ava.Ui.ViewModels
public void LoadProfiles() public void LoadProfiles()
{ {
Profiles.Clear(); Profiles.Clear();
LostProfiles.Clear();
var profiles = _owner.AccountManager.GetAllUsers().OrderByDescending(x => x.AccountState == AccountState.Open); var profiles = _owner.AccountManager.GetAllUsers().OrderByDescending(x => x.AccountState == AccountState.Open);
foreach (var profile in profiles) 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); SelectedProfile = Profiles.FirstOrDefault(x => x.UserId == _owner.AccountManager.LastOpenedUser.UserId);
@ -84,6 +94,42 @@ namespace Ryujinx.Ava.Ui.ViewModels
_owner.AccountManager.OpenUser(_selectedProfile.UserId); _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() public void AddUser()
@ -93,6 +139,25 @@ namespace Ryujinx.Ava.Ui.ViewModels
_owner.Navigate(typeof(UserEditor), (this._owner, userProfile, true)); _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() public void EditUser()
{ {
_owner.Navigate(typeof(UserEditor), (this._owner, _highlightedProfile ?? SelectedProfile, false)); _owner.Navigate(typeof(UserEditor), (this._owner, _highlightedProfile ?? SelectedProfile, false));
@ -134,5 +199,15 @@ namespace Ryujinx.Ava.Ui.ViewModels
LoadProfiles(); LoadProfiles();
} }
public void GoBack()
{
_owner.GoBack();
}
public void RecoverLostAccounts()
{
_owner.Navigate(typeof(UserRecoverer), (this._owner, this));
}
} }
} }

View file

@ -36,6 +36,7 @@ namespace Ryujinx.Ava.Ui.Windows
{ {
public partial class MainWindow : StyleableWindow public partial class MainWindow : StyleableWindow
{ {
internal static MainWindowViewModel MainWindowViewModel { get; private set; }
private bool _canUpdate; private bool _canUpdate;
private bool _isClosing; private bool _isClosing;
private bool _isLoading; private bool _isLoading;
@ -81,6 +82,8 @@ namespace Ryujinx.Ava.Ui.Windows
{ {
ViewModel = new MainWindowViewModel(this); ViewModel = new MainWindowViewModel(this);
MainWindowViewModel = ViewModel;
DataContext = ViewModel; DataContext = ViewModel;
InitializeComponent(); InitializeComponent();

View file

@ -444,7 +444,10 @@ namespace Ryujinx.Ui.App.Common
continue; continue;
} }
ApplicationMetadata appMetadata = LoadAndSaveMetaData(titleId); ApplicationMetadata appMetadata = LoadAndSaveMetaData(titleId, appMetadata =>
{
appMetadata.Title = titleName;
});
if (appMetadata.LastPlayed != "Never" && !DateTime.TryParse(appMetadata.LastPlayed, out _)) if (appMetadata.LastPlayed != "Never" && !DateTime.TryParse(appMetadata.LastPlayed, out _))
{ {

View file

@ -2,6 +2,7 @@
{ {
public class ApplicationMetadata public class ApplicationMetadata
{ {
public string Title { get; set; }
public bool Favorite { get; set; } public bool Favorite { get; set; }
public double TimePlayed { get; set; } public double TimePlayed { get; set; }
public string LastPlayed { get; set; } = "Never"; public string LastPlayed { get; set; } = "Never";