Browse Source

Merge pull request #121 from ionite34/auto-update

pull/14/head
Ionite 1 year ago committed by GitHub
parent
commit
85ec4ae847
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      StabilityMatrix.Tests/StabilityMatrix.Tests.csproj
  2. 1
      StabilityMatrix/Api/A3WebApiManager.cs
  3. 4
      StabilityMatrix/Api/IA3WebApiManager.cs
  4. 95
      StabilityMatrix/App.xaml.cs
  5. 24
      StabilityMatrix/Converters/IsStringNullOrWhitespaceConverter.cs
  6. 93
      StabilityMatrix/DataDirectoryMigrationDialog.xaml
  7. 27
      StabilityMatrix/DataDirectoryMigrationDialog.xaml.cs
  8. 1
      StabilityMatrix/Database/CivitModelQueryCacheEntry.cs
  9. 1
      StabilityMatrix/Database/ILiteDbContext.cs
  10. 30
      StabilityMatrix/Database/LiteDbContext.cs
  11. 9
      StabilityMatrix/DesignData/MockLaunchViewModel.cs
  12. 5
      StabilityMatrix/FirstLaunchSetupWindow.xaml.cs
  13. 2
      StabilityMatrix/Helper/ArchiveHelper.cs
  14. 43
      StabilityMatrix/Helper/DialogFactory.cs
  15. 3
      StabilityMatrix/Helper/EventManager.cs
  16. 25
      StabilityMatrix/Helper/IDialogFactory.cs
  17. 2
      StabilityMatrix/Helper/IPrerequisiteHelper.cs
  18. 18
      StabilityMatrix/Helper/ISettingsManager.cs
  19. 13
      StabilityMatrix/Helper/IUpdateHelper.cs
  20. 113
      StabilityMatrix/Helper/PrerequisiteHelper.cs
  21. 88
      StabilityMatrix/Helper/SettingsManager.cs
  22. 113
      StabilityMatrix/Helper/UpdateHelper.cs
  23. 15
      StabilityMatrix/Helper/Utilities.cs
  24. 4
      StabilityMatrix/InstallerWindow.xaml
  25. 26
      StabilityMatrix/LaunchPage.xaml
  26. 8
      StabilityMatrix/MainWindow.xaml
  27. 10
      StabilityMatrix/MainWindow.xaml.cs
  28. 2
      StabilityMatrix/Models/Api/CivitFile.cs
  29. 2
      StabilityMatrix/Models/CheckpointFile.cs
  30. 8
      StabilityMatrix/Models/Configs/DebugOptions.cs
  31. 92
      StabilityMatrix/Models/FileInterfaces/DirectoryPath.cs
  32. 93
      StabilityMatrix/Models/FileInterfaces/FilePath.cs
  33. 21
      StabilityMatrix/Models/FileInterfaces/FileSystemPath.cs
  34. 27
      StabilityMatrix/Models/FileInterfaces/IPathObject.cs
  35. 13
      StabilityMatrix/Models/GlobalConfig.cs
  36. 146
      StabilityMatrix/Models/GlobalUserSecrets.cs
  37. 1
      StabilityMatrix/Models/ISharedFolders.cs
  38. 19
      StabilityMatrix/Models/InstalledPackage.cs
  39. 1
      StabilityMatrix/Models/Packages/A3WebUI.cs
  40. 12
      StabilityMatrix/Models/Packages/BaseGitPackage.cs
  41. 1
      StabilityMatrix/Models/Packages/ComfyUI.cs
  42. 7
      StabilityMatrix/Models/Packages/VladAutomatic.cs
  43. 6
      StabilityMatrix/Models/Settings/GlobalSettings.cs
  44. 2
      StabilityMatrix/Models/Settings/LibrarySettings.cs
  45. 13
      StabilityMatrix/Models/Settings/Settings.cs
  46. 7
      StabilityMatrix/Models/Settings/WindowSettings.cs
  47. 38
      StabilityMatrix/Models/SharedFolders.cs
  48. 15
      StabilityMatrix/Models/UpdateInfo.cs
  49. 4
      StabilityMatrix/PackageManagerPage.xaml
  50. 65
      StabilityMatrix/Python/PyVenvRunner.cs
  51. 115
      StabilityMatrix/SelectInstallLocationsDialog.xaml
  52. 26
      StabilityMatrix/SelectInstallLocationsDialog.xaml.cs
  53. 9
      StabilityMatrix/Services/DownloadService.cs
  54. 2
      StabilityMatrix/Services/IDownloadService.cs
  55. 4
      StabilityMatrix/SettingsPage.xaml
  56. 31
      StabilityMatrix/StabilityMatrix.csproj
  57. 122
      StabilityMatrix/UpdateWindow.xaml
  58. 27
      StabilityMatrix/UpdateWindow.xaml.cs
  59. 98
      StabilityMatrix/ViewModels/DataDirectoryMigrationViewModel.cs
  60. 3
      StabilityMatrix/ViewModels/FirstLaunchSetupViewModel.cs
  61. 27
      StabilityMatrix/ViewModels/InstallerViewModel.cs
  62. 17
      StabilityMatrix/ViewModels/LaunchViewModel.cs
  63. 89
      StabilityMatrix/ViewModels/MainWindowViewModel.cs
  64. 4
      StabilityMatrix/ViewModels/OneClickInstallViewModel.cs
  65. 5
      StabilityMatrix/ViewModels/PackageManagerViewModel.cs
  66. 4
      StabilityMatrix/ViewModels/RefreshBadgeViewModel.cs
  67. 151
      StabilityMatrix/ViewModels/SelectInstallLocationsViewModel.cs
  68. 31
      StabilityMatrix/ViewModels/SettingsViewModel.cs
  69. 1
      StabilityMatrix/ViewModels/TextToImageViewModel.cs
  70. 79
      StabilityMatrix/ViewModels/UpdateWindowViewModel.cs
  71. 79
      StabilityMatrix/ViewModels/WebLoginViewModel.cs
  72. 65
      StabilityMatrix/WebLoginDialog.xaml
  73. 53
      StabilityMatrix/WebLoginDialog.xaml.cs
  74. 2
      StabilityMatrix/appsettings.Development.json

17
StabilityMatrix.Tests/StabilityMatrix.Tests.csproj

@ -10,15 +10,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNext" Version="4.12.0" />
<PackageReference Include="DotNext" Version="4.12.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.16" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
<PackageReference Include="coverlet.collector" Version="3.2.0" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.4" />
<PackageReference Include="MSTest.TestFramework" Version="3.0.4" />
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Polly" Version="7.2.4" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
</ItemGroup>

1
StabilityMatrix/Api/A3WebApiManager.cs

@ -1,6 +1,5 @@
using System;
using System.Net.Http;
using Polly.Retry;
using Refit;
using StabilityMatrix.Helper;

4
StabilityMatrix/Api/IA3WebApiManager.cs

@ -1,6 +1,4 @@
using System.Net.Http;
using Polly.Retry;
using Refit;
using Refit;
namespace StabilityMatrix.Api;

95
StabilityMatrix/App.xaml.cs

@ -39,7 +39,6 @@ using StabilityMatrix.ViewModels;
using Wpf.Ui.Contracts;
using Wpf.Ui.Services;
using Application = System.Windows.Application;
using ConfigurationExtensions = NLog.ConfigurationExtensions;
using ISnackbarService = StabilityMatrix.Helper.ISnackbarService;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
using SnackbarService = StabilityMatrix.Helper.SnackbarService;
@ -138,35 +137,33 @@ namespace StabilityMatrix
return logConfig;
}
// Find library and initialize settings
private static SettingsManager CreateSettingsManager()
{
var settings = new SettingsManager();
var found = settings.TryFindLibrary();
// Not found, we need to show dialog to choose library location
if (!found)
{
// See if this is an existing user for message variation
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var settingsPath = Path.Combine(appDataPath, "StabilityMatrix", "settings.json");
var isExistingUser = File.Exists(settingsPath);
// TODO: Show dialog
// 1. For portable mode, call settings.SetPortableMode()
// 2. For custom path, call settings.SetLibraryPath(path)
private void App_OnStartup(object sender, StartupEventArgs e)
{
if (AppDomain.CurrentDomain.BaseDirectory.EndsWith("Update\\"))
{
File.Copy(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "StabilityMatrix.exe"),
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "StabilityMatrix.exe"), true);
Process.Start(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..",
"StabilityMatrix.exe"));
Current.Shutdown();
return;
}
// TryFindLibrary should succeed now unless weird issue
if (!settings.TryFindLibrary())
var updateDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Update");
if (Directory.Exists(updateDir))
{
try
{
throw new Exception("Could not set library path.");
Directory.Delete(updateDir, true);
}
catch (Exception exception)
{
Logger.Error(exception, "Failed to delete update file");
}
}
return settings;
}
private void App_OnStartup(object sender, StartupEventArgs e)
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IPageService, PageService>();
serviceCollection.AddSingleton<IContentDialogService, ContentDialogService>();
@ -195,7 +192,11 @@ namespace StabilityMatrix
serviceCollection.AddSingleton<LaunchViewModel>();
serviceCollection.AddSingleton<PackageManagerViewModel>();
serviceCollection.AddSingleton<TextToImageViewModel>();
serviceCollection.AddTransient<UpdateWindowViewModel>();
serviceCollection.AddTransient<InstallerViewModel>();
serviceCollection.AddTransient<SelectInstallLocationsViewModel>();
serviceCollection.AddTransient<DataDirectoryMigrationViewModel>();
serviceCollection.AddTransient<WebLoginViewModel>();
serviceCollection.AddTransient<OneClickInstallViewModel>();
serviceCollection.AddTransient<CheckpointManagerViewModel>();
serviceCollection.AddSingleton<CheckpointBrowserViewModel>();
@ -203,9 +204,8 @@ namespace StabilityMatrix
serviceCollection.Configure<DebugOptions>(Config.GetSection(nameof(DebugOptions)));
var settingsManager = CreateSettingsManager();
serviceCollection.AddSingleton<ISettingsManager>(settingsManager);
serviceCollection.AddSingleton<IUpdateHelper, UpdateHelper>();
serviceCollection.AddSingleton<ISettingsManager, SettingsManager>();
serviceCollection.AddSingleton<BasePackage, A3WebUI>();
serviceCollection.AddSingleton<BasePackage, VladAutomatic>();
serviceCollection.AddSingleton<BasePackage, ComfyUI>();
@ -224,17 +224,14 @@ namespace StabilityMatrix
client.Credentials = new Credentials(githubApiKey);
return client;
});
// Database
serviceCollection.AddSingleton<ILiteDbContext, LiteDbContext>();
// Caches
serviceCollection.AddMemoryCache();
serviceCollection.AddSingleton<IGithubApiCache, GithubApiCache>();
// Setup LiteDb
var connectionString = Config["TempDatabase"] switch
{
"True" => ":temp:",
_ => settingsManager.DatabasePath
};
serviceCollection.AddSingleton<ILiteDbContext>(new LiteDbContext(connectionString));
// Configure Refit and Polly
var defaultRefitSettings = new RefitSettings
{
@ -259,6 +256,7 @@ namespace StabilityMatrix
.Or<TimeoutRejectedException>()
.OrResult(r => retryStatusCodes.Contains(r.StatusCode))
.WaitAndRetryAsync(delay);
// Shorter timeout for local requests
var localTimeout = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3));
var localDelay = Backoff
@ -273,6 +271,10 @@ namespace StabilityMatrix
return Task.CompletedTask;
});
// named client for update
serviceCollection.AddHttpClient("UpdateClient")
.AddPolicyHandler(retryPolicy);
// Add Refit clients
serviceCollection.AddRefitClient<ICivitApi>(defaultRefitSettings)
.ConfigureHttpClient(c =>
@ -285,6 +287,7 @@ namespace StabilityMatrix
// Add Refit client managers
serviceCollection.AddHttpClient("A3Client")
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));
serviceCollection.AddSingleton<IA3WebApiManager>(services =>
new A3WebApiManager(services.GetRequiredService<ISettingsManager>(),
services.GetRequiredService<IHttpClientFactory>())
@ -293,12 +296,14 @@ namespace StabilityMatrix
});
// Add logging
serviceCollection.AddLogging(log =>
serviceCollection.AddLogging(builder =>
{
log.ClearProviders();
log.AddFilter("Microsoft.Extensions.Http", LogLevel.Warning);
log.SetMinimumLevel(LogLevel.Trace);
log.AddNLog(logConfig);
builder.ClearProviders();
builder.AddFilter("Microsoft.Extensions.Http", LogLevel.Warning)
.AddFilter("Microsoft", LogLevel.Warning)
.AddFilter("System", LogLevel.Warning);
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddNLog(logConfig);
});
// Remove HTTPClientFactory logging
@ -311,18 +316,17 @@ namespace StabilityMatrix
Logger?.Warn(ex, "Background Task failed: {ExceptionMessage}", ex.Message);
});
// Insert path extensions
settingsManager.InsertPathExtensions();
serviceProvider = serviceCollection.BuildServiceProvider();
var settingsManager = serviceProvider.GetRequiredService<ISettingsManager>();
// First time setup if needed
if (!settingsManager.Settings.FirstLaunchSetupComplete)
if (!settingsManager.IsEulaAccepted())
{
var setupWindow = serviceProvider.GetRequiredService<FirstLaunchSetupWindow>();
if (setupWindow.ShowDialog() ?? false)
{
settingsManager.SetFirstLaunchSetupComplete(true);
settingsManager.SetEulaAccepted();
}
else
{
@ -333,6 +337,9 @@ namespace StabilityMatrix
var window = serviceProvider.GetRequiredService<MainWindow>();
window.Show();
var updateHelper = serviceProvider.GetRequiredService<IUpdateHelper>();
updateHelper.StartCheckingForUpdates();
}
private void App_OnExit(object sender, ExitEventArgs e)

24
StabilityMatrix/Converters/IsStringNullOrWhitespaceConverter.cs

@ -0,0 +1,24 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace StabilityMatrix.Converters;
[ValueConversion(typeof(string), typeof(bool))]
public class IsStringNullOrWhitespaceConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string strValue)
{
return string.IsNullOrWhiteSpace(strValue);
}
throw new InvalidOperationException("Cannot convert non-string value");
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

93
StabilityMatrix/DataDirectoryMigrationDialog.xaml

