diff --git a/CursorLang.Tests/Services/GiteaReleaseFeedTests.cs b/CursorLang.Tests/Services/GiteaReleaseFeedTests.cs new file mode 100644 index 0000000..36f80eb --- /dev/null +++ b/CursorLang.Tests/Services/GiteaReleaseFeedTests.cs @@ -0,0 +1,305 @@ +using System.Net; +using System.Net.Http; +using System.Runtime.InteropServices; +using CursorLang.Models; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; + +namespace CursorLang.Tests.Services; + +/// +/// Reading the release list of Gitea. The answer of the server is not ours to +/// shape, so what matters is what the app makes of it. +/// +public sealed class GiteaReleaseFeedTests +{ + private const string Releases = """ + [ + { + "tag_name": "v1.2.0", + "draft": false, + "prerelease": false, + "html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", + "assets": [ + { + "name": "CursorLang-1.2.0.0.msixbundle", + "browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle", + "size": 4096 + } + ] + } + ] + """; + + [Fact] + public async Task A_release_is_read_whole() + { + ReleaseInfo? release = await Read(Releases); + + Assert.NotNull(release); + Assert.Equal(new Version(1, 2, 0, 0), release.Version); + Assert.Equal("v1.2.0", release.Tag); + Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString()); + Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName); + Assert.Equal( + "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle", + release.Package.Url.ToString()); + Assert.Equal(4096, release.Package.Size); + } + + // The API of Gitea lives on the server itself, next to the pages of the + // repository + [Fact] + public async Task The_request_goes_to_the_releases_of_the_project() + { + var handler = FakeHttpHandler.Json(Releases); + var feed = new GiteaReleaseFeed(handler.CreateClient(), Options()); + + await feed.GetLatestAsync(TestContext.Current.CancellationToken); + + Uri asked = Assert.Single(handler.Requests).RequestUri!; + Assert.Equal("git.alrakis.kz", asked.Host); + Assert.StartsWith( + "/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal); + } + + // A server sitting under a path of its own keeps that path: dropping it + // would send the request to a place that answers nothing + [Fact] + public async Task A_server_behind_a_path_keeps_it() + { + var handler = FakeHttpHandler.Json(Releases); + var feed = new GiteaReleaseFeed( + handler.CreateClient(), + new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" }); + + await feed.GetLatestAsync(TestContext.Current.CancellationToken); + + Uri asked = Assert.Single(handler.Requests).RequestUri!; + Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal); + } + + // «token» is the scheme of Gitea for keys of access + [Fact] + public void A_closed_repository_gets_the_token_it_asks_for() + { + var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret")); + using var request = new HttpRequestMessage(); + + feed.Authorize(request); + + Assert.Equal("token", request.Headers.Authorization?.Scheme); + Assert.Equal("secret", request.Headers.Authorization?.Parameter); + } + + [Fact] + public void An_open_repository_is_asked_without_a_token() + { + var feed = new GiteaReleaseFeed(new HttpClient(), Options()); + using var request = new HttpRequestMessage(); + + feed.Authorize(request); + + Assert.Null(request.Headers.Authorization); + } + + [Theory] + [InlineData("1.2.3", "1.2.3.0")] + [InlineData("v1.2.3", "1.2.3.0")] + [InlineData("V1.2", "1.2.0.0")] + [InlineData("1.2.3.4", "1.2.3.4")] + public async Task A_version_is_read_out_of_the_tag(string tag, string expected) + { + ReleaseInfo? release = await Read(WithTag(tag)); + + Assert.NotNull(release); + Assert.Equal(Version.Parse(expected), release.Version); + } + + // A pre-release version is not something the app offers by itself: + // such a version is asked for on purpose + [Theory] + [InlineData("v1.2.3-beta")] + [InlineData("nightly")] + [InlineData("release-1")] + public async Task A_tag_that_is_not_a_version_is_passed_over(string tag) + { + Assert.Null(await Read(WithTag(tag))); + } + + [Fact] + public async Task A_draft_and_a_pre_release_are_passed_over() + { + const string releases = """ + [ + { "tag_name": "v3.0.0", "draft": true, "assets": [ + { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }, + { "tag_name": "v2.0.0", "prerelease": true, "assets": [ + { "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }, + { "tag_name": "v1.0.0", "assets": [ + { "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] } + ] + """; + + ReleaseInfo? release = await Read(releases); + + Assert.NotNull(release); + Assert.Equal(new Version(1, 0, 0, 0), release.Version); + } + + // The order of the releases belongs to the server, the highest number to + // the app: a fix to an older branch can be the freshest release + [Fact] + public async Task The_highest_version_wins_over_the_order_of_the_answer() + { + const string releases = """ + [ + { "tag_name": "v1.0.5", "assets": [ + { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }, + { "tag_name": "v2.0.0", "assets": [ + { "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] } + ] + """; + + ReleaseInfo? release = await Read(releases); + + Assert.NotNull(release); + Assert.Equal(new Version(2, 0, 0, 0), release.Version); + } + + [Fact] + public async Task A_release_without_a_package_is_passed_over() + { + const string releases = """ + [ + { "tag_name": "v2.0.0", "assets": [ + { "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] }, + { "tag_name": "v1.0.0", "assets": [ + { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] } + ] + """; + + ReleaseInfo? release = await Read(releases); + + Assert.NotNull(release); + Assert.Equal(new Version(1, 0, 0, 0), release.Version); + } + + // The signature is what Windows checks, but a package offered over an open + // connection is not worth downloading in the first place + [Fact] + public async Task A_package_offered_over_an_open_connection_is_passed_over() + { + const string releases = """ + [ + { "tag_name": "v2.0.0", "assets": [ + { "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] } + ] + """; + + Assert.Null(await Read(releases)); + } + + [Fact] + public async Task A_bundle_wins_over_the_packages_of_single_architectures() + { + const string releases = """ + [ + { "tag_name": "v2.0.0", "assets": [ + { "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" }, + { "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] } + ] + """; + + ReleaseInfo? release = await Read(releases); + + Assert.NotNull(release); + Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString()); + } + + [Fact] + public async Task Out_of_several_packages_the_one_for_this_machine_is_taken() + { + const string releases = """ + [ + { "tag_name": "v2.0.0", "assets": [ + { "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" }, + { "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] } + ] + """; + + string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? "https://host/arm64.msix" + : "https://host/x64.msix"; + + ReleaseInfo? release = await Read(releases); + + Assert.NotNull(release); + Assert.Equal(expected, release.Package.Url.ToString()); + } + + // Without the architecture in the name there is no telling which package is + // for this machine — unless it is the only one there + [Fact] + public async Task A_package_without_an_architecture_is_taken_only_when_alone() + { + const string alone = """ + [ + { "tag_name": "v2.0.0", "assets": [ + { "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] } + ] + """; + + const string ambiguous = """ + [ + { "tag_name": "v2.0.0", "assets": [ + { "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" }, + { "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] } + ] + """; + + Assert.NotNull(await Read(alone)); + Assert.Null(await Read(ambiguous)); + } + + [Fact] + public async Task An_empty_list_of_releases_means_nothing_to_offer() + { + Assert.Null(await Read("[]")); + } + + // A server answering with something else is no reason to fail + [Fact] + public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing() + { + Assert.Null(await Read("""{ "message": "Not Found" }""")); + } + + [Fact] + public async Task A_refusal_of_the_server_is_raised() + { + var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized); + var feed = new GiteaReleaseFeed(handler.CreateClient(), Options()); + + await Assert.ThrowsAsync( + () => feed.GetLatestAsync(TestContext.Current.CancellationToken)); + } + + private static string WithTag(string tag) => $$""" + [ + { "tag_name": "{{tag}}", "assets": [ + { "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] } + ] + """; + + private static UpdateOptions Options(string? token = null) => new() + { + ServiceUri = new Uri("https://git.alrakis.kz/"), + Project = "alrakis/cursor-lang", + AccessToken = token, + }; + + private static Task Read(string json) => + new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options()) + .GetLatestAsync(TestContext.Current.CancellationToken); +} diff --git a/CursorLang.Tests/Services/UpdateOptionsTests.cs b/CursorLang.Tests/Services/UpdateOptionsTests.cs new file mode 100644 index 0000000..4caca12 --- /dev/null +++ b/CursorLang.Tests/Services/UpdateOptionsTests.cs @@ -0,0 +1,38 @@ +using CursorLang.Services; + +namespace CursorLang.Tests.Services; + +/// +/// Where the app looks for its releases. The values belong to the build, and a +/// wrong one shows only as an update that never arrives. +/// +public sealed class UpdateOptionsTests +{ + [Fact] + public void Out_of_the_box_the_releases_are_looked_for_in_the_repository_of_the_app() + { + var options = new UpdateOptions(); + + Assert.Equal("git.alrakis.kz", options.ServiceUri.Host); + Assert.Equal("alrakis/cursor-lang", options.Project); + } + + [Fact] + public void Another_server_is_taken_as_it_is_given() + { + var options = new UpdateOptions + { + ServiceUri = new Uri("https://git.example.com/"), + Project = "team/app", + }; + + Assert.Equal("git.example.com", options.ServiceUri.Host); + Assert.Equal("team/app", options.Project); + } + + [Fact] + public void The_app_asks_about_releases_no_more_than_once_a_day() + { + Assert.Equal(TimeSpan.FromDays(1), new UpdateOptions().CheckInterval); + } +} diff --git a/CursorLang.Tests/Services/UpdateServiceTests.cs b/CursorLang.Tests/Services/UpdateServiceTests.cs new file mode 100644 index 0000000..7f17fc2 --- /dev/null +++ b/CursorLang.Tests/Services/UpdateServiceTests.cs @@ -0,0 +1,158 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using CursorLang.Models; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; + +namespace CursorLang.Tests.Services; + +/// +/// What the app does with a release once it has found one: whether it is newer +/// at all, and what ends up on disk. +/// +public sealed class UpdateServiceTests +{ + [Theory] + [InlineData("1.0.0.0", "1.0.1.0", true)] + [InlineData("1.0.0.0", "2.0.0.0", true)] + [InlineData("1.0.0.0", "1.0.0.0", false)] + [InlineData("1.0.1.0", "1.0.0.0", false)] + public async Task Only_a_higher_version_counts_as_an_update(string current, string found, bool offered) + { + var feed = new FakeReleaseFeed { Release = Release(found) }; + using TempFolder folder = new(); + using UpdateService service = Create(feed, folder, current); + + ReleaseInfo? update = await service.CheckAsync(TestContext.Current.CancellationToken); + + Assert.Equal(offered, update is not null); + } + + [Fact] + public async Task An_empty_repository_leaves_the_app_with_nothing() + { + var feed = new FakeReleaseFeed { Release = null }; + using TempFolder folder = new(); + using UpdateService service = Create(feed, folder); + + Assert.Null(await service.CheckAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task The_package_ends_up_on_disk_whole() + { + byte[] content = Encoding.UTF8.GetBytes(new string('p', 300_000)); + using TempFolder folder = new(); + using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content)); + + string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken); + + Assert.Equal(content, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)); + } + + // The name comes from the version, not from the answer: the app creates a + // file with it, and the answer comes from the other side + [Fact] + public async Task The_name_of_the_file_is_built_by_the_app_itself() + { + using TempFolder folder = new(); + using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1, 2, 3])); + + var release = new ReleaseInfo( + new Version(2, 0, 0, 0), + "v2.0.0", + null, + new ReleaseAsset(@"..\..\evil.msixbundle", new Uri("https://host/a"), 3)); + + string path = await service.DownloadAsync(release, null, TestContext.Current.CancellationToken); + + Assert.Equal("CursorLang-2.0.0.0.msixbundle", Path.GetFileName(path)); + Assert.Equal(folder.Path, Path.GetDirectoryName(path)); + } + + [Fact] + public async Task The_download_reports_how_far_it_has_come() + { + byte[] content = new byte[500_000]; + using TempFolder folder = new(); + using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content)); + + var reported = new CollectingProgress(); + await service.DownloadAsync(Release("2.0.0.0"), reported, TestContext.Current.CancellationToken); + + Assert.NotEmpty(reported.Values); + Assert.All(reported.Values, value => Assert.InRange(value, 0, 1)); + Assert.Equal(reported.Values, [.. reported.Values.Order()]); + Assert.Equal(1, reported.Values[^1]); + } + + [Fact] + public async Task A_closed_repository_gets_the_token_with_the_download_too() + { + var feed = new FakeReleaseFeed(); + using TempFolder folder = new(); + using UpdateService service = Create(feed, folder, client: FakeHttpHandler.Bytes([1])); + + await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken); + + Assert.Single(feed.Authorized); + } + + [Fact] + public async Task A_refusal_of_the_hosting_service_leaves_no_package_behind() + { + using TempFolder folder = new(); + using UpdateService service = Create( + new FakeReleaseFeed(), folder, client: FakeHttpHandler.Status(HttpStatusCode.NotFound)); + + await Assert.ThrowsAsync( + () => service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken)); + + Assert.Empty(Directory.GetFiles(folder.Path)); + } + + // A package left from an earlier download takes up room and is of no use + // once it has been installed + [Fact] + public async Task An_older_download_is_cleared_away() + { + using TempFolder folder = new(); + string leftover = folder.File("CursorLang-1.5.0.0.msixbundle"); + await File.WriteAllTextAsync(leftover, "old", TestContext.Current.CancellationToken); + + using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1])); + string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken); + + Assert.False(File.Exists(leftover)); + Assert.True(File.Exists(path)); + } + + /// + /// The reports as the download makes them. Progress<T> would + /// hand them over to another thread, and a test has nowhere to wait for that. + /// + private sealed class CollectingProgress : IProgress + { + internal List Values { get; } = []; + + public void Report(double value) => Values.Add(value); + } + + private static ReleaseInfo Release(string version) => new( + Version.Parse(version), + $"v{version}", + new Uri("https://host/releases/tag"), + new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0)); + + private static UpdateService Create( + IReleaseFeed feed, + TempFolder folder, + string current = "1.0.0.0", + FakeHttpHandler? client = null) => + new(feed, + (client ?? FakeHttpHandler.Bytes([])).CreateClient(), + Version.Parse(current), + folder.Path); +} diff --git a/CursorLang.Tests/ViewModels/UpdateViewModelTests.cs b/CursorLang.Tests/ViewModels/UpdateViewModelTests.cs new file mode 100644 index 0000000..de9c80c --- /dev/null +++ b/CursorLang.Tests/ViewModels/UpdateViewModelTests.cs @@ -0,0 +1,292 @@ +using System.IO; +using System.Net.Http; +using CursorLang.Models; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; +using CursorLang.ViewModels; + +namespace CursorLang.Tests.ViewModels; + +/// +/// The updates section of the settings window: what it shows at every step and +/// what it asks of the service behind it. +/// +public sealed class UpdateViewModelTests +{ + [Fact] + public void Before_the_first_check_the_section_says_nothing() + { + using UpdateViewModel viewModel = Create(new FakeUpdateService()); + + Assert.Equal(UpdateStatus.Idle, viewModel.Status); + Assert.False(viewModel.HasStatus); + Assert.False(viewModel.IsDownloadOffered); + Assert.False(viewModel.IsInstallOffered); + Assert.True(viewModel.CanCheck); + } + + [Fact] + public async Task An_update_found_is_offered_for_download() + { + var updates = new FakeUpdateService { Release = Release("2.0.0.0") }; + using UpdateViewModel viewModel = Create(updates); + + await viewModel.CheckCommand.ExecuteAsync(null); + + Assert.Equal(UpdateStatus.Available, viewModel.Status); + Assert.True(viewModel.IsDownloadOffered); + Assert.False(viewModel.IsInstallOffered); + Assert.Equal("https://host/releases/tag", viewModel.ReleaseUrl?.ToString()); + Assert.True(viewModel.IsReleaseLinkShown); + Assert.Equal("en:UpdateAvailable", viewModel.StatusText); + } + + [Fact] + public async Task With_the_latest_version_installed_there_is_nothing_to_offer() + { + using UpdateViewModel viewModel = Create(new FakeUpdateService()); + + await viewModel.CheckCommand.ExecuteAsync(null); + + Assert.Equal(UpdateStatus.UpToDate, viewModel.Status); + Assert.False(viewModel.IsDownloadOffered); + Assert.False(viewModel.IsReleaseLinkShown); + } + + [Fact] + public async Task A_check_by_the_button_says_when_it_did_not_work_out() + { + var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") }; + using UpdateViewModel viewModel = Create(updates); + + await viewModel.CheckCommand.ExecuteAsync(null); + + Assert.Equal(UpdateStatus.Failed, viewModel.Status); + Assert.True(viewModel.HasStatus); + } + + // The app does not always start with a live network, and the user who never + // asked about updates has no use for the complaint + [Fact] + public async Task A_check_at_startup_keeps_a_failure_to_itself() + { + var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") }; + using UpdateViewModel viewModel = Create(updates); + + await viewModel.StartAsync(); + + Assert.Equal(UpdateStatus.Idle, viewModel.Status); + Assert.False(viewModel.HasStatus); + } + + [Fact] + public async Task A_successful_check_is_remembered_in_the_settings() + { + var settings = new AppSettings(); + var updates = new FakeUpdateService { Release = Release("2.0.0.0") }; + using UpdateViewModel viewModel = Create(updates, settings); + + await viewModel.CheckCommand.ExecuteAsync(null); + + Assert.NotNull(settings.LastUpdateCheck); + } + + [Fact] + public async Task A_check_that_did_not_work_out_is_not_remembered() + { + var settings = new AppSettings(); + var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") }; + using UpdateViewModel viewModel = Create(updates, settings); + + await viewModel.CheckCommand.ExecuteAsync(null); + + Assert.Null(settings.LastUpdateCheck); + } + + [Fact] + public async Task A_recent_check_is_not_repeated_at_startup() + { + var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow }; + var updates = new FakeUpdateService(); + using UpdateViewModel viewModel = Create(updates, settings); + + await viewModel.StartAsync(); + + Assert.Equal(0, updates.CheckCalls); + } + + [Fact] + public async Task A_check_of_yesterday_is_repeated_at_startup() + { + var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow - TimeSpan.FromDays(2) }; + var updates = new FakeUpdateService(); + using UpdateViewModel viewModel = Create(updates, settings); + + await viewModel.StartAsync(); + + Assert.Equal(1, updates.CheckCalls); + } + + [Fact] + public async Task A_ban_on_checking_by_itself_is_obeyed() + { + var settings = new AppSettings { CheckForUpdates = false }; + var updates = new FakeUpdateService(); + using UpdateViewModel viewModel = Create(updates, settings); + + await viewModel.StartAsync(); + + Assert.Equal(0, updates.CheckCalls); + + // The button still works: the setting is about the app doing it on its own + await viewModel.CheckCommand.ExecuteAsync(null); + Assert.Equal(1, updates.CheckCalls); + } + + [Fact] + public void The_ban_on_checking_travels_to_the_settings() + { + var settings = new AppSettings { CheckForUpdates = true }; + using UpdateViewModel viewModel = Create(new FakeUpdateService(), settings); + + viewModel.CheckAutomatically = false; + + Assert.False(settings.CheckForUpdates); + } + + [Fact] + public async Task A_package_from_Store_is_left_to_Store() + { + var updates = new FakeUpdateService { IsSupported = false, Release = Release("2.0.0.0") }; + using UpdateViewModel viewModel = Create(updates); + + await viewModel.StartAsync(); + await viewModel.CheckCommand.ExecuteAsync(null); + + Assert.False(viewModel.IsSupported); + Assert.Equal(0, updates.CheckCalls); + } + + [Fact] + public async Task A_downloaded_package_is_offered_for_installation() + { + using TempFolder folder = new(); + string package = folder.File("CursorLang-2.0.0.0.msixbundle"); + await File.WriteAllTextAsync(package, "package", TestContext.Current.CancellationToken); + + var updates = new FakeUpdateService { Release = Release("2.0.0.0"), PackagePath = package }; + using UpdateViewModel viewModel = Create(updates); + + await viewModel.CheckCommand.ExecuteAsync(null); + await viewModel.DownloadCommand.ExecuteAsync(null); + + Assert.Equal(UpdateStatus.Ready, viewModel.Status); + Assert.True(viewModel.IsInstallOffered); + Assert.False(viewModel.IsDownloadOffered); + + viewModel.InstallCommand.Execute(null); + + Assert.Equal([package], updates.Installed); + } + + [Fact] + public async Task While_the_package_is_downloading_the_section_shows_it() + { + var updates = new FakeUpdateService + { + Release = Release("2.0.0.0"), + DownloadGate = new TaskCompletionSource(), + }; + + using UpdateViewModel viewModel = Create(updates); + await viewModel.CheckCommand.ExecuteAsync(null); + + Task download = viewModel.DownloadCommand.ExecuteAsync(null); + + Assert.Equal(UpdateStatus.Downloading, viewModel.Status); + Assert.True(viewModel.IsProgressShown); + Assert.True(viewModel.IsBusy); + Assert.False(viewModel.CanCheck); + + updates.DownloadGate.SetResult(); + await download; + } + + [Fact] + public async Task A_download_that_did_not_work_out_is_told_about() + { + var updates = new FakeUpdateService { Release = Release("2.0.0.0") }; + using UpdateViewModel viewModel = Create(updates); + await viewModel.CheckCommand.ExecuteAsync(null); + + updates.Failure = new HttpRequestException("the connection dropped"); + await viewModel.DownloadCommand.ExecuteAsync(null); + + Assert.Equal(UpdateStatus.Failed, viewModel.Status); + } + + // The temp folder is cleared by Windows as it sees fit, and the app has no + // business handing a file that is gone to the installer + [Fact] + public async Task A_package_gone_from_the_disk_is_offered_for_download_again() + { + var updates = new FakeUpdateService + { + Release = Release("2.0.0.0"), + PackagePath = Path.Combine(Path.GetTempPath(), "CursorLang.Tests", "never-written.msixbundle"), + }; + + using UpdateViewModel viewModel = Create(updates); + await viewModel.CheckCommand.ExecuteAsync(null); + await viewModel.DownloadCommand.ExecuteAsync(null); + + viewModel.InstallCommand.Execute(null); + + Assert.Empty(updates.Installed); + Assert.Equal(UpdateStatus.Available, viewModel.Status); + } + + [Fact] + public async Task The_status_is_written_in_the_chosen_language() + { + var localization = new FakeLocalizationService(); + using UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization); + + await viewModel.CheckCommand.ExecuteAsync(null); + Assert.Equal("en:UpdateUpToDate", viewModel.StatusText); + + localization.CurrentLanguage = "ru"; + Assert.Equal("ru:UpdateUpToDate", viewModel.StatusText); + } + + [Fact] + public async Task Closing_unsubscribes_from_the_language() + { + var localization = new FakeLocalizationService(); + UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization); + await viewModel.CheckCommand.ExecuteAsync(null); + + List changed = []; + viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + viewModel.Dispose(); + localization.CurrentLanguage = "ru"; + + Assert.Empty(changed); + } + + private static ReleaseInfo Release(string version) => new( + Version.Parse(version), + $"v{version}", + new Uri("https://host/releases/tag"), + new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0)); + + private static UpdateViewModel Create( + IUpdateService updates, + AppSettings? settings = null, + ILocalizationService? localization = null) => + new(updates, + localization ?? new FakeLocalizationService(), + settings ?? new AppSettings(), + new UpdateOptions()); +} diff --git a/CursorLang/Services/GiteaReleaseFeed.cs b/CursorLang/Services/GiteaReleaseFeed.cs new file mode 100644 index 0000000..356d8ee --- /dev/null +++ b/CursorLang/Services/GiteaReleaseFeed.cs @@ -0,0 +1,229 @@ +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Text.Json; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Gitea releases. +/// +/// +/// The service address is the address of the server itself — "https://git.example.com/": +/// the Gitea API lives on the same host as the repository pages. +/// +internal sealed class GiteaReleaseFeed : IReleaseFeed +{ + /// + /// How many releases to ask the server for. It returns them newest first, but the + /// newest one may turn out to have no package — when the build has not been + /// published yet, for instance — so a small reserve is taken. + /// + private const int PageSize = 10; + + /// + /// A response with the release list is a few kilobytes of text. There is no point + /// waiting longer: the check runs in the background, and a failed one bothers nobody. + /// + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20); + + private readonly HttpClient _client; + private readonly UpdateOptions _options; + + public GiteaReleaseFeed(HttpClient client, UpdateOptions options) + { + _client = client; + _options = options; + } + + /// + /// How the architecture is spelled in the file names built by + /// build-msix.ps1: CursorLang-1.0.0.0-x64.msix. + /// + private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? "arm64" + : "x64"; + + public async Task GetLatestAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(RequestTimeout); + + using var request = new HttpRequestMessage(HttpMethod.Get, BuildReleasesUri()); + Authorize(request); + + using HttpResponseMessage response = await _client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + response.EnsureSuccessStatusCode(); + + await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token); + using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token); + + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + // The order of the releases is up to the server, while what we need is the + // highest version number: a fix released for an old branch may well be the newest one + return document.RootElement.EnumerateArray() + .Select(Read) + .OfType() + .MaxBy(release => release.Version); + } + + public void Authorize(HttpRequestMessage request) + { + if (!string.IsNullOrWhiteSpace(_options.AccessToken)) + { + // "token" is the Gitea scheme of its own for access keys; "Bearer" is not + // understood by every version, while this one has been there since the API appeared + request.Headers.Authorization = new AuthenticationHeaderValue("token", _options.AccessToken); + } + } + + /// + /// Parses a single release. null means the release will not do: a draft, + /// a prerelease or a release without a package. + /// + private static ReleaseInfo? Read(JsonElement release) + { + // A draft is visible only to whoever created it, and the application does not + // offer a prerelease: those are sought out deliberately + if (ReadFlag(release, "draft") || ReadFlag(release, "prerelease")) + { + return null; + } + + Version? version = ParseTag(ReadString(release, "tag_name")); + if (version is null) + { + return null; + } + + if (!release.TryGetProperty("assets", out JsonElement assets) || assets.ValueKind != JsonValueKind.Array) + { + return null; + } + + ReleaseAsset? package = PickPackage(assets.EnumerateArray().Select(ReadAsset).OfType()); + if (package is null) + { + return null; + } + + return new ReleaseInfo( + version, + ReadString(release, "tag_name") ?? version.ToString(), + ReadUri(release, "html_url"), + package); + } + + private static ReleaseAsset? ReadAsset(JsonElement asset) + { + string? name = ReadString(asset, "name"); + Uri? url = ReadUri(asset, "browser_download_url"); + + if (string.IsNullOrWhiteSpace(name) || url is null) + { + return null; + } + + long size = asset.TryGetProperty("size", out JsonElement value) && value.TryGetInt64(out long bytes) + ? bytes + : 0; + + return new ReleaseAsset(name, url, size); + } + + /// + /// The version from a tag. "1.2.3" and "v1.2.3" are understood; a tag with + /// anything besides numbers — "v1.2.3-beta" — counts as a prerelease and is + /// skipped: the application does not offer such versions on its own. + /// + private static Version? ParseTag(string? tag) + { + if (string.IsNullOrWhiteSpace(tag)) + { + return null; + } + + ReadOnlySpan numbers = tag.AsSpan().Trim().TrimStart("vV"); + + foreach (char symbol in numbers) + { + if (!char.IsAsciiDigit(symbol) && symbol != '.') + { + return null; + } + } + + if (!Version.TryParse(numbers, out Version? version)) + { + return null; + } + + // In a tag such as "v1.2" the lower parts are not set at all, yet comparing + // them with the version of the installed package calls for zeros + return new Version( + version.Major, + version.Minor, + Math.Max(version.Build, 0), + Math.Max(version.Revision, 0)); + } + + /// + /// Picks the attached file the application updates itself with. + /// + private static ReleaseAsset? PickPackage(IEnumerable assets) + { + // An unencrypted connection is out right away: Windows will check the package + // signature by itself, but a substituted file is not even worth downloading + ReleaseAsset[] packages = [.. assets.Where(asset => asset.Url.Scheme == Uri.UriSchemeHttps)]; + + ReleaseAsset? bundle = packages.FirstOrDefault( + asset => asset.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)); + + if (bundle is not null) + { + // A bundle carries both architectures, so there is nothing to choose between + return bundle; + } + + ReleaseAsset[] single = [.. packages.Where( + asset => asset.FileName.EndsWith(".msix", StringComparison.OrdinalIgnoreCase))]; + + ReleaseAsset? matching = single.FirstOrDefault( + asset => asset.FileName.Contains(ArchitectureName, StringComparison.OrdinalIgnoreCase)); + + // A package without an architecture in its name will do only when it is the + // only one: otherwise it is unclear which of them is for this machine + return matching ?? (single.Length == 1 ? single[0] : null); + } + + private static string? ReadString(JsonElement element, string name) => + element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static Uri? ReadUri(JsonElement element, string name) => + Uri.TryCreate(ReadString(element, name), UriKind.Absolute, out Uri? uri) ? uri : null; + + private static bool ReadFlag(JsonElement element, string name) => + element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True; + + /// + /// The address the server returns the release list at. The trailing slash matters: + /// without it Uri drops the last part of the address, and + /// "https://host/gitea" would have turned into "https://host/api/...". + /// + private Uri BuildReleasesUri() + { + string service = _options.ServiceUri.AbsoluteUri; + string path = $"api/v1/repos/{_options.Project.Trim('/')}/releases?limit={PageSize}"; + + return new Uri(service.EndsWith('/') ? service + path : $"{service}/{path}"); + } +} diff --git a/CursorLang/Services/IReleaseFeed.cs b/CursorLang/Services/IReleaseFeed.cs new file mode 100644 index 0000000..45f7434 --- /dev/null +++ b/CursorLang/Services/IReleaseFeed.cs @@ -0,0 +1,22 @@ +using System.Net.Http; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// The release list of the repository. +/// +public interface IReleaseFeed +{ + /// + /// Returns the newest release carrying an MSIX package, or null + /// when there is no suitable release. + /// + Task GetLatestAsync(CancellationToken cancellationToken); + + /// + /// Adds to the request whatever a private repository needs. The package is + /// downloaded not by the list itself, but access to it is closed just the same. + /// + void Authorize(HttpRequestMessage request); +} diff --git a/CursorLang/Services/IUpdateService.cs b/CursorLang/Services/IUpdateService.cs new file mode 100644 index 0000000..812638a --- /dev/null +++ b/CursorLang/Services/IUpdateService.cs @@ -0,0 +1,40 @@ +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Checking for and installing new versions of the application. +/// +public interface IUpdateService +{ + /// + /// It makes sense for this installation to update itself. + /// + /// + /// An application installed from the Store is updated by the Store itself: + /// offering a package from elsewhere on top of it will not do — Windows would + /// not accept it anyway. + /// + bool IsSupported { get; } + + /// The version of the running application. + Version CurrentVersion { get; } + + /// + /// Looks for a release newer than the installed one. null means the latest + /// version is installed or there is no suitable release in the repository. + /// + Task CheckAsync(CancellationToken cancellationToken); + + /// + /// Downloads the release package and returns the path to it. + /// + /// + /// progress receives the downloaded fraction from 0 to 1. While the file + /// size is unknown — not every hosting reports it — there will be no calls at all. + /// + Task DownloadAsync(ReleaseInfo release, IProgress? progress, CancellationToken cancellationToken); + + /// Hands the downloaded package over to the Windows app installer. + void Install(string packagePath); +} diff --git a/CursorLang/Services/UpdateOptions.cs b/CursorLang/Services/UpdateOptions.cs new file mode 100644 index 0000000..60d9939 --- /dev/null +++ b/CursorLang/Services/UpdateOptions.cs @@ -0,0 +1,37 @@ +namespace CursorLang.Services; + +/// +/// Where the application learns about new versions from. +/// +/// +/// These settings belong to the build rather than to the user: the repository is +/// chosen by whoever releases the application, and these values have no business +/// being in settings.json. The defaults point at the repository the +/// application is built from. +/// +public sealed class UpdateOptions +{ + /// + /// The address of the Gitea server. Its API lives on the same host as the + /// repository pages, so this is the same address the repository is opened at + /// in a browser. + /// + public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/"); + + /// The project: owner/repository. + public string Project { get; init; } = "alrakis/cursor-lang"; + + /// How often the application checks the releases on its own. + public TimeSpan CheckInterval { get; init; } = TimeSpan.FromDays(1); + + /// + /// An access token for a private repository. + /// + /// + /// Taken from an environment variable rather than from a file in the repository: + /// a secret that gets into a build gets to everyone who received it as well. + /// A public repository needs no token at all. + /// + public string? AccessToken { get; init; } = + Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN"); +} diff --git a/CursorLang/Services/UpdateService.cs b/CursorLang/Services/UpdateService.cs new file mode 100644 index 0000000..b74fc05 --- /dev/null +++ b/CursorLang/Services/UpdateService.cs @@ -0,0 +1,226 @@ +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.InteropServices; +using CursorLang.Interop; +using CursorLang.Models; +using Windows.ApplicationModel; + +namespace CursorLang.Services; + +/// +/// Learns about new versions from the repository and hands the downloaded package +/// over to the installer. +/// +/// +/// The package is installed by the Windows app installer, not by the application on +/// its own. Through PackageManager the update would go without a single +/// window, but then the application would have to explain both an untrusted signature +/// and a policy ban to the user itself — the installer already knows how to do all +/// that and shows the package publisher before the installation, not after. +/// +public sealed class UpdateService : IUpdateService, IDisposable +{ + /// The package is large and the network can be slow: the buffer is taken with room to spare. + private const int BufferSize = 81920; + + /// The version of the running application — it does not change while it runs. + private static readonly Version Current = DetectCurrentVersion(); + + private readonly IReleaseFeed _feed; + private readonly HttpClient _client; + private readonly bool _ownsClient; + private readonly string _downloadFolder; + + public UpdateService(UpdateOptions options) + { + _client = CreateClient(); + _ownsClient = true; + _downloadFolder = Path.Combine(Path.GetTempPath(), "CursorLang"); + _feed = new GiteaReleaseFeed(_client, options); + + CurrentVersion = Current; + } + + /// + /// Takes the releases, the network and the version explicitly: in tests they are + /// not provided by Windows. + /// + internal UpdateService(IReleaseFeed feed, HttpClient client, Version current, string downloadFolder) + { + _feed = feed; + _client = client; + _ownsClient = false; + _downloadFolder = downloadFolder; + + CurrentVersion = current; + } + + public bool IsSupported { get; } = DetectSupport(); + + public Version CurrentVersion { get; } + + public async Task CheckAsync(CancellationToken cancellationToken) + { + ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken); + return release is not null && release.Version > CurrentVersion ? release : null; + } + + public async Task DownloadAsync( + ReleaseInfo release, + IProgress? progress, + CancellationToken cancellationToken) + { + string path = Path.Combine(_downloadFolder, BuildFileName(release)); + string partial = path + ".part"; + + Directory.CreateDirectory(_downloadFolder); + RemoveLeftovers(path); + + using var request = new HttpRequestMessage(HttpMethod.Get, release.Package.Url); + _feed.Authorize(request); + + using HttpResponseMessage response = await _client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + long total = response.Content.Headers.ContentLength ?? release.Package.Size; + + await using (Stream source = await response.Content.ReadAsStreamAsync(cancellationToken)) + await using (FileStream target = File.Create(partial)) + { + byte[] buffer = new byte[BufferSize]; + long copied = 0; + int reported = -1; + int read; + + while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) + { + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + copied += read; + + if (total <= 0) + { + continue; + } + + // The progress bar cannot tell fractions of a percent apart, and + // redrawing on every chunk read would cost more than the download itself + int percent = (int)(copied * 100 / total); + if (percent != reported) + { + reported = percent; + progress?.Report(percent / 100d); + } + } + } + + // A file becomes ready only once downloaded in full: an interrupted download + // must not stay on disk under the package name + File.Move(partial, path, overwrite: true); + return path; + } + + public void Install(string packagePath) => + Process.Start(new ProcessStartInfo(packagePath) { UseShellExecute = true })?.Dispose(); + + public void Dispose() + { + if (_ownsClient) + { + _client.Dispose(); + } + } + + private static HttpClient CreateClient() + { + // The check and the download have different deadlines: seconds are enough for + // the first one, while the second one takes minutes on a slow network. So the + // client has no shared timeout, and every operation allots time for itself + var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(15) }; + var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + + // The User-Agent shows who came: a request without one may well be taken + // for a robot and rejected by the server + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CursorLang", Current.ToString())); + + return client; + } + + /// + /// Whether to check for updates at all: a package from the Store gets them from the Store. + /// + private static bool DetectSupport() + { + if (!PackageIdentityNative.IsPackaged) + { + return true; + } + + try + { + return Package.Current.SignatureKind != PackageSignatureKind.Store; + } + catch (Exception e) when (e is COMException or InvalidOperationException) + { + return true; + } + } + + private static Version DetectCurrentVersion() + { + if (PackageIdentityNative.IsPackaged) + { + try + { + // A package has a version of its own — the one from the manifest. That + // is also the one in the release tag, while the assembly version may differ + PackageVersion version = Package.Current.Id.Version; + return new Version(version.Major, version.Minor, version.Build, version.Revision); + } + catch (Exception e) when (e is COMException or InvalidOperationException) + { + // The package was built without a version in the manifest — the assembly version is left + } + } + + return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0); + } + + /// + /// The file name on disk. Only the extension is taken from the hosting response: + /// the name itself comes from the outside, and a file is created with it. + /// + private static string BuildFileName(ReleaseInfo release) + { + string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase) + ? ".msixbundle" + : ".msix"; + + return $"CursorLang-{release.Version}{extension}"; + } + + /// + /// Removes packages downloaded earlier: they take up a noticeable amount of + /// space and are needed only until the installation. + /// + private void RemoveLeftovers(string keep) + { + try + { + foreach (string file in Directory.EnumerateFiles(_downloadFolder)) + { + if (!string.Equals(file, keep, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(file); + } + } + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // The file is held by another installer — that does not get in the way of the update + } + } +} diff --git a/CursorLang/ViewModels/UpdateViewModel.cs b/CursorLang/ViewModels/UpdateViewModel.cs new file mode 100644 index 0000000..f017cd8 --- /dev/null +++ b/CursorLang/ViewModels/UpdateViewModel.cs @@ -0,0 +1,267 @@ +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CursorLang.Models; +using CursorLang.Services; + +namespace CursorLang.ViewModels; + +/// +/// The updates section of the settings window. +/// +/// +/// A failed check at startup passes in silence: the application does not always start +/// with a live network, and there is no point complaining about it to a user who did +/// not ask about updates. A check started by the button does report a failure — it is +/// awaited and watched. +/// +public sealed partial class UpdateViewModel : ObservableObject, IDisposable +{ + private readonly IUpdateService _updates; + private readonly ILocalizationService _localization; + private readonly AppSettings _settings; + private readonly UpdateOptions _options; + + private CancellationTokenSource? _work; + private ReleaseInfo? _release; + private string? _packagePath; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + [NotifyPropertyChangedFor(nameof(IsBusy))] + [NotifyPropertyChangedFor(nameof(CanCheck))] + [NotifyPropertyChangedFor(nameof(HasStatus))] + [NotifyPropertyChangedFor(nameof(IsProgressUnknown))] + [NotifyPropertyChangedFor(nameof(IsDownloadOffered))] + [NotifyPropertyChangedFor(nameof(IsInstallOffered))] + [NotifyPropertyChangedFor(nameof(IsProgressShown))] + [NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))] + private UpdateStatus _status = UpdateStatus.Idle; + + /// The downloaded fraction: from 0 to 1. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsProgressUnknown))] + private double _progress; + + public UpdateViewModel( + IUpdateService updates, + ILocalizationService localization, + AppSettings settings, + UpdateOptions options) + { + _updates = updates; + _localization = localization; + _settings = settings; + _options = options; + + _localization.PropertyChanged += OnLocalizationChanged; + } + + /// Whether to show the updates section at all. + public bool IsSupported => _updates.IsSupported; + + public string CurrentVersion => _updates.CurrentVersion.ToString(); + + /// Check for new versions at startup. + public bool CheckAutomatically + { + get => _settings.CheckForUpdates; + set + { + if (value == _settings.CheckForUpdates) + { + return; + } + + _settings.CheckForUpdates = value; + OnPropertyChanged(); + } + } + + /// A request or a download is in flight — the buttons freeze for that time. + public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading; + + public bool CanCheck => !IsBusy; + + public bool HasStatus => Status != UpdateStatus.Idle; + + /// + /// The package size is unknown, and the bar shows only the fact of the download. + /// Its start looks the same until the first report arrives. + /// + public bool IsProgressUnknown => Status == UpdateStatus.Downloading && Progress <= 0; + + public bool IsDownloadOffered => Status == UpdateStatus.Available; + + public bool IsInstallOffered => Status == UpdateStatus.Ready; + + public bool IsProgressShown => Status == UpdateStatus.Downloading; + + public bool IsReleaseLinkShown => _release?.PageUrl is not null && Status is not UpdateStatus.Checking; + + /// The release page: the release notes live there too. + public Uri? ReleaseUrl => _release?.PageUrl; + + public string StatusText => Status switch + { + UpdateStatus.Checking => _localization["UpdateChecking"], + UpdateStatus.UpToDate => _localization["UpdateUpToDate"], + UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag), + UpdateStatus.Downloading => _localization["UpdateDownloading"], + UpdateStatus.Ready => _localization["UpdateReady"], + UpdateStatus.Failed => _localization["UpdateFailed"], + _ => string.Empty, + }; + + /// + /// Checks for updates when the user has not forbidden it and enough time has + /// passed since the previous check. Called once at startup. + /// + public async Task StartAsync() + { + if (!IsSupported || !_settings.CheckForUpdates) + { + return; + } + + if (_settings.LastUpdateCheck is { } last && DateTimeOffset.UtcNow - last < _options.CheckInterval) + { + return; + } + + await RunCheckAsync(quiet: true); + } + + public void Dispose() + { + _localization.PropertyChanged -= OnLocalizationChanged; + + _work?.Cancel(); + _work?.Dispose(); + _work = null; + } + + [RelayCommand] + private Task CheckAsync() => RunCheckAsync(quiet: false); + + [RelayCommand] + private async Task DownloadAsync() + { + if (_release is not { } release) + { + return; + } + + CancellationToken token = StartWork(); + Progress = 0; + Status = UpdateStatus.Downloading; + + try + { + var progress = new Progress(value => Progress = value); + _packagePath = await _updates.DownloadAsync(release, progress, token); + Status = UpdateStatus.Ready; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The download was interrupted by the next piece of work: it has already set its own state + } + catch (Exception e) when (IsExpected(e)) + { + Status = UpdateStatus.Failed; + } + } + + [RelayCommand] + private void Install() + { + if (_packagePath is null || !File.Exists(_packagePath)) + { + // The file was removed by the temp folder cleanup — downloading it again is what is left + Status = _release is null ? UpdateStatus.Idle : UpdateStatus.Available; + return; + } + + try + { + _updates.Install(_packagePath); + } + catch (Exception e) when (IsExpected(e)) + { + Status = UpdateStatus.Failed; + } + } + + /// + /// The errors that simply leave the update undone: an unreachable network, an + /// unexpected response, a file in use. Everything else is a reason to crash. + /// + /// + /// OperationCanceledException means an expired request deadline here: + /// cancellation by the application itself is caught by a separate handler above. + /// + private static bool IsExpected(Exception e) => + e is HttpRequestException or JsonException or IOException or UnauthorizedAccessException + or NotSupportedException or InvalidOperationException or Win32Exception + or OperationCanceledException; + + private async Task RunCheckAsync(bool quiet) + { + if (!IsSupported) + { + return; + } + + CancellationToken token = StartWork(); + Status = UpdateStatus.Checking; + + try + { + _release = await _updates.CheckAsync(token); + _packagePath = null; + + _settings.LastUpdateCheck = DateTimeOffset.UtcNow; + Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The check was cancelled by the next piece of work: it has already set its own state + return; + } + catch (Exception e) when (IsExpected(e)) + { + Status = quiet ? UpdateStatus.Idle : UpdateStatus.Failed; + } + + OnPropertyChanged(nameof(ReleaseUrl)); + OnPropertyChanged(nameof(IsReleaseLinkShown)); + } + + /// + /// Starts a new piece of work, cancelling the previous one: the user may have + /// pressed "Check" in the middle of a download. + /// + private CancellationToken StartWork() + { + _work?.Cancel(); + _work?.Dispose(); + _work = new CancellationTokenSource(); + return _work.Token; + } + + private string Format(string key, string? argument) => + string.Format(CultureInfo.CurrentCulture, _localization[key], argument); + + private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == Binding.IndexerName) + { + OnPropertyChanged(nameof(StatusText)); + } + } +}