Browse Source

Merge branch 'main' into inference

# Conflicts:
#	StabilityMatrix.Avalonia/App.axaml
#	StabilityMatrix.Avalonia/DesignData/DesignData.cs
#	StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
#	StabilityMatrix.Avalonia/Views/SettingsPage.axaml
#	StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs
#	StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
#	StabilityMatrix.Core/Services/ISettingsManager.cs
#	StabilityMatrix.Core/Services/SettingsManager.cs
pull/165/head
Ionite 1 year ago
parent
commit
1e3ead9ab1
No known key found for this signature in database
  1. 15
      CHANGELOG.md
  2. 1
      StabilityMatrix.Avalonia/App.axaml
  3. 8
      StabilityMatrix.Avalonia/App.axaml.cs
  4. 12
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  5. 65
      StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs
  6. 13
      StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs
  7. 22
      StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs
  8. 42
      StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml
  9. 347
      StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml
  10. 49
      StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs
  11. 16
      StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs
  12. 265
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
  13. 43
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
  14. 28
      StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
  15. 7
      StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
  16. 86
      StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs
  17. 8
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  18. 12
      StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs
  19. 78
      StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs
  20. 73
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  21. 4
      StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml
  22. 204
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  23. 48
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  24. 2
      StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs
  25. 4
      StabilityMatrix.Core/Helper/FileHash.cs
  26. 10
      StabilityMatrix.Core/Helper/HardwareHelper.cs
  27. 44
      StabilityMatrix.Core/Models/CivitPostDownloadContextAction.cs
  28. 8
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  29. 18
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  30. 9
      StabilityMatrix.Core/Models/IContextAction.cs
  31. 122
      StabilityMatrix.Core/Models/Packages/Fooocus.cs
  32. 18
      StabilityMatrix.Core/Models/Packages/VladAutomatic.cs
  33. 3
      StabilityMatrix.Core/Models/Progress/ProgressState.cs
  34. 1
      StabilityMatrix.Core/Models/Progress/ProgressType.cs
  35. 317
      StabilityMatrix.Core/Models/TrackedDownload.cs
  36. 175
      StabilityMatrix.Core/Services/DownloadService.cs
  37. 18
      StabilityMatrix.Core/Services/IDownloadService.cs
  38. 7
      StabilityMatrix.Core/Services/ISettingsManager.cs
  39. 15
      StabilityMatrix.Core/Services/ITrackedDownloadService.cs
  40. 22
      StabilityMatrix.Core/Services/SettingsManager.cs
  41. 247
      StabilityMatrix.Core/Services/TrackedDownloadService.cs

15
CHANGELOG.md

@ -5,11 +5,26 @@ 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.3.0
### Added
- New installable Package - [Fooocus](https://github.com/lllyasviel/Fooocus)
- Added "Select New Data Directory" button to Settings
- Pause/Resume/Cancel buttons on downloads popup. Paused downloads persists and may be resumed after restarting the app
### Fixed
- Fixed issue where model version wouldn't be selected in the "All Versions" section of the Model Browser
- Improved Checkpoints page indexing performance
- Fixed issue where Checkpoints page may not show all checkpoints after clearing search filter
- Fixed issue where Checkpoints page may show incorrect checkpoints for the given filter after changing pages
## v2.2.1
### Fixed
- Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks
## v2.2.0
### Added
- Added option to search by Base Model in the Model Browser
- Animated page transitions

1
StabilityMatrix.Avalonia/App.axaml

@ -32,6 +32,7 @@
<StyleInclude Source="Styles/ProgressRing.axaml"/>
<StyleInclude Source="Styles/ButtonStyles.axaml"/>
<StyleInclude Source="Styles/SplitButtonStyles.axaml"/>
<StyleInclude Source="Styles/ToggleButtonStyles.axaml"/>
<StyleInclude Source="Controls/SeedCard.axaml"/>
<StyleInclude Source="Controls/SamplerCard.axaml"/>
<StyleInclude Source="Controls/ImageGalleryCard.axaml"/>

8
StabilityMatrix.Avalonia/App.axaml.cs

@ -217,7 +217,8 @@ public sealed class App : Application
services.AddSingleton<MainWindowViewModel>(provider =>
new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(),
provider.GetRequiredService<IDiscordRichPresenceService>(),
provider.GetRequiredService<ServiceManager<ViewModelBase>>())
provider.GetRequiredService<ServiceManager<ViewModelBase>>(),
provider.GetRequiredService<ITrackedDownloadService>())
{
Pages =
{
@ -357,6 +358,7 @@ public sealed class App : Application
services.AddSingleton<BasePackage, ComfyUI>();
services.AddSingleton<BasePackage, VoltaML>();
services.AddSingleton<BasePackage, InvokeAI>();
services.AddSingleton<BasePackage, Fooocus>();
}
private static IServiceCollection ConfigureServices()
@ -385,6 +387,10 @@ public sealed class App : Application
services.AddSingleton<ICompletionProvider, CompletionProvider>();
services.AddSingleton<ITokenizerProvider, TokenizerProvider>();
services.AddSingleton<ITrackedDownloadService, TrackedDownloadService>();
services.AddSingleton<IDisposable>(provider =>
(IDisposable) provider.GetRequiredService<ITrackedDownloadService>());
// Rich presence
services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>();
services.AddSingleton<IDisposable>(provider =>

12
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -101,7 +101,8 @@ public static class DesignData
.AddSingleton<IApiFactory, MockApiFactory>()
.AddSingleton<IInferenceClientManager, MockInferenceClientManager>()
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>()
.AddSingleton<ICompletionProvider, MockCompletionProvider>();
.AddSingleton<ICompletionProvider, MockCompletionProvider>()
.AddSingleton<ITrackedDownloadService, MockTrackedDownloadService>();
// Placeholder services that nobody should need during design time
services
@ -233,13 +234,12 @@ public static class DesignData
})
};
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel>
ProgressManagerViewModel.ProgressItems.AddRange(new ProgressItemViewModelBase[]
{
new(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressItemViewModel(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressReport(0.5f, "Downloading..."))),
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu",
new ProgressReport(0.25f, "Extracting...")))
};
new MockDownloadProgressItemViewModel("Test File 2.exe"),
});
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.UpdateText =

65
StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs

@ -0,0 +1,65 @@
using System.Threading;
using System.Threading.Tasks;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadProgressItemViewModel : PausableProgressItemViewModelBase
{
private Task? dummyTask;
private CancellationTokenSource? cts;
public MockDownloadProgressItemViewModel(string fileName)
{
Name = fileName;
Progress.Value = 5;
Progress.IsIndeterminate = false;
Progress.Text = "Downloading...";
}
/// <inheritdoc />
public override Task Cancel()
{
// Cancel the task that updates progress
cts?.Cancel();
cts = null;
dummyTask = null;
State = ProgressState.Cancelled;
Progress.Text = "Cancelled";
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Pause()
{
// Cancel the task that updates progress
cts?.Cancel();
cts = null;
dummyTask = null;
State = ProgressState.Inactive;
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Resume()
{
// Start a task that updates progress every 100ms
cts = new CancellationTokenSource();
dummyTask = Task.Run(async () =>
{
while (State != ProgressState.Success)
{
await Task.Delay(100, cts.Token);
Progress.Value += 1;
}
}, cts.Token);
State = ProgressState.Working;
return Task.CompletedTask;
}
}

13
StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs

@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
@ -8,8 +9,16 @@ namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadService : IDownloadService
{
public Task DownloadToFileAsync(string downloadUrl, string downloadPath,
IProgress<ProgressReport>? progress = null, string? httpClientName = null)
public Task DownloadToFileAsync(string downloadUrl, string downloadPath, IProgress<ProgressReport>? progress = null,
string? httpClientName = null, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ResumeDownloadToFileAsync(string downloadUrl, string downloadPath, long existingFileSize,
IProgress<ProgressReport>? progress = null, string? httpClientName = null,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}

22
StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockTrackedDownloadService : ITrackedDownloadService
{
/// <inheritdoc />
public IEnumerable<TrackedDownload> Downloads => Array.Empty<TrackedDownload>();
/// <inheritdoc />
public event EventHandler<TrackedDownload>? DownloadAdded;
/// <inheritdoc />
public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath)
{
throw new NotImplementedException();
}
}

42
StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml

@ -11,6 +11,7 @@
<Button Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent-full" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<Button Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel>
</Border>
@ -302,4 +303,45 @@
</Style>
</Style>
</Style>
<!-- Full Transparent -->
<Style Selector="Button.transparent-full">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
</Styles>

347
StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml

@ -0,0 +1,347 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<Border Padding="20">
<StackPanel>
<ToggleButton Classes="success" Content="Success Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="accent" Content="FA Accent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="systemaccent" Content="System Accent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="danger" Content="Danger Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="transparent-full" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Design.PreviewWith>
<!-- Success -->
<Style Selector="ToggleButton.success">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeGreenColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkGreenColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkGreenColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Danger -->
<Style Selector="ToggleButton.danger">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeRedColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkRedColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkRedColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Info -->
<Style Selector="ToggleButton.info">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeBlueColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!--Accent Button-->
<Style Selector="ToggleButton.accent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPointerOver}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPointerOver}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- SystemAccent -->
<Style Selector="ToggleButton.systemaccent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark1}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark1}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark2}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark2}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Transparent -->
<Style Selector="ToggleButton.transparent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Semi-Transparent Info -->
<Style Selector="ToggleButton.transparent-info">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeBlueColorTransparent}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Full Transparent -->
<Style Selector="ToggleButton.transparent-full">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
</Styles>

49
StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs

@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public abstract partial class PausableProgressItemViewModelBase : ProgressItemViewModelBase
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPaused), nameof(IsCompleted), nameof(CanPauseResume), nameof(CanCancel))]
private ProgressState state = ProgressState.Inactive;
/// <summary>
/// Whether the progress is paused
/// </summary>
public bool IsPaused => State == ProgressState.Inactive;
/// <summary>
/// Whether the progress has succeeded, failed or was cancelled
/// </summary>
public override bool IsCompleted => State is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled;
public virtual bool SupportsPauseResume => true;
public virtual bool SupportsCancel => true;
public bool CanPauseResume => SupportsPauseResume && !IsCompleted;
public bool CanCancel => SupportsCancel && !IsCompleted;
private AsyncRelayCommand? pauseCommand;
public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause);
public virtual Task Pause() => Task.CompletedTask;
private AsyncRelayCommand? resumeCommand;
public IAsyncRelayCommand ResumeCommand => resumeCommand ??= new AsyncRelayCommand(Resume);
public virtual Task Resume() => Task.CompletedTask;
private AsyncRelayCommand? cancelCommand;
public IAsyncRelayCommand CancelCommand => cancelCommand ??= new AsyncRelayCommand(Cancel);
public virtual Task Cancel() => Task.CompletedTask;
[RelayCommand]
private Task TogglePauseResume()
{
return IsPaused ? Resume() : Pause();
}
}