@ -0,0 +1,93 @@
<ui:ContentDialog
CloseButtonText="Close"
Loaded="DataDirectoryMigrationDialog_OnLoaded"
Title="Package Information Update"
d:DataContext="{d:DesignInstance Type=viewModels:DataDirectoryMigrationViewModel,
IsDesignTimeCreatable=True}"
d:DesignHeight="512"
d:DesignWidth="640"
mc:Ignorable="d"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
x:Class="StabilityMatrix.DataDirectoryMigrationDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:converters="clr-namespace:StabilityMatrix.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:StabilityMatrix"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewModels="clr-namespace:StabilityMatrix.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ui:ContentDialog.Resources>
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<Style BasedOn="{StaticResource {x:Type ui:ContentDialog}}" TargetType="{x:Type local:DataDirectoryMigrationDialog}" />
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<converters:ValueConverterGroup x:Key="InvertIsStringNullOrWhitespaceConverter">
<converters:IsStringNullOrWhitespaceConverter />
<converters:BoolNegationConverter />
</converters:ValueConverterGroup>
</ui:ContentDialog.Resources>
<Grid
Margin="16"
MaxWidth="700"
MinHeight="300"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel>
<TextBlock
FontSize="16"
Margin="8,8,8,8"
Text="It looks like package information for this Data Directory was created in an older version of Stability Matrix before v1.1.0, and needs to be upgraded."
TextWrapping="Wrap" />
<ui:InfoBar
IsClosable="False"
IsOpen="{Binding AutoMigrateText, Converter={StaticResource InvertIsStringNullOrWhitespaceConverter}}"
Margin="8"
Severity="Success"
Title="{Binding AutoMigrateText, FallbackValue=5 Packages will be automatically migrated to the new format}" />
<ui:InfoBar
IsClosable="False"
IsOpen="{Binding NeedsMoveMigrateText, Converter={StaticResource InvertIsStringNullOrWhitespaceConverter}}"
Margin="8"
Severity="Informational"
Title="{Binding NeedsMoveMigrateText, FallbackValue=2 Packages are not relative to the Data Directory and will be moved this may take a few minutes}" />
</StackPanel>
<!-- Progress for moving -->
<StackPanel
Grid.Row="1"
HorizontalAlignment="Center"
Margin="8"
Orientation="Horizontal"
Visibility="{Binding MigrateCommand.IsRunning, Converter={StaticResource BooleanToVisibilityConverter}}">
<ui:ProgressRing
Height="32"
IsEnabled="{Binding MigrateCommand.IsRunning, Converter={StaticResource BooleanToVisibilityConverter}}"
IsIndeterminate="True"
Width="32" />
<TextBlock
FontSize="16"
Margin="8"
Text="{Binding MigrateProgressText, FallbackValue=Migrating 1 of 2 Packages}"
VerticalAlignment="Center" />
</StackPanel>
<ui:Button
Appearance="Success"
Click="ContinueButton_OnClick"
Content="Continue"
FontSize="16"
Grid.Row="2"
HorizontalAlignment="Center"
Margin="8"
Padding="16,8" />
</Grid>
</ui:ContentDialog>

27
StabilityMatrix/DataDirectoryMigrationDialog.xaml.cs

@ -0,0 +1,27 @@
using System.Windows;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Contracts;
using Wpf.Ui.Controls.ContentDialogControl;
namespace StabilityMatrix;
public partial class DataDirectoryMigrationDialog : ContentDialog
{
public DataDirectoryMigrationDialog(IContentDialogService dialogService, DataDirectoryMigrationViewModel viewModel) : base(
dialogService.GetContentPresenter())
{
InitializeComponent();
DataContext = viewModel;
}
private async void ContinueButton_OnClick(object sender, RoutedEventArgs e)
{
await ((DataDirectoryMigrationViewModel) DataContext).MigrateCommand.ExecuteAsync(null);
Hide(ContentDialogResult.Primary);
}
private void DataDirectoryMigrationDialog_OnLoaded(object sender, RoutedEventArgs e)
{
((DataDirectoryMigrationViewModel) DataContext).OnLoaded();
}
}

1
StabilityMatrix/Database/CivitModelQueryCacheEntry.cs

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using LiteDB;
using StabilityMatrix.Helper;
using StabilityMatrix.Models.Api;
namespace StabilityMatrix.Database;

1
StabilityMatrix/Database/ILiteDbContext.cs

@ -14,6 +14,7 @@ public interface ILiteDbContext : IDisposable
ILiteCollectionAsync<CivitModelVersion> CivitModelVersions { get; }
ILiteCollectionAsync<CivitModelQueryCacheEntry> CivitModelQueryCache { get; }
Task<(CivitModel?, CivitModelVersion?)> FindCivitModelFromFileHashAsync(string hashBlake3);
Task<bool> UpsertCivitModelAsync(CivitModel civitModel);
Task<bool> UpsertCivitModelAsync(IEnumerable<CivitModel> civitModels);

30
StabilityMatrix/Database/LiteDbContext.cs

@ -1,19 +1,32 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using LiteDB;
using LiteDB.Async;
using Microsoft.Extensions.Options;
using StabilityMatrix.Extensions;
using StabilityMatrix.Helper;
using StabilityMatrix.Models.Api;
using StabilityMatrix.Models.Configs;
namespace StabilityMatrix.Database;
public class LiteDbContext : ILiteDbContext
{
public LiteDatabaseAsync Database { get; }
private readonly ISettingsManager settingsManager;
private readonly DebugOptions debugOptions;
private LiteDatabaseAsync? database;
public LiteDatabaseAsync Database
{
get
{
if (database is null) throw new InvalidOperationException("Database not set before access");
return database;
}
private set => database = value;
}
// Notification events
public event EventHandler? CivitModelsChanged;
@ -23,8 +36,13 @@ public class LiteDbContext : ILiteDbContext
public ILiteCollectionAsync<CivitModelVersion> CivitModelVersions => Database.GetCollection<CivitModelVersion>("CivitModelVersions");
public ILiteCollectionAsync<CivitModelQueryCacheEntry> CivitModelQueryCache => Database.GetCollection<CivitModelQueryCacheEntry>("CivitModelQueryCache");
public LiteDbContext(string connectionString)
public LiteDbContext(ISettingsManager settingsManager, IOptions<DebugOptions> debugOptions)
{
this.settingsManager = settingsManager;
this.debugOptions = debugOptions.Value;
var connectionString = debugOptions.Value.TempDatabase ? ":temp:"
: $"Filename={Path.Combine(settingsManager.LibraryDir, "StabilityMatrix.db")};Mode=Exclusive";
Database = new LiteDatabaseAsync(connectionString);
// Register reference fields
@ -94,7 +112,7 @@ public class LiteDbContext : ILiteDbContext
public void Dispose()
{
Database.Dispose();
database?.Dispose();
GC.SuppressFinalize(this);
}
}

9
StabilityMatrix/DesignData/MockLaunchViewModel.cs

@ -1,10 +1,5 @@
using System.ComponentModel;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Helper;
using StabilityMatrix.Python;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Contracts;
using ISnackbarService = StabilityMatrix.Helper.ISnackbarService;
namespace StabilityMatrix.DesignData;
@ -14,7 +9,7 @@ public class MockLaunchViewModel : LaunchViewModel
public MockLaunchViewModel() : base(
null!, null!, null!,
null!, null!, null!,
null!, null!)
null!, null!, null!)
{
InstalledPackages = new()
{
@ -25,7 +20,7 @@ public class MockLaunchViewModel : LaunchViewModel
PackageVersion = "1.0.0",
DisplayVersion = "1.0.0",
InstalledBranch = "main",
Path = @"C:\Users\AppData\StabilityMatrix\mock-package",
LibraryPath = @"Packages\mock-package",
}
};
SelectedPackage = InstalledPackages[0];

5
StabilityMatrix/FirstLaunchSetupWindow.xaml.cs

@ -1,7 +1,4 @@
using System;
using System.Windows;
using System.Windows.Controls;
using Windows.ApplicationModel.Store;
using System.Windows;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Controls.Window;

2
StabilityMatrix/Helper/ArchiveHelper.cs

@ -58,7 +58,7 @@ public static class ArchiveHelper
{
"x", archivePath, $"-o{ProcessRunner.Quote(extractDirectory)}", "-y"
});
await process.WaitForExitAsync();
await ProcessRunner.WaitForExitConditionAsync(process);
var output = await process.StandardOutput.ReadToEndAsync();
var matches = Regex7ZOutput.Matches(output);
var size = ulong.Parse(matches[0].Value);

43
StabilityMatrix/Helper/DialogFactory.cs

@ -19,6 +19,9 @@ public class DialogFactory : IDialogFactory
private readonly LaunchOptionsDialogViewModel launchOptionsDialogViewModel;
private readonly InstallerViewModel installerViewModel;
private readonly OneClickInstallViewModel oneClickInstallViewModel;
private readonly SelectInstallLocationsViewModel selectInstallLocationsViewModel;
private readonly DataDirectoryMigrationViewModel dataDirectoryMigrationViewModel;
private readonly WebLoginViewModel webLoginViewModel;
private readonly InstallerWindowDialogService installerWindowDialogService;
private readonly ISettingsManager settingsManager;
@ -26,12 +29,18 @@ public class DialogFactory : IDialogFactory
LaunchOptionsDialogViewModel launchOptionsDialogViewModel,
ISettingsManager settingsManager, InstallerViewModel installerViewModel,
OneClickInstallViewModel oneClickInstallViewModel,
InstallerWindowDialogService installerWindowDialogService)
SelectInstallLocationsViewModel selectInstallLocationsViewModel,
DataDirectoryMigrationViewModel dataDirectoryMigrationViewModel,
InstallerWindowDialogService installerWindowDialogService,
WebLoginViewModel webLoginViewModel)
{
this.contentDialogService = contentDialogService;
this.launchOptionsDialogViewModel = launchOptionsDialogViewModel;
this.installerViewModel = installerViewModel;
this.oneClickInstallViewModel = oneClickInstallViewModel;
this.selectInstallLocationsViewModel = selectInstallLocationsViewModel;
this.dataDirectoryMigrationViewModel = dataDirectoryMigrationViewModel;
this.webLoginViewModel = webLoginViewModel;
this.installerWindowDialogService = installerWindowDialogService;
this.settingsManager = settingsManager;
}
@ -131,4 +140,36 @@ public class DialogFactory : IDialogFactory
{
return new InstallerWindow(installerViewModel, installerWindowDialogService);
}
public SelectInstallLocationsDialog CreateInstallLocationsDialog()
{
var dialog = new SelectInstallLocationsDialog(contentDialogService,
selectInstallLocationsViewModel)
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false
};
return dialog;
}
public DataDirectoryMigrationDialog CreateDataDirectoryMigrationDialog()
{
var dialog = new DataDirectoryMigrationDialog(contentDialogService,
dataDirectoryMigrationViewModel)
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false
};
return dialog;
}
public WebLoginDialog CreateWebLoginDialog()
{
return new WebLoginDialog(contentDialogService, webLoginViewModel)
{
CloseButtonText = "Cancel",
};
}
}

3
StabilityMatrix/Helper/EventManager.cs

@ -1,4 +1,5 @@
using System;
using StabilityMatrix.Models;
namespace StabilityMatrix.Helper;
@ -17,10 +18,12 @@ public class EventManager
public event EventHandler? OneClickInstallFinished;
public event EventHandler? TeachingTooltipNeeded;
public event EventHandler<bool>? DevModeSettingChanged;
public event EventHandler<UpdateInfo>? UpdateAvailable;
public void OnGlobalProgressChanged(int progress) => GlobalProgressChanged?.Invoke(this, progress);
public void RequestPageChange(Type pageType) => PageChangeRequested?.Invoke(this, pageType);
public void OnInstalledPackagesChanged() => InstalledPackagesChanged?.Invoke(this, EventArgs.Empty);
public void OnOneClickInstallFinished() => OneClickInstallFinished?.Invoke(this, EventArgs.Empty);
public void OnTeachingTooltipNeeded() => TeachingTooltipNeeded?.Invoke(this, EventArgs.Empty);
public void OnDevModeSettingChanged(bool value) => DevModeSettingChanged?.Invoke(this, value);
public void OnUpdateAvailable(UpdateInfo args) => UpdateAvailable?.Invoke(this, args);
}

25
StabilityMatrix/Helper/IDialogFactory.cs

@ -7,8 +7,27 @@ namespace StabilityMatrix.Helper;
public interface IDialogFactory
{
LaunchOptionsDialog CreateLaunchOptionsDialog(IEnumerable<LaunchOptionDefinition> definitions, InstalledPackage installedPackage);
InstallerWindow CreateInstallerWindow();
/// <summary>
/// Creates a dialog that allows the user to enter text for each field name.
/// Return a list of strings that correspond to the field names.
/// If cancel is pressed, return null.
/// <param name="fields">List of (fieldName, placeholder)</param>
/// </summary>
Task<List<string>?> ShowTextEntryDialog(string title,
IEnumerable<(string, string)> fields,
string closeButtonText = "Cancel",
string saveButtonText = "Save");
/// <summary>
/// Creates and shows a confirmation dialog.
/// Return true if the user clicks the primary button.
/// </summary>
Task<bool> ShowConfirmationDialog(string title, string message, string closeButtonText = "Cancel", string primaryButtonText = "Confirm");
OneClickInstallDialog CreateOneClickInstallDialog();
Task<List<string>?> ShowTextEntryDialog(string title, IEnumerable<(string, string)> fieldNames,
string closeButtonText = "Cancel", string saveButtonText = "Save");
InstallerWindow CreateInstallerWindow();
SelectInstallLocationsDialog CreateInstallLocationsDialog();
DataDirectoryMigrationDialog CreateDataDirectoryMigrationDialog();
WebLoginDialog CreateWebLoginDialog();
}

2
StabilityMatrix/Helper/IPrerequisiteHelper.cs

@ -23,4 +23,6 @@ public interface IPrerequisiteHelper
Task SetupPythonDependencies(string installLocation, string requirementsFileName,
IProgress<ProgressReport>? progress = null, Action<string?>? onConsoleOutput = null);
void UpdatePathExtensions();
}

18
StabilityMatrix/Helper/ISettingsManager.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using StabilityMatrix.Models;
using StabilityMatrix.Models.Settings;
using Wpf.Ui.Controls.Window;
namespace StabilityMatrix.Helper;
@ -8,7 +9,10 @@ namespace StabilityMatrix.Helper;
public interface ISettingsManager
{
Settings Settings { get; }
// Events
event EventHandler<bool>? ModelBrowserNsfwEnabledChanged;
event EventHandler<string>? LibraryDirChanged;
// Library settings
bool IsPortableMode { get; }
@ -49,4 +53,18 @@ public interface ISettingsManager
void SetModelBrowserNsfwEnabled(bool value);
void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible);
bool IsSharedFolderCategoryVisible(SharedFolderType type);
bool IsEulaAccepted();
void SetEulaAccepted();
/// <summary>
/// Save a new library path to %APPDATA%/StabilityMatrix/library.json
/// </summary>
void SetLibraryPath(string path);
/// <summary>
/// Enable and create settings files for portable mode
/// Creates the ./Data directory and the `.sm-portable` marker file
/// </summary>
void SetPortableMode();
void SaveSettings();
}

