225 lines
7.9 KiB
C#
225 lines
7.9 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Http.Headers;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using CursorLang.Core.Interop;
|
|
using CursorLang.Core.Models;
|
|
using Windows.ApplicationModel;
|
|
|
|
namespace CursorLang.Core.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
|
|
}
|
|
}
|
|
}
|