modified release pipeline #4

Merged
alex merged 3 commits from add-package-sign-to-pipeline into master 2026-08-13 11:17:23 +00:00
30 changed files with 183 additions and 2333 deletions
Showing only changes of commit dd0bbdea42 - Show all commits
+14 -192
View File
@@ -2,17 +2,17 @@
# 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 is built twice, because the two places it goes to want different # The package goes to the Store and nowhere else, so it leaves the run as an
# things of it. The Store gets a package with the identity reserved in Partner # artifact: someone picks it up and uploads it to Partner Center, which puts its
# Center and no signature — Partner Center signs it there. The release gets a # own signature on it. Nothing is signed here and nothing is attached to the
# package signed here, with SSL.com's certificate the private key of which never # release — a publicly trusted code signing certificate is not to be had, and an
# leaves their HSM; Windows installs nothing else. The two only differ inside, # unsigned package would look like something to install and install nowhere.
# so the Store one carries a suffix in its name and never leaves the artifacts. # 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 and # .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
# signtool come with a NuGet package (Packaging\Tools\SdkTools.csproj), so the # with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
# Windows SDK does not have to be installed. # not have to be installed.
name: Release name: Release
on: on:
@@ -84,25 +84,6 @@ 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 signing account is asked about before anything is built rather than
# at the step that needs it: a release without a signed package is not a
# release, and finding that out after the build and the tests costs the
# whole run
- name: Check the signing credentials
env:
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
run: |
$ErrorActionPreference = 'Stop'
$missing = @('ESIGNER_USERNAME', 'ESIGNER_PASSWORD', 'ESIGNER_TOTP_SECRET') |
Where-Object { -not (Get-Item "Env:$_" -ErrorAction SilentlyContinue).Value }
if ($missing) {
throw "The repository secrets $($missing -join ', ') are not set. They are the SSL.com account the package is signed with; the TOTP secret is the one eSigner hands out for automated signing, not a six-digit code."
}
- name: Show the toolchain - name: Show the toolchain
run: dotnet --info run: dotnet --info
@@ -123,12 +104,7 @@ jobs:
# The package comes out as Partner Center wants it — the Store puts its own # The package comes out as Partner Center wants it — the Store puts its own
# signature on it. The identity comes from repository variables and falls # signature on it. The identity comes from repository variables and falls
# back to the defaults of the script when a variable is not set. # back to the defaults of the script when a variable is not set.
# - name: Pack the MSIX
# This one is picked up by hand and uploaded to Partner Center, so it goes
# no further than the artifacts of the run: attached to the release it
# would sit there as a package nobody can install, next to one that
# installs — telling the two apart is what the suffix in the name is for
- name: Pack the MSIX for the Store
env: env:
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }} IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
PUBLISHER: ${{ vars.MSIX_PUBLISHER }} PUBLISHER: ${{ vars.MSIX_PUBLISHER }}
@@ -136,11 +112,7 @@ jobs:
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$arguments = @{ $arguments = @{ Version = '${{ steps.version.outputs.version }}' }
Version = '${{ steps.version.outputs.version }}'
PackageSuffix = 'store'
OutputPath = 'artifacts/store'
}
# An empty variable is left out rather than passed on: the script has # An empty variable is left out rather than passed on: the script has
# defaults of its own, and an empty string would wipe them # defaults of its own, and an empty string would wipe them
@@ -156,160 +128,10 @@ jobs:
./Packaging/build-msix.ps1 @arguments ./Packaging/build-msix.ps1 @arguments
- name: Keep the Store packages # The artifact is where the package waits to be uploaded to Partner Center
uses: actions/upload-artifact@v4 - name: Keep the package
with:
name: msix-store-${{ steps.version.outputs.version }}
path: artifacts/store/packages/
if-no-files-found: error
# eSigner CKA is a key storage provider: it puts the certificate into the
# store of this user and answers signtool's requests for the private key
# over SSL.com's API, so the key itself never comes down to the runner.
# From signtool's side it looks like a certificate on a token, minus the
# token and minus the person who would plug it in.
#
# The TOTP secret is what stands in for that person: eSigner hands it out
# once, for automated signing, and the tool makes the codes out of it
# itself
- name: Load the signing certificate
id: certificate
env:
CKA_URL: https://github.com/SSLcom/eSignerCKA/releases/download/v1.0.6/SSL.COM-eSigner-CKA_1.0.6.zip
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
run: |
$ErrorActionPreference = 'Stop'
$temporary = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$archive = Join-Path $temporary 'eSignerCKA.zip'
$unpacked = Join-Path $temporary 'eSignerCKA'
# Everything of the adapter's own — the installation and the master
# key it keeps the account in — lives outside the workspace: the
# workspace is what gets packed and uploaded
$suite = Join-Path $env:USERPROFILE '.signingsuite'
$installation = Join-Path $suite 'eSignerCKA'
Remove-Item $unpacked -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $suite -Force | Out-Null
Invoke-WebRequest -Uri $env:CKA_URL -OutFile $archive
Expand-Archive -Path $archive -DestinationPath $unpacked -Force
$installer = Get-ChildItem $unpacked -Recurse -Filter '*.exe' | Select-Object -First 1
if (-not $installer) { throw "No installer inside the eSigner CKA archive at '$env:CKA_URL'." }
# /CURRENTUSER, so that the certificate lands in the store of the user
# the build runs as — the same one signtool then looks in
& $installer.FullName /CURRENTUSER /VERYSILENT /SUPPRESSMSGBOXES "/DIR=$installation" | Out-Null
$tool = Join-Path $installation 'eSignerCKATool.exe'
if (-not (Test-Path $tool)) { throw "eSigner CKA did not install: '$tool' is not there." }
& $tool config -mode product -user $env:ESIGNER_USERNAME -pass $env:ESIGNER_PASSWORD -totp $env:ESIGNER_TOTP_SECRET -key (Join-Path $suite 'master.key') -r
if ($LASTEXITCODE -ne 0) { throw "eSigner CKA turned down the account: the tool exited with code $LASTEXITCODE." }
# A run of the same runner could have left a certificate loaded from
# another account; unload says nothing when there is nothing to
# unload, and its exit code is of no interest for that reason
& $tool unload | Out-Null
& $tool load
if ($LASTEXITCODE -ne 0) { throw "eSigner CKA could not load the certificate: the tool exited with code $LASTEXITCODE." }
# An account may hold more than one certificate — a renewal leaves the
# old one behind — and the one that lives longest is the one to sign
# with: a signature made by a certificate about to expire is timestamped
# and stays good, but the next release would have to be made anyway
$certificate = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $certificate) {
throw 'eSigner CKA loaded nothing into the certificate store. Does the account hold a code signing certificate — a document signature is a different thing and cannot sign a package.'
}
Write-Host "Signing as $($certificate.Subject), good until $($certificate.NotAfter.ToString('yyyy-MM-dd'))."
"thumbprint=$($certificate.Thumbprint)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
"subject=$($certificate.Subject)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
# The package of the release carries the identity the app is known by and
# the publisher of the certificate — Windows works out the family name of
# a package from the two together, so an update replaces the installed
# version only while both stay as they were. The names have no suffix:
# that is what the app looks for when it checks for an update
- name: Pack and sign the MSIX for the release
env:
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
PUBLISHER: ${{ steps.certificate.outputs.subject }}
PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }}
THUMBPRINT: ${{ steps.certificate.outputs.thumbprint }}
run: |
$ErrorActionPreference = 'Stop'
$arguments = @{
Version = '${{ steps.version.outputs.version }}'
OutputPath = 'artifacts/release'
Publisher = $env:PUBLISHER
CertificateThumbprint = $env:THUMBPRINT
}
$variables = @{
IdentityName = $env:IDENTITY_NAME
PublisherDisplayName = $env:PUBLISHER_DISPLAY_NAME
}
foreach ($name in $variables.Keys) {
if ($variables[$name]) { $arguments[$name] = $variables[$name] }
}
./Packaging/build-msix.ps1 @arguments
- name: Keep the signed packages
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/release/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
# looked up first and only made when it is not there
- name: Publish the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
$ErrorActionPreference = 'Stop'
# The GITHUB_ names are what Gitea itself hands to the workflow — its
# actions repeat those of GitHub, and the addresses in them point at
# this Gitea instance. GITHUB_API_URL used not to reach the steps at
# all, so the address is put together from the server one when empty
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
$release = $null
try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { }
if (-not $release) {
$body = @{ tag_name = $env:TAG; name = $env:TAG; draft = $false; prerelease = $false } | ConvertTo-Json
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
}
# Only the signed packages: the Store one stays in the artifacts of
# the run, where whoever uploads it to Partner Center picks it up
foreach ($file in Get-ChildItem artifacts/release/packages -File) {
# A tag can be pushed again after it was deleted; the old file of
# the same name is dropped, otherwise the upload is refused
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
foreach ($asset in $existing) {
Invoke-RestMethod "$api/$($release.id)/assets/$($asset.id)" -Method Delete -Headers $headers | Out-Null
}
Write-Host "Uploading $($file.Name)"
Invoke-RestMethod "$api/$($release.id)/assets?name=$($file.Name)" -Method Post -Headers $headers -Form @{ attachment = $file } | Out-Null
}
@@ -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);
}
+1 -1
View File
@@ -17,7 +17,7 @@
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product> <Product>CursorLang</Product>
<Company>Aleksandr Neichev</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>();
@@ -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}";
} }
} }
+11 -98
View File
@@ -3,15 +3,14 @@
Builds the CursorLang MSIX package. Builds the CursorLang MSIX package.
.DESCRIPTION .DESCRIPTION
Neither Visual Studio nor the Windows SDK is needed: makeappx and signtool Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
arrive as a NuGet package (Tools\SdkTools.csproj) and the application is NuGet package (Tools\SdkTools.csproj) and the application is built by the
built by the plain .NET SDK. plain .NET SDK.
The package comes out of here in one of two shapes. Left unsigned it goes to The package is handed to Partner Center as it comes out of here the Store
Partner Center as it is the Store puts its own signature on it and does the puts its own signature on it and does the rest. Nothing is signed here: a
rest. Signed with -CertificateThumbprint it is a package anyone can install publicly trusted code signing certificate is not to be had, and the Store
from a release, because Windows only takes a package whose signature it asks for none.
trusts.
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.
@@ -21,17 +20,6 @@
section. It is handed out there together with the reserved application name; section. It is handed out there together with the reserved application name;
the default is only good enough for a check on your own machine. the default is only good enough for a check on your own machine.
.PARAMETER PackageSuffix
Goes at the end of the file names. The two builds of a release differ in the
identity inside them and in nothing a file listing shows, so the one meant
for the Store is told apart by a suffix of its own.
.PARAMETER CertificateThumbprint
Signs the packages with the certificate of this thumbprint from the personal
store of the current user. The private key is none of this script's business:
signtool asks the store for it, and behind a cloud certificate eSigner CKA
among them the store answers over the network.
.PARAMETER Install .PARAMETER Install
Puts the package onto this machine to see it working. Developer mode has to Puts the package onto this machine to see it working. Developer mode has to
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
@@ -47,14 +35,8 @@
.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 -PackageSuffix store `
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
.EXAMPLE
# A build for a release — the publisher is the subject of the certificate
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-Publisher "CN=Aleksandr Neichev, O=Aleksandr Neichev, C=KZ" ` -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
-CertificateThumbprint A1B2C3D4E5F60718293A4B5C6D7E8F9012345678
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -68,15 +50,6 @@ param(
[ValidateSet('x64', 'arm64')] [ValidateSet('x64', 'arm64')]
[string[]] $Architectures = @('x64', 'arm64'), [string[]] $Architectures = @('x64', 'arm64'),
[string] $PackageSuffix,
[string] $CertificateThumbprint,
# SSL.com's timestamp server, to go with the certificate the pipeline signs
# with. A signature without a timestamp is only good while the certificate
# is: the day it expires the package stops installing everywhere at once
[string] $TimestampUrl = 'http://ts.ssl.com',
[switch] $Install, [switch] $Install,
[string] $OutputPath [string] $OutputPath
@@ -112,26 +85,6 @@ if (-not (Test-Path $assets)) {
throw "No logos found in '$assets'. Run Packaging\New-Assets.ps1 first." throw "No logos found in '$assets'. Run Packaging\New-Assets.ps1 first."
} }
if ($CertificateThumbprint) {
# Both things below are checked before the build for the same reason the
# ones under -Install are: the build takes minutes and neither answer
# changes while it runs
$certificate = Get-ChildItem "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction SilentlyContinue
if (-not $certificate) {
throw "No certificate with the thumbprint '$CertificateThumbprint' in the personal store of this user. A cloud certificate has to be loaded into the store first — eSigner CKA is what does that."
}
# The Publisher of a package is not a name of the publisher's choosing: it
# is the subject of the certificate the package is signed with, letter for
# letter. signtool turns down a package that claims any other, and it does
# so at the very end — after everything has already been built
if ($certificate.Subject -ne $Publisher) {
throw "The publisher '$Publisher' is not the subject of the certificate, which reads '$($certificate.Subject)'. A package carries the name of whoever signs it."
}
}
if ($Install) { if ($Install) {
# Both things below are checked before the build rather than after it: the # Both things below are checked before the build rather than after it: the
# build takes minutes, and neither of them gets any truer while it runs # build takes minutes, and neither of them gets any truer while it runs
@@ -216,34 +169,11 @@ function Get-SdkTool {
return $tool.FullName return $tool.FullName
} }
function Invoke-Signing {
<#
.SYNOPSIS
Signs a package with the certificate the build was given.
#>
param(
[string] $Path,
[string] $SignTool
)
Write-Host "Signing $([System.IO.Path]::GetFileName($Path))..." -ForegroundColor Cyan
Invoke-Tool -Path $SignTool -Arguments @(
'sign',
'/fd', 'sha256',
'/tr', $TimestampUrl,
'/td', 'sha256',
'/sha1', $CertificateThumbprint,
$Path
)
}
Write-Host 'Fetching the Windows SDK programs...' -ForegroundColor Cyan Write-Host 'Fetching the Windows SDK programs...' -ForegroundColor Cyan
Invoke-Tool -Path 'dotnet' -Arguments @('restore', $toolsProject, '--nologo') Invoke-Tool -Path 'dotnet' -Arguments @('restore', $toolsProject, '--nologo')
$sdkTools = Get-SdkToolsPath $sdkTools = Get-SdkToolsPath
$makeappx = Get-SdkTool -Name 'makeappx.exe' -PackagePath $sdkTools $makeappx = Get-SdkTool -Name 'makeappx.exe' -PackagePath $sdkTools
$signtool = if ($CertificateThumbprint) { Get-SdkTool -Name 'signtool.exe' -PackagePath $sdkTools } else { $null }
# A package registered out of the layout runs straight from that folder, and # A package registered out of the layout runs straight from that folder, and
# the folder is about to be wiped. Left in place, the registration would point # the folder is about to be wiped. Left in place, the registration would point
@@ -268,7 +198,6 @@ 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
$suffix = if ($PackageSuffix) { "-$PackageSuffix" } else { '' }
$built = @() $built = @()
foreach ($architecture in $Architectures) { foreach ($architecture in $Architectures) {
@@ -303,7 +232,7 @@ foreach ($architecture in $Architectures) {
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
$package = Join-Path $packagesPath "CursorLang-$Version-$architecture$suffix.msix" $package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package) Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
$built += $package $built += $package
@@ -322,23 +251,12 @@ if ($built.Count -gt 1) {
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput } $built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
$result = Join-Path $packagesPath "CursorLang-$Version$suffix.msixbundle" $result = Join-Path $packagesPath "CursorLang-$Version.msixbundle"
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version) Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
Remove-Item $bundleInput -Recurse -Force Remove-Item $bundleInput -Recurse -Force
} }
if ($CertificateThumbprint) {
# Signing comes after the bundle rather than before it: makeappx copies the
# packages into the bundle as they are, and a signature on what lies inside
# says nothing about the bundle around it. Windows asks the outer file, so
# every file that leaves here is signed on its own — each one of them is a
# package someone may install
foreach ($package in (@($built) + @($result) | Select-Object -Unique)) {
Invoke-Signing -Path $package -SignTool $signtool
}
}
if ($Install) { if ($Install) {
Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan
@@ -352,12 +270,7 @@ Write-Host 'Done.' -ForegroundColor Green
Write-Host " $result" Write-Host " $result"
Write-Host '' Write-Host ''
Write-Host ' This file is uploaded to Partner Center as it is.'
if ($CertificateThumbprint) {
Write-Host ' This file is signed: Windows installs it on any machine.'
} else {
Write-Host ' This file is uploaded to Partner Center as it is.'
}
if ($Install) { if ($Install) {
Write-Host '' Write-Host ''
+33 -84
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,29 +241,18 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде. сеансе, пропускают себя: показать окно там негде.
Пакет собирается дважды: Store и релиз хотят от него разного. Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам
Partner Center. Поэтому прогон оставляет его в артефактах под именем
`msix-1.2.3.0`, откуда его забирают и загружают руками; к релизу не прикладывается
ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный
пакет, висящий в релизе, выглядел бы как то, что можно установить, и не
устанавливался бы нигде — см. раздел об обновлениях.
Тот, что для Store, несёт identity, зарезервированную в Partner Center, и не Identity берётся из переменных репозитория, а если те не заданы — из значений по
подписан — подпись на него ставит сам Partner Center. Дальше артефактов прогона умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
он не уходит: его забирают и загружают руками. Что это он, видно по имени — и `MSIX_PUBLISHER_DISPLAY_NAME`. Вместе identity и publisher задают family name
`CursorLang-1.2.3.0-store.msixbundle`. Identity берётся из переменных пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store
репозитория, а если те не заданы — из значений по умолчанию в скрипте: примет следующую за другое приложение.
`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` и `MSIX_PUBLISHER_DISPLAY_NAME`.
Тот, что приложен к релизу, пайплайн подписывает сертификатом от SSL.com:
закрытый ключ остаётся в их HSM и на runner не попадает — на нём ставится
eSigner CKA как поставщик хранилища ключей, и `signtool` спрашивает ключ у него
так же, как спросил бы у токена. Учётная запись — три секрета репозитория:
`ESIGNER_USERNAME`, `ESIGNER_PASSWORD` и `ESIGNER_TOTP_SECRET`; последний — тот
секрет, который eSigner выдаёт для автоматической подписи, а не код с телефона.
Проверяются они до сборки, а не на шаге подписи: релиз без подписанного пакета —
не релиз.
`Publisher` у него не `MSIX_PUBLISHER`, а subject этого сертификата, прочитанный
прямо в прогоне: пакет, называющий кого-то другого, Windows считает подделкой.
Вместе с identity этот subject задаёт family name пакета, поэтому от релиза к
релизу оба должны оставаться прежними — иначе обновление встанет рядом со старой
версией, а не заменит её.
## Сборка пакета MSIX ## Сборка пакета MSIX
@@ -323,13 +277,8 @@ powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
Center как есть. Center как есть.
`-CertificateThumbprint` подписывает всё, что собрано, сертификатом с этим Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки
отпечатком из личного хранилища текущего пользователя; `-Publisher` тогда должен на эту машину вместо пакета регистрируется layout — см. `-Install` ниже.
называть его subject — скрипт говорит об этом до сборки, а не после. Обычно
подписывает пайплайн, но то же работает и руками, как только eSigner CKA — или
сертификат любого другого рода — положит сертификат в хранилище.
`-PackageSuffix` дописывается в конец имён файлов: сборки для Store и для релиза
различаются тем, что внутри, и больше ничем.
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
MSIX не может установить среду выполнения как зависимость пакета. MSIX не может установить среду выполнения как зависимость пакета.
+33 -88
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 pipeline signs what it attaches. 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 signed 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,31 +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 is built twice over, because the Store and a release want different The package goes to the Store and nowhere else: it is unsigned, and Partner
things of it. Center puts its own signature on it. So the run leaves it in the artifacts under
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 Store one carries the identity reserved in Partner Center and no signature — The identity comes from repository variables and falls back to the defaults of
Partner Center signs it there. It never leaves the artifacts of the run: someone the script when unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and
picks it up and uploads it by hand. Its name says which one it is — `MSIX_PUBLISHER_DISPLAY_NAME`. Together the identity and the publisher decide
`CursorLang-1.2.3.0-store.msixbundle`. The identity comes from repository the family name of the package, so both have to stay as they are from version to
variables and falls back to the defaults of the script when unset: version, or the Store takes the next one for a different app.
`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
The one attached to the release is signed by the pipeline with a certificate
from SSL.com, whose private key stays in their HSM and never comes down to the
runner: eSigner CKA is installed on it as a key storage provider, and `signtool`
asks that for the key the way it would ask a token. The account is three
repository secrets — `ESIGNER_USERNAME`, `ESIGNER_PASSWORD` and
`ESIGNER_TOTP_SECRET`; the last one is the secret eSigner hands out for
automated signing, not a code read off a phone. They are checked before the
build rather than at the signing step: a release without a signed package is
not a release.
Its `Publisher` is not `MSIX_PUBLISHER` but the subject of that certificate,
read off it in the run — Windows takes a package naming anyone else for a
forgery. Together with the identity that subject decides the family name of the
package, so both have to stay as they are from release to release, or an update
installs beside the old version instead of replacing it.
## Building the MSIX package ## Building the MSIX package
@@ -320,14 +271,8 @@ The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
and arm64; next to it lie the packages of single architectures. Upload the bundle and arm64; next to it lie the packages of single architectures. Upload the bundle
to Partner Center as it is. to Partner Center as it is.
`-CertificateThumbprint` signs everything the build produces, with the Nothing here is signed: the Store signs the package itself, and for installing it
certificate of that thumbprint out of the personal store of the current user; on this machine the layout is registered instead — see `-Install` below.
`-Publisher` then has to name its subject, and the script says so before it
starts building rather than after. The pipeline is what normally signs, but the
same works by hand once eSigner CKA — or a certificate of any other kind — has
put a certificate into the store. `-PackageSuffix` goes at the end of the file
names: the builds for the Store and for a release differ in what is inside them
and in nothing else.
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.