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.Core.Models; using CursorLang.Core.Services; namespace CursorLang.Settings.ViewModels; /// /// The updates section of the settings window. /// /// /// The section always says where things stand: a check flies off the moment the window /// appears, and its outcome — including an unreachable network — stays written in the /// status line. Nothing here is silent, and nothing disappears: a user who opened the /// window is told about updates whether they came looking for them or not. /// public sealed partial class UpdateViewModel : ObservableObject, IDisposable { private readonly IUpdateService _updates; private readonly ILocalizationService _localization; private CancellationTokenSource? _work; private ReleaseInfo? _release; private string? _packagePath; [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusText))] [NotifyPropertyChangedFor(nameof(IsBusy))] [NotifyPropertyChangedFor(nameof(CanCheck))] [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) { _updates = updates; _localization = localization; _localization.PropertyChanged += OnLocalizationChanged; } /// Whether to show the updates section at all. public bool IsSupported => _updates.IsSupported; public string CurrentVersion => _updates.CurrentVersion.ToString(); /// 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; /// /// 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; /// /// What the section says. There is a line for every state, the one before the /// first check included: the status is never an empty spot in the window. /// 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"], _ => _localization["UpdateNotChecked"], }; /// /// Asks about new versions. Called once, when the window has just appeared. /// /// /// It used to be called when the application started, which back then meant when /// the machine was switched on. The background half is a separate process now and /// does not go to the network at all — nothing in it could show the answer — so the /// question is asked when there is a window to answer into. /// /// And it is asked every time that window appears: opening it is a deliberate act /// of the user, rare enough that a request costs nothing, and the answer is what /// the section exists for. A remembered answer from yesterday is worth less than /// today's, so nothing is remembered. /// public Task StartAsync() => CheckAsync(); public void Dispose() { _localization.PropertyChanged -= OnLocalizationChanged; _work?.Cancel(); _work?.Dispose(); _work = null; } [RelayCommand] private async Task CheckAsync() { if (!IsSupported) { return; } CancellationToken token = StartWork(); Status = UpdateStatus.Checking; try { _release = await _updates.CheckAsync(token); _packagePath = null; 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 = UpdateStatus.Failed; } OnPropertyChanged(nameof(ReleaseUrl)); OnPropertyChanged(nameof(IsReleaseLinkShown)); } [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; /// /// 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)); } } }