diff --git a/Ryujinx.Ava/Assets/Locales/en_US.json b/Ryujinx.Ava/Assets/Locales/en_US.json index 24f44deeb..ba5af264b 100644 --- a/Ryujinx.Ava/Assets/Locales/en_US.json +++ b/Ryujinx.Ava/Assets/Locales/en_US.json @@ -524,7 +524,7 @@ "UserErrorUndefinedDescription": "An undefined error occured! This shouldn't happen, please contact a dev!", "OpenSetupGuideMessage": "Open the Setup Guide", "NoUpdate": "No Update", - "TitleUpdateVersionLabel": "Version {0} - {1}", + "TitleUpdateVersionLabel": "Version {0}", "RyujinxInfo": "Ryujinx - Info", "RyujinxConfirm": "Ryujinx - Confirmation", "FileDialogAllTypes": "All types", @@ -585,7 +585,7 @@ "UserProfilesSetProfileImage": "Set Profile Image", "UserProfileEmptyNameError": "Name is required", "UserProfileNoImageError": "Profile image must be set", - "GameUpdateWindowHeading": "{0} Update(s) available for {1} ({2})", + "GameUpdateWindowHeading": "Manage Updates for {0} ({1})", "SettingsTabHotkeysResScaleUpHotkey": "Increase resolution:", "SettingsTabHotkeysResScaleDownHotkey": "Decrease resolution:", "UserProfilesName": "Name:", diff --git a/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs b/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs index c3ba62301..c57b3a26a 100644 --- a/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs +++ b/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs @@ -3,23 +3,17 @@ using Ryujinx.Ava.Common.Locale; namespace Ryujinx.Ava.UI.Models { - internal class TitleUpdateModel + public class TitleUpdateModel { - public bool IsEnabled { get; set; } - public bool IsNoUpdate { get; } public ApplicationControlProperty Control { get; } public string Path { get; } - public string Label => IsNoUpdate - ? LocaleManager.Instance[LocaleKeys.NoUpdate] - : string.Format(LocaleManager.Instance[LocaleKeys.TitleUpdateVersionLabel], Control.DisplayVersionString.ToString(), - Path); + public string Label => string.Format(LocaleManager.Instance[LocaleKeys.TitleUpdateVersionLabel], Control.DisplayVersionString.ToString()); - public TitleUpdateModel(ApplicationControlProperty control, string path, bool isNoUpdate = false) + public TitleUpdateModel(ApplicationControlProperty control, string path) { Control = control; Path = path; - IsNoUpdate = isNoUpdate; } } } \ No newline at end of file diff --git a/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs b/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs index f86cda21a..295402155 100644 --- a/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs +++ b/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs @@ -1601,13 +1601,9 @@ namespace Ryujinx.Ava.UI.ViewModels public async void OpenTitleUpdateManager() { - ApplicationData selection = SelectedApplication; - if (selection != null) + if (SelectedApplication != null) { - if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - await new TitleUpdateWindow(VirtualFileSystem, ulong.Parse(selection.TitleId, NumberStyles.HexNumber), selection.TitleName).ShowDialog(desktop.MainWindow); - } + await TitleUpdateWindow.Show(VirtualFileSystem, ulong.Parse(SelectedApplication.TitleId, NumberStyles.HexNumber), SelectedApplication.TitleName); } } diff --git a/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs b/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs new file mode 100644 index 000000000..131ebd25b --- /dev/null +++ b/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs @@ -0,0 +1,226 @@ +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Ns; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.Ava.UI.Models; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SpanHelpers = LibHac.Common.SpanHelpers; +using Path = System.IO.Path; + +namespace Ryujinx.Ava.UI.ViewModels; + +public class TitleUpdateViewModel : BaseModel +{ + public TitleUpdateMetadata _titleUpdateWindowData; + public readonly string _titleUpdateJsonPath; + private VirtualFileSystem _virtualFileSystem { get; } + private ulong _titleId { get; } + private string _titleName { get; } + + private AvaloniaList _titleUpdates = new(); + private AvaloniaList _views = new(); + private object _selectedUpdate; + + public AvaloniaList TitleUpdates + { + get => _titleUpdates; + set + { + _titleUpdates = value; + OnPropertyChanged(); + } + } + + public AvaloniaList Views + { + get => _views; + set + { + _views = value; + OnPropertyChanged(); + } + } + + public object SelectedUpdate + { + get => _selectedUpdate; + set + { + _selectedUpdate = value; + OnPropertyChanged(); + } + } + + public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName) + { + _virtualFileSystem = virtualFileSystem; + + _titleId = titleId; + _titleName = titleName; + + _titleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "updates.json"); + + try + { + _titleUpdateWindowData = JsonHelper.DeserializeFromFile(_titleUpdateJsonPath); + } + catch + { + Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {_titleId} at {_titleUpdateJsonPath}"); + + _titleUpdateWindowData = new TitleUpdateMetadata + { + Selected = "", + Paths = new List() + }; + } + + LoadUpdates(); + } + + private void LoadUpdates() + { + foreach (string path in _titleUpdateWindowData.Paths) + { + AddUpdate(path); + } + + TitleUpdateModel selected = TitleUpdates.FirstOrDefault(x => x.Path == _titleUpdateWindowData.Selected, null); + + SelectedUpdate = selected; + + SortUpdates(); + } + + public void SortUpdates() + { + var list = TitleUpdates.ToList(); + + list.Sort((first, second) => + { + if (string.IsNullOrEmpty(first.Control.DisplayVersionString.ToString())) + { + return -1; + } + else if (string.IsNullOrEmpty(second.Control.DisplayVersionString.ToString())) + { + return 1; + } + + return Version.Parse(first.Control.DisplayVersionString.ToString()).CompareTo(Version.Parse(second.Control.DisplayVersionString.ToString())) * -1; + }); + + Views.Clear(); + Views.Add(new BaseModel()); + Views.AddRange(list); + + if (SelectedUpdate == null) + { + SelectedUpdate = Views[0]; + } + else if (!TitleUpdates.Contains(SelectedUpdate)) + { + if (Views.Count > 1) + { + SelectedUpdate = Views[1]; + } + else + { + SelectedUpdate = Views[0]; + } + } + } + + private void AddUpdate(string path) + { + if (File.Exists(path) && TitleUpdates.All(x => x.Path != path)) + { + using FileStream file = new(path, FileMode.Open, FileAccess.Read); + + try + { + (Nca patchNca, Nca controlNca) = ApplicationLoader.GetGameUpdateDataFromPartition(_virtualFileSystem, new PartitionFileSystem(file.AsStorage()), _titleId.ToString("x16"), 0); + + if (controlNca != null && patchNca != null) + { + ApplicationControlProperty controlData = new(); + + using UniqueRef nacpFile = new(); + + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref(), "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); + + TitleUpdates.Add(new TitleUpdateModel(controlData, path)); + } + else + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]); + }); + } + } + catch (Exception ex) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogDlcLoadNcaErrorMessage], ex.Message, path)); + }); + } + } + } + + public void RemoveUpdate(TitleUpdateModel update) + { + TitleUpdates.Remove(update); + + SortUpdates(); + } + + public async void Add() + { + OpenFileDialog dialog = new() + { + Title = LocaleManager.Instance[LocaleKeys.SelectUpdateDialogTitle], + AllowMultiple = true + }; + + dialog.Filters.Add(new FileDialogFilter + { + Name = "NSP", + Extensions = { "nsp" } + }); + + if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + string[] files = await dialog.ShowAsync(desktop.MainWindow); + + if (files != null) + { + foreach (string file in files) + { + AddUpdate(file); + } + } + } + + SortUpdates(); + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml b/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml index b4f2e1014..ec931dd96 100644 --- a/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml +++ b/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml @@ -107,6 +107,7 @@ VerticalAlignment="Stretch"> @@ -116,6 +117,9 @@ + diff --git a/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml b/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml index 5a69be9b0..e98580386 100644 --- a/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml +++ b/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml @@ -1,115 +1,135 @@ - - + - - - - - - - - - - - + - - - + + - - + + - \ No newline at end of file + \ No newline at end of file diff --git a/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs b/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs index 848c5587f..9d8b9a7b9 100644 --- a/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs +++ b/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs @@ -1,271 +1,116 @@ -using Avalonia.Collections; using Avalonia.Controls; -using Avalonia.Threading; -using LibHac.Common; -using LibHac.Fs; -using LibHac.Fs.Fsa; -using LibHac.FsSystem; -using LibHac.Ns; -using LibHac.Tools.FsSystem; -using LibHac.Tools.FsSystem.NcaUtils; +using Avalonia.Interactivity; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; using Ryujinx.Ava.Common.Locale; -using Ryujinx.Ava.UI.Controls; using Ryujinx.Ava.UI.Helpers; using Ryujinx.Ava.UI.Models; -using Ryujinx.Common.Configuration; +using Ryujinx.Ava.UI.ViewModels; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; -using Ryujinx.HLE.HOS; -using System; -using System.Collections.Generic; +using Ryujinx.Ui.Common.Helper; using System.IO; -using System.Linq; using System.Text; -using Path = System.IO.Path; -using SpanHelpers = LibHac.Common.SpanHelpers; +using System.Threading.Tasks; +using Button = Avalonia.Controls.Button; namespace Ryujinx.Ava.UI.Windows { - public partial class TitleUpdateWindow : StyleableWindow + public partial class TitleUpdateWindow : UserControl { - private readonly string _titleUpdateJsonPath; - private TitleUpdateMetadata _titleUpdateWindowData; - - private VirtualFileSystem _virtualFileSystem { get; } - private AvaloniaList _titleUpdates { get; set; } - - private ulong _titleId { get; } - private string _titleName { get; } + public TitleUpdateViewModel ViewModel; public TitleUpdateWindow() { DataContext = this; InitializeComponent(); - - Title = $"Ryujinx {Program.Version} - {LocaleManager.Instance[LocaleKeys.UpdateWindowTitle]} - {_titleName} ({_titleId:X16})"; } public TitleUpdateWindow(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName) { - _virtualFileSystem = virtualFileSystem; - _titleUpdates = new AvaloniaList(); - - _titleId = titleId; - _titleName = titleName; - - _titleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "updates.json"); - - try - { - _titleUpdateWindowData = JsonHelper.DeserializeFromFile(_titleUpdateJsonPath); - } - catch - { - _titleUpdateWindowData = new TitleUpdateMetadata - { - Selected = "", - Paths = new List() - }; - } - - DataContext = this; + DataContext = ViewModel = new TitleUpdateViewModel(virtualFileSystem, titleId, titleName); InitializeComponent(); - - Title = $"Ryujinx {Program.Version} - {LocaleManager.Instance[LocaleKeys.UpdateWindowTitle]} - {_titleName} ({_titleId:X16})"; - - LoadUpdates(); - PrintHeading(); } - private void PrintHeading() + public static async Task Show(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName) { - Heading.Text = string.Format(LocaleManager.Instance[LocaleKeys.GameUpdateWindowHeading], _titleUpdates.Count - 1, _titleName, _titleId.ToString("X16")); - } - - private void LoadUpdates() - { - _titleUpdates.Add(new TitleUpdateModel(default, string.Empty, true)); - - foreach (string path in _titleUpdateWindowData.Paths) + ContentDialog contentDialog = new() { - AddUpdate(path); - } - - if (_titleUpdateWindowData.Selected == "") - { - _titleUpdates[0].IsEnabled = true; - } - else - { - TitleUpdateModel selected = _titleUpdates.FirstOrDefault(x => x.Path == _titleUpdateWindowData.Selected); - List enabled = _titleUpdates.Where(x => x.IsEnabled).ToList(); - - foreach (TitleUpdateModel update in enabled) - { - update.IsEnabled = false; - } - - if (selected != null) - { - selected.IsEnabled = true; - } - } - - SortUpdates(); - } - - private void AddUpdate(string path) - { - if (File.Exists(path) && !_titleUpdates.Any(x => x.Path == path)) - { - using FileStream file = new(path, FileMode.Open, FileAccess.Read); - - try - { - (Nca patchNca, Nca controlNca) = ApplicationLoader.GetGameUpdateDataFromPartition(_virtualFileSystem, new PartitionFileSystem(file.AsStorage()), _titleId.ToString("x16"), 0); - - if (controlNca != null && patchNca != null) - { - ApplicationControlProperty controlData = new(); - - using UniqueRef nacpFile = new(); - - controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref(), "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); - nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); - - _titleUpdates.Add(new TitleUpdateModel(controlData, path)); - - foreach (var update in _titleUpdates) - { - update.IsEnabled = false; - } - - _titleUpdates.Last().IsEnabled = true; - } - else - { - Dispatcher.UIThread.Post(async () => - { - await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]); - }); - } - } - catch (Exception ex) - { - Dispatcher.UIThread.Post(async () => - { - await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogDlcLoadNcaErrorMessage], ex.Message, path)); - }); - } - } - } - - private void RemoveUpdates(bool removeSelectedOnly = false) - { - if (removeSelectedOnly) - { - _titleUpdates.RemoveAll(_titleUpdates.Where(x => x.IsEnabled && !x.IsNoUpdate).ToList()); - } - else - { - _titleUpdates.RemoveAll(_titleUpdates.Where(x => !x.IsNoUpdate).ToList()); - } - - _titleUpdates.FirstOrDefault(x => x.IsNoUpdate).IsEnabled = true; - - SortUpdates(); - PrintHeading(); - } - - public void RemoveSelected() - { - RemoveUpdates(true); - } - - public void RemoveAll() - { - RemoveUpdates(); - } - - public async void Add() - { - OpenFileDialog dialog = new() - { - Title = LocaleManager.Instance[LocaleKeys.SelectUpdateDialogTitle], - AllowMultiple = true + PrimaryButtonText = "", + SecondaryButtonText = "", + CloseButtonText = "", + Content = new TitleUpdateWindow(virtualFileSystem, titleId, titleName), + Title = string.Format(LocaleManager.Instance[LocaleKeys.GameUpdateWindowHeading], titleName, titleId.ToString("X16")) }; - dialog.Filters.Add(new FileDialogFilter - { - Name = "NSP", - Extensions = { "nsp" } - }); + Style bottomBorder = new(x => x.OfType().Name("DialogSpace").Child().OfType()); + bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false)); - string[] files = await dialog.ShowAsync(this); + contentDialog.Styles.Add(bottomBorder); - if (files != null) - { - foreach (string file in files) - { - AddUpdate(file); - } - } - - SortUpdates(); - PrintHeading(); + await ContentDialogHelper.ShowAsync(contentDialog); } - private void SortUpdates() + private void Close(object sender, RoutedEventArgs e) { - var list = _titleUpdates.ToList(); - - list.Sort((first, second) => - { - if (string.IsNullOrEmpty(first.Control.DisplayVersionString.ToString())) - { - return -1; - } - else if (string.IsNullOrEmpty(second.Control.DisplayVersionString.ToString())) - { - return 1; - } - - return Version.Parse(first.Control.DisplayVersionString.ToString()).CompareTo(Version.Parse(second.Control.DisplayVersionString.ToString())) * -1; - }); - - _titleUpdates.Clear(); - _titleUpdates.AddRange(list); + ((ContentDialog)Parent).Hide(); } - public void Save() + public void Save(object sender, RoutedEventArgs e) { - _titleUpdateWindowData.Paths.Clear(); + ViewModel._titleUpdateWindowData.Paths.Clear(); - _titleUpdateWindowData.Selected = ""; + ViewModel._titleUpdateWindowData.Selected = ""; - foreach (TitleUpdateModel update in _titleUpdates) + foreach (TitleUpdateModel update in ViewModel.TitleUpdates) { - _titleUpdateWindowData.Paths.Add(update.Path); + ViewModel._titleUpdateWindowData.Paths.Add(update.Path); - if (update.IsEnabled) + if (update == ViewModel.SelectedUpdate) { - _titleUpdateWindowData.Selected = update.Path; + ViewModel._titleUpdateWindowData.Selected = update.Path; } } - using (FileStream titleUpdateJsonStream = File.Create(_titleUpdateJsonPath, 4096, FileOptions.WriteThrough)) + using (FileStream titleUpdateJsonStream = File.Create(ViewModel._titleUpdateJsonPath, 4096, FileOptions.WriteThrough)) { - titleUpdateJsonStream.Write(Encoding.UTF8.GetBytes(JsonHelper.Serialize(_titleUpdateWindowData, true))); + titleUpdateJsonStream.Write(Encoding.UTF8.GetBytes(JsonHelper.Serialize(ViewModel._titleUpdateWindowData, true))); } - if (Owner is MainWindow window) + if (VisualRoot is MainWindow window) { window.ViewModel.LoadApplications(); } - Close(); + ((ContentDialog)Parent).Hide(); + } + + private void OpenLocation(object sender, RoutedEventArgs e) + { + if (sender is Button button) + { + if (button.DataContext is TitleUpdateModel model) + { + OpenHelper.LocateFile(model.Path); + } + } + } + + private void RemoveUpdate(object sender, RoutedEventArgs e) + { + if (sender is Button button) + { + ViewModel.RemoveUpdate((TitleUpdateModel)button.DataContext); + } + } + + private void RemoveAll(object sender, RoutedEventArgs e) + { + ViewModel.TitleUpdates.Clear(); + + ViewModel.SortUpdates(); } } } \ No newline at end of file diff --git a/Ryujinx.Ui.Common/Helper/OpenHelper.cs b/Ryujinx.Ui.Common/Helper/OpenHelper.cs index eaaa73924..355348921 100644 --- a/Ryujinx.Ui.Common/Helper/OpenHelper.cs +++ b/Ryujinx.Ui.Common/Helper/OpenHelper.cs @@ -1,19 +1,75 @@ using Ryujinx.Common.Logging; using System; using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; namespace Ryujinx.Ui.Common.Helper { - public static class OpenHelper + public static partial class OpenHelper { + [LibraryImport("shell32.dll", SetLastError = true)] + public static partial int SHOpenFolderAndSelectItems(IntPtr pidlFolder, uint cidl, IntPtr apidl, uint dwFlags); + + [LibraryImport("shell32.dll", SetLastError = true)] + public static partial void ILFree(IntPtr pidlList); + + [LibraryImport("shell32.dll", SetLastError = true)] + public static partial IntPtr ILCreateFromPathW([MarshalAs(UnmanagedType.LPWStr)] string pszPath); + public static void OpenFolder(string path) { - Process.Start(new ProcessStartInfo + if (Directory.Exists(path)) { - FileName = path, - UseShellExecute = true, - Verb = "open" - }); + Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + Verb = "open" + }); + } + else + { + Logger.Notice.Print(LogClass.Application, $"Directory \"{path}\" doesn't exist!"); + } + } + + public static void LocateFile(string path) + { + if (File.Exists(path)) + { + if (OperatingSystem.IsWindows()) + { + IntPtr pidlList = ILCreateFromPathW(path); + if (pidlList != IntPtr.Zero) + { + try + { + Marshal.ThrowExceptionForHR(SHOpenFolderAndSelectItems(pidlList, 0, IntPtr.Zero, 0)); + } + finally + { + ILFree(pidlList); + } + } + } + else if (OperatingSystem.IsMacOS()) + { + Process.Start("open", $"-R \"{path}\""); + } + else if (OperatingSystem.IsLinux()) + { + Process.Start("dbus-send", $"--session --print-reply --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:\"file://{path}\" string:\"\""); + } + else + { + OpenFolder(Path.GetDirectoryName(path)); + } + } + else + { + Logger.Notice.Print(LogClass.Application, $"File \"{path}\" doesn't exist!"); + } } public static void OpenUrl(string url)