From 7fba28fd0ae809875daeed5e94ab706c8c54721e Mon Sep 17 00:00:00 2001 From: Ionite Date: Thu, 16 Nov 2023 22:33:28 -0500 Subject: [PATCH] Add update channel selection and V3 update format support --- .../ViewModels/Dialogs/UpdateViewModel.cs | 2 +- .../Models/Settings/Settings.cs | 9 +- .../Models/Update/UpdateCollection.cs | 11 -- .../Models/Update/UpdateInfo.cs | 83 +++++---- .../Models/Update/UpdateManifest.cs | 17 ++ .../Models/Update/UpdatePlatforms.cs | 28 +++ StabilityMatrix.Core/Updater/UpdateHelper.cs | 161 ++++++++---------- .../ViewModels/UpdateWindowViewModel.cs | 45 +++-- 8 files changed, 206 insertions(+), 150 deletions(-) delete mode 100644 StabilityMatrix.Core/Models/Update/UpdateCollection.cs create mode 100644 StabilityMatrix.Core/Models/Update/UpdateManifest.cs create mode 100644 StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 95fe270b..d33cb273 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -124,7 +124,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase if (UpdateInfo is null) return; - ReleaseNotes = await GetReleaseNotes(UpdateInfo.ChangelogUrl); + ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString()); } internal async Task GetReleaseNotes(string changelogUrl) diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index 8997f144..19c65f98 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -1,8 +1,8 @@ -using System.Drawing; -using System.Globalization; +using System.Globalization; using System.Text.Json.Serialization; using Semver; using StabilityMatrix.Core.Converters.Json; +using StabilityMatrix.Core.Models.Update; namespace StabilityMatrix.Core.Models.Settings; @@ -38,6 +38,11 @@ public class Settings public string? WebApiHost { get; set; } public string? WebApiPort { get; set; } + /// + /// Preferred update channel + /// + public UpdateChannel PreferredUpdateChannel { get; set; } = UpdateChannel.Stable; + /// /// The last auto-update version that had a notification dismissed by the user /// diff --git a/StabilityMatrix.Core/Models/Update/UpdateCollection.cs b/StabilityMatrix.Core/Models/Update/UpdateCollection.cs deleted file mode 100644 index 46b6cc93..00000000 --- a/StabilityMatrix.Core/Models/Update/UpdateCollection.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; - -namespace StabilityMatrix.Core.Models.Update; - -public record UpdateCollection ( - [property: JsonPropertyName("win-x64")] - UpdateInfo? WindowsX64, - - [property: JsonPropertyName("linux-x64")] - UpdateInfo? LinuxX64 -); diff --git a/StabilityMatrix.Core/Models/Update/UpdateInfo.cs b/StabilityMatrix.Core/Models/Update/UpdateInfo.cs index b0470c85..4e11d891 100644 --- a/StabilityMatrix.Core/Models/Update/UpdateInfo.cs +++ b/StabilityMatrix.Core/Models/Update/UpdateInfo.cs @@ -1,37 +1,58 @@ -using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json.Serialization; using Semver; using StabilityMatrix.Core.Converters.Json; +using StabilityMatrix.Core.Extensions; namespace StabilityMatrix.Core.Models.Update; -[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] -public record UpdateInfo( - [property: JsonPropertyName("version"), JsonConverter(typeof(SemVersionJsonConverter))] - SemVersion Version, - - [property: JsonPropertyName("releaseDate")] - DateTimeOffset ReleaseDate, - - [property: JsonPropertyName("channel")] - UpdateChannel Channel, - - [property: JsonPropertyName("type")] - UpdateType Type, - - [property: JsonPropertyName("url")] - string DownloadUrl, - - [property: JsonPropertyName("changelog")] - string ChangelogUrl, - - // Blake3 hash of the file - [property: JsonPropertyName("hashBlake3")] - string HashBlake3, - - // ED25519 signature of the semicolon seperated string: - // "version + releaseDate + channel + type + url + changelog + hash_blake3" - // verifiable using our stored public key - [property: JsonPropertyName("signature")] - string Signature -); +public record UpdateInfo +{ + [JsonConverter(typeof(SemVersionJsonConverter))] + public required SemVersion Version { get; init; } + + public required DateTimeOffset ReleaseDate { get; init; } + + public UpdateChannel Channel { get; init; } + + public UpdateType Type { get; init; } + + public required Uri Url { get; init; } + + public required Uri Changelog { get; init; } + + /// + /// Blake3 hash of the file + /// + public required string HashBlake3 { get; init; } + + /// + /// ED25519 signature of the semicolon seperated string: + /// "version + releaseDate + channel + type + url + changelog + hash_blake3" + /// verifiable using our stored public key + /// + public required string Signature { get; init; } + + /// + /// Data for use in signature verification. + /// Semicolon separated string of fields: + /// "version, releaseDate, channel, type, url, changelog, hashBlake3" + /// + public string GetSignedData() + { + var channel = Channel.GetStringValue().ToLowerInvariant(); + var date = FormatDateTimeOffsetInvariant(ReleaseDate); + return $"{Version};{date};{channel};" + $"{(int)Type};{Url};{Changelog};" + $"{HashBlake3}"; + } + + /// + /// Format a DatetimeOffset to a culture invariant string for use in signature verification. + /// + private static string FormatDateTimeOffsetInvariant(DateTimeOffset dateTimeOffset) + { + return dateTimeOffset.ToString( + @"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz", + CultureInfo.InvariantCulture + ); + } +} diff --git a/StabilityMatrix.Core/Models/Update/UpdateManifest.cs b/StabilityMatrix.Core/Models/Update/UpdateManifest.cs new file mode 100644 index 00000000..e4be6f15 --- /dev/null +++ b/StabilityMatrix.Core/Models/Update/UpdateManifest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Core.Models.Update; + +[JsonSerializable(typeof(UpdateManifest))] +public record UpdateManifest +{ + public required Dictionary Updates { get; init; } +} + + +// TODO: Bugged in .NET 7 but we can use in 8 https://github.com/dotnet/runtime/pull/79828 +/*[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(UpdateManifest))] +public partial class UpdateManifestContext : JsonSerializerContext +{ +}*/ diff --git a/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs new file mode 100644 index 00000000..b6711cd5 --- /dev/null +++ b/StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using StabilityMatrix.Core.Helper; + +namespace StabilityMatrix.Core.Models.Update; + +public record UpdatePlatforms +{ + [JsonPropertyName("win-x64")] + public UpdateInfo? WindowsX64 { get; init; } + + [JsonPropertyName("linux-x64")] + public UpdateInfo? LinuxX64 { get; init; } + + public UpdateInfo? GetInfoForCurrentPlatform() + { + if (Compat.IsWindows) + { + return WindowsX64; + } + + if (Compat.IsLinux) + { + return LinuxX64; + } + + return null; + } +} diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 325af086..f990a81c 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -1,9 +1,7 @@ -using System.Globalization; -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Configs; using StabilityMatrix.Core.Models.FileInterfaces; @@ -19,11 +17,12 @@ public class UpdateHelper : IUpdateHelper private readonly ILogger logger; private readonly IHttpClientFactory httpClientFactory; private readonly IDownloadService downloadService; + private readonly ISettingsManager settingsManager; private readonly DebugOptions debugOptions; private readonly System.Timers.Timer timer = new(TimeSpan.FromMinutes(60)); private string UpdateManifestUrl => - debugOptions.UpdateManifestUrl ?? "https://cdn.lykos.ai/update-v2.json"; + debugOptions.UpdateManifestUrl ?? "https://cdn.lykos.ai/update-v3.json"; public const string UpdateFolderName = ".StabilityMatrixUpdate"; public static DirectoryPath UpdateFolder => Compat.AppCurrentDir.JoinDir(UpdateFolderName); @@ -34,12 +33,14 @@ public class UpdateHelper : IUpdateHelper ILogger logger, IHttpClientFactory httpClientFactory, IDownloadService downloadService, - IOptions debugOptions + IOptions debugOptions, + ISettingsManager settingsManager ) { this.logger = logger; this.httpClientFactory = httpClientFactory; this.downloadService = downloadService; + this.settingsManager = settingsManager; this.debugOptions = debugOptions.Value; timer.Elapsed += async (_, _) => @@ -57,15 +58,13 @@ public class UpdateHelper : IUpdateHelper public async Task DownloadUpdate(UpdateInfo updateInfo, IProgress progress) { - var downloadUrl = updateInfo.DownloadUrl; - UpdateFolder.Create(); UpdateFolder.Info.Attributes |= FileAttributes.Hidden; // download the file from URL await downloadService .DownloadToFileAsync( - downloadUrl, + updateInfo.Url.ToString(), ExecutablePath, progress: progress, httpClientName: "UpdateClient" @@ -73,31 +72,6 @@ public class UpdateHelper : IUpdateHelper .ConfigureAwait(false); } - /// - /// Format a DatetimeOffset to a culture invariant string for use in signature verification. - /// - private static string FormatDateTimeOffsetInvariant(DateTimeOffset dateTimeOffset) - { - return dateTimeOffset.ToString( - @"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz", - CultureInfo.InvariantCulture - ); - } - - /// - /// Data for use in signature verification. - /// Semicolon separated string of fields: - /// "version, releaseDate, channel, type, url, changelog, hashBlake3" - /// - private static string GetUpdateInfoSignedData(UpdateInfo updateInfo) - { - var channel = updateInfo.Channel.GetStringValue().ToLowerInvariant(); - var date = FormatDateTimeOffsetInvariant(updateInfo.ReleaseDate); - return $"{updateInfo.Version};{date};{channel};" - + $"{(int)updateInfo.Type};{updateInfo.DownloadUrl};{updateInfo.ChangelogUrl};" - + $"{updateInfo.HashBlake3}"; - } - private async Task CheckForUpdate() { try @@ -114,85 +88,94 @@ public class UpdateHelper : IUpdateHelper return; } - var updateCollection = await JsonSerializer - .DeserializeAsync( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false) + var updateManifest = await JsonSerializer + .DeserializeAsync( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } ) .ConfigureAwait(false); - if (updateCollection is null) + if (updateManifest is null) { - logger.LogError("UpdateCollection is null"); + logger.LogError("UpdateManifest is null"); return; } - // Get the update info for our platform - var updateInfo = updateCollection switch - { - _ when Compat.IsWindows && Compat.IsX64 => updateCollection.WindowsX64, - _ when Compat.IsLinux && Compat.IsX64 => updateCollection.LinuxX64, - _ => null - }; - - if (updateInfo is null) + foreach ( + var channel in Enum.GetValues(typeof(UpdateChannel)) + .Cast() + .Where(c => c > UpdateChannel.Unknown) + ) { - logger.LogWarning( - "Could not find compatible update info for the platform {Platform}", - Compat.Platform - ); - return; + if ( + updateManifest.Updates.TryGetValue(channel, out var platforms) + && platforms.GetInfoForCurrentPlatform() is { } update + && ValidateUpdate(update) + ) + { + NotifyUpdateAvailable(update); + return; + } } - logger.LogInformation("UpdateInfo signature: {Signature}", updateInfo.Signature); + logger.LogInformation("No update available"); + } + catch (Exception e) + { + logger.LogError(e, "Couldn't check for update"); + } + } - var updateInfoSignData = GetUpdateInfoSignedData(updateInfo); - logger.LogInformation("UpdateInfo signed data: {SignData}", updateInfoSignData); + private bool ValidateUpdate(UpdateInfo? update) + { + if (update is null) + return false; - // Verify signature - var checker = new SignatureChecker(); - if (!checker.Verify(updateInfoSignData, updateInfo.Signature)) - { - logger.LogError("UpdateInfo signature is invalid: {Info}", updateInfo); - return; - } - logger.LogInformation("UpdateInfo signature verified"); + // Verify signature + var checker = new SignatureChecker(); + var signedData = update.GetSignedData(); - var order = updateInfo.Version.ComparePrecedenceTo(Compat.AppVersion); + if (!checker.Verify(signedData, update.Signature)) + { + logger.LogError( + "UpdateInfo signature {Signature} is invalid, Data = {Data}, UpdateInfo = {Info}", + update.Signature, + signedData, + update + ); + return false; + } - if (order > 0) - { + switch (update.Version.ComparePrecedenceTo(Compat.AppVersion)) + { + case > 0: // Newer version available - logger.LogInformation( - "Update available {AppVer} -> {UpdateVer}", - Compat.AppVersion, - updateInfo.Version - ); - EventManager.Instance.OnUpdateAvailable(updateInfo); - return; - } - if (order == 0) + return true; + case 0: { // Same version available, check if we both have commit hash metadata - var updateHash = updateInfo.Version.Metadata; + var updateHash = update.Version.Metadata; var appHash = Compat.AppVersion.Metadata; // If different, we can update if (updateHash != appHash) { - logger.LogInformation( - "Update available {AppVer} -> {UpdateVer}", - Compat.AppVersion, - updateInfo.Version - ); - EventManager.Instance.OnUpdateAvailable(updateInfo); - return; + return true; } - } - logger.LogInformation("No update available"); - } - catch (Exception e) - { - logger.LogError(e, "Couldn't check for update"); + break; + } } + + return false; + } + + private void NotifyUpdateAvailable(UpdateInfo update) + { + logger.LogInformation( + "Update available {AppVer} -> {UpdateVer}", + Compat.AppVersion, + update.Version + ); + EventManager.Instance.OnUpdateAvailable(update); } } diff --git a/StabilityMatrix/ViewModels/UpdateWindowViewModel.cs b/StabilityMatrix/ViewModels/UpdateWindowViewModel.cs index c5d95229..d61a8295 100644 --- a/StabilityMatrix/ViewModels/UpdateWindowViewModel.cs +++ b/StabilityMatrix/ViewModels/UpdateWindowViewModel.cs @@ -19,28 +19,38 @@ public partial class UpdateWindowViewModel : ObservableObject private readonly IHttpClientFactory httpClientFactory; private readonly IUpdateHelper updateHelper; - public UpdateWindowViewModel(ISettingsManager settingsManager, - IHttpClientFactory httpClientFactory, IUpdateHelper updateHelper) + public UpdateWindowViewModel( + ISettingsManager settingsManager, + IHttpClientFactory httpClientFactory, + IUpdateHelper updateHelper + ) { this.settingsManager = settingsManager; this.httpClientFactory = httpClientFactory; this.updateHelper = updateHelper; } - [ObservableProperty] private string? releaseNotes; - [ObservableProperty] private string? updateText; - [ObservableProperty] private int progressValue; - [ObservableProperty] private bool showProgressBar; - + [ObservableProperty] + private string? releaseNotes; + + [ObservableProperty] + private string? updateText; + + [ObservableProperty] + private int progressValue; + + [ObservableProperty] + private bool showProgressBar; public UpdateInfo? UpdateInfo { get; set; } public async Task OnLoaded() { - UpdateText = $"Stability Matrix v{UpdateInfo?.Version} is now available! You currently have v{Utilities.GetAppVersion()}. Would you like to update now?"; - + UpdateText = + $"Stability Matrix v{UpdateInfo?.Version} is now available! You currently have v{Utilities.GetAppVersion()}. Would you like to update now?"; + var client = httpClientFactory.CreateClient(); - var response = await client.GetAsync(UpdateInfo?.ChangelogUrl); + var response = await client.GetAsync(UpdateInfo?.Changelog); if (response.IsSuccessStatusCode) { ReleaseNotes = await response.Content.ReadAsStringAsync(); @@ -58,14 +68,17 @@ public partial class UpdateWindowViewModel : ObservableObject { return; } - + ShowProgressBar = true; UpdateText = $"Downloading update v{UpdateInfo.Version}..."; - await updateHelper.DownloadUpdate(UpdateInfo, new Progress(report => - { - ProgressValue = Convert.ToInt32(report.Percentage); - })); - + await updateHelper.DownloadUpdate( + UpdateInfo, + new Progress(report => + { + ProgressValue = Convert.ToInt32(report.Percentage); + }) + ); + UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; await Task.Delay(1000); UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds...";