13
StabilityMatrix/Helper/IUpdateHelper.cs

@ -0,0 +1,13 @@
using System;
using System.Threading.Tasks;
using StabilityMatrix.Models;
namespace StabilityMatrix.Helper;
public interface IUpdateHelper
{
Task StartCheckingForUpdates();
Task DownloadUpdate(UpdateInfo updateInfo,
IProgress<ProgressReport> progress);
}

113
StabilityMatrix/Helper/PrerequisiteHelper.cs

@ -20,8 +20,10 @@ public class PrerequisiteHelper : IPrerequisiteHelper
private readonly IGitHubClient gitHubClient;
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private const string VcRedistDownloadUrl = "https://aka.ms/vs/16/release/vc_redist.x64.exe";
private const string PythonDownloadUrl = "https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip";
private const string PythonDownloadHashBlake3 = "24923775f2e07392063aaa0c78fbd4ae0a320e1fc9c6cfbab63803402279fe5a";
private string HomeDir => settingsManager.LibraryDir;
@ -101,9 +103,10 @@ public class PrerequisiteHelper : IPrerequisiteHelper
// Unpack from embedded resources
var resources = GetEmbeddedResources().Where(r => r.StartsWith(resourceDir)).ToArray();
var total = resources.Length;
logger.LogInformation("Unpacking {Num} embedded resources... [{Resources}]", total, string.Join(",", resources));
logger.LogInformation("Unpacking {Num} embedded resources...", total);
// Unpack all resources
var copied = new List<string>();
foreach (var resourceName in resources)
{
// Convert resource name to file name
@ -125,7 +128,9 @@ public class PrerequisiteHelper : IPrerequisiteHelper
}
await using var fileStream = File.Create(outputFilePath);
await resourceStream.CopyToAsync(fileStream);
copied.Add(outputFilePath);
}
logger.LogInformation("Successfully unpacked {Num} embedded resources: [{Resources}]", total, string.Join(",", copied));
}
public async Task UnpackResourcesIfNecessary(IProgress<ProgressReport>? progress = null)
@ -163,48 +168,87 @@ public class PrerequisiteHelper : IPrerequisiteHelper
Directory.CreateDirectory(AssetsDir);
if (!File.Exists(PythonDownloadPath))
// Delete existing python zip if it exists
if (File.Exists(PythonLibraryZipPath))
{
await downloadService.DownloadToFileAsync(PythonDownloadUrl, PythonDownloadPath, progress: progress);
progress?.Report(new ProgressReport(progress: 1f, message: "Python download complete"));
File.Delete(PythonLibraryZipPath);
}
progress?.Report(new ProgressReport(-1, "Installing Python...", isIndeterminate: true));
// Delete existing python dir
if (Directory.Exists(PythonDir))
{
Directory.Delete(PythonDir, true);
}
// Unzip python
await ArchiveHelper.Extract7Z(PythonDownloadPath, PythonDir);
logger.LogInformation(
"Downloading Python from {PythonLibraryZipUrl} to {PythonLibraryZipPath}",
PythonDownloadUrl, PythonLibraryZipPath);
// Cleanup to remove zip if download fails
try
{
// Extract embedded venv
await ExtractAllEmbeddedResources("StabilityMatrix.Assets.venv", PythonDir);
// Add venv to python's library zip
await ArchiveHelper.AddToArchive7Z(PythonLibraryZipPath, VenvTempDir);
// Download python zip
await downloadService.DownloadToFileAsync(PythonDownloadUrl, PythonDownloadPath, progress: progress);
// Verify python hash
var downloadHash = await FileHash.GetBlake3Async(PythonDownloadPath, progress);
if (downloadHash != PythonDownloadHashBlake3)
{
var fileExists = File.Exists(PythonDownloadPath);
var fileSize = new FileInfo(PythonDownloadPath).Length;
var msg = $"Python download hash mismatch: {downloadHash} != {PythonDownloadHashBlake3} " +
$"(file exists: {fileExists}, size: {fileSize})";
throw new Exception(msg);
}
progress?.Report(new ProgressReport(progress: 1f, message: "Python download complete"));
progress?.Report(new ProgressReport(-1, "Installing Python...", isIndeterminate: true));
// We also need 7z if it's not already unpacked
if (!File.Exists(SevenZipPath))
{
await ExtractEmbeddedResource("StabilityMatrix.Assets.7za.exe", AssetsDir);
await ExtractEmbeddedResource("StabilityMatrix.Assets.7za - LICENSE.txt", AssetsDir);
}
// Delete existing python dir
if (Directory.Exists(PythonDir))
{
Directory.Delete(PythonDir, true);
}
// Unzip python
await ArchiveHelper.Extract7Z(PythonDownloadPath, PythonDir);
try
{
// Extract embedded venv
await ExtractAllEmbeddedResources("StabilityMatrix.Assets.venv", PythonDir);
// Add venv to python's library zip
await ArchiveHelper.AddToArchive7Z(PythonLibraryZipPath, VenvTempDir);
}
finally
{
// Remove venv
if (Directory.Exists(VenvTempDir))
{
Directory.Delete(VenvTempDir, true);
}
}
// Extract get-pip.pyc
await ExtractEmbeddedResource("StabilityMatrix.Assets.get-pip.pyc", PythonDir);
// We need to uncomment the #import site line in python310._pth for pip to work
var pythonPthPath = Path.Combine(PythonDir, "python310._pth");
var pythonPthContent = await File.ReadAllTextAsync(pythonPthPath);
pythonPthContent = pythonPthContent.Replace("#import site", "import site");
await File.WriteAllTextAsync(pythonPthPath, pythonPthContent);
progress?.Report(new ProgressReport(1f, "Python install complete"));
}
finally
{
// Remove venv
if (Directory.Exists(VenvTempDir))
// Always delete zip after download
if (File.Exists(PythonDownloadPath))
{
Directory.Delete(VenvTempDir, true);
File.Delete(PythonDownloadPath);
}
}
// Extract get-pip.pyc
await ExtractEmbeddedResource("StabilityMatrix.Assets.get-pip.pyc", PythonDir);
// We need to uncomment the #import site line in python310._pth for pip to work
var pythonPthPath = Path.Combine(PythonDir, "python310._pth");
var pythonPthContent = await File.ReadAllTextAsync(pythonPthPath);
pythonPthContent = pythonPthContent.Replace("#import site", "import site");
await File.WriteAllTextAsync(pythonPthPath, pythonPthContent);
progress?.Report(new ProgressReport(1f, "Python install complete"));
}
public async Task InstallGitIfNecessary(IProgress<ProgressReport>? progress = null)
@ -295,6 +339,13 @@ public class PrerequisiteHelper : IPrerequisiteHelper
progress?.Report(new ProgressReport(1, isIndeterminate: false));
}
public void UpdatePathExtensions()
{
settingsManager.Settings.PathExtensions?.Clear();
settingsManager.AddPathExtension(GitBinPath);
settingsManager.InsertPathExtensions();
}
private async Task UnzipGit(IProgress<ProgressReport>? progress = null)
{
if (progress == null)

88
StabilityMatrix/Helper/SettingsManager.cs

@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
using System.Threading;
using NLog;
using StabilityMatrix.Models;
using StabilityMatrix.Models.Settings;
using StabilityMatrix.Python;
using Wpf.Ui.Controls.Window;
@ -17,11 +18,31 @@ public class SettingsManager : ISettingsManager
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private static readonly ReaderWriterLockSlim FileLock = new();
private static readonly string GlobalSettingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StabilityMatrix",
"global.json");
private readonly string? originalEnvPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process);
// Library properties
public bool IsPortableMode { get; set; }
public string LibraryDir { get; set; } = string.Empty;
public bool IsPortableMode { get; private set; }
private string? libraryDir;
public string LibraryDir
{
get
{
if (string.IsNullOrWhiteSpace(libraryDir))
{
throw new InvalidOperationException("LibraryDir is not set");
}
return libraryDir;
}
private set
{
libraryDir = value;
LibraryDirChanged?.Invoke(this, value);
}
}
// Dynamic paths from library
public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db");
@ -30,7 +51,9 @@ public class SettingsManager : ISettingsManager
public Settings Settings { get; private set; } = new();
// Events
public event EventHandler<bool>? ModelBrowserNsfwEnabledChanged;
public event EventHandler<string>? LibraryDirChanged;
/// <summary>
/// Attempts to locate and set the library path
@ -40,35 +63,37 @@ public class SettingsManager : ISettingsManager
{
// 1. Check portable mode
var appDir = AppContext.BaseDirectory;
IsPortableMode = File.Exists(Path.Combine(appDir, ".sm-portable"));
IsPortableMode = File.Exists(Path.Combine(appDir, "Data", ".sm-portable"));
if (IsPortableMode)
{
LibraryDir = Path.Combine(appDir, "Data");
SetStaticLibraryPaths();
LoadSettings();
return true;
}
// 2. Check %APPDATA%/StabilityMatrix/library.json
var appDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var libraryJsonPath = Path.Combine(appDataDir, "StabilityMatrix", "library.json");
if (File.Exists(libraryJsonPath))
if (!File.Exists(libraryJsonPath))
return false;
try
{
try
{
var libraryJson = File.ReadAllText(libraryJsonPath);
var library = JsonSerializer.Deserialize<LibrarySettings>(libraryJson);
if (!string.IsNullOrWhiteSpace(library?.LibraryPath))
{
LibraryDir = library.LibraryPath;
SetStaticLibraryPaths();
return true;
}
}
catch (Exception e)
var libraryJson = File.ReadAllText(libraryJsonPath);
var library = JsonSerializer.Deserialize<LibrarySettings>(libraryJson);
if (!string.IsNullOrWhiteSpace(library?.LibraryPath))
{
Logger.Warn("Failed to read library.json in AppData: {Message}", e.Message);
LibraryDir = library.LibraryPath;
SetStaticLibraryPaths();
LoadSettings();
return true;
}
}
catch (Exception e)
{
Logger.Warn("Failed to read library.json in AppData: {Message}", e.Message);
}
return false;
}
@ -118,7 +143,9 @@ public class SettingsManager : ISettingsManager
{
var installed = Settings.InstalledPackages;
// Absolute paths are old formats requiring migration
foreach (var package in installed.Where(package => Path.IsPathRooted(package.Path)))
#pragma warning disable CS0618
foreach (var package in installed.Where(package => package.Path != null))
#pragma warning restore CS0618
{
yield return package;
}
@ -306,6 +333,27 @@ public class SettingsManager : ISettingsManager
return Settings.SharedFolderVisibleCategories?.HasFlag(type) ?? false;
}
public bool IsEulaAccepted()
{
if (!File.Exists(GlobalSettingsPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(GlobalSettingsPath)!);
File.Create(GlobalSettingsPath).Close();
return false;
}
var json = File.ReadAllText(GlobalSettingsPath);
var globalSettings = JsonSerializer.Deserialize<GlobalSettings>(json);
return globalSettings?.EulaAccepted ?? false;
}
public void SetEulaAccepted()
{
var globalSettings = new GlobalSettings {EulaAccepted = true};
var json = JsonSerializer.Serialize(globalSettings);
File.WriteAllText(GlobalSettingsPath, json);
}
/// <summary>
/// Loads settings from the settings file
/// If the settings file does not exist, it will be created with default values
@ -337,9 +385,9 @@ public class SettingsManager : ISettingsManager
}
}
private void SaveSettings()
public void SaveSettings()
{
FileLock.TryEnterWriteLock(1000);
FileLock.TryEnterWriteLock(100000);
try
{
var json = JsonSerializer.Serialize(Settings, new JsonSerializerOptions

113
StabilityMatrix/Helper/UpdateHelper.cs

@ -0,0 +1,113 @@
using System;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Models;
using StabilityMatrix.Services;
namespace StabilityMatrix.Helper;
public class UpdateHelper : IUpdateHelper
{
private readonly ILogger<UpdateHelper> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly IDownloadService downloadService;
private readonly DispatcherTimer timer = new();
private static readonly string UpdateFolder =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Update");
public static readonly string ExecutablePath =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Update", "StabilityMatrix.exe");
public UpdateHelper(ILogger<UpdateHelper> logger, IHttpClientFactory httpClientFactory,
IDownloadService downloadService)
{
this.logger = logger;
this.httpClientFactory = httpClientFactory;
this.downloadService = downloadService;
timer.Interval = TimeSpan.FromMinutes(5);
timer.Tick += async (_, _) => { await CheckForUpdate(); };
}
public async Task StartCheckingForUpdates()
{
timer.IsEnabled = true;
timer.Start();
await CheckForUpdate();
}
public async Task DownloadUpdate(UpdateInfo updateInfo,
IProgress<ProgressReport> progress)
{
var downloadUrl = updateInfo.DownloadUrl;
Directory.CreateDirectory(UpdateFolder);
// download the file from URL
await downloadService.DownloadToFileAsync(downloadUrl, ExecutablePath, progress: progress,
httpClientName: "UpdateClient");
}
private async Task CheckForUpdate()
{
var httpClient = httpClientFactory.CreateClient("UpdateClient");
var response = await httpClient.GetAsync("https://cdn.lykos.ai/update.json");
if (!response.IsSuccessStatusCode)
{
logger.LogError("Error while checking for update");
return;
}
var updateInfo =
await JsonSerializer.DeserializeAsync<UpdateInfo>(
await response.Content.ReadAsStreamAsync());
if (updateInfo == null)
{
logger.LogError("UpdateInfo is null");
return;
}
if (updateInfo.Version == Utilities.GetAppVersion())
{
logger.LogInformation("No update available");
return;
}
// check if update is newer
var updateVersion = updateInfo.Version.Split('.');
var currentVersion = Utilities.GetAppVersion().Split('.');
if (updateVersion.Length != 4 || currentVersion.Length != 4)
{
logger.LogError("Invalid version format");
return;
}
var updateVersionInt = new int[4];
var currentVersionInt = new int[4];
for (var i = 0; i < 4; i++)
{
if (int.TryParse(updateVersion[i], out updateVersionInt[i]) &&
int.TryParse(currentVersion[i], out currentVersionInt[i])) continue;
logger.LogError("Invalid version format");
return;
}
// check if update is newer
for (var i = 0; i < 4; i++)
{
if (updateVersionInt[i] <= currentVersionInt[i]) continue;
logger.LogInformation("Update available");
EventManager.Instance.OnUpdateAvailable(updateInfo);
return;
}
}
}

15
StabilityMatrix/Helper/Utilities.cs

@ -0,0 +1,15 @@
using System.Reflection;
namespace StabilityMatrix.Helper;
public static class Utilities
{
public static string GetAppVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version;
return version == null
? "(Unknown)"
: $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
}
}

4
StabilityMatrix/InstallerWindow.xaml

@ -215,10 +215,6 @@
</StackPanel>
</StackPanel>
<Label Content="Install Location" Margin="0,16,0,0" />
<ui:TextBox Margin="0,0,0,8"
Text="{Binding InstallPath, UpdateSourceTrigger=PropertyChanged}" />
<Label Content="Display Name" Margin="0,16,0,0" />
<StackPanel Orientation="Horizontal"
Visibility="{Binding ShowDuplicateWarning, Converter={StaticResource BoolToVisConverter}}">

26
StabilityMatrix/LaunchPage.xaml

@ -19,7 +19,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Page.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisConverter"/>
<BooleanToVisibilityConverter x:Key="BoolToVisConverter" />
<!-- Manually copied style since can't inherit: https://github.com/lepoco/wpfui/blob/main/src/Wpf.Ui/Styles/Controls/ComboBox.xaml -->
<ControlTemplate TargetType="{x:Type ComboBoxItem}" x:Key="SelectPackageDropDownStyle">
@ -58,8 +58,8 @@
VerticalAlignment="Top"
d:DataContext="{d:DesignInstance Type=models:InstalledPackage,
IsDesignTimeCreatable=True}">
<TextBlock Margin="0,5,0,5" Text="{Binding DisplayName}" />
<TextBlock Margin="0,0,0,5" Text="{Binding Path}" />
<TextBlock Margin="0,5,0,5" Text="{Binding DisplayName, Mode=OneWay}" />
<TextBlock Margin="0,0,0,5" Text="{Binding FullPath, Mode=OneWay}" />
</StackPanel>
</Grid>
</Grid>
@ -184,7 +184,7 @@
<TextBlock
Margin="0,5,0,5"
Name="NameTextBlock"
Text="{Binding DisplayName}" />
Text="{Binding DisplayName, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
@ -206,13 +206,15 @@
TextWrapping="Wrap"
VerticalAlignment="Stretch" />
</ScrollViewer>
<ui:Button Grid.Row="2" Grid.ColumnSpan="2"
Margin="24,0,24,8"
Command="{Binding LaunchWebUiCommand}"
Appearance="Success"
FontSize="12"
HorizontalAlignment="Stretch"
Visibility="{Binding ShowWebUiButton, Converter={StaticResource BoolToVisConverter}}"
Content="Open Web UI"/>
<ui:Button
Appearance="Success"
Command="{Binding LaunchWebUiCommand}"
Content="Open Web UI"
FontSize="12"
Grid.ColumnSpan="2"
Grid.Row="2"
HorizontalAlignment="Stretch"
Margin="24,0,24,8"
Visibility="{Binding ShowWebUiButton, Converter={StaticResource BoolToVisConverter}}" />
</Grid>
</Page>

8
StabilityMatrix/MainWindow.xaml

@ -100,6 +100,14 @@
</ui:NavigationView.MenuItems>
<ui:NavigationView.FooterMenuItems>
<!-- Footer menus -->
<ui:NavigationViewItem Content="Update Available"
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource BooleanToVisibilityConverter}}"
Command="{Binding DoUpdateCommand}">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="ArrowDownload24"
Foreground="LimeGreen" />
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Command="{Binding OpenLinkPatreonCommand}" Content="Become a Patron">
<ui:NavigationViewItem.Icon>
<ui:ImageIcon

