228 lines
8.3 KiB
C#
228 lines
8.3 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using CursorLang.Core.Models;
|
|
|
|
namespace CursorLang.Core.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}");
|
|
}
|
|
}
|