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; /// /// Learns about new versions from the repository and hands the downloaded package /// over to the installer. /// /// /// The package is installed by the Windows app installer, not by the application on /// its own. Through PackageManager 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. /// public sealed class UpdateService : IUpdateService, IDisposable { /// The package is large and the network can be slow: the buffer is taken with room to spare. private const int BufferSize = 81920; /// The version of the running application — it does not change while it runs. 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; } /// /// Takes the releases, the network and the version explicitly: in tests they are /// not provided by Windows. /// 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 CheckAsync(CancellationToken cancellationToken) { ReleaseInfo? release = await _feed.GetLatestAsync(cancellationToken); return release is not null && release.Version > CurrentVersion ? release : null; } public async Task DownloadAsync( ReleaseInfo release, IProgress? 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; } /// /// Whether to check for updates at all: a package from the Store gets them from the Store. /// 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); } /// /// 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. /// private static string BuildFileName(ReleaseInfo release) { string extension = release.Package.FileName.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase) ? ".msixbundle" : ".msix"; return $"CursorLang-{release.Version}{extension}"; } /// /// Removes packages downloaded earlier: they take up a noticeable amount of /// space and are needed only until the installation. /// 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 } } }