From 88c489352ba5a922d1ddf0fb8d953be973c43165 Mon Sep 17 00:00:00 2001 From: flysimdev Date: Thu, 12 Nov 2026 14:29:55 +0100 Subject: [PATCH 1/2] Improvemets for the auto update feature --- build-installer.ps1 | 6 +- release-notes/README.md | 3 + src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs | 111 ++++++++++++++++++ src/OrthoStreamXP/Gui/Home/UpdateOffer.cs | 4 + src/OrthoStreamXP/Gui/MainWindow.xaml.cs | 5 + .../Gui/ViewModels/HomeViewModel.cs | 25 +++- src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml | 39 ++++++ .../Gui/Views/UpdateDialog.xaml.cs | 53 +++++++++ src/OrthoStreamXP/Program.cs | 6 +- .../Setup/Installation/AppUpdates.cs | 25 ++++ .../Setup/Installation/GithubReleaseNotes.cs | 45 +++++++ .../GithubReleaseNotesTests.cs | 30 +++++ .../OrthoStreamXP.Tests/ReleaseNotesTests.cs | 66 +++++++++++ tests/OrthoStreamXP.Tests/ViewsTests.cs | 11 ++ 14 files changed, 422 insertions(+), 7 deletions(-) create mode 100644 src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs create mode 100644 src/OrthoStreamXP/Gui/Home/UpdateOffer.cs create mode 100644 src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml create mode 100644 src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml.cs create mode 100644 src/OrthoStreamXP/Setup/Installation/GithubReleaseNotes.cs create mode 100644 tests/OrthoStreamXP.Tests/GithubReleaseNotesTests.cs create mode 100644 tests/OrthoStreamXP.Tests/ReleaseNotesTests.cs diff --git a/build-installer.ps1 b/build-installer.ps1 index 10f565c..c607264 100644 --- a/build-installer.ps1 +++ b/build-installer.ps1 @@ -24,8 +24,12 @@ if ($LASTEXITCODE -ne 0) { throw 'publish failed' } $license = (Get-Content installer\license-notice.md -Raw) + "`n`n---`n`n" + (Get-Content LICENSE -Raw) Set-Content artifacts\license.md $license -Encoding utf8 +# The release notes go into the update feed: the app shows them before it installs the update. +$notes = "release-notes\v$Version.md" +$notesArgs = if (Test-Path $notes) { @('--releaseNotes', $notes) } else { Write-Host "No $notes; the update has no notes"; @() } + # --framework: the installer installs the .NET desktop runtime first when the PC does not have it. -dotnet vpk pack ` +dotnet vpk pack @notesArgs ` --packId OrthoStreamXP.App ` --packVersion $Version ` --packTitle OrthoStreamXP ` diff --git a/release-notes/README.md b/release-notes/README.md index 3589514..9bdc8bc 100644 --- a/release-notes/README.md +++ b/release-notes/README.md @@ -4,6 +4,9 @@ One Markdown file per release, named after its tag: `v1.2.0.md` for the tag `v1. When the tag is pushed, the [release workflow](../.github/workflows/release.yml) puts the file's text at the top of the GitHub Release. GitHub's generated list of changes (merged pull requests, new contributors) follows it. +`build-installer.ps1` also packs the file into the update feed: the app shows it in its update window before +installing (headings, bullets, paragraphs and **bold**; links show their text only). For an update packed without +notes, the app reads them from the GitHub Release instead, up to GitHub's generated "What's Changed" list. ## Releasing diff --git a/src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs b/src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs new file mode 100644 index 0000000..1c68bbb --- /dev/null +++ b/src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs @@ -0,0 +1,111 @@ +using System.Text; + +namespace OrthoStreamXP.Gui; + +/// What a line of the release notes is. +internal enum NoteKind +{ + Heading, + Bullet, + Paragraph, +} + +/// A piece of a line's text, bold or not. +internal sealed record NoteSpan(string Text, bool Bold); + +/// One heading, bullet or paragraph of the release notes. +internal sealed record NoteBlock(NoteKind Kind, IReadOnlyList Spans) +{ + public string Text => string.Concat(Spans.Select(s => s.Text)); +} + +/// +/// Turns the release notes' Markdown (release-notes/vX.Y.Z.md: headings, bullets, paragraphs, bold) into blocks the update +/// window shows. Links keep their text, inline code its content; anything else is shown as written. +/// +internal static class ReleaseNotes +{ + public static IReadOnlyList Parse(string? markdown) + { + var blocks = new List(); + if (string.IsNullOrWhiteSpace(markdown)) return blocks; + + var paragraph = new StringBuilder(); + void EndParagraph() + { + if (paragraph.Length == 0) return; + blocks.Add(new NoteBlock(NoteKind.Paragraph, Inline(paragraph.ToString()))); + paragraph.Clear(); + } + + foreach (string raw in markdown.ReplaceLineEndings("\n").Split('\n')) + { + string line = raw.Trim(); + if (line.Length == 0 || IsRule(line)) + { + EndParagraph(); + } + else if (line.StartsWith('#')) + { + EndParagraph(); + blocks.Add(new NoteBlock(NoteKind.Heading, Inline(line.TrimStart('#').Trim()))); + } + else if (line.Length > 1 && line[0] is '-' or '*' or '+' && line[1] == ' ') + { + EndParagraph(); + blocks.Add(new NoteBlock(NoteKind.Bullet, Inline(line[2..].Trim()))); + } + else if (blocks.Count > 0 && blocks[^1].Kind == NoteKind.Bullet && paragraph.Length == 0 && char.IsWhiteSpace(raw[0])) + { + // An indented line goes on with the bullet above it. + var bullet = blocks[^1]; + blocks[^1] = bullet with { Spans = [.. bullet.Spans, .. Inline(" " + line)] }; + } + else + { + if (paragraph.Length > 0) paragraph.Append(' '); + paragraph.Append(line); + } + } + EndParagraph(); + return blocks; + } + + /// A horizontal rule: ---, *** or ___. + private static bool IsRule(string line) => line.Length >= 3 && line[0] is '-' or '*' or '_' && line.All(c => c == line[0]); + + /// Splits a line at its **bold** markers, and drops the link and code markup. + private static List Inline(string text) + { + var spans = new List(); + string[] parts = Plain(text).Split("**"); + for (int i = 0; i < parts.Length; i++) + { + // An unclosed ** stays as written. + bool bold = i % 2 == 1 && i < parts.Length - 1; + string part = i % 2 == 1 && !bold ? "**" + parts[i] : parts[i]; + if (part.Length > 0) spans.Add(new NoteSpan(part, bold)); + } + return spans; + } + + /// [text](url) becomes text, `code` becomes code. + private static string Plain(string text) + { + var plain = new StringBuilder(text.Length); + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '[' && text.IndexOf(']', i) is var close and > 0 && close + 1 < text.Length && text[close + 1] == '(' + && text.IndexOf(')', close) is var end and > 0) + { + plain.Append(text, i + 1, close - i - 1); + i = end; + } + else if (text[i] != '`') + { + plain.Append(text[i]); + } + } + return plain.ToString(); + } +} diff --git a/src/OrthoStreamXP/Gui/Home/UpdateOffer.cs b/src/OrthoStreamXP/Gui/Home/UpdateOffer.cs new file mode 100644 index 0000000..bae0dc6 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Home/UpdateOffer.cs @@ -0,0 +1,4 @@ +namespace OrthoStreamXP.Gui; + +/// A downloaded new version, as the update window shows it before the person installs it. +internal sealed record UpdateOffer(string Version, string? CurrentVersion, IReadOnlyList Notes, string ReleasePage); diff --git a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs index dfcc866..d4b82b3 100644 --- a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs +++ b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs @@ -13,6 +13,11 @@ public MainWindow(MainViewModel main) _main = main; DataContext = main; InitializeComponent(); + // The Home page's Update button: the release notes first, installed only when confirmed. + _main.Home.UpdateOffered += (_, offer) => + { + if (new UpdateDialog(offer) { Owner = this }.ShowDialog() == true) _main.Home.InstallConfirmedUpdate(); + }; } /// Closing while the scenery is mounted asks first, then unmounts before the window goes. diff --git a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs index bbcb12c..a8726b8 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs @@ -45,8 +45,8 @@ public HomeViewModel(MainViewModel main) ShowReviewOf(null); }); _timer.Tick += (_, _) => Update(); - // Installed only when chosen, and only while the scenery is not streaming: OrthoStreamXP restarts to do it. - InstallUpdate = new RelayCommand(_updates.ApplyAndRestart, () => _updates.Ready is not null && Mount.State == MountState.Stopped); + // Installed only when chosen (after the release notes), and only while the scenery is not streaming: OrthoStreamXP restarts to do it. + InstallUpdate = new RelayCommand(OfferUpdate, () => _updates.Ready is not null && Mount.State == MountState.Stopped); _updateTimer.Tick += async (_, _) => { _updateTimer.Interval = TimeSpan.FromHours(6); @@ -97,8 +97,23 @@ public HomeViewModel(MainViewModel main) public ICommand OpenLog { get; } public ICommand RestartMount { get; } - /// Installs the downloaded version and restarts (only while the streaming is stopped). + /// Shows the downloaded version's release notes, to install it from there (only while the streaming is stopped). public ICommand InstallUpdate { get; } + + /// The person asked to install the downloaded version: the window shows its notes and asks to confirm. + public event EventHandler? UpdateOffered; + + private void OfferUpdate() + { + if (_updates.ReadyVersion is not { } version) return; + UpdateOffered?.Invoke(this, new UpdateOffer(version, _updates.CurrentVersion, ReleaseNotes.Parse(_updates.ReadyNotes), AppUpdates.ReleasePage(version))); + } + + /// Installs the downloaded version and restarts, once confirmed; not while the scenery streams. + public void InstallConfirmedUpdate() + { + if (Mount.State == MountState.Stopped) _updates.ApplyAndRestart(); + } public ICommand ApplyReview { get; } public ICommand DismissReview { get; } @@ -535,8 +550,8 @@ private List BuildNotices() if (_updates.ReadyVersion is { } version) notices.Add(Mount.State == MountState.Stopped ? new Notice(Severity.Info, $"OrthoStreamXP {version} is ready to install", - $"A new version was downloaded (you have {_updates.CurrentVersion}). OrthoStreamXP restarts to install it; your settings, cache and flight history stay.", - "Update and restart", InstallUpdate) + $"A new version was downloaded (you have {_updates.CurrentVersion}). See what's new and install it when you choose; your settings, cache and flight history stay.", + "Update…", InstallUpdate) : new Notice(Severity.Info, $"OrthoStreamXP {version} is ready to install", $"A new version was downloaded (you have {_updates.CurrentVersion}). Stop the streaming when you have finished flying, and install it from here.")); if (_xplaneProblem is not null) notices.Add(_xplaneProblem); diff --git a/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml b/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml new file mode 100644 index 0000000..07de185 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + +