You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
291 lines
10 KiB
291 lines
10 KiB
using System; |
|
using System.Collections.Generic; |
|
using System.Collections.ObjectModel; |
|
using System.Diagnostics; |
|
using System.Linq; |
|
using System.Threading; |
|
using System.Threading.Tasks; |
|
using AsyncAwaitBestPractices; |
|
using Avalonia.Controls.Notifications; |
|
using System.Threading.Tasks.Dataflow; |
|
using Avalonia.Threading; |
|
using AvaloniaEdit.Document; |
|
using CommunityToolkit.Mvvm.ComponentModel; |
|
using CommunityToolkit.Mvvm.Input; |
|
using FluentAvalonia.UI.Controls; |
|
using Microsoft.Extensions.Logging; |
|
using StabilityMatrix.Avalonia.Services; |
|
using StabilityMatrix.Avalonia.Views; |
|
using StabilityMatrix.Core.Attributes; |
|
using StabilityMatrix.Core.Helper.Factory; |
|
using StabilityMatrix.Core.Models; |
|
using StabilityMatrix.Core.Models.Packages; |
|
using StabilityMatrix.Core.Processes; |
|
using StabilityMatrix.Core.Python; |
|
using StabilityMatrix.Core.Services; |
|
|
|
namespace StabilityMatrix.Avalonia.ViewModels; |
|
|
|
[View(typeof(LaunchPageView))] |
|
public partial class LaunchPageViewModel : PageViewModelBase, IDisposable |
|
{ |
|
private readonly ILogger<LaunchPageViewModel> logger; |
|
private readonly ISettingsManager settingsManager; |
|
private readonly IPackageFactory packageFactory; |
|
private readonly IPyRunner pyRunner; |
|
private readonly INotificationService notificationService; |
|
public override string Title => "Launch"; |
|
public override Symbol Icon => Symbol.PlayFilled; |
|
|
|
[ObservableProperty] |
|
private TextDocument consoleDocument = new(); |
|
|
|
// Queue for console updates |
|
private readonly BufferBlock<ProcessOutput> consoleUpdateBuffer = new(); |
|
// Task that updates the console (runs on UI thread) |
|
private Task? consoleUpdateTask; |
|
private CancellationTokenSource? consoleUpdateCts; |
|
|
|
[ObservableProperty] private bool launchButtonVisibility; |
|
[ObservableProperty] private bool stopButtonVisibility; |
|
[ObservableProperty] private bool isLaunchTeachingTipsOpen; |
|
[ObservableProperty] private bool showWebUiButton; |
|
|
|
[ObservableProperty] private InstalledPackage? selectedPackage; |
|
[ObservableProperty] private ObservableCollection<InstalledPackage> installedPackages = new(); |
|
|
|
[ObservableProperty] private BasePackage? runningPackage; |
|
|
|
// private bool clearingPackages; |
|
private string webUiUrl = string.Empty; |
|
|
|
public LaunchPageViewModel(ILogger<LaunchPageViewModel> logger, ISettingsManager settingsManager, IPackageFactory packageFactory, |
|
IPyRunner pyRunner, INotificationService notificationService) |
|
{ |
|
this.logger = logger; |
|
this.settingsManager = settingsManager; |
|
this.packageFactory = packageFactory; |
|
this.pyRunner = pyRunner; |
|
this.notificationService = notificationService; |
|
} |
|
|
|
public override void OnLoaded() |
|
{ |
|
LoadPackages(); |
|
lock (InstalledPackages) |
|
{ |
|
// Skip if no packages |
|
if (!InstalledPackages.Any()) |
|
{ |
|
//logger.LogTrace($"No packages for {nameof(LaunchViewModel)}"); |
|
return; |
|
} |
|
|
|
var activePackageId = settingsManager.Settings.ActiveInstalledPackage; |
|
if (activePackageId != null) |
|
{ |
|
SelectedPackage = InstalledPackages.FirstOrDefault( |
|
x => x.Id == activePackageId) ?? InstalledPackages[0]; |
|
} |
|
} |
|
} |
|
|
|
[RelayCommand] |
|
private async Task LaunchAsync() |
|
{ |
|
var activeInstall = SelectedPackage; |
|
|
|
if (activeInstall == null) |
|
{ |
|
// No selected package: error notification |
|
notificationService.Show(new Notification( |
|
message: "You must install and select a package before launching", |
|
title: "No package selected", |
|
type: NotificationType.Error)); |
|
return; |
|
} |
|
|
|
var activeInstallName = activeInstall.PackageName; |
|
var basePackage = string.IsNullOrWhiteSpace(activeInstallName) |
|
? null |
|
: packageFactory.FindPackageByName(activeInstallName); |
|
|
|
if (basePackage == null) |
|
{ |
|
logger.LogWarning( |
|
"During launch, package name '{PackageName}' did not match a definition", |
|
activeInstallName); |
|
|
|
notificationService.Show(new Notification("Package name invalid", |
|
"Install package name did not match a definition. Please reinstall and let us know about this issue.", |
|
NotificationType.Error)); |
|
return; |
|
} |
|
|
|
// If this is the first launch (LaunchArgs is null), |
|
// load and save a launch options dialog in background |
|
// so that dynamic initial values are saved. |
|
// if (activeInstall.LaunchArgs == null) |
|
// { |
|
// var definitions = basePackage.LaunchOptions; |
|
// // Open a config page and save it |
|
// var dialog = dialogFactory.CreateLaunchOptionsDialog(definitions, activeInstall); |
|
// var args = dialog.AsLaunchArgs(); |
|
// settingsManager.SaveLaunchArgs(activeInstall.Id, args); |
|
// } |
|
|
|
// Clear console |
|
ConsoleDocument.Text = string.Empty; |
|
|
|
await pyRunner.Initialize(); |
|
|
|
// Get path from package |
|
var packagePath = $"{settingsManager.LibraryDir}\\{activeInstall.LibraryPath!}"; |
|
|
|
basePackage.ConsoleOutput += OnProcessOutputReceived; |
|
basePackage.Exited += OnProcessExited; |
|
basePackage.StartupComplete += RunningPackageOnStartupComplete; |
|
|
|
// Start task to update console from queue |
|
consoleUpdateCts = new CancellationTokenSource(); |
|
// Run in UI thread |
|
consoleUpdateTask = Dispatcher.UIThread.InvokeAsync(async () |
|
=> await BeginUpdateConsole(consoleUpdateCts.Token), DispatcherPriority.Render); |
|
|
|
// 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); |
|
RunningPackage = basePackage; |
|
} |
|
|
|
private async Task BeginUpdateConsole(CancellationToken ct) |
|
{ |
|
try |
|
{ |
|
while (true) |
|
{ |
|
ct.ThrowIfCancellationRequested(); |
|
var output = await consoleUpdateBuffer.ReceiveAsync(ct); |
|
using var update = ConsoleDocument.RunUpdate(); |
|
// Handle remove |
|
if (output.ClearLines > 0) |
|
{ |
|
for (var i = 0; i < output.ClearLines; i++) |
|
{ |
|
var lastLineIndex = ConsoleDocument.LineCount - 1; |
|
var line = ConsoleDocument.Lines[lastLineIndex]; |
|
ConsoleDocument.Remove(line.Offset, line.Length); |
|
} |
|
} |
|
// Add new line |
|
ConsoleDocument.Insert(ConsoleDocument.TextLength, output.Text); |
|
} |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
} |
|
} |
|
|
|
public async Task Stop() |
|
{ |
|
if (RunningPackage is null) return; |
|
await RunningPackage.Shutdown(); |
|
|
|
RunningPackage = null; |
|
ShowWebUiButton = false; |
|
|
|
// Can't use buffer here since is null after shutdown |
|
Dispatcher.UIThread.Post(() => |
|
{ |
|
var msg = $"{Environment.NewLine}Stopped process at {DateTimeOffset.Now}{Environment.NewLine}"; |
|
ConsoleDocument.Insert(ConsoleDocument.TextLength, msg ); |
|
}); |
|
} |
|
|
|
public void OpenWebUi() |
|
{ |
|
if (!string.IsNullOrWhiteSpace(webUiUrl)) |
|
{ |
|
ProcessRunner.OpenUrl(webUiUrl); |
|
} |
|
} |
|
|
|
private void OnProcessExited(object? sender, int exitCode) |
|
{ |
|
if (sender is BasePackage package) |
|
{ |
|
package.ConsoleOutput -= OnProcessOutputReceived; |
|
package.Exited -= OnProcessExited; |
|
package.StartupComplete -= RunningPackageOnStartupComplete; |
|
} |
|
RunningPackage = null; |
|
ShowWebUiButton = false; |
|
var msg = |
|
$"{Environment.NewLine}Process finished with exit code {exitCode}{Environment.NewLine}"; |
|
|
|
// Add to buffer |
|
consoleUpdateBuffer.Post(new ProcessOutput { RawText = msg, Text = msg }); |
|
|
|
// Task that waits until buffer is empty, then stops the console update task |
|
Task.Run(async () => |
|
{ |
|
Debug.WriteLine($"Waiting for console update buffer to empty ({consoleUpdateBuffer.Count})"); |
|
while (consoleUpdateBuffer.Count > 0) |
|
{ |
|
await Task.Delay(100); |
|
} |
|
Debug.WriteLine("Console update buffer empty, stopping console update task"); |
|
consoleUpdateCts?.Cancel(); |
|
consoleUpdateCts = null; |
|
consoleUpdateTask = null; |
|
}); |
|
} |
|
|
|
// Callback for processes |
|
private void OnProcessOutputReceived(object? sender, ProcessOutput output) |
|
{ |
|
consoleUpdateBuffer.Post(output); |
|
} |
|
|
|
private void RunningPackageOnStartupComplete(object? sender, string e) |
|
{ |
|
webUiUrl = e; |
|
ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl); |
|
} |
|
|
|
private void LoadPackages() |
|
{ |
|
var packages = settingsManager.Settings.InstalledPackages; |
|
if (!packages?.Any() ?? true) |
|
{ |
|
InstalledPackages.Clear(); |
|
return; |
|
} |
|
|
|
InstalledPackages.Clear(); |
|
|
|
foreach (var package in packages) |
|
{ |
|
InstalledPackages.Add(package); |
|
} |
|
} |
|
|
|
public void Dispose() |
|
{ |
|
// Dispose updates |
|
consoleUpdateBuffer.Complete(); |
|
consoleUpdateCts?.Cancel(); |
|
consoleUpdateCts = null; |
|
consoleUpdateTask = null; |
|
|
|
RunningPackage?.Shutdown(); |
|
GC.SuppressFinalize(this); |
|
} |
|
}
|
|
|