16
StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs

@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
public abstract partial class ProgressItemViewModelBase : ViewModelBase
{
[ObservableProperty] private Guid id;
[ObservableProperty] private string? name;
[ObservableProperty] private bool failed;
public virtual bool IsCompleted => Progress.Value >= 100 || Failed;
public ProgressViewModel Progress { get; } = new();
}

265
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs

@ -32,10 +32,10 @@ using Notification = Avalonia.Controls.Notifications.Notification;
namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDownloadService downloadService;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService;
@ -63,11 +63,13 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
public CheckpointBrowserCardViewModel(
IDownloadService downloadService,
ITrackedDownloadService trackedDownloadService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService)
{
this.downloadService = downloadService;
this.trackedDownloadService = trackedDownloadService;
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.notificationService = notificationService;
@ -197,6 +199,53 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
await DoImport(model, selectedVersion, selectedFile);
}
private static async Task<FilePath> SaveCmInfo(
CivitModel model,
CivitModelVersion modelVersion,
CivitFile modelFile,
DirectoryPath downloadDirectory)
{
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name);
var modelInfo =
new ConnectedModelInfo(model, modelVersion, modelFile, DateTime.UtcNow);
await modelInfo.SaveJsonToDirectory(downloadDirectory, modelFileName);
var jsonName = $"{modelFileName}.cm-info.json";
return downloadDirectory.JoinFile(jsonName);
}
/// <summary>
/// Saves the preview image to the same directory as the model file
/// </summary>
/// <param name="modelVersion"></param>
/// <param name="modelFilePath"></param>
/// <returns>The file path of the saved preview image</returns>
private async Task<FilePath?> SavePreviewImage(CivitModelVersion modelVersion, FilePath modelFilePath)
{
// Skip if model has no images
if (modelVersion.Images == null || modelVersion.Images.Count == 0)
{
return null;
}
var image = modelVersion.Images[0];
var imageExtension = Path.GetExtension(image.Url).TrimStart('.');
if (imageExtension is "jpg" or "jpeg" or "png")
{
var imageDownloadPath =
modelFilePath.Directory!.JoinFile($"{modelFilePath.Name}.preview.{imageExtension}");
var imageTask =
downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
await notificationService.TryAsync(imageTask, "Could not download preview image");
return imageDownloadPath;
}
return null;
}
private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null,
CivitFile? selectedFile = null)
{
@ -205,163 +254,95 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
OnDownloadStart?.Invoke(this);
// Holds files to be deleted on errors
var filesForCleanup = new HashSet<FilePath>();
// Get latest version
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault();
if (modelVersion is null)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
// Set Text when exiting, finally block will set 100 and delay clear progress
try
// Get latest version file
var modelFile = selectedFile ??
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
if (modelFile is null)
{
// Get latest version
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault();
if (modelVersion is null)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
notificationService.Show(new Notification("Model has no files available",
"This model has no files available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
// Get latest version file
var modelFile = selectedFile ??
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
if (modelFile is null)
{
notificationService.Show(new Notification("Model has no files available",
"This model has no files available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory);
var downloadFolder = Path.Combine(settingsManager.ModelsDirectory,
model.Type.ConvertTo<SharedFolderType>().GetStringValue());
// Folders might be missing if user didn't install any packages yet
Directory.CreateDirectory(downloadFolder);
var downloadPath = Path.GetFullPath(Path.Combine(downloadFolder, modelFile.Name));
filesForCleanup.Add(downloadPath);
// Do the download
var progressId = Guid.NewGuid();
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl,
downloadPath,
new Progress<ProgressReport>(report =>
{
if (Math.Abs(report.Percentage - Value) > 0.1)
{
Dispatcher.UIThread.Post(() =>
{
Value = report.Percentage;
Text = $"Downloading... {report.Percentage}%";
});
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, report));
}
}));
var downloadResult =
await notificationService.TryAsync(downloadTask, "Could not download file");
// Failed download handling
if (downloadResult.Exception is not null)
{
// For exceptions other than ApiException or TaskCanceledException, log error
var logLevel = downloadResult.Exception switch
{
HttpRequestException or ApiException or TaskCanceledException => LogLevel.Warn,
_ => LogLevel.Error
};
Logger.Log(logLevel, downloadResult.Exception, "Error during model download");
var downloadDirectory =
rootModelsDirectory.JoinDir(model.Type.ConvertTo<SharedFolderType>()
.GetStringValue());
// Folders might be missing if user didn't install any packages yet
downloadDirectory.Create();
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
}
var downloadPath = downloadDirectory.JoinFile(modelFile.Name);
// When sha256 is available, validate the downloaded file
var fileExpectedSha256 = modelFile.Hashes.SHA256;
if (!string.IsNullOrEmpty(fileExpectedSha256))
{
var hashProgress = new Progress<ProgressReport>(progress =>
{
Value = progress.Percentage;
Text = $"Validating... {progress.Percentage}%";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, progress));
});
var sha256 = await FileHash.GetSha256Async(downloadPath, hashProgress);
if (sha256 != fileExpectedSha256.ToLowerInvariant())
{
Text = "Import Failed!";
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
notificationService.Show(new Notification("Download failed hash validation",
"This may be caused by network or server issues from CivitAI, please try again in a few minutes.",
NotificationType.Error));
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
}
settingsManager.Transaction(
s => s.InstalledModelHashes.Add(modelFile.Hashes.BLAKE3));
notificationService.Show(new Notification("Import complete",
$"{model.Type} {model.Name} imported successfully!", NotificationType.Success));
}
// Download model info and preview first
var cmInfoPath = await SaveCmInfo(model, modelVersion, modelFile, downloadDirectory);
var previewImagePath = await SavePreviewImage(modelVersion, downloadPath);
// Create tracked download
var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath);
IsIndeterminate = true;
// Add hash info
download.ExpectedHashSha256 = modelFile.Hashes.SHA256;
// Save connected model info
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name);
var modelInfo =
new ConnectedModelInfo(CivitModel, modelVersion, modelFile, DateTime.UtcNow);
var modelInfoPath = Path.GetFullPath(Path.Combine(
downloadFolder, modelFileName + ConnectedModelInfo.FileExtension));
filesForCleanup.Add(modelInfoPath);
await modelInfo.SaveJsonToDirectory(downloadFolder, modelFileName);
// Add files to cleanup list
download.ExtraCleanupFileNames.Add(cmInfoPath);
if (previewImagePath is not null)
{
download.ExtraCleanupFileNames.Add(previewImagePath);
}
// If available, save a model image
if (modelVersion.Images != null && modelVersion.Images.Any())
// Attach for progress updates
download.ProgressUpdate += (s, e) =>
{
Value = e.Percentage;
if (e.Type == ProgressType.Hashing)
{
var image = modelVersion.Images[0];
var imageExtension = Path.GetExtension(image.Url).TrimStart('.');
if (imageExtension is "jpg" or "jpeg" or "png")
{
var imageDownloadPath = Path.GetFullPath(Path.Combine(downloadFolder,
$"{modelFileName}.preview.{imageExtension}"));
filesForCleanup.Add(imageDownloadPath);
var imageTask =
downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
await notificationService.TryAsync(imageTask, "Could not download preview image");
}
Text = $"Validating... {e.Percentage}%";
}
else
{
Text = $"Downloading... {e.Percentage}%";
}
};
// Successful - clear cleanup list
filesForCleanup.Clear();
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(1f, "Import complete")));
Text = "Import complete!";
}
catch (Exception e)
{
Debug.WriteLine(e);
}
finally
download.ProgressStateChanged += (s, e) =>
{
foreach (var file in filesForCleanup.Where(file => file.Exists))
if (e == ProgressState.Success)
{
file.Delete();
Logger.Info($"Download cleanup: Deleted file {file}");
Text = "Import Complete";
IsIndeterminate = false;
Value = 100;
CheckIfInstalled();
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
}
else if (e == ProgressState.Cancelled)
{
Text = "Cancelled";
DelayedClearProgress(TimeSpan.FromMilliseconds(500));
}
else if (e == ProgressState.Failed)
{
Text = "Download Failed";
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
}
};
IsIndeterminate = false;
Value = 100;
CheckIfInstalled();
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
}
// Add hash context action
download.ContextAction = CivitPostDownloadContextAction.FromCivitFile(modelFile);
download.Start();
}
private void DelayedClearProgress(TimeSpan delay)

