using System.Net.Http.Headers; using System.Runtime.InteropServices; using System.Text.Json; using CursorLang.Core.Models; namespace CursorLang.Core.Services; /// /// Gitea releases. /// /// /// 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. /// internal sealed class GiteaReleaseFeed : IReleaseFeed { /// /// 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. /// private const int PageSize = 10; /// /// 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. /// 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; } /// /// How the architecture is spelled in the file names built by /// build-msix.ps1: CursorLang-1.0.0.0-x64.msix. /// private static string ArchitectureName => RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; public async Task 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() .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); } } /// /// Parses a single release. null means the release will not do: a draft, /// a prerelease or a release without a package. /// 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()); 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); } /// /// 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. /// private static Version? ParseTag(string? tag) { if (string.IsNullOrWhiteSpace(tag)) { return null; } ReadOnlySpan 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)); } /// /// Picks the attached file the application updates itself with. /// private static ReleaseAsset? PickPackage(IEnumerable 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; /// /// The address the server returns the release list at. The trailing slash matters: /// without it Uri drops the last part of the address, and /// "https://host/gitea" would have turned into "https://host/api/...". /// 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}"); } }