Browse Source

Merge pull request #269 from LykosAI/main

Release v2.6.2
pull/284/head
JT 1 year ago committed by GitHub
parent
commit
35f836076b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 100
      .github/workflows/release.yml
  2. 9
      CHANGELOG.md
  3. 2
      StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs
  4. 19
      StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs
  5. 29
      StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs
  6. 32
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs
  7. 7
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  8. 63
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  9. 2
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  10. 1
      StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml
  11. 11
      StabilityMatrix.Avalonia/Views/MainWindow.axaml
  12. 56
      StabilityMatrix.Core/Converters/Json/DefaultUnknownEnumConverter.cs
  13. 15
      StabilityMatrix.Core/Exceptions/ProcessException.cs
  14. 102
      StabilityMatrix.Core/Helper/ArchiveHelper.cs
  15. 12
      StabilityMatrix.Core/Helper/IPrerequisiteHelper.cs
  16. 27
      StabilityMatrix.Core/Helper/PrerequisiteHelper.cs
  17. 2
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  18. 20
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.Fluent.cs
  19. 71
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  20. 80
      StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
  21. 83
      StabilityMatrix.Core/Models/Packages/KohyaSs.cs
  22. 30
      StabilityMatrix.Core/Models/Packages/VladAutomatic.cs
  23. 2
      StabilityMatrix.Core/Models/Packages/VoltaML.cs
  24. 9
      StabilityMatrix.Core/Models/Settings/Settings.cs
  25. 11
      StabilityMatrix.Core/Models/Update/UpdateCollection.cs
  26. 83
      StabilityMatrix.Core/Models/Update/UpdateInfo.cs
  27. 17
      StabilityMatrix.Core/Models/Update/UpdateManifest.cs
  28. 28
      StabilityMatrix.Core/Models/Update/UpdatePlatforms.cs
  29. 18
      StabilityMatrix.Core/Processes/ProcessResult.cs
  30. 12
      StabilityMatrix.Core/Processes/ProcessRunner.cs
  31. 47
      StabilityMatrix.Core/Python/PyRunner.cs
  32. 219
      StabilityMatrix.Core/Updater/UpdateHelper.cs
  33. 60
      StabilityMatrix.Tests/Avalonia/UpdateViewModelTests.cs
  34. 22
      StabilityMatrix.UITests/TestAppBuilder.cs
  35. 37
      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 }}

9
CHANGELOG.md

@ -5,6 +5,15 @@ 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
### Fixed
- Better error reporting including outputs for git subprocess errors during package install / update
- Fixed `'accelerate' is not recognized as an internal or external command` error when starting training in kohya_ss
- Fixed some instances of `ModuleNotFoundError: No module named 'bitsandbytes.cuda_setup.paths'` error when using 8-bit optimizers in kohya_ss
- Fixed errors preventing Inference outputs from loading in the img2img tabs of other packages
## v2.6.1 ## v2.6.1
### Changed ### Changed
- NVIDIA GPU users will be updated to use CUDA 12.1 for the InvokeAI package for a slight performance improvement - NVIDIA GPU users will be updated to use CUDA 12.1 for the InvokeAI package for a slight performance improvement

2
StabilityMatrix.Avalonia/Helpers/PngDataHelper.cs

@ -104,7 +104,7 @@ public static class PngDataHelper
var dataBytes = Encoding.UTF8.GetBytes(textData); var dataBytes = Encoding.UTF8.GetBytes(textData);
var textDataLength = BitConverter.GetBytes(dataBytes.Length).Reverse(); var textDataLength = BitConverter.GetBytes(dataBytes.Length).Reverse();
var textDataBytes = Text.Concat(dataBytes).ToArray(); var textDataBytes = Text.Concat(dataBytes).ToArray();
var crc = BitConverter.GetBytes(Crc32Algorithm.Compute(textDataBytes)); var crc = BitConverter.GetBytes(Crc32Algorithm.Compute(textDataBytes)).Reverse();
return textDataLength.Concat(textDataBytes).Concat(crc).ToArray(); return textDataLength.Concat(textDataBytes).Concat(crc).ToArray();
} }

19
StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs

