Browse Source

Merge pull request #193 from ionite34/revamped-packages-tab

New package manager UI
pull/109/head
JT 1 year ago committed by GitHub
parent
commit
166ace4874
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 11
      StabilityMatrix.Avalonia/App.axaml.cs
  3. 63
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  4. 1
      StabilityMatrix.Avalonia/Program.cs
  5. 1
      StabilityMatrix.Avalonia/Services/ServiceManager.cs
  6. 29
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserCardViewModel.cs
  7. 11
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
  8. 2
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  9. 4
      StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs
  10. 289
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  11. 313
      StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
  12. 2
      StabilityMatrix.Avalonia/ViewModels/ProgressViewModel.cs
  13. 264
      StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml
  14. 1
      StabilityMatrix.Core/Database/LiteDbContext.cs
  15. 2
      StabilityMatrix.Core/Models/InstalledPackage.cs
  16. 5
      StabilityMatrix.Core/Services/SettingsManager.cs

4
CHANGELOG.md

@ -5,6 +5,10 @@ All notable changes to Stability Matrix will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).
## v2.2.0
### Changed
- Revamped Package Manager UI
## v2.1.2
### Changed

11
StabilityMatrix.Avalonia/App.axaml.cs

@ -38,6 +38,7 @@ using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Api;
@ -200,7 +201,6 @@ public sealed class App : Application
services.AddSingleton<PackageManagerViewModel>()
.AddSingleton<SettingsViewModel>()
.AddSingleton<CheckpointBrowserViewModel>()
.AddSingleton<CheckpointBrowserCardViewModel>()
.AddSingleton<CheckpointsPageViewModel>()
.AddSingleton<LaunchPageViewModel>()
.AddSingleton<ProgressManagerViewModel>();
@ -242,8 +242,11 @@ public sealed class App : Application
services.AddSingleton<UpdateViewModel>();
// Other transients (usually sub view models)
services.AddTransient<CheckpointFolder>();
services.AddTransient<CheckpointFile>();
services.AddTransient<CheckpointFolder>()
.AddTransient<CheckpointFile>()
.AddTransient<CheckpointBrowserCardViewModel>();
services.AddTransient<PackageCardViewModel>();
// Global progress
services.AddSingleton<ProgressManagerViewModel>();
@ -260,8 +263,10 @@ public sealed class App : Application
.Register(provider.GetRequiredService<SelectDataDirectoryViewModel>)
.Register(provider.GetRequiredService<LaunchOptionsViewModel>)
.Register(provider.GetRequiredService<UpdateViewModel>)
.Register(provider.GetRequiredService<CheckpointBrowserCardViewModel>)
.Register(provider.GetRequiredService<CheckpointFolder>)
.Register(provider.GetRequiredService<CheckpointFile>)
.Register(provider.GetRequiredService<PackageCardViewModel>)
.Register(provider.GetRequiredService<RefreshBadgeViewModel>)
.Register(provider.GetRequiredService<ExceptionViewModel>)
.Register(provider.GetRequiredService<EnvVarsViewModel>)

