Browse Source

Merge pull request #345 from ionite34/merge-main-to-dev-e5b8ceb

pull/324/head
Ionite 1 year ago committed by GitHub
parent
commit
0ee96fba7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 100
      .github/workflows/release.yml
  2. 13
      CHANGELOG.md
  3. 7
      README.md
  4. 23
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  5. 63
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  6. 8
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  7. 2
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  8. 2
      StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
  9. 1
      StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml
  10. 62
      StabilityMatrix.Core/Converters/Json/DefaultUnknownEnumConverter.cs
  11. 149
      StabilityMatrix.Core/Helper/Cache/GithubApiCache.cs
  12. 11
      StabilityMatrix.Core/Helper/Cache/IGithubApiCache.cs
  13. 2
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  14. 20
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.Fluent.cs
  15. 71
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  16. 31
      StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
  17. 2
      StabilityMatrix.Core/Models/Packages/BasePackage.cs
  18. 32
      StabilityMatrix.Core/Models/Packages/InvokeAI.cs
  19. 8
      StabilityMatrix.Core/Models/Packages/UnknownPackage.cs
  20. 9
      StabilityMatrix.Core/Models/Settings/Settings.cs
  21. 11
      StabilityMatrix.Core/Models/Update/UpdateCollection.cs
  22. 83
      StabilityMatrix.Core/Models/Update/UpdateInfo.cs
  23. 17
      StabilityMatrix.Core/Models/Update/UpdateManifest.cs
  24. 28
      StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
  25. 219
      StabilityMatrix.Core/Updater/UpdateHelper.cs
  26. 60
      StabilityMatrix.Tests/Avalonia/UpdateViewModelTests.cs
  27. 22
      StabilityMatrix.UITests/TestAppBuilder.cs
  28. 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 }}

13
CHANGELOG.md

@ -5,6 +5,19 @@ 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
- Update will occur the next time the package is updated, or on a fresh install
- Note: CUDA 12.1 is only available on Maxwell (GTX 900 series) and newer GPUs
### Fixed
- Reduced the amount of calls to GitHub to help prevent rate limiting
- Fixed rate limit crash on startup preventing app from starting
## v2.6.0
### Added
- Added **Output Sharing** option for all packages in the three-dots menu on the Packages page

7
README.md

@ -18,9 +18,9 @@
[civitai]: https://civitai.com/
Multi-Platform Package Manager for Stable Diffusion
Multi-Platform Package Manager and Inference UI for Stable Diffusion
### ✨ New in 2.5 - [Inference](#inference), a built-in Stable Diffusion interface powered by ComfyUI
### ✨ New in 2.5 - [Inference](#inference-A-reimagined-built-in-Stable-Diffusion-experience), a built-in interface for Stable Diffusion powered by ComfyUI
### 🖱 One click install and update for Stable Diffusion Web UI Packages
- Supports [Automatic 1111][auto1111], [Comfy UI][comfy], [SD.Next (Vladmandic)][sdnext], [VoltaML][voltaml], [InvokeAI][invokeai], [Fooocus][fooocus], and [Fooocus MRE][fooocus-mre]
@ -107,6 +107,9 @@ Stability Matrix is now available in the following languages, thanks to our comm
If you would like to contribute a translation, please create an issue or contact us on Discord. Include an email where we'll send an invite to our [POEditor](https://poeditor.com/) project.
## Disclaimers
All trademarks, logos, and brand names are the property of their respective owners. All company, product and service names used in this document and licensed applications are for identification purposes only. Use of these names, trademarks, and brands does not imply endorsement.
## License
This repository maintains the latest source code release for Stability Matrix, and is licensed under the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.en.html). Binaries and executable releases are licensed under the [End User License Agreement](https://lykos.ai/license).

23
StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs

@ -140,30 +140,17 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase
SelectedPackage.Name
);
var downloadVersion = new DownloadPackageVersionOptions
{
IsLatest = true,
IsPrerelease = false
};
var downloadVersion = await SelectedPackage.GetLatestVersion();
var installedVersion = new InstalledPackageVersion { IsPrerelease = false };
var versionOptions = await SelectedPackage.GetAllVersionOptions();
if (versionOptions.AvailableVersions != null && versionOptions.AvailableVersions.Any())
if (SelectedPackage.ShouldIgnoreReleases)
{
downloadVersion.VersionTag = versionOptions.AvailableVersions.First().TagName;
installedVersion.InstalledReleaseVersion = downloadVersion.VersionTag;
installedVersion.InstalledBranch = downloadVersion.BranchName;
installedVersion.InstalledCommitSha = downloadVersion.CommitHash;
}
else
{
var latestVersion = await SelectedPackage.GetLatestVersion();
downloadVersion.BranchName = latestVersion.BranchName;
downloadVersion.CommitHash =
(await SelectedPackage.GetAllCommits(downloadVersion.BranchName))
?.FirstOrDefault()
?.Sha ?? string.Empty;
installedVersion.InstalledBranch = downloadVersion.BranchName;
installedVersion.InstalledCommitSha = downloadVersion.CommitHash;
installedVersion.InstalledReleaseVersion = downloadVersion.VersionTag;
}
var torchVersion = SelectedPackage.GetRecommendedTorchVersion();

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