@ -126,16 +126,23 @@ public class UnixPrerequisiteHelper : IPrerequisiteHelper
} }
} }
public async Task RunGit( /// <inheritdoc />
string? workingDirectory = null, public Task RunGit(
ProcessArgs args,
Action<ProcessOutput>? onProcessOutput = null, Action<ProcessOutput>? onProcessOutput = null,
params string[] args string? workingDirectory = null
) )
{ {
var command = // Async progress not supported on Unix
args.Length == 0 ? "git" : "git " + string.Join(" ", args.Select(ProcessRunner.Quote)); return RunGit(args, workingDirectory);
}
/// <inheritdoc />
public async Task RunGit(ProcessArgs args, string? workingDirectory = null)
{
var command = args.Prepend("git");
var result = await ProcessRunner.RunBashCommand(command, workingDirectory ?? ""); var result = await ProcessRunner.RunBashCommand(command.ToArray(), workingDirectory ?? "");
if (result.ExitCode != 0) if (result.ExitCode != 0)
{ {
Logger.Error( Logger.Error(

29
StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Win32; using Microsoft.Win32;
using NLog; using NLog;
using Octokit; using Octokit;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Processes;
@ -64,23 +65,35 @@ public class WindowsPrerequisiteHelper : IPrerequisiteHelper
} }
public async Task RunGit( public async Task RunGit(
string? workingDirectory = null, ProcessArgs args,
Action<ProcessOutput>? onProcessOutput = null, Action<ProcessOutput>? onProcessOutput,
params string[] args string? workingDirectory = null
) )
{ {
var process = ProcessRunner.StartAnsiProcess( var process = ProcessRunner.StartAnsiProcess(
GitExePath, GitExePath,
args, args.ToArray(),
workingDirectory: workingDirectory, workingDirectory,
onProcessOutput,
environmentVariables: new Dictionary<string, string> environmentVariables: new Dictionary<string, string>
{ {
{ "PATH", Compat.GetEnvPathWithExtensions(GitBinPath) } { "PATH", Compat.GetEnvPathWithExtensions(GitBinPath) }
}, }
outputDataReceived: onProcessOutput
); );
await process.WaitForExitAsync().ConfigureAwait(false);
if (process.ExitCode != 0)
{
throw new ProcessException($"Git exited with code {process.ExitCode}");
}
}
public async Task RunGit(ProcessArgs args, string? workingDirectory = null)
{
var result = await ProcessRunner
.GetProcessResultAsync(GitExePath, args, workingDirectory)
.ConfigureAwait(false);
await ProcessRunner.WaitForExitConditionAsync(process); result.EnsureSuccessExitCode();
} }
public async Task<string> GetGitOutput(string? workingDirectory = null, params string[] args) public async Task<string> GetGitOutput(string? workingDirectory = null, params string[] args)

32
StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs

@ -14,8 +14,9 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using NLog; using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
@ -24,6 +25,7 @@ using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Processes;
@ -36,14 +38,13 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[Transient] [Transient]
public partial class InstallerViewModel : ContentDialogViewModelBase public partial class InstallerViewModel : ContentDialogViewModelBase
{ {
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ISettingsManager settingsManager; private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory; private readonly IPackageFactory packageFactory;
private readonly IPyRunner pyRunner; private readonly IPyRunner pyRunner;
private readonly IDownloadService downloadService; private readonly IDownloadService downloadService;
private readonly INotificationService notificationService; private readonly INotificationService notificationService;
private readonly IPrerequisiteHelper prerequisiteHelper; private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly ILogger<InstallerViewModel> logger;
[ObservableProperty] [ObservableProperty]
private BasePackage selectedPackage; private BasePackage selectedPackage;
@ -130,7 +131,8 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
IPyRunner pyRunner, IPyRunner pyRunner,
IDownloadService downloadService, IDownloadService downloadService,
INotificationService notificationService, INotificationService notificationService,
IPrerequisiteHelper prerequisiteHelper IPrerequisiteHelper prerequisiteHelper,
ILogger<InstallerViewModel> logger
) )
{ {
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
@ -139,6 +141,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
this.downloadService = downloadService; this.downloadService = downloadService;
this.notificationService = notificationService; this.notificationService = notificationService;
this.prerequisiteHelper = prerequisiteHelper; this.prerequisiteHelper = prerequisiteHelper;
this.logger = logger;
var filtered = packageFactory.GetAllAvailablePackages().Where(p => p.IsCompatible).ToList(); var filtered = packageFactory.GetAllAvailablePackages().Where(p => p.IsCompatible).ToList();
@ -187,7 +190,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
} }
catch (Exception e) catch (Exception e)
{ {
Logger.Warn("Error getting versions: {Exception}", e.ToString()); logger.LogWarning("Error getting versions: {Exception}", e.ToString());
} }
finally finally
{ {
@ -209,7 +212,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
else else
{ {
var ex = result.Exception!; var ex = result.Exception!;
Logger.Error(ex, $"Error installing package: {ex}"); logger.LogError(ex, $"Error installing package: {ex}");
var dialog = new BetterContentDialog var dialog = new BetterContentDialog
{ {
@ -221,7 +224,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
} }
} }
private Task ActuallyInstall() private async Task ActuallyInstall()
{ {
if (string.IsNullOrWhiteSpace(InstallName)) if (string.IsNullOrWhiteSpace(InstallName))
{ {
@ -232,12 +235,18 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
NotificationType.Error NotificationType.Error
) )
); );
return Task.CompletedTask; return;
} }
var setPackageInstallingStep = new SetPackageInstallingStep(settingsManager, InstallName); var setPackageInstallingStep = new SetPackageInstallingStep(settingsManager, InstallName);
var installLocation = Path.Combine(settingsManager.LibraryDir, "Packages", InstallName); var installLocation = Path.Combine(settingsManager.LibraryDir, "Packages", InstallName);
if (Directory.Exists(installLocation))
{
var installPath = new DirectoryPath(installLocation);
await installPath.DeleteVerboseAsync(logger);
}
var prereqStep = new SetupPrerequisitesStep(prerequisiteHelper, pyRunner); var prereqStep = new SetupPrerequisitesStep(prerequisiteHelper, pyRunner);
var downloadOptions = new DownloadPackageVersionOptions(); var downloadOptions = new DownloadPackageVersionOptions();
@ -313,7 +322,6 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
}; };
Steps = steps; Steps = steps;
return Task.CompletedTask;
} }
public void Cancel() public void Cancel()
@ -401,7 +409,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
Dispatcher.UIThread Dispatcher.UIThread
.InvokeAsync(async () => .InvokeAsync(async () =>
{ {
Logger.Debug($"Release mode: {IsReleaseMode}"); logger.LogDebug($"Release mode: {IsReleaseMode}");
var versionOptions = await SelectedPackage.GetAllVersionOptions(); var versionOptions = await SelectedPackage.GetAllVersionOptions();
AvailableVersions = IsReleaseMode AvailableVersions = IsReleaseMode
@ -413,7 +421,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
return; return;
ReleaseNotes = SelectedVersion.ReleaseNotesMarkdown; ReleaseNotes = SelectedVersion.ReleaseNotesMarkdown;
Logger.Debug($"Loaded release notes for {ReleaseNotes}"); logger.LogDebug($"Loaded release notes for {ReleaseNotes}");
if (!IsReleaseMode) if (!IsReleaseMode)
{ {
@ -492,7 +500,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
} }
catch (Exception e) catch (Exception e)
{ {
Logger.Warn($"Error getting commits: {e.Message}"); logger.LogWarning(e, $"Error getting commits: {e.Message}");
} }
}) })
.SafeFireAndForget(); .SafeFireAndForget();

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

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
@ -14,6 +15,7 @@ using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Python;
@ -139,6 +141,11 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase
"Packages", "Packages",
SelectedPackage.Name SelectedPackage.Name
); );
if (Directory.Exists(installLocation))
{
var installPath = new DirectoryPath(installLocation);
await installPath.DeleteVerboseAsync(logger);
}
var downloadVersion = await SelectedPackage.GetLatestVersion(); var downloadVersion = await SelectedPackage.GetLatestVersion();
var installedVersion = new InstalledPackageVersion { IsPrerelease = false }; var installedVersion = new InstalledPackageVersion { IsPrerelease = false };

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

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

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"/>

