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.icoAnyCPU
- win-x64;win-arm64
+ win-x64truefalse
- true
+ true1.0.01.0.0.01.0.0.0CursorLang
- Aleksandr Neychev
+ Aleksandr NeichevShows the keyboard layout at the cursorCopyright (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.CoreCursorLang.CoreAnyCPU
- win-x64;win-arm64
+ win-x641.0.01.0.0.01.0.0.0CursorLang
- Aleksandr Neychev
- Shared part of CursorLang: models, settings, layout tracking, updates
+ Aleksandr Neichev
+ Shared part of CursorLang: models, settings, layout trackingCopyright (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} — SettingsSettings
@@ -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