63
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -4,12 +4,14 @@ using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Helper;
@ -35,7 +37,8 @@ public static class DesignData
// or else Avalonia analyzers won't work.
public static void Initialize()
{
if (isInitialized) throw new InvalidOperationException("DesignData is already initialized.");
if (isInitialized)
throw new InvalidOperationException("DesignData is already initialized.");
var services = new ServiceCollection();
@ -50,6 +53,7 @@ public static class DesignData
{
Id = activePackageId,
DisplayName = "My Installed Package",
DisplayVersion = "v1.0.0",
PackageName = "stable-diffusion-webui",
PackageVersion = "v1.0.0",
LibraryPath = $"Packages{Path.DirectorySeparatorChar}example-webui",
@ -59,8 +63,8 @@ public static class DesignData
{
Id = Guid.NewGuid(),
DisplayName = "Dank Diffusion",
PackageName = "dank-diffusion",
PackageVersion = "v2.0.0",
PackageName = "ComfyUI",
DisplayVersion = "main@ab73d4a",
LibraryPath = $"Packages{Path.DirectorySeparatorChar}example-webui",
LastUpdateCheck = DateTimeOffset.Now
}
@ -120,13 +124,13 @@ public static class DesignData
Type = LaunchOptionType.String,
Description = "The host name for the Web UI",
DefaultValue = "localhost",
Options = { "--host" }
Options = {"--host"}
}),
LaunchOptionCard.FromDefinition(new LaunchOptionDefinition
{
Name = "API",
Type = LaunchOptionType.Bool,
Options = { "--api" }
Options = {"--api"}
})
};
LaunchOptionsViewModel.UpdateFilterCards();
@ -204,24 +208,29 @@ public static class DesignData
CheckpointBrowserViewModel.ModelCards = new
ObservableCollection<CheckpointBrowserCardViewModel>
{
new(new CivitModel
dialogFactory.Get<CheckpointBrowserCardViewModel>(vm =>
{
vm.CivitModel = new CivitModel
{
Name = "BB95 Furry Mix",
Description = "A furry mix of BB95",
}, downloadService, settingsManager,
dialogFactory, notificationService)
};
})
};
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel>
{
new(new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressReport(0.5f, "Downloading..."))),
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu", new ProgressReport(0.25f, "Extracting...")))
new(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressReport(0.5f, "Downloading..."))),
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu",
new ProgressReport(0.25f, "Extracting...")))
};
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.UpdateText =
$"Stability Matrix v2.0.1 is now available! You currently have v2.0.0. Would you like to update now?";
UpdateViewModel.ReleaseNotes = "## v2.0.1\n- Fixed a bug\n- Added a feature\n- Removed a feature";
UpdateViewModel.ReleaseNotes =
"## v2.0.1\n- Fixed a bug\n- Added a feature\n- Removed a feature";
isInitialized = true;
}
@ -232,20 +241,39 @@ public static class DesignData
public static ServiceManager<ViewModelBase> DialogFactory =>
Services.GetRequiredService<ServiceManager<ViewModelBase>>();
public static MainWindowViewModel MainWindowViewModel =>
Services.GetRequiredService<MainWindowViewModel>();
public static FirstLaunchSetupViewModel FirstLaunchSetupViewModel =>
Services.GetRequiredService<FirstLaunchSetupViewModel>();
public static LaunchPageViewModel LaunchPageViewModel =>
Services.GetRequiredService<LaunchPageViewModel>();
public static PackageManagerViewModel PackageManagerViewModel =>
Services.GetRequiredService<PackageManagerViewModel>();
public static PackageManagerViewModel PackageManagerViewModel
{
get
{
var settings = Services.GetRequiredService<ISettingsManager>();
var vm = Services.GetRequiredService<PackageManagerViewModel>();
vm.Packages = new ObservableCollection<PackageCardViewModel>(
settings.Settings.InstalledPackages.Select(p =>
DialogFactory.Get<PackageCardViewModel>(viewModel => viewModel.Package = p)));
vm.Packages.First().IsUpdateAvailable = true;
return vm;
}
}
public static CheckpointsPageViewModel CheckpointsPageViewModel =>
Services.GetRequiredService<CheckpointsPageViewModel>();
public static SettingsViewModel SettingsViewModel =>
Services.GetRequiredService<SettingsViewModel>();
public static CheckpointBrowserViewModel CheckpointBrowserViewModel =>
Services.GetRequiredService<CheckpointBrowserViewModel>();
public static SelectModelVersionViewModel SelectModelVersionViewModel =>
DialogFactory.Get<SelectModelVersionViewModel>(vm =>
{
@ -291,15 +319,19 @@ public static class DesignData
new ModelVersionViewModel(new HashSet<string> {"ABCD"}, sampleCivitVersions[0]);
// Sample data for dialogs
vm.Versions = new List<ModelVersionViewModel>{sampleViewModel};
vm.Versions = new List<ModelVersionViewModel> {sampleViewModel};
vm.SelectedVersionViewModel = sampleViewModel;
});
public static OneClickInstallViewModel OneClickInstallViewModel =>
Services.GetRequiredService<OneClickInstallViewModel>();
public static SelectDataDirectoryViewModel SelectDataDirectoryViewModel =>
Services.GetRequiredService<SelectDataDirectoryViewModel>();
public static ProgressManagerViewModel ProgressManagerViewModel =>
Services.GetRequiredService<ProgressManagerViewModel>();
public static ExceptionViewModel ExceptionViewModel =>
DialogFactory.Get<ExceptionViewModel>(viewModel =>
{
@ -321,7 +353,8 @@ public static class DesignData
}
});
public static EnvVarsViewModel EnvVarsViewModel => DialogFactory.Get<EnvVarsViewModel>(viewModel =>
public static EnvVarsViewModel EnvVarsViewModel => DialogFactory.Get<EnvVarsViewModel>(
viewModel =>
{
viewModel.EnvVars = new ObservableCollection<EnvVarKeyPair>
{

1
StabilityMatrix.Avalonia/Program.cs

@ -12,7 +12,6 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using NLog;
using Polly.Contrib.WaitAndRetry;
using Projektanker.Icons.Avalonia;

1
StabilityMatrix.Avalonia/Services/ServiceManager.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace StabilityMatrix.Avalonia.Services;

29
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserCardViewModel.cs

@ -38,33 +38,38 @@ public partial class CheckpointBrowserCardViewModel : ProgressViewModel
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService;
private readonly Action<CheckpointBrowserCardViewModel>? onDownloadStart;
public CivitModel CivitModel { get; init; }
public Action<CheckpointBrowserCardViewModel>? OnDownloadStart { get; set; }
public CivitModel CivitModel
{
get => civitModel;
set
{
civitModel = value;
UpdateImage();
CheckIfInstalled();
}
}
public override bool IsTextVisible => Value > 0;
[ObservableProperty] private Uri? cardImage;
[ObservableProperty] private bool isImporting;
[ObservableProperty] private string updateCardText = string.Empty;
[ObservableProperty] private bool showUpdateCard;
private CivitModel civitModel;
public CheckpointBrowserCardViewModel(
CivitModel civitModel,
IDownloadService downloadService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService,
Action<CheckpointBrowserCardViewModel>? onDownloadStart = null)
INotificationService notificationService)
{
this.downloadService = downloadService;
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.notificationService = notificationService;
this.onDownloadStart = onDownloadStart;
CivitModel = civitModel;
UpdateImage();
CheckIfInstalled();
// Update image when nsfw setting changes
settingsManager.RegisterPropertyChangedHandler(
@ -197,7 +202,7 @@ public partial class CheckpointBrowserCardViewModel : ProgressViewModel
IsImporting = true;
Text = "Downloading...";
onDownloadStart?.Invoke(this);
OnDownloadStart?.Invoke(this);
// Holds files to be deleted on errors
var filesForCleanup = new HashSet<FilePath>();

11
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs

@ -228,13 +228,18 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase
return cachedViewModel;
}
var newCard = new CheckpointBrowserCardViewModel(model,
downloadService, settingsManager, dialogFactory, notificationService,
viewModel =>
var newCard = dialogFactory.Get<CheckpointBrowserCardViewModel>(vm =>
{
vm.CivitModel = model;
vm.OnDownloadStart = viewModel =>
{
if (cache.Get(viewModel.CivitModel.Id) != null) return;
cache.Add(viewModel.CivitModel.Id, viewModel);
};
return vm;
});
return newCard;
}).ToList();

2
StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs

@ -6,7 +6,6 @@ using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Python.Runtime;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
@ -14,7 +13,6 @@ using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
using Dispatcher = Avalonia.Threading.Dispatcher;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;

4
StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs

@ -136,9 +136,7 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable, IAsyn
return;
}
OnLoaded();
if (SelectedPackage is null) return;
SelectedPackage = InstalledPackages.FirstOrDefault(x => x.Id == e);
LaunchAsync().SafeFireAndForget();
}

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

@ -0,0 +1,289 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using NLog;
using Polly;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.PackageManager;
public partial class PackageCardViewModel : ProgressViewModel
{
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly Logger logger = LogManager.GetCurrentClassLogger();
[ObservableProperty] private InstalledPackage? package;
[ObservableProperty] private Uri cardImage;
[ObservableProperty] private bool isUpdateAvailable;
[ObservableProperty] private string installedVersion;
public PackageCardViewModel(IPackageFactory packageFactory,
INotificationService notificationService, ISettingsManager settingsManager)
{
this.packageFactory = packageFactory;
this.notificationService = notificationService;
this.settingsManager = settingsManager;
}
partial void OnPackageChanged(InstalledPackage? value)
{
if (string.IsNullOrWhiteSpace(value?.PackageName))
return;
var basePackage = packageFactory[value.PackageName];
CardImage = basePackage?.PreviewImageUri ?? Assets.NoImage;
InstalledVersion = value.DisplayVersion ?? "Unknown";
}
public override async Task OnLoadedAsync()
{
IsUpdateAvailable = await HasUpdate();
}
public void Launch()
{
if (Package == null)
return;
settingsManager.Transaction(s => s.ActiveInstalledPackageId = Package.Id);
EventManager.Instance.RequestPageChange(typeof(LaunchPageViewModel));
EventManager.Instance.OnPackageLaunchRequested(Package.Id);
}
public async Task Uninstall()
{
if (Package?.LibraryPath == null)
{
return;
}
var dialog = new ContentDialog
{
Title = "Are you sure?",
Content = "This will delete all folders in the package directory, including any generated images in that directory as well as any files you may have added.",
PrimaryButtonText = "Yes, delete it",
CloseButtonText = "No, keep it",
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
Text = "Uninstalling...";
IsIndeterminate = true;
Value = -1;
var deleteTask = DeleteDirectoryAsync(Path.Combine(settingsManager.LibraryDir,
Package.LibraryPath));
var taskResult = await notificationService.TryAsync(deleteTask,
"Some files could not be deleted. Please close any open files in the package directory and try again.");
if (taskResult.IsSuccessful)
{
notificationService.Show(new Notification("Success",
$"Package {Package.DisplayName} uninstalled",
NotificationType.Success));
settingsManager.Transaction(settings =>
{
settings.RemoveInstalledPackageAndUpdateActive(Package);
});
EventManager.Instance.OnInstalledPackagesChanged();
}
}
}
public async Task Update()
{
if (Package == null) return;
var basePackage = packageFactory[Package.PackageName!];
if (basePackage == null)
{
logger.Error("Could not find package {SelectedPackagePackageName}",
Package.PackageName);
return;
}
Text = $"Updating {Package.DisplayName}";
basePackage.InstallLocation = Package.FullPath!;
var progress = new Progress<ProgressReport>(progress =>
{
var percent = Convert.ToInt32(progress.Percentage);
Value = percent;
IsIndeterminate = progress.IsIndeterminate;
Text = $"Updating {Package.DisplayName}";
EventManager.Instance.OnGlobalProgressChanged(percent);
});
var updateResult = await basePackage.Update(Package, progress);
if (string.IsNullOrWhiteSpace(updateResult))
{
var errorMsg =
$"There was an error updating {Package.DisplayName}. Please try again later.";
if (Package.PackageName == "automatic")
{
errorMsg = errorMsg.Replace("Please try again later.",
"Please stash any changes before updating, or manually update the package.");
}
// there was an error
notificationService.Show(new Notification("Error updating package",
errorMsg, NotificationType.Error));
}
settingsManager.UpdatePackageVersionNumber(Package.Id, updateResult);
notificationService.Show("Update complete",
$"{Package.DisplayName} has been updated to the latest version.",
NotificationType.Success);
Dispatcher.UIThread.Invoke(() =>
{
using (settingsManager.BeginTransaction())
{
Package.UpdateAvailable = false;
}
IsUpdateAvailable = false;
IsIndeterminate = false;
InstalledVersion = settingsManager.Settings.InstalledPackages
.First(x => x.Id == Package.Id).DisplayVersion ?? "Unknown";
Value = 0;
});
}
public async Task OpenFolder()
{
if (string.IsNullOrWhiteSpace(Package?.FullPath))
return;
await ProcessRunner.OpenFolderBrowser(Package.FullPath);
}
private async Task<bool> HasUpdate()
{
if (Package == null)
return false;
var basePackage = packageFactory[Package.PackageName!];
if (basePackage == null)
return false;
var canCheckUpdate = Package.LastUpdateCheck == null ||
Package.LastUpdateCheck < DateTime.Now.AddMinutes(-15);
if (!canCheckUpdate)
{
return Package.UpdateAvailable;
}
try
{
var hasUpdate = await basePackage.CheckForUpdates(Package);
Package.UpdateAvailable = hasUpdate;
Package.LastUpdateCheck = DateTimeOffset.Now;
settingsManager.SetLastUpdateCheck(Package);
return hasUpdate;
}
catch (Exception e)
{
logger.Error(e, $"Error checking {Package.PackageName} for updates");
return false;
}
}
/// <summary>
/// Deletes a directory and all of its contents recursively.
/// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff.
/// </summary>
/// <param name="targetDirectory"></param>
private Task DeleteDirectoryAsync(string targetDirectory)
{
var policy = Policy.Handle<IOException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)),
onRetry: (exception, calculatedWaitDuration) =>
{
logger.Warn(
exception,
"Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}",
targetDirectory, calculatedWaitDuration);
});
return policy.ExecuteAsync(async () =>
{
await Task.Run(() =>
{
DeleteDirectory(targetDirectory);
});
});
}
private void DeleteDirectory(string targetDirectory)
{
// Skip if directory does not exist
if (!Directory.Exists(targetDirectory))
{
return;
}
// For junction points, delete with recursive false
if (new DirectoryInfo(targetDirectory).LinkTarget != null)
{
logger.Info("Removing junction point {TargetDirectory}", targetDirectory);
try
{
Directory.Delete(targetDirectory, false);
return;
}
catch (IOException ex)
{
throw new IOException($"Failed to delete junction point {targetDirectory}", ex);
}
}
// Recursively delete all subdirectories
var subdirectoryEntries = Directory.GetDirectories(targetDirectory);
foreach (var subdirectoryPath in subdirectoryEntries)
{
DeleteDirectory(subdirectoryPath);
}
// Delete all files in the directory
var fileEntries = Directory.GetFiles(targetDirectory);
foreach (var filePath in fileEntries)
{
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
File.Delete(filePath);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete file {filePath}", ex);
}
}
// Delete the target directory itself
try
{
Directory.Delete(targetDirectory, false);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete directory {targetDirectory}", ex);
}
}
}

