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; /// /// The updates section of the settings window. /// /// /// 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. /// 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; /// The downloaded fraction: from 0 to 1. [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; } /// Whether to show the updates section at all. public bool IsSupported => _updates.IsSupported; public string CurrentVersion => _updates.CurrentVersion.ToString(); /// Check for new versions at startup. public bool CheckAutomatically { get => _settings.CheckForUpdates; set { if (value == _settings.CheckForUpdates) { return; } _settings.CheckForUpdates = value; OnPropertyChanged(); } } /// A request or a download is in flight — the buttons freeze for that time. public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading; public bool CanCheck => !IsBusy; public bool HasStatus => Status != UpdateStatus.Idle; /// /// 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. /// 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; /// The release page: the release notes live there too. 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, }; /// /// Checks for updates when the user has not forbidden it and enough time has /// passed since the previous check. Called once at startup. /// 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(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; } } /// /// 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. /// /// /// OperationCanceledException means an expired request deadline here: /// cancellation by the application itself is caught by a separate handler above. /// 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)); } /// /// Starts a new piece of work, cancelling the previous one: the user may have /// pressed "Check" in the middle of a download. /// 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)); } } }