43
StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs

@ -196,44 +196,29 @@ public partial class CheckpointFile : ViewModelBase
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
// Get all files with supported extensions
var allExtensions = SupportedCheckpointExtensions
.Concat(SupportedImageExtensions)
.Concat(SupportedMetadataExtensions);
var files = allExtensions.AsParallel()
.SelectMany(pattern => Directory.EnumerateFiles(directory, $"*{pattern}", searchOption)).ToDictionary<string, string>(Path.GetFileName);
foreach (var file in files.Keys.Where(k => SupportedCheckpointExtensions.Contains(Path.GetExtension(k))))
foreach (var file in Directory.EnumerateFiles(directory, "*.*", searchOption))
{
var checkpointFile = new CheckpointFile()
if (!SupportedCheckpointExtensions.Any(ext => file.Contains(ext)))
continue;
var checkpointFile = new CheckpointFile
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = Path.Combine(directory, file),
};
// Check for connected model info
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
var cmInfoPath = $"{fileNameWithoutExtension}.cm-info.json";
if (files.TryGetValue(cmInfoPath, out var jsonPath))
var jsonPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(file)}.cm-info.json");
if (File.Exists(jsonPath))
{
try
{
var jsonData = File.ReadAllText(jsonPath);
checkpointFile.ConnectedModel = ConnectedModelInfo.FromJson(jsonData);
}
catch (IOException e)
{
Debug.WriteLine($"Failed to parse {cmInfoPath}: {e}");
}
var json = File.ReadAllText(jsonPath);
var connectedModelInfo = ConnectedModelInfo.FromJson(json);
checkpointFile.ConnectedModel = connectedModelInfo;
}
// Check for preview image
var previewImage = SupportedImageExtensions.Select(ext => $"{fileNameWithoutExtension}.preview{ext}").FirstOrDefault(files.ContainsKey);
if (previewImage != null)
{
checkpointFile.PreviewImagePath = files[previewImage];
}
checkpointFile.PreviewImagePath = SupportedImageExtensions
.Select(ext => Path.Combine(directory,
$"{Path.GetFileNameWithoutExtension(file)}.preview{ext}")).Where(File.Exists)
.FirstOrDefault();
yield return checkpointFile;
}

28
StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@ -10,6 +11,7 @@ using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
@ -95,7 +97,12 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
{
if (string.IsNullOrWhiteSpace(SearchFilter))
{
DisplayedCheckpointFolders = CheckpointFolders;
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(
CheckpointFolders.Select(x =>
{
x.SearchFilter = SearchFilter;
return x;
}));
return;
}
@ -106,7 +113,7 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
folder.SearchFilter = SearchFilter;
}
DisplayedCheckpointFolders = new ObservableCollection<CheckpointManager.CheckpointFolder>(filteredFolders);
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(filteredFolders);
}
private bool ContainsSearchFilter(CheckpointManager.CheckpointFolder folder)
@ -137,7 +144,7 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
var folders = Directory.GetDirectories(modelsDirectory);
// Index all folders
var indexTasks = folders.Select(f => Task.Run(async () =>
var indexTasks = folders.Select(async f =>
{
var checkpointFolder =
new CheckpointManager.CheckpointFolder(settingsManager, downloadService, modelFinder)
@ -148,21 +155,26 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
};
await checkpointFolder.IndexAsync();
return checkpointFolder;
})).ToList();
}).ToList();
await Task.WhenAll(indexTasks);
// Set new observable collection, ordered by alphabetical order
CheckpointFolders =
new ObservableCollection<CheckpointManager.CheckpointFolder>(indexTasks
new ObservableCollection<CheckpointFolder>(indexTasks
.Select(t => t.Result)
.OrderBy(f => f.Title));
if (!string.IsNullOrWhiteSpace(SearchFilter))
{
DisplayedCheckpointFolders = new ObservableCollection<CheckpointManager.CheckpointFolder>(
CheckpointFolders
.Where(x => x.CheckpointFiles.Any(y => y.FileName.Contains(SearchFilter))));
var filtered = CheckpointFolders
.Where(x => x.CheckpointFiles.Any(y => y.FileName.Contains(SearchFilter))).Select(
f =>
{
f.SearchFilter = SearchFilter;
return f;
});
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(filtered);
}
else
{

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

@ -42,8 +42,11 @@ public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
var firstImageUrl = value?.ModelVersion?.Images?.FirstOrDefault(
img => nsfwEnabled || img.Nsfw == "None")?.Url;
Dispatcher.UIThread.InvokeAsync(async
() => await UpdateImage(firstImageUrl));
Dispatcher.UIThread.InvokeAsync(async () =>
{
SelectedFile = value?.CivitFileViewModels.FirstOrDefault();
await UpdateImage(firstImageUrl);
});
}
partial void OnSelectedFileChanged(CivitFileViewModel? value)

86
StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs

@ -0,0 +1,86 @@
using System;
using System.Threading.Tasks;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
public class DownloadProgressItemViewModel : PausableProgressItemViewModelBase
{
private readonly TrackedDownload download;
public DownloadProgressItemViewModel(TrackedDownload download)
{
this.download = download;
Id = download.Id;
Name = download.FileName;
State = download.ProgressState;
OnProgressStateChanged(State);
// If initial progress provided, load it
if (download is {TotalBytes: > 0, DownloadedBytes: > 0})
{
var current = download.DownloadedBytes / (double) download.TotalBytes;
Progress.Value = (float) Math.Ceiling(Math.Clamp(current, 0, 1) * 100);
}
download.ProgressUpdate += (s, e) =>
{
Progress.Value = e.Percentage;
Progress.IsIndeterminate = e.IsIndeterminate;
};
download.ProgressStateChanged += (s, e) =>
{
State = e;
OnProgressStateChanged(e);
};
}
private void OnProgressStateChanged(ProgressState state)
{
if (state == ProgressState.Inactive)
{
Progress.Text = "Paused";
}
else if (state == ProgressState.Working)
{
Progress.Text = "Downloading...";
}
else if (state == ProgressState.Success)
{
Progress.Text = "Completed";
}
else if (state == ProgressState.Cancelled)
{
Progress.Text = "Cancelled";
}
else if (state == ProgressState.Failed)
{
Progress.Text = "Failed";
}
}
/// <inheritdoc />
public override Task Cancel()
{
download.Cancel();
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Pause()
{
download.Pause();
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Resume()
{
download.Resume();
return Task.CompletedTask;
}
}

8
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -24,6 +24,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly IDiscordRichPresenceService discordRichPresenceService;
public string Greeting => "Welcome to Avalonia!";
@ -45,11 +46,13 @@ public partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel(
ISettingsManager settingsManager,
IDiscordRichPresenceService discordRichPresenceService,
ServiceManager<ViewModelBase> dialogFactory)
ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService)
{
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.discordRichPresenceService = discordRichPresenceService;
this.trackedDownloadService = trackedDownloadService;
ProgressManagerViewModel = dialogFactory.Get<ProgressManagerViewModel>();
UpdateViewModel = dialogFactory.Get<UpdateViewModel>();
@ -81,6 +84,9 @@ public partial class MainWindowViewModel : ViewModelBase
// Initialize Discord Rich Presence (this needs LibraryDir so is set here)
discordRichPresenceService.UpdateState();
// Load in-progress downloads
ProgressManagerViewModel.AddDownloads(trackedDownloadService.Downloads);
// Index checkpoints if we dont have
Task.Run(() => settingsManager.IndexCheckpoints()).SafeFireAndForget();

12
StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs

@ -6,21 +6,19 @@ using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ProgressItemViewModel : ViewModelBase
public partial class ProgressItemViewModel : ProgressItemViewModelBase
{
[ObservableProperty] private Guid id;
[ObservableProperty] private string name;
[ObservableProperty] private ProgressReport progress;
[ObservableProperty] private bool failed;
[ObservableProperty] private string? progressText;
public ProgressItemViewModel(ProgressItem progressItem)
{
Id = progressItem.ProgressId;
Name = progressItem.Name;
Progress = progressItem.Progress;
Progress.Value = progressItem.Progress.Percentage;
Failed = progressItem.Failed;
ProgressText = GetProgressText(Progress);
Progress.Text = GetProgressText(progressItem.Progress);
EventManager.Instance.ProgressChanged += OnProgressChanged;
}
@ -30,9 +28,9 @@ public partial class ProgressItemViewModel : ViewModelBase
if (e.ProgressId != Id)
return;
Progress = e.Progress;
Progress.Value = e.Progress.Percentage;
Failed = e.Failed;
ProgressText = GetProgressText(Progress);
Progress.Text = GetProgressText(e.Progress);
}
private string GetProgressText(ProgressReport report)

78
StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs

@ -1,13 +1,21 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using Polly;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -16,15 +24,70 @@ namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(ProgressManagerPage))]
public partial class ProgressManagerViewModel : PageViewModelBase
{
private readonly INotificationService notificationService;
public override string Title => "Download Manager";
public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true};
[ObservableProperty]
private ObservableCollection<ProgressItemViewModel> progressItems;
public AvaloniaList<ProgressItemViewModelBase> ProgressItems { get; } = new();
public ProgressManagerViewModel(
ITrackedDownloadService trackedDownloadService,
INotificationService notificationService)
{
this.notificationService = notificationService;
// Attach to the event
trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded;
}
private void TrackedDownloadService_OnDownloadAdded(object? sender, TrackedDownload e)
{
var vm = new DownloadProgressItemViewModel(e);
// Attach notification handlers
e.ProgressStateChanged += (s, state) =>
{
var download = s as TrackedDownload;
public ProgressManagerViewModel()
switch (state)
{
case ProgressState.Success:
notificationService.Show("Download Completed", $"Download of {e.FileName} completed successfully.", NotificationType.Success);
break;
case ProgressState.Failed:
var msg = "";
if (download?.Exception is { } exception)
{
msg = $"({exception.GetType().Name}) {exception.Message}";
}
notificationService.ShowPersistent("Download Failed", $"Download of {e.FileName} failed: {msg}", NotificationType.Error);
break;
case ProgressState.Cancelled:
notificationService.Show("Download Cancelled", $"Download of {e.FileName} was cancelled.", NotificationType.Warning);
break;
}
};
ProgressItems.Add(vm);
}
public void AddDownloads(IEnumerable<TrackedDownload> downloads)
{
foreach (var download in downloads)
{
if (ProgressItems.Any(vm => vm.Id == download.Id))
{
continue;
}
var vm = new DownloadProgressItemViewModel(download);
ProgressItems.Add(vm);
}
}
private void ShowFailedNotification(string title, string message)
{
ProgressItems = new ObservableCollection<ProgressItemViewModel>();
notificationService.ShowPersistent(title, message, NotificationType.Error);
}
public void StartEventListener()
@ -34,12 +97,7 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public void ClearDownloads()
{
if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f || p.Failed))
return;
var itemsInProgress = ProgressItems
.Where(p => p.Progress.Percentage < 100 && !p.Failed).ToList();
ProgressItems = new ObservableCollection<ProgressItemViewModel>(itemsInProgress);
ProgressItems.RemoveAll(ProgressItems.Where(x => x.IsCompleted));
}
private void OnProgressChanged(object? sender, ProgressItem e)