10
StabilityMatrix/MainWindow.xaml.cs

@ -54,12 +54,18 @@ namespace StabilityMatrix
private void RootNavigation_OnPaneOpened(NavigationView sender, RoutedEventArgs args)
{
settingsManager.SetNavExpanded(true);
if (settingsManager.TryFindLibrary())
{
settingsManager.SetNavExpanded(true);
}
}
private void RootNavigation_OnPaneClosed(NavigationView sender, RoutedEventArgs args)
{
settingsManager.SetNavExpanded(false);
if (settingsManager.TryFindLibrary())
{
settingsManager.SetNavExpanded(false);
}
}
private void MainWindow_OnClosed(object? sender, EventArgs e)

2
StabilityMatrix/Models/Api/CivitFile.cs

@ -5,7 +5,7 @@ namespace StabilityMatrix.Models.Api;
public class CivitFile
{
[JsonPropertyName("sizeKb")]
[JsonPropertyName("sizeKB")]
public double SizeKb { get; set; }
[JsonPropertyName("pickleScanResult")]

2
StabilityMatrix/Models/CheckpointFile.cs

@ -3,13 +3,11 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

8
StabilityMatrix/Models/Configs/DebugOptions.cs

@ -2,5 +2,13 @@
public class DebugOptions
{
/// <summary>
/// Sets up LiteDB to use a temporary database file on each run
/// </summary>
public bool TempDatabase { get; set; }
/// <summary>
/// Always show the one-click install page on launch
/// </summary>
public bool ShowOneClickInstall { get; set; }
}

92
StabilityMatrix/Models/FileInterfaces/DirectoryPath.cs

@ -0,0 +1,92 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace StabilityMatrix.Models.FileInterfaces;
public class DirectoryPath : FileSystemPath, IPathObject
{
private DirectoryInfo? _info;
// ReSharper disable once MemberCanBePrivate.Global
public DirectoryInfo Info => _info ??= new DirectoryInfo(FullPath);
public bool IsSymbolicLink
{
get
{
Info.Refresh();
return Info.Attributes.HasFlag(FileAttributes.ReparsePoint);
}
}
public bool Exists => Info.Exists;
public DirectoryPath(string path) : base(path)
{
}
public DirectoryPath(FileSystemPath path) : base(path)
{
}
public DirectoryPath(params string[] paths) : base(paths)
{
}
public long GetSize()
{
ulong size = 1 + 2;
Info.Refresh();
return Info.EnumerateFiles("*", SearchOption.AllDirectories)
.Sum(file => file.Length);
}
public long GetSize(bool includeSymbolicLinks)
{
if (includeSymbolicLinks) return GetSize();
Info.Refresh();
var files = Info.GetFiles()
.Where(file => !file.Attributes.HasFlag(FileAttributes.ReparsePoint))
.Sum(file => file.Length);
var subDirs = Info.GetDirectories()
.Where(dir => !dir.Attributes.HasFlag(FileAttributes.ReparsePoint))
.Sum(dir => dir.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length));
return files + subDirs;
}
public Task<long> GetSizeAsync(bool includeSymbolicLinks)
{
return Task.Run(() => GetSize(includeSymbolicLinks));
}
/// <summary> Creates the directory. </summary>
public void Create() => Directory.CreateDirectory(FullPath);
/// <summary> Deletes the directory. </summary>
public void Delete() => Directory.Delete(FullPath);
/// <summary> Deletes the directory asynchronously. </summary>
public Task DeleteAsync() => Task.Run(Delete);
/// <summary> Deletes the directory. </summary>
public void Delete(bool recursive) => Directory.Delete(FullPath, recursive);
/// <summary> Deletes the directory asynchronously. </summary>
public Task DeleteAsync(bool recursive) => Task.Run(() => Delete(recursive));
public override string ToString() => FullPath;
// DirectoryPath + DirectoryPath = DirectoryPath
public static DirectoryPath operator +(DirectoryPath path, DirectoryPath other) => new(Path.Combine(path, other.FullPath));
// DirectoryPath + FilePath = FilePath
public static FilePath operator +(DirectoryPath path, FilePath other) => new(Path.Combine(path, other.FullPath));
// DirectoryPath + string = string
public static string operator +(DirectoryPath path, string other) => Path.Combine(path, other);
// Implicit conversions to and from string
public static implicit operator string(DirectoryPath path) => path.FullPath;
public static implicit operator DirectoryPath(string path) => new(path);
}

93
StabilityMatrix/Models/FileInterfaces/FilePath.cs

@ -0,0 +1,93 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StabilityMatrix.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class FilePath : FileSystemPath, IPathObject
{
private FileInfo? _info;
// ReSharper disable once MemberCanBePrivate.Global
public FileInfo Info => _info ??= new FileInfo(FullPath);
public bool IsSymbolicLink
{
get
{
Info.Refresh();
return Info.Attributes.HasFlag(FileAttributes.ReparsePoint);
}
}
public bool Exists => Info.Exists;
public FilePath(string path) : base(path)
{
}
public FilePath(FileSystemPath path) : base(path)
{
}
public FilePath(params string[] paths) : base(paths)
{
}
public long GetSize()
{
Info.Refresh();
return Info.Length;
}
public long GetSize(bool includeSymbolicLinks)
{
if (!includeSymbolicLinks && IsSymbolicLink) return 0;
return GetSize();
}
public Task<long> GetSizeAsync(bool includeSymbolicLinks)
{
return Task.Run(() => GetSize(includeSymbolicLinks));
}
/// <summary> Creates an empty file. </summary>
public void Create() => File.Create(FullPath).Close();
/// <summary> Deletes the file </summary>
public void Delete() => File.Delete(FullPath);
// Methods specific to files
/// <summary> Read text </summary>
public string ReadAllText() => File.ReadAllText(FullPath);
/// <summary> Read text asynchronously </summary>
public Task<string> ReadAllTextAsync(CancellationToken ct = default)
{
return File.ReadAllTextAsync(FullPath, ct);
}
/// <summary> Read bytes </summary>
public byte[] ReadAllBytes() => File.ReadAllBytes(FullPath);
/// <summary> Read bytes asynchronously </summary>
public Task<byte[]> ReadAllBytesAsync(CancellationToken ct = default)
{
return File.ReadAllBytesAsync(FullPath, ct);
}
/// <summary> Write bytes </summary>
public void WriteAllBytes(byte[] bytes) => File.WriteAllBytes(FullPath, bytes);
/// <summary> Write bytes asynchronously </summary>
public Task WriteAllBytesAsync(byte[] bytes, CancellationToken ct = default)
{
return File.WriteAllBytesAsync(FullPath, bytes, ct);
}
// Implicit conversions to and from string
public static implicit operator string(FilePath path) => path.FullPath;
public static implicit operator FilePath(string path) => new(path);
}

21
StabilityMatrix/Models/FileInterfaces/FileSystemPath.cs

@ -0,0 +1,21 @@
using System.IO;
namespace StabilityMatrix.Models.FileInterfaces;
public class FileSystemPath
{
public string FullPath { get; }
protected FileSystemPath(string path)
{
FullPath = path;
}
protected FileSystemPath(FileSystemPath path) : this(path.FullPath)
{
}
protected FileSystemPath(params string[] paths) : this(Path.Combine(paths))
{
}
}

27
StabilityMatrix/Models/FileInterfaces/IPathObject.cs

@ -0,0 +1,27 @@
using System.Threading.Tasks;
namespace StabilityMatrix.Models.FileInterfaces;
public interface IPathObject
{
/// <summary> Full path of the file system object. </summary>
string FullPath { get; }
/// <summary> Whether the file system object is a symbolic link or junction. </summary>
bool IsSymbolicLink { get; }
/// <summary> Gets the size of the file system object. </summary>
long GetSize();
/// <summary> Gets the size of the file system object asynchronously. </summary>
Task<long> GetSizeAsync() => Task.Run(GetSize);
/// <summary> Whether the file system object exists. </summary>
bool Exists { get; }
/// <summary> Deletes the file system object </summary>
void Delete();
/// <summary> Deletes the file system object asynchronously. </summary>
public Task DeleteAsync() => Task.Run(Delete);
}

13
StabilityMatrix/Models/GlobalConfig.cs

@ -1,4 +1,5 @@
using System;
using StabilityMatrix.Models.FileInterfaces;
namespace StabilityMatrix.Models;
@ -23,4 +24,16 @@ public static class GlobalConfig
}
set => libraryDir = value;
}
/// <summary>
/// Full path to the %APPDATA% directory.
/// Usually C:\Users\{username}\AppData\Roaming
/// </summary>
public static DirectoryPath AppDataDir { get; } = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
/// <summary>
/// Full path to the fixed home directory.
/// Currently %APPDATA%\StabilityMatrix
///</summary>
public static FilePath HomeDir { get; } = AppDataDir + @"StabilityMatrix\";
}

146
StabilityMatrix/Models/GlobalUserSecrets.cs

