Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion build-installer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down
3 changes: 3 additions & 0 deletions release-notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 111 additions & 0 deletions src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Text;

namespace OrthoStreamXP.Gui;

/// <summary>What a line of the release notes is.</summary>
internal enum NoteKind
{
Heading,
Bullet,
Paragraph,
}

/// <summary>A piece of a line's text, bold or not.</summary>
internal sealed record NoteSpan(string Text, bool Bold);

/// <summary>One heading, bullet or paragraph of the release notes.</summary>
internal sealed record NoteBlock(NoteKind Kind, IReadOnlyList<NoteSpan> Spans)
{
public string Text => string.Concat(Spans.Select(s => s.Text));
}

/// <summary>
/// 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.
/// </summary>
internal static class ReleaseNotes
{
public static IReadOnlyList<NoteBlock> Parse(string? markdown)
{
var blocks = new List<NoteBlock>();
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;
}

/// <summary>A horizontal rule: ---, *** or ___.</summary>
private static bool IsRule(string line) => line.Length >= 3 && line[0] is '-' or '*' or '_' && line.All(c => c == line[0]);

/// <summary>Splits a line at its **bold** markers, and drops the link and code markup.</summary>
private static List<NoteSpan> Inline(string text)
{
var spans = new List<NoteSpan>();
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;
}

/// <summary>[text](url) becomes text, `code` becomes code.</summary>
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();
}
}
4 changes: 4 additions & 0 deletions src/OrthoStreamXP/Gui/Home/UpdateOffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace OrthoStreamXP.Gui;

/// <summary>A downloaded new version, as the update window shows it before the person installs it.</summary>
internal sealed record UpdateOffer(string Version, string? CurrentVersion, IReadOnlyList<NoteBlock> Notes, string ReleasePage);
5 changes: 5 additions & 0 deletions src/OrthoStreamXP/Gui/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
}

/// <summary>Closing while the scenery is mounted asks first, then unmounts before the window goes.</summary>
Expand Down
25 changes: 20 additions & 5 deletions src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -97,8 +97,23 @@ public HomeViewModel(MainViewModel main)
public ICommand OpenLog { get; }
public ICommand RestartMount { get; }

/// <summary>Installs the downloaded version and restarts (only while the streaming is stopped).</summary>
/// <summary>Shows the downloaded version's release notes, to install it from there (only while the streaming is stopped).</summary>
public ICommand InstallUpdate { get; }

/// <summary>The person asked to install the downloaded version: the window shows its notes and asks to confirm.</summary>
public event EventHandler<UpdateOffer>? 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)));
}

/// <summary>Installs the downloaded version and restarts, once confirmed; not while the scenery streams.</summary>
public void InstallConfirmedUpdate()
{
if (Mount.State == MountState.Stopped) _updates.ApplyAndRestart();
}
public ICommand ApplyReview { get; }
public ICommand DismissReview { get; }

Expand Down Expand Up @@ -535,8 +550,8 @@ private List<Notice> 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);
Expand Down
39 changes: 39 additions & 0 deletions src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<Window x:Class="OrthoStreamXP.Gui.UpdateDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal"
Title="Update OrthoStreamXP"
Width="600" Height="560" MinWidth="440" MinHeight="340"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<Grid Margin="24,20,24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>

<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock x:Name="Heading" FontSize="22" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock x:Name="Intro" Style="{StaticResource Secondary}" Margin="0,6,0,0" />
</StackPanel>

<Border Grid.Row="1" Style="{StaticResource Card}" Padding="0">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,14,20,16" Focusable="False">
<StackPanel>
<StackPanel x:Name="Notes" />
<TextBlock x:Name="NoNotes" Style="{StaticResource Secondary}" Visibility="Collapsed"
Text="This version came without release notes. They are on its GitHub page." />
</StackPanel>
</ScrollViewer>
</Border>

<Button Grid.Row="2" x:Name="ReleasePageLink" Content="Release notes on GitHub" Style="{StaticResource LinkButton}"
HorizontalAlignment="Left" Margin="2,12,0,0" Click="OpenReleasePage" />

<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
<Button Content="Update and restart" Style="{DynamicResource AccentButtonStyle}" IsDefault="True" MinWidth="148" Click="Confirm" />
<Button Content="Not now" IsCancel="True" MinWidth="96" Margin="8,0,0,0" />
</StackPanel>
</Grid>
</Window>
53 changes: 53 additions & 0 deletions src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;

namespace OrthoStreamXP.Gui;

