Browse Source

Merge pull request #342 from ionite34/updates

pull/269/head
Ionite 1 year ago committed by GitHub
parent
commit
e5b8ceb365
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 100
      .github/workflows/release.yml
  2. 4
      CHANGELOG.md
  3. 63
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  4. 2
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  5. 1
      StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml
  6. 62
      StabilityMatrix.Core/Converters/Json/DefaultUnknownEnumConverter.cs
  7. 2
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  8. 20
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.Fluent.cs
  9. 71
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  10. 9
      StabilityMatrix.Core/Models/Settings/Settings.cs
  11. 11
      StabilityMatrix.Core/Models/Update/UpdateCollection.cs
  12. 83
      StabilityMatrix.Core/Models/Update/UpdateInfo.cs
  13. 17
      StabilityMatrix.Core/Models/Update/UpdateManifest.cs
  14. 28
      StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
  15. 219
      StabilityMatrix.Core/Updater/UpdateHelper.cs
  16. 60
      StabilityMatrix.Tests/Avalonia/UpdateViewModelTests.cs
  17. 22
      StabilityMatrix.UITests/TestAppBuilder.cs
  18. 45
      StabilityMatrix/ViewModels/UpdateWindowViewModel.cs

100
.github/workflows/release.yml

@ -26,6 +26,23 @@ on:
type: boolean
description: Mark GitHub Release as Prerelease?
default: false
auto-update-release:
type: boolean
description: Release auto-update?
default: false
auto-update-release-mode:
type: choice
description: Release auto-update mode
options:
- github url
- upload to b2
auto-update-release-channel:
type: choice
description: Release auto-update channel
options:
- stable
- preview
- development
jobs:
release-linux:
@ -180,8 +197,8 @@ jobs:
# Zip each build
- name: Zip Artifacts
run: |
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/*
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/*
- name: Create Github Release
id: create_release
@ -199,3 +216,82 @@ jobs:
body: ${{ steps.release_notes.outputs.release_notes }}
draft: ${{ github.event.inputs.github-release-draft == 'true' }}
prerelease: ${{ github.event.inputs.github-release-prerelease == 'true' }}
publish-auto-update-github:
name: Publish Auto-Update Release (GitHub)
needs: [ release-linux, release-windows ]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auto-update-release == 'true' && github.event.inputs.auto-update-release-mode == 'github url' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set Version from manual input
run: |
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Python Dependencies
run: pip install stability-matrix-tools~=0.2.7
- name: Publish Auto-Update Release
run: sm-tools updates publish-matrix -v $RELEASE_VERSION -y
publish-auto-update-b2:
name: Publish Auto-Update Release (B2)
needs: [ release-linux, release-windows ]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.auto-update-release == 'true' && github.event.inputs.auto-update-release-mode == 'upload to b2' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set Version from manual input
run: |
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Python Dependencies
run: pip install stability-matrix-tools~=0.2.7
- name: Download Changelog
run: >
sm-tools updates download-changelog -v $RELEASE_VERSION -y
--changelog
# Zip each build
- name: Zip Artifacts
run: |
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/*
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/*
# Check that the zips and CHANGELOG.md are in the current working directory
- name: Check files
run: |
if [ ! -f StabilityMatrix-win-x64.zip ]; then
echo "StabilityMatrix-win-x64.zip not found"
exit 1
fi
if [ ! -f StabilityMatrix-linux-x64.zip ]; then
echo "StabilityMatrix-linux-x64.zip not found"
exit 1
fi
if [ ! -f CHANGELOG.md ]; then
echo "CHANGELOG.md not found"
exit 1
fi
- name: Publish Auto-Update Release
run: >
sm-tools updates publish-files-v3 -v $RELEASE_VERSION -y
--channel ${{ github.event.inputs.auto-update-release-channel }}
--changelog CHANGELOG.md
--win-x64 StabilityMatrix-win-x64.zip
--linux-x64 StabilityMatrix-linux-x64.zip
--b2-bucket-name ${{ secrets.B2_BUCKET_NAME }}

4
CHANGELOG.md

@ -5,6 +5,10 @@ All notable changes to Stability Matrix will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).
## v2.6.2
### Changed
- Backend changes for auto-update schema v3, supporting customizable release channels and faster downloads with zip compression
## v2.6.1
### Changed
- NVIDIA GPU users will be updated to use CUDA 12.1 for the InvokeAI package for a slight performance improvement

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

@ -47,6 +47,9 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
[ObservableProperty]
private int progressValue;
[ObservableProperty]
private bool isProgressIndeterminate;
[ObservableProperty]
private bool showProgressBar;
@ -57,7 +60,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
private string? newVersionText;
[GeneratedRegex(
@"(##\s*(v[0-9]+\.[0-9]+\.[0-9]+)((?:\n|.)+?))(?=(##\s*v[0-9]+\.[0-9]+\.[0-9]+)|\z)"
@"(##\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();
@ -82,7 +85,14 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
/// <summary>
/// Formats changelog markdown including up to the current version
/// </summary>
internal static string? FormatChangelog(string markdown, SemVersion currentVersion)
/// <param name="markdown">Markdown to format</param>
/// <param name="currentVersion">Versions equal or below this are excluded</param>
/// <param name="maxChannel">Maximum channel level to include</param>
internal static string? FormatChangelog(
string markdown,
SemVersion currentVersion,
UpdateChannel maxChannel = UpdateChannel.Stable
)
{
var pattern = RegexChangelog();
@ -93,28 +103,59 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
new
{
Block = m.Groups[1].Value.Trim(),
Version = m.Groups[2].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 == $"v{currentVersion.WithoutPrereleaseOrMetadata()}"
x => x.Version == currentVersion.WithoutMetadata()
);
// Support for previous pre-release without changelogs
if (currentVersionBlock == -1)
{
return null;
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 + (currentVersion.IsPrerelease ? 1 : 0))
.Select(x => x.Block)
.ToList();
.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);
}
@ -124,7 +165,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<string> GetReleaseNotes(string changelogUrl)
@ -185,11 +226,13 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix
);
await updateHelper.DownloadUpdate(
UpdateInfo,
new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
IsProgressIndeterminate = report.IsIndeterminate;
})
);

2
StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs

@ -484,7 +484,7 @@ public partial class OutputsPageViewModel : PageViewModelBase
continue;
}
await file.MoveToAsync(newPath);
await file.MoveToWithIncrementAsync(newPath);
}
catch (Exception e)
{

1
StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml

@ -66,6 +66,7 @@
<ProgressBar Grid.Row="3"
Height="200"
Value="{Binding ProgressValue}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
IsVisible="{Binding ShowProgressBar}"
Margin="32"/>

62
StabilityMatrix.Core/Converters/Json/DefaultUnknownEnumConverter.cs

@ -4,11 +4,16 @@ using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Core.Converters.Json;
public class DefaultUnknownEnumConverter<T> : JsonConverter<T> where T : Enum
public class DefaultUnknownEnumConverter<T> : JsonConverter<T>
where T : Enum
{
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
public override T Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
if (reader.TokenType != JsonTokenType.String)
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException();
}
@ -16,15 +21,15 @@ public class DefaultUnknownEnumConverter<T> : JsonConverter<T> where T : Enum
var enumText = reader.GetString()?.Replace(" ", "_");
if (Enum.TryParse(typeof(T), enumText, true, out var result))
{
return (T) result!;
return (T)result!;
}
// Unknown value handling
if (Enum.TryParse(typeof(T), "Unknown", true, out var unknownResult))
if (Enum.TryParse(typeof(T), "Unknown", true, out var unknownResult))
{
return (T) unknownResult!;
return (T)unknownResult!;
}
throw new JsonException($"Unable to parse '{enumText}' to enum '{typeof(T)}'.");
}
@ -38,4 +43,47 @@ public class DefaultUnknownEnumConverter<T> : JsonConverter<T> where T : Enum
writer.WriteStringValue(value.GetStringValue().Replace("_", " "));
}
/// <inheritdoc />
public override T ReadAsPropertyName(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException();
}
var enumText = reader.GetString()?.Replace(" ", "_");
if (Enum.TryParse(typeof(T), enumText, true, out var result))
{
return (T)result!;
}
// Unknown value handling
if (Enum.TryParse(typeof(T), "Unknown", true, out var unknownResult))
{
return (T)unknownResult!;
}
throw new JsonException($"Unable to parse '{enumText}' to enum '{typeof(T)}'.");
}
/// <inheritdoc />
public override void WriteAsPropertyName(
Utf8JsonWriter writer,
T? value,
JsonSerializerOptions options
)
{
if (value == null)
{
writer.WriteNullValue();
return;
}
writer.WritePropertyName(value.GetStringValue().Replace("_", " "));
}
}

2
StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs

@ -106,7 +106,7 @@ public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable<FileSystem
/// <summary>
/// Deletes the directory.
/// </summary>
public void Delete() => Directory.Delete(FullPath);
public void Delete() => Info.Delete();
/// <summary> Deletes the directory asynchronously. </summary>
public Task DeleteAsync() => Task.Run(Delete);

20
StabilityMatrix.Core/Models/FileInterfaces/FilePath.Fluent.cs

@ -0,0 +1,20 @@
namespace StabilityMatrix.Core.Models.FileInterfaces;
public partial class FilePath
{
/// <summary>
/// Return a new <see cref="FilePath"/> with the given file name.
/// </summary>
public FilePath WithName(string fileName)
{
if (
Path.GetDirectoryName(FullPath) is { } directory
&& !string.IsNullOrWhiteSpace(directory)
)
{
return new FilePath(directory, fileName);
}
return new FilePath(fileName);
}
}

71
StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs

@ -7,7 +7,7 @@ namespace StabilityMatrix.Core.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[JsonConverter(typeof(StringJsonConverter<FilePath>))]
public class FilePath : FileSystemPath, IPathObject
public partial class FilePath : FileSystemPath, IPathObject
{
private FileInfo? _info;
@ -33,6 +33,7 @@ public class FilePath : FileSystemPath, IPathObject
[JsonIgnore]
public string NameWithoutExtension => Path.GetFileNameWithoutExtension(Info.Name);
/// <inheritdoc cref="FileInfo.Extension"/>
[JsonIgnore]
public string Extension => Info.Extension;
@ -138,6 +139,26 @@ public class FilePath : FileSystemPath, IPathObject
return File.WriteAllBytesAsync(FullPath, bytes, ct);
}
/// <summary>
/// Rename the file.
/// </summary>
public FilePath Rename(string fileName)
{
if (
Path.GetDirectoryName(FullPath) is { } directory
&& !string.IsNullOrWhiteSpace(directory)
)
{
var target = Path.Combine(directory, fileName);
Info.MoveTo(target, true);
return new FilePath(target);
}
throw new InvalidOperationException(
"Cannot rename a file path that is empty or has no directory"
);
}
/// <summary>
/// Move the file to a directory.
/// </summary>
@ -153,7 +174,7 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary>
public async Task<FilePath> MoveToDirectoryAsync(DirectoryPath directory)
{
await Task.Run(() => Info.MoveTo(directory.FullPath)).ConfigureAwait(false);
await Task.Run(() => Info.MoveTo(directory.JoinFile(Name), true)).ConfigureAwait(false);
// Return the new path
return directory.JoinFile(this);
}
@ -163,25 +184,41 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary>
public async Task<FilePath> MoveToAsync(FilePath destinationFile)
{
await Task.Run(() =>
{
var path = destinationFile.FullPath;
if (destinationFile.Exists)
{
var num = Random.Shared.NextInt64(0, 10000);
path = path.Replace(
destinationFile.NameWithoutExtension,
$"{destinationFile.NameWithoutExtension}_{num}"
);
}
Info.MoveTo(path);
})
.ConfigureAwait(false);
await Task.Run(() => Info.MoveTo(destinationFile.FullPath)).ConfigureAwait(false);
// Return the new path
return destinationFile;
}
/// <summary>
/// Move the file to a target path with auto increment if the file already exists.
/// </summary>
/// <returns>The new path, possibly with incremented file name</returns>
public async Task<FilePath> MoveToWithIncrementAsync(
FilePath destinationFile,
int maxTries = 100
)
{
await Task.Yield();
var targetFile = destinationFile;
for (var i = 1; i < maxTries; i++)
{
if (!targetFile.Exists)
{
return await MoveToAsync(targetFile).ConfigureAwait(false);
}
targetFile = destinationFile.WithName(
destinationFile.NameWithoutExtension + $" ({i})" + destinationFile.Extension
);
}
throw new IOException(
$"Could not move file to {destinationFile} because it already exists."
);
}
/// <summary>
/// Copy the file to a target path.
/// </summary>

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 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; }
/// <summary>
/// Preferred update channel
/// </summary>
public UpdateChannel PreferredUpdateChannel { get; set; } = UpdateChannel.Stable;
/// <summary>
/// The last auto-update version that had a notification dismissed by the user
/// </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 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; }
/// <summary>
/// Blake3 hash of the file
/// </summary>
public required string HashBlake3 { get; init; }
/// <summary>
/// ED25519 signature of the semicolon seperated string:
/// "version + releaseDate + channel + type + url + changelog + hash_blake3"
/// verifiable using our stored public key
/// </summary>
public required string Signature { get; init; }
/// <summary>
/// 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;
}
}

219
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<UpdateHelper> 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<UpdateHelper> logger,
IHttpClientFactory httpClientFactory,
IDownloadService downloadService,
IOptions<DebugOptions> debugOptions
IOptions<DebugOptions> debugOptions,
ISettingsManager settingsManager
)
{
this.logger = logger;
this.httpClientFactory = httpClientFactory;
this.downloadService = downloadService;
this.settingsManager = settingsManager;
this.debugOptions = debugOptions.Value;
timer.Elapsed += async (_, _) =>
@ -57,45 +58,64 @@ public class UpdateHelper : IUpdateHelper
public async Task DownloadUpdate(UpdateInfo updateInfo, IProgress<ProgressReport> progress)
{
var downloadUrl = updateInfo.DownloadUrl;
UpdateFolder.Create();
UpdateFolder.Info.Attributes |= FileAttributes.Hidden;
// download the file from URL
await downloadService
.DownloadToFileAsync(
downloadUrl,
ExecutablePath,
progress: progress,
httpClientName: "UpdateClient"
)
.ConfigureAwait(false);
}
var downloadFile = UpdateFolder.JoinFile(Path.GetFileName(updateInfo.Url.ToString()));
/// <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
);
}
var extractDir = UpdateFolder.JoinDir("extract");
/// <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}";
try
{
// download the file from URL
await downloadService
.DownloadToFileAsync(
updateInfo.Url.ToString(),
downloadFile,
progress: progress,
httpClientName: "UpdateClient"
)
.ConfigureAwait(false);
// Unzip if needed
if (downloadFile.Extension == ".zip")
{
if (extractDir.Exists)
{
await extractDir.DeleteAsync(true).ConfigureAwait(false);
}
extractDir.Create();
progress.Report(
new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract)
);
await ArchiveHelper.Extract(downloadFile, extractDir).ConfigureAwait(false);
// Find binary and move it up to the root
var binaryFile = extractDir
.EnumerateFiles("*.*", SearchOption.AllDirectories)
.First(f => f.Extension.ToLowerInvariant() is ".exe" or ".appimage");
await binaryFile.MoveToAsync(ExecutablePath).ConfigureAwait(false);
}
// Otherwise just rename
else
{
downloadFile.Rename(ExecutablePath.Name);
}
progress.Report(new ProgressReport(1d));
}
finally
{
// Clean up original download
await downloadFile.DeleteAsync().ConfigureAwait(false);
// Clean up extract dir
if (extractDir.Exists)
{
await extractDir.DeleteAsync(true).ConfigureAwait(false);
}
}
}
private async Task CheckForUpdate()
@ -114,85 +134,94 @@ public class UpdateHelper : IUpdateHelper
return;
}
var updateCollection = await JsonSerializer
.DeserializeAsync<UpdateCollection>(
await response.Content.ReadAsStreamAsync().ConfigureAwait(false)
var updateManifest = await JsonSerializer
.DeserializeAsync<UpdateManifest>(
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<UpdateChannel>()
.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);
}
}

60
StabilityMatrix.Tests/Avalonia/UpdateViewModelTests.cs

@ -1,5 +1,6 @@
using Semver;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Core.Models.Update;
namespace StabilityMatrix.Tests.Avalonia;
@ -65,4 +66,63 @@ public class UpdateViewModelTests
""";
Assert.AreEqual(expectedPre, resultPre);
}
[TestMethod]
public void FormatChangelogWithChannelTest()
{
// Arrange
const string markdown = """
# Changelog
All notable changes to Stability Matrix will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).
## v2.4.6
### Added
- Stuff
### Changed
- Things
## v2.4.6-pre.1
### Fixed
- Fixed bug
## v2.4.6-dev.1
### Fixed
- Fixed bug
## v2.4.5
### Changed
- Changed stuff
""";
// Act
var result = UpdateViewModel.FormatChangelog(
markdown,
SemVersion.Parse("2.4.0"),
UpdateChannel.Preview
);
// Assert
const string expected = """
## v2.4.6
### Added
- Stuff
### Changed
- Things
## v2.4.6-pre.1
### Fixed
- Fixed bug
## v2.4.5
### Changed
- Changed stuff
""";
// Should include pre but not dev
Assert.AreEqual(expected, result);
}
}

22
StabilityMatrix.UITests/TestAppBuilder.cs

@ -45,16 +45,18 @@ public static class TestAppBuilder
serviceCollection.AddSingleton<ISettingsManager>(settingsManager);
// IUpdateHelper
var mockUpdateInfo = new UpdateInfo(
SemVersion.Parse("2.999.0"),
DateTimeOffset.UnixEpoch,
UpdateChannel.Stable,
UpdateType.Normal,
"https://example.org",
"https://example.org",
"46e11a5216c55d4c9d3c54385f62f3e1022537ae191615237f05e06d6f8690d0",
"IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA=="
);
var mockUpdateInfo = new UpdateInfo()
{
Version = SemVersion.Parse("2.999.0"),
ReleaseDate = DateTimeOffset.UnixEpoch,
Channel = UpdateChannel.Stable,
Type = UpdateType.Normal,
Url = new Uri("https://example.org"),
Changelog = new Uri("https://example.org"),
HashBlake3 = "46e11a5216c55d4c9d3c54385f62f3e1022537ae191615237f05e06d6f8690d0",
Signature =
"IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA=="
};
var updateHelper = Substitute.For<IUpdateHelper>();
updateHelper

45
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<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
}));
await updateHelper.DownloadUpdate(
UpdateInfo,
new Progress<ProgressReport>(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...";

Loading…
Cancel
Save