73
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -57,6 +57,7 @@ public partial class SettingsViewModel : PageViewModelBase
private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ICompletionProvider completionProvider;
private readonly ITrackedDownloadService trackedDownloadService;
public SharedState SharedState { get; }
@ -124,6 +125,7 @@ public partial class SettingsViewModel : PageViewModelBase
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService,
SharedState sharedState,
ICompletionProvider completionProvider)
{
@ -132,6 +134,7 @@ public partial class SettingsViewModel : PageViewModelBase
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
this.completionProvider = completionProvider;
SharedState = sharedState;
@ -436,6 +439,50 @@ public partial class SettingsViewModel : PageViewModelBase
"Stability Matrix has been added to the Start Menu for all users.", NotificationType.Success);
}
public async Task PickNewDataDirectory()
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog
{
DataContext = viewModel
}
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Restart
var restartDialog = new BetterContentDialog
{
Title = "Restart required",
Content = "Stability Matrix must be restarted for the changes to take effect.",
PrimaryButtonText = "Restart",
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = false,
};
await restartDialog.ShowAsync();
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
}
#endregion
#region Debug Section
@ -589,6 +636,32 @@ public partial class SettingsViewModel : PageViewModelBase
notificationService.Show("Loaded completion file", "");
}
[RelayCommand]
private async Task DebugTrackedDownload()
{
var textFields = new TextBoxField[]
{
new()
{
Label = "Url",
},
new()
{
Label = "File path"
}
};
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var url = textFields[0].Text;
var filePath = textFields[1].Text;
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath));
download.Start();
}
}
#endregion
#region Info Section

4
StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml

@ -50,7 +50,7 @@
</Grid>
<TextBlock
Text="This is where the model checkpoints, LORAs, web UIs, settings, etc. will be installed. If you were satisfied with the previous versions, you don't need to change anything here."
Text="This is where the model checkpoints, LORAs, web UIs, settings, etc. will be installed."
TextWrapping="Wrap"
Foreground="LightGray"
FontSize="12"
@ -59,7 +59,7 @@
<CheckBox
Content="Portable Mode"
IsChecked="{Binding IsPortableMode, Mode=TwoWay}"
Margin="0,16,0,0" />
Margin="0,32,0,0" />
<ui:InfoBar
IsClosable="False"

204
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -1,46 +1,108 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}"
x:DataType="vm:ProgressManagerViewModel"
MaxHeight="250"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="250"
x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage">
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmBase="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Base"
MaxHeight="250"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}"
d:DesignHeight="250"
d:DesignWidth="300"
x:DataType="vm:ProgressManagerViewModel"
mc:Ignorable="d">
<ScrollViewer>
<Grid RowDefinitions="Auto, *">
<TextBlock Grid.Row="0" Text="Downloads"
VerticalAlignment="Center"
TextDecorations="Underline"
FontSize="16"
TextAlignment="Left" />
<Button Grid.Row="0"
Content="Clear Finished"
Padding="4"
Classes="transparent"
Margin="0,0,8,0"
IsVisible="{Binding !!ProgressItems.Count}"
Command="{Binding ClearDownloads}"
HorizontalAlignment="Right" />
<TextBlock
Grid.Row="0"
VerticalAlignment="Center"
FontSize="16"
Text="Downloads"
TextAlignment="Left"
TextDecorations="Underline" />
<Button
Grid.Row="0"
Margin="0,0,8,0"
Padding="4"
HorizontalAlignment="Right"
Classes="transparent"
Command="{Binding ClearDownloads}"
Content="Clear Finished"
IsVisible="{Binding !!ProgressItems.Count}" />
<ItemsRepeater Margin="0,4,0,0" Grid.Row="1" ItemsSource="{Binding ProgressItems, Mode=OneWay}">
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ProgressItemViewModel}">
<Border BorderBrush="#33000000"
Background="#22000000"
BorderThickness="2"
CornerRadius="8"
Padding="8"
Margin="4">
<StackPanel>
<TextBlock Text="{Binding Name, Mode=OneWay}"
Margin="0, 4" />
<ProgressBar Value="{Binding Progress.Percentage, Mode=OneWay}"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Margin="0, 4">
<ItemsControl
Grid.Row="1"
Margin="0,4,0,0"
ItemsSource="{Binding ProgressItems, Mode=OneWay}">
<ItemsControl.DataTemplates>
<DataTemplate DataType="{x:Type vmBase:PausableProgressItemViewModelBase}">
<Border
Margin="4"
Padding="8"
Background="#22000000"
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<!-- non-indeterminate progress -->
<TextBlock
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
<Run Text="{Binding Progress.Value, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
<!-- Buttons -->
<UniformGrid
Grid.Row="0"
Grid.Column="1"
Columns="2">
<ToggleButton
Name="PauseResumeToggleButton"
Classes="transparent-full"
Command="{Binding TogglePauseResumeCommand}"
IsChecked="{Binding IsPaused}"
IsVisible="{Binding CanPauseResume}">
<Panel>
<ui:SymbolIcon IsVisible="{Binding ElementName=PauseResumeToggleButton, Path=!IsChecked}" Symbol="Pause" />
<ui:SymbolIcon IsVisible="{Binding ElementName=PauseResumeToggleButton, Path=IsChecked}" Symbol="Play" />
</Panel>
</ToggleButton>
<Button
Command="{Binding CancelCommand}"
IsVisible="{Binding CanCancel}"
Classes="transparent-full">
<ui:SymbolIcon Symbol="Cancel" />
</Button>
</UniformGrid>
<ProgressBar
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,8,0,4"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Value="{Binding Progress.Value, Mode=OneWay}">
<ProgressBar.Transitions>
<Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150">
@ -51,23 +113,59 @@
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
</Grid>
</Border>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:ProgressItemViewModel}">
<Border
Margin="4"
Padding="8"
Background="#22000000"
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<!-- non-indeterminate progress -->
<TextBlock
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
<Run Text="{Binding Progress.Value, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- non-indeterminate progress -->
<TextBlock Margin="0, 4"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding ProgressText, Mode=OneWay}" />
<Run Text="{Binding Progress.Percentage, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
<!-- indeterminate progress -->
<TextBlock Margin="0, 4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding ProgressText, Mode=OneWay}" />
</StackPanel>
<ProgressBar
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,8,0,4"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Value="{Binding Progress.Value, Mode=OneWay}">
<ProgressBar.Transitions>
<Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150">
<DoubleTransition.Easing>
<SineEaseInOut />
</DoubleTransition.Easing>
</DoubleTransition>
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
</Grid>
</Border>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ItemsControl.DataTemplates>
</ItemsControl>
</Grid>
</ScrollViewer>
</controls:UserControlBase>