8
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -180,7 +180,8 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task<bool> EnsureDataDirectory()
{
// If we can't find library, show selection dialog
if (!settingsManager.TryFindLibrary())
var foundInitially = settingsManager.TryFindLibrary();
if (!foundInitially)
{
var result = await ShowSelectDataDirectoryDialog();
if (!result)
@ -194,7 +195,10 @@ public partial class MainWindowViewModel : ViewModelBase
}
// Tell LaunchPage to load any packages if they selected an existing directory
EventManager.Instance.OnInstalledPackagesChanged();
if (!foundInitially)
{
EventManager.Instance.OnInstalledPackagesChanged();
}
// Check if there are old packages, if so show migration dialog
// TODO: Migration dialog

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)
{

2
StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs

@ -99,7 +99,7 @@ public partial class PackageManagerViewModel : PageViewModelBase
.Bind(PackageCards)
.Subscribe();
timer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(15), IsEnabled = true };
timer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(60), IsEnabled = true };
timer.Tick += async (_, _) => await CheckPackagesForUpdates();
}

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("_", " "));
}
}

149
StabilityMatrix.Core/Helper/Cache/GithubApiCache.cs

@ -1,4 +1,5 @@
using Octokit;
using Microsoft.Extensions.Logging;
using Octokit;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Models.Database;
@ -10,83 +11,85 @@ public class GithubApiCache : IGithubApiCache
{
private readonly ILiteDbContext dbContext;
private readonly IGitHubClient githubApi;
private readonly ILogger<IGithubApiCache> logger;
private readonly TimeSpan cacheDuration = TimeSpan.FromMinutes(15);
public GithubApiCache(ILiteDbContext dbContext, IGitHubClient githubApi)
public GithubApiCache(
ILiteDbContext dbContext,
IGitHubClient githubApi,
ILogger<IGithubApiCache> logger
)
{
this.dbContext = dbContext;
this.githubApi = githubApi;
}
public async Task<Release?> GetLatestRelease(string username, string repository)
{
var cacheKey = $"Releases-{username}-{repository}";
var latestRelease = await dbContext.GetGithubCacheEntry(cacheKey);
if (latestRelease != null && !IsCacheExpired(latestRelease.LastUpdated))
{
return latestRelease.AllReleases.First();
}
var allReleases = await githubApi.Repository.Release.GetAll(username, repository);
if (allReleases == null)
{
return null;
}
var cacheEntry = new GithubCacheEntry
{
CacheKey = cacheKey,
AllReleases = allReleases.OrderByDescending(x => x.CreatedAt)
};
await dbContext.UpsertGithubCacheEntry(cacheEntry);
return cacheEntry.AllReleases.First();
this.logger = logger;
}
public async Task<IEnumerable<Release>> GetAllReleases(string username, string repository)
{
var cacheKey = $"Releases-{username}-{repository}";
var cacheEntry = await dbContext.GetGithubCacheEntry(cacheKey);
var cacheEntry = await dbContext.GetGithubCacheEntry(cacheKey).ConfigureAwait(false);
if (cacheEntry != null && !IsCacheExpired(cacheEntry.LastUpdated))
{
return cacheEntry.AllReleases.OrderByDescending(x => x.CreatedAt);
}
var allReleases = await githubApi.Repository.Release.GetAll(username, repository);
if (allReleases == null)
try
{
return new List<Release>().OrderByDescending(x => x.CreatedAt);
}
var allReleases = await githubApi.Repository.Release
.GetAll(username, repository)
.ConfigureAwait(false);
if (allReleases == null)
{
return new List<Release>();
}
var newCacheEntry = new GithubCacheEntry
{
CacheKey = cacheKey,
AllReleases = allReleases.OrderByDescending(x => x.CreatedAt)
};
await dbContext.UpsertGithubCacheEntry(newCacheEntry);
var newCacheEntry = new GithubCacheEntry
{
CacheKey = cacheKey,
AllReleases = allReleases.OrderByDescending(x => x.CreatedAt)
};
await dbContext.UpsertGithubCacheEntry(newCacheEntry).ConfigureAwait(false);
return newCacheEntry.AllReleases;
return newCacheEntry.AllReleases;
}
catch (ApiException ex)
{
logger.LogWarning(ex, "Failed to get releases from Github API.");
return cacheEntry?.AllReleases.OrderByDescending(x => x.CreatedAt)
?? Enumerable.Empty<Release>();
}
}
public async Task<IEnumerable<Branch>> GetAllBranches(string username, string repository)
{
var cacheKey = $"Branches-{username}-{repository}";
var cacheEntry = await dbContext.GetGithubCacheEntry(cacheKey);
var cacheEntry = await dbContext.GetGithubCacheEntry(cacheKey).ConfigureAwait(false);
if (cacheEntry != null && !IsCacheExpired(cacheEntry.LastUpdated))
{
return cacheEntry.Branches;
}
var branches = await githubApi.Repository.Branch.GetAll(username, repository);
if (branches == null)
try
{
return new List<Branch>();
}
var branches = await githubApi.Repository.Branch
.GetAll(username, repository)
.ConfigureAwait(false);
if (branches == null)
{
return new List<Branch>();
}
var newCacheEntry = new GithubCacheEntry { CacheKey = cacheKey, Branches = branches };
await dbContext.UpsertGithubCacheEntry(newCacheEntry);
var newCacheEntry = new GithubCacheEntry { CacheKey = cacheKey, Branches = branches };
await dbContext.UpsertGithubCacheEntry(newCacheEntry).ConfigureAwait(false);
return newCacheEntry.Branches;
return newCacheEntry.Branches;
}
catch (ApiException ex)
{
logger.LogWarning(ex, "Failed to get branches from Github API.");
return cacheEntry?.Branches ?? Enumerable.Empty<Branch>();
}
}
public async Task<IEnumerable<GitCommit>?> GetAllCommits(
@ -98,37 +101,47 @@ public class GithubApiCache : IGithubApiCache
)
{
var cacheKey = $"Commits-{username}-{repository}-{branch}-{page}-{perPage}";
var cacheEntry = await dbContext.GetGithubCacheEntry(cacheKey);
var cacheEntry = await dbContext.GetGithubCacheEntry(cacheKey).ConfigureAwait(false);
if (cacheEntry != null && !IsCacheExpired(cacheEntry.LastUpdated))
{
return cacheEntry.Commits;
}
var commits = await githubApi.Repository.Commit.GetAll(
username,
repository,
new CommitRequest { Sha = branch },
new ApiOptions
try
{
var commits = await githubApi.Repository.Commit
.GetAll(
username,
repository,
new CommitRequest { Sha = branch },
new ApiOptions
{
PageCount = page,
PageSize = perPage,
StartPage = page
}
)
.ConfigureAwait(false);
if (commits == null)
{
PageCount = page,
PageSize = perPage,
StartPage = page
return new List<GitCommit>();
}
);
if (commits == null)
{
return new List<GitCommit>();
}
var newCacheEntry = new GithubCacheEntry
{
CacheKey = cacheKey,
Commits = commits.Select(x => new GitCommit { Sha = x.Sha })
};
await dbContext.UpsertGithubCacheEntry(newCacheEntry).ConfigureAwait(false);
var newCacheEntry = new GithubCacheEntry
return newCacheEntry.Commits;
}
catch (ApiException ex)
{
CacheKey = cacheKey,
Commits = commits.Select(x => new GitCommit { Sha = x.Sha })
};
await dbContext.UpsertGithubCacheEntry(newCacheEntry);
return newCacheEntry.Commits;
logger.LogWarning(ex, "Failed to get commits from Github API.");
return cacheEntry?.Commits ?? Enumerable.Empty<GitCommit>();
}
}
private bool IsCacheExpired(DateTimeOffset expiration) =>

