using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; using AsyncAwaitBestPractices; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Semver; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Update; using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Updater; namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; [View(typeof(UpdateDialog))] [ManagedService] [Singleton] public partial class UpdateViewModel : ContentDialogViewModelBase { private readonly ISettingsManager settingsManager; private readonly IHttpClientFactory httpClientFactory; private readonly IUpdateHelper updateHelper; private bool isLoaded; [ObservableProperty] private bool isUpdateAvailable; [ObservableProperty] private UpdateInfo? updateInfo; [ObservableProperty] private string? releaseNotes; [ObservableProperty] private string? updateText; [ObservableProperty] private int progressValue; [ObservableProperty] private bool isProgressIndeterminate; [ObservableProperty] private bool showProgressBar; [ObservableProperty] private string? currentVersionText; [ObservableProperty] private string? newVersionText; [GeneratedRegex( @"(##\s*(v[0-9]+\.[0-9]+\.[0-9]+(?:-(?:[0-9A-Za-z-.]+))?)((?:\n|.)+?))(?=(##\s*v[0-9]+\.[0-9]+\.[0-9]+)|\z)" )] private static partial Regex RegexChangelog(); public UpdateViewModel( ISettingsManager settingsManager, IHttpClientFactory httpClientFactory, IUpdateHelper updateHelper ) { this.settingsManager = settingsManager; this.httpClientFactory = httpClientFactory; this.updateHelper = updateHelper; EventManager.Instance.UpdateAvailable += (_, info) => { IsUpdateAvailable = true; UpdateInfo = info; }; updateHelper.StartCheckingForUpdates().SafeFireAndForget(); } /// /// Formats changelog markdown including up to the current version /// /// Markdown to format /// Versions equal or below this are excluded /// Maximum channel level to include internal static string? FormatChangelog( string markdown, SemVersion currentVersion, UpdateChannel maxChannel = UpdateChannel.Stable ) { var pattern = RegexChangelog(); var results = pattern .Matches(markdown) .Select( m => new { Block = m.Groups[1].Value.Trim(), Version = SemVersion.TryParse( m.Groups[2].Value.Trim(), SemVersionStyles.AllowV, out var version ) ? version : null, Content = m.Groups[3].Value.Trim() } ) .Where(x => x.Version is not null) .ToList(); // Join all blocks until and excluding the current version // If we're on a pre-release, include the current release var currentVersionBlock = results.FindIndex( x => x.Version == currentVersion.WithoutMetadata() ); // Support for previous pre-release without changelogs if (currentVersionBlock == -1) { currentVersionBlock = results.FindIndex( x => x.Version == currentVersion.WithoutPrereleaseOrMetadata() ); // Add 1 if found to include the current release if (currentVersionBlock != -1) { currentVersionBlock++; } } // Still not found, just include all if (currentVersionBlock == -1) { currentVersionBlock = results.Count; } // Filter out pre-releases var blocks = results .Take(currentVersionBlock) .Where( x => x.Version!.PrereleaseIdentifiers.Count == 0 || x.Version.PrereleaseIdentifiers[0].Value switch { "pre" when maxChannel >= UpdateChannel.Preview => true, "dev" when maxChannel >= UpdateChannel.Development => true, _ => false } ) .Select(x => x.Block); return string.Join(Environment.NewLine + Environment.NewLine, blocks); } public async Task Preload() { if (UpdateInfo is null) return; ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString()); } internal async Task GetReleaseNotes(string changelogUrl) { using var client = httpClientFactory.CreateClient(); var response = await client.GetAsync(changelogUrl); if (response.IsSuccessStatusCode) { var changelog = await response.Content.ReadAsStringAsync(); // Formatting for new changelog format // https://keepachangelog.com/en/1.1.0/ if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) { return FormatChangelog(changelog, Compat.AppVersion) ?? "## Unable to format release notes"; } return changelog; } else { return "## Unable to load release notes"; } } partial void OnUpdateInfoChanged(UpdateInfo? value) { CurrentVersionText = $"v{Compat.AppVersion}"; NewVersionText = $"v{value?.Version}"; } public override async Task OnLoadedAsync() { if (!isLoaded) { await Preload(); } } /// public override void OnUnloaded() { base.OnUnloaded(); isLoaded = false; } [RelayCommand] private async Task InstallUpdate() { if (UpdateInfo == null) { return; } ShowProgressBar = true; UpdateText = string.Format( Resources.TextTemplate_UpdatingPackage, Resources.Label_StabilityMatrix ); await updateHelper.DownloadUpdate( UpdateInfo, new Progress(report => { ProgressValue = Convert.ToInt32(report.Percentage); IsProgressIndeterminate = report.IsIndeterminate; }) ); // On unix, we need to set the executable bit if (Compat.IsUnix) { File.SetUnixFileMode( UpdateHelper.ExecutablePath, // 0755 UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute ); } UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; await Task.Delay(1000); UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds..."; await Task.Delay(1000); UpdateText = "Update complete. Restarting Stability Matrix in 1 second..."; await Task.Delay(1000); Process.Start(UpdateHelper.ExecutablePath); App.Shutdown(); } }