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));
+ }
+ }
+}