11
StabilityMatrix.Core/Helper/Cache/IGithubApiCache.cs

@ -6,12 +6,15 @@ namespace StabilityMatrix.Core.Helper.Cache;
public interface IGithubApiCache
{
Task<Release?> GetLatestRelease(string username, string repository);
Task<IEnumerable<Release>> GetAllReleases(string username, string repository);
Task<IEnumerable<Branch>> GetAllBranches(string username, string repository);
Task<IEnumerable<GitCommit>?> GetAllCommits(string username, string repository, string branch, int page = 1,
[AliasAs("per_page")] int perPage = 10);
Task<IEnumerable<GitCommit>?> GetAllCommits(
string username,
string repository,
string branch,
int page = 1,
[AliasAs("per_page")] int perPage = 10
);
}

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>

31
StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs

@ -86,31 +86,19 @@ public abstract class BaseGitPackage : BasePackage
};
}
var release = await GetLatestRelease(includePrerelease).ConfigureAwait(false);
var releases = await GithubApi.GetAllReleases(Author, Name).ConfigureAwait(false);
var latestRelease = includePrerelease
? releases.First()
: releases.First(x => !x.Prerelease);
return new DownloadPackageVersionOptions
{
IsLatest = true,
IsPrerelease = release.Prerelease,
VersionTag = release.TagName!
IsPrerelease = latestRelease.Prerelease,
VersionTag = latestRelease.TagName!
};
}
protected async Task<Release> GetLatestRelease(bool includePrerelease = false)
{
var releases = await GithubApi.GetAllReleases(Author, Name).ConfigureAwait(false);
return includePrerelease ? releases.First() : releases.First(x => !x.Prerelease);
}
public override Task<IEnumerable<Branch>> GetAllBranches()
{
return GithubApi.GetAllBranches(Author, Name);
}
public override Task<IEnumerable<Release>> GetAllReleases()
{
return GithubApi.GetAllReleases(Author, Name);
}
public override Task<IEnumerable<GitCommit>?> GetAllCommits(
string branch,
int page = 1,
@ -126,7 +114,7 @@ public abstract class BaseGitPackage : BasePackage
if (!ShouldIgnoreReleases)
{
var allReleases = await GetAllReleases().ConfigureAwait(false);
var allReleases = await GithubApi.GetAllReleases(Author, Name).ConfigureAwait(false);
var releasesList = allReleases.ToList();
if (releasesList.Any())
{
@ -143,7 +131,7 @@ public abstract class BaseGitPackage : BasePackage
}
// Branch mode
var allBranches = await GetAllBranches().ConfigureAwait(false);
var allBranches = await GithubApi.GetAllBranches(Author, Name).ConfigureAwait(false);
packageVersionOptions.AvailableBranches = allBranches.Select(
b => new PackageVersion { TagName = $"{b.Name}", ReleaseNotesMarkdown = string.Empty }
);
@ -423,6 +411,7 @@ public abstract class BaseGitPackage : BasePackage
installedPackage.FullPath!,
onConsoleOutput,
"pull",
"--autostash",
"origin",
installedPackage.Version.InstalledBranch
)

2
StabilityMatrix.Core/Models/Packages/BasePackage.cs

@ -166,8 +166,6 @@ public abstract class BasePackage
int page = 1,
int perPage = 10
);
public abstract Task<IEnumerable<Branch>> GetAllBranches();
public abstract Task<IEnumerable<Release>> GetAllReleases();
public abstract Task<DownloadPackageVersionOptions> GetLatestVersion(
bool includePrerelease = false
);

