using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Collections.Specialized; using System.Linq; using System.Threading; using System.Threading.Tasks; using AsyncAwaitBestPractices; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Notifications; using Avalonia.Controls.Primitives; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using FluentAvalonia.UI.Controls; using Microsoft.Extensions.Logging; using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.ViewModels.PackageManager; [ManagedService] [Transient] public partial class PackageCardViewModel( ILogger logger, IPackageFactory packageFactory, INotificationService notificationService, ISettingsManager settingsManager, INavigationService navigationService, ServiceManager vmFactory, RunningPackageService runningPackageService ) : ProgressViewModel { private string webUiUrl = string.Empty; [ObservableProperty] private InstalledPackage? package; [ObservableProperty] private Uri? cardImageSource; [ObservableProperty] private bool isUpdateAvailable; [ObservableProperty] private string? installedVersion; [ObservableProperty] private bool isUnknownPackage; [ObservableProperty] private bool isSharedModelSymlink; [ObservableProperty] private bool isSharedModelConfig; [ObservableProperty] private bool isSharedModelDisabled; [ObservableProperty] private bool canUseConfigMethod; [ObservableProperty] private bool canUseSymlinkMethod; [ObservableProperty] private bool useSharedOutput; [ObservableProperty] private bool canUseSharedOutput; [ObservableProperty] private bool canUseExtensions; [ObservableProperty] private bool isRunning; [ObservableProperty] private bool showWebUiButton; [ObservableProperty] private DownloadPackageVersionOptions? updateVersion; private void RunningPackagesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (runningPackageService.RunningPackages.Select(x => x.Value) is not { } runningPackages) return; var runningViewModel = runningPackages.FirstOrDefault( x => x.RunningPackage.InstalledPackage.Id == Package?.Id ); if (runningViewModel is not null) { IsRunning = true; runningViewModel.RunningPackage.BasePackage.Exited += BasePackageOnExited; runningViewModel.RunningPackage.BasePackage.StartupComplete += RunningPackageOnStartupComplete; } else if (runningViewModel is null && IsRunning) { IsRunning = false; ShowWebUiButton = false; } } partial void OnPackageChanged(InstalledPackage? value) { if (string.IsNullOrWhiteSpace(value?.PackageName)) return; if ( value.PackageName == UnknownPackage.Key || packageFactory.FindPackageByName(value.PackageName) is null ) { IsUnknownPackage = true; CardImageSource = null; InstalledVersion = "Unknown"; } else { IsUnknownPackage = false; var basePackage = packageFactory[value.PackageName]; CardImageSource = basePackage?.PreviewImageUri ?? Assets.NoImage; InstalledVersion = value.Version?.DisplayVersion ?? "Unknown"; CanUseConfigMethod = basePackage?.AvailableSharedFolderMethods.Contains(SharedFolderMethod.Configuration) ?? false; CanUseSymlinkMethod = basePackage?.AvailableSharedFolderMethods.Contains(SharedFolderMethod.Symlink) ?? false; UseSharedOutput = Package?.UseSharedOutputFolder ?? false; CanUseSharedOutput = basePackage?.SharedOutputFolders != null; CanUseExtensions = basePackage?.SupportsExtensions ?? false; runningPackageService.RunningPackages.CollectionChanged += RunningPackagesOnCollectionChanged; EventManager.Instance.PackageRelaunchRequested += InstanceOnPackageRelaunchRequested; } } private void InstanceOnPackageRelaunchRequested(object? sender, InstalledPackage e) { if (e.Id != Package?.Id) return; navigationService.GoBack(); Launch().SafeFireAndForget(); } public override async Task OnLoadedAsync() { if (Design.IsDesignMode && Package?.DisplayName == "Running Comfy") { IsRunning = true; IsUpdateAvailable = true; ShowWebUiButton = true; } if (Design.IsDesignMode || !settingsManager.IsLibraryDirSet || Package is not { } currentPackage) return; if ( packageFactory.FindPackageByName(currentPackage.PackageName) is { } basePackage and not UnknownPackage ) { // Migrate old packages with null preferred shared folder method currentPackage.PreferredSharedFolderMethod ??= basePackage.RecommendedSharedFolderMethod; switch (currentPackage.PreferredSharedFolderMethod) { case SharedFolderMethod.Configuration: IsSharedModelConfig = true; break; case SharedFolderMethod.Symlink: IsSharedModelSymlink = true; break; case SharedFolderMethod.None: IsSharedModelDisabled = true; break; default: throw new ArgumentOutOfRangeException(); } IsUpdateAvailable = await HasUpdate(); if (IsUpdateAvailable) { UpdateVersion = await basePackage.GetUpdate(currentPackage); } if ( Package != null && !IsRunning && runningPackageService.RunningPackages.TryGetValue(Package.Id, out var runningPackageVm) ) { IsRunning = true; runningPackageVm.RunningPackage.BasePackage.Exited += BasePackageOnExited; runningPackageVm.RunningPackage.BasePackage.StartupComplete += RunningPackageOnStartupComplete; webUiUrl = runningPackageVm.WebUiUrl; ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl); } } } public override void OnUnloaded() { EventManager.Instance.PackageRelaunchRequested -= InstanceOnPackageRelaunchRequested; runningPackageService.RunningPackages.CollectionChanged -= RunningPackagesOnCollectionChanged; } public async Task Launch() { if (Package == null) return; var packagePair = await runningPackageService.StartPackage(Package); if (packagePair != null) { IsRunning = true; packagePair.BasePackage.Exited += BasePackageOnExited; packagePair.BasePackage.StartupComplete += RunningPackageOnStartupComplete; var vm = runningPackageService.GetRunningPackageViewModel(packagePair.InstalledPackage.Id); if (vm != null) { navigationService.NavigateTo(vm, new BetterEntranceNavigationTransition()); } } // settingsManager.Transaction(s => s.ActiveInstalledPackageId = Package.Id); // // navigationService.NavigateTo(new BetterDrillInNavigationTransition()); // EventManager.Instance.OnPackageLaunchRequested(Package.Id); } public void NavToConsole() { if (Package == null) return; var vm = runningPackageService.GetRunningPackageViewModel(Package.Id); if (vm != null) { navigationService.NavigateTo(vm, new BetterEntranceNavigationTransition()); } } public void LaunchWebUi() { if (string.IsNullOrEmpty(webUiUrl)) return; notificationService.TryAsync( Task.Run(() => ProcessRunner.OpenUrl(webUiUrl)), "Failed to open URL", $"{webUiUrl}" ); } private void BasePackageOnExited(object? sender, int exitCode) { Dispatcher .UIThread.InvokeAsync(async () => { logger.LogTrace("Process exited ({Code}) at {Time:g}", exitCode, DateTimeOffset.Now); // Need to wait for streams to finish before detaching handlers if (sender is BaseGitPackage { VenvRunner: not null } package) { var process = package.VenvRunner.Process; if (process is not null) { // Max 5 seconds var ct = new CancellationTokenSource(5000).Token; try { await process.WaitUntilOutputEOF(ct); } catch (OperationCanceledException e) { logger.LogWarning("Waiting for process EOF timed out: {Message}", e.Message); } } } // Detach handlers if (sender is BasePackage basePackage) { basePackage.Exited -= BasePackageOnExited; basePackage.StartupComplete -= RunningPackageOnStartupComplete; } if (Package?.Id != null) { runningPackageService.RunningPackages.Remove(Package.Id); } IsRunning = false; ShowWebUiButton = false; }) .SafeFireAndForget(); } public async Task Uninstall() { if (Package?.LibraryPath == null) { return; } var dialogViewModel = vmFactory.Get(vm => { vm.ExpectedPackageName = Package?.DisplayName; }); var dialog = new BetterContentDialog { Content = dialogViewModel, IsPrimaryButtonEnabled = false, IsSecondaryButtonEnabled = false, IsFooterVisible = false, }; var result = await dialog.ShowAsync(); if (result == ContentDialogResult.Primary) { Text = Resources.Progress_UninstallingPackage; IsIndeterminate = true; Value = -1; var packagePath = new DirectoryPath(settingsManager.LibraryDir, Package.LibraryPath); var deleteTask = packagePath.DeleteVerboseAsync(logger); var taskResult = await notificationService.TryAsync( deleteTask, Resources.Text_SomeFilesCouldNotBeDeleted ); if (taskResult.IsSuccessful) { notificationService.Show( new Notification( Resources.Label_PackageUninstalled, Package.DisplayName, NotificationType.Success ) ); if (!IsUnknownPackage) { settingsManager.Transaction(settings => { settings.RemoveInstalledPackageAndUpdateActive(Package); }); } EventManager.Instance.OnInstalledPackagesChanged(); } Text = ""; IsIndeterminate = false; Value = 0; } } public async Task Update() { if (Package is null || IsUnknownPackage) return; var basePackage = packageFactory[Package.PackageName!]; if (basePackage == null) { logger.LogWarning("Could not find package {SelectedPackagePackageName}", Package.PackageName); notificationService.Show( Resources.Label_InvalidPackageType, Package.PackageName.ToRepr(), NotificationType.Error ); return; } var packageName = Package.DisplayName ?? Package.PackageName ?? ""; Text = $"Updating {packageName}"; IsIndeterminate = true; try { var runner = new PackageModificationRunner { ModificationCompleteMessage = $"Updated {packageName}", ModificationFailedMessage = $"Could not update {packageName}" }; runner.Completed += (_, completedRunner) => { notificationService.OnPackageInstallCompleted(completedRunner); }; var versionOptions = new DownloadPackageVersionOptions { IsLatest = true }; if (Package.Version.IsReleaseMode) { versionOptions = await basePackage.GetLatestVersion(Package.Version.IsPrerelease); } else { var commits = await basePackage.GetAllCommits(Package.Version.InstalledBranch); var latest = commits?.FirstOrDefault(); if (latest == null) throw new Exception("Could not find latest commit"); versionOptions.BranchName = Package.Version.InstalledBranch; versionOptions.CommitHash = latest.Sha; } var updatePackageStep = new UpdatePackageStep( settingsManager, Package, versionOptions, basePackage ); var steps = new List { updatePackageStep }; EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps(steps); IsUpdateAvailable = false; InstalledVersion = Package.Version?.DisplayVersion ?? "Unknown"; notificationService.Show( Resources.Progress_UpdateComplete, string.Format(Resources.TextTemplate_PackageUpdatedToLatest, packageName), NotificationType.Success ); } catch (Exception e) { logger.LogError(e, "Error Updating Package ({PackageName})", basePackage.Name); notificationService.ShowPersistent( string.Format(Resources.TextTemplate_ErrorUpdatingPackage, packageName), e.Message, NotificationType.Error ); } finally { IsIndeterminate = false; Value = 0; Text = ""; } } public async Task Import() { if (!IsUnknownPackage || Design.IsDesignMode) return; var viewModel = vmFactory.Get(vm => { vm.PackagePath = new DirectoryPath(Package?.FullPath ?? throw new InvalidOperationException()); }); var dialog = new TaskDialog { Content = new PackageImportDialog { DataContext = viewModel }, ShowProgressBar = false, Buttons = new List { new(Resources.Action_Import, TaskDialogStandardResult.Yes) { IsDefault = true }, new(Resources.Action_Cancel, TaskDialogStandardResult.Cancel) } }; dialog.Closing += async (sender, e) => { // We only want to use the deferral on the 'Yes' Button if ((TaskDialogStandardResult)e.Result == TaskDialogStandardResult.Yes) { var deferral = e.GetDeferral(); sender.ShowProgressBar = true; sender.SetProgressBarState(0, TaskDialogProgressState.Indeterminate); await using (new MinimumDelay(200, 300)) { var result = await notificationService.TryAsync(viewModel.AddPackageWithCurrentInputs()); if (result.IsSuccessful) { EventManager.Instance.OnInstalledPackagesChanged(); } } deferral.Complete(); } }; dialog.XamlRoot = App.VisualRoot; await dialog.ShowAsync(true); } public async Task OpenFolder() { if (string.IsNullOrWhiteSpace(Package?.FullPath)) return; await ProcessRunner.OpenFolderBrowser(Package.FullPath); } [RelayCommand] public async Task OpenPythonPackagesDialog() { if (Package is not { FullPath: not null }) return; var vm = vmFactory.Get(vm => { vm.VenvPath = new DirectoryPath(Package.FullPath, "venv"); }); await vm.GetDialog().ShowAsync(); } [RelayCommand] public async Task OpenExtensionsDialog() { if ( Package is not { FullPath: not null } || packageFactory.GetPackagePair(Package) is not { } packagePair ) return; var vm = vmFactory.Get(vm => { vm.PackagePair = packagePair; }); var dialog = new BetterContentDialog { Content = vm, MinDialogWidth = 850, MaxDialogHeight = 1100, MaxDialogWidth = 850, ContentMargin = new Thickness(16, 32), CloseOnClickOutside = true, FullSizeDesired = true, IsFooterVisible = false, ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled }; await dialog.ShowAsync(); } [RelayCommand] private void OpenOnGitHub() { if (Package is null) return; var basePackage = packageFactory[Package.PackageName!]; if (basePackage == null) { logger.LogWarning("Could not find package {SelectedPackagePackageName}", Package.PackageName); return; } ProcessRunner.OpenUrl(basePackage.GithubUrl); } [RelayCommand] private async Task Stop() { if (Package is null) return; await runningPackageService.StopPackage(Package.Id); IsRunning = false; ShowWebUiButton = false; } [RelayCommand] private async Task Restart() { await Stop(); await Launch(); } [RelayCommand] private async Task ShowLaunchOptions() { var basePackage = packageFactory.FindPackageByName(Package?.PackageName); if (basePackage == null) { logger.LogWarning("Package {Name} not found", Package?.PackageName); return; } var viewModel = vmFactory.Get(); viewModel.Cards = LaunchOptionCard .FromDefinitions(basePackage.LaunchOptions, Package?.LaunchArgs ?? []) .ToImmutableArray(); logger.LogDebug("Launching config dialog with cards: {CardsCount}", viewModel.Cards.Count); var dialog = new BetterContentDialog { ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, IsPrimaryButtonEnabled = true, PrimaryButtonText = Resources.Action_Save, CloseButtonText = Resources.Action_Cancel, FullSizeDesired = true, DefaultButton = ContentDialogButton.Primary, ContentMargin = new Thickness(32, 16), Padding = new Thickness(0, 16), Content = new LaunchOptionsDialog { DataContext = viewModel, } }; var result = await dialog.ShowAsync(); if (result == ContentDialogResult.Primary && Package != null) { // Save config var args = viewModel.AsLaunchArgs(); settingsManager.SaveLaunchArgs(Package.Id, args); } } private async Task HasUpdate() { if (Package == null || IsUnknownPackage || Design.IsDesignMode) 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); UpdateVersion = await basePackage.GetUpdate(Package); await using (settingsManager.BeginTransaction()) { Package.UpdateAvailable = hasUpdate; Package.LastUpdateCheck = DateTimeOffset.Now; } return hasUpdate; } catch (Exception e) { logger.LogError(e, "Error checking {PackageName} for updates", Package.PackageName); return false; } } public void ToggleSharedModelSymlink() => IsSharedModelSymlink = !IsSharedModelSymlink; public void ToggleSharedModelConfig() => IsSharedModelConfig = !IsSharedModelConfig; public void ToggleSharedModelNone() => IsSharedModelDisabled = !IsSharedModelDisabled; public void ToggleSharedOutput() => UseSharedOutput = !UseSharedOutput; partial void OnUseSharedOutputChanged(bool value) { if (Package == null) return; if (value == Package.UseSharedOutputFolder) return; using var st = settingsManager.BeginTransaction(); Package.UseSharedOutputFolder = value; var basePackage = packageFactory[Package.PackageName!]; if (basePackage == null) return; if (value) { basePackage.SetupOutputFolderLinks(Package.FullPath!); } else { basePackage.RemoveOutputFolderLinks(Package.FullPath!); } } // fake radio button stuff partial void OnIsSharedModelSymlinkChanged(bool oldValue, bool newValue) { if (oldValue == newValue) return; if (newValue != Package!.PreferredSharedFolderMethod is SharedFolderMethod.Symlink) { using var st = settingsManager.BeginTransaction(); Package.PreferredSharedFolderMethod = SharedFolderMethod.Symlink; } if (newValue) { IsSharedModelConfig = false; IsSharedModelDisabled = false; } else { var basePackage = packageFactory[Package!.PackageName!]; basePackage!.RemoveModelFolderLinks(Package.FullPath!, SharedFolderMethod.Symlink); } } partial void OnIsSharedModelConfigChanged(bool oldValue, bool newValue) { if (oldValue == newValue) return; if (newValue != Package!.PreferredSharedFolderMethod is SharedFolderMethod.Configuration) { using var st = settingsManager.BeginTransaction(); Package.PreferredSharedFolderMethod = SharedFolderMethod.Configuration; } if (newValue) { IsSharedModelSymlink = false; IsSharedModelDisabled = false; } else { var basePackage = packageFactory[Package!.PackageName!]; basePackage!.RemoveModelFolderLinks(Package.FullPath!, SharedFolderMethod.Configuration); } } partial void OnIsSharedModelDisabledChanged(bool value) { if (value) { if (Package!.PreferredSharedFolderMethod is not SharedFolderMethod.None) { using var st = settingsManager.BeginTransaction(); Package.PreferredSharedFolderMethod = SharedFolderMethod.None; } IsSharedModelSymlink = false; IsSharedModelConfig = false; } } private void RunningPackageOnStartupComplete(object? sender, string e) { webUiUrl = e.Replace("0.0.0.0", "127.0.0.1"); ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl); } }