11
StabilityMatrix.Avalonia/Views/MainWindow.axaml

@ -21,6 +21,15 @@
DockProperties.IsDropEnabled="True" DockProperties.IsDropEnabled="True"
x:Class="StabilityMatrix.Avalonia.Views.MainWindow"> x:Class="StabilityMatrix.Avalonia.Views.MainWindow">
<controls:AppWindowBase.Resources>
<SolidColorBrush x:Key="BrushB0" Color="#FFFFFFFF" />
<DrawingImage x:Key="BrandsPatreonSymbolWhite">
<DrawingGroup>
<GeometryDrawing Brush="{DynamicResource BrushB0}" Geometry="F1 M1033.05 324.45C1032.86 186.55 925.46 73.53 799.45 32.75C642.97 -17.89 436.59 -10.55 287.17 59.95C106.07 145.41 49.18 332.61 47.06 519.31C45.32 672.81 60.64 1077.1 288.68 1079.98C458.12 1082.13 483.35 863.8 561.75 758.65C617.53 683.84 689.35 662.71 777.76 640.83C929.71 603.22 1033.27 483.3 1033.05 324.45Z" />
</DrawingGroup>
</DrawingImage>
</controls:AppWindowBase.Resources>
<Grid RowDefinitions="Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
<Grid Name="TitleBarHost" <Grid Name="TitleBarHost"
ColumnDefinitions="Auto,Auto,*,Auto" ColumnDefinitions="Auto,Auto,*,Auto"
@ -101,7 +110,7 @@
Content="{x:Static lang:Resources.Label_BecomeAPatron}" Content="{x:Static lang:Resources.Label_BecomeAPatron}"
Tapped="PatreonPatreonItem_OnTapped"> Tapped="PatreonPatreonItem_OnTapped">
<ui:NavigationViewItem.IconSource> <ui:NavigationViewItem.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-patreon"/> <ui:ImageIconSource Source="{StaticResource BrandsPatreonSymbolWhite}" />
</ui:NavigationViewItem.IconSource> </ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem> </ui:NavigationViewItem>

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

15
StabilityMatrix.Core/Exceptions/ProcessException.cs

@ -1,11 +1,22 @@
namespace StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Processes;
namespace StabilityMatrix.Core.Exceptions;
/// <summary> /// <summary>
/// Exception that is thrown when a process fails. /// Exception that is thrown when a process fails.
/// </summary> /// </summary>
public class ProcessException : Exception public class ProcessException : Exception
{ {
public ProcessException(string message) : base(message) public ProcessResult? ProcessResult { get; }
public ProcessException(string message)
: base(message) { }
public ProcessException(ProcessResult processResult)
: base(
$"Process {processResult.ProcessName} exited with code {processResult.ExitCode}. {{StdOut = {processResult.StandardOutput}, StdErr = {processResult.StandardError}}}"
)
{ {
ProcessResult = processResult;
} }
} }

102
StabilityMatrix.Core/Helper/ArchiveHelper.cs