313
StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs

@ -1,29 +1,21 @@
using System;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using Polly;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.PackageManager;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -37,53 +29,21 @@ namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(PackageManagerPage))]
public partial class PackageManagerViewModel : PageViewModelBase
{
private readonly ILogger<PackageManagerViewModel> logger;
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private const int MinutesToWaitForUpdateCheck = 60;
public PackageManagerViewModel(ILogger<PackageManagerViewModel> logger,
ISettingsManager settingsManager, IPackageFactory packageFactory,
INotificationService notificationService, ServiceManager<ViewModelBase> dialogFactory)
public PackageManagerViewModel(ISettingsManager settingsManager, IPackageFactory packageFactory,
ServiceManager<ViewModelBase> dialogFactory)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.notificationService = notificationService;
this.dialogFactory = dialogFactory;
Packages =
new ObservableCollection<InstalledPackage>(settingsManager.Settings.InstalledPackages);
SelectedPackage = Packages.FirstOrDefault();
EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged;
}
[ObservableProperty]
private InstalledPackage? selectedPackage;
[ObservableProperty, NotifyPropertyChangedFor(nameof(ProgressBarVisibility))]
private int progressValue;
[ObservableProperty]
private string progressText = string.Empty;
[ObservableProperty, NotifyPropertyChangedFor(nameof(ProgressBarVisibility))]
private bool isIndeterminate;
[ObservableProperty]
private bool isUninstalling;
[ObservableProperty, NotifyPropertyChangedFor(nameof(SelectedPackage))]
private bool updateAvailable;
[ObservableProperty] private string installButtonText;
[ObservableProperty] private bool installButtonEnabled;
public bool ProgressBarVisibility => ProgressValue > 0 || IsIndeterminate;
public ObservableCollection<InstalledPackage> Packages { get; }
[ObservableProperty] private ObservableCollection<PackageCardViewModel> packages;
public override bool CanNavigateNext { get; protected set; } = true;
public override bool CanNavigatePrevious { get; protected set; }
@ -92,258 +52,23 @@ public partial class PackageManagerViewModel : PageViewModelBase
public override async Task OnLoadedAsync()
{
if (!Design.IsDesignMode)
{
await CheckUpdates();
}
if (Design.IsDesignMode) return;
var installedPackages = settingsManager.Settings.InstalledPackages;
SelectedPackage = installedPackages.FirstOrDefault(x =>
x.Id == settingsManager.Settings.ActiveInstalledPackageId);
SelectedPackage ??= installedPackages.FirstOrDefault();
InstallButtonEnabled = SelectedPackage != null;
}
private async Task CheckUpdates()
{
var installedPackages = settingsManager.Settings.InstalledPackages;
Packages.Clear();
foreach (var packageToUpdate in installedPackages)
{
var basePackage = packageFactory.FindPackageByName(packageToUpdate.PackageName);
if (basePackage == null) continue;
var canCheckUpdate = packageToUpdate.LastUpdateCheck == null ||
packageToUpdate.LastUpdateCheck.Value.AddMinutes(MinutesToWaitForUpdateCheck) <
DateTimeOffset.Now;
if (canCheckUpdate)
{
var hasUpdate = await basePackage.CheckForUpdates(packageToUpdate);
packageToUpdate.UpdateAvailable = hasUpdate;
packageToUpdate.LastUpdateCheck = DateTimeOffset.Now;
settingsManager.SetLastUpdateCheck(packageToUpdate);
}
Packages.Add(packageToUpdate);
}
}
public async Task Launch()
{
if (SelectedPackage == null) return;
if (InstallButtonText == "Launch")
{
settingsManager.Transaction(s => s.ActiveInstalledPackageId = SelectedPackage.Id);
EventManager.Instance.RequestPageChange(typeof(LaunchPageViewModel));
EventManager.Instance.OnPackageLaunchRequested(SelectedPackage.Id);
}
else
Packages = new ObservableCollection<PackageCardViewModel>(installedPackages.Select(
package => dialogFactory.Get<PackageCardViewModel>(vm =>
{
await UpdateSelectedPackage();
}
}
vm.Package = package;
return vm;
})));
public async Task Uninstall()
foreach (var package in Packages)
{
// In design mode, just remove the package from the list
if (Design.IsDesignMode)
{
if (SelectedPackage != null)
{
Packages.Remove(SelectedPackage);
}
return;
await package.OnLoadedAsync();
}
if (SelectedPackage?.LibraryPath == null)
{
logger.LogError("No package selected to uninstall");
return;
}
var dialog = new ContentDialog
{
Title = "Are you sure?",
Content = "This will delete all folders in the package directory, including any generated images in that directory as well as any files you may have added.",
PrimaryButtonText = "Yes, delete it",
CloseButtonText = "No, keep it",
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
IsUninstalling = true;
InstallButtonEnabled = false;
var deleteTask = DeleteDirectoryAsync(Path.Combine(settingsManager.LibraryDir,
SelectedPackage.LibraryPath));
var taskResult = await notificationService.TryAsync(deleteTask,
"Some files could not be deleted. Please close any open files in the package directory and try again.");
if (taskResult.IsSuccessful)
{
notificationService.Show(new Notification("Success",
$"Package {SelectedPackage.DisplayName} uninstalled",
NotificationType.Success));
settingsManager.Transaction(settings =>
{
settings.RemoveInstalledPackageAndUpdateActive(SelectedPackage);
});
}
IsUninstalling = false;
InstallButtonEnabled = true;
await OnLoadedAsync();
}
}
partial void OnSelectedPackageChanged(InstalledPackage? value)
{
if (value is null) return;
UpdateAvailable = value.UpdateAvailable;
InstallButtonText = value.UpdateAvailable ? "Update" : "Launch";
}
/// <summary>
/// Deletes a directory and all of its contents recursively.
/// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff.
/// </summary>
/// <param name="targetDirectory"></param>
private Task DeleteDirectoryAsync(string targetDirectory)
{
var policy = Policy.Handle<IOException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)),
onRetry: (exception, calculatedWaitDuration) =>
{
logger.LogWarning(
exception,
"Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}",
targetDirectory, calculatedWaitDuration);
});
return policy.ExecuteAsync(async () =>
{
await Task.Run(() =>
{
DeleteDirectory(targetDirectory);
});
});
}
private void DeleteDirectory(string targetDirectory)
{
// Skip if directory does not exist
if (!Directory.Exists(targetDirectory))
{
return;
}
// For junction points, delete with recursive false
if (new DirectoryInfo(targetDirectory).LinkTarget != null)
{
logger.LogInformation("Removing junction point {TargetDirectory}", targetDirectory);
try
{
Directory.Delete(targetDirectory, false);
return;
}
catch (IOException ex)
{
throw new IOException($"Failed to delete junction point {targetDirectory}", ex);
}
}
// Recursively delete all subdirectories
var subdirectoryEntries = Directory.GetDirectories(targetDirectory);
foreach (var subdirectoryPath in subdirectoryEntries)
{
DeleteDirectory(subdirectoryPath);
}
// Delete all files in the directory
var fileEntries = Directory.GetFiles(targetDirectory);
foreach (var filePath in fileEntries)
{
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
File.Delete(filePath);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete file {filePath}", ex);
}
}
// Delete the target directory itself
try
{
Directory.Delete(targetDirectory, false);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete directory {targetDirectory}", ex);
}
}
private async Task UpdateSelectedPackage()
{
if (SelectedPackage == null) return;
var installedPackage = SelectedPackage;
var package = packageFactory.FindPackageByName(installedPackage.PackageName);
if (package == null)
{
logger.LogError("Could not find package {SelectedPackagePackageName}",
installedPackage.PackageName);
return;
}
ProgressText = $"Updating {installedPackage.DisplayName} to latest version...";
package.InstallLocation = installedPackage.FullPath!;
var progress = new Progress<ProgressReport>(progress =>
{
var percent = Convert.ToInt32(progress.Percentage);
ProgressValue = percent;
IsIndeterminate = progress.IsIndeterminate;
ProgressText = $"Updating {SelectedPackage.DisplayName} to latest version... {percent:N0}%";
EventManager.Instance.OnGlobalProgressChanged(percent);
});
var updateResult = await package.Update(installedPackage, progress);
if (string.IsNullOrWhiteSpace(updateResult))
{
var errorMsg =
$"There was an error updating {installedPackage.DisplayName}. Please try again later.";
if (installedPackage.PackageName == "automatic")
{
errorMsg = errorMsg.Replace("Please try again later.",
"Please stash any changes before updating, or manually update the package.");
}
// there was an error
notificationService.Show(new Notification("Error updating package",
errorMsg, NotificationType.Error));
}
settingsManager.UpdatePackageVersionNumber(installedPackage.Id, updateResult);
notificationService.Show("Update complete",
$"{installedPackage.DisplayName} has been updated to the latest version.",
NotificationType.Success);
installedPackage.UpdateAvailable = false;
UpdateAvailable = false;
InstallButtonText = "Launch";
await OnLoadedAsync();
await DelayedClearProgress(TimeSpan.FromSeconds(3));
}
[RelayCommand]
private async Task ShowInstallDialog()
public async Task ShowInstallDialog()
{
var viewModel = dialogFactory.Get<InstallerViewModel>();
viewModel.AvailablePackages = packageFactory.GetAllAvailablePackages().ToImmutableArray();
@ -367,12 +92,6 @@ public partial class PackageManagerViewModel : PageViewModelBase
await OnLoadedAsync();
}
private async Task DelayedClearProgress(TimeSpan delay)
{
await Task.Delay(delay);
ProgressText = string.Empty;
ProgressValue = 0;
IsIndeterminate = false;
}
private void OnInstalledPackagesChanged(object? sender, EventArgs e) =>
OnLoadedAsync().SafeFireAndForget();
}

