Compare commits
8
Commits
8f1b02770c
...
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08192d317e | ||
|
|
3bf2018a62 | ||
|
|
cb39198e0a | ||
|
|
a777b71b1a | ||
|
|
71a8d3e6fb | ||
|
|
aee93a4ea3 | ||
|
|
d6886eae7c | ||
|
|
86c0085472 |
@@ -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.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);
|
||||
|
||||
@@ -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.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user