@ -15,7 +15,6 @@ namespace StabilityMatrix.Core.Helper;
public record struct ArchiveInfo(ulong Size, ulong CompressedSize); public record struct ArchiveInfo(ulong Size, ulong CompressedSize);
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public static partial class ArchiveHelper public static partial class ArchiveHelper
{ {
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
@ -59,7 +58,7 @@ public static partial class ArchiveHelper
public static async Task<ArchiveInfo> TestArchive(string archivePath) public static async Task<ArchiveInfo> TestArchive(string archivePath)
{ {
var process = ProcessRunner.StartAnsiProcess(SevenZipPath, new[] {"t", archivePath}); var process = ProcessRunner.StartAnsiProcess(SevenZipPath, new[] { "t", archivePath });
await process.WaitForExitAsync(); await process.WaitForExitAsync();
var output = await process.StandardOutput.ReadToEndAsync(); var output = await process.StandardOutput.ReadToEndAsync();
var matches = Regex7ZOutput().Matches(output); var matches = Regex7ZOutput().Matches(output);
@ -74,11 +73,15 @@ public static partial class ArchiveHelper
var sourceParent = Directory.GetParent(sourceDirectory)?.FullName ?? ""; var sourceParent = Directory.GetParent(sourceDirectory)?.FullName ?? "";
// We must pass in as `directory\` for archive path to be correct // We must pass in as `directory\` for archive path to be correct
var sourceDirName = new DirectoryInfo(sourceDirectory).Name; var sourceDirName = new DirectoryInfo(sourceDirectory).Name;
var process = ProcessRunner.StartAnsiProcess(SevenZipPath, new[]
{ var result = await ProcessRunner
"a", archivePath, sourceDirName + @"\", "-y" .GetProcessResultAsync(
}, workingDirectory: sourceParent); SevenZipPath,
await ProcessRunner.WaitForExitConditionAsync(process); new[] { "a", archivePath, sourceDirName + @"\", "-y" },
workingDirectory: sourceParent
)
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
} }
public static async Task<ArchiveInfo> Extract7Z(string archivePath, string extractDirectory) public static async Task<ArchiveInfo> Extract7Z(string archivePath, string extractDirectory)
@ -113,12 +116,17 @@ public static partial class ArchiveHelper
} }
} }
public static async Task<ArchiveInfo> Extract7Z(string archivePath, string extractDirectory, IProgress<ProgressReport> progress) public static async Task<ArchiveInfo> Extract7Z(
string archivePath,
string extractDirectory,
IProgress<ProgressReport> progress
)
{ {
var outputStore = new StringBuilder(); var outputStore = new StringBuilder();
var onOutput = new Action<string?>(s => var onOutput = new Action<string?>(s =>
{ {
if (s == null) return; if (s == null)
return;
// Parse progress // Parse progress
Logger.Trace($"7z: {s}"); Logger.Trace($"7z: {s}");
@ -128,7 +136,14 @@ public static partial class ArchiveHelper
{ {
var percent = int.Parse(match.Groups[1].Value); var percent = int.Parse(match.Groups[1].Value);
var currentFile = match.Groups[2].Value; var currentFile = match.Groups[2].Value;
progress.Report(new ProgressReport(percent / (float) 100, "Extracting", currentFile, type: ProgressType.Extract)); progress.Report(
new ProgressReport(
percent / (float)100,
"Extracting",
currentFile,
type: ProgressType.Extract
)
);
} }
}); });
progress.Report(new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract)); progress.Report(new ProgressReport(-1, isIndeterminate: true, type: ProgressType.Extract));
@ -216,7 +231,11 @@ public static partial class ArchiveHelper
/// <param name="progress"></param> /// <param name="progress"></param>
/// <param name="archivePath"></param> /// <param name="archivePath"></param>
/// <param name="outputDirectory">Output directory, created if does not exist.</param> /// <param name="outputDirectory">Output directory, created if does not exist.</param>
public static async Task Extract(string archivePath, string outputDirectory, IProgress<ProgressReport>? progress = default) public static async Task Extract(
string archivePath,
string outputDirectory,
IProgress<ProgressReport>? progress = default
)
{ {
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
progress?.Report(new ProgressReport(-1, isIndeterminate: true)); progress?.Report(new ProgressReport(-1, isIndeterminate: true));
@ -229,11 +248,12 @@ public static partial class ArchiveHelper
// If not available, use the size of the archive file // If not available, use the size of the archive file
if (total == 0) if (total == 0)
{ {
total = (ulong) new FileInfo(archivePath).Length; total = (ulong)new FileInfo(archivePath).Length;
} }
// Create an DispatchTimer that monitors the progress of the extraction // Create an DispatchTimer that monitors the progress of the extraction
var progressMonitor = progress switch { var progressMonitor = progress switch
{
null => null, null => null,
_ => new Timer(TimeSpan.FromMilliseconds(36)) _ => new Timer(TimeSpan.FromMilliseconds(36))
}; };
@ -242,34 +262,38 @@ public static partial class ArchiveHelper
{ {
progressMonitor.Elapsed += (_, _) => progressMonitor.Elapsed += (_, _) =>
{ {
if (count == 0) return; if (count == 0)
return;
progress!.Report(new ProgressReport(count, total, message: "Extracting")); progress!.Report(new ProgressReport(count, total, message: "Extracting"));
}; };
} }
await Task.Factory.StartNew(() => await Task.Factory.StartNew(
{ () =>
var extractOptions = new ExtractionOptions
{ {
Overwrite = true, var extractOptions = new ExtractionOptions
ExtractFullPath = true, {
}; Overwrite = true,
using var stream = File.OpenRead(archivePath); ExtractFullPath = true,
using var archive = ReaderFactory.Open(stream); };
using var stream = File.OpenRead(archivePath);
using var archive = ReaderFactory.Open(stream);
// Start the progress reporting timer // Start the progress reporting timer
progressMonitor?.Start(); progressMonitor?.Start();
while (archive.MoveToNextEntry()) while (archive.MoveToNextEntry())
{
var entry = archive.Entry;
if (!entry.IsDirectory)
{ {
count += (ulong) entry.CompressedSize; var entry = archive.Entry;
if (!entry.IsDirectory)
{
count += (ulong)entry.CompressedSize;
}
archive.WriteEntryToDirectory(outputDirectory, extractOptions);
} }
archive.WriteEntryToDirectory(outputDirectory, extractOptions); },
} TaskCreationOptions.LongRunning
}, TaskCreationOptions.LongRunning); );
progress?.Report(new ProgressReport(progress: 1, message: "Done extracting")); progress?.Report(new ProgressReport(progress: 1, message: "Done extracting"));
progressMonitor?.Stop(); progressMonitor?.Stop();
@ -328,14 +352,18 @@ public static partial class ArchiveHelper
{ {
// Not sure why but symlink entries have a key that ends with a space // Not sure why but symlink entries have a key that ends with a space
// and some broken path suffix, so we'll remove everything after the last space // and some broken path suffix, so we'll remove everything after the last space
Logger.Debug($"Checking if output path {outputPath} contains space char: {outputPath.Contains(' ')}"); Logger.Debug(
$"Checking if output path {outputPath} contains space char: {outputPath.Contains(' ')}"
);
if (outputPath.Contains(' ')) if (outputPath.Contains(' '))
{ {
outputPath = outputPath[..outputPath.LastIndexOf(' ')]; outputPath = outputPath[..outputPath.LastIndexOf(' ')];
} }
Logger.Debug($"Extracting symbolic link [{entry.Key.ToRepr()}] " + Logger.Debug(
$"({outputPath.ToRepr()} to {entry.LinkTarget.ToRepr()})"); $"Extracting symbolic link [{entry.Key.ToRepr()}] "
+ $"({outputPath.ToRepr()} to {entry.LinkTarget.ToRepr()})"
);
// Try to write link, if fail, continue copy file // Try to write link, if fail, continue copy file
try try
{ {
@ -346,7 +374,9 @@ public static partial class ArchiveHelper
} }
catch (IOException e) catch (IOException e)
{ {
Logger.Warn($"Could not extract symbolic link, copying file instead: {e.Message}"); Logger.Warn(
$"Could not extract symbolic link, copying file instead: {e.Message}"
);
} }
} }

12
StabilityMatrix.Core/Helper/IPrerequisiteHelper.cs

@ -22,10 +22,16 @@ public interface IPrerequisiteHelper
/// Run embedded git with the given arguments. /// Run embedded git with the given arguments.
/// </summary> /// </summary>
Task RunGit( Task RunGit(
string? workingDirectory = null, ProcessArgs args,
Action<ProcessOutput>? onProcessOutput = null, Action<ProcessOutput>? onProcessOutput,
params string[] args string? workingDirectory = null
); );
/// <summary>
/// Run embedded git with the given arguments.
/// </summary>
Task RunGit(ProcessArgs args, string? workingDirectory = null);
Task<string> GetGitOutput(string? workingDirectory = null, params string[] args); Task<string> GetGitOutput(string? workingDirectory = null, params string[] args);
Task InstallTkinterIfNecessary(IProgress<ProgressReport>? progress = null); Task InstallTkinterIfNecessary(IProgress<ProgressReport>? progress = null);
} }

27
StabilityMatrix.Core/Helper/PrerequisiteHelper.cs

@ -1,8 +1,10 @@
using System.Reflection; using System.Diagnostics;
using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using Octokit; using Octokit;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
@ -61,18 +63,31 @@ public class PrerequisiteHelper : IPrerequisiteHelper
} }
public async Task RunGit( public async Task RunGit(
string? workingDirectory = null, ProcessArgs args,
Action<ProcessOutput>? onProcessOutput = null, Action<ProcessOutput>? onProcessOutput,
params string[] args string? workingDirectory = null
) )
{ {
var process = ProcessRunner.StartAnsiProcess( var process = ProcessRunner.StartAnsiProcess(
GitExePath, GitExePath,
args, args.ToArray(),
workingDirectory, workingDirectory,
onProcessOutput onProcessOutput
); );
await ProcessRunner.WaitForExitConditionAsync(process).ConfigureAwait(false); await process.WaitForExitAsync().ConfigureAwait(false);
if (process.ExitCode != 0)
{
throw new ProcessException($"Git exited with code {process.ExitCode}");
}
}
public async Task RunGit(ProcessArgs args, string? workingDirectory = null)
{
var result = await ProcessRunner
.GetProcessResultAsync(GitExePath, args, workingDirectory)
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
} }
public async Task<string> GetGitOutput(string? workingDirectory = null, params string[] args) public async Task<string> GetGitOutput(string? workingDirectory = null, params string[] args)

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

