EllieHub/EllieHub/Features/AppWindow/Views/Windows/AppView.axaml.cs

301 lines
No EOL
13 KiB
C#

using Avalonia.Controls;
using Avalonia.ReactiveUI;
using Avalonia.Styling;
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Enums;
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppConfig.ViewModels;
using EllieHub.Features.AppWindow.Models;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.Features.AppWindow.Views.Controls;
using EllieHub.Features.BotConfig.Services.Abstractions;
using EllieHub.Features.BotConfig.ViewModels;
using EllieHub.Features.Home.Services.Abstractions;
using EllieHub.Features.Home.ViewModels;
using EllieHub.Features.Home.Views.Windows;
using EllieHub.Services;
using ReactiveUI;
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text.Json;
namespace EllieHub.Features.AppWindow.Views.Windows;
/// <summary>
/// Represents the main window of the application.
/// </summary>
public partial class AppView : ReactiveWindow<AppViewModel>
{
private Task _saveWindowSizeTask = Task.CompletedTask;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAppConfigManager _appConfigManager;
private readonly IBotOrchestrator _botOrchestrator;
private readonly ILogWriter _logWriter;
private readonly IAppResolver _appResolver;
private readonly LateralBarView _lateralBarView;
/// <summary>
/// Designer's constructor. Use the parameterized constructor instead.
/// </summary>
[Obsolete(WindowConstants.DesignerCtorWarning, true)]
public AppView() : this(
DesignStatics.Services.GetRequiredService<IServiceScopeFactory>(),
DesignStatics.Services.GetRequiredService<IBotOrchestrator>(),
DesignStatics.Services.GetRequiredService<ILogWriter>(),
DesignStatics.Services.GetRequiredService<IAppConfigManager>(),
DesignStatics.Services.GetRequiredService<IAppResolver>(),
DesignStatics.Services.GetRequiredService<AppViewModel>(),
DesignStatics.Services.GetRequiredService<LateralBarView>()
)
{
}
/// <summary>
/// Creates the main window of the application.
/// </summary>
/// <param name="scopeFactory">The IoC scope factory.</param>
/// <param name="botOrchestrator">The bot orchestrator.</param>
/// <param name="logWriter">The service responsible for creating log files.</param>
/// <param name="appConfigManager">The manager for the application settings.</param>
/// <param name="appResolver">The service that updates this application.</param>
/// <param name="viewModel">The view-model of this view.</param>
/// <param name="lateralBarView">The lateral bar view.</param>
public AppView(IServiceScopeFactory scopeFactory, IBotOrchestrator botOrchestrator, ILogWriter logWriter,
IAppConfigManager appConfigManager, IAppResolver appResolver, AppViewModel viewModel, LateralBarView lateralBarView)
{
_scopeFactory = scopeFactory;
_appConfigManager = appConfigManager;
_botOrchestrator = botOrchestrator;
_logWriter = logWriter;
_lateralBarView = lateralBarView;
_appResolver = appResolver;
_lateralBarView.ConfigButton.Click += (_, _) =>
{
_lateralBarView.ResetBotButtonBorders();
viewModel.ContentViewModel = GetViewModel<ConfigViewModel>();
};
_lateralBarView.HomeButton.Click += (_, _) =>
{
_lateralBarView.ResetBotButtonBorders();
viewModel.ContentViewModel = GetViewModel<HomeViewModel>();
};
_lateralBarView.BotButtonClick += (button, _) =>
{
var botConfigViewModel = SwitchBotConfigViewModel(button, _lateralBarView);
if (botConfigViewModel is null)
return;
// Update the avatar on the lateral bar.
botConfigViewModel.AvatarChanged += (_, eventArgs) => _lateralBarView.UpdateBotButtonAvatarAsync(eventArgs);
// If the bot instance is deleted, load the Home view.
botConfigViewModel.BotDeleted += (bcvm, _) => RemoveBotAsync(bcvm.Id, _lateralBarView);
};
this.WhenActivated(_ => base.ViewModel = viewModel); // Sets the view-model to one from the IoC container.
InitializeComponent();
}
/// <inheritdoc/>
protected override void OnResized(WindowResizedEventArgs eventArgs)
{
if (base.IsLoaded && _saveWindowSizeTask.IsCompleted)
_saveWindowSizeTask = SaveCurrentWindowSizeAsync(TimeSpan.FromSeconds(1));
base.OnResized(eventArgs);
}
/// <inheritdoc />
/// <exception cref="UnreachableException">Occurs when <see cref="ThemeType"/> has an unimplemented value.</exception>
protected override void OnOpened(EventArgs eventArgs)
{
// Ensure that bots on Unix system have access to the dependencies.
if (Environment.OSVersion.Platform is PlatformID.Unix)
Utilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
// Set the window size from the last session
base.Height = _appConfigManager.AppConfig.WindowSize.Height;
base.Width = _appConfigManager.AppConfig.WindowSize.Width;
// Set the user prefered theme
base.RequestedThemeVariant = _appConfigManager.AppConfig.Theme switch
{
ThemeType.Auto => ThemeVariant.Default,
ThemeType.Light => ThemeVariant.Light,
ThemeType.Dark => ThemeVariant.Dark,
_ => throw new UnreachableException($"No implementation for theme of type {_appConfigManager.AppConfig.Theme} was provided."),
};
// Update the application, if one is available
_ = UpdateAndCloseAsync();
// Import bots from the old updater, if available
if (OperatingSystem.IsWindows())
_ = MigrateOldBotsAsync();
base.OnOpened(eventArgs);
}
/// <inheritdoc />
protected override void OnClosing(WindowClosingEventArgs eventArgs)
{
// Hide the window instead of closing it, in case the user
// prefers the window to be minimized to the system tray.
eventArgs.Cancel = _appConfigManager.AppConfig.MinimizeToTray && !eventArgs.IsProgrammatic;
if (eventArgs.Cancel)
base.Hide();
base.OnClosing(eventArgs);
}
/// <inheritdoc/>
protected override async void OnClosed(EventArgs eventArgs)
{
// When the updater is closed, kill all bots and write their logs.
_botOrchestrator.StopAll();
await _logWriter.FlushAllAsync(true);
base.OnClosed(eventArgs);
}
/// <summary>
/// Saves the size of this window to the application settings after the specified <paramref name="waitTime"/> elapses.
/// </summary>
/// <param name="waitTime">How long to wait before saving the window size to the settings.</param>
/// <param name="cToken">The cancellation token.</param>
private async Task SaveCurrentWindowSizeAsync(TimeSpan waitTime, CancellationToken cToken = default)
{
await Task.Delay(waitTime, cToken);
await _appConfigManager.UpdateConfigAsync(x => x.WindowSize = new(base.Width, base.Height), cToken);
}
/// <summary>
/// Gets a <typeparamref name="T"/> from the <see cref="_scopeFactory"/>.
/// </summary>
/// <typeparam name="T">The type of view-model to be returned.</typeparam>
/// <returns>A <typeparamref name="T"/>.</returns>
private T GetViewModel<T>() where T : ViewModelBase
{
using var scope = _scopeFactory.CreateScope();
return scope.ServiceProvider.GetRequiredService<T>();
}
/// <summary>
/// Removes the bot with the specified Id from the settings file and the lateral bar.
/// </summary>
/// <param name="botId">The Id of the bot to be removed.</param>
/// <param name="lateralBarView">The view of the lateral bar.</param>
/// <exception cref="InvalidOperationException">Occurs when the lateral bar has an unexpected structure layout.</exception>
private async Task RemoveBotAsync(Guid botId, LateralBarView lateralBarView)
{
// Shorthand for fetching the bot button from the lateral bar structure
static Button GetBarButton(Control border)
=> (((border as Border)?.Child as Panel)?.Children[1] as Button) ?? throw new InvalidOperationException("Unexpected layout.");
base.ViewModel ??= GetViewModel<AppViewModel>();
lateralBarView.ResetBotButtonBorders();
base.ViewModel.ContentViewModel = GetViewModel<HomeViewModel>();
if (lateralBarView.ViewModel is not null)
await lateralBarView.ViewModel.RemoveBotButtonAsync(botId);
// Fix weird bug that redraws bot buttons with the wrong avatars.
// A random border with null button content just pops up randomly in ButtonList.Children.
// This probably happens because the view references a Border with a bunch of stuff, whereas the view-model only
// references a Button, but I can't be bothered to do this the right way.
lateralBarView.ButtonList.Children.RemoveAll(lateralBarView.ButtonList.Children.Where(x => GetBarButton(x).Content is null));
}
/// <summary>
/// Switches to the view-model associated with the specified <paramref name="button"/> and updates
/// the selection border on the lateral bar.
/// </summary>
/// <param name="button">The bot button that was pressed.</param>
/// <param name="lateralBarView">The view of the lateral bar.</param>
/// <returns>
/// The view-model associated with <paramref name="button"/>,
/// <see langword="null"/> if the current and requested view-models are the same.
/// </returns>
private BotConfigViewModel? SwitchBotConfigViewModel(Button button, LateralBarView lateralBarView)
{
// If the user clicked on the bot instance that is already active, do not switch.
if (base.ViewModel?.ContentViewModel is BotConfigViewModel currentViewModel && currentViewModel.Id.Equals(button.Content))
return default;
// Switch to the bot config view-model
var botConfigViewModel = GetBotConfigViewModel(button, _scopeFactory);
this.ViewModel ??= GetViewModel<AppViewModel>();
this.ViewModel.ContentViewModel = botConfigViewModel;
// Update the selector on the lateral bar
lateralBarView.ResetBotButtonBorders();
lateralBarView.ApplyBotButtonBorder(button);
return botConfigViewModel;
}
/// <summary>
/// Updates this application, if a new version is available.
/// </summary>
private async Task UpdateAndCloseAsync()
{
_appResolver.RemoveOldFiles();
if (!_appConfigManager.AppConfig.AutomaticUpdates || await _appResolver.CanUpdateAsync() is not true)
return;
_ = new UpdateView().ShowDialog(this);
await _appResolver.InstallOrUpdateAsync(AppContext.BaseDirectory);
_appResolver.LaunchNewVersion();
base.Close();
}
/// <summary>
/// Migrates bots created by the EllieUpdater when EllieHub is run for the first time.
/// </summary>
[SupportedOSPlatform("windows")]
private async Task MigrateOldBotsAsync()
{
var configFileUri = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "EllieBotUpdater", "bots.json");
if (File.Exists(AppStatics.AppConfigUri) || !File.Exists(configFileUri))
return;
var bots = (JsonSerializer.Deserialize<OldUpdaterBotEntry[]>(await File.ReadAllTextAsync(configFileUri)) ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x.PathUri) && File.Exists(Path.Combine(x.PathUri, "EllieBot.exe")))
.Select((x, y) => new BotEntry(x.Guid, new(x.Name, x.PathUri!, (uint)y, x.Version, x.IconUri)));
foreach (var botEntry in bots)
await _appConfigManager.UpdateConfigAsync(x => x.BotEntries.TryAdd(botEntry.Id, botEntry.BotInfo));
_lateralBarView.ViewModel?.ReloadBotButtons(_appConfigManager.AppConfig.BotEntries);
}
/// <summary>
/// Gets a <see cref="BotConfigViewModel"/> from the <paramref name="scopeFactory"/> and initializes
/// its properties with user data.
/// </summary>
/// <param name="button">The button that was pressed in the bot list.</param>
/// <param name="scopeFactory">The IoC scope factory.</param>
/// <returns>The view-model associated with the pressed <paramref name="button"/>.</returns>
/// <exception cref="InvalidCastException">Occurs when <paramref name="button"/> has a <see cref="ContentControl.Content"/> that is not a <see cref="Guid"/>.</exception>
/// <exception cref="InvalidOperationException">Occurs when <paramref name="button"/> has an invalid <see cref="ContentControl.Content"/>.</exception>
private static BotConfigViewModel GetBotConfigViewModel(Button button, IServiceScopeFactory scopeFactory)
{
using var scope = scopeFactory.CreateScope();
var botId = (button.Content ?? throw new InvalidOperationException("Bot button has no valid Id."));
var botResolver = scope.ServiceProvider.GetParameterizedService<EllieResolver>(botId);
return scope.ServiceProvider.GetParameterizedService<BotConfigViewModel>(botResolver);
}
}