@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.DirectoryServices;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text.Json;
using System.Text.Json.Serialization;
using StabilityMatrix.Models.FileInterfaces;
namespace StabilityMatrix.Models;
internal record struct KeyInfo(byte[] Key, byte[] Salt, int Iterations);
/// <summary>
/// Global instance of user secrets.
/// Stored in %APPDATA%\StabilityMatrix\user-secrets.data
/// </summary>
public class GlobalUserSecrets
{
private const int KeySize = 32;
private const int Iterations = 300;
private const int SaltSize = 16;
[JsonIgnore]
public static FilePath File { get; } = GlobalConfig.HomeDir + "user-secrets.data";
public Dictionary<string, string> PatreonCookies { get; set; } = new();
private static SecurityIdentifier? GetComputerSid()
{
var id = new DirectoryEntry($"WinNT://{Environment.MachineName},Computer")
.Children.Cast<DirectoryEntry>().First().InvokeGet("objectSID");
return id is not byte[] bytes ? null :
new SecurityIdentifier(bytes, 0).AccountDomainSid;
}
private static SecureString GetComputerKeyPhrase()
{
var keySource = GetComputerSid()?.ToString();
// If no sid, use username as fallback
keySource ??= Environment.UserName;
// XOR with fixed constant
const string keyPhrase = "StabilityMatrix";
var result = new SecureString();
for (var i = 0; i < keySource.Length; i++)
{
result.AppendChar((char)(keySource[i] ^ keyPhrase[i % keyPhrase.Length]));
}
return result;
}
private static KeyInfo DeriveKeyWithSalt(SecureString password, int saltLength, int iterations, int keyLength)
{
var salt = RandomNumberGenerator.GetBytes(saltLength);
var key = DeriveKey(password, salt, iterations, keyLength);
return new KeyInfo(key, salt, iterations);
}
private static byte[] DeriveKey(SecureString password, byte[] salt, int iterations, int keyLength)
{
var ptr = Marshal.SecureStringToBSTR(password);
try
{
var length = Marshal.ReadInt32(ptr, -4);
var passwordByteArray = new byte[length];
var handle = GCHandle.Alloc(passwordByteArray, GCHandleType.Pinned);
try
{
for (var i = 0; i < length; i++)
{
passwordByteArray[i] = Marshal.ReadByte(ptr, i);
}
using var rfc2898 = new Rfc2898DeriveBytes(passwordByteArray, salt, iterations);
return rfc2898.GetBytes(keyLength);
}
finally
{
Array.Clear(passwordByteArray, 0, passwordByteArray.Length);
handle.Free();
}
}
finally
{
Marshal.ZeroFreeBSTR(ptr);
}
}
private static (byte[], byte[]) EncryptBytes(byte[] data)
{
var keyInfo =
DeriveKeyWithSalt(GetComputerKeyPhrase(), SaltSize, Iterations, KeySize);
using var aes = Aes.Create();
aes.Key = keyInfo.Key;
aes.IV = keyInfo.Salt;
aes.Padding = PaddingMode.PKCS7;
aes.Mode = CipherMode.CBC;
var transform = aes.CreateEncryptor();
return (transform.TransformFinalBlock(data, 0, data.Length), keyInfo.Salt);
}
private static byte[] DecryptBytes(IReadOnlyCollection<byte> encryptedData, byte[] salt)
{
var key =
DeriveKey(GetComputerKeyPhrase(), salt, Iterations, KeySize);
using var aes = Aes.Create();
aes.Key = key;
aes.IV = salt;
aes.Padding = PaddingMode.PKCS7;
aes.Mode = CipherMode.CBC;
var transform = aes.CreateDecryptor();
return transform.TransformFinalBlock(encryptedData.ToArray(), 0, encryptedData.Count);
}
public void SaveToFile()
{
var json = JsonSerializer.SerializeToUtf8Bytes(this);
var (encrypted, salt) = EncryptBytes(json);
// Prepend salt to encrypted json
var fileBytes = salt.Concat(encrypted).ToArray();
File.WriteAllBytes(fileBytes);
}
public static GlobalUserSecrets? LoadFromFile()
{
var fileBytes = File.ReadAllBytes();
// Get salt from start of file
var salt = fileBytes.AsSpan(0, SaltSize).ToArray();
// Get encrypted json from rest of file
var encryptedJson = fileBytes.AsSpan(SaltSize).ToArray();
var json = DecryptBytes(encryptedJson, salt);
return JsonSerializer.Deserialize<GlobalUserSecrets>(json);
}
}

1
StabilityMatrix/Models/ISharedFolders.cs

@ -5,4 +5,5 @@ namespace StabilityMatrix.Models;
public interface ISharedFolders
{
void SetupLinksForPackage(BasePackage basePackage, string installPath);
void UpdateLinksForPackage(BasePackage basePackage, string installPath);
}

19
StabilityMatrix/Models/InstalledPackage.cs

@ -34,6 +34,7 @@ public class InstalledPackage
/// <summary>
/// Full path to the package, using LibraryPath and GlobalConfig.LibraryDir.
/// </summary>
[JsonIgnore]
public string? FullPath => LibraryPath != null ? System.IO.Path.Combine(GlobalConfig.LibraryDir, LibraryPath) : null;
public string? LaunchCommand { get; set; }
@ -88,6 +89,24 @@ public class InstalledPackage
return false;
}
/// <summary>
/// Check if the old Path can be migrated to the new LibraryPath.
/// </summary>
/// <param name="libraryDirectory"></param>
/// <returns></returns>
public bool CanPureMigratePath(string? libraryDirectory = null)
{
#pragma warning disable CS0618
var oldPath = Path;
#pragma warning restore CS0618
if (oldPath == null) return false;
// Check if the path is a sub-path of the library
var library = libraryDirectory ?? GlobalConfig.LibraryDir;
var relativePath = GetSubPath(library, oldPath);
return relativePath != null;
}
/// <summary>
/// Migrate the old Path to the new LibraryPath.
/// If libraryDirectory is null, GlobalConfig.LibraryDir is used.

1
StabilityMatrix/Models/Packages/A3WebUI.cs

