diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b704c24..1514026 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -183,7 +183,8 @@ jobs: # assets anyone can download. main gets a release of its own per build, tagged # with its version - the newest is what the starter checks itself against: # https://github.com/${{ github.repository }}/releases/latest/download/ - # staging keeps one rolling pre-release for testers. + # staging keeps one rolling pre-release, offered only to starters set to the + # testing channel, which tell its builds apart by commit. release: name: Release needs: [publish, version] diff --git a/StarterNG.Tests/UpdateCheckTests.cs b/StarterNG.Tests/UpdateCheckTests.cs new file mode 100644 index 0000000..434bb6d --- /dev/null +++ b/StarterNG.Tests/UpdateCheckTests.cs @@ -0,0 +1,291 @@ +using System.Net; +using System.Net.Http; +using StarterNG.Application; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class UpdateCheckTests +{ + private const string Page = "https://github.com/MaSzyna-EU07/StarterNG/releases/tag/v1.4.107"; + private const string StagingPage = "https://github.com/MaSzyna-EU07/StarterNG/releases/tag/staging"; + private const string RecordPath = "/home/user/.config/starter/updatecheck.txt"; + private const string RunningCommit = "9d981cb0932c372074eaba25d5c917acbacc25d8"; + private const string OtherCommit = "f7219cf7055670ad0f39fc18e0640358b8fff8f7"; + + private static readonly Version Current = new(1, 4, 100, 0); + + [Theory] + [InlineData("v1.4.107")] + [InlineData("v1.5.0")] + [InlineData("1.4.101")] + public async Task A_higher_release_is_an_update(string tag) + { + var check = new Rig(new StubFeed(Stable(tag))).Check; + + Assert.Equal(UpdateCheckOutcome.UpdateAvailable, await check.CheckNowAsync(UpdateChannel.Stable)); + Assert.Equal(UpdateCheck.ParseTag(tag)!.ToString(), check.Available!.Label); + Assert.Equal(Page, check.Available.PageUrl); + } + + [Theory] + [InlineData("v1.4.100")] + [InlineData("v1.4.99")] + [InlineData("v1.3.500")] + [InlineData("staging")] + [InlineData("v1.5.0-rc1")] + public async Task The_same_an_older_or_an_unversioned_release_is_not(string tag) + { + var check = new Rig(new StubFeed(Stable(tag))).Check; + + Assert.Equal(UpdateCheckOutcome.UpToDate, await check.CheckNowAsync(UpdateChannel.Stable)); + Assert.Null(check.Available); + } + + [Fact] + public async Task No_answer_is_reported_as_such_and_not_remembered() + { + var rig = new Rig(new StubFeed(lookup: ReleaseLookup.NoAnswer)); + + Assert.Equal(UpdateCheckOutcome.NoAnswer, await rig.Check.CheckNowAsync(UpdateChannel.Stable)); + Assert.False(rig.Files.FileExists(RecordPath)); + } + + [Fact] + public async Task The_daily_check_asks_once_a_day_and_keeps_showing_the_answer() + { + var feed = new StubFeed(Stable("v1.4.107")); + var rig = new Rig(feed); + await rig.Check.CheckDailyAsync(); + + // A second start the same day: no request, the update still shown. + var again = rig.Restart(); + await again.CheckDailyAsync(); + + Assert.Equal(1, feed.Calls); + Assert.Equal("1.4.107", again.Available!.Label); + } + + [Fact] + public async Task The_next_day_it_asks_again() + { + var feed = new StubFeed(Stable("v1.4.107")); + var rig = new Rig(feed); + await rig.Check.CheckDailyAsync(); + + rig.Clock.Now = rig.Clock.Now.AddDays(1); + await rig.Restart().CheckDailyAsync(); + + Assert.Equal(2, feed.Calls); + } + + [Fact] + public async Task A_remembered_release_already_installed_is_not_offered() + { + var rig = new Rig(new StubFeed(Stable("v1.4.107"))); + await rig.Check.CheckDailyAsync(); + + var upgraded = rig.Restart(current: new Version(1, 4, 107, 0)); + await upgraded.CheckDailyAsync(); + + Assert.Null(upgraded.Available); + } + + [Fact] + public async Task Switching_the_channel_asks_again_the_same_day() + { + var feed = new StubFeed(Stable("v1.4.107")); + var rig = new Rig(feed); + await rig.Check.CheckDailyAsync(); + + rig.Settings.UpdateChannel = UpdateChannel.Staging; + await rig.Restart().CheckDailyAsync(); + + Assert.Equal(2, feed.Calls); + Assert.Equal(UpdateChannel.Staging, feed.LastChannel); + } + + [Fact] + public async Task Switched_off_the_daily_check_does_not_ask_but_the_button_does() + { + var feed = new StubFeed(Stable("v9.0.0")); + var rig = new Rig(feed); + rig.Settings.CheckForUpdates = false; + + await rig.Check.CheckDailyAsync(); + Assert.Equal(0, feed.Calls); + + Assert.Equal(UpdateCheckOutcome.UpdateAvailable, await rig.Check.CheckNowAsync(UpdateChannel.Stable)); + } + + [Fact] + public async Task A_staging_build_from_another_commit_is_offered() + { + var check = new Rig(new StubFeed(Staging(OtherCommit))).Check; + + Assert.Equal(UpdateCheckOutcome.UpdateAvailable, await check.CheckNowAsync(UpdateChannel.Staging)); + Assert.Equal("staging f7219cf", check.Available!.Label); + Assert.Equal(StagingPage, check.Available.PageUrl); + } + + [Fact] + public async Task The_staging_build_already_running_is_not() + { + var check = new Rig(new StubFeed(Staging(RunningCommit))).Check; + + Assert.Equal(UpdateCheckOutcome.UpToDate, await check.CheckNowAsync(UpdateChannel.Staging)); + } + + [Fact] + public async Task Finding_an_update_tells_the_top_bar() + { + var check = new Rig(new StubFeed(Stable("v1.4.107"))).Check; + int changes = 0; + check.Changed += () => changes++; + + await check.CheckNowAsync(UpdateChannel.Stable); + await check.CheckNowAsync(UpdateChannel.Stable); + + Assert.Equal(1, changes); + } + + [Fact] + public void The_commit_is_read_from_the_informational_version() + { + Assert.Equal(RunningCommit, UpdateCheck.CommitOf("1.4.113+" + RunningCommit)); + Assert.Null(UpdateCheck.CommitOf("1.4.113")); + Assert.Null(UpdateCheck.CommitOf(null)); + } + + [Fact] + public void The_four_part_assembly_version_matches_a_three_part_tag() + { + Assert.Equal(new Version(1, 4, 0), UpdateCheck.ParseTag("v1.4")); + Assert.Equal(new Version(1, 4, 107), UpdateCheck.ParseTag("V1.4.107")); + } + + [Fact] + public async Task The_feed_reads_a_stable_release() + { + var feed = Feed(HttpStatusCode.OK, + $$"""{"tag_name":"v1.4.107","html_url":"{{Page}}","target_commitish":"{{OtherCommit}}","prerelease":false}"""); + + var lookup = await feed.LatestAsync(UpdateChannel.Stable); + + Assert.True(lookup.Answered); + Assert.Equal(new ReleaseInfo("v1.4.107", Page, OtherCommit), lookup.Release); + } + + [Fact] + public async Task The_stable_feed_skips_a_pre_release_and_the_staging_feed_takes_it() + { + string json = $$"""{"tag_name":"staging","html_url":"{{StagingPage}}","target_commitish":"{{OtherCommit}}","prerelease":true}"""; + + Assert.Null((await Feed(HttpStatusCode.OK, json).LatestAsync(UpdateChannel.Stable)).Release); + Assert.Equal(OtherCommit, (await Feed(HttpStatusCode.OK, json).LatestAsync(UpdateChannel.Staging)).Release!.Commit); + } + + [Fact] + public async Task The_staging_feed_asks_for_the_staging_tag() + { + string? asked = null; + var feed = new GitHubReleaseFeed(new TestInstallation().Log, Current, new StubHandler(request => + { + asked = request.RequestUri!.AbsolutePath; + return Respond(HttpStatusCode.NotFound, "{}"); + })); + + await feed.LatestAsync(UpdateChannel.Staging); + + Assert.EndsWith("/releases/tags/staging", asked); + } + + [Fact] + public async Task Nothing_released_yet_is_an_answer() + { + var lookup = await Feed(HttpStatusCode.NotFound, """{"message":"Not Found"}""").LatestAsync(UpdateChannel.Stable); + + Assert.True(lookup.Answered); + Assert.Null(lookup.Release); + } + + [Fact] + public async Task No_network_is_no_answer_and_nothing_in_the_log() + { + var log = new TestInstallation().Log; + var feed = new GitHubReleaseFeed(log, Current, new StubHandler(_ => throw new HttpRequestException("offline"))); + + Assert.False((await feed.LatestAsync(UpdateChannel.Stable)).Answered); + Assert.Equal("", log.Text); + } + + [Fact] + public async Task A_broken_answer_is_logged() + { + var log = new TestInstallation().Log; + var feed = new GitHubReleaseFeed(log, Current, new StubHandler(_ => Respond(HttpStatusCode.OK, ""))); + + Assert.False((await feed.LatestAsync(UpdateChannel.Stable)).Answered); + Assert.Contains("update check", log.Text); + } + + private static ReleaseLookup Stable(string tag) => ReleaseLookup.Found(new ReleaseInfo(tag, Page)); + + private static ReleaseLookup Staging(string commit) => + ReleaseLookup.Found(new ReleaseInfo("staging", StagingPage, commit)); + + private static GitHubReleaseFeed Feed(HttpStatusCode status, string body) => + new(new TestInstallation().Log, Current, new StubHandler(_ => Respond(status, body))); + + private static HttpResponseMessage Respond(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body) }; + + /// One installation's worth of update checking, restartable on the same disk. + private sealed class Rig + { + private readonly IReleaseFeed _feed; + private readonly TestInstallation _installation; + + public Rig(IReleaseFeed feed) + { + _feed = feed; + _installation = new TestInstallation(Files, new DateTime(2026, 10, 4, 9, 0, 0)); + Check = Restart(); + } + + public InMemoryFileSystem Files { get; } = new(); + + public SimulatorSettings Settings { get; } = new(); + + public FixedClock Clock => _installation.Clock; + + public UpdateCheck Check { get; } + + public UpdateCheck Restart(Version? current = null) => + new(Settings, _feed, current ?? Current, RunningCommit, Files, Clock, _installation.Log, RecordPath); + } + + private sealed class StubFeed(ReleaseLookup lookup) : IReleaseFeed + { + public int Calls { get; private set; } + + public UpdateChannel? LastChannel { get; private set; } + + public Task LatestAsync(UpdateChannel channel, CancellationToken cancellationToken = default) + { + Calls++; + LastChannel = channel; + return Task.FromResult(lookup); + } + } + + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(respond(request)); + } +} diff --git a/StarterNG/Application/Abstractions/IReleaseFeed.cs b/StarterNG/Application/Abstractions/IReleaseFeed.cs new file mode 100644 index 0000000..76c01d5 --- /dev/null +++ b/StarterNG/Application/Abstractions/IReleaseFeed.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using StarterNG.Domain.Settings; + +namespace StarterNG.Application.Abstractions; + +/// +/// A published build of the starter: its tag, the page it is downloaded from and +/// the commit it was built from - the only way to tell rolling builds apart. +/// +public sealed record ReleaseInfo(string Tag, string PageUrl, string? Commit = null); + +/// +/// What asking for the newest release came back with. No answer - offline, rate +/// limited, timed out - is kept apart from an answer of "nothing released", so a +/// failed check is not remembered as a done one. +/// +public sealed record ReleaseLookup(bool Answered, ReleaseInfo? Release) +{ + public static ReleaseLookup NoAnswer { get; } = new(false, null); + + public static ReleaseLookup Found(ReleaseInfo? release) => new(true, release); +} + +public interface IReleaseFeed +{ + Task LatestAsync(UpdateChannel channel, CancellationToken cancellationToken = default); +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 2df8944..e7f4b44 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using StarterNG.Application.Abstractions; using StarterNG.Domain.Settings; using StarterNG.Infrastructure.Adapters; @@ -52,6 +53,12 @@ public sealed class AppServices Executables = new ExecutableLocator(files, paths, environment, log); SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables); MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log); + var assembly = typeof(AppServices).Assembly; + var version = assembly.GetName().Version ?? new Version(0, 0); + string? commit = UpdateCheck.CommitOf( + assembly.GetCustomAttribute()?.InformationalVersion); + Updates = new UpdateCheck(SettingsStore.Settings, new GitHubReleaseFeed(log, version), version, commit, + files, clock, log, settingsPaths.UpdateCheckPath()); StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log); } @@ -124,6 +131,8 @@ public sealed class AppServices public MissingVehicleLog MissingVehicleLog { get; } + public UpdateCheck Updates { get; } + public StartSimulation StartSimulation { get; } public LocalizationService Localization { get; } diff --git a/StarterNG/Application/UpdateCheck.cs b/StarterNG/Application/UpdateCheck.cs new file mode 100644 index 0000000..3f475c7 --- /dev/null +++ b/StarterNG/Application/UpdateCheck.cs @@ -0,0 +1,177 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; + +namespace StarterNG.Application; + +/// A build to offer: what to call it on the top bar and where to get it. +public sealed record AvailableUpdate(string Label, string PageUrl); + +public enum UpdateCheckOutcome +{ + UpToDate, + UpdateAvailable, + NoAnswer +} + +public sealed class UpdateCheck +{ + private const string DayFormat = "yyyy-MM-dd"; + private const int ShortCommit = 7; + + private readonly SimulatorSettings _settings; + private readonly IReleaseFeed _feed; + private readonly string? _commit; + private readonly IFileSystem _files; + private readonly IClock _clock; + private readonly IDiagnosticsLog _log; + private readonly string _recordPath; + + public UpdateCheck(SimulatorSettings settings, IReleaseFeed feed, Version current, string? commit, + IFileSystem files, IClock clock, IDiagnosticsLog log, string recordPath) + { + _settings = settings; + _feed = feed; + _commit = commit; + _files = files; + _clock = clock; + _log = log; + _recordPath = recordPath; + Current = Normalize(current); + } + + public Version Current { get; } + + /// The build to offer, as last heard of; null when up to date or not asked yet. + public AvailableUpdate? Available { get; private set; } + + public event Action? Changed; + + /// + /// The check made on start. GitHub is asked at most once a day, and its answer + /// is remembered, so a second start the same day still shows the update. + /// + public async Task CheckDailyAsync(CancellationToken cancellationToken = default) + { + if (!_settings.CheckForUpdates) + return; + + var channel = _settings.UpdateChannel; + if (ReadRecord() is { } record && record.Day == Today() && record.Channel == channel) + { + Publish(channel, record.Release); + return; + } + + await CheckNowAsync(channel, cancellationToken); + } + + /// Asks straight away, whatever the switch in the settings says. + public async Task CheckNowAsync(UpdateChannel channel, + CancellationToken cancellationToken = default) + { + var lookup = await _feed.LatestAsync(channel, cancellationToken); + + // Not recorded, so the next start asks again instead of waiting a day. + if (!lookup.Answered) + return UpdateCheckOutcome.NoAnswer; + + WriteRecord(channel, lookup.Release); + Publish(channel, lookup.Release); + return Available is null ? UpdateCheckOutcome.UpToDate : UpdateCheckOutcome.UpdateAvailable; + } + + /// + /// "v1.4.107" as 1.4.107. Anything that is not a plain version - a suffix, a + /// rolling tag like "staging" - is not an update. + /// + public static Version? ParseTag(string tag) + { + string text = tag.StartsWith('v') || tag.StartsWith('V') ? tag[1..] : tag; + return Version.TryParse(text, out var version) ? Normalize(version) : null; + } + + /// The commit a build carries after the "+" of its informational version. + public static string? CommitOf(string? informationalVersion) + { + int plus = informationalVersion?.IndexOf('+') ?? -1; + return plus >= 0 && plus < informationalVersion!.Length - 1 ? informationalVersion[(plus + 1)..] : null; + } + + private void Publish(UpdateChannel channel, ReleaseInfo? release) + { + // Compared again on every start: the build remembered this morning may be + // the very one installed since. + var available = channel == UpdateChannel.Staging ? StagingUpdate(release) : StableUpdate(release); + if (available == Available) + return; + + Available = available; + Changed?.Invoke(); + } + + private AvailableUpdate? StableUpdate(ReleaseInfo? release) => + release is not null && ParseTag(release.Tag) is { } version && version > Current + ? new AvailableUpdate(version.ToString(), release.PageUrl) + : null; + + // The staging build has no version of its own, only its commit, and the newest + // one is the only one there is: any build but the running one is worth offering. + private AvailableUpdate? StagingUpdate(ReleaseInfo? release) => + release is { Commit: { Length: > 0 } commit } && !SameCommit(commit, _commit) + ? new AvailableUpdate($"{release.Tag} {commit[..Math.Min(ShortCommit, commit.Length)]}", release.PageUrl) + : null; + + private static bool SameCommit(string a, string? b) => + !string.IsNullOrEmpty(b) && + (a.StartsWith(b, StringComparison.OrdinalIgnoreCase) || b.StartsWith(a, StringComparison.OrdinalIgnoreCase)); + + private string Today() => _clock.Now.ToString(DayFormat, CultureInfo.InvariantCulture); + + private (string Day, UpdateChannel Channel, ReleaseInfo? Release)? ReadRecord() + { + try + { + if (!_files.FileExists(_recordPath)) + return null; + + string[] lines = _files.ReadAllText(_recordPath).Split('\n', StringSplitOptions.TrimEntries); + if (lines.Length < 5 || !Enum.TryParse(lines[1], out UpdateChannel channel)) + return null; + + var release = lines[2].Length > 0 && lines[3].Length > 0 + ? new ReleaseInfo(lines[2], lines[3], lines[4].Length > 0 ? lines[4] : null) + : null; + return (lines[0], channel, release); + } + catch (Exception ex) + { + _log.Log($"reading {_recordPath}", ex); + return null; + } + } + + private void WriteRecord(UpdateChannel channel, ReleaseInfo? release) + { + try + { + if (Path.GetDirectoryName(_recordPath) is { Length: > 0 } directory) + _files.CreateDirectory(directory); + + _files.WriteAllText(_recordPath, string.Join('\n', Today(), channel, release?.Tag ?? "", + release?.PageUrl ?? "", release?.Commit ?? "")); + } + catch (Exception ex) + { + _log.Log($"writing {_recordPath}", ex); + } + } + + // The assembly carries four parts and the tags three; an unset part reads as -1. + private static Version Normalize(Version version) => + new(version.Major, version.Minor, Math.Max(version.Build, 0)); +} diff --git a/StarterNG/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs index e1dea3c..5dc28f8 100644 --- a/StarterNG/Domain/Settings/SimulatorSettings.cs +++ b/StarterNG/Domain/Settings/SimulatorSettings.cs @@ -36,6 +36,8 @@ public sealed class SimulatorSettings public bool VirtualShunting = true; public bool LogMissingVehicleFiles; public bool DeveloperTools; + public bool CheckForUpdates = true; + public UpdateChannel UpdateChannel = UpdateChannel.Stable; public int RenderEngine; public int Width = 1280; diff --git a/StarterNG/Domain/Settings/UpdateChannel.cs b/StarterNG/Domain/Settings/UpdateChannel.cs new file mode 100644 index 0000000..8f6b032 --- /dev/null +++ b/StarterNG/Domain/Settings/UpdateChannel.cs @@ -0,0 +1,11 @@ +namespace StarterNG.Domain.Settings; + +/// Which builds the starter offers as updates. +public enum UpdateChannel +{ + /// The versioned releases of main. + Stable, + + /// The rolling pre-release of the staging branch, for testers. + Staging +} diff --git a/StarterNG/Infrastructure/Adapters/GitHubReleaseFeed.cs b/StarterNG/Infrastructure/Adapters/GitHubReleaseFeed.cs new file mode 100644 index 0000000..002f678 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/GitHubReleaseFeed.cs @@ -0,0 +1,99 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; + +namespace StarterNG.Infrastructure.Adapters; + +public sealed class GitHubReleaseFeed : IReleaseFeed +{ + private const string ReleasesUrl = "https://api.github.com/repos/MaSzyna-EU07/StarterNG/releases/"; + + /// The tag CI keeps recreating for every build of the staging branch. + public const string StagingTag = "staging"; + + private readonly HttpClient _http; + private readonly IDiagnosticsLog _log; + + public GitHubReleaseFeed(IDiagnosticsLog log, Version current, HttpMessageHandler? handler = null) + { + _log = log; + _http = handler is null ? new HttpClient() : new HttpClient(handler); + _http.Timeout = TimeSpan.FromSeconds(10); + + // The API turns away requests without a User-Agent. + _http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StarterNG", current.ToString())); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + } + + public async Task LatestAsync(UpdateChannel channel, CancellationToken cancellationToken = default) + { + // "latest" is GitHub's own pick: the newest release that is not a pre-release. + string url = ReleasesUrl + (channel == UpdateChannel.Staging ? "tags/" + StagingTag : "latest"); + + try + { + using var response = await _http.GetAsync(url, cancellationToken); + + // Nothing released yet. + if (response.StatusCode == HttpStatusCode.NotFound) + return ReleaseLookup.Found(null); + + response.EnsureSuccessStatusCode(); + return ReleaseLookup.Found(Parse(await response.Content.ReadAsStringAsync(cancellationToken), channel)); + } + // Offline, rate limited or timed out: there is simply no answer this time, + // and a log entry on every start without a network would only be noise. + catch (HttpRequestException) + { + return ReleaseLookup.NoAnswer; + } + catch (TaskCanceledException) + { + return ReleaseLookup.NoAnswer; + } + catch (Exception ex) + { + _log.Log("update check", ex); + return ReleaseLookup.NoAnswer; + } + } + + public static ReleaseInfo? Parse(string json, UpdateChannel channel) + { + var release = JsonSerializer.Deserialize(json, GitHubReleaseContext.Default.GitHubReleaseDocument); + if (release is null || release.Draft || + string.IsNullOrEmpty(release.TagName) || string.IsNullOrEmpty(release.HtmlUrl)) + return null; + + bool belongs = channel == UpdateChannel.Staging + ? release.TagName == StagingTag + : !release.Prerelease; + + return belongs ? new ReleaseInfo(release.TagName, release.HtmlUrl, release.TargetCommitish) : null; + } +} + +internal sealed class GitHubReleaseDocument +{ + [JsonPropertyName("tag_name")] public string? TagName { get; set; } + + [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } + + [JsonPropertyName("target_commitish")] public string? TargetCommitish { get; set; } + + [JsonPropertyName("draft")] public bool Draft { get; set; } + + [JsonPropertyName("prerelease")] public bool Prerelease { get; set; } +} + +[JsonSerializable(typeof(GitHubReleaseDocument))] +internal partial class GitHubReleaseContext : JsonSerializerContext +{ +} diff --git a/StarterNG/Infrastructure/Settings/SettingsPaths.cs b/StarterNG/Infrastructure/Settings/SettingsPaths.cs index 75639c2..dbf1418 100644 --- a/StarterNG/Infrastructure/Settings/SettingsPaths.cs +++ b/StarterNG/Infrastructure/Settings/SettingsPaths.cs @@ -50,6 +50,9 @@ public sealed class SettingsPaths /// public string VehicleCacheDirectory() => Path.Combine(UserConfigDirectory(), "vehicles"); + /// The day of the last update check and the release it found. + public string UpdateCheckPath() => Path.Combine(UserConfigDirectory(), "updatecheck.txt"); + public string UserConfigDirectory() => Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory; } diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs index 79260d8..f7e2e0e 100644 --- a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs +++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs @@ -57,6 +57,11 @@ public sealed class SettingsSerializer s.VirtualShunting = c.GetBool("ai.trainman", true); s.LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false); s.DeveloperTools = c.GetBool("starter.developertools", false); + s.CheckForUpdates = c.GetBool("starter.checkupdates", true); + s.UpdateChannel = string.Equals(c.GetString("starter.updatechannel", "stable"), "staging", + StringComparison.OrdinalIgnoreCase) + ? UpdateChannel.Staging + : UpdateChannel.Stable; s.RenderEngine = IndexOf(SimulatorSettings.RenderEngines, c.GetString("gfxrenderer", "full"), 0); s.Width = c.GetInt("width", 1280); @@ -197,6 +202,8 @@ public sealed class SettingsSerializer c.SetBool("ai.trainman", s.VirtualShunting); c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles); c.SetBool("starter.developertools", s.DeveloperTools); + c.SetBool("starter.checkupdates", s.CheckForUpdates); + c.Set("starter.updatechannel", s.UpdateChannel == UpdateChannel.Staging ? "staging" : "stable"); c.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]); c.SetInt("width", s.Width); diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml index c631bd1..7f3ce12 100644 --- a/StarterNG/MainWindow.axaml +++ b/StarterNG/MainWindow.axaml @@ -55,6 +55,16 @@ + +