48
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -6,6 +6,7 @@
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="700"
x:DataType="vm:SettingsViewModel"
@ -183,6 +184,7 @@
</Grid>
<!-- System Options -->
<Grid Grid.Row="4" Margin="0,8,0,0" RowDefinitions="auto, auto, auto">
<Grid Grid.Row="5" Margin="0,8,0,0" RowDefinitions="auto,*">
<TextBlock
FontWeight="Medium"
@ -225,6 +227,24 @@
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Grid.Row="2"
Header="Select New Data Directory"
Description="Does not move existing data"
IconSource="MoveToFolder"
Margin="8,0">
<ui:SettingsExpander.Footer>
<Button Command="{Binding PickNewDataDirectory}">
<Grid ColumnDefinitions="Auto, Auto">
<avalonia:Icon Grid.Row="0" Value="fa-solid fa-folder-open"
Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
Text="Select Directory"/>
</Grid>
</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Debug Options -->
@ -240,24 +260,24 @@
IconSource="Code"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
Margin="8, 0,8,4">
Margin="8, 0,8,0">
<ui:SettingsExpanderItem Description="Paths" IconSource="Folder"
Margin="4,0,4,0">
Margin="4, 0">
<SelectableTextBlock Text="{Binding DebugPaths}"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Description="Compat Info" IconSource="StarFilled"
Margin="4,0,4,0">
Margin="4,0">
<SelectableTextBlock Text="{Binding DebugCompatInfo}"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Description="GPU Info" IconSource="FullScreenMaximize"
Margin="4,0,4,4">
Margin="4,0">
<SelectableTextBlock Text="{Binding DebugGpuInfo}"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
TextWrapping="WrapWithOverflow" />
@ -265,10 +285,9 @@
<ui:SettingsExpanderItem Content="Animation Scale" IconSource="Clock"
Description="Lower values = faster animations. 0x means animations are instant."
Margin="4,0,4,4">
Margin="4,0">
<ui:SettingsExpanderItem.Footer>
<ComboBox Margin="0, 8"
ItemsSource="{Binding AnimationScaleOptions}"
<ComboBox ItemsSource="{Binding AnimationScaleOptions}"
SelectedItem="{Binding SelectedAnimationScale}">
<ComboBox.ItemTemplate>
<DataTemplate>
@ -282,26 +301,33 @@
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Content="Notification" IconSource="CommentAdd"
Margin="4,0,4,4">
Margin="4,0">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0, 8"
Command="{Binding DebugNotificationCommand}"
Content="New Notification"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Content="Content Dialog" IconSource="NewWindow"
Margin="4,0,4,4">
Margin="4,0">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0, 8"
Command="{Binding DebugContentDialogCommand}"
Content="Show Dialog"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Content="Exceptions" IconSource="Flag"
Margin="4,0">
<ui:SettingsExpanderItem.Footer>
<Button
Command="{Binding DebugThrowExceptionCommand}"
Content="Unhandled Exception"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Content="Download Manager tests" IconSource="Flag"
Margin="4,0,4,4">
<ui:SettingsExpanderItem.Footer>
<SplitButton

2
StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs

@ -23,7 +23,7 @@ public class StringJsonConverter<T> : JsonConverter<T>
throw new JsonException();
}
return (T) Activator.CreateInstance(typeToConvert, value);
return (T?) Activator.CreateInstance(typeToConvert, value);
}
/// <inheritdoc />

4
StabilityMatrix.Core/Helper/FileHash.cs

@ -46,8 +46,8 @@ public static class FileHash
var hash = await GetHashAsync(SHA256.Create(), stream, buffer, totalBytesRead =>
{
progress?.Report(new ProgressReport(totalBytesRead, totalBytes));
});
progress?.Report(new ProgressReport(totalBytesRead, totalBytes, type: ProgressType.Hashing));
}).ConfigureAwait(false);
return hash;
}
finally

10
StabilityMatrix.Core/Helper/HardwareHelper.cs

@ -123,6 +123,16 @@ public static partial class HardwareHelper
{
return IterGpuInfo().Any(gpu => gpu.IsAmd);
}
// Set ROCm for default if AMD and Linux
public static bool PreferRocm() => !HardwareHelper.HasNvidiaGpu()
&& HardwareHelper.HasAmdGpu()
&& Compat.IsLinux;
// Set DirectML for default if AMD and Windows
public static bool PreferDirectML() => !HardwareHelper.HasNvidiaGpu()
&& HardwareHelper.HasAmdGpu()
&& Compat.IsWindows;
}
public enum Level

44
StabilityMatrix.Core/Models/CivitPostDownloadContextAction.cs

@ -0,0 +1,44 @@
using System.Diagnostics;
using System.Text.Json;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Models;
public class CivitPostDownloadContextAction : IContextAction
{
/// <inheritdoc />
public object? Context { get; set; }
public static CivitPostDownloadContextAction FromCivitFile(CivitFile file)
{
return new CivitPostDownloadContextAction
{
Context = file.Hashes.BLAKE3
};
}
public void Invoke(ISettingsManager settingsManager)
{
var result = Context as string;
if (Context is JsonElement jsonElement)
{
result = jsonElement.GetString();
}
if (result is null)
{
Debug.WriteLine($"Context {Context} is not a string.");
return;
}
Debug.WriteLine($"Adding {result} to installed models.");
settingsManager.Transaction(
s =>
{
s.InstalledModelHashes ??= new HashSet<string>();
s.InstalledModelHashes.Add(result);
});
}
}

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

