12 Commits
Author SHA1 Message Date
alex 08192d317e fixed tests
Release / release (push) Successful in 8m58s
2026-08-11 16:34:28 +05:00
alex 3bf2018a62 fixed release version in pipeline
Release / release (push) Failing after 2m19s
2026-08-11 15:56:23 +05:00
alex cb39198e0a fixed pipeline
Release / release (push) Failing after 20s
2026-08-11 15:44:26 +05:00
alex a777b71b1a modified README.md
Release / release (push) Canceled after 2m15s
2026-08-11 15:02:04 +05:00
alex 71a8d3e6fb added CI 2026-08-11 15:01:47 +05:00
alex aee93a4ea3 removed "soap" from ico 2026-08-11 15:01:24 +05:00
alex d6886eae7c added packaging 2026-08-11 15:00:52 +05:00
alex 86c0085472 modified README 2026-08-09 20:28:39 +05:00
alex 8f1b02770c registrated new services in DI container 2026-08-09 20:27:59 +05:00
alex af43aec773 added updater 2026-08-09 20:27:26 +05:00
alex b2873139ae prepared project to MSIX 2026-08-09 20:26:44 +05:00
alex 7ecad05e1c rearranging the settings window layout 2026-08-09 20:25:56 +05:00
37 changed files with 3336 additions and 314 deletions
+82
View File
@@ -0,0 +1,82 @@
# The check every pull request goes through: the solution builds and the tests pass.
#
# The runner has to be a Windows one with the .NET 10 SDK: the application is
# WPF, so neither the build nor the tests happen anywhere else. The tests raise
# real windows and ask Windows for the foreground one, so the runner has to work
# in an interactive desktop session — as a service in session 0 the end-to-end
# checks have no window to wait for.
name: Pull request
on:
pull_request:
branches:
- master
# A new push into the branch makes the previous run pointless
concurrency:
group: pull-request-${{ github.event.pull_request.number }}
cancel-in-progress: true
defaults:
run:
shell: pwsh
jobs:
build:
runs-on: windows-x64
steps:
- name: Check out the sources
uses: actions/checkout@v4
# The exe icon lives in Git LFS, and without it the checkout leaves a text
# pointer that the build cannot read as an icon.
#
# The objects are fetched here rather than by `lfs: true` on the checkout:
# that way they arrive over a request the LFS endpoint accepts. See the
# comment on the header below
- name: Fetch the LFS objects
run: |
$ErrorActionPreference = 'Stop'
# actions/checkout leaves its own token in the config as an
# http.<server>/.extraheader, and git-lfs sends that header on to the
# LFS endpoint, which turns down the token of a workflow: every object
# comes back 401 and the fetch gives up. The repository is public and
# its LFS objects are readable without a token at all, so the header
# simply goes. A private repository would need credentials of its own
# in lfs.url instead
$keys = git config --local --list --name-only | Where-Object { $_ -like '*.extraheader' }
foreach ($key in $keys) { git config --local --unset-all $key }
git lfs pull
if ($LASTEXITCODE -ne 0) { throw "git lfs pull ended with exit code $LASTEXITCODE." }
# A pointer left in place of a file shows itself much later and in a
# way that is hard to read back: the build breaks on the icon
$pointers = git lfs ls-files --name-only |
Where-Object { (Get-Content $_ -TotalCount 1) -like 'version https://git-lfs*' }
if ($pointers) {
throw "Git LFS left pointers instead of files: $($pointers -join ', ')."
}
- name: Show the toolchain
run: dotnet --info
- name: Restore
run: dotnet restore CursorLang.sln --nologo
- name: Build
run: dotnet build CursorLang.sln --configuration Release --no-restore --nologo
# The tests take the application from bin\Release, which is why the whole
# run is a Release one: in Debug they would find no executable and skip
# themselves — a green run that checked nothing
- name: Test
run: >
dotnet test CursorLang.sln
--configuration Release
--no-build
--nologo
--settings CursorLang.Tests/coverage.runsettings
+166
View File
@@ -0,0 +1,166 @@
# The release: a tag of the form v1.2.3 builds the solution, runs the tests and
# packs the MSIX with the version taken from the tag — three numbers of the tag
# and a zero the Store keeps for itself.
#
# The same requirements to the runner as in pull-request.yml apply: Windows, the
# .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
# not have to be installed.
name: Release
on:
push:
tags:
- 'v*'
defaults:
run:
shell: pwsh
jobs:
release:
runs-on: windows-x64
steps:
- name: Check out the sources
uses: actions/checkout@v4
# The exe icon and the MSIX logos live in Git LFS, and without them the
# checkout leaves text pointers in their place — the build fails on the
# icon and the package would carry broken logos.
#
# They are fetched here rather than by `lfs: true` on the checkout: that
# way the objects arrive over a request the LFS endpoint accepts. See the
# comment on the header below
- name: Fetch the LFS objects
run: |
$ErrorActionPreference = 'Stop'
# actions/checkout leaves its own token in the config as an
# http.<server>/.extraheader, and git-lfs sends that header on to the
# LFS endpoint, which turns down the token of a workflow: every object
# comes back 401 and the fetch gives up. The repository is public and
# its LFS objects are readable without a token at all, so the header
# simply goes. A private repository would need credentials of its own
# in lfs.url instead
$keys = git config --local --list --name-only | Where-Object { $_ -like '*.extraheader' }
foreach ($key in $keys) { git config --local --unset-all $key }
git lfs pull
if ($LASTEXITCODE -ne 0) { throw "git lfs pull ended with exit code $LASTEXITCODE." }
# A pointer left in place of a file shows itself much later and in a
# way that is hard to read back: the icon breaks the build, and a logo
# quietly ends up broken inside the package
$pointers = git lfs ls-files --name-only |
Where-Object { (Get-Content $_ -TotalCount 1) -like 'version https://git-lfs*' }
if ($pointers) {
throw "Git LFS left pointers instead of files: $($pointers -join ', ')."
}
# The tag is the only place the version comes from, and it is a plain
# version of three numbers — the same shape the application itself looks
# for in the releases when it checks for an update. A tag of any other
# shape is stopped here rather than halfway through the packaging
- name: Read the version from the tag
id: version
run: |
$ErrorActionPreference = 'Stop'
$tag = '${{ github.ref_name }}'
if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
throw "The tag '$tag' does not fit: a release is tagged as v1.2.3 — three numbers. A fourth one does not belong in the tag: the Store keeps the revision for itself, and the package always gets a zero there."
}
# The package takes four numbers with a zero at the end: the Store
# 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
- name: Show the toolchain
run: dotnet --info
- name: Restore
run: dotnet restore CursorLang.sln --nologo
- name: Build
run: dotnet build CursorLang.sln --configuration Release --no-restore --nologo
- name: Test
run: >
dotnet test CursorLang.sln
--configuration Release
--no-build
--nologo
--settings CursorLang.Tests/coverage.runsettings
# 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
# back to the defaults of the script when a variable is not set.
- name: Pack the MSIX
env:
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
PUBLISHER: ${{ vars.MSIX_PUBLISHER }}
PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }}
run: |
$ErrorActionPreference = 'Stop'
$arguments = @{ Version = '${{ steps.version.outputs.version }}' }
# An empty variable is left out rather than passed on: the script has
# defaults of its own, and an empty string would wipe them
$variables = @{
IdentityName = $env:IDENTITY_NAME
Publisher = $env:PUBLISHER
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 packages
uses: actions/upload-artifact@v4
with:
name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/
if-no-files-found: error
# Gitea creates a release of its own for a pushed tag, so the release is
# looked up first and only made when it is not there
- 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
}
foreach ($file in Get-ChildItem artifacts/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
}
+39
View File
@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
namespace CursorLang.Tests;
@@ -14,6 +16,9 @@ namespace CursorLang.Tests;
///
/// If the application is already running in this session, the checks skip
/// themselves: meddling with someone else's running instance is not their business.
///
/// They skip themselves where there is no desktop to show a window on either — on a
/// build agent living as a Windows service, for one.
/// </remarks>
public sealed class EndToEndTests
{
@@ -59,6 +64,16 @@ public sealed class EndToEndTests
/// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable
{
private const int UOI_NAME = 2;
private const string InteractiveWindowStation = "WinSta0";
[DllImport("user32.dll")]
private static extern IntPtr GetProcessWindowStation();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetUserObjectInformation(IntPtr hObj, int nIndex,
StringBuilder pvInfo, int nLength, out int lpnLengthNeeded);
private Launch(Process process) => Process = process;
internal Process Process { get; }
@@ -66,6 +81,11 @@ public sealed class EndToEndTests
/// <summary>Starts the application first — making sure the place is free.</summary>
internal static Launch Start()
{
if (!HasInteractiveDesktop())
{
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
}
if (Process.GetProcessesByName("CursorLang").Length > 0)
{
Assert.Skip("The application is already running — this check keeps out of someone else's run");
@@ -116,6 +136,25 @@ public sealed class EndToEndTests
return IntPtr.Zero;
}
/// <summary>
/// Whether there is a desktop here to show a window on. A service gets a
/// window station of its own — "Service-0x0-3e7$" and the like: a window can
/// be created there, yet nothing shows it. Only "WinSta0" is the interactive one.
/// </summary>
private static bool HasInteractiveDesktop()
{
IntPtr station = GetProcessWindowStation();
if (station == IntPtr.Zero)
{
return false;
}
var name = new StringBuilder(256);
return GetUserObjectInformation(station, UOI_NAME, name, name.Capacity * sizeof(char), out _)
&& name.ToString().Equals(InteractiveWindowStation, StringComparison.OrdinalIgnoreCase);
}
private static string ExecutablePath()
{
string configured = Assembly.GetExecutingAssembly()
@@ -0,0 +1,305 @@
using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <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);
}
@@ -0,0 +1,38 @@
using CursorLang.Services;
namespace CursorLang.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);
}
[Fact]
public void The_app_asks_about_releases_no_more_than_once_a_day()
{
Assert.Equal(TimeSpan.FromDays(1), new UpdateOptions().CheckInterval);
}
}
@@ -0,0 +1,158 @@
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <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);
}
@@ -0,0 +1,292 @@
using System.IO;
using System.Net.Http;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.ViewModels;
namespace CursorLang.Tests.ViewModels;
/// <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
{
[Fact]
public void Before_the_first_check_the_section_says_nothing()
{
using UpdateViewModel viewModel = Create(new FakeUpdateService());
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
Assert.False(viewModel.HasStatus);
Assert.False(viewModel.IsDownloadOffered);
Assert.False(viewModel.IsInstallOffered);
Assert.True(viewModel.CanCheck);
}
[Fact]
public async Task An_update_found_is_offered_for_download()
{
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
using UpdateViewModel viewModel = Create(updates);
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.Equal(UpdateStatus.Available, viewModel.Status);
Assert.True(viewModel.IsDownloadOffered);
Assert.False(viewModel.IsInstallOffered);
Assert.Equal("https://host/releases/tag", viewModel.ReleaseUrl?.ToString());
Assert.True(viewModel.IsReleaseLinkShown);
Assert.Equal("en:UpdateAvailable", viewModel.StatusText);
}
[Fact]
public async Task With_the_latest_version_installed_there_is_nothing_to_offer()
{
using UpdateViewModel viewModel = Create(new FakeUpdateService());
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.Equal(UpdateStatus.UpToDate, viewModel.Status);
Assert.False(viewModel.IsDownloadOffered);
Assert.False(viewModel.IsReleaseLinkShown);
}
[Fact]
public async Task A_check_by_the_button_says_when_it_did_not_work_out()
{
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
using UpdateViewModel viewModel = Create(updates);
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
Assert.True(viewModel.HasStatus);
}
// The app does not always start with a live network, and the user who never
// asked about updates has no use for the complaint
[Fact]
public async Task A_check_at_startup_keeps_a_failure_to_itself()
{
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
using UpdateViewModel viewModel = Create(updates);
await viewModel.StartAsync();
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
Assert.False(viewModel.HasStatus);
}
[Fact]
public async Task A_successful_check_is_remembered_in_the_settings()
{
var settings = new AppSettings();
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
using UpdateViewModel viewModel = Create(updates, settings);
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.NotNull(settings.LastUpdateCheck);
}
[Fact]
public async Task A_check_that_did_not_work_out_is_not_remembered()
{
var settings = new AppSettings();
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
using UpdateViewModel viewModel = Create(updates, settings);
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.Null(settings.LastUpdateCheck);
}
[Fact]
public async Task A_recent_check_is_not_repeated_at_startup()
{
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow };
var updates = new FakeUpdateService();
using UpdateViewModel viewModel = Create(updates, settings);
await viewModel.StartAsync();
Assert.Equal(0, updates.CheckCalls);
}
[Fact]
public async Task A_check_of_yesterday_is_repeated_at_startup()
{
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow - TimeSpan.FromDays(2) };
var updates = new FakeUpdateService();
using UpdateViewModel viewModel = Create(updates, settings);
await viewModel.StartAsync();
Assert.Equal(1, updates.CheckCalls);
}
[Fact]
public async Task A_ban_on_checking_by_itself_is_obeyed()
{
var settings = new AppSettings { CheckForUpdates = false };
var updates = new FakeUpdateService();
using UpdateViewModel viewModel = Create(updates, settings);
await viewModel.StartAsync();
Assert.Equal(0, updates.CheckCalls);
// The button still works: the setting is about the app doing it on its own
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.Equal(1, updates.CheckCalls);
}
[Fact]
public void The_ban_on_checking_travels_to_the_settings()
{
var settings = new AppSettings { CheckForUpdates = true };
using UpdateViewModel viewModel = Create(new FakeUpdateService(), settings);
viewModel.CheckAutomatically = false;
Assert.False(settings.CheckForUpdates);
}
[Fact]
public async Task A_package_from_Store_is_left_to_Store()
{
var updates = new FakeUpdateService { IsSupported = false, Release = Release("2.0.0.0") };
using UpdateViewModel viewModel = Create(updates);
await viewModel.StartAsync();
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.False(viewModel.IsSupported);
Assert.Equal(0, updates.CheckCalls);
}
[Fact]
public async Task A_downloaded_package_is_offered_for_installation()
{
using TempFolder folder = new();
string package = folder.File("CursorLang-2.0.0.0.msixbundle");
await File.WriteAllTextAsync(package, "package", TestContext.Current.CancellationToken);
var updates = new FakeUpdateService { Release = Release("2.0.0.0"), PackagePath = package };
using UpdateViewModel viewModel = Create(updates);
await viewModel.CheckCommand.ExecuteAsync(null);
await viewModel.DownloadCommand.ExecuteAsync(null);
Assert.Equal(UpdateStatus.Ready, viewModel.Status);
Assert.True(viewModel.IsInstallOffered);
Assert.False(viewModel.IsDownloadOffered);
viewModel.InstallCommand.Execute(null);
Assert.Equal([package], updates.Installed);
}
[Fact]
public async Task While_the_package_is_downloading_the_section_shows_it()
{
var updates = new FakeUpdateService
{
Release = Release("2.0.0.0"),
DownloadGate = new TaskCompletionSource(),
};
using UpdateViewModel viewModel = Create(updates);
await viewModel.CheckCommand.ExecuteAsync(null);
Task download = viewModel.DownloadCommand.ExecuteAsync(null);
Assert.Equal(UpdateStatus.Downloading, viewModel.Status);
Assert.True(viewModel.IsProgressShown);
Assert.True(viewModel.IsBusy);
Assert.False(viewModel.CanCheck);
updates.DownloadGate.SetResult();
await download;
}
[Fact]
public async Task A_download_that_did_not_work_out_is_told_about()
{
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
using UpdateViewModel viewModel = Create(updates);
await viewModel.CheckCommand.ExecuteAsync(null);
updates.Failure = new HttpRequestException("the connection dropped");
await viewModel.DownloadCommand.ExecuteAsync(null);
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
}
// The temp folder is cleared by Windows as it sees fit, and the app has no
// business handing a file that is gone to the installer
[Fact]
public async Task A_package_gone_from_the_disk_is_offered_for_download_again()
{
var updates = new FakeUpdateService
{
Release = Release("2.0.0.0"),
PackagePath = Path.Combine(Path.GetTempPath(), "CursorLang.Tests", "never-written.msixbundle"),
};
using UpdateViewModel viewModel = Create(updates);
await viewModel.CheckCommand.ExecuteAsync(null);
await viewModel.DownloadCommand.ExecuteAsync(null);
viewModel.InstallCommand.Execute(null);
Assert.Empty(updates.Installed);
Assert.Equal(UpdateStatus.Available, viewModel.Status);
}
[Fact]
public async Task The_status_is_written_in_the_chosen_language()
{
var localization = new FakeLocalizationService();
using UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
await viewModel.CheckCommand.ExecuteAsync(null);
Assert.Equal("en:UpdateUpToDate", viewModel.StatusText);
localization.CurrentLanguage = "ru";
Assert.Equal("ru:UpdateUpToDate", viewModel.StatusText);
}
[Fact]
public async Task Closing_unsubscribes_from_the_language()
{
var localization = new FakeLocalizationService();
UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
await viewModel.CheckCommand.ExecuteAsync(null);
List<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,
AppSettings? settings = null,
ILocalizationService? localization = null) =>
new(updates,
localization ?? new FakeLocalizationService(),
settings ?? new AppSettings(),
new UpdateOptions());
}
@@ -124,9 +124,7 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
PlacementMode = PopupPlacementMode.FixedPoint,
ScreenPosition = position,
ScreenMargin = 24,
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = position, ScreenMargin = 24,
};
using var popup = Popup.Create(settings);
@@ -160,9 +158,7 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
PlacementMode = PopupPlacementMode.AtCursor,
CursorSide = AnchorSide.BottomRight,
CursorOffset = 16,
PlacementMode = PopupPlacementMode.AtCursor, CursorSide = AnchorSide.BottomRight, CursorOffset = 16,
};
using var popup = Popup.Create(settings);
@@ -213,23 +209,48 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
PlacementMode = PopupPlacementMode.AtCaret,
CaretSide = AnchorSide.BottomRight,
CaretOffset = 8,
PlacementMode = PopupPlacementMode.AtCaret, CaretSide = AnchorSide.BottomRight, CaretOffset = 8,
};
using var popup = Popup.Create(settings);
popup.ViewModel.ShortName = "RU";
PopupWindowNative.Point before = PopupWindowNative.GetCursorPosition();
PopupWindowNative.Point before = default;
for (int attempt = 0; attempt < 10; attempt++)
{
if (CaretNative.TryGetCaretRect() is not null)
{
Assert.Skip("The foreground window reported a caret of its own");
}
before = PopupWindowNative.GetCursorPosition();
popup.Window.ShowPopup();
PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition();
if (before.X == after.X && before.Y == after.Y)
{
break;
}
if (attempt == 9)
{
Assert.Skip("The cursor kept moving the whole time");
}
}
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
double scale = PopupWindowNative.GetScaleAt(before);
// A gentle check: a window holding the input focus may still have a caret
Assert.True(bounds.Right > bounds.Left);
Assert.True(bounds.Bottom > bounds.Top);
Assert.NotEqual(default, before);
PopupWindowNative.Point expected = PopupLayout.NearAnchor(
PopupLayout.AsAnchor(before),
settings.CaretSide,
PopupLayout.ToPixels(settings.CaretOffset, scale),
bounds.Right - bounds.Left,
bounds.Bottom - bounds.Top);
Assert.Equal(expected.X, bounds.Left);
Assert.Equal(expected.Y, bounds.Top);
});
}
@@ -266,8 +287,7 @@ public sealed class LayoutPopupWindowTests
{
var settings = new AppSettings
{
PlacementMode = PopupPlacementMode.FixedPoint,
ScreenPosition = ScreenPosition.TopLeft,
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = ScreenPosition.TopLeft,
};
using var popup = Popup.Create(settings);
+24
View File
@@ -4,6 +4,21 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang", "CursorLang\Cu
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang.Tests", "CursorLang.Tests\CursorLang.Tests.csproj", "{558F7646-AC3F-4E5E-853D-9F2A09E89C06}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Packaging", "Packaging", "{E927F87B-A585-A586-52B2-1371051E189F}"
ProjectSection(SolutionItems) = preProject
Packaging\AppxManifest.xml = Packaging\AppxManifest.xml
Packaging\build-msix.ps1 = Packaging\build-msix.ps1
Packaging\New-Assets.ps1 = Packaging\New-Assets.ps1
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CI", "CI", "{3F1B9C24-7A0E-4C55-9E2D-6B41A8D5E713}"
ProjectSection(SolutionItems) = preProject
.gitea\workflows\pull-request.yml = .gitea\workflows\pull-request.yml
.gitea\workflows\release.yml = .gitea\workflows\release.yml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SdkTools", "Packaging\Tools\SdkTools.csproj", "{788339D3-F0C3-4F1A-9216-501814C7BF78}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -38,8 +53,17 @@ Global
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x64.Build.0 = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.ActiveCfg = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.Build.0 = Release|Any CPU
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Debug|x64.ActiveCfg = Debug|Any CPU
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Debug|x86.ActiveCfg = Debug|Any CPU
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Release|x64.ActiveCfg = Release|Any CPU
{788339D3-F0C3-4F1A-9216-501814C7BF78}.Release|x86.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{788339D3-F0C3-4F1A-9216-501814C7BF78} = {E927F87B-A585-A586-52B2-1371051E189F}
EndGlobalSection
EndGlobal
+9 -5
View File
@@ -1,5 +1,4 @@
using System.Windows;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.ViewModels;
using CursorLang.Views;
@@ -7,9 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace CursorLang;
/// <summary>
/// Композиционный корень: собирает контейнер и запускает окно настроек.
/// </summary>
// ReSharper disable once RedundantExtendsListEntry
public partial class App : Application
{
private ServiceProvider? _services;
@@ -41,6 +38,8 @@ public partial class App : Application
MainWindow = _services.GetRequiredService<MainWindow>();
MainWindow.Show();
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
_services.GetRequiredService<LayoutNotificationCoordinator>().Start();
_services.GetRequiredService<CapsLockSwitchCoordinator>().Start();
}
@@ -75,9 +74,10 @@ public partial class App : Application
MainWindow.Activate();
}
private static void ConfigureServices(IServiceCollection services)
internal static void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(new KeyboardLayoutOptions());
services.AddSingleton(new UpdateOptions());
services.AddSingleton<SettingsService>();
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
@@ -88,6 +88,8 @@ public partial class App : Application
services.AddSingleton<MainWindowPlacement>();
services.AddSingleton<ILocalizationService, LocalizationService>();
services.AddSingleton<IStartupService, StartupService>();
services.AddSingleton<IUpdateService, UpdateService>();
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
services.AddSingleton<ICapsLockHotkeyService, CapsLockHotkeyService>();
@@ -95,9 +97,11 @@ public partial class App : Application
services.AddSingleton<CapsLockSwitchCoordinator>();
services.AddSingleton<LayoutPopupViewModel>();
services.AddSingleton<UpdateViewModel>();
services.AddSingleton<SettingsViewModel>();
services.AddSingleton<LayoutPopupWindow>();
services.AddSingleton<ILayoutPopupWindow>(provider => provider.GetRequiredService<LayoutPopupWindow>());
services.AddSingleton<MainWindow>();
}
}
+3 -8
View File
@@ -1,10 +1,5 @@
using System.Runtime.CompilerServices;
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
[assembly: InternalsVisibleTo("CursorLang.Tests")]
+20 -2
View File
@@ -1,12 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<UseWPF>true</UseWPF>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Resources\CursorLang.ico</ApplicationIcon>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<PlatformTarget>AnyCPU</PlatformTarget>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company>
<Description>Shows the keyboard layout at the cursor</Description>
<Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup>
<ItemGroup>
Binary file not shown.
+229
View File
@@ -0,0 +1,229 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text.Json;
using CursorLang.Models;
namespace CursorLang.Services;
/// <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}");
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Net.Http;
using CursorLang.Models;
namespace CursorLang.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);
}
+40
View File
@@ -0,0 +1,40 @@
using CursorLang.Models;
namespace CursorLang.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);
}
+37
View File
@@ -0,0 +1,37 @@
namespace CursorLang.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>How often the application checks the releases on its own.</summary>
public TimeSpan CheckInterval { get; init; } = TimeSpan.FromDays(1);
/// <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");
}
+226
View File
@@ -0,0 +1,226 @@
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.InteropServices;
using CursorLang.Interop;
using CursorLang.Models;
using Windows.ApplicationModel;
namespace CursorLang.Services;
/// <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
}
}
}
+267
View File
@@ -0,0 +1,267 @@
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CursorLang.Models;
using CursorLang.Services;
namespace CursorLang.ViewModels;
/// <summary>
/// The updates section of the settings window.
/// </summary>
/// <remarks>
/// A failed check at startup passes in silence: the application does not always start
/// with a live network, and there is no point complaining about it to a user who did
/// not ask about updates. A check started by the button does report a failure — it is
/// awaited and watched.
/// </remarks>
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
{
private readonly IUpdateService _updates;
private readonly ILocalizationService _localization;
private readonly AppSettings _settings;
private readonly UpdateOptions _options;
private CancellationTokenSource? _work;
private ReleaseInfo? _release;
private string? _packagePath;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText))]
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(CanCheck))]
[NotifyPropertyChangedFor(nameof(HasStatus))]
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
[NotifyPropertyChangedFor(nameof(IsDownloadOffered))]
[NotifyPropertyChangedFor(nameof(IsInstallOffered))]
[NotifyPropertyChangedFor(nameof(IsProgressShown))]
[NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))]
private UpdateStatus _status = UpdateStatus.Idle;
/// <summary>The downloaded fraction: from 0 to 1.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
private double _progress;
public UpdateViewModel(
IUpdateService updates,
ILocalizationService localization,
AppSettings settings,
UpdateOptions options)
{
_updates = updates;
_localization = localization;
_settings = settings;
_options = options;
_localization.PropertyChanged += OnLocalizationChanged;
}
/// <summary>Whether to show the updates section at all.</summary>
public bool IsSupported => _updates.IsSupported;
public string CurrentVersion => _updates.CurrentVersion.ToString();
/// <summary>Check for new versions at startup.</summary>
public bool CheckAutomatically
{
get => _settings.CheckForUpdates;
set
{
if (value == _settings.CheckForUpdates)
{
return;
}
_settings.CheckForUpdates = value;
OnPropertyChanged();
}
}
/// <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;
public bool HasStatus => Status != UpdateStatus.Idle;
/// <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;
public string StatusText => Status switch
{
UpdateStatus.Checking => _localization["UpdateChecking"],
UpdateStatus.UpToDate => _localization["UpdateUpToDate"],
UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag),
UpdateStatus.Downloading => _localization["UpdateDownloading"],
UpdateStatus.Ready => _localization["UpdateReady"],
UpdateStatus.Failed => _localization["UpdateFailed"],
_ => string.Empty,
};
/// <summary>
/// Checks for updates when the user has not forbidden it and enough time has
/// passed since the previous check. Called once at startup.
/// </summary>
public async Task StartAsync()
{
if (!IsSupported || !_settings.CheckForUpdates)
{
return;
}
if (_settings.LastUpdateCheck is { } last && DateTimeOffset.UtcNow - last < _options.CheckInterval)
{
return;
}
await RunCheckAsync(quiet: true);
}
public void Dispose()
{
_localization.PropertyChanged -= OnLocalizationChanged;
_work?.Cancel();
_work?.Dispose();
_work = null;
}
[RelayCommand]
private Task CheckAsync() => RunCheckAsync(quiet: false);
[RelayCommand]
private async Task DownloadAsync()
{
if (_release is not { } release)
{
return;
}
CancellationToken token = StartWork();
Progress = 0;
Status = UpdateStatus.Downloading;
try
{
var progress = new Progress<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;
private async Task RunCheckAsync(bool quiet)
{
if (!IsSupported)
{
return;
}
CancellationToken token = StartWork();
Status = UpdateStatus.Checking;
try
{
_release = await _updates.CheckAsync(token);
_packagePath = null;
_settings.LastUpdateCheck = DateTimeOffset.UtcNow;
Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// The check was cancelled by the next piece of work: it has already set its own state
return;
}
catch (Exception e) when (IsExpected(e))
{
Status = quiet ? UpdateStatus.Idle : UpdateStatus.Failed;
}
OnPropertyChanged(nameof(ReleaseUrl));
OnPropertyChanged(nameof(IsReleaseLinkShown));
}
/// <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));
}
}
}
+163 -42
View File
@@ -8,7 +8,7 @@
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
Title="{Binding Localization[SettingsTitle]}"
Width="560" SizeToContent="Height" MaxHeight="900"
Width="1000" SizeToContent="Height" MaxHeight="900"
ResizeMode="CanMinimize"
Background="{DynamicResource Theme.WindowBackground}"
Foreground="{DynamicResource Theme.Foreground}">
@@ -16,12 +16,13 @@
<local:EnumToVisibilityConverter x:Key="EnumToVisibility" />
<local:ColorToBrushConverter x:Key="ColorToBrush" />
<local:ColorToHexConverter x:Key="ColorToHex" />
<BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
<DataTemplate x:Key="EnumOptionTemplate">
<TextBlock Text="{Binding Display}" />
</DataTemplate>
<!-- Образец цвета с подписью: одинаково для фона и для текста -->
<!-- A colour swatch with a caption: the same for the background and for the text -->
<DataTemplate x:Key="ColorSwatchTemplate">
<StackPanel Orientation="Horizontal">
<Border Width="32" Height="16" CornerRadius="2"
@@ -52,12 +53,18 @@
screen entirely and does without scrolling. Scrolling is kept for the case of
a large system font, with which the content is taller than the monitor after all -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<GroupBox Header="{Binding Localization[SectionInterface]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
@@ -87,7 +94,7 @@
<GroupBox Header="{Binding Localization[SectionPlacement]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
@@ -109,7 +116,7 @@
SelectedValuePath="Value"
SelectedValue="{Binding Settings.PlacementMode}" />
<!-- Режим «у курсора»: своя сторона и свой отступ -->
<!-- The "at cursor" mode: a side and an offset of its own -->
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
@@ -144,7 +151,7 @@
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<!-- Режим «у каретки»: свои сторона и отступ -->
<!-- The "at caret" mode: a side and an offset of its own -->
<TextBlock Grid.Row="3" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
@@ -179,7 +186,7 @@
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" />
<!-- Режим «фиксированная точка» -->
<!-- The "fixed point" mode -->
<TextBlock Grid.Row="5" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenPositionLabel]}"
@@ -216,10 +223,106 @@
</Grid>
</GroupBox>
<GroupBox Header="{Binding Localization[SectionBehavior]}">
<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" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[DurationLabel]}" />
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
Value="{Binding Settings.DurationMilliseconds}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockLabel]}" />
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
IsChecked="{Binding Settings.UseCapsLockHotkey}"
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockHoldLabel]}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="150" Maximum="1500" TickFrequency="50"
Value="{Binding Settings.CapsLockHoldMilliseconds}"
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}">
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<!-- The elevation caveat lives in the tooltip to keep the section compact -->
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
ToolTipService.InitialShowDelay="200"
ToolTipService.ShowDuration="60000"
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock TextWrapping="Wrap"
Text="{Binding Localization[CapsLockElevationHint]}" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLabel]}" />
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
IsEnabled="{Binding CanChangeStartup}"
IsChecked="{Binding RunAtStartup}"
Content="{Binding Localization[StartupCheck]}" />
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLockedHint]}" />
</Grid>
</GroupBox>
</StackPanel>
<StackPanel Grid.Column="1" Margin="16,0,0,0">
<GroupBox Header="{Binding Localization[SectionAppearance]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
@@ -267,9 +370,12 @@
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[PreviewLabel]}" />
<!-- The height of the area is sized for the largest font
available: the sample text is fully visible at any
position of the slider -->
<Border Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" Padding="16"
Height="144" ClipToBounds="True"
Height="152" ClipToBounds="True"
Background="{DynamicResource Theme.SurfaceStrong}" CornerRadius="4"
HorizontalAlignment="Stretch">
<Border CornerRadius="4" Padding="10,4"
@@ -286,10 +392,12 @@
</Grid>
</GroupBox>
<GroupBox Header="{Binding Localization[SectionBehavior]}">
<!-- 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="180" />
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
@@ -298,50 +406,63 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[DurationLabel]}" />
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
Value="{Binding Settings.DurationMilliseconds}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
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]}" />
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockLabel]}" />
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
IsChecked="{Binding Settings.UseCapsLockHotkey}"
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
IsChecked="{Binding Updates.CheckAutomatically}"
Content="{Binding Localization[UpdateAutoCheck]}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockHoldLabel]}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="150" Maximum="1500" TickFrequency="50"
Value="{Binding Settings.CapsLockHoldMilliseconds}"
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
<TextBlock Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" TextWrapping="Wrap"
Visibility="{Binding Updates.HasStatus, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Updates.StatusText}" />
<!-- The downloaded fraction is not always known: not every hosting reports the file size -->
<ProgressBar Grid.Row="3" 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="4" 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="3" Grid.Column="1" Grid.ColumnSpan="2"
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[CapsLockHoldHint]}" />
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[UpdateInstallHint]}" />
</Grid>
</GroupBox>
</StackPanel>
</Grid>
</ScrollViewer>
</Window>
+22
View File
@@ -1,4 +1,7 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Navigation;
using CursorLang.Services;
using CursorLang.ViewModels;
@@ -16,4 +19,23 @@ public partial class MainWindow : Window
theme.Register(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
}
}
}
+3 -2
View File
@@ -5,14 +5,15 @@
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>
</assembly>
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap desktop rescap">
<Identity
Name="{IdentityName}"
Publisher="{Publisher}"
Version="{Version}"
ProcessorArchitecture="{Architecture}"/>
<Properties>
<DisplayName>CursorLang</DisplayName>
<PublisherDisplayName>{PublisherDisplayName}</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0"/>
</Dependencies>
<Resources>
<Resource Language="en-us"/>
<Resource Language="ru-ru"/>
</Resources>
<Applications>
<Application Id="App" Executable="CursorLang.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="CursorLang"
Description="Shows the keyboard layout at the cursor"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile
Square71x71Logo="Assets\Square71x71Logo.png"
Square310x310Logo="Assets\Square310x310Logo.png"
Wide310x150Logo="Assets\Wide310x150Logo.png"/>
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.startupTask" Executable="CursorLang.exe"
EntryPoint="Windows.FullTrustApplication">
<desktop:StartupTask TaskId="CursorLangStartup" Enabled="false" DisplayName="CursorLang"/>
</desktop:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust"/>
</Capabilities>
</Package>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+232
View File
@@ -0,0 +1,232 @@
<#
.SYNOPSIS
Draws the application icons: the logos for MSIX and the .ico for the exe.
.DESCRIPTION
The icons are drawn by code rather than kept as pictures so that the whole
set of sizes is rebuilt by a single command whenever another drawing is
wanted.
The current drawing is a placeholder: a dark rounded square with a white
"Aя". Before publishing to the Store it is worth replacing with a real one:
rewriting New-Logo is enough, the rest of the script does not depend on the
drawing.
.EXAMPLE
pwsh -File Packaging\New-Assets.ps1
#>
[CmdletBinding()]
param(
# Where to put the MSIX logos
[string] $AssetsPath,
# Where to put the .ico for the exe
[string] $IconPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# The defaults are worked out here rather than in the parameter declarations:
# in Windows PowerShell $PSScriptRoot is still empty inside param()
$root = $PSScriptRoot
if (-not $AssetsPath) { $AssetsPath = Join-Path $root 'Assets' }
if (-not $IconPath) { $IconPath = Join-Path $root '..\CursorLang\Resources\CursorLang.ico' }
Add-Type -AssemblyName System.Drawing
$Background = [System.Drawing.Color]::FromArgb(255, 0x20, 0x20, 0x20)
$Foreground = [System.Drawing.Color]::FromArgb(255, 0xFF, 0xFF, 0xFF)
$Glyph = 'Aя'
function New-Logo {
param(
[int] $Width,
[int] $Height,
# The plate under the glyph. The Windows taskbar draws its shortcut
# without one, so for those sizes only the white glyph remains
[bool] $WithPlate = $true
)
$bitmap = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
$graphics.Clear([System.Drawing.Color]::Transparent)
$side = [Math]::Min($Width, $Height)
if ($WithPlate) {
# The corner radius is taken as a share of the side so that small
# sizes do not turn into a circle and large ones do not look like a
# plain square
$radius = [Math]::Max(2, [int]($side * 0.18))
$plate = New-Object System.Drawing.Drawing2D.GraphicsPath
$plate.AddArc(0, 0, $radius * 2, $radius * 2, 180, 90)
$plate.AddArc($Width - $radius * 2 - 1, 0, $radius * 2, $radius * 2, 270, 90)
$plate.AddArc($Width - $radius * 2 - 1, $Height - $radius * 2 - 1, $radius * 2, $radius * 2, 0, 90)
$plate.AddArc(0, $Height - $radius * 2 - 1, $radius * 2, $radius * 2, 90, 90)
$plate.CloseFigure()
$brush = New-Object System.Drawing.SolidBrush($Background)
try { $graphics.FillPath($brush, $plate) } finally { $brush.Dispose(); $plate.Dispose() }
}
# Typographic metrics leave out the side bearings MeasureString reports by
# default. Measured with those bearings the glyph looks wider than it is,
# the fitted type comes out too small, and at 16 px the strokes wash out
[System.Drawing.StringFormat] $format = [System.Drawing.StringFormat]::GenericTypographic.Clone()
try {
# The type size is found by trying: with different tile sizes the same
# share of the side gives text that is either cramped or too small
$target = $side * 0.80
$ceiling = $side * 0.86
$fontSize = $side * 0.9
$font = $null
$measured = $null
for ($attempt = 0; $attempt -lt 20; $attempt++) {
if ($font) { $font.Dispose() }
$font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
$measured = $graphics.MeasureString($Glyph, $font, ([System.Drawing.PointF]::new(0, 0)), $format)
if ($measured.Width -le $target -and $measured.Height -le $ceiling) { break }
# Both dimensions are pulled in at once, otherwise a tall glyph
# keeps overflowing while the width already fits
$scale = [Math]::Min($target / $measured.Width, $ceiling / $measured.Height)
$fontSize = $fontSize * $scale * 0.98
}
try {
# AntiAliasGridFit snaps the stems onto the pixel grid, so the
# glyph is placed at a whole pixel: a fractional origin spreads
# every stem across two pixels, which is what blurred the small
# sizes when the text was centred inside a RectangleF
$x = [Math]::Round(($Width - $measured.Width) / 2.0)
$y = [Math]::Round(($Height - $measured.Height) / 2.0)
$brush = New-Object System.Drawing.SolidBrush($Foreground)
$origin = New-Object System.Drawing.PointF($x, $y)
try { $graphics.DrawString($Glyph, $font, $brush, $origin, $format) }
finally { $brush.Dispose() }
}
finally {
$font.Dispose()
}
}
finally {
$format.Dispose()
}
}
finally {
$graphics.Dispose()
}
return $bitmap
}
function Save-Logo {
param(
[string] $Path,
[int] $Width,
[int] $Height,
[bool] $WithPlate = $true
)
$bitmap = New-Logo -Width $Width -Height $Height -WithPlate $WithPlate
try { $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() }
Write-Host " $([System.IO.Path]::GetFileName($Path))"
}
function Save-Icon {
param(
[string] $Path,
[int[]] $Sizes
)
# The .ico is put together by hand: System.Drawing can only save a single
# size to that format, and the icon of the exe is needed in several at once
$images = @()
foreach ($size in $Sizes) {
$bitmap = New-Logo -Width $size -Height $size
try {
$stream = New-Object System.IO.MemoryStream
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
$images += , @{ Size = $size; Bytes = $stream.ToArray() }
$stream.Dispose()
}
finally {
$bitmap.Dispose()
}
}
$output = New-Object System.IO.MemoryStream
$writer = New-Object System.IO.BinaryWriter($output)
try {
$writer.Write([uint16]0) # reserved
$writer.Write([uint16]1) # type: icon
$writer.Write([uint16]$images.Count)
# The directory of entries comes before the pictures themselves, so the
# offsets are worked out beforehand: the header plus 16 bytes per entry
$offset = 6 + 16 * $images.Count
foreach ($image in $images) {
# 256 does not fit in a byte and is written as zero — the format
# means it that way
$dimension = if ($image.Size -ge 256) { 0 } else { $image.Size }
$writer.Write([byte]$dimension) # width
$writer.Write([byte]$dimension) # height
$writer.Write([byte]0) # palette colours: no palette
$writer.Write([byte]0) # reserved
$writer.Write([uint16]1) # planes
$writer.Write([uint16]32) # bits per pixel
$writer.Write([uint32]$image.Bytes.Length)
$writer.Write([uint32]$offset)
$offset += $image.Bytes.Length
}
foreach ($image in $images) {
$writer.Write($image.Bytes)
}
$writer.Flush()
[System.IO.File]::WriteAllBytes($Path, $output.ToArray())
}
finally {
$writer.Dispose()
$output.Dispose()
}
Write-Host " $([System.IO.Path]::GetFileName($Path))"
}
New-Item -ItemType Directory -Path $AssetsPath -Force | Out-Null
New-Item -ItemType Directory -Path (Split-Path -Parent $IconPath) -Force | Out-Null
Write-Host 'MSIX logos:'
Save-Logo -Path (Join-Path $AssetsPath 'StoreLogo.png') -Width 50 -Height 50
Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.png') -Width 44 -Height 44
Save-Logo -Path (Join-Path $AssetsPath 'Square71x71Logo.png') -Width 71 -Height 71
Save-Logo -Path (Join-Path $AssetsPath 'Square150x150Logo.png') -Width 150 -Height 150
Save-Logo -Path (Join-Path $AssetsPath 'Square310x310Logo.png') -Width 310 -Height 310
Save-Logo -Path (Join-Path $AssetsPath 'Wide310x150Logo.png') -Width 310 -Height 150
# The taskbar and the application list take the shortcut without a plate
Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.targetsize-24_altform-unplated.png') -Width 24 -Height 24 -WithPlate $false
Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.targetsize-32_altform-unplated.png') -Width 32 -Height 32 -WithPlate $false
Save-Logo -Path (Join-Path $AssetsPath 'Square44x44Logo.targetsize-48_altform-unplated.png') -Width 48 -Height 48 -WithPlate $false
Write-Host 'The exe icon:'
Save-Icon -Path ([System.IO.Path]::GetFullPath($IconPath)) -Sizes @(16, 24, 32, 48, 64, 128, 256)
+24
View File
@@ -0,0 +1,24 @@
<!--
The project exists for the sake of a single dependency: the package brings
makeappx.exe. That way the MSIX build gets by without an installed Windows SDK
and repeats itself on any machine that has the .NET SDK. The project is not
meant to be built — build-msix.ps1 restores it and takes the program from the
packages folder. It is kept out of the build in CursorLang.sln for the same
reason.
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584">
<!-- Only the programs from the package are needed, there are no assemblies to reference -->
<ExcludeAssets>all</ExcludeAssets>
<GeneratePathProperty>true</GeneratePathProperty>
</PackageReference>
</ItemGroup>
</Project>
+203
View File
@@ -0,0 +1,203 @@
<#
.SYNOPSIS
Builds the CursorLang MSIX package for the Microsoft Store.
.DESCRIPTION
Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
NuGet package (Tools\SdkTools.csproj) and the application is built by the
plain .NET SDK.
The package is handed to Partner Center as it comes out of here — the Store
is where it gets everything else done to it.
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.
.PARAMETER IdentityName
The identity of the package — from Partner Center, the "Product identity"
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.
.EXAMPLE
# A check on your own machine: your architecture alone
pwsh -File Packaging\build-msix.ps1 -Architectures x64
.EXAMPLE
# A build for the Store — the identity comes from Partner Center
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
#>
[CmdletBinding()]
param(
# Four numbers, the last one has to be 0: that is what the Store requires
[string] $Version = '1.0.0.0',
[string] $IdentityName = 'CursorLang',
[string] $Publisher = 'CN=Aleksandr Neychev',
[string] $PublisherDisplayName = 'Aleksandr Neychev',
[ValidateSet('x64', 'arm64')]
[string[]] $Architectures = @('x64', 'arm64'),
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$repository = Split-Path -Parent $root
$project = Join-Path $repository 'CursorLang\CursorLang.csproj'
$assets = Join-Path $root 'Assets'
$manifestTemplate = Join-Path $root 'AppxManifest.xml'
$toolsProject = Join-Path $root 'Tools\SdkTools.csproj'
if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' }
$layoutRoot = Join-Path $OutputPath 'layout'
$packagesPath = Join-Path $OutputPath 'packages'
if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') {
throw "The version '$Version' does not fit: the Store takes four numbers ending in zero, for example 1.0.0.0."
}
if (-not (Test-Path $assets)) {
throw "No logos found in '$assets'. Run Packaging\New-Assets.ps1 first."
}
function Invoke-Tool {
<#
.SYNOPSIS
Runs a program and fails the build if it returned an error.
.DESCRIPTION
A wrapper of our own is needed because PowerShell does not treat the
failure of an external program as an error and quietly moves on.
#>
param(
[string] $Path,
[string[]] $Arguments
)
& $Path @Arguments
if ($LASTEXITCODE -ne 0) {
throw "'$([System.IO.Path]::GetFileName($Path))' exited with code $LASTEXITCODE."
}
}
function Get-SdkToolsPath {
<#
.SYNOPSIS
Returns the folder of the restored Windows SDK Build Tools package.
.DESCRIPTION
The path is asked of MSBuild rather than searched for in the package
cache: the project pins one version of the package, and MSBuild
names exactly it. A search would also find the versions left over
from other builds.
#>
$path = (& dotnet msbuild $toolsProject -getProperty:PkgMicrosoft_Windows_SDK_BuildTools -nologo) |
Where-Object { $_ -and $_.Trim() } |
Select-Object -Last 1
if ($LASTEXITCODE -ne 0 -or -not $path -or -not (Test-Path $path.Trim())) {
throw 'The Windows SDK Build Tools package folder could not be found. Did the restore go through?'
}
return $path.Trim()
}
function Get-SdkTool {
<#
.SYNOPSIS
Returns the path of a program from the Windows SDK Build Tools package.
#>
param(
[string] $Name,
[string] $PackagePath
)
# The bitness of the program has nothing to do with the bitness of the
# package being built: we take the one that runs on this machine
$host64 = if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' }
$tool = Get-ChildItem (Join-Path $PackagePath 'bin') -Recurse -Filter $Name -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "\\$host64\\" } |
Select-Object -First 1
if (-not $tool) {
throw "'$Name' was not found in the Windows SDK Build Tools package."
}
return $tool.FullName
}
Write-Host 'Fetching the Windows SDK programs...' -ForegroundColor Cyan
Invoke-Tool -Path 'dotnet' -Arguments @('restore', $toolsProject, '--nologo')
$sdkTools = Get-SdkToolsPath
$makeappx = Get-SdkTool -Name 'makeappx.exe' -PackagePath $sdkTools
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
$built = @()
foreach ($architecture in $Architectures) {
Write-Host "Building $architecture..." -ForegroundColor Cyan
$layout = Join-Path $layoutRoot $architecture
Invoke-Tool -Path 'dotnet' -Arguments @(
'publish', $project,
'--configuration', 'Release',
'--runtime', "win-$architecture",
'--self-contained', 'true',
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
'--output', $layout,
'--nologo'
)
# Debug symbols have no place in the package: they take up room, the user
# has no use for them, and for crash reports the Store takes them separately
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
$manifest = (Get-Content $manifestTemplate -Raw).
Replace('{IdentityName}', $IdentityName).
Replace('{Publisher}', $Publisher).
Replace('{PublisherDisplayName}', $PublisherDisplayName).
Replace('{Version}', $Version).
Replace('{Architecture}', $architecture)
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
$package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
$built += $package
}
$result = $built[0]
if ($built.Count -gt 1) {
Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan
# makeappx bundle takes everything from a folder, so the separate packages
# are gathered into one of their own first — otherwise the results of
# earlier builds would end up in the bundle
$bundleInput = Join-Path $OutputPath 'bundle'
Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
$result = Join-Path $packagesPath "CursorLang-$Version.msixbundle"
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
Remove-Item $bundleInput -Recurse -Force
}
Write-Host ''
Write-Host 'Done.' -ForegroundColor Green
Write-Host " $result"
Write-Host ''
Write-Host ' This file is uploaded to Partner Center as it is.'
+193
View File
@@ -0,0 +1,193 @@
# cursor-lang
[English version](README.md)
Приложение WPF показывает раскладку клавиатуры у курсора.
## Клонирование
Двоичные ресурсы — иконка exe и логотипы MSIX — хранятся в Git LFS, поэтому
перед клонированием нужно установить [git-lfs](https://git-lfs.com):
```powershell
git lfs install
git clone git@git.alrakis.kz:alrakis/cursor-lang.git
```
Клонирование без него проходит успешно, но вместо изображений остаются
текстовые файлы-указатели, и сборка затем падает на нечитаемой иконке.
Существующий клон чинится командой `git lfs install`, а затем `git lfs pull`.
## Права администратора
Приложение работает от имени обычного пользователя. От повышения прав
отказались, чтобы приложение можно было опубликовать в Microsoft Store: пакеты
MSIX всегда выполняются в контексте вошедшего пользователя, а политика Store
отклоняет приложения, которым права администратора нужны для любой части
функциональности.
Цена этого — необязательная горячая клавиша Caps Lock. Пока фокусом владеет
окно с более высоким уровнем целостности — Диспетчер задач, Редактор реестра,
запросы UAC, — Windows не доставляет нажатия низкоуровневому хуку и не принимает
запрос на смену раскладки, так что горячая клавиша там ничего не делает. Чтение
раскладки чужого окна не ограничено, поэтому сама подсказка продолжает работать
везде.
## Автозапуск
Автозапуск включается из настроек приложения тем способом, который доступен
сборке. Пакет объявляет его в манифесте как `windows.startupTask`. Сборка,
распакованная в папку, прописывается так, как это всегда делали программы для
рабочего стола, — в `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, и прав
администратора для этого не нужно.
И в том, и в другом случае Windows показывает приложение в разделе Параметры —
Приложения — Автозагрузка; если пользователь выключит его там, приложение уже не
сможет включить его обратно и скажет об этом вместо молчаливого отказа. Запись в
реестре при этом остаётся на месте: решение пользователя Windows хранит отдельно
от неё, в `StartupApproved`, и приложение с ним считается.
## Настройки
Расположение `settings.json` зависит от способа установки приложения. Отдельная
установка хранит его в `%APPDATA%\CursorLang`. Пакетная сборка хранит его в
собственной папке данных пакета, которую Windows удаляет вместе с приложением —
предполагается, что приложения из Store не оставляют после себя ничего.
При первом запуске пакетная сборка подхватывает настройки, оставленные отдельной
установкой, и копирует их себе. Исходный файл остаётся на месте: обе сборки могут
быть установлены рядом, и приложение не вправе удалять настройки, которые ему не
принадлежат.
## Обновления
Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск
годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему
приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta`
— пропускается: предварительную версию берут намеренно, приложение её не
предлагает.
Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры.
Если его нет, берётся пакет, в имени которого стоит архитектура этой машины:
`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`, а не хранится в исходниках: секрет, встроенный в
сборку, — это секрет, отданный всем, кто эту сборку получил.
## Тесты
```powershell
dotnet test
```
Тесты лежат в `CursorLang.Tests` и работают на xUnit. Половина приложения —
окна, таймеры диспетчера, перехват клавиатуры — живёт только на потоке STA
с очередью сообщений, поэтому тесты держат один такой поток на весь прогон
и выполняют на нём всё, что этого требует.
Части проверок нужно настоящее окно переднего плана: положение каретки и
просьбу сменить раскладку видно только там. Право вывести окно вперёд Windows
даёт не всегда, и такие проверки сообщают о себе как о пропущенных, а не как
о провалившихся — без окна переднего плана проверять нечего. Сквозные проверки
запускают собранное приложение отдельным процессом и пропускают себя, если
приложение уже работает: вмешиваться в чужой запущенный экземпляр они не вправе.
Покрытие снимается так:
```powershell
dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage.runsettings
```
Непокрытым остаётся то, до чего тестовому процессу не дотянуться: пути, которым
нужен установленный пакет MSIX — `StartupTask` и папка данных пакета, — и
композиционный корень в `App.xaml.cs`, который вместо этого проверяется сквозным
запуском приложения.
## Непрерывная сборка
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
с приложенными пакетами. Номер версии берётся только из тега — тег любого
другого вида останавливает прогон в самом начале. Версия пакета получается
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег
на него не влияет.
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
остаётся текстовой заглушкой и сборка на ней падает. Работать runner лучше
в интерактивном сеансе рабочего стола: тесты поднимают настоящие окна, и те
проверки, которым нужен свой рабочий стол — сквозные, а также те, что просят
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде.
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
берётся из переменных репозитория, а если те не заданы — из значений по
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
и `MSIX_PUBLISHER_DISPLAY_NAME`.
## Сборка пакета MSIX
Ни Visual Studio, ни Windows SDK не требуются — `makeappx` поставляется из
пакета NuGet. Порядок публикации целиком — от регистрации разработчика
до отправки на проверку — описан в
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md).
```powershell
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
powershell -File Packaging\New-Assets.ps1
# Проверка на своей машине: только своя архитектура
powershell -File Packaging\build-msix.ps1 -Architectures x64
# Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
```
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
Center как есть.
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
MSIX не может установить среду выполнения как зависимость пакета.
Чтобы попробовать пакет без установки, зарегистрируйте опубликованный layout —
для этого нужен режим разработчика и не нужна подпись вовсе:
```powershell
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName
```
+187 -7
View File
@@ -1,10 +1,190 @@
# cursor-lang
The WPF application displays the keyboard layout at the cursor
[Русская версия](README.RU.md)
The app requests administrator rights on startup. Windows does not deliver
keyboard input to a low-level hook while an app of a higher integrity level
holds the focus, so without elevation the Caps Lock hotkey would silently stop
working in Task Manager, Registry Editor and anything else started as
administrator. Note that a plain shortcut in the startup folder cannot launch an
elevated app — use a Task Scheduler task with the "highest privileges" flag.
The WPF application displays the keyboard layout at the cursor.
## Cloning
Binary assets — the exe icon and the MSIX logos — are stored in Git LFS, so
[git-lfs](https://git-lfs.com) has to be installed before cloning:
```powershell
git lfs install
git clone git@git.alrakis.kz:alrakis/cursor-lang.git
```
Cloning without it succeeds but leaves text pointer files in place of the
images, and the build then fails on an unreadable icon. An existing clone is
repaired with `git lfs install` followed by `git lfs pull`.
## Administrator rights
The app runs as the ordinary user. Elevation was dropped so that the app can be
published in the Microsoft Store: MSIX packages always run in the context of the
signed-in user, and Store policy rejects apps that need administrator rights for
any part of their functionality.
The cost is the optional Caps Lock hotkey. While a window of a higher integrity
level holds the focus — Task Manager, Registry Editor, UAC prompts — Windows
neither delivers keystrokes to the low-level hook nor accepts the layout change
request, so the hotkey does nothing there. Reading the layout of a foreign window
is not restricted, so the tooltip itself keeps working everywhere.
## Startup
Startup is switched on from the app's settings by whichever means the build has.
A package declares it in the manifest as a `windows.startupTask`. A build unpacked
into a folder registers itself the way desktop programs always have — under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, which needs no administrator
rights.
Either way Windows lists the app in Settings — Apps — Startup; if the user turns
it off there, the app can no longer turn it back on and says so instead of
silently failing. The registry entry stays where it is in that case: Windows keeps
the user's verdict apart from it, under `StartupApproved`, and the app obeys it.
## Settings
Where `settings.json` lives depends on how the app was installed. A separate
install keeps it in `%APPDATA%\CursorLang`. The packaged build keeps it in the
package's own data folder, which Windows removes together with the app — Store
apps are expected to leave nothing behind.
On its first run the packaged build picks up the settings left by a separate
install and copies them over. The original file stays where it is: both builds
may be installed side by side, and the app has no business deleting settings it
does not own.
## Updates
The app looks for new versions among the releases of its own repository. A
release counts when its tag is a plain version — `v1.2.3` or `1.2.3` — and an
MSIX package is attached to it. A tag with anything else in it, `v1.2.3-beta`
among them, is passed over: a pre-release version is asked for on purpose, not
offered by the app.
Out of the attached files the `.msixbundle` is preferred — it carries both
architectures. Failing that, the package whose name holds the architecture of
this machine is taken: `CursorLang-1.2.3.0-x64.msix`. Those are the names
`build-msix.ps1` produces, so a release is made by attaching what it built.
The package is downloaded to the temp folder and handed to the Windows app
installer: it shows the publisher, asks for a confirmation and replaces the
installed version. Windows checks the signature, so the package attached to a
release has to be signed — an unsigned one installs nowhere but a machine in
developer mode. The running app keeps working off the old files until it is
restarted.
An app installed from the Store has no updates section at all: the Store
updates it, and a package from the side is something Windows would not accept
over it anyway.
By itself the app asks about releases once a day, at startup, and remembers the
date of the last successful check in the settings. The check can be turned off
there, which leaves the button in the settings window doing the same 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
```powershell
dotnet test
```
The suite lives in `CursorLang.Tests` and runs on xUnit. Half of the app —
windows, dispatcher timers, the keyboard hook — only works on an STA thread with
a message loop, so the tests keep one such thread for the whole run and drive
everything through it.
A few checks need a real foreground window: the caret position and the layout
switch request are only observable there. Windows does not always grant the
right to bring a window forward, and those checks report themselves as skipped
rather than as failures — there is nothing to verify without a foreground
window. The end-to-end checks start the built application as a separate process
and skip themselves if the app is already running: interfering with someone
else's running instance is not their business.
Coverage is collected with:
```powershell
dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage.runsettings
```
What stays uncovered is what a test process cannot reach: the code paths that
require an MSIX package identity — `StartupTask` and the package data folder —
and the composition root in `App.xaml.cs`, which is exercised end-to-end instead.
## Continuous integration
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,
tested, packed into an MSIX and published as a release with the packages
attached. The version is taken from the tag alone — a tag shaped any other way
stops the run right at the start. The package version ends up as `1.2.3.0`: the
Store takes four numbers and keeps the last one for itself, so the tag has no
say in it.
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
text pointer and the build fails on it. The runner is better off working in an
interactive desktop session: the tests raise real windows, and the checks that
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
service in session 0, where there is no desktop to show a window on.
The package the release carries goes to Partner Center as it is. The identity
comes from repository variables and falls back to the defaults of the script when
unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
## Building the MSIX package
Neither Visual Studio nor the Windows SDK is required — `makeappx` comes from a
NuGet package. The full publishing procedure — from opening a developer account
to submitting for certification — is written up in
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md) (Russian only).
```powershell
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
powershell -File Packaging\New-Assets.ps1
# A check on your own machine: your architecture alone
powershell -File Packaging\build-msix.ps1 -Architectures x64
# For Partner Center — the identity is the one reserved there
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
```
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
and arm64; next to it lie the packages of single architectures. Upload the bundle
to Partner Center as it is.
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.
To try the package without installing it, register the published layout — this
needs Developer Mode and no signature at all:
```powershell
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName
```