@ -140,6 +140,7 @@ public class A3WebUI : BaseGitPackage
public override async Task RunPackage(string installedPackagePath, string arguments)
{
await SetupVenv(installedPackagePath);
PrerequisiteHelper.UpdatePathExtensions();
void HandleConsoleOutput(string? s)
{

12
StabilityMatrix/Models/Packages/BaseGitPackage.cs

@ -36,10 +36,9 @@ public abstract class BaseGitPackage : BasePackage
public override string GithubUrl => $"https://github.com/{Author}/{Name}";
public override string DownloadLocation =>
$"{Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)}\\StabilityMatrix\\Packages\\{Name}.zip";
$"{SettingsManager.LibraryDir}\\Packages\\{Name}.zip";
public override string InstallLocation { get; set; } =
$"{Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)}\\StabilityMatrix\\Packages";
public override string InstallLocation { get; set; }
protected string GetDownloadUrl(string tagName, bool isCommitHash = false)
{
@ -181,7 +180,12 @@ public abstract class BaseGitPackage : BasePackage
else
{
var allCommits = await GetAllCommits(package.InstalledBranch);
var latestCommitHash = allCommits.First().Sha;
if (allCommits == null || !allCommits.Any())
{
Logger.Warn("No commits found for {Package}", package.PackageName);
return false;
}
var latestCommitHash = allCommits[0].Sha;
return latestCommitHash != currentVersion;
}

1
StabilityMatrix/Models/Packages/ComfyUI.cs

@ -101,6 +101,7 @@ public class ComfyUI : BaseGitPackage
public override async Task RunPackage(string installedPackagePath, string arguments)
{
await SetupVenv(installedPackagePath);
PrerequisiteHelper.UpdatePathExtensions();
void HandleConsoleOutput(string? s)
{

7
StabilityMatrix/Models/Packages/VladAutomatic.cs

@ -63,12 +63,6 @@ public class VladAutomatic : BaseGitPackage
Options = new() { "--lowvram", "--medvram" }
},
new()
{
Name = "Xformers",
InitialValue = HardwareHelper.HasNvidiaGpu() ? "--xformers" : null,
Options = new() { "--xformers" }
},
new()
{
Name = "API",
Options = new() { "--api" }
@ -111,6 +105,7 @@ public class VladAutomatic : BaseGitPackage
public override async Task RunPackage(string installedPackagePath, string arguments)
{
await SetupVenv(installedPackagePath);
PrerequisiteHelper.UpdatePathExtensions();
void HandleConsoleOutput(string? s)
{

6
StabilityMatrix/Models/Settings/GlobalSettings.cs

@ -0,0 +1,6 @@
namespace StabilityMatrix.Models.Settings;
public class GlobalSettings
{
public bool EulaAccepted { get; set; }
}

2
StabilityMatrix/Models/LibrarySettings.cs → StabilityMatrix/Models/Settings/LibrarySettings.cs

@ -1,4 +1,4 @@
namespace StabilityMatrix.Models;
namespace StabilityMatrix.Models.Settings;
public class LibrarySettings
{

13
StabilityMatrix/Models/Settings.cs → StabilityMatrix/Models/Settings/Settings.cs

@ -1,19 +1,18 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Wpf.Ui.Controls.Window;
namespace StabilityMatrix.Models;
namespace StabilityMatrix.Models.Settings;
public class Settings
{
public int? Version { get; set; } = 1;
public bool FirstLaunchSetupComplete { get; set; }
public string? Theme { get; set; }
public WindowBackdropType? WindowBackdropType { get; set; }
public string? Theme { get; set; } = "Dark";
public WindowBackdropType? WindowBackdropType { get; set; } =
Wpf.Ui.Controls.Window.WindowBackdropType.Mica;
public List<InstalledPackage> InstalledPackages { get; set; } = new();
public Guid? ActiveInstalledPackage { get; set; }
public bool IsNavExpanded { get; set; }
@ -28,6 +27,8 @@ public class Settings
SharedFolderType.Lora |
SharedFolderType.LyCORIS;
public WindowSettings? WindowSettings { get; set; }
public InstalledPackage? GetActiveInstalledPackage()
{
return InstalledPackages.FirstOrDefault(x => x.Id == ActiveInstalledPackage);

7
StabilityMatrix/Models/Settings/WindowSettings.cs

@ -0,0 +1,7 @@
namespace StabilityMatrix.Models.Settings;
public class WindowSettings
{
public double Width { get; set; }
public double Height { get; set; }
}

38
StabilityMatrix/Models/SharedFolders.cs

@ -1,5 +1,4 @@
using System;
using System.IO;
using System.IO;
using NCode.ReparsePoints;
using NLog;
using StabilityMatrix.Extensions;
@ -21,10 +20,7 @@ public class SharedFolders : ISharedFolders
public void SetupLinksForPackage(BasePackage basePackage, string installPath)
{
var sharedFolders = basePackage.SharedFolders;
if (sharedFolders == null)
{
return;
}
if (sharedFolders == null) return;
var provider = ReparsePointFactory.Provider;
foreach (var (folderType, relativePath) in sharedFolders)
@ -63,6 +59,36 @@ public class SharedFolders : ISharedFolders
}
}
/// <summary>
/// Deletes junction links and remakes them. Unlike SetupLinksForPackage,
/// this will not copy files from the destination to the source.
/// </summary>
public void UpdateLinksForPackage(BasePackage basePackage, string installPath)
{
var sharedFolders = basePackage.SharedFolders;
if (sharedFolders == null) return;
var provider = ReparsePointFactory.Provider;
foreach (var (folderType, relativePath) in sharedFolders)
{
var source = Path.Combine(settingsManager.ModelsDirectory, folderType.GetStringValue());
var destination = Path.GetFullPath(Path.Combine(installPath, relativePath));
// Create source folder if it doesn't exist
if (!Directory.Exists(source))
{
Logger.Info($"Creating junction source {source}");
Directory.CreateDirectory(source);
}
// Delete the destination folder if it exists
if (Directory.Exists(destination))
{
Directory.Delete(destination, false);
}
Logger.Info($"Updating junction link from {source} to {destination}");
provider.CreateLink(destination, source, LinkType.Junction);
}
}
public void RemoveLinksForPackage(BasePackage package, string installPath)
{
var sharedFolders = package.SharedFolders;

15
StabilityMatrix/Models/UpdateInfo.cs

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace StabilityMatrix.Models;
public class UpdateInfo
{
[JsonPropertyName("version")]
public string Version { get; set; }
[JsonPropertyName("url")]
public string DownloadUrl { get; set; }
[JsonPropertyName("changelog")]
public string ChangelogUrl { get; set; }
}

4
StabilityMatrix/PackageManagerPage.xaml

@ -112,8 +112,8 @@
<TextBlock
FontSize="12"
Margin="0,5,0,5"
Text="{Binding SelectedPackage.Path, FallbackValue=C:\Users\Example\Downloads}" />
<StackPanel Margin="0,16,0,0" Orientation="Horizontal">
Text="{Binding SelectedPackage.FullPath, FallbackValue=Packages\stable-diffusion}" />
<StackPanel Margin="0,16,0,0" Orientation="Horizontal">
<ui:Button
Appearance="Success"
Command="{Binding InstallCommand}"

65
StabilityMatrix/Python/PyVenvRunner.cs

@ -5,7 +5,9 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NLog;
using Salaros.Configuration;
using StabilityMatrix.Helper;
using StabilityMatrix.Models.FileInterfaces;
namespace StabilityMatrix.Python;
@ -24,17 +26,17 @@ public class PyVenvRunner : IDisposable
/// <summary>
/// The path to the venv root directory.
/// </summary>
public string RootPath { get; private set; }
public DirectoryPath RootPath { get; }
/// <summary>
/// The path to the python executable.
/// </summary>
public string PythonPath => RootPath + @"\Scripts\python.exe";
public FilePath PythonPath => RootPath + @"Scripts\python.exe";
/// <summary>
/// The path to the pip executable.
/// </summary>
public string PipPath => RootPath + @"\Scripts\pip.exe";
public FilePath PipPath => RootPath + @"Scripts\pip.exe";
/// <summary>
/// List of substrings to suppress from the output.
@ -43,13 +45,13 @@ public class PyVenvRunner : IDisposable
/// </summary>
public List<string> SuppressOutput { get; } = new() { "fatal: not a git repository" };
public PyVenvRunner(string path)
public PyVenvRunner(DirectoryPath path)
{
RootPath = path;
}
// Whether the activate script exists
public bool Exists() => File.Exists(PythonPath);
/// <returns>True if the venv has a Scripts\python.exe file</returns>
public bool Exists() => PythonPath.Exists;
/// <summary>
/// Creates a venv at the configured path.
@ -62,13 +64,11 @@ public class PyVenvRunner : IDisposable
}
// Create RootPath if it doesn't exist
if (!Directory.Exists(RootPath))
{
Directory.CreateDirectory(RootPath);
}
RootPath.Create();
// Create venv
var venvProc = ProcessRunner.StartProcess(PyRunner.PythonExePath, $"-m virtualenv \"{RootPath}\"");
var args = new string[] { "-m", "virtualenv", "--always-copy", RootPath };
var venvProc = ProcessRunner.StartProcess(PyRunner.PythonExePath, args);
await venvProc.WaitForExitAsync();
// Check return code
@ -95,6 +95,39 @@ public class PyVenvRunner : IDisposable
return "torch torchvision torchaudio";
}
/// <summary>
/// Set current python path to pyvenv.cfg
/// This should be called before using the venv, in case user moves the venv directory.
/// </summary>
private void SetPyvenvCfg(string pythonDirectory)
{
// Skip if we are not created yet
if (!Exists()) return;
// Path to pyvenv.cfg
var cfgPath = Path.Combine(RootPath, "pyvenv.cfg");
if (!File.Exists(cfgPath))
{
throw new FileNotFoundException("pyvenv.cfg not found", cfgPath);
}
Logger.Info("Updating pyvenv.cfg with embedded Python directory {PyDir}", pythonDirectory);
// Insert a top section
var topSection = "[top]" + Environment.NewLine;
var cfg = new ConfigParser(topSection + File.ReadAllText(cfgPath));
// Need to set all path keys - home, base-prefix, base-exec-prefix, base-executable
cfg.SetValue("top", "home", pythonDirectory);
cfg.SetValue("top", "base-prefix", pythonDirectory);
cfg.SetValue("top", "base-exec-prefix", pythonDirectory);
cfg.SetValue("top", "base-executable", Path.Combine(pythonDirectory, "python.exe"));
// Convert to string for writing, strip the top section
var cfgString = cfg.ToString().Replace(topSection, "");
File.WriteAllText(cfgPath, cfgString);
}
/// <summary>
/// Install torch with pip, automatically chooses between Cuda and CPU.
/// </summary>
@ -113,17 +146,23 @@ public class PyVenvRunner : IDisposable
{
throw new FileNotFoundException("pip not found", PipPath);
}
SetPyvenvCfg(PyRunner.PythonDir);
Process = ProcessRunner.StartProcess(PythonPath, $"-m pip install {args}", workingDirectory ?? RootPath, outputDataReceived);
await ProcessRunner.WaitForExitConditionAsync(Process);
}
public void RunDetached(string arguments, Action<string?>? outputDataReceived, Action<int>? onExit = null,
bool unbuffered = true, string workingDirectory = "")
public void RunDetached(
string arguments,
Action<string?>? outputDataReceived,
Action<int>? onExit = null,
bool unbuffered = true,
string workingDirectory = "")
{
if (!Exists())
{
throw new InvalidOperationException("Venv python process does not exist");
}
SetPyvenvCfg(PyRunner.PythonDir);
Logger.Debug($"Launching RunDetached at {PythonPath} with args {arguments}");

115
StabilityMatrix/SelectInstallLocationsDialog.xaml

@ -0,0 +1,115 @@
<ui:ContentDialog
CloseButtonText="Close"
Loaded="SelectInstallLocationsDialog_OnLoaded"
Title="Select Data Directory"
d:DataContext="{d:DesignInstance Type=viewModels:SelectInstallLocationsViewModel,
IsDesignTimeCreatable=True}"
d:DesignHeight="512"
d:DesignWidth="640"
mc:Ignorable="d"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
x:Class="StabilityMatrix.SelectInstallLocationsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:controls="clr-namespace:StabilityMatrix.Controls"
xmlns:converters="clr-namespace:StabilityMatrix.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:StabilityMatrix"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewModels="clr-namespace:StabilityMatrix.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ui:ContentDialog.Resources>
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<Style BasedOn="{StaticResource {x:Type ui:ContentDialog}}" TargetType="{x:Type local:SelectInstallLocationsDialog}" />
<converters:BoolNegationConverter x:Key="BoolNegationConverter" />
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
</ui:ContentDialog.Resources>
<Grid
Margin="16,0,16,16"
MaxWidth="700"
MinHeight="400">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<Label
Content="Data Directory"
FontSize="13"
Margin="0,16,0,0" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBox
Height="36"
IsEnabled="{Binding IsPortableMode, Converter={StaticResource BoolNegationConverter}}"
Margin="0,0,8,0"
PlaceholderEnabled="True"
PlaceholderText="{Binding DefaultInstallLocation}"
Text="{Binding DataDirectory, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Stretch" />
<ui:Button
Command="{Binding ShowFolderBrowserDialogCommand}"
Grid.Column="1"
Height="36"
HorizontalAlignment="Stretch"
IsEnabled="{Binding IsPortableMode, Converter={StaticResource BoolNegationConverter}}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="FolderOpen24" />
</ui:Button.Icon>
</ui:Button>
</Grid>
<Label
Content="This is where the model checkpoints, LORAs, web UIs, settings, etc. will be installed."
FontSize="12"
Margin="0,8,0,0" />
<CheckBox
Content="Portable Mode"
IsChecked="{Binding IsPortableMode, Mode=TwoWay}"
Margin="0,16,0,0" />
<ui:InfoBar
IsClosable="False"
IsOpen="True"
Margin="0,8,0,0"
Padding="8,16"
Title="In Portable Mode, all data and settings will be stored in the same directory as the application. You will be able to move the application with its 'Data' folder to a different location or computer." />
</StackPanel>
<!-- Indicator of existing or new data directory -->
<StackPanel
Grid.Row="1"
HorizontalAlignment="Center"
Margin="8,0,8,8"
Orientation="Horizontal"
Visibility="{Binding IsStatusBadgeVisible, Converter={StaticResource BooleanToVisibilityConverter}}">
<controls:RefreshBadge DataContext="{Binding RefreshBadgeViewModel}" />
<TextBlock
FontSize="14"
Text="{Binding RefreshBadgeViewModel.CurrentToolTip}"
VerticalAlignment="Center" />
</StackPanel>
<ui:Button
Appearance="Success"
Click="ContinueButton_OnClick"
Content="Continue"
FontSize="16"
Grid.Row="2"
HorizontalAlignment="Center"
IsEnabled="{Binding IsDirectoryValid}"
Margin="8"
Padding="16,8" />
</Grid>
</ui:ContentDialog>

26
StabilityMatrix/SelectInstallLocationsDialog.xaml.cs

@ -0,0 +1,26 @@
using System.Windows;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Contracts;
using Wpf.Ui.Controls.ContentDialogControl;
namespace StabilityMatrix;
public partial class SelectInstallLocationsDialog : ContentDialog
{
public SelectInstallLocationsDialog(IContentDialogService dialogService, SelectInstallLocationsViewModel viewModel) : base(
dialogService.GetContentPresenter())
{
InitializeComponent();
DataContext = viewModel;
}
private void ContinueButton_OnClick(object sender, RoutedEventArgs e)
{
Hide(ContentDialogResult.Primary);
}
private void SelectInstallLocationsDialog_OnLoaded(object sender, RoutedEventArgs e)
{
((SelectInstallLocationsViewModel) DataContext).OnLoaded();
}
}

9
StabilityMatrix/Services/DownloadService.cs

@ -21,10 +21,13 @@ public class DownloadService : IDownloadService
}
public async Task DownloadToFileAsync(string downloadUrl, string downloadLocation, int bufferSize = ushort.MaxValue,
IProgress<ProgressReport>? progress = null)
IProgress<ProgressReport>? progress = null, string? httpClientName = null)
{
using var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromMinutes(5);
using var client = string.IsNullOrWhiteSpace(httpClientName)
? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName);
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "1.0"));
await using var file = new FileStream(downloadLocation, FileMode.Create, FileAccess.Write, FileShare.None);

2
StabilityMatrix/Services/IDownloadService.cs

@ -7,5 +7,5 @@ namespace StabilityMatrix.Services;
public interface IDownloadService
{
Task DownloadToFileAsync(string downloadUrl, string downloadLocation, int bufferSize = ushort.MaxValue,
IProgress<ProgressReport>? progress = null);
IProgress<ProgressReport>? progress = null, string? httpClientName = null);
}

4
StabilityMatrix/SettingsPage.xaml

@ -188,6 +188,10 @@
Command="{Binding ModelFileSearchCommand}"
Content="Model File Search"
Margin="8" />
<Button
Command="{Binding WebViewDemoCommand}"
Content="Open WebView"
Margin="8" />
</StackPanel>
</StackPanel>
</ui:Card>

31
StabilityMatrix/StabilityMatrix.csproj

@ -21,26 +21,29 @@
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="LiteDB" Version="5.0.16" />
<PackageReference Include="LiteDB.Async" Version="0.1.6" />
<PackageReference Include="MdXaml" Version="1.19.2" />
<PackageReference Include="MdXaml" Version="1.20.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.16" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.8" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.1823.32" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="NCode.ReparsePoints" Version="1.0.2" />
<PackageReference Include="NLog" Version="5.1.4" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.2.3" />
<PackageReference Include="Octokit" Version="6.0.0" />
<PackageReference Include="NLog" Version="5.2.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.0" />
<PackageReference Include="Octokit" Version="6.2.1" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Polly" Version="7.2.4" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
<PackageReference Include="Refit" Version="6.3.2" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.3.2" />
<PackageReference Include="Sentry" Version="3.33.0" />
<PackageReference Include="Sentry.NLog" Version="3.33.0" />
<PackageReference Include="Salaros.ConfigParser" Version="0.3.8" />
<PackageReference Include="Sentry" Version="3.33.1" />
<PackageReference Include="Sentry.NLog" Version="3.33.1" />
<PackageReference Include="SharpCompress" Version="0.33.0" />
<PackageReference Include="WebView2.Runtime.AutoInstaller" Version="1.0.0" />
<PackageReference Include="WPF-UI" Version="3.0.0-preview.2" />
<PackageReference Include="pythonnet" Version="3.0.1" />
</ItemGroup>
@ -85,6 +88,16 @@
<XamlRuntime>Wpf</XamlRuntime>
<SubType>Designer</SubType>
</Page>
<Page Update="SelectInstallLocationsDialog.xaml">
<Generator>MSBuild:Compile</Generator>
<XamlRuntime>Wpf</XamlRuntime>
<SubType>Designer</SubType>
</Page>
<Page Update="WebLoginDialog.xaml">
<Generator>MSBuild:Compile</Generator>
<XamlRuntime>Wpf</XamlRuntime>
<SubType>Designer</SubType>
</Page>
</ItemGroup>
</Project>

122
StabilityMatrix/UpdateWindow.xaml

@ -0,0 +1,122 @@
<ui:FluentWindow
ExtendsContentIntoTitleBar="True"
Height="700"
Icon="pack://application:,,,/Assets/Icon.ico"
Loaded="UpdateWindow_OnLoaded"
Title="Stability Matrix - Update"
Width="700"
WindowBackdropType="{Binding WindowBackdropType}"
WindowStartupLocation="CenterOwner"
d:DataContext="{d:DesignInstance Type=viewModels:UpdateWindowViewModel,
IsDesignTimeCreatable=True}"
d:DesignHeight="700"
d:DesignWidth="700"
mc:Ignorable="d"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
x:Class="StabilityMatrix.UpdateWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:converters="clr-namespace:StabilityMatrix.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewModels="clr-namespace:StabilityMatrix.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:xaml="clr-namespace:MdXaml;assembly=MdXaml">
<ui:FluentWindow.Resources>
<converters:ValueConverterGroup x:Key="InvertAndVisibilitate">
<converters:BoolNegationConverter />
<BooleanToVisibilityConverter />
</converters:ValueConverterGroup>
<converters:BoolNegationConverter x:Key="BoolNegationConverter" />
<BooleanToVisibilityConverter x:Key="BoolToVisConverter" />
<converters:UriToBitmapConverter x:Key="UriToBitmapConverter" />
<xaml:Markdown
AssetPathRoot="{x:Static system:Environment.CurrentDirectory}"
DocumentStyle="{StaticResource DocumentStyle}"
Heading1Style="{StaticResource H1Style}"
Heading2Style="{StaticResource H2Style}"
Heading3Style="{StaticResource H3Style}"
Heading4Style="{StaticResource H4Style}"
ImageStyle="{StaticResource ImageStyle}"
LinkStyle="{StaticResource LinkStyle}"
SeparatorStyle="{StaticResource SeparatorStyle}"
x:Key="Markdown" />
<xaml:TextToFlowDocumentConverter Markdown="{StaticResource Markdown}" x:Key="TextToFlowDocumentConverter" />
</ui:FluentWindow.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar Background="{ui:ThemeResource ApplicationBackgroundBrush}">
<ui:TitleBar.Header>
<TextBlock Margin="16,8" Text="Stability Matrix - Update Available" />
</ui:TitleBar.Header>
</ui:TitleBar>
<TextBlock Grid.Row="1"
Text="A new version of Stability Matrix is available!"
HorizontalAlignment="Center"
FontWeight="Thin"
Margin="0,16,0,0"
FontSize="28"/>
<TextBlock Grid.Row="2"
HorizontalAlignment="Center"
FontSize="18"
TextWrapping="Wrap"
TextAlignment="Center"
Text="{Binding UpdateText, FallbackValue=Update available and stuff}"
Margin="16,32,16,0"/>
<TextBlock Grid.Row="3"
Text="Release Notes"
FontSize="16"
Visibility="{Binding ShowProgressBar, Converter={StaticResource InvertAndVisibilitate}}"
Margin="32,16,32,0"/>
<ProgressBar Grid.Row="4"
Height="200"
Value="{Binding ProgressValue}"
Visibility="{Binding ShowProgressBar, Converter={StaticResource BoolToVisConverter}}"
Margin="32"/>
<Grid Grid.Row="4"
Visibility="{Binding ShowProgressBar, Converter={StaticResource InvertAndVisibilitate}}">
<Border Margin="32, 16"
CornerRadius="16"
Background="#66000000"/>
<FlowDocumentScrollViewer
Margin="32,16"
Document="{Binding ReleaseNotes, Converter={StaticResource TextToFlowDocumentConverter}}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
</Grid>
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,16">
<ui:Button Content="Remind Me Later"
Margin="0,0,8,0"
FontSize="18"
Click="RemindMeLaterButton_OnClick"
Appearance="Info"/>
<ui:Button Content="Install Now"
Margin="8,0,0,0"
FontSize="18"
Command="{Binding InstallUpdateCommand}"
Appearance="Success"/>
</StackPanel>
</Grid>
</ui:FluentWindow>

27
StabilityMatrix/UpdateWindow.xaml.cs

@ -0,0 +1,27 @@
using System.Windows;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Controls.Window;
namespace StabilityMatrix;
public partial class UpdateWindow : FluentWindow
{
private readonly UpdateWindowViewModel viewModel;
public UpdateWindow(UpdateWindowViewModel viewModel)
{
this.viewModel = viewModel;
InitializeComponent();
DataContext = viewModel;
}
private async void UpdateWindow_OnLoaded(object sender, RoutedEventArgs e)
{
await viewModel.OnLoaded();
}
private void RemindMeLaterButton_OnClick(object sender, RoutedEventArgs e)
{
Close();
}
}

98
StabilityMatrix/ViewModels/DataDirectoryMigrationViewModel.cs

@ -0,0 +1,98 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using StabilityMatrix.Helper;
using StabilityMatrix.Python;
namespace StabilityMatrix.ViewModels;
public partial class DataDirectoryMigrationViewModel : ObservableObject
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ISettingsManager settingsManager;
private readonly IPrerequisiteHelper prerequisiteHelper;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AutoMigrateText))]
[NotifyPropertyChangedFor(nameof(MigrateProgressText))]
private int autoMigrateCount;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NeedsMoveMigrateText))]
[NotifyPropertyChangedFor(nameof(MigrateProgressText))]
private int needsMoveMigrateCount;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MigrateProgressText))]
private int migrateProgressCount;
public string AutoMigrateText => AutoMigrateCount == 0 ? string.Empty :
$"{AutoMigrateCount} Packages will be automatically migrated to the new format";
public string NeedsMoveMigrateText => NeedsMoveMigrateCount == 0 ? string.Empty :
$"{NeedsMoveMigrateCount} Packages are not relative to the Data Directory and will be moved, this may take a few minutes";
[ObservableProperty]
private string migrateProgressText = "";
partial void OnMigrateProgressCountChanged(int value)
{
MigrateProgressText = value > 0 ? $"Migrating {value} of {AutoMigrateCount + NeedsMoveMigrateCount} Packages" : string.Empty;
}
public DataDirectoryMigrationViewModel(ISettingsManager settingsManager, IPrerequisiteHelper prerequisiteHelper)
{
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
}
public void OnLoaded()
{
AutoMigrateCount = 0;
NeedsMoveMigrateCount = 0;
// Get all old packages
var oldPackages = settingsManager.GetOldInstalledPackages().ToArray();
// Attempt to migrate with pure, and count successful migrations
AutoMigrateCount = oldPackages.Count(p => p.CanPureMigratePath());
// Any remaining packages need to be moved
NeedsMoveMigrateCount = oldPackages.Length - AutoMigrateCount;
}
[RelayCommand]
private async Task MigrateAsync()
{
await using var delay = new MinimumDelay(200, 300);
// Since we are going to recreate venvs, need python to be installed
if (!prerequisiteHelper.IsPythonInstalled)
{
MigrateProgressText = "Preparing Environment";
await prerequisiteHelper.InstallPythonIfNecessary();
}
var libraryPath = settingsManager.LibraryDir;
var oldPackages = settingsManager.GetOldInstalledPackages().ToArray();
foreach (var package in oldPackages)
{
MigrateProgressCount++;
#pragma warning disable CS0618
Logger.Info($"Migrating package {MigrateProgressCount} of {oldPackages.Length} at path {package.Path}");
#pragma warning restore CS0618
await package.MigratePath();
// Save after each step in case interrupted
settingsManager.SaveSettings();
// Also recreate the venv
var venvPath = Path.Combine(libraryPath, package.FullPath!);
var venv = new PyVenvRunner(venvPath);
await venv.Setup(existsOk: true);
}
}
}

