modified release pipeline (#4)
Release / release (push) Canceled after 47s

await certificate from ssl.com

Reviewed-on: #4
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-08-13 11:17:22 +00:00
committed by alex
parent 5e11b16758
commit 42974ecc8f
34 changed files with 495 additions and 2091 deletions
+34 -4
View File
@@ -2,6 +2,13 @@
# packs the MSIX with the version taken from the tag — three numbers of the tag # packs the MSIX with the version taken from the tag — three numbers of the tag
# and a zero the Store keeps for itself. # 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 # 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 # .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 # 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 # 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 "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 - name: Show the toolchain
run: dotnet --info run: dotnet --info
@@ -121,15 +132,32 @@ jobs:
./Packaging/build-msix.ps1 @arguments ./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 uses: actions/upload-artifact@v4
with: with:
name: msix-${{ steps.version.outputs.version }} name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/ path: artifacts/packages/
if-no-files-found: error if-no-files-found: error
# Gitea creates a release of its own for a pushed tag, so the release is # The .wixpdb next to each installer is left out on purpose: it is of use
# looked up first and only made when it is not there # 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 - name: Publish the release
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -145,6 +173,8 @@ jobs:
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases" $api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" } $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 $release = $null
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { } 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 $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 # A tag can be pushed again after it was deleted; the old file of
# the same name is dropped, otherwise the upload is refused # the same name is dropped, otherwise the upload is refused
$existing = $release.assets | Where-Object { $_.name -eq $file.Name } $existing = $release.assets | Where-Object { $_.name -eq $file.Name }
+3 -3
View File
@@ -14,15 +14,15 @@
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon> <ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> <RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained> <SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun> <PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product> <Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company> <Company>Aleksandr Neichev</Company>
<Description>Shows the keyboard layout at the cursor</Description> <Description>Shows the keyboard layout at the cursor</Description>
<Copyright>Copyright (c) 2026</Copyright> <Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup> </PropertyGroup>
@@ -79,14 +79,15 @@ public sealed class StringsTests
} }
/// <summary> /// <summary>
/// The version of an update is put into the string by the app, so the place /// The version is put into the title by the app, so the place for it has to
/// for it has to be there in both languages. /// be there in both languages: the title is the only place it is shown, and a
/// translation without the placeholder would quietly drop it.
/// </summary> /// </summary>
[Fact] [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("SettingsTitle", English), StringComparison.Ordinal);
Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal); Assert.Contains("{0}", Resources.GetString("SettingsTitle", Russian), StringComparison.Ordinal);
} }
public static TheoryData<string> EnumKeys() public static TheoryData<string> EnumKeys()
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<HttpRequestException>(
() => 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<ReleaseInfo?> Read(string json) =>
new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options())
.GetLatestAsync(TestContext.Current.CancellationToken);
}
@@ -1,32 +0,0 @@
using CursorLang.Core.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// What the app does with a release once it has found one: whether it is newer
/// at all, and what ends up on disk.
/// </summary>
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<HttpRequestException>(
() => 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));
}
/// <summary>
/// The reports as the download makes them. <c>Progress&lt;T&gt;</c> would
/// hand them over to another thread, and a test has nowhere to wait for that.
/// </summary>
private sealed class CollectingProgress : IProgress<double>
{
internal List<double> 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);
}
+3 -3
View File
@@ -11,13 +11,13 @@
<RootNamespace>CursorLang.Core</RootNamespace> <RootNamespace>CursorLang.Core</RootNamespace>
<AssemblyName>CursorLang.Core</AssemblyName> <AssemblyName>CursorLang.Core</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> <RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<Version>1.0.0</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product> <Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company> <Company>Aleksandr Neichev</Company>
<Description>Shared part of CursorLang: models, settings, layout tracking, updates</Description> <Description>Shared part of CursorLang: models, settings, layout tracking</Description>
<Copyright>Copyright (c) 2026</Copyright> <Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup> </PropertyGroup>
-18
View File
@@ -1,18 +0,0 @@
namespace CursorLang.Core.Models;
/// <summary>
/// A file attached to a release.
/// </summary>
/// <param name="FileName">The name the file is saved to disk under.</param>
/// <param name="Url">A direct link to the content.</param>
/// <param name="Size">The size in bytes; zero when the hosting did not report it.</param>
public sealed record ReleaseAsset(string FileName, Uri Url, long Size);
/// <summary>
/// A release found in the repository.
/// </summary>
/// <param name="Version">The version parsed from the tag.</param>
/// <param name="Tag">The tag as is — that is what the interface shows.</param>
/// <param name="PageUrl">The release page: the release notes live there too.</param>
/// <param name="Package">The MSIX package the application updates itself with.</param>
public sealed record ReleaseInfo(Version Version, string Tag, Uri? PageUrl, ReleaseAsset Package);
+1 -43
View File
@@ -59,7 +59,7 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="SettingsTitle" xml:space="preserve"> <data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Settings</value> <value>CursorLang {0} — Settings</value>
</data> </data>
<data name="TrayMenuSettings" xml:space="preserve"> <data name="TrayMenuSettings" xml:space="preserve">
<value>Settings</value> <value>Settings</value>
@@ -211,46 +211,4 @@
<data name="StartupLockedHint" xml:space="preserve"> <data name="StartupLockedHint" xml:space="preserve">
<value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value> <value>Startup for this app is now controlled by Windows: Settings — Apps — Startup.</value>
</data> </data>
<data name="SectionUpdates" xml:space="preserve">
<value>Updates</value>
</data>
<data name="CurrentVersionLabel" xml:space="preserve">
<value>Installed version</value>
</data>
<data name="CheckUpdatesButton" xml:space="preserve">
<value>Check for updates</value>
</data>
<data name="UpdateNotChecked" xml:space="preserve">
<value>Updates have not been checked yet.</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Checking for updates…</value>
</data>
<data name="UpdateUpToDate" xml:space="preserve">
<value>The installed version is the latest one.</value>
</data>
<data name="UpdateAvailable" xml:space="preserve">
<value>Version {0} is available.</value>
</data>
<data name="UpdateDownloading" xml:space="preserve">
<value>Downloading the package…</value>
</data>
<data name="UpdateReady" xml:space="preserve">
<value>The package has been downloaded.</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Could not reach the releases. Check the connection and try again.</value>
</data>
<data name="DownloadUpdateButton" xml:space="preserve">
<value>Download</value>
</data>
<data name="InstallUpdateButton" xml:space="preserve">
<value>Install</value>
</data>
<data name="ReleasePageLink" xml:space="preserve">
<value>Release page</value>
</data>
<data name="UpdateInstallHint" xml:space="preserve">
<value>Windows will show the package and ask to confirm the installation. The new version takes over once the app is restarted.</value>
</data>
</root> </root>
+1 -43
View File
@@ -59,7 +59,7 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="SettingsTitle" xml:space="preserve"> <data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Настройки</value> <value>CursorLang {0} — Настройки</value>
</data> </data>
<data name="TrayMenuSettings" xml:space="preserve"> <data name="TrayMenuSettings" xml:space="preserve">
<value>Настройки</value> <value>Настройки</value>
@@ -211,46 +211,4 @@
<data name="StartupLockedHint" xml:space="preserve"> <data name="StartupLockedHint" xml:space="preserve">
<value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value> <value>Автозапуском этого приложения теперь распоряжается Windows: «Параметры» — «Приложения» — «Автозагрузка».</value>
</data> </data>
<data name="SectionUpdates" xml:space="preserve">
<value>Обновления</value>
</data>
<data name="CurrentVersionLabel" xml:space="preserve">
<value>Установленная версия</value>
</data>
<data name="CheckUpdatesButton" xml:space="preserve">
<value>Проверить обновления</value>
</data>
<data name="UpdateNotChecked" xml:space="preserve">
<value>Обновления ещё не проверялись.</value>
</data>
<data name="UpdateChecking" xml:space="preserve">
<value>Идёт проверка обновлений…</value>
</data>
<data name="UpdateUpToDate" xml:space="preserve">
<value>Установлена последняя версия.</value>
</data>
<data name="UpdateAvailable" xml:space="preserve">
<value>Доступна версия {0}.</value>
</data>
<data name="UpdateDownloading" xml:space="preserve">
<value>Идёт загрузка пакета…</value>
</data>
<data name="UpdateReady" xml:space="preserve">
<value>Пакет скачан.</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Не удалось обратиться к выпускам. Проверьте подключение и повторите попытку.</value>
</data>
<data name="DownloadUpdateButton" xml:space="preserve">
<value>Скачать</value>
</data>
<data name="InstallUpdateButton" xml:space="preserve">
<value>Установить</value>
</data>
<data name="ReleasePageLink" xml:space="preserve">
<value>Страница выпуска</value>
</data>
<data name="UpdateInstallHint" xml:space="preserve">
<value>Windows покажет пакет и попросит подтвердить установку. Новая версия начнёт работать после перезапуска приложения.</value>
</data>
</root> </root>
+37
View File
@@ -0,0 +1,37 @@
using System.Reflection;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
using Windows.ApplicationModel;
namespace CursorLang.Core.Services;
/// <summary>
/// The version of the running application.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
@@ -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;
/// <summary>
/// Gitea releases.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class GiteaReleaseFeed : IReleaseFeed
{
/// <summary>
/// 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.
/// </summary>
private const int PageSize = 10;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// How the architecture is spelled in the file names built by
/// <c>build-msix.ps1</c>: <c>CursorLang-1.0.0.0-x64.msix</c>.
/// </summary>
private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? "arm64"
: "x64";
public async Task<ReleaseInfo?> 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<ReleaseInfo>()
.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);
}
}
/// <summary>
/// Parses a single release. <c>null</c> means the release will not do: a draft,
/// a prerelease or a release without a package.
/// </summary>
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<ReleaseAsset>());
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);
}
/// <summary>
/// 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.
/// </summary>
private static Version? ParseTag(string? tag)
{
if (string.IsNullOrWhiteSpace(tag))
{
return null;
}
ReadOnlySpan<char> 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));
}
/// <summary>
/// Picks the attached file the application updates itself with.
/// </summary>
private static ReleaseAsset? PickPackage(IEnumerable<ReleaseAsset> 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;
/// <summary>
/// The address the server returns the release list at. The trailing slash matters:
/// without it <c>Uri</c> drops the last part of the address, and
/// "https://host/gitea" would have turned into "https://host/api/...".
/// </summary>
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}");
}
}
-21
View File
@@ -1,21 +0,0 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// The release list of the repository.
/// </summary>
public interface IReleaseFeed
{
/// <summary>
/// Returns the newest release carrying an MSIX package, or <c>null</c>
/// when there is no suitable release.
/// </summary>
Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken);
/// <summary>
/// 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.
/// </summary>
void Authorize(HttpRequestMessage request);
}
@@ -1,40 +0,0 @@
using CursorLang.Core.Models;
namespace CursorLang.Core.Services;
/// <summary>
/// Checking for and installing new versions of the application.
/// </summary>
public interface IUpdateService
{
/// <summary>
/// It makes sense for this installation to update itself.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
bool IsSupported { get; }
/// <summary>The version of the running application.</summary>
Version CurrentVersion { get; }
/// <summary>
/// Looks for a release newer than the installed one. <c>null</c> means the latest
/// version is installed or there is no suitable release in the repository.
/// </summary>
Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken);
/// <summary>
/// Downloads the release package and returns the path to it.
/// </summary>
/// <remarks>
/// <c>progress</c> 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.
/// </remarks>
Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress, CancellationToken cancellationToken);
/// <summary>Hands the downloaded package over to the Windows app installer.</summary>
void Install(string packagePath);
}
-34
View File
@@ -1,34 +0,0 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Where the application learns about new versions from.
/// </summary>
/// <remarks>
/// 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 <c>settings.json</c>. The defaults point at the repository the
/// application is built from.
/// </remarks>
public sealed class UpdateOptions
{
/// <summary>
/// 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.
/// </summary>
public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/");
/// <summary>The project: <c>owner/repository</c>.</summary>
public string Project { get; init; } = "alrakis/cursor-lang";
/// <summary>
/// An access token for a private repository.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string? AccessToken { get; init; } =
Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN");
}
-224
View File
@@ -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;
/// <summary>
/// Learns about new versions from the repository and hands the downloaded package
/// over to the installer.
/// </summary>
/// <remarks>
/// The package is installed by the Windows app installer, not by the application on
/// its own. Through <c>PackageManager</c> 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.
/// </remarks>
public sealed class UpdateService : IUpdateService, IDisposable
{
/// <summary>The package is large and the network can be slow: the buffer is taken with room to spare.</summary>
private const int BufferSize = 81920;
/// <summary>The version of the running application — it does not change while it runs.</summary>
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;
}
/// <summary>
/// Takes the releases, the network and the version explicitly: in tests they are
/// not provided by Windows.
/// </summary>
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<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
{
ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken);
return release is not null && release.Version > CurrentVersion ? release : null;
}
public async Task<string> DownloadAsync(
ReleaseInfo release,
IProgress<double>? 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;
}
/// <summary>
/// Whether to check for updates at all: a package from the Store gets them from the Store.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private static string BuildFileName(ReleaseInfo release)
{
string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)
? ".msixbundle"
: ".msix";
return $"CursorLang-{release.Version}{extension}";
}
/// <summary>
/// Removes packages downloaded earlier: they take up a noticeable amount of
/// space and are needed only until the installation.
/// </summary>
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
}
}
}
+1 -3
View File
@@ -22,10 +22,8 @@ public sealed class AppTests
[InlineData(typeof(MainWindowPlacement))] [InlineData(typeof(MainWindowPlacement))]
[InlineData(typeof(ILocalizationService))] [InlineData(typeof(ILocalizationService))]
[InlineData(typeof(IStartupService))] [InlineData(typeof(IStartupService))]
[InlineData(typeof(IUpdateService))] [InlineData(typeof(Version))]
[InlineData(typeof(UpdateOptions))]
[InlineData(typeof(SettingsViewModel))] [InlineData(typeof(SettingsViewModel))]
[InlineData(typeof(UpdateViewModel))]
[InlineData(typeof(MainWindow))] [InlineData(typeof(MainWindow))]
[InlineData(typeof(AppSettings))] [InlineData(typeof(AppSettings))]
public void Everything_the_window_needs_is_declared_in_the_container(Type service) public void Everything_the_window_needs_is_declared_in_the_container(Type service)
@@ -1,19 +1,8 @@
using CursorLang.Core.Models; using CursorLang.Core.Models;
using CursorLang.Settings.Services; using CursorLang.Settings.Services;
using CursorLang.Settings.ViewModels;
using CursorLang.Tests.Shared;
namespace CursorLang.Settings.Tests.Infrastructure; namespace CursorLang.Settings.Tests.Infrastructure;
/// <summary>
/// Parts every test needs but few tests care about.
/// </summary>
internal static class Fake
{
internal static UpdateViewModel Updates() =>
new(new FakeUpdateService(), new FakeLocalizationService());
}
/// <summary> /// <summary>
/// A theme that paints nothing and only remembers the windows attached to it. /// A theme that paints nothing and only remembers the windows attached to it.
/// </summary> /// </summary>
@@ -285,11 +285,12 @@ public sealed class SettingsViewModelTests
private static SettingsViewModel Create( private static SettingsViewModel Create(
AppSettings? settings = null, AppSettings? settings = null,
ILocalizationService? localization = null, ILocalizationService? localization = null,
IStartupService? startup = null) => IStartupService? startup = null,
Version? version = null) =>
new(settings ?? new AppSettings(), new(settings ?? new AppSettings(),
localization ?? new FakeLocalizationService(), localization ?? new FakeLocalizationService(),
startup ?? new FakeStartupService(), 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 // The setting travels to Windows without being awaited: the window must not freeze
private static async Task WaitForStartupRequests(FakeStartupService startup, int count) private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
@@ -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;
/// <summary>
/// The updates section of the settings window: what it shows at every step and
/// what it asks of the service behind it.
/// </summary>
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<string?> 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());
}
@@ -404,39 +404,20 @@ public sealed class MainWindowTests
}); });
} }
// The version is nowhere else in the window, so the title has to carry it
[Fact] [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(() => Sta.Run(() =>
{ {
var updates = new FakeUpdateService(); var localization = new FakeLocalizationService();
using UpdateViewModel section = CreateUpdates(updates); localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings";
using SettingsViewModel viewModel = CreateViewModel(updates: section);
Open(viewModel, window => using SettingsViewModel viewModel = CreateViewModel(
{ localization: localization,
Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand")); version: new Version(1, 2, 3, 0));
Assert.True(check.IsVisible); Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title));
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)));
}); });
} }
@@ -486,11 +467,11 @@ public sealed class MainWindowTests
AppSettings? settings = null, AppSettings? settings = null,
ILocalizationService? localization = null, ILocalizationService? localization = null,
IStartupService? startup = null, IStartupService? startup = null,
UpdateViewModel? updates = null) => Version? version = null) =>
new(settings ?? new AppSettings(), new(settings ?? new AppSettings(),
localization ?? new FakeLocalizationService(), localization ?? new FakeLocalizationService(),
startup ?? new FakeStartupService(), startup ?? new FakeStartupService(),
updates ?? Fake.Updates()); version ?? new Version(1, 0, 0, 0));
private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) => private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) =>
Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), 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<Button> FindButtonsBoundTo(DependencyObject root, string path) => private static IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
FindAll<Button>(root).Where(button => FindAll<Button>(root).Where(button =>
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path); BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
+1 -4
View File
@@ -55,7 +55,6 @@ public partial class App : Application
MainWindow.Show(); MainWindow.Show();
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync(); _ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
} }
protected override void OnExit(ExitEventArgs e) protected override void OnExit(ExitEventArgs e)
@@ -73,7 +72,7 @@ public partial class App : Application
internal static void ConfigureServices(IServiceCollection services) internal static void ConfigureServices(IServiceCollection services)
{ {
services.AddSingleton(new UpdateOptions()); services.AddSingleton(AppVersion.Current);
services.AddSingleton<SettingsService>(); services.AddSingleton<SettingsService>();
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load()); services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
@@ -85,9 +84,7 @@ public partial class App : Application
services.AddSingleton<ILocalizationService, LocalizationService>(); services.AddSingleton<ILocalizationService, LocalizationService>();
services.AddSingleton<IStartupService, StartupService>(); services.AddSingleton<IStartupService, StartupService>();
services.AddSingleton<IUpdateService, UpdateService>();
services.AddSingleton<UpdateViewModel>();
services.AddSingleton<SettingsViewModel>(); services.AddSingleton<SettingsViewModel>();
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();
@@ -16,15 +16,15 @@
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon> <ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> <RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained> <SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun> <PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product> <Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company> <Company>Aleksandr Neichev</Company>
<Description>Settings window of CursorLang</Description> <Description>Settings window of CursorLang</Description>
<Copyright>Copyright (c) 2026</Copyright> <Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup> </PropertyGroup>
@@ -65,6 +65,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
]; ];
private readonly IStartupService _startup; private readonly IStartupService _startup;
private readonly Version _version;
private StartupState _startupState = StartupState.Unavailable; private StartupState _startupState = StartupState.Unavailable;
@@ -72,12 +73,12 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
AppSettings settings, AppSettings settings,
ILocalizationService localization, ILocalizationService localization,
IStartupService startup, IStartupService startup,
UpdateViewModel updates) Version version)
{ {
Settings = settings; Settings = settings;
Localization = localization; Localization = localization;
Updates = updates;
_startup = startup; _startup = startup;
_version = version;
// The interface language is a setting like any other and is stored in the same place // The interface language is a setting like any other and is stored in the same place
Localization.CurrentLanguage = settings.Language; Localization.CurrentLanguage = settings.Language;
@@ -95,8 +96,23 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
public ILocalizationService Localization { get; } public ILocalizationService Localization { get; }
/// <summary>The updates section: it has a state and commands of its own.</summary> /// <summary>
public UpdateViewModel Updates { get; } /// The title of the window, with the version in it.
/// </summary>
/// <remarks>
/// The version is nowhere else in the interface: a package installed from the
/// Store is updated by the Store, so the window has nothing to say about
/// updates — but which version is running is still worth knowing, if only to
/// name it in a bug report.
///
/// Three numbers, not four: the fourth one is the revision the Store keeps
/// for itself, and it is a zero in every package we build. The numbers are
/// put together by hand rather than by <c>ToString(3)</c>, which throws on a
/// version that has fewer of them than asked for.
/// </remarks>
public string Title => string.Format(
Localization["SettingsTitle"],
$"{_version.Major}.{_version.Minor}.{Math.Max(_version.Build, 0)}");
public IReadOnlyList<Color> BackgroundPalette { get; } = Palette; public IReadOnlyList<Color> BackgroundPalette { get; } = Palette;
@@ -188,6 +204,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable
return; return;
} }
OnPropertyChanged(nameof(Title));
Translate(Themes); Translate(Themes);
Translate(PlacementModes); Translate(PlacementModes);
Translate(AnchorSides); Translate(AnchorSides);
@@ -1,238 +0,0 @@
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
namespace CursorLang.Settings.ViewModels;
/// <summary>
/// The updates section of the settings window.
/// </summary>
/// <remarks>
/// The section always says where things stand: a check flies off the moment the window
/// appears, and its outcome — including an unreachable network — stays written in the
/// status line. Nothing here is silent, and nothing disappears: a user who opened the
/// window is told about updates whether they came looking for them or not.
/// </remarks>
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
{
private readonly IUpdateService _updates;
private readonly ILocalizationService _localization;
private CancellationTokenSource? _work;
private ReleaseInfo? _release;
private string? _packagePath;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText))]
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(CanCheck))]
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
[NotifyPropertyChangedFor(nameof(IsDownloadOffered))]
[NotifyPropertyChangedFor(nameof(IsInstallOffered))]
[NotifyPropertyChangedFor(nameof(IsProgressShown))]
[NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))]
private UpdateStatus _status = UpdateStatus.Idle;
/// <summary>The downloaded fraction: from 0 to 1.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
private double _progress;
public UpdateViewModel(IUpdateService updates, ILocalizationService localization)
{
_updates = updates;
_localization = localization;
_localization.PropertyChanged += OnLocalizationChanged;
}
/// <summary>Whether to show the updates section at all.</summary>
public bool IsSupported => _updates.IsSupported;
public string CurrentVersion => _updates.CurrentVersion.ToString();
/// <summary>A request or a download is in flight — the buttons freeze for that time.</summary>
public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading;
public bool CanCheck => !IsBusy;
/// <summary>
/// The package size is unknown, and the bar shows only the fact of the download.
/// Its start looks the same until the first report arrives.
/// </summary>
public bool IsProgressUnknown => Status == UpdateStatus.Downloading && Progress <= 0;
public bool IsDownloadOffered => Status == UpdateStatus.Available;
public bool IsInstallOffered => Status == UpdateStatus.Ready;
public bool IsProgressShown => Status == UpdateStatus.Downloading;
public bool IsReleaseLinkShown => _release?.PageUrl is not null && Status is not UpdateStatus.Checking;
/// <summary>The release page: the release notes live there too.</summary>
public Uri? ReleaseUrl => _release?.PageUrl;
/// <summary>
/// What the section says. There is a line for every state, the one before the
/// first check included: the status is never an empty spot in the window.
/// </summary>
public string StatusText => Status switch
{
UpdateStatus.Checking => _localization["UpdateChecking"],
UpdateStatus.UpToDate => _localization["UpdateUpToDate"],
UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag),
UpdateStatus.Downloading => _localization["UpdateDownloading"],
UpdateStatus.Ready => _localization["UpdateReady"],
UpdateStatus.Failed => _localization["UpdateFailed"],
_ => _localization["UpdateNotChecked"],
};
/// <summary>
/// Asks about new versions. Called once, when the window has just appeared.
/// </summary>
/// <remarks>
/// It used to be called when the application started, which back then meant when
/// the machine was switched on. The background half is a separate process now and
/// does not go to the network at all — nothing in it could show the answer — so the
/// question is asked when there is a window to answer into.
///
/// And it is asked every time that window appears: opening it is a deliberate act
/// of the user, rare enough that a request costs nothing, and the answer is what
/// the section exists for. A remembered answer from yesterday is worth less than
/// today's, so nothing is remembered.
/// </remarks>
public Task StartAsync() => CheckAsync();
public void Dispose()
{
_localization.PropertyChanged -= OnLocalizationChanged;
_work?.Cancel();
_work?.Dispose();
_work = null;
}
[RelayCommand]
private async Task CheckAsync()
{
if (!IsSupported)
{
return;
}
CancellationToken token = StartWork();
Status = UpdateStatus.Checking;
try
{
_release = await _updates.CheckAsync(token);
_packagePath = null;
Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// The check was cancelled by the next piece of work: it has already set its own state
return;
}
catch (Exception e) when (IsExpected(e))
{
Status = UpdateStatus.Failed;
}
OnPropertyChanged(nameof(ReleaseUrl));
OnPropertyChanged(nameof(IsReleaseLinkShown));
}
[RelayCommand]
private async Task DownloadAsync()
{
if (_release is not { } release)
{
return;
}
CancellationToken token = StartWork();
Progress = 0;
Status = UpdateStatus.Downloading;
try
{
var progress = new Progress<double>(value => Progress = value);
_packagePath = await _updates.DownloadAsync(release, progress, token);
Status = UpdateStatus.Ready;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// The download was interrupted by the next piece of work: it has already set its own state
}
catch (Exception e) when (IsExpected(e))
{
Status = UpdateStatus.Failed;
}
}
[RelayCommand]
private void Install()
{
if (_packagePath is null || !File.Exists(_packagePath))
{
// The file was removed by the temp folder cleanup — downloading it again is what is left
Status = _release is null ? UpdateStatus.Idle : UpdateStatus.Available;
return;
}
try
{
_updates.Install(_packagePath);
}
catch (Exception e) when (IsExpected(e))
{
Status = UpdateStatus.Failed;
}
}
/// <summary>
/// The errors that simply leave the update undone: an unreachable network, an
/// unexpected response, a file in use. Everything else is a reason to crash.
/// </summary>
/// <remarks>
/// <c>OperationCanceledException</c> means an expired request deadline here:
/// cancellation by the application itself is caught by a separate handler above.
/// </remarks>
private static bool IsExpected(Exception e) =>
e is HttpRequestException or JsonException or IOException or UnauthorizedAccessException
or NotSupportedException or InvalidOperationException or Win32Exception
or OperationCanceledException;
/// <summary>
/// Starts a new piece of work, cancelling the previous one: the user may have
/// pressed "Check" in the middle of a download.
/// </summary>
private CancellationToken StartWork()
{
_work?.Cancel();
_work?.Dispose();
_work = new CancellationTokenSource();
return _work.Token;
}
private string Format(string key, string? argument) =>
string.Format(CultureInfo.CurrentCulture, _localization[key], argument);
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == Binding.IndexerName)
{
OnPropertyChanged(nameof(StatusText));
}
}
}
+1 -67
View File
@@ -7,7 +7,7 @@
xmlns:vm="clr-namespace:CursorLang.Settings.ViewModels" xmlns:vm="clr-namespace:CursorLang.Settings.ViewModels"
mc:Ignorable="d" mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}" d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
Title="{Binding Localization[SettingsTitle]}" Title="{Binding Title}"
Width="1000" SizeToContent="Height" MaxHeight="900" Width="1000" SizeToContent="Height" MaxHeight="900"
ResizeMode="CanMinimize" ResizeMode="CanMinimize"
Background="{DynamicResource Theme.WindowBackground}" Background="{DynamicResource Theme.WindowBackground}"
@@ -196,72 +196,6 @@
</Grid> </Grid>
</GroupBox> </GroupBox>
<!-- The section is absent for an app from the Store: the Store updates it itself -->
<GroupBox Header="{Binding Localization[SectionUpdates]}"
Visibility="{Binding Updates.IsSupported, Converter={StaticResource BooleanToVisibility}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CurrentVersionLabel]}" />
<TextBlock Grid.Column="1" VerticalAlignment="Center"
Text="{Binding Updates.CurrentVersion}" />
<Button Grid.Column="2" Padding="12,4"
Command="{Binding Updates.CheckCommand}"
IsEnabled="{Binding Updates.CanCheck}"
Content="{Binding Localization[CheckUpdatesButton]}" />
<!-- The status is always on show: the check flies off with the
window, and where things stand is the answer the user came for -->
<TextBlock Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" TextWrapping="Wrap"
Text="{Binding Updates.StatusText}" />
<!-- The downloaded fraction is not always known: not every hosting reports the file size -->
<ProgressBar Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,8,0,0" Height="4" Maximum="1"
Value="{Binding Updates.Progress, Mode=OneWay}"
IsIndeterminate="{Binding Updates.IsProgressUnknown}"
Visibility="{Binding Updates.IsProgressShown, Converter={StaticResource BooleanToVisibility}}" />
<StackPanel Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" Orientation="Horizontal">
<Button Padding="12,4"
Command="{Binding Updates.DownloadCommand}"
Visibility="{Binding Updates.IsDownloadOffered, Converter={StaticResource BooleanToVisibility}}"
Content="{Binding Localization[DownloadUpdateButton]}" />
<Button Padding="12,4"
Command="{Binding Updates.InstallCommand}"
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
Content="{Binding Localization[InstallUpdateButton]}" />
<TextBlock Margin="12,0,0,0" VerticalAlignment="Center"
Visibility="{Binding Updates.IsReleaseLinkShown, Converter={StaticResource BooleanToVisibility}}">
<Hyperlink NavigateUri="{Binding Updates.ReleaseUrl}"
RequestNavigate="OnReleaseLinkNavigate">
<Run Text="{Binding Localization[ReleasePageLink], Mode=OneWay}" />
</Hyperlink>
</TextBlock>
</StackPanel>
<TextBlock Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[UpdateInstallHint]}" />
</Grid>
</GroupBox>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Margin="16,0,0,0"> <StackPanel Grid.Column="1" Margin="16,0,0,0">
@@ -1,7 +1,4 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Windows; using System.Windows;
using System.Windows.Navigation;
using CursorLang.Settings.Services; using CursorLang.Settings.Services;
using CursorLang.Settings.ViewModels; using CursorLang.Settings.ViewModels;
@@ -28,23 +25,4 @@ public partial class MainWindow : Window
theme.Register(this); theme.Register(this);
placement.Attach(this); placement.Attach(this);
} }
/// <summary>
/// Opens the release page in a browser. A link in WPF leads nowhere on its own:
/// where to hand it over is up to the application.
/// </summary>
private void OnReleaseLinkNavigate(object sender, RequestNavigateEventArgs e)
{
e.Handled = true;
try
{
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true })?.Dispose();
}
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException)
{
// There is no browser in the system — that does not get in the way of the
// update, which is downloaded by the button next to it anyway
}
}
} }
-40
View File
@@ -1,40 +0,0 @@
using System.Net;
using System.Text;
namespace CursorLang.Tests.Shared;
/// <summary>
/// The network as the test writes it: the answer is decided here rather than
/// by a repository somewhere.
/// </summary>
public sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _reply;
public FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> reply) => _reply = reply;
public List<HttpRequestMessage> Requests { get; } = [];
public static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
public static FakeHttpHandler Status(HttpStatusCode status) =>
new(_ => new HttpResponseMessage(status));
public static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(content),
});
public HttpClient CreateClient() => new(this);
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(_reply(request));
}
}
+9 -70
View File
@@ -120,75 +120,6 @@ public sealed class FakeStartupService : IStartupService
} }
} }
/// <summary>
/// Releases the test writes itself, with no repository behind them.
/// </summary>
public sealed class FakeUpdateService : IUpdateService
{
public ReleaseInfo? Release { get; set; }
public Exception? Failure { get; set; }
public TaskCompletionSource? DownloadGate { get; set; }
public string PackagePath { get; set; } = string.Empty;
public int CheckCalls { get; private set; }
public List<string> Installed { get; } = [];
public bool IsSupported { get; set; } = true;
public Version CurrentVersion { get; set; } = new(1, 0, 0, 0);
public Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
{
CheckCalls++;
return Failure is null
? Task.FromResult(Release)
: Task.FromException<ReleaseInfo?>(Failure);
}
public async Task<string> DownloadAsync(
ReleaseInfo release,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
if (DownloadGate is not null)
{
await DownloadGate.Task.WaitAsync(cancellationToken);
}
if (Failure is not null)
{
throw Failure;
}
progress?.Report(0.5);
return PackagePath;
}
public void Install(string packagePath) => Installed.Add(packagePath);
}
/// <summary>
/// A release list the test fills in, with no repository behind it.
/// </summary>
public sealed class FakeReleaseFeed : IReleaseFeed
{
public ReleaseInfo? Release { get; set; }
public Exception? Failure { get; set; }
public List<HttpRequestMessage> Authorized { get; } = [];
public Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken) =>
Failure is null ? Task.FromResult(Release) : Task.FromException<ReleaseInfo?>(Failure);
public void Authorize(HttpRequestMessage request) => Authorized.Add(request);
}
/// <summary> /// <summary>
/// Interface strings without resources: the key comes back as is, tagged with the language. /// Interface strings without resources: the key comes back as is, tagged with the language.
/// </summary> /// </summary>
@@ -200,12 +131,20 @@ public sealed class FakeLocalizationService : ILocalizationService
public List<string> RequestedKeys { get; } = []; public List<string> RequestedKeys { get; } = [];
/// <summary>
/// Strings the test writes out in full — for the ones it puts values into.
/// </summary>
public Dictionary<string, string> Strings { get; } = [];
public string this[string key] public string this[string key]
{ {
get get
{ {
RequestedKeys.Add(key); RequestedKeys.Add(key);
return $"{_currentLanguage}:{key}";
return Strings.TryGetValue(key, out string? value)
? value
: $"{_currentLanguage}:{key}";
} }
} }
+152
View File
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The installer for handing the application round outside the Store.
The Store gets an MSIX and signs it itself; this one nobody signs, so Windows
warns about an unknown publisher and the user has to insist. That is the price
of an unsigned build, and short of a certificate from a trusted authority there
is no way round it.
Built by build-installer.ps1 — it publishes both halves of the application and
passes the version, the architecture and the folders in as preprocessor
variables.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"
xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<Package
Name="CursorLang"
Manufacturer="Aleksandr Neichev"
Version="$(var.Version)"
UpgradeCode="B7E4C9A2-5D31-4F86-9E27-3A1C8B0D6F45"
Compressed="yes"
Scope="perUser">
<!--
Installing for the current user alone asks for no administrator rights,
and so for no consent dialog either. The application needs nothing
beyond the user's own account, and one awkward question the less is
worth more here than a copy shared by everyone who signs in.
MSI was designed around installing for the whole machine, and its
validation rules still say so: the ICE checks this trips over are
turned off in the project file, not answered.
-->
<!--
Everything goes inside the .msi. Left to itself WiX puts the cabinet
beside the file, and an installer that has to travel with a second
file next to it is no use to anyone it is sent to
-->
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<MajorUpgrade DowngradeErrorMessage="A newer version of CursorLang is already installed." />
<!--
Both halves may be running, and both hold on to files in the install
folder. Named here, they are asked to close instead of the install
failing halfway through on a file it cannot replace
-->
<util:CloseApplication Id="CloseAgent" Target="CursorLang.exe"
CloseMessage="yes" RebootPrompt="no" />
<util:CloseApplication Id="CloseSettings" Target="CursorLang.Settings.exe"
CloseMessage="yes" RebootPrompt="no" />
<Icon Id="CursorLangIcon" SourceFile="$(var.IconFile)" />
<Property Id="ARPPRODUCTICON" Value="CursorLangIcon" />
<Property Id="ARPNOREPAIR" Value="1" />
<!-- Only the folder is asked about: everything here is installed, always -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseFile)" />
<StandardDirectory Id="LocalAppDataFolder">
<Directory Id="ProgramsFolder" Name="Programs">
<Directory Id="INSTALLFOLDER" Name="CursorLang" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="ShortcutFolder" Name="CursorLang" />
</StandardDirectory>
<Feature Id="Main" Title="CursorLang" Level="1">
<ComponentGroupRef Id="Payload" />
<ComponentRef Id="StartMenuShortcut" />
</Feature>
<!--
The whole published folder: two executables, the libraries and the
runtime. Listing them one by one would mean rewriting this file every
time the runtime changes shape
-->
<ComponentGroup Id="Payload" Directory="INSTALLFOLDER">
<Files Include="$(var.PayloadDir)\**" />
</ComponentGroup>
<!--
The shortcut starts the agent — the half that lives in the tray and
shows the layout. The settings window opens from its menu, so it needs
no shortcut of its own.
The key path is a registry value rather than the shortcut file: for
anything installed into the user's profile Windows Installer wants it
that way, and a shortcut cannot serve as one
-->
<Component Id="StartMenuShortcut" Directory="ShortcutFolder" Guid="D4A81C36-7E52-4B19-A0F3-6C2E9D5B8471">
<Shortcut Id="CursorLangShortcut"
Name="CursorLang"
Description="Shows the keyboard layout at the cursor"
Target="[INSTALLFOLDER]CursorLang.exe"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="RemoveShortcutFolder" Directory="ShortcutFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\CursorLang" Name="Shortcut"
Type="integer" Value="1" KeyPath="yes" />
</Component>
<!--
The startup entry is written by the application itself, when startup is
switched on in its settings, and has to go when the application does:
left behind, it would point at an executable that is gone and Windows
would go on listing it in the startup list for good.
RemoveRegistryValue would have been the obvious way to say that, but it
means the opposite of what it sounds like — it wipes the value as the
product is installed, which would quietly switch startup off for anyone
who had turned it on and then upgraded. Hence reg.exe on the way out.
UPGRADINGPRODUCTCODE tells an uninstall apart from the removal of the
old version during an upgrade: the second one has to leave the entry
alone, or upgrading would cost the user their startup setting anyway.
-->
<CustomAction Id="ForgetStartup" Directory="INSTALLFOLDER" Execute="deferred"
Impersonate="yes" Return="ignore"
ExeCommand="&quot;[SystemFolder]reg.exe&quot; delete &quot;HKCU\Software\Microsoft\Windows\CurrentVersion\Run&quot; /v CursorLang /f" />
<CustomAction Id="ForgetStartupApproval" Directory="INSTALLFOLDER" Execute="deferred"
Impersonate="yes" Return="ignore"
ExeCommand="&quot;[SystemFolder]reg.exe&quot; delete &quot;HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run&quot; /v CursorLang /f" />
<InstallExecuteSequence>
<Custom Action="ForgetStartup" Before="RemoveFiles"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
<Custom Action="ForgetStartupApproval" After="ForgetStartup"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
</InstallExecuteSequence>
<!-- Offered at the end of the wizard, the way an installer usually does -->
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" />
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
<UI>
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed" />
</UI>
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Start CursorLang" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
</Package>
</Wix>
+3
View File
@@ -5,6 +5,9 @@
meant to be built — build-msix.ps1 restores it and takes the program from the meant to be built — build-msix.ps1 restores it and takes the program from the
packages folder. It is kept out of the build in CursorLang.sln for the same packages folder. It is kept out of the build in CursorLang.sln for the same
reason. reason.
The installer needs nothing from here: WiX arrives with its own project, at
Installer\CursorLang.wixproj.
--> -->
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
+45 -75
View File
@@ -1,6 +1,6 @@
<# <#
.SYNOPSIS .SYNOPSIS
Builds the CursorLang MSIX package for the Microsoft Store. Builds the CursorLang MSIX package.
.DESCRIPTION .DESCRIPTION
Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
@@ -8,7 +8,9 @@
plain .NET SDK. plain .NET SDK.
The package is handed to Partner Center as it comes out of here — the Store The package is handed to Partner Center as it comes out of here — the Store
is where it gets everything else done to it. puts its own signature on it and does the rest. Nothing is signed here: a
publicly trusted code signing certificate is not to be had, and the Store
asks for none.
The application is published with its own copy of .NET: Windows does not The application is published with its own copy of .NET: Windows does not
carry one, and MSIX cannot install the runtime as a package dependency. carry one, and MSIX cannot install the runtime as a package dependency.
@@ -23,18 +25,14 @@
be on; a signature is not needed, because what gets registered is the layout be on; a signature is not needed, because what gets registered is the layout
the package is made of rather than the package file. the package is made of rather than the package file.
.EXAMPLE
# A check on your own machine: your architecture alone
pwsh -File Packaging\build-msix.ps1 -Architectures x64
.EXAMPLE .EXAMPLE
# Build and install in one go, to click through the application # Build and install in one go, to click through the application
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install pwsh -File Packaging\build-msix.ps1 -Install
.EXAMPLE .EXAMPLE
# A build for the Store — the identity comes from Partner Center # A build for the Store — the identity comes from Partner Center
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -42,11 +40,8 @@ param(
[string] $Version = '1.0.0.0', [string] $Version = '1.0.0.0',
[string] $IdentityName = 'CursorLang', [string] $IdentityName = 'CursorLang',
[string] $Publisher = 'CN=Aleksandr Neychev', [string] $Publisher = 'CN=Aleksandr Neichev',
[string] $PublisherDisplayName = 'Aleksandr Neychev', [string] $PublisherDisplayName = 'Aleksandr Neichev',
[ValidateSet('x64', 'arm64')]
[string[]] $Architectures = @('x64', 'arm64'),
[switch] $Install, [switch] $Install,
@@ -64,7 +59,14 @@ $assets = Join-Path $root 'Assets'
$manifestTemplate = Join-Path $root 'AppxManifest.xml' $manifestTemplate = Join-Path $root 'AppxManifest.xml'
$toolsProject = Join-Path $root 'Tools\SdkTools.csproj' $toolsProject = Join-Path $root 'Tools\SdkTools.csproj'
if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' } if (-not $OutputPath) {
$OutputPath = Join-Path $repository 'artifacts'
} elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
# Windows names the folder a package was registered from in full, and the
# layout is looked for by that name below. A relative path would never be
# found there, and the registration of a build gone by would be left behind
$OutputPath = Join-Path (Get-Location).Path $OutputPath
}
$layoutRoot = Join-Path $OutputPath 'layout' $layoutRoot = Join-Path $OutputPath 'layout'
$packagesPath = Join-Path $OutputPath 'packages' $packagesPath = Join-Path $OutputPath 'packages'
@@ -77,9 +79,8 @@ if (-not (Test-Path $assets)) {
} }
if ($Install) { if ($Install) {
# Both things below are checked before the build rather than after it: the # Checked before the build rather than after it: the build takes minutes, and
# build takes minutes, and neither of them gets any truer while it runs # this does not get any truer while it runs
$developerMode = Get-ItemPropertyValue ` $developerMode = Get-ItemPropertyValue `
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' ` 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' `
-Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue -Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue
@@ -87,12 +88,6 @@ if ($Install) {
if ($developerMode -ne 1) { if ($developerMode -ne 1) {
throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.' throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.'
} }
$machineArchitecture = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
if ($Architectures -notcontains $machineArchitecture) {
throw "This machine is $machineArchitecture, and that architecture is not being built. Add it to -Architectures, or drop -Install."
}
} }
function Invoke-Tool { function Invoke-Tool {
@@ -189,71 +184,46 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
$built = @() Write-Host 'Building...' -ForegroundColor Cyan
foreach ($architecture in $Architectures) { $layout = Join-Path $layoutRoot 'x64'
Write-Host "Building $architecture..." -ForegroundColor Cyan
$layout = Join-Path $layoutRoot $architecture foreach ($half in @($agentProject, $settingsProject)) {
Invoke-Tool -Path 'dotnet' -Arguments @(
foreach ($half in @($agentProject, $settingsProject)) { 'publish', $half,
Invoke-Tool -Path 'dotnet' -Arguments @( '--configuration', 'Release',
'publish', $half, '--runtime', 'win-x64',
'--configuration', 'Release', '--self-contained', 'true',
'--runtime', "win-$architecture", "-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
'--self-contained', 'true', '--output', $layout,
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))", '--nologo'
'--output', $layout, )
'--nologo'
)
}
# Debug symbols have no place in the package: they take up room, the user
# has no use for them, and for crash reports the Store takes them separately
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
$manifest = (Get-Content $manifestTemplate -Raw).
Replace('{IdentityName}', $IdentityName).
Replace('{Publisher}', $Publisher).
Replace('{PublisherDisplayName}', $PublisherDisplayName).
Replace('{Version}', $Version).
Replace('{Architecture}', $architecture)
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
$package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
$built += $package
} }
$result = $built[0] # Debug symbols have no place in the package: they take up room, the user
# has no use for them, and for crash reports the Store takes them separately
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
if ($built.Count -gt 1) { Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan
# makeappx bundle takes everything from a folder, so the separate packages $manifest = (Get-Content $manifestTemplate -Raw).
# are gathered into one of their own first — otherwise the results of Replace('{IdentityName}', $IdentityName).
# earlier builds would end up in the bundle Replace('{Publisher}', $Publisher).
$bundleInput = Join-Path $OutputPath 'bundle' Replace('{PublisherDisplayName}', $PublisherDisplayName).
Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue Replace('{Version}', $Version).
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null Replace('{Architecture}', 'x64')
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
$result = Join-Path $packagesPath "CursorLang-$Version.msixbundle" Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
Remove-Item $bundleInput -Recurse -Force $result = Join-Path $packagesPath "CursorLang-$Version-x64.msix"
} Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $result)
if ($Install) { if ($Install) {
Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan Write-Host 'Installing...' -ForegroundColor Cyan
# The layout is registered rather than the package file: the two hold the # The layout is registered rather than the package file: the two hold the
# same thing, but a package file Windows only installs when it is signed # same thing, but a package file Windows only installs when it is signed
Add-AppxPackage -Register (Join-Path $layoutRoot "$machineArchitecture\AppxManifest.xml") Add-AppxPackage -Register (Join-Path $layout 'AppxManifest.xml')
} }
Write-Host '' Write-Host ''
+80 -66
View File
@@ -81,8 +81,8 @@ MSIX всегда выполняются в контексте вошедшег
открытии окна. открытии окна.
`CursorLang.Core.dll` — общее для обоих: модели, файл настроек, слежение за `CursorLang.Core.dll` — общее для обоих: модели, файл настроек, слежение за
раскладкой, обновления. Без UI, и так должно остаться: всё, что попадёт туда, раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
попадёт и в фоновый процесс. в фоновый процесс.
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
файл, который одним движением встаёт на место, и посылает агенту файл, который одним движением встаёт на место, и посылает агенту
@@ -167,56 +167,21 @@ MSIX всегда выполняются в контексте вошедшег
## Обновления ## Обновления
Проверка выполняется при открытии окна настроек, а не при включении машины: Приложение обновляет Store, а само приложение об этом не заботится: раздела
фоновая половина в сеть больше не ходит вовсе, да и показать ответ ей нечем. обновлений в окне нет, запросов в сеть нет и кода для них тоже нет.
Настройка в окне так и написана.
Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск Дело не во вкусе, а в цене подписи. MSIX Windows установит только тогда, когда
годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему доверяет подписи на нём, а публично доверенный сертификат для подписи кода
приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta` оказался недосягаем: удостоверяющие центры, которые их продают, здесь его не
— пропускается: предварительную версию берут намеренно, приложение её не выдают, а те, что держали бы ключ в облачном HSM, — тем более, а сертификат на
предлагает. USB-токене сюда не привезти. Без подписи пакет установится только на машине в
режиме разработчика, значит выкладывать в выпуск нечего и искать обновления
негде. Store подписывает пакет своим сертификатом и обновляет приложение сам —
на этом вопрос и закрыт.
Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры. Версия работающего приложения стоит в заголовке окна настроек:
Если его нет, берётся пакет, в имени которого стоит архитектура этой машины: `CursorLang 1.2.3 — Настройки`. Больше её в интерфейсе нигде нет, и стоит она
`CursorLang-1.2.3.0-x64.msix`. Такие имена даёт `build-msix.ps1`, так что выпуск там для того, чтобы её можно было назвать в сообщении об ошибке.
делается прикладыванием того, что он собрал.
Пакет скачивается во временную папку и передаётся установщику приложений
Windows: тот показывает издателя, спрашивает подтверждение и заменяет
установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску
пакет должен быть подписан — неподписанный установится только на машине в режиме
разработчика. Работающее приложение до перезапуска продолжает жить на старых
файлах.
У приложения, установленного из Store, раздела обновлений нет вовсе: его
обновляет Store, а пакет со стороны Windows поверх него всё равно не примет.
Приложение спрашивает о выпусках при каждом появлении окна настроек, и ответ
остаётся в разделе как есть — включая недоступную сеть. Открытие этого окна —
намеренное действие, достаточно редкое, чтобы запрос ничего не стоил, поэтому
между открытиями ничего не запоминается и выключать нечего. Кнопка рядом с
версией задаёт тот же вопрос заново по требованию.
Где искать выпуски, задаётся в `UpdateOptions`: репозиторий принадлежит тому, кто
выпускает приложение, а не пользователю, поэтому значения живут в сборке, а не в
`settings.json`:
```csharp
services.AddSingleton(new UpdateOptions
{
ServiceUri = new Uri("https://git.alrakis.kz/"), // сам сервер Gitea
Project = "alrakis/cursor-lang",
});
```
Выпуски берутся из Gitea, а её API живёт на самом сервере: адрес — тот же, по
которому репозиторий открывают в браузере, и под ним приложение спрашивает
`/api/v1/repos/{владелец}/{репозиторий}/releases`.
Закрытому репозиторию нужен токен. Он читается из переменной окружения
`CURSORLANG_UPDATE_TOKEN`, а не хранится в исходниках: секрет, встроенный в
сборку, — это секрет, отданный всем, кто эту сборку получил.
## Тесты ## Тесты
@@ -262,11 +227,11 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3` на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом проект собирается, проходит тесты и пакуется в MSIX, который остаётся
с приложенными пакетами. Номер версии берётся только из тега — тег любого в артефактах прогона. Номер версии берётся только из тега — тег любого другого
другого вида останавливает прогон в самом начале. Версия пакета получается вида останавливает прогон в самом начале. Версия пакета получается `1.2.3.0`:
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег Store принимает четыре числа и последнее оставляет себе, так что тег на него не
на него не влияет. влияет.
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём — Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка .NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
@@ -276,10 +241,18 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде. сеансе, пропускают себя: показать окно там негде.
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам
берётся из переменных репозитория, а если те не заданы — из значений по Partner Center. Поэтому прогон оставляет его в артефактах под именем
`msix-1.2.3.0`, откуда его забирают и загружают руками; к релизу не прикладывается
ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный
пакет, висящий в релизе, выглядел бы как то, что можно установить, и не
устанавливался бы нигде — см. раздел об обновлениях.
Identity берётся из переменных репозитория, а если те не заданы — из значений по
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
и `MSIX_PUBLISHER_DISPLAY_NAME`. и `MSIX_PUBLISHER_DISPLAY_NAME`. Вместе identity и publisher задают family name
пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store
примет следующую за другое приложение.
## Сборка пакета MSIX ## Сборка пакета MSIX
@@ -292,17 +265,22 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок) # Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
powershell -File Packaging\New-Assets.ps1 powershell -File Packaging\New-Assets.ps1
# Проверка на своей машине: только своя архитектура # Проверка на своей машине
powershell -File Packaging\build-msix.ps1 -Architectures x64 powershell -File Packaging\build-msix.ps1
# Для Partner Center — identity та, что зарезервирована там # Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
``` ```
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64 Результат — `artifacts\packages\CursorLang-<версия>-x64.msix`. Он загружается в
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner Partner Center как есть.
Center как есть.
Собирается только x64. Сборка под arm64 удвоила бы вес каждого релиза ради
машин, которые и так выполняют x64 через эмуляцию.
Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки
на эту машину вместо пакета регистрируется layout — см. `-Install` ниже.
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
MSIX не может установить среду выполнения как зависимость пакета. MSIX не может установить среду выполнения как зависимость пакета.
@@ -310,7 +288,7 @@ MSIX не может установить среду выполнения как
Чтобы посмотреть, как пакет работает на этой машине, соберите его с `-Install`: Чтобы посмотреть, как пакет работает на этой машине, соберите его с `-Install`:
```powershell ```powershell
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install pwsh -File Packaging\build-msix.ps1 -Install
``` ```
Приложение появится в меню «Пуск» как любое установленное. Регистрируется не сам Приложение появится в меню «Пуск» как любое установленное. Регистрируется не сам
@@ -324,5 +302,41 @@ pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
Удалить вручную: Удалить вручную:
```powershell ```powershell
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
``` ```
## Сборка установщика
То же приложение собирается и обычным MSI — чтобы раздавать помимо Store: пока
Store не вынес решение или если так его и не вынесет. Ставить ничего, кроме .NET
SDK, не нужно: WiX приезжает пакетом NuGet, как и `makeappx`.
```powershell
# Собрать и запустить — посмотреть глазами пользователя
pwsh -File Packaging\build-installer.ps1 -Install
# Всё, что нужно релизу
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
```
Результат — `artifacts\installers\CursorLang-<версия>-x64.msi`. Всё лежит внутри
`.msi` — отдельного архива рядом с ним нет.
Устанавливается только для текущего пользователя, в
`%LOCALAPPDATA%\Programs\CursorLang`, поэтому не просит ни прав администратора,
ни подтверждения. Удаление идёт через «Параметры» — «Приложения», как у любой
программы, и уносит с собой запись автозапуска: иначе Windows продолжала бы
показывать в автозагрузке приложение, которого уже нет.
Установщик никто не подписывает, поэтому Windows предупреждает о неизвестном
издателе и пользователю приходится настоять. Покупка сертификата это сразу не
снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока
приложение не наберёт установок.
Про проект WiX стоит знать две вещи, прежде чем его править. Он закреплён на WiX
5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать
лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в
год, но принимать её должен человек, а не сборочный скрипт. И он отключает три
проверки ICE: MSI по-прежнему исходит из установки на всю машину, а установка в
профиль пользователя нарушает правила, которые описывают ровно то, что здесь и
задумано.
+81 -67
View File
@@ -78,8 +78,8 @@ WPF took goes back to the system. The price is a cold start of half a second or
the next time the window is asked for. the next time the window is asked for.
`CursorLang.Core.dll` is what both hold in common: the models, the settings file, `CursorLang.Core.dll` is what both hold in common: the models, the settings file,
the layout tracking, the updates. No UI, and it must stay that way — whatever lands the layout tracking. No UI, and it must stay that way — whatever lands there lands
there lands in the background process. in the background process.
The connection between the two is `settings.json` and nothing else. The window The connection between the two is `settings.json` and nothing else. The window
writes it — whole, into a temporary file moved into place in one step — and then writes it — whole, into a temporary file moved into place in one step — and then
@@ -162,57 +162,22 @@ after a switch of the mode, and that is a question asked once.
## Updates ## Updates
The check runs when the settings window is opened, not when the machine is The Store updates the app, and the app itself does nothing about it: there is no
switched on: the background half no longer goes to the network at all, and there updates section in the window, no request to the network and no code for either.
would be nothing in it to show the answer. The setting in the window says as much.
The app looks for new versions among the releases of its own repository. A That is not a matter of taste but of what a signature costs. Windows installs an
release counts when its tag is a plain version — `v1.2.3` or `1.2.3` — and an MSIX only when it trusts the signature on it, and a publicly trusted code signing
MSIX package is attached to it. A tag with anything else in it, `v1.2.3-beta` certificate has turned out to be beyond reach — the certificate authorities that
among them, is passed over: a pre-release version is asked for on purpose, not sell them will not issue one here, and those that would keep the key in a cloud
offered by the app. HSM will not either, while a certificate on a USB token cannot be shipped in.
Without a signature a package installs nowhere but a machine in developer mode,
so there is nothing to hand out from a release and nothing for an updater to
find. The Store signs the package with its own certificate and updates the app
by itself, which leaves the whole question to it.
Out of the attached files the `.msixbundle` is preferred — it carries both The version of the running app is in the title of the settings window:
architectures. Failing that, the package whose name holds the architecture of `CursorLang 1.2.3 — Settings`. It is nowhere else in the interface, and it is
this machine is taken: `CursorLang-1.2.3.0-x64.msix`. Those are the names there so that a bug report can name it.
`build-msix.ps1` produces, so a release is made by attaching what it built.
The package is downloaded to the temp folder and handed to the Windows app
installer: it shows the publisher, asks for a confirmation and replaces the
installed version. Windows checks the signature, so the package attached to a
release has to be signed — an unsigned one installs nowhere but a machine in
developer mode. The running app keeps working off the old files until it is
restarted.
An app installed from the Store has no updates section at all: the Store
updates it, and a package from the side is something Windows would not accept
over it anyway.
The app asks about releases every time the settings window appears, and the
answer stays in the section as it is — an unreachable network included. Opening
that window is a deliberate act, rare enough for a request to cost nothing, so
nothing is remembered between openings and there is nothing to turn off. The
button next to the version asks the same question again on demand.
Where the releases are looked for is set in `UpdateOptions` — the repository
belongs to whoever publishes the app, not to the user, so the values live in the
build rather than in `settings.json`:
```csharp
services.AddSingleton(new UpdateOptions
{
ServiceUri = new Uri("https://git.alrakis.kz/"), // the Gitea server itself
Project = "alrakis/cursor-lang",
});
```
The releases come from Gitea, and its API sits on the server itself: the address
is the one the repository is opened at in a browser, and the app asks
`/api/v1/repos/{owner}/{repo}/releases` under it.
A closed repository needs a token. It is read from the `CURSORLANG_UPDATE_TOKEN`
environment variable rather than kept in the source: a secret built into the app
is a secret handed to everyone who got the app.
## Tests ## Tests
@@ -257,11 +222,10 @@ running agent and fails if any part of the WPF renderer is in it.
The pipelines live in `.gitea/workflows` and run on Gitea Actions. A pull The pipelines live in `.gitea/workflows` and run on Gitea Actions. A pull
request into `master` is built and tested; a tag of the form `v1.2.3` is built, request into `master` is built and tested; a tag of the form `v1.2.3` is built,
tested, packed into an MSIX and published as a release with the packages tested and packed into an MSIX, which is left in the artifacts of the run. The
attached. The version is taken from the tag alone — a tag shaped any other way version is taken from the tag alone — a tag shaped any other way stops the run
stops the run right at the start. The package version ends up as `1.2.3.0`: the right at the start. The package version ends up as `1.2.3.0`: the Store takes
Store takes four numbers and keeps the last one for itself, so the tag has no four numbers and keeps the last one for itself, so the tag has no say in it.
say in it.
Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10 Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10
SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a
@@ -271,9 +235,18 @@ need a desktop of their own — the end-to-end ones, and those that ask for the
foreground window or the caret — skip themselves on a runner that lives as a foreground window or the caret — skip themselves on a runner that lives as a
service in session 0, where there is no desktop to show a window on. service in session 0, where there is no desktop to show a window on.
The package the release carries goes to Partner Center as it is. The identity The package goes to the Store and nowhere else: it is unsigned, and Partner
comes from repository variables and falls back to the defaults of the script when Center puts its own signature on it. So the run leaves it in the artifacts under
unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`. the name `msix-1.2.3.0`, where whoever uploads it picks it up by hand; nothing
is attached to the release, which Gitea makes for the tag by itself and which
carries the tag alone. An unsigned package hanging off a release would look like
something to install and install nowhere — see the section on updates.
The identity comes from repository variables and falls back to the defaults of
the script when unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and
`MSIX_PUBLISHER_DISPLAY_NAME`. Together the identity and the publisher decide
the family name of the package, so both have to stay as they are from version to
version, or the Store takes the next one for a different app.
## Building the MSIX package ## Building the MSIX package
@@ -286,17 +259,22 @@ to submitting for certification — is written up in
# One-off: draw the logos and the exe icon (already committed, rerun after edits) # One-off: draw the logos and the exe icon (already committed, rerun after edits)
powershell -File Packaging\New-Assets.ps1 powershell -File Packaging\New-Assets.ps1
# A check on your own machine: your architecture alone # A check on your own machine
powershell -File Packaging\build-msix.ps1 -Architectures x64 powershell -File Packaging\build-msix.ps1
# For Partner Center — the identity is the one reserved there # For Partner Center — the identity is the one reserved there
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
``` ```
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64 The result is `artifacts\packages\CursorLang-<version>-x64.msix`. Upload it to
and arm64; next to it lie the packages of single architectures. Upload the bundle Partner Center as it is.
to Partner Center as it is.
Only x64 is built. An arm64 build would double the size of every release for the
sake of machines that run the x64 one under emulation anyway.
Nothing here is signed: the Store signs the package itself, and for installing it
on this machine the layout is registered instead — see `-Install` below.
The app ships with its own copy of .NET: Windows does not include .NET 10, and The app ships with its own copy of .NET: Windows does not include .NET 10, and
MSIX cannot install a runtime as a package dependency. MSIX cannot install a runtime as a package dependency.
@@ -304,7 +282,7 @@ MSIX cannot install a runtime as a package dependency.
To see the package working on this machine, build it with `-Install`: To see the package working on this machine, build it with `-Install`:
```powershell ```powershell
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install pwsh -File Packaging\build-msix.ps1 -Install
``` ```
The application then shows up in the Start menu like any installed one. What The application then shows up in the Start menu like any installed one. What
@@ -318,5 +296,41 @@ the Store answers to the same name and is left alone.
To remove it by hand: To remove it by hand:
```powershell ```powershell
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
``` ```
## Building the installer
The same application also comes as an ordinary MSI, for handing round outside the
Store — to try it out before the Store has passed judgement, or if it never does.
Nothing has to be installed beyond the .NET SDK: WiX comes from a NuGet package,
the same way `makeappx` does.
```powershell
# Build it and run it afterwards, to see what a user sees
pwsh -File Packaging\build-installer.ps1 -Install
# Everything a release needs
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
```
The result is `artifacts\installers\CursorLang-<version>-x64.msi`. Everything
travels inside the .msi; there is no cabinet to send alongside it.
It installs for the current user alone, into `%LOCALAPPDATA%\Programs\CursorLang`,
and so asks for no administrator rights and no consent dialog. Uninstalling goes
through Apps in Settings like any other program and takes the startup entry with
it — otherwise Windows would go on listing an application that is no longer there.
Nobody signs the installer, so Windows warns about an unknown publisher and the
user has to insist. Buying a certificate would not silence it at once either:
SmartScreen goes by reputation, and a fresh certificate has none until enough
people have installed the application.
Two things about the WiX project are worth knowing before touching it. It pins
WiX 5 rather than the current 7: from version 6 the toolset asks every build to
accept the Open Source Maintenance Fee licence — free below $10,000 of yearly
revenue, but a decision for a person rather than for a build script. And it turns
off three ICE validation rules: MSI still assumes an installation for the whole
machine, and installing into the user's own profile trips rules that describe
exactly what was intended here.