@ -1,14 +1,19 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using StabilityMatrix.Core.Converters.Json;
namespace StabilityMatrix.Core.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[JsonConverter(typeof(StringJsonConverter<DirectoryPath>))]
public class DirectoryPath : FileSystemPath, IPathObject
{
private DirectoryInfo? info;
// ReSharper disable once MemberCanBePrivate.Global
[JsonIgnore]
public DirectoryInfo Info => info ??= new DirectoryInfo(FullPath);
[JsonIgnore]
public bool IsSymbolicLink
{
get
@ -21,14 +26,17 @@ public class DirectoryPath : FileSystemPath, IPathObject
/// <summary>
/// Gets a value indicating whether the directory exists.
/// </summary>
[JsonIgnore]
public bool Exists => Info.Exists;
/// <inheritdoc/>
[JsonIgnore]
public string Name => Info.Name;
/// <summary>
/// Get the parent directory.
/// </summary>
[JsonIgnore]
public DirectoryPath? Parent => Info.Parent == null
? null : new DirectoryPath(Info.Parent);

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

@ -1,15 +1,20 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json.Serialization;
using StabilityMatrix.Core.Converters.Json;
namespace StabilityMatrix.Core.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[JsonConverter(typeof(StringJsonConverter<FilePath>))]
public class FilePath : FileSystemPath, IPathObject
{
private FileInfo? _info;
// ReSharper disable once MemberCanBePrivate.Global
[JsonIgnore]
public FileInfo Info => _info ??= new FileInfo(FullPath);
[JsonIgnore]
public bool IsSymbolicLink
{
get
@ -19,13 +24,16 @@ public class FilePath : FileSystemPath, IPathObject
}
}
[JsonIgnore]
public bool Exists => Info.Exists;
[JsonIgnore]
public string Name => Info.Name;
/// <summary>
/// Get the directory of the file.
/// </summary>
[JsonIgnore]
public DirectoryPath? Directory
{
get
@ -115,6 +123,16 @@ public class FilePath : FileSystemPath, IPathObject
return File.WriteAllBytesAsync(FullPath, bytes, ct);
}
/// <summary>
/// Move the file to a directory.
/// </summary>
public FilePath MoveTo(FilePath destinationFile)
{
Info.MoveTo(destinationFile.FullPath, true);
// Return the new path
return destinationFile;
}
/// <summary>
/// Move the file to a directory.
/// </summary>

9
StabilityMatrix.Core/Models/IContextAction.cs

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Models;
[JsonDerivedType(typeof(CivitPostDownloadContextAction), "CivitPostDownload")]
public interface IContextAction
{
object? Context { get; set; }
}

122
StabilityMatrix.Core/Models/Packages/Fooocus.cs

@ -0,0 +1,122 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Models.Packages;
public class Fooocus : BaseGitPackage
{
public Fooocus(IGithubApiCache githubApi, ISettingsManager settingsManager,
IDownloadService downloadService, IPrerequisiteHelper prerequisiteHelper) : base(githubApi,
settingsManager, downloadService, prerequisiteHelper)
{
}
public override string Name => "Fooocus";
public override string DisplayName { get; set; } = "Fooocus";
public override string Author => "lllyasviel";
public override string Blurb =>
"Fooocus is a rethinking of Stable Diffusion and Midjourney’s designs";
public override string LicenseType => "GPL-3.0";
public override string LicenseUrl => "https://github.com/lllyasviel/Fooocus/blob/main/LICENSE";
public override string LaunchCommand => "launch.py";
public override Uri PreviewImageUri =>
new("https://user-images.githubusercontent.com/19834515/261830306-f79c5981-cf80-4ee3-b06b-3fef3f8bfbc7.png");
public override List<LaunchOptionDefinition> LaunchOptions => new()
{
LaunchOptionDefinition.Extras
};
public override Dictionary<SharedFolderType, IReadOnlyList<string>> SharedFolders => new()
{
[SharedFolderType.StableDiffusion] = new[] {"models/checkpoints"},
[SharedFolderType.Diffusers] = new[] {"models/diffusers"},
[SharedFolderType.Lora] = new[] {"models/loras"},
[SharedFolderType.CLIP] = new[] {"models/clip"},
[SharedFolderType.TextualInversion] = new[] {"models/embeddings"},
[SharedFolderType.VAE] = new[] {"models/vae"},
[SharedFolderType.ApproxVAE] = new[] {"models/vae_approx"},
[SharedFolderType.ControlNet] = new[] {"models/controlnet"},
[SharedFolderType.GLIGEN] = new[] {"models/gligen"},
[SharedFolderType.ESRGAN] = new[] {"models/upscale_models"},
[SharedFolderType.Hypernetwork] = new[] {"models/hypernetworks"}
};
public override async Task<string> GetLatestVersion()
{
var release = await GetLatestRelease().ConfigureAwait(false);
return release.TagName!;
}
public override async Task InstallPackage(IProgress<ProgressReport>? progress = null)
{
await base.InstallPackage(progress).ConfigureAwait(false);
var venvRunner = await SetupVenv(InstallLocation).ConfigureAwait(false);
progress?.Report(new ProgressReport(-1f, "Installing torch...", isIndeterminate: true));
var torchVersion = "cpu";
var gpus = HardwareHelper.IterGpuInfo().ToList();
if (gpus.Any(g => g.IsNvidia))
{
torchVersion = "cu118";
}
else if (HardwareHelper.PreferRocm())
{
torchVersion = "rocm5.4.2";
}
await venvRunner
.PipInstall(
$"torch==2.0.1 torchvision==0.15.2 --extra-index-url https://download.pytorch.org/whl/{torchVersion}",
OnConsoleOutput).ConfigureAwait(false);
progress?.Report(new ProgressReport(-1f, "Installing requirements...",
isIndeterminate: true));
await venvRunner.PipInstall("-r requirements_versions.txt", OnConsoleOutput)
.ConfigureAwait(false);
}
public override async Task RunPackage(string installedPackagePath, string command, string arguments)
{
await SetupVenv(installedPackagePath).ConfigureAwait(false);
void HandleConsoleOutput(ProcessOutput s)
{
OnConsoleOutput(s);
if (s.Text.Contains("To see the GUI go to", StringComparison.OrdinalIgnoreCase))
{
var regex = new Regex(@"(https?:\/\/)([^:\s]+):(\d+)");
var match = regex.Match(s.Text);
if (match.Success)
{
WebUrl = match.Value;
}
OnStartupComplete(WebUrl);
}
}
void HandleExit(int i)
{
Debug.WriteLine($"Venv process exited with code {i}");
OnExit(i);
}
var args = $"\"{Path.Combine(installedPackagePath, command)}\" {arguments}";
VenvRunner?.RunDetached(
args.TrimEnd(),
HandleConsoleOutput,
HandleExit);
}
}

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

@ -98,7 +98,7 @@ public class VladAutomatic : BaseGitPackage
{
Name = "Use DirectML if no compatible GPU is detected",
Type = LaunchOptionType.Bool,
InitialValue = PreferDirectML(),
InitialValue = HardwareHelper.PreferDirectML(),
Options = new() { "--use-directml" }
},
new()
@ -112,7 +112,7 @@ public class VladAutomatic : BaseGitPackage
{
Name = "Force use of AMD ROCm backend",
Type = LaunchOptionType.Bool,
InitialValue = PreferRocm(),
InitialValue = HardwareHelper.PreferRocm(),
Options = new() { "--use-rocm" }
},
new()
@ -138,16 +138,6 @@ public class VladAutomatic : BaseGitPackage
public override string ExtraLaunchArguments => "";
// Set ROCm for default if AMD and Linux
private static bool PreferRocm() => !HardwareHelper.HasNvidiaGpu()
&& HardwareHelper.HasAmdGpu()
&& Compat.IsLinux;
// Set DirectML for default if AMD and Windows
private static bool PreferDirectML() => !HardwareHelper.HasNvidiaGpu()
&& HardwareHelper.HasAmdGpu()
&& Compat.IsWindows;
public override Task<string> GetLatestVersion() => Task.FromResult("master");
public override async Task<IEnumerable<PackageVersion>> GetAllVersions(bool isReleaseMode = true)
@ -177,13 +167,13 @@ public class VladAutomatic : BaseGitPackage
await venvRunner.CustomInstall("launch.py --use-cuda --debug --test", OnConsoleOutput)
.ConfigureAwait(false);
}
else if (PreferRocm())
else if (HardwareHelper.PreferRocm())
{
// ROCm
await venvRunner.CustomInstall("launch.py --use-rocm --debug --test", OnConsoleOutput)
.ConfigureAwait(false);
}
else if (PreferDirectML())
else if (HardwareHelper.PreferDirectML())
{
// DirectML
await venvRunner.CustomInstall("launch.py --use-directml --debug --test", OnConsoleOutput)

3
StabilityMatrix.Core/Models/Progress/ProgressState.cs

@ -5,5 +5,6 @@ public enum ProgressState
Inactive,
Working,
Success,
Failed
Failed,
Cancelled
}

1
StabilityMatrix.Core/Models/Progress/ProgressType.cs

@ -6,4 +6,5 @@ public enum ProgressType
Download,
Extract,
Update,
Hashing,
}

317
StabilityMatrix.Core/Models/TrackedDownload.cs

