268 lines
8.5 KiB
C#
268 lines
8.5 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|