diff --git a/Ryujinx.Ava/Assets/Locales/en_US.json b/Ryujinx.Ava/Assets/Locales/en_US.json index ebf30df6..d9483db4 100644 --- a/Ryujinx.Ava/Assets/Locales/en_US.json +++ b/Ryujinx.Ava/Assets/Locales/en_US.json @@ -553,6 +553,20 @@ "SettingsTabHotkeysToggleMuteHotkey": "Mute:", "ControllerMotionTitle": "Motion Control Settings", "ControllerRumbleTitle": "Rumble Settings", - "SettingsSelectThemeFileDialogTitle" : "Select Theme File", - "SettingsXamlThemeFile" : "Xaml Theme File" + "SettingsSelectThemeFileDialogTitle": "Select Theme File", + "SettingsXamlThemeFile": "Xaml Theme File", + "AvatarWindowTitle": "Manage Accounts - Avatar", + "Amiibo": "Amiibo", + "Unknown": "Unknown", + "Usage": "Usage", + "Writable": "Writable", + "SelectDlcDialogTitle": "Select DLC files", + "SelectUpdateDialogTitle": "Select update files", + "UserProfileWindowTitle": "Manage User Profiles", + "CheatWindowTitle": "Manage Game Cheats", + "DlcWindowTitle": "Manage Game DLC", + "UpdateWindowTitle": "Manage Game Updates", + "CheatWindowHeading": "Cheats Available for {0} [{1}]", + "DlcWindowHeading": "DLC Available for {0} [{1}]", + "GameUpdateWindowHeading": "DLC Available for {0} [{1}]" } diff --git a/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml new file mode 100644 index 00000000..c6f43f43 --- /dev/null +++ b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs new file mode 100644 index 00000000..728b8906 --- /dev/null +++ b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs @@ -0,0 +1,105 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Windows; +using Ryujinx.HLE.FileSystem; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using System.IO; +using Image = SixLabors.ImageSharp.Image; + +namespace Ryujinx.Ava.Ui.Controls +{ + public class ProfileImageSelectionDialog : StyleableWindow + { + private readonly ContentManager _contentManager; + + public bool FirmwareFound => _contentManager.GetCurrentFirmwareVersion() != null; + + public byte[] BufferImageProfile { get; set; } + + public ProfileImageSelectionDialog(ContentManager contentManager) + { + _contentManager = contentManager; + DataContext = this; + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } + + public ProfileImageSelectionDialog() + { + DataContext = this; + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private async void Import_OnClick(object sender, RoutedEventArgs e) + { + OpenFileDialog dialog = new(); + dialog.Filters.Add(new FileDialogFilter + { + Name = LocaleManager.Instance["AllSupportedFormats"], + Extensions = { "jpg", "jpeg", "png", "bmp" } + }); + dialog.Filters.Add(new FileDialogFilter { Name = "JPEG", Extensions = { "jpg", "jpeg" } }); + dialog.Filters.Add(new FileDialogFilter { Name = "PNG", Extensions = { "png" } }); + dialog.Filters.Add(new FileDialogFilter { Name = "BMP", Extensions = { "bmp" } }); + + dialog.AllowMultiple = false; + + string[] image = await dialog.ShowAsync(this); + + if (image != null) + { + if (image.Length > 0) + { + string imageFile = image[0]; + + ProcessProfileImage(File.ReadAllBytes(imageFile)); + } + + Close(); + } + } + + private async void SelectFirmwareImage_OnClick(object sender, RoutedEventArgs e) + { + if (FirmwareFound) + { + AvatarWindow window = new(_contentManager); + + await window.ShowDialog(this); + + BufferImageProfile = window.SelectedImage; + + Close(); + } + } + + private void ProcessProfileImage(byte[] buffer) + { + using (Image image = Image.Load(buffer)) + { + image.Mutate(x => x.Resize(256, 256)); + + using (MemoryStream streamJpg = new()) + { + image.SaveAsJpeg(streamJpg); + + BufferImageProfile = streamJpg.ToArray(); + } + } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/Amiibo.cs b/Ryujinx.Ava/Ui/Models/Amiibo.cs new file mode 100644 index 00000000..8644ab52 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/Amiibo.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Ryujinx.Ava.Ui.Models +{ + public class Amiibo + { + public struct AmiiboJson + { + [JsonPropertyName("amiibo")] public List Amiibo { get; set; } + [JsonPropertyName("lastUpdated")] public DateTime LastUpdated { get; set; } + } + + public struct AmiiboApi + { + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("head")] public string Head { get; set; } + [JsonPropertyName("tail")] public string Tail { get; set; } + [JsonPropertyName("image")] public string Image { get; set; } + [JsonPropertyName("amiiboSeries")] public string AmiiboSeries { get; set; } + [JsonPropertyName("character")] public string Character { get; set; } + [JsonPropertyName("gameSeries")] public string GameSeries { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("release")] public Dictionary Release { get; set; } + + [JsonPropertyName("gamesSwitch")] public List GamesSwitch { get; set; } + + public override string ToString() + { + return Name; + } + + public string GetId() + { + return Head + Tail; + } + + public override bool Equals(object obj) + { + if (obj is AmiiboApi amiibo) + { + return amiibo.Head + amiibo.Tail == Head + Tail; + } + + return false; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + } + + public class AmiiboApiGamesSwitch + { + [JsonPropertyName("amiiboUsage")] public List AmiiboUsage { get; set; } + + [JsonPropertyName("gameID")] public List GameId { get; set; } + + [JsonPropertyName("gameName")] public string GameName { get; set; } + } + + public class AmiiboApiUsage + { + [JsonPropertyName("Usage")] public string Usage { get; set; } + + [JsonPropertyName("write")] public bool Write { get; set; } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/CheatModel.cs b/Ryujinx.Ava/Ui/Models/CheatModel.cs new file mode 100644 index 00000000..cdab27cd --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/CheatModel.cs @@ -0,0 +1,37 @@ +using Ryujinx.Ava.Ui.ViewModels; +using System; + +namespace Ryujinx.Ava.Ui.Models +{ + public class CheatModel : BaseModel + { + private bool _isEnabled; + + public event EventHandler EnableToggled; + + public CheatModel(string name, string buildId, bool isEnabled) + { + Name = name; + BuildId = buildId; + IsEnabled = isEnabled; + } + + public bool IsEnabled + { + get => _isEnabled; + set + { + _isEnabled = value; + EnableToggled?.Invoke(this, _isEnabled); + OnPropertyChanged(); + } + } + + public string BuildId { get; } + + public string BuildIdKey => $"{BuildId}-{Name}"; + public string Name { get; } + + public string CleanName => Name.Substring(1, Name.Length - 8); + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/CheatsList.cs b/Ryujinx.Ava/Ui/Models/CheatsList.cs new file mode 100644 index 00000000..f2b0592e --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/CheatsList.cs @@ -0,0 +1,51 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +namespace Ryujinx.Ava.Ui.Models +{ + public class CheatsList : ObservableCollection + { + public CheatsList(string buildId, string path) + { + BuildId = buildId; + Path = path; + CollectionChanged += CheatsList_CollectionChanged; + } + + private void CheatsList_CollectionChanged(object sender, + NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + (e.NewItems[0] as CheatModel).EnableToggled += Item_EnableToggled; + } + } + + private void Item_EnableToggled(object sender, bool e) + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsEnabled))); + } + + public string BuildId { get; } + public string Path { get; } + + public bool IsEnabled + { + get + { + return this.ToList().TrueForAll(x => x.IsEnabled); + } + set + { + foreach (var cheat in this) + { + cheat.IsEnabled = value; + } + + OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsEnabled))); + } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/DlcModel.cs b/Ryujinx.Ava/Ui/Models/DlcModel.cs new file mode 100644 index 00000000..7e5f4a62 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/DlcModel.cs @@ -0,0 +1,18 @@ +namespace Ryujinx.Ava.Ui.Models +{ + public class DlcModel + { + public bool IsEnabled { get; set; } + public string TitleId { get; } + public string ContainerPath { get; } + public string FullPath { get; } + + public DlcModel(string titleId, string containerPath, string fullPath, bool isEnabled) + { + TitleId = titleId; + ContainerPath = containerPath; + FullPath = fullPath; + IsEnabled = isEnabled; + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs b/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs index a23b55cc..1c9f3b05 100644 --- a/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs +++ b/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs @@ -1,6 +1,6 @@ namespace Ryujinx.Ava.Ui.Models { - internal class ProfileImageModel + public class ProfileImageModel { public ProfileImageModel(string name, byte[] data) { diff --git a/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs b/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs index f864e70a..2bf6dbfa 100644 --- a/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs +++ b/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs @@ -9,8 +9,11 @@ namespace Ryujinx.Ava.Ui.Models public bool IsNoUpdate { get; } public ApplicationControlProperty Control { get; } public string Path { get; } - public string Label => IsNoUpdate ? LocaleManager.Instance["NoUpdate"] : - string.Format(LocaleManager.Instance["TitleUpdateVersionLabel"], Control.DisplayVersionString.ToString(), Path); + + public string Label => IsNoUpdate + ? LocaleManager.Instance["NoUpdate"] + : string.Format(LocaleManager.Instance["TitleUpdateVersionLabel"], Control.DisplayVersionString.ToString(), + Path); public TitleUpdateModel(ApplicationControlProperty control, string path, bool isNoUpdate = false) { diff --git a/Ryujinx.Ava/Ui/Models/UserProfile.cs b/Ryujinx.Ava/Ui/Models/UserProfile.cs new file mode 100644 index 00000000..351ada76 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/UserProfile.cs @@ -0,0 +1,61 @@ +using Ryujinx.Ava.Ui.ViewModels; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using Profile = Ryujinx.HLE.HOS.Services.Account.Acc.UserProfile; + +namespace Ryujinx.Ava.Ui.Models +{ + public class UserProfile : BaseModel + { + private readonly Profile _profile; + private byte[] _image; + private string _name; + private UserId _userId; + + public byte[] Image + { + get => _image; + set + { + _image = value; + OnPropertyChanged(); + } + } + + public UserId UserId + { + get => _userId; + set + { + _userId = value; + OnPropertyChanged(); + } + } + + public string Name + { + get => _name; + set + { + _name = value; + OnPropertyChanged(); + } + } + + public UserProfile(Profile profile) + { + _profile = profile; + + Image = profile.Image; + Name = profile.Name; + UserId = profile.UserId; + } + + public bool IsOpened => _profile.AccountState == AccountState.Open; + + public void UpdateState() + { + OnPropertyChanged(nameof(IsOpened)); + OnPropertyChanged(nameof(Name)); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs new file mode 100644 index 00000000..9f411ba2 --- /dev/null +++ b/Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs @@ -0,0 +1,450 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.Ava.Ui.Windows; +using Ryujinx.Common; +using Ryujinx.Common.Configuration; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.Ui.ViewModels +{ + public class AmiiboWindowViewModel : BaseModel, IDisposable + { + private const string DefaultJson = "{ \"amiibo\": [] }"; + private const float AmiiboImageSize = 350f; + + private readonly string _amiiboJsonPath; + private readonly byte[] _amiiboLogoBytes; + private readonly HttpClient _httpClient; + private readonly StyleableWindow _owner; + + private Bitmap _amiiboImage; + private List _amiiboList; + private AvaloniaList _amiibos; + private ObservableCollection _amiiboSeries; + + private int _amiiboSelectedIndex; + private int _seriesSelectedIndex; + private bool _enableScanning; + private bool _showAllAmiibo; + private bool _useRandomUuid; + private string _usage; + + public AmiiboWindowViewModel(StyleableWindow owner, string lastScannedAmiiboId, string titleId) + { + _owner = owner; + _httpClient = new HttpClient { Timeout = TimeSpan.FromMilliseconds(5000) }; + LastScannedAmiiboId = lastScannedAmiiboId; + TitleId = titleId; + + Directory.CreateDirectory(Path.Join(AppDataManager.BaseDirPath, "system", "amiibo")); + + _amiiboJsonPath = Path.Join(AppDataManager.BaseDirPath, "system", "amiibo", "Amiibo.json"); + _amiiboList = new List(); + _amiiboSeries = new ObservableCollection(); + _amiibos = new AvaloniaList(); + + _amiiboLogoBytes = EmbeddedResources.Read("Ryujinx.Ui.Common/Resources/Logo_Amiibo.png"); + + _ = LoadContentAsync(); + } + + public AmiiboWindowViewModel() { } + + public string TitleId { get; set; } + public string LastScannedAmiiboId { get; set; } + + public UserResult Response { get; private set; } + + public bool UseRandomUuid + { + get => _useRandomUuid; + set + { + _useRandomUuid = value; + + OnPropertyChanged(); + } + } + + public bool ShowAllAmiibo + { + get => _showAllAmiibo; + set + { + _showAllAmiibo = value; + +#pragma warning disable 4014 + ParseAmiiboData(); +#pragma warning restore 4014 + + OnPropertyChanged(); + } + } + + public AvaloniaList AmiiboList + { + get => _amiibos; + set + { + _amiibos = value; + + OnPropertyChanged(); + } + } + + public ObservableCollection AmiiboSeries + { + get => _amiiboSeries; + set + { + _amiiboSeries = value; + OnPropertyChanged(); + } + } + + public int SeriesSelectedIndex + { + get => _seriesSelectedIndex; + set + { + _seriesSelectedIndex = value; + + FilterAmiibo(); + + OnPropertyChanged(); + } + } + + public int AmiiboSelectedIndex + { + get => _amiiboSelectedIndex; + set + { + _amiiboSelectedIndex = value; + + EnableScanning = _amiiboSelectedIndex >= 0 && _amiiboSelectedIndex < _amiibos.Count; + + SetAmiiboDetails(); + + OnPropertyChanged(); + } + } + + public Bitmap AmiiboImage + { + get => _amiiboImage; + set + { + _amiiboImage = value; + + OnPropertyChanged(); + } + } + + public string Usage + { + get => _usage; + set + { + _usage = value; + + OnPropertyChanged(); + } + } + + public bool EnableScanning + { + get => _enableScanning; + set + { + _enableScanning = value; + + OnPropertyChanged(); + } + } + + public void Dispose() + { + _httpClient.Dispose(); + } + + private async Task LoadContentAsync() + { + string amiiboJsonString = DefaultJson; + + if (File.Exists(_amiiboJsonPath)) + { + amiiboJsonString = File.ReadAllText(_amiiboJsonPath); + + if (await NeedsUpdate(JsonSerializer.Deserialize(amiiboJsonString).LastUpdated)) + { + amiiboJsonString = await DownloadAmiiboJson(); + } + } + else + { + try + { + amiiboJsonString = await DownloadAmiiboJson(); + } + catch + { + ShowInfoDialog(); + } + } + + _amiiboList = JsonSerializer.Deserialize(amiiboJsonString).Amiibo; + _amiiboList = _amiiboList.OrderBy(amiibo => amiibo.AmiiboSeries).ToList(); + + ParseAmiiboData(); + } + + private void ParseAmiiboData() + { + _amiiboSeries.Clear(); + _amiibos.Clear(); + + for (int i = 0; i < _amiiboList.Count; i++) + { + if (!_amiiboSeries.Contains(_amiiboList[i].AmiiboSeries)) + { + if (!ShowAllAmiibo) + { + foreach (Amiibo.AmiiboApiGamesSwitch game in _amiiboList[i].GamesSwitch) + { + if (game != null) + { + if (game.GameId.Contains(TitleId)) + { + AmiiboSeries.Add(_amiiboList[i].AmiiboSeries); + + break; + } + } + } + } + else + { + AmiiboSeries.Add(_amiiboList[i].AmiiboSeries); + } + } + } + + if (LastScannedAmiiboId != "") + { + SelectLastScannedAmiibo(); + } + else + { + SeriesSelectedIndex = 0; + } + } + + private void SelectLastScannedAmiibo() + { + Amiibo.AmiiboApi scanned = _amiiboList.FirstOrDefault(amiibo => amiibo.GetId() == LastScannedAmiiboId); + + SeriesSelectedIndex = AmiiboSeries.IndexOf(scanned.AmiiboSeries); + AmiiboSelectedIndex = AmiiboList.IndexOf(scanned); + } + + private void FilterAmiibo() + { + _amiibos.Clear(); + + if (_seriesSelectedIndex < 0) + { + return; + } + + List amiiboSortedList = _amiiboList + .Where(amiibo => amiibo.AmiiboSeries == _amiiboSeries[SeriesSelectedIndex]) + .OrderBy(amiibo => amiibo.Name).ToList(); + + for (int i = 0; i < amiiboSortedList.Count; i++) + { + if (!_amiibos.Contains(amiiboSortedList[i])) + { + if (!_showAllAmiibo) + { + foreach (Amiibo.AmiiboApiGamesSwitch game in amiiboSortedList[i].GamesSwitch) + { + if (game != null) + { + if (game.GameId.Contains(TitleId)) + { + _amiibos.Add(amiiboSortedList[i]); + + break; + } + } + } + } + else + { + _amiibos.Add(amiiboSortedList[i]); + } + } + } + + AmiiboSelectedIndex = 0; + } + + private void SetAmiiboDetails() + { + ResetAmiiboPreview(); + + Usage = string.Empty; + + if (_amiiboSelectedIndex < 0) + { + return; + } + + Amiibo.AmiiboApi selected = _amiibos[_amiiboSelectedIndex]; + + string imageUrl = _amiiboList.FirstOrDefault(amiibo => amiibo.Equals(selected)).Image; + + string usageString = ""; + + for (int i = 0; i < _amiiboList.Count; i++) + { + if (_amiiboList[i].Equals(selected)) + { + bool writable = false; + + foreach (Amiibo.AmiiboApiGamesSwitch item in _amiiboList[i].GamesSwitch) + { + if (item.GameId.Contains(TitleId)) + { + foreach (Amiibo.AmiiboApiUsage usageItem in item.AmiiboUsage) + { + usageString += Environment.NewLine + + $"- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}"; + + writable = usageItem.Write; + } + } + } + + if (usageString.Length == 0) + { + usageString = LocaleManager.Instance["Unknown"] + "."; + } + + Usage = $"{LocaleManager.Instance["Usage"]} {(writable ? $" ({LocaleManager.Instance["Writable"]})" : "")} : {usageString}"; + } + } + + _ = UpdateAmiiboPreview(imageUrl); + } + + private async Task NeedsUpdate(DateTime oldLastModified) + { + try + { + HttpResponseMessage response = + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "https://amiibo.ryujinx.org/")); + + if (response.IsSuccessStatusCode) + { + return response.Content.Headers.LastModified != oldLastModified; + } + + return false; + } + catch + { + ShowInfoDialog(); + + return false; + } + } + + private async Task DownloadAmiiboJson() + { + HttpResponseMessage response = await _httpClient.GetAsync("https://amiibo.ryujinx.org/"); + + if (response.IsSuccessStatusCode) + { + string amiiboJsonString = await response.Content.ReadAsStringAsync(); + + using (FileStream dlcJsonStream = File.Create(_amiiboJsonPath, 4096, FileOptions.WriteThrough)) + { + dlcJsonStream.Write(Encoding.UTF8.GetBytes(amiiboJsonString)); + } + + return amiiboJsonString; + } + + await ContentDialogHelper.CreateInfoDialog(_owner, LocaleManager.Instance["DialogAmiiboApiTitle"], + LocaleManager.Instance["DialogAmiiboApiFailFetchMessage"], + LocaleManager.Instance["InputDialogOk"], + "", + LocaleManager.Instance["RyujinxInfo"]); + + Close(); + + return DefaultJson; + } + + private void Close() + { + Dispatcher.UIThread.Post(_owner.Close); + } + + private async Task UpdateAmiiboPreview(string imageUrl) + { + HttpResponseMessage response = await _httpClient.GetAsync(imageUrl); + + if (response.IsSuccessStatusCode) + { + byte[] amiiboPreviewBytes = await response.Content.ReadAsByteArrayAsync(); + using (MemoryStream memoryStream = new(amiiboPreviewBytes)) + { + Bitmap bitmap = new(memoryStream); + + double ratio = Math.Min(AmiiboImageSize / bitmap.Size.Width, + AmiiboImageSize / bitmap.Size.Height); + + int resizeHeight = (int)(bitmap.Size.Height * ratio); + int resizeWidth = (int)(bitmap.Size.Width * ratio); + + AmiiboImage = bitmap.CreateScaledBitmap(new PixelSize(resizeWidth, resizeHeight)); + } + } + } + + private void ResetAmiiboPreview() + { + using (MemoryStream memoryStream = new(_amiiboLogoBytes)) + { + Bitmap bitmap = new(memoryStream); + + AmiiboImage = bitmap; + } + } + + private async void ShowInfoDialog() + { + await ContentDialogHelper.CreateInfoDialog(_owner, LocaleManager.Instance["DialogAmiiboApiTitle"], + LocaleManager.Instance["DialogAmiiboApiConnectErrorMessage"], + LocaleManager.Instance["InputDialogOk"], + "", + LocaleManager.Instance["RyujinxInfo"]); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/ViewModels/AvatarProfileViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/AvatarProfileViewModel.cs new file mode 100644 index 00000000..c2983741 --- /dev/null +++ b/Ryujinx.Ava/Ui/ViewModels/AvatarProfileViewModel.cs @@ -0,0 +1,363 @@ +using Avalonia.Media; +using DynamicData; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Ncm; +using LibHac.Tools.Fs; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.HLE.FileSystem; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Color = Avalonia.Media.Color; + +namespace Ryujinx.Ava.Ui.ViewModels +{ + internal class AvatarProfileViewModel : BaseModel, IDisposable + { + private const int MaxImageTasks = 4; + + private static readonly Dictionary _avatarStore = new(); + private static bool _isPreloading; + private static Action _loadCompleteAction; + + private ObservableCollection _images; + private Color _backgroundColor = Colors.White; + + private int _selectedIndex; + private int _imagesLoaded; + private bool _isActive; + private byte[] _selectedImage; + private bool _isIndeterminate = true; + + public bool IsActive + { + get => _isActive; + set => _isActive = value; + } + + public AvatarProfileViewModel() + { + _images = new ObservableCollection(); + } + + public AvatarProfileViewModel(Action loadCompleteAction) + { + _images = new ObservableCollection(); + + if (_isPreloading) + { + _loadCompleteAction = loadCompleteAction; + } + else + { + ReloadImages(); + } + } + + public Color BackgroundColor + { + get => _backgroundColor; + set + { + _backgroundColor = value; + + IsActive = false; + + ReloadImages(); + } + } + + public ObservableCollection Images + { + get => _images; + set + { + _images = value; + OnPropertyChanged(); + } + } + + public bool IsIndeterminate + { + get => _isIndeterminate; + set + { + _isIndeterminate = value; + + OnPropertyChanged(); + } + } + + public int ImageCount => _avatarStore.Count; + + public int ImagesLoaded + { + get => _imagesLoaded; + set + { + _imagesLoaded = value; + OnPropertyChanged(); + } + } + + public int SelectedIndex + { + get => _selectedIndex; + set + { + _selectedIndex = value; + + if (_selectedIndex == -1) + { + SelectedImage = null; + } + else + { + SelectedImage = _images[_selectedIndex].Data; + } + + OnPropertyChanged(); + } + } + + public byte[] SelectedImage + { + get => _selectedImage; + private set => _selectedImage = value; + } + + public void ReloadImages() + { + if (_isPreloading) + { + IsIndeterminate = false; + return; + } + Task.Run(() => + { + IsActive = true; + + Images.Clear(); + int selectedIndex = _selectedIndex; + int index = 0; + + ImagesLoaded = 0; + IsIndeterminate = false; + + var keys = _avatarStore.Keys.ToList(); + + var newImages = new List(); + var tasks = new List(); + + for (int i = 0; i < MaxImageTasks; i++) + { + var start = i; + tasks.Add(Task.Run(() => ImageTask(start))); + } + + Task.WaitAll(tasks.ToArray()); + + Images.AddRange(newImages); + + void ImageTask(int start) + { + for (int i = start; i < keys.Count; i += MaxImageTasks) + { + if (!IsActive) + { + return; + } + + var key = keys[i]; + var image = _avatarStore[keys[i]]; + + var data = ProcessImage(image); + newImages.Add(new ProfileImageModel(key, data)); + if (index++ == selectedIndex) + { + SelectedImage = data; + } + + Interlocked.Increment(ref _imagesLoaded); + OnPropertyChanged(nameof(ImagesLoaded)); + } + } + }); + } + + private byte[] ProcessImage(byte[] data) + { + using (MemoryStream streamJpg = new()) + { + Image avatarImage = Image.Load(data, new PngDecoder()); + + avatarImage.Mutate(x => x.BackgroundColor(new Rgba32(BackgroundColor.R, + BackgroundColor.G, + BackgroundColor.B, + BackgroundColor.A))); + avatarImage.SaveAsJpeg(streamJpg); + + return streamJpg.ToArray(); + } + } + + public static void PreloadAvatars(ContentManager contentManager, VirtualFileSystem virtualFileSystem) + { + try + { + if (_avatarStore.Count > 0) + { + return; + } + + _isPreloading = true; + + string contentPath = + contentManager.GetInstalledContentPath(0x010000000000080A, StorageId.BuiltInSystem, + NcaContentType.Data); + string avatarPath = virtualFileSystem.SwitchPathToSystemPath(contentPath); + + if (!string.IsNullOrWhiteSpace(avatarPath)) + { + using (IStorage ncaFileStream = new LocalStorage(avatarPath, FileAccess.Read, FileMode.Open)) + { + Nca nca = new(virtualFileSystem.KeySet, ncaFileStream); + IFileSystem romfs = nca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid); + + foreach (DirectoryEntryEx item in romfs.EnumerateEntries()) + { + // TODO: Parse DatabaseInfo.bin and table.bin files for more accuracy. + if (item.Type == DirectoryEntryType.File && item.FullPath.Contains("chara") && + item.FullPath.Contains("szs")) + { + using var file = new UniqueRef(); + + romfs.OpenFile(ref file.Ref(), ("/" + item.FullPath).ToU8Span(), OpenMode.Read) + .ThrowIfFailure(); + + using (MemoryStream stream = new()) + using (MemoryStream streamPng = new()) + { + file.Get.AsStream().CopyTo(stream); + + stream.Position = 0; + + Image avatarImage = Image.LoadPixelData(DecompressYaz0(stream), 256, 256); + + avatarImage.SaveAsPng(streamPng); + + _avatarStore.Add(item.FullPath, streamPng.ToArray()); + } + } + } + } + } + } + finally + { + _isPreloading = false; + _loadCompleteAction?.Invoke(); + } + } + + private static byte[] DecompressYaz0(Stream stream) + { + using (BinaryReader reader = new(stream)) + { + reader.ReadInt32(); // Magic + + uint decodedLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt32()); + + reader.ReadInt64(); // Padding + + byte[] input = new byte[stream.Length - stream.Position]; + stream.Read(input, 0, input.Length); + + uint inputOffset = 0; + + byte[] output = new byte[decodedLength]; + uint outputOffset = 0; + + ushort mask = 0; + byte header = 0; + + while (outputOffset < decodedLength) + { + if ((mask >>= 1) == 0) + { + header = input[inputOffset++]; + mask = 0x80; + } + + if ((header & mask) != 0) + { + if (outputOffset == output.Length) + { + break; + } + + output[outputOffset++] = input[inputOffset++]; + } + else + { + byte byte1 = input[inputOffset++]; + byte byte2 = input[inputOffset++]; + + uint dist = (uint)((byte1 & 0xF) << 8) | byte2; + uint position = outputOffset - (dist + 1); + + uint length = (uint)byte1 >> 4; + if (length == 0) + { + length = (uint)input[inputOffset++] + 0x12; + } + else + { + length += 2; + } + + uint gap = outputOffset - position; + uint nonOverlappingLength = length; + + if (nonOverlappingLength > gap) + { + nonOverlappingLength = gap; + } + + Buffer.BlockCopy(output, (int)position, output, (int)outputOffset, (int)nonOverlappingLength); + outputOffset += nonOverlappingLength; + position += nonOverlappingLength; + length -= nonOverlappingLength; + + while (length-- > 0) + { + output[outputOffset++] = output[position++]; + } + } + } + + return output; + } + } + + public void Dispose() + { + _loadCompleteAction = null; + IsActive = false; + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs index dbc19f75..bc8e6450 100644 --- a/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs +++ b/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs @@ -67,6 +67,8 @@ namespace Ryujinx.Ava.Ui.ViewModels private bool _isPaused; private bool _showContent = true; private bool _isLoadingIndeterminate = true; + private bool _showAll; + private string _lastScannedAmiiboId; private ReadOnlyObservableCollection _appsObservableList; public string TitleName { get; internal set; } @@ -695,15 +697,28 @@ namespace Ryujinx.Ava.Ui.ViewModels } } - public void OpenAmiiboWindow() + public async void OpenAmiiboWindow() { if (!_isAmiiboRequested) { return; } + + if (_owner.AppHost.Device.System.SearchingForAmiibo(out int deviceId)) + { + string titleId = _owner.AppHost.Device.Application.TitleIdText.ToUpper(); + AmiiboWindow window = new(_showAll, _lastScannedAmiiboId, titleId); - // TODO : Implement Amiibo window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + await window.ShowDialog(_owner); + + if (window.IsScanned) + { + _showAll = window.ViewModel.ShowAllAmiibo; + _lastScannedAmiiboId = window.ScannedAmiibo.GetId(); + + _owner.AppHost.Device.System.ScanAmiibo(deviceId, _lastScannedAmiiboId, window.ViewModel.UseRandomUuid); + } + } } public void HandleShaderProgress(Switch emulationContext) @@ -953,10 +968,11 @@ namespace Ryujinx.Ava.Ui.ViewModels LoadConfigurableHotKeys(); } - public void ManageProfiles() + public async void ManageProfiles() { - // TODO : Implement Profiles window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + UserProfileWindow window = new(_owner.AccountManager, _owner.ContentManager, _owner.VirtualFileSystem); + + await window.ShowDialog(_owner); } public async void OpenAboutWindow() @@ -1227,33 +1243,60 @@ namespace Ryujinx.Ava.Ui.ViewModels } } - public void OpenTitleUpdateManager() + public async void OpenTitleUpdateManager() { - // TODO : Implement Update window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var selection = SelectedApplication; + + if (selection != null) + { + TitleUpdateWindow titleUpdateManager = + new(_owner.VirtualFileSystem, selection.TitleId, selection.TitleName); + + await titleUpdateManager.ShowDialog(_owner); + } } - public void OpenDlcManager() + public async void OpenDlcManager() { - // TODO : Implement Dlc window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var selection = SelectedApplication; + + if (selection != null) + { + DlcManagerWindow dlcManager = new(_owner.VirtualFileSystem, ulong.Parse(selection.TitleId, NumberStyles.HexNumber), selection.TitleName); + + await dlcManager.ShowDialog(_owner); + } } - public void OpenCheatManager() + public async void OpenCheatManager() { - // TODO : Implement cheat window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var selection = SelectedApplication; + + if (selection != null) + { + CheatWindow cheatManager = new(_owner.VirtualFileSystem, selection.TitleId, selection.TitleName); + + await cheatManager.ShowDialog(_owner); + } } - public void OpenCheatManagerForCurrentApp() + public async void OpenCheatManagerForCurrentApp() { if (!IsGameRunning) { return; } - // TODO : Implement cheat window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var application = _owner.AppHost.Device.Application; + + if (application != null) + { + CheatWindow cheatManager = new(_owner.VirtualFileSystem, application.TitleIdText, application.TitleName); + + await cheatManager.ShowDialog(_owner); + + _owner.AppHost.Device.EnableCheats(); + } } public void OpenDeviceSaveDirectory() diff --git a/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs new file mode 100644 index 00000000..d75f65b1 --- /dev/null +++ b/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs @@ -0,0 +1,166 @@ +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Windows; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile; + +namespace Ryujinx.Ava.Ui.ViewModels +{ + public class UserProfileViewModel : BaseModel, IDisposable + { + private const uint MaxProfileNameLength = 0x20; + + private readonly UserProfileWindow _owner; + + private UserProfile _selectedProfile; + private string _tempUserName; + + public UserProfileViewModel() + { + Profiles = new ObservableCollection(); + } + + public UserProfileViewModel(UserProfileWindow owner) : this() + { + _owner = owner; + + LoadProfiles(); + } + + public ObservableCollection Profiles { get; set; } + + public UserProfile SelectedProfile + { + get => _selectedProfile; + set + { + _selectedProfile = value; + + OnPropertyChanged(nameof(SelectedProfile)); + OnPropertyChanged(nameof(IsSelectedProfileDeletable)); + } + } + + public bool IsSelectedProfileDeletable => + _selectedProfile != null && _selectedProfile.UserId != AccountManager.DefaultUserId; + + public void Dispose() + { + } + + public void LoadProfiles() + { + Profiles.Clear(); + + var profiles = _owner.AccountManager.GetAllUsers() + .OrderByDescending(x => x.AccountState == AccountState.Open); + + foreach (var profile in profiles) + { + Profiles.Add(new UserProfile(profile)); + } + + SelectedProfile = Profiles.FirstOrDefault(x => x.UserId == _owner.AccountManager.LastOpenedUser.UserId); + + if (SelectedProfile == null) + { + SelectedProfile = Profiles.First(); + + if (SelectedProfile != null) + { + _owner.AccountManager.OpenUser(_selectedProfile.UserId); + } + } + } + + public async void ChooseProfileImage() + { + await SelectProfileImage(); + } + + public async Task SelectProfileImage(bool isNewUser = false) + { + ProfileImageSelectionDialog selectionDialog = new(_owner.ContentManager); + + await selectionDialog.ShowDialog(_owner); + + if (selectionDialog.BufferImageProfile != null) + { + if (isNewUser) + { + if (!string.IsNullOrWhiteSpace(_tempUserName)) + { + _owner.AccountManager.AddUser(_tempUserName, selectionDialog.BufferImageProfile); + } + } + else if (SelectedProfile != null) + { + _owner.AccountManager.SetUserImage(SelectedProfile.UserId, selectionDialog.BufferImageProfile); + SelectedProfile.Image = selectionDialog.BufferImageProfile; + + SelectedProfile = null; + } + + LoadProfiles(); + } + } + + public async void AddUser() + { + var dlgTitle = LocaleManager.Instance["InputDialogAddNewProfileTitle"]; + var dlgMainText = LocaleManager.Instance["InputDialogAddNewProfileHeader"]; + var dlgSubText = string.Format(LocaleManager.Instance["InputDialogAddNewProfileSubtext"], + MaxProfileNameLength); + + _tempUserName = + await ContentDialogHelper.CreateInputDialog(dlgTitle, dlgMainText, dlgSubText, _owner, + MaxProfileNameLength); + + if (!string.IsNullOrWhiteSpace(_tempUserName)) + { + await SelectProfileImage(true); + } + + _tempUserName = String.Empty; + } + + public async void DeleteUser() + { + if (_selectedProfile != null) + { + var lastUserId = _owner.AccountManager.LastOpenedUser.UserId; + + if (_selectedProfile.UserId == lastUserId) + { + // If we are deleting the currently open profile, then we must open something else before deleting. + var profile = Profiles.FirstOrDefault(x => x.UserId != lastUserId); + + if (profile == null) + { + ContentDialogHelper.CreateErrorDialog(_owner, + LocaleManager.Instance["DialogUserProfileDeletionWarningMessage"]); + return; + } + + _owner.AccountManager.OpenUser(profile.UserId); + } + + var result = + await ContentDialogHelper.CreateConfirmationDialog(_owner, + LocaleManager.Instance["DialogUserProfileDeletionConfirmMessage"], "", + LocaleManager.Instance["InputDialogYes"], LocaleManager.Instance["InputDialogNo"], ""); + + if (result == UserResult.Yes) + { + _owner.AccountManager.DeleteUser(_selectedProfile.UserId); + } + } + + LoadProfiles(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml b/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml new file mode 100644 index 00000000..f91bb313 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs new file mode 100644 index 00000000..33abeb8d --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs @@ -0,0 +1,137 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class CheatWindow : StyleableWindow + { + private readonly string _enabledCheatsPath; + public bool NoCheatsFound { get; } + + private AvaloniaList LoadedCheats { get; } + + public string Heading { get; } + + public CheatWindow() + { + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["CheatWindowTitle"]; + } + + public CheatWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName) + { + LoadedCheats = new AvaloniaList(); + + Heading = string.Format(LocaleManager.Instance["CheatWindowHeading"], titleName, titleId.ToUpper()); + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + + string modsBasePath = virtualFileSystem.ModLoader.GetModsBasePath(); + string titleModsPath = virtualFileSystem.ModLoader.GetTitleDir(modsBasePath, titleId); + ulong titleIdValue = ulong.Parse(titleId, System.Globalization.NumberStyles.HexNumber); + + _enabledCheatsPath = Path.Combine(titleModsPath, "cheats", "enabled.txt"); + + string[] enabled = { }; + + if (File.Exists(_enabledCheatsPath)) + { + enabled = File.ReadAllLines(_enabledCheatsPath); + } + + int cheatAdded = 0; + + var mods = new ModLoader.ModCache(); + + ModLoader.QueryContentsDir(mods, new DirectoryInfo(Path.Combine(modsBasePath, "contents")), titleIdValue); + + string currentCheatFile = string.Empty; + string buildId = string.Empty; + string parentPath = string.Empty; + + CheatsList currentGroup = null; + + foreach (var cheat in mods.Cheats) + { + if (cheat.Path.FullName != currentCheatFile) + { + currentCheatFile = cheat.Path.FullName; + parentPath = currentCheatFile.Replace(titleModsPath, ""); + + buildId = Path.GetFileNameWithoutExtension(currentCheatFile).ToUpper(); + currentGroup = new CheatsList(buildId, parentPath); + + LoadedCheats.Add(currentGroup); + } + + var model = new CheatModel(cheat.Name, buildId, enabled.Contains($"{buildId}-{cheat.Name}")); + currentGroup?.Add(model); + + cheatAdded++; + } + + if (cheatAdded == 0) + { + NoCheatsFound = true; + } + + DataContext = this; + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["CheatWindowTitle"]; + } + + [Conditional("DEBUG")] + private void AttachDebugDevTools() + { + this.AttachDevTools(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + public void Save() + { + if (NoCheatsFound) + { + return; + } + + List enabledCheats = new List(); + + foreach (var cheats in LoadedCheats) + { + foreach (var cheat in cheats) + { + if (cheat.IsEnabled) + { + enabledCheats.Add(cheat.BuildIdKey); + } + } + } + + Directory.CreateDirectory(Path.GetDirectoryName(_enabledCheatsPath)); + + File.WriteAllLines(_enabledCheatsPath, enabledCheats); + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml new file mode 100644 index 00000000..94b3895e --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs new file mode 100644 index 00000000..cb2ed324 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs @@ -0,0 +1,261 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Tools.Fs; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using Path = System.IO.Path; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class DlcManagerWindow : StyleableWindow + { + private readonly List _dlcContainerList; + private readonly string _dlcJsonPath; + + public VirtualFileSystem VirtualFileSystem { get; } + + public AvaloniaList Dlcs { get; set; } + public Grid DlcGrid { get; private set; } + public ulong TitleId { get; } + public string TitleName { get; } + + public string Heading => string.Format(LocaleManager.Instance["DlcWindowHeading"], TitleName, TitleId.ToString("X16")); + + public DlcManagerWindow() + { + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["DlcWindowTitle"]; + } + + public DlcManagerWindow(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName) + { + VirtualFileSystem = virtualFileSystem; + TitleId = titleId; + TitleName = titleName; + + _dlcJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "dlc.json"); + + try + { + _dlcContainerList = JsonHelper.DeserializeFromFile>(_dlcJsonPath); + } + catch + { + _dlcContainerList = new List(); + } + + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["DlcWindowTitle"]; + + LoadDlcs(); + } + + [Conditional("DEBUG")] + private void AttachDebugDevTools() + { + this.AttachDevTools(); + } + + private void InitializeComponent() + { + Dlcs = new AvaloniaList(); + + AvaloniaXamlLoader.Load(this); + + DlcGrid = this.FindControl("DlcGrid"); + } + + private void LoadDlcs() + { + foreach (DlcContainer dlcContainer in _dlcContainerList) + { + using FileStream containerFile = File.OpenRead(dlcContainer.Path); + + PartitionFileSystem pfs = new PartitionFileSystem(containerFile.AsStorage()); + + VirtualFileSystem.ImportTickets(pfs); + + foreach (DlcNca dlcNca in dlcContainer.DlcNcaList) + { + using var ncaFile = new UniqueRef(); + pfs.OpenFile(ref ncaFile.Ref(), dlcNca.Path.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), dlcContainer.Path); + + if (nca != null) + { + Dlcs.Add(new DlcModel(nca.Header.TitleId.ToString("X16"), dlcContainer.Path, dlcNca.Path, + dlcNca.Enabled)); + } + } + } + } + + private Nca TryCreateNca(IStorage ncaStorage, string containerPath) + { + try + { + return new Nca(VirtualFileSystem.KeySet, ncaStorage); + } + catch (Exception ex) + { + ContentDialogHelper.CreateErrorDialog(this, + string.Format(LocaleManager.Instance[ + "DialogDlcLoadNcaErrorMessage"], ex.Message, containerPath)); + } + + return null; + } + + private void AddDlc(string path) + { + if (!File.Exists(path) || Dlcs.FirstOrDefault(x => x.ContainerPath == path) != null) + { + return; + } + + using (FileStream containerFile = File.OpenRead(path)) + { + PartitionFileSystem pfs = new PartitionFileSystem(containerFile.AsStorage()); + bool containsDlc = false; + + VirtualFileSystem.ImportTickets(pfs); + + foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) + { + using var ncaFile = new UniqueRef(); + + pfs.OpenFile(ref ncaFile.Ref(), fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), path); + + if (nca == null) + { + continue; + } + + if (nca.Header.ContentType == NcaContentType.PublicData) + { + if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000) != TitleId) + { + break; + } + + Dlcs.Add(new DlcModel(nca.Header.TitleId.ToString("X16"), path, fileEntry.FullPath, true)); + + containsDlc = true; + } + } + + if (!containsDlc) + { + ContentDialogHelper.CreateErrorDialog(this, LocaleManager.Instance["DialogDlcNoDlcErrorMessage"]); + } + } + } + + private void RemoveDlcs(bool removeSelectedOnly = false) + { + if (removeSelectedOnly) + { + Dlcs.RemoveAll(Dlcs.Where(x => x.IsEnabled).ToList()); + } + else + { + Dlcs.Clear(); + } + } + + public void RemoveSelected() + { + RemoveDlcs(true); + } + + public void RemoveAll() + { + RemoveDlcs(); + } + + public async void Add() + { + OpenFileDialog dialog = new OpenFileDialog() { Title = LocaleManager.Instance["SelectDlcDialogTitle"], AllowMultiple = true }; + + dialog.Filters.Add(new FileDialogFilter { Name = "NSP", Extensions = { "nsp" } }); + + string[] files = await dialog.ShowAsync(this); + + if (files != null) + { + foreach (string file in files) + { + AddDlc(file); + } + } + } + + public void Save() + { + _dlcContainerList.Clear(); + + DlcContainer container = default; + + foreach (DlcModel dlc in Dlcs) + { + if (container.Path != dlc.ContainerPath) + { + if (!string.IsNullOrWhiteSpace(container.Path)) + { + _dlcContainerList.Add(container); + } + + container = new DlcContainer { Path = dlc.ContainerPath, DlcNcaList = new List() }; + } + + container.DlcNcaList.Add(new DlcNca + { + Enabled = dlc.IsEnabled, + TitleId = Convert.ToUInt64(dlc.TitleId, 16), + Path = dlc.FullPath + }); + } + + if (!string.IsNullOrWhiteSpace(container.Path)) + { + _dlcContainerList.Add(container); + } + + using (FileStream dlcJsonStream = File.Create(_dlcJsonPath, 4096, FileOptions.WriteThrough)) + { + dlcJsonStream.Write(Encoding.UTF8.GetBytes(JsonHelper.Serialize(_dlcContainerList, true))); + } + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml new file mode 100644 index 00000000..347c2cf5 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs new file mode 100644 index 00000000..edc1abcd --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs @@ -0,0 +1,265 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using LibHac.Ns; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using Path = System.IO.Path; +using SpanHelpers = LibHac.Common.SpanHelpers; +using LibHac.Tools.FsSystem; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class TitleUpdateWindow : StyleableWindow + { + private readonly string _updateJsonPath; + private TitleUpdateMetadata _titleUpdateWindowData; + + public VirtualFileSystem VirtualFileSystem { get; } + + internal AvaloniaList TitleUpdates { get; set; } + public string TitleId { get; } + public string TitleName { get; } + + public string Heading => string.Format(LocaleManager.Instance["GameUpdateWindowHeading"], TitleName, TitleId.ToUpper()); + + public TitleUpdateWindow() + { + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["UpdateWindowTitle"]; + } + + public TitleUpdateWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName) + { + VirtualFileSystem = virtualFileSystem; + TitleId = titleId; + TitleName = titleName; + + _updateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId, "updates.json"); + + try + { + _titleUpdateWindowData = JsonHelper.DeserializeFromFile(_updateJsonPath); + } + catch + { + _titleUpdateWindowData = new TitleUpdateMetadata {Selected = "", Paths = new List()}; + } + + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["UpdateWindowTitle"]; + + LoadUpdates(); + } + + [Conditional("DEBUG")] + private void AttachDebugDevTools() + { + this.AttachDevTools(); + } + + private void InitializeComponent() + { + TitleUpdates = new AvaloniaList(); + + AvaloniaXamlLoader.Load(this); + } + + private void LoadUpdates() + { + TitleUpdates.Add(new TitleUpdateModel(default, string.Empty, true)); + + foreach (string path in _titleUpdateWindowData.Paths) + { + 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)) + { + PartitionFileSystem nsp = new PartitionFileSystem(file.AsStorage()); + + try + { + (Nca patchNca, Nca controlNca) = + ApplicationLoader.GetGameUpdateDataFromPartition(VirtualFileSystem, nsp, TitleId, 0); + + if (controlNca != null && patchNca != null) + { + ApplicationControlProperty controlData = new ApplicationControlProperty(); + + using var nacpFile = new UniqueRef(); + + 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 + { + ContentDialogHelper.CreateErrorDialog(this, + LocaleManager.Instance["DialogUpdateAddUpdateErrorMessage"]); + } + } + catch (Exception ex) + { + ContentDialogHelper.CreateErrorDialog(this, + string.Format(LocaleManager.Instance["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(); + } + + public void RemoveSelected() + { + RemoveUpdates(true); + } + + public void RemoveAll() + { + RemoveUpdates(); + } + + public async void Add() + { + OpenFileDialog dialog = new OpenFileDialog() { Title = LocaleManager.Instance["SelectUpdateDialogTitle"], AllowMultiple = true }; + + dialog.Filters.Add(new FileDialogFilter { Name = "NSP", Extensions = { "nsp" } }); + + string[] files = await dialog.ShowAsync(this); + + if (files != null) + { + foreach (string file in files) + { + AddUpdate(file); + } + } + + SortUpdates(); + } + + private 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; + }); + + TitleUpdates.Clear(); + + TitleUpdates.AddRange(list); + } + + public void Save() + { + _titleUpdateWindowData.Paths.Clear(); + + _titleUpdateWindowData.Selected = ""; + + foreach (TitleUpdateModel update in TitleUpdates) + { + _titleUpdateWindowData.Paths.Add(update.Path); + + if (update.IsEnabled) + { + _titleUpdateWindowData.Selected = update.Path; + } + } + + using (FileStream dlcJsonStream = File.Create(_updateJsonPath, 4096, FileOptions.WriteThrough)) + { + dlcJsonStream.Write(Encoding.UTF8.GetBytes(JsonHelper.Serialize(_titleUpdateWindowData, true))); + } + + if (Owner is MainWindow window) + { + window.ViewModel.LoadApplications(); + } + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml b/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml new file mode 100644 index 00000000..4b004206 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +