diff --git a/Ryujinx.Ava/Assets/Locales/en_US.json b/Ryujinx.Ava/Assets/Locales/en_US.json index 4561ad8be3..be3071cc92 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 074b16612e..fbd4d4b37c 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 8b09bafdda..8f7c2e73cb 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 47f4392e8b..7f76661429 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 9ba631ad74..ced8832866 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 0000000000..ce337c7b4c --- /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 0000000000..499cd918e8 --- /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 90c5c1fec4..898527e6a0 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 ea996da8f0..f5b51e4e3c 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 0000000000..6345ec6746 --- /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 0000000000..f093686dd4 --- /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 c06bce231f..bc6d8c09ca 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 0000000000..70478cea93 --- /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 e943687a16..4e6d344625 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 351ada76f4..c0ea9451ac 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 cd43701720..c7053eb188 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 a48b06e61a..eb9f69d638 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 8b5a39a7d7..c0a94154c2 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 9a6c1997e6..6f25478517 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 2631a8cf8e..e19f7483fc 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";