Browse Source

Add update channel selection and V3 update format support

pull/269/head
Ionite 1 year ago
parent
commit
7fba28fd0a
No known key found for this signature in database
  1. 2
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  2. 9
      StabilityMatrix.Core/Models/Settings/Settings.cs
  3. 11
      StabilityMatrix.Core/Models/Update/UpdateCollection.cs
  4. 83
      StabilityMatrix.Core/Models/Update/UpdateInfo.cs
  5. 17
      StabilityMatrix.Core/Models/Update/UpdateManifest.cs
  6. 28
      StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
  7. 161
      StabilityMatrix.Core/Updater/UpdateHelper.cs
  8. 45
      StabilityMatrix/ViewModels/UpdateWindowViewModel.cs

2
StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs

@ -124,7 +124,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
if (UpdateInfo is null) if (UpdateInfo is null)
return; return;
ReleaseNotes = await GetReleaseNotes(UpdateInfo.ChangelogUrl); ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString());
} }
internal async Task<string> GetReleaseNotes(string changelogUrl) internal async Task<string> GetReleaseNotes(string changelogUrl)

9
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 System.Text.Json.Serialization;
using Semver; using Semver;
using StabilityMatrix.Core.Converters.Json; using StabilityMatrix.Core.Converters.Json;
using StabilityMatrix.Core.Models.Update;
namespace StabilityMatrix.Core.Models.Settings; namespace StabilityMatrix.Core.Models.Settings;
@ -38,6 +38,11 @@ public class Settings
public string? WebApiHost { get; set; } public string? WebApiHost { get; set; }
public string? WebApiPort { get; set; } public string? WebApiPort { get; set; }
/// <summary>
/// Preferred update channel
/// </summary>
public UpdateChannel PreferredUpdateChannel { get; set; } = UpdateChannel.Stable;
/// <summary> /// <summary>
/// The last auto-update version that had a notification dismissed by the user /// The last auto-update version that had a notification dismissed by the user
/// </summary> /// </summary>

11
StabilityMatrix.Core/Models/Update/UpdateCollection.cs

@ -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
);

83
StabilityMatrix.Core/Models/Update/UpdateInfo.cs

@ -1,37 +1,58 @@
using System.Diagnostics.CodeAnalysis; using System.Globalization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Semver; using Semver;
using StabilityMatrix.Core.Converters.Json; using StabilityMatrix.Core.Converters.Json;
using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Core.Models.Update; namespace StabilityMatrix.Core.Models.Update;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] public record UpdateInfo
public record UpdateInfo( {
[property: JsonPropertyName("version"), JsonConverter(typeof(SemVersionJsonConverter))] [JsonConverter(typeof(SemVersionJsonConverter))]
SemVersion Version, public required SemVersion Version { get; init; }
[property: JsonPropertyName("releaseDate")] public required DateTimeOffset ReleaseDate { get; init; }
DateTimeOffset ReleaseDate,
public UpdateChannel Channel { get; init; }
[property: JsonPropertyName("channel")]
UpdateChannel Channel, public UpdateType Type { get; init; }
[property: JsonPropertyName("type")] public required Uri Url { get; init; }
UpdateType Type,
public required Uri Changelog { get; init; }
[property: JsonPropertyName("url")]
string DownloadUrl, /// <summary>
/// Blake3 hash of the file
[property: JsonPropertyName("changelog")] /// </summary>
string ChangelogUrl, public required string HashBlake3 { get; init; }
// Blake3 hash of the file /// <summary>
[property: JsonPropertyName("hashBlake3")] /// ED25519 signature of the semicolon seperated string:
string HashBlake3, /// "version + releaseDate + channel + type + url + changelog + hash_blake3"
/// verifiable using our stored public key
// ED25519 signature of the semicolon seperated string: /// </summary>
// "version + releaseDate + channel + type + url + changelog + hash_blake3" public required string Signature { get; init; }
// verifiable using our stored public key
[property: JsonPropertyName("signature")] /// <summary>
string Signature /// Data for use in signature verification.
); /// Semicolon separated string of fields:
/// "version, releaseDate, channel, type, url, changelog, hashBlake3"
/// </summary>
public string GetSignedData()
{
var channel = Channel.GetStringValue().ToLowerInvariant();
var date = FormatDateTimeOffsetInvariant(ReleaseDate);
return $"{Version};{date};{channel};" + $"{(int)Type};{Url};{Changelog};" + $"{HashBlake3}";
}
/// <summary>
/// Format a DatetimeOffset to a culture invariant string for use in signature verification.
/// </summary>
private static string FormatDateTimeOffsetInvariant(DateTimeOffset dateTimeOffset)
{
return dateTimeOffset.ToString(
@"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz",
CultureInfo.InvariantCulture
);
}
}

