diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 752ab25..9dce398 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,6 +2,13 @@ # packs the MSIX with the version taken from the tag — three numbers of the tag # and a zero the Store keeps for itself. # +# The package goes to the Store and nowhere else, so it leaves the run as an +# artifact: someone picks it up and uploads it to Partner Center, which puts its +# own signature on it. Nothing is signed here and nothing is attached to the +# release — a publicly trusted code signing certificate is not to be had, and an +# unsigned package would look like something to install and install nowhere. +# Gitea makes the release for the tag itself, and it carries the tag alone. +# # The same requirements to the runner as in pull-request.yml apply: Windows, the # .NET 10 SDK and an interactive desktop session for the tests. makeappx comes # with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does @@ -77,6 +84,10 @@ jobs: # reserves the last one, so it carries nothing the tag could tell "version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + # The installer answers to nobody about a fourth number and takes the + # tag as it is + "plain=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Show the toolchain run: dotnet --info @@ -121,15 +132,32 @@ jobs: ./Packaging/build-msix.ps1 @arguments - - name: Keep the packages + # The other half of the release: the same application as an ordinary + # installer, for handing round outside the Store. Nobody signs it, so + # SmartScreen warns about it — see Packaging\installer.iss + - name: Build the installer + run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }} + + # The artifact is where the package waits to be uploaded to Partner Center + - name: Keep the package uses: actions/upload-artifact@v4 with: name: msix-${{ steps.version.outputs.version }} path: artifacts/packages/ if-no-files-found: error - # Gitea creates a release of its own for a pushed tag, so the release is - # looked up first and only made when it is not there + # The .wixpdb next to each installer is left out on purpose: it is of use + # only when something has to be traced back to the WiX source + - name: Keep the installer + uses: actions/upload-artifact@v4 + with: + name: installer-${{ steps.version.outputs.plain }} + path: artifacts/installers/*.msi + if-no-files-found: error + + # Only the installers go into the release. The MSIX stays in the artifacts + # of the run: unsigned, it installs nowhere, and its one destination is + # Partner Center - name: Publish the release env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} @@ -145,6 +173,8 @@ jobs: $api = "$root/repos/$env:GITHUB_REPOSITORY/releases" $headers = @{ Authorization = "token $env:GITEA_TOKEN" } + # Gitea makes a release of its own for a pushed tag, so the release is + # looked up first and only made when it is not there $release = $null try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { } @@ -153,7 +183,7 @@ jobs: $release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body } - foreach ($file in Get-ChildItem artifacts/packages -File) { + foreach ($file in Get-ChildItem artifacts/installers -File -Filter *.msi) { # A tag can be pushed again after it was deleted; the old file of # the same name is dropped, otherwise the upload is refused $existing = $release.assets | Where-Object { $_.name -eq $file.Name } diff --git a/CursorLang.Agent/CursorLang.Agent.csproj b/CursorLang.Agent/CursorLang.Agent.csproj index 6658901..e25e9f8 100644 --- a/CursorLang.Agent/CursorLang.Agent.csproj +++ b/CursorLang.Agent/CursorLang.Agent.csproj @@ -14,15 +14,15 @@ app.manifest ..\CursorLang.Core\Resources\CursorLang.ico AnyCPU - win-x64;win-arm64 + win-x64 true false - true + true 1.0.0 1.0.0.0 1.0.0.0 CursorLang - Aleksandr Neychev + Aleksandr Neichev Shows the keyboard layout at the cursor Copyright (c) 2026 diff --git a/CursorLang.Core.Tests/Resources/StringsTests.cs b/CursorLang.Core.Tests/Resources/StringsTests.cs index 6acca5a..afeca45 100644 --- a/CursorLang.Core.Tests/Resources/StringsTests.cs +++ b/CursorLang.Core.Tests/Resources/StringsTests.cs @@ -79,14 +79,15 @@ public sealed class StringsTests } /// - /// The version of an update is put into the string by the app, so the place - /// for it has to be there in both languages. + /// The version is put into the title by the app, so the place for it has to + /// be there in both languages: the title is the only place it is shown, and a + /// translation without the placeholder would quietly drop it. /// [Fact] - public void The_string_about_an_available_update_has_room_for_the_version() + public void The_title_of_the_window_has_room_for_the_version() { - Assert.Contains("{0}", Resources.GetString("UpdateAvailable", English), StringComparison.Ordinal); - Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal); + Assert.Contains("{0}", Resources.GetString("SettingsTitle", English), StringComparison.Ordinal); + Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal); } public static TheoryData EnumKeys() diff --git a/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs b/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs deleted file mode 100644 index 7ffe52b..0000000 --- a/CursorLang.Core.Tests/Services/GiteaReleaseFeedTests.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System.Net; -using System.Runtime.InteropServices; -using CursorLang.Core.Models; -using CursorLang.Core.Services; -using CursorLang.Tests.Shared; - -namespace CursorLang.Core.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.Core.Tests/Services/UpdateOptionsTests.cs b/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs deleted file mode 100644 index 218c368..0000000 --- a/CursorLang.Core.Tests/Services/UpdateOptionsTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CursorLang.Core.Services; - -namespace CursorLang.Core.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); - } -} diff --git a/CursorLang.Core.Tests/Services/UpdateServiceTests.cs b/CursorLang.Core.Tests/Services/UpdateServiceTests.cs deleted file mode 100644 index a671c93..0000000 --- a/CursorLang.Core.Tests/Services/UpdateServiceTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Net; -using System.Text; -using CursorLang.Core.Models; -using CursorLang.Core.Services; -using CursorLang.Tests.Shared; - -namespace CursorLang.Core.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.Core/CursorLang.Core.csproj b/CursorLang.Core/CursorLang.Core.csproj index 74e20bc..c52d5c3 100644 --- a/CursorLang.Core/CursorLang.Core.csproj +++ b/CursorLang.Core/CursorLang.Core.csproj @@ -11,13 +11,13 @@ CursorLang.Core CursorLang.Core AnyCPU - win-x64;win-arm64 + win-x64 1.0.0 1.0.0.0 1.0.0.0 CursorLang - Aleksandr Neychev - Shared part of CursorLang: models, settings, layout tracking, updates + Aleksandr Neichev + Shared part of CursorLang: models, settings, layout tracking Copyright (c) 2026 diff --git a/CursorLang.Core/Models/ReleaseInfo.cs b/CursorLang.Core/Models/ReleaseInfo.cs deleted file mode 100644 index 20a9fc8..0000000 --- a/CursorLang.Core/Models/ReleaseInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CursorLang.Core.Models; - -/// -/// A file attached to a release. -/// -/// The name the file is saved to disk under. -/// A direct link to the content. -/// The size in bytes; zero when the hosting did not report it. -public sealed record ReleaseAsset(string FileName, Uri Url, long Size); - -/// -/// A release found in the repository. -/// -/// The version parsed from the tag. -/// The tag as is — that is what the interface shows. -/// The release page: the release notes live there too. -/// The MSIX package the application updates itself with. -public sealed record ReleaseInfo(Version Version, string Tag, Uri? PageUrl, ReleaseAsset Package); diff --git a/CursorLang.Core/Resources/Strings.resx b/CursorLang.Core/Resources/Strings.resx index 1e72d35..49378a1 100644 --- a/CursorLang.Core/Resources/Strings.resx +++ b/CursorLang.Core/Resources/Strings.resx @@ -59,7 +59,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - CursorLang — Settings + CursorLang {0} — Settings Settings @@ -211,46 +211,4 @@ Startup for this app is now controlled by Windows: Settings — Apps — Startup. - - Updates - - - Installed version - - - Check for updates - - - Updates have not been checked yet. - - - Checking for updates… - - - The installed version is the latest one. - - - Version {0} is available. - - - Downloading the package… - - - The package has been downloaded. - - - Could not reach the releases. Check the connection and try again. - - - Download - - - Install - - - Release page - - - Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted. - diff --git a/CursorLang.Core/Resources/Strings.ru.resx b/CursorLang.Core/Resources/Strings.ru.resx index 76064c8..6c9d00f 100644 --- a/CursorLang.Core/Resources/Strings.ru.resx +++ b/CursorLang.Core/Resources/Strings.ru.resx @@ -59,7 +59,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - CursorLang — Настройки + CursorLang {0} — Настройки Настройки @@ -211,46 +211,4 @@ Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка». - - Обновления - - - Установленная версия - - - Проверить обновления - - - Обновления ещё не проверялись. - - - Идёт проверка обновлений… - - - Установлена последняя версия. - - - Доступна версия {0}. - - - Идёт загрузка пакета… - - - Пакет скачан. - - - Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку. - - - Скачать - - - Установить - - - Страница выпуска - - - Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения. - diff --git a/CursorLang.Core/Services/AppVersion.cs b/CursorLang.Core/Services/AppVersion.cs new file mode 100644 index 0000000..0e60b32 --- /dev/null +++ b/CursorLang.Core/Services/AppVersion.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using CursorLang.Core.Interop; +using Windows.ApplicationModel; + +namespace CursorLang.Core.Services; + +/// +/// The version of the running application. +/// +/// +/// The same application runs from a package and from a folder, and the two keep +/// their version in different places. The answer is computed once: it cannot +/// change while the process lives. +/// +public static class AppVersion +{ + public static Version Current { get; } = Detect(); + + private static Version Detect() + { + if (PackageIdentityNative.IsPackaged) + { + try + { + 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); + } +} diff --git a/CursorLang.Core/Services/GiteaReleaseFeed.cs b/CursorLang.Core/Services/GiteaReleaseFeed.cs deleted file mode 100644 index 795c386..0000000 --- a/CursorLang.Core/Services/GiteaReleaseFeed.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System.Net.Http.Headers; -using System.Runtime.InteropServices; -using System.Text.Json; -using CursorLang.Core.Models; - -namespace CursorLang.Core.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.Core/Services/IReleaseFeed.cs b/CursorLang.Core/Services/IReleaseFeed.cs deleted file mode 100644 index 8772216..0000000 --- a/CursorLang.Core/Services/IReleaseFeed.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CursorLang.Core.Models; - -namespace CursorLang.Core.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.Core/Services/IUpdateService.cs b/CursorLang.Core/Services/IUpdateService.cs deleted file mode 100644 index 35fcf40..0000000 --- a/CursorLang.Core/Services/IUpdateService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CursorLang.Core.Models; - -namespace CursorLang.Core.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.Core/Services/UpdateOptions.cs b/CursorLang.Core/Services/UpdateOptions.cs deleted file mode 100644 index f386214..0000000 --- a/CursorLang.Core/Services/UpdateOptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace CursorLang.Core.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"; - - /// - /// 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.Core/Services/UpdateService.cs b/CursorLang.Core/Services/UpdateService.cs deleted file mode 100644 index 6ec8acd..0000000 --- a/CursorLang.Core/Services/UpdateService.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Reflection; -using System.Runtime.InteropServices; -using CursorLang.Core.Interop; -using CursorLang.Core.Models; -using Windows.ApplicationModel; - -namespace CursorLang.Core.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.Settings.Tests/AppTests.cs b/CursorLang.Settings.Tests/AppTests.cs index 71ee512..172bb2a 100644 --- a/CursorLang.Settings.Tests/AppTests.cs +++ b/CursorLang.Settings.Tests/AppTests.cs @@ -22,10 +22,8 @@ public sealed class AppTests [InlineData(typeof(MainWindowPlacement))] [InlineData(typeof(ILocalizationService))] [InlineData(typeof(IStartupService))] - [InlineData(typeof(IUpdateService))] - [InlineData(typeof(UpdateOptions))] + [InlineData(typeof(Version))] [InlineData(typeof(SettingsViewModel))] - [InlineData(typeof(UpdateViewModel))] [InlineData(typeof(MainWindow))] [InlineData(typeof(AppSettings))] public void Everything_the_window_needs_is_declared_in_the_container(Type service) diff --git a/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs b/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs index e3c653f..f269be2 100644 --- a/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs +++ b/CursorLang.Settings.Tests/Infrastructure/SettingsFakes.cs @@ -1,19 +1,8 @@ using CursorLang.Core.Models; using CursorLang.Settings.Services; -using CursorLang.Settings.ViewModels; -using CursorLang.Tests.Shared; namespace CursorLang.Settings.Tests.Infrastructure; -/// -/// Parts every test needs but few tests care about. -/// -internal static class Fake -{ - internal static UpdateViewModel Updates() => - new(new FakeUpdateService(), new FakeLocalizationService()); -} - /// /// A theme that paints nothing and only remembers the windows attached to it. /// diff --git a/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs index fb0dafe..3a23b53 100644 --- a/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs +++ b/CursorLang.Settings.Tests/ViewModels/SettingsViewModelTests.cs @@ -285,11 +285,12 @@ public sealed class SettingsViewModelTests private static SettingsViewModel Create( AppSettings? settings = null, ILocalizationService? localization = null, - IStartupService? startup = null) => + IStartupService? startup = null, + Version? version = null) => new(settings ?? new AppSettings(), localization ?? new FakeLocalizationService(), startup ?? new FakeStartupService(), - Fake.Updates()); + version ?? new Version(1, 0, 0, 0)); // The setting travels to Windows without being awaited: the window must not freeze private static async Task WaitForStartupRequests(FakeStartupService startup, int count) diff --git a/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs b/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs deleted file mode 100644 index 5566929..0000000 --- a/CursorLang.Settings.Tests/ViewModels/UpdateViewModelTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System.IO; -using System.Net.Http; -using CursorLang.Core.Models; -using CursorLang.Core.Services; -using CursorLang.Settings.ViewModels; -using CursorLang.Tests.Shared; - -namespace CursorLang.Settings.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 -{ - // The section has something to say at every moment, the one before the first - // answer included: the status line is never an empty spot in the window - [Fact] - public void Before_the_first_check_the_section_says_so() - { - using UpdateViewModel viewModel = Create(new FakeUpdateService()); - - Assert.Equal(UpdateStatus.Idle, viewModel.Status); - Assert.Equal("en:UpdateNotChecked", viewModel.StatusText); - 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.Equal("en:UpdateFailed", viewModel.StatusText); - } - - // The window is opened by hand, and the answer is what the section is there - // for: a dead network is part of the answer rather than a reason to say nothing - [Fact] - public async Task A_check_when_the_window_opens_says_when_it_did_not_work_out() - { - var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") }; - using UpdateViewModel viewModel = Create(updates); - - await viewModel.StartAsync(); - - Assert.Equal(UpdateStatus.Failed, viewModel.Status); - Assert.Equal("en:UpdateFailed", viewModel.StatusText); - } - - // Nothing is remembered between openings: an answer from yesterday is worth - // less than today's, and the request costs nothing at this rate - [Fact] - public async Task Every_opening_of_the_window_asks_anew() - { - var updates = new FakeUpdateService(); - using UpdateViewModel viewModel = Create(updates); - - await viewModel.StartAsync(); - Assert.Equal(1, updates.CheckCalls); - - await viewModel.StartAsync(); - Assert.Equal(2, updates.CheckCalls); - } - - [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, - ILocalizationService? localization = null) => - new(updates, localization ?? new FakeLocalizationService()); -} diff --git a/CursorLang.Settings.Tests/Views/MainWindowTests.cs b/CursorLang.Settings.Tests/Views/MainWindowTests.cs index 5d33cec..8345ca3 100644 --- a/CursorLang.Settings.Tests/Views/MainWindowTests.cs +++ b/CursorLang.Settings.Tests/Views/MainWindowTests.cs @@ -404,39 +404,20 @@ public sealed class MainWindowTests }); } + // The version is nowhere else in the window, so the title has to carry it [Fact] - public void The_updates_are_checked_by_the_button_in_the_window() + public void The_title_of_the_window_shows_the_version() { Sta.Run(() => { - var updates = new FakeUpdateService(); - using UpdateViewModel section = CreateUpdates(updates); - using SettingsViewModel viewModel = CreateViewModel(updates: section); + var localization = new FakeLocalizationService(); + localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings"; - Open(viewModel, window => - { - Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand")); + using SettingsViewModel viewModel = CreateViewModel( + localization: localization, + version: new Version(1, 2, 3, 0)); - Assert.True(check.IsVisible); - check.Command.Execute(null); - - Assert.Equal(1, updates.CheckCalls); - }); - }); - } - - // An app installed from the Store is updated by the Store - [Fact] - public void An_app_that_updates_itself_elsewhere_shows_no_updates_section() - { - Sta.Run(() => - { - using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false }); - using SettingsViewModel viewModel = CreateViewModel(updates: section); - - Open(viewModel, window => - Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button => - Assert.False(button.IsVisible))); + Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title)); }); } @@ -486,11 +467,11 @@ public sealed class MainWindowTests AppSettings? settings = null, ILocalizationService? localization = null, IStartupService? startup = null, - UpdateViewModel? updates = null) => + Version? version = null) => new(settings ?? new AppSettings(), localization ?? new FakeLocalizationService(), startup ?? new FakeStartupService(), - updates ?? Fake.Updates()); + version ?? new Version(1, 0, 0, 0)); private static void Open(SettingsViewModel viewModel, Action check) => Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check); @@ -525,9 +506,6 @@ public sealed class MainWindowTests } } - private static UpdateViewModel CreateUpdates(IUpdateService updates) => - new(updates, new FakeLocalizationService()); - private static IEnumerable