added updater
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user