17
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<UpdateChannel, UpdatePlatforms> 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
{
}*/

28
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;
}
}

161
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.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Configs; using StabilityMatrix.Core.Models.Configs;
using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.FileInterfaces;
@ -19,11 +17,12 @@ public class UpdateHelper : IUpdateHelper
private readonly ILogger<UpdateHelper> logger; private readonly ILogger<UpdateHelper> logger;
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly IDownloadService downloadService; private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private readonly DebugOptions debugOptions; private readonly DebugOptions debugOptions;
private readonly System.Timers.Timer timer = new(TimeSpan.FromMinutes(60)); private readonly System.Timers.Timer timer = new(TimeSpan.FromMinutes(60));
private string UpdateManifestUrl => 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 const string UpdateFolderName = ".StabilityMatrixUpdate";
public static DirectoryPath UpdateFolder => Compat.AppCurrentDir.JoinDir(UpdateFolderName); public static DirectoryPath UpdateFolder => Compat.AppCurrentDir.JoinDir(UpdateFolderName);
@ -34,12 +33,14 @@ public class UpdateHelper : IUpdateHelper
ILogger<UpdateHelper> logger, ILogger<UpdateHelper> logger,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IDownloadService downloadService, IDownloadService downloadService,
IOptions<DebugOptions> debugOptions IOptions<DebugOptions> debugOptions,
ISettingsManager settingsManager
) )
{ {
this.logger = logger; this.logger = logger;
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
this.downloadService = downloadService; this.downloadService = downloadService;
this.settingsManager = settingsManager;
this.debugOptions = debugOptions.Value; this.debugOptions = debugOptions.Value;
timer.Elapsed += async (_, _) => timer.Elapsed += async (_, _) =>
@ -57,15 +58,13 @@ public class UpdateHelper : IUpdateHelper
public async Task DownloadUpdate(UpdateInfo updateInfo, IProgress<ProgressReport> progress) public async Task DownloadUpdate(UpdateInfo updateInfo, IProgress<ProgressReport> progress)
{ {
var downloadUrl = updateInfo.DownloadUrl;
UpdateFolder.Create(); UpdateFolder.Create();
UpdateFolder.Info.Attributes |= FileAttributes.Hidden; UpdateFolder.Info.Attributes |= FileAttributes.Hidden;
// download the file from URL // download the file from URL
await downloadService await downloadService
.DownloadToFileAsync( .DownloadToFileAsync(
downloadUrl, updateInfo.Url.ToString(),
ExecutablePath, ExecutablePath,
progress: progress, progress: progress,
httpClientName: "UpdateClient" httpClientName: "UpdateClient"
@ -73,31 +72,6 @@ public class UpdateHelper : IUpdateHelper
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <summary>
/// Format a DatetimeOffset to a culture invariant string for use in signature verification.
/// </summary>
private static string FormatDateTimeOffsetInvariant(DateTimeOffset dateTimeOffset)
{
return dateTimeOffset.ToString(
@"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz",
CultureInfo.InvariantCulture
);
}
/// <summary>
/// Data for use in signature verification.
/// Semicolon separated string of fields:
/// "version, releaseDate, channel, type, url, changelog, hashBlake3"
/// </summary>
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() private async Task CheckForUpdate()
{ {
try try
@ -114,85 +88,94 @@ public class UpdateHelper : IUpdateHelper
return; return;
} }
var updateCollection = await JsonSerializer var updateManifest = await JsonSerializer
.DeserializeAsync<UpdateCollection>( .DeserializeAsync<UpdateManifest>(
await response.Content.ReadAsStreamAsync().ConfigureAwait(false) await response.Content.ReadAsStreamAsync().ConfigureAwait(false),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }
) )
.ConfigureAwait(false); .ConfigureAwait(false);
if (updateCollection is null) if (updateManifest is null)
{ {
logger.LogError("UpdateCollection is null"); logger.LogError("UpdateManifest is null");
return; return;
} }
// Get the update info for our platform foreach (
var updateInfo = updateCollection switch var channel in Enum.GetValues(typeof(UpdateChannel))
{ .Cast<UpdateChannel>()
_ when Compat.IsWindows && Compat.IsX64 => updateCollection.WindowsX64, .Where(c => c > UpdateChannel.Unknown)
_ when Compat.IsLinux && Compat.IsX64 => updateCollection.LinuxX64, )
_ => null
};
if (updateInfo is null)
{ {
logger.LogWarning( if (
"Could not find compatible update info for the platform {Platform}", updateManifest.Updates.TryGetValue(channel, out var platforms)
Compat.Platform && platforms.GetInfoForCurrentPlatform() is { } update
); && ValidateUpdate(update)
return; )
{
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); private bool ValidateUpdate(UpdateInfo? update)
logger.LogInformation("UpdateInfo signed data: {SignData}", updateInfoSignData); {
if (update is null)
return false;
// Verify signature // Verify signature
var checker = new SignatureChecker(); var checker = new SignatureChecker();
if (!checker.Verify(updateInfoSignData, updateInfo.Signature)) var signedData = update.GetSignedData();
{
logger.LogError("UpdateInfo signature is invalid: {Info}", updateInfo);
return;
}
logger.LogInformation("UpdateInfo signature verified");
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 // Newer version available
logger.LogInformation( return true;
"Update available {AppVer} -> {UpdateVer}", case 0:
Compat.AppVersion,
updateInfo.Version
);
EventManager.Instance.OnUpdateAvailable(updateInfo);
return;
}
if (order == 0)
{ {
// Same version available, check if we both have commit hash metadata // 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; var appHash = Compat.AppVersion.Metadata;
// If different, we can update // If different, we can update
if (updateHash != appHash) if (updateHash != appHash)
{ {
logger.LogInformation( return true;
"Update available {AppVer} -> {UpdateVer}",
Compat.AppVersion,
updateInfo.Version
);
EventManager.Instance.OnUpdateAvailable(updateInfo);
return;
} }
}
logger.LogInformation("No update available"); break;
} }
catch (Exception e)
{
logger.LogError(e, "Couldn't check for update");
} }
return false;
}
private void NotifyUpdateAvailable(UpdateInfo update)
{
logger.LogInformation(
"Update available {AppVer} -> {UpdateVer}",
Compat.AppVersion,
update.Version
);
EventManager.Instance.OnUpdateAvailable(update);
} }
} }