2
StabilityMatrix.Avalonia/ViewModels/ProgressViewModel.cs

@ -5,7 +5,7 @@ namespace StabilityMatrix.Avalonia.ViewModels;
/// <summary>
/// Generic view model for progress reporting.
/// </summary>
public partial class ProgressViewModel : ObservableObject
public partial class ProgressViewModel : ViewModelBase
{
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsTextVisible))]
private string? text;

264
StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml

@ -7,128 +7,190 @@
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:packageManager="clr-namespace:StabilityMatrix.Avalonia.ViewModels.PackageManager"
xmlns:faicon="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="viewModels:PackageManagerViewModel"
x:CompileBindings="True"
d:DataContext="{x:Static designData:DesignData.PackageManagerViewModel}"
x:Class="StabilityMatrix.Avalonia.Views.PackageManagerPage">
<Grid Margin="16" RowDefinitions="Auto,*">
<StackPanel
Grid.Row="0"
Orientation="Vertical"
IsVisible="{Binding ProgressBarVisibility, FallbackValue=True}">
<Grid Margin="16" RowDefinitions="Auto,*,Auto">
<!-- Title -->
<TextBlock Grid.Row="0" Text="{Binding Title}"
Margin="0,0,0,16"
FontSize="24"/>
<ProgressBar
IsIndeterminate="{Binding IsIndeterminate, FallbackValue=False}"
Maximum="100"
Value="{Binding ProgressValue, FallbackValue=10}"
Width="500" />
<TextBlock
HorizontalAlignment="Center"
Padding="8"
Text="{Binding ProgressText, FallbackValue=Installing...}" />
</StackPanel>
<!-- Cards -->
<ScrollViewer Grid.Row="1">
<ItemsRepeater
ItemsSource="{Binding Packages}">
<ItemsRepeater.Layout>
<UniformGridLayout MinColumnSpacing="12" MinRowSpacing="12" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type packageManager:PackageCardViewModel}">
<controls:Card Padding="8">
<Grid RowDefinitions="Auto, Auto, Auto, Auto"
ColumnDefinitions="Auto">
<StackPanel
Grid.Row="1"
HorizontalAlignment="Left"
Margin="16"
Orientation="Horizontal">
<StackPanel Orientation="Vertical">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Package.DisplayName}"
TextAlignment="Left" />
<Button
Command="{Binding ShowInstallDialogCommand}"
Height="40"
HorizontalAlignment="Stretch"
Margin="0,0,0,16"
Classes="success"
VerticalContentAlignment="Center">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="Add" />
<Label
Content="Add Package"
Margin="8,0,0,0"
VerticalContentAlignment="Center" />
</StackPanel>
Grid.Row="0"
Grid.RowSpan="2"
HorizontalContentAlignment="Right"
HorizontalAlignment="Right"
Margin="8,-8,0,4"
Classes="transparent"
Width="24"
BorderThickness="0">
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" />
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft">
<MenuItem Header="Check for Update"
Command="{Binding OnLoadedAsync}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Refresh" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Open in Explorer"
Command="{Binding OpenFolder}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="OpenFolder" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="Uninstall"
Command="{Binding Uninstall}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Delete" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
<ListBox
ItemsSource="{Binding Packages}"
SelectedItem="{Binding SelectedPackage, Mode=TwoWay}"
IsVisible="{Binding !!Packages.Count}">
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="2,0,0,0"
VerticalAlignment="Center"
Text="{Binding InstalledVersion}" />
<ListBox.Template>
<ControlTemplate>
<Border
BorderBrush="#FFCCCCCC"
BorderThickness="1"
CornerRadius="5">
<ItemsPresenter />
</Border>
</ControlTemplate>
</ListBox.Template>
<controls:BetterAdvancedImage
Grid.Row="2"
Grid.Column="0"
Margin="0,8,0,8"
Height="160"
Width="200"
CornerRadius="4"
HorizontalAlignment="Center"
VerticalContentAlignment="Top"
HorizontalContentAlignment="Center"
Source="{Binding CardImage}"
Stretch="UniformToFill" />
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:InstalledPackage}">
<StackPanel Margin="8" VerticalAlignment="Top">
<TextBlock Margin="0,5,0,5" Text="{Binding DisplayName}" />
<TextBlock Margin="0,0,0,5" Text="{Binding DisplayVersion}" />
<Grid Grid.Row="3" Grid.Column="0"
IsVisible="{Binding IsUpdateAvailable}"
ColumnDefinitions="*, *">
<Button Grid.Column="0" Classes="accent"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Command="{Binding Launch}">
<StackPanel Orientation="Horizontal" Margin="0,2,0,2">
<faicon:Icon Value="fa-solid fa-rocket"
Margin="0,0,8,0"
FontSize="14" />
<TextBlock Text="Launch" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Button>
<Button Grid.Column="1" Classes="accent"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Command="{Binding Update}">
<StackPanel Orientation="Horizontal" Margin="0,2,0,2">
<faicon:Icon Value="fa-solid fa-download"
Margin="0,0,8,0"
FontSize="14" />
<TextBlock Text="Update" />
</StackPanel>
</Button>
</Grid>
<StackPanel Margin="32,0,0,0" Orientation="Vertical">
<TextBlock
FontSize="24"
FontWeight="Bold"
Text="{Binding SelectedPackage.DisplayName,
FallbackValue='Click &quot;Add Package&quot; to install a package'}" />
<TextBlock FontSize="12" Margin="0,5,0,5">
<Run Text="{Binding SelectedPackage.PackageName, FallbackValue=''}" />
<Run Text="{Binding SelectedPackage.DisplayVersion, FallbackValue=''}" />
</TextBlock>
<TextBlock
FontSize="12"
Margin="0,5,0,5"
Text="{Binding SelectedPackage.FullPath, FallbackValue=''}" />
<UniformGrid
Margin="0,16,0,0"
Columns="2"
MinHeight="48"
MinWidth="208"
HorizontalAlignment="Left">
<Button
<!-- Big launch button -->
<Button Grid.Row="3" Grid.Column="0" Classes="accent"
VerticalAlignment="Bottom"
IsVisible="{Binding !IsUpdateAvailable}"
Command="{Binding Launch}"
Content="{Binding InstallButtonText}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Margin="0,2,0,2">
<faicon:Icon Value="fa-solid fa-rocket"
Margin="0,0,8,0"
IsEnabled="{Binding InstallButtonEnabled}"
IsVisible="{Binding InstallButtonEnabled}"
Classes="success"/>
<Button
Command="{Binding Uninstall}"
Content="Uninstall"
FontSize="14" />
<TextBlock Text="Launch" />
</StackPanel>
</Button>
<!-- Update overlay -->
<Rectangle
Grid.Row="0" Grid.RowSpan="4"
Grid.Column="0"
Fill="#DD000000"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Margin="8,0,0,0"
Classes="danger"
IsEnabled="{Binding !IsUninstalling}"
IsVisible="{Binding InstallButtonEnabled}"/>
</UniformGrid>
<StackPanel Orientation="Horizontal" IsVisible="{Binding IsUninstalling}">
<controls:ProgressRing Height="24" Width="24"
Margin="8, 16, 8, 8"
IsVisible="{Binding IsProgressVisible}" />
<Grid Grid.Row="0" Grid.RowSpan="4"
Grid.Column="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
BorderThickness="4"
IsIndeterminate="True" HorizontalAlignment="Left" />
<TextBlock Text="Uninstalling..." VerticalAlignment="Center" Margin="0,8,0,0" />
</StackPanel>
</StackPanel>
RowDefinitions="Auto, *"
IsVisible="{Binding IsProgressVisible}">
<controls:ProgressRing
HorizontalAlignment="Center"
IsIndeterminate="{Binding IsIndeterminate}"
Width="120"
Height="120"
StartAngle="90"
EndAngle="450"
Value="{Binding Value}"
VerticalAlignment="Center" />
<TextBlock Grid.Row="1"
HorizontalAlignment="Center"
Margin="8,8,8,0"
TextAlignment="Center"
Width="180"
Text="{Binding Text, TargetNullValue=Updating so and so to the latest version... 5%}"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</Grid>
</Grid>
</controls:Card>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ScrollViewer>
<!-- Teaching Tip -->
<ui:TeachingTip Grid.Row="0" Name="TeachingTip1"
Target="{Binding #AddPackagesButton}"
Title="Add a package to get started!"
PreferredPlacement="Top"
IsOpen="{Binding !Packages.Count}"/>
<!-- Add Packages Button -->
<Button Grid.Row="2"
Classes="transparent"
VerticalAlignment="Bottom"
Name="AddPackagesButton"
Margin="0, 8, 0, 0"
HorizontalAlignment="Stretch"
Command="{Binding ShowInstallDialog}">
<StackPanel Orientation="Horizontal" Margin="8">
<ui:SymbolIcon FontSize="18" Symbol="Add" />
<TextBlock Margin="4,0,0,0" Text="Add Package" />
</StackPanel>
</Button>
</Grid>
</controls:UserControlBase>

