8 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
21 changed files with 1273 additions and 26 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()
@@ -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
Binary file not shown.
+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
```