From 2872164e074e1087d3d5a2c5fd828318648c3ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:25:15 +0000 Subject: [PATCH] Show LinuxFiles release notes on the Release notes page On Linux the page now fetches the GitHub release for the running version (linux-v) and renders its Markdown with the existing Markdig preview renderer. Links open in the default browser; images show only alt text and HTML stays literal. When the fetch fails the page links to the GitHub release page instead of the upstream Files blog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBkiuJoaDgZGawU94wvpCJ --- CHANGELOG.md | 2 + docs/linux-port/wayland-previews.md | 6 +- src/Files.App/Constants.cs | 8 +- .../Helpers/Application/ReleaseNotesHelper.cs | 95 +++++++++++++++++++ .../Services/App/AppUpdateNoneService.cs | 8 +- .../FilePreviews/DesktopTextPreview.xaml.cs | 24 +---- .../FilePreviews/PreviewTextRenderer.cs | 86 ++++++++++++++--- .../ViewModels/ReleaseNotesViewModel.cs | 15 +++ src/Files.App/Views/ReleaseNotesPage.xaml | 26 ++++- src/Files.App/Views/ReleaseNotesPage.xaml.cs | 93 ++++++++++++++++-- 10 files changed, 310 insertions(+), 53 deletions(-) create mode 100644 src/Files.App/Helpers/Application/ReleaseNotesHelper.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0d41645840..ec58ca6d3763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ LinuxFiles is an independent Linux-only fork of [Files](https://github.com/files ## Unreleased +- The Release notes page shows the LinuxFiles GitHub release notes for the running version, rendered from Markdown with clickable links. Offline or on errors it links to the release page instead. + ## 0.1.0-alpha8 - 2026-10-16 Eighth public alpha, focused on sharper text and real media/HTML previews on Wayland and X11. Expect rough edges and keep backups. Report issues at diff --git a/docs/linux-port/wayland-previews.md b/docs/linux-port/wayland-previews.md index b52096957911..468b22f1b890 100644 --- a/docs/linux-port/wayland-previews.md +++ b/docs/linux-port/wayland-previews.md @@ -1,6 +1,6 @@ # Media and HTML previews without native window embedding -Status: media playback and rendered HTML previews are implemented for the preview pane. The release notes page still opens the browser. This note records why, how, and what a fully interactive HTML view would cost. +Status: media playback and rendered HTML previews are implemented for the preview pane. The release notes page renders the GitHub release Markdown. This note records why, how, and what a fully interactive HTML view would cost. ## What the X11 host actually did @@ -37,9 +37,9 @@ So the Wayland work did not need to match an existing X11 implementation. The ne - Nix: neither library is in the flake's `runtimeLibs` yet, because WebKitGTK would add a large closure. Both previews fall back on NixOS until the flake adds them (perhaps behind an option). - Flatpak: not covered yet. -## Release notes (not changed) +## Release notes (implemented) -`ReleaseNotesPage` points at the upstream Files blog (`files.community/blog/posts/v…?minimal`), which describes the Windows app and needs network access and interactive links. That makes it a poor fit for a snapshot. On Wayland it keeps opening the page in the default browser. A better fix is to render the LinuxFiles GitHub release body (Markdown) with the existing Markdig preview renderer, which needs a fetch and caching design of its own. +`ReleaseNotesPage` fetches the GitHub release for the running version (`GET https://api.github.com/repos/MemerGamer/LinuxFiles/releases/tags/linux-v`, 10 s timeout, User-Agent, no authentication) and renders its `body` with the Markdig preview renderer (`PreviewTextRenderer`). Raw HTML stays literal text, images show only their alt text, and only absolute http(s) links are clickable; they open in the default browser through `ILauncherService`. A successful fetch is cached for the session. When the fetch fails (offline, 404, rate limit or an empty body), the page shows a link to the GitHub release page and opens it in the default browser, as Wayland did before. `WebView2` is no longer used on Linux, since it has no native backend there. ## Interactive HTML on Wayland: options and cost diff --git a/src/Files.App/Constants.cs b/src/Files.App/Constants.cs index 2b7dd68f1cae..f92b0c094e1b 100644 --- a/src/Files.App/Constants.cs +++ b/src/Files.App/Constants.cs @@ -187,7 +187,13 @@ public static class ExternalUrl public static bool IsSupportUsAvailable => !OperatingSystem.IsLinux() || ForkSupportUsUrl.Length > 0; public static string ActiveSupportUsUrl => OperatingSystem.IsLinux() ? ForkSupportUsUrl : SupportUsUrl; public const string CrowdinUrl = @"https://crowdin.com/project/files-app"; - public static readonly string ReleaseNotesUrl = $"https://files.community/blog/posts/v{Package.Current.Id.Version.Major}-{Package.Current.Id.Version.Minor}-{Package.Current.Id.Version.Build}?minimal"; + // Release tags look like linux-v0.1.0-alpha8, built from the informational version ( in Files.App.csproj). + public static readonly string LinuxReleaseTag = $"linux-v{ReleaseNotesHelper.AppVersionName}"; + public static readonly string ReleaseNotesUrl = OperatingSystem.IsLinux() + ? $"https://github.com/MemerGamer/LinuxFiles/releases/tag/{LinuxReleaseTag}" + : $"https://files.community/blog/posts/v{Package.Current.Id.Version.Major}-{Package.Current.Id.Version.Minor}-{Package.Current.Id.Version.Build}?minimal"; + + public static readonly string LinuxReleaseApiUrl = $"https://api.github.com/repos/MemerGamer/LinuxFiles/releases/tags/{LinuxReleaseTag}"; } public static class DocsPath diff --git a/src/Files.App/Helpers/Application/ReleaseNotesHelper.cs b/src/Files.App/Helpers/Application/ReleaseNotesHelper.cs new file mode 100644 index 000000000000..12014225f0b8 --- /dev/null +++ b/src/Files.App/Helpers/Application/ReleaseNotesHelper.cs @@ -0,0 +1,95 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.Net.Http; +using System.Reflection; + +namespace Files.App.Helpers +{ + /// + /// Fetches the LinuxFiles GitHub release notes (Markdown) for the running version. + /// + public static class ReleaseNotesHelper + { + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + + private const int MaxResponseBytes = 1024 * 1024; + + private static readonly SemaphoreSlim fetchLock = new(1, 1); + + private static string? cachedMarkdown; + + /// + /// Gets the running version as written in <Version>, for example 0.1.0-alpha8. + /// + public static string AppVersionName { get; } = GetAppVersionName(); + + private static string GetAppVersionName() + { + var assembly = typeof(ReleaseNotesHelper).Assembly; + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString(3) + ?? string.Empty; + + // Drop build metadata ("+") in case a build adds it. + var metadataStart = version.IndexOf('+'); + return metadataStart >= 0 ? version[..metadataStart] : version; + } + + /// + /// Gets the release body for the running version, or null when it is unavailable (offline, not found, rate limited or empty). + /// A successful result is cached for the session; failures are retried on the next call. + /// + public static async Task GetLinuxReleaseNotesAsync(CancellationToken cancellationToken = default) + { + if (cachedMarkdown is not null) + return cachedMarkdown; + + await fetchLock.WaitAsync(cancellationToken); + try + { + if (cachedMarkdown is not null) + return cachedMarkdown; + + using var client = new HttpClient() { Timeout = RequestTimeout, MaxResponseContentBufferSize = MaxResponseBytes }; + using var request = new HttpRequestMessage(HttpMethod.Get, Constants.ExternalUrl.LinuxReleaseApiUrl); + if (!request.Headers.UserAgent.TryParseAdd($"LinuxFiles/{AppVersionName}")) + request.Headers.UserAgent.ParseAdd("LinuxFiles"); + request.Headers.Accept.ParseAdd("application/vnd.github+json"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + + using var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + App.Logger.LogInformation("Release notes for {Tag} are unavailable: HTTP {StatusCode}", Constants.ExternalUrl.LinuxReleaseTag, (int)response.StatusCode); + return null; + } + + var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); + using var document = JsonDocument.Parse(content); + if (document.RootElement.ValueKind is JsonValueKind.Object && + document.RootElement.TryGetProperty("body", out var body) && + body.ValueKind is JsonValueKind.String && + body.GetString() is { } markdown && + !string.IsNullOrWhiteSpace(markdown)) + cachedMarkdown = markdown; + + return cachedMarkdown; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + App.Logger.LogInformation(ex, "Failed to fetch the release notes for {Tag}", Constants.ExternalUrl.LinuxReleaseTag); + return null; + } + finally + { + fetchLock.Release(); + } + } + } +} diff --git a/src/Files.App/Services/App/AppUpdateNoneService.cs b/src/Files.App/Services/App/AppUpdateNoneService.cs index eef78fbcfaa2..55279ce31d07 100644 --- a/src/Files.App/Services/App/AppUpdateNoneService.cs +++ b/src/Files.App/Services/App/AppUpdateNoneService.cs @@ -19,8 +19,6 @@ public bool AreReleaseNotesAvailable private set => SetProperty(ref _areReleaseNotesAvailable, value); } - public new event PropertyChangedEventHandler? PropertyChanged { add { } remove { } } - public Task CheckAndUpdateFilesLauncherAsync() { return Task.CompletedTask; @@ -33,6 +31,12 @@ public Task CheckForUpdatesAsync() public async Task CheckForReleaseNotesAsync() { + if (OperatingSystem.IsLinux()) + { + AreReleaseNotesAvailable = await ReleaseNotesHelper.GetLinuxReleaseNotesAsync() is not null; + return; + } + using var client = new HttpClient(); try diff --git a/src/Files.App/UserControls/FilePreviews/DesktopTextPreview.xaml.cs b/src/Files.App/UserControls/FilePreviews/DesktopTextPreview.xaml.cs index f060d2af1223..72d5f77e9d2b 100644 --- a/src/Files.App/UserControls/FilePreviews/DesktopTextPreview.xaml.cs +++ b/src/Files.App/UserControls/FilePreviews/DesktopTextPreview.xaml.cs @@ -29,8 +29,7 @@ private void BuildContent() var dark = ActualTheme == ElementTheme.Dark || (ActualTheme == ElementTheme.Default && Application.Current?.RequestedTheme == ApplicationTheme.Dark); - // RichTextBlock draws nothing on Uno Skia, so the paragraphs are built in a detached block - // collection and each one is shown in its own wrapping TextBlock. + // The paragraphs are built in a detached block collection, then shown as TextBlocks (see AddParagraphTextBlocks). var blocks = new RichTextBlock().Blocks; switch (ViewModel.Kind) { @@ -54,26 +53,7 @@ private void BuildContent() } } - foreach (var block in blocks.OfType()) - { - var textBlock = new TextBlock - { - IsTextSelectionEnabled = true, - TextWrapping = TextWrapping.Wrap, - Margin = block.Margin, - FontFamily = block.FontFamily, - FontSize = block.FontSize, - FontWeight = block.FontWeight, - FontStyle = block.FontStyle, - }; - foreach (var inline in block.Inlines.ToList()) - { - block.Inlines.Remove(inline); - textBlock.Inlines.Add(inline); - } - - ContentPanel.Children.Add(textBlock); - } + PreviewTextRenderer.AddParagraphTextBlocks(ContentPanel, blocks); if (!string.IsNullOrEmpty(ViewModel.Footer)) { diff --git a/src/Files.App/UserControls/FilePreviews/PreviewTextRenderer.cs b/src/Files.App/UserControls/FilePreviews/PreviewTextRenderer.cs index aa05680be9e4..1d25d8d0805b 100644 --- a/src/Files.App/UserControls/FilePreviews/PreviewTextRenderer.cs +++ b/src/Files.App/UserControls/FilePreviews/PreviewTextRenderer.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using ColorCode; +using System.Diagnostics.CodeAnalysis; using Files.App.ViewModels.Previews; using Markdig; using Markdig.Syntax; @@ -10,6 +11,7 @@ using Microsoft.UI; using Microsoft.UI.Text; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Media; using Windows.UI; @@ -18,7 +20,8 @@ namespace Files.App.UserControls.FilePreviews { /// /// Turns untrusted text into inert XAML inlines/blocks: syntax highlighted code and basic Markdown. - /// Links are rendered as underlined text only (never navigated), raw HTML is shown as literal text and images are not loaded. + /// Links are rendered as underlined text only unless the caller opts in to opening http(s) links, + /// raw HTML is shown as literal text and images are not loaded. /// public static class PreviewTextRenderer { @@ -117,7 +120,8 @@ public static void AddCodeInlines(Paragraph paragraph, Model model, bool dark) return null; } - public static void AddMarkdownBlocks(BlockCollection blocks, Model model, bool dark) + /// Opens absolute http(s) links when set; otherwise links are inert text. + public static void AddMarkdownBlocks(BlockCollection blocks, Model model, bool dark, Action? openLink = null) { if (model.Markdown is null) { @@ -127,7 +131,7 @@ public static void AddMarkdownBlocks(BlockCollection blocks, Model model, bool d try { foreach (var block in model.Markdown) - AddBlock(blocks, block, 0, dark); + AddBlock(blocks, block, 0, dark, openLink); } catch (Exception) { @@ -136,6 +140,33 @@ public static void AddMarkdownBlocks(BlockCollection blocks, Model model, bool d } } + /// + /// RichTextBlock draws nothing on Uno Skia, so each paragraph is moved into its own wrapping TextBlock. + /// + public static void AddParagraphTextBlocks(Panel target, BlockCollection blocks) + { + foreach (var block in blocks.OfType()) + { + var textBlock = new TextBlock + { + IsTextSelectionEnabled = true, + TextWrapping = TextWrapping.Wrap, + Margin = block.Margin, + FontFamily = block.FontFamily, + FontSize = block.FontSize, + FontWeight = block.FontWeight, + FontStyle = block.FontStyle, + }; + foreach (var inline in block.Inlines.ToList()) + { + block.Inlines.Remove(inline); + textBlock.Inlines.Add(inline); + } + + target.Children.Add(textBlock); + } + } + private static Paragraph PlainParagraph(string text) { var paragraph = new Paragraph(); @@ -143,7 +174,7 @@ private static Paragraph PlainParagraph(string text) return paragraph; } - private static void AddBlock(BlockCollection blocks, Block block, int depth, bool dark) + private static void AddBlock(BlockCollection blocks, Block block, int depth, bool dark, Action? openLink) { if (depth >= 32 || blocks.Count >= 2048) throw new System.IO.InvalidDataException("The Markdown preview limit was exceeded."); @@ -160,14 +191,14 @@ private static void AddBlock(BlockCollection blocks, Block block, int depth, boo FontSize = heading.Level switch { 1 => 24, 2 => 20, 3 => 17, _ => 15 }, Margin = new Thickness(depth * 20, heading.Level <= 2 ? 8 : 4, 0, 6), }; - AddInlines(paragraph.Inlines, heading.Inline, dark); + AddInlines(paragraph.Inlines, heading.Inline, dark, openLink); blocks.Add(paragraph); break; } case ParagraphBlock paragraphBlock: { var paragraph = new Paragraph { Margin = indent }; - AddInlines(paragraph.Inlines, paragraphBlock.Inline, dark); + AddInlines(paragraph.Inlines, paragraphBlock.Inline, dark, openLink); blocks.Add(paragraph); break; } @@ -187,12 +218,12 @@ private static void AddBlock(BlockCollection blocks, Block block, int depth, boo { var paragraph = new Paragraph { Margin = new Thickness(depth * 20 + 8, 0, 0, 2) }; paragraph.Inlines.Add(new Run { Text = marker }); - AddInlines(paragraph.Inlines, itemParagraph.Inline, dark); + AddInlines(paragraph.Inlines, itemParagraph.Inline, dark, openLink); blocks.Add(paragraph); } else { - AddBlock(blocks, child, depth + 1, dark); + AddBlock(blocks, child, depth + 1, dark, openLink); } first = false; } @@ -203,7 +234,7 @@ private static void AddBlock(BlockCollection blocks, Block block, int depth, boo { var firstIndex = blocks.Count; foreach (var child in quote) - AddBlock(blocks, child, depth + 1, dark); + AddBlock(blocks, child, depth + 1, dark, openLink); for (var i = firstIndex; i < blocks.Count; i++) { @@ -240,12 +271,12 @@ private static void AddBlock(BlockCollection blocks, Block block, int depth, boo } case ContainerBlock container: foreach (var child in container) - AddBlock(blocks, child, depth, dark); + AddBlock(blocks, child, depth, dark, openLink); break; } } - private static void AddInlines(InlineCollection target, ContainerInline? container, bool dark, int depth = 0) + private static void AddInlines(InlineCollection target, ContainerInline? container, bool dark, Action? openLink, int depth = 0) { if (container is null) return; @@ -273,7 +304,7 @@ private static void AddInlines(InlineCollection target, ContainerInline? contain case EmphasisInline emphasis: { Span span = emphasis.DelimiterCount >= 2 ? new Bold() : new Italic(); - AddInlines(span.Inlines, emphasis, dark, depth + 1); + AddInlines(span.Inlines, emphasis, dark, openLink, depth + 1); target.Add(span); break; } @@ -281,17 +312,33 @@ private static void AddInlines(InlineCollection target, ContainerInline? contain { var alt = new Span(); alt.Inlines.Add(new Run { Text = "[" }); - AddInlines(alt.Inlines, link, dark, depth + 1); + AddInlines(alt.Inlines, link, dark, openLink, depth + 1); alt.Inlines.Add(new Run { Text = "]" }); target.Add(alt); break; } + case LinkInline link when openLink is not null && TryGetWebUri(link.Url, out var uri): + { + var hyperlink = new Hyperlink(); + hyperlink.Click += (_, _) => openLink(uri); + AddInlines(hyperlink.Inlines, link, dark, null, depth + 1); + target.Add(hyperlink); + break; + } + case AutolinkInline auto when openLink is not null && !auto.IsEmail && TryGetWebUri(auto.Url, out var uri): + { + var hyperlink = new Hyperlink(); + hyperlink.Click += (_, _) => openLink(uri); + hyperlink.Inlines.Add(new Run { Text = auto.Url }); + target.Add(hyperlink); + break; + } case LinkInline link: { // Rendered as text only; links are never opened from the preview. var span = new Underline(); span.Foreground = new SolidColorBrush(dark ? Color.FromArgb(255, 117, 190, 255) : Color.FromArgb(255, 0, 90, 180)); - AddInlines(span.Inlines, link, dark, depth + 1); + AddInlines(span.Inlines, link, dark, openLink, depth + 1); target.Add(span); break; } @@ -307,11 +354,20 @@ private static void AddInlines(InlineCollection target, ContainerInline? contain target.Add(new Run { Text = html.Tag }); break; case ContainerInline nested: - AddInlines(target, nested, dark, depth + 1); + AddInlines(target, nested, dark, openLink, depth + 1); break; } } } + + private static bool TryGetWebUri(string? url, [NotNullWhen(true)] out Uri? uri) + { + if (Uri.TryCreate(url, UriKind.Absolute, out uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + return true; + + uri = null; + return false; + } } } diff --git a/src/Files.App/ViewModels/ReleaseNotesViewModel.cs b/src/Files.App/ViewModels/ReleaseNotesViewModel.cs index 05f667e58543..ba8af35b2f77 100644 --- a/src/Files.App/ViewModels/ReleaseNotesViewModel.cs +++ b/src/Files.App/ViewModels/ReleaseNotesViewModel.cs @@ -1,6 +1,9 @@ // Copyright (c) Files Community // SPDX-License-Identifier: MPL-2.0 +using Files.App.UserControls.FilePreviews; +using Files.App.ViewModels.Previews; + namespace Files.App.ViewModels { public sealed partial class ReleaseNotesViewModel : ObservableObject @@ -14,5 +17,17 @@ public sealed partial class ReleaseNotesViewModel : ObservableObject public ReleaseNotesViewModel() { } + + /// + /// Fetches and parses the LinuxFiles release notes for the running version; null means the fallback should be shown. + /// + public async Task LoadLinuxReleaseNotesAsync(CancellationToken cancellationToken) + { + var markdown = await ReleaseNotesHelper.GetLinuxReleaseNotesAsync(cancellationToken); + if (markdown is null) + return null; + + return await Task.Run(() => PreviewTextRenderer.Parse(markdown, TextPreviewKind.Markdown, null, cancellationToken), cancellationToken); + } } } diff --git a/src/Files.App/Views/ReleaseNotesPage.xaml b/src/Files.App/Views/ReleaseNotesPage.xaml index 3bcd5ddde3c0..c353cc3a566c 100644 --- a/src/Files.App/Views/ReleaseNotesPage.xaml +++ b/src/Files.App/Views/ReleaseNotesPage.xaml @@ -18,10 +18,29 @@ - + + + + + + + + + Content="{helpers:ResourceString Key=ReleaseNotes}" + Visibility="Collapsed" /> appInstance ?? throw new InvalidOperationException("The release notes page has not been initialized."); - public bool NativeViewsAvailable { get; } = NativePreviewSupport.IsAvailable; + private readonly CancellationTokenSource loadCancellation = new(); + + private PreviewTextRenderer.Model? releaseNotesModel; + + private bool isDisposed; public ReleaseNotesPage() { InitializeComponent(); - BrowserLink.Visibility = NativeViewsAvailable ? Visibility.Collapsed : Visibility.Visible; + ActualThemeChanged += (_, _) => BuildReleaseNotes(); } protected override async void OnNavigatedTo(NavigationEventArgs e) @@ -89,19 +95,85 @@ protected override async void OnNavigatedTo(NavigationEventArgs e) AppInstance.ToolbarViewModel.PathComponents.Add(item); base.OnNavigatedTo(e); - if (!NativeViewsAvailable) - await OpenReleaseNotesAsync(); + await LoadContentAsync(); + } + + private async Task LoadContentAsync() + { + // The page can be left while OnNavigatedTo is still awaiting + if (isDisposed) + return; + + if (OperatingSystem.IsLinux()) + { + LoadingRing.Visibility = Visibility.Visible; + LoadingRing.IsActive = true; + try + { + releaseNotesModel = await ViewModel.LoadLinuxReleaseNotesAsync(loadCancellation.Token); + } + catch (OperationCanceledException) + { + return; + } + finally + { + LoadingRing.IsActive = false; + LoadingRing.Visibility = Visibility.Collapsed; + } + + if (isDisposed) + return; + + if (releaseNotesModel is not null) + { + BuildReleaseNotes(); + ReleaseNotesScrollViewer.Visibility = Visibility.Visible; + return; + } + } + + // Fallback: WebView2 has no native backend on Linux (docs/linux-port/wayland-previews.md), so open the default browser there + if (!OperatingSystem.IsLinux()) + { + FindName(nameof(BlogPostWebView)); + return; + } + + BrowserLink.Visibility = Visibility.Visible; + await OpenReleaseNotesAsync(); } + private void BuildReleaseNotes() + { + ReleaseNotesPanel.Children.Clear(); + if (releaseNotesModel is null) + return; + + var dark = ActualTheme == ElementTheme.Dark + || (ActualTheme == ElementTheme.Default && Application.Current?.RequestedTheme == ApplicationTheme.Dark); + + // Links open in the default browser; images are shown as their alt text and HTML as literal text + var blocks = new RichTextBlock().Blocks; + PreviewTextRenderer.AddMarkdownBlocks(blocks, releaseNotesModel, dark, OpenLink); + PreviewTextRenderer.AddParagraphTextBlocks(ReleaseNotesPanel, blocks); + } + + private async void OpenLink(Uri uri) + => await LaunchUriAsync(uri); + private async void OpenReleaseNotes_Click(object sender, RoutedEventArgs e) => await OpenReleaseNotesAsync(); - private async Task OpenReleaseNotesAsync() + private Task OpenReleaseNotesAsync() + => LaunchUriAsync(new Uri(ViewModel.BlogPostUrl)); + + private static async Task LaunchUriAsync(Uri uri) { try { await Ioc.Default.GetRequiredService() - .LaunchUriAsync(new Uri(ViewModel.BlogPostUrl)); + .LaunchUriAsync(uri); } catch (Exception ex) { @@ -209,7 +281,14 @@ private async Task TryExecuteForwardedHotKeyAsync(string message) public void Dispose() { - if (!NativeViewsAvailable) + if (isDisposed) + return; + + isDisposed = true; + loadCancellation.Cancel(); + loadCancellation.Dispose(); + + if (BlogPostWebView is null) return; BlogPostWebView.WebMessageReceived -= WebView_WebMessageReceived;