Compare commits
4
Commits
163b86d239
...
8f1b02770c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1b02770c | ||
|
|
af43aec773 | ||
|
|
b2873139ae | ||
|
|
7ecad05e1c |
@@ -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());
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user