Compare commits
6
Commits
d6886eae7c
..
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08192d317e | ||
|
|
3bf2018a62 | ||
|
|
cb39198e0a | ||
|
|
a777b71b1a | ||
|
|
71a8d3e6fb | ||
|
|
aee93a4ea3 |
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace CursorLang.Tests;
|
namespace CursorLang.Tests;
|
||||||
|
|
||||||
@@ -14,6 +16,9 @@ namespace CursorLang.Tests;
|
|||||||
///
|
///
|
||||||
/// If the application is already running in this session, the checks skip
|
/// If the application is already running in this session, the checks skip
|
||||||
/// themselves: meddling with someone else's running instance is not their business.
|
/// 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>
|
/// </remarks>
|
||||||
public sealed class EndToEndTests
|
public sealed class EndToEndTests
|
||||||
{
|
{
|
||||||
@@ -59,6 +64,16 @@ public sealed class EndToEndTests
|
|||||||
/// <summary>A started application that shuts down together with the check.</summary>
|
/// <summary>A started application that shuts down together with the check.</summary>
|
||||||
private sealed class Launch : IDisposable
|
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;
|
private Launch(Process process) => Process = process;
|
||||||
|
|
||||||
internal Process Process { get; }
|
internal Process Process { get; }
|
||||||
@@ -66,6 +81,11 @@ public sealed class EndToEndTests
|
|||||||
/// <summary>Starts the application first — making sure the place is free.</summary>
|
/// <summary>Starts the application first — making sure the place is free.</summary>
|
||||||
internal static Launch Start()
|
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)
|
if (Process.GetProcessesByName("CursorLang").Length > 0)
|
||||||
{
|
{
|
||||||
Assert.Skip("The application is already running — this check keeps out of someone else's run");
|
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;
|
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()
|
private static string ExecutablePath()
|
||||||
{
|
{
|
||||||
string configured = Assembly.GetExecutingAssembly()
|
string configured = Assembly.GetExecutingAssembly()
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ public sealed class LayoutPopupWindowTests
|
|||||||
{
|
{
|
||||||
var settings = new AppSettings
|
var settings = new AppSettings
|
||||||
{
|
{
|
||||||
PlacementMode = PopupPlacementMode.FixedPoint,
|
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = position, ScreenMargin = 24,
|
||||||
ScreenPosition = position,
|
|
||||||
ScreenMargin = 24,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
using var popup = Popup.Create(settings);
|
using var popup = Popup.Create(settings);
|
||||||
@@ -160,9 +158,7 @@ public sealed class LayoutPopupWindowTests
|
|||||||
{
|
{
|
||||||
var settings = new AppSettings
|
var settings = new AppSettings
|
||||||
{
|
{
|
||||||
PlacementMode = PopupPlacementMode.AtCursor,
|
PlacementMode = PopupPlacementMode.AtCursor, CursorSide = AnchorSide.BottomRight, CursorOffset = 16,
|
||||||
CursorSide = AnchorSide.BottomRight,
|
|
||||||
CursorOffset = 16,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
using var popup = Popup.Create(settings);
|
using var popup = Popup.Create(settings);
|
||||||
@@ -213,23 +209,48 @@ public sealed class LayoutPopupWindowTests
|
|||||||
{
|
{
|
||||||
var settings = new AppSettings
|
var settings = new AppSettings
|
||||||
{
|
{
|
||||||
PlacementMode = PopupPlacementMode.AtCaret,
|
PlacementMode = PopupPlacementMode.AtCaret, CaretSide = AnchorSide.BottomRight, CaretOffset = 8,
|
||||||
CaretSide = AnchorSide.BottomRight,
|
|
||||||
CaretOffset = 8,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
using var popup = Popup.Create(settings);
|
using var popup = Popup.Create(settings);
|
||||||
popup.ViewModel.ShortName = "RU";
|
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();
|
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;
|
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
|
PopupWindowNative.Point expected = PopupLayout.NearAnchor(
|
||||||
Assert.True(bounds.Right > bounds.Left);
|
PopupLayout.AsAnchor(before),
|
||||||
Assert.True(bounds.Bottom > bounds.Top);
|
settings.CaretSide,
|
||||||
Assert.NotEqual(default, before);
|
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
|
var settings = new AppSettings
|
||||||
{
|
{
|
||||||
PlacementMode = PopupPlacementMode.FixedPoint,
|
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = ScreenPosition.TopLeft,
|
||||||
ScreenPosition = ScreenPosition.TopLeft,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
using var popup = Popup.Create(settings);
|
using var popup = Popup.Create(settings);
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang", "CursorLang\Cu
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang.Tests", "CursorLang.Tests\CursorLang.Tests.csproj", "{558F7646-AC3F-4E5E-853D-9F2A09E89C06}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang.Tests", "CursorLang.Tests\CursorLang.Tests.csproj", "{558F7646-AC3F-4E5E-853D-9F2A09E89C06}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{788339D3-F0C3-4F1A-9216-501814C7BF78} = {E927F87B-A585-A586-52B2-1371051E189F}
|
||||||
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
Binary file not shown.
+34
-11
@@ -135,28 +135,51 @@ dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage
|
|||||||
композиционный корень в `App.xaml.cs`, который вместо этого проверяется сквозным
|
композиционный корень в `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
|
## Сборка пакета MSIX
|
||||||
|
|
||||||
Ни Visual Studio, ни Windows SDK не требуются — `makeappx` и `signtool`
|
Ни Visual Studio, ни Windows SDK не требуются — `makeappx` поставляется из
|
||||||
поставляются из пакета NuGet. Порядок публикации целиком — от регистрации
|
пакета NuGet. Порядок публикации целиком — от регистрации разработчика
|
||||||
разработчика до отправки на проверку — описан в
|
до отправки на проверку — описан в
|
||||||
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md).
|
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md).
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
|
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
|
||||||
powershell -File Packaging\New-Assets.ps1
|
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 `
|
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
|
Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
|
||||||
и arm64. Загружать в Partner Center его нужно неподписанным — Store подписывает
|
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
|
||||||
пакет собственным сертификатом.
|
Center как есть.
|
||||||
|
|
||||||
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
|
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
|
||||||
MSIX не может установить среду выполнения как зависимость пакета.
|
MSIX не может установить среду выполнения как зависимость пакета.
|
||||||
@@ -166,5 +189,5 @@ MSIX не может установить среду выполнения как
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
|
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 —
|
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.
|
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
|
## Building the MSIX package
|
||||||
|
|
||||||
Neither Visual Studio nor the Windows SDK is required — `makeappx` and
|
Neither Visual Studio nor the Windows SDK is required — `makeappx` comes from a
|
||||||
`signtool` come from a NuGet package. The full publishing procedure — from
|
NuGet package. The full publishing procedure — from opening a developer account
|
||||||
opening a developer account to submitting for certification — is written up in
|
to submitting for certification — is written up in
|
||||||
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md) (Russian only).
|
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md) (Russian only).
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
|
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
|
||||||
powershell -File Packaging\New-Assets.ps1
|
powershell -File Packaging\New-Assets.ps1
|
||||||
|
|
||||||
# Build and sign for local testing
|
# A check on your own machine: your architecture alone
|
||||||
powershell -File Packaging\build-msix.ps1 -Architectures x64 -Sign
|
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 `
|
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
|
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
|
and arm64; next to it lie the packages of single architectures. Upload the bundle
|
||||||
certificate.
|
to Partner Center as it is.
|
||||||
|
|
||||||
The app ships with its own copy of .NET: Windows does not include .NET 10, and
|
The app ships with its own copy of .NET: Windows does not include .NET 10, and
|
||||||
MSIX cannot install a runtime as a package dependency.
|
MSIX cannot install a runtime as a package dependency.
|
||||||
@@ -164,5 +186,5 @@ needs Developer Mode and no signature at all:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
|
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