Compare commits
4
Commits
71a8d3e6fb
..
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08192d317e | ||
|
|
3bf2018a62 | ||
|
|
cb39198e0a | ||
|
|
a777b71b1a |
@@ -26,12 +26,40 @@ jobs:
|
||||
runs-on: windows-x64
|
||||
|
||||
steps:
|
||||
# lfs: true is not a nicety: the exe icon lives in Git LFS, and without it
|
||||
# the checkout leaves a text pointer that the build cannot read as an icon
|
||||
- name: Check out the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# The release: a tag of the form v1.2.3.0 builds the solution, runs the tests
|
||||
# and packs the MSIX with the version taken from the tag.
|
||||
# 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
|
||||
@@ -21,28 +22,60 @@ jobs:
|
||||
runs-on: windows-x64
|
||||
|
||||
steps:
|
||||
# lfs: true is not a nicety: the exe icon and the MSIX logos live in Git
|
||||
# LFS, and without it the checkout leaves text pointers in their place —
|
||||
# the build fails on the icon and the package would carry broken logos
|
||||
- name: Check out the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
# The tag is the only place the version comes from: the Store takes four
|
||||
# numbers ending in zero, so anything else is stopped here rather than
|
||||
# halfway through the packaging
|
||||
# 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+\.0$') {
|
||||
throw "The tag '$tag' does not fit: a release is tagged as v1.2.3.0 — four numbers ending in zero."
|
||||
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."
|
||||
}
|
||||
|
||||
"version=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
# 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
|
||||
|
||||
@@ -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();
|
||||
popup.Window.ShowPopup();
|
||||
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);
|
||||
|
||||
+34
-11
@@ -135,28 +135,51 @@ dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage
|
||||
композиционный корень в `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` и `signtool`
|
||||
поставляются из пакета NuGet. Порядок публикации целиком — от регистрации
|
||||
разработчика до отправки на проверку — описан в
|
||||
Ни 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 -Sign
|
||||
# Проверка на своей машине: только своя архитектура
|
||||
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
||||
|
||||
# Сборка для Store — identity берётся из Partner Center
|
||||
# Для Partner Center — identity та, что зарезервирована там
|
||||
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
||||
-IdentityName 12345Alrakis.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
```
|
||||
|
||||
Результат — `artifacts\packages\CursorLang-<version>.msixbundle`, покрывающий x64
|
||||
и arm64. Загружать в Partner Center его нужно неподписанным — Store подписывает
|
||||
пакет собственным сертификатом.
|
||||
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
|
||||
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
|
||||
Center как есть.
|
||||
|
||||
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
|
||||
MSIX не может установить среду выполнения как зависимость пакета.
|
||||
@@ -166,5 +189,5 @@ MSIX не может установить среду выполнения как
|
||||
|
||||
```powershell
|
||||
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
|
||||
Remove-AppxPackage (Get-AppxPackage -Name Alrakis.CursorLang).PackageFullName
|
||||
Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName
|
||||
```
|
||||
|
||||
@@ -133,28 +133,50 @@ 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` and
|
||||
`signtool` come from a NuGet package. The full publishing procedure — from
|
||||
opening a developer account to submitting for certification — is written up in
|
||||
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
|
||||
|
||||
# Build and sign for local testing
|
||||
powershell -File Packaging\build-msix.ps1 -Architectures x64 -Sign
|
||||
# A check on your own machine: your architecture alone
|
||||
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
||||
|
||||
# Build for the Store — identity comes from Partner Center
|
||||
# For Partner Center — the identity is the one reserved there
|
||||
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
||||
-IdentityName 12345Alrakis.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
||||
```
|
||||
|
||||
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
|
||||
and arm64. Upload it to Partner Center unsigned — the Store signs it with its own
|
||||
certificate.
|
||||
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.
|
||||
@@ -164,5 +186,5 @@ needs Developer Mode and no signature at all:
|
||||
|
||||
```powershell
|
||||
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
|
||||
Remove-AppxPackage (Get-AppxPackage -Name Alrakis.CursorLang).PackageFullName
|
||||
Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user