@ -0,0 +1,317 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using AsyncAwaitBestPractices;
using NLog;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Models;
[JsonSerializable(typeof(TrackedDownload))]
public class TrackedDownload
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
[JsonIgnore]
private IDownloadService? downloadService;
[JsonIgnore]
private Task? downloadTask;
[JsonIgnore]
private CancellationTokenSource? downloadCancellationTokenSource;
[JsonIgnore]
private CancellationTokenSource? downloadPauseTokenSource;
[JsonIgnore]
private CancellationTokenSource AggregateCancellationTokenSource =>
CancellationTokenSource.CreateLinkedTokenSource(
downloadCancellationTokenSource?.Token ?? CancellationToken.None,
downloadPauseTokenSource?.Token ?? CancellationToken.None);
public required Guid Id { get; init; }
public required Uri SourceUrl { get; init; }
public Uri? RedirectedUrl { get; init; }
public required DirectoryPath DownloadDirectory { get; init; }
public required string FileName { get; init; }
public required string TempFileName { get; init; }
public string? ExpectedHashSha256 { get; set; }
[JsonIgnore]
[MemberNotNullWhen(true, nameof(ExpectedHashSha256))]
public bool ValidateHash => ExpectedHashSha256 is not null;
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProgressState ProgressState { get; set; } = ProgressState.Inactive;
public List<string> ExtraCleanupFileNames { get; init; } = new();
// Used for restoring progress on load
public long DownloadedBytes { get; set; }
public long TotalBytes { get; set; }
/// <summary>
/// Optional context action to be invoked on completion
/// </summary>
public IContextAction? ContextAction { get; set; }
[JsonIgnore]
public Exception? Exception { get; private set; }
#region Events
private WeakEventManager<ProgressReport>? progressUpdateEventManager;
public event EventHandler<ProgressReport> ProgressUpdate
{
add
{
progressUpdateEventManager ??= new WeakEventManager<ProgressReport>();
progressUpdateEventManager.AddEventHandler(value);
}
remove => progressUpdateEventManager?.RemoveEventHandler(value);
}
protected void OnProgressUpdate(ProgressReport e)
{
// Update downloaded and total bytes
DownloadedBytes = Convert.ToInt64(e.Current);
TotalBytes = Convert.ToInt64(e.Total);
progressUpdateEventManager?.RaiseEvent(this, e, nameof(ProgressUpdate));
}
private WeakEventManager<ProgressState>? progressStateChangedEventManager;
public event EventHandler<ProgressState> ProgressStateChanged
{
add
{
progressStateChangedEventManager ??= new WeakEventManager<ProgressState>();
progressStateChangedEventManager.AddEventHandler(value);
}
remove => progressStateChangedEventManager?.RemoveEventHandler(value);
}
protected void OnProgressStateChanged(ProgressState e)
{
progressStateChangedEventManager?.RaiseEvent(this, e, nameof(ProgressStateChanged));
}
#endregion
[MemberNotNull(nameof(downloadService))]
private void EnsureDownloadService()
{
if (downloadService == null)
{
throw new InvalidOperationException("Download service is not set.");
}
}
private async Task StartDownloadTask(long resumeFromByte, CancellationToken cancellationToken)
{
var progress = new Progress<ProgressReport>(OnProgressUpdate);
await downloadService!.ResumeDownloadToFileAsync(
SourceUrl.ToString(),
DownloadDirectory.JoinFile(TempFileName),
resumeFromByte,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false);
// If hash validation is enabled, validate the hash
if (ValidateHash)
{
OnProgressUpdate(new ProgressReport(0, isIndeterminate: true, type: ProgressType.Hashing));
var hash = await FileHash.GetSha256Async(DownloadDirectory.JoinFile(TempFileName), progress).ConfigureAwait(false);
if (hash != ExpectedHashSha256?.ToLowerInvariant())
{
throw new Exception($"Hash validation for {FileName} failed, expected {ExpectedHashSha256} but got {hash}");
}
}
}
public void Start()
{
if (ProgressState != ProgressState.Inactive)
{
throw new InvalidOperationException($"Download state must be inactive to start, not {ProgressState}");
}
Logger.Debug("Starting download {Download}", FileName);
EnsureDownloadService();
downloadCancellationTokenSource = new CancellationTokenSource();
downloadPauseTokenSource = new CancellationTokenSource();
downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token)
.ContinueWith(OnDownloadTaskCompleted);
ProgressState = ProgressState.Working;
OnProgressStateChanged(ProgressState);
}
public void Resume()
{
if (ProgressState != ProgressState.Inactive)
{
Logger.Warn("Attempted to resume download {Download} but it is not paused ({State})", FileName, ProgressState);
}
Logger.Debug("Resuming download {Download}", FileName);
// Read the temp file to get the current size
var tempSize = 0L;
var tempFile = DownloadDirectory.JoinFile(TempFileName);
if (tempFile.Exists)
{
tempSize = tempFile.Info.Length;
}
EnsureDownloadService();
downloadCancellationTokenSource = new CancellationTokenSource();
downloadPauseTokenSource = new CancellationTokenSource();
downloadTask = StartDownloadTask(tempSize, AggregateCancellationTokenSource.Token)
.ContinueWith(OnDownloadTaskCompleted);
ProgressState = ProgressState.Working;
OnProgressStateChanged(ProgressState);
}
public void Pause()
{
if (ProgressState != ProgressState.Working)
{
Logger.Warn("Attempted to pause download {Download} but it is not in progress ({State})", FileName, ProgressState);
return;
}
Logger.Debug("Pausing download {Download}", FileName);
downloadPauseTokenSource?.Cancel();
}
public void Cancel()
{
if (ProgressState is not (ProgressState.Working or ProgressState.Inactive))
{
Logger.Warn("Attempted to cancel download {Download} but it is not in progress ({State})", FileName, ProgressState);
return;
}
Logger.Debug("Cancelling download {Download}", FileName);
// Cancel token if it exists
if (downloadCancellationTokenSource is { } token)
{
token.Cancel();
}
// Otherwise handle it manually
else
{
DoCleanup();
ProgressState = ProgressState.Cancelled;
OnProgressStateChanged(ProgressState);
}
}
/// <summary>
/// Deletes the temp file and any extra cleanup files
/// </summary>
private void DoCleanup()
{
try
{
DownloadDirectory.JoinFile(TempFileName).Delete();
}
catch (IOException)
{
Logger.Warn("Failed to delete temp file {TempFile}", TempFileName);
}
foreach (var extraFile in ExtraCleanupFileNames)
{
try
{
DownloadDirectory.JoinFile(extraFile).Delete();
}
catch (IOException)
{
Logger.Warn("Failed to delete extra cleanup file {ExtraFile}", extraFile);
}
}
}
/// <summary>
/// Invoked by the task's completion callback
/// </summary>
private void OnDownloadTaskCompleted(Task task)
{
// For cancelled, check if it was actually cancelled or paused
if (task.IsCanceled)
{
// If the task was cancelled, set the state to cancelled
if (downloadCancellationTokenSource?.IsCancellationRequested == true)
{
ProgressState = ProgressState.Cancelled;
}
// If the task was not cancelled, set the state to paused
else if (downloadPauseTokenSource?.IsCancellationRequested == true)
{
ProgressState = ProgressState.Inactive;
}
else
{
throw new InvalidOperationException("Download task was cancelled but neither cancellation token was cancelled.");
}
}
// For faulted
else if (task.IsFaulted)
{
// Set the exception
Exception = task.Exception;
ProgressState = ProgressState.Failed;
}
// Otherwise success
else
{
ProgressState = ProgressState.Success;
}
// For failed or cancelled, delete the temp files
if (ProgressState is ProgressState.Failed or ProgressState.Cancelled)
{
DoCleanup();
}
else if (ProgressState == ProgressState.Success)
{
// Move the temp file to the final file
DownloadDirectory.JoinFile(TempFileName).MoveTo(DownloadDirectory.JoinFile(FileName));
}
// For pause, just do nothing
OnProgressStateChanged(ProgressState);
// Dispose of the task and cancellation token
downloadTask = null;
downloadCancellationTokenSource = null;
downloadPauseTokenSource = null;
}
public void SetDownloadService(IDownloadService service)
{
downloadService = service;
}
}

175
StabilityMatrix.Core/Services/DownloadService.cs

