Browse Source

Merge branch 'main' of https://github.com/ionite34/StabilityMatrix

pull/78/head v2.3.0
Ionite 1 year ago
parent
commit
7019817728
No known key found for this signature in database
  1. 26
      CHANGELOG.md
  2. 18
      README.md
  3. 1
      StabilityMatrix.Avalonia/App.axaml
  4. 36
      StabilityMatrix.Avalonia/App.axaml.cs
  5. 408
      StabilityMatrix.Avalonia/Controls/AutoGrid.cs
  6. 21
      StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
  7. 70
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  8. 65
      StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs
  9. 13
      StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs
  10. 22
      StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs
  11. 91
      StabilityMatrix.Avalonia/Extensions/DirectoryPathExtensions.cs
  12. 52
      StabilityMatrix.Avalonia/Languages/Cultures.cs
  13. 206
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  14. 23
      StabilityMatrix.Avalonia/Languages/Resources.ja-JP.resx
  15. 69
      StabilityMatrix.Avalonia/Languages/Resources.resx
  16. 92
      StabilityMatrix.Avalonia/Services/ServiceManager.cs
  17. 18
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  18. 42
      StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml
  19. 347
      StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml
  20. 49
      StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs
  21. 16
      StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs
  22. 271
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
  23. 93
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
  24. 109
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs
  25. 4
      StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs
  26. 64
      StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
  27. 8
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs
  28. 61
      StabilityMatrix.Avalonia/ViewModels/Dialogs/LaunchOptionsViewModel.cs
  29. 6
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  30. 221
      StabilityMatrix.Avalonia/ViewModels/Dialogs/PackageImportViewModel.cs
  31. 7
      StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
  32. 86
      StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs
  33. 6
      StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs
  34. 8
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  35. 221
      StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs
  36. 187
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  37. 118
      StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
  38. 18
      StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs
  39. 78
      StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs
  40. 122
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  41. 30
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml
  42. 40
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  43. 3
      StabilityMatrix.Avalonia/Views/Dialogs/InstallerDialog.axaml
  44. 3
      StabilityMatrix.Avalonia/Views/Dialogs/LaunchOptionsDialog.axaml
  45. 51
      StabilityMatrix.Avalonia/Views/Dialogs/PackageImportDialog.axaml
  46. 17
      StabilityMatrix.Avalonia/Views/Dialogs/PackageImportDialog.axaml.cs
  47. 4
      StabilityMatrix.Avalonia/Views/Dialogs/SelectDataDirectoryDialog.axaml
  48. 105
      StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml
  49. 11
      StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs
  50. 55
      StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml
  51. 206
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  52. 675
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  53. 34
      StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs
  54. 4
      StabilityMatrix.Core/Helper/FileHash.cs
  55. 10
      StabilityMatrix.Core/Helper/HardwareHelper.cs
  56. 24
      StabilityMatrix.Core/Helper/PropertyComparer.cs
  57. 4
      StabilityMatrix.Core/Models/Api/CivitModelFpType.cs
  58. 16
      StabilityMatrix.Core/Models/Api/CivitModelType.cs
  59. 18
      StabilityMatrix.Core/Models/Api/CivitModelsRequest.cs
  60. 4
      StabilityMatrix.Core/Models/Api/CivitSortMode.cs
  61. 44
      StabilityMatrix.Core/Models/CivitPostDownloadContextAction.cs
  62. 8
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  63. 22
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  64. 9
      StabilityMatrix.Core/Models/IContextAction.cs
  65. 5
      StabilityMatrix.Core/Models/InstalledPackage.cs
  66. 9
      StabilityMatrix.Core/Models/LaunchOptionCard.cs
  67. 12
      StabilityMatrix.Core/Models/Packages/BaseGitPackage.cs
  68. 7
      StabilityMatrix.Core/Models/Packages/BasePackage.cs
  69. 122
      StabilityMatrix.Core/Models/Packages/Fooocus.cs
  70. 2
      StabilityMatrix.Core/Models/Packages/InvokeAI.cs
  71. 105
      StabilityMatrix.Core/Models/Packages/UnknownPackage.cs
  72. 77
      StabilityMatrix.Core/Models/Packages/VladAutomatic.cs
  73. 3
      StabilityMatrix.Core/Models/Progress/ProgressState.cs
  74. 1
      StabilityMatrix.Core/Models/Progress/ProgressType.cs
  75. 3
      StabilityMatrix.Core/Models/Settings/Settings.cs
  76. 317
      StabilityMatrix.Core/Models/TrackedDownload.cs
  77. 17
      StabilityMatrix.Core/Models/UnknownInstalledPackage.cs
  78. 183
      StabilityMatrix.Core/Services/DownloadService.cs
  79. 18
      StabilityMatrix.Core/Services/IDownloadService.cs
  80. 8
      StabilityMatrix.Core/Services/ISettingsManager.cs
  81. 15
      StabilityMatrix.Core/Services/ITrackedDownloadService.cs
  82. 23
      StabilityMatrix.Core/Services/SettingsManager.cs
  83. 247
      StabilityMatrix.Core/Services/TrackedDownloadService.cs
  84. 5
      StabilityMatrix.Tests/Avalonia/DesignDataTests.cs

26
CHANGELOG.md