32
StabilityMatrix.Core/Models/Packages/InvokeAI.cs

@ -192,10 +192,38 @@ public class InvokeAI : BaseGitPackage
{
// If has Nvidia Gpu, install CUDA version
case TorchVersion.Cuda:
await InstallCudaTorch(venvRunner, progress, onConsoleOutput).ConfigureAwait(false);
progress?.Report(
new ProgressReport(-1f, "Installing PyTorch for CUDA", isIndeterminate: true)
);
var args = new List<Argument>();
if (exists)
{
var pipPackages = await venvRunner.PipList().ConfigureAwait(false);
var hasCuda121 = pipPackages.Any(
p => p.Name == "torch" && p.Version.Contains("cu121")
);
if (!hasCuda121)
{
args.Add("--upgrade");
args.Add("--force-reinstall");
}
}
await venvRunner
.PipInstall(
new PipInstallArgs(args.Any() ? args.ToArray() : Array.Empty<Argument>())
.WithTorch("==2.1.0")
.WithTorchVision("==0.16.0")
.WithXFormers("==0.0.22post7")
.WithTorchExtraIndex("cu121"),
onConsoleOutput
)
.ConfigureAwait(false);
Logger.Info("Starting InvokeAI install (CUDA)...");
pipCommandArgs =
"-e .[xformers] --use-pep517 --extra-index-url https://download.pytorch.org/whl/cu118";
"-e .[xformers] --use-pep517 --extra-index-url https://download.pytorch.org/whl/cu121";
break;
// For AMD, Install ROCm version
case TorchVersion.Rocm:

8
StabilityMatrix.Core/Models/Packages/UnknownPackage.cs

@ -163,12 +163,4 @@ public class UnknownPackage : BasePackage
int page = 1,
int perPage = 10
) => Task.FromResult<IEnumerable<GitCommit>?>(null);
/// <inheritdoc />
public override Task<IEnumerable<Branch>> GetAllBranches() =>
Task.FromResult(Enumerable.Empty<Branch>());
/// <inheritdoc />
public override Task<IEnumerable<Release>> GetAllReleases() =>
Task.FromResult(Enumerable.Empty<Release>());
}

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