Browse Source

Merge branch 'main' into installed-checkpoints-page

pull/109/head
JT 1 year ago committed by GitHub
parent
commit
fc059fb628
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      CHANGELOG.md
  2. 1
      StabilityMatrix.Avalonia/App.axaml
  3. 7
      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. 271
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
  13. 86
      StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs
  14. 8
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  15. 12
      StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs
  16. 78
      StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs
  17. 31
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  18. 206
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  19. 10
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  20. 34
      StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs
  21. 4
      StabilityMatrix.Core/Helper/FileHash.cs
  22. 44
      StabilityMatrix.Core/Models/CivitPostDownloadContextAction.cs
  23. 8
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  24. 22
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  25. 9
      StabilityMatrix.Core/Models/IContextAction.cs
  26. 3
      StabilityMatrix.Core/Models/Progress/ProgressState.cs
  27. 1
      StabilityMatrix.Core/Models/Progress/ProgressType.cs
  28. 317
      StabilityMatrix.Core/Models/TrackedDownload.cs
  29. 183
      StabilityMatrix.Core/Services/DownloadService.cs
  30. 18
      StabilityMatrix.Core/Services/IDownloadService.cs
  31. 8
      StabilityMatrix.Core/Services/ISettingsManager.cs
  32. 15
      StabilityMatrix.Core/Services/ITrackedDownloadService.cs
  33. 23
      StabilityMatrix.Core/Services/SettingsManager.cs
  34. 247
      StabilityMatrix.Core/Services/TrackedDownloadService.cs

7
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2
### 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
@ -21,7 +22,13 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2
### Fixed
- Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks
## 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

@ -28,5 +28,6 @@
<StyleInclude Source="Styles/ProgressRing.axaml"/>
<StyleInclude Source="Styles/ButtonStyles.axaml"/>
<StyleInclude Source="Styles/SplitButtonStyles.axaml"/>
<StyleInclude Source="Styles/ToggleButtonStyles.axaml"/>
</Application.Styles>
</Application>

7
StabilityMatrix.Avalonia/App.axaml.cs

@ -211,7 +211,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 =
{
@ -336,6 +337,10 @@ public sealed class App : Application
services.AddSingleton<IUpdateHelper, UpdateHelper>();
services.AddSingleton<INavigationService, NavigationService>();
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

@ -91,7 +91,8 @@ public static class DesignData
.AddSingleton<ISharedFolders, MockSharedFolders>()
.AddSingleton<IDownloadService, MockDownloadService>()
.AddSingleton<IHttpClientFactory, MockHttpClientFactory>()
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>();
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>()
.AddSingleton<ITrackedDownloadService, MockTrackedDownloadService>();
// Placeholder services that nobody should need during design time
services
@ -257,13 +258,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();
}