@ -5,11 +5,37 @@ All notable changes to Stability Matrix will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).
## v2.3.0
### Added
- New installable Package - [Fooocus](https://github.com/lllyasviel/Fooocus)
- Added "Select New Data Directory" button to Settings
- Added "Skip to First/Last Page" buttons to the Model Browser
- Added VAE as a checkpoint category in the Model Browser
- Pause/Resume/Cancel buttons on downloads popup. Paused downloads persists and may be resumed after restarting the app
- Unknown Package installs in the Package directory will now show up with a button to import them
### Fixed
- Fixed issue where model version wouldn't be selected in the "All Versions" section of the Model Browser
- Improved Checkpoints page indexing performance
- Fixed issue where Checkpoints page may not show all checkpoints after clearing search filter
- Fixed issue where Checkpoints page may show incorrect checkpoints for the given filter after changing pages
- Fixed issue where Open Web UI button would try to load 0.0.0.0 addresses
- Fixed Dictionary error when launch arguments saved with duplicate arguments
- Fixed Launch arguments search not working
### Changed
- Changed update method for SD.Next to use the built-in upgrade functionality
- Model Browser navigation buttons are no longer disabled while changing pages
## v2.2.1
### Fixed
- Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks
## v2.2.1 ## v2.2.1
### Fixed ### Fixed
- Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks - Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks
## v2.2.0 ## v2.2.0
### Added ### Added
- Added option to search by Base Model in the Model Browser - Added option to search by Base Model in the Model Browser
- Animated page transitions - Animated page transitions

18
README.md

@ -13,23 +13,27 @@
[sdnext]: https://github.com/vladmandic/automatic [sdnext]: https://github.com/vladmandic/automatic
[voltaml]: https://github.com/VoltaML/voltaML-fast-stable-diffusion [voltaml]: https://github.com/VoltaML/voltaML-fast-stable-diffusion
[invokeai]: https://github.com/invoke-ai/InvokeAI [invokeai]: https://github.com/invoke-ai/InvokeAI
[fooocus]: https://github.com/lllyasviel/Fooocus
[civitai]: https://civitai.com/ [civitai]: https://civitai.com/
Multi-Platform Package Manager for Stable Diffusion Multi-Platform Package Manager for Stable Diffusion
### 🖱 One click install and update for Stable Diffusion Web UI Packages ### 🖱 One click install and update for Stable Diffusion Web UI Packages
- Supports [Automatic 1111][auto1111], [Comfy UI][comfy], [SD.Next (Vladmandic)][sdnext], [VoltaML][voltaml], [InvokeAI][invokeai] - Supports [Automatic 1111][auto1111], [Comfy UI][comfy], [SD.Next (Vladmandic)][sdnext], [VoltaML][voltaml], [InvokeAI][invokeai], and [Fooocus][fooocus]
- Embedded Git and Python dependencies, with no need for either to be globally installed - Embedded Git and Python dependencies, with no need for either to be globally installed
- Fully Portable, move Stability Matrix's Data Directory to a new drive or computer at any time - Fully portable - move Stability Matrix's Data Directory to a new drive or computer at any time
### 🚀 Launcher with syntax highlighted terminal emulator, routed GUI input prompts ### 🚀 Launcher with syntax highlighted terminal emulator, routed GUI input prompts
- Launch arguments editor with predefined or custom options for each Package install - Launch arguments editor with predefined or custom options for each Package install
- Package environment variables - Configurable Environment Variables
### 🗃 Checkpoint Manager, configured to be shared by all Package installs ### 🗃 Checkpoint Manager, configured to be shared by all Package installs
- Option to find CivitAI metadata and preview thumbnails for new local imports - Option to find CivitAI metadata and preview thumbnails for new local imports
### ☁ Model Browser to import from [CivitAI][civitai] ### ☁ Model Browser to import from [CivitAI][civitai]
- Automatically imports to the associated model folder depending on the model type - Automatically imports to the associated model folder depending on the model type
- Also downloads relavent metadata files and preview image - Downloads relevant metadata files and preview image
![header](https://github.com/LykosAI/StabilityMatrix/assets/13956642/a9c5f925-8561-49ba-855b-1b7bf57d7c0d) ![header](https://github.com/LykosAI/StabilityMatrix/assets/13956642/a9c5f925-8561-49ba-855b-1b7bf57d7c0d)
@ -48,17 +52,17 @@ Multi-Platform Package Manager for Stable Diffusion
### Model browser powered by [Civit AI][civitai] ### Model browser powered by [Civit AI][civitai]
- Downloads new models, automatically uses the appropriate shared model directory - Downloads new models, automatically uses the appropriate shared model directory
- Available immediately to all installed packages - Pause and resume downloads, even after closing the app
<p align="center"> <p align="center">
<img style="width: 80%; height: 80%" src="https://github.com/LykosAI/StabilityMatrix/assets/13956642/30b9f610-6033-4307-8d92-7d72b93cd73e" alt=""/> <img style="width: 80%; height: 80%" src="https://github.com/LykosAI/StabilityMatrix/assets/13956642/30b9f610-6033-4307-8d92-7d72b93cd73e" alt=""/>
</p> </p>
### Shared model directory for all your packages ### Shared model directory for all your packages
- Import local models by simple drag and drop - Import local models by simple drag and drop
- Option to find CivitAI metadata and preview thumbnails for new local imports
- Toggle visibility of categories like LoRA, VAE, CLIP, etc. - Toggle visibility of categories like LoRA, VAE, CLIP, etc.
- For models imported from Civit AI, shows additional information like version, fp precision, and preview thumbnail on hover
<p align="center"> <p align="center">
<img style="width: 80%; height: 80%" src="https://github.com/LykosAI/StabilityMatrix/assets/13956642/d42d1c53-67a4-45a0-b009-21400d44e17e" alt=""/> <img style="width: 80%; height: 80%" src="https://github.com/LykosAI/StabilityMatrix/assets/13956642/d42d1c53-67a4-45a0-b009-21400d44e17e" alt=""/>
</p> </p>

1
StabilityMatrix.Avalonia/App.axaml

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

36
StabilityMatrix.Avalonia/App.axaml.cs

@ -34,11 +34,13 @@ using Sentry;
using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.DesignData; using StabilityMatrix.Avalonia.DesignData;
using StabilityMatrix.Avalonia.Helpers; using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.PackageManager; using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views;
@ -57,8 +59,6 @@ using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater; using StabilityMatrix.Core.Updater;
using Application = Avalonia.Application; using Application = Avalonia.Application;
using CheckpointFile = StabilityMatrix.Avalonia.ViewModels.CheckpointManager.CheckpointFile;
using CheckpointFolder = StabilityMatrix.Avalonia.ViewModels.CheckpointManager.CheckpointFolder;
using LogLevel = Microsoft.Extensions.Logging.LogLevel; using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace StabilityMatrix.Avalonia; namespace StabilityMatrix.Avalonia;
@ -194,7 +194,12 @@ public sealed class App : Application
Services = services.BuildServiceProvider(); Services = services.BuildServiceProvider();
var settingsManager = Services.GetRequiredService<ISettingsManager>(); var settingsManager = Services.GetRequiredService<ISettingsManager>();
settingsManager.TryFindLibrary();
if (settingsManager.TryFindLibrary())
{
Cultures.TrySetSupportedCulture(settingsManager.Settings.Language);
}
Services.GetRequiredService<ProgressManagerViewModel>().StartEventListener(); Services.GetRequiredService<ProgressManagerViewModel>().StartEventListener();
} }
@ -204,13 +209,15 @@ public sealed class App : Application
.AddSingleton<SettingsViewModel>() .AddSingleton<SettingsViewModel>()
.AddSingleton<CheckpointBrowserViewModel>() .AddSingleton<CheckpointBrowserViewModel>()
.AddSingleton<CheckpointsPageViewModel>() .AddSingleton<CheckpointsPageViewModel>()
.AddSingleton<NewCheckpointsPageViewModel>()
.AddSingleton<LaunchPageViewModel>() .AddSingleton<LaunchPageViewModel>()
.AddSingleton<ProgressManagerViewModel>(); .AddSingleton<ProgressManagerViewModel>();
services.AddSingleton<MainWindowViewModel>(provider => services.AddSingleton<MainWindowViewModel>(provider =>
new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(), new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(),
provider.GetRequiredService<IDiscordRichPresenceService>(), provider.GetRequiredService<IDiscordRichPresenceService>(),
provider.GetRequiredService<ServiceManager<ViewModelBase>>()) provider.GetRequiredService<ServiceManager<ViewModelBase>>(),
provider.GetRequiredService<ITrackedDownloadService>())
{ {
Pages = Pages =
{ {
@ -240,14 +247,16 @@ public sealed class App : Application
services.AddTransient<LaunchOptionsViewModel>(); services.AddTransient<LaunchOptionsViewModel>();
services.AddTransient<ExceptionViewModel>(); services.AddTransient<ExceptionViewModel>();
services.AddTransient<EnvVarsViewModel>(); services.AddTransient<EnvVarsViewModel>();
services.AddTransient<PackageImportViewModel>();
// Dialog view models (singleton)
services.AddSingleton<FirstLaunchSetupViewModel>(); services.AddSingleton<FirstLaunchSetupViewModel>();
services.AddSingleton<UpdateViewModel>(); services.AddSingleton<UpdateViewModel>();
// Other transients (usually sub view models) // Other transients (usually sub view models)
services.AddTransient<CheckpointFolder>() services.AddTransient<CheckpointFolder>();
.AddTransient<CheckpointFile>() services.AddTransient<CheckpointFile>();
.AddTransient<CheckpointBrowserCardViewModel>(); services.AddTransient<CheckpointBrowserCardViewModel>();
services.AddTransient<PackageCardViewModel>(); services.AddTransient<PackageCardViewModel>();
// Global progress // Global progress
@ -273,7 +282,9 @@ public sealed class App : Application
.Register(provider.GetRequiredService<ExceptionViewModel>) .Register(provider.GetRequiredService<ExceptionViewModel>)
.Register(provider.GetRequiredService<EnvVarsViewModel>) .Register(provider.GetRequiredService<EnvVarsViewModel>)
.Register(provider.GetRequiredService<ProgressManagerViewModel>) .Register(provider.GetRequiredService<ProgressManagerViewModel>)
.Register(provider.GetRequiredService<FirstLaunchSetupViewModel>)); .Register(provider.GetRequiredService<FirstLaunchSetupViewModel>)
.Register(provider.GetRequiredService<PackageImportViewModel>)
);
} }
internal static void ConfigureViews(IServiceCollection services) internal static void ConfigureViews(IServiceCollection services)
@ -285,6 +296,7 @@ public sealed class App : Application
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddSingleton<CheckpointBrowserPage>(); services.AddSingleton<CheckpointBrowserPage>();
services.AddSingleton<ProgressManagerPage>(); services.AddSingleton<ProgressManagerPage>();
services.AddSingleton<NewCheckpointsPage>();
// Dialogs // Dialogs
services.AddTransient<SelectDataDirectoryDialog>(); services.AddTransient<SelectDataDirectoryDialog>();
@ -292,6 +304,7 @@ public sealed class App : Application
services.AddTransient<UpdateDialog>(); services.AddTransient<UpdateDialog>();
services.AddTransient<ExceptionDialog>(); services.AddTransient<ExceptionDialog>();
services.AddTransient<EnvVarsDialog>(); services.AddTransient<EnvVarsDialog>();
services.AddTransient<PackageImportDialog>();
// Controls // Controls
services.AddTransient<RefreshBadge>(); services.AddTransient<RefreshBadge>();
@ -308,6 +321,7 @@ public sealed class App : Application
services.AddSingleton<BasePackage, ComfyUI>(); services.AddSingleton<BasePackage, ComfyUI>();
services.AddSingleton<BasePackage, VoltaML>(); services.AddSingleton<BasePackage, VoltaML>();
services.AddSingleton<BasePackage, InvokeAI>(); services.AddSingleton<BasePackage, InvokeAI>();
services.AddSingleton<BasePackage, Fooocus>();
} }
private static IServiceCollection ConfigureServices() private static IServiceCollection ConfigureServices()
@ -333,6 +347,10 @@ public sealed class App : Application
services.AddSingleton<IUpdateHelper, UpdateHelper>(); services.AddSingleton<IUpdateHelper, UpdateHelper>();
services.AddSingleton<INavigationService, NavigationService>(); services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<ITrackedDownloadService, TrackedDownloadService>();
services.AddSingleton<IDisposable>(provider =>
(IDisposable) provider.GetRequiredService<ITrackedDownloadService>());
// Rich presence // Rich presence
services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>(); services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>();
services.AddSingleton<IDisposable>(provider => services.AddSingleton<IDisposable>(provider =>

408
StabilityMatrix.Avalonia/Controls/AutoGrid.cs

@ -0,0 +1,408 @@
// Modified from https://github.com/AvaloniaUI/AvaloniaAutoGrid
/*The MIT License (MIT)
Copyright (c) 2013 Charles Brown (carbonrobot)
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Layout;
namespace StabilityMatrix.Avalonia.Controls;
/// <summary>
/// Defines a flexible grid area that consists of columns and rows.
/// Depending on the orientation, either the rows or the columns are auto-generated,
/// and the children's position is set according to their index.
/// </summary>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class AutoGrid : Grid
{
/// <summary>
/// Gets or sets the child horizontal alignment.
/// </summary>
/// <value>The child horizontal alignment.</value>
[Category("Layout"), Description("Presets the horizontal alignment of all child controls")]
public HorizontalAlignment? ChildHorizontalAlignment
{
get => (HorizontalAlignment?)GetValue(ChildHorizontalAlignmentProperty);
set => SetValue(ChildHorizontalAlignmentProperty, value);
}
/// <summary>
/// Gets or sets the child margin.
/// </summary>
/// <value>The child margin.</value>
[Category("Layout"), Description("Presets the margin of all child controls")]
public Thickness? ChildMargin
{
get => (Thickness?)GetValue(ChildMarginProperty);
set => SetValue(ChildMarginProperty, value);
}
/// <summary>
/// Gets or sets the child vertical alignment.
/// </summary>
/// <value>The child vertical alignment.</value>
[Category("Layout"), Description("Presets the vertical alignment of all child controls")]
public VerticalAlignment? ChildVerticalAlignment
{
get => (VerticalAlignment?)GetValue(ChildVerticalAlignmentProperty);
set => SetValue(ChildVerticalAlignmentProperty, value);
}
/// <summary>
/// Gets or sets the column count
/// </summary>
[Category("Layout"), Description("Defines a set number of columns")]
public int ColumnCount
{
get => (int)GetValue(ColumnCountProperty)!;
set => SetValue(ColumnCountProperty, value);
}
/// <summary>
/// Gets or sets the fixed column width
/// </summary>
[Category("Layout"), Description("Presets the width of all columns set using the ColumnCount property")]
public GridLength ColumnWidth
{
get => (GridLength)GetValue(ColumnWidthProperty)!;
set => SetValue(ColumnWidthProperty, value);
}
/// <summary>
/// Gets or sets a value indicating whether the children are automatically indexed.
/// <remarks>
/// The default is <c>true</c>.
/// Note that if children are already indexed, setting this property to <c>false</c> will not remove their indices.
/// </remarks>
/// </summary>
[Category("Layout"), Description("Set to false to disable the auto layout functionality")]
public bool IsAutoIndexing
{
get => (bool)GetValue(IsAutoIndexingProperty)!;
set => SetValue(IsAutoIndexingProperty, value);
}
/// <summary>
/// Gets or sets the orientation.
/// <remarks>The default is Vertical.</remarks>
/// </summary>
/// <value>The orientation.</value>
[Category("Layout"), Description("Defines the directionality of the autolayout. Use vertical for a column first layout, horizontal for a row first layout.")]
public Orientation Orientation
{
get => (Orientation)GetValue(OrientationProperty)!;
set => SetValue(OrientationProperty, value);
}
/// <summary>
/// Gets or sets the number of rows
/// </summary>
[Category("Layout"), Description("Defines a set number of rows")]
public int RowCount
{
get => (int)GetValue(RowCountProperty)!;
set => SetValue(RowCountProperty, value);
}
/// <summary>
/// Gets or sets the fixed row height
/// </summary>
[Category("Layout"), Description("Presets the height of all rows set using the RowCount property")]
public GridLength RowHeight
{
get => (GridLength)GetValue(RowHeightProperty)!;
set => SetValue(RowHeightProperty, value);
}
/// <summary>
/// Handles the column count changed event
/// </summary>
public static void ColumnCountChanged(AvaloniaPropertyChangedEventArgs e)
{
if ((int)e.NewValue! < 0)
return;
var grid = (AutoGrid)e.Sender;
// look for an existing column definition for the height
var width = grid.ColumnWidth;
if (!grid.IsSet(ColumnWidthProperty) && grid.ColumnDefinitions.Count > 0)
width = grid.ColumnDefinitions[0].Width;
// clear and rebuild
grid.ColumnDefinitions.Clear();
for (var i = 0; i < (int)e.NewValue; i++)
grid.ColumnDefinitions.Add(
new ColumnDefinition() { Width = width });
}
/// <summary>
/// Handle the fixed column width changed event
/// </summary>
public static void FixedColumnWidthChanged(AvaloniaPropertyChangedEventArgs e)
{
var grid = (AutoGrid)e.Sender;
// add a default column if missing
if (grid.ColumnDefinitions.Count == 0)
grid.ColumnDefinitions.Add(new ColumnDefinition());
// set all existing columns to this width
foreach (var t in grid.ColumnDefinitions)
t.Width = (GridLength)e.NewValue!;
}
/// <summary>
/// Handle the fixed row height changed event
/// </summary>
public static void FixedRowHeightChanged(AvaloniaPropertyChangedEventArgs e)
{
var grid = (AutoGrid)e.Sender;
// add a default row if missing
if (grid.RowDefinitions.Count == 0)
grid.RowDefinitions.Add(new RowDefinition());
// set all existing rows to this height
foreach (var t in grid.RowDefinitions)
t.Height = (GridLength)e.NewValue!;
}
/// <summary>
/// Handles the row count changed event
/// </summary>
public static void RowCountChanged(AvaloniaPropertyChangedEventArgs e)
{
if ((int)e.NewValue! < 0)
return;
var grid = (AutoGrid)e.Sender;
// look for an existing row to get the height
var height = grid.RowHeight;
if (!grid.IsSet(RowHeightProperty) && grid.RowDefinitions.Count > 0)
height = grid.RowDefinitions[0].Height;
// clear and rebuild
grid.RowDefinitions.Clear();
for (var i = 0; i < (int)e.NewValue; i++)
grid.RowDefinitions.Add(
new RowDefinition() { Height = height });
}
/// <summary>
/// Called when [child horizontal alignment changed].
/// </summary>
private static void OnChildHorizontalAlignmentChanged(AvaloniaPropertyChangedEventArgs e)
{
var grid = (AutoGrid)e.Sender;
foreach (var child in grid.Children)
{
child.SetValue(HorizontalAlignmentProperty,
grid.ChildHorizontalAlignment ?? AvaloniaProperty.UnsetValue);
}
}
/// <summary>
/// Called when [child layout changed].
/// </summary>
private static void OnChildMarginChanged(AvaloniaPropertyChangedEventArgs e)
{
var grid = (AutoGrid)e.Sender;
foreach (var child in grid.Children)
{
child.SetValue(MarginProperty, grid.ChildMargin ?? AvaloniaProperty.UnsetValue);
}
}
/// <summary>
/// Called when [child vertical alignment changed].
/// </summary>
private static void OnChildVerticalAlignmentChanged(AvaloniaPropertyChangedEventArgs e)
{
var grid = (AutoGrid)e.Sender;
foreach (var child in grid.Children)
{
child.SetValue(VerticalAlignmentProperty, grid.ChildVerticalAlignment ?? AvaloniaProperty.UnsetValue);
}
}
/// <summary>
/// Apply child margins and layout effects such as alignment
/// </summary>
private void ApplyChildLayout(Control child)
{
if (ChildMargin != null)
{
child.SetValue(MarginProperty, ChildMargin.Value, BindingPriority.Template);
}
if (ChildHorizontalAlignment != null)
{
child.SetValue(HorizontalAlignmentProperty, ChildHorizontalAlignment.Value, BindingPriority.Template);
}
if (ChildVerticalAlignment != null)
{
child.SetValue(VerticalAlignmentProperty, ChildVerticalAlignment.Value, BindingPriority.Template);
}
}
/// <summary>
/// Clamp a value to its maximum.
/// </summary>
private int Clamp(int value, int max)
{
return (value > max) ? max : value;
}
/// <summary>
/// Perform the grid layout of row and column indexes
/// </summary>
private void PerformLayout()
{
var fillRowFirst = Orientation == Orientation.Horizontal;
var rowCount = RowDefinitions.Count;
var colCount = ColumnDefinitions.Count;
if (rowCount == 0 || colCount == 0)
return;
var position = 0;
var skip = new bool[rowCount, colCount];
foreach (var child in Children.OfType<Control>())
{
var childIsCollapsed = !child.IsVisible;
if (IsAutoIndexing && !childIsCollapsed)
{
if (fillRowFirst)
{
var row = Clamp(position / colCount, rowCount - 1);
var col = Clamp(position % colCount, colCount - 1);
if (skip[row, col])
{
position++;
row = (position / colCount);
col = (position % colCount);
}
SetRow(child, row);
SetColumn(child, col);
position += GetColumnSpan(child);
var offset = GetRowSpan(child) - 1;
while (offset > 0)
{
skip[row + offset--, col] = true;
}
}
else
{
var row = Clamp(position % rowCount, rowCount - 1);
var col = Clamp(position / rowCount, colCount - 1);
if (skip[row, col])
{
position++;
row = position % rowCount;
col = position / rowCount;
}
SetRow(child, row);
SetColumn(child, col);
position += GetRowSpan(child);
var offset = GetColumnSpan(child) - 1;
while (offset > 0)
{
skip[row, col + offset--] = true;
}
}
}
ApplyChildLayout(child);
}
}
public static readonly AvaloniaProperty<HorizontalAlignment?> ChildHorizontalAlignmentProperty =
AvaloniaProperty.Register<AutoGrid, HorizontalAlignment?>("ChildHorizontalAlignment");
public static readonly AvaloniaProperty<Thickness?> ChildMarginProperty =
AvaloniaProperty.Register<AutoGrid, Thickness?>("ChildMargin");
public static readonly AvaloniaProperty<VerticalAlignment?> ChildVerticalAlignmentProperty =
AvaloniaProperty.Register<AutoGrid, VerticalAlignment?>("ChildVerticalAlignment");
public static readonly AvaloniaProperty<int> ColumnCountProperty =
AvaloniaProperty.RegisterAttached<Control, int>("ColumnCount", typeof(AutoGrid), 1);
public static readonly AvaloniaProperty<GridLength> ColumnWidthProperty =
AvaloniaProperty.RegisterAttached<Control, GridLength>("ColumnWidth", typeof(AutoGrid), GridLength.Auto);
public static readonly AvaloniaProperty<bool> IsAutoIndexingProperty =
AvaloniaProperty.Register<AutoGrid, bool>("IsAutoIndexing", true);
public static readonly AvaloniaProperty<Orientation> OrientationProperty =
AvaloniaProperty.Register<AutoGrid, Orientation>("Orientation", Orientation.Vertical);
public static readonly AvaloniaProperty<int> RowCountProperty =
AvaloniaProperty.RegisterAttached<Control, int>("RowCount", typeof(AutoGrid), 1);
public static readonly AvaloniaProperty<GridLength> RowHeightProperty =
AvaloniaProperty.RegisterAttached<Control, GridLength>("RowHeight", typeof(AutoGrid), GridLength.Auto);
static AutoGrid()
{
AffectsMeasure<AutoGrid>(ChildHorizontalAlignmentProperty, ChildMarginProperty,
ChildVerticalAlignmentProperty, ColumnCountProperty, ColumnWidthProperty, IsAutoIndexingProperty, OrientationProperty,
RowHeightProperty);
ChildHorizontalAlignmentProperty.Changed.Subscribe(OnChildHorizontalAlignmentChanged);
ChildMarginProperty.Changed.Subscribe(OnChildMarginChanged);
ChildVerticalAlignmentProperty.Changed.Subscribe(OnChildVerticalAlignmentChanged);
ColumnCountProperty.Changed.Subscribe(ColumnCountChanged);
RowCountProperty.Changed.Subscribe(RowCountChanged);
ColumnWidthProperty.Changed.Subscribe(FixedColumnWidthChanged);
RowHeightProperty.Changed.Subscribe(FixedRowHeightChanged);
}
#region Overrides
/// <summary>
/// Measures the children of a <see cref="T:System.Windows.Controls.Grid"/> in anticipation of arranging them during the <see cref="M:ArrangeOverride"/> pass.
/// </summary>
/// <param name="constraint">Indicates an upper limit size that should not be exceeded.</param>
/// <returns>
/// <see cref="Size"/> that represents the required size to arrange child content.
/// </returns>
protected override Size MeasureOverride(Size constraint)
{
PerformLayout();
return base.MeasureOverride(constraint);
}
#endregion Overrides
}

21
StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs

@ -132,6 +132,15 @@ public class BetterContentDialog : ContentDialog
get => GetValue(MaxDialogHeightProperty); get => GetValue(MaxDialogHeightProperty);
set => SetValue(MaxDialogHeightProperty, value); set => SetValue(MaxDialogHeightProperty, value);
} }
public static readonly StyledProperty<Thickness> ContentMarginProperty = AvaloniaProperty.Register<BetterContentDialog, Thickness>(
"ContentMargin");
public Thickness ContentMargin
{
get => GetValue(ContentMarginProperty);
set => SetValue(ContentMarginProperty, value);
}
public BetterContentDialog() public BetterContentDialog()
@ -205,6 +214,18 @@ public class BetterContentDialog : ContentDialog
TryBindButtons(); TryBindButtons();
} }
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
var background = e.NameScope.Find<FABorder>("BackgroundElement");
if (background is not null)
{
background.Margin = ContentMargin;
}
}
private void OnLoaded(object? sender, RoutedEventArgs? e) private void OnLoaded(object? sender, RoutedEventArgs? e)
{ {
TryBindButtons(); TryBindButtons();

70
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -4,7 +4,6 @@ using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models;
@ -12,8 +11,8 @@ using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Database; using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
@ -25,8 +24,6 @@ using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater; using StabilityMatrix.Core.Updater;
using CheckpointFile = StabilityMatrix.Avalonia.ViewModels.CheckpointManager.CheckpointFile;
using CheckpointFolder = StabilityMatrix.Avalonia.ViewModels.CheckpointManager.CheckpointFolder;
namespace StabilityMatrix.Avalonia.DesignData; namespace StabilityMatrix.Avalonia.DesignData;
@ -91,7 +88,8 @@ public static class DesignData
.AddSingleton<ISharedFolders, MockSharedFolders>() .AddSingleton<ISharedFolders, MockSharedFolders>()
.AddSingleton<IDownloadService, MockDownloadService>() .AddSingleton<IDownloadService, MockDownloadService>()
.AddSingleton<IHttpClientFactory, MockHttpClientFactory>() .AddSingleton<IHttpClientFactory, MockHttpClientFactory>()
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>(); .AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>()
.AddSingleton<ITrackedDownloadService, MockTrackedDownloadService>();
// Placeholder services that nobody should need during design time // Placeholder services that nobody should need during design time
services services
@ -192,6 +190,11 @@ public static class DesignData
{ {
Title = "StableDiffusion", Title = "StableDiffusion",
DirectoryPath = "Packages/Lora/Subfolder", DirectoryPath = "Packages/Lora/Subfolder",
},
new(settingsManager, downloadService, modelFinder)
{
Title = "Lora",
DirectoryPath = "Packages/StableDiffusion/Subfolder",
} }
}, },
CheckpointFiles = new AdvancedObservableList<CheckpointFile> CheckpointFiles = new AdvancedObservableList<CheckpointFile>
@ -223,14 +226,42 @@ public static class DesignData
}) })
}; };
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel> NewCheckpointsPageViewModel.AllCheckpoints = new ObservableCollection<CheckpointFile>
{ {
new(new ProgressItem(Guid.NewGuid(), "Test File.exe", new()
new ProgressReport(0.5f, "Downloading..."))), {
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu", FilePath = "~/Models/StableDiffusion/electricity-light.safetensors",
new ProgressReport(0.25f, "Extracting..."))) Title = "Auroral Background",
PreviewImagePath = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/" +
"78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg",
ConnectedModel = new ConnectedModelInfo
{
VersionName = "Lightning Auroral",
BaseModel = "SD 1.5",
ModelName = "Auroral Background",
ModelType = CivitModelType.Model,
FileMetadata = new CivitFileMetadata
{
Format = CivitModelFormat.SafeTensor,
Fp = CivitModelFpType.fp16,
Size = CivitModelSize.pruned,
}
}
},
new()
{
FilePath = "~/Models/Lora/model.safetensors",
Title = "Some model"
}
}; };
ProgressManagerViewModel.ProgressItems.AddRange(new ProgressItemViewModelBase[]
{
new ProgressItemViewModel(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressReport(0.5f, "Downloading..."))),
new MockDownloadProgressItemViewModel("Test File 2.exe"),
});
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>(); UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.UpdateText = UpdateViewModel.UpdateText =
$"Stability Matrix v2.0.1 is now available! You currently have v2.0.0. Would you like to update now?"; $"Stability Matrix v2.0.1 is now available! You currently have v2.0.0. Would you like to update now?";
@ -262,10 +293,15 @@ public static class DesignData
{ {
var settings = Services.GetRequiredService<ISettingsManager>(); var settings = Services.GetRequiredService<ISettingsManager>();
var vm = Services.GetRequiredService<PackageManagerViewModel>(); var vm = Services.GetRequiredService<PackageManagerViewModel>();
vm.Packages = new ObservableCollection<PackageCardViewModel>(
settings.Settings.InstalledPackages.Select(p => vm.SetPackages(settings.Settings.InstalledPackages);
DialogFactory.Get<PackageCardViewModel>(viewModel => viewModel.Package = p))); vm.SetUnknownPackages(new InstalledPackage[]
vm.Packages.First().IsUpdateAvailable = true; {
UnknownInstalledPackage.FromDirectoryName("sd-unknown"),
});
vm.PackageCards[0].IsUpdateAvailable = true;
return vm; return vm;
} }
} }
@ -273,6 +309,9 @@ public static class DesignData
public static CheckpointsPageViewModel CheckpointsPageViewModel => public static CheckpointsPageViewModel CheckpointsPageViewModel =>
Services.GetRequiredService<CheckpointsPageViewModel>(); Services.GetRequiredService<CheckpointsPageViewModel>();
public static NewCheckpointsPageViewModel NewCheckpointsPageViewModel =>
Services.GetRequiredService<NewCheckpointsPageViewModel>();
public static SettingsViewModel SettingsViewModel => public static SettingsViewModel SettingsViewModel =>
Services.GetRequiredService<SettingsViewModel>(); Services.GetRequiredService<SettingsViewModel>();
@ -368,6 +407,9 @@ public static class DesignData
}; };
}); });
public static PackageImportViewModel PackageImportViewModel =>
DialogFactory.Get<PackageImportViewModel>();
public static RefreshBadgeViewModel RefreshBadgeViewModel => new() public static RefreshBadgeViewModel RefreshBadgeViewModel => new()
{ {
State = ProgressState.Success State = ProgressState.Success

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;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
@ -8,8 +9,16 @@ namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadService : IDownloadService public class MockDownloadService : IDownloadService
{ {
public Task DownloadToFileAsync(string downloadUrl, string downloadPath, public Task DownloadToFileAsync(string downloadUrl, string downloadPath, IProgress<ProgressReport>? progress = null,
IProgress<ProgressReport>? progress = null, string? httpClientName = 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; 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();
}
}

91
StabilityMatrix.Avalonia/Extensions/DirectoryPathExtensions.cs

@ -0,0 +1,91 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Polly;
using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Avalonia.Extensions;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public static class DirectoryPathExtensions
{
/// <summary>
/// Deletes a directory and all of its contents recursively.
/// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff.
/// </summary>
public static Task DeleteVerboseAsync(this DirectoryPath directory, ILogger? logger = default)
{
var policy = Policy.Handle<IOException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)),
onRetry: (exception, calculatedWaitDuration) =>
{
logger?.LogWarning(
exception,
"Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}",
directory, calculatedWaitDuration);
});
return policy.ExecuteAsync(async () =>
{
await Task.Run(() => { DeleteVerbose(directory, logger); });
});
}
/// <summary>
/// Deletes a directory and all of its contents recursively.
/// Removes link targets without deleting the source.
/// </summary>
public static void DeleteVerbose(this DirectoryPath directory, ILogger? logger = default)
{
// Skip if directory does not exist
if (!directory.Exists)
{
return;
}
// For junction points, delete with recursive false
if (directory.IsSymbolicLink)
{
logger?.LogInformation("Removing junction point {TargetDirectory}", directory);
try
{
directory.Delete(false);
return;
}
catch (IOException ex)
{
throw new IOException($"Failed to delete junction point {directory}", ex);
}
}
// Recursively delete all subdirectories
foreach (var subDir in directory.Info.EnumerateDirectories())
{
DeleteVerbose(subDir, logger);
}
// Delete all files in the directory
foreach (var filePath in directory.Info.EnumerateFiles())
{
try
{
filePath.Attributes = FileAttributes.Normal;
filePath.Delete();
}
catch (IOException ex)
{
throw new IOException($"Failed to delete file {filePath.FullName}", ex);
}
}
// Delete this directory
try
{
directory.Delete(false);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete directory {directory}", ex);
}
}
}

52
StabilityMatrix.Avalonia/Languages/Cultures.cs

@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace StabilityMatrix.Avalonia.Languages;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public static class Cultures
{
public static CultureInfo Default { get; } = new("en-US");
public static CultureInfo Current => Resources.Culture;
public static readonly Dictionary<string, CultureInfo> SupportedCulturesByCode =
new Dictionary<string, CultureInfo>
{
["en-US"] = Default,
["ja-JP"] = new("ja-JP")
};
public static IReadOnlyList<CultureInfo> SupportedCultures
=> SupportedCulturesByCode.Values.ToImmutableList();
public static CultureInfo GetSupportedCultureOrDefault(string? cultureCode)
{
if (cultureCode is null
|| !SupportedCulturesByCode.TryGetValue(cultureCode, out var culture))
{
return Default;
}
return culture;
}
public static bool TrySetSupportedCulture(string? cultureCode)
{
if (cultureCode is null
|| !SupportedCulturesByCode.TryGetValue(cultureCode, out var culture))
{
return false;
}
Resources.Culture = culture;
return true;
}
public static bool TrySetSupportedCulture(CultureInfo? cultureInfo)
{
return cultureInfo is not null && TrySetSupportedCulture(cultureInfo.Name);
}
}

206
StabilityMatrix.Avalonia/Languages/Resources.Designer.cs generated

@ -0,0 +1,206 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace StabilityMatrix.Avalonia.Languages {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("StabilityMatrix.Avalonia.Languages.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Cancel.
/// </summary>
public static string Action_Cancel {
get {
return ResourceManager.GetString("Action_Cancel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import.
/// </summary>
public static string Action_Import {
get {
return ResourceManager.GetString("Action_Import", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Launch.
/// </summary>
public static string Action_Launch {
get {
return ResourceManager.GetString("Action_Launch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Quit.
/// </summary>
public static string Action_Quit {
get {
return ResourceManager.GetString("Action_Quit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Relaunch.
/// </summary>
public static string Action_Relaunch {
get {
return ResourceManager.GetString("Action_Relaunch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Relaunch Later.
/// </summary>
public static string Action_RelaunchLater {
get {
return ResourceManager.GetString("Action_RelaunchLater", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save.
/// </summary>
public static string Action_Save {
get {
return ResourceManager.GetString("Action_Save", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Branches.
/// </summary>
public static string Label_Branches {
get {
return ResourceManager.GetString("Label_Branches", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Language.
/// </summary>
public static string Label_Language {
get {
return ResourceManager.GetString("Label_Language", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Package Type.
/// </summary>
public static string Label_PackageType {
get {
return ResourceManager.GetString("Label_PackageType", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Relaunch Required.
/// </summary>
public static string Label_RelaunchRequired {
get {
return ResourceManager.GetString("Label_RelaunchRequired", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Releases.
/// </summary>
public static string Label_Releases {
get {
return ResourceManager.GetString("Label_Releases", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unknown Package.
/// </summary>
public static string Label_UnknownPackage {
get {
return ResourceManager.GetString("Label_UnknownPackage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Version.
/// </summary>
public static string Label_Version {
get {
return ResourceManager.GetString("Label_Version", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Version Type.
/// </summary>
public static string Label_VersionType {
get {
return ResourceManager.GetString("Label_VersionType", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Relaunch is required for new language option to take effect.
/// </summary>
public static string Text_RelaunchRequiredToApplyLanguage {
get {
return ResourceManager.GetString("Text_RelaunchRequiredToApplyLanguage", resourceCulture);
}
}
}
}

23
StabilityMatrix.Avalonia/Languages/Resources.ja-JP.resx

@ -0,0 +1,23 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Action_Save" xml:space="preserve">
<value>保存</value>
</data>
<data name="Action_Cancel" xml:space="preserve">
<value>戻る</value>
</data>
<data name="Label_Language" xml:space="preserve">
<value>言語</value>
</data>
</root>

69
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Action_Launch" xml:space="preserve">
<value>Launch</value>
</data>
<data name="Action_Quit" xml:space="preserve">
<value>Quit</value>
</data>
<data name="Action_Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="Action_Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Label_Language" xml:space="preserve">
<value>Language</value>
</data>
<data name="Text_RelaunchRequiredToApplyLanguage" xml:space="preserve">
<value>Relaunch is required for new language option to take effect</value>
</data>
<data name="Action_Relaunch" xml:space="preserve">
<value>Relaunch</value>
</data>
<data name="Action_RelaunchLater" xml:space="preserve">
<value>Relaunch Later</value>
</data>
<data name="Label_RelaunchRequired" xml:space="preserve">
<value>Relaunch Required</value>
</data>
<data name="Label_UnknownPackage" xml:space="preserve">
<value>Unknown Package</value>
</data>
<data name="Action_Import" xml:space="preserve">
<value>Import</value>
</data>
<data name="Label_PackageType" xml:space="preserve">
<value>Package Type</value>
</data>
<data name="Label_Version" xml:space="preserve">
<value>Version</value>
</data>
<data name="Label_VersionType" xml:space="preserve">
<value>Version Type</value>
</data>
<data name="Label_Releases" xml:space="preserve">
<value>Releases</value>
</data>
<data name="Label_Branches" xml:space="preserve">
<value>Branches</value>
</data>
</root>

92
StabilityMatrix.Avalonia/Services/ServiceManager.cs

@ -1,10 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Services; namespace StabilityMatrix.Avalonia.Services;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class ServiceManager<T> public class ServiceManager<T>
{ {
// Holds providers // Holds providers
@ -111,6 +115,48 @@ public class ServiceManager<T>
$"Service of type {typeof(TService)} is not registered for {typeof(T)}"); $"Service of type {typeof(TService)} is not registered for {typeof(T)}");
} }
/// <summary>
/// Get a view model instance from runtime type
/// </summary>
[SuppressMessage("ReSharper", "InconsistentlySynchronizedField")]
public T Get(Type serviceType)
{
if (!serviceType.IsAssignableFrom(typeof(T)))
{
throw new ArgumentException(
$"Service type {serviceType} is not assignable from {typeof(T)}");
}
if (instances.TryGetValue(serviceType, out var instance))
{
if (instance is null)
{
throw new ArgumentException(
$"Service of type {serviceType} was registered as null");
}
return (T) instance;
}
if (providers.TryGetValue(serviceType, out var provider))
{
if (provider is null)
{
throw new ArgumentException(
$"Service of type {serviceType} was registered as null");
}
var result = provider();
if (result is null)
{
throw new ArgumentException(
$"Service provider for type {serviceType} returned null");
}
return (T) result;
}
throw new ArgumentException(
$"Service of type {serviceType} is not registered for {typeof(T)}");
}
/// <summary> /// <summary>
/// Get a view model instance with an initializer parameter /// Get a view model instance with an initializer parameter
/// </summary> /// </summary>
@ -129,4 +175,50 @@ public class ServiceManager<T>
initializer(instance); initializer(instance);
return instance; return instance;
} }
/// <summary>
/// Get a view model instance, set as DataContext of its View, and return
/// a BetterContentDialog with that View as its Content
/// </summary>
public BetterContentDialog GetDialog<TService>() where TService : T
{
var instance = Get<TService>()!;
if (Attribute.GetCustomAttribute(instance.GetType(), typeof(ViewAttribute)) is not ViewAttribute
viewAttr)
{
throw new InvalidOperationException($"View not found for {instance.GetType().FullName}");
}
if (Activator.CreateInstance(viewAttr.GetViewType()) is not Control view)
{
throw new NullReferenceException($"Unable to create instance for {instance.GetType().FullName}");
}
return new BetterContentDialog { Content = view };
}
/// <summary>
/// Get a view model instance with initializer, set as DataContext of its View, and return
/// a BetterContentDialog with that View as its Content
/// </summary>
public BetterContentDialog GetDialog<TService>(Action<TService> initializer) where TService : T
{
var instance = Get(initializer)!;
if (Attribute.GetCustomAttribute(instance.GetType(), typeof(ViewAttribute)) is not ViewAttribute
viewAttr)
{
throw new InvalidOperationException($"View not found for {instance.GetType().FullName}");
}
if (Activator.CreateInstance(viewAttr.GetViewType()) is not Control view)
{
throw new NullReferenceException($"Unable to create instance for {instance.GetType().FullName}");
}
view.DataContext = instance;
return new BetterContentDialog { Content = view };
}
} }

18
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -8,7 +8,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon> <ApplicationIcon>./Assets/Icon.ico</ApplicationIcon>
<Version>2.1.1-dev.1</Version> <Version>2.3.0-dev.1</Version>
<InformationalVersion>$(Version)</InformationalVersion> <InformationalVersion>$(Version)</InformationalVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting> <EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup> </PropertyGroup>
@ -25,6 +25,7 @@
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.0" /> <PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" /> <PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="DynamicData" Version="7.14.2" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.0" /> <PackageReference Include="FluentAvaloniaUI" Version="2.0.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.207" /> <PackageReference Include="FluentIcons.Avalonia" Version="1.1.207" />
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.207" /> <PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.207" />
@ -76,4 +77,19 @@
<!-- Only for linux-64 --> <!-- Only for linux-64 -->
<AvaloniaResource Include="Assets\linux-x64\**" Condition="'$(RuntimeIdentifier)' == 'linux-x64'" /> <AvaloniaResource Include="Assets\linux-x64\**" Condition="'$(RuntimeIdentifier)' == 'linux-x64'" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Languages\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Compile Update="Languages\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
</Project> </Project>

42
StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml

@ -11,6 +11,7 @@
<Button Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" /> <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-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent" Content="Transparent 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" /> <Button Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Border> </Border>
@ -302,4 +303,45 @@
</Style> </Style>
</Style> </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> </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; namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
{ {
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDownloadService downloadService; private readonly IDownloadService downloadService;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly ISettingsManager settingsManager; private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory; private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService; private readonly INotificationService notificationService;
@ -63,11 +63,13 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
public CheckpointBrowserCardViewModel( public CheckpointBrowserCardViewModel(
IDownloadService downloadService, IDownloadService downloadService,
ITrackedDownloadService trackedDownloadService,
ISettingsManager settingsManager, ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory, ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService) INotificationService notificationService)
{ {
this.downloadService = downloadService; this.downloadService = downloadService;
this.trackedDownloadService = trackedDownloadService;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory; this.dialogFactory = dialogFactory;
this.notificationService = notificationService; this.notificationService = notificationService;
@ -197,6 +199,53 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
await DoImport(model, selectedVersion, selectedFile); 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, private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null,
CivitFile? selectedFile = null) CivitFile? selectedFile = null)
{ {
@ -204,164 +253,96 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
Text = "Downloading..."; Text = "Downloading...";
OnDownloadStart?.Invoke(this); 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 // Get latest version file
var filesForCleanup = new HashSet<FilePath>(); var modelFile = selectedFile ??
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
// Set Text when exiting, finally block will set 100 and delay clear progress if (modelFile is null)
try
{ {
// Get latest version notificationService.Show(new Notification("Model has no files available",
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault(); "This model has no files available for download", NotificationType.Warning));
if (modelVersion is null) Text = "Unable to Download";
{ return;
notificationService.Show(new Notification("Model has no versions available", }
"This model has no versions available for download", NotificationType.Warning));
Text = "Unable to Download"; var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory);
return;
} 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 // Download model info and preview first
var modelFile = selectedFile ?? var cmInfoPath = await SaveCmInfo(model, modelVersion, modelFile, downloadDirectory);
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model); var previewImagePath = await SavePreviewImage(modelVersion, downloadPath);
if (modelFile is null)
// 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", Text = $"Validating... {e.Percentage}%";
"This model has no files available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
} }
else
var downloadFolder = Path.Combine(settingsManager.ModelsDirectory,
model.Type.ConvertTo<SharedFolderType>().GetStringValue());
// Folders might be missing if user didn't install any packages yet
Directory.CreateDirectory(downloadFolder);
var downloadPath = Path.GetFullPath(Path.Combine(downloadFolder, modelFile.Name));
filesForCleanup.Add(downloadPath);
// Do the download
var progressId = Guid.NewGuid();
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl,
downloadPath,
new Progress<ProgressReport>(report =>
{
if (Math.Abs(report.Percentage - Value) > 0.1)
{
Dispatcher.UIThread.Post(() =>
{
Value = report.Percentage;
Text = $"Downloading... {report.Percentage}%";
});
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, report));
}
}));
var downloadResult =
await notificationService.TryAsync(downloadTask, "Could not download file");
// Failed download handling
if (downloadResult.Exception is not null)
{ {
// For exceptions other than ApiException or TaskCanceledException, log error Text = $"Downloading... {e.Percentage}%";
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;
} }
};
// When sha256 is available, validate the downloaded file download.ProgressStateChanged += (s, e) =>
var fileExpectedSha256 = modelFile.Hashes.SHA256; {
if (!string.IsNullOrEmpty(fileExpectedSha256)) if (e == ProgressState.Success)
{ {
var hashProgress = new Progress<ProgressReport>(progress => Text = "Import Complete";
{
Value = progress.Percentage; IsIndeterminate = false;
Text = $"Validating... {progress.Percentage}%"; Value = 100;
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, CheckIfInstalled();
modelFile.Name, progress)); DelayedClearProgress(TimeSpan.FromMilliseconds(800));
});
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));
} }
else if (e == ProgressState.Cancelled)
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())
{ {
var image = modelVersion.Images[0]; Text = "Cancelled";
var imageExtension = Path.GetExtension(image.Url).TrimStart('.'); DelayedClearProgress(TimeSpan.FromMilliseconds(500));
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");
}
} }
else if (e == ProgressState.Failed)
// 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))
{ {
file.Delete(); Text = "Download Failed";
Logger.Info($"Download cleanup: Deleted file {file}"); DelayedClearProgress(TimeSpan.FromMilliseconds(800));
} }
};
IsIndeterminate = false;
Value = 100; // Add hash context action
CheckIfInstalled(); download.ContextAction = CivitPostDownloadContextAction.FromCivitFile(modelFile);
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
} download.Start();
} }
private void DelayedClearProgress(TimeSpan delay) private void DelayedClearProgress(TimeSpan delay)

93
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs

@ -1,14 +1,19 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Reactive;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncAwaitBestPractices; using AsyncAwaitBestPractices;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Notifications; using AvaloniaEdit.Utils;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
@ -17,6 +22,7 @@ using Refit;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
@ -28,6 +34,7 @@ using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
using Notification = Avalonia.Controls.Notifications.Notification;
using Symbol = FluentIcons.Common.Symbol; using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -60,10 +67,12 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
[ObservableProperty] private bool hasSearched; [ObservableProperty] private bool hasSearched;
[ObservableProperty] private bool canGoToNextPage; [ObservableProperty] private bool canGoToNextPage;
[ObservableProperty] private bool canGoToPreviousPage; [ObservableProperty] private bool canGoToPreviousPage;
[ObservableProperty] private bool canGoToFirstPage;
[ObservableProperty] private bool canGoToLastPage;
[ObservableProperty] private bool isIndeterminate; [ObservableProperty] private bool isIndeterminate;
[ObservableProperty] private bool noResultsFound; [ObservableProperty] private bool noResultsFound;
[ObservableProperty] private string noResultsText = string.Empty; [ObservableProperty] private string noResultsText = string.Empty;
[ObservableProperty] private string selectedBaseModelType = "All"; [ObservableProperty] private string selectedBaseModelType = "All";
private List<CheckpointBrowserCardViewModel> allModelCards = new(); private List<CheckpointBrowserCardViewModel> allModelCards = new();
@ -94,6 +103,16 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
CurrentPageNumber = 1; CurrentPageNumber = 1;
CanGoToNextPage = true; CanGoToNextPage = true;
CanGoToLastPage = true;
Observable
.FromEventPattern<PropertyChangedEventArgs>(this, nameof(PropertyChanged))
.Where(x => x.EventArgs.PropertyName == nameof(CurrentPageNumber))
.Throttle(TimeSpan.FromMilliseconds(250))
.Select(_ => CurrentPageNumber)
.Where(page => page <= TotalPages && page > 0)
.ObserveOn(SynchronizationContext.Current)
.Subscribe(_ => TrySearchAgain(false).SafeFireAndForget(), err => Logger.Error(err));
} }
public override void OnLoaded() public override void OnLoaded()
@ -102,6 +121,14 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
var searchOptions = settingsManager.Settings.ModelSearchOptions; var searchOptions = settingsManager.Settings.ModelSearchOptions;
// Fix SelectedModelType if someone had selected the obsolete "Model" option
if (searchOptions is {SelectedModelType: CivitModelType.Model})
{
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions(
SelectedPeriod, SortMode, CivitModelType.Checkpoint, SelectedBaseModelType));
searchOptions = settingsManager.Settings.ModelSearchOptions;
}
SelectedPeriod = searchOptions?.SelectedPeriod ?? CivitPeriod.Month; SelectedPeriod = searchOptions?.SelectedPeriod ?? CivitPeriod.Month;
SortMode = searchOptions?.SortMode ?? CivitSortMode.HighestRated; SortMode = searchOptions?.SortMode ?? CivitSortMode.HighestRated;
SelectedModelType = searchOptions?.SelectedModelType ?? CivitModelType.Checkpoint; SelectedModelType = searchOptions?.SelectedModelType ?? CivitModelType.Checkpoint;
@ -250,13 +277,21 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
}).ToList(); }).ToList();
allModelCards = updateCards; allModelCards = updateCards;
ModelCards =
new ObservableCollection<CheckpointBrowserCardViewModel>( var filteredCards = updateCards.Where(FilterModelCardsPredicate);
updateCards.Where(FilterModelCardsPredicate)); if (SortMode == CivitSortMode.Installed)
{
filteredCards =
filteredCards.OrderByDescending(x => x.UpdateCardText == "Update Available");
}
ModelCards =new ObservableCollection<CheckpointBrowserCardViewModel>(filteredCards);
} }
TotalPages = metadata?.TotalPages ?? 1; TotalPages = metadata?.TotalPages ?? 1;
CanGoToFirstPage = CurrentPageNumber != 1;
CanGoToPreviousPage = CurrentPageNumber > 1; CanGoToPreviousPage = CurrentPageNumber > 1;
CanGoToNextPage = CurrentPageNumber < TotalPages; CanGoToNextPage = CurrentPageNumber < TotalPages;
CanGoToLastPage = CurrentPageNumber != TotalPages;
// Status update // Status update
ShowMainLoadingSpinner = false; ShowMainLoadingSpinner = false;
IsIndeterminate = false; IsIndeterminate = false;
@ -309,6 +344,26 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
{ {
modelRequest.BaseModel = SelectedBaseModelType; modelRequest.BaseModel = SelectedBaseModelType;
} }
if (SortMode == CivitSortMode.Installed)
{
var connectedModels =
CheckpointFile.GetAllCheckpointFiles(settingsManager.ModelsDirectory)
.Where(c => c.IsConnectedModel);
if (SelectedModelType != CivitModelType.All)
{
connectedModels = connectedModels.Where(c => c.ModelType == SelectedModelType);
}
modelRequest = new CivitModelsRequest
{
CommaSeparatedModelIds = string.Join(",",
connectedModels.Select(c => c.ConnectedModel!.ModelId).GroupBy(m => m)
.Select(g => g.First())),
Types = SelectedModelType == CivitModelType.All ? null : new[] {SelectedModelType}
};
}
// See if query is cached // See if query is cached
var cachedQuery = await liteDbContext.CivitModelQueryCache var cachedQuery = await liteDbContext.CivitModelQueryCache
@ -344,22 +399,32 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
UpdateResultsText(); UpdateResultsText();
} }
[RelayCommand] public void FirstPage()
private async Task PreviousPage()
{ {
if (CurrentPageNumber == 1) return; CurrentPageNumber = 1;
}
public void PreviousPage()
{
if (CurrentPageNumber == 1)
return;
CurrentPageNumber--; CurrentPageNumber--;
await TrySearchAgain(false);
} }
[RelayCommand] public void NextPage()
private async Task NextPage()
{ {
if (CurrentPageNumber == TotalPages)
return;
CurrentPageNumber++; CurrentPageNumber++;
await TrySearchAgain(false);
} }
public void LastPage()
{
CurrentPageNumber = TotalPages;
}
partial void OnShowNsfwChanged(bool value) partial void OnShowNsfwChanged(bool value)
{ {
settingsManager.Transaction(s => s.ModelBrowserNsfwEnabled, value); settingsManager.Transaction(s => s.ModelBrowserNsfwEnabled, value);

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

@ -14,6 +14,7 @@ using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Processes;
@ -46,6 +47,7 @@ public partial class CheckpointFile : ViewModelBase
public bool IsConnectedModel => ConnectedModel != null; public bool IsConnectedModel => ConnectedModel != null;
[ObservableProperty] private bool isLoading; [ObservableProperty] private bool isLoading;
[ObservableProperty] private CivitModelType modelType;
public string FileName => Path.GetFileName((string?) FilePath); public string FileName => Path.GetFileName((string?) FilePath);
@ -196,45 +198,63 @@ public partial class CheckpointFile : ViewModelBase
/// </summary> /// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly) public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly)
{ {
// Get all files with supported extensions foreach (var file in Directory.EnumerateFiles(directory, "*.*", searchOption))
var allExtensions = SupportedCheckpointExtensions
.Concat(SupportedImageExtensions)
.Concat(SupportedMetadataExtensions);
var files = allExtensions.AsParallel()
.SelectMany(pattern => Directory.EnumerateFiles(directory, $"*{pattern}", searchOption)).ToDictionary<string, string>(Path.GetFileName);
foreach (var file in files.Keys.Where(k => SupportedCheckpointExtensions.Contains(Path.GetExtension(k))))
{ {
var checkpointFile = new CheckpointFile() if (!SupportedCheckpointExtensions.Any(ext => file.Contains(ext)))
continue;
var checkpointFile = new CheckpointFile
{ {
Title = Path.GetFileNameWithoutExtension(file), Title = Path.GetFileNameWithoutExtension(file),
FilePath = Path.Combine(directory, file), FilePath = Path.Combine(directory, file),
}; };
// Check for connected model info var jsonPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(file)}.cm-info.json");
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); if (File.Exists(jsonPath))
var cmInfoPath = $"{fileNameWithoutExtension}.cm-info.json";
if (files.TryGetValue(cmInfoPath, out var jsonPath))
{ {
try var json = File.ReadAllText(jsonPath);
{ var connectedModelInfo = ConnectedModelInfo.FromJson(json);
var jsonData = File.ReadAllText(jsonPath); checkpointFile.ConnectedModel = connectedModelInfo;
checkpointFile.ConnectedModel = ConnectedModelInfo.FromJson(jsonData);
}
catch (IOException e)
{
Debug.WriteLine($"Failed to parse {cmInfoPath}: {e}");
}
} }
// Check for preview image checkpointFile.PreviewImagePath = SupportedImageExtensions
var previewImage = SupportedImageExtensions.Select(ext => $"{fileNameWithoutExtension}.preview{ext}").FirstOrDefault(files.ContainsKey); .Select(ext => Path.Combine(directory,
if (previewImage != null) $"{Path.GetFileNameWithoutExtension(file)}.preview{ext}")).Where(File.Exists)
.FirstOrDefault();
yield return checkpointFile;
}
}
public static IEnumerable<CheckpointFile> GetAllCheckpointFiles(string modelsDirectory)
{
foreach (var file in Directory.EnumerateFiles(modelsDirectory, "*.*", SearchOption.AllDirectories))
{
if (!SupportedCheckpointExtensions.Any(ext => file.Contains(ext)))
continue;
var checkpointFile = new CheckpointFile
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = file,
};
var jsonPath = Path.Combine(Path.GetDirectoryName(file),
Path.GetFileNameWithoutExtension(file) + ".cm-info.json");
if (File.Exists(jsonPath))
{ {
checkpointFile.PreviewImagePath = files[previewImage]; var json = File.ReadAllText(jsonPath);
var connectedModelInfo = ConnectedModelInfo.FromJson(json);
checkpointFile.ConnectedModel = connectedModelInfo;
checkpointFile.ModelType = GetCivitModelType(file);
} }
checkpointFile.PreviewImagePath = SupportedImageExtensions
.Select(ext => Path.Combine(Path.GetDirectoryName(file),
$"{Path.GetFileNameWithoutExtension(file)}.preview{ext}")).Where(File.Exists)
.FirstOrDefault();
yield return checkpointFile; yield return checkpointFile;
} }
} }
@ -253,4 +273,39 @@ public partial class CheckpointFile : ViewModelBase
yield return checkpointFile; yield return checkpointFile;
} }
} }
private static CivitModelType GetCivitModelType(string filePath)
{
if (filePath.Contains(SharedFolderType.StableDiffusion.ToString()))
{
return CivitModelType.Checkpoint;
}
if (filePath.Contains(SharedFolderType.ControlNet.ToString()))
{
return CivitModelType.Controlnet;
}
if (filePath.Contains(SharedFolderType.Lora.ToString()))
{
return CivitModelType.LORA;
}
if (filePath.Contains(SharedFolderType.TextualInversion.ToString()))
{
return CivitModelType.TextualInversion;
}
if (filePath.Contains(SharedFolderType.Hypernetwork.ToString()))
{
return CivitModelType.Hypernetwork;
}
if (filePath.Contains(SharedFolderType.LyCORIS.ToString()))
{
return CivitModelType.LoCon;
}
return CivitModelType.Unknown;
}
} }

4
StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -414,8 +415,7 @@ public partial class CheckpointFolder : ViewModelBase
{ {
// Create subfolder // Create subfolder
var subFolder = new CheckpointFolder(settingsManager, var subFolder = new CheckpointFolder(settingsManager,
downloadService, modelFinder, downloadService, modelFinder, useCategoryVisibility: false)
useCategoryVisibility: false)
{ {
Title = Path.GetFileName(folder), Title = Path.GetFileName(folder),
DirectoryPath = folder, DirectoryPath = folder,

64
StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -10,6 +11,7 @@ using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using NLog; using NLog;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
@ -68,45 +70,59 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
this.downloadService = downloadService; this.downloadService = downloadService;
this.modelFinder = modelFinder; this.modelFinder = modelFinder;
} }
public override async Task OnLoadedAsync() public override async Task OnLoadedAsync()
{ {
var sw = Stopwatch.StartNew();
DisplayedCheckpointFolders = CheckpointFolders; DisplayedCheckpointFolders = CheckpointFolders;
// Set UI states // Set UI states
IsImportAsConnected = settingsManager.Settings.IsImportAsConnected; IsImportAsConnected = settingsManager.Settings.IsImportAsConnected;
// Refresh search filter // Refresh search filter
OnSearchFilterChanged(string.Empty); OnSearchFilterChanged(string.Empty);
Logger.Info($"Loaded {DisplayedCheckpointFolders.Count} checkpoint folders in {sw.ElapsedMilliseconds}ms");
if (Design.IsDesignMode) return; if (Design.IsDesignMode) return;
await Dispatcher.UIThread.InvokeAsync(async () => IsLoading = CheckpointFolders.Count == 0;
{ IsIndexing = CheckpointFolders.Count > 0;
IsLoading = CheckpointFolders.Count == 0; await IndexFolders();
IsIndexing = CheckpointFolders.Count > 0; IsLoading = false;
await IndexFolders(); IsIndexing = false;
IsLoading = false;
IsIndexing = false; Logger.Info($"OnLoadedAsync in {sw.ElapsedMilliseconds}ms");
});
} }
// ReSharper disable once UnusedParameterInPartialMethod // ReSharper disable once UnusedParameterInPartialMethod
partial void OnSearchFilterChanged(string value) partial void OnSearchFilterChanged(string value)
{ {
var sw = Stopwatch.StartNew();
if (string.IsNullOrWhiteSpace(SearchFilter)) if (string.IsNullOrWhiteSpace(SearchFilter))
{ {
DisplayedCheckpointFolders = CheckpointFolders; DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(
CheckpointFolders.Select(x =>
{
x.SearchFilter = SearchFilter;
return x;
}));
sw.Stop();
Logger.Info($"OnSearchFilterChanged in {sw.ElapsedMilliseconds}ms");
return; return;
} }
sw.Restart();
var filteredFolders = CheckpointFolders var filteredFolders = CheckpointFolders
.Where(ContainsSearchFilter).ToList(); .Where(ContainsSearchFilter).ToList();
foreach (var folder in filteredFolders) foreach (var folder in filteredFolders)
{ {
folder.SearchFilter = SearchFilter; folder.SearchFilter = SearchFilter;
} }
sw.Stop();
Logger.Info($"ContainsSearchFilter in {sw.ElapsedMilliseconds}ms");
DisplayedCheckpointFolders = new ObservableCollection<CheckpointManager.CheckpointFolder>(filteredFolders); DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(filteredFolders);
} }
private bool ContainsSearchFilter(CheckpointManager.CheckpointFolder folder) private bool ContainsSearchFilter(CheckpointManager.CheckpointFolder folder)
@ -136,11 +152,13 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
var folders = Directory.GetDirectories(modelsDirectory); var folders = Directory.GetDirectories(modelsDirectory);
var sw = Stopwatch.StartNew();
// Index all folders // Index all folders
var indexTasks = folders.Select(f => Task.Run(async () => var indexTasks = folders.Select(async f =>
{ {
var checkpointFolder = var checkpointFolder =
new CheckpointManager.CheckpointFolder(settingsManager, downloadService, modelFinder) new CheckpointFolder(settingsManager, downloadService, modelFinder)
{ {
Title = Path.GetFileName(f), Title = Path.GetFileName(f),
DirectoryPath = f, DirectoryPath = f,
@ -148,21 +166,29 @@ public partial class CheckpointsPageViewModel : PageViewModelBase
}; };
await checkpointFolder.IndexAsync(); await checkpointFolder.IndexAsync();
return checkpointFolder; return checkpointFolder;
})).ToList(); }).ToList();
await Task.WhenAll(indexTasks); await Task.WhenAll(indexTasks);
sw.Stop();
Logger.Info($"IndexFolders in {sw.ElapsedMilliseconds}ms");
// Set new observable collection, ordered by alphabetical order // Set new observable collection, ordered by alphabetical order
CheckpointFolders = CheckpointFolders =
new ObservableCollection<CheckpointManager.CheckpointFolder>(indexTasks new ObservableCollection<CheckpointFolder>(indexTasks
.Select(t => t.Result) .Select(t => t.Result)
.OrderBy(f => f.Title)); .OrderBy(f => f.Title));
if (!string.IsNullOrWhiteSpace(SearchFilter)) if (!string.IsNullOrWhiteSpace(SearchFilter))
{ {
DisplayedCheckpointFolders = new ObservableCollection<CheckpointManager.CheckpointFolder>( var filtered = CheckpointFolders
CheckpointFolders .Where(x => x.CheckpointFiles.Any(y => y.FileName.Contains(SearchFilter))).Select(
.Where(x => x.CheckpointFiles.Any(y => y.FileName.Contains(SearchFilter)))); f =>
{
f.SearchFilter = SearchFilter;
return f;
});
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(filtered);
} }
else else
{ {

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

@ -199,14 +199,14 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
version = SelectedVersion?.TagName ?? version = SelectedVersion?.TagName ??
throw new NullReferenceException("Selected version is null"); throw new NullReferenceException("Selected version is null");
await DownloadPackage(version, false); await DownloadPackage(version, false, null);
} }
else else
{ {
version = SelectedCommit?.Sha ?? version = SelectedCommit?.Sha ??
throw new NullReferenceException("Selected commit is null"); throw new NullReferenceException("Selected commit is null");
await DownloadPackage(version, true); await DownloadPackage(version, true, SelectedVersion!.TagName);
} }
await InstallPackage(); await InstallPackage();
@ -271,7 +271,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
return branch == null ? version : $"{branch}@{version[..7]}"; return branch == null ? version : $"{branch}@{version[..7]}";
} }
private Task<string> DownloadPackage(string version, bool isCommitHash) private Task<string> DownloadPackage(string version, bool isCommitHash, string? branch)
{ {
InstallProgress.Text = "Downloading package..."; InstallProgress.Text = "Downloading package...";
@ -282,7 +282,7 @@ public partial class InstallerViewModel : ContentDialogViewModelBase
EventManager.Instance.OnGlobalProgressChanged((int) progress.Percentage); EventManager.Instance.OnGlobalProgressChanged((int) progress.Percentage);
}); });
return SelectedPackage.DownloadPackage(version, isCommitHash, progress); return SelectedPackage.DownloadPackage(version, isCommitHash, branch, progress);
} }
private async Task InstallPackage() private async Task InstallPackage()

61
StabilityMatrix.Avalonia/ViewModels/Dialogs/LaunchOptionsViewModel.cs

@ -1,9 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using FuzzySharp; using FuzzySharp;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper.Cache; using StabilityMatrix.Core.Helper.Cache;
@ -14,26 +18,28 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(LaunchOptionsDialog))] [View(typeof(LaunchOptionsDialog))]
public partial class LaunchOptionsViewModel : ContentDialogViewModelBase public partial class LaunchOptionsViewModel : ContentDialogViewModelBase
{ {
private readonly ILogger<LaunchOptionsViewModel> logger;
private readonly LRUCache<string, ImmutableList<LaunchOptionCard>> cache = new(100); private readonly LRUCache<string, ImmutableList<LaunchOptionCard>> cache = new(100);
[ObservableProperty] private string title = "Launch Options"; [ObservableProperty]
[ObservableProperty] private bool isSearchBoxEnabled = true; private string title = "Launch Options";
[ObservableProperty]
private bool isSearchBoxEnabled = true;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(FilteredCards))]
private string searchText = string.Empty; private string searchText = string.Empty;
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<LaunchOptionCard>? filteredCards; private IReadOnlyList<LaunchOptionCard>? filteredCards;
public IReadOnlyList<LaunchOptionCard>? Cards { get; set; } public IReadOnlyList<LaunchOptionCard>? Cards { get; set; }
/// <summary> /// <summary>
/// Return cards that match the search text /// Return cards that match the search text
/// </summary> /// </summary>
private IReadOnlyList<LaunchOptionCard>? GetFilteredCards() private IReadOnlyList<LaunchOptionCard>? GetFilteredCards(string? text)
{ {
var text = SearchText;
if (string.IsNullOrWhiteSpace(text) || text.Length < 2) if (string.IsNullOrWhiteSpace(text) || text.Length < 2)
{ {
return Cards; return Cards;
@ -50,18 +56,30 @@ public partial class LaunchOptionsViewModel : ContentDialogViewModelBase
Type = LaunchOptionType.Bool, Type = LaunchOptionType.Bool,
Options = Array.Empty<LaunchOption>() Options = Array.Empty<LaunchOption>()
}; };
var extracted = Process var extracted = Process.ExtractTop(searchCard, Cards, c => c.Title.ToLowerInvariant());
.ExtractTop(searchCard, Cards, c => c.Title.ToLowerInvariant()); var results = extracted.Where(r => r.Score > 40).Select(r => r.Value).ToImmutableList();
var results = extracted
.Where(r => r.Score > 40)
.Select(r => r.Value)
.ToImmutableList();
cache.Add(text, results); cache.Add(text, results);
return results; return results;
} }
public void UpdateFilterCards() => FilteredCards = GetFilteredCards(); public void UpdateFilterCards() => FilteredCards = GetFilteredCards(SearchText);
public LaunchOptionsViewModel(ILogger<LaunchOptionsViewModel> logger)
{
this.logger = logger;
Observable
.FromEventPattern<PropertyChangedEventArgs>(this, nameof(PropertyChanged))
.Where(x => x.EventArgs.PropertyName == nameof(SearchText))
.Throttle(TimeSpan.FromMilliseconds(50))
.Select(_ => SearchText)
.ObserveOn(SynchronizationContext.Current!)
.Subscribe(
text => FilteredCards = GetFilteredCards(text),
err => logger.LogError(err, "Error while filtering launch options")
);
}
public override void OnLoaded() public override void OnLoaded()
{ {
@ -75,8 +93,9 @@ public partial class LaunchOptionsViewModel : ContentDialogViewModelBase
public List<LaunchOption> AsLaunchArgs() public List<LaunchOption> AsLaunchArgs()
{ {
var launchArgs = new List<LaunchOption>(); var launchArgs = new List<LaunchOption>();
if (Cards is null) return launchArgs; if (Cards is null)
return launchArgs;
foreach (var card in Cards) foreach (var card in Cards)
{ {
launchArgs.AddRange(card.Options); launchArgs.AddRange(card.Options);

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

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@ -55,7 +56,8 @@ public partial class OneClickInstallViewModel : ViewModelBase
SubHeaderText = "Choose your preferred interface and click Install to get started!"; SubHeaderText = "Choose your preferred interface and click Install to get started!";
ShowInstallButton = true; ShowInstallButton = true;
AllPackages = AllPackages =
new ObservableCollection<BasePackage>(this.packageFactory.GetAllAvailablePackages()); new ObservableCollection<BasePackage>(this.packageFactory.GetAllAvailablePackages()
.Where(p => p.OfferInOneClickInstaller));
SelectedPackage = AllPackages[0]; SelectedPackage = AllPackages[0];
} }
@ -157,7 +159,7 @@ public partial class OneClickInstallViewModel : ViewModelBase
EventManager.Instance.OnGlobalProgressChanged(OneClickInstallProgress); EventManager.Instance.OnGlobalProgressChanged(OneClickInstallProgress);
}); });
await SelectedPackage.DownloadPackage(version, false, progress); await SelectedPackage.DownloadPackage(version, false, version, progress);
SubHeaderText = "Download Complete"; SubHeaderText = "Download Complete";
OneClickInstallProgress = 100; OneClickInstallProgress = 100;
} }

221
StabilityMatrix.Avalonia/ViewModels/Dialogs/PackageImportViewModel.cs

@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using NLog;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(PackageImportDialog))]
public partial class PackageImportViewModel : ContentDialogViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IPackageFactory packageFactory;
private readonly ISettingsManager settingsManager;
[ObservableProperty] private DirectoryPath? packagePath;
[ObservableProperty] private BasePackage? selectedBasePackage;
public IReadOnlyList<BasePackage> AvailablePackages
=> packageFactory.GetAllAvailablePackages().ToImmutableArray();
[ObservableProperty] private PackageVersion? selectedVersion;
[ObservableProperty] private ObservableCollection<GitCommit>? availableCommits;
[ObservableProperty] private ObservableCollection<PackageVersion>? availableVersions;
[ObservableProperty] private GitCommit? selectedCommit;
// Version types (release or commit)
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ReleaseLabelText),
nameof(IsReleaseMode), nameof(SelectedVersion))]
private PackageVersionType selectedVersionType = PackageVersionType.Commit;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsReleaseModeAvailable))]
private PackageVersionType availableVersionTypes =
PackageVersionType.GithubRelease | PackageVersionType.Commit;
public string ReleaseLabelText => IsReleaseMode ? "Version" : "Branch";
public bool IsReleaseMode
{
get => SelectedVersionType == PackageVersionType.GithubRelease;
set => SelectedVersionType = value ? PackageVersionType.GithubRelease : PackageVersionType.Commit;
}
public bool IsReleaseModeAvailable => AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease);
public PackageImportViewModel(
IPackageFactory packageFactory,
ISettingsManager settingsManager)
{
this.packageFactory = packageFactory;
this.settingsManager = settingsManager;
}
public override async Task OnLoadedAsync()
{
SelectedBasePackage ??= AvailablePackages[0];
if (Design.IsDesignMode) return;
// Populate available versions
try
{
if (IsReleaseMode)
{
var versions = (await SelectedBasePackage.GetAllVersions()).ToList();
AvailableVersions = new ObservableCollection<PackageVersion>(versions);
if (!AvailableVersions.Any()) return;
SelectedVersion = AvailableVersions[0];
}
else
{
var branches = (await SelectedBasePackage.GetAllBranches()).ToList();
AvailableVersions = new ObservableCollection<PackageVersion>(branches.Select(b =>
new PackageVersion
{
TagName = b.Name,
ReleaseNotesMarkdown = b.Commit.Label
}));
UpdateSelectedVersionToLatestMain();
}
}
catch (Exception e)
{
Logger.Warn("Error getting versions: {Exception}", e.ToString());
}
}
private static string GetDisplayVersion(string version, string? branch)
{
return branch == null ? version : $"{branch}@{version[..7]}";
}
// When available version types change, reset selected version type if not compatible
partial void OnAvailableVersionTypesChanged(PackageVersionType value)
{
if (!value.HasFlag(SelectedVersionType))
{
SelectedVersionType = value;
}
}
// When changing branch / release modes, refresh
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnSelectedVersionTypeChanged(PackageVersionType value)
=> OnSelectedBasePackageChanged(SelectedBasePackage);
partial void OnSelectedBasePackageChanged(BasePackage? value)
{
if (value is null || SelectedBasePackage is null)
{
AvailableVersions?.Clear();
AvailableCommits?.Clear();
return;
}
AvailableVersions?.Clear();
AvailableCommits?.Clear();
AvailableVersionTypes = SelectedBasePackage.ShouldIgnoreReleases
? PackageVersionType.Commit
: PackageVersionType.GithubRelease | PackageVersionType.Commit;
if (Design.IsDesignMode) return;
Dispatcher.UIThread.InvokeAsync(async () =>
{
Logger.Debug($"Release mode: {IsReleaseMode}");
var versions = (await value.GetAllVersions(IsReleaseMode)).ToList();
if (!versions.Any()) return;
AvailableVersions = new ObservableCollection<PackageVersion>(versions);
Logger.Debug($"Available versions: {string.Join(", ", AvailableVersions)}");
SelectedVersion = AvailableVersions[0];
if (!IsReleaseMode)
{
var commits = (await value.GetAllCommits(SelectedVersion.TagName))?.ToList();
if (commits is null || commits.Count == 0) return;
AvailableCommits = new ObservableCollection<GitCommit>(commits);
SelectedCommit = AvailableCommits[0];
UpdateSelectedVersionToLatestMain();
}
}).SafeFireAndForget();
}
private void UpdateSelectedVersionToLatestMain()
{
if (AvailableVersions is null)
{
SelectedVersion = null;
}
else
{
// First try to find master
var version = AvailableVersions.FirstOrDefault(x => x.TagName == "master");
// If not found, try main
version ??= AvailableVersions.FirstOrDefault(x => x.TagName == "main");
// If still not found, just use the first one
version ??= AvailableVersions[0];
SelectedVersion = version;
}
}
public void AddPackageWithCurrentInputs()
{
if (SelectedBasePackage is null || PackagePath is null)
return;
string version;
if (IsReleaseMode)
{
version = SelectedVersion?.TagName ??
throw new NullReferenceException("Selected version is null");
}
else
{
version = SelectedCommit?.Sha ??
throw new NullReferenceException("Selected commit is null");
}
var branch = SelectedVersionType == PackageVersionType.GithubRelease ?
null : SelectedVersion!.TagName;
var package = new InstalledPackage
{
Id = Guid.NewGuid(),
DisplayName = PackagePath.Name,
PackageName = SelectedBasePackage.Name,
LibraryPath = $"Packages{Path.DirectorySeparatorChar}{PackagePath.Name}",
PackageVersion = version,
DisplayVersion = GetDisplayVersion(version, branch),
InstalledBranch = branch,
LaunchCommand = SelectedBasePackage.LaunchCommand,
LastUpdateCheck = DateTimeOffset.Now,
};
settingsManager.Transaction(s => s.InstalledPackages.Add(package));
}
}

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

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

86
StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs

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

6
StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs

@ -33,7 +33,6 @@ using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
using Notification = Avalonia.Controls.Notifications.Notification;
using Symbol = FluentIcons.Common.Symbol; using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -305,13 +304,16 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable, IAsyn
IsPrimaryButtonEnabled = true, IsPrimaryButtonEnabled = true,
PrimaryButtonText = "Save", PrimaryButtonText = "Save",
CloseButtonText = "Cancel", CloseButtonText = "Cancel",
FullSizeDesired = true,
DefaultButton = ContentDialogButton.Primary, DefaultButton = ContentDialogButton.Primary,
ContentMargin = new Thickness(32, 16),
Padding = new Thickness(0, 16), Padding = new Thickness(0, 16),
Content = new LaunchOptionsDialog Content = new LaunchOptionsDialog
{ {
DataContext = viewModel, DataContext = viewModel,
} }
}; };
var result = await dialog.ShowAsync(); var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary) if (result == ContentDialogResult.Primary)
@ -448,7 +450,7 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable, IAsyn
private void RunningPackageOnStartupComplete(object? sender, string e) private void RunningPackageOnStartupComplete(object? sender, string e)
{ {
webUiUrl = e; webUiUrl = e.Replace("0.0.0.0", "127.0.0.1");
ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl); ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl);
} }

8
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

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

221
StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs

@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using AvaloniaEdit.Utils;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using Refit;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(NewCheckpointsPage))]
public partial class NewCheckpointsPageViewModel : PageViewModelBase
{
private readonly ILogger<NewCheckpointsPageViewModel> logger;
private readonly ISettingsManager settingsManager;
private readonly ILiteDbContext liteDbContext;
private readonly ICivitApi civitApi;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService;
public override string Title => "Checkpoint Manager";
public override IconSource IconSource => new SymbolIconSource
{Symbol = Symbol.Cellular5g, IsFilled = true};
public NewCheckpointsPageViewModel(ILogger<NewCheckpointsPageViewModel> logger,
ISettingsManager settingsManager, ILiteDbContext liteDbContext, ICivitApi civitApi,
ServiceManager<ViewModelBase> dialogFactory, INotificationService notificationService)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.liteDbContext = liteDbContext;
this.civitApi = civitApi;
this.dialogFactory = dialogFactory;
this.notificationService = notificationService;
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ConnectedCheckpoints))]
[NotifyPropertyChangedFor(nameof(NonConnectedCheckpoints))]
private ObservableCollection<CheckpointFile> allCheckpoints = new();
[ObservableProperty]
private ObservableCollection<CivitModel> civitModels = new();
public ObservableCollection<CheckpointFile> ConnectedCheckpoints => new(
AllCheckpoints.Where(x => x.IsConnectedModel)
.OrderBy(x => x.ConnectedModel!.ModelName)
.ThenBy(x => x.ModelType)
.GroupBy(x => x.ConnectedModel!.ModelId)
.Select(x => x.First()));
public ObservableCollection<CheckpointFile> NonConnectedCheckpoints => new(
AllCheckpoints.Where(x => !x.IsConnectedModel).OrderBy(x => x.ModelType));
public override async Task OnLoadedAsync()
{
if (Design.IsDesignMode) return;
var files = CheckpointFile.GetAllCheckpointFiles(settingsManager.ModelsDirectory);
AllCheckpoints = new ObservableCollection<CheckpointFile>(files);
var connectedModelIds = ConnectedCheckpoints.Select(x => x.ConnectedModel.ModelId);
var modelRequest = new CivitModelsRequest
{
CommaSeparatedModelIds = string.Join(',', connectedModelIds)
};
// See if query is cached
var cachedQuery = await liteDbContext.CivitModelQueryCache
.IncludeAll()
.FindByIdAsync(ObjectHash.GetMd5Guid(modelRequest));
// If cached, update model cards
if (cachedQuery is not null)
{
CivitModels = new ObservableCollection<CivitModel>(cachedQuery.Items);
// Start remote query (background mode)
// Skip when last query was less than 2 min ago
var timeSinceCache = DateTimeOffset.UtcNow - cachedQuery.InsertedAt;
if (timeSinceCache?.TotalMinutes >= 2)
{
CivitQuery(modelRequest).SafeFireAndForget();
}
}
else
{
await CivitQuery(modelRequest);
}
}
public async Task ShowVersionDialog(int modelId)
{
var model = CivitModels.FirstOrDefault(m => m.Id == modelId);
if (model == null)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
return;
}
var versions = model.ModelVersions;
if (versions is null || versions.Count == 0)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
return;
}
var dialog = new BetterContentDialog
{
Title = model.Name,
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
MaxDialogWidth = 750,
};
var viewModel = dialogFactory.Get<SelectModelVersionViewModel>();
viewModel.Dialog = dialog;
viewModel.Versions = versions.Select(version =>
new ModelVersionViewModel(
settingsManager.Settings.InstalledModelHashes ?? new HashSet<string>(), version))
.ToImmutableArray();
viewModel.SelectedVersionViewModel = viewModel.Versions[0];
dialog.Content = new SelectModelVersionDialog
{
DataContext = viewModel
};
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return;
}
var selectedVersion = viewModel?.SelectedVersionViewModel?.ModelVersion;
var selectedFile = viewModel?.SelectedFile?.CivitFile;
}
private async Task CivitQuery(CivitModelsRequest request)
{
try
{
var modelResponse = await civitApi.GetModels(request);
var models = modelResponse.Items;
// Filter out unknown model types and archived/taken-down models
models = models.Where(m => m.Type.ConvertTo<SharedFolderType>() > 0)
.Where(m => m.Mode == null).ToList();
// Database update calls will invoke `OnModelsUpdated`
// Add to database
await liteDbContext.UpsertCivitModelAsync(models);
// Add as cache entry
var cacheNew = await liteDbContext.UpsertCivitModelQueryCacheEntryAsync(
new CivitModelQueryCacheEntry
{
Id = ObjectHash.GetMd5Guid(request),
InsertedAt = DateTimeOffset.UtcNow,
Request = request,
Items = models,
Metadata = modelResponse.Metadata
});
if (cacheNew)
{
CivitModels = new ObservableCollection<CivitModel>(models);
}
}
catch (OperationCanceledException)
{
notificationService.Show(new Notification("Request to CivitAI timed out",
"Could not check for checkpoint updates. Please try again later."));
logger.LogWarning($"CivitAI query timed out ({request})");
}
catch (HttpRequestException e)
{
notificationService.Show(new Notification("CivitAI can't be reached right now",
"Could not check for checkpoint updates. Please try again later."));
logger.LogWarning(e, $"CivitAI query HttpRequestException ({request})");
}
catch (ApiException e)
{
notificationService.Show(new Notification("CivitAI can't be reached right now",
"Could not check for checkpoint updates. Please try again later."));
logger.LogWarning(e, $"CivitAI query ApiException ({request})");
}
catch (Exception e)
{
notificationService.Show(new Notification("CivitAI can't be reached right now",
$"Unknown exception during CivitAI query: {e.GetType().Name}"));
logger.LogError(e, $"CivitAI query unknown exception ({request})");
}
}
}

187
StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs

@ -1,20 +1,22 @@
using System; using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Notifications; using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using NLog; using Microsoft.Extensions.Logging;
using Polly;
using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
@ -23,27 +25,33 @@ namespace StabilityMatrix.Avalonia.ViewModels.PackageManager;
public partial class PackageCardViewModel : ProgressViewModel public partial class PackageCardViewModel : ProgressViewModel
{ {
private readonly ILogger<PackageCardViewModel> logger;
private readonly IPackageFactory packageFactory; private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService; private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager; private readonly ISettingsManager settingsManager;
private readonly INavigationService navigationService; private readonly INavigationService navigationService;
private readonly Logger logger = LogManager.GetCurrentClassLogger(); private readonly ServiceManager<ViewModelBase> vmFactory;
[ObservableProperty] private InstalledPackage? package; [ObservableProperty] private InstalledPackage? package;
[ObservableProperty] private Uri cardImage; [ObservableProperty] private string? cardImageSource;
[ObservableProperty] private bool isUpdateAvailable; [ObservableProperty] private bool isUpdateAvailable;
[ObservableProperty] private string installedVersion; [ObservableProperty] private string? installedVersion;
[ObservableProperty] private bool isUnknownPackage;
public PackageCardViewModel( public PackageCardViewModel(
ILogger<PackageCardViewModel> logger,
IPackageFactory packageFactory, IPackageFactory packageFactory,
INotificationService notificationService, INotificationService notificationService,
ISettingsManager settingsManager, ISettingsManager settingsManager,
INavigationService navigationService) INavigationService navigationService,
ServiceManager<ViewModelBase> vmFactory)
{ {
this.logger = logger;
this.packageFactory = packageFactory; this.packageFactory = packageFactory;
this.notificationService = notificationService; this.notificationService = notificationService;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.navigationService = navigationService; this.navigationService = navigationService;
this.vmFactory = vmFactory;
} }
partial void OnPackageChanged(InstalledPackage? value) partial void OnPackageChanged(InstalledPackage? value)
@ -51,9 +59,21 @@ public partial class PackageCardViewModel : ProgressViewModel
if (string.IsNullOrWhiteSpace(value?.PackageName)) if (string.IsNullOrWhiteSpace(value?.PackageName))
return; return;
var basePackage = packageFactory[value.PackageName]; if (value.PackageName == UnknownPackage.Key)
CardImage = basePackage?.PreviewImageUri ?? Assets.NoImage; {
InstalledVersion = value.DisplayVersion ?? "Unknown"; IsUnknownPackage = true;
CardImageSource = "";
InstalledVersion = "Unknown";
}
else
{
IsUnknownPackage = false;
var basePackage = packageFactory[value.PackageName];
CardImageSource = basePackage?.PreviewImageUri.ToString()
?? Assets.NoImage.ToString();
InstalledVersion = value.DisplayVersion ?? "Unknown";
}
} }
public override async Task OnLoadedAsync() public override async Task OnLoadedAsync()
@ -94,9 +114,10 @@ public partial class PackageCardViewModel : ProgressViewModel
Text = "Uninstalling..."; Text = "Uninstalling...";
IsIndeterminate = true; IsIndeterminate = true;
Value = -1; Value = -1;
var deleteTask = DeleteDirectoryAsync(Path.Combine(settingsManager.LibraryDir, var packagePath = new DirectoryPath(settingsManager.LibraryDir, Package.LibraryPath);
Package.LibraryPath)); var deleteTask = packagePath.DeleteVerboseAsync(logger);
var taskResult = await notificationService.TryAsync(deleteTask, var taskResult = await notificationService.TryAsync(deleteTask,
"Some files could not be deleted. Please close any open files in the package directory and try again."); "Some files could not be deleted. Please close any open files in the package directory and try again.");
if (taskResult.IsSuccessful) if (taskResult.IsSuccessful)
@ -104,11 +125,14 @@ public partial class PackageCardViewModel : ProgressViewModel
notificationService.Show(new Notification("Success", notificationService.Show(new Notification("Success",
$"Package {Package.DisplayName} uninstalled", $"Package {Package.DisplayName} uninstalled",
NotificationType.Success)); NotificationType.Success));
settingsManager.Transaction(settings => if (!IsUnknownPackage)
{ {
settings.RemoveInstalledPackageAndUpdateActive(Package); settingsManager.Transaction(settings =>
}); {
settings.RemoveInstalledPackageAndUpdateActive(Package);
});
}
EventManager.Instance.OnInstalledPackagesChanged(); EventManager.Instance.OnInstalledPackagesChanged();
} }
@ -117,25 +141,27 @@ public partial class PackageCardViewModel : ProgressViewModel
public async Task Update() public async Task Update()
{ {
if (Package == null) return; if (Package is null || IsUnknownPackage) return;
var basePackage = packageFactory[Package.PackageName!]; var basePackage = packageFactory[Package.PackageName!];
if (basePackage == null) if (basePackage == null)
{ {
logger.Warn("Could not find package {SelectedPackagePackageName}", logger.LogWarning("Could not find package {SelectedPackagePackageName}",
Package.PackageName); Package.PackageName);
notificationService.Show("Invalid Package type", notificationService.Show("Invalid Package type",
$"Package {Package.PackageName.ToRepr()} is not a valid package type", $"Package {Package.PackageName.ToRepr()} is not a valid package type",
NotificationType.Error); NotificationType.Error);
return; return;
} }
var packageName = Package.DisplayName ?? Package.PackageName ?? "";
Text = $"Updating {Package.DisplayName}"; Text = $"Updating {packageName}";
IsIndeterminate = true; IsIndeterminate = true;
var progressId = Guid.NewGuid(); var progressId = Guid.NewGuid();
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
Package.DisplayName, Package.DisplayName ?? Package.PackageName!,
new ProgressReport(0f, isIndeterminate: true, type: ProgressType.Update))); new ProgressReport(0f, isIndeterminate: true, type: ProgressType.Update)));
try try
@ -152,7 +178,7 @@ public partial class PackageCardViewModel : ProgressViewModel
EventManager.Instance.OnGlobalProgressChanged(percent); EventManager.Instance.OnGlobalProgressChanged(percent);
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
Package.DisplayName, progress)); packageName, progress));
}); });
var updateResult = await basePackage.Update(Package, progress); var updateResult = await basePackage.Update(Package, progress);
@ -170,15 +196,15 @@ public partial class PackageCardViewModel : ProgressViewModel
InstalledVersion = Package.DisplayVersion ?? "Unknown"; InstalledVersion = Package.DisplayVersion ?? "Unknown";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
Package.DisplayName, packageName,
new ProgressReport(1f, "Update complete", type: ProgressType.Update))); new ProgressReport(1f, "Update complete", type: ProgressType.Update)));
} }
catch (Exception e) catch (Exception e)
{ {
logger.Error(e, "Error Updating Package ({PackageName})", basePackage.Name); logger.LogError(e, "Error Updating Package ({PackageName})", basePackage.Name);
notificationService.ShowPersistent($"Error Updating {Package.DisplayName}", e.Message, NotificationType.Error); notificationService.ShowPersistent($"Error Updating {Package.DisplayName}", e.Message, NotificationType.Error);
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
Package.DisplayName, packageName,
new ProgressReport(0f, "Update failed", type: ProgressType.Update), Failed: true)); new ProgressReport(0f, "Update failed", type: ProgressType.Update), Failed: true));
} }
finally finally
@ -188,6 +214,29 @@ public partial class PackageCardViewModel : ProgressViewModel
Text = ""; Text = "";
} }
} }
public async Task Import()
{
if (!IsUnknownPackage || Design.IsDesignMode) return;
PackageImportViewModel viewModel = null!;
var dialog = vmFactory.GetDialog<PackageImportViewModel>(vm =>
{
vm.PackagePath = new DirectoryPath(Package?.FullPath ?? throw new InvalidOperationException());
viewModel = vm;
});
dialog.PrimaryButtonText = Resources.Action_Import;
dialog.CloseButtonText = Resources.Action_Cancel;
dialog.DefaultButton = ContentDialogButton.Primary;
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
viewModel.AddPackageWithCurrentInputs();
EventManager.Instance.OnInstalledPackagesChanged();
}
}
public async Task OpenFolder() public async Task OpenFolder()
{ {
@ -199,7 +248,7 @@ public partial class PackageCardViewModel : ProgressViewModel
private async Task<bool> HasUpdate() private async Task<bool> HasUpdate()
{ {
if (Package == null) if (Package == null || IsUnknownPackage || Design.IsDesignMode)
return false; return false;
var basePackage = packageFactory[Package.PackageName!]; var basePackage = packageFactory[Package.PackageName!];
@ -224,86 +273,8 @@ public partial class PackageCardViewModel : ProgressViewModel
} }
catch (Exception e) catch (Exception e)
{ {
logger.Error(e, $"Error checking {Package.PackageName} for updates"); logger.LogError(e, "Error checking {PackageName} for updates", Package.PackageName);
return false; return false;
} }
} }
/// <summary>
/// Deletes a directory and all of its contents recursively.
/// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff.
/// </summary>
/// <param name="targetDirectory"></param>
private Task DeleteDirectoryAsync(string targetDirectory)
{
var policy = Policy.Handle<IOException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)),
onRetry: (exception, calculatedWaitDuration) =>
{
logger.Warn(
exception,
"Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}",
targetDirectory, calculatedWaitDuration);
});
return policy.ExecuteAsync(async () =>
{
await Task.Run(() =>
{
DeleteDirectory(targetDirectory);
});
});
}
private void DeleteDirectory(string targetDirectory)
{
// Skip if directory does not exist
if (!Directory.Exists(targetDirectory))
{
return;
}
// For junction points, delete with recursive false
if (new DirectoryInfo(targetDirectory).LinkTarget != null)
{
logger.Info("Removing junction point {TargetDirectory}", targetDirectory);
try
{
Directory.Delete(targetDirectory, false);
return;
}
catch (IOException ex)
{
throw new IOException($"Failed to delete junction point {targetDirectory}", ex);
}
}
// Recursively delete all subdirectories
var subdirectoryEntries = Directory.GetDirectories(targetDirectory);
foreach (var subdirectoryPath in subdirectoryEntries)
{
DeleteDirectory(subdirectoryPath);
}
// Delete all files in the directory
var fileEntries = Directory.GetFiles(targetDirectory);
foreach (var filePath in fileEntries)
{
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
File.Delete(filePath);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete file {filePath}", ex);
}
}
// Delete the target directory itself
try
{
Directory.Delete(targetDirectory, false);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete directory {targetDirectory}", ex);
}
}
} }

118
StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs

@ -1,11 +1,13 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncAwaitBestPractices; using AsyncAwaitBestPractices;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel; using DynamicData;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
@ -17,6 +19,8 @@ using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol; using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -34,39 +38,73 @@ public partial class PackageManagerViewModel : PageViewModelBase
private readonly IPackageFactory packageFactory; private readonly IPackageFactory packageFactory;
private readonly ServiceManager<ViewModelBase> dialogFactory; private readonly ServiceManager<ViewModelBase> dialogFactory;
public PackageManagerViewModel(ISettingsManager settingsManager, IPackageFactory packageFactory, public override string Title => "Packages";
ServiceManager<ViewModelBase> dialogFactory) public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true };
/// <summary>
/// List of installed packages
/// </summary>
private readonly SourceCache<InstalledPackage, Guid> installedPackages = new(p => p.Id);
/// <summary>
/// List of indexed packages without a corresponding installed package
/// </summary>
private readonly SourceCache<InstalledPackage, Guid> unknownInstalledPackages = new(p => p.Id);
public IObservableCollection<InstalledPackage> Packages { get; } =
new ObservableCollectionExtended<InstalledPackage>();
public IObservableCollection<PackageCardViewModel> PackageCards { get; } =
new ObservableCollectionExtended<PackageCardViewModel>();
public PackageManagerViewModel(
ISettingsManager settingsManager,
IPackageFactory packageFactory,
ServiceManager<ViewModelBase> dialogFactory
)
{ {
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.packageFactory = packageFactory; this.packageFactory = packageFactory;
this.dialogFactory = dialogFactory; this.dialogFactory = dialogFactory;
EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged; EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged;
var installed = installedPackages.Connect();
var unknown = unknownInstalledPackages.Connect();
installed
.Or(unknown)
.DeferUntilLoaded()
.Bind(Packages)
.Transform(p => dialogFactory.Get<PackageCardViewModel>(vm =>
{
vm.Package = p;
vm.OnLoadedAsync().SafeFireAndForget();
}))
.Bind(PackageCards)
.Subscribe();
} }
[ObservableProperty] private ObservableCollection<PackageCardViewModel> packages; public void SetPackages(IEnumerable<InstalledPackage> packages)
{
installedPackages.Edit(s => s.Load(packages));
}
public void SetUnknownPackages(IEnumerable<InstalledPackage> packages)
{
unknownInstalledPackages.Edit(s => s.Load(packages));
}
public override bool CanNavigateNext { get; protected set; } = true;
public override bool CanNavigatePrevious { get; protected set; }
public override string Title => "Packages";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true};
public override async Task OnLoadedAsync() public override async Task OnLoadedAsync()
{ {
if (Design.IsDesignMode) return; if (Design.IsDesignMode)
return;
var installedPackages = settingsManager.Settings.InstalledPackages;
Packages = new ObservableCollection<PackageCardViewModel>(installedPackages.Select(
package => dialogFactory.Get<PackageCardViewModel>(vm =>
{
vm.Package = package;
return vm;
})));
foreach (var package in Packages) installedPackages.EditDiff(settingsManager.Settings.InstalledPackages, InstalledPackage.Comparer);
{
await package.OnLoadedAsync(); var currentUnknown = await Task.Run(IndexUnknownPackages);
} unknownInstalledPackages.Edit(s => s.Load(currentUnknown));
} }
public async Task ShowInstallDialog() public async Task ShowInstallDialog()
@ -83,16 +121,40 @@ public partial class PackageManagerViewModel : PageViewModelBase
IsPrimaryButtonEnabled = false, IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false, IsSecondaryButtonEnabled = false,
IsFooterVisible = false, IsFooterVisible = false,
Content = new InstallerDialog Content = new InstallerDialog { DataContext = viewModel }
{
DataContext = viewModel
}
}; };
await dialog.ShowAsync(); await dialog.ShowAsync();
await OnLoadedAsync(); await OnLoadedAsync();
} }
private IEnumerable<UnknownInstalledPackage> IndexUnknownPackages()
{
var packageDir = new DirectoryPath(settingsManager.LibraryDir).JoinDir("Packages");
if (!packageDir.Exists)
{
yield break;
}
var currentPackages = settingsManager.Settings.InstalledPackages.ToImmutableArray();
foreach (var subDir in packageDir.Info
.EnumerateDirectories()
.Select(info => new DirectoryPath(info)))
{
var expectedLibraryPath = $"Packages{Path.DirectorySeparatorChar}{subDir.Name}";
// Skip if the package is already installed
if (currentPackages.Any(p => p.LibraryPath == expectedLibraryPath))
{
continue;
}
yield return UnknownInstalledPackage.FromDirectoryName(subDir.Name);
}
}
private void OnInstalledPackagesChanged(object? sender, EventArgs e) => private void OnInstalledPackagesChanged(object? sender, EventArgs e) =>
OnLoadedAsync().SafeFireAndForget(); OnLoadedAsync().SafeFireAndForget();
} }

18
StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs

@ -6,21 +6,16 @@ using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels; namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ProgressItemViewModel : ViewModelBase public 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) public ProgressItemViewModel(ProgressItem progressItem)
{ {
Id = progressItem.ProgressId; Id = progressItem.ProgressId;
Name = progressItem.Name; Name = progressItem.Name;
Progress = progressItem.Progress; Progress.Value = progressItem.Progress.Percentage;
Failed = progressItem.Failed; Failed = progressItem.Failed;
ProgressText = GetProgressText(Progress); Progress.Text = GetProgressText(progressItem.Progress);
Progress.IsIndeterminate = progressItem.Progress.IsIndeterminate;
EventManager.Instance.ProgressChanged += OnProgressChanged; EventManager.Instance.ProgressChanged += OnProgressChanged;
} }
@ -30,9 +25,10 @@ public partial class ProgressItemViewModel : ViewModelBase
if (e.ProgressId != Id) if (e.ProgressId != Id)
return; return;
Progress = e.Progress; Progress.Value = e.Progress.Percentage;
Failed = e.Failed; Failed = e.Failed;
ProgressText = GetProgressText(Progress); Progress.Text = GetProgressText(e.Progress);
Progress.IsIndeterminate = e.Progress.IsIndeterminate;
} }
private string GetProgressText(ProgressReport report) private string GetProgressText(ProgressReport report)

78
StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs

@ -1,13 +1,21 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Polly;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol; using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -16,17 +24,72 @@ namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(ProgressManagerPage))] [View(typeof(ProgressManagerPage))]
public partial class ProgressManagerViewModel : PageViewModelBase public partial class ProgressManagerViewModel : PageViewModelBase
{ {
private readonly INotificationService notificationService;
public override string Title => "Download Manager"; public override string Title => "Download Manager";
public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true}; public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true};
[ObservableProperty] public AvaloniaList<ProgressItemViewModelBase> ProgressItems { get; } = new();
private ObservableCollection<ProgressItemViewModel> progressItems;
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() public void StartEventListener()
{ {
EventManager.Instance.ProgressChanged += OnProgressChanged; EventManager.Instance.ProgressChanged += OnProgressChanged;
@ -34,12 +97,7 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public void ClearDownloads() public void ClearDownloads()
{ {
if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f || p.Failed)) ProgressItems.RemoveAll(ProgressItems.Where(x => x.IsCompleted));
return;
var itemsInProgress = ProgressItems
.Where(p => p.Progress.Percentage < 100 && !p.Failed).ToList();
ProgressItems = new ObservableCollection<ProgressItemViewModel>(itemsInProgress);
} }
private void OnProgressChanged(object? sender, ProgressItem e) private void OnProgressChanged(object? sender, ProgressItem e)

122
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -2,6 +2,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@ -11,12 +13,14 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls.Notifications; using Avalonia.Controls.Notifications;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using NLog; using NLog;
using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Helpers; using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
@ -45,6 +49,7 @@ public partial class SettingsViewModel : PageViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper; private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner; private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory; private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ITrackedDownloadService trackedDownloadService;
public SharedState SharedState { get; } public SharedState SharedState { get; }
@ -65,6 +70,11 @@ public partial class SettingsViewModel : PageViewModelBase
"System", "System",
}; };
[ObservableProperty] private CultureInfo selectedLanguage;
// ReSharper disable once MemberCanBeMadeStatic.Global
public IReadOnlyList<CultureInfo> AvailableLanguages => Cultures.SupportedCultures;
public IReadOnlyList<float> AnimationScaleOptions { get; } = new[] public IReadOnlyList<float> AnimationScaleOptions { get; } = new[]
{ {
0f, 0f,
@ -103,17 +113,20 @@ public partial class SettingsViewModel : PageViewModelBase
IPrerequisiteHelper prerequisiteHelper, IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner, IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory, ServiceManager<ViewModelBase> dialogFactory,
SharedState sharedState) SharedState sharedState,
ITrackedDownloadService trackedDownloadService)
{ {
this.notificationService = notificationService; this.notificationService = notificationService;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper; this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner; this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory; this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
SharedState = sharedState; SharedState = sharedState;
SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1];
SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language);
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown;
SelectedAnimationScale = settingsManager.Settings.AnimationScale; SelectedAnimationScale = settingsManager.Settings.AnimationScale;
@ -142,6 +155,43 @@ public partial class SettingsViewModel : PageViewModelBase
_ => ThemeVariant.Default _ => ThemeVariant.Default
}; };
} }
partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue)
{
if (oldValue is null || newValue.Name == Cultures.Current.Name) return;
// Set locale
if (AvailableLanguages.Contains(newValue))
{
Logger.Info("Changing language from {Old} to {New}",
oldValue, newValue);
Cultures.TrySetSupportedCulture(newValue);
settingsManager.Transaction(s => s.Language = newValue.Name);
var dialog = new BetterContentDialog
{
Title = Resources.Label_RelaunchRequired,
Content = Resources.Text_RelaunchRequiredToApplyLanguage,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = Resources.Action_Relaunch,
CloseButtonText = Resources.Action_RelaunchLater
};
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
}
else
{
Logger.Info("Requested invalid language change from {Old} to {New}",
oldValue, newValue);
}
}
partial void OnRemoveSymlinksOnShutdownChanged(bool value) partial void OnRemoveSymlinksOnShutdownChanged(bool value)
{ {
@ -329,6 +379,50 @@ public partial class SettingsViewModel : PageViewModelBase
"Stability Matrix has been added to the Start Menu for all users.", NotificationType.Success); "Stability Matrix has been added to the Start Menu for all users.", NotificationType.Success);
} }
public async Task PickNewDataDirectory()
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog
{
DataContext = viewModel
}
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Restart
var restartDialog = new BetterContentDialog
{
Title = "Restart required",
Content = "Stability Matrix must be restarted for the changes to take effect.",
PrimaryButtonText = "Restart",
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = false,
};
await restartDialog.ShowAsync();
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
}
#endregion #endregion
#region Debug Section #region Debug Section
@ -406,6 +500,32 @@ public partial class SettingsViewModel : PageViewModelBase
// Use try-catch to generate traceback information // Use try-catch to generate traceback information
throw new OperationCanceledException("Example Message"); 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 #endregion
#region Info Section #region Info Section

30
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml

@ -8,7 +8,8 @@
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager" xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager"
xmlns:checkpointBrowser="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser" xmlns:checkpointBrowser="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="700"
x:DataType="viewModels:CheckpointBrowserViewModel" x:DataType="viewModels:CheckpointBrowserViewModel"
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}" d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}"
x:CompileBindings="True" x:CompileBindings="True"
@ -40,7 +41,7 @@
Margin="0,8,0,8" Margin="0,8,0,8"
Height="300" Height="300"
StretchDirection="Both" StretchDirection="Both"
CornerRadius="4" CornerRadius="8"
VerticalContentAlignment="Top" VerticalContentAlignment="Top"
HorizontalContentAlignment="Center" HorizontalContentAlignment="Center"
Source="{Binding CardImage}" Source="{Binding CardImage}"
@ -274,13 +275,30 @@
</TextBlock> </TextBlock>
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal"> <StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
<Button <Button
Command="{Binding PreviousPageCommand}" Command="{Binding FirstPage}"
IsEnabled="{Binding CanGoToFirstPage}"
ToolTip.Tip="First Page"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-backward-fast"/>
</Button>
<Button
Command="{Binding PreviousPage}"
ToolTip.Tip="Previous Page"
IsEnabled="{Binding CanGoToPreviousPage}" IsEnabled="{Binding CanGoToPreviousPage}"
Margin="0,0,8,0"> Margin="0,0,8,0">
<ui:SymbolIcon Symbol="PreviousFilled" /> <avalonia:Icon Value="fa-solid fa-caret-left"/>
</Button> </Button>
<Button Command="{Binding NextPageCommand}" IsEnabled="{Binding CanGoToNextPage}"> <Button Command="{Binding NextPage}"
<ui:SymbolIcon Symbol="NextFilled" /> IsEnabled="{Binding CanGoToNextPage}"
ToolTip.Tip="Next Page"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-caret-right"/>
</Button>
<Button
Command="{Binding LastPage}"
ToolTip.Tip="Last Page"
IsEnabled="{Binding CanGoToLastPage}">
<avalonia:Icon Value="fa-solid fa-forward-fast"/>
</Button> </Button>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

40
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml

@ -239,17 +239,19 @@
</Grid> </Grid>
</Expander.Header> </Expander.Header>
<StackPanel Orientation="Vertical"> <Grid RowDefinitions="Auto, Auto">
<!-- Subfolders --> <!-- Subfolders -->
<StackPanel Orientation="Vertical"> <ItemsRepeater Grid.Row="0"
<ItemsControl HorizontalAlignment="Stretch"
HorizontalAlignment="Stretch" ItemTemplate="{DynamicResource CheckpointFolderGridDataTemplate}"
ItemTemplate="{DynamicResource CheckpointFolderGridDataTemplate}" ItemsSource="{Binding SubFolders, Mode=OneWay}"
ItemsSource="{Binding SubFolders, Mode=OneWay}" Margin="8,0,8,8" >
Margin="8,0,8,8" /> <ItemsRepeater.Layout>
</StackPanel> <StackLayout Orientation="Vertical"/>
</ItemsRepeater.Layout>
</ItemsRepeater>
<!-- Files Grid --> <!-- Files Grid -->
<Grid Name="FilesGrid" <Grid Grid.Row="1" Name="FilesGrid"
Background="Transparent" Background="Transparent"
DragDrop.AllowDrop="True"> DragDrop.AllowDrop="True">
<ItemsRepeater <ItemsRepeater
@ -265,11 +267,8 @@
TextAlignment="Center" TextAlignment="Center"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Margin="8" Margin="8"
IsVisible="{Binding !CheckpointFiles.Count}"> Text="Drag &amp; drop checkpoints here to import"
<Run Text="Drag &amp; drop"/> IsVisible="{Binding !CheckpointFiles.Count}"/>
<Run Text="{Binding FolderType}"/>
<Run Text="checkpoints here to import"/>
</TextBlock>
<!-- Blurred background for drag and drop --> <!-- Blurred background for drag and drop -->
<Border <Border
CornerRadius="8" CornerRadius="8"
@ -291,27 +290,26 @@
<StackPanel <StackPanel
Margin="0,8" Margin="0,8"
Orientation="Vertical" Orientation="Vertical"
DataContext="{Binding Progress}"
VerticalAlignment="Center"> VerticalAlignment="Center">
<!-- Import progress --> <!-- Import progress -->
<TextBlock <TextBlock
Effect="{StaticResource TextDropShadowEffect}" Effect="{StaticResource TextDropShadowEffect}"
FontSize="18" FontSize="18"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Text="{Binding Text}" Text="{Binding Progress.Text}"
VerticalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding IsTextVisible}" /> IsVisible="{Binding Progress.IsTextVisible}" />
<ProgressBar <ProgressBar
Effect="{StaticResource TextDropShadowEffect}" Effect="{StaticResource TextDropShadowEffect}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
IsIndeterminate="{Binding IsIndeterminate, FallbackValue=False}" IsIndeterminate="{Binding Progress.IsIndeterminate, FallbackValue=False}"
Margin="64,8" Margin="64,8"
VerticalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding IsProgressVisible}" IsVisible="{Binding Progress.IsProgressVisible}"
Value="{Binding Value, FallbackValue=20}" /> Value="{Binding Progress.Value, FallbackValue=20}" />
</StackPanel> </StackPanel>
</Grid> </Grid>
</StackPanel> </Grid>
</Expander> </Expander>
</DataTemplate> </DataTemplate>

3
StabilityMatrix.Avalonia/Views/Dialogs/InstallerDialog.axaml

@ -135,7 +135,8 @@
TextWrapping="Wrap" TextWrapping="Wrap"
IsVisible="{Binding SelectedPackage.Disclaimer, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding SelectedPackage.Disclaimer, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0"> <StackPanel Orientation="Horizontal" Margin="0,8,0,0"
IsVisible="{Binding IsReleaseModeAvailable}">
<ToggleButton <ToggleButton
Content="Releases" Content="Releases"
IsChecked="{Binding IsReleaseMode}" IsChecked="{Binding IsReleaseMode}"

3
StabilityMatrix.Avalonia/Views/Dialogs/LaunchOptionsDialog.axaml

@ -23,7 +23,7 @@
</controls:UserControlBase.Resources> </controls:UserControlBase.Resources>
<Grid MinWidth="400" RowDefinitions="0.2*,0.8*" Margin="8"> <Grid MinWidth="400" RowDefinitions="Auto,*" Margin="8">
<StackPanel <StackPanel
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Spacing="4" Spacing="4"
@ -54,6 +54,7 @@
<!-- Option Cards --> <!-- Option Cards -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl <ItemsControl
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Padding="8" Padding="8"
ItemsSource="{Binding FilteredCards}"> ItemsSource="{Binding FilteredCards}">

51
StabilityMatrix.Avalonia/Views/Dialogs/PackageImportDialog.axaml

@ -0,0 +1,51 @@
<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:ui="using:FluentAvalonia.UI.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
d:DataContext="{x:Static mocks:DesignData.PackageImportViewModel}"
x:DataType="vmDialogs:PackageImportViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.PackageImportDialog">
<StackPanel Margin="8" Spacing="8">
<ui:SettingsExpander Header="{x:Static lang:Resources.Label_PackageType}">
<ui:SettingsExpander.Footer>
<ui:FAComboBox
HorizontalAlignment="Stretch"
ItemsSource="{Binding AvailablePackages}"
DisplayMemberBinding="{Binding DisplayName}"
SelectedItem="{Binding SelectedBasePackage}"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{x:Static lang:Resources.Label_VersionType}">
<ui:SettingsExpander.Footer>
<ToggleSwitch
IsEnabled="{Binding IsReleaseModeAvailable}"
OnContent="{x:Static lang:Resources.Label_Releases}"
OffContent="{x:Static lang:Resources.Label_Branches}"
IsChecked="{Binding IsReleaseMode}"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{x:Static lang:Resources.Label_Version}">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<ui:FAComboBox
ItemsSource="{Binding AvailableVersions}"
DisplayMemberBinding="{Binding TagName}"
SelectedItem="{Binding SelectedVersion}"/>
<ui:FAComboBox
IsVisible="{Binding !IsReleaseMode}"
ItemsSource="{Binding AvailableCommits}"
DisplayMemberBinding="{Binding Sha}"
SelectedItem="{Binding SelectedCommit}"/>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</controls:UserControlBase>

17
StabilityMatrix.Avalonia/Views/Dialogs/PackageImportDialog.axaml.cs

@ -0,0 +1,17 @@
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Views.Dialogs;
public partial class PackageImportDialog : UserControlBase
{
public PackageImportDialog()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

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

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

105
StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml

@ -0,0 +1,105 @@
<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:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:checkpointManager="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Views.NewCheckpointsPage"
d:DataContext="{x:Static mocks:DesignData.NewCheckpointsPageViewModel}"
x:CompileBindings="True"
x:Name="ControlBase"
x:DataType="viewModels:NewCheckpointsPageViewModel">
<ScrollViewer>
<Grid RowDefinitions="Auto, Auto" Margin="8">
<TextBlock Text="Installed Checkpoints"
FontSize="24"
Margin="8"/>
<ItemsRepeater Grid.Row="1" ItemsSource="{Binding ConnectedCheckpoints}">
<ItemsRepeater.Layout>
<UniformGridLayout />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type checkpointManager:CheckpointFile}">
<controls:Card
Margin="8"
MaxHeight="450"
Width="300"
CornerRadius="8">
<Grid RowDefinitions="Auto, Auto, Auto, Auto">
<TextBlock Grid.Row="0"
Margin="0,0,0,8"
Text="{Binding ConnectedModel.ModelName}" />
<controls:BetterAdvancedImage
Grid.Row="1"
Height="250"
Stretch="UniformToFill"
CornerRadius="8"
Source="{Binding PreviewImagePath}" />
<Grid Grid.Row="1"
Margin="8,8,0,0"
ColumnDefinitions="Auto, Auto">
<controls:Card
Grid.Column="0"
Classes="info"
Height="24"
HorizontalAlignment="Left"
Padding="4"
Margin="0,0,4,0"
VerticalAlignment="Top">
<TextBlock
FontSize="11"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding ModelType}"
VerticalAlignment="Center" />
</controls:Card>
<controls:Card
Grid.Column="1"
Classes="info"
Height="24"
Margin="4,0"
HorizontalAlignment="Left"
Padding="4"
VerticalAlignment="Top">
<TextBlock
FontSize="11"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding ConnectedModel.BaseModel}"
VerticalAlignment="Center" />
</controls:Card>
</Grid>
<Grid Grid.Row="2"
Margin="0,16,0,0"
ColumnDefinitions="*, *">
<Button Grid.Column="0"
Content="Update"
Classes="accent"
Margin="0,0,4,0"
HorizontalAlignment="Stretch" />
<Button Grid.Column="1"
Margin="4,0,0,0"
Content="All Versions"
Classes="accent"
Command="{Binding $parent[ItemsRepeater].((viewModels:NewCheckpointsPageViewModel)DataContext).ShowVersionDialog}"
CommandParameter="{Binding ConnectedModel.ModelId}"
HorizontalAlignment="Stretch" />
</Grid>
</Grid>
</controls:Card>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Grid>
</ScrollViewer>
</controls:UserControlBase>

11
StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs

@ -0,0 +1,11 @@
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Views;
public partial class NewCheckpointsPage : UserControlBase
{
public NewCheckpointsPage()
{
InitializeComponent();
}
}

55
StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml

@ -4,11 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:packageManager="clr-namespace:StabilityMatrix.Avalonia.ViewModels.PackageManager" xmlns:packageManager="clr-namespace:StabilityMatrix.Avalonia.ViewModels.PackageManager"
xmlns:faicon="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia" xmlns:faicon="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="viewModels:PackageManagerViewModel" x:DataType="viewModels:PackageManagerViewModel"
x:CompileBindings="True" x:CompileBindings="True"
@ -24,7 +24,7 @@
<!-- Cards --> <!-- Cards -->
<ScrollViewer Grid.Row="1"> <ScrollViewer Grid.Row="1">
<ItemsRepeater <ItemsRepeater
ItemsSource="{Binding Packages}"> ItemsSource="{Binding PackageCards}">
<ItemsRepeater.Layout> <ItemsRepeater.Layout>
<UniformGridLayout MinColumnSpacing="12" MinRowSpacing="12" /> <UniformGridLayout MinColumnSpacing="12" MinRowSpacing="12" />
</ItemsRepeater.Layout> </ItemsRepeater.Layout>
@ -36,7 +36,7 @@
<TextBlock Grid.Row="0" <TextBlock Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Text="{Binding Package.DisplayName}" Text="{Binding Package.DisplayName, FallbackValue=''}"
TextAlignment="Left" /> TextAlignment="Left" />
<Button <Button
Grid.Row="0" Grid.Row="0"
@ -51,6 +51,7 @@
<Button.Flyout> <Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft"> <MenuFlyout Placement="BottomEdgeAlignedLeft">
<MenuItem Header="Check for Update" <MenuItem Header="Check for Update"
IsVisible="{Binding !IsUnknownPackage}"
Command="{Binding OnLoadedAsync}"> Command="{Binding OnLoadedAsync}">
<MenuItem.Icon> <MenuItem.Icon>
<ui:SymbolIcon Symbol="Refresh" /> <ui:SymbolIcon Symbol="Refresh" />
@ -79,6 +80,7 @@
VerticalAlignment="Center" VerticalAlignment="Center"
Text="{Binding InstalledVersion}" /> Text="{Binding InstalledVersion}" />
<!-- Normal packages shows image -->
<controls:BetterAdvancedImage <controls:BetterAdvancedImage
Grid.Row="2" Grid.Row="2"
Grid.Column="0" Grid.Column="0"
@ -89,12 +91,34 @@
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalContentAlignment="Top" VerticalContentAlignment="Top"
HorizontalContentAlignment="Center" HorizontalContentAlignment="Center"
Source="{Binding CardImage}" Source="{Binding CardImageSource}"
IsVisible="{Binding !IsUnknownPackage}"
Stretch="UniformToFill" /> Stretch="UniformToFill" />
<!-- Unknown packages panel -->
<Border
Grid.Row="2"
Grid.Column="0"
Margin="0,8,0,8"
Height="160"
Width="200"
CornerRadius="4"
HorizontalAlignment="Center"
IsVisible="{Binding IsUnknownPackage}"
Background="#202020">
<TextBlock
TextAlignment="Center"
VerticalAlignment="Center"
FontSize="18"
TextWrapping="Wrap"
Text="{x:Static lang:Resources.Label_UnknownPackage}"/>
</Border>
<Grid Grid.Row="3" Grid.Column="0" <Grid Grid.Row="3" Grid.Column="0"
IsVisible="{Binding IsUpdateAvailable}" IsVisible="{Binding IsUpdateAvailable}"
ColumnDefinitions="*, *"> ColumnDefinitions="*, *">
<!-- Launch and update buttons -->
<Button Grid.Column="0" Classes="accent" <Button Grid.Column="0" Classes="accent"
VerticalAlignment="Bottom" VerticalAlignment="Bottom"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
@ -122,14 +146,33 @@
<!-- Big launch button --> <!-- Big launch button -->
<Button Grid.Row="3" Grid.Column="0" Classes="accent" <Button Grid.Row="3" Grid.Column="0" Classes="accent"
VerticalAlignment="Bottom" VerticalAlignment="Bottom"
IsVisible="{Binding !IsUpdateAvailable}"
Command="{Binding Launch}" Command="{Binding Launch}"
HorizontalAlignment="Stretch"> HorizontalAlignment="Stretch">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="!IsUpdateAvailable" />
<Binding Path="!IsUnknownPackage" />
</MultiBinding>
</Button.IsVisible>
<StackPanel Orientation="Horizontal" Margin="0,2,0,2"> <StackPanel Orientation="Horizontal" Margin="0,2,0,2">
<faicon:Icon Value="fa-solid fa-rocket" <faicon:Icon Value="fa-solid fa-rocket"
Margin="0,0,8,0" Margin="0,0,8,0"
FontSize="14" /> FontSize="14" />
<TextBlock Text="Launch" /> <TextBlock Text="{x:Static lang:Resources.Action_Launch}" />
</StackPanel>
</Button>
<!-- Import button (for unknown) -->
<Button Grid.Row="3" Grid.Column="0" Classes="transparent-info"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Command="{Binding Import}"
IsVisible="{Binding IsUnknownPackage}">
<StackPanel Orientation="Horizontal" Margin="0,2,0,2">
<faicon:Icon Value="fa-solid fa-circle-question"
Margin="0,0,8,0"
FontSize="14" />
<TextBlock Text="{x:Static lang:Resources.Action_Import}" />
</StackPanel> </StackPanel>
</Button> </Button>

206
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -1,46 +1,156 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui" <controls:UserControlBase
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns="https://github.com/avaloniaui"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}" xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
x:DataType="vm:ProgressManagerViewModel" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
MaxHeight="250" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="250" xmlns:ui="using:FluentAvalonia.UI.Controls"
x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage"> 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> <ScrollViewer>
<Grid RowDefinitions="Auto, *"> <Grid RowDefinitions="Auto, *">
<TextBlock Grid.Row="0" Text="Downloads" <TextBlock
VerticalAlignment="Center" Grid.Row="0"
TextDecorations="Underline" VerticalAlignment="Center"
FontSize="16" FontSize="16"
TextAlignment="Left" /> Text="Downloads"
<Button Grid.Row="0" TextAlignment="Left"
Content="Clear Finished" TextDecorations="Underline" />
Padding="4" <Button
Classes="transparent" Grid.Row="0"
Margin="0,0,8,0" Margin="0,0,8,0"
IsVisible="{Binding !!ProgressItems.Count}" Padding="4"
Command="{Binding ClearDownloads}" HorizontalAlignment="Right"
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}"> <DataTemplate DataType="{x:Type vm:ProgressItemViewModel}">
<Border BorderBrush="#33000000" <Border
Background="#22000000" Margin="4"
BorderThickness="2" Padding="8"
CornerRadius="8" Background="#22000000"
Padding="8" BorderBrush="#33000000"
Margin="4"> BorderThickness="2"
<StackPanel> CornerRadius="8">
<TextBlock Text="{Binding Name, Mode=OneWay}" <Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
Margin="0, 4" /> <StackPanel Grid.Row="0" Grid.Column="0">
<ProgressBar Value="{Binding Progress.Percentage, Mode=OneWay}" <TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Margin="0, 4"> <!-- 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> <ProgressBar.Transitions>
<Transitions> <Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150"> <DoubleTransition Property="Value" Duration="00:00:00.150">
@ -51,23 +161,11 @@
</Transitions> </Transitions>
</ProgressBar.Transitions> </ProgressBar.Transitions>
</ProgressBar> </ProgressBar>
</Grid>
<!-- 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>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsRepeater.ItemTemplate> </ItemsControl.DataTemplates>
</ItemsRepeater> </ItemsControl>
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>
</controls:UserControlBase> </controls:UserControlBase>

675
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -1,317 +1,368 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui" <controls:UserControlBase
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="StabilityMatrix.Avalonia.Views.SettingsPage"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns="https://github.com/avaloniaui"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="700" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
x:DataType="vm:SettingsViewModel" xmlns:ui="using:FluentAvalonia.UI.Controls"
x:CompileBindings="True" xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
d:DataContext="{x:Static mocks:DesignData.SettingsViewModel}" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
x:Class="StabilityMatrix.Avalonia.Views.SettingsPage"> d:DataContext="{x:Static mocks:DesignData.SettingsViewModel}"
d:DesignHeight="700"
<Grid> d:DesignWidth="800"
<ScrollViewer VerticalScrollBarVisibility="Auto"> x:CompileBindings="True"
<Grid RowDefinitions="Auto, Auto, Auto, Auto, Auto, Auto, Auto, Auto" x:DataType="vm:SettingsViewModel"
Margin="8, 16"> mc:Ignorable="d">
<!-- Theme -->
<Grid Grid.Row="0" RowDefinitions="auto,*">
<TextBlock
FontWeight="Medium"
Text="Appearance"
Margin="0,0,0,8" />
<ui:SettingsExpander
Grid.Row="1"
Header="Theme"
IconSource="WeatherMoon"
Margin="8,0,8,4">
<ui:SettingsExpander.Footer>
<ComboBox
ItemsSource="{Binding AvailableThemes}"
SelectedItem="{Binding SelectedTheme}"
MinWidth="100"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- TODO: Text2Image host port settings -->
<!-- Checkpoints Manager Options -->
<Grid Grid.Row="1" Margin="0,8,0,0" RowDefinitions="auto,*,Auto">
<TextBlock
FontWeight="Medium"
Text="Checkpoint Manager"
Margin="0,0,0,8" />
<ui:SettingsExpander
Grid.Row="1"
IconSource="Folder"
Header="Remove shared checkpoints directory symbolic links on shutdown"
Description="Select this option if you're having problems moving Stability Matrix to another drive"
Margin="8,0">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8"
IsChecked="{Binding RemoveSymlinksOnShutdown}"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
IconSource="Refresh"
Header="Reset Checkpoints Cache"
Description="Rebuilds the installed checkpoints cache. Use if checkpoints are incorrectly labeled in the Model Browser."
Margin="8, 4">
<ui:SettingsExpander.Footer>
<Button Command="{Binding ResetCheckpointCache}"
Content="Reset Checkpoints Cache"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Environment Options -->
<Grid Grid.Row="2" Margin="0,8,0,0" RowDefinitions="Auto, Auto, Auto">
<TextBlock
FontWeight="Medium"
Text="Package Environment"
Margin="0,0,0,8" />
<ui:SettingsExpander Grid.Row="1"
Header="Environment Variables"
IconSource="OtherUser"
Margin="8,0">
<ui:SettingsExpander.Footer>
<Button Content="Edit"
Command="{Binding OpenEnvVarsDialogCommand}"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Grid.Row="2"
Header="Embedded Python"
Margin="8,4">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-python"/>
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="16">
<controls:ProgressRing
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}"
IsVisible="{Binding CheckPythonVersionCommand.IsRunning}"
IsIndeterminate="True"
BorderThickness="3"/>
<Button Content="Check Version" Command="{Binding CheckPythonVersionCommand}"/>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Integrations -->
<Grid Grid.Row="3" Margin="0,8,0,0" RowDefinitions="auto,*">
<TextBlock
FontWeight="Medium"
Text="Integrations"
Margin="0,0,0,8" />
<ui:SettingsExpander Grid.Row="1"
Header="Discord Rich Presence"
Margin="8,0,8,4">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-discord"/>
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch
IsChecked="{Binding IsDiscordRichPresenceEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- System Options -->
<Grid Grid.Row="4" Margin="0,8,0,0" RowDefinitions="auto,*">
<TextBlock
FontWeight="Medium"
Text="System"
Margin="0,0,0,8" />
<ui:SettingsExpander
Grid.Row="1"
ToolTip.Tip="{OnPlatform Default='Only available on Windows', Windows={x:Null}}"
Header="Add Stability Matrix to the Start Menu"
Description="Uses the current app location, you can run this again if you move the app"
IconSource="StarAdd"
Margin="8,0,8,4">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:ProgressRing
IsIndeterminate="True"
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}"
BorderThickness="3">
<controls:ProgressRing.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="AddToStartMenuCommand.IsRunning"/>
<Binding Path="AddToGlobalStartMenuCommand.IsRunning"/>
</MultiBinding>
</controls:ProgressRing.IsVisible>
</controls:ProgressRing>
<SplitButton
Command="{Binding AddToStartMenuCommand}"
IsEnabled="{OnPlatform Default=False, Windows=True}"
Content="Add for Current User">
<SplitButton.Flyout>
<ui:FAMenuFlyout Placement="Bottom">
<ui:MenuFlyoutItem
Command="{Binding AddToGlobalStartMenuCommand}"
IconSource="Admin"
Text="Add for All Users"/>
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Debug Options -->
<Grid Grid.Row="5" RowDefinitions="auto,*"
Margin="0,8,0,0"
IsVisible="{Binding SharedState.IsDebugMode}" >
<TextBlock
FontWeight="Medium"
Text="Debug Options"
Margin="0,0,0,8" />
<ui:SettingsExpander
Grid.Row="1"
IconSource="Code"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
Margin="8, 0,8,4">
<ui:SettingsExpanderItem Description="Paths" IconSource="Folder"
Margin="4">
<SelectableTextBlock Text="{Binding DebugPaths}"
Foreground="{DynamicResource TextControlPlaceholderForeground}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Description="Compat Info" IconSource="StarFilled" <ScrollViewer VerticalScrollBarVisibility="Auto">
Margin="4,0,4,4"> <StackPanel Margin="8,16" Spacing="8">
<SelectableTextBlock Text="{Binding DebugCompatInfo}" <!-- Theme -->
Foreground="{DynamicResource TextControlPlaceholderForeground}" <Grid RowDefinitions="Auto,*,*">
TextWrapping="WrapWithOverflow" /> <TextBlock
</ui:SettingsExpanderItem> Margin="0,0,0,8"
FontWeight="Medium"
<ui:SettingsExpanderItem Description="GPU Info" IconSource="FullScreenMaximize" Text="Appearance" />
Margin="4,0,4,4"> <ui:SettingsExpander
<SelectableTextBlock Text="{Binding DebugGpuInfo}" Grid.Row="1"
Foreground="{DynamicResource TextControlPlaceholderForeground}" Margin="8,0,8,4"
TextWrapping="WrapWithOverflow" /> Header="Theme"
</ui:SettingsExpanderItem> IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableThemes}"
SelectedItem="{Binding SelectedTheme}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
IsVisible="{Binding SharedState.IsDebugMode}"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableLanguages}"
DisplayMemberBinding="{Binding NativeName}"
SelectedItem="{Binding SelectedLanguage}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<ui:SettingsExpanderItem Content="Animation Scale" IconSource="Clock" <!-- Checkpoints Manager Options -->
Description="Lower values = faster animations. 0x means animations are instant." <Grid RowDefinitions="auto,*,Auto">
Margin="4,0,4,4"> <TextBlock
<ui:SettingsExpanderItem.Footer> Margin="0,0,0,8"
<ComboBox Margin="0, 8" FontWeight="Medium"
ItemsSource="{Binding AnimationScaleOptions}" Text="Checkpoint Manager" />
SelectedItem="{Binding SelectedAnimationScale}"> <ui:SettingsExpander
<ComboBox.ItemTemplate> Grid.Row="1"
<DataTemplate> Margin="8,0"
<TextBlock> Description="Select this option if you're having problems moving Stability Matrix to another drive"
<Run Text="{Binding }"/><Run Text="x"/> Header="Remove shared checkpoints directory symbolic links on shutdown"
</TextBlock> IconSource="Folder">
</DataTemplate> <ui:SettingsExpander.Footer>
</ComboBox.ItemTemplate> <CheckBox Margin="8" IsChecked="{Binding RemoveSymlinksOnShutdown}" />
</ComboBox> </ui:SettingsExpander.Footer>
</ui:SettingsExpanderItem.Footer> </ui:SettingsExpander>
</ui:SettingsExpanderItem> <ui:SettingsExpander
Grid.Row="2"
<ui:SettingsExpanderItem Content="Notification" IconSource="CommentAdd" Margin="8,4"
Margin="4,0,4,4"> Description="Rebuilds the installed checkpoints cache. Use if checkpoints are incorrectly labeled in the Model Browser."
<ui:SettingsExpanderItem.Footer> Header="Reset Checkpoints Cache"
<Button IconSource="Refresh">
Margin="0, 8" <ui:SettingsExpander.Footer>
Command="{Binding DebugNotificationCommand}" <Button Command="{Binding ResetCheckpointCache}" Content="Reset Checkpoints Cache" />
Content="New Notification"/> </ui:SettingsExpander.Footer>
</ui:SettingsExpanderItem.Footer> </ui:SettingsExpander>
</ui:SettingsExpanderItem> </Grid>
<ui:SettingsExpanderItem Content="Content Dialog" IconSource="NewWindow" <!-- Environment Options -->
Margin="4,0,4,4"> <Grid RowDefinitions="Auto, Auto, Auto">
<ui:SettingsExpanderItem.Footer> <TextBlock
<Button Margin="0,0,0,8"
Margin="0, 8" FontWeight="Medium"
Command="{Binding DebugContentDialogCommand}" Text="Package Environment" />
Content="Show Dialog"/>
</ui:SettingsExpanderItem.Footer> <ui:SettingsExpander
</ui:SettingsExpanderItem> Grid.Row="1"
Margin="8,0"
<ui:SettingsExpanderItem Content="Exceptions" IconSource="Flag" Header="Environment Variables"
Margin="4,0,4,4"> IconSource="OtherUser">
<ui:SettingsExpanderItem.Footer> <ui:SettingsExpander.Footer>
<Button <Button Command="{Binding OpenEnvVarsDialogCommand}" Content="Edit" />
Margin="0, 8" </ui:SettingsExpander.Footer>
Command="{Binding DebugThrowExceptionCommand}" </ui:SettingsExpander>
Content="Unhandled Exception"/>
</ui:SettingsExpanderItem.Footer> <ui:SettingsExpander
</ui:SettingsExpanderItem> Grid.Row="2"
Margin="8,4"
</ui:SettingsExpander> Header="Embedded Python">
</Grid> <ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-python" />
<!-- TODO: Python card --> </ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<!-- TODO: Debug card --> <StackPanel Orientation="Horizontal" Spacing="16">
<controls:ProgressRing
<!-- TODO: Directories card --> BorderThickness="3"
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}"
<Grid Grid.Row="6" RowDefinitions="auto,*" Margin="0,4,0,0"> IsIndeterminate="True"
<StackPanel IsVisible="{Binding CheckPythonVersionCommand.IsRunning}" />
Grid.Row="1" <Button Command="{Binding CheckPythonVersionCommand}" Content="Check Version" />
HorizontalAlignment="Left" </StackPanel>
Orientation="Vertical"> </ui:SettingsExpander.Footer>
<TextBlock </ui:SettingsExpander>
FontSize="15" </Grid>
FontWeight="Bold"
Margin="0,8" <!-- Integrations -->
Text="About" /> <Grid RowDefinitions="auto,*">
<Image <TextBlock
Height="112" Margin="0,0,0,8"
HorizontalAlignment="Left" FontWeight="Medium"
Margin="8" Text="Integrations" />
Source="/Assets/Icon.png" <ui:SettingsExpander
Width="112" /> Grid.Row="1"
<TextBlock Margin="8,0,8,4"
FontWeight="Medium" Header="Discord Rich Presence">
Margin="8" <ui:SettingsExpander.IconSource>
Text="Stability Matrix" /> <controls:FASymbolIconSource Symbol="fa-brands fa-discord" />
<Panel> </ui:SettingsExpander.IconSource>
<Button <ui:SettingsExpander.Footer>
Name="VersionButton" <ToggleSwitch IsChecked="{Binding IsDiscordRichPresenceEnabled}" />
Command="{Binding OnVersionClick}" </ui:SettingsExpander.Footer>
Classes="transparent" </ui:SettingsExpander>
BorderThickness="0" </Grid>
Content="{Binding AppVersion}"
Margin="8,0,8,8" <!-- System Options -->
Padding="2,0,2,0"/> <Grid RowDefinitions="auto, auto, auto">
<ui:TeachingTip <TextBlock
PreferredPlacement="RightTop" Margin="0,0,0,8"
Target="{Binding #VersionButton}" FontWeight="Medium"
IsOpen="{Binding IsVersionTapTeachingTipOpen}" Text="System" />
Title="{Binding VersionFlyoutText}"/> <ui:SettingsExpander
</Panel> Grid.Row="1"
Margin="8,0,8,4"
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal"> Description="Uses the current app location, you can run this again if you move the app"
<Button Header="Add Stability Matrix to the Start Menu"
Content="License and Open Source Notices" IconSource="StarAdd"
Command="{Binding ShowLicensesDialogCommand}" ToolTip.Tip="{OnPlatform Default='Only available on Windows',
HorizontalAlignment="Left" Windows={x:Null}}">
Margin="8" /> <ui:SettingsExpander.Footer>
</StackPanel> <StackPanel Orientation="Horizontal" Spacing="8">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}"
IsIndeterminate="True">
<controls:ProgressRing.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="AddToStartMenuCommand.IsRunning" />
<Binding Path="AddToGlobalStartMenuCommand.IsRunning" />
</MultiBinding>
</controls:ProgressRing.IsVisible>
</controls:ProgressRing>
<SplitButton
Command="{Binding AddToStartMenuCommand}"
Content="Add for Current User"
IsEnabled="{OnPlatform Default=False,
Windows=True}">
<SplitButton.Flyout>
<ui:FAMenuFlyout Placement="Bottom">
<ui:MenuFlyoutItem
Command="{Binding AddToGlobalStartMenuCommand}"
IconSource="Admin"
Text="Add for All Users" />
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel> </StackPanel>
</Grid> </ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Extra space at the bottom --> <ui:SettingsExpander
<Panel Grid.Row="7" Margin="0,0,0,16" /> Grid.Row="2"
Margin="8,0"
Description="Does not move existing data"
Header="Select New Data Directory"
IconSource="MoveToFolder">
<ui:SettingsExpander.Footer>
<Button Command="{Binding PickNewDataDirectory}">
<Grid ColumnDefinitions="Auto, Auto">
<avalonia:Icon
Grid.Row="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Value="fa-solid fa-folder-open" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="Select Directory" />
</Grid>
</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid> </Grid>
</ScrollViewer>
</Grid> <!-- Debug Options -->
<Grid
IsVisible="{Binding SharedState.IsDebugMode}"
RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Debug Options" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,0"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
IconSource="Code">
<ui:SettingsExpanderItem
Margin="4,0"
Description="Paths"
IconSource="Folder">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugPaths}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="Compat Info"
IconSource="StarFilled">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugCompatInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="GPU Info"
IconSource="FullScreenMaximize">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugGpuInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Animation Scale"
Description="Lower values = faster animations. 0x means animations are instant."
IconSource="Clock">
<ui:SettingsExpanderItem.Footer>
<ComboBox ItemsSource="{Binding AnimationScaleOptions}" SelectedItem="{Binding SelectedAnimationScale}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding}" /><Run Text="x" />
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Notification"
IconSource="CommentAdd">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugNotificationCommand}" Content="New Notification" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Content Dialog"
IconSource="NewWindow">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugContentDialogCommand}" Content="Show Dialog" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Exceptions"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugThrowExceptionCommand}" Content="Unhandled Exception" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Download Manager tests"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Grid>
<!-- TODO: Directories card -->
<Grid RowDefinitions="auto,*">
<StackPanel
Grid.Row="1"
HorizontalAlignment="Left"
Orientation="Vertical">
<TextBlock
Margin="0,8"
FontSize="15"
FontWeight="Bold"
Text="About" />
<Image
Width="112"
Height="112"
Margin="8"
HorizontalAlignment="Left"
Source="/Assets/Icon.png" />
<TextBlock
Margin="8"
FontWeight="Medium"
Text="Stability Matrix" />
<Panel>
<Button
Name="VersionButton"
Margin="8,0,8,8"
Padding="2,0,2,0"
BorderThickness="0"
Classes="transparent"
Command="{Binding OnVersionClick}"
Content="{Binding AppVersion}" />
<ui:TeachingTip
Title="{Binding VersionFlyoutText}"
IsOpen="{Binding IsVersionTapTeachingTipOpen}"
PreferredPlacement="RightTop"
Target="{Binding #VersionButton}" />
</Panel>
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<Button
Margin="8"
HorizontalAlignment="Left"
Command="{Binding ShowLicensesDialogCommand}"
Content="License and Open Source Notices" />
</StackPanel>
</StackPanel>
</Grid>
<!-- Extra space at the bottom -->
<Panel Margin="0,0,0,16" />
</StackPanel>
</ScrollViewer>
</controls:UserControlBase> </controls:UserControlBase>

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 => 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; return hash;
} }
finally finally

10
StabilityMatrix.Core/Helper/HardwareHelper.cs

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

24
StabilityMatrix.Core/Helper/PropertyComparer.cs

@ -0,0 +1,24 @@
namespace StabilityMatrix.Core.Helper;
public class PropertyComparer<T> : IEqualityComparer<T> where T : class
{
private Func<T, object> Expr { get; set; }
public PropertyComparer(Func<T, object> expr)
{
Expr = expr;
}
public bool Equals(T? x, T? y)
{
if (x == null || y == null) return false;
var first = Expr.Invoke(x);
var second = Expr.Invoke(y);
return first.Equals(second);
}
public int GetHashCode(T obj)
{
return obj.GetHashCode();
}
}

4
StabilityMatrix.Core/Models/Api/CivitModelFpType.cs

@ -8,6 +8,8 @@ namespace StabilityMatrix.Core.Models.Api;
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public enum CivitModelFpType public enum CivitModelFpType
{ {
bf16,
fp16, fp16,
fp32 fp32,
tf32
} }

16
StabilityMatrix.Core/Models/Api/CivitModelType.cs

@ -9,23 +9,29 @@ namespace StabilityMatrix.Core.Models.Api;
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public enum CivitModelType public enum CivitModelType
{ {
Unknown,
[ConvertTo<SharedFolderType>(SharedFolderType.StableDiffusion)] [ConvertTo<SharedFolderType>(SharedFolderType.StableDiffusion)]
Checkpoint, Checkpoint,
[ConvertTo<SharedFolderType>(SharedFolderType.TextualInversion)] [ConvertTo<SharedFolderType>(SharedFolderType.TextualInversion)]
TextualInversion, TextualInversion,
[ConvertTo<SharedFolderType>(SharedFolderType.Hypernetwork)] [ConvertTo<SharedFolderType>(SharedFolderType.Hypernetwork)]
Hypernetwork, Hypernetwork,
AestheticGradient,
[ConvertTo<SharedFolderType>(SharedFolderType.Lora)] [ConvertTo<SharedFolderType>(SharedFolderType.Lora)]
LORA, LORA,
[ConvertTo<SharedFolderType>(SharedFolderType.ControlNet)] [ConvertTo<SharedFolderType>(SharedFolderType.ControlNet)]
Controlnet, Controlnet,
Poses,
[ConvertTo<SharedFolderType>(SharedFolderType.StableDiffusion)]
Model,
[ConvertTo<SharedFolderType>(SharedFolderType.LyCORIS)] [ConvertTo<SharedFolderType>(SharedFolderType.LyCORIS)]
LoCon, LoCon,
[ConvertTo<SharedFolderType>(SharedFolderType.VAE)]
VAE,
// Unused/obsolete/unknown/meta options
AestheticGradient,
Model,
Poses,
Upscaler,
Wildcards,
Workflows,
Other, Other,
All, All,
Unknown
} }

18
StabilityMatrix.Core/Models/Api/CivitModelsRequest.cs

@ -117,4 +117,22 @@ public class CivitModelsRequest
/// </summary> /// </summary>
[AliasAs("baseModels")] [AliasAs("baseModels")]
public string? BaseModel { get; set; } public string? BaseModel { get; set; }
[AliasAs("ids")]
public string CommaSeparatedModelIds { get; set; }
public override string ToString()
{
return $"Page: {Page}, " +
$"Query: {Query}, " +
$"Tag: {Tag}, " +
$"Username: {Username}, " +
$"Types: {Types}, " +
$"Sort: {Sort}, " +
$"Period: {Period}, " +
$"Rating: {Rating}, " +
$"Nsfw: {Nsfw}, " +
$"BaseModel: {BaseModel}, " +
$"CommaSeparatedModelIds: {CommaSeparatedModelIds}";
}
} }

4
StabilityMatrix.Core/Models/Api/CivitSortMode.cs

@ -11,5 +11,7 @@ public enum CivitSortMode
[EnumMember(Value = "Most Downloaded")] [EnumMember(Value = "Most Downloaded")]
MostDownloaded, MostDownloaded,
[EnumMember(Value = "Newest")] [EnumMember(Value = "Newest")]
Newest Newest,
[EnumMember(Value = "Installed")]
Installed,
} }

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

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

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

5
StabilityMatrix.Core/Models/InstalledPackage.cs

@ -39,7 +39,7 @@ public class InstalledPackage
public DateTimeOffset? LastUpdateCheck { get; set; } public DateTimeOffset? LastUpdateCheck { get; set; }
public bool UpdateAvailable { get; set; } public bool UpdateAvailable { get; set; }
/// <summary> /// <summary>
/// Get the path as a relative sub-path of the relative path. /// Get the path as a relative sub-path of the relative path.
/// If not a sub-path, return null. /// If not a sub-path, return null.
@ -158,6 +158,9 @@ public class InstalledPackage
LibraryPath = System.IO.Path.Combine("Packages", packageFolderName); LibraryPath = System.IO.Path.Combine("Packages", packageFolderName);
} }
public static IEqualityComparer<InstalledPackage> Comparer { get; } =
new PropertyComparer<InstalledPackage>(p => p.Id);
protected bool Equals(InstalledPackage other) protected bool Equals(InstalledPackage other)
{ {
return Id.Equals(other.Id); return Id.Equals(other.Id);

9
StabilityMatrix.Core/Models/LaunchOptionCard.cs

@ -45,8 +45,13 @@ public readonly record struct LaunchOptionCard
// During card creation, store dict of options with initial values // During card creation, store dict of options with initial values
var initialOptions = new Dictionary<string, object>(); var initialOptions = new Dictionary<string, object>();
// Dict of // To dictionary ignoring duplicates
var launchArgsDict = launchArgs.ToDictionary(launchArg => launchArg.Name); var launchArgsDict = launchArgs
.ToLookup(launchArg => launchArg.Name)
.ToDictionary(
group => group.Key,
group => group.First()
);
// Create cards // Create cards
foreach (var definition in definitions) foreach (var definition in definitions)

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

@ -139,7 +139,7 @@ public abstract class BaseGitPackage : BasePackage
} }
public override async Task<string> DownloadPackage(string version, bool isCommitHash, public override async Task<string> DownloadPackage(string version, bool isCommitHash,
IProgress<ProgressReport>? progress = null) string? branch, IProgress<ProgressReport>? progress = null)
{ {
var downloadUrl = GetDownloadUrl(version, isCommitHash); var downloadUrl = GetDownloadUrl(version, isCommitHash);
@ -151,7 +151,7 @@ public abstract class BaseGitPackage : BasePackage
await DownloadService await DownloadService
.DownloadToFileAsync(downloadUrl, DownloadLocation, progress: progress) .DownloadToFileAsync(downloadUrl, DownloadLocation, progress: progress)
.ConfigureAwait(false); .ConfigureAwait(false);
progress?.Report(new ProgressReport(100, message: "Download Complete")); progress?.Report(new ProgressReport(100, message: "Download Complete"));
return version; return version;
@ -246,7 +246,8 @@ public abstract class BaseGitPackage : BasePackage
{ {
var releases = await GetAllReleases().ConfigureAwait(false); var releases = await GetAllReleases().ConfigureAwait(false);
var latestRelease = releases.First(x => includePrerelease || !x.Prerelease); var latestRelease = releases.First(x => includePrerelease || !x.Prerelease);
await DownloadPackage(latestRelease.TagName, false, progress).ConfigureAwait(false); await DownloadPackage(latestRelease.TagName, false, null, progress)
.ConfigureAwait(false);
await InstallPackage(progress).ConfigureAwait(false); await InstallPackage(progress).ConfigureAwait(false);
return latestRelease.TagName; return latestRelease.TagName;
} }
@ -260,8 +261,9 @@ public abstract class BaseGitPackage : BasePackage
{ {
throw new Exception("No commits found for branch"); throw new Exception("No commits found for branch");
} }
await DownloadPackage(latestCommit.Sha, true, progress).ConfigureAwait(false); await DownloadPackage(latestCommit.Sha, true, installedPackage.InstalledBranch, progress)
.ConfigureAwait(false);
await InstallPackage(progress).ConfigureAwait(false); await InstallPackage(progress).ConfigureAwait(false);
return latestCommit.Sha; return latestCommit.Sha;
} }

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

@ -18,6 +18,7 @@ public abstract class BasePackage
public abstract string LicenseType { get; } public abstract string LicenseType { get; }
public abstract string LicenseUrl { get; } public abstract string LicenseUrl { get; }
public virtual string Disclaimer => string.Empty; public virtual string Disclaimer => string.Empty;
public virtual bool OfferInOneClickInstaller => true;
/// <summary> /// <summary>
/// Primary command to launch the package. 'Launch' buttons uses this. /// Primary command to launch the package. 'Launch' buttons uses this.
@ -33,7 +34,7 @@ public abstract class BasePackage
public virtual bool ShouldIgnoreReleases => false; public virtual bool ShouldIgnoreReleases => false;
public virtual bool UpdateAvailable { get; set; } public virtual bool UpdateAvailable { get; set; }
public abstract Task<string> DownloadPackage(string version, bool isCommitHash, public abstract Task<string> DownloadPackage(string version, bool isCommitHash, string? branch,
IProgress<ProgressReport>? progress = null); IProgress<ProgressReport>? progress = null);
public abstract Task InstallPackage(IProgress<ProgressReport>? progress = null); public abstract Task InstallPackage(IProgress<ProgressReport>? progress = null);
public abstract Task RunPackage(string installedPackagePath, string command, string arguments); public abstract Task RunPackage(string installedPackagePath, string command, string arguments);
@ -72,8 +73,8 @@ public abstract class BasePackage
public abstract Task<IEnumerable<Branch>> GetAllBranches(); public abstract Task<IEnumerable<Branch>> GetAllBranches();
public abstract Task<IEnumerable<Release>> GetAllReleases(); public abstract Task<IEnumerable<Release>> GetAllReleases();
public abstract string DownloadLocation { get; } public virtual string? DownloadLocation { get; }
public abstract string InstallLocation { get; set; } public virtual string? InstallLocation { get; set; }
public event EventHandler<ProcessOutput>? ConsoleOutput; public event EventHandler<ProcessOutput>? ConsoleOutput;
public event EventHandler<int>? Exited; public event EventHandler<int>? Exited;

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

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

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

@ -116,7 +116,7 @@ public class InvokeAI : BaseGitPackage
public override Task<string> GetLatestVersion() => Task.FromResult("main"); public override Task<string> GetLatestVersion() => Task.FromResult("main");
public override Task<string> DownloadPackage(string version, bool isCommitHash, public override Task<string> DownloadPackage(string version, bool isCommitHash, string? branch,
IProgress<ProgressReport>? progress = null) IProgress<ProgressReport>? progress = null)
{ {
return Task.FromResult(version); return Task.FromResult(version);

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

@ -0,0 +1,105 @@
using Octokit;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Core.Models.Packages;
public class UnknownPackage : BasePackage
{
public static string Key => "unknown-package";
public override string Name => Key;
public override string DisplayName { get; set; } = "Unknown Package";
public override string Author => "";
public override string GithubUrl => "";
public override string LicenseType => "AGPL-3.0";
public override string LicenseUrl =>
"https://github.com/LykosAI/StabilityMatrix/blob/main/LICENSE";
public override string Blurb => "A dank interface for diffusion";
public override string LaunchCommand => "test";
public override Uri PreviewImageUri => new("");
public override IReadOnlyList<string> ExtraLaunchCommands => new[]
{
"test-config",
};
/// <inheritdoc />
public override Task<string> DownloadPackage(string version, bool isCommitHash, string? branch, IProgress<ProgressReport>? progress = null)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task InstallPackage(IProgress<ProgressReport>? progress = null)
{
throw new NotImplementedException();
}
public override Task RunPackage(string installedPackagePath, string command, string arguments)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task SetupModelFolders(DirectoryPath installDirectory)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task UpdateModelFolders(DirectoryPath installDirectory)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task RemoveModelFolderLinks(DirectoryPath installDirectory)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override void Shutdown()
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task WaitForShutdown()
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task<bool> CheckForUpdates(InstalledPackage package)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task<string> Update(InstalledPackage installedPackage, IProgress<ProgressReport>? progress = null,
bool includePrerelease = false)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public override Task<IEnumerable<Release>> GetReleaseTags() => Task.FromResult(Enumerable.Empty<Release>());
public override List<LaunchOptionDefinition> LaunchOptions => new();
public override Task<string> GetLatestVersion() => Task.FromResult(string.Empty);
public override Task<IEnumerable<PackageVersion>> GetAllVersions(bool isReleaseMode = true) => Task.FromResult(Enumerable.Empty<PackageVersion>());
/// <inheritdoc />
public override Task<IEnumerable<GitCommit>?> GetAllCommits(string branch, int page = 1, int perPage = 10) => Task.FromResult<IEnumerable<GitCommit>?>(null);
/// <inheritdoc />
public override Task<IEnumerable<Branch>> GetAllBranches() => Task.FromResult(Enumerable.Empty<Branch>());
/// <inheritdoc />
public override Task<IEnumerable<Release>> GetAllReleases() => Task.FromResult(Enumerable.Empty<Release>());
}

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

@ -98,7 +98,7 @@ public class VladAutomatic : BaseGitPackage
{ {
Name = "Use DirectML if no compatible GPU is detected", Name = "Use DirectML if no compatible GPU is detected",
Type = LaunchOptionType.Bool, Type = LaunchOptionType.Bool,
InitialValue = PreferDirectML(), InitialValue = HardwareHelper.PreferDirectML(),
Options = new() { "--use-directml" } Options = new() { "--use-directml" }
}, },
new() new()
@ -112,7 +112,7 @@ public class VladAutomatic : BaseGitPackage
{ {
Name = "Force use of AMD ROCm backend", Name = "Force use of AMD ROCm backend",
Type = LaunchOptionType.Bool, Type = LaunchOptionType.Bool,
InitialValue = PreferRocm(), InitialValue = HardwareHelper.PreferRocm(),
Options = new() { "--use-rocm" } Options = new() { "--use-rocm" }
}, },
new() new()
@ -137,16 +137,6 @@ public class VladAutomatic : BaseGitPackage
}; };
public override string ExtraLaunchArguments => ""; public override string ExtraLaunchArguments => "";
// Set ROCm for default if AMD and Linux
private static bool PreferRocm() => !HardwareHelper.HasNvidiaGpu()
&& HardwareHelper.HasAmdGpu()
&& Compat.IsLinux;
// Set DirectML for default if AMD and Windows
private static bool PreferDirectML() => !HardwareHelper.HasNvidiaGpu()
&& HardwareHelper.HasAmdGpu()
&& Compat.IsWindows;
public override Task<string> GetLatestVersion() => Task.FromResult("master"); public override Task<string> GetLatestVersion() => Task.FromResult("master");
@ -177,13 +167,13 @@ public class VladAutomatic : BaseGitPackage
await venvRunner.CustomInstall("launch.py --use-cuda --debug --test", OnConsoleOutput) await venvRunner.CustomInstall("launch.py --use-cuda --debug --test", OnConsoleOutput)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
else if (PreferRocm()) else if (HardwareHelper.PreferRocm())
{ {
// ROCm // ROCm
await venvRunner.CustomInstall("launch.py --use-rocm --debug --test", OnConsoleOutput) await venvRunner.CustomInstall("launch.py --use-rocm --debug --test", OnConsoleOutput)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
else if (PreferDirectML()) else if (HardwareHelper.PreferDirectML())
{ {
// DirectML // DirectML
await venvRunner.CustomInstall("launch.py --use-directml --debug --test", OnConsoleOutput) await venvRunner.CustomInstall("launch.py --use-directml --debug --test", OnConsoleOutput)
@ -198,20 +188,22 @@ public class VladAutomatic : BaseGitPackage
progress?.Report(new ProgressReport(1, isIndeterminate: false)); progress?.Report(new ProgressReport(1, isIndeterminate: false));
} }
public override async Task<string> DownloadPackage(string version, bool isCommitHash, IProgress<ProgressReport>? progress = null) public override async Task<string> DownloadPackage(string version, bool isCommitHash,
string? branch, IProgress<ProgressReport>? progress = null)
{ {
progress?.Report(new ProgressReport(0.1f, message: "Downloading package...", isIndeterminate: true, type: ProgressType.Download)); progress?.Report(new ProgressReport(-1f, message: "Downloading package...",
isIndeterminate: true, type: ProgressType.Download));
var installDir = new DirectoryPath(InstallLocation); var installDir = new DirectoryPath(InstallLocation);
installDir.Create(); installDir.Create();
await PrerequisiteHelper.RunGit( await PrerequisiteHelper
installDir.Parent ?? "", "clone", "https://github.com/vladmandic/automatic", installDir.Name) .RunGit(installDir.Parent ?? "", "clone", "https://github.com/vladmandic/automatic",
.ConfigureAwait(false); installDir.Name).ConfigureAwait(false);
await PrerequisiteHelper.RunGit( await PrerequisiteHelper.RunGit(
InstallLocation, "checkout", version).ConfigureAwait(false); InstallLocation, "checkout", version).ConfigureAwait(false);
return version; return version;
} }
@ -253,16 +245,19 @@ public class VladAutomatic : BaseGitPackage
throw new Exception("Installed branch is null"); throw new Exception("Installed branch is null");
} }
progress?.Report(new ProgressReport(0.1f, message: "Downloading package update...", progress?.Report(new ProgressReport(-1f, message: "Downloading package update...",
isIndeterminate: true, type: ProgressType.Download)); isIndeterminate: true, type: ProgressType.Update));
var version = await GithubApi.GetAllCommits(Author, Name, installedPackage.InstalledBranch).ConfigureAwait(false); await PrerequisiteHelper.RunGit(installedPackage.FullPath, "checkout",
var latest = version?.FirstOrDefault(); installedPackage.InstalledBranch).ConfigureAwait(false);
var venvRunner = new PyVenvRunner(Path.Combine(installedPackage.FullPath!, "venv"));
venvRunner.WorkingDirectory = InstallLocation;
venvRunner.EnvironmentVariables = SettingsManager.Settings.EnvironmentVariables;
await venvRunner.CustomInstall("launch.py --upgrade --test", OnConsoleOutput)
.ConfigureAwait(false);
if (latest?.Sha is null)
{
throw new Exception("Could not get latest version");
}
try try
{ {
@ -271,22 +266,18 @@ public class VladAutomatic : BaseGitPackage
.GetGitOutput(installedPackage.FullPath, "rev-parse", "HEAD") .GetGitOutput(installedPackage.FullPath, "rev-parse", "HEAD")
.ConfigureAwait(false); .ConfigureAwait(false);
if (output.Replace(Environment.NewLine, "") == latest.Sha) return output.Replace(Environment.NewLine, "").Replace("\n", "");
{
return latest.Sha;
}
} }
catch (Exception e) catch (Exception e)
{ {
Logger.Warn(e, "Could not get current git hash, continuing with update"); Logger.Warn(e, "Could not get current git hash, continuing with update");
} }
finally
await PrerequisiteHelper.RunGit(installedPackage.FullPath, "pull", {
"origin", installedPackage.InstalledBranch).ConfigureAwait(false); progress?.Report(new ProgressReport(1f, message: "Update Complete", isIndeterminate: false,
type: ProgressType.Update));
progress?.Report(new ProgressReport(1f, message: "Update Complete", isIndeterminate: false, }
type: ProgressType.Generic));
return installedPackage.InstalledBranch;
return latest.Sha;
} }
} }

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

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

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

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

3
StabilityMatrix.Core/Models/Settings/Settings.cs

@ -7,7 +7,8 @@ public class Settings
public int? Version { get; set; } = 1; public int? Version { get; set; } = 1;
public bool FirstLaunchSetupComplete { get; set; } public bool FirstLaunchSetupComplete { get; set; }
public string? Theme { get; set; } = "Dark"; public string? Theme { get; set; } = "Dark";
public string? Language { get; set; } = "en-US";
public List<InstalledPackage> InstalledPackages { get; set; } = new(); public List<InstalledPackage> InstalledPackages { get; set; } = new();
[JsonPropertyName("ActiveInstalledPackage")] [JsonPropertyName("ActiveInstalledPackage")]

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

17
StabilityMatrix.Core/Models/UnknownInstalledPackage.cs

@ -0,0 +1,17 @@
using StabilityMatrix.Core.Models.Packages;
namespace StabilityMatrix.Core.Models;
public class UnknownInstalledPackage : InstalledPackage
{
public static UnknownInstalledPackage FromDirectoryName(string name)
{
return new UnknownInstalledPackage
{
Id = Guid.NewGuid(),
PackageName = UnknownPackage.Key,
DisplayName = name,
LibraryPath = $"Packages{System.IO.Path.DirectorySeparatorChar}{name}",
};
}
}

183
StabilityMatrix.Core/Services/DownloadService.cs

@ -17,43 +17,66 @@ public class DownloadService : IDownloadService
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
public async Task DownloadToFileAsync(string downloadUrl, string downloadPath, public async Task DownloadToFileAsync(
IProgress<ProgressReport>? progress = null, string? httpClientName = null) string downloadUrl,
string downloadPath,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
)
{ {
using var client = string.IsNullOrWhiteSpace(httpClientName) using var client = string.IsNullOrWhiteSpace(httpClientName)
? httpClientFactory.CreateClient() ? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName); : httpClientFactory.CreateClient(httpClientName);
client.Timeout = TimeSpan.FromMinutes(10); client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); client.DefaultRequestHeaders.UserAgent.Add(
await using var file = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None); new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await using var file = new FileStream(
downloadPath,
FileMode.Create,
FileAccess.Write,
FileShare.None
);
long contentLength = 0; 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; contentLength = response.Content.Headers.ContentLength ?? 0;
var delays = Backoff.DecorrelatedJitterBackoffV2( var delays = Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(50), retryCount: 3); TimeSpan.FromMilliseconds(50),
retryCount: 3
);
foreach (var delay in delays) foreach (var delay in delays)
{ {
if (contentLength > 0) break; if (contentLength > 0)
break;
logger.LogDebug("Retrying get-headers for content-length"); logger.LogDebug("Retrying get-headers for content-length");
await Task.Delay(delay); await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); response = await client
.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
contentLength = response.Content.Headers.ContentLength ?? 0; contentLength = response.Content.Headers.ContentLength ?? 0;
} }
var isIndeterminate = 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 totalBytesRead = 0L;
var buffer = new byte[BufferSize]; var buffer = new byte[BufferSize];
while (true) while (true)
{ {
var bytesRead = await stream.ReadAsync(buffer); var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0) break; if (bytesRead == 0)
await file.WriteAsync(buffer.AsMemory(0, bytesRead)); break;
await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken)
.ConfigureAwait(false);
totalBytesRead += bytesRead; totalBytesRead += bytesRead;
@ -63,13 +86,125 @@ public class DownloadService : IDownloadService
} }
else else
{ {
progress?.Report(new ProgressReport(current: Convert.ToUInt64(totalBytesRead), progress?.Report(
total: Convert.ToUInt64(contentLength), message: "Downloading...")); 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!")); progress?.Report(new ProgressReport(1f, message: "Download complete!"));
} }
@ -77,11 +212,13 @@ public class DownloadService : IDownloadService
{ {
using var client = httpClientFactory.CreateClient(); using var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10); client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
try try
{ {
var response = await client.GetAsync(url); var response = await client.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStreamAsync(); return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
} }
catch (Exception e) catch (Exception e)
{ {

18
StabilityMatrix.Core/Services/IDownloadService.cs

@ -4,8 +4,22 @@ namespace StabilityMatrix.Core.Services;
public interface IDownloadService public interface IDownloadService
{ {
Task DownloadToFileAsync(string downloadUrl, string downloadPath, Task DownloadToFileAsync(
IProgress<ProgressReport>? progress = null, string? httpClientName = null); 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); Task<Stream> GetImageStreamFromUrl(string url);
} }

8
StabilityMatrix.Core/Services/ISettingsManager.cs

@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using System.Linq.Expressions; using System.Linq.Expressions;
using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Models.Settings;
namespace StabilityMatrix.Core.Services; namespace StabilityMatrix.Core.Services;
@ -12,10 +13,17 @@ public interface ISettingsManager
bool IsLibraryDirSet { get; } bool IsLibraryDirSet { get; }
string DatabasePath { get; } string DatabasePath { get; }
string ModelsDirectory { get; } string ModelsDirectory { get; }
string DownloadsDirectory { get; }
Settings Settings { get; } Settings Settings { get; }
event EventHandler<string>? LibraryDirChanged; event EventHandler<string>? LibraryDirChanged;
event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged; 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 /> /// <inheritdoc />
SettingsTransaction BeginTransaction(); 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"); public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db");
private string SettingsPath => Path.Combine(LibraryDir, "settings.json"); private string SettingsPath => Path.Combine(LibraryDir, "settings.json");
public string ModelsDirectory => Path.Combine(LibraryDir, "Models"); public string ModelsDirectory => Path.Combine(LibraryDir, "Models");
public string DownloadsDirectory => Path.Combine(LibraryDir, ".downloads");
public Settings Settings { get; private set; } = new(); public Settings Settings { get; private set; } = new();
public event EventHandler<string>? LibraryDirChanged; public event EventHandler<string>? LibraryDirChanged;
public event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged; 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 /> /// <inheritdoc />
public SettingsTransaction BeginTransaction() 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);
}
}

5
StabilityMatrix.Tests/Avalonia/DesignDataTests.cs

@ -9,12 +9,13 @@ public class DesignDataTests
[ClassInitialize] [ClassInitialize]
public static void ClassInitialize(TestContext context) public static void ClassInitialize(TestContext context)
{ {
StabilityMatrix.Avalonia.DesignData.DesignData.Initialize(); SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
DesignData.Initialize();
} }
// Return all properties // Return all properties
public static IEnumerable<object[]> DesignDataProperties => public static IEnumerable<object[]> DesignDataProperties =>
typeof(StabilityMatrix.Avalonia.DesignData.DesignData).GetProperties() typeof(DesignData).GetProperties()
.Select(p => new object[] { p }); .Select(p => new object[] { p });
[TestMethod] [TestMethod]

Loading…
Cancel
Save