/// <summary>Asks before installing a downloaded version, with its release notes: true when the person chose to update.</summary>
internal sealed partial class UpdateDialog
{
private readonly UpdateOffer _offer;

public UpdateDialog(UpdateOffer offer)
{
_offer = offer;
InitializeComponent();
Heading.Text = $"OrthoStreamXP {offer.Version} is ready to install";
Intro.Text = (offer.CurrentVersion is null ? "" : $"You have {offer.CurrentVersion}. ")
+ "OrthoStreamXP closes, installs the update and starts again. Your settings, cache and flight history stay.";
ReleasePageLink.ToolTip = offer.ReleasePage;
if (offer.Notes.Count == 0) NoNotes.Visibility = Visibility.Visible;
for (int i = 0; i < offer.Notes.Count; i++) Notes.Children.Add(Show(offer.Notes[i], first: i == 0));
}

private static FrameworkElement Show(NoteBlock block, bool first)
{
var text = new TextBlock { TextWrapping = TextWrapping.Wrap };
foreach (NoteSpan span in block.Spans)
text.Inlines.Add(new Run(span.Text) { FontWeight = span.Bold ? FontWeights.SemiBold : FontWeights.Normal });

switch (block.Kind)
{
case NoteKind.Heading:
text.FontSize = 16;
text.FontWeight = FontWeights.SemiBold;
text.Margin = new Thickness(0, first ? 0 : 16, 0, 2);
return text;
case NoteKind.Bullet:
var row = new DockPanel { Margin = new Thickness(4, first ? 0 : 6, 0, 0) };
var dot = new TextBlock { Text = "•", Margin = new Thickness(0, 0, 10, 0) };
DockPanel.SetDock(dot, Dock.Left);
row.Children.Add(dot);
row.Children.Add(text);
return row;
default:
text.Margin = new Thickness(0, first ? 0 : 8, 0, 0);
return text;
}
}

private void Confirm(object sender, RoutedEventArgs e) => DialogResult = true;

private void OpenReleasePage(object sender, RoutedEventArgs e) => Shell.Open(_offer.ReleasePage);
}
6 changes: 5 additions & 1 deletion src/OrthoStreamXP/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
using OrthoStreamXP.Gui;

// The installer's hooks first: on install, update or uninstall Velopack runs them and ends the process here.
Velopack.VelopackApp.Build().OnBeforeUninstallFastCallback(_ => OrthoStreamXP.Setup.Installation.BeforeUninstall()).Run();
// A downloaded update is not installed at start (Velopack's default): only when the person chooses it on the Home page.
Velopack.VelopackApp.Build()
.SetAutoApplyOnStartup(false)
.OnBeforeUninstallFastCallback(_ => OrthoStreamXP.Setup.Installation.BeforeUninstall())
.Run();

// The log, the settings file and the window's English text all use one number and date format, whatever
// the PC's regional settings (WPF needs real culture data, so invariant globalization is not an option).
Expand Down
25 changes: 25 additions & 0 deletions src/OrthoStreamXP/Setup/Installation/AppUpdates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ internal sealed class AppUpdates
/// <summary>Where the releases are: the project's GitHub page (betas included, as the first releases are).</summary>
public const string Repository = "https://github.com/flysimdev/OrthoStreamXP";

/// <summary>For the release notes of versions whose update carries none; GitHub asks for a user agent.</summary>
private static readonly HttpClient Http = CreateHttpClient();

private readonly UpdateManager? _manager;
private readonly GithubReleaseNotes _githubNotes = new(Http, Repository["https://github.com/".Length..]);
private bool _checking;

public AppUpdates()
Expand All @@ -38,6 +42,15 @@ public AppUpdates()

public string? ReadyVersion => Ready?.TargetFullRelease.Version.ToString();

/// <summary>
/// The downloaded version's release notes (Markdown, from release-notes/vX.Y.Z.md): from its update, else from its GitHub
/// Release; null when neither has any.
/// </summary>
public string? ReadyNotes { get; private set; }

/// <summary>The GitHub Release page of a version.</summary>
public static string ReleasePage(string version) => $"{Repository}/releases/tag/v{version}";

/// <summary>Looks for a newer version and downloads it; true when one became ready. Problems (offline, GitHub busy) are only logged.</summary>
public async Task<bool> CheckAsync()
{
Expand All @@ -48,6 +61,10 @@ public async Task<bool> CheckAsync()
if (await _manager.CheckForUpdatesAsync().ConfigureAwait(true) is not { } update) return false;
Log.Info($"OrthoStreamXP {update.TargetFullRelease.Version} is available (this is {CurrentVersion}); downloading it");
await _manager.DownloadUpdatesAsync(update).ConfigureAwait(true);
string? notes = update.TargetFullRelease.NotesMarkdown;
ReadyNotes = string.IsNullOrWhiteSpace(notes)
? await _githubNotes.FetchAsync(update.TargetFullRelease.Version.ToString(), CancellationToken.None).ConfigureAwait(true)
: notes;
Ready = update;
Log.Step($"OrthoStreamXP {ReadyVersion} is downloaded and ready to install");
return true;
Expand All @@ -63,6 +80,14 @@ public async Task<bool> CheckAsync()
}
}

private static HttpClient CreateHttpClient()
{
var http = new HttpClient();
string version = typeof(AppUpdates).Assembly.GetName().Version?.ToString(3) ?? "0";
http.DefaultRequestHeaders.UserAgent.ParseAdd($"orthostreamxp/{version}");
return http;
}

/// <summary>Installs the downloaded version and starts OrthoStreamXP again (this process ends).</summary>
public void ApplyAndRestart()
{
Expand Down
Loading
Loading