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. 6
      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. 56
      StabilityMatrix.Core/Converters/Json/DefaultUnknownEnumConverter.cs
  11. 89
      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. 65
      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. 181
      StabilityMatrix.Core/Updater/UpdateHelper.cs
  26. 60
      StabilityMatrix.Tests/Avalonia/UpdateViewModelTests.cs
  27. 20
      StabilityMatrix.UITests/TestAppBuilder.cs
  28. 33
      StabilityMatrix/ViewModels/UpdateWindowViewModel.cs

100
.github/workflows/release.yml

@ -26,6 +26,23 @@ on:
type: boolean type: boolean
description: Mark GitHub Release as Prerelease? description: Mark GitHub Release as Prerelease?
default: false 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: jobs:
release-linux: release-linux:
@ -180,8 +197,8 @@ jobs:
# Zip each build # Zip each build
- name: Zip Artifacts - name: Zip Artifacts
run: | run: |
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64 zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/*
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64 zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/*
- name: Create Github Release - name: Create Github Release
id: create_release id: create_release
@ -199,3 +216,82 @@ jobs:
body: ${{ steps.release_notes.outputs.release_notes }} body: ${{ steps.release_notes.outputs.release_notes }}
draft: ${{ github.event.inputs.github-release-draft == 'true' }} draft: ${{ github.event.inputs.github-release-draft == 'true' }}
prerelease: ${{ github.event.inputs.github-release-prerelease == '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/), 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). 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 ## v2.6.0
### Added ### Added
- Added **Output Sharing** option for all packages in the three-dots menu on the Packages page - 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/ [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 ### 🖱 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] - 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. 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 ## 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). 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 SelectedPackage.Name
); );
var downloadVersion = new DownloadPackageVersionOptions var downloadVersion = await SelectedPackage.GetLatestVersion();
{
IsLatest = true,
IsPrerelease = false
};
var installedVersion = new InstalledPackageVersion { IsPrerelease = false }; var installedVersion = new InstalledPackageVersion { IsPrerelease = false };
var versionOptions = await SelectedPackage.GetAllVersionOptions(); if (SelectedPackage.ShouldIgnoreReleases)
if (versionOptions.AvailableVersions != null && versionOptions.AvailableVersions.Any())
{ {
downloadVersion.VersionTag = versionOptions.AvailableVersions.First().TagName; installedVersion.InstalledBranch = downloadVersion.BranchName;
installedVersion.InstalledReleaseVersion = downloadVersion.VersionTag; installedVersion.InstalledCommitSha = downloadVersion.CommitHash;
} }
else else
{ {
var latestVersion = await SelectedPackage.GetLatestVersion(); installedVersion.InstalledReleaseVersion = downloadVersion.VersionTag;
downloadVersion.BranchName = latestVersion.BranchName;
downloadVersion.CommitHash =
(await SelectedPackage.GetAllCommits(downloadVersion.BranchName))
?.FirstOrDefault()
?.Sha ?? string.Empty;
installedVersion.InstalledBranch = downloadVersion.BranchName;
installedVersion.InstalledCommitSha = downloadVersion.CommitHash;
} }
var torchVersion = SelectedPackage.GetRecommendedTorchVersion(); var torchVersion = SelectedPackage.GetRecommendedTorchVersion();

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

@ -47,6 +47,9 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
[ObservableProperty] [ObservableProperty]
private int progressValue; private int progressValue;
[ObservableProperty]
private bool isProgressIndeterminate;
[ObservableProperty] [ObservableProperty]
private bool showProgressBar; private bool showProgressBar;
@ -57,7 +60,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
private string? newVersionText; private string? newVersionText;
[GeneratedRegex( [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(); private static partial Regex RegexChangelog();
@ -82,7 +85,14 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
/// <summary> /// <summary>
/// Formats changelog markdown including up to the current version /// Formats changelog markdown including up to the current version
/// </summary> /// </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(); var pattern = RegexChangelog();
@ -93,28 +103,59 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
new new
{ {
Block = m.Groups[1].Value.Trim(), 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() Content = m.Groups[3].Value.Trim()
} }
) )
.Where(x => x.Version is not null)
.ToList(); .ToList();
// Join all blocks until and excluding the current version // Join all blocks until and excluding the current version
// If we're on a pre-release, include the current release // If we're on a pre-release, include the current release
var currentVersionBlock = results.FindIndex( 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)
{
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) if (currentVersionBlock == -1)
{ {
return null; currentVersionBlock = results.Count;
} }
// Filter out pre-releases
var blocks = results var blocks = results
.Take(currentVersionBlock + (currentVersion.IsPrerelease ? 1 : 0)) .Take(currentVersionBlock)
.Select(x => x.Block) .Where(
.ToList(); 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); return string.Join(Environment.NewLine + Environment.NewLine, blocks);
} }
@ -124,7 +165,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)
@ -185,11 +226,13 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
Resources.TextTemplate_UpdatingPackage, Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix Resources.Label_StabilityMatrix
); );
await updateHelper.DownloadUpdate( await updateHelper.DownloadUpdate(
UpdateInfo, UpdateInfo,
new Progress<ProgressReport>(report => new Progress<ProgressReport>(report =>
{ {
ProgressValue = Convert.ToInt32(report.Percentage); ProgressValue = Convert.ToInt32(report.Percentage);
IsProgressIndeterminate = report.IsIndeterminate;
}) })
); );

6
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

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

2
StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs

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

2
StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs

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

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

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

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

@ -4,9 +4,14 @@ using StabilityMatrix.Core.Extensions;
namespace StabilityMatrix.Core.Converters.Json; 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)
{ {
@ -16,13 +21,13 @@ public class DefaultUnknownEnumConverter<T> : JsonConverter<T> where T : Enum
var enumText = reader.GetString()?.Replace(" ", "_"); var enumText = reader.GetString()?.Replace(" ", "_");
if (Enum.TryParse(typeof(T), enumText, true, out var result)) if (Enum.TryParse(typeof(T), enumText, true, out var result))
{ {
return (T) result!; return (T)result!;
} }
// Unknown value handling // 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)}'."); 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("_", " ")); 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("_", " "));
}
} }

89
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.Attributes;
using StabilityMatrix.Core.Database; using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.Database;
@ -10,52 +11,37 @@ public class GithubApiCache : IGithubApiCache
{ {
private readonly ILiteDbContext dbContext; private readonly ILiteDbContext dbContext;
private readonly IGitHubClient githubApi; private readonly IGitHubClient githubApi;
private readonly ILogger<IGithubApiCache> logger;
private readonly TimeSpan cacheDuration = TimeSpan.FromMinutes(15); 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.dbContext = dbContext;
this.githubApi = githubApi; this.githubApi = githubApi;
} this.logger = logger;
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();
} }
public async Task<IEnumerable<Release>> GetAllReleases(string username, string repository) public async Task<IEnumerable<Release>> GetAllReleases(string username, string repository)
{ {
var cacheKey = $"Releases-{username}-{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)) if (cacheEntry != null && !IsCacheExpired(cacheEntry.LastUpdated))
{ {
return cacheEntry.AllReleases.OrderByDescending(x => x.CreatedAt); return cacheEntry.AllReleases.OrderByDescending(x => x.CreatedAt);
} }
var allReleases = await githubApi.Repository.Release.GetAll(username, repository); try
{
var allReleases = await githubApi.Repository.Release
.GetAll(username, repository)
.ConfigureAwait(false);
if (allReleases == null) if (allReleases == null)
{ {
return new List<Release>().OrderByDescending(x => x.CreatedAt); return new List<Release>();
} }
var newCacheEntry = new GithubCacheEntry var newCacheEntry = new GithubCacheEntry
@ -63,31 +49,48 @@ public class GithubApiCache : IGithubApiCache
CacheKey = cacheKey, CacheKey = cacheKey,
AllReleases = allReleases.OrderByDescending(x => x.CreatedAt) AllReleases = allReleases.OrderByDescending(x => x.CreatedAt)
}; };
await dbContext.UpsertGithubCacheEntry(newCacheEntry); 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) public async Task<IEnumerable<Branch>> GetAllBranches(string username, string repository)
{ {
var cacheKey = $"Branches-{username}-{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)) if (cacheEntry != null && !IsCacheExpired(cacheEntry.LastUpdated))
{ {
return cacheEntry.Branches; return cacheEntry.Branches;
} }
var branches = await githubApi.Repository.Branch.GetAll(username, repository); try
{
var branches = await githubApi.Repository.Branch
.GetAll(username, repository)
.ConfigureAwait(false);
if (branches == null) if (branches == null)
{ {
return new List<Branch>(); return new List<Branch>();
} }
var newCacheEntry = new GithubCacheEntry { CacheKey = cacheKey, Branches = branches }; var newCacheEntry = new GithubCacheEntry { CacheKey = cacheKey, Branches = branches };
await dbContext.UpsertGithubCacheEntry(newCacheEntry); 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( public async Task<IEnumerable<GitCommit>?> GetAllCommits(
string username, string username,
@ -98,13 +101,16 @@ public class GithubApiCache : IGithubApiCache
) )
{ {
var cacheKey = $"Commits-{username}-{repository}-{branch}-{page}-{perPage}"; 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)) if (cacheEntry != null && !IsCacheExpired(cacheEntry.LastUpdated))
{ {
return cacheEntry.Commits; return cacheEntry.Commits;
} }
var commits = await githubApi.Repository.Commit.GetAll( try
{
var commits = await githubApi.Repository.Commit
.GetAll(
username, username,
repository, repository,
new CommitRequest { Sha = branch }, new CommitRequest { Sha = branch },
@ -114,7 +120,8 @@ public class GithubApiCache : IGithubApiCache
PageSize = perPage, PageSize = perPage,
StartPage = page StartPage = page
} }
); )
.ConfigureAwait(false);
if (commits == null) if (commits == null)
{ {
@ -126,10 +133,16 @@ public class GithubApiCache : IGithubApiCache
CacheKey = cacheKey, CacheKey = cacheKey,
Commits = commits.Select(x => new GitCommit { Sha = x.Sha }) Commits = commits.Select(x => new GitCommit { Sha = x.Sha })
}; };
await dbContext.UpsertGithubCacheEntry(newCacheEntry); await dbContext.UpsertGithubCacheEntry(newCacheEntry).ConfigureAwait(false);
return newCacheEntry.Commits; return newCacheEntry.Commits;
} }
catch (ApiException ex)
{
logger.LogWarning(ex, "Failed to get commits from Github API.");
return cacheEntry?.Commits ?? Enumerable.Empty<GitCommit>();
}
}
private bool IsCacheExpired(DateTimeOffset expiration) => private bool IsCacheExpired(DateTimeOffset expiration) =>
expiration.Add(cacheDuration) < DateTimeOffset.UtcNow; expiration.Add(cacheDuration) < DateTimeOffset.UtcNow;

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

@ -6,12 +6,15 @@ namespace StabilityMatrix.Core.Helper.Cache;
public interface IGithubApiCache public interface IGithubApiCache
{ {
Task<Release?> GetLatestRelease(string username, string repository);
Task<IEnumerable<Release>> GetAllReleases(string username, string repository); Task<IEnumerable<Release>> GetAllReleases(string username, string repository);
Task<IEnumerable<Branch>> GetAllBranches(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, Task<IEnumerable<GitCommit>?> GetAllCommits(
[AliasAs("per_page")] int perPage = 10); 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> /// <summary>
/// Deletes the directory. /// Deletes the directory.
/// </summary> /// </summary>
public void Delete() => Directory.Delete(FullPath); public void Delete() => Info.Delete();
/// <summary> Deletes the directory asynchronously. </summary> /// <summary> Deletes the directory asynchronously. </summary>
public Task DeleteAsync() => Task.Run(Delete); 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);
}
}

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

@ -7,7 +7,7 @@ namespace StabilityMatrix.Core.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[JsonConverter(typeof(StringJsonConverter<FilePath>))] [JsonConverter(typeof(StringJsonConverter<FilePath>))]
public class FilePath : FileSystemPath, IPathObject public partial class FilePath : FileSystemPath, IPathObject
{ {
private FileInfo? _info; private FileInfo? _info;
@ -33,6 +33,7 @@ public class FilePath : FileSystemPath, IPathObject
[JsonIgnore] [JsonIgnore]
public string NameWithoutExtension => Path.GetFileNameWithoutExtension(Info.Name); public string NameWithoutExtension => Path.GetFileNameWithoutExtension(Info.Name);
/// <inheritdoc cref="FileInfo.Extension"/>
[JsonIgnore] [JsonIgnore]
public string Extension => Info.Extension; public string Extension => Info.Extension;
@ -138,6 +139,26 @@ public class FilePath : FileSystemPath, IPathObject
return File.WriteAllBytesAsync(FullPath, bytes, ct); 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> /// <summary>
/// Move the file to a directory. /// Move the file to a directory.
/// </summary> /// </summary>
@ -153,7 +174,7 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary> /// </summary>
public async Task<FilePath> MoveToDirectoryAsync(DirectoryPath directory) 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 the new path
return directory.JoinFile(this); return directory.JoinFile(this);
} }
@ -163,23 +184,39 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary> /// </summary>
public async Task<FilePath> MoveToAsync(FilePath destinationFile) public async Task<FilePath> MoveToAsync(FilePath destinationFile)
{ {
await Task.Run(() => 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++)
{ {
var path = destinationFile.FullPath; if (!targetFile.Exists)
if (destinationFile.Exists)
{ {
var num = Random.Shared.NextInt64(0, 10000); return await MoveToAsync(targetFile).ConfigureAwait(false);
path = path.Replace( }
destinationFile.NameWithoutExtension,
$"{destinationFile.NameWithoutExtension}_{num}" targetFile = destinationFile.WithName(
destinationFile.NameWithoutExtension + $" ({i})" + destinationFile.Extension
); );
} }
Info.MoveTo(path); throw new IOException(
}) $"Could not move file to {destinationFile} because it already exists."
.ConfigureAwait(false); );
// Return the new path
return destinationFile;
} }
/// <summary> /// <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 return new DownloadPackageVersionOptions
{ {
IsLatest = true, IsLatest = true,
IsPrerelease = release.Prerelease, IsPrerelease = latestRelease.Prerelease,
VersionTag = release.TagName! 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( public override Task<IEnumerable<GitCommit>?> GetAllCommits(
string branch, string branch,
int page = 1, int page = 1,
@ -126,7 +114,7 @@ public abstract class BaseGitPackage : BasePackage
if (!ShouldIgnoreReleases) if (!ShouldIgnoreReleases)
{ {
var allReleases = await GetAllReleases().ConfigureAwait(false); var allReleases = await GithubApi.GetAllReleases(Author, Name).ConfigureAwait(false);
var releasesList = allReleases.ToList(); var releasesList = allReleases.ToList();
if (releasesList.Any()) if (releasesList.Any())
{ {
@ -143,7 +131,7 @@ public abstract class BaseGitPackage : BasePackage
} }
// Branch mode // Branch mode
var allBranches = await GetAllBranches().ConfigureAwait(false); var allBranches = await GithubApi.GetAllBranches(Author, Name).ConfigureAwait(false);
packageVersionOptions.AvailableBranches = allBranches.Select( packageVersionOptions.AvailableBranches = allBranches.Select(
b => new PackageVersion { TagName = $"{b.Name}", ReleaseNotesMarkdown = string.Empty } b => new PackageVersion { TagName = $"{b.Name}", ReleaseNotesMarkdown = string.Empty }
); );
@ -423,6 +411,7 @@ public abstract class BaseGitPackage : BasePackage
installedPackage.FullPath!, installedPackage.FullPath!,
onConsoleOutput, onConsoleOutput,
"pull", "pull",
"--autostash",
"origin", "origin",
installedPackage.Version.InstalledBranch installedPackage.Version.InstalledBranch
) )

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

@ -166,8 +166,6 @@ public abstract class BasePackage
int page = 1, int page = 1,
int perPage = 10 int perPage = 10
); );
public abstract Task<IEnumerable<Branch>> GetAllBranches();
public abstract Task<IEnumerable<Release>> GetAllReleases();
public abstract Task<DownloadPackageVersionOptions> GetLatestVersion( public abstract Task<DownloadPackageVersionOptions> GetLatestVersion(
bool includePrerelease = false 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 // If has Nvidia Gpu, install CUDA version
case TorchVersion.Cuda: 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)..."); Logger.Info("Starting InvokeAI install (CUDA)...");
pipCommandArgs = 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; break;
// For AMD, Install ROCm version // For AMD, Install ROCm version
case TorchVersion.Rocm: case TorchVersion.Rocm:

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

@ -163,12 +163,4 @@ public class UnknownPackage : BasePackage
int page = 1, int page = 1,
int perPage = 10 int perPage = 10
) => Task.FromResult<IEnumerable<GitCommit>?>(null); ) => 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 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;
}
}

181
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,45 +58,64 @@ 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;
var downloadFile = UpdateFolder.JoinFile(Path.GetFileName(updateInfo.Url.ToString()));
var extractDir = UpdateFolder.JoinDir("extract");
try
{
// download the file from URL // download the file from URL
await downloadService await downloadService
.DownloadToFileAsync( .DownloadToFileAsync(
downloadUrl, updateInfo.Url.ToString(),
ExecutablePath, downloadFile,
progress: progress, progress: progress,
httpClientName: "UpdateClient" httpClientName: "UpdateClient"
) )
.ConfigureAwait(false); .ConfigureAwait(false);
}
/// <summary> // Unzip if needed
/// Format a DatetimeOffset to a culture invariant string for use in signature verification. if (downloadFile.Extension == ".zip")
/// </summary>
private static string FormatDateTimeOffsetInvariant(DateTimeOffset dateTimeOffset)
{ {
return dateTimeOffset.ToString( if (extractDir.Exists)
@"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz", {
CultureInfo.InvariantCulture 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);
} }
/// <summary> progress.Report(new ProgressReport(1d));
/// Data for use in signature verification. }
/// Semicolon separated string of fields: finally
/// "version, releaseDate, channel, type, url, changelog, hashBlake3" {
/// </summary> // Clean up original download
private static string GetUpdateInfoSignedData(UpdateInfo updateInfo) await downloadFile.DeleteAsync().ConfigureAwait(false);
// Clean up extract dir
if (extractDir.Exists)
{ {
var channel = updateInfo.Channel.GetStringValue().ToLowerInvariant(); await extractDir.DeleteAsync(true).ConfigureAwait(false);
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()
@ -114,85 +134,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>()
.Where(c => c > UpdateChannel.Unknown)
)
{ {
_ when Compat.IsWindows && Compat.IsX64 => updateCollection.WindowsX64, if (
_ when Compat.IsLinux && Compat.IsX64 => updateCollection.LinuxX64, updateManifest.Updates.TryGetValue(channel, out var platforms)
_ => null && platforms.GetInfoForCurrentPlatform() is { } update
}; && ValidateUpdate(update)
)
if (updateInfo is null)
{ {
logger.LogWarning( NotifyUpdateAvailable(update);
"Could not find compatible update info for the platform {Platform}",
Compat.Platform
);
return; 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();
if (!checker.Verify(signedData, update.Signature))
{ {
logger.LogError("UpdateInfo signature is invalid: {Info}", updateInfo); logger.LogError(
return; "UpdateInfo signature {Signature} is invalid, Data = {Data}, UpdateInfo = {Info}",
update.Signature,
signedData,
update
);
return false;
} }
logger.LogInformation("UpdateInfo signature verified");
var order = updateInfo.Version.ComparePrecedenceTo(Compat.AppVersion); switch (update.Version.ComparePrecedenceTo(Compat.AppVersion))
if (order > 0)
{ {
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 break;
);
EventManager.Instance.OnUpdateAvailable(updateInfo);
return;
} }
} }
logger.LogInformation("No update available"); return false;
} }
catch (Exception e)
private void NotifyUpdateAvailable(UpdateInfo update)
{ {
logger.LogError(e, "Couldn't check for 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 Semver;
using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Core.Models.Update;
namespace StabilityMatrix.Tests.Avalonia; namespace StabilityMatrix.Tests.Avalonia;
@ -65,4 +66,63 @@ public class UpdateViewModelTests
"""; """;
Assert.AreEqual(expectedPre, resultPre); 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);
}
} }

20
StabilityMatrix.UITests/TestAppBuilder.cs

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

33
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();
@ -61,10 +71,13 @@ public partial class UpdateWindowViewModel : ObservableObject
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,
new Progress<ProgressReport>(report =>
{ {
ProgressValue = Convert.ToInt32(report.Percentage); 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);

Loading…
Cancel
Save