71
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,25 +184,41 @@ 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);
{
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);
// Return the new path // Return the new path
return destinationFile; 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> /// <summary>
/// Copy the file to a target path. /// Copy the file to a target path.
/// </summary> /// </summary>

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

@ -181,39 +181,25 @@ public abstract class BaseGitPackage : BasePackage
IProgress<ProgressReport>? progress = null IProgress<ProgressReport>? progress = null
) )
{ {
if (!string.IsNullOrWhiteSpace(versionOptions.VersionTag)) await PrerequisiteHelper
{ .RunGit(
await PrerequisiteHelper new[]
.RunGit( {
null,
null,
"clone",
"--branch",
versionOptions.VersionTag,
GithubUrl,
$"\"{installLocation}\""
)
.ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(versionOptions.BranchName))
{
await PrerequisiteHelper
.RunGit(
null,
null,
"clone", "clone",
"--branch", "--branch",
versionOptions.BranchName, !string.IsNullOrWhiteSpace(versionOptions.VersionTag)
? versionOptions.VersionTag
: versionOptions.BranchName ?? MainBranch,
GithubUrl, GithubUrl,
$"\"{installLocation}\"" installLocation
) }
.ConfigureAwait(false); )
} .ConfigureAwait(false);
if (!versionOptions.IsLatest && !string.IsNullOrWhiteSpace(versionOptions.CommitHash)) if (!versionOptions.IsLatest && !string.IsNullOrWhiteSpace(versionOptions.CommitHash))
{ {
await PrerequisiteHelper await PrerequisiteHelper
.RunGit(installLocation, null, "checkout", versionOptions.CommitHash) .RunGit(new[] { "checkout", versionOptions.CommitHash }, installLocation)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@ -327,12 +313,9 @@ public abstract class BaseGitPackage : BasePackage
.ConfigureAwait(false); .ConfigureAwait(false);
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installedPackage.FullPath!, new[] { "remote", "add", "origin", GithubUrl },
onConsoleOutput, onConsoleOutput,
"remote", installedPackage.FullPath
"add",
"origin",
GithubUrl
) )
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@ -341,7 +324,7 @@ public abstract class BaseGitPackage : BasePackage
{ {
progress?.Report(new ProgressReport(-1f, "Fetching tags...", isIndeterminate: true)); progress?.Report(new ProgressReport(-1f, "Fetching tags...", isIndeterminate: true));
await PrerequisiteHelper await PrerequisiteHelper
.RunGit(installedPackage.FullPath!, onConsoleOutput, "fetch", "--tags") .RunGit(new[] { "fetch", "--tags" }, onConsoleOutput, installedPackage.FullPath)
.ConfigureAwait(false); .ConfigureAwait(false);
progress?.Report( progress?.Report(
@ -353,11 +336,9 @@ public abstract class BaseGitPackage : BasePackage
); );
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installedPackage.FullPath!, new[] { "checkout", versionOptions.VersionTag, "--force" },
onConsoleOutput, onConsoleOutput,
"checkout", installedPackage.FullPath
versionOptions.VersionTag,
"--force"
) )
.ConfigureAwait(false); .ConfigureAwait(false);
@ -381,7 +362,7 @@ public abstract class BaseGitPackage : BasePackage
// fetch // fetch
progress?.Report(new ProgressReport(-1f, "Fetching data...", isIndeterminate: true)); progress?.Report(new ProgressReport(-1f, "Fetching data...", isIndeterminate: true));
await PrerequisiteHelper await PrerequisiteHelper
.RunGit(installedPackage.FullPath!, onConsoleOutput, "fetch") .RunGit("fetch", onConsoleOutput, installedPackage.FullPath)
.ConfigureAwait(false); .ConfigureAwait(false);
if (versionOptions.IsLatest) if (versionOptions.IsLatest)
@ -396,11 +377,9 @@ public abstract class BaseGitPackage : BasePackage
); );
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installedPackage.FullPath!, new[] { "checkout", versionOptions.BranchName!, "--force" },
onConsoleOutput, onConsoleOutput,
"checkout", installedPackage.FullPath
versionOptions.BranchName,
"--force"
) )
.ConfigureAwait(false); .ConfigureAwait(false);
@ -408,12 +387,15 @@ public abstract class BaseGitPackage : BasePackage
progress?.Report(new ProgressReport(-1f, "Pulling changes...", isIndeterminate: true)); progress?.Report(new ProgressReport(-1f, "Pulling changes...", isIndeterminate: true));
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installedPackage.FullPath!, new[]
{
"pull",
"--autostash",
"origin",
installedPackage.Version.InstalledBranch!
},
onConsoleOutput, onConsoleOutput,
"pull", installedPackage.FullPath!
"--autostash",
"origin",
installedPackage.Version.InstalledBranch
) )
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@ -429,11 +411,9 @@ public abstract class BaseGitPackage : BasePackage
); );
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installedPackage.FullPath!, new[] { "checkout", versionOptions.CommitHash!, "--force" },
onConsoleOutput, onConsoleOutput,
"checkout", installedPackage.FullPath
versionOptions.CommitHash,
"--force"
) )
.ConfigureAwait(false); .ConfigureAwait(false);
} }