3
StabilityMatrix/ViewModels/FirstLaunchSetupViewModel.cs

@ -5,9 +5,6 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Controls;
using StabilityMatrix.Helper;
using StabilityMatrix.Models;
using Wpf.Ui.Common;
using Wpf.Ui.Controls.IconElements;
namespace StabilityMatrix.ViewModels;

27
StabilityMatrix/ViewModels/InstallerViewModel.cs

@ -1,7 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
@ -59,9 +58,6 @@ public partial class InstallerViewModel : ObservableObject
[ObservableProperty]
private string installButtonText;
[ObservableProperty]
private string installPath;
[ObservableProperty]
private string installName;
@ -117,8 +113,6 @@ public partial class InstallerViewModel : ObservableObject
SecondaryProgressText = "";
InstallButtonText = "Install";
ProgressValue = 0;
InstallPath =
$"{Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)}\\StabilityMatrix\\Packages";
IsReleaseMode = true;
IsReleaseModeEnabled = true;
@ -159,6 +153,10 @@ public partial class InstallerViewModel : ObservableObject
}
ReleaseNotes = SelectedVersion.ReleaseNotesMarkdown;
ShowDuplicateWarning =
settingsManager.Settings.InstalledPackages.Any(p =>
p.LibraryPath.Equals($"Packages\\{InstallName}"));
}
[RelayCommand]
@ -251,16 +249,11 @@ public partial class InstallerViewModel : ObservableObject
OnSelectedPackageChanged(SelectedPackage);
}
partial void OnInstallNameChanged(string oldValue, string newValue)
{
var path = Path.GetFullPath($"{InstallPath}\\{newValue}");
ShowDuplicateWarning = settingsManager.Settings.InstalledPackages.Any(p => p.Path.Equals(path));
}
partial void OnInstallPathChanged(string oldValue, string newValue)
partial void OnInstallNameChanged(string? oldValue, string newValue)
{
var path = Path.GetFullPath($"{newValue}\\{InstallName}");
ShowDuplicateWarning = settingsManager.Settings.InstalledPackages.Any(p => p.Path.Equals(path));
ShowDuplicateWarning =
settingsManager.Settings.InstalledPackages.Any(p =>
p.LibraryPath.Equals($"Packages\\{newValue}"));
}
partial void OnSelectedVersionChanged(PackageVersion? value)
@ -299,7 +292,7 @@ public partial class InstallerViewModel : ObservableObject
await InstallGitIfNecessary();
SelectedPackage.InstallLocation = $"{InstallPath}\\{InstallName}";
SelectedPackage.InstallLocation = $"{settingsManager.LibraryDir}\\Packages\\{InstallName}";
SelectedPackage.DisplayName = InstallName;
if (!PyRunner.PipInstalled || !PyRunner.VenvInstalled)
@ -335,7 +328,7 @@ public partial class InstallerViewModel : ObservableObject
var package = new InstalledPackage
{
DisplayName = SelectedPackage.DisplayName,
Path = SelectedPackage.InstallLocation,
LibraryPath = $"Packages\\{InstallName}",
Id = Guid.NewGuid(),
PackageName = SelectedPackage.Name,
PackageVersion = version,

17
StabilityMatrix/ViewModels/LaunchViewModel.cs

@ -1,6 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
@ -31,6 +30,7 @@ public partial class LaunchViewModel : ObservableObject
private readonly IPyRunner pyRunner;
private readonly IDialogFactory dialogFactory;
private readonly ISnackbarService snackbarService;
private readonly ISharedFolders sharedFolders;
private BasePackage? runningPackage;
private bool clearingPackages = false;
@ -73,7 +73,8 @@ public partial class LaunchViewModel : ObservableObject
ILogger<LaunchViewModel> logger,
IPyRunner pyRunner,
IDialogFactory dialogFactory,
ISnackbarService snackbarService)
ISnackbarService snackbarService,
ISharedFolders sharedFolders)
{
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
@ -83,6 +84,7 @@ public partial class LaunchViewModel : ObservableObject
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.snackbarService = snackbarService;
this.sharedFolders = sharedFolders;
SetProcessRunning(false);
EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged;
@ -157,14 +159,19 @@ public partial class LaunchViewModel : ObservableObject
await pyRunner.Initialize();
// Get path from package
var packagePath = activeInstall.Path!;
var packagePath = $"{settingsManager.LibraryDir}\\{activeInstall.LibraryPath!}";
basePackage.ConsoleOutput += OnConsoleOutput;
basePackage.Exited += OnExit;
basePackage.StartupComplete += RunningPackageOnStartupComplete;
// Update shared folder links (in case library paths changed)
sharedFolders.UpdateLinksForPackage(basePackage, packagePath);
// Load user launch args from settings and convert to string
var userArgs = settingsManager.GetLaunchArgs(activeInstall.Id);
var userArgsString = string.Join(" ", userArgs.Select(opt => opt.ToArgString()));
// Join with extras, if any
userArgsString = string.Join(" ", userArgsString, basePackage.ExtraLaunchArguments);
await basePackage.RunPackage(packagePath, userArgsString);
@ -195,7 +202,7 @@ public partial class LaunchViewModel : ObservableObject
// Use dynamic parsed args over static
if (package is IArgParsable parsable)
{
var rootPath = activeInstall.Path!;
var rootPath = activeInstall.FullPath!;
var moduleName = parsable.RelativeArgsDefinitionScriptPath;
var parser = new ArgParser(pyRunner, rootPath, moduleName);
definitions = await parser.GetArgsAsync();
@ -281,6 +288,7 @@ public partial class LaunchViewModel : ObservableObject
runningPackage = null;
SetProcessRunning(false);
ConsoleOutput += $"{Environment.NewLine}Stopped process at {DateTimeOffset.Now}{Environment.NewLine}";
ShowWebUiButton = false;
return Task.CompletedTask;
}
@ -340,6 +348,7 @@ public partial class LaunchViewModel : ObservableObject
ConsoleOutput += $"Venv process exited with code {exitCode}";
ScrollNeeded?.Invoke(this, EventArgs.Empty);
SetProcessRunning(false);
ShowWebUiButton = false;
});
}
}

89
StabilityMatrix/ViewModels/MainWindowViewModel.cs

@ -1,5 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
@ -9,9 +9,11 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Options;
using StabilityMatrix.Helper;
using StabilityMatrix.Models;
using StabilityMatrix.Models.Configs;
using StabilityMatrix.Services;
using Wpf.Ui.Appearance;
using Wpf.Ui.Controls.ContentDialogControl;
using Wpf.Ui.Controls.Window;
using EventManager = StabilityMatrix.Helper.EventManager;
@ -22,13 +24,22 @@ public partial class MainWindowViewModel : ObservableObject
private readonly ISettingsManager settingsManager;
private readonly IDialogFactory dialogFactory;
private readonly INotificationBarService notificationBarService;
private readonly UpdateWindowViewModel updateWindowViewModel;
private readonly DebugOptions debugOptions;
public MainWindowViewModel(ISettingsManager settingsManager, IDialogFactory dialogFactory, INotificationBarService notificationBarService, IOptions<DebugOptions> debugOptions)
private UpdateInfo? updateInfo;
public MainWindowViewModel(
ISettingsManager settingsManager,
IDialogFactory dialogFactory,
INotificationBarService notificationBarService,
UpdateWindowViewModel updateWindowViewModel,
IOptions<DebugOptions> debugOptions)
{
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.notificationBarService = notificationBarService;
this.updateWindowViewModel = updateWindowViewModel;
this.debugOptions = debugOptions.Value;
// Listen to dev mode event
@ -47,10 +58,24 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private bool isTextToImagePageEnabled;
[ObservableProperty]
private bool isUpdateAvailable;
public async Task OnLoaded()
{
SetTheme();
EventManager.Instance.GlobalProgressChanged += OnGlobalProgressChanged;
EventManager.Instance.UpdateAvailable += (_, args) =>
{
IsUpdateAvailable = true;
updateInfo = args;
};
// show path selection window if no paths are set
await DoSettingsCheck();
// Insert path extensions
settingsManager.InsertPathExtensions();
if (debugOptions.ShowOneClickInstall || !settingsManager.Settings.InstalledPackages.Any())
{
@ -83,6 +108,66 @@ public partial class MainWindowViewModel : ObservableObject
ProcessRunner.OpenUrl("https://discord.gg/TUrgfECxHz");
}
[RelayCommand]
private void DoUpdate()
{
updateWindowViewModel.UpdateInfo = updateInfo;
var updateWindow = new UpdateWindow(updateWindowViewModel);
updateWindow.ShowDialog();
}
private async Task DoSettingsCheck()
{
// Check if library path is set
if (!settingsManager.TryFindLibrary())
{
// See if this is an existing user for message variation
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var settingsPath = Path.Combine(appDataPath, "StabilityMatrix", "settings.json");
var isExistingUser = File.Exists(settingsPath);
// need to show dialog to choose library location
if (!settingsManager.TryFindLibrary())
{
var dialog = dialogFactory.CreateInstallLocationsDialog();
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
Application.Current.Shutdown();
}
// 1. For portable mode, call settings.SetPortableMode()
var viewModel = (dialog.DataContext as SelectInstallLocationsViewModel)!;
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
}
}
// Try to find library again, should be found now
if (!settingsManager.TryFindLibrary())
{
throw new Exception("Could not find library after setting path");
}
// Check if there are old packages, if so show migration dialog
if (settingsManager.GetOldInstalledPackages().Any())
{
var dialog = dialogFactory.CreateDataDirectoryMigrationDialog();
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
Application.Current.Shutdown();
}
}
}
/// <summary>
/// Updates the taskbar progress bar value and state.
/// </summary>

4
StabilityMatrix/ViewModels/OneClickInstallViewModel.cs

