JT
1 year ago
committed by
GitHub
34 changed files with 1961 additions and 258 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
@ -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(); |
||||||
|
} |
||||||
|
} |
@ -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> |
@ -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(); |
||||||
|
} |
||||||
|
} |
@ -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(); |
||||||
|
} |
@ -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; |
||||||
|
} |
||||||
|
} |
@ -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()); |
||||||
|
} |
||||||
|
} |
@ -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); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
using System.Text.Json.Serialization; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
[JsonDerivedType(typeof(CivitPostDownloadContextAction), "CivitPostDownload")] |
||||||
|
public interface IContextAction |
||||||
|
{ |
||||||
|
object? Context { get; set; } |
||||||
|
} |
@ -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; |
||||||
|
} |
||||||
|
} |
@ -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); |
||||||
|
} |
@ -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…
Reference in new issue