83
StabilityMatrix.Core/Models/Packages/KohyaSs.cs

@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Python.Runtime;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
@ -14,13 +15,19 @@ namespace StabilityMatrix.Core.Models.Packages;
[Singleton(typeof(BasePackage))] [Singleton(typeof(BasePackage))]
public class KohyaSs : BaseGitPackage public class KohyaSs : BaseGitPackage
{ {
private readonly IPyRunner pyRunner;
public KohyaSs( public KohyaSs(
IGithubApiCache githubApi, IGithubApiCache githubApi,
ISettingsManager settingsManager, ISettingsManager settingsManager,
IDownloadService downloadService, IDownloadService downloadService,
IPrerequisiteHelper prerequisiteHelper IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner
) )
: base(githubApi, settingsManager, downloadService, prerequisiteHelper) { } : base(githubApi, settingsManager, downloadService, prerequisiteHelper)
{
this.pyRunner = pyRunner;
}
public override string Name => "kohya_ss"; public override string Name => "kohya_ss";
public override string DisplayName { get; set; } = "kohya_ss"; public override string DisplayName { get; set; } = "kohya_ss";
@ -147,6 +154,8 @@ public class KohyaSs : BaseGitPackage
// Install // Install
venvRunner.RunDetached("setup/setup_sm.py", onConsoleOutput); venvRunner.RunDetached("setup/setup_sm.py", onConsoleOutput);
await venvRunner.Process.WaitForExitAsync().ConfigureAwait(false); await venvRunner.Process.WaitForExitAsync().ConfigureAwait(false);
await venvRunner.PipInstall("bitsandbytes-windows").ConfigureAwait(false);
} }
else if (Compat.IsLinux) else if (Compat.IsLinux)
{ {
@ -168,28 +177,64 @@ public class KohyaSs : BaseGitPackage
await SetupVenv(installedPackagePath).ConfigureAwait(false); await SetupVenv(installedPackagePath).ConfigureAwait(false);
// update gui files to point to venv accelerate // update gui files to point to venv accelerate
var filesToUpdate = new[] await pyRunner.RunInThreadWithLock(() =>
{ {
"lora_gui.py", var scope = Py.CreateScope();
"dreambooth_gui.py", scope.Exec(
"textual_inversion_gui.py", """
Path.Combine("library", "wd14_caption_gui.py"), import ast
"finetune_gui.py"
}; class StringReplacer(ast.NodeTransformer):
def __init__(self, old: str, new: str, replace_count: int = -1):
self.old = old
self.new = new
self.replace_count = replace_count
def visit_Constant(self, node: ast.Constant) -> ast.Constant:
if isinstance(node.value, str) and self.old in node.value:
new_value = node.value.replace(self.old, self.new, self.replace_count)
node.value = new_value
return node
def rewrite_module(self, module_text: str) -> str:
tree = ast.parse(module_text)
tree = self.visit(tree)
return ast.unparse(tree)
"""
);
foreach (var file in filesToUpdate)
{
var path = Path.Combine(installedPackagePath, file);
var text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
var replacementAcceleratePath = Compat.IsWindows var replacementAcceleratePath = Compat.IsWindows
? @".\\venv\\scripts\\accelerate" ? @".\venv\scripts\accelerate"
: "./venv/bin/accelerate"; : "./venv/bin/accelerate";
text = text.Replace(
"run_cmd = f'accelerate launch", var replacer = scope.InvokeMethod(
$"run_cmd = f'{replacementAcceleratePath} launch" "StringReplacer",
"accelerate".ToPython(),
$"{replacementAcceleratePath}".ToPython(),
1.ToPython()
); );
await File.WriteAllTextAsync(path, text).ConfigureAwait(false);
} var filesToUpdate = new[]
{
"lora_gui.py",
"dreambooth_gui.py",
"textual_inversion_gui.py",
Path.Combine("library", "wd14_caption_gui.py"),
"finetune_gui.py"
};
foreach (var file in filesToUpdate)
{
var path = Path.Combine(installedPackagePath, file);
var text = File.ReadAllText(path);
if (text.Contains(replacementAcceleratePath.Replace(@"\", @"\\")))
continue;
var result = replacer.InvokeMethod("rewrite_module", text.ToPython());
var resultStr = result.ToString();
File.WriteAllText(path, resultStr);
}
});
void HandleConsoleOutput(ProcessOutput s) void HandleConsoleOutput(ProcessOutput s)
{ {

30
StabilityMatrix.Core/Models/Packages/VladAutomatic.cs

@ -242,29 +242,28 @@ public class VladAutomatic : BaseGitPackage
{ {
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installDir.Parent ?? "", new[] { "clone", "https://github.com/vladmandic/automatic", installDir.Name },
null, installDir.Parent?.FullPath ?? ""
"clone",
"https://github.com/vladmandic/automatic",
installDir.Name
) )
.ConfigureAwait(false); .ConfigureAwait(false);
await PrerequisiteHelper await PrerequisiteHelper
.RunGit(installLocation, null, "checkout", downloadOptions.CommitHash) .RunGit(new[] { "checkout", downloadOptions.CommitHash }, installLocation)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
else if (!string.IsNullOrWhiteSpace(downloadOptions.BranchName)) else if (!string.IsNullOrWhiteSpace(downloadOptions.BranchName))
{ {
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installDir.Parent ?? "", new[]
null, {
"clone", "clone",
"-b", "-b",
downloadOptions.BranchName, downloadOptions.BranchName,
"https://github.com/vladmandic/automatic", "https://github.com/vladmandic/automatic",
installDir.Name installDir.Name
},
installDir.Parent?.FullPath ?? ""
) )
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@ -325,10 +324,9 @@ public class VladAutomatic : BaseGitPackage
await PrerequisiteHelper await PrerequisiteHelper
.RunGit( .RunGit(
installedPackage.FullPath, new[] { "checkout", versionOptions.BranchName! },
onConsoleOutput, onConsoleOutput,
"checkout", installedPackage.FullPath
versionOptions.BranchName
) )
.ConfigureAwait(false); .ConfigureAwait(false);

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

@ -62,7 +62,7 @@ public class VoltaML : BaseGitPackage
public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink; public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink;
public override IEnumerable<TorchVersion> AvailableTorchVersions => public override IEnumerable<TorchVersion> AvailableTorchVersions =>
new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.DirectMl, TorchVersion.Mps }; new[] { TorchVersion.Cpu, TorchVersion.Cuda, TorchVersion.DirectMl };
public override IEnumerable<SharedFolderMethod> AvailableSharedFolderMethods => public override IEnumerable<SharedFolderMethod> AvailableSharedFolderMethods =>
new[] { SharedFolderMethod.Symlink, SharedFolderMethod.None }; new[] { SharedFolderMethod.Symlink, SharedFolderMethod.None };

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

18
StabilityMatrix.Core/Processes/ProcessResult.cs

@ -1,8 +1,24 @@
namespace StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Exceptions;
namespace StabilityMatrix.Core.Processes;
public readonly record struct ProcessResult public readonly record struct ProcessResult
{ {
public required int ExitCode { get; init; } public required int ExitCode { get; init; }
public string? StandardOutput { get; init; } public string? StandardOutput { get; init; }
public string? StandardError { get; init; } public string? StandardError { get; init; }
public string? ProcessName { get; init; }
public TimeSpan Elapsed { get; init; }
public bool IsSuccessExitCode => ExitCode == 0;
public void EnsureSuccessExitCode()
{
if (!IsSuccessExitCode)
{
throw new ProcessException(this);
}
}
} }

12
StabilityMatrix.Core/Processes/ProcessRunner.cs

@ -208,7 +208,9 @@ public static class ProcessRunner
{ {
ExitCode = process.ExitCode, ExitCode = process.ExitCode,
StandardOutput = stdout, StandardOutput = stdout,
StandardError = stderr StandardError = stderr,
ProcessName = process.MachineName,
Elapsed = process.ExitTime - process.StartTime
}; };
} }
@ -425,6 +427,14 @@ public static class ProcessRunner
CancellationToken cancelToken = default CancellationToken cancelToken = default
) )
{ {
if (process is AnsiProcess)
{
throw new ArgumentException(
$"{nameof(WaitForExitConditionAsync)} does not support AnsiProcess, which uses custom async data reading",
nameof(process)
);
}
var stdout = new StringBuilder(); var stdout = new StringBuilder();
var stderr = new StringBuilder(); var stderr = new StringBuilder();
process.OutputDataReceived += (_, args) => stdout.Append(args.Data); process.OutputDataReceived += (_, args) => stdout.Append(args.Data);

47
StabilityMatrix.Core/Python/PyRunner.cs

@ -124,8 +124,11 @@ public class PyRunner : IPyRunner
{ {
throw new FileNotFoundException("get-pip not found", GetPipPath); throw new FileNotFoundException("get-pip not found", GetPipPath);
} }
var p = ProcessRunner.StartAnsiProcess(PythonExePath, "-m get-pip");
await ProcessRunner.WaitForExitConditionAsync(p); var result = await ProcessRunner
.GetProcessResultAsync(PythonExePath, "-m get-pip")
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
} }
/// <summary> /// <summary>
@ -137,8 +140,10 @@ public class PyRunner : IPyRunner
{ {
throw new FileNotFoundException("pip not found", PipExePath); throw new FileNotFoundException("pip not found", PipExePath);
} }
var p = ProcessRunner.StartAnsiProcess(PipExePath, $"install {package}"); var result = await ProcessRunner
await ProcessRunner.WaitForExitConditionAsync(p); .GetProcessResultAsync(PipExePath, $"install {package}")
.ConfigureAwait(false);
result.EnsureSuccessExitCode();
} }
/// <summary> /// <summary>
@ -159,15 +164,16 @@ public class PyRunner : IPyRunner
try try
{ {
return await Task.Run( return await Task.Run(
() => () =>
{
using (Py.GIL())
{ {
return func(); using (Py.GIL())
} {
}, return func();
cancelToken }
); },
cancelToken
)
.ConfigureAwait(false);
} }
finally finally
{ {
@ -193,15 +199,16 @@ public class PyRunner : IPyRunner
try try
{ {
await Task.Run( await Task.Run(
() => () =>
{
using (Py.GIL())
{ {
action(); using (Py.GIL())
} {
}, action();
cancelToken }
); },
cancelToken
)
.ConfigureAwait(false);
} }
finally finally
{ {

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.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;
// download the file from URL var downloadFile = UpdateFolder.JoinFile(Path.GetFileName(updateInfo.Url.ToString()));
await downloadService
.DownloadToFileAsync(
downloadUrl,
ExecutablePath,
progress: progress,
httpClientName: "UpdateClient"
)
.ConfigureAwait(false);
}
/// <summary> var extractDir = UpdateFolder.JoinDir("extract");
/// Format a DatetimeOffset to a culture invariant string for use in signature verification.
/// </summary>
private static string FormatDateTimeOffsetInvariant(DateTimeOffset dateTimeOffset)
{
return dateTimeOffset.ToString(
@"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz",
CultureInfo.InvariantCulture
);
}
/// <summary> try
/// Data for use in signature verification. {
/// Semicolon separated string of fields: // download the file from URL
/// "version, releaseDate, channel, type, url, changelog, hashBlake3" await downloadService
/// </summary> .DownloadToFileAsync(
private static string GetUpdateInfoSignedData(UpdateInfo updateInfo) updateInfo.Url.ToString(),
{ downloadFile,
var channel = updateInfo.Channel.GetStringValue().ToLowerInvariant(); progress: progress,
var date = FormatDateTimeOffsetInvariant(updateInfo.ReleaseDate); httpClientName: "UpdateClient"
return $"{updateInfo.Version};{date};{channel};" )
+ $"{(int)updateInfo.Type};{updateInfo.DownloadUrl};{updateInfo.ChangelogUrl};" .ConfigureAwait(false);
+ $"{updateInfo.HashBlake3}";
// 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() 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>()
_ when Compat.IsWindows && Compat.IsX64 => updateCollection.WindowsX64, .Where(c => c > UpdateChannel.Unknown)
_ when Compat.IsLinux && Compat.IsX64 => updateCollection.LinuxX64, )
_ => null
};
if (updateInfo is null)
{ {
logger.LogWarning( if (
"Could not find compatible update info for the platform {Platform}", updateManifest.Updates.TryGetValue(channel, out var platforms)
Compat.Platform && platforms.GetInfoForCurrentPlatform() is { } update
); && ValidateUpdate(update)
return; )
{
NotifyUpdateAvailable(update);
return;
}
} }
logger.LogInformation("UpdateInfo signature: {Signature}", updateInfo.Signature); logger.LogInformation("No update available");
}
catch (Exception e)
{
logger.LogError(e, "Couldn't check for update");
}
}
var updateInfoSignData = GetUpdateInfoSignedData(updateInfo); private bool ValidateUpdate(UpdateInfo? update)
logger.LogInformation("UpdateInfo signed data: {SignData}", updateInfoSignData); {
if (update is null)
return false;
// Verify signature // Verify signature
var checker = new SignatureChecker(); var checker = new SignatureChecker();
if (!checker.Verify(updateInfoSignData, updateInfo.Signature)) var signedData = update.GetSignedData();
{
logger.LogError("UpdateInfo signature is invalid: {Info}", updateInfo);
return;
}
logger.LogInformation("UpdateInfo signature verified");
var order = updateInfo.Version.ComparePrecedenceTo(Compat.AppVersion); if (!checker.Verify(signedData, update.Signature))
{
logger.LogError(
"UpdateInfo signature {Signature} is invalid, Data = {Data}, UpdateInfo = {Info}",
update.Signature,
signedData,
update
);
return false;
}
if (order > 0) switch (update.Version.ComparePrecedenceTo(Compat.AppVersion))
{ {
case > 0:
// Newer version available // Newer version available
logger.LogInformation( return true;
"Update available {AppVer} -> {UpdateVer}", case 0:
Compat.AppVersion,
updateInfo.Version
);
EventManager.Instance.OnUpdateAvailable(updateInfo);
return;
}
if (order == 0)
{ {
// Same version available, check if we both have commit hash metadata // Same version available, check if we both have commit hash metadata
var updateHash = updateInfo.Version.Metadata; var updateHash = update.Version.Metadata;
var appHash = Compat.AppVersion.Metadata; var appHash = Compat.AppVersion.Metadata;
// If different, we can update // If different, we can update
if (updateHash != appHash) if (updateHash != appHash)
{ {
logger.LogInformation( return true;
"Update available {AppVer} -> {UpdateVer}",
Compat.AppVersion,
updateInfo.Version
);
EventManager.Instance.OnUpdateAvailable(updateInfo);
return;
} }
}
logger.LogInformation("No update available"); break;
} }
catch (Exception e)
{
logger.LogError(e, "Couldn't check for update");
} }
return false;
}
private void NotifyUpdateAvailable(UpdateInfo update)
{
logger.LogInformation(
"Update available {AppVer} -> {UpdateVer}",
Compat.AppVersion,
update.Version
);
EventManager.Instance.OnUpdateAvailable(update);
} }
} }

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

22
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"),
"IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA==" HashBlake3 = "46e11a5216c55d4c9d3c54385f62f3e1022537ae191615237f05e06d6f8690d0",
); Signature =
"IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA=="
};
var updateHelper = Substitute.For<IUpdateHelper>(); var updateHelper = Substitute.For<IUpdateHelper>();
updateHelper updateHelper

37
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,
ProgressValue = Convert.ToInt32(report.Percentage); new Progress<ProgressReport>(report =>
})); {
ProgressValue = Convert.ToInt32(report.Percentage);
})
);
UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds...";
await Task.Delay(1000); await Task.Delay(1000);

Loading…
Cancel
Save