1
StabilityMatrix.Core/Database/LiteDbContext.cs

@ -115,7 +115,6 @@ public class LiteDbContext : ILiteDbContext
public void Dispose()
{
database?.UnderlyingDatabase.Dispose();
database?.Dispose();
GC.SuppressFinalize(this);
}

2
StabilityMatrix.Core/Models/InstalledPackage.cs

@ -38,7 +38,7 @@ public class InstalledPackage
public List<LaunchOption>? LaunchArgs { get; set; }
public DateTimeOffset? LastUpdateCheck { get; set; }
[JsonIgnore] public bool UpdateAvailable { get; set; }
public bool UpdateAvailable { get; set; }
/// <summary>
/// Get the path as a relative sub-path of the relative path.

5
StabilityMatrix.Core/Services/SettingsManager.cs

@ -339,7 +339,6 @@ public class SettingsManager : ISettingsManager
}
package.PackageVersion = newVersion;
package.DisplayVersion = string.IsNullOrWhiteSpace(package.InstalledBranch)
? newVersion
: $"{package.InstalledBranch}@{newVersion[..7]}";
@ -349,7 +348,9 @@ public class SettingsManager : ISettingsManager
public void SetLastUpdateCheck(InstalledPackage package)
{
Settings.InstalledPackages.First(p => p.DisplayName == package.DisplayName).LastUpdateCheck = package.LastUpdateCheck;
var installedPackage = Settings.InstalledPackages.First(p => p.DisplayName == package.DisplayName);
installedPackage.LastUpdateCheck = package.LastUpdateCheck;
installedPackage.UpdateAvailable = package.UpdateAvailable;
SaveSettings();
}

Loading…
Cancel
Save