@ -116,7 +116,7 @@ public partial class OneClickInstallViewModel : ObservableObject
// get latest version & download & install
SubHeaderText = "Getting latest version...";
var latestVersion = await a1111.GetLatestVersion();
a1111.InstallLocation += "\\stable-diffusion-webui";
a1111.InstallLocation = $"{settingsManager.LibraryDir}\\Packages\\stable-diffusion-webui";
a1111.ConsoleOutput += (_, output) => SubSubHeaderText = output;
await DownloadPackage(a1111, latestVersion);
@ -128,7 +128,7 @@ public partial class OneClickInstallViewModel : ObservableObject
var package = new InstalledPackage
{
DisplayName = a1111.DisplayName,
Path = a1111.InstallLocation,
LibraryPath = "Packages\\stable-diffusion-webui",
Id = Guid.NewGuid(),
PackageName = a1111.Name,
PackageVersion = latestVersion,

5
StabilityMatrix/ViewModels/PackageManagerViewModel.cs

@ -157,7 +157,7 @@ public partial class PackageManagerViewModel : ObservableObject
[RelayCommand]
private async Task Uninstall()
{
if (SelectedPackage?.Path == null)
if (SelectedPackage?.LibraryPath == null)
{
logger.LogError("No package selected to uninstall");
return;
@ -174,7 +174,8 @@ public partial class PackageManagerViewModel : ObservableObject
{
IsUninstalling = true;
InstallButtonEnabled = false;
var deleteTask = DeleteDirectoryAsync(SelectedPackage.Path);
var deleteTask = DeleteDirectoryAsync(Path.Combine(settingsManager.LibraryDir,
SelectedPackage.LibraryPath));
var taskResult = await snackbarService.TryAsync(deleteTask,
"Some files could not be deleted. Please close any open files in the package directory and try again.");
if (taskResult.IsSuccessful)

4
StabilityMatrix/ViewModels/RefreshBadgeViewModel.cs

@ -18,6 +18,7 @@ public partial class RefreshBadgeViewModel : ObservableObject
public string WorkingToolTipText = "Loading...";
public string SuccessToolTipText = "Success";
public string InactiveToolTipText = "";
public string FailToolTipText = "Failed";
public SymbolIcon InactiveIcon = new(SymbolRegular.Circle12);
@ -25,6 +26,7 @@ public partial class RefreshBadgeViewModel : ObservableObject
public SymbolIcon FailIcon = new(SymbolRegular.ErrorCircle12);
public SolidColorBrush SuccessColorBrush = AppBrushes.SuccessGreen;
public SolidColorBrush InactiveColorBrush = AppBrushes.WarningYellow;
public SolidColorBrush FailColorBrush = AppBrushes.FailedRed;
public Func<Task<bool>>? RefreshFunc { get; set; }
@ -50,6 +52,7 @@ public partial class RefreshBadgeViewModel : ObservableObject
public SolidColorBrush ColorBrush => State switch
{
ProgressState.Success => SuccessColorBrush,
ProgressState.Inactive => InactiveColorBrush,
ProgressState.Failed => FailColorBrush,
_ => Brushes.Gray,
};
@ -58,6 +61,7 @@ public partial class RefreshBadgeViewModel : ObservableObject
{
ProgressState.Working => WorkingToolTipText,
ProgressState.Success => SuccessToolTipText,
ProgressState.Inactive => InactiveToolTipText,
ProgressState.Failed => FailToolTipText,
_ => ""
};

151
StabilityMatrix/ViewModels/SelectInstallLocationsViewModel.cs

@ -0,0 +1,151 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using Ookii.Dialogs.Wpf;
using StabilityMatrix.Helper;
using StabilityMatrix.Models;
using StabilityMatrix.Models.Settings;
namespace StabilityMatrix.ViewModels;
public partial class SelectInstallLocationsViewModel : ObservableObject
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private const string ValidExistingDirectoryText = "Valid existing data directory found";
private const string InvalidDirectoryText =
"Directory must be empty or have a valid settings.json file";
[ObservableProperty] private string dataDirectory;
[ObservableProperty] private bool isPortableMode;
[ObservableProperty] private string directoryStatusText = string.Empty;
[ObservableProperty] private bool isStatusBadgeVisible;
[ObservableProperty] private bool isDirectoryValid;
public RefreshBadgeViewModel RefreshBadgeViewModel { get; } = new()
{
State = ProgressState.Inactive,
SuccessToolTipText = ValidExistingDirectoryText,
FailToolTipText = InvalidDirectoryText
};
public string DefaultInstallLocation => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StabilityMatrix");
public SelectInstallLocationsViewModel()
{
DataDirectory = DefaultInstallLocation;
RefreshBadgeViewModel.RefreshFunc = ValidateDataDirectory;
}
public void OnLoaded()
{
RefreshBadgeViewModel.RefreshCommand.ExecuteAsync(null).SafeFireAndForget();
}
// Revalidate on data directory change
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnDataDirectoryChanged(string value)
{
RefreshBadgeViewModel.RefreshCommand.ExecuteAsync(null).SafeFireAndForget();
}
// Validates current data directory
private async Task<bool> ValidateDataDirectory()
{
await using var delay = new MinimumDelay(100, 200);
// Doesn't exist, this is fine as a new install, hide badge
if (!Directory.Exists(DataDirectory))
{
IsStatusBadgeVisible = false;
IsDirectoryValid = true;
return true;
}
// Otherwise check that a settings.json exists
var settingsPath = Path.Combine(DataDirectory, "settings.json");
// settings.json exists: Try deserializing it
if (File.Exists(settingsPath))
{
try
{
var jsonText = await File.ReadAllTextAsync(settingsPath);
var _ = JsonSerializer.Deserialize<Settings>(jsonText, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
});
// If successful, show existing badge
IsStatusBadgeVisible = true;
IsDirectoryValid = true;
DirectoryStatusText = ValidExistingDirectoryText;
return true;
}
catch (Exception e)
{
Logger.Info("Failed to deserialize settings.json: {Msg}", e.Message);
// If not, show error badge, and set directory to invalid to prevent continuing
IsStatusBadgeVisible = true;
IsDirectoryValid = false;
DirectoryStatusText = InvalidDirectoryText;
return false;
}
}
// No settings.json
// Check if the directory is %APPDATA%\StabilityMatrix: hide badge and set directory valid
if (DataDirectory == DefaultInstallLocation)
{
IsStatusBadgeVisible = false;
IsDirectoryValid = true;
return true;
}
// Check if the directory is empty: hide badge and set directory to valid
var isEmpty = !Directory.EnumerateFileSystemEntries(DataDirectory).Any();
if (isEmpty)
{
IsStatusBadgeVisible = false;
IsDirectoryValid = true;
return true;
}
// Not empty and not appdata: show error badge, and set directory to invalid
IsStatusBadgeVisible = true;
IsDirectoryValid = false;
DirectoryStatusText = InvalidDirectoryText;
return false;
}
[RelayCommand]
private void ShowFolderBrowserDialog()
{
var dialog = new VistaFolderBrowserDialog
{
Description = "Select a folder",
UseDescriptionForTitle = true
};
if (dialog.ShowDialog() != true) return;
var path = dialog.SelectedPath;
if (path == null) return;
DataDirectory = path;
}
partial void OnIsPortableModeChanged(bool value)
{
DataDirectory = value
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data")
: DefaultInstallLocation;
}
}

31
StabilityMatrix/ViewModels/SettingsViewModel.cs

@ -6,7 +6,6 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
@ -37,6 +36,7 @@ public partial class SettingsViewModel : ObservableObject
{
private readonly ILogger<SettingsViewModel> logger;
private readonly ISettingsManager settingsManager;
private readonly IDialogFactory dialogFactory;
private readonly IPackageFactory packageFactory;
private readonly IPyRunner pyRunner;
private readonly ISnackbarService snackbarService;
@ -92,6 +92,7 @@ public partial class SettingsViewModel : ObservableObject
public SettingsViewModel(
ISettingsManager settingsManager,
IContentDialogService contentDialogService,
IDialogFactory dialogFactory,
IA3WebApiManager a3WebApiManager,
IPyRunner pyRunner,
ISnackbarService snackbarService,
@ -104,6 +105,7 @@ public partial class SettingsViewModel : ObservableObject
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.contentDialogService = contentDialogService;
this.dialogFactory = dialogFactory;
this.snackbarService = snackbarService;
this.a3WebApiManager = a3WebApiManager;
this.pyRunner = pyRunner;
@ -123,14 +125,7 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private WindowBackdropType windowBackdropType;
public string AppVersion => $"Version {GetAppVersion()}";
private string GetAppVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version;
return version == null ? "(Unknown)" : $"{version.Major}.{version.Minor}.{version.Build}";
}
public string AppVersion => $"Version {Utilities.GetAppVersion()}";
partial void OnSelectedThemeChanged(string value)
{
@ -273,6 +268,24 @@ public partial class SettingsViewModel : ObservableObject
}
}
[RelayCommand]
private async Task WebViewDemo()
{
var enterUri = await dialogFactory.ShowTextEntryDialog("Enter a URI",
new[] {
("Enter URI", "https://lykos.ai")
});
if (enterUri == null) return;
var uri = new Uri(enterUri.First());
var dialog = dialogFactory.CreateWebLoginDialog();
var loginViewModel = dialog.ViewModel;
loginViewModel.CurrentUri = uri;
var dialogResult = await dialog.ShowAsync();
logger.LogInformation("LoginDialog result: {Result}", dialogResult);
}
[RelayCommand]
private async Task PingWebApi()
{

1
StabilityMatrix/ViewModels/TextToImageViewModel.cs

@ -5,7 +5,6 @@ using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;

79
StabilityMatrix/ViewModels/UpdateWindowViewModel.cs

@ -0,0 +1,79 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Helper;
using StabilityMatrix.Models;
using Wpf.Ui.Controls.Window;
namespace StabilityMatrix.ViewModels;
public partial class UpdateWindowViewModel : ObservableObject
{
private readonly ISettingsManager settingsManager;
private readonly IHttpClientFactory httpClientFactory;
private readonly IUpdateHelper updateHelper;
public UpdateWindowViewModel(ISettingsManager settingsManager,
IHttpClientFactory httpClientFactory, IUpdateHelper updateHelper)
{
this.settingsManager = settingsManager;
this.httpClientFactory = httpClientFactory;
this.updateHelper = updateHelper;
}
[ObservableProperty] private string? releaseNotes;
[ObservableProperty] private string? updateText;
[ObservableProperty] private int progressValue;
[ObservableProperty] private bool showProgressBar;
public UpdateInfo? UpdateInfo { get; set; }
public WindowBackdropType WindowBackdropType => settingsManager.Settings.WindowBackdropType ??
WindowBackdropType.Mica;
public async Task OnLoaded()
{
UpdateText = $"Stability Matrix v{UpdateInfo?.Version} is now available! You currently have v{Utilities.GetAppVersion()}. Would you like to update now?";
var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(UpdateInfo?.ChangelogUrl);
if (response.IsSuccessStatusCode)
{
ReleaseNotes = await response.Content.ReadAsStringAsync();
}
else
{
ReleaseNotes = "## Unable to load release notes";
}
}
[RelayCommand]
private async Task InstallUpdate()
{
if (UpdateInfo == null)
{
return;
}
ShowProgressBar = true;
UpdateText = $"Downloading update v{UpdateInfo.Version}...";
await updateHelper.DownloadUpdate(UpdateInfo, new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
}));
UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds...";
await Task.Delay(1000);
UpdateText = "Update complete. Restarting Stability Matrix in 1 second...";
await Task.Delay(1000);
Process.Start(UpdateHelper.ExecutablePath);
Application.Current.Shutdown();
}
}

79
StabilityMatrix/ViewModels/WebLoginViewModel.cs

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Web.WebView2.Core;
using NLog;
namespace StabilityMatrix.ViewModels;
public record struct NavigationResult(Uri? Uri, List<CoreWebView2Cookie>? Cookies);
public partial class WebLoginViewModel : ObservableObject
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
// Login Url, set externally on dialog creation
[ObservableProperty] private string? loginUrl;
// Bound current url source
[ObservableProperty] private Uri? currentUri;
// Always true after first navigation completed
[ObservableProperty] private bool isContentLoaded;
// Events
public event EventHandler<NavigationResult> NavigationCompleted = delegate { };
public event EventHandler<NavigationResult> SourceChanged = delegate { };
public void OnLoaded()
{
}
/// <summary>
/// Called on navigation source changes.
/// </summary>
public void OnSourceChanged(Uri? source, List<CoreWebView2Cookie>? cookies)
{
Logger.Debug($"WebView source changed to {source} ({cookies?.Count} cookies)");
SourceChanged.Invoke(this, new NavigationResult(source, cookies));
}
/// <summary>
/// Called on navigation completed. (After scrollbar patch)
/// </summary>
public void OnNavigationCompleted(Uri? uri)
{
Logger.Debug($"WebView loaded: {uri}");
NavigationCompleted.Invoke(this, new NavigationResult(uri, null));
IsContentLoaded = true;
}
/// <summary>
/// Waits for navigation to a specific uri
/// </summary>
public async Task WaitForNavigation(Uri uri, CancellationToken ct = default)
{
Logger.Debug($"Waiting for navigation to {uri}");
var navigationTask = new TaskCompletionSource<bool>();
var handler = new EventHandler<NavigationResult>((_, result) =>
{
navigationTask.TrySetResult(true);
});
NavigationCompleted += handler;
try
{
await using (ct.Register(() => navigationTask.TrySetCanceled()))
{
CurrentUri = uri;
await navigationTask.Task;
}
}
finally
{
NavigationCompleted -= handler;
}
}
}

65
StabilityMatrix/WebLoginDialog.xaml

@ -0,0 +1,65 @@
<ui:ContentDialog
CloseButtonText="Close"
DialogMaxHeight="750"
DialogMaxWidth="600"
Loaded="WebLoginDialog_OnLoaded"
Title="Login"
d:DataContext="{d:DesignInstance Type=viewModels:WebLoginViewModel,
IsDesignTimeCreatable=True}"
d:DesignHeight="650"
d:DesignWidth="600"
mc:Ignorable="d"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
x:Class="StabilityMatrix.WebLoginDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:converters="clr-namespace:StabilityMatrix.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:StabilityMatrix"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewModels="clr-namespace:StabilityMatrix.ViewModels"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ui:ContentDialog.Resources>
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<Style BasedOn="{StaticResource {x:Type ui:ContentDialog}}" TargetType="{x:Type local:WebLoginDialog}">
<!-- Disable scrollbars on the dialog -->
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
</Style>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<converters:ValueConverterGroup x:Key="InvertIsStringNullOrWhitespaceConverter">
<converters:IsStringNullOrWhitespaceConverter />
<converters:BoolNegationConverter />
</converters:ValueConverterGroup>
</ui:ContentDialog.Resources>
<Grid Margin="0,0,0,12">
<Border
BorderBrush="Transparent"
BorderThickness="8"
CornerRadius="4"
Padding="8">
<DockPanel
Background="{DynamicResource ApplicationBackgroundBrush}"
MaxHeight="600"
MinHeight="500"
MinWidth="400">
<!-- Content -->
<wv2:WebView2
DefaultBackgroundColor="Transparent"
MinHeight="500"
Name="LoginWebView"
NavigationCompleted="LoginWebView_OnNavigationCompleted"
Source="{Binding CurrentUri, Mode=TwoWay}"
SourceChanged="LoginWebView_OnSourceChanged" />
</DockPanel>
</Border>
</Grid>
</ui:ContentDialog>

53
StabilityMatrix/WebLoginDialog.xaml.cs

@ -0,0 +1,53 @@
using System.Windows;
using Microsoft.Web.WebView2.Core;
using NLog;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Contracts;
using Wpf.Ui.Controls.ContentDialogControl;
namespace StabilityMatrix;
public partial class WebLoginDialog : ContentDialog
{
private const string DisableScrollbarJs =
@"document.querySelector('body').style.overflow='scroll';
var style=document.createElement('style');style.type='text/css';
style.innerHTML='::-webkit-scrollbar{display:none}';
document.getElementsByTagName('body')[0].appendChild(style)";
private readonly Microsoft.Web.WebView2.Wpf.WebView2 currentWebView;
public WebLoginViewModel ViewModel { get; set; }
public WebLoginDialog(IContentDialogService dialogService, WebLoginViewModel viewModel) : base(
dialogService.GetContentPresenter())
{
InitializeComponent();
DataContext = viewModel;
ViewModel = viewModel;
currentWebView = LoginWebView;
}
// Pass through OnLoaded to ViewModel
private void WebLoginDialog_OnLoaded(object sender, RoutedEventArgs e) => ViewModel.OnLoaded();
// On nav complete we run js to hide scrollbar while allowing scrolling
private async void LoginWebView_OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
if (e.IsSuccess)
{
await currentWebView.ExecuteScriptAsync(DisableScrollbarJs);
}
// Pass to ViewModel event
ViewModel.OnNavigationCompleted(currentWebView.Source);
}
// This happens before OnNavigationCompleted
private async void LoginWebView_OnSourceChanged(object? sender, CoreWebView2SourceChangedEventArgs e)
{
var url = currentWebView.Source;
var cookies = url is null ? null :
await currentWebView.CoreWebView2.CookieManager.GetCookiesAsync(url.AbsoluteUri);
ViewModel.OnSourceChanged(currentWebView.Source, cookies);
}
}

2
StabilityMatrix/appsettings.Development.json

@ -1,7 +1,7 @@
{
"GithubApiKey": "",
"TempDatabase": false,
"DebugOptions": {
"TempDatabase": false,
"ShowOneClickInstall": false
}
}

Loading…
Cancel
Save