diff --git a/Directory.Packages.props b/Directory.Packages.props index 301024cf8a..d06b2d6cf8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index b3cab7f5f6..e82d379a0f 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -775,8 +775,10 @@ "NetworkInterfaceTooltip": "The network interface used for LAN/LDN features.\n\nIn conjunction with a VPN or XLink Kai and a game with LAN support, can be used to spoof a same-network connection over the Internet.\n\nLeave on DEFAULT if unsure.", "NetworkInterfaceDefault": "Default", "PackagingShaders": "Packaging Shaders", - "AboutChangelogButton": "View Changelog on GitHub", - "AboutChangelogButtonTooltipMessage": "Click to open the changelog for this version in your default browser.", + "AboutChangelogButton": "View Changelog", + "AboutChangelogButtonTooltipMessage": "Click to view the changelog.", + "ChangelogWindowTitle": "Changelog", + "ChangelogDescription": "Showing the 10 most recent versions. For more changelog version history please visit Ryujinx GitHub page.", "SettingsTabNetworkMultiplayer": "Multiplayer", "MultiplayerMode": "Mode:", "MultiplayerModeTooltip": "Change LDN multiplayer mode.\n\nLdnMitm will modify local wireless/local play functionality in games to function as if it were LAN, allowing for local, same-network connections with other Ryujinx instances and hacked Nintendo Switch consoles that have the ldn_mitm module installed.\n\nMultiplayer requires all players to be on the same game version (i.e. Super Smash Bros. Ultimate v13.0.1 can't connect to v13.0.0).\n\nLeave DISABLED if unsure.", diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj index 6718b7fcc4..989b281a89 100644 --- a/src/Ryujinx/Ryujinx.csproj +++ b/src/Ryujinx/Ryujinx.csproj @@ -44,6 +44,7 @@ + diff --git a/src/Ryujinx/UI/Windows/AboutWindow.axaml b/src/Ryujinx/UI/Windows/AboutWindow.axaml index 69fa82517a..cadd875bcf 100644 --- a/src/Ryujinx/UI/Windows/AboutWindow.axaml +++ b/src/Ryujinx/UI/Windows/AboutWindow.axaml @@ -87,8 +87,7 @@ Padding="5" HorizontalAlignment="Center" Background="Transparent" - Click="Button_OnClick" - Tag="https://github.com/Ryujinx/Ryujinx/wiki/Changelog#ryujinx-changelog"> + Click="OpenChangelogWindow"> diff --git a/src/Ryujinx/UI/Windows/AboutWindow.axaml.cs b/src/Ryujinx/UI/Windows/AboutWindow.axaml.cs index c32661b0ce..9663eec919 100644 --- a/src/Ryujinx/UI/Windows/AboutWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/AboutWindow.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Styling; +using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Helpers; @@ -52,6 +53,18 @@ namespace Ryujinx.Ava.UI.Windows } } + private void OpenChangelogWindow(object sender, RoutedEventArgs e) + { + ChangelogWindow changelogWindow = new ChangelogWindow(); + + // Find the parent window to use as the owner for the modal dialog + var parentWindow = this.FindAncestorOfType(); + if (parentWindow != null) + { + changelogWindow.ShowDialog(parentWindow); // Pass the parent window as the owner + } + } + private void AmiiboLabel_OnPointerPressed(object sender, PointerPressedEventArgs e) { if (sender is TextBlock) diff --git a/src/Ryujinx/UI/Windows/ChangelogWindow.axaml b/src/Ryujinx/UI/Windows/ChangelogWindow.axaml new file mode 100644 index 0000000000..f8f6821e8c --- /dev/null +++ b/src/Ryujinx/UI/Windows/ChangelogWindow.axaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Windows/ChangelogWindow.axaml.cs b/src/Ryujinx/UI/Windows/ChangelogWindow.axaml.cs new file mode 100644 index 0000000000..1992e59492 --- /dev/null +++ b/src/Ryujinx/UI/Windows/ChangelogWindow.axaml.cs @@ -0,0 +1,153 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; +using HtmlAgilityPack; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.UI.Common.Helper; +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Button = Avalonia.Controls.Button; + + +namespace Ryujinx.Ava.UI.Windows +{ + public partial class ChangelogWindow : StyleableWindow + { + public ChangelogWindow() + { + DataContext = this; + InitializeComponent(); + InitializeAsync(); + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance[LocaleKeys.ChangelogWindowTitle]; + } + + private async void InitializeAsync() + { + try + { + LoadingTextBlock.IsVisible = true; // Show the loading text + ChangelogTextBlock.IsVisible = false; // Hide the changelog text initially + + string changelogHtml = await FetchChangelogHtml(); + string changelog = ParseChangelogForRecentVersions(changelogHtml, 10); + LoadChangelog(changelog); + + LoadingTextBlock.IsVisible = false; // Hide the loading text + ChangelogTextBlock.IsVisible = true; // Show the changelog text + } + catch (Exception ex) + { + LoadingTextBlock.Text = "Failed to load changelog: " + ex.Message; + } + } + + private void LoadChangelog(string changelog) + { + ChangelogTextBlock.Text = changelog; + } + + private static async Task FetchChangelogHtml() + { + using var client = new HttpClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Ryujinx-Updater/1.0.0"); + return await client.GetStringAsync("https://github.com/Ryujinx/Ryujinx/wiki/Changelog"); + } + + private static string ParseChangelogForRecentVersions(string html, int count) + { + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var headers = doc.DocumentNode.SelectNodes("//div[contains(@class, 'markdown-heading')]//h2"); + if (headers != null) + { + var content = new StringBuilder(); + int versionsFound = 0; + + foreach (var header in headers) + { + if (versionsFound >= count) + break; // Stop after finding the desired number of versions + + content.Append(header.OuterHtml); + var currentNode = header.ParentNode.NextSibling; + + while (currentNode != null && versionsFound < count) + { + if (currentNode.Name == "div" && currentNode.SelectSingleNode("h2") != null) + { + versionsFound++; // Increment for each version header found + if (versionsFound >= count) + break; + } + content.Append(currentNode.OuterHtml); + currentNode = currentNode.NextSibling; + } + } + + return ConvertHtmlToPlainText(content.ToString()); + } + return "No changelog found."; + } + + private static string ConvertHtmlToPlainText(string html) + { + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // Recursively format nodes + string formattedText = FormatNode(doc.DocumentNode); + + return HtmlEntity.DeEntitize(formattedText); + } + + private static string FormatNode(HtmlNode node, int depth = 0) + { + StringBuilder sb = new StringBuilder(); + foreach (var child in node.ChildNodes) + { + switch (child.Name) + { + case "ul": + // Recursively format the list items + sb.Append(FormatNode(child, depth)); + sb.AppendLine(); + break; + + case "li": + // Format list item based on depth: "-" for top-level, "+" for nested items + string prefix = (depth == 0 ? "- " : new string(' ', depth * 4) + "+ "); + sb.AppendLine($"{prefix}{FormatNode(child, depth + 1).Trim()}"); + break; + + case "p": + case "#text": // Handling direct text nodes + if (!string.IsNullOrWhiteSpace(child.InnerText)) + { + // Trim the text + string text = HtmlEntity.DeEntitize(child.InnerText).Trim(); + sb.Append($"{text}\n"); + } + break; + + default: + // Recursively process other types of nodes + if (child.HasChildNodes) + { + sb.Append(FormatNode(child, depth)); // Keep current depth for other types + } + break; + } + } + return sb.ToString(); + } + } +}