added updater

This commit is contained in:
2026-08-09 20:27:26 +05:00
parent b2873139ae
commit af43aec773
10 changed files with 1614 additions and 0 deletions
+229
View File
@@ -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}");
}
}
+22
View File
@@ -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);
}
+40
View File
@@ -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);
}
+37
View File
@@ -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");
}
+226
View File
@@ -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
}
}
}
+267
View File
@@ -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));
}
}
}