271
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)
{
@ -204,164 +253,96 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
Text = "Downloading...";
OnDownloadStart?.Invoke(this);
// 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;
}
// Holds files to be deleted on errors
var filesForCleanup = new HashSet<FilePath>();
// 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;
}
var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory);
var downloadDirectory =
rootModelsDirectory.JoinDir(model.Type.ConvertTo<SharedFolderType>()
.GetStringValue());
// Folders might be missing if user didn't install any packages yet
downloadDirectory.Create();
var downloadPath = downloadDirectory.JoinFile(modelFile.Name);
// Get latest version file
var modelFile = selectedFile ??
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
if (modelFile is null)
// 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);
// Add hash info
download.ExpectedHashSha256 = modelFile.Hashes.SHA256;
// Add files to cleanup list
download.ExtraCleanupFileNames.Add(cmInfoPath);
if (previewImagePath is not null)
{
download.ExtraCleanupFileNames.Add(previewImagePath);
}
// Attach for progress updates
download.ProgressUpdate += (s, e) =>
{
Value = e.Percentage;
if (e.Type == ProgressType.Hashing)
{
notificationService.Show(new Notification("Model has no files available",
"This model has no files available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
Text = $"Validating... {e.Percentage}%";
}
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)
else
{
// 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");
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
Text = $"Downloading... {e.Percentage}%";
}
};
// When sha256 is available, validate the downloaded file
var fileExpectedSha256 = modelFile.Hashes.SHA256;
if (!string.IsNullOrEmpty(fileExpectedSha256))
download.ProgressStateChanged += (s, e) =>
{
if (e == ProgressState.Success)
{
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));
Text = "Import Complete";
IsIndeterminate = false;
Value = 100;
CheckIfInstalled();
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
}
IsIndeterminate = true;
// 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);
// If available, save a model image
if (modelVersion.Images != null && modelVersion.Images.Any())
else if (e == ProgressState.Cancelled)
{
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 = "Cancelled";
DelayedClearProgress(TimeSpan.FromMilliseconds(500));
}
// 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
{
foreach (var file in filesForCleanup.Where(file => file.Exists))
else if (e == ProgressState.Failed)
{
file.Delete();
Logger.Info($"Download cleanup: Deleted file {file}");
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)

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,17 +24,72 @@ 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;
}
public ProgressManagerViewModel()
private void TrackedDownloadService_OnDownloadAdded(object? sender, TrackedDownload e)
{
ProgressItems = new ObservableCollection<ProgressItemViewModel>();
var vm = new DownloadProgressItemViewModel(e);
// Attach notification handlers
e.ProgressStateChanged += (s, state) =>
{
var download = s as TrackedDownload;
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)
{
notificationService.ShowPersistent(title, message, NotificationType.Error);
}
public void StartEventListener()
{
EventManager.Instance.ProgressChanged += OnProgressChanged;
@ -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)

31
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -46,6 +46,7 @@ public partial class SettingsViewModel : PageViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ITrackedDownloadService trackedDownloadService;
public SharedState SharedState { get; }
@ -104,13 +105,15 @@ public partial class SettingsViewModel : PageViewModelBase
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory,
SharedState sharedState)
SharedState sharedState,
ITrackedDownloadService trackedDownloadService)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
SharedState = sharedState;
@ -451,6 +454,32 @@ public partial class SettingsViewModel : PageViewModelBase
// Use try-catch to generate traceback information
throw new OperationCanceledException("Example Message");
}
[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

206
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -1,46 +1,156 @@
<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}" />
<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">
<DoubleTransition.Easing>
<SineEaseInOut />
</DoubleTransition.Easing>
</DoubleTransition>
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
</Grid>
</Border>
</DataTemplate>
<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">
<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>
<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 +161,11 @@
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
<!-- 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 ProgressText, Mode=OneWay}" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ItemsControl.DataTemplates>
</ItemsControl>
</Grid>
</ScrollViewer>
</controls:UserControlBase>

10
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -267,6 +267,16 @@
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Content="Download Manager tests" IconSource="Flag"
Margin="4,0,4,4">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0, 8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Grid>

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

@ -0,0 +1,34 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Converters.Json;
/// <summary>
/// Json converter for types that serialize to string by `ToString()` and
/// can be created by `Activator.CreateInstance(Type, string)`
/// </summary>
public class StringJsonConverter<T> : JsonConverter<T>
{
/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException();
}
var value = reader.GetString();
if (value is null)
{
throw new JsonException();
}
return (T?) Activator.CreateInstance(typeToConvert, value);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
writer.WriteStringValue(value?.ToString());
}
}

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

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

22
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,12 +123,22 @@ 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>
public async Task<FilePath> MoveToAsync(DirectoryPath directory)
{
await Task.Run(() => Info.MoveTo(directory.FullPath));
await Task.Run(() => Info.MoveTo(directory.FullPath)).ConfigureAwait(false);
// Return the new path
return directory.JoinFile(this);
}
@ -130,7 +148,7 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary>
public async Task<FilePath> MoveToAsync(FilePath destinationFile)
{
await Task.Run(() => Info.MoveTo(destinationFile.FullPath));
await Task.Run(() => Info.MoveTo(destinationFile.FullPath)).ConfigureAwait(false);
// Return the new path
return destinationFile;
}

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

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

183
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,13 +86,125 @@ 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);
}

8
StabilityMatrix.Core/Services/ISettingsManager.cs

@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Linq.Expressions;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Settings;
namespace StabilityMatrix.Core.Services;
@ -12,10 +13,17 @@ public interface ISettingsManager
bool IsLibraryDirSet { get; }
string DatabasePath { get; }
string ModelsDirectory { get; }
string DownloadsDirectory { get; }
Settings Settings { get; }
event EventHandler<string>? LibraryDirChanged;
event EventHandler<PropertyChangedEventArgs>? 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);
/// <inheritdoc />
SettingsTransaction BeginTransaction();

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

23
StabilityMatrix.Core/Services/SettingsManager.cs

@ -49,12 +49,33 @@ 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 Settings Settings { get; private set; } = new();
public event EventHandler<string>? LibraryDirChanged;
public event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged;
/// <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