45
StabilityMatrix/ViewModels/UpdateWindowViewModel.cs

@ -19,28 +19,38 @@ public partial class UpdateWindowViewModel : ObservableObject
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly IUpdateHelper updateHelper; private readonly IUpdateHelper updateHelper;
public UpdateWindowViewModel(ISettingsManager settingsManager, public UpdateWindowViewModel(
IHttpClientFactory httpClientFactory, IUpdateHelper updateHelper) ISettingsManager settingsManager,
IHttpClientFactory httpClientFactory,
IUpdateHelper updateHelper
)
{ {
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
this.updateHelper = updateHelper; this.updateHelper = updateHelper;
} }
[ObservableProperty] private string? releaseNotes; [ObservableProperty]
[ObservableProperty] private string? updateText; private string? releaseNotes;
[ObservableProperty] private int progressValue;
[ObservableProperty] private bool showProgressBar; [ObservableProperty]
private string? updateText;
[ObservableProperty]
private int progressValue;
[ObservableProperty]
private bool showProgressBar;
public UpdateInfo? UpdateInfo { get; set; } public UpdateInfo? UpdateInfo { get; set; }
public async Task OnLoaded() 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 client = httpClientFactory.CreateClient();
var response = await client.GetAsync(UpdateInfo?.ChangelogUrl); var response = await client.GetAsync(UpdateInfo?.Changelog);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
ReleaseNotes = await response.Content.ReadAsStringAsync(); ReleaseNotes = await response.Content.ReadAsStringAsync();
@ -58,14 +68,17 @@ public partial class UpdateWindowViewModel : ObservableObject
{ {
return; return;
} }
ShowProgressBar = true; ShowProgressBar = true;
UpdateText = $"Downloading update v{UpdateInfo.Version}..."; UpdateText = $"Downloading update v{UpdateInfo.Version}...";
await updateHelper.DownloadUpdate(UpdateInfo, new Progress<ProgressReport>(report => await updateHelper.DownloadUpdate(
{ UpdateInfo,
ProgressValue = Convert.ToInt32(report.Percentage); new Progress<ProgressReport>(report =>
})); {
ProgressValue = Convert.ToInt32(report.Percentage);
})
);
UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds...";
await Task.Delay(1000); await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds..."; UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds...";

Loading…
Cancel
Save