@ -17,43 +17,66 @@ public class DownloadService : IDownloadService
this.httpClientFactory = httpClientFactory;
}
public async Task DownloadToFileAsync(string downloadUrl, string downloadPath,
IProgress<ProgressReport>? progress = null, string? httpClientName = null)
public async Task DownloadToFileAsync(
string downloadUrl,
string downloadPath,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
)
{
using var client = string.IsNullOrWhiteSpace(httpClientName)
? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName);
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0"));
await using var file = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None);
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await using var file = new FileStream(
downloadPath,
FileMode.Create,
FileAccess.Write,
FileShare.None
);
long contentLength = 0;
var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead);
var response = await client
.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
contentLength = response.Content.Headers.ContentLength ?? 0;
var delays = Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(50), retryCount: 3);
TimeSpan.FromMilliseconds(50),
retryCount: 3
);
foreach (var delay in delays)
{
if (contentLength > 0) break;
if (contentLength > 0)
break;
logger.LogDebug("Retrying get-headers for content-length");
await Task.Delay(delay);
response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
response = await client
.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
contentLength = response.Content.Headers.ContentLength ?? 0;
}
var isIndeterminate = contentLength == 0;
await using var stream = await response.Content.ReadAsStreamAsync();
await using var stream = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
var totalBytesRead = 0L;
var buffer = new byte[BufferSize];
while (true)
{
var bytesRead = await stream.ReadAsync(buffer);
if (bytesRead == 0) break;
await file.WriteAsync(buffer.AsMemory(0, bytesRead));
var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
break;
await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken)
.ConfigureAwait(false);
totalBytesRead += bytesRead;
@ -63,12 +86,124 @@ public class DownloadService : IDownloadService
}
else
{
progress?.Report(new ProgressReport(current: Convert.ToUInt64(totalBytesRead),
total: Convert.ToUInt64(contentLength), message: "Downloading..."));
progress?.Report(
new ProgressReport(
current: Convert.ToUInt64(totalBytesRead),
total: Convert.ToUInt64(contentLength),
message: "Downloading..."
)
);
}
}
await file.FlushAsync();
await file.FlushAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(new ProgressReport(1f, message: "Download complete!"));
}
/// <inheritdoc />
public async Task ResumeDownloadToFileAsync(
string downloadUrl,
string downloadPath,
long existingFileSize,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default)
{
using var client = string.IsNullOrWhiteSpace(httpClientName)
? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName);
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
// Create file if it doesn't exist
if (!File.Exists(downloadPath))
{
logger.LogInformation("Resume file doesn't exist, creating file {DownloadPath}", downloadPath);
File.Create(downloadPath).Close();
}
await using var file = new FileStream(
downloadPath,
FileMode.Append,
FileAccess.Write,
FileShare.None
);
// Remaining content length
long remainingContentLength = 0;
// Total of the original content
long originalContentLength = 0;
using var request = new HttpRequestMessage();
request.Method = HttpMethod.Get;
request.RequestUri = new Uri(downloadUrl);
request.Headers.Range = new RangeHeaderValue(existingFileSize, null);
HttpResponseMessage? response = null;
foreach (var delay in Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(50),
retryCount: 4
))
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
remainingContentLength = response.Content.Headers.ContentLength ?? 0;
originalContentLength = response.Content.Headers.ContentRange?.Length.GetValueOrDefault() ?? 0;
if (remainingContentLength > 0)
break;
logger.LogDebug("Retrying get-headers for content-length");
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
if (response == null)
{
throw new ApplicationException("Response is null");
}
var isIndeterminate = remainingContentLength == 0;
await using var stream = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
var totalBytesRead = 0L;
var buffer = new byte[BufferSize];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
break;
await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken)
.ConfigureAwait(false);
totalBytesRead += bytesRead;
if (isIndeterminate)
{
progress?.Report(new ProgressReport(-1, isIndeterminate: true));
}
else
{
progress?.Report(
new ProgressReport(
// Report the current as session current + original start size
current: Convert.ToUInt64(totalBytesRead + existingFileSize),
// Total as the original total
total: Convert.ToUInt64(originalContentLength),
message: "Downloading..."
)
);
}
}
await file.FlushAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(new ProgressReport(1f, message: "Download complete!"));
}
@ -77,11 +212,13 @@ public class DownloadService : IDownloadService
{
using var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0"));
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
try
{
var response = await client.GetAsync(url);
return await response.Content.ReadAsStreamAsync();
var response = await client.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
}
catch (Exception e)
{

18
StabilityMatrix.Core/Services/IDownloadService.cs

@ -4,8 +4,22 @@ namespace StabilityMatrix.Core.Services;
public interface IDownloadService
{
Task DownloadToFileAsync(string downloadUrl, string downloadPath,
IProgress<ProgressReport>? progress = null, string? httpClientName = null);
Task DownloadToFileAsync(
string downloadUrl,
string downloadPath,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
);
Task ResumeDownloadToFileAsync(
string downloadUrl,
string downloadPath,
long existingFileSize,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
);
Task<Stream> GetImageStreamFromUrl(string url);
}

7
StabilityMatrix.Core/Services/ISettingsManager.cs

@ -13,6 +13,7 @@ public interface ISettingsManager
bool IsLibraryDirSet { get; }
string DatabasePath { get; }
string ModelsDirectory { get; }
string DownloadsDirectory { get; }
DirectoryPath TagsDirectory { get; }
Settings Settings { get; }
@ -27,6 +28,12 @@ public interface ISettingsManager
/// </summary>
event EventHandler<RelayPropertyChangedEventArgs>? SettingsPropertyChanged;
/// <summary>
/// Register a handler that fires once when LibraryDir is first set.
/// Will fire instantly if it is already set.
/// </summary>
void RegisterOnLibraryDirSet(Action<string> handler);
/// <summary>
/// Event fired when Settings are loaded from disk
/// </summary>

15
StabilityMatrix.Core/Services/ITrackedDownloadService.cs

@ -0,0 +1,15 @@
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Core.Services;
public interface ITrackedDownloadService
{
IEnumerable<TrackedDownload> Downloads { get; }
event EventHandler<TrackedDownload>? DownloadAdded;
TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath);
TrackedDownload NewDownload(string downloadUrl, FilePath downloadPath) => NewDownload(new Uri(downloadUrl), downloadPath);
}

22
StabilityMatrix.Core/Services/SettingsManager.cs

@ -56,6 +56,8 @@ public class SettingsManager : ISettingsManager
public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db");
private string SettingsPath => Path.Combine(LibraryDir, "settings.json");
public string ModelsDirectory => Path.Combine(LibraryDir, "Models");
public string DownloadsDirectory => Path.Combine(LibraryDir, ".downloads");
public DirectoryPath TagsDirectory => new(LibraryDir, "Tags");
public Settings Settings { get; private set; } = new();
@ -69,6 +71,26 @@ public class SettingsManager : ISettingsManager
/// <inheritdoc />
public event EventHandler? Loaded;
/// <inheritdoc />
public void RegisterOnLibraryDirSet(Action<string> handler)
{
if (IsLibraryDirSet)
{
handler(LibraryDir);
return;
}
LibraryDirChanged += Handler;
return;
void Handler(object? sender, string dir)
{
LibraryDirChanged -= Handler;
handler(dir);
}
}
/// <inheritdoc />
public SettingsTransaction BeginTransaction()
{

247
StabilityMatrix.Core/Services/TrackedDownloadService.cs

@ -0,0 +1,247 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Core.Services;
public class TrackedDownloadService : ITrackedDownloadService, IDisposable
{
private readonly ILogger<TrackedDownloadService> logger;
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private readonly ConcurrentDictionary<Guid, (TrackedDownload, FileStream)> downloads = new();
public IEnumerable<TrackedDownload> Downloads => downloads.Values.Select(x => x.Item1);
/// <inheritdoc />
public event EventHandler<TrackedDownload>? DownloadAdded;
public TrackedDownloadService(
ILogger<TrackedDownloadService> logger,
IDownloadService downloadService,
ISettingsManager settingsManager)
{
this.logger = logger;
this.downloadService = downloadService;
this.settingsManager = settingsManager;
// Index for in-progress downloads when library dir loaded
settingsManager.RegisterOnLibraryDirSet(path =>
{
var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory);
// Ignore if not exist
if (!downloadsDir.Exists) return;
LoadInProgressDownloads(downloadsDir);
});
}
private void OnDownloadAdded(TrackedDownload download)
{
DownloadAdded?.Invoke(this, download);
}
/// <summary>
/// Creates a new tracked download with backed json file and adds it to the dictionary.
/// </summary>
/// <param name="download"></param>
private void AddDownload(TrackedDownload download)
{
// Set download service
download.SetDownloadService(downloadService);
// Create json file
var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory);
downloadsDir.Create();
var jsonFile = downloadsDir.JoinFile($"{download.Id}.json");
var jsonFileStream = jsonFile.Info.Open(FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read);
// Serialize to json
var json = JsonSerializer.Serialize(download);
jsonFileStream.Write(Encoding.UTF8.GetBytes(json));
jsonFileStream.Flush();
// Add to dictionary
downloads.TryAdd(download.Id, (download, jsonFileStream));
// Connect to state changed event to update json file
AttachHandlers(download);
logger.LogDebug("Added download {Download}", download.FileName);
OnDownloadAdded(download);
}
/// <summary>
/// Update the json file for the download.
/// </summary>
private void UpdateJsonForDownload(TrackedDownload download)
{
// Serialize to json
var json = JsonSerializer.Serialize(download);
var jsonBytes = Encoding.UTF8.GetBytes(json);
// Write to file
var (_, fs) = downloads[download.Id];
fs.Seek(0, SeekOrigin.Begin);
fs.Write(jsonBytes);
fs.Flush();
}
private void AttachHandlers(TrackedDownload download)
{
download.ProgressStateChanged += TrackedDownload_OnProgressStateChanged;
}
/// <summary>
/// Handler when the download's state changes
/// </summary>
private void TrackedDownload_OnProgressStateChanged(object? sender, ProgressState e)
{
if (sender is not TrackedDownload download)
{
return;
}
// Update json file
UpdateJsonForDownload(download);
// If the download is completed, remove it from the dictionary and delete the json file
if (e is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled)
{
if (downloads.TryRemove(download.Id, out var downloadInfo))
{
downloadInfo.Item2.Dispose();
// Delete json file
new DirectoryPath(settingsManager.DownloadsDirectory).JoinFile($"{download.Id}.json").Delete();
logger.LogDebug("Removed download {Download}", download.FileName);
}
}
// On successes, run the continuation action
if (e == ProgressState.Success)
{
if (download.ContextAction is CivitPostDownloadContextAction action)
{
logger.LogDebug("Running context action for {Download}", download.FileName);
action.Invoke(settingsManager);
}
}
}
private void LoadInProgressDownloads(DirectoryPath downloadsDir)
{
logger.LogDebug("Indexing in-progress downloads at {DownloadsDir}...", downloadsDir);
var jsonFiles = downloadsDir.Info.EnumerateFiles("*.json", SearchOption.TopDirectoryOnly);
// Add to dictionary, the file name is the guid
foreach (var file in jsonFiles)
{
// Try to get a shared write handle
try
{
var fileStream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
// Deserialize json and add to dictionary
var download = JsonSerializer.Deserialize<TrackedDownload>(fileStream)!;
// If the download is marked as working, pause it
if (download.ProgressState == ProgressState.Working)
{
download.ProgressState = ProgressState.Inactive;
}
else if (download.ProgressState != ProgressState.Inactive)
{
// If the download is not inactive, skip it
logger.LogWarning("Skipping download {Download} with state {State}", download.FileName, download.ProgressState);
fileStream.Dispose();
// Delete json file
logger.LogDebug("Deleting json file for {Download} with unsupported state", download.FileName);
file.Delete();
continue;
}
download.SetDownloadService(downloadService);
downloads.TryAdd(download.Id, (download, fileStream));
AttachHandlers(download);
OnDownloadAdded(download);
logger.LogDebug("Loaded in-progress download {Download}", download.FileName);
}
catch (Exception e)
{
logger.LogInformation(e, "Could not open file {File} for reading", file.Name);
}
}
}
public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath)
{
var download = new TrackedDownload
{
Id = Guid.NewGuid(),
SourceUrl = downloadUrl,
DownloadDirectory = downloadPath.Directory!,
FileName = downloadPath.Name,
TempFileName = NewTempFileName(downloadPath.Directory!),
};
AddDownload(download);
return download;
}
/// <summary>
/// Generate a new temp file name that is unique in the given directory.
/// In format of "Unconfirmed {id}.smdownload"
/// </summary>
/// <param name="parentDir"></param>
/// <returns></returns>
private static string NewTempFileName(DirectoryPath parentDir)
{
FilePath? tempFile = null;
for (var i = 0; i < 10; i++)
{
if (tempFile is {Exists: false})
{
return tempFile.Name;
}
var id = Random.Shared.Next(1000000, 9999999);
tempFile = parentDir.JoinFile($"Unconfirmed {id}.smdownload");
}
throw new Exception("Failed to generate a unique temp file name.");
}
/// <inheritdoc />
public void Dispose()
{
foreach (var (download, fs) in downloads.Values)
{
if (download.ProgressState == ProgressState.Working)
{
try
{
download.Pause();
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to pause download {Download}", download.FileName);
}
}
}
GC.SuppressFinalize(this);
}
}
Loading…
Cancel
Save