Compare commits
12
Commits
163b86d239
...
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08192d317e | ||
|
|
3bf2018a62 | ||
|
|
cb39198e0a | ||
|
|
a777b71b1a | ||
|
|
71a8d3e6fb | ||
|
|
aee93a4ea3 | ||
|
|
d6886eae7c | ||
|
|
86c0085472 | ||
|
|
8f1b02770c | ||
|
|
af43aec773 | ||
|
|
b2873139ae | ||
|
|
7ecad05e1c |
@@ -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()
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reading the release list of Gitea. The answer of the server is not ours to
|
||||
/// shape, so what matters is what the app makes of it.
|
||||
/// </summary>
|
||||
public sealed class GiteaReleaseFeedTests
|
||||
{
|
||||
private const string Releases = """
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.2.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0",
|
||||
"assets": [
|
||||
{
|
||||
"name": "CursorLang-1.2.0.0.msixbundle",
|
||||
"browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
|
||||
"size": 4096
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task A_release_is_read_whole()
|
||||
{
|
||||
ReleaseInfo? release = await Read(Releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(1, 2, 0, 0), release.Version);
|
||||
Assert.Equal("v1.2.0", release.Tag);
|
||||
Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString());
|
||||
Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName);
|
||||
Assert.Equal(
|
||||
"https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
|
||||
release.Package.Url.ToString());
|
||||
Assert.Equal(4096, release.Package.Size);
|
||||
}
|
||||
|
||||
// The API of Gitea lives on the server itself, next to the pages of the
|
||||
// repository
|
||||
[Fact]
|
||||
public async Task The_request_goes_to_the_releases_of_the_project()
|
||||
{
|
||||
var handler = FakeHttpHandler.Json(Releases);
|
||||
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
|
||||
|
||||
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Uri asked = Assert.Single(handler.Requests).RequestUri!;
|
||||
Assert.Equal("git.alrakis.kz", asked.Host);
|
||||
Assert.StartsWith(
|
||||
"/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// A server sitting under a path of its own keeps that path: dropping it
|
||||
// would send the request to a place that answers nothing
|
||||
[Fact]
|
||||
public async Task A_server_behind_a_path_keeps_it()
|
||||
{
|
||||
var handler = FakeHttpHandler.Json(Releases);
|
||||
var feed = new GiteaReleaseFeed(
|
||||
handler.CreateClient(),
|
||||
new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" });
|
||||
|
||||
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Uri asked = Assert.Single(handler.Requests).RequestUri!;
|
||||
Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// «token» is the scheme of Gitea for keys of access
|
||||
[Fact]
|
||||
public void A_closed_repository_gets_the_token_it_asks_for()
|
||||
{
|
||||
var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret"));
|
||||
using var request = new HttpRequestMessage();
|
||||
|
||||
feed.Authorize(request);
|
||||
|
||||
Assert.Equal("token", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("secret", request.Headers.Authorization?.Parameter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_open_repository_is_asked_without_a_token()
|
||||
{
|
||||
var feed = new GiteaReleaseFeed(new HttpClient(), Options());
|
||||
using var request = new HttpRequestMessage();
|
||||
|
||||
feed.Authorize(request);
|
||||
|
||||
Assert.Null(request.Headers.Authorization);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3", "1.2.3.0")]
|
||||
[InlineData("v1.2.3", "1.2.3.0")]
|
||||
[InlineData("V1.2", "1.2.0.0")]
|
||||
[InlineData("1.2.3.4", "1.2.3.4")]
|
||||
public async Task A_version_is_read_out_of_the_tag(string tag, string expected)
|
||||
{
|
||||
ReleaseInfo? release = await Read(WithTag(tag));
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(Version.Parse(expected), release.Version);
|
||||
}
|
||||
|
||||
// A pre-release version is not something the app offers by itself:
|
||||
// such a version is asked for on purpose
|
||||
[Theory]
|
||||
[InlineData("v1.2.3-beta")]
|
||||
[InlineData("nightly")]
|
||||
[InlineData("release-1")]
|
||||
public async Task A_tag_that_is_not_a_version_is_passed_over(string tag)
|
||||
{
|
||||
Assert.Null(await Read(WithTag(tag)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_draft_and_a_pre_release_are_passed_over()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v3.0.0", "draft": true, "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
|
||||
{ "tag_name": "v2.0.0", "prerelease": true, "assets": [
|
||||
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] },
|
||||
{ "tag_name": "v1.0.0", "assets": [
|
||||
{ "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
|
||||
}
|
||||
|
||||
// The order of the releases belongs to the server, the highest number to
|
||||
// the app: a fix to an older branch can be the freshest release
|
||||
[Fact]
|
||||
public async Task The_highest_version_wins_over_the_order_of_the_answer()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v1.0.5", "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(2, 0, 0, 0), release.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_release_without_a_package_is_passed_over()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] },
|
||||
{ "tag_name": "v1.0.0", "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
|
||||
}
|
||||
|
||||
// The signature is what Windows checks, but a package offered over an open
|
||||
// connection is not worth downloading in the first place
|
||||
[Fact]
|
||||
public async Task A_package_offered_over_an_open_connection_is_passed_over()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
Assert.Null(await Read(releases));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bundle_wins_over_the_packages_of_single_architectures()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" },
|
||||
{ "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Out_of_several_packages_the_one_for_this_machine_is_taken()
|
||||
{
|
||||
const string releases = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" },
|
||||
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? "https://host/arm64.msix"
|
||||
: "https://host/x64.msix";
|
||||
|
||||
ReleaseInfo? release = await Read(releases);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal(expected, release.Package.Url.ToString());
|
||||
}
|
||||
|
||||
// Without the architecture in the name there is no telling which package is
|
||||
// for this machine — unless it is the only one there
|
||||
[Fact]
|
||||
public async Task A_package_without_an_architecture_is_taken_only_when_alone()
|
||||
{
|
||||
const string alone = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
const string ambiguous = """
|
||||
[
|
||||
{ "tag_name": "v2.0.0", "assets": [
|
||||
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" },
|
||||
{ "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
Assert.NotNull(await Read(alone));
|
||||
Assert.Null(await Read(ambiguous));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_list_of_releases_means_nothing_to_offer()
|
||||
{
|
||||
Assert.Null(await Read("[]"));
|
||||
}
|
||||
|
||||
// A server answering with something else is no reason to fail
|
||||
[Fact]
|
||||
public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing()
|
||||
{
|
||||
Assert.Null(await Read("""{ "message": "Not Found" }"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_of_the_server_is_raised()
|
||||
{
|
||||
var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized);
|
||||
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => feed.GetLatestAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static string WithTag(string tag) => $$"""
|
||||
[
|
||||
{ "tag_name": "{{tag}}", "assets": [
|
||||
{ "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
|
||||
]
|
||||
""";
|
||||
|
||||
private static UpdateOptions Options(string? token = null) => new()
|
||||
{
|
||||
ServiceUri = new Uri("https://git.alrakis.kz/"),
|
||||
Project = "alrakis/cursor-lang",
|
||||
AccessToken = token,
|
||||
};
|
||||
|
||||
private static Task<ReleaseInfo?> Read(string json) =>
|
||||
new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options())
|
||||
.GetLatestAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the app looks for its releases. The values belong to the build, and a
|
||||
/// wrong one shows only as an update that never arrives.
|
||||
/// </summary>
|
||||
public sealed class UpdateOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Out_of_the_box_the_releases_are_looked_for_in_the_repository_of_the_app()
|
||||
{
|
||||
var options = new UpdateOptions();
|
||||
|
||||
Assert.Equal("git.alrakis.kz", options.ServiceUri.Host);
|
||||
Assert.Equal("alrakis/cursor-lang", options.Project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Another_server_is_taken_as_it_is_given()
|
||||
{
|
||||
var options = new UpdateOptions
|
||||
{
|
||||
ServiceUri = new Uri("https://git.example.com/"),
|
||||
Project = "team/app",
|
||||
};
|
||||
|
||||
Assert.Equal("git.example.com", options.ServiceUri.Host);
|
||||
Assert.Equal("team/app", options.Project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_app_asks_about_releases_no_more_than_once_a_day()
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromDays(1), new UpdateOptions().CheckInterval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What the app does with a release once it has found one: whether it is newer
|
||||
/// at all, and what ends up on disk.
|
||||
/// </summary>
|
||||
public sealed class UpdateServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.0.0.0", "1.0.1.0", true)]
|
||||
[InlineData("1.0.0.0", "2.0.0.0", true)]
|
||||
[InlineData("1.0.0.0", "1.0.0.0", false)]
|
||||
[InlineData("1.0.1.0", "1.0.0.0", false)]
|
||||
public async Task Only_a_higher_version_counts_as_an_update(string current, string found, bool offered)
|
||||
{
|
||||
var feed = new FakeReleaseFeed { Release = Release(found) };
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(feed, folder, current);
|
||||
|
||||
ReleaseInfo? update = await service.CheckAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(offered, update is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_repository_leaves_the_app_with_nothing()
|
||||
{
|
||||
var feed = new FakeReleaseFeed { Release = null };
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(feed, folder);
|
||||
|
||||
Assert.Null(await service.CheckAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_package_ends_up_on_disk_whole()
|
||||
{
|
||||
byte[] content = Encoding.UTF8.GetBytes(new string('p', 300_000));
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
|
||||
|
||||
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(content, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
// The name comes from the version, not from the answer: the app creates a
|
||||
// file with it, and the answer comes from the other side
|
||||
[Fact]
|
||||
public async Task The_name_of_the_file_is_built_by_the_app_itself()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1, 2, 3]));
|
||||
|
||||
var release = new ReleaseInfo(
|
||||
new Version(2, 0, 0, 0),
|
||||
"v2.0.0",
|
||||
null,
|
||||
new ReleaseAsset(@"..\..\evil.msixbundle", new Uri("https://host/a"), 3));
|
||||
|
||||
string path = await service.DownloadAsync(release, null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("CursorLang-2.0.0.0.msixbundle", Path.GetFileName(path));
|
||||
Assert.Equal(folder.Path, Path.GetDirectoryName(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_download_reports_how_far_it_has_come()
|
||||
{
|
||||
byte[] content = new byte[500_000];
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
|
||||
|
||||
var reported = new CollectingProgress();
|
||||
await service.DownloadAsync(Release("2.0.0.0"), reported, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEmpty(reported.Values);
|
||||
Assert.All(reported.Values, value => Assert.InRange(value, 0, 1));
|
||||
Assert.Equal(reported.Values, [.. reported.Values.Order()]);
|
||||
Assert.Equal(1, reported.Values[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_closed_repository_gets_the_token_with_the_download_too()
|
||||
{
|
||||
var feed = new FakeReleaseFeed();
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(feed, folder, client: FakeHttpHandler.Bytes([1]));
|
||||
|
||||
await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Single(feed.Authorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_of_the_hosting_service_leaves_no_package_behind()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
using UpdateService service = Create(
|
||||
new FakeReleaseFeed(), folder, client: FakeHttpHandler.Status(HttpStatusCode.NotFound));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Empty(Directory.GetFiles(folder.Path));
|
||||
}
|
||||
|
||||
// A package left from an earlier download takes up room and is of no use
|
||||
// once it has been installed
|
||||
[Fact]
|
||||
public async Task An_older_download_is_cleared_away()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
string leftover = folder.File("CursorLang-1.5.0.0.msixbundle");
|
||||
await File.WriteAllTextAsync(leftover, "old", TestContext.Current.CancellationToken);
|
||||
|
||||
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1]));
|
||||
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(File.Exists(leftover));
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reports as the download makes them. <c>Progress<T></c> would
|
||||
/// hand them over to another thread, and a test has nowhere to wait for that.
|
||||
/// </summary>
|
||||
private sealed class CollectingProgress : IProgress<double>
|
||||
{
|
||||
internal List<double> Values { get; } = [];
|
||||
|
||||
public void Report(double value) => Values.Add(value);
|
||||
}
|
||||
|
||||
private static ReleaseInfo Release(string version) => new(
|
||||
Version.Parse(version),
|
||||
$"v{version}",
|
||||
new Uri("https://host/releases/tag"),
|
||||
new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0));
|
||||
|
||||
private static UpdateService Create(
|
||||
IReleaseFeed feed,
|
||||
TempFolder folder,
|
||||
string current = "1.0.0.0",
|
||||
FakeHttpHandler? client = null) =>
|
||||
new(feed,
|
||||
(client ?? FakeHttpHandler.Bytes([])).CreateClient(),
|
||||
Version.Parse(current),
|
||||
folder.Path);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
namespace CursorLang.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The updates section of the settings window: what it shows at every step and
|
||||
/// what it asks of the service behind it.
|
||||
/// </summary>
|
||||
public sealed class UpdateViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Before_the_first_check_the_section_says_nothing()
|
||||
{
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService());
|
||||
|
||||
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
|
||||
Assert.False(viewModel.HasStatus);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsInstallOffered);
|
||||
Assert.True(viewModel.CanCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_update_found_is_offered_for_download()
|
||||
{
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Available, viewModel.Status);
|
||||
Assert.True(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsInstallOffered);
|
||||
Assert.Equal("https://host/releases/tag", viewModel.ReleaseUrl?.ToString());
|
||||
Assert.True(viewModel.IsReleaseLinkShown);
|
||||
Assert.Equal("en:UpdateAvailable", viewModel.StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_latest_version_installed_there_is_nothing_to_offer()
|
||||
{
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService());
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.UpToDate, viewModel.Status);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsReleaseLinkShown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_by_the_button_says_when_it_did_not_work_out()
|
||||
{
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
|
||||
Assert.True(viewModel.HasStatus);
|
||||
}
|
||||
|
||||
// The app does not always start with a live network, and the user who never
|
||||
// asked about updates has no use for the complaint
|
||||
[Fact]
|
||||
public async Task A_check_at_startup_keeps_a_failure_to_itself()
|
||||
{
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
|
||||
Assert.False(viewModel.HasStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_check_is_remembered_in_the_settings()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(settings.LastUpdateCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_that_did_not_work_out_is_not_remembered()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(settings.LastUpdateCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_recent_check_is_not_repeated_at_startup()
|
||||
{
|
||||
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_of_yesterday_is_repeated_at_startup()
|
||||
{
|
||||
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow - TimeSpan.FromDays(2) };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_ban_on_checking_by_itself_is_obeyed()
|
||||
{
|
||||
var settings = new AppSettings { CheckForUpdates = false };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
|
||||
// The button still works: the setting is about the app doing it on its own
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ban_on_checking_travels_to_the_settings()
|
||||
{
|
||||
var settings = new AppSettings { CheckForUpdates = true };
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService(), settings);
|
||||
|
||||
viewModel.CheckAutomatically = false;
|
||||
|
||||
Assert.False(settings.CheckForUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_package_from_Store_is_left_to_Store()
|
||||
{
|
||||
var updates = new FakeUpdateService { IsSupported = false, Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(viewModel.IsSupported);
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_downloaded_package_is_offered_for_installation()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
string package = folder.File("CursorLang-2.0.0.0.msixbundle");
|
||||
await File.WriteAllTextAsync(package, "package", TestContext.Current.CancellationToken);
|
||||
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0"), PackagePath = package };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Ready, viewModel.Status);
|
||||
Assert.True(viewModel.IsInstallOffered);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
|
||||
viewModel.InstallCommand.Execute(null);
|
||||
|
||||
Assert.Equal([package], updates.Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task While_the_package_is_downloading_the_section_shows_it()
|
||||
{
|
||||
var updates = new FakeUpdateService
|
||||
{
|
||||
Release = Release("2.0.0.0"),
|
||||
DownloadGate = new TaskCompletionSource(),
|
||||
};
|
||||
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Task download = viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Downloading, viewModel.Status);
|
||||
Assert.True(viewModel.IsProgressShown);
|
||||
Assert.True(viewModel.IsBusy);
|
||||
Assert.False(viewModel.CanCheck);
|
||||
|
||||
updates.DownloadGate.SetResult();
|
||||
await download;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_download_that_did_not_work_out_is_told_about()
|
||||
{
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
updates.Failure = new HttpRequestException("the connection dropped");
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
|
||||
}
|
||||
|
||||
// The temp folder is cleared by Windows as it sees fit, and the app has no
|
||||
// business handing a file that is gone to the installer
|
||||
[Fact]
|
||||
public async Task A_package_gone_from_the_disk_is_offered_for_download_again()
|
||||
{
|
||||
var updates = new FakeUpdateService
|
||||
{
|
||||
Release = Release("2.0.0.0"),
|
||||
PackagePath = Path.Combine(Path.GetTempPath(), "CursorLang.Tests", "never-written.msixbundle"),
|
||||
};
|
||||
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
viewModel.InstallCommand.Execute(null);
|
||||
|
||||
Assert.Empty(updates.Installed);
|
||||
Assert.Equal(UpdateStatus.Available, viewModel.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_status_is_written_in_the_chosen_language()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
Assert.Equal("en:UpdateUpToDate", viewModel.StatusText);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
Assert.Equal("ru:UpdateUpToDate", viewModel.StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closing_unsubscribes_from_the_language()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.Dispose();
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
private static ReleaseInfo Release(string version) => new(
|
||||
Version.Parse(version),
|
||||
$"v{version}",
|
||||
new Uri("https://host/releases/tag"),
|
||||
new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0));
|
||||
|
||||
private static UpdateViewModel Create(
|
||||
IUpdateService updates,
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null) =>
|
||||
new(updates,
|
||||
localization ?? new FakeLocalizationService(),
|
||||
settings ?? new AppSettings(),
|
||||
new UpdateOptions());
|
||||
}
|
||||
@@ -124,9 +124,7 @@ public sealed class LayoutPopupWindowTests
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.FixedPoint,
|
||||
ScreenPosition = position,
|
||||
ScreenMargin = 24,
|
||||
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = position, ScreenMargin = 24,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
@@ -160,9 +158,7 @@ public sealed class LayoutPopupWindowTests
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.AtCursor,
|
||||
CursorSide = AnchorSide.BottomRight,
|
||||
CursorOffset = 16,
|
||||
PlacementMode = PopupPlacementMode.AtCursor, CursorSide = AnchorSide.BottomRight, CursorOffset = 16,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
@@ -213,23 +209,48 @@ public sealed class LayoutPopupWindowTests
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.AtCaret,
|
||||
CaretSide = AnchorSide.BottomRight,
|
||||
CaretOffset = 8,
|
||||
PlacementMode = PopupPlacementMode.AtCaret, CaretSide = AnchorSide.BottomRight, CaretOffset = 8,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
popup.ViewModel.ShortName = "RU";
|
||||
|
||||
PopupWindowNative.Point before = PopupWindowNative.GetCursorPosition();
|
||||
popup.Window.ShowPopup();
|
||||
PopupWindowNative.Point before = default;
|
||||
|
||||
for (int attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
if (CaretNative.TryGetCaretRect() is not null)
|
||||
{
|
||||
Assert.Skip("The foreground window reported a caret of its own");
|
||||
}
|
||||
|
||||
before = PopupWindowNative.GetCursorPosition();
|
||||
popup.Window.ShowPopup();
|
||||
PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition();
|
||||
|
||||
if (before.X == after.X && before.Y == after.Y)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt == 9)
|
||||
{
|
||||
Assert.Skip("The cursor kept moving the whole time");
|
||||
}
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
|
||||
double scale = PopupWindowNative.GetScaleAt(before);
|
||||
|
||||
// A gentle check: a window holding the input focus may still have a caret
|
||||
Assert.True(bounds.Right > bounds.Left);
|
||||
Assert.True(bounds.Bottom > bounds.Top);
|
||||
Assert.NotEqual(default, before);
|
||||
PopupWindowNative.Point expected = PopupLayout.NearAnchor(
|
||||
PopupLayout.AsAnchor(before),
|
||||
settings.CaretSide,
|
||||
PopupLayout.ToPixels(settings.CaretOffset, scale),
|
||||
bounds.Right - bounds.Left,
|
||||
bounds.Bottom - bounds.Top);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,8 +287,7 @@ public sealed class LayoutPopupWindowTests
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
PlacementMode = PopupPlacementMode.FixedPoint,
|
||||
ScreenPosition = ScreenPosition.TopLeft,
|
||||
PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = ScreenPosition.TopLeft,
|
||||
};
|
||||
|
||||
using var popup = Popup.Create(settings);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.ViewModels;
|
||||
using CursorLang.Views;
|
||||
@@ -7,9 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CursorLang;
|
||||
|
||||
/// <summary>
|
||||
/// Композиционный корень: собирает контейнер и запускает окно настроек.
|
||||
/// </summary>
|
||||
// ReSharper disable once RedundantExtendsListEntry
|
||||
public partial class App : Application
|
||||
{
|
||||
private ServiceProvider? _services;
|
||||
@@ -41,6 +38,8 @@ public partial class App : Application
|
||||
MainWindow = _services.GetRequiredService<MainWindow>();
|
||||
MainWindow.Show();
|
||||
|
||||
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
||||
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
|
||||
_services.GetRequiredService<LayoutNotificationCoordinator>().Start();
|
||||
_services.GetRequiredService<CapsLockSwitchCoordinator>().Start();
|
||||
}
|
||||
@@ -75,9 +74,10 @@ public partial class App : Application
|
||||
MainWindow.Activate();
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
internal static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(new KeyboardLayoutOptions());
|
||||
services.AddSingleton(new UpdateOptions());
|
||||
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
|
||||
@@ -88,6 +88,8 @@ public partial class App : Application
|
||||
services.AddSingleton<MainWindowPlacement>();
|
||||
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<IStartupService, StartupService>();
|
||||
services.AddSingleton<IUpdateService, UpdateService>();
|
||||
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
|
||||
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
|
||||
services.AddSingleton<ICapsLockHotkeyService, CapsLockHotkeyService>();
|
||||
@@ -95,9 +97,11 @@ public partial class App : Application
|
||||
services.AddSingleton<CapsLockSwitchCoordinator>();
|
||||
|
||||
services.AddSingleton<LayoutPopupViewModel>();
|
||||
services.AddSingleton<UpdateViewModel>();
|
||||
services.AddSingleton<SettingsViewModel>();
|
||||
|
||||
services.AddSingleton<LayoutPopupWindow>();
|
||||
services.AddSingleton<ILayoutPopupWindow>(provider => provider.GetRequiredService<LayoutPopupWindow>());
|
||||
services.AddSingleton<MainWindow>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
|
||||
[assembly: ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
|
||||
[assembly: InternalsVisibleTo("CursorLang.Tests")]
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Resources\CursorLang.ico</ApplicationIcon>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Description>Shows the keyboard layout at the cursor</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,229 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea releases.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service address is the address of the server itself — "https://git.example.com/":
|
||||
/// the Gitea API lives on the same host as the repository pages.
|
||||
/// </remarks>
|
||||
internal sealed class GiteaReleaseFeed : IReleaseFeed
|
||||
{
|
||||
/// <summary>
|
||||
/// How many releases to ask the server for. It returns them newest first, but the
|
||||
/// newest one may turn out to have no package — when the build has not been
|
||||
/// published yet, for instance — so a small reserve is taken.
|
||||
/// </summary>
|
||||
private const int PageSize = 10;
|
||||
|
||||
/// <summary>
|
||||
/// A response with the release list is a few kilobytes of text. There is no point
|
||||
/// waiting longer: the check runs in the background, and a failed one bothers nobody.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private readonly UpdateOptions _options;
|
||||
|
||||
public GiteaReleaseFeed(HttpClient client, UpdateOptions options)
|
||||
{
|
||||
_client = client;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the architecture is spelled in the file names built by
|
||||
/// <c>build-msix.ps1</c>: <c>CursorLang-1.0.0.0-x64.msix</c>.
|
||||
/// </summary>
|
||||
private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? "arm64"
|
||||
: "x64";
|
||||
|
||||
public async Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(RequestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildReleasesUri());
|
||||
Authorize(request);
|
||||
|
||||
using HttpResponseMessage response = await _client.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token);
|
||||
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The order of the releases is up to the server, while what we need is the
|
||||
// highest version number: a fix released for an old branch may well be the newest one
|
||||
return document.RootElement.EnumerateArray()
|
||||
.Select(Read)
|
||||
.OfType<ReleaseInfo>()
|
||||
.MaxBy(release => release.Version);
|
||||
}
|
||||
|
||||
public void Authorize(HttpRequestMessage request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.AccessToken))
|
||||
{
|
||||
// "token" is the Gitea scheme of its own for access keys; "Bearer" is not
|
||||
// understood by every version, while this one has been there since the API appeared
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("token", _options.AccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single release. <c>null</c> means the release will not do: a draft,
|
||||
/// a prerelease or a release without a package.
|
||||
/// </summary>
|
||||
private static ReleaseInfo? Read(JsonElement release)
|
||||
{
|
||||
// A draft is visible only to whoever created it, and the application does not
|
||||
// offer a prerelease: those are sought out deliberately
|
||||
if (ReadFlag(release, "draft") || ReadFlag(release, "prerelease"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Version? version = ParseTag(ReadString(release, "tag_name"));
|
||||
if (version is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!release.TryGetProperty("assets", out JsonElement assets) || assets.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReleaseAsset? package = PickPackage(assets.EnumerateArray().Select(ReadAsset).OfType<ReleaseAsset>());
|
||||
if (package is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReleaseInfo(
|
||||
version,
|
||||
ReadString(release, "tag_name") ?? version.ToString(),
|
||||
ReadUri(release, "html_url"),
|
||||
package);
|
||||
}
|
||||
|
||||
private static ReleaseAsset? ReadAsset(JsonElement asset)
|
||||
{
|
||||
string? name = ReadString(asset, "name");
|
||||
Uri? url = ReadUri(asset, "browser_download_url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || url is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long size = asset.TryGetProperty("size", out JsonElement value) && value.TryGetInt64(out long bytes)
|
||||
? bytes
|
||||
: 0;
|
||||
|
||||
return new ReleaseAsset(name, url, size);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The version from a tag. "1.2.3" and "v1.2.3" are understood; a tag with
|
||||
/// anything besides numbers — "v1.2.3-beta" — counts as a prerelease and is
|
||||
/// skipped: the application does not offer such versions on its own.
|
||||
/// </summary>
|
||||
private static Version? ParseTag(string? tag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> numbers = tag.AsSpan().Trim().TrimStart("vV");
|
||||
|
||||
foreach (char symbol in numbers)
|
||||
{
|
||||
if (!char.IsAsciiDigit(symbol) && symbol != '.')
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Version.TryParse(numbers, out Version? version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// In a tag such as "v1.2" the lower parts are not set at all, yet comparing
|
||||
// them with the version of the installed package calls for zeros
|
||||
return new Version(
|
||||
version.Major,
|
||||
version.Minor,
|
||||
Math.Max(version.Build, 0),
|
||||
Math.Max(version.Revision, 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the attached file the application updates itself with.
|
||||
/// </summary>
|
||||
private static ReleaseAsset? PickPackage(IEnumerable<ReleaseAsset> assets)
|
||||
{
|
||||
// An unencrypted connection is out right away: Windows will check the package
|
||||
// signature by itself, but a substituted file is not even worth downloading
|
||||
ReleaseAsset[] packages = [.. assets.Where(asset => asset.Url.Scheme == Uri.UriSchemeHttps)];
|
||||
|
||||
ReleaseAsset? bundle = packages.FirstOrDefault(
|
||||
asset => asset.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (bundle is not null)
|
||||
{
|
||||
// A bundle carries both architectures, so there is nothing to choose between
|
||||
return bundle;
|
||||
}
|
||||
|
||||
ReleaseAsset[] single = [.. packages.Where(
|
||||
asset => asset.FileName.EndsWith(".msix", StringComparison.OrdinalIgnoreCase))];
|
||||
|
||||
ReleaseAsset? matching = single.FirstOrDefault(
|
||||
asset => asset.FileName.Contains(ArchitectureName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// A package without an architecture in its name will do only when it is the
|
||||
// only one: otherwise it is unclear which of them is for this machine
|
||||
return matching ?? (single.Length == 1 ? single[0] : null);
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static Uri? ReadUri(JsonElement element, string name) =>
|
||||
Uri.TryCreate(ReadString(element, name), UriKind.Absolute, out Uri? uri) ? uri : null;
|
||||
|
||||
private static bool ReadFlag(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True;
|
||||
|
||||
/// <summary>
|
||||
/// The address the server returns the release list at. The trailing slash matters:
|
||||
/// without it <c>Uri</c> drops the last part of the address, and
|
||||
/// "https://host/gitea" would have turned into "https://host/api/...".
|
||||
/// </summary>
|
||||
private Uri BuildReleasesUri()
|
||||
{
|
||||
string service = _options.ServiceUri.AbsoluteUri;
|
||||
string path = $"api/v1/repos/{_options.Project.Trim('/')}/releases?limit={PageSize}";
|
||||
|
||||
return new Uri(service.EndsWith('/') ? service + path : $"{service}/{path}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Net.Http;
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The release list of the repository.
|
||||
/// </summary>
|
||||
public interface IReleaseFeed
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the newest release carrying an MSIX package, or <c>null</c>
|
||||
/// when there is no suitable release.
|
||||
/// </summary>
|
||||
Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Adds to the request whatever a private repository needs. The package is
|
||||
/// downloaded not by the list itself, but access to it is closed just the same.
|
||||
/// </summary>
|
||||
void Authorize(HttpRequestMessage request);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using CursorLang.Models;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Checking for and installing new versions of the application.
|
||||
/// </summary>
|
||||
public interface IUpdateService
|
||||
{
|
||||
/// <summary>
|
||||
/// It makes sense for this installation to update itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An application installed from the Store is updated by the Store itself:
|
||||
/// offering a package from elsewhere on top of it will not do — Windows would
|
||||
/// not accept it anyway.
|
||||
/// </remarks>
|
||||
bool IsSupported { get; }
|
||||
|
||||
/// <summary>The version of the running application.</summary>
|
||||
Version CurrentVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Looks for a release newer than the installed one. <c>null</c> means the latest
|
||||
/// version is installed or there is no suitable release in the repository.
|
||||
/// </summary>
|
||||
Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the release package and returns the path to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>progress</c> receives the downloaded fraction from 0 to 1. While the file
|
||||
/// size is unknown — not every hosting reports it — there will be no calls at all.
|
||||
/// </remarks>
|
||||
Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Hands the downloaded package over to the Windows app installer.</summary>
|
||||
void Install(string packagePath);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Where the application learns about new versions from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These settings belong to the build rather than to the user: the repository is
|
||||
/// chosen by whoever releases the application, and these values have no business
|
||||
/// being in <c>settings.json</c>. The defaults point at the repository the
|
||||
/// application is built from.
|
||||
/// </remarks>
|
||||
public sealed class UpdateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The address of the Gitea server. Its API lives on the same host as the
|
||||
/// repository pages, so this is the same address the repository is opened at
|
||||
/// in a browser.
|
||||
/// </summary>
|
||||
public Uri ServiceUri { get; init; } = new("https://git.alrakis.kz/");
|
||||
|
||||
/// <summary>The project: <c>owner/repository</c>.</summary>
|
||||
public string Project { get; init; } = "alrakis/cursor-lang";
|
||||
|
||||
/// <summary>How often the application checks the releases on its own.</summary>
|
||||
public TimeSpan CheckInterval { get; init; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// An access token for a private repository.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taken from an environment variable rather than from a file in the repository:
|
||||
/// a secret that gets into a build gets to everyone who received it as well.
|
||||
/// A public repository needs no token at all.
|
||||
/// </remarks>
|
||||
public string? AccessToken { get; init; } =
|
||||
Environment.GetEnvironmentVariable("CURSORLANG_UPDATE_TOKEN");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Models;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Learns about new versions from the repository and hands the downloaded package
|
||||
/// over to the installer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The package is installed by the Windows app installer, not by the application on
|
||||
/// its own. Through <c>PackageManager</c> the update would go without a single
|
||||
/// window, but then the application would have to explain both an untrusted signature
|
||||
/// and a policy ban to the user itself — the installer already knows how to do all
|
||||
/// that and shows the package publisher before the installation, not after.
|
||||
/// </remarks>
|
||||
public sealed class UpdateService : IUpdateService, IDisposable
|
||||
{
|
||||
/// <summary>The package is large and the network can be slow: the buffer is taken with room to spare.</summary>
|
||||
private const int BufferSize = 81920;
|
||||
|
||||
/// <summary>The version of the running application — it does not change while it runs.</summary>
|
||||
private static readonly Version Current = DetectCurrentVersion();
|
||||
|
||||
private readonly IReleaseFeed _feed;
|
||||
private readonly HttpClient _client;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly string _downloadFolder;
|
||||
|
||||
public UpdateService(UpdateOptions options)
|
||||
{
|
||||
_client = CreateClient();
|
||||
_ownsClient = true;
|
||||
_downloadFolder = Path.Combine(Path.GetTempPath(), "CursorLang");
|
||||
_feed = new GiteaReleaseFeed(_client, options);
|
||||
|
||||
CurrentVersion = Current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the releases, the network and the version explicitly: in tests they are
|
||||
/// not provided by Windows.
|
||||
/// </summary>
|
||||
internal UpdateService(IReleaseFeed feed, HttpClient client, Version current, string downloadFolder)
|
||||
{
|
||||
_feed = feed;
|
||||
_client = client;
|
||||
_ownsClient = false;
|
||||
_downloadFolder = downloadFolder;
|
||||
|
||||
CurrentVersion = current;
|
||||
}
|
||||
|
||||
public bool IsSupported { get; } = DetectSupport();
|
||||
|
||||
public Version CurrentVersion { get; }
|
||||
|
||||
public async Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken);
|
||||
return release is not null && release.Version > CurrentVersion ? release : null;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(
|
||||
ReleaseInfo release,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = Path.Combine(_downloadFolder, BuildFileName(release));
|
||||
string partial = path + ".part";
|
||||
|
||||
Directory.CreateDirectory(_downloadFolder);
|
||||
RemoveLeftovers(path);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, release.Package.Url);
|
||||
_feed.Authorize(request);
|
||||
|
||||
using HttpResponseMessage response = await _client.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
long total = response.Content.Headers.ContentLength ?? release.Package.Size;
|
||||
|
||||
await using (Stream source = await response.Content.ReadAsStreamAsync(cancellationToken))
|
||||
await using (FileStream target = File.Create(partial))
|
||||
{
|
||||
byte[] buffer = new byte[BufferSize];
|
||||
long copied = 0;
|
||||
int reported = -1;
|
||||
int read;
|
||||
|
||||
while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
copied += read;
|
||||
|
||||
if (total <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The progress bar cannot tell fractions of a percent apart, and
|
||||
// redrawing on every chunk read would cost more than the download itself
|
||||
int percent = (int)(copied * 100 / total);
|
||||
if (percent != reported)
|
||||
{
|
||||
reported = percent;
|
||||
progress?.Report(percent / 100d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file becomes ready only once downloaded in full: an interrupted download
|
||||
// must not stay on disk under the package name
|
||||
File.Move(partial, path, overwrite: true);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Install(string packagePath) =>
|
||||
Process.Start(new ProcessStartInfo(packagePath) { UseShellExecute = true })?.Dispose();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient()
|
||||
{
|
||||
// The check and the download have different deadlines: seconds are enough for
|
||||
// the first one, while the second one takes minutes on a slow network. So the
|
||||
// client has no shared timeout, and every operation allots time for itself
|
||||
var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(15) };
|
||||
var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
|
||||
// The User-Agent shows who came: a request without one may well be taken
|
||||
// for a robot and rejected by the server
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CursorLang", Current.ToString()));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to check for updates at all: a package from the Store gets them from the Store.
|
||||
/// </summary>
|
||||
private static bool DetectSupport()
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Package.Current.SignatureKind != PackageSignatureKind.Store;
|
||||
}
|
||||
catch (Exception e) when (e is COMException or InvalidOperationException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static Version DetectCurrentVersion()
|
||||
{
|
||||
if (PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
try
|
||||
{
|
||||
// A package has a version of its own — the one from the manifest. That
|
||||
// is also the one in the release tag, while the assembly version may differ
|
||||
PackageVersion version = Package.Current.Id.Version;
|
||||
return new Version(version.Major, version.Minor, version.Build, version.Revision);
|
||||
}
|
||||
catch (Exception e) when (e is COMException or InvalidOperationException)
|
||||
{
|
||||
// The package was built without a version in the manifest — the assembly version is left
|
||||
}
|
||||
}
|
||||
|
||||
return Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The file name on disk. Only the extension is taken from the hosting response:
|
||||
/// the name itself comes from the outside, and a file is created with it.
|
||||
/// </summary>
|
||||
private static string BuildFileName(ReleaseInfo release)
|
||||
{
|
||||
string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase)
|
||||
? ".msixbundle"
|
||||
: ".msix";
|
||||
|
||||
return $"CursorLang-{release.Version}{extension}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes packages downloaded earlier: they take up a noticeable amount of
|
||||
/// space and are needed only until the installation.
|
||||
/// </summary>
|
||||
private void RemoveLeftovers(string keep)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (string file in Directory.EnumerateFiles(_downloadFolder))
|
||||
{
|
||||
if (!string.Equals(file, keep, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The file is held by another installer — that does not get in the way of the update
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Data;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CursorLang.Models;
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The updates section of the settings window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A failed check at startup passes in silence: the application does not always start
|
||||
/// with a live network, and there is no point complaining about it to a user who did
|
||||
/// not ask about updates. A check started by the button does report a failure — it is
|
||||
/// awaited and watched.
|
||||
/// </remarks>
|
||||
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IUpdateService _updates;
|
||||
private readonly ILocalizationService _localization;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly UpdateOptions _options;
|
||||
|
||||
private CancellationTokenSource? _work;
|
||||
private ReleaseInfo? _release;
|
||||
private string? _packagePath;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusText))]
|
||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||
[NotifyPropertyChangedFor(nameof(CanCheck))]
|
||||
[NotifyPropertyChangedFor(nameof(HasStatus))]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDownloadOffered))]
|
||||
[NotifyPropertyChangedFor(nameof(IsInstallOffered))]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressShown))]
|
||||
[NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))]
|
||||
private UpdateStatus _status = UpdateStatus.Idle;
|
||||
|
||||
/// <summary>The downloaded fraction: from 0 to 1.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
||||
private double _progress;
|
||||
|
||||
public UpdateViewModel(
|
||||
IUpdateService updates,
|
||||
ILocalizationService localization,
|
||||
AppSettings settings,
|
||||
UpdateOptions options)
|
||||
{
|
||||
_updates = updates;
|
||||
_localization = localization;
|
||||
_settings = settings;
|
||||
_options = options;
|
||||
|
||||
_localization.PropertyChanged += OnLocalizationChanged;
|
||||
}
|
||||
|
||||
/// <summary>Whether to show the updates section at all.</summary>
|
||||
public bool IsSupported => _updates.IsSupported;
|
||||
|
||||
public string CurrentVersion => _updates.CurrentVersion.ToString();
|
||||
|
||||
/// <summary>Check for new versions at startup.</summary>
|
||||
public bool CheckAutomatically
|
||||
{
|
||||
get => _settings.CheckForUpdates;
|
||||
set
|
||||
{
|
||||
if (value == _settings.CheckForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.CheckForUpdates = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A request or a download is in flight — the buttons freeze for that time.</summary>
|
||||
public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading;
|
||||
|
||||
public bool CanCheck => !IsBusy;
|
||||
|
||||
public bool HasStatus => Status != UpdateStatus.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// The package size is unknown, and the bar shows only the fact of the download.
|
||||
/// Its start looks the same until the first report arrives.
|
||||
/// </summary>
|
||||
public bool IsProgressUnknown => Status == UpdateStatus.Downloading && Progress <= 0;
|
||||
|
||||
public bool IsDownloadOffered => Status == UpdateStatus.Available;
|
||||
|
||||
public bool IsInstallOffered => Status == UpdateStatus.Ready;
|
||||
|
||||
public bool IsProgressShown => Status == UpdateStatus.Downloading;
|
||||
|
||||
public bool IsReleaseLinkShown => _release?.PageUrl is not null && Status is not UpdateStatus.Checking;
|
||||
|
||||
/// <summary>The release page: the release notes live there too.</summary>
|
||||
public Uri? ReleaseUrl => _release?.PageUrl;
|
||||
|
||||
public string StatusText => Status switch
|
||||
{
|
||||
UpdateStatus.Checking => _localization["UpdateChecking"],
|
||||
UpdateStatus.UpToDate => _localization["UpdateUpToDate"],
|
||||
UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag),
|
||||
UpdateStatus.Downloading => _localization["UpdateDownloading"],
|
||||
UpdateStatus.Ready => _localization["UpdateReady"],
|
||||
UpdateStatus.Failed => _localization["UpdateFailed"],
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks for updates when the user has not forbidden it and enough time has
|
||||
/// passed since the previous check. Called once at startup.
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (!IsSupported || !_settings.CheckForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_settings.LastUpdateCheck is { } last && DateTimeOffset.UtcNow - last < _options.CheckInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunCheckAsync(quiet: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_localization.PropertyChanged -= OnLocalizationChanged;
|
||||
|
||||
_work?.Cancel();
|
||||
_work?.Dispose();
|
||||
_work = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task CheckAsync() => RunCheckAsync(quiet: false);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
if (_release is not { } release)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token = StartWork();
|
||||
Progress = 0;
|
||||
Status = UpdateStatus.Downloading;
|
||||
|
||||
try
|
||||
{
|
||||
var progress = new Progress<double>(value => Progress = value);
|
||||
_packagePath = await _updates.DownloadAsync(release, progress, token);
|
||||
Status = UpdateStatus.Ready;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// The download was interrupted by the next piece of work: it has already set its own state
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = UpdateStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Install()
|
||||
{
|
||||
if (_packagePath is null || !File.Exists(_packagePath))
|
||||
{
|
||||
// The file was removed by the temp folder cleanup — downloading it again is what is left
|
||||
Status = _release is null ? UpdateStatus.Idle : UpdateStatus.Available;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_updates.Install(_packagePath);
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = UpdateStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The errors that simply leave the update undone: an unreachable network, an
|
||||
/// unexpected response, a file in use. Everything else is a reason to crash.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>OperationCanceledException</c> means an expired request deadline here:
|
||||
/// cancellation by the application itself is caught by a separate handler above.
|
||||
/// </remarks>
|
||||
private static bool IsExpected(Exception e) =>
|
||||
e is HttpRequestException or JsonException or IOException or UnauthorizedAccessException
|
||||
or NotSupportedException or InvalidOperationException or Win32Exception
|
||||
or OperationCanceledException;
|
||||
|
||||
private async Task RunCheckAsync(bool quiet)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token = StartWork();
|
||||
Status = UpdateStatus.Checking;
|
||||
|
||||
try
|
||||
{
|
||||
_release = await _updates.CheckAsync(token);
|
||||
_packagePath = null;
|
||||
|
||||
_settings.LastUpdateCheck = DateTimeOffset.UtcNow;
|
||||
Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// The check was cancelled by the next piece of work: it has already set its own state
|
||||
return;
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = quiet ? UpdateStatus.Idle : UpdateStatus.Failed;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ReleaseUrl));
|
||||
OnPropertyChanged(nameof(IsReleaseLinkShown));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new piece of work, cancelling the previous one: the user may have
|
||||
/// pressed "Check" in the middle of a download.
|
||||
/// </summary>
|
||||
private CancellationToken StartWork()
|
||||
{
|
||||
_work?.Cancel();
|
||||
_work?.Dispose();
|
||||
_work = new CancellationTokenSource();
|
||||
return _work.Token;
|
||||
}
|
||||
|
||||
private string Format(string key, string? argument) =>
|
||||
string.Format(CultureInfo.CurrentCulture, _localization[key], argument);
|
||||
|
||||
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == Binding.IndexerName)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
}
|
||||
}
|
||||
+392
-271
@@ -8,7 +8,7 @@
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
||||
Title="{Binding Localization[SettingsTitle]}"
|
||||
Width="560" SizeToContent="Height" MaxHeight="900"
|
||||
Width="1000" SizeToContent="Height" MaxHeight="900"
|
||||
ResizeMode="CanMinimize"
|
||||
Background="{DynamicResource Theme.WindowBackground}"
|
||||
Foreground="{DynamicResource Theme.Foreground}">
|
||||
@@ -16,12 +16,13 @@
|
||||
<local:EnumToVisibilityConverter x:Key="EnumToVisibility" />
|
||||
<local:ColorToBrushConverter x:Key="ColorToBrush" />
|
||||
<local:ColorToHexConverter x:Key="ColorToHex" />
|
||||
<BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
|
||||
|
||||
<DataTemplate x:Key="EnumOptionTemplate">
|
||||
<TextBlock Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Образец цвета с подписью: одинаково для фона и для текста -->
|
||||
<!-- A colour swatch with a caption: the same for the background and for the text -->
|
||||
<DataTemplate x:Key="ColorSwatchTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="32" Height="16" CornerRadius="2"
|
||||
@@ -52,296 +53,416 @@
|
||||
screen entirely and does without scrolling. Scrolling is kept for the case of
|
||||
a large system font, with which the content is taller than the monitor after all -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionInterface]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[LanguageLabel]}" />
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding Localization.AvailableLanguages}"
|
||||
DisplayMemberPath="DisplayName"
|
||||
SelectedValuePath="Code"
|
||||
SelectedValue="{Binding Settings.Language}" />
|
||||
<GroupBox Header="{Binding Localization[SectionInterface]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ThemeLabel]}" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding Themes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.Theme}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[LanguageLabel]}" />
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding Localization.AvailableLanguages}"
|
||||
DisplayMemberPath="DisplayName"
|
||||
SelectedValuePath="Code"
|
||||
SelectedValue="{Binding Settings.Language}" />
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionPlacement]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ThemeLabel]}" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding Themes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.Theme}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PlacementModeLabel]}" />
|
||||
<ComboBox Grid.Column="1" Grid.ColumnSpan="2"
|
||||
ItemsSource="{Binding PlacementModes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.PlacementMode}" />
|
||||
<GroupBox Header="{Binding Localization[SectionPlacement]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Режим «у курсора»: своя сторона и свой отступ -->
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorCornerLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CursorSide}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PlacementModeLabel]}" />
|
||||
<ComboBox Grid.Column="1" Grid.ColumnSpan="2"
|
||||
ItemsSource="{Binding PlacementModes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.PlacementMode}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorOffsetLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CursorOffset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<!-- The "at cursor" mode: a side and an offset of its own -->
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorCornerLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CursorSide}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
|
||||
<!-- Режим «у каретки»: свои сторона и отступ -->
|
||||
<TextBlock Grid.Row="3" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorCornerLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CaretSide}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorOffsetLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CursorOffset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorOffsetLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CaretOffset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CaretOffset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<!-- The "at caret" mode: a side and an offset of its own -->
|
||||
<TextBlock Grid.Row="3" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorCornerLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CaretSide}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
|
||||
<!-- Режим «фиксированная точка» -->
|
||||
<TextBlock Grid.Row="5" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenPositionLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<ComboBox Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding ScreenPositions}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.ScreenPosition}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorOffsetLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CaretOffset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CaretOffset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
|
||||
<TextBlock Grid.Row="6" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenMarginLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="200" TickFrequency="1"
|
||||
Value="{Binding Settings.ScreenMargin}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.ScreenMargin, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
<!-- The "fixed point" mode -->
|
||||
<TextBlock Grid.Row="5" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenPositionLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<ComboBox Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding ScreenPositions}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.ScreenPosition}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionAppearance]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="6" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenMarginLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="200" TickFrequency="1"
|
||||
Value="{Binding Settings.ScreenMargin}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.ScreenMargin, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[FontSizeLabel]}" />
|
||||
<Slider Grid.Column="1" Minimum="10" Maximum="72" TickFrequency="1"
|
||||
Value="{Binding Settings.FontSize}" />
|
||||
<TextBlock Grid.Column="2" Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.FontSize, StringFormat={}{0:F0}}" />
|
||||
<GroupBox Header="{Binding Localization[SectionBehavior]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[OpacityLabel]}" />
|
||||
<Slider Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0.1" Maximum="1" TickFrequency="0.05"
|
||||
Value="{Binding Settings.Opacity}" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.Opacity, StringFormat={}{0:P0}}" />
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[DurationLabel]}" />
|
||||
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
|
||||
Value="{Binding Settings.DurationMilliseconds}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
|
||||
<TextBlock Margin="4,0,0,0"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[MillisecondsSuffix]}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[BackgroundColorLabel]}" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding BackgroundPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.BackgroundColor}" />
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CapsLockLabel]}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
IsChecked="{Binding Settings.UseCapsLockHotkey}"
|
||||
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[TextColorLabel]}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding TextPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.ForegroundColor}" />
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CapsLockHoldLabel]}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="150" Maximum="1500" TickFrequency="50"
|
||||
Value="{Binding Settings.CapsLockHoldMilliseconds}"
|
||||
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
|
||||
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
|
||||
Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
|
||||
<TextBlock Margin="4,0,0,0"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[MillisecondsSuffix]}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PreviewLabel]}" />
|
||||
<Border Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" Padding="16"
|
||||
Height="144" ClipToBounds="True"
|
||||
Background="{DynamicResource Theme.SurfaceStrong}" CornerRadius="4"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Border CornerRadius="4" Padding="10,4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Opacity="{Binding Settings.Opacity}"
|
||||
Background="{Binding Settings.BackgroundColor,
|
||||
Converter={StaticResource ColorToBrush}}">
|
||||
<TextBlock Text="RU" FontWeight="SemiBold"
|
||||
FontSize="{Binding Settings.FontSize}"
|
||||
Foreground="{Binding Settings.ForegroundColor,
|
||||
Converter={StaticResource ColorToBrush}}" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}">
|
||||
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" />
|
||||
<InlineUIContainer BaselineAlignment="Baseline">
|
||||
<!-- The elevation caveat lives in the tooltip to keep the section compact -->
|
||||
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
|
||||
Foreground="{DynamicResource Theme.Accent}"
|
||||
TextDecorations="Underline"
|
||||
Cursor="Help"
|
||||
ToolTipService.InitialShowDelay="200"
|
||||
ToolTipService.ShowDuration="60000"
|
||||
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}">
|
||||
<TextBlock.ToolTip>
|
||||
<ToolTip>
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Text="{Binding Localization[CapsLockElevationHint]}" />
|
||||
</ToolTip>
|
||||
</TextBlock.ToolTip>
|
||||
</TextBlock>
|
||||
</InlineUIContainer>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Localization[StartupLabel]}" />
|
||||
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
|
||||
IsEnabled="{Binding CanChangeStartup}"
|
||||
IsChecked="{Binding RunAtStartup}"
|
||||
Content="{Binding Localization[StartupCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Localization[StartupLockedHint]}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0">
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionAppearance]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[FontSizeLabel]}" />
|
||||
<Slider Grid.Column="1" Minimum="10" Maximum="72" TickFrequency="1"
|
||||
Value="{Binding Settings.FontSize}" />
|
||||
<TextBlock Grid.Column="2" Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.FontSize, StringFormat={}{0:F0}}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[OpacityLabel]}" />
|
||||
<Slider Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0.1" Maximum="1" TickFrequency="0.05"
|
||||
Value="{Binding Settings.Opacity}" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.Opacity, StringFormat={}{0:P0}}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[BackgroundColorLabel]}" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding BackgroundPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.BackgroundColor}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[TextColorLabel]}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding TextPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.ForegroundColor}" />
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PreviewLabel]}" />
|
||||
<!-- The height of the area is sized for the largest font
|
||||
available: the sample text is fully visible at any
|
||||
position of the slider -->
|
||||
<Border Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" Padding="16"
|
||||
Height="152" ClipToBounds="True"
|
||||
Background="{DynamicResource Theme.SurfaceStrong}" CornerRadius="4"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Border CornerRadius="4" Padding="10,4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Opacity="{Binding Settings.Opacity}"
|
||||
Background="{Binding Settings.BackgroundColor,
|
||||
Converter={StaticResource ColorToBrush}}">
|
||||
<TextBlock Text="RU" FontWeight="SemiBold"
|
||||
FontSize="{Binding Settings.FontSize}"
|
||||
Foreground="{Binding Settings.ForegroundColor,
|
||||
Converter={StaticResource ColorToBrush}}" />
|
||||
</Border>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionBehavior]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<!-- The section is absent for an app from the Store: the Store updates it itself -->
|
||||
<GroupBox Header="{Binding Localization[SectionUpdates]}"
|
||||
Visibility="{Binding Updates.IsSupported, Converter={StaticResource BooleanToVisibility}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[DurationLabel]}" />
|
||||
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
|
||||
Value="{Binding Settings.DurationMilliseconds}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
|
||||
<TextBlock Margin="4,0,0,0"
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CurrentVersionLabel]}" />
|
||||
<TextBlock Grid.Column="1" VerticalAlignment="Center"
|
||||
Text="{Binding Updates.CurrentVersion}" />
|
||||
<Button Grid.Column="2" Padding="12,4"
|
||||
Command="{Binding Updates.CheckCommand}"
|
||||
IsEnabled="{Binding Updates.CanCheck}"
|
||||
Content="{Binding Localization[CheckUpdatesButton]}" />
|
||||
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
IsChecked="{Binding Updates.CheckAutomatically}"
|
||||
Content="{Binding Localization[UpdateAutoCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" TextWrapping="Wrap"
|
||||
Visibility="{Binding Updates.HasStatus, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Updates.StatusText}" />
|
||||
|
||||
<!-- The downloaded fraction is not always known: not every hosting reports the file size -->
|
||||
<ProgressBar Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,8,0,0" Height="4" Maximum="1"
|
||||
Value="{Binding Updates.Progress, Mode=OneWay}"
|
||||
IsIndeterminate="{Binding Updates.IsProgressUnknown}"
|
||||
Visibility="{Binding Updates.IsProgressShown, Converter={StaticResource BooleanToVisibility}}" />
|
||||
|
||||
<StackPanel Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" Orientation="Horizontal">
|
||||
<Button Padding="12,4"
|
||||
Command="{Binding Updates.DownloadCommand}"
|
||||
Visibility="{Binding Updates.IsDownloadOffered, Converter={StaticResource BooleanToVisibility}}"
|
||||
Content="{Binding Localization[DownloadUpdateButton]}" />
|
||||
<Button Padding="12,4"
|
||||
Command="{Binding Updates.InstallCommand}"
|
||||
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
|
||||
Content="{Binding Localization[InstallUpdateButton]}" />
|
||||
<TextBlock Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding Updates.IsReleaseLinkShown, Converter={StaticResource BooleanToVisibility}}">
|
||||
<Hyperlink NavigateUri="{Binding Updates.ReleaseUrl}"
|
||||
RequestNavigate="OnReleaseLinkNavigate">
|
||||
<Run Text="{Binding Localization[ReleasePageLink], Mode=OneWay}" />
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[MillisecondsSuffix]}" />
|
||||
</StackPanel>
|
||||
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Localization[UpdateInstallHint]}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CapsLockLabel]}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
IsChecked="{Binding Settings.UseCapsLockHotkey}"
|
||||
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CapsLockHoldLabel]}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="150" Maximum="1500" TickFrequency="50"
|
||||
Value="{Binding Settings.CapsLockHoldMilliseconds}"
|
||||
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
|
||||
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
|
||||
Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
|
||||
<TextBlock Margin="4,0,0,0"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[MillisecondsSuffix]}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[CapsLockHoldHint]}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Window>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Navigation;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.ViewModels;
|
||||
|
||||
@@ -16,4 +19,23 @@ public partial class MainWindow : Window
|
||||
theme.Register(this);
|
||||
placement.Attach(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the release page in a browser. A link in WPF leads nowhere on its own:
|
||||
/// where to hand it over is up to the application.
|
||||
/// </summary>
|
||||
private void OnReleaseLinkNavigate(object sender, RequestNavigateEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true })?.Dispose();
|
||||
}
|
||||
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException)
|
||||
{
|
||||
// There is no browser in the system — that does not get in the way of the
|
||||
// update, which is downloaded by the button next to it anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
|
||||
@@ -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